Add a snapshot test for `Path` SVG layout (#412)

This adds a dependency on the [SnapshotTesting](https://github.com/pointfreeco/swift-snapshot-testing) library, which allows testing our SVG layout algorithm end-to-end. We use the `--screenshot` flag of Chromium-based browsers (MS Edge in this case) to produce a PNG snapshot of a view rendered with `StaticHTMLRenderer`.

This test works only on macOS for now due to its dependency on `NSImage`, but that should be fine as we'd expect the same SVG output to be rendered in the same way on all platforms.

* Implement snapshot tests with headless MS Edge

* Increase snapshot tests timeout

* Force 1.0 resolution scale for headless Edge

* Avoid complex layouts in the snapshot test

* Exclude dir from target sources, upload failures

* Add a test to verify that fusion works

* Enable fusion of modifiers nested three times

* Filter out empty attributes

* Run snapshot tests only on macOS for now

* Fully exclude snapshot testing on WASI

* Fix `testOptional` snapshot

* Clean up code formatting

* Copy failed snapshots to a readable directory

* Make the copy script more resilient

* Use `--force-color-profile=srgb` Chromium flag

* Re-enable spooky hanger test

* Clean up testSpookyHanger

* Fix linter warnings

* Fix file_length linter warning

* Silence linter warning for `Text.attributes` func

* Split `PathLayout.swift` to appease the linter
This commit is contained in:
Max Desiatov 2021-06-21 16:45:21 +01:00 committed by GitHub
parent e6c37a4c80
commit d35e37c4f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 791 additions and 652 deletions

View File

@ -33,10 +33,11 @@ jobs:
shell: bash shell: bash
run: | run: |
set -ex set -ex
sudo xcode-select --switch /Applications/Xcode_12.3.app/Contents/Developer/ sudo xcode-select --switch /Applications/Xcode_12.4.app/Contents/Developer/
# avoid building unrelated products for testing by specifying the test product explicitly # avoid building unrelated products for testing by specifying the test product explicitly
swift build --product TokamakPackageTests swift build --product TokamakPackageTests
`xcrun --find xctest` .build/debug/TokamakPackageTests.xctest `xcrun --find xctest` .build/debug/TokamakPackageTests.xctest ||
find /var/folders -iname SnapshotTests -exec cp -r {} . \;
rm -rf Sources/TokamakGTKCHelpers/*.c rm -rf Sources/TokamakGTKCHelpers/*.c
@ -55,6 +56,13 @@ jobs:
./benchmark.sh ./benchmark.sh
- name: Upload failed snapshots
uses: actions/upload-artifact@v2
if: ${{ failure() }}
with:
name: Failed snapshots
path: SnapshotTests
gtk_macos_build: gtk_macos_build:
runs-on: macos-latest runs-on: macos-latest
@ -64,7 +72,7 @@ jobs:
shell: bash shell: bash
run: | run: |
set -ex set -ex
sudo xcode-select --switch /Applications/Xcode_12.3.app/Contents/Developer/ sudo xcode-select --switch /Applications/Xcode_12.4.app/Contents/Developer/
brew install gtk+3 brew install gtk+3

View File

@ -53,9 +53,19 @@ let package = Package(
url: "https://github.com/swiftwasm/JavaScriptKit.git", url: "https://github.com/swiftwasm/JavaScriptKit.git",
.upToNextMinor(from: "0.10.0") .upToNextMinor(from: "0.10.0")
), ),
.package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.12.0"), .package(
.package(url: "https://github.com/swiftwasm/OpenCombineJS.git", .upToNextMinor(from: "0.1.1")), url: "https://github.com/OpenCombine/OpenCombine.git",
.package(name: "Benchmark", url: "https://github.com/google/swift-benchmark", from: "0.1.0"), from: "0.12.0"
),
.package(
url: "https://github.com/swiftwasm/OpenCombineJS.git",
.upToNextMinor(from: "0.1.1")
),
.package(
name: "Benchmark",
url: "https://github.com/google/swift-benchmark",
from: "0.1.0"
),
.package( .package(
name: "SnapshotTesting", name: "SnapshotTesting",
url: "https://github.com/pointfreeco/swift-snapshot-testing.git", url: "https://github.com/pointfreeco/swift-snapshot-testing.git",
@ -190,7 +200,7 @@ let package = Package(
.product( .product(
name: "SnapshotTesting", name: "SnapshotTesting",
package: "SnapshotTesting", package: "SnapshotTesting",
condition: .when(platforms: [.macOS, .linux]) condition: .when(platforms: [.macOS])
), ),
], ],
exclude: ["__Snapshots__"] exclude: ["__Snapshots__"]

View File

@ -41,7 +41,8 @@ final class MountedApp<R: Renderer>: MountedCompositeElement<R> {
/// Mounts a child scene within the app. /// Mounts a child scene within the app.
/// - Parameters: /// - Parameters:
/// - renderer: A instance conforming to the `Renderer` protocol to render the mounted scene with. /// - renderer: An instance conforming to the `Renderer` protocol to render the mounted
/// scene with.
/// - childBody: The body of the child scene to mount for this app. /// - childBody: The body of the child scene to mount for this app.
/// - Returns: Returns an instance of the `MountedScene` class that's already mounted in this app. /// - Returns: Returns an instance of the `MountedScene` class that's already mounted in this app.
private func mountChild(_ renderer: R, _ childBody: _AnyScene) -> MountedScene<R> { private func mountChild(_ renderer: R, _ childBody: _AnyScene) -> MountedScene<R> {

View File

@ -34,8 +34,8 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
mountedChildren = [child] mountedChildren = [child]
child.mount(before: sibling, on: self, with: reconciler) child.mount(before: sibling, on: self, with: reconciler)
// `_TargetRef` (and `TargetRefType` generic eraser protocol it conforms to) is a composite view, so it's enough // `_TargetRef` (and `TargetRefType` generic eraser protocol it conforms to) is a composite
// to check for it only here. // view, so it's enough check for it only here.
if var targetRef = view.view as? TargetRefType { if var targetRef = view.view as? TargetRefType {
// `_TargetRef` body is not always a host view that has a target, need to traverse // `_TargetRef` body is not always a host view that has a target, need to traverse
// all descendants to find a `MountedHostView<R>` instance. // all descendants to find a `MountedHostView<R>` instance.

View File

@ -32,6 +32,7 @@ func _getTypeByMangledNameInContext(
_ genericArguments: UnsafeRawPointer? _ genericArguments: UnsafeRawPointer?
) -> Any.Type? ) -> Any.Type?
// swiftlint:disable:next line_length
/// https://github.com/apple/swift/blob/f2c42509628bed66bf5b8ee02fae778a2ba747a1/include/swift/Reflection/Records.h#L160 /// https://github.com/apple/swift/blob/f2c42509628bed66bf5b8ee02fae778a2ba747a1/include/swift/Reflection/Records.h#L160
struct FieldDescriptor { struct FieldDescriptor {
let mangledTypeNameOffset: Int32 let mangledTypeNameOffset: Int32

View File

@ -1,628 +0,0 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 06/28/2020.
//
import Foundation
/// The outline of a 2D shape.
public struct Path: Equatable, LosslessStringConvertible {
public class _PathBox: Equatable {
var elements: [Element] = []
public static func == (lhs: Path._PathBox, rhs: Path._PathBox) -> Bool {
lhs.elements == rhs.elements
}
init() {}
init(elements: [Element]) {
self.elements = elements
}
}
public var description: String {
var pathString = [String]()
for element in elements {
switch element {
case let .move(to: pos):
pathString.append("\(pos.x) \(pos.y) m")
case let .line(to: pos):
pathString.append("\(pos.x) \(pos.y) l")
case let .curve(to: pos, control1: c1, control2: c2):
pathString.append("\(c1.x) \(c1.y) \(c2.x) \(c2.y) \(pos.x) \(pos.y) c")
case let .quadCurve(to: pos, control: c):
pathString.append("\(c.x) \(c.y) \(pos.x) \(pos.y) q")
case .closeSubpath:
pathString.append("h")
}
}
return pathString.joined(separator: " ")
}
public enum Storage: Equatable {
case empty
case rect(CGRect)
case ellipse(CGRect)
indirect case roundedRect(FixedRoundedRect)
indirect case stroked(StrokedPath)
indirect case trimmed(TrimmedPath)
case path(_PathBox)
}
public enum Element: Equatable {
case move(to: CGPoint)
case line(to: CGPoint)
case quadCurve(to: CGPoint, control: CGPoint)
case curve(to: CGPoint, control1: CGPoint, control2: CGPoint)
case closeSubpath
}
public var storage: Storage
public let sizing: _Sizing
public var elements: [Element] { storage.elements }
public init() {
storage = .empty
sizing = .fixed
}
init(storage: Storage, sizing: _Sizing = .fixed) {
self.storage = storage
self.sizing = sizing
}
public init(_ rect: CGRect) {
self.init(storage: .rect(rect))
}
public init(roundedRect rect: CGRect, cornerSize: CGSize, style: RoundedCornerStyle = .circular) {
self.init(
storage: .roundedRect(FixedRoundedRect(rect: rect, cornerSize: cornerSize, style: style))
)
}
public init(
roundedRect rect: CGRect,
cornerRadius: CGFloat,
style: RoundedCornerStyle = .circular
) {
self.init(
storage: .roundedRect(FixedRoundedRect(
rect: rect,
cornerSize: CGSize(width: cornerRadius, height: cornerRadius),
style: style
))
)
}
public init(ellipseIn rect: CGRect) {
self.init(storage: .ellipse(rect))
}
public init(_ callback: (inout Self) -> ()) {
var base = Self()
callback(&base)
self = base
}
public init?(_ string: String) {
// FIXME: Somehow make this from a string?
self.init()
}
// FIXME: We don't have CGPath
// public var cgPath: CGPath {
//
// }
public var isEmpty: Bool {
storage == .empty
}
public var boundingRect: CGRect {
switch storage {
case .empty: return .zero
case let .rect(rect): return rect
case let .ellipse(rect): return rect
case let .roundedRect(fixedRoundedRect): return fixedRoundedRect.rect
case let .stroked(strokedPath): return strokedPath.path.boundingRect
case let .trimmed(trimmedPath): return trimmedPath.path.boundingRect
case let .path(pathBox):
// Note: Copied from TokamakStaticHTML/Shapes/Path.swift
// Should the control points be included in the positions array?
let positions = pathBox.elements.compactMap { elem -> CGPoint? in
switch elem {
case let .move(to: pos): return pos
case let .line(to: pos): return pos
case let .curve(to: pos, control1: _, control2: _): return pos
case let .quadCurve(to: pos, control: _): return pos
case .closeSubpath: return nil
}
}
let xPos = positions.map(\.x).sorted(by: <)
let minX = xPos.first ?? 0
let maxX = xPos.last ?? 0
let yPos = positions.map(\.y).sorted(by: <)
let minY = yPos.first ?? 0
let maxY = yPos.last ?? 0
return CGRect(
origin: CGPoint(x: minX, y: minY),
size: CGSize(width: maxX - minX, height: maxY - minY)
)
}
}
public func contains(_ p: CGPoint, eoFill: Bool = false) -> Bool {
false
}
public func forEach(_ body: (Element) -> ()) {
elements.forEach { body($0) }
}
public func strokedPath(_ style: StrokeStyle) -> Self {
Self(storage: .stroked(StrokedPath(path: self, style: style)))
}
public func trimmedPath(from: CGFloat, to: CGFloat) -> Self {
Self(storage: .trimmed(TrimmedPath(path: self, from: from, to: to)))
}
// FIXME: In SwiftUI, but we don't have CGPath...
// public init(_ path: CGPath)
// public init(_ path: CGMutablePath)
}
public enum RoundedCornerStyle: Hashable, Equatable {
case circular
case continuous
}
public extension Path.Storage {
var elements: [Path.Element] {
switch self {
case .empty:
return []
case let .rect(rect):
return [
.move(to: rect.origin),
.line(to: CGPoint(x: rect.size.width, y: 0).offset(by: rect.origin)),
.line(to: CGPoint(x: rect.size.width, y: rect.size.height).offset(by: rect.origin)),
.line(to: CGPoint(x: 0, y: rect.size.height).offset(by: rect.origin)),
.closeSubpath,
]
case let .ellipse(rect):
// Scale down from a circle of max(width, height) in order to limit
// precision loss. Scaling up from a unit circle also looked alright,
// but scaling down is likely a bit better.
let size = max(rect.size.width, rect.size.height)
guard size > 0 else { return [] }
let transform: CGAffineTransform
if rect.size.width > rect.size.height {
transform = CGAffineTransform(
scaleX: 1,
y: rect.size.height / rect.size.width
)
} else if rect.size.height > rect.size.width {
transform = CGAffineTransform(
scaleX: rect.size.width / rect.size.height,
y: 1
)
} else {
transform = .identity
}
let elements = [
[.move(to: CGPoint(x: size, y: size / 2))],
getArc(
center: CGPoint(
x: size / 2,
y: size / 2
),
radius: size / 2,
startAngle: Angle(radians: 0),
endAngle: Angle(radians: 2 * Double.pi),
clockwise: false
),
[.closeSubpath],
].flatMap { $0 }
return elements.map {
transform
.translatedBy(x: rect.origin.x, y: rect.origin.y)
.transform(element: $0)
}
case let .roundedRect(roundedRect):
// A cornerSize of nil means that we are drawing a Capsule
// In other words the corner size should be half of the min
// of the size and width
let rect = roundedRect.rect
let cornerSize = roundedRect.cornerSize ??
CGSize(
width: min(rect.size.width, rect.size.height) / 2,
height: min(rect.size.width, rect.size.height) / 2
)
let cornerStyle = roundedRect.style
switch cornerStyle {
case .continuous:
return [
.move(to: CGPoint(x: rect.size.width, y: rect.size.height / 2).offset(by: rect.origin)),
.line(
to: CGPoint(x: rect.size.width, y: rect.size.height - cornerSize.height)
.offset(by: rect.origin)
),
.quadCurve(
to: CGPoint(x: rect.size.width - cornerSize.width, y: rect.size.height)
.offset(by: rect.origin),
control: CGPoint(x: rect.size.width, y: rect.size.height)
.offset(by: rect.origin)
),
.line(to: CGPoint(x: cornerSize.width, y: rect.size.height).offset(by: rect.origin)),
.quadCurve(
to: CGPoint(x: 0, y: rect.size.height - cornerSize.height)
.offset(by: rect.origin),
control: CGPoint(x: 0, y: rect.size.height)
.offset(by: rect.origin)
),
.line(to: CGPoint(x: 0, y: cornerSize.height).offset(by: rect.origin)),
.quadCurve(
to: CGPoint(x: cornerSize.width, y: 0)
.offset(by: rect.origin),
control: CGPoint.zero
.offset(by: rect.origin)
),
.line(to: CGPoint(x: rect.size.width - cornerSize.width, y: 0).offset(by: rect.origin)),
.quadCurve(
to: CGPoint(x: rect.size.width, y: cornerSize.height)
.offset(by: rect.origin),
control: CGPoint(x: rect.size.width, y: 0)
.offset(by: rect.origin)
),
.closeSubpath,
]
case .circular:
// TODO: This currently only supports circular corners and not ellipsoidal...
// This could be implemented by transforming the elements returned by
// the getArc calls.
return
[
[
.move(
to: CGPoint(x: rect.size.width, y: rect.size.height / 2)
.offset(by: rect.origin)
),
.line(
to: CGPoint(x: rect.size.width, y: rect.size.height - cornerSize.height)
.offset(by: rect.origin)
),
],
getArc(
center: CGPoint(
x: rect.size.width - cornerSize.width,
y: rect.size.height - cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: 0),
endAngle: Angle(radians: Double.pi / 2),
clockwise: false
),
[.line(
to: CGPoint(x: cornerSize.width, y: rect.size.height)
.offset(by: rect.origin)
)],
getArc(
center: CGPoint(
x: cornerSize.width,
y: rect.size.height - cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: Double.pi / 2),
endAngle: Angle(radians: Double.pi),
clockwise: false
),
[.line(
to: CGPoint(x: 0, y: cornerSize.height)
.offset(by: rect.origin)
)],
getArc(
center: CGPoint(
x: cornerSize.width,
y: cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: Double.pi),
endAngle: Angle(radians: 3 * Double.pi / 2),
clockwise: false
),
[.line(
to: CGPoint(x: rect.size.width - cornerSize.width, y: 0)
.offset(by: rect.origin)
)],
getArc(
center: CGPoint(
x: rect.size.width - cornerSize.width,
y: cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: 3 * Double.pi / 2),
endAngle: Angle(radians: 2 * Double.pi),
clockwise: false
),
[.closeSubpath],
].flatMap { $0 }
}
case let .stroked(stroked):
// TODO: This is not actually how stroking is implemented
return stroked.path.storage.elements
case let .trimmed(trimmed):
// TODO: This is not actually how trimmingis implemented
return trimmed.path.storage.elements
case let .path(box):
return box.elements
}
}
}
public extension Path {
private mutating func append(_ other: Storage, transform: CGAffineTransform = .identity) {
guard other != .empty else { return }
// If self.storage is empty, replace with other storage.
// Otherwise append elements to current storage.
switch (storage, transform.isIdentity) {
case (.empty, true):
storage = other
default:
append(other.elements, transform: transform)
}
}
private mutating func append(_ elements: [Element], transform: CGAffineTransform = .identity) {
guard !elements.isEmpty else { return }
let elements_: [Element]
if transform.isIdentity {
elements_ = elements
} else {
elements_ = elements.map { transform.transform(element: $0) }
}
switch storage {
case let .path(pathBox):
pathBox.elements.append(contentsOf: elements_)
default:
storage = .path(_PathBox(elements: storage.elements + elements_))
}
}
mutating func move(to p: CGPoint) {
append([.move(to: p)])
}
mutating func addLine(to p: CGPoint) {
append([.line(to: p)])
}
mutating func addQuadCurve(to p: CGPoint, control cp: CGPoint) {
append([.quadCurve(to: p, control: cp)])
}
mutating func addCurve(to p: CGPoint, control1 cp1: CGPoint, control2 cp2: CGPoint) {
append([.curve(to: p, control1: cp1, control2: cp2)])
}
mutating func closeSubpath() {
append([.closeSubpath])
}
mutating func addRect(_ rect: CGRect, transform: CGAffineTransform = .identity) {
append(.rect(rect), transform: transform)
}
mutating func addRoundedRect(
in rect: CGRect,
cornerSize: CGSize,
style: RoundedCornerStyle = .circular,
transform: CGAffineTransform = .identity
) {
append(
.roundedRect(FixedRoundedRect(rect: rect, cornerSize: cornerSize, style: style)),
transform: transform
)
}
mutating func addEllipse(in rect: CGRect, transform: CGAffineTransform = .identity) {
append(.ellipse(rect), transform: transform)
}
mutating func addRects(_ rects: [CGRect], transform: CGAffineTransform = .identity) {
rects.forEach { addRect($0, transform: transform) }
}
mutating func addLines(_ lines: [CGPoint]) {
lines.forEach { addLine(to: $0) }
}
mutating func addRelativeArc(
center: CGPoint,
radius: CGFloat,
startAngle: Angle,
delta: Angle,
transform: CGAffineTransform = .identity
) {
addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: startAngle + delta,
clockwise: false,
transform: transform
)
}
// There's a great article on bezier curves here:
// https://pomax.github.io/bezierinfo
// FIXME: Handle negative delta
mutating func addArc(
center: CGPoint,
radius: CGFloat,
startAngle: Angle,
endAngle: Angle,
clockwise: Bool,
transform: CGAffineTransform = .identity
) {
let arc = getArc(
center: center,
radius: radius,
startAngle: endAngle,
endAngle: endAngle + (.radians(.pi * 2) - endAngle) + startAngle,
clockwise: false
)
append(arc, transform: transform)
}
// FIXME: How does this arc method work?
mutating func addArc(
tangent1End p1: CGPoint,
tangent2End p2: CGPoint,
radius: CGFloat,
transform: CGAffineTransform = .identity
) {}
mutating func addPath(_ path: Path, transform: CGAffineTransform = .identity) {
append(path.storage, transform: transform)
}
var currentPoint: CGPoint? {
switch elements.last {
case let .move(to: point): return point
case let .line(to: point): return point
case let .curve(to: point, control1: _, control2: _): return point
case let .quadCurve(to: point, control: _): return point
default: return nil
}
}
func applying(_ transform: CGAffineTransform) -> Path {
guard transform != .identity else { return self }
let elements = self.elements.map { transform.transform(element: $0) }
let box = _PathBox(elements: elements)
return Path(storage: .path(box), sizing: .fixed)
}
func offsetBy(dx: CGFloat, dy: CGFloat) -> Path {
applying(.init(translationX: dx, y: dy))
}
}
extension Path: Shape {
public func path(in rect: CGRect) -> Path {
self
}
}
public extension CGAffineTransform {
func transform(element: Path.Element) -> Path.Element {
switch element {
case let .move(to: p):
return .move(to: transform(point: p))
case let .line(to: p):
return .line(to: transform(point: p))
case let .curve(to: p, control1: c1, control2: c2):
return .curve(
to: transform(point: p),
control1: transform(point: c1),
control2: transform(point: c2)
)
case let .quadCurve(to: p, control: c):
return .quadCurve(to: transform(point: p), control: transform(point: c))
case .closeSubpath:
return element
}
}
}
private func getArc(
center: CGPoint,
radius: CGFloat,
startAngle: Angle,
endAngle: Angle,
clockwise: Bool
) -> [Path.Element] {
if clockwise {
return getArc(
center: center,
radius: radius,
startAngle: endAngle,
endAngle: endAngle + (.radians(.pi * 2) - endAngle) + startAngle,
clockwise: false
)
} else {
let angle = abs(startAngle.radians - endAngle.radians)
if angle > .pi / 2 {
// Split the angle into 90º chunks
let chunk1 = Angle.radians(startAngle.radians + (.pi / 2))
return getArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: chunk1,
clockwise: clockwise
) +
getArc(
center: center,
radius: radius,
startAngle: chunk1,
endAngle: endAngle,
clockwise: clockwise
)
} else {
let angle = CGFloat(angle)
let endPoint = CGPoint(
x: (radius * cos(angle)) + center.x,
y: (radius * sin(angle)) + center.y
)
let l = (4 / 3) * tan(angle / 4)
let c1 = CGPoint(x: radius + center.x, y: (l * radius) + center.y)
let c2 = CGPoint(
x: ((cos(angle) + l * sin(angle)) * radius) + center.x,
y: ((sin(angle) - l * cos(angle)) * radius) + center.y
)
return [
.curve(
to: endPoint.rotate(startAngle, around: center),
control1: c1.rotate(startAngle, around: center),
control2: c2.rotate(startAngle, around: center)
),
]
}
}
}

View File

@ -0,0 +1,192 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 06/28/2020.
//
import Foundation
/// The outline of a 2D shape.
public struct Path: Equatable, LosslessStringConvertible {
public class _PathBox: Equatable {
var elements: [Element] = []
public static func == (lhs: Path._PathBox, rhs: Path._PathBox) -> Bool {
lhs.elements == rhs.elements
}
init() {}
init(elements: [Element]) {
self.elements = elements
}
}
public var description: String {
var pathString = [String]()
for element in elements {
switch element {
case let .move(to: pos):
pathString.append("\(pos.x) \(pos.y) m")
case let .line(to: pos):
pathString.append("\(pos.x) \(pos.y) l")
case let .curve(to: pos, control1: c1, control2: c2):
pathString.append("\(c1.x) \(c1.y) \(c2.x) \(c2.y) \(pos.x) \(pos.y) c")
case let .quadCurve(to: pos, control: c):
pathString.append("\(c.x) \(c.y) \(pos.x) \(pos.y) q")
case .closeSubpath:
pathString.append("h")
}
}
return pathString.joined(separator: " ")
}
public enum Storage: Equatable {
case empty
case rect(CGRect)
case ellipse(CGRect)
indirect case roundedRect(FixedRoundedRect)
indirect case stroked(StrokedPath)
indirect case trimmed(TrimmedPath)
case path(_PathBox)
}
public enum Element: Equatable {
case move(to: CGPoint)
case line(to: CGPoint)
case quadCurve(to: CGPoint, control: CGPoint)
case curve(to: CGPoint, control1: CGPoint, control2: CGPoint)
case closeSubpath
}
public var storage: Storage
public let sizing: _Sizing
public var elements: [Element] { storage.elements }
public init() {
storage = .empty
sizing = .fixed
}
init(storage: Storage, sizing: _Sizing = .fixed) {
self.storage = storage
self.sizing = sizing
}
public init(_ rect: CGRect) {
self.init(storage: .rect(rect))
}
public init(roundedRect rect: CGRect, cornerSize: CGSize, style: RoundedCornerStyle = .circular) {
self.init(
storage: .roundedRect(FixedRoundedRect(rect: rect, cornerSize: cornerSize, style: style))
)
}
public init(
roundedRect rect: CGRect,
cornerRadius: CGFloat,
style: RoundedCornerStyle = .circular
) {
self.init(
storage: .roundedRect(FixedRoundedRect(
rect: rect,
cornerSize: CGSize(width: cornerRadius, height: cornerRadius),
style: style
))
)
}
public init(ellipseIn rect: CGRect) {
self.init(storage: .ellipse(rect))
}
public init(_ callback: (inout Self) -> ()) {
var base = Self()
callback(&base)
self = base
}
public init?(_ string: String) {
// FIXME: Somehow make this from a string?
self.init()
}
// FIXME: We don't have CGPath
// public var cgPath: CGPath {
//
// }
public var isEmpty: Bool {
storage == .empty
}
public var boundingRect: CGRect {
switch storage {
case .empty: return .zero
case let .rect(rect): return rect
case let .ellipse(rect): return rect
case let .roundedRect(fixedRoundedRect): return fixedRoundedRect.rect
case let .stroked(strokedPath): return strokedPath.path.boundingRect
case let .trimmed(trimmedPath): return trimmedPath.path.boundingRect
case let .path(pathBox):
// Note: Copied from TokamakStaticHTML/Shapes/Path.swift
// Should the control points be included in the positions array?
let positions = pathBox.elements.compactMap { elem -> CGPoint? in
switch elem {
case let .move(to: pos): return pos
case let .line(to: pos): return pos
case let .curve(to: pos, control1: _, control2: _): return pos
case let .quadCurve(to: pos, control: _): return pos
case .closeSubpath: return nil
}
}
let xPos = positions.map(\.x).sorted(by: <)
let minX = xPos.first ?? 0
let maxX = xPos.last ?? 0
let yPos = positions.map(\.y).sorted(by: <)
let minY = yPos.first ?? 0
let maxY = yPos.last ?? 0
return CGRect(
origin: CGPoint(x: minX, y: minY),
size: CGSize(width: maxX - minX, height: maxY - minY)
)
}
}
public func contains(_ p: CGPoint, eoFill: Bool = false) -> Bool {
false
}
public func forEach(_ body: (Element) -> ()) {
elements.forEach { body($0) }
}
public func strokedPath(_ style: StrokeStyle) -> Self {
Self(storage: .stroked(StrokedPath(path: self, style: style)))
}
public func trimmedPath(from: CGFloat, to: CGFloat) -> Self {
Self(storage: .trimmed(TrimmedPath(path: self, from: from, to: to)))
}
// FIXME: In SwiftUI, but we don't have CGPath...
// public init(_ path: CGPath)
// public init(_ path: CGMutablePath)
}
public enum RoundedCornerStyle: Hashable, Equatable {
case circular
case continuous
}

View File

@ -0,0 +1,265 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Max Desiatov on 20/06/2021.
//
import Foundation
public extension Path.Storage {
var elements: [Path.Element] {
switch self {
case .empty:
return []
case let .rect(rect):
return [
.move(to: rect.origin),
.line(to: CGPoint(x: rect.size.width, y: 0).offset(by: rect.origin)),
.line(to: CGPoint(x: rect.size.width, y: rect.size.height).offset(by: rect.origin)),
.line(to: CGPoint(x: 0, y: rect.size.height).offset(by: rect.origin)),
.closeSubpath,
]
case let .ellipse(rect):
// Scale down from a circle of max(width, height) in order to limit
// precision loss. Scaling up from a unit circle also looked alright,
// but scaling down is likely a bit better.
let size = max(rect.size.width, rect.size.height)
guard size > 0 else { return [] }
let transform: CGAffineTransform
if rect.size.width > rect.size.height {
transform = CGAffineTransform(
scaleX: 1,
y: rect.size.height / rect.size.width
)
} else if rect.size.height > rect.size.width {
transform = CGAffineTransform(
scaleX: rect.size.width / rect.size.height,
y: 1
)
} else {
transform = .identity
}
let elements = [
[.move(to: CGPoint(x: size, y: size / 2))],
getArc(
center: CGPoint(
x: size / 2,
y: size / 2
),
radius: size / 2,
startAngle: Angle(radians: 0),
endAngle: Angle(radians: 2 * Double.pi),
clockwise: false
),
[.closeSubpath],
].flatMap { $0 }
return elements.map {
transform
.translatedBy(x: rect.origin.x, y: rect.origin.y)
.transform(element: $0)
}
case let .roundedRect(roundedRect):
// A cornerSize of nil means that we are drawing a Capsule
// In other words the corner size should be half of the min
// of the size and width
let rect = roundedRect.rect
let cornerSize = roundedRect.cornerSize ??
CGSize(
width: min(rect.size.width, rect.size.height) / 2,
height: min(rect.size.width, rect.size.height) / 2
)
let cornerStyle = roundedRect.style
switch cornerStyle {
case .continuous:
return [
.move(to: CGPoint(x: rect.size.width, y: rect.size.height / 2).offset(by: rect.origin)),
.line(
to: CGPoint(x: rect.size.width, y: rect.size.height - cornerSize.height)
.offset(by: rect.origin)
),
.quadCurve(
to: CGPoint(x: rect.size.width - cornerSize.width, y: rect.size.height)
.offset(by: rect.origin),
control: CGPoint(x: rect.size.width, y: rect.size.height)
.offset(by: rect.origin)
),
.line(to: CGPoint(x: cornerSize.width, y: rect.size.height).offset(by: rect.origin)),
.quadCurve(
to: CGPoint(x: 0, y: rect.size.height - cornerSize.height)
.offset(by: rect.origin),
control: CGPoint(x: 0, y: rect.size.height)
.offset(by: rect.origin)
),
.line(to: CGPoint(x: 0, y: cornerSize.height).offset(by: rect.origin)),
.quadCurve(
to: CGPoint(x: cornerSize.width, y: 0)
.offset(by: rect.origin),
control: CGPoint.zero
.offset(by: rect.origin)
),
.line(to: CGPoint(x: rect.size.width - cornerSize.width, y: 0).offset(by: rect.origin)),
.quadCurve(
to: CGPoint(x: rect.size.width, y: cornerSize.height)
.offset(by: rect.origin),
control: CGPoint(x: rect.size.width, y: 0)
.offset(by: rect.origin)
),
.closeSubpath,
]
case .circular:
// TODO: This currently only supports circular corners and not ellipsoidal...
// This could be implemented by transforming the elements returned by
// the getArc calls.
return
[
[
.move(
to: CGPoint(x: rect.size.width, y: rect.size.height / 2)
.offset(by: rect.origin)
),
.line(
to: CGPoint(x: rect.size.width, y: rect.size.height - cornerSize.height)
.offset(by: rect.origin)
),
],
getArc(
center: CGPoint(
x: rect.size.width - cornerSize.width,
y: rect.size.height - cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: 0),
endAngle: Angle(radians: Double.pi / 2),
clockwise: false
),
[.line(
to: CGPoint(x: cornerSize.width, y: rect.size.height)
.offset(by: rect.origin)
)],
getArc(
center: CGPoint(
x: cornerSize.width,
y: rect.size.height - cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: Double.pi / 2),
endAngle: Angle(radians: Double.pi),
clockwise: false
),
[.line(
to: CGPoint(x: 0, y: cornerSize.height)
.offset(by: rect.origin)
)],
getArc(
center: CGPoint(
x: cornerSize.width,
y: cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: Double.pi),
endAngle: Angle(radians: 3 * Double.pi / 2),
clockwise: false
),
[.line(
to: CGPoint(x: rect.size.width - cornerSize.width, y: 0)
.offset(by: rect.origin)
)],
getArc(
center: CGPoint(
x: rect.size.width - cornerSize.width,
y: cornerSize.height
)
.offset(by: rect.origin),
radius: cornerSize.width,
startAngle: Angle(radians: 3 * Double.pi / 2),
endAngle: Angle(radians: 2 * Double.pi),
clockwise: false
),
[.closeSubpath],
].flatMap { $0 }
}
case let .stroked(stroked):
// TODO: This is not actually how stroking is implemented
return stroked.path.storage.elements
case let .trimmed(trimmed):
// TODO: This is not actually how trimmingis implemented
return trimmed.path.storage.elements
case let .path(box):
return box.elements
}
}
}
public extension Path {
var currentPoint: CGPoint? {
switch elements.last {
case let .move(to: point): return point
case let .line(to: point): return point
case let .curve(to: point, control1: _, control2: _): return point
case let .quadCurve(to: point, control: _): return point
default: return nil
}
}
func applying(_ transform: CGAffineTransform) -> Path {
guard transform != .identity else { return self }
let elements = self.elements.map { transform.transform(element: $0) }
let box = _PathBox(elements: elements)
return Path(storage: .path(box), sizing: .fixed)
}
func offsetBy(dx: CGFloat, dy: CGFloat) -> Path {
applying(.init(translationX: dx, y: dy))
}
}
extension Path: Shape {
public func path(in rect: CGRect) -> Path {
self
}
}
public extension CGAffineTransform {
func transform(element: Path.Element) -> Path.Element {
switch element {
case let .move(to: p):
return .move(to: transform(point: p))
case let .line(to: p):
return .line(to: transform(point: p))
case let .curve(to: p, control1: c1, control2: c2):
return .curve(
to: transform(point: p),
control1: transform(point: c1),
control2: transform(point: c2)
)
case let .quadCurve(to: p, control: c):
return .quadCurve(to: transform(point: p), control: transform(point: c))
case .closeSubpath:
return element
}
}
}

View File

@ -0,0 +1,209 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Max Desiatov on 20/06/2021.
//
import Foundation
public extension Path {
private mutating func append(_ other: Storage, transform: CGAffineTransform = .identity) {
guard other != .empty else { return }
// If self.storage is empty, replace with other storage.
// Otherwise append elements to current storage.
switch (storage, transform.isIdentity) {
case (.empty, true):
storage = other
default:
append(other.elements, transform: transform)
}
}
private mutating func append(_ elements: [Element], transform: CGAffineTransform = .identity) {
guard !elements.isEmpty else { return }
let elements_: [Element]
if transform.isIdentity {
elements_ = elements
} else {
elements_ = elements.map { transform.transform(element: $0) }
}
switch storage {
case let .path(pathBox):
pathBox.elements.append(contentsOf: elements_)
default:
storage = .path(_PathBox(elements: storage.elements + elements_))
}
}
mutating func move(to p: CGPoint) {
append([.move(to: p)])
}
mutating func addLine(to p: CGPoint) {
append([.line(to: p)])
}
mutating func addQuadCurve(to p: CGPoint, control cp: CGPoint) {
append([.quadCurve(to: p, control: cp)])
}
mutating func addCurve(to p: CGPoint, control1 cp1: CGPoint, control2 cp2: CGPoint) {
append([.curve(to: p, control1: cp1, control2: cp2)])
}
mutating func closeSubpath() {
append([.closeSubpath])
}
mutating func addRect(_ rect: CGRect, transform: CGAffineTransform = .identity) {
append(.rect(rect), transform: transform)
}
mutating func addRoundedRect(
in rect: CGRect,
cornerSize: CGSize,
style: RoundedCornerStyle = .circular,
transform: CGAffineTransform = .identity
) {
append(
.roundedRect(FixedRoundedRect(rect: rect, cornerSize: cornerSize, style: style)),
transform: transform
)
}
mutating func addEllipse(in rect: CGRect, transform: CGAffineTransform = .identity) {
append(.ellipse(rect), transform: transform)
}
mutating func addRects(_ rects: [CGRect], transform: CGAffineTransform = .identity) {
rects.forEach { addRect($0, transform: transform) }
}
mutating func addLines(_ lines: [CGPoint]) {
lines.forEach { addLine(to: $0) }
}
mutating func addRelativeArc(
center: CGPoint,
radius: CGFloat,
startAngle: Angle,
delta: Angle,
transform: CGAffineTransform = .identity
) {
addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: startAngle + delta,
clockwise: false,
transform: transform
)
}
// There's a great article on bezier curves here:
// https://pomax.github.io/bezierinfo
// FIXME: Handle negative delta
mutating func addArc(
center: CGPoint,
radius: CGFloat,
startAngle: Angle,
endAngle: Angle,
clockwise: Bool,
transform: CGAffineTransform = .identity
) {
let arc = getArc(
center: center,
radius: radius,
startAngle: endAngle,
endAngle: endAngle + (.radians(.pi * 2) - endAngle) + startAngle,
clockwise: false
)
append(arc, transform: transform)
}
// FIXME: How does this arc method work?
mutating func addArc(
tangent1End p1: CGPoint,
tangent2End p2: CGPoint,
radius: CGFloat,
transform: CGAffineTransform = .identity
) {}
mutating func addPath(_ path: Path, transform: CGAffineTransform = .identity) {
append(path.storage, transform: transform)
}
}
func getArc(
center: CGPoint,
radius: CGFloat,
startAngle: Angle,
endAngle: Angle,
clockwise: Bool
) -> [Path.Element] {
if clockwise {
return getArc(
center: center,
radius: radius,
startAngle: endAngle,
endAngle: endAngle + (.radians(.pi * 2) - endAngle) + startAngle,
clockwise: false
)
} else {
let angle = abs(startAngle.radians - endAngle.radians)
if angle > .pi / 2 {
// Split the angle into 90º chunks
let chunk1 = Angle.radians(startAngle.radians + (.pi / 2))
return getArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: chunk1,
clockwise: clockwise
) +
getArc(
center: center,
radius: radius,
startAngle: chunk1,
endAngle: endAngle,
clockwise: clockwise
)
} else {
let angle = CGFloat(angle)
let endPoint = CGPoint(
x: (radius * cos(angle)) + center.x,
y: (radius * sin(angle)) + center.y
)
let l = (4 / 3) * tan(angle / 4)
let c1 = CGPoint(x: radius + center.x, y: (l * radius) + center.y)
let c2 = CGPoint(
x: ((cos(angle) + l * sin(angle)) * radius) + center.x,
y: ((sin(angle) - l * cos(angle)) * radius) + center.y
)
return [
.curve(
to: endPoint.rotate(startAngle, around: center),
control1: c1.rotate(startAngle, around: center),
control2: c2.rotate(startAngle, around: center)
),
]
}
}
}

View File

@ -145,6 +145,7 @@ extension Text: AnyHTML {
} }
extension Text { extension Text {
// swiftlint:disable function_body_length
static func attributes( static func attributes(
from modifiers: [_Modifier], from modifiers: [_Modifier],
environment: EnvironmentValues environment: EnvironmentValues
@ -188,7 +189,6 @@ extension Text {
let decorationColor = strikethrough?.1?.cssValue(environment) let decorationColor = strikethrough?.1?.cssValue(environment)
?? underline?.1?.cssValue(environment) ?? underline?.1?.cssValue(environment)
?? "inherit" ?? "inherit"
let resolvedFont = font == nil ? nil : _FontProxy(font!).resolve(in: environment) let resolvedFont = font == nil ? nil : _FontProxy(font!).resolve(in: environment)
return [ return [
@ -208,4 +208,5 @@ extension Text {
"class": isRedacted ? "_tokamak-text-redacted" : "", "class": isRedacted ? "_tokamak-text-redacted" : "",
] ]
} }
// swiftlint:enable function_body_length
} }

View File

@ -0,0 +1,92 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Max Desiatov on 13/06/2021.
//
// SnapshotTesting with image snapshots are only supported on iOS.
#if os(macOS)
import SnapshotTesting
import TokamakStaticHTML
import XCTest
// Needed for `NSImage`, but would be great to make this truly cross-platform.
import class AppKit.NSImage
public extension Snapshotting where Value: View, Format == NSImage {
static var image: Snapshotting { .image() }
/// A snapshot strategy for comparing Tokamak Views based on pixel equality.
static func image(precision: Float = 1, size: CGSize? = nil) -> Snapshotting {
SimplySnapshotting.image(precision: precision).asyncPullback { view in
Async { callback in
let html = Data(StaticHTMLRenderer(view).render().utf8)
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let renderedPath = cwd.appendingPathComponent("rendered.html")
// swiftlint:disable:next force_try
try! html.write(to: renderedPath)
let browser = Process()
browser
.launchPath = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
var arguments = [
"--headless",
"--disable-gpu",
"--force-device-scale-factor=1.0",
"--force-color-profile=srgb",
"--screenshot",
renderedPath.path,
]
if let size = size {
arguments.append("--window-size=\(Int(size.width)),\(Int(size.height))")
}
browser.arguments = arguments
browser.terminationHandler = { _ in
callback(NSImage(
contentsOfFile: cwd.appendingPathComponent("screenshot.png")
.path
)!)
}
browser.launch()
}
}
}
}
struct Star: Shape {
func path(in rect: CGRect) -> Path {
Path { path in
path.move(to: .init(x: 40, y: 0))
path.addLine(to: .init(x: 20, y: 76))
path.addLine(to: .init(x: 80, y: 30.4))
path.addLine(to: .init(x: 0, y: 30.4))
path.addLine(to: .init(x: 64, y: 76))
path.addLine(to: .init(x: 40, y: 0))
}
}
}
final class LayoutTests: XCTestCase {
func testPath() {
assertSnapshot(
matching: Star().fill(Color(red: 1, green: 0.75, blue: 0.1, opacity: 1)),
as: .image(size: .init(width: 100, height: 100)),
timeout: 10
)
}
}
#endif

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -23,7 +23,6 @@
@testable import TokamakCore @testable import TokamakCore
import XCTest import XCTest
// swiftlint:disable type_body_length
class GetSetStructTests: XCTestCase { class GetSetStructTests: XCTestCase {
// swiftlint:disable force_cast // swiftlint:disable force_cast
func testGet() throws { func testGet() throws {

View File

@ -40,20 +40,9 @@ final class ReconcilerStressTests: XCTestCase {
let renderer = TestRenderer(SpookyHanger()) let renderer = TestRenderer(SpookyHanger())
let root = renderer.rootTarget let root = renderer.rootTarget
return
let list = root.subviews[0].subviews[0]
XCTAssertTrue( XCTAssertTrue(
root.view root.subviews[0].view
.view is NavigationView<List<Never, ForEach<[String], String, NavigationLink<Text, Text>>>> .view is NavigationView<List<Never, ForEach<[String], String, NavigationLink<Text, Text>>>>
) )
guard let link = list.subviews[0].view.view as? NavigationLink<Text, Text> else {
XCTAssert(false, "navigation has no link")
return
}
_NavigationLinkProxy(link).activate()
} }
} }