Skip to content

Add Path and Shape #123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
May 9, 2025
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Examples/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Examples/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,9 @@ let package = Package(
name: "NotesExample",
dependencies: exampleDependencies
),
.executableTarget(
name: "PathsExample",
dependencies: exampleDependencies
)
]
)
125 changes: 125 additions & 0 deletions Examples/Sources/PathsExample/PathsApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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<Int>) -> 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()
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
173 changes: 173 additions & 0 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
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)
}
}
}

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 {
Expand Down
1 change: 1 addition & 0 deletions Sources/Gtk3Backend/Gtk3Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading