Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .swift-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
6.2.4
7 changes: 7 additions & 0 deletions Examples/Sources/WindowingExample/WindowingApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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.
Expand All @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@
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(_:)))
}
Expand Down Expand Up @@ -283,6 +291,22 @@
) { _ 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(
Expand Down Expand Up @@ -311,6 +335,22 @@
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) {
Expand Down Expand Up @@ -445,7 +485,7 @@
func setSize(of widget: Widget, to proposedSize: ProposedViewSize) {
var foundConstraint = false
for constraint in widget.constraints {
if constraint.firstAnchor === widget.widthAnchor {

Check warning on line 488 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Prefer For-Where Violation: `where` clauses are preferred over a single `if` inside a `for` (for_where)
if let proposedWidth = proposedSize.width {
constraint.constant = CGFloat(proposedWidth)
constraint.isActive = true
Expand All @@ -463,7 +503,7 @@

foundConstraint = false
for constraint in widget.constraints {
if constraint.firstAnchor === widget.heightAnchor {

Check warning on line 506 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Prefer For-Where Violation: `where` clauses are preferred over a single `if` inside a `for` (for_where)
if let proposedHeight = proposedSize.height {
constraint.constant = CGFloat(proposedHeight)
constraint.isActive = true
Expand Down Expand Up @@ -654,7 +694,7 @@
environment: EnvironmentValues,
onChange: @escaping (Double) -> Void
) {
// TODO: Implement decimalPlaces

Check warning on line 697 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Implement decimalPlaces) (todo)
let slider = slider as! NSSlider
slider.minValue = minimum
slider.maxValue = maximum
Expand Down Expand Up @@ -774,7 +814,7 @@
.name
case .emailAddress:
.emailAddress
case .text, .digits(_), .decimal(_):

Check warning on line 817 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Empty Enum Arguments Violation: Arguments can be omitted when matching enums with associated values if they are not used (empty_enum_arguments)

Check warning on line 817 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Empty Enum Arguments Violation: Arguments can be omitted when matching enums with associated values if they are not used (empty_enum_arguments)
nil
}
}
Expand Down Expand Up @@ -828,7 +868,7 @@
.name
case .emailAddress:
.emailAddress
case .text, .digits(_), .decimal(_):

Check warning on line 871 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Empty Enum Arguments Violation: Arguments can be omitted when matching enums with associated values if they are not used (empty_enum_arguments)

Check warning on line 871 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Empty Enum Arguments Violation: Arguments can be omitted when matching enums with associated values if they are not used (empty_enum_arguments)
nil
}
}
Expand Down Expand Up @@ -918,7 +958,7 @@
public func baseItemPadding(
ofSelectableListView listView: Widget
) -> SwiftCrossUI.EdgeInsets {
// TODO: Figure out if there's a way to compute this more directly. At

Check warning on line 961 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Figure out if there's a way to...) (todo)
// the moment these are just figures from empirical observations.
SwiftCrossUI.EdgeInsets(top: 0, bottom: 0, leading: 8, trailing: 8)
}
Expand Down Expand Up @@ -1342,7 +1382,7 @@
panel.showsHiddenFiles = fileDialogOptions.showHiddenFiles
panel.allowsOtherFileTypes = fileDialogOptions.allowOtherContentTypes

// TODO: allowedContentTypes

Check warning on line 1385 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (allowedContentTypes) (todo)

panel.allowsMultipleSelection = openDialogOptions.allowMultipleSelections
panel.canChooseFiles = openDialogOptions.allowSelectingFiles
Expand Down Expand Up @@ -1381,7 +1421,7 @@
panel.showsHiddenFiles = fileDialogOptions.showHiddenFiles
panel.allowsOtherFileTypes = fileDialogOptions.allowOtherContentTypes

// TODO: allowedContentTypes

Check warning on line 1424 in Sources/AppKitBackend/AppKitBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (allowedContentTypes) (todo)

panel.nameFieldLabel = saveDialogOptions.nameFieldLabel ?? panel.nameFieldLabel
panel.nameFieldStringValue = saveDialogOptions.defaultFileName ?? ""
Expand Down
18 changes: 16 additions & 2 deletions Sources/DummyBackend/DummyBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public final class DummyBackend: AppBackend {
public var content: Widget?
public var resizeHandler: ((SIMD2<Int>) -> Void)?
public var closeHandler: (() -> Void)?
public var isActive = false

public init(defaultSize: SIMD2<Int>?) {
size = defaultSize ?? Self.defaultSize
Expand Down Expand Up @@ -256,6 +257,7 @@ public final class DummyBackend: AppBackend {
public var supportedPickerStyles: [BackendPickerStyle] = []

public var incomingURLHandler: ((URL) -> Void)?
public var isAppActive = true

public init() {}

Expand Down Expand Up @@ -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?()
Expand Down
22 changes: 22 additions & 0 deletions Sources/Gtk/Widgets/ApplicationWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<gboolean>.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
}
4 changes: 4 additions & 0 deletions Sources/Gtk/Widgets/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
18 changes: 17 additions & 1 deletion Sources/Gtk3/Widgets/ApplicationWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,33 @@ public class ApplicationWindow: Window {
) -> Void = { _, value1, data in
SignalBox1<gint>.run(data, value1)
}

addSignal(
name: "notify::scale-factor",
handler: gCallback(handler2)
) { [weak self] (scaleFactor: gint) in
guard let self else { return }
self.notifyScaleFactor?(Int(scaleFactor))
}

let handler3:
@convention(c) (
UnsafeMutableRawPointer,
gboolean,
UnsafeMutableRawPointer
) -> Void = { _, value1, data in
SignalBox1<gboolean>.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
}
4 changes: 4 additions & 0 deletions Sources/Gtk3/Widgets/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
14 changes: 13 additions & 1 deletion Sources/Gtk3Backend/Gtk3Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions Sources/GtkBackend/GtkBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions Sources/SwiftCrossUI/Backend/AppBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Sources/SwiftCrossUI/Environment/EnvironmentValues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: AppBackend>(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
Expand Down
9 changes: 9 additions & 0 deletions Sources/SwiftCrossUI/Values/ScenePhase.swift
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions Sources/UIKitBackend/UIKitBackend+Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
18 changes: 18 additions & 0 deletions Sources/WinUIBackend/WinUIBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading