diff --git a/Sources/Portico/Core/View+OnChange.swift b/Sources/Portico/Core/View+OnChange.swift new file mode 100644 index 0000000..4a67c6b --- /dev/null +++ b/Sources/Portico/Core/View+OnChange.swift @@ -0,0 +1,228 @@ +import Gtk + +/// Installs the synchronous subscription path for a binding-backed value. +/// +/// The initial snapshot and each callback use non-tracking reads. The previous +/// value is updated before invoking the action so write-back actions cannot +/// recurse on a stale snapshot. +@MainActor +private func installOnChange( + _ binding: Binding, + initial: Bool, + registry: NodeRegistry, + _ action: @escaping @MainActor (V, V) -> Void +) { + var previous = binding.untrackedValue + if initial { + let seed = previous + DependencyTracker.untracked { action(seed, seed) } + } + registry.add(binding.subscribe { next in + guard next != previous else { return } + let old = previous + previous = next + DependencyTracker.untracked { action(old, next) } + }) +} + +/// Installs the dependency-tracked closure path for a value source. +/// +/// The tracker keeps only sources that register a state or Observation +/// dependency. It compares successive values and suppresses tracking while +/// invoking the action. +@MainActor +private func installOnChange( + _ value: @escaping @MainActor () -> V, + initial: Bool, + registry: NodeRegistry, + _ action: @escaping @MainActor (V, V) -> Void +) { + var previous: V? + let tracker = DependencyTracker { + let next = value() + guard let old = previous else { + previous = next + if initial { DependencyTracker.untracked { action(next, next) } } + return + } + guard old != next else { return } + previous = next + DependencyTracker.untracked { action(old, next) } + } + tracker.run() + if tracker.hasDependencies || tracker.didArmObservation { registry.add(tracker) } +} + +extension WidgetView { + /// Runs `action` when the bound value changes. + /// + /// The value is compared with `==`, so writing an equal value does not fire + /// the action. The modifier is installed during mount and remains active + /// while the subtree is mounted, including while its widget is hidden. It is + /// released when the subtree tears down. + /// + /// State-derived sources such as `$value` from `@State`, + /// `$model.property` from `@Bindable`, `Binding(model, \.property)`, and + /// `$value` from `@Environment(\.someKey)` notify this modifier. Observable + /// model bindings deliver on the coalesced idle flush described by + /// ``ObservationBridge``, rather than synchronously with the mutation. + /// + /// A binding created with ``Binding/init(get:set:)`` never fires because its + /// ``Binding/subscribe(_:)`` is inert by design. Use the closure overload + /// when the getter reads a tracked source. + /// + /// - Parameters: + /// - value: The binding whose changes trigger `action`. + /// - initial: Whether to run `action` once during mount. When `true`, the + /// mount-time value is passed as both `oldValue` and `newValue`. + /// - action: The side effect to run with the old and new values. + /// - Returns: A copy of this view with the change handler installed. + public func onChange( + of value: Binding, + initial: Bool = false, + _ action: @escaping @MainActor (V, V) -> Void + ) -> Self { + appending { _, ctx in + installOnChange(value, initial: initial, registry: ctx.registry, action) + } + } + + /// Runs an action when the bound value changes, ignoring the old and new values. + /// + /// This is the zero-argument counterpart of ``onChange(of:initial:_:)``. + /// + /// - Parameters: + /// - value: The binding whose changes trigger `action`. + /// - initial: Whether to run `action` once during mount. + /// - action: The side effect to run after a distinct value change. + /// - Returns: A copy of this view with the change handler installed. + public func onChange( + of value: Binding, + initial: Bool = false, + _ action: @escaping @MainActor () -> Void + ) -> Self { + onChange(of: value, initial: initial) { (_: V, _: V) in action() } + } + + /// Runs `action` when the value returned by the tracked closure changes. + /// + /// The closure is evaluated inside a ``DependencyTracker``. Reads of + /// `@State`, `@Environment` values, and properties of `@Observable` models + /// become triggers. Plain values, ordinary classes and structs, global + /// variables, and GTK/GObject properties are not tracked. If the closure + /// reads nothing reactive, the tracker is dropped and this modifier is inert. + /// + /// The returned value must be `Equatable`; project a non-Equatable value to + /// an Equatable property or identity, for example + /// `.onChange(of: { model.items.count })` or + /// `.onChange(of: { ObjectIdentifier(model.child) })`. The action runs with + /// tracking suppressed, so reads in the action do not become triggers. + /// + /// - Parameters: + /// - value: A tracked closure that produces the value to compare. + /// - initial: Whether to run `action` once during mount. When `true`, the + /// mount-time value is passed as both `oldValue` and `newValue`. + /// - action: The side effect to run with the old and new values. + /// - Returns: A copy of this view with the change handler installed. + public func onChange( + of value: @escaping @MainActor () -> V, + initial: Bool = false, + _ action: @escaping @MainActor (V, V) -> Void + ) -> Self { + appending { _, ctx in + installOnChange(value, initial: initial, registry: ctx.registry, action) + } + } + + /// Runs an action when the tracked value changes, ignoring the old and new values. + /// + /// This is the zero-argument counterpart of the tracked two-argument + /// ``onChange(of:initial:_:)`` overload. + /// + /// - Parameters: + /// - value: A tracked closure that produces the value to compare. + /// - initial: Whether to run `action` once during mount. + /// - action: The side effect to run after a distinct value change. + /// - Returns: A copy of this view with the change handler installed. + public func onChange( + of value: @escaping @MainActor () -> V, + initial: Bool = false, + _ action: @escaping @MainActor () -> Void + ) -> Self { + onChange(of: value, initial: initial) { (_: V, _: V) in action() } + } +} + +extension View { + /// The `AnyView`-returning counterpart of the ``WidgetView`` binding + /// `onChange(of:initial:_:)` modifier for containers and other non-widget views. + /// + /// - Parameters: + /// - value: The binding whose changes trigger `action`. + /// - initial: Whether to run `action` once during mount. + /// - action: The side effect to run with the old and new values. + /// - Returns: An ``AnyView`` wrapping this view with the change handler installed. + public func onChange( + of value: Binding, + initial: Bool = false, + _ action: @escaping @MainActor (V, V) -> Void + ) -> AnyView { + AnyView(makeWidget: { ctx in + let widget = AnyView(self).makeWidget(ctx) + installOnChange(value, initial: initial, registry: ctx.registry, action) + return widget + }) + } + + /// The zero-argument `AnyView`-returning counterpart of the binding change + /// modifier. It ignores the old and new values. + /// + /// - Parameters: + /// - value: The binding whose changes trigger `action`. + /// - initial: Whether to run `action` once during mount. + /// - action: The side effect to run after a distinct value change. + /// - Returns: An ``AnyView`` wrapping this view with the change handler installed. + public func onChange( + of value: Binding, + initial: Bool = false, + _ action: @escaping @MainActor () -> Void + ) -> AnyView { + onChange(of: value, initial: initial) { (_: V, _: V) in action() } + } + + /// The `AnyView`-returning counterpart of the tracked-closure + /// `onChange(of:initial:_:)` modifier for containers and other non-widget views. + /// + /// - Parameters: + /// - value: A tracked closure that produces the value to compare. + /// - initial: Whether to run `action` once during mount. + /// - action: The side effect to run with the old and new values. + /// - Returns: An ``AnyView`` wrapping this view with the change handler installed. + public func onChange( + of value: @escaping @MainActor () -> V, + initial: Bool = false, + _ action: @escaping @MainActor (V, V) -> Void + ) -> AnyView { + AnyView(makeWidget: { ctx in + let widget = AnyView(self).makeWidget(ctx) + installOnChange(value, initial: initial, registry: ctx.registry, action) + return widget + }) + } + + /// The zero-argument `AnyView`-returning counterpart of the tracked change + /// modifier. It ignores the old and new values. + /// + /// - Parameters: + /// - value: A tracked closure that produces the value to compare. + /// - initial: Whether to run `action` once during mount. + /// - action: The side effect to run after a distinct value change. + /// - Returns: An ``AnyView`` wrapping this view with the change handler installed. + public func onChange( + of value: @escaping @MainActor () -> V, + initial: Bool = false, + _ action: @escaping @MainActor () -> Void + ) -> AnyView { + onChange(of: value, initial: initial) { (_: V, _: V) in action() } + } +} diff --git a/Tests/PorticoTests/OnChangeTests.swift b/Tests/PorticoTests/OnChangeTests.swift new file mode 100644 index 0000000..f40f1ed --- /dev/null +++ b/Tests/PorticoTests/OnChangeTests.swift @@ -0,0 +1,221 @@ +import Observation +import Testing + +@_spi(Portico) import Portico +@_spi(SGTKInternal) import Gtk + +@_silgen_name("g_main_context_iteration") +private nonisolated func onChange_g_main_context_iteration( + _ context: UnsafeMutableRawPointer?, _ mayBlock: Int32 +) -> Int32 + +/// Drives the GLib default main context until `condition` holds or `turns` is exhausted. +/// Pumps GLib so coalesced Observation callbacks can reach the test action. +@MainActor private func pumpOnChange(until condition: () -> Bool, turns: Int = 200) { + for _ in 0..(get: { box.peek() }, set: { box.set($0) }) + var fires = 0 + let ctx = MountContext() + _ = AnyView(Label(str: "x").onChange(of: custom) { fires += 1 }) + .makeWidget(ctx) + defer { ctx.registry.teardown() } + + custom.wrappedValue = 1 + #expect(fires == 0) + } + + @Test func nonReactiveClosureLeavesNoResidue() { + guard Gtk.initCheck() else { return } + let ctx = MountContext() + _ = AnyView(Label(str: "x").onChange(of: { 1 }) { _, _ in }) + .makeWidget(ctx) + defer { ctx.registry.teardown() } + + #expect(ctx.registry.isEmpty) + } + + @Test func actionReadsDoNotBecomeTriggers() { + guard Gtk.initCheck() else { return } + let source = StateBox(0) + let actionSource = StateBox(100) + var evaluations = 0 + var fires = 0 + let ctx = MountContext() + _ = AnyView(Label(str: "x").onChange(of: { + evaluations += 1 + return source.get() + }) { + _ = actionSource.get() + fires += 1 + }).makeWidget(ctx) + defer { ctx.registry.teardown() } + + #expect(evaluations == 1) + source.set(1) + #expect(evaluations == 2) + #expect(fires == 1) + actionSource.set(101) + #expect(evaluations == 2) + #expect(fires == 1) + } + + @Test func widgetViewOverloadPreservesSelf() { + guard Gtk.initCheck() else { return } + let box = StateBox(0) + let view: Portico.Label = Portico.Label(str: "x") + .onChange(of: Binding(box)) { _, _ in } + .label("done") + let ctx = MountContext() + let widget = AnyView(view).makeWidget(ctx) as! Gtk.Label + defer { ctx.registry.teardown() } + + #expect(widget.getText() == "done") + } + + @Test func viewOverloadWorksOnContainer() { + guard Gtk.initCheck() else { return } + let box = StateBox(0) + var fires = 0 + let view = VStack { Label(str: "x") } + .onChange(of: Binding(box)) { fires += 1 } + let ctx = MountContext() + _ = AnyView(view).makeWidget(ctx) + defer { ctx.registry.teardown() } + + box.set(1) + #expect(fires == 1) + } +}