diff --git a/Sources/Example/ExampleApp.swift b/Sources/Example/ExampleApp.swift index 80a65ef..04cef9c 100644 --- a/Sources/Example/ExampleApp.swift +++ b/Sources/Example/ExampleApp.swift @@ -1,14 +1,22 @@ import Portico -struct Counter: View { - @State private var count = 0 +private struct Item: Identifiable { let id: Int; let title: String } + +struct ListDemo: View { + @State private var items = [Item(id: 1, title: "One"), + Item(id: 2, title: "Two"), + Item(id: 3, title: "Three")] + @State private var next = 4 var body: some View { VStack(spacing: 12) { - Label { "Count: \(count)" } HStack(spacing: 12) { - Button("-") { count -= 1 } - Button("+") { count += 1 } + Button("Add") { items.append(Item(id: next, title: "Item \(next)")); next += 1 } + Button("Remove first") { if !items.isEmpty { items.removeFirst() } } + Button("Reverse") { items.reverse() } + } + ForEach($items, id: \.id) { item in + Label(item.title) } } } @@ -19,8 +27,8 @@ struct ExampleApp: App { var applicationId: String? { "dev.bscubed.PorticoExample" } var body: some Scene { - Window { _ in Counter() } - .title("Portico P1") + Window { _ in ListDemo() } + .title("Portico P4 — ForEach") .defaultSize(width: 1280, height: 800) } } diff --git a/Sources/Portico/App/Window.swift b/Sources/Portico/App/Window.swift index 11b4e7f..a16923e 100644 --- a/Sources/Portico/App/Window.swift +++ b/Sources/Portico/App/Window.swift @@ -83,6 +83,10 @@ public extension Window { let ctx = MountContext() let child = AnyView(content(window)).makeWidget(ctx) window.setContent(content: child) + _ = window.connectCloseRequest { [registry = ctx.registry] _ in + registry.teardown() + return false // allow the default close to proceed + } window.present() } } diff --git a/Sources/Portico/Containers/EitherView.swift b/Sources/Portico/Containers/EitherView.swift new file mode 100644 index 0000000..2af5a8a --- /dev/null +++ b/Sources/Portico/Containers/EitherView.swift @@ -0,0 +1,70 @@ +import Gtk + +/// A view that switches between two branches based on a `Binding` condition. +/// +/// Internally uses `Gtk.Stack` with named children `"true"` and `"false"`. +/// Each branch's widgets are mounted at most once — the first time that +/// branch becomes active. Subsequent condition changes only call +/// `setVisibleChildName`. +@MainActor public struct EitherView: View { + private let condition: Binding + private let firstContent: [AnyView] + private let secondContent: [AnyView] + + public var body: Never { fatalError() } + + /// - Parameters: + /// - condition: The binding that selects the visible branch. + /// - first: The view for the `true` branch (mounted once, lazily). + /// - second: The view for the `false` branch (mounted once, lazily). + public init( + _ condition: Binding, + @ViewBuilder first: () -> [AnyView], + @ViewBuilder second: () -> [AnyView] + ) { + self.condition = condition + self.firstContent = first() + self.secondContent = second() + } +} + +@_spi(Portico) extension EitherView: Mountable { + @_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget { + let stack = Gtk.Stack() + + // Branch widget caches — nil until the branch is first mounted. + var trueWidgets: [Gtk.Widget]? = nil + var falseWidgets: [Gtk.Widget]? = nil + + // Mount the initially-active branch first so it's visible immediately. + if condition.wrappedValue { + trueWidgets = firstContent.map { $0.makeWidget(ctx) } + for w in trueWidgets! { _ = stack.addNamed(child: w, name: "true") } + } else { + falseWidgets = secondContent.map { $0.makeWidget(ctx) } + for w in falseWidgets! { _ = stack.addNamed(child: w, name: "false") } + } + + // Track changes — re-evaluate condition, lazily mount the other + // branch on first flip. + let tracker = DependencyTracker { [stack] in + let isTrue = condition.wrappedValue + if isTrue { + if trueWidgets == nil { + trueWidgets = firstContent.map { $0.makeWidget(ctx) } + for w in trueWidgets! { _ = stack.addNamed(child: w, name: "true") } + } + stack.setVisibleChildName(name: "true") + } else { + if falseWidgets == nil { + falseWidgets = secondContent.map { $0.makeWidget(ctx) } + for w in falseWidgets! { _ = stack.addNamed(child: w, name: "false") } + } + stack.setVisibleChildName(name: "false") + } + } + tracker.run() + ctx.registry.add(tracker) + return stack + } +} diff --git a/Sources/Portico/Containers/ForEach.swift b/Sources/Portico/Containers/ForEach.swift new file mode 100644 index 0000000..4c0dc3f --- /dev/null +++ b/Sources/Portico/Containers/ForEach.swift @@ -0,0 +1,107 @@ +import Gtk + +/// A view that renders one row per element of a reactive array, keyed by a +/// stable identifier, and applies a keyed diff on change — appending new rows, +/// removing deleted rows, and reordering moved rows. Surviving rows are never +/// rebuilt (widget identity and subscriptions are preserved). +/// +/// Backed by a `Gtk.Box`. Reactivity requires a state-backed `Binding` +/// (e.g. `$items`); a custom `Binding(get:set:)` does not track and will not +/// update. Rows are built from an element value snapshot at insert time. +@MainActor public struct ForEach: View { + private let data: Binding<[Element]> + private let id: KeyPath + private let orientation: Gtk.Orientation + private let spacing: Int32 + private let row: (Element) -> [AnyView] + + public var body: Never { fatalError() } + + /// - Parameters: + /// - data: The reactive array driving the rows. + /// - id: Key path to each element's stable identifier. IDs MUST be + /// unique; on a duplicate the first occurrence wins and later ones are + /// ignored for that diff. + /// - orientation: Backing box orientation (default `.vertical`). + /// - spacing: Spacing between rows in pixels (default 0). + /// - row: Builds one row's views from an element value snapshot. + public init( + _ data: Binding<[Element]>, + id: KeyPath, + orientation: Gtk.Orientation = .vertical, + spacing: Int32 = 0, + @ViewBuilder row: @escaping (Element) -> [AnyView] + ) { + self.data = data + self.id = id + self.orientation = orientation + self.spacing = spacing + self.row = row + } +} + +@_spi(Portico) extension ForEach: Mountable { + @_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget { + let box = Gtk.Box(orientation: orientation, spacing: spacing) + + // Per-key on-screen state, persisted across diffs in the tracker closure. + var rows: [ID: (widgets: [Gtk.Widget], registry: NodeRegistry)] = [:] + var order: [ID] = [] + + func applyDiff() { + // Deduplicate by id (first occurrence wins), preserving input order. + var ordered: [(ID, Element)] = [] + var seen = Set() + for e in data.wrappedValue { + let key = e[keyPath: id] + if seen.insert(key).inserted { ordered.append((key, e)) } + } + let newOrder = ordered.map { $0.0 } + let newSet = Set(newOrder) + + // Removals: detach widgets, tear down the row's registry, drop it. + for key in order where !newSet.contains(key) { + if let r = rows[key] { + for w in r.widgets { box.remove(child: w) } + r.registry.teardown() + ctx.registry.removeChild(r.registry) + rows[key] = nil + } + } + + // Insert new rows and enforce target order via running sibling. + var prev: Gtk.Widget? = nil + for (key, element) in ordered { + let entry: (widgets: [Gtk.Widget], registry: NodeRegistry) + if let existing = rows[key] { + entry = existing + var sib = prev + for w in entry.widgets { + box.reorderChildAfter(child: w, sibling: sib) + sib = w + } + } else { + let child = ctx.makeChild() + let widgets = row(element).map { $0.makeWidget(child) } + entry = (widgets, child.registry) + rows[key] = entry + var sib = prev + for w in entry.widgets { + box.insertChildAfter(child: w, sibling: sib) + sib = w + } + } + prev = entry.widgets.last ?? prev + } + order = newOrder + } + + // Initial mount + dependency registration happen inside the tracker + // run (same pattern as EitherView/OptionalView): applyDiff reads + // data.wrappedValue, registering this tracker with the backing box. + let tracker = DependencyTracker { applyDiff() } + tracker.run() + ctx.registry.add(tracker) + return box + } +} diff --git a/Sources/Portico/Containers/GtkRevealer+SingleChildContainer.swift b/Sources/Portico/Containers/GtkRevealer+SingleChildContainer.swift new file mode 100644 index 0000000..9c3edc3 --- /dev/null +++ b/Sources/Portico/Containers/GtkRevealer+SingleChildContainer.swift @@ -0,0 +1,9 @@ +import Gtk + +@_spi(Portico) extension Gtk.Revealer: SingleChildContainer { + /// Delegates to `Gtk.Revealer.setChild(child:)` with the distinct SPI + /// name to support nullable child semantics (nil detaches). + @_spi(Portico) public func attachChild(_ child: Gtk.Widget?) { + setChild(child: child) + } +} diff --git a/Sources/Portico/Containers/OptionalView.swift b/Sources/Portico/Containers/OptionalView.swift new file mode 100644 index 0000000..2a0ccc8 --- /dev/null +++ b/Sources/Portico/Containers/OptionalView.swift @@ -0,0 +1,42 @@ +import Gtk + +/// A view that shows or hides its child based on a `Binding` condition. +/// +/// Internally uses `Gtk.Revealer`. The child widget is mounted exactly once; +/// subsequent condition changes only toggle `setRevealChild`. +@MainActor public struct OptionalView: View { + private let condition: Binding + private let content: [AnyView] + + public var body: Never { fatalError() } + + /// - Parameters: + /// - condition: The binding that controls visibility. + /// - content: The view to conditionally reveal (mounted once). + public init( + _ condition: Binding, + @ViewBuilder content: () -> [AnyView] + ) { + self.condition = condition + self.content = content() + } +} + +@_spi(Portico) extension OptionalView: Mountable { + @_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget { + let revealer = Gtk.Revealer() + // Mount child once — build-once invariant. + for child in content { + revealer.attachChild(child.makeWidget(ctx)) + } + // Apply initial state. + revealer.setRevealChild(revealChild: condition.wrappedValue) + // Track changes. + let tracker = DependencyTracker { [revealer] in + revealer.setRevealChild(revealChild: condition.wrappedValue) + } + tracker.run() + ctx.registry.add(tracker) + return revealer + } +} diff --git a/Sources/Portico/Core/GtkWidget+View.swift b/Sources/Portico/Core/GtkWidget+View.swift new file mode 100644 index 0000000..2199767 --- /dev/null +++ b/Sources/Portico/Core/GtkWidget+View.swift @@ -0,0 +1,18 @@ +import Gtk + +/// Enables raw `Gtk.Widget` instances inside `@ViewBuilder` closures. +/// +/// Any generated widget (`Gtk.Label`, `Gtk.Box`, …) can be used directly +/// as a view without an explicit Portico wrapper: +/// ```swift +/// VStack { +/// Gtk.Label(str: "Hello") // ← raw widget drop-in +/// } +/// ``` +extension Gtk.Widget: View { + public var body: Never { fatalError() } +} + +@_spi(Portico) extension Gtk.Widget: Mountable { + public func mount(_: MountContext) -> Gtk.Widget { self } +} diff --git a/Sources/Portico/Core/MountContext.swift b/Sources/Portico/Core/MountContext.swift index 73b148c..e46b63c 100644 --- a/Sources/Portico/Core/MountContext.swift +++ b/Sources/Portico/Core/MountContext.swift @@ -1,9 +1,20 @@ -import Gtk - -/// Context threaded through the mount pipeline. -/// -/// Empty in P0; reserved for the P4 signal/subscription registry needed -/// for per-node teardown on unmount. +/// Context threaded through the mount pipeline. Carries the ``NodeRegistry`` +/// that collects reactive resources for teardown. @_spi(Portico) @MainActor public final class MountContext { - @_spi(Portico) public init() {} + /// The registry for the current subtree; reactive sites register here. + @_spi(Portico) public let registry: NodeRegistry + + /// Creates a root context with a fresh registry. + @_spi(Portico) public init() { self.registry = NodeRegistry() } + + private init(registry: NodeRegistry) { self.registry = registry } + + /// Returns a child context whose registry is an independently-torn-down + /// child of this context's registry. Used per `ForEach` row so removing a + /// row releases only that row's resources. + @_spi(Portico) public func makeChild() -> MountContext { + let child = NodeRegistry() + registry.addChild(child) + return MountContext(registry: child) + } } diff --git a/Sources/Portico/Core/NodeRegistry.swift b/Sources/Portico/Core/NodeRegistry.swift new file mode 100644 index 0000000..d180695 --- /dev/null +++ b/Sources/Portico/Core/NodeRegistry.swift @@ -0,0 +1,49 @@ +import Gtk + +/// Collects the reactive resources created while mounting one subtree so +/// they can be released together when that subtree unmounts. +/// +/// Holds subscription tokens, signal handles, and dependency trackers, plus +/// child registries for independently-unmountable sub-subtrees (e.g. each +/// `ForEach` row). `teardown()` cancels tokens, disconnects handles, tears +/// down trackers, and recurses into children. +@_spi(Portico) @MainActor public final class NodeRegistry { + private var tokens: [SubscriptionToken] = [] + private var handles: [SignalHandle] = [] + private var trackers: [DependencyTracker] = [] + private var children: [NodeRegistry] = [] + + @_spi(Portico) public init() {} + + /// Registers a subscription token to cancel on teardown. + @_spi(Portico) public func add(_ token: SubscriptionToken) { tokens.append(token) } + + /// Registers a GObject signal handle to disconnect on teardown. + @_spi(Portico) public func add(_ handle: SignalHandle) { handles.append(handle) } + + /// Registers a dependency tracker to detach from its state boxes on teardown. + @_spi(Portico) public func add(_ tracker: DependencyTracker) { trackers.append(tracker) } + + /// Registers a child registry for an independently-unmountable sub-subtree. + @_spi(Portico) public func addChild(_ child: NodeRegistry) { children.append(child) } + + /// Removes a child registry (called after the child has been torn down, + /// e.g. a removed `ForEach` row) so this registry no longer retains it. + @_spi(Portico) public func removeChild(_ child: NodeRegistry) { + children.removeAll { $0 === child } + } + + /// Releases every registered resource and recurses into child registries. + /// Safe to call more than once (token cancel and handle disconnect are + /// idempotent). + @_spi(Portico) public func teardown() { + for t in tokens { t.cancel() } + tokens.removeAll() + for i in handles.indices { handles[i].disconnect() } + handles.removeAll() + for tr in trackers { tr.teardown() } + trackers.removeAll() + for c in children { c.teardown() } + children.removeAll() + } +} diff --git a/Sources/Portico/Core/View+Modifiers.swift b/Sources/Portico/Core/View+Modifiers.swift new file mode 100644 index 0000000..f730c36 --- /dev/null +++ b/Sources/Portico/Core/View+Modifiers.swift @@ -0,0 +1,289 @@ +import Gtk +@_spi(SGTKInternal) import GObject +import CGObject + +// MARK: - C GValue helpers (private) + +/// Allocates a zero-initialized C `GValue` of the given type, calls `setup`, +/// passes it to `body`, then unsets it. +/// - Note: Inline helper to avoid `deinit` isolation issues under +/// `StrictConcurrency=complete` with module-wide `@MainActor`. +private func withGValue( + gType: UInt, + setup: (UnsafeMutablePointer) -> Void, + body: (UnsafeMutablePointer) throws -> R +) rethrows -> R { + var gvalue = GValue() + portico_g_value_init(&gvalue, gType) + defer { portico_g_value_unset(&gvalue) } + setup(&gvalue) + return try body(&gvalue) +} + +@_silgen_name("g_value_init") +private nonisolated func portico_g_value_init(_: UnsafeMutablePointer, _: UInt) + +@_silgen_name("g_value_unset") +private nonisolated func portico_g_value_unset(_: UnsafeMutablePointer) + +@_silgen_name("g_value_set_string") +private nonisolated func portico_g_value_set_string(_: UnsafeMutablePointer, _: UnsafePointer?) + +@_silgen_name("g_value_set_int") +private nonisolated func portico_g_value_set_int(_: UnsafeMutablePointer, _: Int32) + +@_silgen_name("g_value_set_boolean") +private nonisolated func portico_g_value_set_boolean(_: UnsafeMutablePointer, _: Int32) + +@_silgen_name("g_value_set_double") +private nonisolated func portico_g_value_set_double(_: UnsafeMutablePointer, _: Double) + +// MARK: - Preferred width + +extension View { + /// Sets the preferred width (static, applied once at mount). + public func preferredWidth(_ width: Int32) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let (_, curH) = w.getSizeRequest() + w.setSizeRequest(width: width, height: curH) + return w + }) + } + + /// Sets the preferred width (reactive, updates on binding change). + public func preferredWidth(_ width: Binding) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let (_, curH) = w.getSizeRequest() + w.setSizeRequest(width: width.wrappedValue, height: curH) + ctx.registry.add(width.subscribe { [w] newW in + let (_, curH) = w.getSizeRequest() + w.setSizeRequest(width: newW, height: curH) + }) + return w + }) + } +} + +// MARK: - Preferred height + +extension View { + /// Sets the preferred height (static, applied once at mount). + public func preferredHeight(_ height: Int32) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let (curW, _) = w.getSizeRequest() + w.setSizeRequest(width: curW, height: height) + return w + }) + } + + /// Sets the preferred height (reactive, updates on binding change). + public func preferredHeight(_ height: Binding) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let (curW, _) = w.getSizeRequest() + w.setSizeRequest(width: curW, height: height.wrappedValue) + ctx.registry.add(height.subscribe { [w] newH in + let (curW, _) = w.getSizeRequest() + w.setSizeRequest(width: curW, height: newH) + }) + return w + }) + } +} + +// MARK: - Horizontal expand + +extension View { + /// Sets whether the widget expands horizontally (static). + public func hexpand(_ expand: Bool) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.setHexpand(expand: expand) + return w + }) + } + + /// Sets whether the widget expands horizontally (reactive). + public func hexpand(_ expand: Binding) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.setHexpand(expand: expand.wrappedValue) + ctx.registry.add(expand.subscribe { [w] v in + w.setHexpand(expand: v) + }) + return w + }) + } +} + +// MARK: - Vertical expand + +extension View { + /// Sets whether the widget expands vertically (static). + public func vexpand(_ expand: Bool) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.setVexpand(expand: expand) + return w + }) + } + + /// Sets whether the widget expands vertically (reactive). + public func vexpand(_ expand: Binding) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.setVexpand(expand: expand.wrappedValue) + ctx.registry.add(expand.subscribe { [w] v in + w.setVexpand(expand: v) + }) + return w + }) + } +} + +// MARK: - Margin + +extension View { + /// Sets the margin on all four sides to the same value (static). + public func margin(_ m: Int32) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.setMarginStart(margin: m) + w.setMarginEnd(margin: m) + w.setMarginTop(margin: m) + w.setMarginBottom(margin: m) + return w + }) + } + + /// Sets the margin on all four sides to the same value (reactive). + public func margin(_ m: Binding) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let initial = m.wrappedValue + w.setMarginStart(margin: initial) + w.setMarginEnd(margin: initial) + w.setMarginTop(margin: initial) + w.setMarginBottom(margin: initial) + ctx.registry.add(m.subscribe { [w] v in + w.setMarginStart(margin: v) + w.setMarginEnd(margin: v) + w.setMarginTop(margin: v) + w.setMarginBottom(margin: v) + }) + return w + }) + } +} + +// MARK: - CSS class + +extension View { + /// Adds a CSS class to the widget (static). + public func cssClass(_ name: String) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + w.addCssClass(cssClass: name) + return w + }) + } + + /// Adds a CSS class to the widget (reactive). + /// + /// When the binding changes, the old class is removed and the new one + /// is added. + public func cssClass(_ name: Binding) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + let initial = name.wrappedValue + if !initial.isEmpty { + w.addCssClass(cssClass: initial) + } + var prev = initial + ctx.registry.add(name.subscribe { [w] newName in + guard newName != prev else { return } + w.removeCssClass(cssClass: prev) + if !newName.isEmpty { + w.addCssClass(cssClass: newName) + } + prev = newName + }) + return w + }) + } +} + +// MARK: - .bind escape hatch + +extension View { + /// Binds a string-typed value to a named GObject property. + public func bind(_ value: Binding, to property: String) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + value.wrappedValue.withCString { cstr in + withGValue(gType: gTypeString, setup: { portico_g_value_set_string($0, cstr) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + } + ctx.registry.add(value.subscribe { [w] newV in + newV.withCString { cstr in + withGValue(gType: gTypeString, setup: { portico_g_value_set_string($0, cstr) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + } + }) + return w + }) + } + + /// Binds an int32-typed value to a named GObject property. + public func bind(_ value: Binding, to property: String) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + withGValue(gType: gTypeInt, setup: { portico_g_value_set_int($0, value.wrappedValue) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + ctx.registry.add(value.subscribe { [w] newV in + withGValue(gType: gTypeInt, setup: { portico_g_value_set_int($0, newV) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + }) + return w + }) + } + + /// Binds a boolean value to a named GObject property. + public func bind(_ value: Binding, to property: String) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + withGValue(gType: gTypeBoolean, setup: { portico_g_value_set_boolean($0, value.wrappedValue ? 1 : 0) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + ctx.registry.add(value.subscribe { [w] newV in + withGValue(gType: gTypeBoolean, setup: { portico_g_value_set_boolean($0, newV ? 1 : 0) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + }) + return w + }) + } + + /// Binds a double value to a named GObject property. + public func bind(_ value: Binding, to property: String) -> AnyView { + AnyView(makeWidget: { ctx in + let w = AnyView(self).makeWidget(ctx) + withGValue(gType: gTypeDouble, setup: { portico_g_value_set_double($0, value.wrappedValue) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + ctx.registry.add(value.subscribe { [w] newV in + withGValue(gType: gTypeDouble, setup: { portico_g_value_set_double($0, newV) }) { ptr in + w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr))) + } + }) + return w + }) + } +} diff --git a/Sources/Portico/Core/ViewBuilder.swift b/Sources/Portico/Core/ViewBuilder.swift index ee79288..f93f17a 100644 --- a/Sources/Portico/Core/ViewBuilder.swift +++ b/Sources/Portico/Core/ViewBuilder.swift @@ -1,8 +1,9 @@ /// Result builder for composing ``View`` hierarchies. /// -/// Conditionals (`buildOptional`/`buildEither`) are passthrough in P0 -/// (they collapse to plain arrays) — correct for the static-only scope. -/// P2 replaces them to emit `OptionalView`/`EitherView` nodes. +/// `buildOptional`/`buildEither` are static-only passthroughs — +/// they collapse to plain arrays. Dynamic conditionals use the +/// explicit ``EitherView`` and ``OptionalView`` types which accept +/// a `Binding` and mount branches via `Gtk.Stack`/`Gtk.Revealer`. @resultBuilder public enum ViewBuilder { /// Wraps a single ``View`` in an ``AnyView``. public static func buildExpression(_ v: V) -> [AnyView] { [AnyView(v)] } diff --git a/Sources/Portico/State/DependencyTracker.swift b/Sources/Portico/State/DependencyTracker.swift index a62d846..0500047 100644 --- a/Sources/Portico/State/DependencyTracker.swift +++ b/Sources/Portico/State/DependencyTracker.swift @@ -9,6 +9,7 @@ @_spi(Portico) public static var current: DependencyTracker? private let body: () -> Void + private var deregistrations: [() -> Void] = [] @_spi(Portico) public init(_ body: @escaping () -> Void) { self.body = body @@ -25,4 +26,17 @@ /// Re-evaluates the body; called by a state box when its value changes. @_spi(Portico) public func reevaluate() { run() } + + /// Records how to detach this tracker from a state box it registered with. + /// Called by ``StateBox/get()`` on first registration. + @_spi(Portico) public func addDeregistration(_ deregister: @escaping () -> Void) { + deregistrations.append(deregister) + } + + /// Detaches this tracker from every state box it depends on, so it stops + /// re-evaluating. Idempotent. + @_spi(Portico) public func teardown() { + for d in deregistrations { d() } + deregistrations.removeAll() + } } diff --git a/Sources/Portico/State/StateBox.swift b/Sources/Portico/State/StateBox.swift index 4676afe..9813219 100644 --- a/Sources/Portico/State/StateBox.swift +++ b/Sources/Portico/State/StateBox.swift @@ -18,7 +18,11 @@ /// as a dependent, then returns the value. @_spi(Portico) public func get() -> Value { if let t = DependencyTracker.current { - dependents[ObjectIdentifier(t)] = t + let oid = ObjectIdentifier(t) + if dependents[oid] == nil { + dependents[oid] = t + t.addDeregistration { [weak self] in self?.dependents[oid] = nil } + } } return value } diff --git a/Sources/Portico/Widgets/Button.swift b/Sources/Portico/Widgets/Button.swift index 740cd1c..a210b55 100644 --- a/Sources/Portico/Widgets/Button.swift +++ b/Sources/Portico/Widgets/Button.swift @@ -3,9 +3,8 @@ import Gtk /// A button with a click action, backed by `Gtk.Button`. /// /// Supports text labels and icon-name variants. The `connectClicked` -/// ``SignalHandle`` is intentionally discarded in P0 — the closure box -/// is retained by GObject for the widget's lifetime. Per-node teardown -/// is wired in P4. +/// ``SignalHandle`` is registered with the mount context's ``NodeRegistry`` +/// for teardown on unmount. @MainActor public struct Button: View { private enum Kind { case label(String) @@ -38,7 +37,7 @@ import Gtk case .icon(let s): button = Gtk.Button(iconName: s) } let action = self.action - _ = button.connectClicked { _ in action() } + ctx.registry.add(button.connectClicked { _ in action() }) return button } } diff --git a/Sources/Portico/Widgets/Label.swift b/Sources/Portico/Widgets/Label.swift index 2ef579d..7d87af0 100644 --- a/Sources/Portico/Widgets/Label.swift +++ b/Sources/Portico/Widgets/Label.swift @@ -45,14 +45,15 @@ import Gtk label.setText(str: s) case .binding(let b): label.setText(str: b.wrappedValue) - _ = b.subscribe { [label] v in + ctx.registry.add(b.subscribe { [label] v in label.setText(str: v) - } + }) case .dynamic(let make): let tracker = DependencyTracker { [label] in label.setText(str: make()) } tracker.run() + ctx.registry.add(tracker) } return label } diff --git a/Tests/PorticoTests/ConditionalTests.swift b/Tests/PorticoTests/ConditionalTests.swift new file mode 100644 index 0000000..d426261 --- /dev/null +++ b/Tests/PorticoTests/ConditionalTests.swift @@ -0,0 +1,168 @@ +import Testing +@_spi(SGTKInternal) import Gtk +@_spi(Portico) import Portico + +@MainActor @Suite(.serialized) struct ConditionalTests { + + // MARK: - EitherView + + @Test func eitherViewInitiallyTrueBranch() { + guard Gtk.initCheck() else { return } + ConditionProbe.reset() + + let box = StateBox(true) + let binding = Binding(box) + let view = EitherView(binding, + first: { FirstProbeLabel() }, + second: { SecondProbeLabel() } + ) + let widget = AnyView(view).makeWidget(MountContext()) + let stack = widget as! Gtk.Stack + + // Initially, only the "true" branch is mounted and visible. + #expect(stack.getVisibleChildName() == "true") + #expect(ConditionProbe.firstCount == 1) + #expect(ConditionProbe.secondCount == 0) + + // Toggle to false — lazy-mounts the "false" branch. + box.set(false) + #expect(stack.getVisibleChildName() == "false") + #expect(ConditionProbe.firstCount == 1) + #expect(ConditionProbe.secondCount == 1) + + // Toggle back to true — no new mounts. + box.set(true) + #expect(stack.getVisibleChildName() == "true") + #expect(ConditionProbe.firstCount == 1) + #expect(ConditionProbe.secondCount == 1) + } + + @Test func eitherViewInitiallyFalseBranch() { + guard Gtk.initCheck() else { return } + ConditionProbe.reset() + + let box = StateBox(false) + let binding = Binding(box) + let view = EitherView(binding, + first: { FirstProbeLabel() }, + second: { SecondProbeLabel() } + ) + let widget = AnyView(view).makeWidget(MountContext()) + let stack = widget as! Gtk.Stack + + // Initially, only the "false" branch is mounted and visible. + #expect(stack.getVisibleChildName() == "false") + #expect(ConditionProbe.firstCount == 0) + #expect(ConditionProbe.secondCount == 1) + + // Toggle to true — lazy-mounts the "true" branch. + box.set(true) + #expect(stack.getVisibleChildName() == "true") + #expect(ConditionProbe.firstCount == 1) + #expect(ConditionProbe.secondCount == 1) + + // Toggle back to false — no new mounts. + box.set(false) + #expect(stack.getVisibleChildName() == "false") + #expect(ConditionProbe.firstCount == 1) + #expect(ConditionProbe.secondCount == 1) + } + + @Test func eitherViewTracksConditionChanges() { + guard Gtk.initCheck() else { return } + ConditionProbe.reset() + + let box = StateBox(true) + let binding = Binding(box) + let view = EitherView(binding, + first: { FirstProbeLabel() }, + second: { SecondProbeLabel() } + ) + let widget = AnyView(view).makeWidget(MountContext()) + let stack = widget as! Gtk.Stack + + // Three toggles: true→false→true→false + box.set(false) + box.set(true) + box.set(false) + + #expect(stack.getVisibleChildName() == "false") + #expect(ConditionProbe.firstCount == 1) + #expect(ConditionProbe.secondCount == 1) + } + + // MARK: - OptionalView + + @Test func optionalViewMountsChildOnce() { + guard Gtk.initCheck() else { return } + ConditionProbe.reset() + + let box = StateBox(true) + let binding = Binding(box) + let view = OptionalView(binding) { FirstProbeLabel() } + let widget = AnyView(view).makeWidget(MountContext()) + let revealer = widget as! Gtk.Revealer + + // Initially revealed, child mounted once. + #expect(revealer.getRevealChild() == true) + #expect(ConditionProbe.firstCount == 1) + + // Toggle to false — child still mounted, just hidden. + box.set(false) + #expect(revealer.getRevealChild() == false) + #expect(ConditionProbe.firstCount == 1) + + // Toggle back to true — still same child. + box.set(true) + #expect(revealer.getRevealChild() == true) + #expect(ConditionProbe.firstCount == 1) + } + + @Test func optionalViewHidesWhenConditionFalse() { + guard Gtk.initCheck() else { return } + ConditionProbe.reset() + + let box = StateBox(false) + let binding = Binding(box) + let view = OptionalView(binding) { FirstProbeLabel() } + let widget = AnyView(view).makeWidget(MountContext()) + let revealer = widget as! Gtk.Revealer + + // Initially hidden, but child WAS mounted (build-once). + #expect(revealer.getRevealChild() == false) + #expect(ConditionProbe.firstCount == 1) + + // Toggle to true — child visible, no remount. + box.set(true) + #expect(revealer.getRevealChild() == true) + #expect(ConditionProbe.firstCount == 1) + } +} + +// MARK: - Test helpers + +@MainActor fileprivate enum ConditionProbe { + static var firstCount = 0 + static var secondCount = 0 + + static func reset() { + firstCount = 0 + secondCount = 0 + } +} + +@MainActor fileprivate struct FirstProbeLabel: View, Mountable { + var body: Never { fatalError() } + func mount(_ ctx: MountContext) -> Gtk.Widget { + ConditionProbe.firstCount += 1 + return Gtk.Label(str: "first") + } +} + +@MainActor fileprivate struct SecondProbeLabel: View, Mountable { + var body: Never { fatalError() } + func mount(_ ctx: MountContext) -> Gtk.Widget { + ConditionProbe.secondCount += 1 + return Gtk.Label(str: "second") + } +} diff --git a/Tests/PorticoTests/ForEachTests.swift b/Tests/PorticoTests/ForEachTests.swift new file mode 100644 index 0000000..c855c0c --- /dev/null +++ b/Tests/PorticoTests/ForEachTests.swift @@ -0,0 +1,179 @@ +import Testing +@_spi(SGTKInternal) import Gtk +@_spi(Portico) import Portico + +// MARK: - Probe types + +/// A label that records how many times it was mounted per key, +/// for verifying ForEach rebuild-avoidance. +private final class MountCounter { + var counts: [Int: Int] = [:] + func inc(_ key: Int) { counts[key, default: 0] += 1 } +} + +private struct ProbeRow: View { + let key: Int + let label: String + let counter: MountCounter + + var body: Never { fatalError() } + + init(key: Int, label: String, counter: MountCounter) { + self.key = key; self.label = label; self.counter = counter + } +} + +@_spi(Portico) extension ProbeRow: Mountable { + @_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget { + counter.inc(key) + return Gtk.Label(str: label) + } +} + +/// A shared mutable dictionary for tracking per-key subscription fires. +private final class FireCounter { + var fires: [Int: Int] = [:] + func inc(_ key: Int) { fires[key, default: 0] += 1 } +} + +/// A row that subscribes to a StateBox so we can verify the subscription +/// is cancelled on row removal. +private struct SubscribingRow: View { + let key: Int + let box: StateBox + let counter: FireCounter + var body: Never { fatalError() } + + init(key: Int, box: StateBox, counter: FireCounter) { + self.key = key; self.box = box; self.counter = counter + } +} + +@_spi(Portico) extension SubscribingRow: Mountable { + @_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget { + ctx.registry.add(box.subscribe { [key, counter] _ in + counter.inc(key) + }) + return Gtk.Label(str: "row \(key)") + } +} + +// MARK: - Tests + +@MainActor @Suite(.serialized) struct ForEachTests { + + // Helper: collect visible child labels from a Gtk.Box in order. + private func childLabels(_ box: Gtk.Box) -> [String] { + var result: [String] = [] + var child = box.getFirstChild() + while let w = child { + // getFirstChild returns base Widget; reconstruct Label from pointer. + let lbl = Gtk.Label(retaining: w.pointer) + result.append(lbl.getText()) + child = w.getNextSibling() + } + return result + } + + // Helper: count the children of a Gtk.Box. + private func childCount(_ box: Gtk.Box) -> Int { + var n = 0 + var child = box.getFirstChild() + while let w = child { n += 1; child = w.getNextSibling() } + return n + } + + @Test func initialMountRendersRowsInOrder() { + guard Gtk.initCheck() else { return } + let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")]) + let binding = Binding(items) + let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in + Label(title) + } + let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box + #expect(childCount(box) == 3) + #expect(childLabels(box) == ["One", "Two", "Three"]) + } + + @Test func appendAddsRowWithoutRebuild() { + guard Gtk.initCheck() else { return } + let counter = MountCounter() + let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")]) + let binding = Binding(items) + let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in + ProbeRow(key: id, label: title, counter: counter) + } + let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box + #expect(childCount(box) == 3) + #expect(counter.counts[1] == 1) + #expect(counter.counts[2] == 1) + #expect(counter.counts[3] == 1) + + var arr = items.get() + arr.append((4, "Four")) + items.set(arr) + #expect(childCount(box) == 4) + // Previously-mounted keys still counted once (never rebuilt). + #expect(counter.counts[1] == 1) + #expect(counter.counts[2] == 1) + #expect(counter.counts[3] == 1) + #expect(counter.counts[4] == 1) + } + + @Test func removeDropsRowAndTearsDownSubscription() { + guard Gtk.initCheck() else { return } + let box = StateBox(0) + let fires = FireCounter() + let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")]) + let binding = Binding(items) + + // Use actual Gtk.Labels with subscriptions that increment fires. + let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in + SubscribingRow(key: id, box: box, counter: fires) + } + let container = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box + #expect(childCount(container) == 3) + + // Fire all: every row's subscription fires. + box.set(1) + #expect(fires.fires[1] == 1 && fires.fires[2] == 1 && fires.fires[3] == 1) + + // Remove the middle element. + var arr = items.get() + arr.remove(at: 1) // remove key 2 + items.set(arr) + #expect(childCount(container) == 2) + #expect(childLabels(container) == ["row 1", "row 3"]) + + // Fire again: removed row 2's subscription must NOT respond. + box.set(2) + #expect(fires.fires[1] == 2) + #expect(fires.fires[2] == 1) // unchanged — subscription was cancelled + #expect(fires.fires[3] == 2) + } + + @Test func reorderPreservesWidgetsNoRebuild() { + guard Gtk.initCheck() else { return } + let counter = MountCounter() + let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")]) + let binding = Binding(items) + let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in + ProbeRow(key: id, label: title, counter: counter) + } + let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box + #expect(childLabels(box) == ["One", "Two", "Three"]) + #expect(counter.counts[1] == 1) + #expect(counter.counts[2] == 1) + #expect(counter.counts[3] == 1) + + // Reverse the array. + var arr = items.get() + arr.reverse() + items.set(arr) + #expect(childLabels(box) == ["Three", "Two", "One"]) + // Mount counts unchanged — identity preserved. + #expect(counter.counts[1] == 1) + #expect(counter.counts[2] == 1) + #expect(counter.counts[3] == 1) + } +} diff --git a/Tests/PorticoTests/ModifierTests.swift b/Tests/PorticoTests/ModifierTests.swift new file mode 100644 index 0000000..ea620c4 --- /dev/null +++ b/Tests/PorticoTests/ModifierTests.swift @@ -0,0 +1,248 @@ +import Testing +@_spi(SGTKInternal) import Gtk +@_spi(Portico) import Portico + +@MainActor @Suite(.serialized) struct ModifierTests { + + // MARK: - Static modifiers + + @Test func staticPreferredWidth() { + guard Gtk.initCheck() else { return } + let w = AnyView(Label("x").preferredWidth(42)).makeWidget(MountContext()) + #expect(w.getSizeRequest().width == 42) + } + + @Test func staticPreferredHeight() { + guard Gtk.initCheck() else { return } + let w = AnyView(Label("x").preferredHeight(99)).makeWidget(MountContext()) + #expect(w.getSizeRequest().height == 99) + } + + @Test func staticHexpand() { + guard Gtk.initCheck() else { return } + let w = AnyView(Label("x").hexpand(true)).makeWidget(MountContext()) + #expect(w.getHexpand() == true) + let w2 = AnyView(Label("x").hexpand(false)).makeWidget(MountContext()) + #expect(w2.getHexpand() == false) + } + + @Test func staticVexpand() { + guard Gtk.initCheck() else { return } + let w = AnyView(Label("x").vexpand(true)).makeWidget(MountContext()) + #expect(w.getVexpand() == true) + } + + @Test func staticMargin() { + guard Gtk.initCheck() else { return } + let w = AnyView(Label("x").margin(7)).makeWidget(MountContext()) + #expect(w.getMarginStart() == 7) + #expect(w.getMarginEnd() == 7) + #expect(w.getMarginTop() == 7) + #expect(w.getMarginBottom() == 7) + } + + @Test func staticCssClass() { + guard Gtk.initCheck() else { return } + let w = AnyView(Label("x").cssClass("myclass")).makeWidget(MountContext()) + #expect(w.hasCssClass(cssClass: "myclass")) + } + + // MARK: - Reactive modifiers + + @Test func reactivePreferredWidth() { + guard Gtk.initCheck() else { return } + let box = StateBox(40) + let w = AnyView(Label("x").preferredWidth(Binding(box))).makeWidget( + MountContext()) + defer { box.set(0) } + + #expect(w.getSizeRequest().width == 40) + box.set(60) + #expect(w.getSizeRequest().width == 60) + } + @Test func reactiveCssClass() { + guard Gtk.initCheck() else { return } + let box = StateBox("a") + let w = AnyView(Label("x").cssClass(Binding(box))).makeWidget( + MountContext()) + defer { box.set("") } + + #expect(w.hasCssClass(cssClass: "a")) + box.set("b") + #expect(!w.hasCssClass(cssClass: "a")) + #expect(w.hasCssClass(cssClass: "b")) + } + + @Test func reactivePreferredHeight() { + guard Gtk.initCheck() else { return } + let box = StateBox(99) + let w = AnyView(Label("x").preferredHeight(Binding(box))).makeWidget( + MountContext()) + _ = box + + #expect(w.getSizeRequest().height == 99) + box.set(150) + #expect(w.getSizeRequest().height == 150) + } + + @Test func reactiveHexpand() { + guard Gtk.initCheck() else { return } + let box = StateBox(false) + let w = AnyView(Label("x").hexpand(Binding(box))).makeWidget( + MountContext()) + _ = box + + #expect(w.getHexpand() == false) + box.set(true) + #expect(w.getHexpand() == true) + } + + @Test func reactiveVexpand() { + guard Gtk.initCheck() else { return } + let box = StateBox(false) + let w = AnyView(Label("x").vexpand(Binding(box))).makeWidget( + MountContext()) + _ = box + + #expect(w.getVexpand() == false) + box.set(true) + #expect(w.getVexpand() == true) + } + + @Test func reactiveMargin() { + guard Gtk.initCheck() else { return } + let box = StateBox(5) + let w = AnyView(Label("x").margin(Binding(box))).makeWidget( + MountContext()) + _ = box + + #expect(w.getMarginStart() == 5) + #expect(w.getMarginEnd() == 5) + #expect(w.getMarginTop() == 5) + #expect(w.getMarginBottom() == 5) + box.set(12) + #expect(w.getMarginStart() == 12) + #expect(w.getMarginEnd() == 12) + #expect(w.getMarginTop() == 12) + #expect(w.getMarginBottom() == 12) + } + + // MARK: - Axis preservation + + @Test func axisPreservation() { + guard Gtk.initCheck() else { return } + let w = AnyView( + Label("x") + .preferredWidth(42) + .preferredHeight(99) + ).makeWidget(MountContext()) + let sr = w.getSizeRequest() + #expect(sr.width == 42) + #expect(sr.height == 99) + } + + @Test func reactiveWidthPreservesHeight() { + guard Gtk.initCheck() else { return } + let box = StateBox(50) + let w = AnyView( + Label("x") + .preferredWidth(100) // static height 0 (unset) + .preferredHeight(200) // static width 100 preserved + .preferredWidth(Binding(box)) // reactive width + ).makeWidget(MountContext()) + defer { box.set(0) } + + // After static: width=100, height=200 + // After reactive initial: width=50, height=200 (width overwritten, height preserved) + let sr = w.getSizeRequest() + #expect(sr.height == 200) + + box.set(80) + let sr2 = w.getSizeRequest() + #expect(sr2.width == 80) + #expect(sr2.height == 200) + } + + // MARK: - .bind modifier + + @Test func bindBoolToVisible() { + guard Gtk.initCheck() else { return } + let box = StateBox(true) + let w = AnyView( + Label("x").bind(Binding(box), to: "visible") + ).makeWidget(MountContext()) + defer { box.set(false) } + + #expect(w.getVisible() == true) + box.set(false) + #expect(w.getVisible() == false) + } + + @Test func bindStringToTooltip() { + guard Gtk.initCheck() else { return } + let box = StateBox("hello") + let w = AnyView( + Label("x").bind(Binding(box), to: "tooltip-text") + ).makeWidget(MountContext()) + defer { box.set("") } + + #expect(w.getTooltipText() == "hello") + box.set("world") + #expect(w.getTooltipText() == "world") + } + + @Test func bindIntToWidthRequest() { + guard Gtk.initCheck() else { return } + let box = StateBox(50) + let w = AnyView( + Label("x").bind(Binding(box), to: "width-request") + ).makeWidget(MountContext()) + _ = box + + #expect(w.getSizeRequest().width == 50) + box.set(120) + #expect(w.getSizeRequest().width == 120) + } + + @Test func bindDoubleToOpacity() { + guard Gtk.initCheck() else { return } + let box = StateBox(1.0) + let w = AnyView( + Label("x").bind(Binding(box), to: "opacity") + ).makeWidget(MountContext()) + _ = box + + #expect(w.getOpacity() == 1.0) + box.set(0.0) + #expect(w.getOpacity() == 0.0) + } + + // MARK: - Raw widget drop-in + + @Test func rawWidgetInVStack() { + guard Gtk.initCheck() else { return } + let raw = Gtk.Label(str: "raw") + let container = AnyView(VStack(spacing: 0) { raw }).makeWidget( + MountContext()) + + // VStack is backed by Gtk.Box — verify the raw label is a child + // GTK wrappers create new Swift objects for the same GObject, so + // compare the underlying object pointers instead of using ===. + var found = false + var cur = container.getFirstChild() + while let c = cur { + if c.pointer == raw.pointer { found = true; break } + cur = c.getNextSibling() + } + #expect(found) + } + + @Test func rawWidgetModifierChain() { + guard Gtk.initCheck() else { return } + let raw = Gtk.Label(str: "chained") + let w = AnyView(raw.preferredWidth(100).hexpand(true)).makeWidget( + MountContext()) + #expect(w.getSizeRequest().width == 100) + #expect(w.getHexpand() == true) + } +} diff --git a/Tests/PorticoTests/TeardownTests.swift b/Tests/PorticoTests/TeardownTests.swift new file mode 100644 index 0000000..43af95c --- /dev/null +++ b/Tests/PorticoTests/TeardownTests.swift @@ -0,0 +1,57 @@ +import Testing +@_spi(SGTKInternal) import Gtk +@_spi(Portico) import Portico + +@MainActor @Suite struct TeardownTests { + + // MARK: - No GTK needed + + @Test func nodeRegistryCancelsTokens() { + let box = StateBox(0) + var count = 0 + let registry = NodeRegistry() + registry.add(box.subscribe { _ in count += 1 }) + box.set(1) + #expect(count == 1) + registry.teardown() + box.set(2) + #expect(count == 1) + } + + @Test func trackerTeardownStopsReevaluation() { + let box = StateBox(0) + var evaluateCount = 0 + let tracker = DependencyTracker { _ = box.get(); evaluateCount += 1 } + tracker.run() + #expect(evaluateCount == 1) + box.set(1) + #expect(evaluateCount == 2) + tracker.teardown() + box.set(2) + #expect(evaluateCount == 2) + } + + @Test func nodeRegistryTeardownIsIdempotent() { + let box = StateBox(0) + var count = 0 + let registry = NodeRegistry() + registry.add(box.subscribe { _ in count += 1 }) + box.set(1) + #expect(count == 1) + registry.teardown() + registry.teardown() // must not crash + box.set(2) + #expect(count == 1) + } + + // MARK: - GTK-dependent + + @Test(.serialized) func nodeRegistryDisconnectsSignalHandle() { + guard Gtk.initCheck() else { return } + let button = Gtk.Button(label: "test") + let ctx = MountContext() + ctx.registry.add(button.connectClicked { _ in }) + // Teardown must not crash with a real SignalHandle. + ctx.registry.teardown() + } +}