portico/Sources/Portico/State/EnvironmentValues.swift
Brendan Szymanski da91b02a6a 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.
2026-07-30 16:53:10 -04:00

114 lines
5 KiB
Swift

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