diff --git a/.github/workflows/build-test-and-docs.yml b/.github/workflows/build-test-and-docs.yml index 96ec4c1941a..e752ff58bc7 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 - steps: # For swift-testing (everything else works with 5.10) - - name: Force Xcode 16.2 - run: sudo xcode-select -switch /Applications/Xcode_16.2.app + runs-on: macos-15 + steps: + - 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: @@ -67,8 +71,8 @@ jobs: - TV - Vision 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 @@ -104,6 +108,8 @@ jobs: buildtarget StressTestExample buildtarget NotesExample buildtarget PathsExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample if [ $device_type != TV ]; then # Slider is not implemented for tvOS @@ -131,10 +137,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 @@ -165,6 +171,8 @@ jobs: buildtarget PathsExample buildtarget ControlsExample buildtarget RandomNumberGeneratorExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample # TODO test whether this works on Catalyst # buildtarget SplitExample @@ -295,6 +303,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 && \ @@ -305,7 +316,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 1d9f7d44a56..f9d47a33f71 100644 --- a/Examples/Bundler.toml +++ b/Examples/Bundler.toml @@ -69,3 +69,8 @@ version = '0.1.0' identifier = 'dev.swiftcrossui.ForEachExample' product = 'ForEachExample' 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 7bae86806ab..9d75315a954 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -80,6 +80,11 @@ let package = Package( .executableTarget( name: "ForEachExample", 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 00000000000..6b851e5299b --- /dev/null +++ b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift @@ -0,0 +1,203 @@ +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") + + #if canImport(UIKitBackend) + CustomNativeViewControllerButton(label: "Custom UIViewController button") + #endif + + 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, tvOS 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 + } + } + + #if !os(tvOS) + Slider(value: $value, in: 0...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 + } + #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), id: \.self) { 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 00000000000..c958f40aef6 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 00000000000..d396b806963 --- /dev/null +++ b/Examples/Sources/AdvancedCustomizationExample/CustomNativeButton.swift @@ -0,0 +1,141 @@ +import SwiftCrossUI + +struct CustomNativeButton { + var label: String +} + +#if canImport(GtkBackend) + import GtkBackend + import Gtk + + extension CustomNativeButton: GtkWidgetRepresentable { + func makeGtkWidget(context: Context) -> Gtk.Button { + Gtk.Button() + } + + func updateGtkWidget( + _ button: Gtk.Button, + context: Context + ) { + button.label = label + button.expandHorizontally = true + button.useExpandHorizontally = true + 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: Context) -> Gtk3.Button { + Gtk3.Button() + } + + func updateGtk3Widget( + _ button: Gtk3.Button, + context: Context + ) { + button.label = label + button.expandHorizontally = true + button.useExpandHorizontally = true + 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: Context) -> NSButton { + NSButton() + } + + func updateNSView( + _ button: NSButton, + context: Context + ) { + button.title = label + button.bezelColor = .magenta + } + } +#endif + +#if canImport(UIKitBackend) + import UIKitBackend + import UIKit + + extension CustomNativeButton: UIViewRepresentable { + func makeUIView(context: Context) -> UIButton { + UIButton() + } + + func updateUIView( + _ button: UIButton, + context: Context + ) { + button.setTitle(label, for: .normal) + if #available(iOS 15.0, tvOS 15.0, *) { + button.configuration = .bordered() + } + } + } + + class CustomViewController: UIViewController { + var button = UIButton() + + override func loadView() { + if #available(iOS 15.0, tvOS 15.0, *) { + button.configuration = .bordered() + } + view = button + } + } + + struct CustomNativeViewControllerButton: UIViewControllerRepresentable { + var label: String + + func makeUIViewController(context: Context) -> CustomViewController { + CustomViewController() + } + + func updateUIViewController( + _ controller: CustomViewController, + context: Context + ) { + controller.button.setTitle(label, for: .normal) + } + } +#endif + +#if canImport(WinUIBackend) + import WinUIBackend + import WinUI + import UWP + + extension CustomNativeButton: WinUIElementRepresentable { + func makeWinUIElement( + context: Context + ) -> WinUI.Button { + WinUI.Button() + } + + func updateWinUIElement( + _ button: WinUI.Button, + context: Context + ) { + 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/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 49e9eb250a3..22afee8fb49 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -510,30 +510,44 @@ public final class AppKitBackend: AppBackend { } public func setSize(of widget: Widget, to size: SIMD2) { + setSize(of: widget, to: ProposedViewSize(ViewSize(Double(size.x), Double(size.y)))) + } + + func setSize(of widget: Widget, to proposedSize: ProposedViewSize) { var foundConstraint = false for constraint in widget.constraints { if constraint.firstAnchor === widget.widthAnchor { - constraint.constant = CGFloat(size.x) + if let proposedWidth = proposedSize.width { + constraint.constant = CGFloat(proposedWidth) + constraint.isActive = true + } else { + constraint.isActive = false + } foundConstraint = true break } } - if !foundConstraint { - widget.widthAnchor.constraint(equalToConstant: CGFloat(size.x)).isActive = true + if !foundConstraint, let proposedWidth = proposedSize.width { + widget.widthAnchor.constraint(equalToConstant: proposedWidth).isActive = true } foundConstraint = false for constraint in widget.constraints { if constraint.firstAnchor === widget.heightAnchor { - constraint.constant = CGFloat(size.y) + if let proposedHeight = proposedSize.height { + constraint.constant = CGFloat(proposedHeight) + constraint.isActive = true + } else { + constraint.isActive = false + } foundConstraint = true break } } - if !foundConstraint { - widget.heightAnchor.constraint(equalToConstant: CGFloat(size.y)).isActive = true + if !foundConstraint, let proposedHeight = proposedSize.height { + widget.heightAnchor.constraint(equalToConstant: proposedHeight).isActive = true } } diff --git a/Sources/AppKitBackend/InspectionModifiers.swift b/Sources/AppKitBackend/InspectionModifiers.swift new file mode 100644 index 00000000000..24e17205f26 --- /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 498518f09ac..c127582263f 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -1,8 +1,9 @@ import AppKit import SwiftCrossUI -public struct NSViewRepresentableContext { - public let coordinator: Coordinator +/// The context associated with an instance of ``Representable``. +public struct NSViewRepresentableContext { + public let coordinator: Representable.Coordinator public internal(set) var environment: EnvironmentValues } @@ -16,7 +17,7 @@ public protocol NSViewRepresentable: View where Content == Never { /// Create the initial NSView instance. @MainActor - func makeNSView(context: NSViewRepresentableContext) -> NSViewType + func makeNSView(context: Context) -> NSViewType /// Update the view with new values. /// - Parameters: @@ -27,7 +28,7 @@ public protocol NSViewRepresentable: View where Content == Never { @MainActor func updateNSView( _ nsView: NSViewType, - context: NSViewRepresentableContext + context: Context ) /// Make the coordinator for this view. @@ -38,26 +39,20 @@ public protocol NSViewRepresentable: View where Content == Never { @MainActor func makeCoordinator() -> Coordinator - /// Compute the view's size. + /// Compute the view's preferred size for the given proposal. /// /// The default implementation uses `nsView.intrinsicContentSize` and - /// `nsView.sizeThatFits(_:)` to determine the return value. + /// `nsView.contentHuggingPriority(for:)` to determine the view's + /// preferred size. /// - Parameters: - /// - proposal: The proposed frame for the view to render in. - /// - nsVIew: The view being queried for its preferred size. + /// - proposal: The proposed size for the view. + /// - nsView: The view being queried for its preferred size. /// - context: The context, including the coordinator and environment values. - /// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size`` - /// property is what frame the view 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 view can be. The - /// ``SwiftCrossUI/ViewSize/idealSize`` property should not vary with the - /// `proposal`, and should only depend on the view's contents. Pass `nil` - /// for the maximum width/height if the view has no maximum size (and - /// therefore may occupy the entire screen). - func determineViewSize( - for proposal: ProposedViewSize, + /// - Returns: The view's preferred size. + func sizeThatFits( + _ proposal: ProposedViewSize, nsView: NSViewType, - context: NSViewRepresentableContext + context: Context ) -> ViewSize /// Called to clean up the view when it's removed. @@ -65,27 +60,48 @@ 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) } +extension NSViewRepresentable { + /// Context associated with the representable view. + public typealias Context = NSViewRepresentableContext +} + +/// Default implementations. extension NSViewRepresentable { public static func dismantleNSView(_: NSViewType, coordinator _: Coordinator) { // no-op } - public func determineViewSize( - for proposal: ProposedViewSize, + public func sizeThatFits( + _ proposal: ProposedViewSize, nsView: NSViewType, - context _: NSViewRepresentableContext + context _: Context ) -> ViewSize { let intrinsicSize = nsView.intrinsicContentSize - let sizeThatFits = nsView.fittingSize + let growsHorizontally = nsView.contentHuggingPriority(for: .horizontal) < .defaultHigh + let growsVertically = nsView.contentHuggingPriority(for: .vertical) < .defaultHigh + + let idealWidth = intrinsicSize.width == NSView.noIntrinsicMetric + ? 10 : intrinsicSize.width + let idealHeight = intrinsicSize.height == NSView.noIntrinsicMetric + ? 10 : intrinsicSize.height + + // When the view doesn't grow along a dimension, we use its fittingSize + // (rather than its intrinsicContentSize), because the intrinsicContentSize + // of some views (such as NSButton) are too small. In NSButton's case, the + // intrinsicContentSize doesn't include padding. return ViewSize( - intrinsicSize.width < 0 ? (proposal.width ?? 10) : sizeThatFits.width, - intrinsicSize.height < 0 ? (proposal.height ?? 10) : sizeThatFits.height + growsHorizontally + ? (proposal.width ?? idealWidth) + : nsView.fittingSize.width, + growsVertically + ? (proposal.height ?? idealHeight) + : nsView.fittingSize.height ) } } @@ -128,15 +144,11 @@ extension View where Self: NSViewRepresentable { environment: EnvironmentValues, backend: Backend ) -> ViewLayoutResult { - guard backend is AppKitBackend else { - fatalError("NSViewRepresentable updated by \(Backend.self)") - } - let representingWidget = widget as! RepresentingWidget representingWidget.update(with: environment) - let size = representingWidget.representable.determineViewSize( - for: proposedSize, + let size = representingWidget.representable.sizeThatFits( + proposedSize, nsView: representingWidget.subview, context: representingWidget.context! ) @@ -165,7 +177,7 @@ extension NSViewRepresentable where Coordinator == Void { /// it's a convenient location. final class RepresentingWidget: NSView { var representable: Representable - var context: NSViewRepresentableContext? + var context: Representable.Context? init(representable: Representable) { self.representable = representable @@ -196,14 +208,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 = Representable.Context( 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 56ab5f05659..8afc043ae96 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 946707145e2..142e3b30fcb 100644 --- a/Sources/Gtk/Widgets/Widget.swift +++ b/Sources/Gtk/Widgets/Widget.swift @@ -62,12 +62,12 @@ open class Widget: GObject { } open 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 06b8a657d46..6a169c3b4b7 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 3815c20256c..ef134acc933 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 0f257b82744..4bc0a0b50ff 100644 --- a/Sources/Gtk3/Widgets/Widget.swift +++ b/Sources/Gtk3/Widgets/Widget.swift @@ -166,6 +166,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 00000000000..2451fc2e661 --- /dev/null +++ b/Sources/Gtk3Backend/Gtk3WidgetRepresentable.swift @@ -0,0 +1,224 @@ +import Gtk3 +import SwiftCrossUI + +/// The context associated with an instance of ``Representable``. +public struct Gtk3WidgetRepresentableContext { + public let coordinator: Representable.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: Context) -> 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: Context + ) + + /// 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 preferred size for the given proposal. + /// + /// The default implementation uses `gtkWidget.naturalSize()` and + /// `hexpand`/`vexpand` to determine the widget's preferred size. + /// - Parameters: + /// - proposal: The proposed size for the widget. + /// - gtkWidget: The widget being queried for its preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: The widget's preferred size. + func sizeThatFits( + _ proposal: ProposedViewSize, + gtkWidget: Gtk3WidgetType, + context: Context + ) -> 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 { + /// Context associated with the representable widget. + public typealias Context = Gtk3WidgetRepresentableContext +} + +extension Gtk3WidgetRepresentable { + public static func dismantleGtk3Widget(_: Gtk3WidgetType, coordinator _: Coordinator) { + // no-op + } + + public func sizeThatFits( + _ proposal: ProposedViewSize, + gtkWidget: Gtk3WidgetType, + context _: Context + ) -> ViewSize { + let naturalSize = gtkWidget.getNaturalSize() + let idealWidth = naturalSize.width == -1 ? 10 : Double(naturalSize.width) + let idealHeight = naturalSize.height == -1 ? 10 : Double(naturalSize.height) + let growsHorizontally = gtkWidget.expandHorizontally && gtkWidget.useExpandHorizontally + let growsVertically = gtkWidget.expandVertically && gtkWidget.useExpandVertically + + return ViewSize( + growsHorizontally + ? max(proposal.width ?? idealWidth, idealWidth) + : idealWidth, + growsVertically + ? max(proposal.height ?? idealHeight, idealHeight) + : idealHeight + ) + } +} + +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 computeLayout( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: ProposedViewSize, + environment: EnvironmentValues, + backend: Backend + ) -> ViewLayoutResult { + 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.sizeThatFits( + proposedSize, + gtkWidget: representingWidget.child!, + context: representingWidget.context! + ) + + return ViewLayoutResult.leafView(size: size) + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + guard let backend = backend as? Gtk3Backend else { + fatalError("Gtk3WidgetRepresentable updated by \(Backend.self)") + } + + let representingWidget = widget as! RepresentingWidget + backend.setSize(of: representingWidget, to: layout.size.vector) + let sizeRequest = representingWidget.child!.getSizeRequest() + representingWidget.savedSizeRequest = SIMD2( + sizeRequest.width, + sizeRequest.height + ) + backend.setSize(of: representingWidget.child!, to: layout.size.vector) + } +} + +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: Representable.Context? + 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 = Representable.Context( + 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 00000000000..35edd818b12 --- /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 00000000000..2b8c7dcda8e --- /dev/null +++ b/Sources/GtkBackend/GtkWidgetRepresentable.swift @@ -0,0 +1,222 @@ +import Gtk +import SwiftCrossUI + +/// The context associated with an instance of ``Representable``. +public struct GtkWidgetRepresentableContext { + public let coordinator: Representable.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: Context) -> 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: Context + ) + + /// 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 preferred size for the given proposal. + /// + /// The default implementation uses `gtkWidget.naturalSize()` and + /// `hexpand`/`vexpand` to determine the widget's preferred size. + /// - proposal: The proposed size for the widget. + /// - gtkWidget: The widget being queried for its preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: The widget's preferred size. + func sizeThatFits( + _ proposal: ProposedViewSize, + gtkWidget: GtkWidgetType, + context: Context + ) -> 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 { + /// Context associated with the representable widget. + public typealias Context = GtkWidgetRepresentableContext +} + +extension GtkWidgetRepresentable { + public static func dismantleGtkWidget(_: GtkWidgetType, coordinator _: Coordinator) { + // no-op + } + + public func sizeThatFits( + _ proposal: ProposedViewSize, + gtkWidget: GtkWidgetType, + context _: Context + ) -> ViewSize { + let naturalSize = gtkWidget.getNaturalSize() + let idealWidth = naturalSize.width == -1 ? 10 : Double(naturalSize.width) + let idealHeight = naturalSize.height == -1 ? 10 : Double(naturalSize.height) + let growsHorizontally = gtkWidget.expandHorizontally && gtkWidget.useExpandHorizontally + let growsVertically = gtkWidget.expandVertically && gtkWidget.useExpandVertically + + return ViewSize( + growsHorizontally + ? max(proposal.width ?? idealWidth, idealWidth) + : idealWidth, + growsVertically + ? max(proposal.height ?? idealHeight, idealHeight) + : idealHeight + ) + } +} + +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 computeLayout( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: ProposedViewSize, + environment: EnvironmentValues, + backend: Backend + ) -> ViewLayoutResult { + 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.sizeThatFits( + proposedSize, + gtkWidget: representingWidget.child!, + context: representingWidget.context! + ) + + return ViewLayoutResult.leafView(size: size) + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + guard let backend = backend as? GtkBackend else { + fatalError("GtkWidgetRepresentable updated by \(Backend.self)") + } + + let representingWidget = widget as! RepresentingWidget + backend.setSize(of: representingWidget, to: layout.size.vector) + let sizeRequest = representingWidget.child!.getSizeRequest() + representingWidget.savedSizeRequest = SIMD2( + sizeRequest.width, + sizeRequest.height + ) + backend.setSize(of: representingWidget.child!, to: layout.size.vector) + } +} + +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: Representable.Context? + 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 = Representable.Context( + 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 00000000000..c791d7e73ea --- /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/Backend/AnyWidget.swift b/Sources/SwiftCrossUI/Backend/AnyWidget.swift index a561531e0be..eeffd34225e 100644 --- a/Sources/SwiftCrossUI/Backend/AnyWidget.swift +++ b/Sources/SwiftCrossUI/Backend/AnyWidget.swift @@ -23,7 +23,7 @@ public class AnyWidget { for backend: Backend.Type ) -> Backend.Widget { guard let widget = widget as? Backend.Widget else { - fatalError("AnyWidget used with incompatible backend \(backend)") + fatalError("AnyWidget used with incompatible backend \(backend); widget type is \(type(of: widget))") } return widget } @@ -33,7 +33,7 @@ public class AnyWidget { /// more concise than using ``AnyWidget/concreteWidget(for:)``. public func into() -> T { guard let widget = widget as? T else { - fatalError("AnyWidget used with incompatible widget type \(T.self)") + fatalError("AnyWidget used with incompatible widget type \(T.self); actual widget type is \(type(of: widget))") } return widget } diff --git a/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md b/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md index ab54ea10e29..1888f248faf 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 b363c2919d4..5eb03707bd1 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 computeLayout( _ widget: Backend.Widget, - children: _ImageChildren, + children: ImageChildren, proposedSize: ProposedViewSize, environment: EnvironmentValues, backend: Backend @@ -118,7 +118,7 @@ extension Image: TypeSafeView { func commit( _ widget: Backend.Widget, - children: _ImageChildren, + children: ImageChildren, layout: ViewLayoutResult, 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 00000000000..307e9258160 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift @@ -0,0 +1,115 @@ +/// 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 computeLayout( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: ProposedViewSize, + environment: EnvironmentValues, + backend: Backend + ) -> ViewLayoutResult { + if inspectionPoints.contains(.beforeUpdate) { + action(AnyWidget(widget), children) + } + let result = child.computeLayout( + widget, + children: children, + proposedSize: proposedSize, + environment: environment, + backend: backend + ) + return result + } + + package func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + child.commit( + widget, + children: children, + layout: layout, + environment: environment, + backend: backend + ) + if inspectionPoints.contains(.afterUpdate) { + action(AnyWidget(widget), children) + } + } +} diff --git a/Sources/SwiftCrossUI/Views/NavigationLink.swift b/Sources/SwiftCrossUI/Views/NavigationLink.swift index 388e704c4e1..76f7c8d3dd1 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 00000000000..ba1af35b1c6 --- /dev/null +++ b/Sources/UIKitBackend/InspectionModifiers.swift @@ -0,0 +1,164 @@ +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 (UIKitBackend.TextView) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension Slider { + @available(tvOS, unavailable) + 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, *) + @available(iOS, unavailable) + @available(tvOS, unavailable) + @available(visionOS, unavailable) + 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, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + @available(macCatalyst, unavailable) + 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 or visionOS. + @available(iOS 13.0, visionOS 1.0, *) + @available(tvOS, unavailable) + @available(macCatalyst, unavailable) + 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 f23ee39a7de..ec0cbfa637c 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/UIKitBackend+Passive.swift b/Sources/UIKitBackend/UIKitBackend+Passive.swift index a90b7962aee..57497679323 100644 --- a/Sources/UIKitBackend/UIKitBackend+Passive.swift +++ b/Sources/UIKitBackend/UIKitBackend+Passive.swift @@ -36,7 +36,7 @@ extension UIKitBackend { } public func createTextView() -> Widget { - WrapperWidget() + WrapperWidget() } public func updateTextView( @@ -44,7 +44,7 @@ extension UIKitBackend { content: String, environment: EnvironmentValues ) { - let wrapper = textView as! WrapperWidget + let wrapper = textView as! WrapperWidget wrapper.child.overrideUserInterfaceStyle = environment.colorScheme.userInterfaceStyle wrapper.child.attributedText = UIKitBackend.attributedString( text: content, @@ -102,109 +102,111 @@ extension UIKitBackend { } } -final class CustomTextView: UIView { - var isSelectable: Bool = false +extension UIKitBackend { + public final class TextView: UIView { + public var isSelectable: Bool = false - var attributedText: NSAttributedString { - get { - textStorage - } - set { - textStorage.setAttributedString(newValue) - setNeedsDisplay() + public var attributedText: NSAttributedString { + get { + textStorage + } + set { + textStorage.setAttributedString(newValue) + setNeedsDisplay() + } } - } - - var text: String { - attributedText.string - } - var layoutManager: NSLayoutManager - var textStorage: NSTextStorage - var textContainer: NSTextContainer - - override init(frame: CGRect) { - layoutManager = NSLayoutManager() + public var text: String { + attributedText.string + } - textStorage = NSTextStorage(attributedString: NSAttributedString(string: "")) - textStorage.addLayoutManager(layoutManager) + public var layoutManager: NSLayoutManager + public var textStorage: NSTextStorage + public var textContainer: NSTextContainer - textContainer = NSTextContainer(size: frame.size) - textContainer.lineBreakMode = .byTruncatingTail - textContainer.lineFragmentPadding = 0 - layoutManager.addTextContainer(textContainer) + public override init(frame: CGRect) { + layoutManager = NSLayoutManager() - super.init(frame: frame) + textStorage = NSTextStorage(attributedString: NSAttributedString(string: "")) + textStorage.addLayoutManager(layoutManager) - isOpaque = false + textContainer = NSTextContainer(size: frame.size) + textContainer.lineBreakMode = .byTruncatingTail + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) - // Inspired by https://medium.com/kinandcartacreated/making-uilabel-accessible-5f3d5c342df4 - // Thank you to Sam Dods for the base idea - #if !os(tvOS) - let longPress = UILongPressGestureRecognizer( - target: self, action: #selector(didLongPress)) - addGestureRecognizer(longPress) - isUserInteractionEnabled = true - #endif - } + super.init(frame: frame) - required init?(coder aDecoder: NSCoder) { - fatalError("init?(coder:) not implemented") - } + isOpaque = false - override var canBecomeFirstResponder: Bool { - isSelectable - } + // Inspired by https://medium.com/kinandcartacreated/making-uilabel-accessible-5f3d5c342df4 + // Thank you to Sam Dods for the base idea + #if !os(tvOS) + let longPress = UILongPressGestureRecognizer( + target: self, action: #selector(didLongPress)) + addGestureRecognizer(longPress) + isUserInteractionEnabled = true + #endif + } - override func layoutSubviews() { - super.layoutSubviews() - if textContainer.size != bounds.size { - textContainer.size = bounds.size - setNeedsDisplay() + public required init?(coder aDecoder: NSCoder) { + fatalError("init?(coder:) not implemented") } - } - override func draw(_ rect: CGRect) { - let range = layoutManager.glyphRange(for: textContainer) - layoutManager.drawBackground(forGlyphRange: range, at: bounds.origin) - layoutManager.drawGlyphs(forGlyphRange: range, at: bounds.origin) - } + public override var canBecomeFirstResponder: Bool { + isSelectable + } - @objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) { - #if !os(tvOS) - guard - isSelectable, - gesture.state == .began, - !text.isEmpty - else { - return + public override func layoutSubviews() { + super.layoutSubviews() + if textContainer.size != bounds.size { + textContainer.size = bounds.size + setNeedsDisplay() } - window?.endEditing(true) - guard becomeFirstResponder() else { return } + } - let menu = UIMenuController.shared - if !menu.isMenuVisible { - menu.showMenu(from: self, rect: bounds) - } - #endif - } + public override func draw(_ rect: CGRect) { + let range = layoutManager.glyphRange(for: textContainer) + layoutManager.drawBackground(forGlyphRange: range, at: bounds.origin) + layoutManager.drawGlyphs(forGlyphRange: range, at: bounds.origin) + } - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - return action == #selector(copy(_:)) - } + @objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) { + #if !os(tvOS) + guard + isSelectable, + gesture.state == .began, + !text.isEmpty + else { + return + } + window?.endEditing(true) + guard becomeFirstResponder() else { return } + + let menu = UIMenuController.shared + if !menu.isMenuVisible { + menu.showMenu(from: self, rect: bounds) + } + #endif + } - private func cancelSelection() { - #if !os(tvOS) - let menu = UIMenuController.shared - menu.hideMenu(from: self) - #endif - } + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + return action == #selector(copy(_:)) + } - @objc override func copy(_ sender: Any?) { - #if !os(tvOS) - cancelSelection() - let board = UIPasteboard.general - board.string = text - #endif + private func cancelSelection() { + #if !os(tvOS) + let menu = UIMenuController.shared + menu.hideMenu(from: self) + #endif + } + + @objc public override func copy(_ sender: Any?) { + #if !os(tvOS) + cancelSelection() + let board = UIPasteboard.general + board.string = text + #endif + } } } diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 0aadba7bf5a..36cb2de0580 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -24,7 +24,11 @@ final class RootViewController: UIViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - backend.onTraitCollectionChange?() + + let previous = previousTraitCollection?.userInterfaceStyle + if UITraitCollection.current.userInterfaceStyle != previous { + backend.onTraitCollectionChange?() + } } #endif @@ -43,8 +47,8 @@ final class RootViewController: UIViewController { override func viewWillTransition( to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator ) { - super.viewWillTransition(to: size, with: coordinator) resizeHandler?(size) + super.viewWillTransition(to: size, with: coordinator) } func setChild(to child: some WidgetProtocol) { diff --git a/Sources/UIKitBackend/UIViewControllerRepresentable.swift b/Sources/UIKitBackend/UIViewControllerRepresentable.swift index ad436307275..be706dc7f3d 100644 --- a/Sources/UIKitBackend/UIViewControllerRepresentable.swift +++ b/Sources/UIKitBackend/UIViewControllerRepresentable.swift @@ -1,29 +1,32 @@ import SwiftCrossUI import UIKit -public struct UIViewControllerRepresentableContext { - public let coordinator: Coordinator +/// The context associated with an instance of ``Representable``. +public struct UIViewControllerRepresentableContext< + Representable: UIViewControllerRepresentable +> { + public let coordinator: Representable.Coordinator public internal(set) var environment: EnvironmentValues } -public protocol UIViewControllerRepresentable: View -where Content == Never { +/// A wrapper that you use to integrate a UIKit view controller into your +/// SwiftCrossUI view hierarchy. +public protocol UIViewControllerRepresentable: View where Content == Never { associatedtype UIViewControllerType: UIViewController associatedtype Coordinator = Void /// Create the initial UIViewController instance. - func makeUIViewController(context: UIViewControllerRepresentableContext) - -> UIViewControllerType + func makeUIViewController(context: Context) -> UIViewControllerType /// Update the view with new values. /// - Note: This may be called even when `context` has not changed. /// - Parameters: /// - uiViewController: The controller to update. - /// - context: The context, including the coordinator and potentially new environment - /// values. + /// - context: The context, including the coordinator and environment values. func updateUIViewController( _ uiViewController: UIViewControllerType, - context: UIViewControllerRepresentableContext) + context: Context + ) /// Make the coordinator for this controller. /// @@ -31,25 +34,20 @@ where Content == Never { /// the view hierarchy (i.e. through bindings). func makeCoordinator() -> Coordinator - /// Compute the view's size. + /// Compute the controller's view's preferred size for the given proposal. /// /// The default implementation uses `uiViewController.view.intrinsicContentSize` and - /// `uiViewController.view.systemLayoutSizeFitting(_:)` to determine the return value. + /// `uiViewController.view.systemLayoutSizeFitting(_:)` to determine the view's + /// preferred size. /// - Parameters: - /// - proposal: The proposed frame for the view to render in. + /// - proposal: The proposed size for the view. /// - uiViewController: The controller being queried for its view's preferred size. /// - context: The context, including the coordinator and environment values. - /// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size`` - /// property is what frame the view 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 view can be. The ``SwiftCrossUI/ViewSize/idealSize`` - /// property should not vary with the `proposal`, and should only depend on the view's - /// contents. Pass `nil` for the maximum width/height if the view has no maximum size - /// (and therefore may occupy the entire screen). - func determineViewSize( - for proposal: ProposedViewSize, + /// - Returns: The view's preferred size. + func sizeThatFits( + _ proposal: ProposedViewSize, uiViewController: UIViewControllerType, - context: UIViewControllerRepresentableContext + context: Context ) -> ViewSize /// Called to clean up the view when it's removed. @@ -59,27 +57,34 @@ where Content == Never { /// - uiViewController: The controller being dismantled. /// - coordinator: The coordinator. static func dismantleUIViewController( - _ uiViewController: UIViewControllerType, coordinator: Coordinator) + _ uiViewController: UIViewControllerType, + coordinator: Coordinator + ) +} + +extension UIViewControllerRepresentable { + /// Context associated with the representable controller. + public typealias Context = UIViewControllerRepresentableContext } extension UIViewControllerRepresentable { public static func dismantleUIViewController( - _: UIViewControllerType, coordinator _: Coordinator + _: UIViewControllerType, + coordinator _: Coordinator ) { // no-op } - public func determineViewSize( - for proposal: ProposedViewSize, + public func sizeThatFits( + _ proposal: ProposedViewSize, uiViewController: UIViewControllerType, - context: UIViewControllerRepresentableContext + context: Context ) -> ViewSize { defaultViewSize(proposal: proposal, view: uiViewController.view) } } -extension View -where Self: UIViewControllerRepresentable { +extension View where Self: UIViewControllerRepresentable { public var body: Never { preconditionFailure("This should never be called") } @@ -110,44 +115,49 @@ where Self: UIViewControllerRepresentable { } } - public func update( + public func computeLayout( _ widget: Backend.Widget, children _: any ViewGraphNodeChildren, proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend _: Backend, - dryRun: Bool + backend _: Backend ) -> ViewLayoutResult { let representingWidget = widget as! ControllerRepresentingWidget representingWidget.update(with: environment) - let size = representingWidget.representable.determineViewSize( - for: proposedSize, + let size = representingWidget.representable.sizeThatFits( + proposedSize, uiViewController: representingWidget.subcontroller, context: representingWidget.context! ) - if !dryRun { - representingWidget.width = LayoutSystem.roundSize(size.width) - representingWidget.height = LayoutSystem.roundSize(size.height) - } - return ViewLayoutResult.leafView(size: size) } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let representingWidget = widget as! ControllerRepresentingWidget + representingWidget.width = layout.size.vector.x + representingWidget.height = layout.size.vector.y + } } -extension UIViewControllerRepresentable -where Coordinator == Void { +extension UIViewControllerRepresentable where Coordinator == Void { public func makeCoordinator() { return () } } -final class ControllerRepresentingWidget: - BaseControllerWidget -{ +final class ControllerRepresentingWidget< + Representable: UIViewControllerRepresentable +>: BaseControllerWidget { var representable: Representable - var context: UIViewControllerRepresentableContext? + var context: Representable.Context? lazy var subcontroller: Representable.UIViewControllerType = { let subcontroller = representable.makeUIViewController(context: context!) @@ -155,6 +165,7 @@ final class ControllerRepresentingWidget { - public let coordinator: Coordinator +/// The context associated with an instance of ``Representable``. +public struct UIViewRepresentableContext { + public let coordinator: Representable.Coordinator public internal(set) var environment: EnvironmentValues } -public protocol UIViewRepresentable: View -where Content == Never { +/// A wrapper that you use to integrate a UIKit view into your SwiftCrossUI +/// view hierarchy. +public protocol UIViewRepresentable: View where Content == Never { associatedtype UIViewType: UIView associatedtype Coordinator = Void /// Create the initial UIView instance. @MainActor - func makeUIView(context: UIViewRepresentableContext) -> UIViewType + func makeUIView(context: Context) -> UIViewType /// Update the view with new values. /// - Parameters: @@ -22,7 +24,7 @@ where Content == Never { /// values. /// - Note: This may be called even when `context` has not changed. @MainActor - func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext) + func updateUIView(_ uiView: UIViewType, context: Context) /// Make the coordinator for this view. /// @@ -31,24 +33,20 @@ where Content == Never { @MainActor func makeCoordinator() -> Coordinator - /// Compute the view's size. + /// Compute the view's preferred size. + /// + /// The default implementation uses `uiView.intrinsicContentSize` and + /// `uiView.systemLayoutSizeFitting(_:)` to determine the view's + /// preferred size. /// - Parameters: - /// - proposal: The proposed frame for the view to render in. + /// - proposal: The proposed size for the view. /// - uiView: The view being queried for its preferred size. /// - context: The context, including the coordinator and environment values. - /// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size`` - /// property is what frame the view 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 view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property - /// should not vary with the `proposal`, and should only depend on the view's contents. - /// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore - /// may occupy the entire screen). - /// - /// The default implementation uses `uiView.intrinsicContentSize` and `uiView.systemLayoutSizeFitting(_:)` - /// to determine the return value. - func determineViewSize( - for proposal: SIMD2, uiView: UIViewType, - context: UIViewRepresentableContext + /// - Returns: The view's preferred size. + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: UIViewType, + context: Context ) -> ViewSize /// Called to clean up the view when it's removed. @@ -63,17 +61,34 @@ where Content == Never { static func dismantleUIView(_ uiView: UIViewType, coordinator: Coordinator) } +extension UIViewRepresentable { + /// Context associated with the representable view. + public typealias Context = UIViewRepresentableContext +} + // Used both here and by UIViewControllerRepresentable func defaultViewSize(proposal: ProposedViewSize, view: UIView) -> ViewSize { - let size = CGSize(width: proposal.width ?? 10, height: proposal.height ?? 10) - let sizeThatFits = view.systemLayoutSizeFitting(size) + let intrinsicSize = view.intrinsicContentSize + + let growsHorizontally = view.contentHuggingPriority(for: .horizontal) < .defaultHigh + let growsVertically = view.contentHuggingPriority(for: .vertical) < .defaultHigh - let minimumSize = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - let maximumSize = view.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize) + let idealWidth = intrinsicSize.width == UIView.noIntrinsicMetric + ? 10 : intrinsicSize.width + let idealHeight = intrinsicSize.height == UIView.noIntrinsicMetric + ? 10 : intrinsicSize.height + // When the view doesn't grow along a dimension, we use its fittingSize + // (rather than its intrinsicContentSize), because the intrinsicContentSize + // of some views (such as NSButton) are too small. In NSButton's case, the + // intrinsicContentSize doesn't include padding. return ViewSize( - sizeThatFits.width, - sizeThatFits.height + growsHorizontally + ? (proposal.width ?? idealWidth) + : idealWidth, + growsVertically + ? (proposal.height ?? idealHeight) + : idealHeight ) } @@ -82,17 +97,16 @@ extension UIViewRepresentable { // no-op } - public func determineViewSize( - for proposal: ProposedViewSize, + public func sizeThatFits( + _ proposal: ProposedViewSize, uiView: UIViewType, - context _: UIViewRepresentableContext + context _: Context ) -> ViewSize { defaultViewSize(proposal: proposal, view: uiView) } } -extension View -where Self: UIViewRepresentable { +extension View where Self: UIViewRepresentable { public var body: Never { preconditionFailure("This should never be called") } @@ -133,12 +147,11 @@ where Self: UIViewRepresentable { let representingWidget = widget as! ViewRepresentingWidget representingWidget.update(with: environment) - let size = - representingWidget.representable.determineViewSize( - for: proposedSize, - uiView: representingWidget.subview, - context: representingWidget.context! - ) + let size = representingWidget.representable.sizeThatFits( + proposedSize, + uiView: representingWidget.subview, + context: representingWidget.context! + ) return ViewLayoutResult.leafView(size: size) } @@ -156,8 +169,7 @@ where Self: UIViewRepresentable { } } -extension UIViewRepresentable -where Coordinator == Void { +extension UIViewRepresentable where Coordinator == Void { public func makeCoordinator() { return () } @@ -165,7 +177,8 @@ where Coordinator == Void { final class ViewRepresentingWidget: BaseViewWidget { var representable: Representable - var context: UIViewRepresentableContext? + var context: Representable.Context? + var subviewConstraints: [NSLayoutConstraint] = [] lazy var subview: Representable.UIViewType = { let view = representable.makeUIView(context: context!) @@ -173,22 +186,29 @@ final class ViewRepresentingWidget: BaseView self.addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ + subviewConstraints = [ view.topAnchor.constraint(equalTo: self.topAnchor), view.leadingAnchor.constraint(equalTo: self.leadingAnchor), view.trailingAnchor.constraint(equalTo: self.trailingAnchor), view.bottomAnchor.constraint(equalTo: self.bottomAnchor), - ]) + ] + NSLayoutConstraint.activate(subviewConstraints) return view }() 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 = Representable.Context( + coordinator: representable.makeCoordinator(), + environment: environment + ) + self.context = context + representable.updateUIView(subview, context: context) } } diff --git a/Sources/UIKitBackend/Widget.swift b/Sources/UIKitBackend/Widget.swift index dc5c56405b9..0c57b919bc3 100644 --- a/Sources/UIKitBackend/Widget.swift +++ b/Sources/UIKitBackend/Widget.swift @@ -327,6 +327,7 @@ class ContainerWidget: BaseControllerWidget { self.child = child super.init() add(childWidget: child) + child.view.translatesAutoresizingMaskIntoConstraints = false } } diff --git a/Sources/WinUIBackend/InspectionModifiers.swift b/Sources/WinUIBackend/InspectionModifiers.swift new file mode 100644 index 00000000000..3706e670760 --- /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 16fb34bf010..87ca71a9d4e 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 { @@ -342,8 +342,8 @@ public final class WinUIBackend: AppBackend { } public func setIncomingURLHandler(to action: @escaping (URL) -> Void) { - print("Implement set incoming url handler") - // TODO + // TODO: Implement WinUIBackend setIncomingURLHandler + logger.warning("\(#function) not implemented") } public func createContainer() -> Widget { @@ -421,6 +421,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 @@ -495,14 +501,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 @@ -546,13 +563,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 00000000000..299e7e976b5 --- /dev/null +++ b/Sources/WinUIBackend/WinUIElementRepresentable.swift @@ -0,0 +1,226 @@ +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 + +/// The context associated with an instance of ``Representable``. +public struct WinUIElementRepresentableContext { + public let coordinator: Representable.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: Context + ) -> 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: Context + ) + + /// 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. + /// Compute the element's preferred size for the given proposal. + /// - Parameters: + /// - proposal: The proposed size for the element. + /// - winUIElement: The element being queried for its preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: The element's preferred size. + func sizeThatFits( + _ proposal: ProposedViewSize, + winUIElement: WinUIElementType, + context: Context + ) -> 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 { + /// Context associated with the representable element. + public typealias Context = WinUIElementRepresentableContext +} + +extension WinUIElementRepresentable { + public static func dismantleWinUIElement(_: WinUIElementType, coordinator _: Coordinator) { + // no-op + } + + public func sizeThatFits( + _ proposal: ProposedViewSize, + winUIElement: WinUIElementType, + context _: Context + ) -> ViewSize { + let adjustment: SIMD2 = WinUIBackend.sizeCorrection(for: winUIElement) + + let allocation = WindowsFoundation.Size( + width: proposal.width.map(Float.init) ?? .infinity, + height: proposal.height.map(Float.init) ?? .infinity + ) + try! winUIElement.measure(allocation) + let sizeThatFits = winUIElement.desiredSize + + let idealWidth = Double(sizeThatFits.width) + Double(adjustment.x) + let idealHeight = Double(sizeThatFits.height) + Double(adjustment.y) + + return ViewSize( + idealWidth, + idealHeight + ) + } +} + +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 computeLayout( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: ProposedViewSize, + environment: EnvironmentValues, + backend: Backend + ) -> ViewLayoutResult { + 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.sizeThatFits( + proposedSize, + winUIElement: representingWidget.child!, + context: representingWidget.context! + ) + + return ViewLayoutResult.leafView(size: size) + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + guard let backend = backend as? WinUIBackend else { + fatalError("WinUIElementRepresentable updated by \(Backend.self)") + } + + let representingWidget = widget as! RepresentingWidget + backend.setSize(of: representingWidget, to: layout.size.vector) + representingWidget.savedSize = SIMD2( + representingWidget.child!.width, + representingWidget.child!.height + ) + backend.setSize(of: representingWidget.child!, to: layout.size.vector) + } +} + +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: Representable.Context? + 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 = Representable.Context( + 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) + } + } +}