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.
114 lines
5 KiB
Swift
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
|
|
}
|
|
}
|