diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 69de82d110b..8af595901f7 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -161,9 +161,12 @@ struct SheetDemo: View { @HotReloadable struct WindowingApp: App { @State var title = "My window" - @State var resizable = false + @State var resizable = true @State var isAlertSceneShown = false @State var toggle = false + @State var enforceMaxSize = true + @State var closable = true + @State var minimizable = true var body: some Scene { WindowGroup(title) { @@ -174,9 +177,12 @@ struct WindowingApp: App { TextField("My window", text: $title) } - Button(resizable ? "Disable resizing" : "Enable resizing") { - resizable = !resizable - } + Toggle("Enable resizing", isOn: $resizable) + .windowResizeBehavior(resizable ? .enabled : .disabled) + Toggle("Enable closing", isOn: $closable) + .windowDismissBehavior(closable ? .enabled : .disabled) + Toggle("Enable minimizing", isOn: $minimizable) + .preferredWindowMinimizeBehavior(minimizable ? .enabled : .disabled) Image(Bundle.module.bundleURL.appendingPathComponent("Banner.png")) .resizable() @@ -204,7 +210,6 @@ struct WindowingApp: App { } } .defaultSize(width: 500, height: 500) - .windowResizability(resizable ? .contentMinSize : .contentSize) .commands { CommandMenu("Demo menu") { Button("Menu item") {} @@ -225,10 +230,13 @@ struct WindowingApp: App { #hotReloadable { Text("This a secondary window!") .padding(10) + + Toggle("Enforce max size", isOn: $enforceMaxSize) + .toggleStyle(.checkbox) } } .defaultSize(width: 200, height: 200) - .windowResizability(.contentMinSize) + .windowResizability(enforceMaxSize ? .contentSize : .contentMinSize) WindowGroup("Tertiary window") { #hotReloadable { @@ -237,7 +245,6 @@ struct WindowingApp: App { } } .defaultSize(width: 200, height: 200) - .windowResizability(.contentMinSize) #endif } } diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 4e8177c6d4f..d5f48dc15cb 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -87,9 +87,18 @@ public final class AppKitBackend: AppBackend { window.setContentSize(NSSize(width: newSize.x, height: newSize.y)) } - public func setMinimumSize(ofWindow window: Window, to minimumSize: SIMD2) { - window.contentMinSize.width = CGFloat(minimumSize.x) - window.contentMinSize.height = CGFloat(minimumSize.y) + public func setSizeLimits( + ofWindow window: Window, + minimum minimumSize: SIMD2, + maximum maximumSize: SIMD2? + ) { + window.contentMinSize = CGSize(width: minimumSize.x, height: minimumSize.y) + window.contentMaxSize = + if let maximumSize { + CGSize(width: maximumSize.x, height: maximumSize.y) + } else { + CGSize(width: Double.infinity, height: .infinity) + } } public func setResizeHandler( @@ -103,7 +112,24 @@ public final class AppKitBackend: AppBackend { window.title = title } - public func setResizability(ofWindow window: Window, to resizable: Bool) { + public func setBehaviors( + ofWindow window: Window, + closable: Bool, + minimizable: Bool, + resizable: Bool + ) { + if closable { + window.styleMask.insert(.closable) + } else { + window.styleMask.remove(.closable) + } + + if minimizable { + window.styleMask.insert(.miniaturizable) + } else { + window.styleMask.remove(.miniaturizable) + } + if resizable { window.styleMask.insert(.resizable) } else { diff --git a/Sources/DummyBackend/DummyBackend.swift b/Sources/DummyBackend/DummyBackend.swift index 28dbabab667..861a67d3c68 100644 --- a/Sources/DummyBackend/DummyBackend.swift +++ b/Sources/DummyBackend/DummyBackend.swift @@ -7,8 +7,11 @@ public final class DummyBackend: AppBackend { public var size: SIMD2 public var minimumSize: SIMD2 = .zero + public var maximumSize: SIMD2? public var title = "Window" public var resizable = true + public var closable = true + public var minimizable = true public var content: Widget? public var resizeHandler: ((SIMD2) -> Void)? @@ -264,7 +267,14 @@ public final class DummyBackend: AppBackend { window.title = title } - public func setResizability(ofWindow window: Window, to resizable: Bool) { + public func setBehaviors( + ofWindow window: Window, + closable: Bool, + minimizable: Bool, + resizable: Bool + ) { + window.closable = closable + window.minimizable = minimizable window.resizable = resizable } @@ -284,8 +294,13 @@ public final class DummyBackend: AppBackend { window.size = newSize } - public func setMinimumSize(ofWindow window: Window, to minimumSize: SIMD2) { + public func setSizeLimits( + ofWindow window: Window, + minimum minimumSize: SIMD2, + maximum maximumSize: SIMD2? + ) { window.minimumSize = minimumSize + window.maximumSize = maximumSize } public func setResizeHandler(ofWindow window: Window, to action: @escaping (SIMD2) -> Void) diff --git a/Sources/Gtk/Widgets/Window.swift b/Sources/Gtk/Widgets/Window.swift index 210ca9a740a..905b392ac72 100644 --- a/Sources/Gtk/Widgets/Window.swift +++ b/Sources/Gtk/Widgets/Window.swift @@ -13,6 +13,7 @@ open class Window: Widget { @GObjectProperty(named: "title") public var title: String? @GObjectProperty(named: "resizable") public var resizable: Bool + @GObjectProperty(named: "deletable") public var deletable: Bool @GObjectProperty(named: "modal") public var isModal: Bool @GObjectProperty(named: "decorated") public var isDecorated: Bool @GObjectProperty(named: "destroy-with-parent") public var destroyWithParent: Bool diff --git a/Sources/Gtk3/Widgets/Window.swift b/Sources/Gtk3/Widgets/Window.swift index 0613f3166de..3717716243c 100644 --- a/Sources/Gtk3/Widgets/Window.swift +++ b/Sources/Gtk3/Widgets/Window.swift @@ -11,6 +11,7 @@ open class Window: Bin { @GObjectProperty(named: "title") public var title: String? @GObjectProperty(named: "resizable") public var resizable: Bool + @GObjectProperty(named: "deletable") public var deletable: Bool @GObjectProperty(named: "modal") public var isModal: Bool @GObjectProperty(named: "decorated") public var isDecorated: Bool diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 542476e1ea8..30b91ef712c 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -50,6 +50,28 @@ public final class Gtk3Backend: AppBackend { /// precreated window until it gets 'created' via `createWindow`. var windows: [Window] = [] + private struct LogLocation: Hashable, Equatable { + let file: String + let line: Int + let column: Int + } + + private var logsPerformed: Set = [] + + func debugLogOnce( + _ message: String, + file: String = #file, + line: Int = #line, + column: Int = #column + ) { + #if DEBUG + let location = LogLocation(file: file, line: line, column: column) + if logsPerformed.insert(location).inserted { + logger.notice("\(message)") + } + #endif + } + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -160,7 +182,18 @@ public final class Gtk3Backend: AppBackend { window.title = title } - public func setResizability(ofWindow window: Window, to resizable: Bool) { + public func setBehaviors( + ofWindow window: Window, + closable: Bool, + minimizable: Bool, + resizable: Bool + ) { + // FIXME: This doesn't seem to work on macOS at least + window.deletable = closable + + // TODO: Figure out if there's some magic way to disable minimization + // in a framework where the minimize button usually doesn't even exist + window.resizable = resizable } @@ -207,9 +240,19 @@ public final class Gtk3Backend: AppBackend { ) } - public func setMinimumSize(ofWindow window: Window, to minimumSize: SIMD2) { + public func setSizeLimits( + ofWindow window: Window, + minimum minimumSize: SIMD2, + maximum maximumSize: SIMD2? + ) { let child = window.child! as! CustomRootWidget child.setMinimumSize(minimumWidth: minimumSize.x, minimumHeight: minimumSize.y) + + // NB: GTK does not support setting maximum sizes for widgets. It just doesn't. + // https://discourse.gnome.org/t/how-to-build-fixed-size-windows-in-gtk-4/22807/10 + if maximumSize != nil { + debugLogOnce("GTK does not support setting maximum window sizes") + } } public func setResizeHandler( diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index d074dc532b7..fe0288d8ffb 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -55,6 +55,28 @@ public final class GtkBackend: AppBackend { /// precreated window until it gets 'created' via `createWindow`. var windows: [Window] = [] + private struct LogLocation: Hashable, Equatable { + let file: String + let line: Int + let column: Int + } + + private var logsPerformed: Set = [] + + func debugLogOnce( + _ message: String, + file: String = #file, + line: Int = #line, + column: Int = #column + ) { + #if DEBUG + let location = LogLocation(file: file, line: line, column: column) + if logsPerformed.insert(location).inserted { + logger.notice("\(message)") + } + #endif + } + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -149,7 +171,18 @@ public final class GtkBackend: AppBackend { window.title = title } - public func setResizability(ofWindow window: Window, to resizable: Bool) { + public func setBehaviors( + ofWindow window: Window, + closable: Bool, + minimizable: Bool, + resizable: Bool + ) { + // FIXME: This doesn't seem to work on macOS at least + window.deletable = closable + + // TODO: Figure out if there's some magic way to disable minimization + // in a framework where the minimize button usually doesn't even exist + window.resizable = resizable } @@ -192,8 +225,18 @@ public final class GtkBackend: AppBackend { child.preemptAllocatedSize(allocatedWidth: newSize.x, allocatedHeight: newSize.y) } - public func setMinimumSize(ofWindow window: Window, to minimumSize: SIMD2) { + public func setSizeLimits( + ofWindow window: Window, + minimum minimumSize: SIMD2, + maximum maximumSize: SIMD2? + ) { window.setMinimumSize(to: Size(width: minimumSize.x, height: minimumSize.y)) + + // NB: GTK does not support setting maximum sizes for widgets. It just doesn't. + // https://discourse.gnome.org/t/how-to-build-fixed-size-windows-in-gtk-4/22807/10 + if maximumSize != nil { + debugLogOnce("GTK does not support setting maximum window sizes") + } } public func setResizeHandler( diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 1d2eb976833..ebdb19b1783 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -136,9 +136,20 @@ public protocol AppBackend: Sendable { func createWindow(withDefaultSize defaultSize: SIMD2?) -> Window /// Sets the title of a window. func setTitle(ofWindow window: Window, to title: String) - /// Sets the resizability of a window. Even if resizable, the window - /// shouldn't be allowed to become smaller than its content. - func setResizability(ofWindow window: Window, to resizable: Bool) + /// Sets the behaviors of a window. + /// - Parameters: + /// - window: The window to set the behaviors on. + /// - closable: Whether the window can be closed by the user. + /// - minimizable: Whether the window can be minimized by the user. + /// - resizable: Whether the window can be resized by the user. Even if + /// resizable, the window shouldn't be allowed to become smaller than its + /// minimum size, or larger than its maximum size. + func setBehaviors( + ofWindow window: Window, + closable: Bool, + minimizable: Bool, + resizable: Bool + ) /// Sets the root child of a window (replaces the previous child if any). func setChild(ofWindow window: Window, to child: Widget) /// Gets the size of the given window in pixels. @@ -148,9 +159,20 @@ public protocol AppBackend: Sendable { func isWindowProgrammaticallyResizable(_ window: Window) -> Bool /// Sets the size of the given window in pixels. func setSize(ofWindow window: Window, to newSize: SIMD2) - /// Sets the minimum width and height of the window. Prevents the user from making the - /// window any smaller than the given size. - func setMinimumSize(ofWindow window: Window, to minimumSize: SIMD2) + /// Sets the minimum and maximum width and height of a window. + /// + /// Prevents the user from making the window any smaller or larger than the given minimum and + /// maximum sizes, respectively. + /// - Parameters: + /// - window: The window to set the size limits of. + /// - minimumSize: The minimum window size. + /// - maximumSize: The maximum window size. If `nil`, any existing maximum size + /// constraints should be removed. + func setSizeLimits( + ofWindow window: Window, + minimum minimumSize: SIMD2, + maximum maximumSize: SIMD2? + ) /// Sets the handler for the window's resizing events. `action` takes the proposed size /// of the window and returns the final size for the window (which allows SwiftCrossUI /// to implement features such as dynamic minimum window sizes based off the content's diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index 5925f325b32..d6a0adfb378 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -98,6 +98,9 @@ public struct EnvironmentValues { /// Whether the text should be selectable. Set by ``View/textSelectionEnabled(_:)``. public var isTextSelectionEnabled: Bool + /// The resizing behaviour of the current window. + var windowResizability: WindowResizability + /// The menu ordering to use. public var menuOrder: MenuOrder @@ -229,6 +232,7 @@ public struct EnvironmentValues { isEnabled = true scrollDismissesKeyboardMode = .automatic isTextSelectionEnabled = false + windowResizability = .automatic menuOrder = .automatic allowLayoutCaching = false } diff --git a/Sources/SwiftCrossUI/Scenes/Modifiers/WindowResizabilityModifier.swift b/Sources/SwiftCrossUI/Scenes/Modifiers/WindowResizabilityModifier.swift new file mode 100644 index 00000000000..f640b9b5213 --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/Modifiers/WindowResizabilityModifier.swift @@ -0,0 +1,12 @@ +extension Scene { + /// Sets the resizability of windows controlled by this scene. + /// + /// This modifier controls how SwiftCrossUI determines the bounds within which + /// windows can be resized, whereas ``View/windowResizeBehavior(_:)`` controls + /// whether the user can resize the enclosing window. The only time this + /// modifier can disable interactive resizing is when a window's content has + /// a fixed size and `resizability` is ``WindowResizability/contentSize``. + public func windowResizability(_ resizability: WindowResizability) -> some Scene { + environment(\.windowResizability, resizability) + } +} diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroup.swift b/Sources/SwiftCrossUI/Scenes/WindowGroup.swift index f9ab41cd839..7a407ad2284 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroup.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowGroup.swift @@ -25,8 +25,6 @@ public struct WindowGroup: Scene { /// The default size of the window (only has effect at time of creation). Defaults to /// 900x450. var defaultSize: SIMD2 - /// The window's resizing behaviour. - var resizability: WindowResizability /// Creates a window group optionally specifying a title. Window title defaults /// to `ProcessInfo.processInfo.processName`. @@ -37,7 +35,6 @@ public struct WindowGroup: Scene { #else self.title = title ?? ProcessInfo.processInfo.processName #endif - resizability = .automatic defaultSize = SIMD2(900, 450) } @@ -47,11 +44,4 @@ public struct WindowGroup: Scene { windowGroup.defaultSize = SIMD2(width, height) return windowGroup } - - /// Sets the resizability of a window. - public func windowResizability(_ resizability: WindowResizability) -> Self { - var windowGroup = self - windowGroup.resizability = resizability - return windowGroup - } } diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift b/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift index 8b99eb09055..b8cedc70075 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift @@ -39,7 +39,6 @@ public final class WindowGroupNode: SceneGraphNode { backend.setChild(ofWindow: window, to: container) backend.setTitle(ofWindow: window, to: scene.title) - backend.setResizability(ofWindow: window, to: scene.resizability.isResizable) self.window = window parentEnvironment = environment @@ -146,7 +145,6 @@ public final class WindowGroupNode: SceneGraphNode { // the default size changed would resize the window (which is incorrect // behaviour). backend.setTitle(ofWindow: window, to: newScene.title) - backend.setResizability(ofWindow: window, to: newScene.resizability.isResizable) scene = newScene } @@ -167,59 +165,63 @@ public final class WindowGroupNode: SceneGraphNode { } .with(\.window, window) - let finalContentResult: ViewLayoutResult - if scene.resizability.isResizable { - let minimumWindowSize = viewGraph.computeLayout( - with: newScene?.body, - proposedSize: .zero, - environment: environment.with(\.allowLayoutCaching, true) - ).size + let minimumWindowSize = viewGraph.computeLayout( + with: newScene?.body, + proposedSize: .zero, + environment: environment.with(\.allowLayoutCaching, true) + ).size + + // With `.contentSize`, the window's maximum size is the maximum size of its + // content. With `.contentMinSize` (and `.automatic`), there is no maximum + // size. + let maximumWindowSize: ViewSize? = switch environment.windowResizability { + case .contentSize: + viewGraph.computeLayout( + with: newScene?.body, + proposedSize: .infinity, + environment: environment.with(\.allowLayoutCaching, true) + ).size + case .automatic, .contentMinSize: + nil + } - let clampedWindowSize = ViewSize( - max(minimumWindowSize.width, Double(proposedWindowSize.x)), + let clampedWindowSize = ViewSize( + min( + maximumWindowSize?.width ?? .infinity, + max(minimumWindowSize.width, Double(proposedWindowSize.x)) + ), + min( + maximumWindowSize?.height ?? .infinity, max(minimumWindowSize.height, Double(proposedWindowSize.y)) ) + ) - if clampedWindowSize.vector != proposedWindowSize && !windowSizeIsFinal { - // Restart the window update if the content has caused the window to - // change size. - return update( - scene, - proposedWindowSize: clampedWindowSize.vector, - needsWindowSizeCommit: true, - backend: backend, - environment: environment, - windowSizeIsFinal: true - ) - } - - // Set this even if the window isn't programmatically resizable - // because the window may still be user resizable. - backend.setMinimumSize(ofWindow: window, to: minimumWindowSize.vector) - - finalContentResult = viewGraph.computeLayout( - proposedSize: ProposedViewSize(proposedWindowSize), - environment: environment - ) - } else { - let initialContentResult = viewGraph.computeLayout( - with: newScene?.body, - proposedSize: ProposedViewSize(proposedWindowSize), - environment: environment + if clampedWindowSize.vector != proposedWindowSize && !windowSizeIsFinal { + // Restart the window update if the content has caused the window to + // change size. + return update( + scene, + proposedWindowSize: clampedWindowSize.vector, + needsWindowSizeCommit: true, + backend: backend, + environment: environment, + windowSizeIsFinal: true ) - if initialContentResult.size.vector != proposedWindowSize && !windowSizeIsFinal { - return update( - scene, - proposedWindowSize: initialContentResult.size.vector, - needsWindowSizeCommit: true, - backend: backend, - environment: environment, - windowSizeIsFinal: true - ) - } - finalContentResult = initialContentResult } + // Set these even if the window isn't programmatically resizable + // because the window may still be user resizable. + backend.setSizeLimits( + ofWindow: window, + minimum: minimumWindowSize.vector, + maximum: maximumWindowSize?.vector + ) + + let finalContentResult = viewGraph.computeLayout( + proposedSize: ProposedViewSize(proposedWindowSize), + environment: environment + ) + viewGraph.commit() backend.setPosition( @@ -232,6 +234,16 @@ public final class WindowGroupNode: SceneGraphNode { backend.setSize(ofWindow: window, to: proposedWindowSize) } + backend.setBehaviors( + ofWindow: window, + closable: + finalContentResult.preferences.windowDismissBehavior?.isEnabled ?? true, + minimizable: + finalContentResult.preferences.preferredWindowMinimizeBehavior?.isEnabled ?? true, + resizable: + finalContentResult.preferences.windowResizeBehavior?.isEnabled ?? true + ) + if isFirstUpdate { backend.show(window: window) isFirstUpdate = false diff --git a/Sources/SwiftCrossUI/Values/WindowInteractionBehavior.swift b/Sources/SwiftCrossUI/Values/WindowInteractionBehavior.swift new file mode 100644 index 00000000000..731907f4054 --- /dev/null +++ b/Sources/SwiftCrossUI/Values/WindowInteractionBehavior.swift @@ -0,0 +1,16 @@ +/// The behavior for a window interaction. +public enum WindowInteractionBehavior: Sendable { + /// The automatic behavior. + case automatic + /// The disabled behavior. + case disabled + /// The enabled behavior. + case enabled + + var isEnabled: Bool { + switch self { + case .automatic, .enabled: true + case .disabled: false + } + } +} diff --git a/Sources/SwiftCrossUI/Values/WindowResizability.swift b/Sources/SwiftCrossUI/Values/WindowResizability.swift index 312b224675e..b6a30a20df5 100644 --- a/Sources/SwiftCrossUI/Values/WindowResizability.swift +++ b/Sources/SwiftCrossUI/Values/WindowResizability.swift @@ -4,17 +4,10 @@ public enum WindowResizability: Sendable { /// on the type of scene. This currently means it'll just default to `contentMinSize`. case automatic /// The window is not resizable and its size is tied to the size of its content. + /// + /// This is not supported on GTK; it behaves identically to ``contentMinSize`` on + /// GtkBackend and Gtk3Backend. case contentSize /// The window is resizable but must be at least as big as its content. case contentMinSize - - /// Whether this level of resizability implies that the window is resizable. - var isResizable: Bool { - switch self { - case .automatic, .contentMinSize: - return true - case .contentSize: - return false - } - } } diff --git a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift index f089beb40d5..1e0efcf49ec 100644 --- a/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift +++ b/Sources/SwiftCrossUI/ViewGraph/PreferenceValues.swift @@ -7,7 +7,10 @@ public struct PreferenceValues: Sendable { presentationCornerRadius: nil, presentationDragIndicatorVisibility: nil, presentationBackground: nil, - interactiveDismissDisabled: nil + interactiveDismissDisabled: nil, + windowDismissBehavior: nil, + preferredWindowMinimizeBehavior: nil, + windowResizeBehavior: nil ) public var onOpenURL: (@Sendable @MainActor (URL) -> Void)? @@ -27,13 +30,25 @@ public struct PreferenceValues: Sendable { /// Controls whether the user can interactively dismiss enclosing sheets. public var interactiveDismissDisabled: Bool? + /// Controls whether the user can close the enclosing window. + public var windowDismissBehavior: WindowInteractionBehavior? + + /// Controls whether the user can minimize the enclosing window. + public var preferredWindowMinimizeBehavior: WindowInteractionBehavior? + + /// Controls whether the user can resize the enclosing window. + public var windowResizeBehavior: WindowInteractionBehavior? + init( onOpenURL: (@Sendable @MainActor (URL) -> Void)?, presentationDetents: [PresentationDetent]?, presentationCornerRadius: Double?, presentationDragIndicatorVisibility: Visibility?, presentationBackground: Color?, - interactiveDismissDisabled: Bool? + interactiveDismissDisabled: Bool?, + windowDismissBehavior: WindowInteractionBehavior?, + preferredWindowMinimizeBehavior: WindowInteractionBehavior?, + windowResizeBehavior: WindowInteractionBehavior? ) { self.onOpenURL = onOpenURL self.presentationDetents = presentationDetents @@ -41,6 +56,9 @@ public struct PreferenceValues: Sendable { self.presentationDragIndicatorVisibility = presentationDragIndicatorVisibility self.presentationBackground = presentationBackground self.interactiveDismissDisabled = interactiveDismissDisabled + self.windowDismissBehavior = windowDismissBehavior + self.preferredWindowMinimizeBehavior = preferredWindowMinimizeBehavior + self.windowResizeBehavior = windowResizeBehavior } init(merging children: [PreferenceValues]) { @@ -63,5 +81,9 @@ public struct PreferenceValues: Sendable { }.first presentationBackground = children.compactMap { $0.presentationBackground }.first interactiveDismissDisabled = children.compactMap { $0.interactiveDismissDisabled }.first + + windowDismissBehavior = children.compactMap { $0.windowDismissBehavior }.first + preferredWindowMinimizeBehavior = children.compactMap { $0.preferredWindowMinimizeBehavior }.first + windowResizeBehavior = children.compactMap { $0.windowResizeBehavior }.first } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/WindowModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/WindowModifiers.swift new file mode 100644 index 00000000000..d347a7d31c3 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/WindowModifiers.swift @@ -0,0 +1,31 @@ +extension View { + /// Sets the closability of the enclosing window. + /// + /// This only controls whether user can close the window via the title + /// bar close button, built-in keyboard shortcuts such as Cmd+W or Alt+F4, + /// etc. Windows can always be closed programmatically. + public func windowDismissBehavior(_ behavior: WindowInteractionBehavior) -> some View { + preference(key: \.windowDismissBehavior, value: behavior) + } + + /// Sets the minimizability of the enclosing window. + /// + /// - Important: This isn't supported on GtkBackend or Gtk3Backend. + public func preferredWindowMinimizeBehavior( + _ behavior: WindowInteractionBehavior + ) -> some View { + preference(key: \.preferredWindowMinimizeBehavior, value: behavior) + } + + /// Sets the resizability of the enclosing window. + /// + /// This modifier controls whether the user can resize the enclosing window, + /// whereas ``Scene/windowResizability(_:)`` controls how SwiftCrossUI + /// determines the bounds within which windows can be resized. The only time + /// that ``Scene/windowResizability(_:)`` can disable interactive resizing + /// is when the window's content has a fixed size and the + /// ``WindowResizability`` is ``WindowResizability/contentSize``. + public func windowResizeBehavior(_ behavior: WindowInteractionBehavior) -> some View { + preference(key: \.windowResizeBehavior, value: behavior) + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 7089b933e51..2afaafbd6ce 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -139,8 +139,18 @@ extension UIKitBackend { #endif } - public func setResizability(ofWindow window: Window, to resizable: Bool) { - logger.notice("ignoring \(#function) call") + public func setBehaviors( + ofWindow window: Window, + closable: Bool, + minimizable: Bool, + resizable: Bool + ) { + if #available(iOS 16, tvOS 16, macCatalyst 16, *) { + window.windowScene?.windowingBehaviors?.isClosable = closable + window.windowScene?.windowingBehaviors?.isMiniaturizable = minimizable + } + + logger.notice("ignoring resizability change") } public func setSize(ofWindow window: Window, to newSize: SIMD2) { @@ -157,10 +167,20 @@ extension UIKitBackend { #endif } - public func setMinimumSize(ofWindow window: Window, to minimumSize: SIMD2) { + public func setSizeLimits( + ofWindow window: Window, + minimum minimumSize: SIMD2, + maximum maximumSize: SIMD2? + ) { // if windowScene is nil, either the window isn't shown or it must be fullscreen - // if sizeRestrictions is nil, the device doesn't support setting a minimum window size - window.windowScene?.sizeRestrictions?.minimumSize = CGSize( - width: CGFloat(minimumSize.x), height: CGFloat(minimumSize.y)) + // if sizeRestrictions is nil, the device doesn't support setting window size bounds + window.windowScene?.sizeRestrictions?.minimumSize = + CGSize(width: minimumSize.x, height: minimumSize.y) + window.windowScene?.sizeRestrictions?.maximumSize = + if let maximumSize { + CGSize(width: maximumSize.x, height: maximumSize.y) + } else { + CGSize(width: Double.infinity, height: .infinity) + } } } diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 87ca71a9d4e..9b1d4a6d45c 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -200,8 +200,12 @@ public final class WinUIBackend: AppBackend { try! window.appWindow.resizeClient(size) } - public func setMinimumSize(ofWindow window: Window, to minimumSize: SIMD2) { - missing("window minimum size") + public func setSizeLimits( + ofWindow window: Window, + minimum minimumSize: SIMD2, + maximum maximumSize: SIMD2? + ) { + logger.warning("\(#function) unimplemented") } public func setResizeHandler( @@ -221,8 +225,15 @@ public final class WinUIBackend: AppBackend { window.title = title } - public func setResizability(ofWindow window: Window, to value: Bool) { - (window.appWindow.presenter as! OverlappedPresenter).isResizable = value + public func setBehaviors( + ofWindow window: Window, + closable: Bool, + minimizable: Bool, + resizable: Bool + ) { + // TODO: Set window closability (need to reach down to Win32 for this) + (window.appWindow.presenter as? OverlappedPresenter)?.isMinimizable = minimizable + (window.appWindow.presenter as? OverlappedPresenter)?.isResizable = resizable } public func setChild(ofWindow window: Window, to widget: Widget) { @@ -253,10 +264,6 @@ public final class WinUIBackend: AppBackend { public func show(widget _: Widget) {} - private func missing(_ message: String) { - // print("missing: \(message)") - } - private func renderItems(_ items: [ResolvedMenu.Item]) -> [MenuFlyoutItemBase] { items.map { item in switch item { @@ -633,7 +640,7 @@ public final class WinUIBackend: AppBackend { let block = textView as! TextBlock block.text = content block.isTextSelectionEnabled = environment.isTextSelectionEnabled - missing("font design handling (monospace vs normal)") + // TODO: Font design handling (monospace vs normal) environment.apply(to: block) } @@ -897,8 +904,8 @@ public final class WinUIBackend: AppBackend { } } - missing("proper picker updating logic") - missing("picker font handling") + // TODO: Proper picker updating logic + // TODO: Picker font handling picker.options = options }