diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 25df375fd4d..8a8cf2d3e7d 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -157,6 +157,56 @@ struct SheetDemo: View { } } +struct OpenWindowDemo: View { + @Environment(\.openWindow) private var openWindow + @Environment(\.supportsMultipleWindows) private var supportsMultipleWindows + + var body: some View { + Text("Backend supports multi-window: \(supportsMultipleWindows)") + + Button("Open singleton window") { + openWindow(id: "singleton-window") + } + Button("Open new tertiary window instance") { + openWindow(id: "tertiary-window") + } + } +} + +struct TertiaryWindowView: View { + @Environment(\.dismissWindow) private var dismissWindow + @Environment(\.openWindow) private var openWindow + + var body: some View { + VStack { + Text("This a tertiary window!") + + Button("Close window") { + dismissWindow() + } + Button("Open new instance") { + openWindow(id: "tertiary-window") + } + } + .padding() + } +} + +struct SingletonWindowView: View { + @Environment(\.dismissWindow) private var dismissWindow + + var body: some View { + VStack { + Text("This a singleton window!") + + Button("Close window") { + dismissWindow() + } + } + .padding() + } +} + @main @HotReloadable struct WindowingApp: App { @@ -198,6 +248,10 @@ struct WindowingApp: App { Divider() SheetDemo() + + Divider() + + OpenWindowDemo() .padding(.bottom, 20) } .padding(20) @@ -222,19 +276,27 @@ struct WindowingApp: App { AlertScene("Alert scene", isPresented: $isAlertSceneShown) {} #if !(os(iOS) || os(tvOS) || os(Windows)) - WindowGroup("Secondary window") { + WindowGroup("Secondary window", id: "secondary-window") { #hotReloadable { Text("This a secondary window!") - .padding(10) + .padding() + } + } + .defaultSize(width: 200, height: 200) + .windowResizability(.contentMinSize) + + WindowGroup("Tertiary window (hidden)", id: "tertiary-window") { + #hotReloadable { + TertiaryWindowView() } } .defaultSize(width: 200, height: 200) .windowResizability(.contentMinSize) + .defaultLaunchBehavior(.suppressed) - WindowGroup("Tertiary window") { + Window("Singleton window", id: "singleton-window") { #hotReloadable { - Text("This a tertiary window!") - .padding(10) + SingletonWindowView() } } .defaultSize(width: 200, height: 200) diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index dbae6ce17d0..9574b30f57a 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -25,6 +25,7 @@ public final class AppKitBackend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = false public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true + public let supportsMultipleWindows = true public let deviceClass = DeviceClass.desktop public let supportedDatePickerStyles: [DatePickerStyle] = [.automatic, .graphical, .compact] @@ -69,6 +70,11 @@ public final class AppKitBackend: AppBackend { ) window.delegate = window.customDelegate + // NB: If this isn't set, AppKit will crash within -[NSApplication run] + // the *second* time `openWindow` is called. I have absolutely no idea + // why. + window.isReleasedWhenClosed = false + return window } @@ -97,7 +103,7 @@ public final class AppKitBackend: AppBackend { ofWindow window: Window, to action: @escaping (SIMD2) -> Void ) { - window.customDelegate.setHandler(action) + window.customDelegate.setResizeHandler(action) } public func setTitle(ofWindow window: Window, to title: String) { @@ -124,6 +130,17 @@ public final class AppKitBackend: AppBackend { window.makeKeyAndOrderFront(nil) } + public func close(window: Window) { + window.close() + } + + public func setCloseHandler( + ofWindow window: Window, + to action: @escaping () -> Void + ) { + window.customDelegate.setCloseHandler(action) + } + public func openExternalURL(_ url: URL) throws { NSWorkspace.shared.open(url) } @@ -2374,11 +2391,16 @@ public class NSCustomWindow: NSWindow { class Delegate: NSObject, NSWindowDelegate { var resizeHandler: ((SIMD2) -> Void)? + var closeHandler: (() -> Void)? - func setHandler(_ resizeHandler: @escaping (SIMD2) -> Void) { + func setResizeHandler(_ resizeHandler: @escaping (SIMD2) -> Void) { self.resizeHandler = resizeHandler } + func setCloseHandler(_ closeHandler: @escaping () -> Void) { + self.closeHandler = closeHandler + } + func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { guard let resizeHandler else { return frameSize @@ -2400,6 +2422,10 @@ public class NSCustomWindow: NSWindow { return frameSize } + func windowWillClose(_ notification: Notification) { + closeHandler?() + } + func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { (window as! NSCustomWindow).persistentUndoManager } diff --git a/Sources/DummyBackend/DummyBackend.swift b/Sources/DummyBackend/DummyBackend.swift index d0c7d1ab7e3..1da3f68532f 100644 --- a/Sources/DummyBackend/DummyBackend.swift +++ b/Sources/DummyBackend/DummyBackend.swift @@ -11,6 +11,7 @@ public final class DummyBackend: AppBackend { public var resizable = true public var content: Widget? public var resizeHandler: ((SIMD2) -> Void)? + public var closeHandler: (() -> Void)? public init(defaultSize: SIMD2?) { size = defaultSize ?? Self.defaultSize @@ -247,6 +248,7 @@ public final class DummyBackend: AppBackend { public var menuImplementationStyle = MenuImplementationStyle.dynamicPopover public var deviceClass = DeviceClass.desktop public var canRevealFiles = false + public let supportsMultipleWindows = true public var supportedDatePickerStyles: [DatePickerStyle] = [] public var incomingURLHandler: ((URL) -> Void)? @@ -298,6 +300,14 @@ public final class DummyBackend: AppBackend { public func activate(window: Window) {} + public func close(window: Window) { + window.closeHandler?() + } + + public func setCloseHandler(ofWindow window: Window, to action: @escaping () -> Void) { + window.closeHandler = action + } + public func runInMainThread(action: @escaping @MainActor () -> Void) { DispatchQueue.main.async { action() diff --git a/Sources/Gtk/Widgets/Window.swift b/Sources/Gtk/Widgets/Window.swift index 210ca9a740a..f0a2cb7318e 100644 --- a/Sources/Gtk/Widgets/Window.swift +++ b/Sources/Gtk/Widgets/Window.swift @@ -83,16 +83,10 @@ open class Window: Widget { public func present() { gtk_window_present(castedPointer()) + } - addSignal(name: "close-request") { [weak self] () in - guard let self else { return } - self.onCloseRequest?(self) - } - - addSignal(name: "destroy") { [weak self] () in - guard let self else { return } - self.onDestroy?(self) - } + public func close() { + gtk_window_close(castedPointer()) } public func setEscapeKeyPressedHandler(to handler: (() -> Void)?) { @@ -111,8 +105,24 @@ open class Window: Widget { private var escapeKeyEventController: EventControllerKey? - public var onCloseRequest: ((Window) -> Void)? - public var onDestroy: ((Window) -> Void)? + public var onCloseRequest: ((Window) -> Void)? { + didSet { + addSignal(name: "close-request") { [weak self] () in + guard let self else { return } + self.onCloseRequest?(self) + } + } + } + + public var onDestroy: ((Window) -> Void)? { + didSet { + addSignal(name: "destroy") { [weak self] () in + guard let self else { return } + self.onDestroy?(self) + } + } + } + public var escapeKeyPressed: (() -> Void)? } diff --git a/Sources/Gtk3/Widgets/Window.swift b/Sources/Gtk3/Widgets/Window.swift index 0613f3166de..f5ca318ac42 100644 --- a/Sources/Gtk3/Widgets/Window.swift +++ b/Sources/Gtk3/Widgets/Window.swift @@ -61,6 +61,10 @@ open class Window: Bin { gtk_window_present(castedPointer()) } + public func close() { + gtk_window_close(castedPointer()) + } + public func setMinimumSize(to minimumSize: Size) { gtk_widget_set_size_request( castedPointer(), @@ -72,4 +76,20 @@ open class Window: Bin { public func setPosition(to position: WindowPosition) { gtk_window_set_position(castedPointer(), position.toGtk()) } + + public var onCloseRequest: ((Window) -> Void)? { + didSet { + let handler: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "delete-event", handler: gCallback(handler)) { + [weak self] (_: OpaquePointer) in + guard let self else { return } + self.onCloseRequest?(self) + } + } + } } diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 635b0e0d067..96dce4ddca4 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -37,6 +37,7 @@ public final class Gtk3Backend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = true public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true + public let supportsMultipleWindows = true public let deviceClass = DeviceClass.desktop public let supportedDatePickerStyles: [DatePickerStyle] = [] @@ -325,6 +326,25 @@ public final class Gtk3Backend: AppBackend { window.present() } + public func close(window: Window) { + window.close() + window.destroy() + + // NB: It seems GTK3 won't automatically signal `::delete-event` if + // the window is closed programmatically. + window.onCloseRequest?(window) + } + + public func setCloseHandler( + ofWindow window: Window, + to action: @escaping () -> Void + ) { + window.onCloseRequest = { _ in + action() + window.destroy() + } + } + public func openExternalURL(_ url: URL) throws { // Used instead of gtk_uri_launcher_launch to maintain <4.10 compatibility var error: UnsafeMutablePointer? = nil diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index b69d084a161..1fb39e14ba4 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -41,6 +41,7 @@ public final class GtkBackend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = false public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true + public let supportsMultipleWindows = true public let deviceClass = DeviceClass.desktop public let defaultSheetCornerRadius = 10 public let supportedDatePickerStyles: [DatePickerStyle] = [.automatic, .graphical] @@ -239,6 +240,21 @@ public final class GtkBackend: AppBackend { window.present() } + public func close(window: Window) { + window.close() + window.destroy() + } + + public func setCloseHandler( + ofWindow window: Window, + to action: @escaping () -> Void + ) { + window.onCloseRequest = { _ in + action() + window.destroy() + } + } + public func openExternalURL(_ url: URL) throws { // Used instead of gtk_uri_launcher_launch to maintain <4.10 compatibility gtk_show_uri(nil, url.absoluteString, guint(GDK_CURRENT_TIME)) diff --git a/Sources/SwiftCrossUI/App.swift b/Sources/SwiftCrossUI/App.swift index e475d3aad8e..39fe13e2537 100644 --- a/Sources/SwiftCrossUI/App.swift +++ b/Sources/SwiftCrossUI/App.swift @@ -112,7 +112,13 @@ extension App { label: String, metadataProvider: Logger.MetadataProvider? ) -> any LogHandler { - StreamLogHandler.standardError(label: label) + var logHandler = StreamLogHandler.standardError(label: label) + #if DEBUG + logHandler.logLevel = .debug + #else + logHandler.logLevel = .info + #endif + return logHandler } /// Runs the application. diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 136ca5d7d4d..e3eff42cbac 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -97,6 +97,9 @@ public protocol AppBackend: Sendable { /// Whether the backend can reveal files in the system file manager or not. /// Mobile backends generally can't. var canRevealFiles: Bool { get } + /// Whether the backend can have multiple windows open at once. Mobile + /// backends generally can't. + var supportsMultipleWindows: Bool { get } /// The supported date picker styles. Must include ``DatePickerStyle/automatic`` if date pickers /// are supported at all. @@ -171,6 +174,31 @@ public protocol AppBackend: Sendable { /// receives an external URL or file to handle from the desktop environment. /// May be used in other circumstances eventually. func activate(window: Window) + /// Closes a window. + /// + /// At some point during or after execution of this function, the handler + /// set by ``setCloseHandler(ofWindow:to:)-8ogpa`` should be called. + /// Oftentimes this will be done automatically by the backend's underlying + /// UI framework. + /// + /// This is primarily used by ``DismissWindowAction``. + func close(window: Window) + /// Sets the handler for the window's close events (for example, when the + /// user clicks the close button in the title bar). + /// + /// The close handler should also be called whenever ``close(window:)-9xucx`` + /// is called (some UI frameworks do this automatically). + /// + /// This is used by SwiftCrossUI to release scene nodes' references to + /// `window` when the window is closed. + /// + /// This is only called once per window; as such, it doesn't matter if + /// setting the close handler again overrides the previous handler or adds a + /// new one. + func setCloseHandler( + ofWindow window: Window, + to action: @escaping () -> Void + ) /// Sets the application's global menu. Some backends may make use of the host /// platform's global menu bar (such as macOS's menu bar), and others may render their @@ -859,6 +887,19 @@ extension AppBackend { todo() } + // MARK: Windows + + public func setCloseHandler( + ofWindow window: Window, + to action: @escaping () -> Void + ) { + todo() + } + + public func close(window: Window) { + todo() + } + // MARK: Application public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) { diff --git a/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift b/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift index d6e7bbc3e84..2e72f9c0fab 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift @@ -1,7 +1,10 @@ /// An action that dismisses the current presentation context. /// /// Use the `dismiss` environment value to get an instance of this action, -/// then call it to dismiss the current sheet. +/// then call it to dismiss (close) the enclosing sheet. +/// +/// If you want to close the enclosing window, use ``EnvironmentValues/dismissWindow`` +/// instead. /// /// Example usage: /// ```swift @@ -47,7 +50,10 @@ extension EnvironmentValues { /// An action that dismisses the current presentation context. /// /// Use this environment value to get a dismiss action that can be called - /// to dismiss the current sheet, popover, or other presentation. + /// to dismiss (close) the enclosing sheet, popover, or other presentation. + /// + /// If you want to close the enclosing window, use ``EnvironmentValues/dismissWindow`` + /// instead. /// /// Example: /// ```swift diff --git a/Sources/SwiftCrossUI/Environment/Actions/DismissWindowAction.swift b/Sources/SwiftCrossUI/Environment/Actions/DismissWindowAction.swift new file mode 100644 index 00000000000..0f9c74a1d1f --- /dev/null +++ b/Sources/SwiftCrossUI/Environment/Actions/DismissWindowAction.swift @@ -0,0 +1,38 @@ +/// An action that closes the enclosing window. +/// +/// Use the `dismissWindow` environment value to get an instance of this action, +/// then call it to close the enclosing window. +/// +/// Example usage: +/// ```swift +/// struct ContentView: View { +/// @Environment(\.dismissWindow) var dismissWindow +/// +/// var body: some View { +/// VStack { +/// Text("Window Content") +/// Button("Close") { +/// dismissWindow() +/// } +/// } +/// } +/// } +/// ``` +@MainActor +public struct DismissWindowAction { + let backend: any AppBackend + let window: MainActorBox + + /// Closes the enclosing window. + public func callAsFunction() { + func closeWindow(backend: Backend) { + guard let window = window.value else { + logger.warning("dismissWindow() accessed outside of a window's scope") + return + } + backend.close(window: window as! Backend.Window) + } + + closeWindow(backend: backend) + } +} diff --git a/Sources/SwiftCrossUI/Environment/Actions/OpenWindowAction.swift b/Sources/SwiftCrossUI/Environment/Actions/OpenWindowAction.swift new file mode 100644 index 00000000000..23ffb78e699 --- /dev/null +++ b/Sources/SwiftCrossUI/Environment/Actions/OpenWindowAction.swift @@ -0,0 +1,34 @@ +/// An action that opens a window with the specified ID. +@MainActor +public struct OpenWindowAction { + let backend: any AppBackend + + /// Opens the window with the specified ID. + public func callAsFunction(id: String) { + guard backend.supportsMultipleWindows else { + logger.warning( + """ + openWindow(id:) called but the backend doesn't support \ + multi-window, ignoring + """ + ) + return + } + + guard let openWindow = Self.openFunctionsByID[id] else { + logger.warning( + """ + openWindow(id:) called with an ID that does not have an \ + associated window + """, + metadata: ["id": "\(id)"] + ) + return + } + openWindow() + } + + // FIXME: we should make this a preference instead, if we can get those to + // propagate beyond the scene level + static var openFunctionsByID: [String: @Sendable @MainActor () -> Void] = [:] +} diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index 6f8593abf6c..85afb72e33b 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -195,6 +195,23 @@ public struct EnvironmentValues { ) } + /// Opens a window with the specified ID. + @MainActor + public var openWindow: OpenWindowAction { + return OpenWindowAction( + backend: backend + ) + } + + /// Closes the enclosing window. + @MainActor + public var dismissWindow: DismissWindowAction { + return DismissWindowAction( + backend: backend, + window: .init(value: window) + ) + } + /// Reveals a file in the system's file manager. This opens /// the file's enclosing directory and highlighting the file. /// @@ -207,6 +224,13 @@ public struct EnvironmentValues { ) } + /// Whether the backend can have multiple windows open at once. Mobile + /// backends generally can't. + @MainActor + public var supportsMultipleWindows: Bool { + backend.supportsMultipleWindows + } + /// The current calendar that views should use when handling dates. public var calendar: Calendar diff --git a/Sources/SwiftCrossUI/Scenes/Window.swift b/Sources/SwiftCrossUI/Scenes/Window.swift new file mode 100644 index 00000000000..951b8110154 --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/Window.swift @@ -0,0 +1,127 @@ +#if !os(WASI) + import Foundation +#endif + +/// A scene that presents a single window. +public struct Window: Scene { + public typealias Node = WindowNode + + var windowInfo: WindowInfo + var id: String + var launchBehavior: SceneLaunchBehavior = .automatic + + public var commands: Commands = .empty + + /// Creates a window scene specifying a title and an ID. + public init( + _ title: String, + id: String, + @ViewBuilder _ content: @escaping () -> Content + ) { + self.id = id + self.windowInfo = WindowInfo(title: title, content: content) + } + + /// Sets the default size of a window (used when creating new instances of + /// the window). + public consuming func defaultSize(width: Int, height: Int) -> Self { + self.windowInfo.defaultSize = SIMD2(width, height) + return self + } + + /// Sets the resizability of a window. + public consuming func windowResizability( + _ resizability: WindowResizability + ) -> Self { + self.windowInfo.resizability = resizability + return self + } + + /// Sets the default launch behavior of a window. + public consuming func defaultLaunchBehavior( + _ launchBehavior: SceneLaunchBehavior + ) -> Self { + self.launchBehavior = launchBehavior + return self + } +} + +/// The ``SceneGraphNode`` corresponding to a ``Window`` scene. +public final class WindowNode: SceneGraphNode { + public typealias NodeScene = Window + + /// The reference to the underlying window object, which also manages + /// the window's view graph. + /// + /// `nil` if the window is closed. + private var windowReference: WindowReference? = nil + + /// The underlying scene. + private var scene: Window + + public init( + from scene: Window, + backend: Backend, + environment: EnvironmentValues + ) { + self.scene = scene + + let openOnAppLaunch = switch scene.launchBehavior { + case .presented: true + case .automatic, .suppressed: false + } + + if openOnAppLaunch { + self.windowReference = WindowReference( + info: scene.windowInfo, + backend: backend, + environment: environment, + onClose: { self.windowReference = nil } + ) + } + + setOpenFunction(for: scene, backend: backend, environment: environment) + } + + public func update( + _ newScene: Window?, + backend: Backend, + environment: EnvironmentValues + ) { + if let newScene { + self.scene = newScene + } + + windowReference?.update( + newScene?.windowInfo, + backend: backend, + environment: environment + ) + + setOpenFunction(for: scene, backend: backend, environment: environment) + } + + private func setOpenFunction( + for scene: Window, + backend: Backend, + environment: EnvironmentValues + ) { + OpenWindowAction.openFunctionsByID[scene.id] = { [weak self] in + guard let self else { return } + + if let windowReference { + // the window is already open: activate it + windowReference.activate(backend: backend) + } else { + // the window is not open: create a new instance + windowReference = WindowReference( + info: scene.windowInfo, + backend: backend, + environment: environment, + onClose: { self.windowReference = nil }, + updateImmediately: true + ) + } + } + } +} diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroup.swift b/Sources/SwiftCrossUI/Scenes/WindowGroup.swift index f9ab41cd839..2ec68c9500b 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroup.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowGroup.swift @@ -10,48 +10,128 @@ public struct WindowGroup: Scene { public var commands: Commands = .empty - /// Storing the window group contents lazily allows us to recompute the view - /// when the window size changes without having to recompute the whole app. - /// This allows the window group contents to remain linked to the app state - /// instead of getting frozen in time when the app's body gets evaluated. - var content: () -> Content - - var body: Content { - content() - } + var windowInfo: WindowInfo + var id: String? + var launchBehavior: SceneLaunchBehavior = .automatic - /// The title of the window (shown in the title bar on most OSes). - var title: String - /// 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`. - public init(_ title: String? = nil, @ViewBuilder _ content: @escaping () -> Content) { - self.content = content + /// Creates a window group optionally specifying a title and an ID. Window title + /// defaults to `ProcessInfo.processInfo.processName`. + public init( + _ title: String? = nil, + id: String? = nil, + @ViewBuilder _ content: @escaping () -> Content + ) { #if os(WASI) - self.title = title ?? "Title" + let title = title ?? "Title" #else - self.title = title ?? ProcessInfo.processInfo.processName + let title = title ?? ProcessInfo.processInfo.processName #endif - resizability = .automatic - defaultSize = SIMD2(900, 450) + self.id = id + self.windowInfo = WindowInfo(title: title, content: content) } - /// Sets the default size of a window (used when creating new instances of the window). - public func defaultSize(width: Int, height: Int) -> Self { - var windowGroup = self - windowGroup.defaultSize = SIMD2(width, height) - return windowGroup + /// Sets the default size of a window (used when creating new instances of + /// the window). + public consuming func defaultSize(width: Int, height: Int) -> Self { + self.windowInfo.defaultSize = SIMD2(width, height) + return self } /// Sets the resizability of a window. - public func windowResizability(_ resizability: WindowResizability) -> Self { - var windowGroup = self - windowGroup.resizability = resizability - return windowGroup + public consuming func windowResizability( + _ resizability: WindowResizability + ) -> Self { + self.windowInfo.resizability = resizability + return self + } + + /// Sets the default launch behavior of a window. + public consuming func defaultLaunchBehavior( + _ launchBehavior: SceneLaunchBehavior + ) -> Self { + self.launchBehavior = launchBehavior + return self + } +} + +/// The ``SceneGraphNode`` corresponding to a ``WindowGroup`` scene. +public final class WindowGroupNode: SceneGraphNode { + public typealias NodeScene = WindowGroup + + /// The references to the underlying window objects, which also manages + /// each window's view graph. + /// + /// Empty if there are currently no instances of the window. + private var windowReferences: [UUID: WindowReference] = [:] + + /// The underlying scene. + private var scene: WindowGroup + + public init( + from scene: WindowGroup, + backend: Backend, + environment: EnvironmentValues + ) { + self.scene = scene + + let openOnAppLaunch = switch scene.launchBehavior { + case .automatic, .presented: true + case .suppressed: false + } + + if openOnAppLaunch { + let windowID = UUID() + self.windowReferences = [ + windowID: WindowReference( + info: scene.windowInfo, + backend: backend, + environment: environment, + onClose: { self.windowReferences[windowID] = nil } + ) + ] + } + + setOpenFunction(for: scene, backend: backend, environment: environment) + } + + public func update( + _ newScene: WindowGroup?, + backend: Backend, + environment: EnvironmentValues + ) { + if let newScene { + self.scene = newScene + } + + for windowReference in windowReferences.values { + windowReference.update( + newScene?.windowInfo, + backend: backend, + environment: environment + ) + } + + setOpenFunction(for: scene, backend: backend, environment: environment) + } + + private func setOpenFunction( + for scene: WindowGroup, + backend: Backend, + environment: EnvironmentValues + ) { + if let id = scene.id { + OpenWindowAction.openFunctionsByID[id] = { [weak self] in + guard let self else { return } + + let windowID = UUID() + windowReferences[windowID] = WindowReference( + info: scene.windowInfo, + backend: backend, + environment: environment, + onClose: { self.windowReferences[windowID] = nil }, + updateImmediately: true + ) + } + } } } diff --git a/Sources/SwiftCrossUI/Scenes/WindowInfo.swift b/Sources/SwiftCrossUI/Scenes/WindowInfo.swift new file mode 100644 index 00000000000..f981e39e60e --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/WindowInfo.swift @@ -0,0 +1,34 @@ +/// Holds information describing a window. +struct WindowInfo { + /// The title of the window (shown in the title bar on most OSes). + var title: String + /// 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 + /// The window's content. + var content: () -> Content + + /// Whether the value of `resizability` implies that the window is resizable. + var isResizable: Bool { + switch resizability { + case .automatic, .contentMinSize: + return true + case .contentSize: + return false + } + } + + init( + title: String, + defaultSize: SIMD2 = SIMD2(900, 450), + resizability: WindowResizability = .automatic, + content: @escaping () -> Content + ) { + self.title = title + self.defaultSize = defaultSize + self.resizability = resizability + self.content = content + } +} diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift b/Sources/SwiftCrossUI/Scenes/WindowReference.swift similarity index 72% rename from Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift rename to Sources/SwiftCrossUI/Scenes/WindowReference.swift index 4ddcdaa0b91..f49e9d088c2 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowReference.swift @@ -1,56 +1,59 @@ -/// The ``SceneGraphNode`` corresponding to a ``WindowGroup`` scene. Holds -/// the scene's view graph and window handle. -public final class WindowGroupNode: SceneGraphNode { - public typealias NodeScene = WindowGroup - - /// The node's scene. - private var scene: WindowGroup - /// The view graph of the window group's root view. Will need to be multiple - /// view graphs once having multiple copies of a window is supported. - private var viewGraph: ViewGraph - /// The window that the group is getting rendered in. Will need to be multiple - /// windows once having multiple copies of a window is supported. - private var window: Any +/// Holds the view graph and window handle for a single window. +@MainActor +final class WindowReference { + /// The window info. + private var info: WindowInfo + /// The view graph of the window's root view. + private let viewGraph: ViewGraph + /// The window being rendered in. + private let window: Any /// `false` after the first scene update. private var isFirstUpdate = true /// The environment most recently provided by this node's parent scene. private var parentEnvironment: EnvironmentValues /// The container used to center the root view in the window. - private var containerWidget: AnyWidget + private let containerWidget: AnyWidget - public init( - from scene: WindowGroup, + /// - Parameters: + /// - onClose: The action to perform when the window is closed. Should + /// dispose of the scene's reference to this `WindowReference`. + /// - updateImmediately: Whether to call `update(_:backend:environment:)` + /// after performing setup. Set this to `true` if opening as a result of + /// ``EnvironmentValues/openWindow``. + init( + info: WindowInfo, backend: Backend, - environment: EnvironmentValues + environment: EnvironmentValues, + onClose: @escaping @Sendable @MainActor () -> Void, + updateImmediately: Bool = false ) { - self.scene = scene - let window = backend.createWindow(withDefaultSize: scene.defaultSize) + self.info = info + let window = backend.createWindow(withDefaultSize: info.defaultSize) viewGraph = ViewGraph( - for: scene.body, + for: info.content(), backend: backend, environment: environment.with(\.window, window) ) let rootWidget = viewGraph.rootNode.concreteNode(for: Backend.self).widget - + let container = backend.createContainer() backend.insert(rootWidget, into: container, at: 0) self.containerWidget = AnyWidget(container) - + backend.setChild(ofWindow: window, to: container) - backend.setTitle(ofWindow: window, to: scene.title) - backend.setResizability(ofWindow: window, to: scene.resizability.isResizable) + backend.setTitle(ofWindow: window, to: info.title) + backend.setResizability(ofWindow: window, to: info.isResizable) self.window = window parentEnvironment = environment - backend.setResizeHandler(ofWindow: window) { [weak self] newSize in - guard let self else { - return - } + backend.setCloseHandler(ofWindow: window, to: onClose) - _ = self.update( - self.scene, + backend.setResizeHandler(ofWindow: window) { [weak self] newSize in + guard let self else { return } + self.update( + self.info, proposedWindowSize: newSize, needsWindowSizeCommit: false, backend: backend, @@ -59,14 +62,11 @@ public final class WindowGroupNode: SceneGraphNode { !backend.isWindowProgrammaticallyResizable(window) ) } - + backend.setWindowEnvironmentChangeHandler(of: window) { [weak self] in - guard let self else { - return - } - - _ = self.update( - self.scene, + guard let self else { return } + self.update( + self.info, proposedWindowSize: backend.size(ofWindow: window), needsWindowSizeCommit: false, backend: backend, @@ -75,10 +75,14 @@ public final class WindowGroupNode: SceneGraphNode { !backend.isWindowProgrammaticallyResizable(window) ) } + + if updateImmediately { + self.update(nil, backend: backend, environment: environment) + } } - public func update( - _ newScene: WindowGroup?, + func update( + _ newInfo: WindowInfo?, backend: Backend, environment: EnvironmentValues ) { @@ -92,15 +96,15 @@ public final class WindowGroupNode: SceneGraphNode { let proposedWindowSize: SIMD2 let usedDefaultSize: Bool if isFirstUpdate && isProgramaticallyResizable { - proposedWindowSize = (newScene ?? scene).defaultSize + proposedWindowSize = (newInfo ?? info).defaultSize usedDefaultSize = true } else { proposedWindowSize = backend.size(ofWindow: window) usedDefaultSize = false } - _ = update( - newScene, + update( + newInfo, proposedWindowSize: proposedWindowSize, needsWindowSizeCommit: usedDefaultSize, backend: backend, @@ -111,7 +115,7 @@ public final class WindowGroupNode: SceneGraphNode { /// Updates the WindowGroupNode. /// - Parameters: - /// - newScene: The scene's body if recomputed. + /// - newInfo: The scene's body if recomputed. /// - proposedWindowSize: The proposed window size. /// - needsWindowSizeCommit: Whether the proposed window size matches the /// windows current size (or imminent size in the case of a window @@ -125,40 +129,40 @@ public final class WindowGroupNode: SceneGraphNode { /// - windowSizeIsFinal: If true, no further resizes can/will be made. This /// is true on platforms that don't support programmatic window resizing, /// and when a window is full screen. - public func update( - _ newScene: WindowGroup?, + func update( + _ newInfo: WindowInfo?, proposedWindowSize: SIMD2, needsWindowSizeCommit: Bool, backend: Backend, environment: EnvironmentValues, windowSizeIsFinal: Bool = false - ) -> ViewLayoutResult { + ) { guard let window = window as? Backend.Window else { fatalError("Scene updated with a backend incompatible with the window it was given") } - + parentEnvironment = environment - if let newScene { + if let newInfo { // Don't set default size even if it has changed. We only set that once // at window creation since some backends don't have a concept of // 'default' size which would mean that setting the default size every time // 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 + backend.setTitle(ofWindow: window, to: newInfo.title) + backend.setResizability(ofWindow: window, to: newInfo.isResizable) + info = newInfo } - + let environment = - backend.computeWindowEnvironment(window: window, rootEnvironment: environment) + backend.computeWindowEnvironment(window: window, rootEnvironment: environment) .with(\.onResize) { [weak self] _ in guard let self else { return } // TODO: Figure out whether this would still work if we didn't recompute the // scene's body. I have a vague feeling that it wouldn't work in all cases? // But I don't have the time to come up with a counterexample right now. - _ = self.update( - self.scene, + self.update( + self.info, proposedWindowSize: backend.size(ofWindow: window), needsWindowSizeCommit: false, backend: backend, @@ -168,9 +172,9 @@ public final class WindowGroupNode: SceneGraphNode { .with(\.window, window) let finalContentResult: ViewLayoutResult - if scene.resizability.isResizable { + if info.isResizable { let minimumWindowSize = viewGraph.computeLayout( - with: newScene?.body, + with: newInfo?.content(), proposedSize: .zero, environment: environment.with(\.allowLayoutCaching, true) ).size @@ -183,14 +187,15 @@ public final class WindowGroupNode: SceneGraphNode { if clampedWindowSize.vector != proposedWindowSize && !windowSizeIsFinal { // Restart the window update if the content has caused the window to // change size. - return update( - scene, + update( + info, proposedWindowSize: clampedWindowSize.vector, needsWindowSizeCommit: true, backend: backend, environment: environment, windowSizeIsFinal: true ) + return } // Set this even if the window isn't programmatically resizable @@ -203,19 +208,20 @@ public final class WindowGroupNode: SceneGraphNode { ) } else { let initialContentResult = viewGraph.computeLayout( - with: newScene?.body, + with: newInfo?.content(), proposedSize: ProposedViewSize(proposedWindowSize), environment: environment ) if initialContentResult.size.vector != proposedWindowSize && !windowSizeIsFinal { - return update( - scene, + update( + info, proposedWindowSize: initialContentResult.size.vector, needsWindowSizeCommit: true, backend: backend, environment: environment, windowSizeIsFinal: true ) + return } finalContentResult = initialContentResult } @@ -231,12 +237,18 @@ public final class WindowGroupNode: SceneGraphNode { if needsWindowSizeCommit { backend.setSize(ofWindow: window, to: proposedWindowSize) } - + if isFirstUpdate { backend.show(window: window) isFirstUpdate = false } + } - return finalContentResult + func activate(backend: Backend) { + guard let window = window as? Backend.Window else { + fatalError("Scene updated with a backend incompatible with the window it was given") + } + + backend.activate(window: window) } } diff --git a/Sources/SwiftCrossUI/Values/SceneLaunchBehavior.swift b/Sources/SwiftCrossUI/Values/SceneLaunchBehavior.swift new file mode 100644 index 00000000000..6731522b129 --- /dev/null +++ b/Sources/SwiftCrossUI/Values/SceneLaunchBehavior.swift @@ -0,0 +1,13 @@ +/// The launch behavior for a scene. +public enum SceneLaunchBehavior: Sendable { + /// SwiftCrossUI decides whether to use ``presented`` or ``surpressed`` + /// depending on the type of scene. + /// + /// Currently, `presented` will be used for ``WindowGroup``, and + /// `surpressed` will be used for ``Window``. + case automatic + /// The scene will be shown on app launch. + case presented + /// The scene will not be shown on app launch. + case suppressed +} diff --git a/Sources/SwiftCrossUI/Values/WindowResizability.swift b/Sources/SwiftCrossUI/Values/WindowResizability.swift index 312b224675e..236648b599b 100644 --- a/Sources/SwiftCrossUI/Values/WindowResizability.swift +++ b/Sources/SwiftCrossUI/Values/WindowResizability.swift @@ -7,14 +7,4 @@ public enum WindowResizability: Sendable { 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/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 7089b933e51..02e992b949f 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -131,6 +131,17 @@ extension UIKitBackend { window.makeKeyAndVisible() } + public func close(window: Window) { + print("UIKitBackend: ignoring \(#function) call") + } + + public func setCloseHandler( + ofWindow window: Window, + to action: @escaping () -> Void + ) { + print("UIKitBackend: ignoring \(#function) call") + } + public func isWindowProgrammaticallyResizable(_ window: Window) -> Bool { #if os(visionOS) true diff --git a/Sources/UIKitBackend/UIKitBackend.swift b/Sources/UIKitBackend/UIKitBackend.swift index 8b89c173715..bf32456775d 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -25,6 +25,7 @@ public final class UIKitBackend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = false public let canRevealFiles = false + public let supportsMultipleWindows = false public var deviceClass: DeviceClass { switch UIDevice.current.userInterfaceIdiom { diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index dd8b1a1cdc3..093b1089337 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -43,6 +43,7 @@ public final class WinUIBackend: AppBackend { public let requiresImageUpdateOnScaleFactorChange = false public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = false + public let supportsMultipleWindows = true public let deviceClass = DeviceClass.desktop public let supportedDatePickerStyles: [DatePickerStyle] = [ .automatic, .graphical, .compact, .wheel, @@ -244,6 +245,19 @@ public final class WinUIBackend: AppBackend { try! window.activate() } + public func close(window: Window) { + try! window.close() + } + + public func setCloseHandler( + ofWindow window: Window, + to action: @escaping () -> Void + ) { + window.closed.addHandler { _, _ in + action() + } + } + public func openExternalURL(_ url: URL) throws { _ = UWP.Launcher.launchUriAsync(WindowsFoundation.Uri(url.absoluteString)) } @@ -836,7 +850,8 @@ public final class WinUIBackend: AppBackend { slider.valueChanged.addHandler { [weak internalState] _, event in guard let internalState else { return } internalState.sliderChangeActions[ObjectIdentifier(slider)]?( - Double(event?.newValue ?? 0)) + Double(event?.newValue ?? 0) + ) } slider.stepFrequency = 0.01 return slider @@ -1430,9 +1445,7 @@ public final class WinUIBackend: AppBackend { tapGestureTarget.background = brush tapGestureTarget.pointerPressed.addHandler { [weak tapGestureTarget] _, _ in - guard let tapGestureTarget else { - return - } + guard let tapGestureTarget else { return } tapGestureTarget.clickHandler?() } return tapGestureTarget