From 2797197c1f82d6c73372b61ae555db186ed68a07 Mon Sep 17 00:00:00 2001 From: stackotter Date: Tue, 24 Jun 2025 15:05:09 +1000 Subject: [PATCH 1/3] Introduce 'inspect' modifiers for accessing underlying native widgets These changes make platform-specific native customizations significantly easier to perform. Hopefully this will make SwiftCrossUI significantly more viable for actual production apps that often just need to get things working even if there's not a nice neat first party API for it yet. Inspiration was taken from swiftui-introspect. --- .github/workflows/build-test-and-docs.yml | 14 +- Examples/Bundler.toml | 5 + Examples/Package.swift | 7 +- .../AdvancedCustomizationApp.swift | 198 ++++++++++++++++++ .../AdvancedCustomizationExample/Banner.png | Bin 0 -> 14960 bytes .../AppKitBackend/InspectionModifiers.swift | 107 ++++++++++ Sources/Gtk3/Pixbuf.swift | 6 +- Sources/Gtk3Backend/InspectionModifiers.swift | 132 ++++++++++++ Sources/GtkBackend/InspectionModifiers.swift | 141 +++++++++++++ .../SwiftCrossUI.docc/Examples.md | 1 + Sources/SwiftCrossUI/Views/Image.swift | 20 +- .../Views/Modifiers/InspectModifier.swift | 101 +++++++++ .../SwiftCrossUI/Views/NavigationLink.swift | 2 +- .../UIKitBackend/InspectionModifiers.swift | 155 ++++++++++++++ .../UIKitBackend/UIKitBackend+Container.swift | 2 +- .../WinUIBackend/InspectionModifiers.swift | 140 +++++++++++++ 16 files changed, 1014 insertions(+), 17 deletions(-) create mode 100644 Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift create mode 100644 Examples/Sources/AdvancedCustomizationExample/Banner.png create mode 100644 Sources/AppKitBackend/InspectionModifiers.swift create mode 100644 Sources/Gtk3Backend/InspectionModifiers.swift create mode 100644 Sources/GtkBackend/InspectionModifiers.swift create mode 100644 Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift create mode 100644 Sources/UIKitBackend/InspectionModifiers.swift create mode 100644 Sources/WinUIBackend/InspectionModifiers.swift diff --git a/.github/workflows/build-test-and-docs.yml b/.github/workflows/build-test-and-docs.yml index bc0d77b321..a61ad64dc1 100644 --- a/.github/workflows/build-test-and-docs.yml +++ b/.github/workflows/build-test-and-docs.yml @@ -41,6 +41,7 @@ jobs: cd Examples && \ swift build --target GtkBackend && \ swift build --target Gtk3Backend && \ + swift build --target GtkExample && \ swift build --target CounterExample && \ swift build --target ControlsExample && \ swift build --target RandomNumberGeneratorExample && \ @@ -51,8 +52,9 @@ jobs: swift build --target StressTestExample && \ swift build --target SpreadsheetExample && \ swift build --target NotesExample && \ - swift build --target GtkExample && \ - swift build --target PathsExample + swift build --target PathsExample && \ + swift build --target WebViewExample && \ + swift build --target AdvancedCustomizationExample - name: Test run: swift test --test-product swift-cross-uiPackageTests @@ -101,6 +103,8 @@ jobs: buildtarget StressTestExample buildtarget NotesExample buildtarget PathsExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample if [ $device_type != TV ]; then # Slider is not implemented for tvOS @@ -161,6 +165,8 @@ jobs: buildtarget PathsExample buildtarget ControlsExample buildtarget RandomNumberGeneratorExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample # TODO test whether this works on Catalyst # buildtarget SplitExample @@ -281,6 +287,7 @@ jobs: - name: Build examples working-directory: ./Examples run: | + swift build --target GtkExample && \ swift build --target CounterExample && \ swift build --target ControlsExample && \ swift build --target RandomNumberGeneratorExample && \ @@ -291,7 +298,8 @@ jobs: swift build --target StressTestExample && \ swift build --target SpreadsheetExample && \ swift build --target NotesExample && \ - swift build --target GtkExample + swift build --target PathsExample && \ + swift build --target AdvancedCustomizationExample - name: Test run: swift test --test-product swift-cross-uiPackageTests diff --git a/Examples/Bundler.toml b/Examples/Bundler.toml index 7d0f8864a9..4a226830e1 100644 --- a/Examples/Bundler.toml +++ b/Examples/Bundler.toml @@ -59,3 +59,8 @@ version = '0.1.0' identifier = 'dev.swiftcrossui.WebViewExample' product = 'WebViewExample' version = '0.1.0' + +[apps.AdvancedCustomizationExample] +identifier = 'dev.swiftcrossui.AdvancedCustomizationExample' +product = 'AdvancedCustomizationExample' +version = '0.1.0' diff --git a/Examples/Package.swift b/Examples/Package.swift index 11f210b821..fbfb9c28af 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 import Foundation import PackageDescription @@ -72,6 +72,11 @@ let package = Package( .executableTarget( name: "WebViewExample", dependencies: exampleDependencies + ), + .executableTarget( + name: "AdvancedCustomizationExample", + dependencies: exampleDependencies, + resources: [.copy("Banner.png")] ) ] ) diff --git a/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift new file mode 100644 index 0000000000..ff8054e317 --- /dev/null +++ b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift @@ -0,0 +1,198 @@ +import DefaultBackend +import Foundation +import SwiftCrossUI + +#if canImport(WinUIBackend) + import WinUI +#endif + +#if canImport(SwiftBundlerRuntime) + import SwiftBundlerRuntime +#endif + +@main +@HotReloadable +struct CounterApp: App { + @State var count = 0 + @State var value = 0.0 + @State var color: String? = nil + @State var name = "" + + var body: some Scene { + WindowGroup("CounterExample: \(count)") { + #hotReloadable { + ScrollView { + HStack(spacing: 20) { + Button("-") { + count -= 1 + } + + Text("Count: \(count)") + .inspect { text in + #if canImport(AppKitBackend) + text.isSelectable = true + #elseif canImport(UIKitBackend) + #if !targetEnvironment(macCatalyst) + text.isHighlighted = true + text.highlightTextColor = .yellow + #endif + #elseif canImport(WinUIBackend) + text.isTextSelectionEnabled = true + #elseif canImport(GtkBackend) + text.selectable = true + #elseif canImport(Gtk3Backend) + text.selectable = true + #endif + } + + Button("+") { + count += 1 + }.inspect(.afterUpdate) { button in + #if canImport(AppKitBackend) + // Button is an NSButton on macOS + button.bezelColor = .red + #elseif canImport(UIKitBackend) + if #available(iOS 15.0, *) { + button.configuration = .bordered() + } + #elseif canImport(WinUIBackend) + button.cornerRadius.topLeft = 10 + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 255, g: 0, b: 0) + button.background = brush + #elseif canImport(GtkBackend) + button.css.set(property: .backgroundColor(.init(1, 0, 0))) + #elseif canImport(Gtk3Backend) + button.css.set(property: .backgroundColor(.init(1, 0, 0))) + #endif + } + } + + Slider($value, minimum: 0, maximum: 10) + .inspect { slider in + #if canImport(AppKitBackend) + slider.numberOfTickMarks = 10 + #elseif canImport(UIKitBackend) + slider.thumbTintColor = .blue + #elseif canImport(WinUIBackend) + slider.isThumbToolTipEnabled = true + #elseif canImport(GtkBackend) + slider.drawValue = true + #elseif canImport(Gtk3Backend) + slider.drawValue = true + #endif + } + + #if !canImport(Gtk3Backend) + Picker(of: ["Red", "Green", "Blue"], selection: $color) + .inspect(.afterUpdate) { picker in + #if canImport(AppKitBackend) + picker.preferredEdge = .maxX + #elseif canImport(UIKitBackend) && os(iOS) + // Can't think of something to do to the + // UIPickerView, but the point is that you + // could do something if you needed to! + // This would be a UITableView on tvOS. + // And could be either a UITableView or a + // UIPickerView on Mac Catalyst depending + // on Mac Catalyst version and interface + // idiom. + #elseif canImport(WinUIBackend) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 255, g: 0, b: 0) + picker.background = brush + #elseif canImport(GtkBackend) + picker.enableSearch = true + #endif + } + #endif + + TextField("Name", text: $name) + .inspect(.afterUpdate) { textField in + #if canImport(AppKitBackend) + textField.backgroundColor = .blue + #elseif canImport(UIKitBackend) + textField.borderStyle = .bezel + #elseif canImport(WinUIBackend) + textField.selectionHighlightColor.color = .init(a: 255, r: 0, g: 255, b: 0) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 0, g: 0, b: 255) + textField.background = brush + #elseif canImport(GtkBackend) + textField.xalign = 1 + textField.css.set(property: .backgroundColor(.init(0, 0, 1))) + #elseif canImport(Gtk3Backend) + textField.hasFrame = false + textField.css.set(property: .backgroundColor(.init(0, 0, 1))) + #endif + } + + ScrollView { + ForEach(Array(1...50)) { number in + Text("Line \(number)") + }.padding() + }.inspect(.afterUpdate) { scrollView in + #if canImport(AppKitBackend) + scrollView.borderType = .grooveBorder + #elseif canImport(UIKitBackend) + scrollView.alwaysBounceHorizontal = true + #elseif canImport(WinUIBackend) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 0, g: 255, b: 0) + scrollView.borderBrush = brush + scrollView.borderThickness = .init( + left: 1, top: 1, right: 1, bottom: 1 + ) + #elseif canImport(GtkBackend) + scrollView.css.set(property: .border(color: .init(1, 0, 0), width: 2)) + #elseif canImport(Gtk3Backend) + scrollView.css.set(property: .border(color: .init(1, 0, 0), width: 2)) + #endif + }.frame(height: 200) + + List(["Red", "Green", "Blue"], id: \.self, selection: $color) { color in + Text(color) + }.inspect(.afterUpdate) { table in + #if canImport(AppKitBackend) + table.usesAlternatingRowBackgroundColors = true + #elseif canImport(UIKitBackend) + table.isEditing = true + #elseif canImport(WinUIBackend) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 255, g: 0, b: 255) + table.borderBrush = brush + table.borderThickness = .init( + left: 1, top: 1, right: 1, bottom: 1 + ) + #elseif canImport(GtkBackend) + table.showSeparators = true + #elseif canImport(Gtk3Backend) + table.selectionMode = .multiple + #endif + } + + Image(Bundle.module.bundleURL.appendingPathComponent("Banner.png")) + .resizable() + .inspect(.afterUpdate) { image in + #if canImport(AppKitBackend) + image.isEditable = true + #elseif canImport(UIKitBackend) + image.layer.borderWidth = 1 + image.layer.borderColor = .init(red: 0, green: 1, blue: 0, alpha: 1) + #elseif canImport(WinUIBackend) + // Couldn't find anything visually interesting + // to do to the WinUI.Image, but the point is + // that you could do something if you wanted to. + #elseif canImport(GtkBackend) + image.css.set(property: .border(color: .init(0, 1, 0), width: 2)) + #elseif canImport(Gtk3Backend) + image.css.set(property: .border(color: .init(0, 1, 0), width: 2)) + #endif + } + .aspectRatio(contentMode: .fit) + }.padding() + } + } + .defaultSize(width: 400, height: 200) + } +} diff --git a/Examples/Sources/AdvancedCustomizationExample/Banner.png b/Examples/Sources/AdvancedCustomizationExample/Banner.png new file mode 100644 index 0000000000000000000000000000000000000000..c958f40aef6ae2b34c2e9172224b43c929cc0cdf GIT binary patch literal 14960 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 H2>8DMYJ?7_ literal 0 HcmV?d00001 diff --git a/Sources/AppKitBackend/InspectionModifiers.swift b/Sources/AppKitBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..24e17205f2 --- /dev/null +++ b/Sources/AppKitBackend/InspectionModifiers.swift @@ -0,0 +1,107 @@ +import AppKit +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSButton) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSTextField) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSSlider) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Picker { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSPopUpButton) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSTextField) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSScrollView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSTableView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: NSScrollView) in + action(view.documentView as! NSTableView) + } + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSSplitView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: NSView) in + action(view.subviews[0] as! NSSplitView) + } + } +} + +extension Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSImageView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (_: NSView, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension Table { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSScrollView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} diff --git a/Sources/Gtk3/Pixbuf.swift b/Sources/Gtk3/Pixbuf.swift index 06b8a657d4..6a169c3b4b 100644 --- a/Sources/Gtk3/Pixbuf.swift +++ b/Sources/Gtk3/Pixbuf.swift @@ -34,10 +34,12 @@ public struct Pixbuf { } public func scaled(toWidth width: Int, andHeight height: Int) -> Pixbuf { + // This operation fails if the destination width or destination height + // is 0, so just make sure neither dimension hits zero. let newPointer = gdk_pixbuf_scale_simple( pointer, - gint(width), - gint(height), + gint(max(width, 1)), + gint(max(height, 1)), GDK_INTERP_BILINEAR ) return Pixbuf(pointer: newPointer!) diff --git a/Sources/Gtk3Backend/InspectionModifiers.swift b/Sources/Gtk3Backend/InspectionModifiers.swift new file mode 100644 index 0000000000..35edd818b1 --- /dev/null +++ b/Sources/Gtk3Backend/InspectionModifiers.swift @@ -0,0 +1,132 @@ +import Gtk3 +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Widget) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Button) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Label) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Scale) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Entry) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.ScrolledWindow) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.ListBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Paned) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: Gtk3.Fixed) in + action(view.children[0] as! Gtk3.Paned) + } + } +} + +extension SwiftCrossUI.Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Image) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (_: Gtk3.Widget, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension HStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension VStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ZStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Group { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Shape { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.DrawingArea) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} diff --git a/Sources/GtkBackend/InspectionModifiers.swift b/Sources/GtkBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..c791d7e73e --- /dev/null +++ b/Sources/GtkBackend/InspectionModifiers.swift @@ -0,0 +1,141 @@ +import Gtk +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Widget) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Button) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Label) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Scale) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Picker { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.DropDown) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Entry) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.ScrolledWindow) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.ListBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Paned) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: Gtk.Fixed) in + action(view.children[0] as! Gtk.Paned) + } + } +} + +extension SwiftCrossUI.Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Picture) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (_: Gtk.Widget, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension HStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension VStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ZStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Group { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Shape { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.DrawingArea) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} diff --git a/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md b/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md index ab54ea10e2..1888f248fa 100644 --- a/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md +++ b/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md @@ -20,6 +20,7 @@ A few examples are included with SwiftCrossUI to demonstrate some of its basic f - `NotesExample`, an app showcasing multi-line text editing and a more realistic usage of SwiftCrossUI. - `PathsExample`, an app showcasing the use of ``Path`` to draw various shapes. - `WebViewExample`, an app showcasing the use of ``WebView`` to display websites. Only works on Apple platforms so far. +- `AdvancedCustomizationExample`, an app showcasing SwiftCrossUI's more advanced APIs for customizing the underlying native views of your app. ## Running examples diff --git a/Sources/SwiftCrossUI/Views/Image.swift b/Sources/SwiftCrossUI/Views/Image.swift index 2f3be3e6f8..26b0e59815 100644 --- a/Sources/SwiftCrossUI/Views/Image.swift +++ b/Sources/SwiftCrossUI/Views/Image.swift @@ -46,7 +46,7 @@ extension Image: View { extension Image: TypeSafeView { func layoutableChildren( backend: Backend, - children: _ImageChildren + children: ImageChildren ) -> [LayoutSystem.LayoutableChild] { [] } @@ -55,12 +55,12 @@ extension Image: TypeSafeView { backend: Backend, snapshots: [ViewGraphSnapshotter.NodeSnapshot]?, environment: EnvironmentValues - ) -> _ImageChildren { - _ImageChildren(backend: backend) + ) -> ImageChildren { + ImageChildren(backend: backend) } func asWidget( - _ children: _ImageChildren, + _ children: ImageChildren, backend: Backend ) -> Backend.Widget { children.container.into() @@ -68,7 +68,7 @@ extension Image: TypeSafeView { func update( _ widget: Backend.Widget, - children: _ImageChildren, + children: ImageChildren, proposedSize: SIMD2, environment: EnvironmentValues, backend: Backend, @@ -159,12 +159,14 @@ extension Image: TypeSafeView { } } -class _ImageChildren: ViewGraphNodeChildren { +/// Image's persistent storage. Only exposed with the `package` access level +/// in order for backends to implement the `Image.inspect(_:_:)` modifier. +package class ImageChildren: ViewGraphNodeChildren { var cachedImageSource: Image.Source? = nil var cachedImage: ImageFormats.Image? = nil var cachedImageDisplaySize: SIMD2 = .zero var container: AnyWidget - var imageWidget: AnyWidget + package var imageWidget: AnyWidget var imageChanged = false var isContainerEmpty = true var lastScaleFactor: Double = 1 @@ -174,6 +176,6 @@ class _ImageChildren: ViewGraphNodeChildren { imageWidget = AnyWidget(backend.createImageView()) } - var widgets: [AnyWidget] = [] - var erasedNodes: [ErasedViewGraphNode] = [] + package var widgets: [AnyWidget] = [] + package var erasedNodes: [ErasedViewGraphNode] = [] } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift new file mode 100644 index 0000000000..e8f5d3fd7c --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift @@ -0,0 +1,101 @@ +/// A point at which a view's underlying widget can be inspected. +public struct InspectionPoints: OptionSet, RawRepresentable, Hashable, Sendable { + public var rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let onCreate = Self(rawValue: 1 << 0) + public static let beforeUpdate = Self(rawValue: 1 << 1) + public static let afterUpdate = Self(rawValue: 1 << 2) +} + +/// The `View.inspect(_:_:)` family of modifiers is implemented within each +/// backend. Make sure to import your chosen backend in any files where you +/// need to inspect a widget. This type simply supports the implementation of +/// those backend-specific modifiers. +package struct InspectView { + var child: Child + var inspectionPoints: InspectionPoints + var action: @MainActor (_ widget: AnyWidget, _ children: any ViewGraphNodeChildren) -> Void + + package init( + child: Child, + inspectionPoints: InspectionPoints, + action: @escaping @MainActor @Sendable (WidgetType) -> Void + ) { + self.child = child + self.inspectionPoints = inspectionPoints + self.action = { widget, _ in + action(widget.into()) + } + } + + package init( + child: Child, + inspectionPoints: InspectionPoints, + action: @escaping @MainActor @Sendable (WidgetType, Children) -> Void + ) { + self.child = child + self.inspectionPoints = inspectionPoints + self.action = { widget, children in + action(widget.into(), children as! Children) + } + } +} + +extension InspectView: View { + package var body: some View { EmptyView() } + + package func asWidget( + _ children: any ViewGraphNodeChildren, + backend: Backend + ) -> Backend.Widget { + let widget = child.asWidget(children, backend: backend) + if inspectionPoints.contains(.onCreate) { + action(AnyWidget(widget), children) + } + return widget + } + + package func children( + backend: Backend, + snapshots: [ViewGraphSnapshotter.NodeSnapshot]?, + environment: EnvironmentValues + ) -> any ViewGraphNodeChildren { + child.children(backend: backend, snapshots: snapshots, environment: environment) + } + + package func layoutableChildren( + backend: Backend, + children: any ViewGraphNodeChildren + ) -> [LayoutSystem.LayoutableChild] { + child.layoutableChildren(backend: backend, children: children) + } + + package func update( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + if inspectionPoints.contains(.beforeUpdate) { + action(AnyWidget(widget), children) + } + let result = child.update( + widget, + children: children, + proposedSize: proposedSize, + environment: environment, + backend: backend, + dryRun: dryRun + ) + if inspectionPoints.contains(.afterUpdate) { + action(AnyWidget(widget), children) + } + return result + } +} diff --git a/Sources/SwiftCrossUI/Views/NavigationLink.swift b/Sources/SwiftCrossUI/Views/NavigationLink.swift index 388e704c4e..76f7c8d3dd 100644 --- a/Sources/SwiftCrossUI/Views/NavigationLink.swift +++ b/Sources/SwiftCrossUI/Views/NavigationLink.swift @@ -2,7 +2,7 @@ // some practical examples). /// A navigation primitive that appends a value to the current navigation path on click. /// -/// Unlike Apples SwiftUI API a `NavigationLink` can be outside of a `NavigationStack` +/// Unlike Apple's SwiftUI API, a `NavigationLink` can be outside of a `NavigationStack` /// as long as they share the same `NavigationPath`. public struct NavigationLink: View { public var body: some View { diff --git a/Sources/UIKitBackend/InspectionModifiers.swift b/Sources/UIKitBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..511d7b1daa --- /dev/null +++ b/Sources/UIKitBackend/InspectionModifiers.swift @@ -0,0 +1,155 @@ +import UIKit +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: any WidgetProtocol) in + action(view.view) + } + } + + nonisolated func inspectAsWrapperWidget( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WrapperWidget) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIButton) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UILabel) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UISlider) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension SwiftCrossUI.Picker { + /// Inspects the picker's underlying `UIView` on Mac Catalyst. Will be a + /// `UIPickerView` if running on Mac Catalyst 14.0+ with the Mac user + /// interface idiom, and a `UIPickerView` otherwise. + @available(macCatalyst 13.0, *) + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: any WidgetProtocol) in + if let view = view as? UITableViewPicker { + action(view.child) + } else if let view = view as? UIPickerViewPicker { + action(view.child) + } else { + action(view.view) + } + } + } + + /// Inspects the picker's underlying `UITableView` on tvOS. + @available(tvOS 13.0, *) + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UITableView) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } + + /// Inspects the picker's underlying `UIPickerView` on iOS. + @available(iOS 13.0, *) + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIPickerView) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UITextField) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIScrollView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: ScrollWidget) in + action(view.scrollView) + } + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UITableView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (view: WrapperWidget) in + action(view.child) + } + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UISplitViewController) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (view: WrapperControllerWidget) in + action(view.child) + } + } +} + +extension Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIImageView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (_: UIView, children: ImageChildren) in + let wrapper: WrapperWidget = children.imageWidget.into() + action(wrapper.child) + } + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Container.swift b/Sources/UIKitBackend/UIKitBackend+Container.swift index 5375a24aa4..e19d0b5546 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -2,7 +2,7 @@ import SwiftCrossUI import UIKit final class ScrollWidget: ContainerWidget { - private var scrollView = UIScrollView() + var scrollView = UIScrollView() private var childWidthConstraint: NSLayoutConstraint? private var childHeightConstraint: NSLayoutConstraint? diff --git a/Sources/WinUIBackend/InspectionModifiers.swift b/Sources/WinUIBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..3706e67076 --- /dev/null +++ b/Sources/WinUIBackend/InspectionModifiers.swift @@ -0,0 +1,140 @@ +import WinUI +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.FrameworkElement) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Button) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.TextBlock) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Slider) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Picker { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.ComboBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.TextBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.ScrollViewer) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.ListView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.SplitView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Image) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (_: WinUI.FrameworkElement, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension HStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension VStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ZStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Group { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Shape { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Path) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} From 94a76740351a58dc46b8dda99d7ae055e5a24767 Mon Sep 17 00:00:00 2001 From: stackotter Date: Thu, 10 Jul 2025 12:39:22 +1000 Subject: [PATCH 2/3] Bump Apple platform workflows to Xcode 16.3/Swift 6.1 to fix xcodebuild The Swift Package Collection signing certificate used by Xcode 15.4 expired 10 hours ago and broke any workflows that used xcodebuild (for SwiftCrossUI's CI at least). It's probably a good that that all platforms are getting tested against the same Swift version now anyway. --- .github/workflows/build-test-and-docs.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-test-and-docs.yml b/.github/workflows/build-test-and-docs.yml index a61ad64dc1..ae0e7d8f9e 100644 --- a/.github/workflows/build-test-and-docs.yml +++ b/.github/workflows/build-test-and-docs.yml @@ -11,10 +11,10 @@ on: jobs: macos: - runs-on: macos-14 + runs-on: macos-15 steps: - - name: Force Xcode 15.4 - run: sudo xcode-select -switch /Applications/Xcode_15.4.app + - name: Force Xcode 16.3 (Swift 6.1) + run: sudo xcode-select -switch /Applications/Xcode_16.3.app - name: Swift version run: swift --version @@ -60,7 +60,7 @@ jobs: run: swift test --test-product swift-cross-uiPackageTests uikit: - runs-on: macos-14 + runs-on: macos-15 strategy: matrix: device-type: @@ -68,8 +68,8 @@ jobs: - iPad - TV steps: - - name: Force Xcode 15.4 - run: sudo xcode-select -switch /Applications/Xcode_15.4.app + - name: Force Xcode 16.3 (Swift 6.1) + run: sudo xcode-select -switch /Applications/Xcode_16.3.app - name: Swift version run: swift --version @@ -131,10 +131,10 @@ jobs: xcodebuild-device-type: ${{ matrix.device-type }} uikit-catalyst: - runs-on: macos-14 + runs-on: macos-15 steps: - - name: Force Xcode 15.4 - run: sudo xcode-select -switch /Applications/Xcode_15.4.app + - name: Force Xcode 16.3 (Swift 6.1) + run: sudo xcode-select -switch /Applications/Xcode_16.3.app - name: Swift version run: swift --version From a02da752cf9cd50c99b3ce43d573975b69225d58 Mon Sep 17 00:00:00 2001 From: stackotter Date: Thu, 10 Jul 2025 12:39:22 +1000 Subject: [PATCH 3/3] Create ...Representable protocol for each b-end (fix ui/appkit ones) --- .github/workflows/build-test-and-docs.yml | 4 + .../AdvancedCustomizationApp.swift | 9 +- .../CustomNativeButton.swift | 111 ++++++++ .../AppKitBackend/NSViewRepresentable.swift | 41 ++- Sources/Gtk/Widgets/Fixed.swift | 4 +- Sources/Gtk/Widgets/Widget.swift | 38 ++- Sources/Gtk3/Widgets/Fixed.swift | 6 +- Sources/Gtk3/Widgets/Widget.swift | 33 +++ .../Gtk3Backend/Gtk3WidgetRepresentable.swift | 229 ++++++++++++++++ .../GtkBackend/GtkWidgetRepresentable.swift | 228 ++++++++++++++++ .../UIKitBackend/UIViewRepresentable.swift | 11 +- Sources/WinUIBackend/WinUIBackend.swift | 39 ++- .../WinUIElementRepresentable.swift | 252 ++++++++++++++++++ 13 files changed, 962 insertions(+), 43 deletions(-) create mode 100644 Examples/Sources/AdvancedCustomizationExample/CustomNativeButton.swift create mode 100644 Sources/Gtk3Backend/Gtk3WidgetRepresentable.swift create mode 100644 Sources/GtkBackend/GtkWidgetRepresentable.swift create mode 100644 Sources/WinUIBackend/WinUIElementRepresentable.swift diff --git a/.github/workflows/build-test-and-docs.yml b/.github/workflows/build-test-and-docs.yml index ae0e7d8f9e..cc615307e9 100644 --- a/.github/workflows/build-test-and-docs.yml +++ b/.github/workflows/build-test-and-docs.yml @@ -42,6 +42,8 @@ jobs: swift build --target GtkBackend && \ swift build --target Gtk3Backend && \ swift build --target GtkExample && \ + # Work around SwiftPM incremental build issue + swift package clean && \ swift build --target CounterExample && \ swift build --target ControlsExample && \ swift build --target RandomNumberGeneratorExample && \ @@ -288,6 +290,8 @@ jobs: working-directory: ./Examples run: | swift build --target GtkExample && \ + # Work around SwiftPM incremental build issue + swift package clean && \ swift build --target CounterExample && \ swift build --target ControlsExample && \ swift build --target RandomNumberGeneratorExample && \ diff --git a/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift index ff8054e317..75b7c11f00 100644 --- a/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift +++ b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift @@ -19,9 +19,11 @@ struct CounterApp: App { @State var name = "" var body: some Scene { - WindowGroup("CounterExample: \(count)") { + WindowGroup("Inspect modifier and custom native views") { #hotReloadable { ScrollView { + CustomNativeButton(label: "Custom native button") + HStack(spacing: 20) { Button("-") { count -= 1 @@ -32,10 +34,7 @@ struct CounterApp: App { #if canImport(AppKitBackend) text.isSelectable = true #elseif canImport(UIKitBackend) - #if !targetEnvironment(macCatalyst) - text.isHighlighted = true - text.highlightTextColor = .yellow - #endif + text.isUserInteractionEnabled = true #elseif canImport(WinUIBackend) text.isTextSelectionEnabled = true #elseif canImport(GtkBackend) diff --git a/Examples/Sources/AdvancedCustomizationExample/CustomNativeButton.swift b/Examples/Sources/AdvancedCustomizationExample/CustomNativeButton.swift new file mode 100644 index 0000000000..4945ec8e45 --- /dev/null +++ b/Examples/Sources/AdvancedCustomizationExample/CustomNativeButton.swift @@ -0,0 +1,111 @@ +struct CustomNativeButton { + typealias Coordinator = Void + + var label: String +} + +#if canImport(GtkBackend) + import GtkBackend + import Gtk + + extension CustomNativeButton: GtkWidgetRepresentable { + func makeGtkWidget(context: GtkWidgetRepresentableContext) -> Gtk.Button { + Gtk.Button() + } + + func updateGtkWidget( + _ button: Gtk.Button, + context: GtkWidgetRepresentableContext + ) { + button.label = label + button.css.clear() + button.css.set(properties: [.backgroundColor(.init(1, 0, 1, 1))]) + } + } +#endif + +#if canImport(Gtk3Backend) + import Gtk3Backend + import Gtk3 + + extension CustomNativeButton: Gtk3WidgetRepresentable { + func makeGtk3Widget(context: Gtk3WidgetRepresentableContext) -> Gtk3.Button { + Gtk3.Button() + } + + func updateGtk3Widget( + _ button: Gtk3.Button, + context: Gtk3WidgetRepresentableContext + ) { + button.label = label + button.css.clear() + button.css.set(properties: [.backgroundColor(.init(1, 0, 1, 1))]) + } + } +#endif + +#if canImport(AppKitBackend) + import AppKitBackend + import AppKit + + extension CustomNativeButton: NSViewRepresentable { + func makeNSView(context: NSViewRepresentableContext) -> NSButton { + NSButton() + } + + func updateNSView( + _ button: NSButton, + context: NSViewRepresentableContext + ) { + button.title = label + button.bezelColor = .magenta + } + } +#endif + +#if canImport(UIKitBackend) + import UIKitBackend + import UIKit + + extension CustomNativeButton: UIViewRepresentable { + func makeUIView(context: UIViewRepresentableContext) -> UIButton { + UIButton() + } + + func updateUIView( + _ button: UIButton, + context: UIViewRepresentableContext + ) { + button.setTitle(label, for: .normal) + if #available(iOS 15.0, *) { + button.configuration = .bordered() + } + } + } +#endif + +#if canImport(WinUIBackend) + import WinUIBackend + import WinUI + import UWP + + extension CustomNativeButton: WinUIElementRepresentable { + func makeWinUIElement( + context: WinUIElementRepresentableContext + ) -> WinUI.Button { + WinUI.Button() + } + + func updateWinUIElement( + _ button: WinUI.Button, + context: WinUIElementRepresentableContext + ) { + let block = TextBlock() + block.text = label + button.content = block + let brush = WinUI.SolidColorBrush() + brush.color = UWP.Color(a: 255, r: 255, g: 0, b: 255) + button.background = brush + } + } +#endif diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index 115b323a8a..fa6a6806ac 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -65,7 +65,7 @@ public protocol NSViewRepresentable: View where Content == Never { /// This method is called after all AppKit lifecycle methods, such as /// `nsView.didMoveToSuperview()`. The default implementation does nothing. /// - Parameters: - /// - nsVIew: The view being dismantled. + /// - nsView: The view being dismantled. /// - coordinator: The coordinator. static func dismantleNSView(_ nsView: NSViewType, coordinator: Coordinator) } @@ -76,7 +76,8 @@ extension NSViewRepresentable { } public func determineViewSize( - for proposal: SIMD2, nsView: NSViewType, + for proposal: SIMD2, + nsView: NSViewType, context _: NSViewRepresentableContext ) -> ViewSize { let intrinsicSize = nsView.intrinsicContentSize @@ -84,15 +85,21 @@ extension NSViewRepresentable { let roundedSizeThatFits = SIMD2( Int(sizeThatFits.width.rounded(.up)), - Int(sizeThatFits.height.rounded(.up))) + Int(sizeThatFits.height.rounded(.up)) + ) let roundedIntrinsicSize = SIMD2( Int(intrinsicSize.width.rounded(.awayFromZero)), - Int(intrinsicSize.height.rounded(.awayFromZero))) + Int(intrinsicSize.height.rounded(.awayFromZero)) + ) return ViewSize( size: SIMD2( - intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x, - intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y + intrinsicSize.width < 0.0 + ? proposal.x + : max(min(proposal.x, roundedSizeThatFits.x), roundedIntrinsicSize.x), + intrinsicSize.height < 0.0 + ? proposal.y + : max(min(proposal.y, roundedSizeThatFits.y), roundedIntrinsicSize.y) ), // The 10 here is a somewhat arbitrary constant value so that it's always the same. // See also `Color` and `Picker`, which use the same constant. @@ -100,8 +107,12 @@ extension NSViewRepresentable { intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x, intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y ), + // We don't have a nice way of measuring these, so just set them to the + // view's minimum sizes along each dimension to at least be correct. + idealWidthForProposedHeight: max(0, roundedSizeThatFits.x), + idealHeightForProposedWidth: max(0, roundedSizeThatFits.y), minimumWidth: max(0, roundedIntrinsicSize.x), - minimumHeight: max(0, roundedIntrinsicSize.x), + minimumHeight: max(0, roundedIntrinsicSize.y), maximumWidth: nil, maximumHeight: nil ) @@ -154,6 +165,9 @@ extension View where Self: NSViewRepresentable { let representingWidget = widget as! RepresentingWidget representingWidget.update(with: environment) + // We need to do this for `fittingSize` to work correctly (it takes all + // constraints into account). + backend.setSize(of: representingWidget, to: proposedSize) let size = representingWidget.representable.determineViewSize( for: proposedSize, nsView: representingWidget.subview, @@ -209,14 +223,17 @@ final class RepresentingWidget: NSView { }() func update(with environment: EnvironmentValues) { - if context == nil { - context = .init( + if var context { + context.environment = environment + representable.updateNSView(subview, context: context) + self.context = context + } else { + let context = NSViewRepresentableContext( coordinator: representable.makeCoordinator(), environment: environment ) - } else { - context!.environment = environment - representable.updateNSView(subview, context: context!) + self.context = context + representable.updateNSView(subview, context: context) } } diff --git a/Sources/Gtk/Widgets/Fixed.swift b/Sources/Gtk/Widgets/Fixed.swift index 56ab5f0565..8afc043ae9 100644 --- a/Sources/Gtk/Widgets/Fixed.swift +++ b/Sources/Gtk/Widgets/Fixed.swift @@ -41,8 +41,8 @@ open class Fixed: Widget { public var children: [Widget] = [] /// Creates a new `GtkFixed`. - public convenience init() { - self.init(gtk_fixed_new()) + public init() { + super.init(gtk_fixed_new()) } public func put(_ child: Widget, x: Double, y: Double) { diff --git a/Sources/Gtk/Widgets/Widget.swift b/Sources/Gtk/Widgets/Widget.swift index 1ad5a93ee5..f8ce5c364d 100644 --- a/Sources/Gtk/Widgets/Widget.swift +++ b/Sources/Gtk/Widgets/Widget.swift @@ -62,12 +62,12 @@ open class Widget: GObject { } public func setSizeRequest(width: Int, height: Int) { - gtk_widget_set_size_request(widgetPointer, Int32(width), Int32(height)) + gtk_widget_set_size_request(widgetPointer, gint(width), gint(height)) } public func getSizeRequest() -> Size { - var width: Int32 = 0 - var height: Int32 = 0 + var width: gint = 0 + var height: gint = 0 gtk_widget_get_size_request(widgetPointer, &width, &height) return Size(width: Int(width), height: Int(height)) } @@ -82,6 +82,38 @@ open class Widget: GObject { ) } + public struct MeasureResult { + public var minimum: Int + public var natural: Int + public var minimumBaseline: Int + public var naturalBaseline: Int + } + + public func measure( + orientation: Orientation, + forPerpendicularSize perpendicularSize: Int + ) -> MeasureResult { + var minimum: gint = 0 + var natural: gint = 0 + var minimumBaseline: gint = 0 + var naturalBaseline: gint = 0 + gtk_widget_measure( + widgetPointer, + orientation.toGtk(), + gint(perpendicularSize), + &minimum, + &natural, + &minimumBaseline, + &naturalBaseline + ) + return MeasureResult( + minimum: Int(minimum), + natural: Int(natural), + minimumBaseline: Int(minimumBaseline), + naturalBaseline: Int(naturalBaseline) + ) + } + public func insertActionGroup(_ name: String, _ actionGroup: any GActionGroup) { gtk_widget_insert_action_group( widgetPointer, diff --git a/Sources/Gtk3/Widgets/Fixed.swift b/Sources/Gtk3/Widgets/Fixed.swift index 3815c20256..ef134acc93 100644 --- a/Sources/Gtk3/Widgets/Fixed.swift +++ b/Sources/Gtk3/Widgets/Fixed.swift @@ -37,12 +37,12 @@ import CGtk3 /// If you know none of these things are an issue for your application, /// and prefer the simplicity of `GtkFixed`, by all means use the /// widget. But you should be aware of the tradeoffs. -public class Fixed: Widget { +open class Fixed: Widget { public var children: [Widget] = [] /// Creates a new `GtkFixed`. - public convenience init() { - self.init(gtk_fixed_new()) + public init() { + super.init(gtk_fixed_new()) } public func put(_ child: Widget, x: Int, y: Int) { diff --git a/Sources/Gtk3/Widgets/Widget.swift b/Sources/Gtk3/Widgets/Widget.swift index fadce43488..0cf1d03062 100644 --- a/Sources/Gtk3/Widgets/Widget.swift +++ b/Sources/Gtk3/Widgets/Widget.swift @@ -131,6 +131,39 @@ open class Widget: GObject { ) } + public struct MeasureResult { + public var minimum: Int + public var natural: Int + } + + public func measure( + orientation: Orientation, + forPerpendicularSize perpendicularSize: Int + ) -> MeasureResult { + var minimum: gint = 0 + var natural: gint = 0 + switch orientation { + case .horizontal: + gtk_widget_get_preferred_width_for_height( + widgetPointer, + gint(perpendicularSize), + &minimum, + &natural + ) + case .vertical: + gtk_widget_get_preferred_height_for_width( + widgetPointer, + gint(perpendicularSize), + &minimum, + &natural + ) + } + return MeasureResult( + minimum: Int(minimum), + natural: Int(natural) + ) + } + public func insertActionGroup(_ name: String, _ actionGroup: any GActionGroup) { gtk_widget_insert_action_group( widgetPointer, diff --git a/Sources/Gtk3Backend/Gtk3WidgetRepresentable.swift b/Sources/Gtk3Backend/Gtk3WidgetRepresentable.swift new file mode 100644 index 0000000000..f33005efd6 --- /dev/null +++ b/Sources/Gtk3Backend/Gtk3WidgetRepresentable.swift @@ -0,0 +1,229 @@ +import Gtk3 +import SwiftCrossUI + +public struct Gtk3WidgetRepresentableContext { + public let coordinator: Coordinator + public internal(set) var environment: EnvironmentValues +} + +/// A wrapper that you use to integrate a Gtk 3 widget into your SwiftCrossUI +/// view hierarchy. +public protocol Gtk3WidgetRepresentable: View where Content == Never { + /// The underlying Gtk 3 widget. + associatedtype Gtk3WidgetType: Gtk3.Widget + /// A type providing persistent storage for representable implementations. + associatedtype Coordinator = Void + + /// Create the initial `Gtk3.Widget` instance. + @MainActor + func makeGtk3Widget(context: Gtk3WidgetRepresentableContext) -> Gtk3WidgetType + + /// Update the widget with new values. + /// - Parameters: + /// - gtkWidget: The widget to update. + /// - context: The context, including the coordinator and potentially new + /// environment values. + /// - Note: This may be called even when `context` has not changed. + @MainActor + func updateGtk3Widget( + _ gtkWidget: Gtk3WidgetType, + context: Gtk3WidgetRepresentableContext + ) + + /// Make the coordinator for this widget. + /// + /// The coordinator is used when the widget needs to communicate changes to + /// the rest of the view hierarchy (i.e. through bindings), and is often the + /// widget's delegate. + @MainActor + func makeCoordinator() -> Coordinator + + /// Compute the widget's size. + /// + /// The default implementation uses `gtkWidget.naturalSize()` with a + /// temporarily disabled size request (`SIMD2(-1, -1)`) to determine the + /// widget's ideal size, and `Gtk3.Widget.measure` to measure the widget's + /// actual size and minimum size. + /// - Parameters: + /// - proposal: The proposed frame for the widget to render in. + /// - gtkWidget: The widget being queried for its preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: Information about the widget's size. The + /// ``SwiftCrossUI/ViewSize/size`` property is what frame the widget 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 widget can be. The ``SwiftCrossUI/ViewSize/idealSize`` + /// property should not vary with the `proposal`, and should only depend + /// on the widget's contents. Pass `nil` for the maximum width/height if + /// the widget has no maximum size (and therefore may occupy the entire + /// screen). + func determineViewSize( + for proposal: SIMD2, + gtkWidget: Gtk3WidgetType, + context: Gtk3WidgetRepresentableContext + ) -> ViewSize + + /// Called to clean up the widget when it's removed. + /// + /// The default implementation does nothing. + /// - Parameters: + /// - gtkWidget: The widget being dismantled. + /// - coordinator: The coordinator. + static func dismantleGtk3Widget(_ gtkWidget: Gtk3WidgetType, coordinator: Coordinator) +} + +extension Gtk3WidgetRepresentable { + public static func dismantleGtk3Widget(_: Gtk3WidgetType, coordinator _: Coordinator) { + // no-op + } + + public func determineViewSize( + for proposal: SIMD2, + gtkWidget: Gtk3WidgetType, + context _: Gtk3WidgetRepresentableContext + ) -> ViewSize { + let (idealWidth, idealHeight) = gtkWidget.getNaturalSize() + let idealSize = SIMD2(idealWidth, idealHeight) + + let sizeThatFitsWidth = gtkWidget.measure( + orientation: .vertical, + forPerpendicularSize: proposal.x + ) + let sizeThatFitsHeight = gtkWidget.measure( + orientation: .horizontal, + forPerpendicularSize: proposal.y + ) + + return ViewSize( + size: SIMD2( + proposal.x, + sizeThatFitsWidth.natural + ), + idealSize: idealSize, + idealWidthForProposedHeight: sizeThatFitsHeight.natural, + idealHeightForProposedWidth: sizeThatFitsWidth.natural, + minimumWidth: sizeThatFitsHeight.minimum, + minimumHeight: sizeThatFitsWidth.minimum, + maximumWidth: nil, + maximumHeight: nil + ) + } +} + +extension View where Self: Gtk3WidgetRepresentable { + public var body: Never { + preconditionFailure("This should never be called") + } + + public func children( + backend _: Backend, + snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?, + environment _: EnvironmentValues + ) -> any ViewGraphNodeChildren { + EmptyViewChildren() + } + + public func layoutableChildren( + backend _: Backend, + children _: any ViewGraphNodeChildren + ) -> [LayoutSystem.LayoutableChild] { + [] + } + + public func asWidget( + _: any ViewGraphNodeChildren, + backend _: Backend + ) -> Backend.Widget { + if let widget = RepresentingWidget(representable: self) as? Backend.Widget { + return widget + } else { + fatalError("Gtk3WidgetRepresentable requested by \(Backend.self)") + } + } + + public func update( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + guard let backend = backend as? Gtk3Backend else { + fatalError("Gtk3RepresentableRepresentable updated by \(Backend.self)") + } + + let representingWidget = widget as! RepresentingWidget + if let child = representingWidget.child, + let savedSizeRequest = representingWidget.savedSizeRequest + { + backend.setSize(of: child, to: savedSizeRequest) + } + representingWidget.update(with: environment) + + let size = representingWidget.representable.determineViewSize( + for: proposedSize, + gtkWidget: representingWidget.child!, + context: representingWidget.context! + ) + + if !dryRun { + backend.setSize(of: representingWidget, to: size.size) + let sizeRequest = representingWidget.child!.getSizeRequest() + representingWidget.savedSizeRequest = SIMD2( + sizeRequest.width, + sizeRequest.height + ) + backend.setSize(of: representingWidget.child!, to: size.size) + } + + return ViewUpdateResult.leafView(size: size) + } +} + +extension Gtk3WidgetRepresentable where Coordinator == Void { + public func makeCoordinator() { + return () + } +} + +/// Exists to handle `deinit`, the rest of the stuff is just in here cause +/// it's a convenient location. +@MainActor +final class RepresentingWidget: Gtk3.Fixed { + var representable: Representable + var context: Gtk3WidgetRepresentableContext? + var savedSizeRequest: SIMD2? + + init(representable: Representable) { + self.representable = representable + super.init() + } + + var child: Representable.Gtk3WidgetType? + + func update(with environment: EnvironmentValues) { + if var context, let child { + context.environment = environment + representable.updateGtk3Widget(child, context: context) + self.context = context + } else { + let context = Gtk3WidgetRepresentableContext( + coordinator: representable.makeCoordinator(), + environment: environment + ) + let child = representable.makeGtk3Widget(context: context) + put(child, x: 0, y: 0) + child.show() + representable.updateGtk3Widget(child, context: context) + self.child = child + self.context = context + } + } + + deinit { + if let context, let child { + Representable.dismantleGtk3Widget(child, coordinator: context.coordinator) + } + } +} diff --git a/Sources/GtkBackend/GtkWidgetRepresentable.swift b/Sources/GtkBackend/GtkWidgetRepresentable.swift new file mode 100644 index 0000000000..dc9e37f07b --- /dev/null +++ b/Sources/GtkBackend/GtkWidgetRepresentable.swift @@ -0,0 +1,228 @@ +import Gtk +import SwiftCrossUI + +public struct GtkWidgetRepresentableContext { + public let coordinator: Coordinator + public internal(set) var environment: EnvironmentValues +} + +/// A wrapper that you use to integrate a Gtk widget into your SwiftCrossUI +/// view hierarchy. +public protocol GtkWidgetRepresentable: View where Content == Never { + /// The underlying Gtk widget. + associatedtype GtkWidgetType: Gtk.Widget + /// A type providing persistent storage for representable implementations. + associatedtype Coordinator = Void + + /// Create the initial `Gtk.Widget` instance. + @MainActor + func makeGtkWidget(context: GtkWidgetRepresentableContext) -> GtkWidgetType + + /// Update the widget with new values. + /// - Parameters: + /// - gtkWidget: The widget to update. + /// - context: The context, including the coordinator and potentially new + /// environment values. + /// - Note: This may be called even when `context` has not changed. + @MainActor + func updateGtkWidget( + _ gtkWidget: GtkWidgetType, + context: GtkWidgetRepresentableContext + ) + + /// Make the coordinator for this widget. + /// + /// The coordinator is used when the widget needs to communicate changes to + /// the rest of the view hierarchy (i.e. through bindings), and is often the + /// widget's delegate. + @MainActor + func makeCoordinator() -> Coordinator + + /// Compute the widget's size. + /// + /// The default implementation uses `gtkWidget.naturalSize()` with a + /// temporarily disabled size request (`SIMD2(-1, -1)`) to determine the + /// widget's ideal size, and `Gtk.Widget.measure` to measure the widget's + /// actual size and minimum size. + /// - Parameters: + /// - proposal: The proposed frame for the widget to render in. + /// - gtkWidget: The widget being queried for its preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: Information about the widget's size. The + /// ``SwiftCrossUI/ViewSize/size`` property is what frame the widget 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 widget can be. The ``SwiftCrossUI/ViewSize/idealSize`` + /// property should not vary with the `proposal`, and should only depend + /// on the widget's contents. Pass `nil` for the maximum width/height if + /// the widget has no maximum size (and therefore may occupy the entire + /// screen). + func determineViewSize( + for proposal: SIMD2, + gtkWidget: GtkWidgetType, + context: GtkWidgetRepresentableContext + ) -> ViewSize + + /// Called to clean up the widget when it's removed. + /// + /// The default implementation does nothing. + /// - Parameters: + /// - gtkWidget: The widget being dismantled. + /// - coordinator: The coordinator. + static func dismantleGtkWidget(_ gtkWidget: GtkWidgetType, coordinator: Coordinator) +} + +extension GtkWidgetRepresentable { + public static func dismantleGtkWidget(_: GtkWidgetType, coordinator _: Coordinator) { + // no-op + } + + public func determineViewSize( + for proposal: SIMD2, + gtkWidget: GtkWidgetType, + context _: GtkWidgetRepresentableContext + ) -> ViewSize { + let (idealWidth, idealHeight) = gtkWidget.getNaturalSize() + let idealSize = SIMD2(idealWidth, idealHeight) + + let sizeThatFitsWidth = gtkWidget.measure( + orientation: .vertical, + forPerpendicularSize: proposal.x + ) + let sizeThatFitsHeight = gtkWidget.measure( + orientation: .horizontal, + forPerpendicularSize: proposal.y + ) + + return ViewSize( + size: SIMD2( + proposal.x, + sizeThatFitsWidth.natural + ), + idealSize: idealSize, + idealWidthForProposedHeight: sizeThatFitsHeight.natural, + idealHeightForProposedWidth: sizeThatFitsWidth.natural, + minimumWidth: sizeThatFitsHeight.minimum, + minimumHeight: sizeThatFitsWidth.minimum, + maximumWidth: nil, + maximumHeight: nil + ) + } +} + +extension View where Self: GtkWidgetRepresentable { + public var body: Never { + preconditionFailure("This should never be called") + } + + public func children( + backend _: Backend, + snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?, + environment _: EnvironmentValues + ) -> any ViewGraphNodeChildren { + EmptyViewChildren() + } + + public func layoutableChildren( + backend _: Backend, + children _: any ViewGraphNodeChildren + ) -> [LayoutSystem.LayoutableChild] { + [] + } + + public func asWidget( + _: any ViewGraphNodeChildren, + backend _: Backend + ) -> Backend.Widget { + if let widget = RepresentingWidget(representable: self) as? Backend.Widget { + return widget + } else { + fatalError("GtkWidgetRepresentable requested by \(Backend.self)") + } + } + + public func update( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + guard let backend = backend as? GtkBackend else { + fatalError("GtkWidgetRepresentable updated by \(Backend.self)") + } + + let representingWidget = widget as! RepresentingWidget + if let child = representingWidget.child, + let savedSizeRequest = representingWidget.savedSizeRequest + { + backend.setSize(of: child, to: savedSizeRequest) + } + representingWidget.update(with: environment) + + let size = representingWidget.representable.determineViewSize( + for: proposedSize, + gtkWidget: representingWidget.child!, + context: representingWidget.context! + ) + + if !dryRun { + backend.setSize(of: representingWidget, to: size.size) + let sizeRequest = representingWidget.child!.getSizeRequest() + representingWidget.savedSizeRequest = SIMD2( + sizeRequest.width, + sizeRequest.height + ) + backend.setSize(of: representingWidget.child!, to: size.size) + } + + return ViewUpdateResult.leafView(size: size) + } +} + +extension GtkWidgetRepresentable where Coordinator == Void { + public func makeCoordinator() { + return () + } +} + +/// Exists to handle `deinit`, the rest of the stuff is just in here cause +/// it's a convenient location. +@MainActor +final class RepresentingWidget: Gtk.Fixed { + var representable: Representable + var context: GtkWidgetRepresentableContext? + var savedSizeRequest: SIMD2? + + init(representable: Representable) { + self.representable = representable + super.init() + } + + var child: Representable.GtkWidgetType? + + func update(with environment: EnvironmentValues) { + if var context, let child { + context.environment = environment + representable.updateGtkWidget(child, context: context) + self.context = context + } else { + let context = GtkWidgetRepresentableContext( + coordinator: representable.makeCoordinator(), + environment: environment + ) + let child = representable.makeGtkWidget(context: context) + put(child, x: 0, y: 0) + representable.updateGtkWidget(child, context: context) + self.child = child + self.context = context + } + } + + deinit { + if let context, let child { + Representable.dismantleGtkWidget(child, coordinator: context.coordinator) + } + } +} diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 5b20929cd9..9906f9ae00 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -190,11 +190,14 @@ final class ViewRepresentingWidget: BaseView }() func update(with environment: EnvironmentValues) { - if context == nil { - context = .init(coordinator: representable.makeCoordinator(), environment: environment) + if var context { + context.environment = environment + representable.updateUIView(subview, context: context) + self.context = context } else { - context!.environment = environment - representable.updateUIView(subview, context: context!) + let context = UIViewRepresentableContext(coordinator: representable.makeCoordinator(), environment: environment) + self.context = context + representable.updateUIView(subview, context: context) } } diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index dab4b369a5..f44702ad8e 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -9,7 +9,7 @@ import WinUIInterop import WindowsFoundation // Many force tries are required for the WinUI backend but we don't really want them -// anywhere else so just disable them for this file. +// anywhere else so just disable the lint rule at a file level. // swiftlint:disable force_try extension App { @@ -404,6 +404,12 @@ public final class WinUIBackend: AppBackend { } public func naturalSize(of widget: Widget) -> SIMD2 { + Self.naturalSize(of: widget) + } + + /// A static version of `naturalSize(of:)` for convenience. Used by + /// WinUIElementRepresentable. + public nonisolated static func naturalSize(of widget: Widget) -> SIMD2 { let allocation = WindowsFoundation.Size( width: .infinity, height: .infinity @@ -478,14 +484,25 @@ public final class WinUIBackend: AppBackend { try! widget.measure(allocation) let computedSize = widget.desiredSize + let adjustment = sizeCorrection(for: widget) + + let out = SIMD2( + Int(computedSize.width) + adjustment.x, + Int(computedSize.height) + adjustment.y + ) + + return out + } - // Some elements don't get their default padding/border applied until - // they've been rendered. For such elements we have to compute out own - // adjustment factors based off values taken from WinUI's default theme. - // We can detect such elements because their padding property will be set - // to zero until first render (and atm WinUIBackend doesn't set this padding - // property itself so this is a safe detection method). + /// Some elements don't get their default padding/border applied until + /// they've been rendered. For such elements we have to compute our own + /// adjustment factors based off values taken from WinUI's default theme. + /// We can detect such elements because their padding property will be set + /// to zero until first render (and atm WinUIBackend doesn't set this padding + /// property itself so this is a safe detection method). + public nonisolated static func sizeCorrection(for widget: Widget) -> SIMD2 { let adjustment: SIMD2 + let noPadding = Thickness(left: 0, top: 0, right: 0, bottom: 0) if let button = widget as? WinUI.Button, button.padding == noPadding { // WinUI buttons have padding, but the `padding` property returns // zero until the button has been rendered at least once. And even @@ -529,13 +546,7 @@ public final class WinUIBackend: AppBackend { } else { adjustment = .zero } - - let out = SIMD2( - Int(computedSize.width) + adjustment.x, - Int(computedSize.height) + adjustment.y - ) - - return out + return adjustment } public func setSize(of widget: Widget, to size: SIMD2) { diff --git a/Sources/WinUIBackend/WinUIElementRepresentable.swift b/Sources/WinUIBackend/WinUIElementRepresentable.swift new file mode 100644 index 0000000000..870dd43e32 --- /dev/null +++ b/Sources/WinUIBackend/WinUIElementRepresentable.swift @@ -0,0 +1,252 @@ +import WinUI +import WindowsFoundation +import SwiftCrossUI + +// Many force tries are required for the WinUI backend but we don't really want them +// anywhere else so just disable the lint rule at a file level. +// swiftlint:disable force_try + +public struct WinUIElementRepresentableContext { + public let coordinator: Coordinator + public internal(set) var environment: EnvironmentValues +} + +/// A wrapper that you use to integrate a WinUI element into your SwiftCrossUI +/// view hierarchy. +public protocol WinUIElementRepresentable: View where Content == Never { + /// The underlying Gtk widget. + associatedtype WinUIElementType: WinUI.FrameworkElement + /// A type providing persistent storage for representable implementations. + associatedtype Coordinator = Void + + /// Create the initial element instance. + @MainActor + func makeWinUIElement( + context: WinUIElementRepresentableContext + ) -> WinUIElementType + + /// Update the widget with new values. + /// - Parameters: + /// - winUIElement: The element to update. + /// - context: The context, including the coordinator and potentially new + /// environment values. + /// - Note: This may be called even when `context` has not changed. + @MainActor + func updateWinUIElement( + _ winUIElement: WinUIElementType, + context: WinUIElementRepresentableContext + ) + + /// Make the coordinator for this element. + /// + /// The coordinator is used when the element needs to communicate changes to + /// the rest of the view hierarchy (i.e. through bindings), and is often the + /// element's delegate. + @MainActor + func makeCoordinator() -> Coordinator + + /// Compute the element's size. + /// - Parameters: + /// - proposal: The proposed frame for the element to render in. + /// - winUIElement: The element being queried for its preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: Information about the element's size. The + /// ``SwiftCrossUI/ViewSize/size`` property is what frame the element 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 element can be. The ``SwiftCrossUI/ViewSize/idealSize`` + /// property should not vary with the `proposal`, and should only depend + /// on the element's contents. Pass `nil` for the maximum width/height if + /// the element has no maximum size (and therefore may occupy the entire + /// screen). + func determineViewSize( + for proposal: SIMD2, + winUIElement: WinUIElementType, + context: WinUIElementRepresentableContext + ) -> ViewSize + + /// Called to clean up the element when it's removed. + /// + /// The default implementation does nothing. + /// - Parameters: + /// - gtkElement: The element being dismantled. + /// - coordinator: The coordinator. + static func dismantleWinUIElement(_ winUIElement: WinUIElementType, coordinator: Coordinator) +} + +extension WinUIElementRepresentable { + public static func dismantleWinUIElement(_: WinUIElementType, coordinator _: Coordinator) { + // no-op + } + + public func determineViewSize( + for proposal: SIMD2, + winUIElement: WinUIElementType, + context _: WinUIElementRepresentableContext + ) -> ViewSize { + let idealSize = WinUIBackend.naturalSize(of: winUIElement) + + let adjustment: SIMD2 = WinUIBackend.sizeCorrection(for: winUIElement) + + let widthAllocation = WindowsFoundation.Size( + width: Float(proposal.x), + height: .infinity + ) + try! winUIElement.measure(widthAllocation) + let sizeThatFitsWidth = winUIElement.desiredSize + + let heightAllocation = WindowsFoundation.Size( + width: .infinity, + height: Float(proposal.y) + ) + try! winUIElement.measure(heightAllocation) + let sizeThatFitsHeight = winUIElement.desiredSize + + let minimumHeightAllocation = WindowsFoundation.Size( + width: Float(proposal.x), + height: 0 + ) + try! winUIElement.measure(minimumHeightAllocation) + let minimumHeightForWidth = winUIElement.desiredSize.height + + let minimumWidthAllocation = WindowsFoundation.Size( + width: 0, + height: Float(proposal.y) + ) + try! winUIElement.measure(minimumWidthAllocation) + let minimumWidthForHeight = winUIElement.desiredSize.width + + return ViewSize( + size: SIMD2( + Int(sizeThatFitsWidth.width.rounded(.up)), + Int(sizeThatFitsWidth.height.rounded(.up)) + ) &+ adjustment, + idealSize: idealSize, + idealWidthForProposedHeight: + Int(sizeThatFitsHeight.width.rounded(.up)) + adjustment.x, + idealHeightForProposedWidth: + Int(sizeThatFitsWidth.height.rounded(.up)) + adjustment.y, + minimumWidth: Int(minimumHeightForWidth.rounded(.up)) + adjustment.x, + minimumHeight: Int(minimumWidthForHeight.rounded(.up)) + adjustment.y, + maximumWidth: nil, + maximumHeight: nil + ) + } +} + +extension View where Self: WinUIElementRepresentable { + public var body: Never { + preconditionFailure("This should never be called") + } + + public func children( + backend _: Backend, + snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?, + environment _: EnvironmentValues + ) -> any ViewGraphNodeChildren { + EmptyViewChildren() + } + + public func layoutableChildren( + backend _: Backend, + children _: any ViewGraphNodeChildren + ) -> [LayoutSystem.LayoutableChild] { + [] + } + + public func asWidget( + _: any ViewGraphNodeChildren, + backend _: Backend + ) -> Backend.Widget { + if let widget = RepresentingWidget(representable: self) as? Backend.Widget { + return widget + } else { + fatalError("WinUIElementRepresentable requested by \(Backend.self)") + } + } + + public func update( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + guard let backend = backend as? WinUIBackend else { + fatalError("WinUIElementRepresentable updated by \(Backend.self)") + } + + let representingWidget = widget as! RepresentingWidget + if let child = representingWidget.child, + let savedSize = representingWidget.savedSize + { + child.width = savedSize.x + child.height = savedSize.y + } + representingWidget.update(with: environment) + + let size = representingWidget.representable.determineViewSize( + for: proposedSize, + winUIElement: representingWidget.child!, + context: representingWidget.context! + ) + + if !dryRun { + backend.setSize(of: representingWidget, to: size.size) + representingWidget.savedSize = SIMD2( + representingWidget.child!.width, + representingWidget.child!.height + ) + backend.setSize(of: representingWidget.child!, to: size.size) + } + + return ViewUpdateResult.leafView(size: size) + } +} + +extension WinUIElementRepresentable where Coordinator == Void { + public func makeCoordinator() { + return () + } +} + +/// Exists to handle `deinit`, the rest of the stuff is just in here cause +/// it's a convenient location. +@MainActor +final class RepresentingWidget: WinUI.Canvas { + var representable: Representable + var context: WinUIElementRepresentableContext? + var savedSize: SIMD2? + + init(representable: Representable) { + self.representable = representable + super.init() + } + + var child: Representable.WinUIElementType? + + func update(with environment: EnvironmentValues) { + if var context, let child { + context.environment = environment + representable.updateWinUIElement(child, context: context) + self.context = context + } else { + let context = WinUIElementRepresentableContext( + coordinator: representable.makeCoordinator(), + environment: environment + ) + let child = representable.makeWinUIElement(context: context) + children.append(child) + representable.updateWinUIElement(child, context: context) + self.child = child + self.context = context + } + } + + deinit { + if let context, let child { + Representable.dismantleWinUIElement(child, coordinator: context.coordinator) + } + } +}