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
|
||||
run: |
|
||||
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
|
||||
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
|
||||
|
||||
|
@ -55,6 +56,13 @@ jobs:
|
|||
|
||||
./benchmark.sh
|
||||
|
||||
- name: Upload failed snapshots
|
||||
uses: actions/upload-artifact@v2
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: Failed snapshots
|
||||
path: SnapshotTests
|
||||
|
||||
gtk_macos_build:
|
||||
runs-on: macos-latest
|
||||
|
||||
|
@ -64,7 +72,7 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
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
|
||||
|
||||
|
|
|
@ -53,9 +53,19 @@ let package = Package(
|
|||
url: "https://github.com/swiftwasm/JavaScriptKit.git",
|
||||
.upToNextMinor(from: "0.10.0")
|
||||
),
|
||||
.package(url: "https://github.com/OpenCombine/OpenCombine.git", 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(
|
||||
url: "https://github.com/OpenCombine/OpenCombine.git",
|
||||
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(
|
||||
name: "SnapshotTesting",
|
||||
url: "https://github.com/pointfreeco/swift-snapshot-testing.git",
|
||||
|
@ -190,7 +200,7 @@ let package = Package(
|
|||
.product(
|
||||
name: "SnapshotTesting",
|
||||
package: "SnapshotTesting",
|
||||
condition: .when(platforms: [.macOS, .linux])
|
||||
condition: .when(platforms: [.macOS])
|
||||
),
|
||||
],
|
||||
exclude: ["__Snapshots__"]
|
||||
|
|
|
@ -41,7 +41,8 @@ final class MountedApp<R: Renderer>: MountedCompositeElement<R> {
|
|||
|
||||
/// Mounts a child scene within the app.
|
||||
/// - 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.
|
||||
/// - Returns: Returns an instance of the `MountedScene` class that's already mounted in this app.
|
||||
private func mountChild(_ renderer: R, _ childBody: _AnyScene) -> MountedScene<R> {
|
||||
|
|
|
@ -34,8 +34,8 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {
|
|||
mountedChildren = [child]
|
||||
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
|
||||
// to check for it only here.
|
||||
// `_TargetRef` (and `TargetRefType` generic eraser protocol it conforms to) is a composite
|
||||
// view, so it's enough check for it only here.
|
||||
if var targetRef = view.view as? TargetRefType {
|
||||
// `_TargetRef` body is not always a host view that has a target, need to traverse
|
||||
// all descendants to find a `MountedHostView<R>` instance.
|
||||
|
|
|
@ -32,6 +32,7 @@ func _getTypeByMangledNameInContext(
|
|||
_ genericArguments: UnsafeRawPointer?
|
||||
) -> Any.Type?
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
/// https://github.com/apple/swift/blob/f2c42509628bed66bf5b8ee02fae778a2ba747a1/include/swift/Reflection/Records.h#L160
|
||||
struct FieldDescriptor {
|
||||
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 {
|
||||
// swiftlint:disable function_body_length
|
||||
static func attributes(
|
||||
from modifiers: [_Modifier],
|
||||
environment: EnvironmentValues
|
||||
|
@ -188,7 +189,6 @@ extension Text {
|
|||
let decorationColor = strikethrough?.1?.cssValue(environment)
|
||||
?? underline?.1?.cssValue(environment)
|
||||
?? "inherit"
|
||||
|
||||
let resolvedFont = font == nil ? nil : _FontProxy(font!).resolve(in: environment)
|
||||
|
||||
return [
|
||||
|
@ -208,4 +208,5 @@ extension Text {
|
|||
"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
|
||||
import XCTest
|
||||
|
||||
// swiftlint:disable type_body_length
|
||||
class GetSetStructTests: XCTestCase {
|
||||
// swiftlint:disable force_cast
|
||||
func testGet() throws {
|
||||
|
|
|
@ -40,20 +40,9 @@ final class ReconcilerStressTests: XCTestCase {
|
|||
let renderer = TestRenderer(SpookyHanger())
|
||||
let root = renderer.rootTarget
|
||||
|
||||
return
|
||||
|
||||
let list = root.subviews[0].subviews[0]
|
||||
|
||||
XCTAssertTrue(
|
||||
root.view
|
||||
root.subviews[0].view
|
||||
.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