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:
parent
058945af03
commit
da91b02a6a
17 changed files with 1257 additions and 15 deletions
95
Sources/Example/EnvironmentDemo.swift
Normal file
95
Sources/Example/EnvironmentDemo.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
34
Sources/Portico/Core/View+Environment.swift
Normal file
34
Sources/Portico/Core/View+Environment.swift
Normal 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) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
164
Sources/Portico/State/Environment.swift
Normal file
164
Sources/Portico/State/Environment.swift
Normal 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> = []
|
||||
114
Sources/Portico/State/EnvironmentValues.swift
Normal file
114
Sources/Portico/State/EnvironmentValues.swift
Normal 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
|
||||
}
|
||||
}
|
||||
83
Sources/Portico/State/ObservationBridge.swift
Normal file
83
Sources/Portico/State/ObservationBridge.swift
Normal 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 {}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue