Add reactive if/else scene branching to SceneBuilder

This commit is contained in:
Brendan Szymanski 2026-08-02 18:10:26 -04:00
parent b398edf3ee
commit 0b3761db4b
9 changed files with 523 additions and 19 deletions

View file

@ -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
}
}

View file

@ -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<First: Scene, Second: Scene>: 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<S: Scene>(_ 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
}
}

View file

@ -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?
}

View file

@ -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<Wrapped: Scene>: 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)"
}
}

View file

@ -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<A: App>(_ 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))
}
}

View file

@ -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<S: Scene>(_ scene: S) -> S { scene }
/// Wraps the `if` branch of an `if`/`else`.
public static func buildEither<F: Scene, S: Scene>(first: F) -> EitherScene<F, S> {
EitherScene(.first(first))
}
/// Wraps the `else` branch of an `if`/`else`.
public static func buildEither<F: Scene, S: Scene>(second: S) -> EitherScene<F, S> {
EitherScene(.second(second))
}
/// Wraps a bare `if`; `nil` selects no window.
public static func buildOptional<S: Scene>(_ scene: S?) -> OptionalScene<S> {
OptionalScene(scene)
}
/// Passes through an availability-guarded branch unchanged.
public static func buildLimitedAvailability<S: Scene>(_ scene: S) -> S { scene }
}

View file

@ -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() }
}

View file

@ -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
}
}

View file

@ -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..<turns {
if condition() { return false }
_ = scene_g_main_context_iteration(nil, 0)
}
return true
}
@MainActor @Suite(.serialized) struct SceneBuilderTests {
// MARK: - Pure identity tests
@SceneBuilder private func eitherScene(_ flag: Bool) -> 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<EitherScene<A, B>, 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")
}
}