diff --git a/.github/workflows/build-test-and-docs.yml b/.github/workflows/build-test-and-docs.yml index bc0d77b321..a61ad64dc1 100644 --- a/.github/workflows/build-test-and-docs.yml +++ b/.github/workflows/build-test-and-docs.yml @@ -41,6 +41,7 @@ jobs: cd Examples && \ swift build --target GtkBackend && \ swift build --target Gtk3Backend && \ + swift build --target GtkExample && \ swift build --target CounterExample && \ swift build --target ControlsExample && \ swift build --target RandomNumberGeneratorExample && \ @@ -51,8 +52,9 @@ jobs: swift build --target StressTestExample && \ swift build --target SpreadsheetExample && \ swift build --target NotesExample && \ - swift build --target GtkExample && \ - swift build --target PathsExample + swift build --target PathsExample && \ + swift build --target WebViewExample && \ + swift build --target AdvancedCustomizationExample - name: Test run: swift test --test-product swift-cross-uiPackageTests @@ -101,6 +103,8 @@ jobs: buildtarget StressTestExample buildtarget NotesExample buildtarget PathsExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample if [ $device_type != TV ]; then # Slider is not implemented for tvOS @@ -161,6 +165,8 @@ jobs: buildtarget PathsExample buildtarget ControlsExample buildtarget RandomNumberGeneratorExample + buildtarget WebViewExample + buildtarget AdvancedCustomizationExample # TODO test whether this works on Catalyst # buildtarget SplitExample @@ -281,6 +287,7 @@ jobs: - name: Build examples working-directory: ./Examples run: | + swift build --target GtkExample && \ swift build --target CounterExample && \ swift build --target ControlsExample && \ swift build --target RandomNumberGeneratorExample && \ @@ -291,7 +298,8 @@ jobs: swift build --target StressTestExample && \ swift build --target SpreadsheetExample && \ swift build --target NotesExample && \ - swift build --target GtkExample + swift build --target PathsExample && \ + swift build --target AdvancedCustomizationExample - name: Test run: swift test --test-product swift-cross-uiPackageTests diff --git a/Examples/Bundler.toml b/Examples/Bundler.toml index 7d0f8864a9..4a226830e1 100644 --- a/Examples/Bundler.toml +++ b/Examples/Bundler.toml @@ -59,3 +59,8 @@ version = '0.1.0' identifier = 'dev.swiftcrossui.WebViewExample' product = 'WebViewExample' version = '0.1.0' + +[apps.AdvancedCustomizationExample] +identifier = 'dev.swiftcrossui.AdvancedCustomizationExample' +product = 'AdvancedCustomizationExample' +version = '0.1.0' diff --git a/Examples/Package.swift b/Examples/Package.swift index 11f210b821..fbfb9c28af 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 import Foundation import PackageDescription @@ -72,6 +72,11 @@ let package = Package( .executableTarget( name: "WebViewExample", dependencies: exampleDependencies + ), + .executableTarget( + name: "AdvancedCustomizationExample", + dependencies: exampleDependencies, + resources: [.copy("Banner.png")] ) ] ) diff --git a/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift new file mode 100644 index 0000000000..ff8054e317 --- /dev/null +++ b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift @@ -0,0 +1,198 @@ +import DefaultBackend +import Foundation +import SwiftCrossUI + +#if canImport(WinUIBackend) + import WinUI +#endif + +#if canImport(SwiftBundlerRuntime) + import SwiftBundlerRuntime +#endif + +@main +@HotReloadable +struct CounterApp: App { + @State var count = 0 + @State var value = 0.0 + @State var color: String? = nil + @State var name = "" + + var body: some Scene { + WindowGroup("CounterExample: \(count)") { + #hotReloadable { + ScrollView { + HStack(spacing: 20) { + Button("-") { + count -= 1 + } + + Text("Count: \(count)") + .inspect { text in + #if canImport(AppKitBackend) + text.isSelectable = true + #elseif canImport(UIKitBackend) + #if !targetEnvironment(macCatalyst) + text.isHighlighted = true + text.highlightTextColor = .yellow + #endif + #elseif canImport(WinUIBackend) + text.isTextSelectionEnabled = true + #elseif canImport(GtkBackend) + text.selectable = true + #elseif canImport(Gtk3Backend) + text.selectable = true + #endif + } + + Button("+") { + count += 1 + }.inspect(.afterUpdate) { button in + #if canImport(AppKitBackend) + // Button is an NSButton on macOS + button.bezelColor = .red + #elseif canImport(UIKitBackend) + if #available(iOS 15.0, *) { + button.configuration = .bordered() + } + #elseif canImport(WinUIBackend) + button.cornerRadius.topLeft = 10 + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 255, g: 0, b: 0) + button.background = brush + #elseif canImport(GtkBackend) + button.css.set(property: .backgroundColor(.init(1, 0, 0))) + #elseif canImport(Gtk3Backend) + button.css.set(property: .backgroundColor(.init(1, 0, 0))) + #endif + } + } + + Slider($value, minimum: 0, maximum: 10) + .inspect { slider in + #if canImport(AppKitBackend) + slider.numberOfTickMarks = 10 + #elseif canImport(UIKitBackend) + slider.thumbTintColor = .blue + #elseif canImport(WinUIBackend) + slider.isThumbToolTipEnabled = true + #elseif canImport(GtkBackend) + slider.drawValue = true + #elseif canImport(Gtk3Backend) + slider.drawValue = true + #endif + } + + #if !canImport(Gtk3Backend) + Picker(of: ["Red", "Green", "Blue"], selection: $color) + .inspect(.afterUpdate) { picker in + #if canImport(AppKitBackend) + picker.preferredEdge = .maxX + #elseif canImport(UIKitBackend) && os(iOS) + // Can't think of something to do to the + // UIPickerView, but the point is that you + // could do something if you needed to! + // This would be a UITableView on tvOS. + // And could be either a UITableView or a + // UIPickerView on Mac Catalyst depending + // on Mac Catalyst version and interface + // idiom. + #elseif canImport(WinUIBackend) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 255, g: 0, b: 0) + picker.background = brush + #elseif canImport(GtkBackend) + picker.enableSearch = true + #endif + } + #endif + + TextField("Name", text: $name) + .inspect(.afterUpdate) { textField in + #if canImport(AppKitBackend) + textField.backgroundColor = .blue + #elseif canImport(UIKitBackend) + textField.borderStyle = .bezel + #elseif canImport(WinUIBackend) + textField.selectionHighlightColor.color = .init(a: 255, r: 0, g: 255, b: 0) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 0, g: 0, b: 255) + textField.background = brush + #elseif canImport(GtkBackend) + textField.xalign = 1 + textField.css.set(property: .backgroundColor(.init(0, 0, 1))) + #elseif canImport(Gtk3Backend) + textField.hasFrame = false + textField.css.set(property: .backgroundColor(.init(0, 0, 1))) + #endif + } + + ScrollView { + ForEach(Array(1...50)) { number in + Text("Line \(number)") + }.padding() + }.inspect(.afterUpdate) { scrollView in + #if canImport(AppKitBackend) + scrollView.borderType = .grooveBorder + #elseif canImport(UIKitBackend) + scrollView.alwaysBounceHorizontal = true + #elseif canImport(WinUIBackend) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 0, g: 255, b: 0) + scrollView.borderBrush = brush + scrollView.borderThickness = .init( + left: 1, top: 1, right: 1, bottom: 1 + ) + #elseif canImport(GtkBackend) + scrollView.css.set(property: .border(color: .init(1, 0, 0), width: 2)) + #elseif canImport(Gtk3Backend) + scrollView.css.set(property: .border(color: .init(1, 0, 0), width: 2)) + #endif + }.frame(height: 200) + + List(["Red", "Green", "Blue"], id: \.self, selection: $color) { color in + Text(color) + }.inspect(.afterUpdate) { table in + #if canImport(AppKitBackend) + table.usesAlternatingRowBackgroundColors = true + #elseif canImport(UIKitBackend) + table.isEditing = true + #elseif canImport(WinUIBackend) + let brush = WinUI.SolidColorBrush() + brush.color = .init(a: 255, r: 255, g: 0, b: 255) + table.borderBrush = brush + table.borderThickness = .init( + left: 1, top: 1, right: 1, bottom: 1 + ) + #elseif canImport(GtkBackend) + table.showSeparators = true + #elseif canImport(Gtk3Backend) + table.selectionMode = .multiple + #endif + } + + Image(Bundle.module.bundleURL.appendingPathComponent("Banner.png")) + .resizable() + .inspect(.afterUpdate) { image in + #if canImport(AppKitBackend) + image.isEditable = true + #elseif canImport(UIKitBackend) + image.layer.borderWidth = 1 + image.layer.borderColor = .init(red: 0, green: 1, blue: 0, alpha: 1) + #elseif canImport(WinUIBackend) + // Couldn't find anything visually interesting + // to do to the WinUI.Image, but the point is + // that you could do something if you wanted to. + #elseif canImport(GtkBackend) + image.css.set(property: .border(color: .init(0, 1, 0), width: 2)) + #elseif canImport(Gtk3Backend) + image.css.set(property: .border(color: .init(0, 1, 0), width: 2)) + #endif + } + .aspectRatio(contentMode: .fit) + }.padding() + } + } + .defaultSize(width: 400, height: 200) + } +} diff --git a/Examples/Sources/AdvancedCustomizationExample/Banner.png b/Examples/Sources/AdvancedCustomizationExample/Banner.png new file mode 100644 index 0000000000..c958f40aef Binary files /dev/null and b/Examples/Sources/AdvancedCustomizationExample/Banner.png differ diff --git a/Sources/AppKitBackend/InspectionModifiers.swift b/Sources/AppKitBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..24e17205f2 --- /dev/null +++ b/Sources/AppKitBackend/InspectionModifiers.swift @@ -0,0 +1,107 @@ +import AppKit +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSButton) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSTextField) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSSlider) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Picker { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSPopUpButton) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSTextField) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSScrollView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSTableView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: NSScrollView) in + action(view.documentView as! NSTableView) + } + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSSplitView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: NSView) in + action(view.subviews[0] as! NSSplitView) + } + } +} + +extension Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSImageView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (_: NSView, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension Table { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (NSScrollView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} diff --git a/Sources/Gtk3/Pixbuf.swift b/Sources/Gtk3/Pixbuf.swift index 06b8a657d4..6a169c3b4b 100644 --- a/Sources/Gtk3/Pixbuf.swift +++ b/Sources/Gtk3/Pixbuf.swift @@ -34,10 +34,12 @@ public struct Pixbuf { } public func scaled(toWidth width: Int, andHeight height: Int) -> Pixbuf { + // This operation fails if the destination width or destination height + // is 0, so just make sure neither dimension hits zero. let newPointer = gdk_pixbuf_scale_simple( pointer, - gint(width), - gint(height), + gint(max(width, 1)), + gint(max(height, 1)), GDK_INTERP_BILINEAR ) return Pixbuf(pointer: newPointer!) diff --git a/Sources/Gtk3Backend/InspectionModifiers.swift b/Sources/Gtk3Backend/InspectionModifiers.swift new file mode 100644 index 0000000000..35edd818b1 --- /dev/null +++ b/Sources/Gtk3Backend/InspectionModifiers.swift @@ -0,0 +1,132 @@ +import Gtk3 +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Widget) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Button) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Label) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Scale) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Entry) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.ScrolledWindow) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.ListBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Paned) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: Gtk3.Fixed) in + action(view.children[0] as! Gtk3.Paned) + } + } +} + +extension SwiftCrossUI.Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Image) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (_: Gtk3.Widget, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension HStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension VStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ZStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Group { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Shape { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk3.DrawingArea) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} diff --git a/Sources/GtkBackend/InspectionModifiers.swift b/Sources/GtkBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..c791d7e73e --- /dev/null +++ b/Sources/GtkBackend/InspectionModifiers.swift @@ -0,0 +1,141 @@ +import Gtk +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Widget) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Button) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Label) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Scale) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Picker { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.DropDown) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Entry) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.ScrolledWindow) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.ListBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Paned) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: Gtk.Fixed) in + action(view.children[0] as! Gtk.Paned) + } + } +} + +extension SwiftCrossUI.Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Picture) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (_: Gtk.Widget, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension HStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension VStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ZStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Group { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.Fixed) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Shape { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (Gtk.DrawingArea) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} diff --git a/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md b/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md index ab54ea10e2..1888f248fa 100644 --- a/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md +++ b/Sources/SwiftCrossUI/SwiftCrossUI.docc/Examples.md @@ -20,6 +20,7 @@ A few examples are included with SwiftCrossUI to demonstrate some of its basic f - `NotesExample`, an app showcasing multi-line text editing and a more realistic usage of SwiftCrossUI. - `PathsExample`, an app showcasing the use of ``Path`` to draw various shapes. - `WebViewExample`, an app showcasing the use of ``WebView`` to display websites. Only works on Apple platforms so far. +- `AdvancedCustomizationExample`, an app showcasing SwiftCrossUI's more advanced APIs for customizing the underlying native views of your app. ## Running examples diff --git a/Sources/SwiftCrossUI/Views/Image.swift b/Sources/SwiftCrossUI/Views/Image.swift index 2f3be3e6f8..26b0e59815 100644 --- a/Sources/SwiftCrossUI/Views/Image.swift +++ b/Sources/SwiftCrossUI/Views/Image.swift @@ -46,7 +46,7 @@ extension Image: View { extension Image: TypeSafeView { func layoutableChildren( backend: Backend, - children: _ImageChildren + children: ImageChildren ) -> [LayoutSystem.LayoutableChild] { [] } @@ -55,12 +55,12 @@ extension Image: TypeSafeView { backend: Backend, snapshots: [ViewGraphSnapshotter.NodeSnapshot]?, environment: EnvironmentValues - ) -> _ImageChildren { - _ImageChildren(backend: backend) + ) -> ImageChildren { + ImageChildren(backend: backend) } func asWidget( - _ children: _ImageChildren, + _ children: ImageChildren, backend: Backend ) -> Backend.Widget { children.container.into() @@ -68,7 +68,7 @@ extension Image: TypeSafeView { func update( _ widget: Backend.Widget, - children: _ImageChildren, + children: ImageChildren, proposedSize: SIMD2, environment: EnvironmentValues, backend: Backend, @@ -159,12 +159,14 @@ extension Image: TypeSafeView { } } -class _ImageChildren: ViewGraphNodeChildren { +/// Image's persistent storage. Only exposed with the `package` access level +/// in order for backends to implement the `Image.inspect(_:_:)` modifier. +package class ImageChildren: ViewGraphNodeChildren { var cachedImageSource: Image.Source? = nil var cachedImage: ImageFormats.Image? = nil var cachedImageDisplaySize: SIMD2 = .zero var container: AnyWidget - var imageWidget: AnyWidget + package var imageWidget: AnyWidget var imageChanged = false var isContainerEmpty = true var lastScaleFactor: Double = 1 @@ -174,6 +176,6 @@ class _ImageChildren: ViewGraphNodeChildren { imageWidget = AnyWidget(backend.createImageView()) } - var widgets: [AnyWidget] = [] - var erasedNodes: [ErasedViewGraphNode] = [] + package var widgets: [AnyWidget] = [] + package var erasedNodes: [ErasedViewGraphNode] = [] } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift new file mode 100644 index 0000000000..e8f5d3fd7c --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/InspectModifier.swift @@ -0,0 +1,101 @@ +/// A point at which a view's underlying widget can be inspected. +public struct InspectionPoints: OptionSet, RawRepresentable, Hashable, Sendable { + public var rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let onCreate = Self(rawValue: 1 << 0) + public static let beforeUpdate = Self(rawValue: 1 << 1) + public static let afterUpdate = Self(rawValue: 1 << 2) +} + +/// The `View.inspect(_:_:)` family of modifiers is implemented within each +/// backend. Make sure to import your chosen backend in any files where you +/// need to inspect a widget. This type simply supports the implementation of +/// those backend-specific modifiers. +package struct InspectView { + var child: Child + var inspectionPoints: InspectionPoints + var action: @MainActor (_ widget: AnyWidget, _ children: any ViewGraphNodeChildren) -> Void + + package init( + child: Child, + inspectionPoints: InspectionPoints, + action: @escaping @MainActor @Sendable (WidgetType) -> Void + ) { + self.child = child + self.inspectionPoints = inspectionPoints + self.action = { widget, _ in + action(widget.into()) + } + } + + package init( + child: Child, + inspectionPoints: InspectionPoints, + action: @escaping @MainActor @Sendable (WidgetType, Children) -> Void + ) { + self.child = child + self.inspectionPoints = inspectionPoints + self.action = { widget, children in + action(widget.into(), children as! Children) + } + } +} + +extension InspectView: View { + package var body: some View { EmptyView() } + + package func asWidget( + _ children: any ViewGraphNodeChildren, + backend: Backend + ) -> Backend.Widget { + let widget = child.asWidget(children, backend: backend) + if inspectionPoints.contains(.onCreate) { + action(AnyWidget(widget), children) + } + return widget + } + + package func children( + backend: Backend, + snapshots: [ViewGraphSnapshotter.NodeSnapshot]?, + environment: EnvironmentValues + ) -> any ViewGraphNodeChildren { + child.children(backend: backend, snapshots: snapshots, environment: environment) + } + + package func layoutableChildren( + backend: Backend, + children: any ViewGraphNodeChildren + ) -> [LayoutSystem.LayoutableChild] { + child.layoutableChildren(backend: backend, children: children) + } + + package func update( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + if inspectionPoints.contains(.beforeUpdate) { + action(AnyWidget(widget), children) + } + let result = child.update( + widget, + children: children, + proposedSize: proposedSize, + environment: environment, + backend: backend, + dryRun: dryRun + ) + if inspectionPoints.contains(.afterUpdate) { + action(AnyWidget(widget), children) + } + return result + } +} diff --git a/Sources/SwiftCrossUI/Views/NavigationLink.swift b/Sources/SwiftCrossUI/Views/NavigationLink.swift index 388e704c4e..76f7c8d3dd 100644 --- a/Sources/SwiftCrossUI/Views/NavigationLink.swift +++ b/Sources/SwiftCrossUI/Views/NavigationLink.swift @@ -2,7 +2,7 @@ // some practical examples). /// A navigation primitive that appends a value to the current navigation path on click. /// -/// Unlike Apples SwiftUI API a `NavigationLink` can be outside of a `NavigationStack` +/// Unlike Apple's SwiftUI API, a `NavigationLink` can be outside of a `NavigationStack` /// as long as they share the same `NavigationPath`. public struct NavigationLink: View { public var body: some View { diff --git a/Sources/UIKitBackend/InspectionModifiers.swift b/Sources/UIKitBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..511d7b1daa --- /dev/null +++ b/Sources/UIKitBackend/InspectionModifiers.swift @@ -0,0 +1,155 @@ +import UIKit +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: any WidgetProtocol) in + action(view.view) + } + } + + nonisolated func inspectAsWrapperWidget( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WrapperWidget) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIButton) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UILabel) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UISlider) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension SwiftCrossUI.Picker { + /// Inspects the picker's underlying `UIView` on Mac Catalyst. Will be a + /// `UIPickerView` if running on Mac Catalyst 14.0+ with the Mac user + /// interface idiom, and a `UIPickerView` otherwise. + @available(macCatalyst 13.0, *) + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: any WidgetProtocol) in + if let view = view as? UITableViewPicker { + action(view.child) + } else if let view = view as? UIPickerViewPicker { + action(view.child) + } else { + action(view.view) + } + } + } + + /// Inspects the picker's underlying `UITableView` on tvOS. + @available(tvOS 13.0, *) + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UITableView) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } + + /// Inspects the picker's underlying `UIPickerView` on iOS. + @available(iOS 13.0, *) + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIPickerView) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UITextField) -> Void + ) -> some View { + inspectAsWrapperWidget(inspectionPoints) { wrapper in + action(wrapper.child) + } + } +} + +extension ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIScrollView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { (view: ScrollWidget) in + action(view.scrollView) + } + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UITableView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (view: WrapperWidget) in + action(view.child) + } + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UISplitViewController) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (view: WrapperControllerWidget) in + action(view.child) + } + } +} + +extension Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (UIImageView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (_: UIView, children: ImageChildren) in + let wrapper: WrapperWidget = children.imageWidget.into() + action(wrapper.child) + } + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Container.swift b/Sources/UIKitBackend/UIKitBackend+Container.swift index 5375a24aa4..e19d0b5546 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -2,7 +2,7 @@ import SwiftCrossUI import UIKit final class ScrollWidget: ContainerWidget { - private var scrollView = UIScrollView() + var scrollView = UIScrollView() private var childWidthConstraint: NSLayoutConstraint? private var childHeightConstraint: NSLayoutConstraint? diff --git a/Sources/WinUIBackend/InspectionModifiers.swift b/Sources/WinUIBackend/InspectionModifiers.swift new file mode 100644 index 0000000000..3706e67076 --- /dev/null +++ b/Sources/WinUIBackend/InspectionModifiers.swift @@ -0,0 +1,140 @@ +import WinUI +import SwiftCrossUI + +extension View { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.FrameworkElement) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Button { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Button) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Text { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.TextBlock) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Slider { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Slider) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Picker { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.ComboBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension TextField { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.TextBox) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.ScrollView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.ScrollViewer) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension List { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.ListView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension NavigationSplitView { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.SplitView) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Image { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Image) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints) { + (_: WinUI.FrameworkElement, children: ImageChildren) in + action(children.imageWidget.into()) + } + } +} + +extension HStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension VStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension ZStack { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension Group { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Canvas) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +} + +extension SwiftCrossUI.Shape { + public func inspect( + _ inspectionPoints: InspectionPoints = .onCreate, + _ action: @escaping @MainActor @Sendable (WinUI.Path) -> Void + ) -> some View { + InspectView(child: self, inspectionPoints: inspectionPoints, action: action) + } +}