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:
parent
145d0a710d
commit
c309fc3d1a
9 changed files with 519 additions and 15 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
Sources/Portico/State/Binding.swift
Normal file
47
Sources/Portico/State/Binding.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
28
Sources/Portico/State/DependencyTracker.swift
Normal file
28
Sources/Portico/State/DependencyTracker.swift
Normal 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() }
|
||||
}
|
||||
37
Sources/Portico/State/State.swift
Normal file
37
Sources/Portico/State/State.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
56
Sources/Portico/State/StateBox.swift
Normal file
56
Sources/Portico/State/StateBox.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
|
||||
18
Sources/Portico/State/SubscriptionToken.swift
Normal file
18
Sources/Portico/State/SubscriptionToken.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
Tests/PorticoTests/ReactiveLabelTests.swift
Normal file
138
Tests/PorticoTests/ReactiveLabelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
131
Tests/PorticoTests/StateTests.swift
Normal file
131
Tests/PorticoTests/StateTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue