Implement collections and teardown

This commit is contained in:
Brendan Szymanski 2026-07-24 00:07:02 -04:00
parent fd2cae3808
commit 4594728770
14 changed files with 476 additions and 56 deletions

View file

@ -1,29 +1,23 @@
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)
}
}
}
}
struct ToggleDemo: View {
@State private var show = true
var body: some View {
VStack(spacing: 12) {
Button("Toggle") { show.toggle() }
EitherView($show,
first: { Label("First branch — visible") },
second: { Label("Second branch — hidden") }
)
}
}
}
@ -33,8 +27,8 @@ struct ExampleApp: App {
var applicationId: String? { "dev.bscubed.PorticoExample" }
var body: some Scene {
Window { _ in ToggleDemo() }
.title("Portico P2")
Window { _ in ListDemo() }
.title("Portico P4 — ForEach")
.defaultSize(width: 1280, height: 800)
}
}

View file

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

View file

@ -64,7 +64,7 @@ import Gtk
}
}
tracker.run()
ctx.registry.add(tracker)
return stack
}
}

View file

@ -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<Element, ID: Hashable>: View {
private let data: Binding<[Element]>
private let id: KeyPath<Element, ID>
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<Element, ID>,
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<ID>()
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
}
}

View file

@ -36,6 +36,7 @@ import Gtk
revealer.setRevealChild(revealChild: condition.wrappedValue)
}
tracker.run()
ctx.registry.add(tracker)
return revealer
}
}

View file

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

View file

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

View file

@ -57,10 +57,10 @@ extension View {
let w = AnyView(self).makeWidget(ctx)
let (_, curH) = w.getSizeRequest()
w.setSizeRequest(width: width.wrappedValue, height: curH)
_ = width.subscribe { [w] newW in
ctx.registry.add(width.subscribe { [w] newW in
let (_, curH) = w.getSizeRequest()
w.setSizeRequest(width: newW, height: curH)
}
})
return w
})
}
@ -85,10 +85,10 @@ extension View {
let w = AnyView(self).makeWidget(ctx)
let (curW, _) = w.getSizeRequest()
w.setSizeRequest(width: curW, height: height.wrappedValue)
_ = height.subscribe { [w] newH in
ctx.registry.add(height.subscribe { [w] newH in
let (curW, _) = w.getSizeRequest()
w.setSizeRequest(width: curW, height: newH)
}
})
return w
})
}
@ -111,9 +111,9 @@ extension View {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.setHexpand(expand: expand.wrappedValue)
_ = expand.subscribe { [w] v in
ctx.registry.add(expand.subscribe { [w] v in
w.setHexpand(expand: v)
}
})
return w
})
}
@ -136,9 +136,9 @@ extension View {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
w.setVexpand(expand: expand.wrappedValue)
_ = expand.subscribe { [w] v in
ctx.registry.add(expand.subscribe { [w] v in
w.setVexpand(expand: v)
}
})
return w
})
}
@ -168,12 +168,12 @@ extension View {
w.setMarginEnd(margin: initial)
w.setMarginTop(margin: initial)
w.setMarginBottom(margin: initial)
_ = m.subscribe { [w] v in
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
})
}
@ -203,14 +203,14 @@ extension View {
w.addCssClass(cssClass: initial)
}
var prev = initial
_ = name.subscribe { [w] newName in
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
})
}
@ -228,13 +228,13 @@ extension View {
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
}
_ = value.subscribe { [w] newV in
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
})
}
@ -246,11 +246,11 @@ extension View {
withGValue(gType: gTypeInt, setup: { portico_g_value_set_int($0, value.wrappedValue) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
_ = value.subscribe { [w] newV in
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
})
}
@ -262,11 +262,11 @@ extension View {
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)))
}
_ = value.subscribe { [w] newV in
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
})
}
@ -278,11 +278,11 @@ extension View {
withGValue(gType: gTypeDouble, setup: { portico_g_value_set_double($0, value.wrappedValue) }) { ptr in
w.setProperty(propertyName: property, value: Value(takingOwnership: UnsafeMutableRawPointer(ptr)))
}
_ = value.subscribe { [w] newV in
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
})
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Int>
let counter: FireCounter
var body: Never { fatalError() }
init(key: Int, box: StateBox<Int>, 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)
}
}

View file

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