diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index fd40d14e6c7..e7a14c95202 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 { @@ -204,6 +254,10 @@ struct WindowingApp: App { Divider() SheetDemo() + + Divider() + + OpenWindowDemo() .padding(.bottom, 20) } .padding(20) @@ -227,7 +281,7 @@ 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 { VStack { Text("This a secondary window!") @@ -241,10 +295,18 @@ struct WindowingApp: App { .defaultSize(width: 200, height: 200) .windowResizability(enforceMaxSize ? .contentSize : .contentMinSize) - WindowGroup("Tertiary window") { + WindowGroup("Tertiary window (hidden)", id: "tertiary-window") { + #hotReloadable { + TertiaryWindowView() + } + } + .defaultSize(width: 200, height: 200) + .defaultLaunchBehavior(.suppressed) + .windowResizability(.contentMinSize) + + 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 cc65e9b2319..29561fe1aa9 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 } @@ -106,7 +112,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) { @@ -150,6 +156,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) } @@ -2400,11 +2417,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 @@ -2426,6 +2448,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 3afd302b7eb..e216fd247ae 100644 --- a/Sources/DummyBackend/DummyBackend.swift +++ b/Sources/DummyBackend/DummyBackend.swift @@ -14,6 +14,7 @@ public final class DummyBackend: AppBackend { public var minimizable = true public var content: Widget? public var resizeHandler: ((SIMD2) -> Void)? + public var closeHandler: (() -> Void)? public init(defaultSize: SIMD2?) { size = defaultSize ?? Self.defaultSize @@ -250,6 +251,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)? @@ -313,6 +315,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 905b392ac72..e4ec4749713 100644 --- a/Sources/Gtk/Widgets/Window.swift +++ b/Sources/Gtk/Widgets/Window.swift @@ -84,16 +84,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)?) { @@ -112,8 +106,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 3717716243c..1baee4d7d61 100644 --- a/Sources/Gtk3/Widgets/Window.swift +++ b/Sources/Gtk3/Widgets/Window.swift @@ -62,6 +62,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(), @@ -73,4 +77,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 39af7a1a5fd..dbb62601c2d 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] = [] @@ -368,6 +369,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 da2a04c376f..3f4cd821a8d 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] @@ -260,6 +261,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 c3dba8fa4f2..67fb0759685 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 de71b183760..f9953c332f0 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. @@ -193,6 +196,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 @@ -881,6 +909,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 916310761f5..69a6d75082c 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -98,8 +98,14 @@ public struct EnvironmentValues { /// Whether the text should be selectable. Set by ``View/textSelectionEnabled(_:)``. public var isTextSelectionEnabled: Bool - /// The resizing behaviour of the current window. + /// The resizing behaviour of the enclosing window. var windowResizability: WindowResizability + /// The default launch behavior of the enclosing window. + var defaultLaunchBehavior: SceneLaunchBehavior + /// The default size of the enclosing window. + /// + /// Defaults to 900x450. + var defaultWindowSize: SIMD2 /// The menu ordering to use. public var menuOrder: MenuOrder @@ -198,6 +204,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. /// @@ -210,6 +233,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 @@ -245,6 +275,8 @@ public struct EnvironmentValues { scrollDismissesKeyboardMode = .automatic isTextSelectionEnabled = false windowResizability = .automatic + defaultLaunchBehavior = .automatic + defaultWindowSize = SIMD2(900, 450) menuOrder = .automatic allowLayoutCaching = false calendar = .current diff --git a/Sources/SwiftCrossUI/Scenes/AlertScene.swift b/Sources/SwiftCrossUI/Scenes/AlertScene.swift index 055200b877f..9f0a7212216 100644 --- a/Sources/SwiftCrossUI/Scenes/AlertScene.swift +++ b/Sources/SwiftCrossUI/Scenes/AlertScene.swift @@ -10,8 +10,6 @@ public struct AlertScene: Scene { @Binding var isPresented: Bool var actions: [AlertAction] - public let commands = Commands.empty - /// Creates an alert scene. /// /// The exact behavior of the alert is backend-dependent, but it typically diff --git a/Sources/SwiftCrossUI/Scenes/Modifiers/DefaultLaunchBehaviorModifier.swift b/Sources/SwiftCrossUI/Scenes/Modifiers/DefaultLaunchBehaviorModifier.swift new file mode 100644 index 00000000000..59e421a88da --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/Modifiers/DefaultLaunchBehaviorModifier.swift @@ -0,0 +1,8 @@ +extension Scene { + /// Sets the default launch behavior of windows controlled by this scene. + public func defaultLaunchBehavior( + _ launchBehavior: SceneLaunchBehavior + ) -> some Scene { + environment(\.defaultLaunchBehavior, launchBehavior) + } +} diff --git a/Sources/SwiftCrossUI/Scenes/Modifiers/DefaultWindowSize.swift b/Sources/SwiftCrossUI/Scenes/Modifiers/DefaultWindowSize.swift new file mode 100644 index 00000000000..4142fe36728 --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/Modifiers/DefaultWindowSize.swift @@ -0,0 +1,8 @@ +extension Scene { + /// Sets the default size of windows controlled by this scene. + /// + /// Used when creating new window instances. + public func defaultSize(width: Int, height: Int) -> some Scene { + environment(\.defaultWindowSize, SIMD2(width, height)) + } +} diff --git a/Sources/SwiftCrossUI/Scenes/Window.swift b/Sources/SwiftCrossUI/Scenes/Window.swift new file mode 100644 index 00000000000..e7c9f68d51e --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/Window.swift @@ -0,0 +1,98 @@ +#if !os(WASI) + import Foundation +#endif + +/// A scene that presents a single window. +public struct Window: WindowingScene { + public typealias Node = WindowNode + + /// The title of the window (shown in the title bar on most OSes). + var title: String + /// The window's content. + var content: () -> Content + /// The window's ID. + /// + /// This should never change after creation. + let id: String + + /// Creates a window scene specifying a title and an ID. + public init( + _ title: String, + id: String, + @ViewBuilder _ content: @escaping () -> Content + ) { + self.id = id + self.title = title + self.content = content + } +} + +/// 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 environment.defaultLaunchBehavior { + case .presented: true + case .automatic, .suppressed: false + } + + if openOnAppLaunch { + self.windowReference = WindowReference( + scene: scene, + backend: backend, + environment: environment, + onClose: { self.windowReference = nil } + ) + } + } + + public func update( + _ newScene: Window?, + backend: Backend, + environment: EnvironmentValues + ) -> SceneUpdateResult { + if let newScene { + self.scene = newScene + } + + 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( + scene: scene, + backend: backend, + environment: environment, + onClose: { self.windowReference = nil }, + updateImmediately: true + ) + } + } + + return windowReference?.update( + newScene, + backend: backend, + environment: environment + ) ?? .leafScene() + } +} diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroup.swift b/Sources/SwiftCrossUI/Scenes/WindowGroup.swift index acab64000ef..874b59867dd 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroup.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowGroup.swift @@ -1,45 +1,107 @@ -#if !os(WASI) - import Foundation -#endif +import Foundation /// A scene that presents a group of identically structured windows. Currently /// only supports having a single instance of the window but will eventually /// support duplicating the window. -public struct WindowGroup: Scene { +public struct WindowGroup: WindowingScene { public typealias Node = WindowGroupNode - /// 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() - } - /// 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 content. + var content: () -> Content + /// The window's ID. + /// + /// This should never change after creation. + let id: String? - /// 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 - defaultSize = SIMD2(900, 450) + self.id = id + self.title = title + self.content = content + } +} + +/// 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 manage + /// 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 environment.defaultLaunchBehavior { + case .automatic, .presented: true + case .suppressed: false + } + + if openOnAppLaunch { + let windowID = UUID() + self.windowReferences = [ + windowID: WindowReference( + scene: scene, + backend: backend, + environment: environment, + onClose: { self.windowReferences[windowID] = nil } + ) + ] + } } - /// 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 + public func update( + _ newScene: WindowGroup?, + backend: Backend, + environment: EnvironmentValues + ) -> SceneUpdateResult { + if let newScene { + self.scene = newScene + } + + if let id = scene.id { + OpenWindowAction.openFunctionsByID[id] = { [weak self] in + guard let self else { return } + + let windowID = UUID() + windowReferences[windowID] = WindowReference( + scene: scene, + backend: backend, + environment: environment, + onClose: { self.windowReferences[windowID] = nil }, + updateImmediately: true + ) + } + } + + let results = windowReferences.values.map { windowReference in + windowReference.update( + newScene, + backend: backend, + environment: environment + ) + } + return SceneUpdateResult(childResults: results) } } diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift b/Sources/SwiftCrossUI/Scenes/WindowReference.swift similarity index 79% rename from Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift rename to Sources/SwiftCrossUI/Scenes/WindowReference.swift index 3f1db392f49..2b60d8c8878 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowReference.swift @@ -1,53 +1,56 @@ -/// 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 scene. + private var scene: SceneType + /// 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( + scene: SceneType, backend: Backend, - environment: EnvironmentValues + environment: EnvironmentValues, + onClose: @escaping @Sendable @MainActor () -> Void, + updateImmediately: Bool = false ) { self.scene = scene - let window = backend.createWindow(withDefaultSize: scene.defaultSize) + let window = backend.createWindow(withDefaultSize: environment.defaultWindowSize) viewGraph = ViewGraph( - for: scene.body, + for: scene.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) self.window = window parentEnvironment = environment - backend.setResizeHandler(ofWindow: window) { [weak self] newSize in - guard let self else { - return - } + backend.setCloseHandler(ofWindow: window, to: onClose) + backend.setResizeHandler(ofWindow: window) { [weak self] newSize in + guard let self else { return } _ = self.update( self.scene, proposedWindowSize: newSize, @@ -58,12 +61,9 @@ public final class WindowGroupNode: SceneGraphNode { !backend.isWindowProgrammaticallyResizable(window) ) } - + backend.setWindowEnvironmentChangeHandler(of: window) { [weak self] in - guard let self else { - return - } - + guard let self else { return } _ = self.update( self.scene, proposedWindowSize: backend.size(ofWindow: window), @@ -74,10 +74,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( + _ newScene: SceneType?, backend: Backend, environment: EnvironmentValues ) -> SceneUpdateResult { @@ -91,7 +95,7 @@ public final class WindowGroupNode: SceneGraphNode { let proposedWindowSize: SIMD2 let usedDefaultSize: Bool if isFirstUpdate && isProgramaticallyResizable { - proposedWindowSize = (newScene ?? scene).defaultSize + proposedWindowSize = environment.defaultWindowSize usedDefaultSize = true } else { proposedWindowSize = backend.size(ofWindow: window) @@ -108,9 +112,9 @@ public final class WindowGroupNode: SceneGraphNode { ) } - /// Updates the WindowGroupNode. + /// Updates the `WindowReference`. /// - Parameters: - /// - newScene: The scene's body if recomputed. + /// - newScene: The scene. `nil` if this is the first update. /// - 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 @@ -124,8 +128,8 @@ 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?, + private func update( + _ newScene: SceneType?, proposedWindowSize: SIMD2, needsWindowSizeCommit: Bool, backend: Backend, @@ -135,7 +139,7 @@ public final class WindowGroupNode: SceneGraphNode { 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 { @@ -147,9 +151,9 @@ public final class WindowGroupNode: SceneGraphNode { backend.setTitle(ofWindow: window, to: newScene.title) scene = newScene } - + 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 @@ -166,7 +170,7 @@ public final class WindowGroupNode: SceneGraphNode { .with(\.window, window) let minimumWindowSize = viewGraph.computeLayout( - with: newScene?.body, + with: newScene?.content(), proposedSize: .zero, environment: environment.with(\.allowLayoutCaching, true) ).size @@ -177,7 +181,7 @@ public final class WindowGroupNode: SceneGraphNode { let maximumWindowSize: ViewSize? = switch environment.windowResizability { case .contentSize: viewGraph.computeLayout( - with: newScene?.body, + with: newScene?.content(), proposedSize: .infinity, environment: environment.with(\.allowLayoutCaching, true) ).size @@ -251,4 +255,12 @@ public final class WindowGroupNode: SceneGraphNode { return .leafScene() } + + 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/Scenes/WindowingScene.swift b/Sources/SwiftCrossUI/Scenes/WindowingScene.swift new file mode 100644 index 00000000000..99b7c2833be --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/WindowingScene.swift @@ -0,0 +1,5 @@ +protocol WindowingScene: Scene { + associatedtype Content: View + var title: String { get } + var content: () -> Content { get } +} 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/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 2afaafbd6ce..d4676df0b7f 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 f3e9f33e2f0..8ce797baaa7 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -66,6 +66,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, @@ -278,6 +279,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)) } @@ -866,7 +880,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 @@ -1460,9 +1475,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