From 16046b3016060e37403e66b2c912720bbb8a79cc Mon Sep 17 00:00:00 2001 From: "Kaleb A. Ascevich" Date: Sat, 3 Jan 2026 11:57:13 -0500 Subject: [PATCH 1/7] add `AlertScene` --- Sources/SwiftCrossUI/Scenes/AlertScene.swift | 65 ++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Sources/SwiftCrossUI/Scenes/AlertScene.swift diff --git a/Sources/SwiftCrossUI/Scenes/AlertScene.swift b/Sources/SwiftCrossUI/Scenes/AlertScene.swift new file mode 100644 index 00000000000..fdb41002ae1 --- /dev/null +++ b/Sources/SwiftCrossUI/Scenes/AlertScene.swift @@ -0,0 +1,65 @@ +/// A scene that shows a standalone alert. +public struct AlertScene: Scene { + public typealias Node = AlertSceneNode + + var title: String + var isPresented: Binding + var actions: [AlertAction] + + public let commands = Commands.empty + + public init( + _ title: String, + isPresented: Binding, + @AlertActionsBuilder actions: () -> [AlertAction] = { [.ok] } + ) { + self.title = title + self.isPresented = isPresented + self.actions = actions() + } +} + +public final class AlertSceneNode: SceneGraphNode { + public typealias NodeScene = AlertScene + + private var scene: AlertScene + private var alert: Any? + + public init( + from scene: AlertScene, + backend: Backend, + environment: EnvironmentValues + ) { + self.scene = scene + } + + public func update( + _ newScene: AlertScene?, + backend: Backend, + environment: EnvironmentValues + ) { + if let newScene { + self.scene = newScene + } + + if scene.isPresented.wrappedValue, alert == nil { + let alert = backend.createAlert() + backend.updateAlert( + alert, + title: scene.title, + actionLabels: scene.actions.map(\.label), + environment: environment + ) + backend.showAlert(alert, window: nil) { responseId in + self.alert = nil + self.scene.isPresented.wrappedValue = false + self.scene.actions[responseId].action() + } + + self.alert = alert + } else if !scene.isPresented.wrappedValue, let alert { + backend.dismissAlert(alert as! Backend.Alert, window: nil) + self.alert = nil + } + } +} From 027778205ac36697d34b1af383db1761f3c81f14 Mon Sep 17 00:00:00 2001 From: "Kaleb A. Ascevich" Date: Sat, 3 Jan 2026 11:57:52 -0500 Subject: [PATCH 2/7] fix casing of "OK" label for default alert action --- Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift b/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift index 3ad758ddb07..89f47e3e50c 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift @@ -5,7 +5,7 @@ /// breaking ``Button``'s API would have much more wide-reaching impacts than /// breaking this single-purpose API. public struct AlertAction: Sendable { - public static let ok = AlertAction(label: "Ok", action: {}) + public static let ok = AlertAction(label: "OK", action: {}) public var label: String public var action: @MainActor @Sendable () -> Void From 98d615c21c776ac9725e8d041d69d8c96f2f00fd Mon Sep 17 00:00:00 2001 From: "Kaleb A. Ascevich" Date: Sat, 3 Jan 2026 12:08:46 -0500 Subject: [PATCH 3/7] add `AlertScene` to WindowingExample --- Examples/Sources/WindowingExample/WindowingApp.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 29056b06053..18ff40cc049 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -162,6 +162,7 @@ struct SheetDemo: View { struct WindowingApp: App { @State var title = "My window" @State var resizable = false + @State var isStandaloneAlertShown = false var body: some Scene { WindowGroup(title) { @@ -189,6 +190,9 @@ struct WindowingApp: App { #endif AlertDemo() + Button("Show standalone alert") { + isStandaloneAlertShown = true + } Divider() @@ -210,6 +214,9 @@ struct WindowingApp: App { } } } + + AlertScene("Standalone alert", isPresented: $isStandaloneAlertShown) + #if !os(iOS) && !os(tvOS) WindowGroup("Secondary window") { #hotReloadable { From 97ec26625cb98cce6f5fe970e6f117726e3c9304 Mon Sep 17 00:00:00 2001 From: "Kaleb A. Ascevich" Date: Sat, 3 Jan 2026 12:17:23 -0500 Subject: [PATCH 4/7] improve `AlertScene` documentation --- Sources/SwiftCrossUI/Scenes/AlertScene.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/SwiftCrossUI/Scenes/AlertScene.swift b/Sources/SwiftCrossUI/Scenes/AlertScene.swift index fdb41002ae1..1e4f92bc2fa 100644 --- a/Sources/SwiftCrossUI/Scenes/AlertScene.swift +++ b/Sources/SwiftCrossUI/Scenes/AlertScene.swift @@ -1,4 +1,7 @@ /// A scene that shows a standalone alert. +/// +/// The exact behavior of the alert is backend-dependent, but it typically +/// shows up as an application modal or standalone window. public struct AlertScene: Scene { public typealias Node = AlertSceneNode @@ -8,6 +11,16 @@ public struct AlertScene: Scene { public let commands = Commands.empty + /// Creates an alert scene. + /// + /// The exact behavior of the alert is backend-dependent, but it typically + /// shows up as an application modal or standalone window. + /// + /// - Parameters: + /// - title: The alert's title. + /// - isPresented: A binding to a `Bool` that controls whether the alert + /// is presented. + /// - actions: The alert's actions. public init( _ title: String, isPresented: Binding, @@ -19,6 +32,7 @@ public struct AlertScene: Scene { } } +/// The scene graph node for ``AlertScene``. public final class AlertSceneNode: SceneGraphNode { public typealias NodeScene = AlertScene From baf48b234f02dde19f66b30a16c9bf3bbc545cfc Mon Sep 17 00:00:00 2001 From: "Kaleb A. Ascevich" Date: Sat, 3 Jan 2026 12:26:59 -0500 Subject: [PATCH 5/7] allow empty `AlertActionsBuilder` block to match SwiftUI An empty block returns the default "OK" action. `AlertScene` and `presentAlert` now require the `actions` parameter; an empty closure provides the previous behavior. --- Examples/Sources/WindowingExample/WindowingApp.swift | 4 ++-- Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift | 5 +++++ Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift | 2 -- .../Environment/Actions/PresentAlertAction.swift | 2 +- Sources/SwiftCrossUI/Scenes/AlertScene.swift | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 18ff40cc049..c08000230cb 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -59,7 +59,7 @@ struct AlertDemo: View { Button("Present error") { Task { - await presentAlert("Failed to succeed") + await presentAlert("Failed to succeed") {} } } } @@ -215,7 +215,7 @@ struct WindowingApp: App { } } - AlertScene("Standalone alert", isPresented: $isStandaloneAlertShown) + AlertScene("Standalone alert", isPresented: $isStandaloneAlertShown) {} #if !os(iOS) && !os(tvOS) WindowGroup("Secondary window") { diff --git a/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift b/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift index a78436c86c8..b517e12fc06 100644 --- a/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift +++ b/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift @@ -1,6 +1,11 @@ /// A builder for `[AlertAction]`. @resultBuilder public struct AlertActionsBuilder { + /// If no actions are provided, return a default "OK" action. + public static func buildBlock() -> [AlertAction] { + [AlertAction(label: "OK", action: {})] + } + public static func buildPartialBlock(first: Button) -> [AlertAction] { [ AlertAction( diff --git a/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift b/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift index 89f47e3e50c..c2dc9910ac9 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift @@ -5,8 +5,6 @@ /// breaking ``Button``'s API would have much more wide-reaching impacts than /// breaking this single-purpose API. public struct AlertAction: Sendable { - public static let ok = AlertAction(label: "OK", action: {}) - public var label: String public var action: @MainActor @Sendable () -> Void } diff --git a/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift b/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift index a045493766f..9fd0c8bb0b8 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift @@ -10,7 +10,7 @@ public struct PresentAlertAction { @discardableResult public func callAsFunction( _ title: String, - @AlertActionsBuilder actions: () -> [AlertAction] = { [.ok] } + @AlertActionsBuilder actions: () -> [AlertAction] ) async -> Int { let actions = actions() diff --git a/Sources/SwiftCrossUI/Scenes/AlertScene.swift b/Sources/SwiftCrossUI/Scenes/AlertScene.swift index 1e4f92bc2fa..b94e488e606 100644 --- a/Sources/SwiftCrossUI/Scenes/AlertScene.swift +++ b/Sources/SwiftCrossUI/Scenes/AlertScene.swift @@ -24,7 +24,7 @@ public struct AlertScene: Scene { public init( _ title: String, isPresented: Binding, - @AlertActionsBuilder actions: () -> [AlertAction] = { [.ok] } + @AlertActionsBuilder actions: () -> [AlertAction] ) { self.title = title self.isPresented = isPresented From 9cb25f35b5ab633cf92e373dc996a74a3d2a6504 Mon Sep 17 00:00:00 2001 From: "Kaleb A. Ascevich" Date: Sun, 4 Jan 2026 22:54:56 -0500 Subject: [PATCH 6/7] implement PR requested changes --- .../Sources/WindowingExample/WindowingApp.swift | 8 ++++---- .../Builders/AlertActionsBuilder.swift | 2 +- .../Environment/Actions/AlertAction.swift | 2 ++ .../Environment/Actions/PresentAlertAction.swift | 2 +- Sources/SwiftCrossUI/Scenes/AlertScene.swift | 16 +++++++++------- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index c08000230cb..cdecd9714b6 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -162,7 +162,7 @@ struct SheetDemo: View { struct WindowingApp: App { @State var title = "My window" @State var resizable = false - @State var isStandaloneAlertShown = false + @State var isAlertSceneShown = false var body: some Scene { WindowGroup(title) { @@ -190,8 +190,8 @@ struct WindowingApp: App { #endif AlertDemo() - Button("Show standalone alert") { - isStandaloneAlertShown = true + Button("Show alert scene") { + isAlertSceneShown = true } Divider() @@ -215,7 +215,7 @@ struct WindowingApp: App { } } - AlertScene("Standalone alert", isPresented: $isStandaloneAlertShown) {} + AlertScene("Alert scene", isPresented: $isAlertSceneShown) {} #if !os(iOS) && !os(tvOS) WindowGroup("Secondary window") { diff --git a/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift b/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift index b517e12fc06..b864694cee0 100644 --- a/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift +++ b/Sources/SwiftCrossUI/Builders/AlertActionsBuilder.swift @@ -3,7 +3,7 @@ public struct AlertActionsBuilder { /// If no actions are provided, return a default "OK" action. public static func buildBlock() -> [AlertAction] { - [AlertAction(label: "OK", action: {})] + [.default] } public static func buildPartialBlock(first: Button) -> [AlertAction] { diff --git a/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift b/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift index c2dc9910ac9..e2429954857 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/AlertAction.swift @@ -5,6 +5,8 @@ /// breaking ``Button``'s API would have much more wide-reaching impacts than /// breaking this single-purpose API. public struct AlertAction: Sendable { + public static let `default` = AlertAction(label: "OK", action: {}) + public var label: String public var action: @MainActor @Sendable () -> Void } diff --git a/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift b/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift index 9fd0c8bb0b8..931aed9de87 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/PresentAlertAction.swift @@ -10,7 +10,7 @@ public struct PresentAlertAction { @discardableResult public func callAsFunction( _ title: String, - @AlertActionsBuilder actions: () -> [AlertAction] + @AlertActionsBuilder actions: () -> [AlertAction] = { [.default] } ) async -> Int { let actions = actions() diff --git a/Sources/SwiftCrossUI/Scenes/AlertScene.swift b/Sources/SwiftCrossUI/Scenes/AlertScene.swift index b94e488e606..137cf7db478 100644 --- a/Sources/SwiftCrossUI/Scenes/AlertScene.swift +++ b/Sources/SwiftCrossUI/Scenes/AlertScene.swift @@ -1,12 +1,13 @@ /// A scene that shows a standalone alert. /// /// The exact behavior of the alert is backend-dependent, but it typically -/// shows up as an application modal or standalone window. +/// shows up as an application modal, or attaches itself to the app's main +/// window. public struct AlertScene: Scene { public typealias Node = AlertSceneNode var title: String - var isPresented: Binding + @Binding var isPresented: Bool var actions: [AlertAction] public let commands = Commands.empty @@ -14,7 +15,8 @@ public struct AlertScene: Scene { /// Creates an alert scene. /// /// The exact behavior of the alert is backend-dependent, but it typically - /// shows up as an application modal or standalone window. + /// shows up as an application modal, or attaches itself to the app's main + /// window. /// /// - Parameters: /// - title: The alert's title. @@ -27,7 +29,7 @@ public struct AlertScene: Scene { @AlertActionsBuilder actions: () -> [AlertAction] ) { self.title = title - self.isPresented = isPresented + self._isPresented = isPresented self.actions = actions() } } @@ -56,7 +58,7 @@ public final class AlertSceneNode: SceneGraphNode { self.scene = newScene } - if scene.isPresented.wrappedValue, alert == nil { + if scene.isPresented, alert == nil { let alert = backend.createAlert() backend.updateAlert( alert, @@ -66,12 +68,12 @@ public final class AlertSceneNode: SceneGraphNode { ) backend.showAlert(alert, window: nil) { responseId in self.alert = nil - self.scene.isPresented.wrappedValue = false + self.scene.isPresented = false self.scene.actions[responseId].action() } self.alert = alert - } else if !scene.isPresented.wrappedValue, let alert { + } else if !scene.isPresented, let alert { backend.dismissAlert(alert as! Backend.Alert, window: nil) self.alert = nil } From 5a3c886b8db904b633d178183a89298d58970f6e Mon Sep 17 00:00:00 2001 From: "Kaleb A. Ascevich" Date: Mon, 5 Jan 2026 00:43:25 -0500 Subject: [PATCH 7/7] assign `mainWindow` on initial window creation to fix `AlertScene` crash --- Sources/UIKitBackend/UIKitBackend+Window.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 720a2deef81..61a7f27a04a 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -69,11 +69,16 @@ extension UIKitBackend { public typealias Window = UIWindow public func createWindow(withDefaultSize _: SIMD2?) -> Window { - var window: UIWindow + let window: UIWindow if !Self.hasReturnedAWindow { + if let mainWindow = Self.mainWindow { + window = mainWindow + } else { + window = UIWindow() + Self.mainWindow = window + } Self.hasReturnedAWindow = true - window = Self.mainWindow ?? UIWindow() } else { window = UIWindow() }