portico/Sources/Portico/State/Environment.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

164 lines
6.8 KiB
Swift

/// 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> = []