diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml index c65a5e457e..0defc319d5 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 e17d48e9ba..31e39ccffe 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 bf47d5b286..be272b0238 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 2b313e3de3..b6d900edc4 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/Package.resolved b/Examples/Package.resolved index 4075ca6801..7e685c1471 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 f36ceb4e91..744cab1c7d 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 0000000000..1d11425ca4 --- /dev/null +++ b/Examples/Sources/PathsExample/PathsApp.swift @@ -0,0 +1,123 @@ +import DefaultBackend +import Foundation // for sin, cos +import SwiftCrossUI + +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 { + Path() + .addArc( + center: bounds.center, + radius: min(bounds.width, bounds.height) / 2.0 - 2.5, + 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 + ) + } +} + +@main +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() + } + } + } +} diff --git a/Package.resolved b/Package.resolved index e6482ec185..0b88c424e0 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 7eb121501b..34160e8713 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/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 84b1368791..018b4b7032 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,178 @@ 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() + applyActions(source.actions, to: path) + } + } + + 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), + 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 * 180.0 / .pi), + endAngle: CGFloat(endAngle * 180.0 / .pi), + 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) + } + } + } + + 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/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 4ed614d289..3d8c78bacc 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 diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 384cc2ceaf..85519ab214 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/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 4e1665004e..3ee795e8fe 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,34 @@ public protocol AppBackend { gesture: TapGesture, action: @escaping () -> Void ) + + // 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:)`` + /// 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. 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. + func renderPath( + _ path: Path, + container: Widget, + strokeColor: Color, + fillColor: Color, + overrideStrokeStyle: StrokeStyle? + ) } extension AppBackend { @@ -843,4 +872,24 @@ extension AppBackend { ) { todo() } + + // MARK: Paths + public func createPathWidget() -> Widget { + todo() + } + public func createPath() -> Path { + todo() + } + public func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + todo() + } + public 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 new file mode 100644 index 0000000000..f4a8f02d54 --- /dev/null +++ b/Sources/SwiftCrossUI/Path.swift @@ -0,0 +1,373 @@ +import Foundation // for sin and cos + +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: Double) + /// Corners are rounded. + case round + /// Corners are beveled. + case bevel +} + +public struct StrokeStyle { + 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. +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 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: + /// ``` + /// [ 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 + + public init(linearTransform: SIMD4, translation: SIMD2) { + self.linearTransform = linearTransform + self.translation = translation + } + + 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 scaling(by factor: Double) -> AffineTransform { + AffineTransform( + linearTransform: SIMD4(x: factor, y: 0.0, z: 0.0, w: factor), + translation: .zero + ) + } + + public static func rotation(radians: Double, center: SIMD2) -> AffineTransform { + 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 + ) + ) + } + + 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 { + /// A rectangle in 2-D space. + /// + /// This type is inspired by `CGRect`. + 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: Double { origin.x } + public var y: Double { origin.y } + public var width: Double { size.x } + 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) + 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: Double) + case arc( + center: SIMD2, + radius: Double, + startAngle: Double, + endAngle: Double, + clockwise: Bool + ) + case transform(AffineTransform) + case subpath([Action]) + } + + /// 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(width: 1.0) + + 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 + ) -> Path { + actions.append(.quadCurve(control: control, end: endPoint)) + 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, + 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: Double) -> Path { + actions.append(.circle(center: center, radius: radius)) + return self + } + + /// 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. + /// - 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 which of the two possible arcs to + /// draw between the given start and end angles. + public consuming func addArc( + center: SIMD2, + radius: Double, + startAngle: Double, + 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, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: clockwise + ) + ) + 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 + } + + /// 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 + } + + /// Set the fill rule for the path. + public consuming func fillRule(_ rule: FillRule) -> Path { + fillRule = rule + return self + } +} + +extension Path { + @inlinable + public consuming func `if`( + _ condition: Bool, + then ifTrue: (consuming Path) throws -> Path, + else ifFalse: (consuming Path) throws -> Path = { $0 } + ) rethrows -> Path { + if condition { + try ifTrue(self) + } else { + try ifFalse(self) + } + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift new file mode 100644 index 0000000000..0292313b59 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Capsule.swift @@ -0,0 +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 { + let radius = min(bounds.width, bounds.height) / 2.0 + return RoundedRectangle(cornerRadius: radius).path(in: bounds) + } +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift new file mode 100644 index 0000000000..f3b2e26405 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift @@ -0,0 +1,23 @@ +public struct Circle: Shape { + public init() {} + + public func path(in bounds: Path.Rect) -> Path { + Path() + .addCircle(center: bounds.center, radius: min(bounds.width, bounds.height) / 2.0) + } + + 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: 0, + minimumHeight: 0, + 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 0000000000..6aff86d9d2 --- /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/Rectangle.swift b/Sources/SwiftCrossUI/Views/Shapes/Rectangle.swift new file mode 100644 index 0000000000..65e813a721 --- /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/RoundedRectangle.swift b/Sources/SwiftCrossUI/Views/Shapes/RoundedRectangle.swift new file mode 100644 index 0000000000..a1225f0504 --- /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 not applicable. 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 new file mode 100644 index 0000000000..7588a48e86 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -0,0 +1,123 @@ +/// 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. + /// + /// 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 shape's contents. Pass `nil` for the + /// maximum width/height if the shape has no maximum size. + func size(fitting proposal: SIMD2) -> ViewSize +} + +extension Shape { + public var body: EmptyView { return EmptyView() } + + public func size(fitting proposal: SIMD2) -> ViewSize { + return ViewSize( + size: proposal, + idealSize: SIMD2(x: 10, y: 10), + minimumWidth: 0, + minimumHeight: 0, + maximumWidth: nil, + maximumHeight: nil + ) + } + + 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.createPathWidget() + 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: 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: .clear, + fillColor: environment.suggestedForegroundColor, + overrideStrokeStyle: nil + ) + } + + return ViewUpdateResult.leafView(size: size) + } +} + +final class ShapeStorage: ViewGraphNodeChildren { + let widgets: [AnyWidget] = [] + let erasedNodes: [ErasedViewGraphNode] = [] + var backendPath: Any! + var oldPath: Path? +} diff --git a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift new file mode 100644 index 0000000000..8232007a67 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift @@ -0,0 +1,87 @@ +/// 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 } + 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 f0577e143c..6d7d468c75 100644 --- a/Sources/UIKitBackend/UIColor+Color.swift +++ b/Sources/UIKitBackend/UIColor+Color.swift @@ -27,4 +27,13 @@ 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 new file mode 100644 index 0000000000..fb61c4941a --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+Path.swift @@ -0,0 +1,181 @@ +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 { + PathWidget() + } + + public func createPath() -> UIBezierPath { + UIBezierPath() + } + + func applyStrokeStyle(_ strokeStyle: StrokeStyle, to path: UIBezierPath) { + 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: UIBezierPath, _ source: SwiftCrossUI.Path, pointsChanged: Bool) { + path.usesEvenOddFillRule = (source.fillRule == .evenOdd) + + applyStrokeStyle(source.strokeStyle, to: path) + + 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 + ) + ) + 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) + } + } + } + + public func renderPath( + _ path: Path, + container: Widget, + strokeColor: Color, + fillColor: Color, + overrideStrokeStyle: StrokeStyle? + ) { + if let overrideStrokeStyle { + applyStrokeStyle(overrideStrokeStyle, to: path) + } + + let widget = container as! PathWidget + let shapeLayer = widget.shapeLayer + + shapeLayer.path = path.cgPath + shapeLayer.lineWidth = path.lineWidth + shapeLayer.miterLimit = path.miterLimit + shapeLayer.fillRule = path.usesEvenOddFillRule ? .evenOdd : .nonZero + + 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 + } + + 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 + } +} + +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) + ) + } +} diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 3aeddef045..e1666fda89 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,259 @@ 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 pathGeometry: PathGeometry + if collection.size > 0, + let castedLast = collection.getAt(collection.size - 1) as? PathGeometry + { + pathGeometry = castedLast + } else { + pathGeometry = PathGeometry() + collection.append(pathGeometry) + } + + var figure: PathFigure + 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 = pathGeometry.figures.getAt(pathGeometry.figures.size - 1)! + } else { + figure = PathFigure() + figure.startPoint = lastPoint + pathGeometry.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 pathGeometry = geometry.getAt(geometry.size - 1) as? PathGeometry, + pathGeometry.figures.size > 0 + { + let figure = pathGeometry.figures.getAt(pathGeometry.figures.size - 1)! + if figure.segments.size > 0 { + let newFigure = PathFigure() + newFigure.startPoint = lastPoint + pathGeometry.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 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)) + ) + defer { lastPoint = endPoint } + + 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 { + 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 + } + } + + if geometry.size > 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()) + } + 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 +1632,8 @@ public class CustomWindow: WinUI.Window { WinUI.Grid.setRow(child, 1) } } + +public final class GeometryGroupHolder { + var group = GeometryGroup() + var strokeStyle: StrokeStyle? +}