Implement P4 collections and teardown
This commit is contained in:
parent
9de6f1b3b1
commit
ebc7980e27
14 changed files with 476 additions and 56 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ import Gtk
|
|||
}
|
||||
}
|
||||
tracker.run()
|
||||
|
||||
ctx.registry.add(tracker)
|
||||
return stack
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
Sources/Portico/Containers/ForEach.swift
Normal file
107
Sources/Portico/Containers/ForEach.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import Gtk
|
|||
revealer.setRevealChild(revealChild: condition.wrappedValue)
|
||||
}
|
||||
tracker.run()
|
||||
ctx.registry.add(tracker)
|
||||
return revealer
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
Sources/Portico/Core/NodeRegistry.swift
Normal file
49
Sources/Portico/Core/NodeRegistry.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
179
Tests/PorticoTests/ForEachTests.swift
Normal file
179
Tests/PorticoTests/ForEachTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
57
Tests/PorticoTests/TeardownTests.swift
Normal file
57
Tests/PorticoTests/TeardownTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue