Add reactive if/else scene branching to SceneBuilder
This commit is contained in:
parent
b398edf3ee
commit
0b3761db4b
9 changed files with 523 additions and 19 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
Sources/Portico/App/EitherScene.swift
Normal file
48
Sources/Portico/App/EitherScene.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
33
Sources/Portico/App/OptionalScene.swift
Normal file
33
Sources/Portico/App/OptionalScene.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
40
Sources/Portico/App/SceneHandle.swift
Normal file
40
Sources/Portico/App/SceneHandle.swift
Normal 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() }
|
||||
}
|
||||
89
Sources/Portico/App/SceneHost.swift
Normal file
89
Sources/Portico/App/SceneHost.swift
Normal 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
|
||||
}
|
||||
}
|
||||
233
Tests/PorticoTests/SceneBuilderTests.swift
Normal file
233
Tests/PorticoTests/SceneBuilderTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue