From 84a3a2ed13de68fee4aeb919dc966ad8fb3a02aa Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Tue, 1 Apr 2025 21:15:14 -0400 Subject: [PATCH 01/16] initial implementation of path --- Sources/SwiftCrossUI/Backend/AppBackend.swift | 30 +++ Sources/SwiftCrossUI/Path.swift | 225 ++++++++++++++++++ .../SwiftCrossUI/Views/Shapes/Circle.swift | 15 ++ .../SwiftCrossUI/Views/Shapes/Rectangle.swift | 7 + Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 73 ++++++ Sources/UIKitBackend/UIKitBackend+Path.swift | 106 +++++++++ 6 files changed, 456 insertions(+) create mode 100644 Sources/SwiftCrossUI/Path.swift create mode 100644 Sources/SwiftCrossUI/Views/Shapes/Circle.swift create mode 100644 Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift create mode 100644 Sources/SwiftCrossUI/Views/Shapes/Shape.swift create mode 100644 Sources/UIKitBackend/UIKitBackend+Path.swift diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 4e166500..ad39187e 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -44,6 +44,7 @@ public protocol AppBackend { associatedtype Widget associatedtype Menu associatedtype Alert + associatedtype Path /// Creates an instance of the backend. init() @@ -527,6 +528,24 @@ public protocol AppBackend { gesture: TapGesture, action: @escaping () -> Void ) + + // MARK: Paths + /// Create a path. It will not be shown until ``renderPath(_:container:)`` is called. + func createPath() -> Path + /// Update a path. The updates do not need to be visible before ``renderPath(_:container:)`` + /// is called. + /// - Parameters: + /// - path: The path to be updated. + /// - source: The source to copy the path from. + /// - pointsChanged: If `false`, the ``Path/actions`` of the source have not changed. + func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) + /// Draw a path to the screen. + /// - Parameters: + /// - path: The path to be rendered. + /// - container: The container widget that the path will render in. It has no other + /// children. + /// - environment: The environment values, including color. + func renderPath(_ path: Path, container: Widget, environment: EnvironmentValues) } extension AppBackend { @@ -843,4 +862,15 @@ extension AppBackend { ) { todo() } + + // MARK: Paths + func createPath() -> Path { + todo() + } + func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + todo() + } + func renderPath(_ path: Path, container: Widget, environment: EnvironmentValues) { + todo() + } } diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift new file mode 100644 index 00000000..f67e372f --- /dev/null +++ b/Sources/SwiftCrossUI/Path.swift @@ -0,0 +1,225 @@ +import Foundation // for sinf and cosf + +public enum StrokeCap { + /// The stroke ends square exactly at the last point. + case butt + /// The stroke ends with a semicircle. + case round + /// The stroke ends square half of the stroke width past the last point. + case square +} + +public enum StrokeJoin { + /// Corners are sharp, unless they are longer than `limit` times half the stroke width, + /// in which case they are beveled. + case miter(limit: Float) + /// Corners are rounded. + case round + /// Corners are beveled. + case bevel +} + +public struct StrokeStyle { + public var width: Float = 1.0 + public var cap: StrokeCap = .butt + public var join: StrokeJoin = .miter(limit: 10.0) + + public static let none = StrokeStyle(width: 0.0) +} + +/// An enum describing how a path is shaded. +public enum FillRule { + /// A region is shaded if it is enclosed an odd number of times. + case evenOdd + /// A region is shaded if it is enclosed at all. + /// + /// This is also known as the "non-zero" rule. + case winding +} + +/// A type representing an affine transformation on a 2-D point. +/// +/// Performing an affine transform consists of translating by ``translation`` followed by +/// multiplying by ``linearTransform``. +public struct AffineTransform: Equatable { + /// The linear transformation. This is a 2x2 matrix stored in row-major order. + /// + /// The four properties (`x`, `y`, `z`, `w`) correspond to the 2x2 matrix as follows: + /// ``` + /// [ x y ] + /// [ z w ] + /// ``` + public var linearTransform: SIMD4 + /// The translation applied after the linear transformation. + public var translation: SIMD2 + + public init(linearTransform: SIMD4, translation: SIMD2) { + self.linearTransform = linearTransform + self.translation = translation + } + + public static func translate(x: Float, y: Float) -> AffineTransform { + AffineTransform( + linearTransform: SIMD4(x: 1.0, y: 0.0, z: 0.0, w: 1.0), + translation: SIMD2(x: x, y: y) + ) + } + + public static func scale(by factor: Float) -> AffineTransform { + AffineTransform( + linearTransform: SIMD4(x: factor, y: 0.0, z: 0.0, w: factor), + translation: .zero + ) + } + + public static func rotate( + radians: Float, + center: SIMD2 = .zero + ) -> AffineTransform { + let sine = sinf(radians) + let cosine = cosf(radians) + return AffineTransform( + linearTransform: SIMD4(x: cosine, y: -sine, z: sine, w: cosine), + translation: SIMD2( + x: -center.x * cosine - center.y * sine + center.x, + y: center.x * sine - center.y * cosine + center.y + ) + ) + } + + public static func rotate( + degrees: Float, + center: SIMD2 = .zero + ) -> AffineTransform { + rotate(radians: degrees * (.pi / 180.0), center: center) + } + + public static let identity = AffineTransform( + linearTransform: SIMD4(x: 1.0, y: 0.0, z: 0.0, w: 1.0), + translation: .zero + ) +} + +public struct Path { + public struct Rect: Equatable { + public var origin: SIMD2 + public var size: SIMD2 + + public init(origin: SIMD2, size: SIMD2) { + self.origin = origin + self.size = size + } + + public var x: Float { origin.x } + public var y: Float { origin.y } + public var width: Float { size.x } + public var height: Float { size.y } + + public init(x: Float, y: Float, width: Float, height: Float) { + origin = SIMD2(x: x, y: y) + size = SIMD2(x: width, y: height) + } + } + + /// The types of actions that can be performed on a path. + public enum Action: Equatable { + case moveTo(SIMD2) + case lineTo(SIMD2) + case quadCurve(control: SIMD2, end: SIMD2) + case cubicCurve(control1: SIMD2, control2: SIMD2, end: SIMD2) + case rectangle(Rect) + case circle(center: SIMD2, radius: Float) + case arc( + center: SIMD2, + radius: Float, + startAngle: Float, + endAngle: Float, + clockwise: Bool + ) + case transform(AffineTransform) + // case subpath([Action], FillRule) + } + + /// A list of every action that has been performed on this path. + /// + /// This property is meant for backends implementing paths. If the backend has a similar + /// path type built-in (such as `UIBezierPath` or `GskPathBuilder`), constructing the + /// path should consist of looping over this array and calling the method that corresponds + /// to each action. + public private(set) var actions: [Action] = [] + public private(set) var fillRule: FillRule = .evenOdd + public private(set) var strokeStyle: StrokeStyle = .none + + public init() {} + + public consuming func move(to point: SIMD2) -> Path { + actions.append(.moveTo(point)) + return self + } + + public consuming func addLine(to point: SIMD2) -> Path { + actions.append(.lineTo(point)) + return self + } + + public consuming func addQuadCurve( + control: SIMD2, + to endPoint: SIMD2 + ) -> Path { + actions.append(.quadCurve(control: control, end: endPoint)) + return self + } + + public consuming func addCubicCurve( + control1: SIMD2, + control2: SIMD2, + to endPoint: SIMD2 + ) -> Path { + actions.append(.cubicCurve(control1: control1, control2: control2, end: endPoint)) + return self + } + + public consuming func addRectangle(_ rect: Rect) -> Path { + actions.append(.rectangle(rect)) + return self + } + + public consuming func addCircle(center: SIMD2, radius: Float) -> Path { + actions.append(.circle(center: center, radius: radius)) + return self + } + + public consuming func addArc( + center: SIMD2, + radius: Float, + startAngle: Float, + endAngle: Float, + clockwise: Bool + ) -> Path { + actions.append( + .arc( + center: center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: clockwise + ) + ) + return self + } + + public consuming func transform(_ transform: AffineTransform) -> Path { + actions.append(.transform(transform)) + return self + } + + public consuming func stroke(style: StrokeStyle) -> Path { + strokeStyle = style + return self + } + + public consuming func fillRule(_ rule: FillRule) -> Path { + fillRule = rule + return self + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift new file mode 100644 index 00000000..39f72ff4 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift @@ -0,0 +1,15 @@ +public struct Circle: Shape { + public init() {} + + public func path(in bounds: Path.Rect) -> Path { + Path().addCircle( + center: SIMD2(x: bounds.x + bounds.width / 2.0, y: bounds.y + bounds.height / 2.0), + radius: min(bounds.width, bounds.height) / 2.0 + ) + } + + public func size(fitting proposal: SIMD2) -> SIMD2 { + let minDim = min(proposal.x, proposal.y) + return SIMD2(x: minDim, y: minDim) + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift b/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift new file mode 100644 index 00000000..65e813a7 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift @@ -0,0 +1,7 @@ +public struct Rectangle: Shape { + public init() {} + + public func path(in bounds: Path.Rect) -> Path { + Path().addRectangle(bounds) + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift new file mode 100644 index 00000000..3ab45421 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -0,0 +1,73 @@ +public protocol Shape: View +where Content == EmptyView { + /// Draw the path for this shape. + func path(in bounds: Path.Rect) -> Path + /// Determine the ideal size of this shape given the proposed bounds. + /// + /// The default implementation returns the proposal unmodified. + func size(fitting proposal: SIMD2) -> SIMD2 +} + +extension Shape { + public var body: EmptyView { return EmptyView() } + + public func size(fitting proposal: SIMD2) -> SIMD2 { + return proposal + } + + public func children( + backend _: Backend, + snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?, + environment _: EnvironmentValues + ) -> any ViewGraphNodeChildren { + ShapeStorage() + } + + public func asWidget( + _ children: any ViewGraphNodeChildren, backend: Backend + ) -> Backend.Widget { + let container = backend.createContainer() + let storage = children as! ShapeStorage + storage.backendPath = backend.createPath() + storage.oldPath = nil + return container + } + + public func update( + _ widget: Backend.Widget, children: any ViewGraphNodeChildren, proposedSize: SIMD2, + environment: EnvironmentValues, backend: Backend, dryRun: Bool + ) -> ViewUpdateResult { + let storage = children as! ShapeStorage + let size = size(fitting: proposedSize) + + let path = path(in: Path.Rect(x: 0.0, y: 0.0, width: Float(size.x), height: Float(size.y))) + let pointsChanged = storage.oldPath?.actions != path.actions + storage.oldPath = path + + let backendPath = storage.backendPath as! Backend.Path + backend.updatePath(backendPath, path, pointsChanged: pointsChanged) + + if !dryRun { + backend.setSize(of: widget, to: size) + backend.renderPath(backendPath, container: widget, environment: environment) + } + + return ViewUpdateResult.leafView( + size: ViewSize( + size: size, + idealSize: SIMD2(x: 10, y: 10), + minimumWidth: 0, + minimumHeight: 0, + maximumWidth: nil, + maximumHeight: nil + ) + ) + } +} + +final class ShapeStorage: ViewGraphNodeChildren { + let widgets: [AnyWidget] = [] + let erasedNodes: [ErasedViewGraphNode] = [] + var backendPath: Any! + var oldPath: Path? +} diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift new file mode 100644 index 00000000..32967b9e --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -0,0 +1,106 @@ +import SwiftCrossUI +import UIKit + +extension UIKitBackend { + public typealias Path = UIBezierPath + + public func createPath() -> UIBezierPath { + UIBezierPath() + } + + public func updatePath(_ path: UIBezierPath, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + path.usesEvenOddFillRule = (source.fillRule == .evenOdd) + path.lineWidth = CGFloat(source.strokeStyle.width) + + path.lineCapStyle = + switch source.strokeStyle.cap { + case .butt: + .butt + case .round: + .round + case .square: + .square + } + + switch source.strokeStyle.join { + case .miter(let limit): + path.lineJoinStyle = .miter + path.miterLimit = CGFloat(limit) + case .round: + path.lineJoinStyle = .round + case .bevel: + path.lineJoinStyle = .bevel + } + + if pointsChanged { + path.removeAllPoints() + + for action in source.actions { + switch action { + case .moveTo(let point): + path.move(to: CGPoint(x: Double(point.x), y: Double(point.y))) + case .lineTo(let point): + path.addLine(to: CGPoint(x: Double(point.x), y: Double(point.y))) + case .quadCurve(let control, let end): + path.addQuadCurve( + to: CGPoint(x: Double(end.x), y: Double(end.y)), + controlPoint: CGPoint(x: Double(control.x), y: Double(control.y))) + case .cubicCurve(let control1, let control2, let end): + path.addCurve( + to: CGPoint(x: Double(end.x), y: Double(end.y)), + controlPoint1: CGPoint(x: Double(control1.x), y: Double(control1.y)), + controlPoint2: CGPoint(x: Double(control2.x), y: Double(control2.y))) + case .rectangle(let rect): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addRect( + CGRect( + x: CGFloat(rect.x), + y: CGFloat(rect.y), + width: CGFloat(rect.width), + height: CGFloat(rect.height) + ) + ) + path.cgPath = cgPath + case .circle(let center, let radius): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addEllipse( + in: CGRect( + x: CGFloat(center.x - radius), + y: CGFloat(center.y - radius), + width: CGFloat(radius * 2.0), + height: CGFloat(radius * 2.0) + ) + ) + path.cgPath = cgPath + case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): + path.addArc( + withCenter: CGPoint(x: Double(center.x), y: Double(center.y)), + radius: CGFloat(radius), + startAngle: CGFloat(startAngle), + endAngle: CGFloat(endAngle), + clockwise: clockwise + ) + case .transform(let transform): + let cgAT = CGAffineTransform( + a: Double(transform.linearTransform.x), + b: Double(transform.linearTransform.y), + c: Double(transform.linearTransform.z), + d: Double(transform.linearTransform.w), + tx: Double(transform.translation.x), + ty: Double(transform.translation.y) + ) + path.apply(cgAT) + } + } + } + } + + public func renderPath(_ path: UIBezierPath, container: Widget, environment: EnvironmentValues) + { + let maskLayer = container.view.layer.mask as? CAShapeLayer ?? CAShapeLayer() + maskLayer.path = path.cgPath + maskLayer.fillRule = path.usesEvenOddFillRule ? .evenOdd : .nonZero + container.view.layer.mask = maskLayer + container.view.backgroundColor = .red // TODO + } +} From 941c5ffc9bac613f3315d4363b69115a1b06cbe5 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Wed, 2 Apr 2025 21:05:52 -0400 Subject: [PATCH 02/16] My math is wrong... --- Sources/SwiftCrossUI/Path.swift | 165 ++++++++++++------ Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 2 +- Sources/UIKitBackend/UIKitBackend+Path.swift | 52 +++--- 3 files changed, 140 insertions(+), 79 deletions(-) diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index f67e372f..665a5dd9 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -1,4 +1,4 @@ -import Foundation // for sinf and cosf +import Foundation // for sin and cos public enum StrokeCap { /// The stroke ends square exactly at the last point. @@ -12,7 +12,7 @@ public enum StrokeCap { public enum StrokeJoin { /// Corners are sharp, unless they are longer than `limit` times half the stroke width, /// in which case they are beveled. - case miter(limit: Float) + case miter(limit: Double) /// Corners are rounded. case round /// Corners are beveled. @@ -20,7 +20,7 @@ public enum StrokeJoin { } public struct StrokeStyle { - public var width: Float = 1.0 + public var width: Double = 1.0 public var cap: StrokeCap = .butt public var join: StrokeJoin = .miter(limit: 10.0) @@ -39,9 +39,9 @@ public enum FillRule { /// A type representing an affine transformation on a 2-D point. /// -/// Performing an affine transform consists of translating by ``translation`` followed by -/// multiplying by ``linearTransform``. -public struct AffineTransform: Equatable { +/// Performing an affine transform consists of multiplying the matrix ``linearTransform`` +/// by the point as a column vector, then adding ``translation``. +public struct AffineTransform: Equatable, CustomDebugStringConvertible { /// The linear transformation. This is a 2x2 matrix stored in row-major order. /// /// The four properties (`x`, `y`, `z`, `w`) correspond to the 2x2 matrix as follows: @@ -49,35 +49,33 @@ public struct AffineTransform: Equatable { /// [ x y ] /// [ z w ] /// ``` - public var linearTransform: SIMD4 + public var linearTransform: SIMD4 /// The translation applied after the linear transformation. - public var translation: SIMD2 + public var translation: SIMD2 - public init(linearTransform: SIMD4, translation: SIMD2) { + public init(linearTransform: SIMD4, translation: SIMD2) { self.linearTransform = linearTransform self.translation = translation } - public static func translate(x: Float, y: Float) -> AffineTransform { + public static func translation(x: Double, y: Double) -> AffineTransform { AffineTransform( linearTransform: SIMD4(x: 1.0, y: 0.0, z: 0.0, w: 1.0), translation: SIMD2(x: x, y: y) ) } - public static func scale(by factor: Float) -> AffineTransform { + public static func scaling(by factor: Double) -> AffineTransform { AffineTransform( linearTransform: SIMD4(x: factor, y: 0.0, z: 0.0, w: factor), translation: .zero ) } - public static func rotate( - radians: Float, - center: SIMD2 = .zero - ) -> AffineTransform { - let sine = sinf(radians) - let cosine = cosf(radians) + public static func rotation(radians: Double, center: SIMD2) -> AffineTransform { + // Making the sine negative so that positive rotations are clockwise + let sine = sin(-radians) + let cosine = cos(radians) return AffineTransform( linearTransform: SIMD4(x: cosine, y: -sine, z: sine, w: cosine), translation: SIMD2( @@ -87,35 +85,100 @@ public struct AffineTransform: Equatable { ) } - public static func rotate( - degrees: Float, - center: SIMD2 = .zero - ) -> AffineTransform { - rotate(radians: degrees * (.pi / 180.0), center: center) + public static func rotation(degrees: Double, center: SIMD2) -> AffineTransform { + rotation(radians: degrees * (.pi / 180.0), center: center) } public static let identity = AffineTransform( linearTransform: SIMD4(x: 1.0, y: 0.0, z: 0.0, w: 1.0), translation: .zero ) + + public func inverted() -> AffineTransform? { + let determinant = linearTransform.x * linearTransform.w - linearTransform.y * linearTransform.z + if determinant == 0.0 { + return nil + } + + return AffineTransform( + linearTransform: SIMD4( + x: linearTransform.w, + y: -linearTransform.y, + z: -linearTransform.z, + w: linearTransform.x + ) / determinant, + translation: SIMD2( + x: (linearTransform.y * translation.y - linearTransform.w * translation.x), + y: (linearTransform.z * translation.x - linearTransform.x * translation.y) + ) / determinant + ) + } + + public func followedBy(_ other: AffineTransform) -> AffineTransform { + // Composing two transformations is equivalent to forming the 3x3 matrix shown by + // `debugDescription`, then multiplying `other * self` (the left matrix is applied + // after the right matrix). + return AffineTransform( + linearTransform: SIMD4( + x: other.linearTransform.x * linearTransform.x + other.linearTransform.y + * linearTransform.z, + y: other.linearTransform.x * linearTransform.y + other.linearTransform.y + * linearTransform.w, + z: other.linearTransform.z * linearTransform.x + other.linearTransform.w + * linearTransform.z, + w: other.linearTransform.z * linearTransform.y + other.linearTransform.w + * linearTransform.w + ), + translation: SIMD2( + x: other.linearTransform.x * translation.x + other.linearTransform.y * translation.y + + other.translation.x, + y: other.linearTransform.z * translation.x + other.linearTransform.w * translation.y + + other.translation.y + ) + ) + } + + public var debugDescription: String { + let numberFormat = "%.5g" + let a = String(format: numberFormat, linearTransform.x) + let b = String(format: numberFormat, linearTransform.y) + let c = String(format: numberFormat, linearTransform.z) + let d = String(format: numberFormat, linearTransform.w) + let tx = String(format: numberFormat, translation.x) + let ty = String(format: numberFormat, translation.y) + let zero = String(format: numberFormat, 0.0) + let one = String(format: numberFormat, 1.0) + + let maxLength = [a, b, c, d, tx, ty, zero, one].map(\.count).max()! + + func pad(_ s: String) -> String { + String(repeating: " ", count: maxLength - s.count) + s + } + + return """ + [ \(pad(a)) \(pad(b)) \(pad(tx)) ] + [ \(pad(c)) \(pad(d)) \(pad(ty)) ] + [ \(pad(zero)) \(pad(zero)) \(pad(one)) ] + """ + } } public struct Path { public struct Rect: Equatable { - public var origin: SIMD2 - public var size: SIMD2 + public var origin: SIMD2 + public var size: SIMD2 - public init(origin: SIMD2, size: SIMD2) { + public init(origin: SIMD2, size: SIMD2) { self.origin = origin self.size = size } - public var x: Float { origin.x } - public var y: Float { origin.y } - public var width: Float { size.x } - public var height: Float { size.y } + public var x: Double { origin.x } + public var y: Double { origin.y } + public var width: Double { size.x } + public var height: Double { size.y } - public init(x: Float, y: Float, width: Float, height: Float) { + public init(x: Double, y: Double, width: Double, height: Double) { origin = SIMD2(x: x, y: y) size = SIMD2(x: width, y: height) } @@ -123,17 +186,17 @@ public struct Path { /// The types of actions that can be performed on a path. public enum Action: Equatable { - case moveTo(SIMD2) - case lineTo(SIMD2) - case quadCurve(control: SIMD2, end: SIMD2) - case cubicCurve(control1: SIMD2, control2: SIMD2, end: SIMD2) + case moveTo(SIMD2) + case lineTo(SIMD2) + case quadCurve(control: SIMD2, end: SIMD2) + case cubicCurve(control1: SIMD2, control2: SIMD2, end: SIMD2) case rectangle(Rect) - case circle(center: SIMD2, radius: Float) + case circle(center: SIMD2, radius: Double) case arc( - center: SIMD2, - radius: Float, - startAngle: Float, - endAngle: Float, + center: SIMD2, + radius: Double, + startAngle: Double, + endAngle: Double, clockwise: Bool ) case transform(AffineTransform) @@ -152,28 +215,28 @@ public struct Path { public init() {} - public consuming func move(to point: SIMD2) -> Path { + public consuming func move(to point: SIMD2) -> Path { actions.append(.moveTo(point)) return self } - public consuming func addLine(to point: SIMD2) -> Path { + public consuming func addLine(to point: SIMD2) -> Path { actions.append(.lineTo(point)) return self } public consuming func addQuadCurve( - control: SIMD2, - to endPoint: SIMD2 + control: SIMD2, + to endPoint: SIMD2 ) -> Path { actions.append(.quadCurve(control: control, end: endPoint)) return self } public consuming func addCubicCurve( - control1: SIMD2, - control2: SIMD2, - to endPoint: SIMD2 + control1: SIMD2, + control2: SIMD2, + to endPoint: SIMD2 ) -> Path { actions.append(.cubicCurve(control1: control1, control2: control2, end: endPoint)) return self @@ -184,16 +247,16 @@ public struct Path { return self } - public consuming func addCircle(center: SIMD2, radius: Float) -> Path { + public consuming func addCircle(center: SIMD2, radius: Double) -> Path { actions.append(.circle(center: center, radius: radius)) return self } public consuming func addArc( - center: SIMD2, - radius: Float, - startAngle: Float, - endAngle: Float, + center: SIMD2, + radius: Double, + startAngle: Double, + endAngle: Double, clockwise: Bool ) -> Path { actions.append( diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index 3ab45421..f6a2de9d 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -40,7 +40,7 @@ extension Shape { let storage = children as! ShapeStorage let size = size(fitting: proposedSize) - let path = path(in: Path.Rect(x: 0.0, y: 0.0, width: Float(size.x), height: Float(size.y))) + let path = path(in: Path.Rect(x: 0.0, y: 0.0, width: Double(size.x), height: Double(size.y))) let pointsChanged = storage.oldPath?.actions != path.actions storage.oldPath = path diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index 32967b9e..8081093b 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -38,58 +38,56 @@ extension UIKitBackend { for action in source.actions { switch action { case .moveTo(let point): - path.move(to: CGPoint(x: Double(point.x), y: Double(point.y))) + path.move(to: CGPoint(x: point.x, y: point.y)) case .lineTo(let point): - path.addLine(to: CGPoint(x: Double(point.x), y: Double(point.y))) + path.addLine(to: CGPoint(x: point.x, y: point.y)) case .quadCurve(let control, let end): path.addQuadCurve( - to: CGPoint(x: Double(end.x), y: Double(end.y)), - controlPoint: CGPoint(x: Double(control.x), y: Double(control.y))) + to: CGPoint(x: end.x, y: end.y), + controlPoint: CGPoint(x: control.x, y: control.y) + ) case .cubicCurve(let control1, let control2, let end): path.addCurve( - to: CGPoint(x: Double(end.x), y: Double(end.y)), - controlPoint1: CGPoint(x: Double(control1.x), y: Double(control1.y)), - controlPoint2: CGPoint(x: Double(control2.x), y: Double(control2.y))) + to: CGPoint(x: end.x, y: end.y), + controlPoint1: CGPoint(x: control1.x, y: control1.y), + controlPoint2: CGPoint(x: control2.x, y: control2.y) + ) case .rectangle(let rect): let cgPath: CGMutablePath = path.cgPath.mutableCopy()! cgPath.addRect( - CGRect( - x: CGFloat(rect.x), - y: CGFloat(rect.y), - width: CGFloat(rect.width), - height: CGFloat(rect.height) - ) + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) ) path.cgPath = cgPath case .circle(let center, let radius): let cgPath: CGMutablePath = path.cgPath.mutableCopy()! cgPath.addEllipse( in: CGRect( - x: CGFloat(center.x - radius), - y: CGFloat(center.y - radius), - width: CGFloat(radius * 2.0), - height: CGFloat(radius * 2.0) + x: center.x - radius, + y: center.y - radius, + width: radius * 2.0, + height: radius * 2.0 ) ) path.cgPath = cgPath case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): path.addArc( - withCenter: CGPoint(x: Double(center.x), y: Double(center.y)), + withCenter: CGPoint(x: center.x, y: center.y), radius: CGFloat(radius), startAngle: CGFloat(startAngle), endAngle: CGFloat(endAngle), clockwise: clockwise ) case .transform(let transform): - let cgAT = CGAffineTransform( - a: Double(transform.linearTransform.x), - b: Double(transform.linearTransform.y), - c: Double(transform.linearTransform.z), - d: Double(transform.linearTransform.w), - tx: Double(transform.translation.x), - ty: Double(transform.translation.y) + path.apply( + CGAffineTransform( + a: transform.linearTransform.x, + b: transform.linearTransform.y, + c: transform.linearTransform.z, + d: transform.linearTransform.w, + tx: transform.translation.x, + ty: transform.translation.y + ) ) - path.apply(cgAT) } } } @@ -101,6 +99,6 @@ extension UIKitBackend { maskLayer.path = path.cgPath maskLayer.fillRule = path.usesEvenOddFillRule ? .evenOdd : .nonZero container.view.layer.mask = maskLayer - container.view.backgroundColor = .red // TODO + container.view.backgroundColor = environment.suggestedForegroundColor.uiColor } } From e5cd76c44336f10e6d2f27147857dc6d43903de3 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Thu, 3 Apr 2025 17:59:21 -0400 Subject: [PATCH 03/16] that took way too long --- Sources/SwiftCrossUI/Path.swift | 13 +++++--- Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 3 +- Sources/UIKitBackend/UIKitBackend+Path.swift | 33 +++++++++++++------ 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index 665a5dd9..a17a9789 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -49,6 +49,9 @@ public struct AffineTransform: Equatable, CustomDebugStringConvertible { /// [ x y ] /// [ z w ] /// ``` + /// - Remark: The matrices in some graphics frameworks, such as WinUI's `Matrix` and + /// CoreGraphics' `CGAffineTransform`, take the transpose of this matrix. The reason for + /// this difference is left- vs right-multiplication; the values are identical. public var linearTransform: SIMD4 /// The translation applied after the linear transformation. public var translation: SIMD2 @@ -73,14 +76,13 @@ public struct AffineTransform: Equatable, CustomDebugStringConvertible { } public static func rotation(radians: Double, center: SIMD2) -> AffineTransform { - // Making the sine negative so that positive rotations are clockwise - let sine = sin(-radians) + let sine = sin(radians) let cosine = cos(radians) return AffineTransform( linearTransform: SIMD4(x: cosine, y: -sine, z: sine, w: cosine), translation: SIMD2( - x: -center.x * cosine - center.y * sine + center.x, - y: center.x * sine - center.y * cosine + center.y + x: -center.x * cosine + center.y * sine + center.x, + y: -center.x * sine - center.y * cosine + center.y ) ) } @@ -95,7 +97,8 @@ public struct AffineTransform: Equatable, CustomDebugStringConvertible { ) public func inverted() -> AffineTransform? { - let determinant = linearTransform.x * linearTransform.w - linearTransform.y * linearTransform.z + let determinant = + linearTransform.x * linearTransform.w - linearTransform.y * linearTransform.z if determinant == 0.0 { return nil } diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index f6a2de9d..83f67805 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -40,7 +40,8 @@ extension Shape { let storage = children as! ShapeStorage let size = size(fitting: proposedSize) - let path = path(in: Path.Rect(x: 0.0, y: 0.0, width: Double(size.x), height: Double(size.y))) + let path = path( + in: Path.Rect(x: 0.0, y: 0.0, width: Double(size.x), height: Double(size.y))) let pointsChanged = storage.oldPath?.actions != path.actions storage.oldPath = path diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index 8081093b..54adbc9f 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -78,16 +78,7 @@ extension UIKitBackend { clockwise: clockwise ) case .transform(let transform): - path.apply( - CGAffineTransform( - a: transform.linearTransform.x, - b: transform.linearTransform.y, - c: transform.linearTransform.z, - d: transform.linearTransform.w, - tx: transform.translation.x, - ty: transform.translation.y - ) - ) + path.apply(CGAffineTransform(transform)) } } } @@ -102,3 +93,25 @@ extension UIKitBackend { container.view.backgroundColor = environment.suggestedForegroundColor.uiColor } } + +extension CGAffineTransform { + public init(_ transform: AffineTransform) { + self.init( + a: transform.linearTransform.x, + b: transform.linearTransform.z, + c: transform.linearTransform.y, + d: transform.linearTransform.w, + tx: transform.translation.x, + ty: transform.translation.y + ) + } +} + +extension AffineTransform { + public init(cg transform: CGAffineTransform) { + self.init( + linearTransform: SIMD4(x: transform.a, y: transform.c, z: transform.b, w: transform.d), + translation: SIMD2(x: transform.tx, y: transform.ty) + ) + } +} From e2164a762ec5b06e8085415ef4e49a12625c3ad8 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Thu, 3 Apr 2025 20:35:53 -0400 Subject: [PATCH 04/16] A bunch more shape stuff --- Sources/SwiftCrossUI/Backend/AppBackend.swift | 20 ++- Sources/SwiftCrossUI/Path.swift | 27 ++- .../SwiftCrossUI/Views/Shapes/Capsule.swift | 51 ++++++ .../SwiftCrossUI/Views/Shapes/Circle.swift | 22 ++- .../SwiftCrossUI/Views/Shapes/Ellipse.swift | 19 ++ Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 48 +++-- .../Views/Shapes/StyledShape.swift | 80 +++++++++ Sources/UIKitBackend/UIColor+Color.swift | 5 + Sources/UIKitBackend/UIKitBackend+Path.swift | 164 ++++++++++++------ 9 files changed, 345 insertions(+), 91 deletions(-) create mode 100644 Sources/SwiftCrossUI/Views/Shapes/Capsule.swift create mode 100644 Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift create mode 100644 Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index ad39187e..e03b117e 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -544,8 +544,16 @@ public protocol AppBackend { /// - path: The path to be rendered. /// - container: The container widget that the path will render in. It has no other /// children. - /// - environment: The environment values, including color. - func renderPath(_ path: Path, container: Widget, environment: EnvironmentValues) + /// - strokeColor: The color to draw the path's stroke. + /// - fillColor: The color to shade the path's fill. + /// - overrideStrokeStyle: If present, a value to override the path's stroke style. + func renderPath( + _ path: Path, + container: Widget, + strokeColor: Color, + fillColor: Color, + overrideStrokeStyle: StrokeStyle? + ) } extension AppBackend { @@ -870,7 +878,13 @@ extension AppBackend { func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { todo() } - func renderPath(_ path: Path, container: Widget, environment: EnvironmentValues) { + func renderPath( + _ path: Path, + container: Widget, + strokeColor: Color, + fillColor: Color, + overrideStrokeStyle: StrokeStyle? + ) { todo() } } diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index a17a9789..b6d42630 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -20,11 +20,15 @@ public enum StrokeJoin { } public struct StrokeStyle { - public var width: Double = 1.0 - public var cap: StrokeCap = .butt - public var join: StrokeJoin = .miter(limit: 10.0) - - public static let none = StrokeStyle(width: 0.0) + public var width: Double + public var cap: StrokeCap + public var join: StrokeJoin + + public init(width: Double, cap: StrokeCap = .butt, join: StrokeJoin = .miter(limit: 10.0)) { + self.width = width + self.cap = cap + self.join = join + } } /// An enum describing how a path is shaded. @@ -181,6 +185,8 @@ public struct Path { public var width: Double { size.x } public var height: Double { size.y } + public var center: SIMD2 { size * 0.5 + origin } + public init(x: Double, y: Double, width: Double, height: Double) { origin = SIMD2(x: x, y: y) size = SIMD2(x: width, y: height) @@ -203,7 +209,7 @@ public struct Path { clockwise: Bool ) case transform(AffineTransform) - // case subpath([Action], FillRule) + case subpath([Action], FillRule) } /// A list of every action that has been performed on this path. @@ -214,7 +220,7 @@ public struct Path { /// to each action. public private(set) var actions: [Action] = [] public private(set) var fillRule: FillRule = .evenOdd - public private(set) var strokeStyle: StrokeStyle = .none + public private(set) var strokeStyle = StrokeStyle(width: 1.0) public init() {} @@ -274,11 +280,16 @@ public struct Path { return self } - public consuming func transform(_ transform: AffineTransform) -> Path { + public consuming func applyTransform(_ transform: AffineTransform) -> Path { actions.append(.transform(transform)) return self } + public consuming func addSubpath(_ subpath: Path) -> Path { + actions.append(.subpath(subpath.actions, subpath.fillRule)) + return self + } + public consuming func stroke(style: StrokeStyle) -> Path { strokeStyle = style return self diff --git a/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift new file mode 100644 index 00000000..9f7c2635 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift @@ -0,0 +1,51 @@ +public struct Capsule: Shape { + public init() {} + + public func path(in bounds: Path.Rect) -> Path { + if bounds.width > bounds.height { + let radius = bounds.height / 2.0 + + return Path() + .move(to: SIMD2(x: bounds.width + bounds.x - radius, y: bounds.y)) + .addArc( + center: SIMD2(x: bounds.width + bounds.x - radius, y: bounds.center.y), + radius: radius, + startAngle: .pi * 1.5, + endAngle: .pi * 0.5, + clockwise: true + ) + .addLine(to: SIMD2(x: radius + bounds.x, y: bounds.height + bounds.y)) + .addArc( + center: SIMD2(x: radius + bounds.x, y: bounds.center.y), + radius: radius, + startAngle: .pi * 0.5, + endAngle: .pi * 1.5, + clockwise: true + ) + .addLine(to: SIMD2(x: bounds.width + bounds.x - radius, y: bounds.y)) + } else if bounds.width < bounds.height { + let radius = bounds.width / 2.0 + + return Path() + .move(to: SIMD2(x: bounds.x, y: bounds.height + bounds.y - radius)) + .addArc( + center: SIMD2(x: bounds.center.x, y: bounds.height + bounds.y - radius), + radius: radius, + startAngle: .pi, + endAngle: 0.0, + clockwise: false + ) + .addLine(to: SIMD2(x: bounds.width + bounds.x, y: radius + bounds.y)) + .addArc( + center: SIMD2(x: bounds.center.x, y: radius + bounds.y), + radius: radius, + startAngle: 0.0, + endAngle: .pi, + clockwise: false + ) + .addLine(to: SIMD2(x: bounds.x, y: bounds.height + bounds.y - radius)) + } else { + return Circle().path(in: bounds) + } + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift index 39f72ff4..90b224b2 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift @@ -2,14 +2,22 @@ public struct Circle: Shape { public init() {} public func path(in bounds: Path.Rect) -> Path { - Path().addCircle( - center: SIMD2(x: bounds.x + bounds.width / 2.0, y: bounds.y + bounds.height / 2.0), - radius: min(bounds.width, bounds.height) / 2.0 - ) + Path() + .addCircle(center: bounds.center, radius: min(bounds.width, bounds.height) / 2.0) } - public func size(fitting proposal: SIMD2) -> SIMD2 { - let minDim = min(proposal.x, proposal.y) - return SIMD2(x: minDim, y: minDim) + public func size(fitting proposal: SIMD2) -> ViewSize { + let diameter = min(proposal.x, proposal.y) + + return ViewSize( + size: SIMD2(x: diameter, y: diameter), + idealSize: SIMD2(x: 10, y: 10), + idealWidthForProposedHeight: proposal.y, + idealHeightForProposedWidth: proposal.x, + minimumWidth: 1, + minimumHeight: 1, + maximumWidth: nil, + maximumHeight: nil + ) } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift b/Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift new file mode 100644 index 00000000..6aff86d9 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Ellipse.swift @@ -0,0 +1,19 @@ +public struct Ellipse: Shape { + public init() {} + + public func path(in bounds: Path.Rect) -> Path { + Path() + .addCircle(center: .zero, radius: bounds.width / 2.0) + .applyTransform( + AffineTransform( + linearTransform: SIMD4( + x: 1.0, + y: 0.0, + z: 0.0, + w: bounds.height / bounds.width + ), + translation: bounds.center + ) + ) + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index 83f67805..df2d54df 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -4,15 +4,30 @@ where Content == EmptyView { func path(in bounds: Path.Rect) -> Path /// Determine the ideal size of this shape given the proposed bounds. /// - /// The default implementation returns the proposal unmodified. - func size(fitting proposal: SIMD2) -> SIMD2 + /// The default implementation accepts the proposal and imposes no practical limit on + /// the shape's size. + /// - Returns: Information about the shape's size. The ``ViewSize/size`` property is what + /// frame the shape will actually be rendered with if the current layout pass is not + /// a dry run, while the other properties are used to inform the layout engine how big + /// or small the shape can be. The ``ViewSize/idealSize`` property should not vary with + /// the `proposal`, and should only depend on the view's contents. Pass `nil` for the + /// maximum width/height if the shape has no maximum size (and therefore may occupy + /// the entire screen). + func size(fitting proposal: SIMD2) -> ViewSize } extension Shape { public var body: EmptyView { return EmptyView() } - public func size(fitting proposal: SIMD2) -> SIMD2 { - return proposal + public func size(fitting proposal: SIMD2) -> ViewSize { + return ViewSize( + size: proposal, + idealSize: SIMD2(x: 10, y: 10), + minimumWidth: 1, + minimumHeight: 1, + maximumWidth: nil, + maximumHeight: nil + ) } public func children( @@ -41,7 +56,9 @@ extension Shape { let size = size(fitting: proposedSize) let path = path( - in: Path.Rect(x: 0.0, y: 0.0, width: Double(size.x), height: Double(size.y))) + in: Path.Rect(x: 0.0, y: 0.0, width: Double(size.size.x), height: Double(size.size.y)) + ) + let pointsChanged = storage.oldPath?.actions != path.actions storage.oldPath = path @@ -49,20 +66,17 @@ extension Shape { backend.updatePath(backendPath, path, pointsChanged: pointsChanged) if !dryRun { - backend.setSize(of: widget, to: size) - backend.renderPath(backendPath, container: widget, environment: environment) + backend.setSize(of: widget, to: size.size) + backend.renderPath( + backendPath, + container: widget, + strokeColor: .clear, + fillColor: environment.suggestedForegroundColor, + overrideStrokeStyle: nil + ) } - return ViewUpdateResult.leafView( - size: ViewSize( - size: size, - idealSize: SIMD2(x: 10, y: 10), - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ) - ) + return ViewUpdateResult.leafView(size: size) } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift new file mode 100644 index 00000000..2fd6f172 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift @@ -0,0 +1,80 @@ +public protocol StyledShape: Shape { + var strokeColor: Color? { get } + var fillColor: Color? { get } + var strokeStyle: StrokeStyle? { get } +} + +struct StyledShapeImpl: StyledShape { + var base: Base + var strokeColor: Color? + var fillColor: Color? + var strokeStyle: StrokeStyle? + + init( + base: Base, strokeColor: Color? = nil, fillColor: Color? = nil, + strokeStyle: StrokeStyle? = nil + ) { + self.base = base + + if let styledBase = base as? any StyledShape { + self.strokeColor = strokeColor ?? styledBase.strokeColor + self.fillColor = fillColor ?? styledBase.fillColor + self.strokeStyle = strokeStyle ?? styledBase.strokeStyle + } else { + self.strokeColor = strokeColor + self.fillColor = fillColor + self.strokeStyle = strokeStyle + } + } + + func path(in bounds: Path.Rect) -> Path { + return base.path(in: bounds) + } + + func size(fitting proposal: SIMD2) -> ViewSize { + return base.size(fitting: proposal) + } +} + +extension Shape { + public func fill(_ color: Color) -> some StyledShape { + StyledShapeImpl(base: self, fillColor: color) + } + + public func stroke(_ color: Color, style: StrokeStyle? = nil) -> some StyledShape { + StyledShapeImpl(base: self, strokeColor: color, strokeStyle: style) + } +} + +extension StyledShape { + public func update( + _ widget: Backend.Widget, children: any ViewGraphNodeChildren, proposedSize: SIMD2, + environment: EnvironmentValues, backend: Backend, dryRun: Bool + ) -> ViewUpdateResult { + let storage = children as! ShapeStorage + let size = size(fitting: proposedSize) + + let path = path( + in: Path.Rect(x: 0.0, y: 0.0, width: Double(size.size.x), height: Double(size.size.y)) + ) + + let pointsChanged = storage.oldPath?.actions != path.actions + storage.oldPath = path + + let backendPath = storage.backendPath as! Backend.Path + backend.updatePath(backendPath, path, pointsChanged: pointsChanged) + + if !dryRun { + backend.setSize(of: widget, to: size.size) + backend.renderPath( + backendPath, + container: widget, + strokeColor: strokeColor ?? .clear, + fillColor: fillColor ?? .clear, + overrideStrokeStyle: strokeStyle + ) + } + + return ViewUpdateResult.leafView(size: size) + } +} diff --git a/Sources/UIKitBackend/UIColor+Color.swift b/Sources/UIKitBackend/UIColor+Color.swift index f0577e14..921fe87a 100644 --- a/Sources/UIKitBackend/UIColor+Color.swift +++ b/Sources/UIKitBackend/UIColor+Color.swift @@ -27,4 +27,9 @@ extension Color { var uiColor: UIColor { UIColor(color: self) } + + var cgColor: CGColor { + CGColor( + red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: CGFloat(alpha)) + } } diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index 54adbc9f..49a3d8d0 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -8,12 +8,11 @@ extension UIKitBackend { UIBezierPath() } - public func updatePath(_ path: UIBezierPath, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { - path.usesEvenOddFillRule = (source.fillRule == .evenOdd) - path.lineWidth = CGFloat(source.strokeStyle.width) + func applyStrokeStyle(_ strokeStyle: StrokeStyle, to path: UIBezierPath) { + path.lineWidth = CGFloat(strokeStyle.width) path.lineCapStyle = - switch source.strokeStyle.cap { + switch strokeStyle.cap { case .butt: .butt case .round: @@ -22,7 +21,7 @@ extension UIKitBackend { .square } - switch source.strokeStyle.join { + switch strokeStyle.join { case .miter(let limit): path.lineJoinStyle = .miter path.miterLimit = CGFloat(limit) @@ -31,66 +30,119 @@ extension UIKitBackend { case .bevel: path.lineJoinStyle = .bevel } + } + + public func updatePath(_ path: UIBezierPath, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + path.usesEvenOddFillRule = (source.fillRule == .evenOdd) + + applyStrokeStyle(source.strokeStyle, to: path) if pointsChanged { path.removeAllPoints() - for action in source.actions { - switch action { - case .moveTo(let point): - path.move(to: CGPoint(x: point.x, y: point.y)) - case .lineTo(let point): - path.addLine(to: CGPoint(x: point.x, y: point.y)) - case .quadCurve(let control, let end): - path.addQuadCurve( - to: CGPoint(x: end.x, y: end.y), - controlPoint: CGPoint(x: control.x, y: control.y) - ) - case .cubicCurve(let control1, let control2, let end): - path.addCurve( - to: CGPoint(x: end.x, y: end.y), - controlPoint1: CGPoint(x: control1.x, y: control1.y), - controlPoint2: CGPoint(x: control2.x, y: control2.y) - ) - case .rectangle(let rect): - let cgPath: CGMutablePath = path.cgPath.mutableCopy()! - cgPath.addRect( - CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) - ) - path.cgPath = cgPath - case .circle(let center, let radius): - let cgPath: CGMutablePath = path.cgPath.mutableCopy()! - cgPath.addEllipse( - in: CGRect( - x: center.x - radius, - y: center.y - radius, - width: radius * 2.0, - height: radius * 2.0 - ) - ) - path.cgPath = cgPath - case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): - path.addArc( - withCenter: CGPoint(x: center.x, y: center.y), - radius: CGFloat(radius), - startAngle: CGFloat(startAngle), - endAngle: CGFloat(endAngle), - clockwise: clockwise + applyActions(source.actions, to: path) + } + } + + func applyActions(_ actions: [SwiftCrossUI.Path.Action], to path: UIBezierPath) { + for action in actions { + switch action { + case .moveTo(let point): + path.move(to: CGPoint(x: point.x, y: point.y)) + case .lineTo(let point): + path.addLine(to: CGPoint(x: point.x, y: point.y)) + case .quadCurve(let control, let end): + path.addQuadCurve( + to: CGPoint(x: end.x, y: end.y), + controlPoint: CGPoint(x: control.x, y: control.y) + ) + case .cubicCurve(let control1, let control2, let end): + path.addCurve( + to: CGPoint(x: end.x, y: end.y), + controlPoint1: CGPoint(x: control1.x, y: control1.y), + controlPoint2: CGPoint(x: control2.x, y: control2.y) + ) + case .rectangle(let rect): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addRect( + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + ) + path.cgPath = cgPath + case .circle(let center, let radius): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addEllipse( + in: CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2.0, + height: radius * 2.0 ) - case .transform(let transform): - path.apply(CGAffineTransform(transform)) - } + ) + path.cgPath = cgPath + case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): + path.addArc( + withCenter: CGPoint(x: center.x, y: center.y), + radius: CGFloat(radius), + startAngle: CGFloat(startAngle), + endAngle: CGFloat(endAngle), + clockwise: clockwise + ) + case .transform(let transform): + path.apply(CGAffineTransform(transform)) + case .subpath(let actions, let fillRule): + let subpath = UIBezierPath() + subpath.usesEvenOddFillRule = (fillRule == .evenOdd) + applyActions(actions, to: subpath) + path.append(subpath) } } + } - public func renderPath(_ path: UIBezierPath, container: Widget, environment: EnvironmentValues) - { - let maskLayer = container.view.layer.mask as? CAShapeLayer ?? CAShapeLayer() - maskLayer.path = path.cgPath - maskLayer.fillRule = path.usesEvenOddFillRule ? .evenOdd : .nonZero - container.view.layer.mask = maskLayer - container.view.backgroundColor = environment.suggestedForegroundColor.uiColor + public func renderPath( + _ path: Path, + container: Widget, + strokeColor: Color, + fillColor: Color, + overrideStrokeStyle: StrokeStyle? + ) { + if let overrideStrokeStyle { + applyStrokeStyle(overrideStrokeStyle, to: path) + } + + let shapeLayer = container.view.layer.sublayers?[0] as? CAShapeLayer ?? CAShapeLayer() + + shapeLayer.path = path.cgPath + shapeLayer.lineWidth = path.lineWidth + shapeLayer.miterLimit = path.miterLimit + shapeLayer.fillRule = path.usesEvenOddFillRule ? .evenOdd : .nonZero + + shapeLayer.lineJoin = + switch path.lineJoinStyle { + case .miter: + .miter + case .round: + .round + case .bevel: + .bevel + } + + shapeLayer.lineCap = + switch path.lineCapStyle { + case .butt: + .butt + case .round: + .round + case .square: + .square + } + + shapeLayer.strokeColor = strokeColor.cgColor + shapeLayer.fillColor = fillColor.cgColor + + if shapeLayer.superlayer !== container.view.layer { + container.view.layer.addSublayer(shapeLayer) + } } } From f65b366e8291c6bffac8a2c815d9f9521137f1b5 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Tue, 8 Apr 2025 21:01:11 -0400 Subject: [PATCH 05/16] Code formatting, comments, and rounded rectangle --- Sources/SwiftCrossUI/Path.swift | 27 +- .../SwiftCrossUI/Views/Shapes/Capsule.swift | 48 +- .../Views/Shapes/RoundedRectangle.swift | 447 ++++++++++++++++++ Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 9 +- .../Views/Shapes/StyledShape.swift | 12 +- Sources/UIKitBackend/UIKitBackend+Path.swift | 98 ++-- 6 files changed, 535 insertions(+), 106 deletions(-) create mode 100644 Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index b6d42630..fbf5ee78 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -171,6 +171,9 @@ public struct AffineTransform: Equatable, CustomDebugStringConvertible { } public struct Path { + /// A rectangle in 2-D space. + /// + /// This type is inspired by `CGRect`. public struct Rect: Equatable { public var origin: SIMD2 public var size: SIMD2 @@ -186,6 +189,8 @@ public struct Path { public var height: Double { size.y } public var center: SIMD2 { size * 0.5 + origin } + public var maxX: Double { size.x + origin.x } + public var maxY: Double { size.y + origin.y } public init(x: Double, y: Double, width: Double, height: Double) { origin = SIMD2(x: x, y: y) @@ -209,7 +214,6 @@ public struct Path { clockwise: Bool ) case transform(AffineTransform) - case subpath([Action], FillRule) } /// A list of every action that has been performed on this path. @@ -286,10 +290,14 @@ public struct Path { } public consuming func addSubpath(_ subpath: Path) -> Path { - actions.append(.subpath(subpath.actions, subpath.fillRule)) + actions.append(contentsOf: subpath.actions) return self } + /// Set the default stroke style for the path. + /// + /// This is not necessarily respected; it can be overridden by ``Shape/stroke(_:style:)``, + /// and is lost when the path is passed to ``addSubpath(_:)``. public consuming func stroke(style: StrokeStyle) -> Path { strokeStyle = style return self @@ -300,3 +308,18 @@ public struct Path { return self } } + +extension Path { + @inlinable + public consuming func `if`( + _ condition: Bool, + then ifTrue: (consuming Path) -> Path, + else ifFalse: (consuming Path) -> Path = { $0 } + ) -> Path { + if condition { + ifTrue(self) + } else { + ifFalse(self) + } + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift index 9f7c2635..0292313b 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift @@ -1,51 +1,9 @@ +/// A rounded rectangle whose corner radius is equal to half the length of its shortest side. public struct Capsule: Shape { public init() {} public func path(in bounds: Path.Rect) -> Path { - if bounds.width > bounds.height { - let radius = bounds.height / 2.0 - - return Path() - .move(to: SIMD2(x: bounds.width + bounds.x - radius, y: bounds.y)) - .addArc( - center: SIMD2(x: bounds.width + bounds.x - radius, y: bounds.center.y), - radius: radius, - startAngle: .pi * 1.5, - endAngle: .pi * 0.5, - clockwise: true - ) - .addLine(to: SIMD2(x: radius + bounds.x, y: bounds.height + bounds.y)) - .addArc( - center: SIMD2(x: radius + bounds.x, y: bounds.center.y), - radius: radius, - startAngle: .pi * 0.5, - endAngle: .pi * 1.5, - clockwise: true - ) - .addLine(to: SIMD2(x: bounds.width + bounds.x - radius, y: bounds.y)) - } else if bounds.width < bounds.height { - let radius = bounds.width / 2.0 - - return Path() - .move(to: SIMD2(x: bounds.x, y: bounds.height + bounds.y - radius)) - .addArc( - center: SIMD2(x: bounds.center.x, y: bounds.height + bounds.y - radius), - radius: radius, - startAngle: .pi, - endAngle: 0.0, - clockwise: false - ) - .addLine(to: SIMD2(x: bounds.width + bounds.x, y: radius + bounds.y)) - .addArc( - center: SIMD2(x: bounds.center.x, y: radius + bounds.y), - radius: radius, - startAngle: 0.0, - endAngle: .pi, - clockwise: false - ) - .addLine(to: SIMD2(x: bounds.x, y: bounds.height + bounds.y - radius)) - } else { - return Circle().path(in: bounds) - } + let radius = min(bounds.width, bounds.height) / 2.0 + return RoundedRectangle(cornerRadius: radius).path(in: bounds) } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift b/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift new file mode 100644 index 00000000..da89ab5c --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift @@ -0,0 +1,447 @@ +/// A rounded rectangle. +/// +/// This is not necessarily four line segments and four circular arcs. If possible, this shape +/// uses smoother curves to make the transition between the edges and corners less abrupt. +public struct RoundedRectangle: Shape { + public var cornerRadius: Double + + public init(cornerRadius: Double) { + assert( + cornerRadius >= 0.0 && cornerRadius.isFinite, + "Corner radius must be a positive finite value") + self.cornerRadius = cornerRadius + } + + // This shape tries to mimic an order 5 superellipse, extending the sides with line segments. + // Since paths don't support quintic curves, I'm using an approximation consisting of + // two cubic curves and a line segment. This constant is the list of control points for + // the cubic curves. See https://www.desmos.com/calculator/chwx3ddx6u . + // + // Preconditions: + // - points.0 is the same as if a line segment and a circular arc were used + // - points.6.y == 0.0 + private static let points = ( + SIMD2(0.292893218813, 0.292893218813), + SIMD2(0.517, 0.0687864376269), + SIMD2(0.87, 0.0337), + SIMD2(1.13130356636, 0.0139677719414), + SIMD2(1.1973, 0.0089), + SIMD2(1.5038, 0.0002), + SIMD2(1.7, 0.0) + ) + + // This corresponds to r_{min} in the above Desmos link. This is the minimum ratio of + // cornerRadius to half the side length at which the superellipse is ignored. Above this, + // line segments and circular arcs are used. + private static let rMin = 0.441968022436 + + public func path(in bounds: Path.Rect) -> Path { + // just to avoid `RoundedRectangle.` qualifiers + let rMin = RoundedRectangle.rMin + let points = RoundedRectangle.points + + let effectiveRadius = min(cornerRadius, bounds.width / 2.0, bounds.height / 2.0) + let xRatio = effectiveRadius / (bounds.width / 2.0) + let yRatio = effectiveRadius / (bounds.height / 2.0) + + // MARK: Early exits + // These code paths are guaranteed to not use the approximations of the quintic curves. + + // Optimization: just a circle + if bounds.width == bounds.height && bounds.width <= cornerRadius * 2.0 { + return Circle().path(in: bounds) + } + + // Optimization: just a rectangle + if effectiveRadius == 0.0 { + return Rectangle().path(in: bounds) + } + + // Optimization: corner radius is too large to use quintic curves + if xRatio >= rMin && yRatio >= rMin { + return Path() + .move(to: SIMD2(x: bounds.x + effectiveRadius, y: bounds.y)) + .addLine(to: SIMD2(x: bounds.maxX - effectiveRadius, y: bounds.y)) + .addArc( + center: SIMD2(x: bounds.maxX - effectiveRadius, y: bounds.y + effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 1.5, + endAngle: 0.0, + clockwise: true + ) + .addLine(to: SIMD2(x: bounds.maxX, y: bounds.maxY - effectiveRadius)) + .addArc( + center: SIMD2( + x: bounds.maxX - effectiveRadius, y: bounds.maxY - effectiveRadius), + radius: effectiveRadius, + startAngle: 0.0, + endAngle: .pi * 0.5, + clockwise: true + ) + .addLine(to: SIMD2(x: bounds.x + effectiveRadius, y: bounds.maxY)) + .addArc( + center: SIMD2(x: bounds.x + effectiveRadius, y: bounds.maxY - effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 0.5, + endAngle: .pi, + clockwise: true + ) + .addLine(to: SIMD2(x: bounds.x, y: bounds.y + effectiveRadius)) + .addArc( + center: SIMD2(x: bounds.x + effectiveRadius, y: bounds.y + effectiveRadius), + radius: effectiveRadius, + startAngle: .pi, + endAngle: .pi * 1.5, + clockwise: true + ) + } + + return Path() + // MARK: Top edge, right side + .move(to: SIMD2(x: bounds.center.x, y: bounds.y)) + .if(xRatio >= rMin) { + $0 + .addLine(to: SIMD2(x: bounds.maxX - effectiveRadius, y: bounds.y)) + .addArc( + center: SIMD2( + x: bounds.maxX - effectiveRadius, y: bounds.y + effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 1.5, + endAngle: .pi * 1.75, + clockwise: true + ) + } else: { + $0 + .addLine( + to: SIMD2( + x: bounds.maxX - points.6.x * effectiveRadius, + y: bounds.y + points.6.y * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.5.x * effectiveRadius, + y: bounds.y + points.5.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.4.x * effectiveRadius, + y: bounds.y + points.4.y * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.3.x * effectiveRadius, + y: bounds.y + points.3.y * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.2.x * effectiveRadius, + y: bounds.y + points.2.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.1.x * effectiveRadius, + y: bounds.y + points.1.y * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.0.x * effectiveRadius, + y: bounds.y + points.0.y * effectiveRadius + ) + ) + } + // MARK: Right edge + .if(yRatio >= rMin) { + $0 + .addArc( + center: SIMD2( + x: bounds.maxX - effectiveRadius, y: bounds.y + effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 1.75, + endAngle: 0.0, + clockwise: true + ) + .addLine(to: SIMD2(x: bounds.maxX, y: bounds.maxY - effectiveRadius)) + .addArc( + center: SIMD2( + x: bounds.maxX - effectiveRadius, y: bounds.maxY - effectiveRadius), + radius: effectiveRadius, + startAngle: 0.0, + endAngle: .pi * 0.25, + clockwise: true + ) + } else: { + $0 + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.1.y * effectiveRadius, + y: bounds.y + points.1.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.2.y * effectiveRadius, + y: bounds.y + points.2.x * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.3.y * effectiveRadius, + y: bounds.y + points.3.x * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.4.y * effectiveRadius, + y: bounds.y + points.4.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.5.y * effectiveRadius, + y: bounds.y + points.5.x * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.6.y * effectiveRadius, + y: bounds.y + points.6.x * effectiveRadius + ) + ) + .addLine( + to: SIMD2( + x: bounds.maxX - points.6.y * effectiveRadius, + y: bounds.maxY - points.6.x * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.5.y * effectiveRadius, + y: bounds.maxY - points.5.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.4.y * effectiveRadius, + y: bounds.maxY - points.4.x * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.3.y * effectiveRadius, + y: bounds.maxY - points.3.x * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.2.y * effectiveRadius, + y: bounds.maxY - points.2.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.1.y * effectiveRadius, + y: bounds.maxY - points.1.x * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.0.y * effectiveRadius, + y: bounds.maxY - points.0.x * effectiveRadius + ) + ) + } + // MARK: Bottom edge + .if(xRatio >= rMin) { + $0 + .addArc( + center: SIMD2( + x: bounds.maxX - effectiveRadius, y: bounds.maxY - effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 0.25, + endAngle: .pi * 0.5, + clockwise: true + ) + .addLine(to: SIMD2(x: bounds.x + effectiveRadius, y: bounds.maxY)) + .addArc( + center: SIMD2( + x: bounds.x + effectiveRadius, y: bounds.maxY - effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 0.5, + endAngle: .pi * 0.75, + clockwise: true + ) + } else: { + $0 + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.1.x * effectiveRadius, + y: bounds.maxY - points.1.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.2.x * effectiveRadius, + y: bounds.maxY - points.2.y * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.3.x * effectiveRadius, + y: bounds.maxY - points.3.y * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.maxX - points.4.x * effectiveRadius, + y: bounds.maxY - points.4.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.maxX - points.5.x * effectiveRadius, + y: bounds.maxY - points.5.y * effectiveRadius + ), + to: SIMD2( + x: bounds.maxX - points.6.x * effectiveRadius, + y: bounds.maxY - points.6.y * effectiveRadius + ) + ) + .addLine( + to: SIMD2( + x: bounds.x + points.6.x * effectiveRadius, + y: bounds.maxY - points.6.y * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.5.x * effectiveRadius, + y: bounds.maxY - points.5.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.4.x * effectiveRadius, + y: bounds.maxY - points.4.y * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.3.x * effectiveRadius, + y: bounds.maxY - points.3.y * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.2.x * effectiveRadius, + y: bounds.maxY - points.2.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.1.x * effectiveRadius, + y: bounds.maxY - points.1.y * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.0.x * effectiveRadius, + y: bounds.maxY - points.0.y * effectiveRadius + ) + ) + } + // MARK: Left edge + .if(yRatio >= rMin) { + $0 + .addArc( + center: SIMD2( + x: bounds.x + effectiveRadius, y: bounds.maxY - effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 0.75, + endAngle: .pi, + clockwise: true + ) + .addLine(to: SIMD2(x: bounds.x, y: bounds.y + effectiveRadius)) + .addArc( + center: SIMD2(x: bounds.x + effectiveRadius, y: bounds.y + effectiveRadius), + radius: effectiveRadius, + startAngle: .pi, + endAngle: .pi * 1.25, + clockwise: true + ) + } else: { + $0 + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.1.y * effectiveRadius, + y: bounds.maxY - points.1.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.2.y * effectiveRadius, + y: bounds.maxY - points.2.x * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.3.y * effectiveRadius, + y: bounds.maxY - points.3.x * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.4.y * effectiveRadius, + y: bounds.maxY - points.4.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.5.y * effectiveRadius, + y: bounds.maxY - points.5.x * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.6.y * effectiveRadius, + y: bounds.maxY - points.6.x * effectiveRadius + ) + ) + .addLine( + to: SIMD2( + x: bounds.x + points.6.y * effectiveRadius, + y: bounds.y + points.6.x * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.5.y * effectiveRadius, + y: bounds.y + points.5.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.4.y * effectiveRadius, + y: bounds.y + points.4.x * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.3.y * effectiveRadius, + y: bounds.y + points.3.x * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.2.y * effectiveRadius, + y: bounds.y + points.2.x * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.1.y * effectiveRadius, + y: bounds.y + points.1.x * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.0.y * effectiveRadius, + y: bounds.y + points.0.x * effectiveRadius + ) + ) + } + // MARK: Top edge, left side + .if(xRatio >= rMin) { + $0 + .addArc( + center: SIMD2(x: bounds.x + effectiveRadius, y: bounds.y + effectiveRadius), + radius: effectiveRadius, + startAngle: .pi * 1.25, + endAngle: .pi * 1.5, + clockwise: true + ) + } else: { + $0 + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.1.x * effectiveRadius, + y: bounds.y + points.1.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.2.x * effectiveRadius, + y: bounds.y + points.2.y * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.3.x * effectiveRadius, + y: bounds.y + points.3.y * effectiveRadius + ) + ) + .addCubicCurve( + control1: SIMD2( + x: bounds.x + points.4.x * effectiveRadius, + y: bounds.y + points.4.y * effectiveRadius + ), + control2: SIMD2( + x: bounds.x + points.5.x * effectiveRadius, + y: bounds.y + points.5.y * effectiveRadius + ), + to: SIMD2( + x: bounds.x + points.6.x * effectiveRadius, + y: bounds.y + points.6.y * effectiveRadius + ) + ) + } + .addLine(to: SIMD2(x: bounds.center.x, y: bounds.y)) + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index df2d54df..ea90d596 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -1,6 +1,7 @@ public protocol Shape: View where Content == EmptyView { /// Draw the path for this shape. + /// func path(in bounds: Path.Rect) -> Path /// Determine the ideal size of this shape given the proposed bounds. /// @@ -49,8 +50,12 @@ extension Shape { } public func update( - _ widget: Backend.Widget, children: any ViewGraphNodeChildren, proposedSize: SIMD2, - environment: EnvironmentValues, backend: Backend, dryRun: Bool + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool ) -> ViewUpdateResult { let storage = children as! ShapeStorage let size = size(fitting: proposedSize) diff --git a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift index 2fd6f172..fef67bfd 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift @@ -11,7 +11,9 @@ struct StyledShapeImpl: StyledShape { var strokeStyle: StrokeStyle? init( - base: Base, strokeColor: Color? = nil, fillColor: Color? = nil, + base: Base, + strokeColor: Color? = nil, + fillColor: Color? = nil, strokeStyle: StrokeStyle? = nil ) { self.base = base @@ -48,8 +50,12 @@ extension Shape { extension StyledShape { public func update( - _ widget: Backend.Widget, children: any ViewGraphNodeChildren, proposedSize: SIMD2, - environment: EnvironmentValues, backend: Backend, dryRun: Bool + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool ) -> ViewUpdateResult { let storage = children as! ShapeStorage let size = size(fitting: proposedSize) diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index 49a3d8d0..e51988de 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -40,63 +40,53 @@ extension UIKitBackend { if pointsChanged { path.removeAllPoints() - applyActions(source.actions, to: path) - } - } - - func applyActions(_ actions: [SwiftCrossUI.Path.Action], to path: UIBezierPath) { - for action in actions { - switch action { - case .moveTo(let point): - path.move(to: CGPoint(x: point.x, y: point.y)) - case .lineTo(let point): - path.addLine(to: CGPoint(x: point.x, y: point.y)) - case .quadCurve(let control, let end): - path.addQuadCurve( - to: CGPoint(x: end.x, y: end.y), - controlPoint: CGPoint(x: control.x, y: control.y) - ) - case .cubicCurve(let control1, let control2, let end): - path.addCurve( - to: CGPoint(x: end.x, y: end.y), - controlPoint1: CGPoint(x: control1.x, y: control1.y), - controlPoint2: CGPoint(x: control2.x, y: control2.y) - ) - case .rectangle(let rect): - let cgPath: CGMutablePath = path.cgPath.mutableCopy()! - cgPath.addRect( - CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) - ) - path.cgPath = cgPath - case .circle(let center, let radius): - let cgPath: CGMutablePath = path.cgPath.mutableCopy()! - cgPath.addEllipse( - in: CGRect( - x: center.x - radius, - y: center.y - radius, - width: radius * 2.0, - height: radius * 2.0 + for action in source.actions { + switch action { + case .moveTo(let point): + path.move(to: CGPoint(x: point.x, y: point.y)) + case .lineTo(let point): + path.addLine(to: CGPoint(x: point.x, y: point.y)) + case .quadCurve(let control, let end): + path.addQuadCurve( + to: CGPoint(x: end.x, y: end.y), + controlPoint: CGPoint(x: control.x, y: control.y) + ) + case .cubicCurve(let control1, let control2, let end): + path.addCurve( + to: CGPoint(x: end.x, y: end.y), + controlPoint1: CGPoint(x: control1.x, y: control1.y), + controlPoint2: CGPoint(x: control2.x, y: control2.y) ) - ) - path.cgPath = cgPath - case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): - path.addArc( - withCenter: CGPoint(x: center.x, y: center.y), - radius: CGFloat(radius), - startAngle: CGFloat(startAngle), - endAngle: CGFloat(endAngle), - clockwise: clockwise - ) - case .transform(let transform): - path.apply(CGAffineTransform(transform)) - case .subpath(let actions, let fillRule): - let subpath = UIBezierPath() - subpath.usesEvenOddFillRule = (fillRule == .evenOdd) - applyActions(actions, to: subpath) - path.append(subpath) + case .rectangle(let rect): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addRect( + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + ) + path.cgPath = cgPath + case .circle(let center, let radius): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addEllipse( + in: CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2.0, + height: radius * 2.0 + ) + ) + path.cgPath = cgPath + case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): + path.addArc( + withCenter: CGPoint(x: center.x, y: center.y), + radius: CGFloat(radius), + startAngle: CGFloat(startAngle), + endAngle: CGFloat(endAngle), + clockwise: clockwise + ) + case .transform(let transform): + path.apply(CGAffineTransform(transform)) + } } } - } public func renderPath( From fb41c3a44a561d252277a1d0658dbfc3affdd150 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 11 Apr 2025 19:17:57 -0400 Subject: [PATCH 06/16] AppKitBackend --- Sources/AppKitBackend/AppKitBackend.swift | 166 ++++++++++++++++++ Sources/SwiftCrossUI/Backend/AppBackend.swift | 15 +- Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 2 +- Sources/UIKitBackend/UIKitBackend+Path.swift | 21 ++- 4 files changed, 193 insertions(+), 11 deletions(-) diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 84b13687..19c9c81c 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -14,6 +14,7 @@ public final class AppKitBackend: AppBackend { public typealias Widget = NSView public typealias Menu = NSMenu public typealias Alert = NSAlert + public typealias Path = NSBezierPath public let defaultTableRowContentHeight = 20 public let defaultTableCellVerticalPadding = 4 @@ -1143,6 +1144,171 @@ public final class AppKitBackend: AppBackend { tapGestureTarget.longPressHandler = action } } + + final class NSBezierPathView: NSView { + var path: NSBezierPath! + var fillColor: NSColor = .clear + var strokeColor: NSColor = .clear + + override func draw(_ dirtyRect: NSRect) { + fillColor.set() + path.fill() + strokeColor.set() + path.stroke() + } + } + + public func createPathWidget() -> NSView { + NSBezierPathView() + } + + public func createPath() -> Path { + NSBezierPath() + } + + func applyStrokeStyle(_ strokeStyle: StrokeStyle, to path: NSBezierPath) { + path.lineWidth = CGFloat(strokeStyle.width) + + path.lineCapStyle = + switch strokeStyle.cap { + case .butt: + .butt + case .round: + .round + case .square: + .square + } + + switch strokeStyle.join { + case .miter(let limit): + path.lineJoinStyle = .miter + path.miterLimit = CGFloat(limit) + case .round: + path.lineJoinStyle = .round + case .bevel: + path.lineJoinStyle = .bevel + } + } + + public func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + applyStrokeStyle(source.strokeStyle, to: path) + + if pointsChanged { + path.removeAllPoints() + + for action in source.actions { + switch action { + case .moveTo(let point): + path.move(to: NSPoint(x: point.x, y: point.y)) + case .lineTo(let point): + if path.isEmpty { + path.move(to: .zero) + } + path.line(to: NSPoint(x: point.x, y: point.y)) + case .quadCurve(let control, let end): + if path.isEmpty { + path.move(to: .zero) + } + + if #available(macOS 14, *) { + // Use the native quadratic curve function + path.curve( + to: NSPoint(x: end.x, y: end.y), + controlPoint: NSPoint(x: control.x, y: control.y) + ) + } else { + let start = path.currentPoint + // Build a cubic curve that follows the same path as the quadratic + path.curve( + to: NSPoint(x: end.x, y: end.y), + controlPoint1: NSPoint( + x: (start.x + 2.0 * control.x) / 3.0, + y: (start.y + 2.0 * control.y) / 3.0 + ), + controlPoint2: NSPoint( + x: (2.0 * control.x + end.x) / 3.0, + y: (2.0 * control.y + end.y) / 3.0 + ) + ) + } + case .cubicCurve(let control1, let control2, let end): + if path.isEmpty { + path.move(to: .zero) + } + + path.curve( + to: NSPoint(x: end.x, y: end.y), + controlPoint1: NSPoint(x: control1.x, y: control1.y), + controlPoint2: NSPoint(x: control2.x, y: control2.y) + ) + case .rectangle(let rect): + path.appendRect( + NSRect( + origin: NSPoint(x: rect.x, y: rect.y), + size: NSSize( + width: CGFloat(rect.width), + height: CGFloat(rect.height) + ) + ) + ) + case .circle(let center, let radius): + path.appendOval( + in: NSRect( + origin: NSPoint(x: center.x - radius, y: center.y - radius), + size: NSSize( + width: CGFloat(radius) * 2.0, + height: CGFloat(radius) * 2.0 + ) + ) + ) + case .arc( + let center, + let radius, + let startAngle, + let endAngle, + let clockwise + ): + path.appendArc( + withCenter: NSPoint(x: center.x, y: center.y), + radius: CGFloat(radius), + startAngle: CGFloat(startAngle), + endAngle: CGFloat(endAngle), + clockwise: clockwise + ) + case .transform(let transform): + path.transform( + using: Foundation.AffineTransform( + m11: CGFloat(transform.linearTransform.x), + m12: CGFloat(transform.linearTransform.z), + m21: CGFloat(transform.linearTransform.y), + m22: CGFloat(transform.linearTransform.w), + tX: CGFloat(transform.translation.x), + tY: CGFloat(transform.translation.y) + ) + ) + } + } + } + } + + public func renderPath( + _ path: Path, + container: Widget, + strokeColor: Color, + fillColor: Color, + overrideStrokeStyle: StrokeStyle? + ) { + if let overrideStrokeStyle { + applyStrokeStyle(overrideStrokeStyle, to: path) + } + + let widget = container as! NSBezierPathView + widget.path = path + widget.strokeColor = strokeColor.nsColor + widget.fillColor = fillColor.nsColor + + widget.setNeedsDisplay(widget.bounds) + } } final class NSCustomTapGestureTarget: NSView { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index e03b117e..3ee795e8 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -530,6 +530,8 @@ public protocol AppBackend { ) // MARK: Paths + /// Create a widget that can contain a path. + func createPathWidget() -> Widget /// Create a path. It will not be shown until ``renderPath(_:container:)`` is called. func createPath() -> Path /// Update a path. The updates do not need to be visible before ``renderPath(_:container:)`` @@ -542,8 +544,8 @@ public protocol AppBackend { /// Draw a path to the screen. /// - Parameters: /// - path: The path to be rendered. - /// - container: The container widget that the path will render in. It has no other - /// children. + /// - container: The container widget that the path will render in. Created with + /// ``createPathWidget()``. /// - strokeColor: The color to draw the path's stroke. /// - fillColor: The color to shade the path's fill. /// - overrideStrokeStyle: If present, a value to override the path's stroke style. @@ -872,13 +874,16 @@ extension AppBackend { } // MARK: Paths - func createPath() -> Path { + public func createPathWidget() -> Widget { todo() } - func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + public func createPath() -> Path { todo() } - func renderPath( + public func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + todo() + } + public func renderPath( _ path: Path, container: Widget, strokeColor: Color, diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index ea90d596..8c737567 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -42,7 +42,7 @@ extension Shape { public func asWidget( _ children: any ViewGraphNodeChildren, backend: Backend ) -> Backend.Widget { - let container = backend.createContainer() + let container = backend.createPathWidget() let storage = children as! ShapeStorage storage.backendPath = backend.createPath() storage.oldPath = nil diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index e51988de..03d3dfa7 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -1,9 +1,23 @@ import SwiftCrossUI import UIKit +final class PathWidget: BaseViewWidget { + let shapeLayer = CAShapeLayer() + + override init() { + super.init() + + layer.addSublayer(shapeLayer) + } +} + extension UIKitBackend { public typealias Path = UIBezierPath + public func createPathWidget() -> any WidgetProtocol { + BaseViewWidget() + } + public func createPath() -> UIBezierPath { UIBezierPath() } @@ -100,7 +114,8 @@ extension UIKitBackend { applyStrokeStyle(overrideStrokeStyle, to: path) } - let shapeLayer = container.view.layer.sublayers?[0] as? CAShapeLayer ?? CAShapeLayer() + let widget = container as! PathWidget + let shapeLayer = widget.shapeLayer shapeLayer.path = path.cgPath shapeLayer.lineWidth = path.lineWidth @@ -129,10 +144,6 @@ extension UIKitBackend { shapeLayer.strokeColor = strokeColor.cgColor shapeLayer.fillColor = fillColor.cgColor - - if shapeLayer.superlayer !== container.view.layer { - container.view.layer.addSublayer(shapeLayer) - } } } From f54697576e1123a7193926cfd3a53ed0e6d0b5b3 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 11 Apr 2025 19:40:37 -0400 Subject: [PATCH 07/16] Fix subpaths --- Sources/AppKitBackend/AppKitBackend.swift | 169 ++++++++++--------- Sources/GtkBackend/GtkBackend.swift | 1 + Sources/SwiftCrossUI/Path.swift | 3 +- Sources/UIKitBackend/UIKitBackend+Path.swift | 95 ++++++----- 4 files changed, 142 insertions(+), 126 deletions(-) diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 19c9c81c..b8928f81 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1195,98 +1195,105 @@ public final class AppKitBackend: AppBackend { if pointsChanged { path.removeAllPoints() + applyActions(source.actions, to: path) + } + } - for action in source.actions { - switch action { - case .moveTo(let point): - path.move(to: NSPoint(x: point.x, y: point.y)) - case .lineTo(let point): - if path.isEmpty { - path.move(to: .zero) - } - path.line(to: NSPoint(x: point.x, y: point.y)) - case .quadCurve(let control, let end): - if path.isEmpty { - path.move(to: .zero) - } - - if #available(macOS 14, *) { - // Use the native quadratic curve function - path.curve( - to: NSPoint(x: end.x, y: end.y), - controlPoint: NSPoint(x: control.x, y: control.y) - ) - } else { - let start = path.currentPoint - // Build a cubic curve that follows the same path as the quadratic - path.curve( - to: NSPoint(x: end.x, y: end.y), - controlPoint1: NSPoint( - x: (start.x + 2.0 * control.x) / 3.0, - y: (start.y + 2.0 * control.y) / 3.0 - ), - controlPoint2: NSPoint( - x: (2.0 * control.x + end.x) / 3.0, - y: (2.0 * control.y + end.y) / 3.0 - ) - ) - } - case .cubicCurve(let control1, let control2, let end): - if path.isEmpty { - path.move(to: .zero) - } + func applyActions(_ actions: [SwiftCrossUI.Path.Action], to path: NSBezierPath) { + for action in actions { + switch action { + case .moveTo(let point): + path.move(to: NSPoint(x: point.x, y: point.y)) + case .lineTo(let point): + if path.isEmpty { + path.move(to: .zero) + } + path.line(to: NSPoint(x: point.x, y: point.y)) + case .quadCurve(let control, let end): + if path.isEmpty { + path.move(to: .zero) + } + if #available(macOS 14, *) { + // Use the native quadratic curve function path.curve( to: NSPoint(x: end.x, y: end.y), - controlPoint1: NSPoint(x: control1.x, y: control1.y), - controlPoint2: NSPoint(x: control2.x, y: control2.y) + controlPoint: NSPoint(x: control.x, y: control.y) ) - case .rectangle(let rect): - path.appendRect( - NSRect( - origin: NSPoint(x: rect.x, y: rect.y), - size: NSSize( - width: CGFloat(rect.width), - height: CGFloat(rect.height) - ) + } else { + let start = path.currentPoint + // Build a cubic curve that follows the same path as the quadratic + path.curve( + to: NSPoint(x: end.x, y: end.y), + controlPoint1: NSPoint( + x: (start.x + 2.0 * control.x) / 3.0, + y: (start.y + 2.0 * control.y) / 3.0 + ), + controlPoint2: NSPoint( + x: (2.0 * control.x + end.x) / 3.0, + y: (2.0 * control.y + end.y) / 3.0 ) ) - case .circle(let center, let radius): - path.appendOval( - in: NSRect( - origin: NSPoint(x: center.x - radius, y: center.y - radius), - size: NSSize( - width: CGFloat(radius) * 2.0, - height: CGFloat(radius) * 2.0 - ) + } + case .cubicCurve(let control1, let control2, let end): + if path.isEmpty { + path.move(to: .zero) + } + + path.curve( + to: NSPoint(x: end.x, y: end.y), + controlPoint1: NSPoint(x: control1.x, y: control1.y), + controlPoint2: NSPoint(x: control2.x, y: control2.y) + ) + case .rectangle(let rect): + path.appendRect( + NSRect( + origin: NSPoint(x: rect.x, y: rect.y), + size: NSSize( + width: CGFloat(rect.width), + height: CGFloat(rect.height) ) ) - case .arc( - let center, - let radius, - let startAngle, - let endAngle, - let clockwise - ): - path.appendArc( - withCenter: NSPoint(x: center.x, y: center.y), - radius: CGFloat(radius), - startAngle: CGFloat(startAngle), - endAngle: CGFloat(endAngle), - clockwise: clockwise - ) - case .transform(let transform): - path.transform( - using: Foundation.AffineTransform( - m11: CGFloat(transform.linearTransform.x), - m12: CGFloat(transform.linearTransform.z), - m21: CGFloat(transform.linearTransform.y), - m22: CGFloat(transform.linearTransform.w), - tX: CGFloat(transform.translation.x), - tY: CGFloat(transform.translation.y) + ) + case .circle(let center, let radius): + path.appendOval( + in: NSRect( + origin: NSPoint(x: center.x - radius, y: center.y - radius), + size: NSSize( + width: CGFloat(radius) * 2.0, + height: CGFloat(radius) * 2.0 ) ) - } + ) + case .arc( + let center, + let radius, + let startAngle, + let endAngle, + let clockwise + ): + path.appendArc( + withCenter: NSPoint(x: center.x, y: center.y), + radius: CGFloat(radius), + startAngle: CGFloat(startAngle), + endAngle: CGFloat(endAngle), + clockwise: clockwise + ) + case .transform(let transform): + path.transform( + using: Foundation.AffineTransform( + m11: CGFloat(transform.linearTransform.x), + m12: CGFloat(transform.linearTransform.z), + m21: CGFloat(transform.linearTransform.y), + m22: CGFloat(transform.linearTransform.w), + tX: CGFloat(transform.translation.x), + tY: CGFloat(transform.translation.y) + ) + ) + case .subpath(let subpathActions): + let subpath = NSBezierPath() + applyActions(subpathActions, to: subpath) + path.append(subpath) } } } diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 384cc2ce..85519ab2 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -22,6 +22,7 @@ public final class GtkBackend: AppBackend { public typealias Widget = Gtk.Widget public typealias Menu = Gtk.PopoverMenu public typealias Alert = Gtk.MessageDialog + public typealias Path = Never public let defaultTableRowContentHeight = 20 public let defaultTableCellVerticalPadding = 4 diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index fbf5ee78..5daa3e47 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -214,6 +214,7 @@ public struct Path { clockwise: Bool ) case transform(AffineTransform) + case subpath([Action]) } /// A list of every action that has been performed on this path. @@ -290,7 +291,7 @@ public struct Path { } public consuming func addSubpath(_ subpath: Path) -> Path { - actions.append(contentsOf: subpath.actions) + actions.append(.subpath(subpath.actions)) return self } diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index 03d3dfa7..0ff47f71 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -53,52 +53,59 @@ extension UIKitBackend { if pointsChanged { path.removeAllPoints() + applyActions(source.actions, to: path) + } + } - for action in source.actions { - switch action { - case .moveTo(let point): - path.move(to: CGPoint(x: point.x, y: point.y)) - case .lineTo(let point): - path.addLine(to: CGPoint(x: point.x, y: point.y)) - case .quadCurve(let control, let end): - path.addQuadCurve( - to: CGPoint(x: end.x, y: end.y), - controlPoint: CGPoint(x: control.x, y: control.y) - ) - case .cubicCurve(let control1, let control2, let end): - path.addCurve( - to: CGPoint(x: end.x, y: end.y), - controlPoint1: CGPoint(x: control1.x, y: control1.y), - controlPoint2: CGPoint(x: control2.x, y: control2.y) - ) - case .rectangle(let rect): - let cgPath: CGMutablePath = path.cgPath.mutableCopy()! - cgPath.addRect( - CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) - ) - path.cgPath = cgPath - case .circle(let center, let radius): - let cgPath: CGMutablePath = path.cgPath.mutableCopy()! - cgPath.addEllipse( - in: CGRect( - x: center.x - radius, - y: center.y - radius, - width: radius * 2.0, - height: radius * 2.0 - ) - ) - path.cgPath = cgPath - case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): - path.addArc( - withCenter: CGPoint(x: center.x, y: center.y), - radius: CGFloat(radius), - startAngle: CGFloat(startAngle), - endAngle: CGFloat(endAngle), - clockwise: clockwise + func applyActions(_ actions: [SwiftCrossUI.Path.Action], to path: UIBezierPath) { + for action in actions { + switch action { + case .moveTo(let point): + path.move(to: CGPoint(x: point.x, y: point.y)) + case .lineTo(let point): + path.addLine(to: CGPoint(x: point.x, y: point.y)) + case .quadCurve(let control, let end): + path.addQuadCurve( + to: CGPoint(x: end.x, y: end.y), + controlPoint: CGPoint(x: control.x, y: control.y) + ) + case .cubicCurve(let control1, let control2, let end): + path.addCurve( + to: CGPoint(x: end.x, y: end.y), + controlPoint1: CGPoint(x: control1.x, y: control1.y), + controlPoint2: CGPoint(x: control2.x, y: control2.y) + ) + case .rectangle(let rect): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addRect( + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + ) + path.cgPath = cgPath + case .circle(let center, let radius): + let cgPath: CGMutablePath = path.cgPath.mutableCopy()! + cgPath.addEllipse( + in: CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2.0, + height: radius * 2.0 ) - case .transform(let transform): - path.apply(CGAffineTransform(transform)) - } + ) + path.cgPath = cgPath + case .arc(let center, let radius, let startAngle, let endAngle, let clockwise): + path.addArc( + withCenter: CGPoint(x: center.x, y: center.y), + radius: CGFloat(radius), + startAngle: CGFloat(startAngle), + endAngle: CGFloat(endAngle), + clockwise: clockwise + ) + case .transform(let transform): + path.apply(CGAffineTransform(transform)) + case .subpath(let subpathActions): + let subpath = UIBezierPath() + applyActions(subpathActions, to: subpath) + path.append(subpath) } } } From 7b283e3096024131c7d674a581ed89eb6505a4d3 Mon Sep 17 00:00:00 2001 From: William Baker Date: Fri, 11 Apr 2025 23:48:47 -0400 Subject: [PATCH 08/16] Add `Path = Never` to Gtk3Backend to get CI off my back --- Sources/Gtk3Backend/Gtk3Backend.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 4ed614d2..3d8c78ba 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -22,6 +22,7 @@ public final class Gtk3Backend: AppBackend { public typealias Widget = Gtk3.Widget public typealias Menu = Gtk3.Menu public typealias Alert = Gtk3.MessageDialog + public typealias Path = Never public let defaultTableRowContentHeight = 20 public let defaultTableCellVerticalPadding = 4 From c11935b93d2772bc66790ea34f97ca3b721cd917 Mon Sep 17 00:00:00 2001 From: bbrk24 Date: Tue, 15 Apr 2025 19:10:30 -0400 Subject: [PATCH 09/16] Implement Path in WinUIBackend --- Examples/Package.resolved | 14 +- Examples/Package.swift | 4 + Examples/Sources/PathsExample/PathsApp.swift | 96 ++++++++ Package.resolved | 4 +- Package.swift | 2 +- Sources/SwiftCrossUI/Path.swift | 19 ++ Sources/WinUIBackend/WinUIBackend.swift | 236 ++++++++++++++++++- 7 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 Examples/Sources/PathsExample/PathsApp.swift diff --git a/Examples/Package.resolved b/Examples/Package.resolved index 4075ca68..7e685c14 100644 --- a/Examples/Package.resolved +++ b/Examples/Package.resolved @@ -175,8 +175,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto", "state" : { - "revision" : "a6ce32a18b81b04ce7e897d1d98df6eb2da04786", - "version" : "3.12.2" + "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", + "version" : "3.12.3" } }, { @@ -308,7 +308,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-winui", "state" : { - "revision" : "42fe0034b7162f2de71ceea95725915d1147455a" + "revision" : "a81bc36e3ac056fbc740e9df30ff0d80af5ecd21" } }, { @@ -343,8 +343,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/yonaskolb/XcodeGen", "state" : { - "revision" : "82c6ab9bbd5b6075fc0887d897733fc0c4ffc9ab", - "version" : "2.42.0" + "revision" : "7193eb447a6f60061f069e07bc1efd32d73c0e19", + "version" : "2.43.0" } }, { @@ -352,8 +352,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tuist/XcodeProj", "state" : { - "revision" : "447c159b0c5fb047a024fd8d942d4a76cf47dde0", - "version" : "8.16.0" + "revision" : "dc3b87a4e69f9cd06c6cb16199f5d0472e57ef6b", + "version" : "8.24.3" } }, { diff --git a/Examples/Package.swift b/Examples/Package.swift index f36ceb4e..744cab1c 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -65,5 +65,9 @@ let package = Package( name: "NotesExample", dependencies: exampleDependencies ), + .executableTarget( + name: "PathsExample", + dependencies: exampleDependencies + ) ] ) diff --git a/Examples/Sources/PathsExample/PathsApp.swift b/Examples/Sources/PathsExample/PathsApp.swift new file mode 100644 index 00000000..d1ea6e0c --- /dev/null +++ b/Examples/Sources/PathsExample/PathsApp.swift @@ -0,0 +1,96 @@ +import SwiftCrossUI +import DefaultBackend +import Foundation // for sin, cos + +struct ArcShape: StyledShape { + var startAngle: Double + var endAngle: Double + var clockwise: Bool + + var strokeColor: Color? = Color.green + let fillColor: Color? = nil + let strokeStyle: StrokeStyle? = StrokeStyle(width: 5.0) + + func path(in bounds: Path.Rect) -> Path { + let radius = min(bounds.width, bounds.height) / 2.0 - 2.5 + + return Path() + .move(to: bounds.center + radius * SIMD2(x: cos(startAngle), y: sin(startAngle))) + .addArc( + center: bounds.center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: clockwise + ) + } + + func size(fitting proposal: SIMD2) -> ViewSize { + let diameter = max(11, min(proposal.x, proposal.y)) + return ViewSize( + size: SIMD2(x: diameter, y: diameter), + idealSize: SIMD2(x: 100, y: 100), + idealWidthForProposedHeight: proposal.y, + idealHeightForProposedWidth: proposal.x, + minimumWidth: 11, + minimumHeight: 11, + maximumWidth: nil, + maximumHeight: nil + ) + } +} + +struct PathsApp: App { + var body: some Scene { + WindowGroup("PathsApp") { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(.gray) + + HStack { + VStack { + Text("Clockwise") + + HStack { + ArcShape(startAngle: .pi * 2.0/3.0, endAngle: .pi * 1.5, clockwise: true) + + ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 1.0/3.0, clockwise: true) + } + + HStack { + ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 2.0/3.0, clockwise: true) + + ArcShape(startAngle: .pi * 1.0/3.0, endAngle: .pi * 1.5, clockwise: true) + } + } + + VStack { + Text("Counter-clockwise") + + HStack { + ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 2.0/3.0, clockwise: false) + + ArcShape(startAngle: .pi * 1.0/3.0, endAngle: .pi * 1.5, clockwise: false) + } + + HStack { + ArcShape(startAngle: .pi * 2.0/3.0, endAngle: .pi * 1.5, clockwise: false) + + ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 1.0/3.0, clockwise: false) + } + } + }.padding() + } + .padding() + + Ellipse() + .fill(.blue) + .padding() + } + } + } +} + +// Even though this file isn't called main.swift, `@main` isn't allowed and this is +PathsApp.main() diff --git a/Package.resolved b/Package.resolved index e6482ec1..0b88c424 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b91f917bbebb41572e4a589a5f5f3fa23970b5412aa202d7d5a9ffd85f7382b3", + "originHash" : "b27fd5296427d0b831ec73c4d0cea714e68fa1e046809b1001bb42cf87171755", "pins" : [ { "identity" : "jpeg", @@ -112,7 +112,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-winui", "state" : { - "revision" : "42fe0034b7162f2de71ceea95725915d1147455a" + "revision" : "a81bc36e3ac056fbc740e9df30ff0d80af5ecd21" } }, { diff --git a/Package.swift b/Package.swift index 7eb12150..34160e87 100644 --- a/Package.swift +++ b/Package.swift @@ -107,7 +107,7 @@ let package = Package( ), .package( url: "https://github.com/stackotter/swift-winui", - branch: "42fe0034b7162f2de71ceea95725915d1147455a" + branch: "a81bc36e3ac056fbc740e9df30ff0d80af5ecd21" ), // .package( // url: "https://github.com/stackotter/TermKit", diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index 5daa3e47..dd1c6683 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -266,6 +266,24 @@ public struct Path { return self } + /// Add an arc segment to the path. + /// + /// The behavior is not defined if the starting point is not what is implied by `center`, + /// `radius`, and `startAngle`. Some backends (such as UIKit) will add a line segment + /// to connect the arc to the starting point, while others (such as WinUI) will move the + /// arc in unintuitive ways. If this arc is the first segment of the current path, or + /// the previous segment was a rectangle or circle, be sure to call ``move(to:)`` before + /// this. + /// - Parameters: + /// - center: The location of the center of the circle. + /// - radius: The radius of the circle. + /// - startAngle: The angle of the start of the arc, measured in radians clockwise from + // right. Must be between 0 and 2pi (inclusive). + /// - endAngle: The angle of the end of the arc, measured in radians clockwise from right. + /// Must be between 0 and 2pi (inclusive). + /// - clockwise: `true` if the arc is to be drawn clockwise, `false` if the arc is to + /// be drawn counter-clockwise. Used to determine whether to draw the larger arc or + /// the smaller arc identified by the given start and end angles. public consuming func addArc( center: SIMD2, radius: Double, @@ -273,6 +291,7 @@ public struct Path { endAngle: Double, clockwise: Bool ) -> Path { + assert((0.0 ... (2.0 * .pi)).contains(startAngle) && (0.0 ... (2.0 * .pi)).contains(endAngle)) actions.append( .arc( center: center, diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 3aeddef0..d9936854 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -19,7 +19,6 @@ extension App { WinUIBackend() } } - class WinUIApplication: SwiftApplication { static var callback: ((WinUIApplication) -> Void)? @@ -27,12 +26,12 @@ class WinUIApplication: SwiftApplication { Self.callback?(self) } } - public final class WinUIBackend: AppBackend { public typealias Window = CustomWindow public typealias Widget = WinUI.FrameworkElement public typealias Menu = Void public typealias Alert = WinUI.ContentDialog + public typealias Path = GeometryGroupHolder public let defaultTableRowContentHeight = 20 public let defaultTableCellVerticalPadding = 4 @@ -1163,6 +1162,234 @@ public final class WinUIBackend: AppBackend { } } + public func createPathWidget() -> Widget { + WinUI.Path() + } + + public func createPath() -> Path { + GeometryGroupHolder() + } + + public func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + path.strokeStyle = source.strokeStyle + + if pointsChanged { + path.group.children.clear() + applyActions(source.actions, to: path.group.children) + } + + path.group.fillRule = + switch source.fillRule { + case .evenOdd: + .evenOdd + case .winding: + .nonzero + } + } + + func requirePathFigure( + _ collection: WinUI.GeometryCollection, + lastPoint: Point + ) -> PathFigure { + var pathGeo: PathGeometry + if collection.size > 0, + let castedLast = collection.getAt(collection.size - 1) as? PathGeometry { + pathGeo = castedLast + } else { + pathGeo = PathGeometry() + collection.append(pathGeo) + } + + var figure: PathFigure + if pathGeo.figures.size > 0 { + // Note: the if check and force-unwrap is necessary. You can't do an `if let` + // here because PathFigureCollection uses unsigned integers for its indices so + // `size - 1` would underflow (causing a fatalError) if it's empty. + figure = pathGeo.figures.getAt(pathGeo.figures.size - 1)! + } else { + figure = PathFigure() + figure.startPoint = lastPoint + pathGeo.figures.append(figure) + } + + return figure + } + + func applyActions(_ actions: [SwiftCrossUI.Path.Action], to geometry: WinUI.GeometryCollection) { + var lastPoint = Point(x: 0.0, y: 0.0) + + for action in actions { + switch action { + case .moveTo(let point): + lastPoint = Point(x: Float(point.x), y: Float(point.y)) + + if geometry.size > 0, + let pathGeo = geometry.getAt(geometry.size - 1) as? PathGeometry, + pathGeo.figures.size > 0 { + let figure = pathGeo.figures.getAt(pathGeo.figures.size - 1)! + if figure.segments.size > 0 { + let newFigure = PathFigure() + newFigure.startPoint = lastPoint + pathGeo.figures.append(newFigure) + } else { + figure.startPoint = lastPoint + } + } + case .lineTo(let point): + let wfPoint = Point(x: Float(point.x), y: Float(point.y)) + defer { lastPoint = wfPoint } + + let figure = requirePathFigure(geometry, lastPoint: lastPoint) + + let segment = LineSegment() + segment.point = wfPoint + figure.segments.append(segment) + case .quadCurve(let control, let end): + let wfControl = Point(x: Float(control.x), y: Float(control.y)) + let wfEnd = Point(x: Float(end.x), y: Float(end.y)) + defer { lastPoint = wfEnd } + + let figure = requirePathFigure(geometry, lastPoint: lastPoint) + + let segment = QuadraticBezierSegment () + segment.point1 = wfControl + segment.point2 = wfEnd + figure.segments.append(segment) + case .cubicCurve(let control1, let control2, let end): + let wfControl1 = Point(x: Float(control1.x), y: Float(control1.y)) + let wfControl2 = Point(x: Float(control2.x), y: Float(control2.y)) + let wfEnd = Point(x: Float(end.x), y: Float(end.y)) + defer { lastPoint = wfEnd } + + let figure = requirePathFigure(geometry, lastPoint: lastPoint) + + let segment = BezierSegment() + segment.point1 = wfControl1 + segment.point2 = wfControl2 + segment.point3 = wfEnd + figure.segments.append(segment) + case .rectangle(let rect): + let rectGeo = RectangleGeometry() + rectGeo.rect = Rect( + x: Float(rect.x), + y: Float(rect.y), + width: Float(rect.width), + height: Float(rect.height) + ) + geometry.append(rectGeo) + case .circle(let center, let radius): + let ellipse = EllipseGeometry() + ellipse.radiusX = radius + ellipse.radiusY = radius + ellipse.center = Point(x: Float(center.x), y: Float(center.y)) + geometry.append(ellipse) + case .arc( + let center, + let radius, + let startAngle, + let endAngle, + let clockwise + ): + let endPoint = Point( + x: Float(center.x + radius * cos(endAngle)), + y: Float(center.y + radius * sin(endAngle)) + ) + defer { lastPoint = endPoint } + + let figure = requirePathFigure(geometry, lastPoint: lastPoint) + + let segment = ArcSegment() + + if clockwise { + if startAngle < endAngle { + segment.isLargeArc = (endAngle - startAngle > .pi) + } else { + segment.isLargeArc = (startAngle - endAngle < .pi) + } + segment.sweepDirection = .clockwise + } else { + if startAngle < endAngle { + segment.isLargeArc = (endAngle - startAngle < .pi) + } else { + segment.isLargeArc = (startAngle - endAngle > .pi) + } + segment.sweepDirection = .counterclockwise + } + + segment.point = endPoint + segment.size = Size(width: Float(radius), height: Float(radius)) + + figure.segments.append(segment) + case .transform(let transform): + let matrixTransform = MatrixTransform() + matrixTransform.matrix = Matrix( + m11: transform.linearTransform.x, + m12: transform.linearTransform.z, + m21: transform.linearTransform.y, + m22: transform.linearTransform.w, + offsetX: transform.translation.x, + offsetY: transform.translation.y + ) + + for case let geo? in geometry { + if geo.transform == nil { + geo.transform = matrixTransform + } else if let group = geo.transform as? TransformGroup { + group.children.append(matrixTransform) + } else { + let group = TransformGroup() + group.children.append(geo.transform) + group.children.append(matrixTransform) + geo.transform = group + } + } + case .subpath(let actions): + let subGeo = GeometryGroup() + applyActions(actions, to: subGeo.children) + geometry.append(subGeo) + } + } + } + + public func renderPath( + _ path: Path, + container: Widget, + strokeColor: SwiftCrossUI.Color, + fillColor: SwiftCrossUI.Color, + overrideStrokeStyle: StrokeStyle? + ) { + let winUiPath = container as! WinUI.Path + let strokeStyle = overrideStrokeStyle ?? path.strokeStyle! + + winUiPath.fill = WinUI.SolidColorBrush(fillColor.uwpColor) + winUiPath.stroke = WinUI.SolidColorBrush(strokeColor.uwpColor) + winUiPath.strokeThickness = strokeStyle.width + + switch strokeStyle.cap { + case .butt: + winUiPath.strokeStartLineCap = .flat + winUiPath.strokeEndLineCap = .flat + case .round: + winUiPath.strokeStartLineCap = .round + winUiPath.strokeEndLineCap = .round + case .square: + winUiPath.strokeStartLineCap = .square + winUiPath.strokeEndLineCap = .square + } + + switch strokeStyle.join { + case .miter(let limit): + winUiPath.strokeMiterLimit = limit + winUiPath.strokeLineJoin = .miter + case .round: + winUiPath.strokeLineJoin = .round + case .bevel: + winUiPath.strokeLineJoin = .bevel + } + + winUiPath.data = path.group + } + // public func createTable(rows: Int, columns: Int) -> Widget { // let grid = Grid() // grid.columnSpacing = 10 @@ -1380,3 +1607,8 @@ public class CustomWindow: WinUI.Window { WinUI.Grid.setRow(child, 1) } } + +public final class GeometryGroupHolder { + var group = GeometryGroup() + var strokeStyle: StrokeStyle? +} From 5dca6a80dbafb714e3b1a144a06e198a6aebbd0f Mon Sep 17 00:00:00 2001 From: William Baker Date: Tue, 15 Apr 2025 19:14:17 -0400 Subject: [PATCH 10/16] format code --- Examples/Sources/PathsExample/PathsApp.swift | 68 ++++++++++++++------ Sources/SwiftCrossUI/Path.swift | 4 +- Sources/WinUIBackend/WinUIBackend.swift | 13 ++-- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/Examples/Sources/PathsExample/PathsApp.swift b/Examples/Sources/PathsExample/PathsApp.swift index d1ea6e0c..860e47a4 100644 --- a/Examples/Sources/PathsExample/PathsApp.swift +++ b/Examples/Sources/PathsExample/PathsApp.swift @@ -1,6 +1,6 @@ -import SwiftCrossUI import DefaultBackend -import Foundation // for sin, cos +import Foundation // for sin, cos +import SwiftCrossUI struct ArcShape: StyledShape { var startAngle: Double @@ -13,7 +13,7 @@ struct ArcShape: StyledShape { func path(in bounds: Path.Rect) -> Path { let radius = min(bounds.width, bounds.height) / 2.0 - 2.5 - + return Path() .move(to: bounds.center + radius * SIMD2(x: cos(startAngle), y: sin(startAngle))) .addArc( @@ -53,31 +53,63 @@ struct PathsApp: App { Text("Clockwise") HStack { - ArcShape(startAngle: .pi * 2.0/3.0, endAngle: .pi * 1.5, clockwise: true) - - ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 1.0/3.0, clockwise: true) + ArcShape( + startAngle: .pi * 2.0 / 3.0, + endAngle: .pi * 1.5, + clockwise: true + ) + + ArcShape( + startAngle: .pi * 1.5, + endAngle: .pi * 1.0 / 3.0, + clockwise: true + ) } - HStack { - ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 2.0/3.0, clockwise: true) - - ArcShape(startAngle: .pi * 1.0/3.0, endAngle: .pi * 1.5, clockwise: true) + HStack { + ArcShape( + startAngle: .pi * 1.5, + endAngle: .pi * 2.0 / 3.0, + clockwise: true + ) + + ArcShape( + startAngle: .pi * 1.0 / 3.0, + endAngle: .pi * 1.5, + clockwise: true + ) } } - + VStack { Text("Counter-clockwise") - HStack { - ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 2.0/3.0, clockwise: false) - - ArcShape(startAngle: .pi * 1.0/3.0, endAngle: .pi * 1.5, clockwise: false) + HStack { + ArcShape( + startAngle: .pi * 1.5, + endAngle: .pi * 2.0 / 3.0, + clockwise: false + ) + + ArcShape( + startAngle: .pi * 1.0 / 3.0, + endAngle: .pi * 1.5, + clockwise: false + ) } HStack { - ArcShape(startAngle: .pi * 2.0/3.0, endAngle: .pi * 1.5, clockwise: false) - - ArcShape(startAngle: .pi * 1.5, endAngle: .pi * 1.0/3.0, clockwise: false) + ArcShape( + startAngle: .pi * 2.0 / 3.0, + endAngle: .pi * 1.5, + clockwise: false + ) + + ArcShape( + startAngle: .pi * 1.5, + endAngle: .pi * 1.0 / 3.0, + clockwise: false + ) } } }.padding() diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index dd1c6683..3ecc4e72 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -267,7 +267,7 @@ public struct Path { } /// Add an arc segment to the path. - /// + /// /// The behavior is not defined if the starting point is not what is implied by `center`, /// `radius`, and `startAngle`. Some backends (such as UIKit) will add a line segment /// to connect the arc to the starting point, while others (such as WinUI) will move the @@ -291,7 +291,7 @@ public struct Path { endAngle: Double, clockwise: Bool ) -> Path { - assert((0.0 ... (2.0 * .pi)).contains(startAngle) && (0.0 ... (2.0 * .pi)).contains(endAngle)) + assert((0.0...(2.0 * .pi)).contains(startAngle) && (0.0...(2.0 * .pi)).contains(endAngle)) actions.append( .arc( center: center, diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index d9936854..0573970c 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -1193,7 +1193,8 @@ public final class WinUIBackend: AppBackend { ) -> PathFigure { var pathGeo: PathGeometry if collection.size > 0, - let castedLast = collection.getAt(collection.size - 1) as? PathGeometry { + let castedLast = collection.getAt(collection.size - 1) as? PathGeometry + { pathGeo = castedLast } else { pathGeo = PathGeometry() @@ -1215,7 +1216,8 @@ public final class WinUIBackend: AppBackend { return figure } - func applyActions(_ actions: [SwiftCrossUI.Path.Action], to geometry: WinUI.GeometryCollection) { + func applyActions(_ actions: [SwiftCrossUI.Path.Action], to geometry: WinUI.GeometryCollection) + { var lastPoint = Point(x: 0.0, y: 0.0) for action in actions { @@ -1224,8 +1226,9 @@ public final class WinUIBackend: AppBackend { lastPoint = Point(x: Float(point.x), y: Float(point.y)) if geometry.size > 0, - let pathGeo = geometry.getAt(geometry.size - 1) as? PathGeometry, - pathGeo.figures.size > 0 { + let pathGeo = geometry.getAt(geometry.size - 1) as? PathGeometry, + pathGeo.figures.size > 0 + { let figure = pathGeo.figures.getAt(pathGeo.figures.size - 1)! if figure.segments.size > 0 { let newFigure = PathFigure() @@ -1251,7 +1254,7 @@ public final class WinUIBackend: AppBackend { let figure = requirePathFigure(geometry, lastPoint: lastPoint) - let segment = QuadraticBezierSegment () + let segment = QuadraticBezierSegment() segment.point1 = wfControl segment.point2 = wfEnd figure.segments.append(segment) From 4b3bec12d58e37a87501d573f5bd766c059ec80f Mon Sep 17 00:00:00 2001 From: bbrk24 Date: Tue, 15 Apr 2025 20:00:04 -0400 Subject: [PATCH 11/16] Fix edge case with transforms --- Sources/WinUIBackend/WinUIBackend.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 0573970c..89d0082c 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -1346,6 +1346,14 @@ public final class WinUIBackend: AppBackend { geo.transform = group } } + + if geometry.size > 0, + let pathGeo = geometry.getAt(geometry.size - 1) as? PathGeometry, + pathGeo.figures.contains(where: { ($0?.segments.size ?? 0) > 0 }) + { + // Start a new PathGeometry so that transforms don't apply going forward + geometry.append(PathGeometry()) + } case .subpath(let actions): let subGeo = GeometryGroup() applyActions(actions, to: subGeo.children) From c43908ed5b1af7a9ff06b918132e0d720ed004b4 Mon Sep 17 00:00:00 2001 From: bbrk24 Date: Tue, 15 Apr 2025 22:57:20 -0400 Subject: [PATCH 12/16] Fix arc on WinUIBackend --- Examples/Sources/PathsExample/PathsApp.swift | 7 ++----- Sources/SwiftCrossUI/Path.swift | 7 ------- Sources/WinUIBackend/WinUIBackend.swift | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Examples/Sources/PathsExample/PathsApp.swift b/Examples/Sources/PathsExample/PathsApp.swift index 860e47a4..8064f37f 100644 --- a/Examples/Sources/PathsExample/PathsApp.swift +++ b/Examples/Sources/PathsExample/PathsApp.swift @@ -12,13 +12,10 @@ struct ArcShape: StyledShape { let strokeStyle: StrokeStyle? = StrokeStyle(width: 5.0) func path(in bounds: Path.Rect) -> Path { - let radius = min(bounds.width, bounds.height) / 2.0 - 2.5 - - return Path() - .move(to: bounds.center + radius * SIMD2(x: cos(startAngle), y: sin(startAngle))) + Path() .addArc( center: bounds.center, - radius: radius, + radius: min(bounds.width, bounds.height) / 2.0 - 2.5, startAngle: startAngle, endAngle: endAngle, clockwise: clockwise diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index 3ecc4e72..24a9c4e5 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -267,13 +267,6 @@ public struct Path { } /// Add an arc segment to the path. - /// - /// The behavior is not defined if the starting point is not what is implied by `center`, - /// `radius`, and `startAngle`. Some backends (such as UIKit) will add a line segment - /// to connect the arc to the starting point, while others (such as WinUI) will move the - /// arc in unintuitive ways. If this arc is the first segment of the current path, or - /// the previous segment was a rectangle or circle, be sure to call ``move(to:)`` before - /// this. /// - Parameters: /// - center: The location of the center of the circle. /// - radius: The radius of the circle. diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 89d0082c..a076140f 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -1293,6 +1293,10 @@ public final class WinUIBackend: AppBackend { let endAngle, let clockwise ): + let startPoint = Point( + x: Float(center.x + radius * cos(startAngle)), + y: Float(center.y + radius * sin(startAngle)) + ) let endPoint = Point( x: Float(center.x + radius * cos(endAngle)), y: Float(center.y + radius * sin(endAngle)) @@ -1301,6 +1305,16 @@ public final class WinUIBackend: AppBackend { let figure = requirePathFigure(geometry, lastPoint: lastPoint) + if startPoint != lastPoint { + if figure.segments.size > 0 { + let connector = LineSegment() + connector.point = startPoint + figure.segments.append(connector) + } else { + figure.startPoint = startPoint + } + } + let segment = ArcSegment() if clockwise { From b8b3d0546a95df824da35f83104a5ce4ad983b68 Mon Sep 17 00:00:00 2001 From: William Baker Date: Wed, 16 Apr 2025 22:58:36 -0400 Subject: [PATCH 13/16] final cleanup and documentation --- Sources/SwiftCrossUI/Path.swift | 35 +++++++++++++++++ .../SwiftCrossUI/Views/Shapes/Circle.swift | 4 +- .../Views/Shapes/RoundedRectangle.swift | 2 +- Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 38 +++++++++++++++++-- .../Views/Shapes/StyledShape.swift | 1 + 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index 24a9c4e5..ac775d48 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -229,16 +229,32 @@ public struct Path { public init() {} + /// Move the path's current point to the given point. + /// + /// This does not draw a line segment. For that, see ``addLine(to:)``. + /// + /// If ``addLine(to:)``, ``addQuadCurve(control:to:)``, + /// ``addCubicCurve(control1:control2:to:)``, or + /// ``addArc(center:radius:startAngle:endAngle:clockwise:)`` is called on an empty path + /// without calling this method first, the start point is implicitly (0, 0). public consuming func move(to point: SIMD2) -> Path { actions.append(.moveTo(point)) return self } + /// Add a line segment from the current point to the given point. + /// + /// After this, the path's current point will be the endpoint of this line segment. public consuming func addLine(to point: SIMD2) -> Path { actions.append(.lineTo(point)) return self } + /// Add a quadratic Bézier curve to the path. + /// + /// This creates an order-2 curve starting at the path's current point, bending towards + /// `control`, and ending at `endPoint`. After this, the path's current point will be + /// `endPoint`. public consuming func addQuadCurve( control: SIMD2, to endPoint: SIMD2 @@ -247,6 +263,11 @@ public struct Path { return self } + /// Add a cubic Bézier curve to the path. + /// + /// This creates an order-3 curve starting at the path's current point, bending towards + /// `control1` and `control2`, and ending at `endPoint`. After this, the path's current + /// point will be `endPoint`. public consuming func addCubicCurve( control1: SIMD2, control2: SIMD2, @@ -267,6 +288,9 @@ public struct Path { } /// Add an arc segment to the path. + /// + /// After this, the path's current point will be the endpoint implied by `center`, `radius`, + /// and `endAngle`. /// - Parameters: /// - center: The location of the center of the circle. /// - radius: The radius of the circle. @@ -297,11 +321,21 @@ public struct Path { return self } + /// Apply the given transform to the segments in the path so far. + /// + /// While this may adjust the path's current point, it does not otherwise affect segments + /// that are added to the path after this method call. public consuming func applyTransform(_ transform: AffineTransform) -> Path { actions.append(.transform(transform)) return self } + /// Add the entirety of another path as part of this path. + /// + /// This can be necessary to section off transforms, as transforms applied to `subpath` + /// will not affect this path. + /// + /// The fill rule and preferred stroke style of the subpath are ignored. public consuming func addSubpath(_ subpath: Path) -> Path { actions.append(.subpath(subpath.actions)) return self @@ -316,6 +350,7 @@ public struct Path { return self } + /// Set the fill rule for the path. public consuming func fillRule(_ rule: FillRule) -> Path { fillRule = rule return self diff --git a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift index 90b224b2..f3b2e264 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift @@ -14,8 +14,8 @@ public struct Circle: Shape { idealSize: SIMD2(x: 10, y: 10), idealWidthForProposedHeight: proposal.y, idealHeightForProposedWidth: proposal.x, - minimumWidth: 1, - minimumHeight: 1, + minimumWidth: 0, + minimumHeight: 0, maximumWidth: nil, maximumHeight: nil ) diff --git a/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift b/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift index da89ab5c..a1225f05 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift @@ -31,7 +31,7 @@ public struct RoundedRectangle: Shape { ) // This corresponds to r_{min} in the above Desmos link. This is the minimum ratio of - // cornerRadius to half the side length at which the superellipse is ignored. Above this, + // cornerRadius to half the side length at which the superellipse is not applicable. Above this, // line segments and circular arcs are used. private static let rMin = 0.441968022436 diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index 8c737567..42f29256 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -1,7 +1,39 @@ +/// A 2-D shape that can be drawn as a view. +/// +/// If no stroke color or fill color is specified, the default is no stroke and a fill of the +/// current foreground color. public protocol Shape: View where Content == EmptyView { /// Draw the path for this shape. /// + /// The bounds passed to a shape that is immediately drawn as a view will always have an + /// origin of (0, 0). However, you may pass a different bounding box to subpaths. For example, + /// this code draws a rectangle in the left half of the bounds and an ellipse in the right half: + /// ```swift + /// func path(in bounds: Path.Rect) -> Path { + /// Path() + /// .addSubpath( + /// Rectangle().path( + /// in: Path.Rect( + /// x: bounds.x, + /// y: bounds.y, + /// width: bounds.width / 2.0, + /// height: bounds.height + /// ) + /// ) + /// ) + /// .addSubpath( + /// Ellipse().path( + /// in: Path.Rect( + /// x: bounds.center.x, + /// y: bounds.y, + /// width: bounds.width / 2.0, + /// height: bounds.height + /// ) + /// ) + /// ) + /// } + /// ``` func path(in bounds: Path.Rect) -> Path /// Determine the ideal size of this shape given the proposed bounds. /// @@ -11,7 +43,7 @@ where Content == EmptyView { /// frame the shape will actually be rendered with if the current layout pass is not /// a dry run, while the other properties are used to inform the layout engine how big /// or small the shape can be. The ``ViewSize/idealSize`` property should not vary with - /// the `proposal`, and should only depend on the view's contents. Pass `nil` for the + /// the `proposal`, and should only depend on the shape's contents. Pass `nil` for the /// maximum width/height if the shape has no maximum size (and therefore may occupy /// the entire screen). func size(fitting proposal: SIMD2) -> ViewSize @@ -24,8 +56,8 @@ extension Shape { return ViewSize( size: proposal, idealSize: SIMD2(x: 10, y: 10), - minimumWidth: 1, - minimumHeight: 1, + minimumWidth: 0, + minimumHeight: 0, maximumWidth: nil, maximumHeight: nil ) diff --git a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift index fef67bfd..8232007a 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift @@ -1,3 +1,4 @@ +/// A shape that has style information attached to it, including color and stroke style. public protocol StyledShape: Shape { var strokeColor: Color? { get } var fillColor: Color? { get } From 62032eb790e141eb6c987375e38672376bd08e2a Mon Sep 17 00:00:00 2001 From: William Baker Date: Sun, 27 Apr 2025 10:19:00 -0400 Subject: [PATCH 14/16] Address some PR comments --- Sources/SwiftCrossUI/Path.swift | 4 +- Sources/SwiftCrossUI/Views/Shapes/Shape.swift | 6 +-- Sources/UIKitBackend/UIColor+Color.swift | 6 ++- Sources/UIKitBackend/UIKitBackend+Path.swift | 40 ++++++++++--------- Sources/WinUIBackend/WinUIBackend.swift | 26 ++++++------ 5 files changed, 44 insertions(+), 38 deletions(-) diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index ac775d48..4a3b1220 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -299,8 +299,8 @@ public struct Path { /// - endAngle: The angle of the end of the arc, measured in radians clockwise from right. /// Must be between 0 and 2pi (inclusive). /// - clockwise: `true` if the arc is to be drawn clockwise, `false` if the arc is to - /// be drawn counter-clockwise. Used to determine whether to draw the larger arc or - /// the smaller arc identified by the given start and end angles. + /// be drawn counter-clockwise. Used to determine which of the two possible arcs to + /// draw between the given start and end angles. public consuming func addArc( center: SIMD2, radius: Double, diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index 42f29256..7588a48e 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -2,8 +2,7 @@ /// /// If no stroke color or fill color is specified, the default is no stroke and a fill of the /// current foreground color. -public protocol Shape: View -where Content == EmptyView { +public protocol Shape: View where Content == EmptyView { /// Draw the path for this shape. /// /// The bounds passed to a shape that is immediately drawn as a view will always have an @@ -44,8 +43,7 @@ where Content == EmptyView { /// a dry run, while the other properties are used to inform the layout engine how big /// or small the shape can be. The ``ViewSize/idealSize`` property should not vary with /// the `proposal`, and should only depend on the shape's contents. Pass `nil` for the - /// maximum width/height if the shape has no maximum size (and therefore may occupy - /// the entire screen). + /// maximum width/height if the shape has no maximum size. func size(fitting proposal: SIMD2) -> ViewSize } diff --git a/Sources/UIKitBackend/UIColor+Color.swift b/Sources/UIKitBackend/UIColor+Color.swift index 921fe87a..6d7d468c 100644 --- a/Sources/UIKitBackend/UIColor+Color.swift +++ b/Sources/UIKitBackend/UIColor+Color.swift @@ -30,6 +30,10 @@ extension Color { var cgColor: CGColor { CGColor( - red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: CGFloat(alpha)) + red: CGFloat(red), + green: CGFloat(green), + blue: CGFloat(blue), + alpha: CGFloat(alpha) + ) } } diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index 0ff47f71..bc8997ca 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -129,25 +129,29 @@ extension UIKitBackend { shapeLayer.miterLimit = path.miterLimit shapeLayer.fillRule = path.usesEvenOddFillRule ? .evenOdd : .nonZero - shapeLayer.lineJoin = - switch path.lineJoinStyle { - case .miter: - .miter - case .round: - .round - case .bevel: - .bevel - } + switch path.lineJoinStyle { + case .miter: + shapeLayer.lineJoin = .miter + case .round: + shapeLayer.lineJoin = .round + case .bevel: + shapeLayer.lineJoin = .bevel + @unknown default: + print("Warning: unrecognized lineJoinStyle \(path.lineJoinStyle)") + shapeLayer.lineJoin = .miter + } - shapeLayer.lineCap = - switch path.lineCapStyle { - case .butt: - .butt - case .round: - .round - case .square: - .square - } + switch path.lineCapStyle { + case .butt: + shapeLayer.lineCap = .butt + case .round: + shapeLayer.lineCap = .round + case .square: + shapeLayer.lineCap = .square + @unknown default: + print("Warning: unrecognized lineCapStyle \(path.lineCapStyle)") + shapeLayer.lineCap = .butt + } shapeLayer.strokeColor = strokeColor.cgColor shapeLayer.fillColor = fillColor.cgColor diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index a076140f..e1666fda 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -1191,26 +1191,26 @@ public final class WinUIBackend: AppBackend { _ collection: WinUI.GeometryCollection, lastPoint: Point ) -> PathFigure { - var pathGeo: PathGeometry + var pathGeometry: PathGeometry if collection.size > 0, let castedLast = collection.getAt(collection.size - 1) as? PathGeometry { - pathGeo = castedLast + pathGeometry = castedLast } else { - pathGeo = PathGeometry() - collection.append(pathGeo) + pathGeometry = PathGeometry() + collection.append(pathGeometry) } var figure: PathFigure - if pathGeo.figures.size > 0 { + if pathGeometry.figures.size > 0 { // Note: the if check and force-unwrap is necessary. You can't do an `if let` // here because PathFigureCollection uses unsigned integers for its indices so // `size - 1` would underflow (causing a fatalError) if it's empty. - figure = pathGeo.figures.getAt(pathGeo.figures.size - 1)! + figure = pathGeometry.figures.getAt(pathGeometry.figures.size - 1)! } else { figure = PathFigure() figure.startPoint = lastPoint - pathGeo.figures.append(figure) + pathGeometry.figures.append(figure) } return figure @@ -1226,14 +1226,14 @@ public final class WinUIBackend: AppBackend { lastPoint = Point(x: Float(point.x), y: Float(point.y)) if geometry.size > 0, - let pathGeo = geometry.getAt(geometry.size - 1) as? PathGeometry, - pathGeo.figures.size > 0 + let pathGeometry = geometry.getAt(geometry.size - 1) as? PathGeometry, + pathGeometry.figures.size > 0 { - let figure = pathGeo.figures.getAt(pathGeo.figures.size - 1)! + let figure = pathGeometry.figures.getAt(pathGeometry.figures.size - 1)! if figure.segments.size > 0 { let newFigure = PathFigure() newFigure.startPoint = lastPoint - pathGeo.figures.append(newFigure) + pathGeometry.figures.append(newFigure) } else { figure.startPoint = lastPoint } @@ -1362,8 +1362,8 @@ public final class WinUIBackend: AppBackend { } if geometry.size > 0, - let pathGeo = geometry.getAt(geometry.size - 1) as? PathGeometry, - pathGeo.figures.contains(where: { ($0?.segments.size ?? 0) > 0 }) + let pathGeometry = geometry.getAt(geometry.size - 1) as? PathGeometry, + pathGeometry.figures.contains(where: { ($0?.segments.size ?? 0) > 0 }) { // Start a new PathGeometry so that transforms don't apply going forward geometry.append(PathGeometry()) From 1a0846b73ab4e904a491984d1428d3eb058dedb6 Mon Sep 17 00:00:00 2001 From: William Baker Date: Mon, 28 Apr 2025 08:07:23 -0400 Subject: [PATCH 15/16] Make Path.if rethrows --- Sources/SwiftCrossUI/Path.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftCrossUI/Path.swift b/Sources/SwiftCrossUI/Path.swift index 4a3b1220..f4a8f02d 100644 --- a/Sources/SwiftCrossUI/Path.swift +++ b/Sources/SwiftCrossUI/Path.swift @@ -361,13 +361,13 @@ extension Path { @inlinable public consuming func `if`( _ condition: Bool, - then ifTrue: (consuming Path) -> Path, - else ifFalse: (consuming Path) -> Path = { $0 } - ) -> Path { + then ifTrue: (consuming Path) throws -> Path, + else ifFalse: (consuming Path) throws -> Path = { $0 } + ) rethrows -> Path { if condition { - ifTrue(self) + try ifTrue(self) } else { - ifFalse(self) + try ifFalse(self) } } } From f4570f3689f4745c30578f931c3718676ba21599 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Tue, 29 Apr 2025 21:05:07 -0400 Subject: [PATCH 16/16] Various fixes --- .github/workflows/swift-macos.yml | 3 ++- .github/workflows/swift-uikit.yml | 1 + .gitignore | 2 +- Examples/Bundler.toml | 5 +++++ Examples/Sources/PathsExample/PathsApp.swift | 4 +--- Sources/AppKitBackend/AppKitBackend.swift | 4 ++-- Sources/UIKitBackend/UIKitBackend+Path.swift | 2 +- 7 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml index c65a5e45..0defc319 100644 --- a/.github/workflows/swift-macos.yml +++ b/.github/workflows/swift-macos.yml @@ -35,6 +35,7 @@ jobs: swift build --target StressTestExample && \ swift build --target SpreadsheetExample && \ swift build --target NotesExample && \ - swift build --target GtkExample + swift build --target GtkExample && \ + swift build --target PathsExample - name: Test run: swift test --test-product swift-cross-uiPackageTests diff --git a/.github/workflows/swift-uikit.yml b/.github/workflows/swift-uikit.yml index e17d48e9..31e39ccf 100644 --- a/.github/workflows/swift-uikit.yml +++ b/.github/workflows/swift-uikit.yml @@ -46,6 +46,7 @@ jobs: buildtarget NavigationExample buildtarget StressTestExample buildtarget NotesExample + buildtarget PathsExample if [ $devicetype != TV ]; then # Slider is not implemented for tvOS diff --git a/.gitignore b/.gitignore index bf47d5b2..be272b02 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,6 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata /.vscode *.pyc -/.swiftpm +.swiftpm vcpkg_installed/ *.trace diff --git a/Examples/Bundler.toml b/Examples/Bundler.toml index 2b313e3d..b6d900ed 100644 --- a/Examples/Bundler.toml +++ b/Examples/Bundler.toml @@ -49,3 +49,8 @@ version = '0.1.0' identifier = 'dev.swiftcrossui.NotesExample' product = 'NotesExample' version = '0.1.0' + +[apps.PathsExample] +identifier = 'dev.swiftcrossui.PathsExample' +product = 'PathsExample' +version = '0.1.0' diff --git a/Examples/Sources/PathsExample/PathsApp.swift b/Examples/Sources/PathsExample/PathsApp.swift index 8064f37f..1d11425c 100644 --- a/Examples/Sources/PathsExample/PathsApp.swift +++ b/Examples/Sources/PathsExample/PathsApp.swift @@ -37,6 +37,7 @@ struct ArcShape: StyledShape { } } +@main struct PathsApp: App { var body: some Scene { WindowGroup("PathsApp") { @@ -120,6 +121,3 @@ struct PathsApp: App { } } } - -// Even though this file isn't called main.swift, `@main` isn't allowed and this is -PathsApp.main() diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index b8928f81..018b4b70 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1275,8 +1275,8 @@ public final class AppKitBackend: AppBackend { path.appendArc( withCenter: NSPoint(x: center.x, y: center.y), radius: CGFloat(radius), - startAngle: CGFloat(startAngle), - endAngle: CGFloat(endAngle), + startAngle: CGFloat(startAngle * 180.0 / .pi), + endAngle: CGFloat(endAngle * 180.0 / .pi), clockwise: clockwise ) case .transform(let transform): diff --git a/Sources/UIKitBackend/UIKitBackend+Path.swift b/Sources/UIKitBackend/UIKitBackend+Path.swift index bc8997ca..fb61c494 100644 --- a/Sources/UIKitBackend/UIKitBackend+Path.swift +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -15,7 +15,7 @@ extension UIKitBackend { public typealias Path = UIBezierPath public func createPathWidget() -> any WidgetProtocol { - BaseViewWidget() + PathWidget() } public func createPath() -> UIBezierPath {