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:
parent
e6c37a4c80
commit
d35e37c4f5
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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__"]
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 |
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue