94 lines
3.8 KiB
Swift
94 lines
3.8 KiB
Swift
/// 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
|
|
|
|
/// Number of trackers currently registered with the bridge.
|
|
static var registrationCount: Int { registered.count }
|
|
|
|
/// 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.
|
|
///
|
|
/// `onChange` runs on the thread that mutated the observable. Off-main
|
|
/// mutations therefore hop to the main actor before marking the tracker dirty.
|
|
nonisolated func _porticoObservationDidChange(_ id: UInt64) {
|
|
if porticoIsMainThread() {
|
|
MainActor.assumeIsolated { ObservationBridge.markDirty(id) }
|
|
} else {
|
|
_Concurrency.Task { @MainActor in ObservationBridge.markDirty(id) }
|
|
}
|
|
}
|
|
|
|
/// Number of trackers currently registered with the Observation bridge.
|
|
@_spi(Portico) @MainActor public func _porticoObservationRegistrationCount() -> Int {
|
|
ObservationBridge.registrationCount
|
|
}
|
|
|
|
/// 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 {}
|