diff --git a/Sources/Portico/App/ApplicationWindow.swift b/Sources/Portico/App/ApplicationWindow.swift index 3b32cf9..fe400a6 100644 --- a/Sources/Portico/App/ApplicationWindow.swift +++ b/Sources/Portico/App/ApplicationWindow.swift @@ -62,7 +62,11 @@ public extension ApplicationWindow { } @_spi(Portico) extension ApplicationWindow: MountableScene { - @_spi(Portico) public func attach(to app: Adw.Application) { + /// An `ApplicationWindow` attaches its own window directly, with no + /// conditional wrapping it. + @_spi(Portico) public var identity: SceneIdentity { .leaf } + + @_spi(Portico) public func attach(to app: Adw.Application) -> SceneHandle? { let window = Adw.ApplicationWindow(app: app) if let s = config.defaultSize { @@ -81,10 +85,12 @@ public extension ApplicationWindow { let ctx = MountContext() let child = AnyView(content(window)).makeWidget(ctx) window.setContent(content: child) - _ = window.connectCloseRequest { [registry = ctx.registry] _ in - registry.teardown() + let handle = SceneHandle(window: window, registry: ctx.registry) + _ = window.connectCloseRequest { [weak handle] _ in + handle?.releaseResources() return false // allow the default close to proceed } window.present() + return handle } } diff --git a/Sources/Portico/App/EitherScene.swift b/Sources/Portico/App/EitherScene.swift new file mode 100644 index 0000000..71c7d45 --- /dev/null +++ b/Sources/Portico/App/EitherScene.swift @@ -0,0 +1,48 @@ +import Adw + +/// The scene produced by an `if`/`else` inside ``SceneBuilder``. +/// +/// Carries whichever branch the condition selected. Unlike ``EitherView``, which +/// mounts both branches into a `Gtk.Stack` and toggles visibility, a scene branch +/// change replaces the whole window - top-level windows cannot be swapped without +/// rebuilding them. +public struct EitherScene: Scene { + enum Branch { + case first(First) + case second(Second) + } + + let branch: Branch + + init(_ branch: Branch) { self.branch = branch } + + public var body: Never { fatalError() } +} + +@_spi(Portico) extension EitherScene: MountableScene { + @_spi(Portico) public var identity: SceneIdentity { + switch branch { + case .first(let s): return .first(Self.mountable(s).identity) + case .second(let s): return .second(Self.mountable(s).identity) + } + } + + @_spi(Portico) public func attach(to app: Adw.Application) -> SceneHandle? { + switch branch { + case .first(let s): return Self.mountable(s).attach(to: app) + case .second(let s): return Self.mountable(s).attach(to: app) + } + } + + /// Runtime-casts a branch to ``MountableScene``, matching the dispatch + /// ``PorticoRuntime`` uses for the root scene. + private static func mountable(_ scene: S) -> MountableScene { + guard let m = scene as? MountableScene else { + fatalError( + "Portico: every branch of a conditional Scene must conform to " + + "MountableScene (use ApplicationWindow)" + ) + } + return m + } +} diff --git a/Sources/Portico/App/MountableScene.swift b/Sources/Portico/App/MountableScene.swift index 3591535..b0a3105 100644 --- a/Sources/Portico/App/MountableScene.swift +++ b/Sources/Portico/App/MountableScene.swift @@ -1,12 +1,52 @@ import Adw -/// SPI mount requirement for scenes — a standalone protocol (NOT a +/// Identifies which conditional branch a scene tree selected. +/// +/// Two scene values with equal identities select the same window slot, so the +/// runtime leaves the live window in place. The path is built outward: each +/// conditional wrapper prepends its own marker to the child's path. +@_spi(Portico) public struct SceneIdentity: Equatable, Sendable { + /// The branch markers, outermost first. + @_spi(Portico) public let path: [UInt8] + + private init(path: [UInt8]) { self.path = path } + + /// A scene that attaches a window directly, with no conditional above it. + @_spi(Portico) public static let leaf = SceneIdentity(path: []) + + /// The selection made by an `if` whose condition is false and which has no `else`. + @_spi(Portico) public static let empty = SceneIdentity(path: [3]) + + /// Wraps `child` as the `if` branch of an `if`/`else`. + @_spi(Portico) public static func first(_ child: SceneIdentity) -> SceneIdentity { + SceneIdentity(path: [0] + child.path) + } + + /// Wraps `child` as the `else` branch of an `if`/`else`. + @_spi(Portico) public static func second(_ child: SceneIdentity) -> SceneIdentity { + SceneIdentity(path: [1] + child.path) + } + + /// Wraps `child` as the taken branch of a bare `if`. + @_spi(Portico) public static func present(_ child: SceneIdentity) -> SceneIdentity { + SceneIdentity(path: [2] + child.path) + } +} + +/// SPI mount requirement for scenes - a standalone protocol (NOT a /// refinement of `Scene`) so `Scene` stays public without exposing SPI /// in its inheritance clause. /// /// Dispatch uses `app.body as? MountableScene` (mirrors ``AnyView``'s -/// runtime-cast pattern). For v1 the only conformer is ``ApplicationWindow``. +/// runtime-cast pattern). Conformers: ``ApplicationWindow``, +/// ``EitherScene``, and ``OptionalScene``. @_spi(Portico) @MainActor public protocol MountableScene { - /// Attaches the scene to the given application, constructing and showing its window. - func attach(to app: Adw.Application) + /// The branch-selection path of this scene tree. + var identity: SceneIdentity { get } + + /// Builds and presents this scene's window. + /// + /// - Returns: A handle for later dismantling, or `nil` when the scene + /// selects no window at all (a false bare `if`). + func attach(to app: Adw.Application) -> SceneHandle? } diff --git a/Sources/Portico/App/OptionalScene.swift b/Sources/Portico/App/OptionalScene.swift new file mode 100644 index 0000000..2094bc1 --- /dev/null +++ b/Sources/Portico/App/OptionalScene.swift @@ -0,0 +1,33 @@ +import Adw + +/// The scene produced by a bare `if` inside ``SceneBuilder``. +/// +/// When the condition is false the app presents no window. Because GTK keeps an +/// application alive only while it owns at least one window, a false condition on +/// the *first* evaluation makes the process exit immediately. +public struct OptionalScene: Scene { + let wrapped: Wrapped? + + init(_ wrapped: Wrapped?) { self.wrapped = wrapped } + + public var body: Never { fatalError() } +} + +@_spi(Portico) extension OptionalScene: MountableScene { + @_spi(Portico) public var identity: SceneIdentity { + guard let wrapped else { return .empty } + guard let m = wrapped as? MountableScene else { fatalError(Self.message) } + return .present(m.identity) + } + + @_spi(Portico) public func attach(to app: Adw.Application) -> SceneHandle? { + guard let wrapped else { return nil } + guard let m = wrapped as? MountableScene else { fatalError(Self.message) } + return m.attach(to: app) + } + + private static var message: String { + "Portico: the body of a conditional Scene must conform to MountableScene " + + "(use ApplicationWindow)" + } +} diff --git a/Sources/Portico/App/PorticoRuntime.swift b/Sources/Portico/App/PorticoRuntime.swift index 0b15e01..5ec1f19 100644 --- a/Sources/Portico/App/PorticoRuntime.swift +++ b/Sources/Portico/App/PorticoRuntime.swift @@ -23,8 +23,8 @@ import Darwin /// scene tree via `connectActivate`, runs the GTK main loop, and /// terminates the process with the returned exit code. /// - /// - Precondition: `app.body` must conform to ``MountableScene`` - /// (v1 supports a single ``ApplicationWindow`` scene). + /// - Precondition: `app.body`, and every branch of a conditional in it, + /// must conform to ``MountableScene``. /// - Parameter app: The ``App`` instance to run. public static func run(_ app: A) { GLibExecutorFactory.install() @@ -33,16 +33,8 @@ import Darwin applicationId: app.applicationId, flags: .defaultFlags ) - _ = adwApp.connectActivate { _ in - let scene = app.body - guard let scene = scene as? MountableScene else { - fatalError( - "Portico: top-level Scene must conform to MountableScene " - + "(v1 supports a single ApplicationWindow scene)" - ) - } - scene.attach(to: adwApp) - } + let host = SceneHost(app: adwApp) { app.body } + _ = adwApp.connectActivate { _ in host.start() } exit(adwApp.run(argv: CommandLine.arguments)) } } diff --git a/Sources/Portico/App/SceneBuilder.swift b/Sources/Portico/App/SceneBuilder.swift index d171755..b522bc2 100644 --- a/Sources/Portico/App/SceneBuilder.swift +++ b/Sources/Portico/App/SceneBuilder.swift @@ -1,5 +1,28 @@ /// Result builder for composing ``Scene`` hierarchies. +/// +/// `if`/`else` produces an ``EitherScene`` and a bare `if` produces an +/// ``OptionalScene``. Unlike ``ViewBuilder``, these are *not* static +/// passthroughs: the selected branch is re-evaluated whenever the state the +/// condition reads changes, and a change of branch replaces the live window. @resultBuilder public enum SceneBuilder { /// Passes through a single scene unchanged. public static func buildBlock(_ scene: S) -> S { scene } + + /// Wraps the `if` branch of an `if`/`else`. + public static func buildEither(first: F) -> EitherScene { + EitherScene(.first(first)) + } + + /// Wraps the `else` branch of an `if`/`else`. + public static func buildEither(second: S) -> EitherScene { + EitherScene(.second(second)) + } + + /// Wraps a bare `if`; `nil` selects no window. + public static func buildOptional(_ scene: S?) -> OptionalScene { + OptionalScene(scene) + } + + /// Passes through an availability-guarded branch unchanged. + public static func buildLimitedAvailability(_ scene: S) -> S { scene } } diff --git a/Sources/Portico/App/SceneHandle.swift b/Sources/Portico/App/SceneHandle.swift new file mode 100644 index 0000000..1fe6c21 --- /dev/null +++ b/Sources/Portico/App/SceneHandle.swift @@ -0,0 +1,40 @@ +import Adw + +/// A live window attached by a ``MountableScene``, plus the reactive resources +/// mounted into it. +@_spi(Portico) @MainActor public final class SceneHandle { + /// The presented window. + @_spi(Portico) public let window: Adw.ApplicationWindow + + private let registry: NodeRegistry + private var resourcesReleased = false + + /// `true` once ``dismantle()`` has destroyed the window. + @_spi(Portico) public private(set) var isDismantled = false + + init(window: Adw.ApplicationWindow, registry: NodeRegistry) { + self.window = window + self.registry = registry + } + + /// Cancels every reactive subscription mounted into this window, leaving the + /// window itself untouched. Called from the window's `close-request` handler, + /// where GTK finishes the close. Idempotent. + func releaseResources() { + guard !resourcesReleased else { return } + resourcesReleased = true + registry.teardown() + } + + /// Releases reactive resources and destroys the window. Used when the runtime + /// swaps to a different scene branch. Idempotent. + @_spi(Portico) public func dismantle() { + guard !isDismantled else { return } + releaseResources() + window.destroy() + isDismantled = true + } + + /// Re-presents the window, for a second `activate` on a single-instance app. + @_spi(Portico) public func present() { window.present() } +} diff --git a/Sources/Portico/App/SceneHost.swift b/Sources/Portico/App/SceneHost.swift new file mode 100644 index 0000000..ea0d5c7 --- /dev/null +++ b/Sources/Portico/App/SceneHost.swift @@ -0,0 +1,89 @@ +import Adw + +/// Owns the application's live scene and re-attaches it when the selected +/// ``SceneBuilder`` branch changes. +/// +/// The scene value is produced inside a ``DependencyTracker``, so the state the +/// branch condition reads becomes a dependency. Building the scene value is cheap - +/// an ``ApplicationWindow``'s content closure is not invoked until `attach` - so +/// only the condition, not the view tree, is tracked. +/// +/// A re-evaluation whose ``SceneIdentity`` matches the live one is discarded: the +/// window is rebuilt exactly when the selected branch changes, never merely because +/// some state the body read changed. +@_spi(Portico) @MainActor public final class SceneHost { + private let app: Adw.Application + private let makeScene: () -> any Scene + private var tracker: DependencyTracker? + private var isStarted = false + private var pendingScene: (any MountableScene)? + private var targetIdentity: SceneIdentity? + private var flushScheduled = false + + /// The currently attached window, or `nil` when the selected branch attaches none. + @_spi(Portico) public private(set) var live: SceneHandle? + + /// The identity of ``live``. + @_spi(Portico) public private(set) var liveIdentity: SceneIdentity? + + /// - Parameters: + /// - app: The application windows are attached to. + /// - makeScene: Produces the root scene; called on every re-evaluation. + @_spi(Portico) public init(app: Adw.Application, makeScene: @escaping () -> any Scene) { + self.app = app + self.makeScene = makeScene + } + + /// Evaluates the scene tree and presents the selected window synchronously. + /// + /// A second call - a second `activate` on a single-instance app - re-presents + /// the live window instead of building a duplicate. + @_spi(Portico) public func start() { + if isStarted { + live?.present() + return + } + let t = DependencyTracker { [weak self] in self?.evaluate() } + tracker = t + t.run() + isStarted = true + flush() + } + + /// The tracked body: builds the scene value and records a swap when the + /// selected branch differs from the one already targeted. + private func evaluate() { + let scene = makeScene() + guard let m = scene as? MountableScene else { + fatalError( + "Portico: top-level Scene must conform to MountableScene " + + "(use ApplicationWindow, optionally behind an if/else)" + ) + } + let id = m.identity + guard targetIdentity != id else { return } + targetIdentity = id + pendingScene = m + scheduleFlush() + } + + private func scheduleFlush() { + guard isStarted, !flushScheduled else { return } + flushScheduled = true + Idle { [weak self] in self?.flush() } + } + + /// Presents the pending window, then destroys the previous one. The order + /// matters: GTK quits the application the moment it owns no windows. + private func flush() { + flushScheduled = false + guard let scene = pendingScene, let id = targetIdentity else { return } + pendingScene = nil + // The branch flipped away and back before the idle ran - keep the live window. + guard id != liveIdentity else { return } + let next = scene.attach(to: app) + live?.dismantle() + live = next + liveIdentity = id + } +} diff --git a/Tests/PorticoTests/SceneBuilderTests.swift b/Tests/PorticoTests/SceneBuilderTests.swift new file mode 100644 index 0000000..f6b8c39 --- /dev/null +++ b/Tests/PorticoTests/SceneBuilderTests.swift @@ -0,0 +1,233 @@ +import Testing +@_spi(Portico) import Portico +@_spi(SGTKInternal) import Gtk +import Adw + +@_silgen_name("g_main_context_iteration") +private nonisolated func scene_g_main_context_iteration( + _ context: UnsafeMutableRawPointer?, _ mayBlock: Int32 +) -> Int32 + +/// Drives the GLib main context until `condition` holds or `turns` is exhausted. +/// Returns `true` if the loop exhausted turns without the condition becoming true. +@MainActor private func pump(until condition: () -> Bool, turns: Int = 200) -> Bool { + for _ in 0.. some Scene { + if flag { + ApplicationWindow { _ in Gtk.Label(str: "first") } + .title("A") + } else { + ApplicationWindow { _ in Gtk.Label(str: "second") } + .title("B") + } + } + + @Test func ifElseSelectsFirstBranch() { + let scene = eitherScene(true) + #expect((scene as? MountableScene)?.identity == .first(.leaf)) + } + + @Test func ifElseSelectsSecondBranch() { + let scene = eitherScene(false) + #expect((scene as? MountableScene)?.identity == .second(.leaf)) + } + + @SceneBuilder private func bareIfScene(_ flag: Bool) -> some Scene { + if flag { + ApplicationWindow { _ in Gtk.Label(str: "present") } + .title("Present") + } + } + + @Test func bareIfIdentityPresent() { + let scene = bareIfScene(true) + #expect((scene as? MountableScene)?.identity == .present(.leaf)) + } + + @Test func bareIfIdentityEmpty() { + let scene = bareIfScene(false) + #expect((scene as? MountableScene)?.identity == .empty) + } + + @SceneBuilder private func nestedScene(_ level: Int) -> some Scene { + if level == 0 { + ApplicationWindow { _ in Gtk.Label(str: "zero") } + .title("Zero") + } else if level == 1 { + ApplicationWindow { _ in Gtk.Label(str: "one") } + .title("One") + } else { + ApplicationWindow { _ in Gtk.Label(str: "other") } + .title("Other") + } + } + + @Test func nestedElseIfIdentity() { + // Swift result builders handle `else if` chains by nesting the first + // two arms — buildEither(first: buildEither(first: A, second: B), + // second: C) — producing EitherScene, C> (SE-0289). + // Level 0 → first(.first(.leaf)) = [0, 0]. + #expect( + (nestedScene(0) as? MountableScene)?.identity + == .first(.first(.leaf)) + ) + // Level 1 → first(.second(.leaf)) = [0, 1]. + #expect( + (nestedScene(1) as? MountableScene)?.identity + == .first(.second(.leaf)) + ) + // Level 2 → second(.leaf) = [1]. + #expect( + (nestedScene(2) as? MountableScene)?.identity + == .second(.leaf) + ) + } + + // MARK: - Live swap tests (require GTK + Adw) + + @Test func hostSwapsWindowOnBranchFlip() { + guard Gtk.initCheck() else { return } + + let box = StateBox(false) + let app = Adw.Application(applicationId: nil, flags: .defaultFlags) + + @SceneBuilder func makeScene() -> some Scene { + if Binding(box).wrappedValue { + ApplicationWindow { _ in Gtk.Label(str: "main") } + .title("Main") + .defaultSize(width: 1080, height: 720) + } else { + ApplicationWindow { _ in Gtk.Label(str: "onboard") } + .title("Onboarding") + .defaultSize(width: 800, height: 600) + } + } + + let host = SceneHost(app: app, makeScene: { makeScene() }) + host.start() + + #expect(host.liveIdentity == .second(.leaf)) + #expect(host.live?.window.getTitle() == "Onboarding") + + let old = host.live! + box.set(true) + let timedOut = pump(until: { host.liveIdentity == .first(.leaf) }) + #expect(!timedOut, "pump timed out waiting for branch swap") + #expect(host.live?.window.getTitle() == "Main") + #expect(old.isDismantled) + #expect(host.live !== old) + } + + @Test func swapTearsDownOldSubtree() { + guard Gtk.initCheck() else { return } + + let branchBox = StateBox(false) + let titleBox = StateBox(0) + let app = Adw.Application(applicationId: nil, flags: .defaultFlags) + + @SceneBuilder func makeScene() -> some Scene { + if Binding(branchBox).wrappedValue { + ApplicationWindow { _ in Gtk.Label(str: "main window") } + .title("Main") + } else { + ApplicationWindow { _ in + Portico.Label(str: nil).label("v=\(titleBox.get())") + } + .title("Onboarding") + } + } + + let host = SceneHost(app: app, makeScene: { makeScene() }) + host.start() + + let old = host.live! + // Capture the mounted label before the swap. + let oldLabel = old.window.getChild() as? Gtk.Label + let textBefore = oldLabel?.getText() + + branchBox.set(true) + let timedOut = pump(until: { host.liveIdentity == .first(.leaf) }) + #expect(!timedOut, "pump timed out waiting for branch swap") + + // After teardown, setting titleBox should NOT update the old label. + titleBox.set(99) + // Pump a few turns so any stray updates would fire. + _ = pump(until: { false }, turns: 10) + + // The old label text must still be what it was before the swap. + #expect(oldLabel?.getText() == textBefore, + "Old label text changed after teardown: was '\(textBefore ?? "nil")', now '\(oldLabel?.getText() ?? "nil")'") + } + + @Test func identicalIdentityDoesNotRebuild() { + guard Gtk.initCheck() else { return } + + let box = StateBox("Initial") + let app = Adw.Application(applicationId: nil, flags: .defaultFlags) + + @SceneBuilder func makeScene() -> some Scene { + ApplicationWindow { _ in Gtk.Label(str: "fixed content") } + .title(Binding(box).wrappedValue) + } + + let host = SceneHost(app: app, makeScene: { makeScene() }) + host.start() + + let before = host.live! + box.set("Changed") + _ = pump(until: { false }, turns: 50) + + #expect(host.live === before, "Window was rebuilt when only title changed") + } + + @Test func falseBareIfAttachesNoWindow() { + guard Gtk.initCheck() else { return } + + let box = StateBox(true) + let app = Adw.Application(applicationId: nil, flags: .defaultFlags) + + @SceneBuilder func makeScene() -> some Scene { + if Binding(box).wrappedValue { + ApplicationWindow { _ in Gtk.Label(str: "visible") } + } + } + + let host = SceneHost(app: app, makeScene: { makeScene() }) + host.start() + #expect(host.live != nil) + + let old = host.live! + box.set(false) + let timedOut = pump(until: { host.liveIdentity == .empty }) + #expect(!timedOut, "pump timed out waiting for empty branch") + #expect(host.live == nil) + #expect(old.isDismantled) + } + + @Test func secondStartRepresentsLiveWindow() { + guard Gtk.initCheck() else { return } + + let app = Adw.Application(applicationId: nil, flags: .defaultFlags) + + @SceneBuilder func makeScene() -> some Scene { + ApplicationWindow { _ in Gtk.Label(str: "single") } + .title("Single") + } + + let host = SceneHost(app: app, makeScene: { makeScene() }) + host.start() + let first = host.live! + host.start() + #expect(host.live === first, "Second start() created a new window") + } +}