State module with reactive label bindings and tracked closures

Add @State, StateBox, Binding, DependencyTracker, and
SubscriptionToken for targeted reactive updates.

Label gains two reactive overloads: Label(Binding<String>)
for the explicit-binding path and Label(() -> String) for
tracked closures. Both update the Gtk.Label text in-place
without re-mounting.

Regression tests verify label updates survive when the
Swift wrapper is deallocated after the GObject is attached
to the widget tree (GTK does not retain wrappers).

Includes 28 new tests across ReactiveLabelTests and
StateTests suites.
This commit is contained in:
Brendan Szymanski 2026-07-23 21:06:09 -04:00
parent 145d0a710d
commit c309fc3d1a
9 changed files with 519 additions and 15 deletions

View file

@ -1,17 +1,26 @@
import Portico
struct Counter: View {
@State private var count = 0
var body: some View {
VStack(spacing: 12) {
Label { "Count: \(count)" }
HStack(spacing: 12) {
Button("-") { count -= 1 }
Button("+") { count += 1 }
}
}
}
}
@main
struct ExampleApp: App {
var applicationId: String? { "dev.bscubed.PorticoExample" }
var body: some Scene {
Window { window in
VStack(spacing: 12) {
Label("Hello Portico!")
Button("Click me") { print("clicked!") }
}
}
.title("Portico P0")
.defaultSize(width: 1280, height: 800)
Window { _ in Counter() }
.title("Portico P1")
.defaultSize(width: 1280, height: 800)
}
}

View file

@ -0,0 +1,47 @@
/// A two-way connection to a mutable value source.
///
/// State-derived bindings (created via ``State/projectedValue``)
/// use tracking reads and notifying writes. Custom bindings created
/// with ``init(get:set:)`` provide an escape hatch for constant or
/// bridging bindings where ``subscribe(_:)`` is inert.
@MainActor public struct Binding<Value> {
private let getter: () -> Value
private let setter: (Value) -> Void
private let subscriber: (@escaping (Value) -> Void) -> SubscriptionToken
/// The current value. Get tracks (registers a dependency within a
/// ``DependencyTracker``); set notifies.
public var wrappedValue: Value {
get { getter() }
nonmutating set { setter(newValue) }
}
/// Registers a callback that fires on every change until the returned
/// token is cancelled.
public func subscribe(
_ onChange: @escaping (Value) -> Void
) -> SubscriptionToken {
subscriber(onChange)
}
/// Escape hatch for custom or constant bindings.
///
/// The ``subscribe(_:)`` method on this binding is inert (returns
/// a no-op token that never fires).
public init(
get: @escaping () -> Value,
set: @escaping (Value) -> Void
) {
self.getter = get
self.setter = set
self.subscriber = { _ in SubscriptionToken(onCancel: {}) }
}
/// Creates a binding backed by a state box: tracking get, notifying
/// set, and live subscription.
@_spi(Portico) public init(_ box: StateBox<Value>) {
self.getter = { box.get() }
self.setter = { box.set($0) }
self.subscriber = { box.subscribe($0) }
}
}

View file

@ -0,0 +1,28 @@
/// Tracks which state boxes are read during a reactive body evaluation.
///
/// Set ``current`` before evaluation; state-box reads register the
/// tracker as a dependent so the body is re-evaluated when any
/// dependency changes. Saves/restores prior `current` so nested
/// trackers compose correctly.
@_spi(Portico) @MainActor public final class DependencyTracker {
/// The tracker whose body is currently being evaluated.
@_spi(Portico) public static var current: DependencyTracker?
private let body: () -> Void
@_spi(Portico) public init(_ body: @escaping () -> Void) {
self.body = body
}
/// Runs the body with `current` set to `self`, so state reads register
/// edges. Restores the prior `current` on exit.
@_spi(Portico) public func run() {
let previous = DependencyTracker.current
DependencyTracker.current = self
body()
DependencyTracker.current = previous
}
/// Re-evaluates the body; called by a state box when its value changes.
@_spi(Portico) public func reevaluate() { run() }
}

View file

@ -0,0 +1,37 @@
/// A property wrapper for reactive state owned by a ``View``.
///
/// Mutations fire targeted updates only widgets that read this state
/// (via ``Binding`` or a tracked closure) are affected. The backing
/// ``StateBox`` is a reference type, so struct copies share the same
/// storage (``nonmutating set`` on a `let` wrapper).
///
/// ```swift
/// struct Counter: View {
/// @State private var count = 0
/// var body: some View {
/// HStack {
/// Button("-") { count -= 1 }
/// Label { "\(count)" }
/// Button("+") { count += 1 }
/// }
/// }
/// }
/// ```
@propertyWrapper @MainActor public struct State<Value> {
private let box: StateBox<Value>
public init(wrappedValue: Value) {
box = StateBox(wrappedValue)
}
/// The current value. Reads track dependencies; writes notify.
public var wrappedValue: Value {
get { box.get() }
nonmutating set { box.set(newValue) }
}
/// A ``Binding`` to this state, for passing to child views.
public var projectedValue: Binding<Value> {
Binding(box)
}
}

View file

@ -0,0 +1,56 @@
/// Mutable state storage shared by ``State`` and ``Binding``.
///
/// Maintains a subscriber list (explicit binding callbacks, held strongly)
/// and a dependent map (``DependencyTracker`` instances, held strongly).
/// ``notify()`` snapshots both before invoking callbacks, making reentrant
/// mutations safe.
@_spi(Portico) @MainActor public final class StateBox<Value> {
private var value: Value
private var nextID = 0
private var subscribers: [Int: (Value) -> Void] = [:]
private var dependents: [ObjectIdentifier: DependencyTracker] = [:]
@_spi(Portico) public init(_ value: Value) {
self.value = value
}
/// Tracking read registers the current ``DependencyTracker`` (if any)
/// as a dependent, then returns the value.
@_spi(Portico) public func get() -> Value {
if let t = DependencyTracker.current {
dependents[ObjectIdentifier(t)] = t
}
return value
}
/// Non-tracking read. Does not register a dependency.
@_spi(Portico) public func peek() -> Value { value }
/// Stores a new value and notifies all subscribers and dependents.
@_spi(Portico) public func set(_ newValue: Value) {
value = newValue
notify()
}
/// Registers a callback that fires on every ``set(_:)`` call until the
/// returned token is cancelled.
@_spi(Portico) public func subscribe(
_ onChange: @escaping (Value) -> Void
) -> SubscriptionToken {
let id = nextID
nextID += 1
subscribers[id] = onChange
return SubscriptionToken { [weak self] in
self?.subscribers[id] = nil
}
}
// MARK: - Private
private func notify() {
let v = value
for cb in Array(subscribers.values) { cb(v) }
for t in Array(dependents.values) { t.reevaluate() }
}
}

View file

@ -0,0 +1,18 @@
/// A handle that can cancel a live subscription.
///
/// ``cancel()`` is idempotent; deinit does NOT automatically cancel.
/// Until explicitly cancelled, the subscription remains active regardless
/// of whether the token is still held.
@MainActor public final class SubscriptionToken {
private var onCancel: (() -> Void)?
@_spi(Portico) public init(onCancel: @escaping () -> Void) {
self.onCancel = onCancel
}
/// Removes the underlying subscription. Idempotent; does NOT fire on deinit.
public func cancel() {
onCancel?()
onCancel = nil
}
}

View file

@ -1,19 +1,59 @@
import Gtk
/// A static label widget backed by `Gtk.Label`.
// MARK: - Label
/// A text label backed by `Gtk.Label` with three source modes:
/// constant text, explicit ``Binding``, and a tracked closure.
///
/// ``Text`` and `Label` are identical in P0 both mount a `Gtk.Label`.
/// `Label` mirrors the wrapper type name; ``Text`` mirrors SwiftUI naming.
/// Reactive forms (`Binding` and closure) update the label in place
/// the widget is never recreated on state change.
@MainActor public struct Label: View {
let text: String
private enum Source {
case constant(String)
case binding(Binding<String>)
case dynamic(() -> String)
}
private let source: Source
public var body: Never { fatalError() }
/// Creates a label displaying the given text.
public init(_ text: String) { self.text = text }
/// Creates a static label that never changes.
public init(_ text: String) {
source = .constant(text)
}
/// Creates a label that updates when the binding changes.
public init(_ text: Binding<String>) {
source = .binding(text)
}
/// Creates a label whose text is recomputed via a tracked closure
/// whenever any state read inside it changes.
public init(_ make: @escaping () -> String) {
source = .dynamic(make)
}
}
// MARK: - Mountable
@_spi(Portico) extension Label: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
Gtk.Label(str: text)
let label = Gtk.Label(str: "")
switch source {
case .constant(let s):
label.setText(str: s)
case .binding(let b):
label.setText(str: b.wrappedValue)
_ = b.subscribe { [label] v in
label.setText(str: v)
}
case .dynamic(let make):
let tracker = DependencyTracker { [label] in
label.setText(str: make())
}
tracker.run()
}
return label
}
}

View file

@ -0,0 +1,138 @@
import Testing
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
@MainActor @Suite(.serialized) struct ReactiveLabelTests {
// MARK: - GTK-dependent tests
@Test func explicitBindingUpdatesLabel() {
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let w = AnyView(Label(Binding(box))).makeWidget(MountContext())
let lbl = w as! Gtk.Label
defer { box.set("") } // suppress unused-variable in release
#expect(lbl.getText() == "a")
box.set("b")
#expect(lbl.getText() == "b")
}
@Test func explicitBindingUpdatesMultipleTimes() {
guard Gtk.initCheck() else { return }
let box = StateBox("first")
let lbl = AnyView(Label(Binding(box))).makeWidget(
MountContext()) as! Gtk.Label
defer { box.set("") }
#expect(lbl.getText() == "first")
box.set("second")
#expect(lbl.getText() == "second")
box.set("third")
#expect(lbl.getText() == "third")
}
@Test func trackedClosureUpdatesLabel() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let lbl = AnyView(Label { "Count: \(box.get())" })
.makeWidget(MountContext()) as! Gtk.Label
defer { box.set(0) }
#expect(lbl.getText() == "Count: 0")
box.set(1)
#expect(lbl.getText() == "Count: 1")
box.set(2)
#expect(lbl.getText() == "Count: 2")
}
@Test func reactiveNodeMountsExactlyOnce() {
guard Gtk.initCheck() else { return }
MountProbe.count = 0
let box = StateBox(0)
defer { box.set(0) }
let lbl = AnyView(ProbeLabel(box: box))
.makeWidget(MountContext()) as! Gtk.Label
#expect(MountProbe.count == 1)
#expect(lbl.getText() == "n=0")
box.set(1)
#expect(MountProbe.count == 1)
#expect(lbl.getText() == "n=1")
}
@Test func bindingNodeMountsExactlyOnce() {
guard Gtk.initCheck() else { return }
MountProbe.count = 0
let box = StateBox("a")
defer { box.set("") }
let lbl = AnyView(ProbeBindingLabel(box: box))
.makeWidget(MountContext()) as! Gtk.Label
#expect(MountProbe.count == 1)
#expect(lbl.getText() == "a")
box.set("b")
#expect(MountProbe.count == 1)
#expect(lbl.getText() == "b")
}
@Test func constantLabelStaysUnchanged() {
guard Gtk.initCheck() else { return }
let lbl = AnyView(Label("static"))
.makeWidget(MountContext()) as! Gtk.Label
#expect(lbl.getText() == "static")
}
// MARK: - Regression: wrapper not retained after mount
@Test func trackedLabelUpdatesWhenWrapperNotRetained() {
guard Gtk.initCheck() else { return }
let box = StateBox(0)
let parent = Gtk.Box(orientation: .vertical, spacing: 0)
do {
let w = AnyView(Label { "n=\(box.get())" }).makeWidget(MountContext())
parent.append(child: w)
}
box.set(1)
let child = Gtk.Label(retaining: parent.getFirstChild()!.pointer)
#expect(child.getText() == "n=1")
}
@Test func explicitBindingUpdatesWhenWrapperNotRetained() {
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let parent = Gtk.Box(orientation: .vertical, spacing: 0)
do {
let w = AnyView(Label(Binding(box))).makeWidget(MountContext())
parent.append(child: w)
}
box.set("b")
let child = Gtk.Label(retaining: parent.getFirstChild()!.pointer)
#expect(child.getText() == "b")
}
}
// MARK: - Test helpers
@MainActor fileprivate enum MountProbe { static var count = 0 }
@MainActor fileprivate struct ProbeLabel: View, Mountable {
let box: StateBox<Int>
var body: Never { fatalError() }
func mount(_ ctx: MountContext) -> Gtk.Widget {
MountProbe.count += 1
return AnyView(Label { "n=\(box.get())" }).makeWidget(ctx)
}
}
@MainActor fileprivate struct ProbeBindingLabel: View, Mountable {
let box: StateBox<String>
var body: Never { fatalError() }
func mount(_ ctx: MountContext) -> Gtk.Widget {
MountProbe.count += 1
return AnyView(Label(Binding(box))).makeWidget(ctx)
}
}

View file

@ -0,0 +1,131 @@
import Testing
@_spi(Portico) import Portico
@MainActor @Suite struct StateTests {
// MARK: - StateBox
@Test func stateBoxNotifiesSubscribers() {
let box = StateBox(1)
var seen: [Int] = []
_ = box.subscribe { seen.append($0) }
box.set(2)
box.set(3)
#expect(seen == [2, 3])
}
@Test func cancelStopsNotifications() {
let box = StateBox(0)
var callCount = 0
let token = box.subscribe { _ in callCount += 1 }
token.cancel()
box.set(9)
#expect(callCount == 0)
}
@Test func cancelIsIdempotent() {
let box = StateBox(0)
let token = box.subscribe { _ in }
token.cancel()
token.cancel() // must not crash
}
@Test func peekDoesNotTrack() {
let box = StateBox("a")
var evaluateCount = 0
let tracker = DependencyTracker { _ = box.peek(); evaluateCount += 1 }
tracker.run()
#expect(evaluateCount == 1)
box.set("b")
// Tracker registered as weak dependent only if get() was called.
// Since peek() does not register, the tracker should NOT reevaluate.
#expect(evaluateCount == 1)
}
@Test func trackerReevaluatesOnChange() {
let box = StateBox(0)
var log: [Int] = []
let tracker = DependencyTracker { log.append(box.get()) }
tracker.run()
#expect(log == [0])
box.set(5)
#expect(log == [0, 5])
}
@Test func trackerSeesLatestValueOnReevaluation() {
let box = StateBox(0)
var lastSeen = 0
let tracker = DependencyTracker { lastSeen = box.get() }
tracker.run()
#expect(lastSeen == 0)
box.set(42)
#expect(lastSeen == 42)
}
@Test func boxStronglyOwnsTracker() {
let box = StateBox(0)
var runs = 0
do {
let t = DependencyTracker { _ = box.get(); runs += 1 }
t.run()
}
#expect(runs == 1)
box.set(1)
#expect(runs == 2, "box must keep the tracker alive after the external ref drops")
}
// MARK: - Binding
@Test func bindingTracksAndWrites() {
let box = StateBox("a")
let b = Binding(box)
#expect(b.wrappedValue == "a")
b.wrappedValue = "b"
#expect(box.peek() == "b")
}
@Test func bindingFromStateBoxTracksInTracker() {
let box = StateBox("start")
let b = Binding(box)
var log: [String] = []
let tracker = DependencyTracker { log.append(b.wrappedValue) }
tracker.run()
#expect(log == ["start"])
box.set("changed")
#expect(log == ["start", "changed"])
}
@Test func customBindingSubscribeIsInert() {
let b = Binding(get: { 42 }, set: { _ in })
var called = false
let token = b.subscribe { _ in called = true }
_ = token
// Custom bindings never fire subscriptions
#expect(!called)
}
// MARK: - State property wrapper
@Test func statePropertyWrapper() {
struct H { @State var x = 0 }
let h = H()
h.x = 5
#expect(h.x == 5)
#expect(h.$x.wrappedValue == 5)
}
@Test func stateProjectedBindingChangesBox() {
struct H { @State var count = 0 }
let h = H()
h.$count.wrappedValue = 10
#expect(h.count == 10)
}
@Test func stateNonmutatingSetThroughStructCopy() {
struct H { @State var value = "old" }
let original = H()
let copy = original // same StateBox
copy.value = "new"
#expect(original.value == "new") // shared reference
}
}