diff --git a/.github/workflows/build-test-and-docs.yml b/.github/workflows/build-test-and-docs.yml index bc0d77b321..cc615307e9 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 @@ -41,6 +41,9 @@ jobs: cd Examples && \ 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 && \ @@ -51,14 +54,15 @@ 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 uikit: - runs-on: macos-14 + runs-on: macos-15 strategy: matrix: device-type: @@ -66,8 +70,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 @@ -101,6 +105,8 @@ jobs: buildtarget StressTestExample buildtarget NotesExample buildtarget PathsExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample if [ $device_type != TV ]; then # Slider is not implemented for tvOS @@ -127,10 +133,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 @@ -161,6 +167,8 @@ jobs: buildtarget PathsExample buildtarget ControlsExample buildtarget RandomNumberGeneratorExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample # TODO test whether this works on Catalyst # buildtarget SplitExample @@ -281,6 +289,9 @@ jobs: - name: Build examples 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 && \ @@ -291,7 +302,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..75b7c11f00 --- /dev/null +++ b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift @@ -0,0 +1,197 @@ +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("Inspect modifier and custom native views") { + #hotReloadable { + ScrollView { + CustomNativeButton(label: "Custom native button") + + HStack(spacing: 20) { + Button("-") { + count -= 1 + } + + Text("Count: \(count)") + .inspect { text in + #if canImport(AppKitBackend) + text.isSelectable = true + #elseif canImport(UIKitBackend) + text.isUserInteractionEnabled = true + #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 0000000000..c958f40aef Binary files /dev/null and b/Examples/Sources/AdvancedCustomizationExample/Banner.png differ 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/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/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/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/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/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/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/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/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/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) + } +} 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) + } + } +}