diff --git a/.swift-version b/.swift-version new file mode 100644 index 00000000000..31b44b032b5 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.2.4 \ No newline at end of file diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index c339462cc42..f007c26fe92 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -194,11 +194,14 @@ struct TertiaryWindowView: View { struct SingletonWindowView: View { @Environment(\.dismissWindow) private var dismissWindow + @Environment(\.scenePhase) private var scenePhase var body: some View { VStack { Text("This a singleton window!") + Text("Window scene phase: \(scenePhase)") + Button("Close window") { dismissWindow() } @@ -218,6 +221,8 @@ struct WindowingApp: App { @State var closable = true @State var minimizable = true + @Environment(\.scenePhase) var scenePhase + var bannerImage: URL { // TODO(stackotter): Update SwiftBundlerRuntime to support fetching // resources in a cross platform manner. @@ -241,6 +246,8 @@ struct WindowingApp: App { TextField("My window", text: $title) } + Text("App scene phase: \(scenePhase)") + Toggle("Enable resizing", isOn: $resizable) .windowResizeBehavior(resizable ? .enabled : .disabled) Toggle("Enable closing", isOn: $closable) diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index d8803550b11..90a7d8f7326 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -165,6 +165,14 @@ public final class AppKitBackend: AppBackend { window.makeKeyAndOrderFront(nil) } + public func isWindowActive(_ window: Window) -> Bool { + return window.isKeyWindow + } + + public func isApplicationActive() -> Bool { + return NSApplication.shared.isActive + } + public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) { MenuBar.setUpMenuBar(extraMenus: submenus.map(Self.renderSubmenu(_:))) } @@ -283,6 +291,22 @@ public final class AppKitBackend: AppBackend { ) { _ in action() } + + // For updating views that rely on `scenePhase` + NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { _ in + action() + } + NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { _ in + action() + } } public func computeWindowEnvironment( @@ -311,6 +335,22 @@ public final class AppKitBackend: AppBackend { action() } } + + // For updating views that rely on `scenePhase` + NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { _ in + action() + } + NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: nil, + queue: .main + ) { _ in + action() + } } public func setIncomingURLHandler(to action: @escaping (URL) -> Void) { diff --git a/Sources/DummyBackend/DummyBackend.swift b/Sources/DummyBackend/DummyBackend.swift index 717de97c8fc..42be7488485 100644 --- a/Sources/DummyBackend/DummyBackend.swift +++ b/Sources/DummyBackend/DummyBackend.swift @@ -15,6 +15,7 @@ public final class DummyBackend: AppBackend { public var content: Widget? public var resizeHandler: ((SIMD2) -> Void)? public var closeHandler: (() -> Void)? + public var isActive = false public init(defaultSize: SIMD2?) { size = defaultSize ?? Self.defaultSize @@ -256,6 +257,7 @@ public final class DummyBackend: AppBackend { public var supportedPickerStyles: [BackendPickerStyle] = [] public var incomingURLHandler: ((URL) -> Void)? + public var isAppActive = true public init() {} @@ -312,9 +314,21 @@ public final class DummyBackend: AppBackend { window.resizeHandler = action } - public func show(window: Window) {} + public func show(window: Window) { + window.isActive = true + } + + public func activate(window: Window) { + window.isActive = true + } - public func activate(window: Window) {} + public func isWindowActive(_ window: Window) -> Bool { + window.isActive + } + + public func isApplicationActive() -> Bool { + isAppActive + } public func close(window: Window) { window.closeHandler?() diff --git a/Sources/Gtk/Widgets/ApplicationWindow.swift b/Sources/Gtk/Widgets/ApplicationWindow.swift index cd2439a9f2c..4570848e9a3 100644 --- a/Sources/Gtk/Widgets/ApplicationWindow.swift +++ b/Sources/Gtk/Widgets/ApplicationWindow.swift @@ -12,5 +12,27 @@ public class ApplicationWindow: Window { registerSignals() } + public override func registerSignals() { + super.registerSignals() + + let handler2: + @convention(c) ( + UnsafeMutableRawPointer, + gboolean, + UnsafeMutableRawPointer + ) -> Void = { _, value1, data in + SignalBox1.run(data, value1) + } + addSignal( + name: "notify::is-active", + handler: gCallback(handler2) + ) { [weak self] (isActive: gboolean) in + guard let self else { return } + self.notifyIsActive?(isActive != 0) + } + } + + public var notifyIsActive: ((Bool) -> Void)? + @GObjectProperty(named: "show-menubar") public var showMenuBar: Bool } diff --git a/Sources/Gtk/Widgets/Window.swift b/Sources/Gtk/Widgets/Window.swift index d410ae2e7e1..c703aa74f71 100644 --- a/Sources/Gtk/Widgets/Window.swift +++ b/Sources/Gtk/Widgets/Window.swift @@ -19,6 +19,10 @@ open class Window: Widget { @GObjectProperty(named: "decorated") public var isDecorated: Bool @GObjectProperty(named: "destroy-with-parent") public var destroyWithParent: Bool + public var isActive: Bool { + gtk_window_is_active(castedPointer()).toBool() + } + public func setTransient(for other: Window) { gtk_window_set_transient_for(castedPointer(), other.castedPointer()) } diff --git a/Sources/Gtk3/Widgets/ApplicationWindow.swift b/Sources/Gtk3/Widgets/ApplicationWindow.swift index b9bce1f458b..caeed1fc7ea 100644 --- a/Sources/Gtk3/Widgets/ApplicationWindow.swift +++ b/Sources/Gtk3/Widgets/ApplicationWindow.swift @@ -23,7 +23,6 @@ public class ApplicationWindow: Window { ) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal( name: "notify::scale-factor", handler: gCallback(handler2) @@ -31,9 +30,26 @@ public class ApplicationWindow: Window { guard let self else { return } self.notifyScaleFactor?(Int(scaleFactor)) } + + let handler3: + @convention(c) ( + UnsafeMutableRawPointer, + gboolean, + UnsafeMutableRawPointer + ) -> Void = { _, value1, data in + SignalBox1.run(data, value1) + } + addSignal( + name: "notify::is-active", + handler: gCallback(handler3) + ) { [weak self] (isActive: gboolean) in + guard let self else { return } + self.notifyIsActive?(isActive != 0) + } } public var notifyScaleFactor: ((Int) -> Void)? + public var notifyIsActive: ((Bool) -> Void)? @GObjectProperty(named: "show-menubar") public var showMenuBar: Bool } diff --git a/Sources/Gtk3/Widgets/Window.swift b/Sources/Gtk3/Widgets/Window.swift index 7a3d907cef7..6a32ee4d034 100644 --- a/Sources/Gtk3/Widgets/Window.swift +++ b/Sources/Gtk3/Widgets/Window.swift @@ -16,6 +16,10 @@ open class Window: Bin { @GObjectProperty(named: "modal") public var isModal: Bool @GObjectProperty(named: "decorated") public var isDecorated: Bool + public var isActive: Bool { + gtk_window_is_active(castedPointer()).toBool() + } + public func setTransient(for other: Window) { gtk_window_set_transient_for(castedPointer(), other.castedPointer()) } diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 8334a6a0301..72137651487 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -365,6 +365,14 @@ public final class Gtk3Backend: AppBackend { window.present() } + public func isWindowActive(_ window: Window) -> Bool { + window.isActive + } + + public func isApplicationActive() -> Bool { + windows.contains(where: \.isActive) + } + public func close(window: Window) { window.close() @@ -503,6 +511,7 @@ public final class Gtk3Backend: AppBackend { public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) { // TODO: React to theme changes + // TODO: Notify when app focus changes } public func computeWindowEnvironment( @@ -516,10 +525,13 @@ public final class Gtk3Backend: AppBackend { public func setWindowEnvironmentChangeHandler( of window: Window, to action: @escaping () -> Void - ) { + ) { window.notifyScaleFactor = { _ in action() } + window.notifyIsActive = { _ in + action() + } } public func setIncomingURLHandler(to action: @escaping (URL) -> Void) { diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 9e7fb03a106..13ddf7fbc66 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -259,6 +259,14 @@ public final class GtkBackend: AppBackend { window.present() } + public func isWindowActive(_ window: Window) -> Bool { + window.isActive + } + + public func isApplicationActive() -> Bool { + windows.contains(where: \.isActive) + } + public func close(window: Window) { window.close() } @@ -471,6 +479,7 @@ public final class GtkBackend: AppBackend { public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) { // TODO: React to theme changes + // TODO: Notify when app focus changes } public func computeWindowEnvironment( @@ -486,6 +495,10 @@ public final class GtkBackend: AppBackend { to action: @escaping () -> Void ) { // TODO: Notify when window scale factor changes + + window.notifyIsActive = { _ in + action() + } } public func setIncomingURLHandler(to action: @escaping (URL) -> Void) { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 0c1a1698519..0ae154cfb13 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -283,6 +283,16 @@ public protocol AppBackend: Sendable { to action: @escaping () -> Void ) + /// Returns whether the given window is currently active. + func isWindowActive(_ window: Window) -> Bool + + /// Returns whether the application is currently active. + /// + /// Usually this returns `true` if and only if any of the app's windows are + /// active, but on platforms such as macOS it can also return `true` if the + /// app doesn't have any open windows but still appears in the menu bar. + func isApplicationActive() -> Bool + /// Sets the application's global menu. /// /// Some backends may make use of the host platform's global menu bar diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index beb7ab7d767..8177e250a8d 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -184,6 +184,22 @@ public struct EnvironmentValues { RevealFileAction(backend: backend) } + /// The active state of the current scene (or, if accessed outside of a scene, + /// the app as a whole). + @MainActor + public var scenePhase: ScenePhase { + func scenePhase(backend: Backend) -> ScenePhase { + let isActive = if let window { + backend.isWindowActive(window as! Backend.Window) + } else { + backend.isApplicationActive() + } + return if isActive { .active } else { .inactive } + } + + return scenePhase(backend: backend) + } + /// Whether the backend can have multiple windows open at once. Mobile /// backends generally can't. @MainActor diff --git a/Sources/SwiftCrossUI/Values/ScenePhase.swift b/Sources/SwiftCrossUI/Values/ScenePhase.swift new file mode 100644 index 00000000000..6772ab904bf --- /dev/null +++ b/Sources/SwiftCrossUI/Values/ScenePhase.swift @@ -0,0 +1,9 @@ +/// The active state of a scene or app. +public enum ScenePhase: Hashable, Sendable { + /// The scene is active. + case active + /// The scene is inactive. + case inactive + + // TODO: Figure out how .background would work on desktops +} diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index ad09b646292..a501b1b6cf7 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -128,6 +128,14 @@ extension UIKitBackend { window.makeKeyAndVisible() } + public func isWindowActive(_ window: Window) -> Bool { + window.isKeyWindow + } + + public func isApplicationActive() -> Bool { + UIApplication.shared.applicationState == .active + } + public func close(window: Window) { logger.notice("UIKitBackend: ignoring \(#function) call") } diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 47e2f047202..94bf5fcec03 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -288,6 +288,14 @@ public final class WinUIBackend: AppBackend { try! window.activate() } + public func isWindowActive(_ window: Window) -> Bool { + window.isActive + } + + public func isApplicationActive() -> Bool { + windows.contains(where: \.isActive) + } + public func close(window: Window) { try! window.close() } @@ -2216,6 +2224,7 @@ public class CustomWindow: WinUI.Window { var child: WinUIBackend.Widget? var grid: WinUI.Grid var cachedAppWindow: WinAppSDK.AppWindow! + var isActive = false private(set) var menuBarIsVisible = false @@ -2265,6 +2274,15 @@ public class CustomWindow: WinUI.Window { WinUI.Grid.setRow(menuBar, 0) self.content = grid + // NB: This event fires when the window is activated _or_ deactivated. + self.activated.addHandler { [weak self] _, args in + switch args?.windowActivationState { + case .activated: self?.isActive = true + case .deactivated: self?.isActive = false + default: break + } + } + // Caching appWindow is apparently a good idea in terms of performance: // https://github.com/thebrowsercompany/swift-winrt/issues/199#issuecomment-2611006020 cachedAppWindow = appWindow