Port @Environment and @Observable support

Adds SwiftUI-style @Environment property wrapper with key-path and
object-injection flavors, an @Observable bridge that flushes on a GLib
idle source at highIdle priority (ahead of GTK redraw), targeted widget
updates driven by Observation tracking, and two-way observable bindings
that ride the existing bindProperty path.

New library files (4):
- State/ObservationBridge.swift -- coalesced flush bridge
- State/EnvironmentValues.swift -- scope-local key-to-box map
- State/Environment.swift -- @Environment wrapper, DynamicProperty
- Core/View+Environment.swift -- .environment(...) modifiers

Modified library files (8):
- DependencyTracker: import Observation, observing: param, didArmObservation
- EitherView/ForEach: opt container trackers out of Observation
- NodeRegistry: isEmpty for retention check
- PropertyBinding: hasDependencies || didArmObservation gate
- Binding: readOnly init, observable key-path init
- MountContext: environment field, withEnvironment, inheritance
- AnyView: _resolveDynamicProperties hook before user body eval

New tests (21 across 3 files):
- EnvironmentTests (7): scoping, nesting, projections, ForEach, lazy branches, reuse
- ObservableTests (11): acceptance case, write-back, coalescing, teardown,
  binding echo, re-arm de-dupe, retention, flush ordering
- StateTests (+3): Observation arming unit tests

Example: EnvironmentDemoPage demonstrates the feature end to end.
This commit is contained in:
Brendan Szymanski 2026-07-30 16:53:10 -04:00
parent 058945af03
commit da91b02a6a
17 changed files with 1257 additions and 15 deletions

View file

@ -0,0 +1,95 @@
import Observation
import Portico
/// Demonstrates `@Environment` + `@Observable`: one model injected into a
/// subtree, read by multiple different view types, with mutations propagating
/// through the Observation bridge and two-way bindings writing back into the
/// model with no extra plumbing.
///
/// The model is declared at file scope so every pane resolves the same type
/// from `@Environment(DemoModel.self)`. `.environment(model)` scopes it to the
/// `VStack` that holds both panes.
// MARK: - Model
@Observable
private final class DemoModel {
var label = "Hello, Environment!"
var isDark = false
var fontSize = 14.0
}
// MARK: - Panes
/// Read-only status pane: displays the model's current state. Any mutation from
/// the controls pane reaches this widget through the Observation bridge no
/// `Binding`, no manual subscription.
private struct StatusPane: View {
@Environment(DemoModel.self) private var model
var body: some View {
VStack(spacing: 8) {
Label { model.label }
.title2()
Label {
"Font: \(Int(model.fontSize))pt, \(model.isDark ? "Dark" : "Light")"
}
.dimmed()
}
.card()
.halign(.center)
}
}
/// Interactive controls pane: mutates the injected model through two-way
/// bindings (`Binding(model, \.keyPath)`). Every change writes directly into
/// the model; the status pane sees it on the next idle flush.
private struct ControlsPane: View {
@Environment(DemoModel.self) private var model
var body: some View {
ListBox {
EntryRow()
.title("Label Text")
.text(Binding(model, \.label))
SwitchRow()
.title("Dark Mode")
.active(Binding(model, \.isDark))
SpinRow(min: 8, max: 48, step: 2)
.title("Font Size")
.value(Binding(model, \.fontSize))
}
.boxedList()
}
}
// MARK: - Page
struct EnvironmentDemoPage: View {
private let model = DemoModel()
var body: some View {
Clamp {
StatusPage {
VStack(spacing: 24) {
StatusPane()
ControlsPane()
}
.environment(model)
}
.title("Environment + Observable")
.description(
"An @Observable model injected with .environment(model), "
+ "read by two view types. Controls write through "
+ "Binding(model, \\.keyPath); the bridge pushes updates "
+ "to every widget that read the changed property."
)
.iconName("network-workgroup-symbolic")
}
.hexpand(true)
.vexpand(true)
}
}

View file

@ -21,10 +21,11 @@ struct ExampleApp: App {
counterPage
settingsPage
AsyncDemoPage()
EnvironmentDemoPage()
}
.onPageChanged { page in currentPage = page }
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 4")
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 5")
.dimmed()
.halign(.center)
.margin(8)

View file

@ -29,8 +29,12 @@ public struct AnyView: View {
} else if let m = view as? Mountable {
self.makeWidget = { ctx in m.mount(ctx) }
} else {
// User struct: evaluate body once at mount time.
self.makeWidget = { ctx in AnyView(view.body).makeWidget(ctx) }
// User struct: bind its environment-backed properties to the
// mounting scope, then evaluate body once.
self.makeWidget = { ctx in
_resolveDynamicProperties(view, in: ctx.environment)
return AnyView(view.body).makeWidget(ctx)
}
}
}
}

View file

@ -1,20 +1,46 @@
/// Context threaded through the mount pipeline. Carries the ``NodeRegistry``
/// that collects reactive resources for teardown.
/// that collects reactive resources for teardown, and the ``EnvironmentValues``
/// visible to the subtree being mounted.
@_spi(Portico) @MainActor public final class MountContext {
/// 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() }
/// The environment visible to this subtree.
///
/// Containers pass their context to children unchanged, so a context
/// produced by ``withEnvironment(_:)`` is seen by exactly the subtree the
/// `.environment(...)` modifier wraps - that is what makes injection
/// subtree-scoped without any extra plumbing in containers.
@_spi(Portico) public let environment: EnvironmentValues
private init(registry: NodeRegistry) { self.registry = registry }
/// Creates a root context with a fresh registry and an empty environment.
@_spi(Portico) public init() {
self.registry = NodeRegistry()
self.environment = EnvironmentValues()
}
private init(registry: NodeRegistry, environment: EnvironmentValues) {
self.registry = registry
self.environment = environment
}
/// 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.
/// row releases only that row's resources. The environment is inherited.
@_spi(Portico) public func makeChild() -> MountContext {
let child = NodeRegistry()
registry.addChild(child)
return MountContext(registry: child)
return MountContext(registry: child, environment: environment)
}
/// Returns a context sharing this registry but carrying a derived
/// environment. Teardown scope is deliberately unchanged: injecting a value
/// does not create an independently-unmountable subtree.
@_spi(Portico) public func withEnvironment(
_ transform: (inout EnvironmentValues) -> Void
) -> MountContext {
var derived = environment
transform(&derived)
return MountContext(registry: registry, environment: derived)
}
}

View file

@ -37,6 +37,15 @@ import Gtk
/// Registers a child registry for an independently-unmountable sub-subtree.
@_spi(Portico) public func addChild(_ child: NodeRegistry) { children.append(child) }
/// `true` when nothing has registered here and no child registry exists.
///
/// Lets a caller check that a mount left no reactive residue - the
/// "a static value costs nothing after mount" property.
@_spi(Portico) public var isEmpty: Bool {
tokens.isEmpty && gtkHandles.isEmpty && adwHandles.isEmpty
&& gobjectHandles.isEmpty && trackers.isEmpty && children.isEmpty
}
/// 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) {

View file

@ -83,7 +83,7 @@ public func bindInterpolation(
if let staticText = text.staticText { write(staticText); return }
let tracker = DependencyTracker { write(text.evaluate()) }
tracker.run()
if tracker.hasDependencies { registry.add(tracker) }
if tracker.hasDependencies || tracker.didArmObservation { registry.add(tracker) }
}
/// ``bindInterpolation(_:registry:write:)`` for a nullable property; a `nil` text writes
@ -98,5 +98,5 @@ public func bindOptionalInterpolation(
if let staticText = text.staticText { write(staticText); return }
let tracker = DependencyTracker { write(text.evaluate()) }
tracker.run()
if tracker.hasDependencies { registry.add(tracker) }
if tracker.hasDependencies || tracker.didArmObservation { registry.add(tracker) }
}

View file

@ -0,0 +1,34 @@
import Gtk
extension View {
/// Injects `value` into the environment slot named by `keyPath` for this
/// view and everything it mounts.
///
/// Scoping falls out of the existing pipeline: the modifier hands the
/// wrapped view a context carrying a derived ``EnvironmentValues``, and
/// every container passes its context to children unchanged, so exactly
/// this subtree sees the override. Siblings and ancestors keep whatever
/// they resolved.
///
/// The injected slot is one shared ``StateBox``, so every
/// `@Environment(keyPath)` declaration inside the subtree reads and writes
/// the same cell.
public func environment<Value>(
_ keyPath: WritableKeyPath<EnvironmentValues, Value>,
_ value: Value
) -> AnyView {
AnyView(makeWidget: { ctx in
AnyView(self).makeWidget(ctx.withEnvironment { $0[keyPath: keyPath] = value })
})
}
/// Injects `object`, keyed by its dynamic type, for this view's subtree.
///
/// Read it back with `@Environment(T.self)`. Change notification for an
/// `@Observable` object comes from Observation, not from the environment.
public func environment<T: AnyObject>(_ object: T) -> AnyView {
AnyView(makeWidget: { ctx in
AnyView(self).makeWidget(ctx.withEnvironment { $0.setObject(object) })
})
}
}

View file

@ -1,3 +1,5 @@
import Observation
/// A two-way connection to a mutable value source.
///
/// State-derived bindings (created via ``State/projectedValue``)
@ -51,4 +53,54 @@
self.subscriber = { box.subscribe($0) }
self.peeker = { box.peek() }
}
/// Creates a binding backed by a state box that ignores writes.
///
/// Backs ``Environment/projectedValue``: an environment slot's value is
/// owned by the ancestor that injected it, so a consumer may observe it
/// but not replace it. Reads still track and subscriptions still fire.
@_spi(Portico) public init(readOnly box: StateBox<Value>) {
self.getter = { box.get() }
self.setter = { _ in }
self.subscriber = { box.subscribe($0) }
self.peeker = { box.peek() }
}
/// Creates a two-way binding to a property of an `@Observable` object.
///
/// Completes the loop for injected models: the object's properties are the
/// writable half of a read-only `@Environment`, and this hands them to the
/// widget property binders that expect a ``Binding``. Widget edits write
/// straight through to the object, and Observation carries the change to
/// every other widget that read the same property.
///
/// ``subscribe(_:)`` is backed by a ``DependencyTracker``, so the callback
/// fires on the coalesced idle flush rather than inside the setter - see
/// ``ObservationBridge``. The returned token owns the tracker; cancelling
/// it (or tearing down the registry holding it) stops the updates.
///
/// - Parameters:
/// - object: The observable model. Held strongly by the binding.
/// - keyPath: The property to read and write.
public init<T: AnyObject & Observation.Observable>(
_ object: T,
_ keyPath: ReferenceWritableKeyPath<T, Value>
) {
self.getter = { object[keyPath: keyPath] }
self.setter = { object[keyPath: keyPath] = $0 }
self.peeker = { object[keyPath: keyPath] }
self.subscriber = { onChange in
var isInitialRun = true
let tracker = DependencyTracker {
let value = object[keyPath: keyPath]
if isInitialRun {
isInitialRun = false
return
}
onChange(value)
}
tracker.run()
return SubscriptionToken { tracker.teardown() }
}
}
}

View file

@ -1,9 +1,23 @@
import Observation
/// 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.
///
/// A tracker also participates in Swift Observation unless it opts out. Its
/// body runs inside `withObservationTracking`, so reading a property of an
/// `@Observable` class registers that property too, and mutating it schedules
/// the same targeted re-evaluation a ``StateBox`` write would - see
/// ``ObservationBridge``.
///
/// Container trackers (the `ForEach` diff, the `EitherView` branch switch)
/// pass `observing: false`. They mount subtrees, and a nested
/// `withObservationTracking` attributes the *inner* body's reads to the outer
/// scope as well (measured on Linux Swift 6.3.3), which would make every
/// container re-diff whenever any descendant's observable changed.
@_spi(Portico) @MainActor public final class DependencyTracker {
/// The tracker whose body is currently being evaluated.
@_spi(Portico) public static var current: DependencyTracker?
@ -11,8 +25,38 @@
private let body: () -> Void
private var deregistrations: [() -> Void] = []
@_spi(Portico) public init(_ body: @escaping () -> Void) {
/// Identifies this tracker to the non-isolated Observation callback.
/// `nil` when the tracker opted out of Observation.
private var observationID: UInt64?
/// One reusable marker, captured by every `onChange` closure this tracker
/// arms. Created on the first armed ``run()`` and never replaced, so a
/// re-evaluation allocates nothing.
private var armToken: ObservationArmToken?
/// `true` while at least one observation registration made by this tracker
/// is still live.
///
/// `withObservationTracking` releases its `onChange` closure when the body
/// touched no `@Observable` property. ``armToken`` is captured by that
/// closure, so the token stops being uniquely referenced exactly when an
/// observation edge exists. That makes the retention test precise: a
/// tracker with neither ``StateBox`` edges nor observable edges can never
/// fire and may be dropped, while one with only observable edges must be
/// kept - ``ObservationBridge`` holds trackers weakly.
@_spi(Portico) public var didArmObservation: Bool {
guard armToken != nil else { return false }
return !isKnownUniquelyReferenced(&armToken!)
}
/// - Parameters:
/// - observing: Whether the body participates in Swift Observation.
/// Pass `false` for a tracker whose body mounts a subtree.
/// - body: The reactive body.
@_spi(Portico) public init(observing: Bool = true, _ body: @escaping () -> Void) {
self.body = body
self.observationID = nil
if observing { self.observationID = ObservationBridge.register(self) }
}
/// Runs the body with `current` set to `self`, so state reads register
@ -20,7 +64,18 @@
@_spi(Portico) public func run() {
let previous = DependencyTracker.current
DependencyTracker.current = self
body()
if let id = observationID {
let token = armToken ?? {
let fresh = ObservationArmToken()
armToken = fresh
return fresh
}()
withObservationTracking(body) {
withExtendedLifetime(token) { _porticoObservationDidChange(id) }
}
} else {
body()
}
DependencyTracker.current = previous
}
@ -51,5 +106,6 @@
@_spi(Portico) public func teardown() {
for d in deregistrations { d() }
deregistrations.removeAll()
if let id = observationID { ObservationBridge.unregister(id) }
}
}

View file

@ -0,0 +1,164 @@
/// SwiftUI-style `@Environment`: the property wrapper, its shared resolved
/// storage, and the mount-time walk that binds every ``DynamicProperty``
/// declared by a view.
///
/// A stored property of a ``View`` that must be bound to the mounting scope
/// before `body` runs.
///
/// ``AnyView`` walks a user view's stored properties once, at mount, and hands
/// each conformer the scope's ``EnvironmentValues``. Mount happens exactly once
/// per view instance, so the walk is not on any update path.
@MainActor public protocol DynamicProperty {
/// Binds this property to `environment`. Called once, before `body`.
func _resolve(in environment: EnvironmentValues)
}
/// Shared, resolved storage for one `@Environment` declaration.
///
/// A reference type so that struct copies of the enclosing view - and of the
/// wrapper itself, which `Mirror` hands out by value - all observe the same
/// resolution.
@MainActor final class EnvironmentSlot<Value> {
/// What this declaration resolved to, plus the reference identifying it.
///
/// The identity is carried explicitly rather than derived from `value`,
/// because `Value` may be an `Optional` whose `nil` has no stable object
/// to compare.
struct Resolution {
var box: StateBox<Value>?
var value: Value?
var identity: AnyObject?
static func box(_ box: StateBox<Value>) -> Resolution {
Resolution(box: box, value: nil, identity: box)
}
static func object(_ value: Value, identity: AnyObject?) -> Resolution {
Resolution(box: nil, value: value, identity: identity)
}
}
var resolution: Resolution?
}
/// Reads a value injected by an ancestor's `.environment(...)` modifier.
///
/// Two flavors, mirroring SwiftUI:
///
/// - `@Environment(\.isFullscreen) var isFullscreen` - a key-path slot. Reads
/// are *tracking*: inside a reactive closure the read registers a dependency,
/// so a later write updates only the widgets that consumed it. Writes are
/// allowed and land in the box shared by the whole scope, so every other
/// declaration of the same slot in that scope sees them.
/// - `@Environment(Model.self) var model` - an injected reference type,
/// typically `@Observable`. Read-only; change notification comes from
/// Observation, not from a box.
///
/// A declaration that is never resolved (read before mount, or on a view that
/// was never mounted) falls back to the key's process-wide default box, so it
/// still shares state with every other unresolved declaration of that slot.
@propertyWrapper @MainActor public struct Environment<Value>: DynamicProperty {
private let bind: (EnvironmentValues) -> EnvironmentSlot<Value>.Resolution?
private let slot = EnvironmentSlot<Value>()
/// Binds this declaration to the environment slot named by `keyPath`.
public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
bind = { env in env._box(for: keyPath).map { .box($0) } }
}
/// Binds this declaration to the injected instance of `type`.
public init(_ type: Value.Type) where Value: AnyObject {
bind = { env in
env.object(Value.self).map { .object($0, identity: $0) }
}
}
/// Binds this declaration to the injected instance of `type`, or `nil`.
public init<T: AnyObject>(_ type: T.Type) where Value == T? {
bind = { env in
let found = env.object(T.self)
return .object(found, identity: found)
}
}
/// The current value. Read-only, matching SwiftUI.
///
/// Key-path slots read through the scope's shared ``StateBox``, so the read
/// *tracks*: a reactive closure that reads it re-runs when the scope's
/// value is replaced. Mutable shared state belongs in an `@Observable`
/// object injected with ``View/environment(_:)-(T)``, whose properties are
/// writable and propagate on their own.
public var wrappedValue: Value {
let r = resolution
if let box = r.box { return box.get() }
return r.value!
}
/// A read-only ``Binding`` to the scope's slot, for feeding the existing
/// binding-taking inits and modifiers.
///
/// Writes through this binding are ignored: the slot's value is owned by
/// whichever ancestor injected it. Only meaningful for key-path slots;
/// traps on an object slot.
public var projectedValue: Binding<Value> {
guard let box = resolution.box else {
fatalError("@Environment object slots have no binding")
}
return Binding(readOnly: box)
}
/// Binds this declaration to `environment`.
///
/// Traps when the same declaration is bound twice to *different* values.
/// One resolution is shared by every copy of the enclosing view struct, so
/// mounting one view instance into two different environment scopes would
/// otherwise leave the first subtree silently reading the second scope's
/// cell. Mounting the same instance twice within one scope resolves
/// identically and is allowed.
@_spi(Portico) public func _resolve(in environment: EnvironmentValues) {
let next = bind(environment)
if let previous = slot.resolution, previous.identity !== next?.identity {
fatalError(
"""
@Environment: view instance mounted into two different \
environment scopes. A view instance resolves its environment \
once; build a fresh instance per mount instead of reusing one \
across scopes.
"""
)
}
slot.resolution = next
}
/// The resolution, binding lazily against an empty scope if the mount-time
/// walk never reached this declaration.
private var resolution: EnvironmentSlot<Value>.Resolution {
if let r = slot.resolution { return r }
guard let r = bind(EnvironmentValues()) else {
fatalError("@Environment: no value injected for this slot")
}
slot.resolution = r
return r
}
}
/// Binds every ``DynamicProperty`` stored in `view` to `environment`.
///
/// Types with no dynamic properties are recorded on first sight and skipped
/// thereafter, so the reflection cost is one `Mirror` per view *type*, not per
/// instance.
@_spi(Portico) @MainActor
public func _resolveDynamicProperties<V>(_ view: V, in environment: EnvironmentValues) {
let id = ObjectIdentifier(V.self)
if _inertViewTypes.contains(id) { return }
var found = false
for child in Mirror(reflecting: view).children {
guard let property = child.value as? DynamicProperty else { continue }
found = true
property._resolve(in: environment)
}
if !found { _inertViewTypes.insert(id) }
}
/// View types already known to store no ``DynamicProperty``.
@MainActor private var _inertViewTypes: Set<ObjectIdentifier> = []

View file

@ -0,0 +1,114 @@
/// Environment storage: a scope-local map from ``EnvironmentKey`` type to the
/// ``StateBox`` that holds that key's value for the scope.
///
/// Boxes - not raw values - are what a scope stores, and that is the whole
/// trick: every `@Environment` declaration in a subtree resolves to the *same*
/// box, so a write from one declaration notifies the trackers registered by
/// every other declaration. That reuses the existing ``StateBox`` /
/// ``DependencyTracker`` machinery verbatim: no tree diff, no rebuild, just the
/// same targeted widget writes that `@State` already performs.
/// A type that identifies one environment slot and supplies its fallback value.
///
/// Conform a caseless enum and add a computed property on ``EnvironmentValues``
/// so the slot is reachable through a key path.
public protocol EnvironmentKey {
/// The value stored in this slot.
associatedtype Value
/// The value seen by readers in scopes where nothing has been injected.
static var defaultValue: Value { get }
}
/// Captures the ``StateBox`` touched by an ``EnvironmentValues`` subscript read.
///
/// A key path such as `\.isFullscreen` names a *value*, not the box behind it.
/// Resolution therefore reads the key path once with a probe installed; the
/// subscript hands the probe the box it resolved, and the read result is
/// discarded.
@_spi(Portico) @MainActor public final class EnvironmentBoxProbe {
/// The box handed over by the subscript read, or `nil` when the key path
/// did not route through ``EnvironmentValues/subscript(_:)``.
@_spi(Portico) public var captured: AnyObject?
/// Creates an unfilled probe.
@_spi(Portico) public init() {}
}
/// A scope's environment: the boxes it overrides, plus the objects it injects.
///
/// Value semantics. Overriding a slot in a copy leaves every other scope
/// untouched, which is what makes `.environment(_:_:)` subtree-scoped.
@MainActor public struct EnvironmentValues {
/// Installed by ``_box(for:)`` around a single key-path read.
@_spi(Portico) public static var _probe: EnvironmentBoxProbe?
/// Process-wide fallback boxes, one per key type, created on first demand.
///
/// Two subtrees that never inject a value share this box, so an unprovided
/// slot still behaves as one globally shared cell.
private static var defaultBoxes: [ObjectIdentifier: AnyObject] = [:]
/// Boxes this scope overrides, keyed by ``EnvironmentKey`` type.
private var boxes: [ObjectIdentifier: AnyObject] = [:]
/// Objects this scope injects, keyed by dynamic type.
private var objects: [ObjectIdentifier: AnyObject] = [:]
/// Creates an empty environment - every slot resolves to its key's default.
public init() {}
/// Reads or overrides the slot identified by `key`.
///
/// The getter is a *non-tracking* read: `@Environment` reads through the
/// resolved box instead, so that the tracking read happens at the point the
/// view actually consumes the value. The setter installs a fresh box in
/// this copy, shadowing whatever the parent scope resolved.
public subscript<K: EnvironmentKey>(key: K.Type) -> K.Value {
get {
let box = resolvedBox(K.self)
EnvironmentValues._probe?.captured = box
return box.peek()
}
set { boxes[ObjectIdentifier(K.self)] = StateBox(newValue) }
}
/// Injects `object`, keyed by its dynamic type, for this scope's subtree.
@_spi(Portico) public mutating func setObject<T: AnyObject>(_ object: T) {
objects[ObjectIdentifier(T.self)] = object
}
/// The object injected for `type` in this scope, or `nil`.
@_spi(Portico) public func object<T: AnyObject>(_ type: T.Type) -> T? {
objects[ObjectIdentifier(T.self)] as? T
}
/// Resolves the box a key path names, without consuming its value.
///
/// Returns `nil` only if `keyPath` does not route through
/// ``subscript(_:)`` - a malformed environment accessor.
@_spi(Portico) public func _box<Value>(
for keyPath: KeyPath<EnvironmentValues, Value>
) -> StateBox<Value>? {
let probe = EnvironmentBoxProbe()
let saved = EnvironmentValues._probe
EnvironmentValues._probe = probe
defer { EnvironmentValues._probe = saved }
_ = self[keyPath: keyPath]
return probe.captured as? StateBox<Value>
}
/// This scope's box for `key`, falling back to the shared default box.
private func resolvedBox<K: EnvironmentKey>(_ key: K.Type) -> StateBox<K.Value> {
if let box = boxes[ObjectIdentifier(K.self)] as? StateBox<K.Value> { return box }
return EnvironmentValues.defaultBox(K.self)
}
/// The process-wide fallback box for `key`, created on first demand.
private static func defaultBox<K: EnvironmentKey>(_ key: K.Type) -> StateBox<K.Value> {
let id = ObjectIdentifier(K.self)
if let box = defaultBoxes[id] as? StateBox<K.Value> { return box }
let box = StateBox(K.defaultValue)
defaultBoxes[id] = box
return box
}
}

View file

@ -0,0 +1,83 @@
/// Bridges Swift Observation to Portico's targeted-update model.
///
/// Swift's `withObservationTracking(_:onChange:)` has three properties that
/// make it unusable as a drop-in for ``StateBox`` notification, and this file
/// exists to fix all three:
///
/// 1. `onChange` is one-shot. It must be re-armed by re-running the tracked
/// body, which ``DependencyTracker/run()`` already does.
/// 2. `onChange` is a *willSet* hook - measured on Linux Swift 6.3.3, reading
/// the property inside it still yields the old value. Re-evaluating there
/// would write stale text into the widget.
/// 3. `onChange` is `@Sendable` and non-isolated, so it cannot touch main-actor
/// state directly.
///
/// The bridge answers all three by marking the tracker dirty and flushing on a
/// GLib idle source at ``SourcePriority/highIdle`` - after the mutation has
/// landed, before GTK's layout/redraw idle (`G_PRIORITY_HIGH_IDLE + 20`), and
/// coalesced so N mutations in one turn produce one re-evaluation.
@MainActor enum ObservationBridge {
private static var nextID: UInt64 = 1
private static var registered: [UInt64: WeakTracker] = [:]
private static var dirty: [UInt64] = []
private static var flushScheduled = false
/// Weak handle so a torn-down tracker is not kept alive by the registry.
private struct WeakTracker {
weak var tracker: DependencyTracker?
}
/// Registers `tracker` and returns the `Sendable` token that identifies it
/// from inside an `onChange` closure.
static func register(_ tracker: DependencyTracker) -> UInt64 {
let id = nextID
nextID += 1
registered[id] = WeakTracker(tracker: tracker)
return id
}
/// Drops `id` from the registry; called on tracker teardown.
static func unregister(_ id: UInt64) {
registered[id] = nil
dirty.removeAll { $0 == id }
}
/// Queues `id` for re-evaluation on the next idle turn.
static func markDirty(_ id: UInt64) {
guard registered[id] != nil else { return }
if !dirty.contains(id) { dirty.append(id) }
guard !flushScheduled else { return }
flushScheduled = true
Idle(priority: .highIdle) { flush() }
}
/// Re-runs every dirty tracker, which also re-arms its observation.
///
/// Snapshots the queue first: a re-evaluation may itself dirty another
/// tracker, and that one belongs to the next turn, not this one.
private static func flush() {
flushScheduled = false
let batch = dirty
dirty.removeAll()
for id in batch {
registered[id]?.tracker?.reevaluate()
}
}
}
/// Trampoline out of the non-isolated, `@Sendable` `onChange` closure.
///
/// Valid because every Portico mutation happens on the GLib main thread; this
/// is the same `MainActor.assumeIsolated` bridge the GLib source callbacks in
/// `MainLoopSources.swift` already rely on.
nonisolated func _porticoObservationDidChange(_ id: UInt64) {
MainActor.assumeIsolated { ObservationBridge.markDirty(id) }
}
/// Empty marker captured by a tracker's `onChange` closure.
///
/// Naturally `Sendable` - a final class with no stored state - so it crosses
/// into the `@Sendable` closure without any unchecked escape hatch. Its only
/// job is to report, via its reference count, whether `withObservationTracking`
/// kept the closure alive; see ``DependencyTracker/didArmObservation``.
nonisolated final class ObservationArmToken: Sendable {}

View file

@ -47,7 +47,7 @@ import Gtk
// Track changes re-evaluate condition, lazily mount the other
// branch on first flip.
let tracker = DependencyTracker { [stack] in
let tracker = DependencyTracker(observing: false) { [stack] in
let isTrue = condition.wrappedValue
if isTrue {
if trueWidgets == nil {

View file

@ -99,7 +99,7 @@ import Gtk
// 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() }
let tracker = DependencyTracker(observing: false) { applyDiff() }
tracker.run()
ctx.registry.add(tracker)
return box