diff --git a/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift index 6b851e5299b..13c49e4e0fd 100644 --- a/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift +++ b/Examples/Sources/AdvancedCustomizationExample/AdvancedCustomizationApp.swift @@ -119,7 +119,8 @@ struct CounterApp: App { #elseif canImport(UIKitBackend) textField.borderStyle = .bezel #elseif canImport(WinUIBackend) - textField.selectionHighlightColor.color = .init(a: 255, r: 0, g: 255, b: 0) + 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 diff --git a/Examples/Sources/ControlsExample/ControlsApp.swift b/Examples/Sources/ControlsExample/ControlsApp.swift index 81ac16adab3..89b7ec486b1 100644 --- a/Examples/Sources/ControlsExample/ControlsApp.swift +++ b/Examples/Sources/ControlsExample/ControlsApp.swift @@ -17,6 +17,8 @@ struct ControlsApp: App { @State var flavor: String? = nil @State var enabled = true @State var menuToggleState = false + @State var progressViewSize: Int = 10 + @State var isProgressViewResizable = true var body: some Scene { WindowGroup("ControlsApp") { @@ -83,11 +85,21 @@ struct ControlsApp: App { Text("Value: \(text)") } + VStack { + Toggle( + "Enable ProgressView resizability", isOn: $isProgressViewResizable) + Slider(value: $progressViewSize, in: 10...100) + ProgressView() + .resizable(isProgressViewResizable) + .frame(width: progressViewSize, height: progressViewSize) + } + VStack { Text("Drop down") HStack { Text("Flavor: ") - Picker(of: ["Vanilla", "Chocolate", "Strawberry"], selection: $flavor) + Picker( + of: ["Vanilla", "Chocolate", "Strawberry"], selection: $flavor) } Text("You chose: \(flavor ?? "Nothing yet!")") } diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 23d2fa30abb..4e8177c6d4f 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -502,6 +502,15 @@ public final class AppKitBackend: AppBackend { } public func naturalSize(of widget: Widget) -> SIMD2 { + if let spinner = widget.subviews.first as? NSProgressIndicator, + spinner.style == .spinning + { + let size = spinner.intrinsicContentSize + return SIMD2( + Int(size.width), + Int(size.height) + ) + } let size = widget.intrinsicContentSize return SIMD2( Int(size.width), @@ -1199,11 +1208,32 @@ public final class AppKitBackend: AppBackend { } public func createProgressSpinner() -> Widget { + let container = NSView() + let spinner = NSProgressIndicator() + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.isIndeterminate = true + spinner.style = .spinning + spinner.startAnimation(nil) + container.addSubview(spinner) + return container + } + + public func setSize( + ofProgressSpinner widget: Widget, + to size: SIMD2 + ) { + guard Int(widget.frame.size.height) != size.y else { return } + setSize(of: widget, to: size) let spinner = NSProgressIndicator() + spinner.translatesAutoresizingMaskIntoConstraints = false spinner.isIndeterminate = true spinner.style = .spinning spinner.startAnimation(nil) - return spinner + spinner.widthAnchor.constraint(equalToConstant: CGFloat(size.x)).isActive = true + spinner.heightAnchor.constraint(equalToConstant: CGFloat(size.y)).isActive = true + + widget.subviews = [] + widget.addSubview(spinner) } public func createProgressBar() -> Widget { diff --git a/Sources/AppKitBackend/InspectionModifiers.swift b/Sources/AppKitBackend/InspectionModifiers.swift index 24e17205f26..fa4d7bd0f3e 100644 --- a/Sources/AppKitBackend/InspectionModifiers.swift +++ b/Sources/AppKitBackend/InspectionModifiers.swift @@ -91,7 +91,8 @@ extension Image { _ inspectionPoints: InspectionPoints = .onCreate, _ action: @escaping @MainActor @Sendable (NSImageView) -> Void ) -> some View { - InspectView(child: self, inspectionPoints: inspectionPoints) { (_: NSView, children: ImageChildren) in + InspectView(child: self, inspectionPoints: inspectionPoints) { + (_: NSView, children: ImageChildren) in action(children.imageWidget.into()) } } diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index c127582263f..7a404e48f08 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -86,9 +86,11 @@ extension NSViewRepresentable { let growsHorizontally = nsView.contentHuggingPriority(for: .horizontal) < .defaultHigh let growsVertically = nsView.contentHuggingPriority(for: .vertical) < .defaultHigh - let idealWidth = intrinsicSize.width == NSView.noIntrinsicMetric + let idealWidth = + intrinsicSize.width == NSView.noIntrinsicMetric ? 10 : intrinsicSize.width - let idealHeight = intrinsicSize.height == NSView.noIntrinsicMetric + let idealHeight = + intrinsicSize.height == NSView.noIntrinsicMetric ? 10 : intrinsicSize.height // When the view doesn't grow along a dimension, we use its fittingSize diff --git a/Sources/Gtk3/Utility/GSimpleAction.swift b/Sources/Gtk3/Utility/GSimpleAction.swift index e2c00e31c9c..a5274419b34 100644 --- a/Sources/Gtk3/Utility/GSimpleAction.swift +++ b/Sources/Gtk3/Utility/GSimpleAction.swift @@ -51,11 +51,11 @@ public class GSimpleAction: GAction, GObjectRepresentable { private func connectActionSignal( _ value: some AnyObject, handler: - @convention(c) ( - UnsafeMutableRawPointer, - OpaquePointer, - UnsafeMutableRawPointer - ) -> Void + @convention(c) ( + UnsafeMutableRawPointer, + OpaquePointer, + UnsafeMutableRawPointer + ) -> Void ) { g_signal_connect_data( UnsafeMutableRawPointer(actionPointer), diff --git a/Sources/Gtk3Backend/InspectionModifiers.swift b/Sources/Gtk3Backend/InspectionModifiers.swift index 35edd818b12..5b8ff98d395 100644 --- a/Sources/Gtk3Backend/InspectionModifiers.swift +++ b/Sources/Gtk3Backend/InspectionModifiers.swift @@ -80,7 +80,8 @@ extension SwiftCrossUI.Image { _ inspectionPoints: InspectionPoints = .onCreate, _ action: @escaping @MainActor @Sendable (Gtk3.Image) -> Void ) -> some View { - InspectView(child: self, inspectionPoints: inspectionPoints) { (_: Gtk3.Widget, children: ImageChildren) in + InspectView(child: self, inspectionPoints: inspectionPoints) { + (_: Gtk3.Widget, children: ImageChildren) in action(children.imageWidget.into()) } } diff --git a/Sources/GtkBackend/InspectionModifiers.swift b/Sources/GtkBackend/InspectionModifiers.swift index c791d7e73ea..cd63391c765 100644 --- a/Sources/GtkBackend/InspectionModifiers.swift +++ b/Sources/GtkBackend/InspectionModifiers.swift @@ -89,7 +89,8 @@ extension SwiftCrossUI.Image { _ inspectionPoints: InspectionPoints = .onCreate, _ action: @escaping @MainActor @Sendable (Gtk.Picture) -> Void ) -> some View { - InspectView(child: self, inspectionPoints: inspectionPoints) { (_: Gtk.Widget, children: ImageChildren) in + InspectView(child: self, inspectionPoints: inspectionPoints) { + (_: Gtk.Widget, children: ImageChildren) in action(children.imageWidget.into()) } } diff --git a/Sources/SwiftCrossUI/Backend/AnyWidget.swift b/Sources/SwiftCrossUI/Backend/AnyWidget.swift index eeffd34225e..49bd6bc859d 100644 --- a/Sources/SwiftCrossUI/Backend/AnyWidget.swift +++ b/Sources/SwiftCrossUI/Backend/AnyWidget.swift @@ -23,7 +23,9 @@ public class AnyWidget { for backend: Backend.Type ) -> Backend.Widget { guard let widget = widget as? Backend.Widget else { - fatalError("AnyWidget used with incompatible backend \(backend); widget type is \(type(of: widget))") + fatalError( + "AnyWidget used with incompatible backend \(backend); widget type is \(type(of: widget))" + ) } return widget } @@ -33,7 +35,9 @@ 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); actual widget type is \(type(of: widget))") + fatalError( + "AnyWidget used with incompatible widget type \(T.self); actual widget type is \(type(of: widget))" + ) } return widget } diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 6641ff9d71f..1d2eb976833 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -550,6 +550,16 @@ public protocol AppBackend: Sendable { /// Creates an indeterminate progress spinner. func createProgressSpinner() -> Widget + /// Sets the size of a progress spinner. + /// + /// This method exists because AppKitBackend requires special handling to resize progress spinners. + /// + /// The default implementation forwards to ``AppBackend/setSize(of:to:)``. + func setSize( + ofProgressSpinner widget: Widget, + to size: SIMD2 + ) + /// Creates a progress bar. func createProgressBar() -> Widget /// Updates a progress bar to reflect the given progress (between 0 and 1), and the @@ -1123,6 +1133,13 @@ extension AppBackend { todo() } + public func setSize( + ofProgressSpinner widget: Widget, + to size: SIMD2 + ) { + setSize(of: widget, to: size) + } + public func createProgressBar() -> Widget { todo() } diff --git a/Sources/SwiftCrossUI/Scenes/AlertScene.swift b/Sources/SwiftCrossUI/Scenes/AlertScene.swift index 137cf7db478..885056d9775 100644 --- a/Sources/SwiftCrossUI/Scenes/AlertScene.swift +++ b/Sources/SwiftCrossUI/Scenes/AlertScene.swift @@ -73,7 +73,7 @@ public final class AlertSceneNode: SceneGraphNode { } self.alert = alert - } else if !scene.isPresented, let alert { + } else if !scene.isPresented, let alert { backend.dismissAlert(alert as! Backend.Alert, window: nil) self.alert = nil } diff --git a/Sources/SwiftCrossUI/Views/Menu.swift b/Sources/SwiftCrossUI/Views/Menu.swift index df8519d4f03..4454175a589 100644 --- a/Sources/SwiftCrossUI/Views/Menu.swift +++ b/Sources/SwiftCrossUI/Views/Menu.swift @@ -96,8 +96,9 @@ extension Menu: TypeSafeView { action: {} ) case .menuButton: - let menu = children.menu.flatMap { $0 as? Backend.Menu } - ?? backend.createPopoverMenu() + let menu = + children.menu.flatMap { $0 as? Backend.Menu } + ?? backend.createPopoverMenu() children.menu = menu backend.updateButton( widget, diff --git a/Sources/SwiftCrossUI/Views/ProgressView.swift b/Sources/SwiftCrossUI/Views/ProgressView.swift index abd34ca147c..f3ebabba358 100644 --- a/Sources/SwiftCrossUI/Views/ProgressView.swift +++ b/Sources/SwiftCrossUI/Views/ProgressView.swift @@ -4,6 +4,7 @@ public struct ProgressView: View { private var label: Label private var progress: Double? private var kind: Kind + private var isSpinnerResizable: Bool = false private enum Kind { case spinner @@ -23,7 +24,7 @@ public struct ProgressView: View { private var progressIndicator: some View { switch kind { case .spinner: - ProgressSpinnerView() + ProgressSpinnerView(isResizable: isSpinnerResizable) case .bar: ProgressBarView(value: progress) } @@ -50,6 +51,14 @@ public struct ProgressView: View { self.kind = .bar self.progress = value.map(Double.init) } + + /// Makes the ProgressView resize to fit the available space. + /// Only affects ``Kind/spinner``. + public func resizable(_ isResizable: Bool = true) -> Self { + var progressView = self + progressView.isSpinnerResizable = isResizable + return progressView + } } extension ProgressView where Label == EmptyView { @@ -101,7 +110,11 @@ extension ProgressView where Label == Text { } struct ProgressSpinnerView: ElementaryView { - init() {} + let isResizable: Bool + + init(isResizable: Bool = false) { + self.isResizable = isResizable + } func asWidget(backend: Backend) -> Backend.Widget { backend.createProgressSpinner() @@ -113,8 +126,27 @@ struct ProgressSpinnerView: ElementaryView { environment: EnvironmentValues, backend: Backend ) -> ViewLayoutResult { - let size = ViewSize(backend.naturalSize(of: widget)) - return ViewLayoutResult.leafView(size: size) + let naturalSize = backend.naturalSize(of: widget) + + guard isResizable else { + return ViewLayoutResult.leafView(size: ViewSize(naturalSize)) + } + + let dimension: Double + + if let proposedWidth = proposedSize.width, let proposedHeight = proposedSize.height { + dimension = min(proposedWidth, proposedHeight) + } else if let proposedWidth = proposedSize.width { + dimension = proposedWidth + } else if let proposedHeight = proposedSize.height { + dimension = proposedHeight + } else { + dimension = Double(min(naturalSize.x, naturalSize.y)) + } + + return ViewLayoutResult.leafView( + size: ViewSize(dimension, dimension) + ) } func commit( @@ -122,7 +154,12 @@ struct ProgressSpinnerView: ElementaryView { layout: ViewLayoutResult, environment: EnvironmentValues, backend: Backend - ) {} + ) { + // Doesn't change the rendered size of ProgressSpinner + // on UIKitBackend, but still sets container size to + // (width: n, height: n) n = min(proposedSize.x, proposedSize.y) + backend.setSize(ofProgressSpinner: widget, to: layout.size.vector) + } } struct ProgressBarView: ElementaryView { diff --git a/Sources/UIKitBackend/InspectionModifiers.swift b/Sources/UIKitBackend/InspectionModifiers.swift index ba1af35b1c6..19ea355372a 100644 --- a/Sources/UIKitBackend/InspectionModifiers.swift +++ b/Sources/UIKitBackend/InspectionModifiers.swift @@ -1,5 +1,5 @@ -import UIKit import SwiftCrossUI +import UIKit extension View { public func inspect( diff --git a/Sources/UIKitBackend/UIKitBackend+Menu.swift b/Sources/UIKitBackend/UIKitBackend+Menu.swift index 8feb20d60a0..7f0ad57b66d 100644 --- a/Sources/UIKitBackend/UIKitBackend+Menu.swift +++ b/Sources/UIKitBackend/UIKitBackend+Menu.swift @@ -59,11 +59,12 @@ extension UIKitBackend { buttonWidget.child.menu = menu.uiMenu buttonWidget.child.showsMenuAsPrimaryAction = true if #available(iOS 16, tvOS 17, macCatalyst 16, *) { - buttonWidget.child.preferredMenuElementOrder = switch environment.menuOrder { - case .automatic: .automatic - case .priority: .priority - case .fixed: .fixed - } + buttonWidget.child.preferredMenuElementOrder = + switch environment.menuOrder { + case .automatic: .automatic + case .priority: .priority + case .fixed: .fixed + } } } else { preconditionFailure("Current OS is too old to support menu buttons.") diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 5a144c6f988..4c927f8113e 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -73,9 +73,11 @@ func defaultViewSize(proposal: ProposedViewSize, view: UIView) -> ViewSize { let growsHorizontally = view.contentHuggingPriority(for: .horizontal) < .defaultHigh let growsVertically = view.contentHuggingPriority(for: .vertical) < .defaultHigh - let idealWidth = intrinsicSize.width == UIView.noIntrinsicMetric + let idealWidth = + intrinsicSize.width == UIView.noIntrinsicMetric ? 10 : intrinsicSize.width - let idealHeight = intrinsicSize.height == UIView.noIntrinsicMetric + let idealHeight = + intrinsicSize.height == UIView.noIntrinsicMetric ? 10 : intrinsicSize.height // When the view doesn't grow along a dimension, we use its fittingSize diff --git a/Sources/WinUIBackend/InspectionModifiers.swift b/Sources/WinUIBackend/InspectionModifiers.swift index 3706e670760..258a2abf94a 100644 --- a/Sources/WinUIBackend/InspectionModifiers.swift +++ b/Sources/WinUIBackend/InspectionModifiers.swift @@ -1,5 +1,5 @@ -import WinUI import SwiftCrossUI +import WinUI extension View { public func inspect( diff --git a/Sources/WinUIBackend/WinUIElementRepresentable.swift b/Sources/WinUIBackend/WinUIElementRepresentable.swift index 299e7e976b5..e6d22a0e70f 100644 --- a/Sources/WinUIBackend/WinUIElementRepresentable.swift +++ b/Sources/WinUIBackend/WinUIElementRepresentable.swift @@ -1,6 +1,6 @@ +import SwiftCrossUI 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.