diff --git a/Makefile b/Makefile index 15cd74aa4..8b701c683 100755 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -LINKER_FLAGS := $(shell pkg-config --libs gtk+-3.0) +LINKER_FLAGS := $(shell pkg-config --libs gtk+-3.0 gdk-3.0) C_FLAGS := $(shell pkg-config --cflags gtk+-3.0) SWIFT_LINKER_FLAGS ?= -Xlinker $(shell echo $(LINKER_FLAGS) | sed -e "s/ / -Xlinker /g" | sed -e "s/-Xlinker -Wl,-framework,/-Xlinker -framework -Xlinker /g") SWIFT_C_FLAGS ?= -Xcc $(shell echo $(C_FLAGS) | sed -e "s/ / -Xcc /g") diff --git a/Package.swift b/Package.swift index 09961ed97..3ea083aad 100644 --- a/Package.swift +++ b/Package.swift @@ -86,13 +86,22 @@ let package = Package( .brew(["gtk+3"]), ] ), + .systemLibrary( + name: "CGDK", + pkgConfig: "gdk-3.0", + providers: [ + .apt(["libgtk+-3.0", "gtk+-3.0"]), + // .yum(["gtk3-devel"]), + .brew(["gtk+3"]), + ] + ), .target( name: "TokamakGTKCHelpers", dependencies: ["CGTK"] ), .target( name: "TokamakGTK", - dependencies: ["TokamakCore", "CGTK", "TokamakGTKCHelpers", "CombineShim"] + dependencies: ["TokamakCore", "CGTK", "CGDK", "TokamakGTKCHelpers", "CombineShim"] ), .target( name: "TokamakGTKDemo", diff --git a/Sources/CGDK/CGDK-Bridging-Header.h b/Sources/CGDK/CGDK-Bridging-Header.h new file mode 100644 index 000000000..93cc80d39 --- /dev/null +++ b/Sources/CGDK/CGDK-Bridging-Header.h @@ -0,0 +1 @@ +#include diff --git a/Sources/CGDK/module.modulemap b/Sources/CGDK/module.modulemap new file mode 100644 index 000000000..529917f17 --- /dev/null +++ b/Sources/CGDK/module.modulemap @@ -0,0 +1,8 @@ +module CGDK { + header "./termios-Header.h" + header "./CGDK-Bridging-Header.h" + + link "gdk-3" + + export * +} diff --git a/Sources/CGDK/termios-Header.h b/Sources/CGDK/termios-Header.h new file mode 100644 index 000000000..497e40582 --- /dev/null +++ b/Sources/CGDK/termios-Header.h @@ -0,0 +1 @@ +#include \ No newline at end of file diff --git a/Sources/TokamakCore/Shapes/Path.swift b/Sources/TokamakCore/Shapes/Path.swift index 5357c4475..20010b6e6 100644 --- a/Sources/TokamakCore/Shapes/Path.swift +++ b/Sources/TokamakCore/Shapes/Path.swift @@ -25,6 +25,19 @@ import WASILibc /// The outline of a 2D shape. public struct Path: Equatable, LosslessStringConvertible { + public class _PathBox: Equatable { + var elements: [Element] = [] + public static func == (lhs: Path._PathBox, rhs: Path._PathBox) -> Bool { + lhs.elements == rhs.elements + } + + init() {} + + init(elements: [Element]) { + self.elements = elements + } + } + public var description: String { var pathString = [String]() for element in elements { @@ -51,7 +64,7 @@ public struct Path: Equatable, LosslessStringConvertible { indirect case roundedRect(FixedRoundedRect) indirect case stroked(StrokedPath) indirect case trimmed(TrimmedPath) -// case path(PathBox) + case path(_PathBox) } public enum Element: Equatable { @@ -64,15 +77,8 @@ public struct Path: Equatable, LosslessStringConvertible { public var storage: Storage public let sizing: _Sizing - public var elements: [Element] = [] - public var transform: CGAffineTransform = .identity - - public struct _SubPath: Equatable { - public let path: Path - public let transform: CGAffineTransform - } - public var subpaths: [_SubPath] = [] + public var elements: [Element] { storage.elements } public init() { storage = .empty @@ -139,6 +145,29 @@ public struct Path: Equatable, LosslessStringConvertible { case let .roundedRect(fixedRoundedRect): return fixedRoundedRect.rect case let .stroked(strokedPath): return strokedPath.path.boundingRect case let .trimmed(trimmedPath): return trimmedPath.path.boundingRect + case let .path(pathBox): + // Note: Copied from TokamakStaticHTML/Shapes/Path.swift + // Should the control points be included in the positions array? + let positions = pathBox.elements.compactMap { elem -> CGPoint? in + switch elem { + case let .move(to: pos): return pos + case let .line(to: pos): return pos + case let .curve(to: pos, control1: _, control2: _): return pos + case let .quadCurve(to: pos, control: _): return pos + case .closeSubpath: return nil + } + } + let xPos = positions.map(\.x).sorted(by: <) + let minX = xPos.first ?? 0 + let maxX = xPos.last ?? 0 + let yPos = positions.map(\.y).sorted(by: <) + let minY = yPos.first ?? 0 + let maxY = yPos.last ?? 0 + + return CGRect( + origin: CGPoint(x: minX, y: minY), + size: CGSize(width: maxX - minX, height: maxY - minY) + ) } } @@ -168,33 +197,255 @@ public enum RoundedCornerStyle: Hashable, Equatable { case continuous } +public extension Path.Storage { + var elements: [Path.Element] { + switch self { + case .empty: + return [] + case let .rect(rect): + return [ + .move(to: rect.origin), + .line(to: CGPoint(x: rect.size.width, y: 0).offset(by: rect.origin)), + .line(to: CGPoint(x: rect.size.width, y: rect.size.height).offset(by: rect.origin)), + .line(to: CGPoint(x: 0, y: rect.size.height).offset(by: rect.origin)), + .closeSubpath, + ] + + case let .ellipse(rect): + // Scale down from a circle of max(width, height) in order to limit + // precision loss. Scaling up from a unit circle also looked alright, + // but scaling down is likely a bit better. + let size = max(rect.size.width, rect.size.height) + guard size > 0 else { return [] } + let transform: CGAffineTransform + if rect.size.width > rect.size.height { + transform = CGAffineTransform( + scaleX: 1, + y: rect.size.height / rect.size.width + ) + } else if rect.size.height > rect.size.width { + transform = CGAffineTransform( + scaleX: rect.size.width / rect.size.height, + y: 1 + ) + } else { + transform = .identity + } + let elements = [ + [.move(to: CGPoint(x: size, y: size / 2))], + getArc( + center: CGPoint( + x: size / 2, + y: size / 2 + ), + radius: size / 2, + startAngle: Angle(radians: 0), + endAngle: Angle(radians: 2 * Double.pi), + clockwise: false + ), + [.closeSubpath], + ].flatMap { $0 } + return elements.map { + transform + .translatedBy(x: rect.origin.x, y: rect.origin.y) + .transform(element: $0) + } + + case let .roundedRect(roundedRect): + // A cornerSize of nil means that we are drawing a Capsule + // In other words the corner size should be half of the min + // of the size and width + let rect = roundedRect.rect + let cornerSize = roundedRect.cornerSize ?? + CGSize( + width: min(rect.size.width, rect.size.height) / 2, + height: min(rect.size.width, rect.size.height) / 2 + ) + let cornerStyle = roundedRect.style + switch cornerStyle { + case .continuous: + return [ + .move(to: CGPoint(x: rect.size.width, y: rect.size.height / 2).offset(by: rect.origin)), + .line( + to: CGPoint(x: rect.size.width, y: rect.size.height - cornerSize.height) + .offset(by: rect.origin) + ), + .quadCurve( + to: CGPoint(x: rect.size.width - cornerSize.width, y: rect.size.height) + .offset(by: rect.origin), + control: CGPoint(x: rect.size.width, y: rect.size.height) + .offset(by: rect.origin) + ), + .line(to: CGPoint(x: cornerSize.width, y: rect.size.height).offset(by: rect.origin)), + .quadCurve( + to: CGPoint(x: 0, y: rect.size.height - cornerSize.height) + .offset(by: rect.origin), + control: CGPoint(x: 0, y: rect.size.height) + .offset(by: rect.origin) + ), + .line(to: CGPoint(x: 0, y: cornerSize.height).offset(by: rect.origin)), + .quadCurve( + to: CGPoint(x: cornerSize.width, y: 0) + .offset(by: rect.origin), + control: CGPoint.zero + .offset(by: rect.origin) + ), + .line(to: CGPoint(x: rect.size.width - cornerSize.width, y: 0).offset(by: rect.origin)), + .quadCurve( + to: CGPoint(x: rect.size.width, y: cornerSize.height) + .offset(by: rect.origin), + control: CGPoint(x: rect.size.width, y: 0) + .offset(by: rect.origin) + ), + .closeSubpath, + ] + + case .circular: + // TODO: This currently only supports circular corners and not ellipsoidal... + // This could be implemented by transforming the elements returned by + // the getArc calls. + return + [ + [ + .move( + to: CGPoint(x: rect.size.width, y: rect.size.height / 2) + .offset(by: rect.origin) + ), + .line( + to: CGPoint(x: rect.size.width, y: rect.size.height - cornerSize.height) + .offset(by: rect.origin) + ), + ], + getArc( + center: CGPoint( + x: rect.size.width - cornerSize.width, + y: rect.size.height - cornerSize.height + ) + .offset(by: rect.origin), + radius: cornerSize.width, + startAngle: Angle(radians: 0), + endAngle: Angle(radians: Double.pi / 2), + clockwise: false + ), + [.line( + to: CGPoint(x: cornerSize.width, y: rect.size.height) + .offset(by: rect.origin) + )], + getArc( + center: CGPoint( + x: cornerSize.width, + y: rect.size.height - cornerSize.height + ) + .offset(by: rect.origin), + radius: cornerSize.width, + startAngle: Angle(radians: Double.pi / 2), + endAngle: Angle(radians: Double.pi), + clockwise: false + ), + [.line( + to: CGPoint(x: 0, y: cornerSize.height) + .offset(by: rect.origin) + )], + getArc( + center: CGPoint( + x: cornerSize.width, + y: cornerSize.height + ) + .offset(by: rect.origin), + radius: cornerSize.width, + startAngle: Angle(radians: Double.pi), + endAngle: Angle(radians: 3 * Double.pi / 2), + clockwise: false + ), + [.line( + to: CGPoint(x: rect.size.width - cornerSize.width, y: 0) + .offset(by: rect.origin) + )], + getArc( + center: CGPoint( + x: rect.size.width - cornerSize.width, + y: cornerSize.height + ) + .offset(by: rect.origin), + radius: cornerSize.width, + startAngle: Angle(radians: 3 * Double.pi / 2), + endAngle: Angle(radians: 2 * Double.pi), + clockwise: false + ), + [.closeSubpath], + ].flatMap { $0 } + } + + case let .stroked(stroked): + // TODO: This is not actually how stroking is implemented + return stroked.path.storage.elements + + case let .trimmed(trimmed): + // TODO: This is not actually how trimmingis implemented + return trimmed.path.storage.elements + + case let .path(box): + return box.elements + } + } +} + public extension Path { + private mutating func append(_ other: Storage, transform: CGAffineTransform = .identity) { + guard other != .empty else { return } + + // If self.storage is empty, replace with other storage. + // Otherwise append elements to current storage. + switch (storage, transform.isIdentity) { + case (.empty, true): + storage = other + + default: + append(other.elements, transform: transform) + } + } + + private mutating func append(_ elements: [Element], transform: CGAffineTransform = .identity) { + guard !elements.isEmpty else { return } + + let elements_: [Element] + if transform.isIdentity { + elements_ = elements + } else { + elements_ = elements.map { transform.transform(element: $0) } + } + + switch storage { + case let .path(pathBox): + pathBox.elements.append(contentsOf: elements_) + + default: + storage = .path(_PathBox(elements: storage.elements + elements_)) + } + } + mutating func move(to p: CGPoint) { - elements.append(.move(to: p)) + append([.move(to: p)]) } mutating func addLine(to p: CGPoint) { - elements.append(.line(to: p)) + append([.line(to: p)]) } mutating func addQuadCurve(to p: CGPoint, control cp: CGPoint) { - elements.append(.quadCurve(to: p, control: cp)) + append([.quadCurve(to: p, control: cp)]) } mutating func addCurve(to p: CGPoint, control1 cp1: CGPoint, control2 cp2: CGPoint) { - elements.append(.curve(to: p, control1: cp1, control2: cp2)) + append([.curve(to: p, control1: cp1, control2: cp2)]) } mutating func closeSubpath() { - elements.append(.closeSubpath) + append([.closeSubpath]) } mutating func addRect(_ rect: CGRect, transform: CGAffineTransform = .identity) { - move(to: rect.origin) - addLine(to: CGPoint(x: rect.size.width, y: 0).offset(by: rect.origin)) - addLine(to: CGPoint(x: rect.size.width, y: rect.size.height).offset(by: rect.origin)) - addLine(to: CGPoint(x: 0, y: rect.size.height).offset(by: rect.origin)) - closeSubpath() + append(.rect(rect), transform: transform) } mutating func addRoundedRect( @@ -203,53 +454,18 @@ public extension Path { style: RoundedCornerStyle = .circular, transform: CGAffineTransform = .identity ) { - move(to: CGPoint(x: rect.size.width, y: rect.size.height / 2).offset(by: rect.origin)) - addLine( - to: CGPoint(x: rect.size.width, y: rect.size.height - cornerSize.height) - .offset(by: rect.origin) - ) - addQuadCurve( - to: CGPoint(x: rect.size.width - cornerSize.width, y: rect.size.height) - .offset(by: rect.origin), - control: CGPoint(x: rect.size.width, y: rect.size.height) - .offset(by: rect.origin) - ) - addLine(to: CGPoint(x: cornerSize.width, y: rect.size.height).offset(by: rect.origin)) - addQuadCurve( - to: CGPoint(x: 0, y: rect.size.height - cornerSize.height) - .offset(by: rect.origin), - control: CGPoint(x: 0, y: rect.size.height) - .offset(by: rect.origin) - ) - addLine(to: CGPoint(x: 0, y: cornerSize.height).offset(by: rect.origin)) - addQuadCurve( - to: CGPoint(x: cornerSize.width, y: 0) - .offset(by: rect.origin), - control: CGPoint.zero - .offset(by: rect.origin) - ) - addLine(to: CGPoint(x: rect.size.width - cornerSize.width, y: 0).offset(by: rect.origin)) - addQuadCurve( - to: CGPoint(x: rect.size.width, y: cornerSize.height) - .offset(by: rect.origin), - control: CGPoint(x: rect.size.width, y: 0) - .offset(by: rect.origin) + append( + .roundedRect(FixedRoundedRect(rect: rect, cornerSize: cornerSize, style: style)), + transform: transform ) - closeSubpath() } mutating func addEllipse(in rect: CGRect, transform: CGAffineTransform = .identity) { - subpaths.append(.init( - path: .init(ellipseIn: .init( - origin: rect.origin.offset(by: .init(x: rect.size.width / 2, y: rect.size.height / 2)), - size: .init(width: rect.size.width / 2, height: rect.size.height / 2) - )), - transform: transform - )) + append(.ellipse(rect), transform: transform) } mutating func addRects(_ rects: [CGRect], transform: CGAffineTransform = .identity) { - rects.forEach { addRect($0) } + rects.forEach { addRect($0, transform: transform) } } mutating func addLines(_ lines: [CGPoint]) { @@ -268,7 +484,8 @@ public extension Path { radius: radius, startAngle: startAngle, endAngle: startAngle + delta, - clockwise: false + clockwise: false, + transform: transform ) } @@ -283,57 +500,14 @@ public extension Path { clockwise: Bool, transform: CGAffineTransform = .identity ) { - if clockwise { - addArc( - center: center, - radius: radius, - startAngle: endAngle, - endAngle: endAngle + (.radians(.pi * 2) - endAngle) + startAngle, - clockwise: false - ) - } else { - let angle = abs(startAngle.radians - endAngle.radians) - if angle > .pi / 2 { - // Split the angle into 90º chunks - let chunk1 = Angle.radians(startAngle.radians + (.pi / 2)) - addArc( - center: center, - radius: radius, - startAngle: startAngle, - endAngle: chunk1, - clockwise: clockwise - ) - addArc( - center: center, - radius: radius, - startAngle: chunk1, - endAngle: endAngle, - clockwise: clockwise - ) - } else { - let startPoint = CGPoint( - x: radius + center.x, - y: center.y - ) - let endPoint = CGPoint( - x: (radius * cos(angle)) + center.x, - y: (radius * sin(angle)) + center.y - ) - let l = (4 / 3) * tan(angle / 4) - let c1 = CGPoint(x: radius + center.x, y: (l * radius) + center.y) - let c2 = CGPoint( - x: ((cos(angle) + l * sin(angle)) * radius) + center.x, - y: ((sin(angle) - l * cos(angle)) * radius) + center.y - ) - - move(to: startPoint.rotate(startAngle, around: center)) - addCurve( - to: endPoint.rotate(startAngle, around: center), - control1: c1.rotate(startAngle, around: center), - control2: c2.rotate(startAngle, around: center) - ) - } - } + let arc = getArc( + center: center, + radius: radius, + startAngle: endAngle, + endAngle: endAngle + (.radians(.pi * 2) - endAngle) + startAngle, + clockwise: false + ) + append(arc, transform: transform) } // FIXME: How does this arc method work? @@ -345,7 +519,7 @@ public extension Path { ) {} mutating func addPath(_ path: Path, transform: CGAffineTransform = .identity) { - subpaths.append(.init(path: path, transform: transform)) + append(path.storage, transform: transform) } var currentPoint: CGPoint? { @@ -359,13 +533,14 @@ public extension Path { } func applying(_ transform: CGAffineTransform) -> Path { - var path = self - path.transform = transform - return path + guard transform != .identity else { return self } + let elements = self.elements.map { transform.transform(element: $0) } + let box = _PathBox(elements: elements) + return Path(storage: .path(box), sizing: .fixed) } func offsetBy(dx: CGFloat, dy: CGFloat) -> Path { - applying(transform.translatedBy(x: dx, y: dy)) + applying(.init(translationX: dx, y: dy)) } } @@ -374,3 +549,85 @@ extension Path: Shape { self } } + +public extension CGAffineTransform { + func transform(element: Path.Element) -> Path.Element { + switch element { + case let .move(to: p): + return .move(to: transform(point: p)) + + case let .line(to: p): + return .line(to: transform(point: p)) + + case let .curve(to: p, control1: c1, control2: c2): + return .curve( + to: transform(point: p), + control1: transform(point: c1), + control2: transform(point: c2) + ) + + case let .quadCurve(to: p, control: c): + return .quadCurve(to: transform(point: p), control: transform(point: c)) + + case .closeSubpath: + return element + } + } +} + +private func getArc( + center: CGPoint, + radius: CGFloat, + startAngle: Angle, + endAngle: Angle, + clockwise: Bool +) -> [Path.Element] { + if clockwise { + return getArc( + center: center, + radius: radius, + startAngle: endAngle, + endAngle: endAngle + (.radians(.pi * 2) - endAngle) + startAngle, + clockwise: false + ) + } else { + let angle = abs(startAngle.radians - endAngle.radians) + if angle > .pi / 2 { + // Split the angle into 90º chunks + let chunk1 = Angle.radians(startAngle.radians + (.pi / 2)) + return getArc( + center: center, + radius: radius, + startAngle: startAngle, + endAngle: chunk1, + clockwise: clockwise + ) + + getArc( + center: center, + radius: radius, + startAngle: chunk1, + endAngle: endAngle, + clockwise: clockwise + ) + } else { + let endPoint = CGPoint( + x: (radius * cos(angle)) + center.x, + y: (radius * sin(angle)) + center.y + ) + let l = (4 / 3) * tan(angle / 4) + let c1 = CGPoint(x: radius + center.x, y: (l * radius) + center.y) + let c2 = CGPoint( + x: ((cos(angle) + l * sin(angle)) * radius) + center.x, + y: ((sin(angle) - l * cos(angle)) * radius) + center.y + ) + + return [ + .curve( + to: endPoint.rotate(startAngle, around: center), + control1: c1.rotate(startAngle, around: center), + control2: c2.rotate(startAngle, around: center) + ), + ] + } + } +} diff --git a/Sources/TokamakGTK/GSignal.swift b/Sources/TokamakGTK/GSignal.swift index 318c470bc..7e6b7cf2a 100644 --- a/Sources/TokamakGTK/GSignal.swift +++ b/Sources/TokamakGTK/GSignal.swift @@ -38,6 +38,30 @@ extension UnsafeMutablePointer where Pointee == GtkWidget { )) } + /// Connect with a c function pointer, but with an extra opaque pointer. + @discardableResult + func connect( + signal: UnsafePointer, + data: gpointer? = nil, + handler: @convention(c) @escaping ( + UnsafeMutablePointer?, + OpaquePointer, + UnsafeRawPointer + ) -> Bool, + destroy: @convention(c) @escaping (UnsafeRawPointer, UnsafeRawPointer) -> () + ) -> Int { + let handler = unsafeBitCast(handler, to: GCallback.self) + let destroy = unsafeBitCast(destroy, to: GClosureNotify.self) + return Int(g_signal_connect_data( + self, + signal, + handler, + data, + destroy, + GConnectFlags(rawValue: 0) + )) + } + /// Connect with a context-capturing closure. @discardableResult func connect( @@ -76,6 +100,36 @@ extension UnsafeMutablePointer where Pointee == GtkWidget { }) } + /// Connect with a context-capturing closure (with the GtkWidget and an + /// OpaquePointer passed through) + @discardableResult + func connect( + signal: UnsafePointer, + closure: @escaping (UnsafeMutablePointer?, OpaquePointer) -> () + ) -> Int { + let closureBox = Unmanaged.passRetained(DualParamClosureBox(closure)).retain().toOpaque() + return connect(signal: signal, data: closureBox, handler: { widget, context, closureBox in + let unpackedAction = Unmanaged?, + OpaquePointer, + () + >> + .fromOpaque(closureBox) + if let widget = widget { + unpackedAction.takeUnretainedValue().closure(widget, context) + } + return true + }, destroy: { closureBox, _ in + let unpackedAction = Unmanaged?, + OpaquePointer, + () + >> + .fromOpaque(closureBox) + unpackedAction.release() + }) + } + func disconnect( gtype: GType, signal: UnsafePointer @@ -100,3 +154,9 @@ final class SingleParamClosureBox { init(_ closure: @escaping (T) -> U) { self.closure = closure } } + +final class DualParamClosureBox { + let closure: (T, U) -> V + + init(_ closure: @escaping (T, U) -> V) { self.closure = closure } +} diff --git a/Sources/TokamakGTK/Modifiers/WidgetModifier.swift b/Sources/TokamakGTK/Modifiers/WidgetModifier.swift index 68de1e0f2..fbd51f1cb 100644 --- a/Sources/TokamakGTK/Modifiers/WidgetModifier.swift +++ b/Sources/TokamakGTK/Modifiers/WidgetModifier.swift @@ -31,16 +31,20 @@ extension WidgetAttributeModifier { let context = gtk_widget_get_style_context(widget) let provider = gtk_css_provider_new() - let renderedStyle = attributes.reduce("", { $0 + "\($1.0):\($1.1);"}) + let renderedStyle = attributes.reduce("") { $0 + "\($1.0):\($1.1);" } - gtk_css_provider_load_from_data(provider, - "* { \(renderedStyle) }", - -1, - nil) + gtk_css_provider_load_from_data( + provider, + "* { \(renderedStyle) }", + -1, + nil + ) - gtk_style_context_add_provider(context, - OpaquePointer(provider), - 1 /* GTK_STYLE_PROVIDER_PRIORITY_FALLBACK */) + gtk_style_context_add_provider( + context, + OpaquePointer(provider), + 1 /* GTK_STYLE_PROVIDER_PRIORITY_FALLBACK */ + ) g_object_unref(provider) } @@ -54,8 +58,8 @@ extension ModifiedContent: ViewDeferredToRenderer where Content: View { let anyWidget: AnyWidget if let anyView = content as? ViewDeferredToRenderer, let _anyWidget = mapAnyView( - anyView.deferredBody, - transform: { (widget: AnyWidget) in widget } + anyView.deferredBody, + transform: { (widget: AnyWidget) in widget } ) { anyWidget = _anyWidget @@ -64,28 +68,60 @@ extension ModifiedContent: ViewDeferredToRenderer where Content: View { } else { return AnyView(content) } - return AnyView(WidgetView { + + let build: (UnsafeMutablePointer) -> UnsafeMutablePointer = { let contentWidget = anyWidget.new($0) widgetModifier.modify(widget: contentWidget) return contentWidget } - update: { widget in + + let update: (Widget) -> () = { widget in anyWidget.update(widget: widget) // Is it correct to apply the modifier again after updating? // I assume so since the modifier parameters may have changed. - if case .widget(let w) = widget.storage { + if case let .widget(w) = widget.storage { widgetModifier.modify(widget: w) } } - content: { - if let parentView = anyWidget as? ParentView, parentView.children.count > 1 { - ForEach(Array(parentView.children.enumerated()), id: \.offset) { _, view in - view - } - } else if let parentView = anyWidget as? ParentView, parentView.children.count == 1 { - parentView.children[0] - } - }) + + // All this could be done using a single result builder for the content parameter, + // but since we are already wrapping in an AnyView, there's no reason to also + // wrap the contents in the inferred _ConditionalContent wrappers too. + // So instead, the conditional logic is moved out of the result builder world. + // This gives slightly lighter View type hierarchies. + if let parentView = anyWidget as? ParentView, parentView.children.count > 1 { + return AnyView( + WidgetView( + build: build, + update: update, + content: { + ForEach(Array(parentView.children.enumerated()), id: \.offset) { _, view in + view + } + } + ) + ) + } else if let parentView = anyWidget as? ParentView, parentView.children.count == 1 { + return AnyView( + WidgetView( + build: build, + update: update, + content: { + parentView.children[0] + } + ) + ) + } else { + return AnyView( + WidgetView( + build: build, + update: update, + content: { + EmptyView() + } + ) + ) + } } } diff --git a/Sources/TokamakGTK/Shapes/Shape.swift b/Sources/TokamakGTK/Shapes/Shape.swift new file mode 100644 index 000000000..42b8cf582 --- /dev/null +++ b/Sources/TokamakGTK/Shapes/Shape.swift @@ -0,0 +1,170 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Morten Bek Ditlevsen on 29/12/2020. +// + +import CGDK +import CGTK +import TokamakCore + +func createPath(from elements: [Path.Element], in cr: OpaquePointer) { + var current: CGPoint = .zero + var start: CGPoint = .zero + for element in elements { + switch element { + case let .move(to: p): + cairo_move_to(cr, p.x, p.y) + current = p + start = p + + case let .line(to: p): + cairo_line_to(cr, p.x, p.y) + current = p + + case .closeSubpath: + cairo_close_path(cr) + current = start + + case let .curve(to: p, control1: c1, control2: c2): + cairo_curve_to(cr, c1.x, c1.y, c2.x, c2.y, p.x, p.y) + current = p + + case let .quadCurve(to: p, control: c): + let c1 = CGPoint(x: (current.x + 2 * c.x) / 3, y: (current.y + 2 * c.y) / 3) + let c2 = CGPoint(x: (p.x + 2 * c.x) / 3, y: (p.y + 2 * c.y) / 3) + cairo_curve_to(cr, c1.x, c1.y, c2.x, c2.y, p.x, p.y) + current = p + } + } +} + +extension _ShapeView: ViewDeferredToRenderer { + public var deferredBody: AnyView { + AnyView(WidgetView(build: { _ in + let w = gtk_drawing_area_new() + bindAction(to: w!) + return w! + }) {}) + } + + func bindAction(to drawingArea: UnsafeMutablePointer) { + drawingArea.connect(signal: "draw", closure: { widget, cr in + cairo_save(cr) + + let width = gtk_widget_get_allocated_width(widget) + let height = gtk_widget_get_allocated_height(widget) + + let c = (style as? Color) ?? foregroundColor ?? Color.black + + var color = c.resolveToCairo(in: environment) + + gdk_cairo_set_source_rgba(cr, &color) + + let path = shape.path(in: CGRect( + origin: .zero, + size: CGSize( + width: Double(width), + height: Double(height) + ) + )) + let elements: [Path.Element] + let stroke: Bool + if case let .stroked(strokedPath) = path.storage { + elements = strokedPath.path.elements + stroke = true + let style = strokedPath.style + + cairo_set_line_width(cr, style.lineWidth) + cairo_set_line_join(cr, style.lineJoin.cairo) + cairo_set_line_cap(cr, style.lineCap.cairo) + cairo_set_miter_limit(cr, style.miterLimit) + cairo_set_dash(cr, style.dash, Int32(style.dash.count), style.dashPhase) + + } else { + elements = path.elements + stroke = false + } + + cairo_set_fill_rule(cr, fillStyle.cairo) + + createPath(from: elements, in: cr) + + // It kind of appears to be ok to reset the clip (in order to draw outside the frame)... + // This could be error prone, however, and a source of future bugs... + cairo_reset_clip(cr) + + if stroke { + cairo_stroke(cr) + } else { + cairo_fill(cr) + } + + cairo_restore(cr) + }) + } +} + +extension CGLineJoin { + var cairo: cairo_line_join_t { + switch self { + case .miter: + return cairo_line_join_t(rawValue: 0) /* CAIRO_LINE_JOIN_MITER */ + case .round: + return cairo_line_join_t(rawValue: 1) /* CAIRO_LINE_JOIN_ROUND */ + case .bevel: + return cairo_line_join_t(rawValue: 2) /* CAIRO_LINE_JOIN_BEVEL */ + } + } +} + +extension CGLineCap { + var cairo: cairo_line_cap_t { + switch self { + case .butt: + return cairo_line_cap_t(rawValue: 0) /* CAIRO_LINE_CAP_BUTT */ + case .round: + return cairo_line_cap_t(rawValue: 1) /* CAIRO_LINE_CAP_ROUND */ + case .square: + return cairo_line_cap_t(rawValue: 2) /* CAIRO_LINE_CAP_SQUARE */ + } + } +} + +extension FillStyle { + var cairo: cairo_fill_rule_t { + if isEOFilled { + return cairo_fill_rule_t(rawValue: 1) /* CAIRO_FILL_RULE_EVEN_ODD */ + } else { + return cairo_fill_rule_t(rawValue: 0) /* CAIRO_FILL_RULE_WINDING */ + } + } +} + +extension AnyColorBox.ResolvedValue { + var cairo: GdkRGBA { + GdkRGBA( + red: Double(red), + green: Double(green), + blue: Double(blue), + alpha: Double(opacity) + ) + } +} + +extension Color { + func resolveToCairo(in environment: EnvironmentValues) -> GdkRGBA { + _ColorProxy(self).resolve(in: environment).cairo + } +} diff --git a/Sources/TokamakStaticHTML/Shapes/Path.swift b/Sources/TokamakStaticHTML/Shapes/Path.swift index b62eac453..bc9c9b9f1 100644 --- a/Sources/TokamakStaticHTML/Shapes/Path.swift +++ b/Sources/TokamakStaticHTML/Shapes/Path.swift @@ -88,6 +88,8 @@ extension Path: ViewDeferredToRenderer { storage: trimmed.path.storage, strokeStyle: strokeStyle ) // TODO: Trim the path + case .path: + return svgFrom(elements: elements, strokeStyle: strokeStyle) } } @@ -117,65 +119,13 @@ extension Path: ViewDeferredToRenderer { ])) } - func svgFrom( - subpaths: [_SubPath], - strokeStyle: StrokeStyle = .zero - ) -> AnyView { - AnyView(ForEach(Array(subpaths.enumerated()), id: \.offset) { _, path in - path.path.svgBody(strokeStyle: strokeStyle) - }) - } - - var storageSize: CGSize { - switch storage { - case .empty: - return .zero - case let .rect(rect), let .ellipse(rect): - return rect.size - case let .roundedRect(rect): - return rect.rect.size - case let .stroked(path): - return path.path.size - case let .trimmed(path): - return path.path.size - } - } - - var elementsSize: CGSize { - // Curves may clip without an explicit size - let positions = elements.compactMap { elem -> CGPoint? in - switch elem { - case let .move(to: pos): return pos - case let .line(to: pos): return pos - case let .curve(to: pos, control1: _, control2: _): return pos - case let .quadCurve(to: pos, control: _): return pos - case .closeSubpath: return nil - } - } - let xPos = positions.map(\.x).sorted(by: <) - let minX = xPos.first ?? 0 - let maxX = xPos.last ?? 0 - let yPos = positions.map(\.y).sorted(by: <) - let minY = yPos.first ?? 0 - let maxY = yPos.last ?? 0 - - return CGSize(width: abs(maxX - min(0, minX)), height: abs(maxY - min(0, minY))) - } - - var size: CGSize { - .init( - width: max(storageSize.width, elementsSize.width), - height: max(storageSize.height, elementsSize.height) - ) - } + var size: CGSize { boundingRect.size } @ViewBuilder func svgBody( strokeStyle: StrokeStyle = .zero ) -> some View { svgFrom(storage: storage, strokeStyle: strokeStyle) - svgFrom(elements: elements, strokeStyle: strokeStyle) - svgFrom(subpaths: subpaths, strokeStyle: strokeStyle) } public var deferredBody: AnyView {