Skip to main content

teksilo_core/
binding.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Binding registry: the dirty-tracking infrastructure shared between
5//! `Signal<T>` instances and `WidgetTree`.
6//!
7//! Despite the module name (kept for historical reasons), this file no
8//! longer contains any state primitive — `Signal<T>` in `signal.rs` is
9//! the only reactive type. The registry, its binding entries, and the
10//! `BindingLevel` enum live here because they are the shared vocabulary
11//! between signals and the widget tree.
12
13use std::cell::{Cell, RefCell};
14use std::collections::HashMap;
15use std::rc::Rc;
16
17use crate::widget_id::WidgetId;
18
19/// Dirty-tracking granularity for a property binding.
20/// Determined by the primitive widget implementor, not the consumer.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum BindingLevel {
23    /// Visual-only change (color, opacity). Marks the widget for repaint;
24    /// layout is skipped.
25    RepaintOnly,
26    /// Visual-only change that propagates through the entire subtree.
27    /// Used by `enabled_when` so that flipping a single node's
28    /// `enabled_state` marks the whole disabled subtree for repaint —
29    /// leaves like `IconWidget` resolve their role color from
30    /// [`crate::widget::PaintContext::effective_enabled`], which the
31    /// paint walker computes by AND-ing ancestor enabled-states.
32    /// Without this propagation, only the bound node would repaint and
33    /// descendants would keep their stale enabled colors.
34    SubtreeRepaint,
35    /// Size-affecting change (text content, constraint value). Marks the widget
36    /// for relayout and propagates upward through ancestors.
37    Relayout,
38    /// Data-model change requiring the widget's `build()` to re-run.
39    /// Used by data-driven widgets (Repeater, ListView, TreeView) to trigger
40    /// a full rebuild of their child subtree when the underlying data changes.
41    Rebuild,
42    /// Accessibility-tree change only. Flips `WidgetTree::a11y_dirty` without
43    /// touching repaint, layout, or rebuild flags. Orthogonal to the other
44    /// levels — widgets whose accessibility output depends on a signal that
45    /// does not visually affect the widget itself (e.g., a `document_version`
46    /// bumped by every text edit) bind at this level so AccessKit's tree
47    /// rebuilds as soon as the underlying data changes, regardless of
48    /// whether the widget needs repainting.
49    AccessibilityOnly,
50}
51
52/// A registered binding between a signal and a widget property.
53///
54/// Carries the source-signal generation closure so the registry can
55/// construct a [`BindingGroup`] on first registration. After that, all
56/// bindings sharing one `source_id` collapse into a single group entry —
57/// the closure is stored once on the group and never duplicated.
58/// Pre-optimization the registry walked all bindings every frame and
59/// polled per binding, even though many shared the same underlying
60/// source.
61#[derive(Clone)]
62pub(crate) struct Binding {
63    /// Widget to mark dirty when the source signal changes.
64    pub widget_id: WidgetId,
65    /// The dirty-tracking level for this binding.
66    pub level: BindingLevel,
67    /// Read the source signal's current change generation.
68    pub generation: Rc<dyn Fn() -> u64>,
69    /// Stable identity of the source signal — see
70    /// `Signal::source_id`.
71    /// Used by [`BindingRegistry::register`] to look up the matching
72    /// [`BindingGroup`] in O(1).
73    pub source_id: usize,
74}
75
76/// All bindings that share one source signal.
77///
78/// Stored once per `source_id` in the registry's `HashMap`. The
79/// generation closure is captured at first-registration time and reused
80/// for every subsequent binding on the same source — N bindings on one
81/// signal cost one poll per frame, not N.
82struct BindingGroup {
83    generation: Rc<dyn Fn() -> u64>,
84    /// The source generation this registry last acted on. **This is the
85    /// consumer half of dirty tracking, and it lives here on purpose.**
86    ///
87    /// Each `WidgetTree` owns its own `BindingRegistry`, so N open
88    /// windows bound to one shared `Signal` get N independent
89    /// `last_seen` values. Keeping it on the signal instead — as the
90    /// `dirty: bool` this replaced did — meant one slot for N consumers,
91    /// and a flush that both read and cleared it: whichever tree
92    /// reconciled first consumed the flag and every other window
93    /// silently skipped its binding, permanently. See
94    /// `signal::MutableInner::generation`.
95    ///
96    /// A `Cell` rather than a plain field so [`BindingRegistry::flush_all_dirty`]
97    /// can update it while holding only a shared borrow of the map.
98    last_seen: Cell<u64>,
99    /// `(widget_id, level)` pairs to flush when this source is dirty.
100    /// `AccessibilityOnly` lives in the same Vec as visual levels —
101    /// the flush walker dispatches on `level` to the right bucket.
102    /// Dedupe key within the Vec is `(widget_id, is_a11y(level))`,
103    /// preserving the original "a11y vs visual buckets are separate"
104    /// semantics so a widget can hold one of each on the same source.
105    bindings: Vec<(WidgetId, BindingLevel)>,
106}
107
108/// Shared registry of all active property bindings.
109///
110/// Indexed by source-signal identity (`source_id`) so the per-frame
111/// flush iterates unique sources rather than every binding. Typical
112/// catalog scene: ~30-40 unique sources covering 100-300+ bindings
113/// (every reactive theme query, every `label`, every visibility
114/// signal pulls a binding off the same shared root).
115///
116/// **One registry per [`WidgetTree`](crate::WidgetTree)**, and that
117/// matters: the registry is where "have I acted on this change yet?" is
118/// remembered, so two windows sharing a `Signal` each answer it for
119/// themselves. Cloning a `BindingRegistry` shares the same state (it is
120/// `Rc`-backed) and is only ever done to hold onto one tree's registry,
121/// never to hand a second tree the first tree's bookkeeping.
122#[derive(Clone, Default)]
123pub struct BindingRegistry {
124    by_source: Rc<RefCell<HashMap<usize, BindingGroup>>>,
125}
126
127impl BindingRegistry {
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    pub(crate) fn register(&self, binding: Binding) {
133        let mut by_source = self.by_source.borrow_mut();
134        let group = by_source.entry(binding.source_id).or_insert_with(|| {
135            // Seed `last_seen` at the source's CURRENT generation: the
136            // widget registering this binding has, by construction, just
137            // read the current value in its `build()`, so it is not stale
138            // and must not fire on the very next flush.
139            //
140            // Only on group *creation*. A later binding joining an
141            // existing group inherits that group's `last_seen`, which may
142            // be older — deliberately: one widget rebuilding must not
143            // swallow a pending change on behalf of the other widgets
144            // bound to the same source. Inheriting an older value can at
145            // worst cost the newcomer one redundant repaint.
146            let generation = binding.generation.clone();
147            let seen = generation();
148            BindingGroup {
149                generation,
150                last_seen: Cell::new(seen),
151                bindings: Vec::new(),
152            }
153        });
154        // Dedup within the group by (widget_id, a11y bucket). Visual
155        // levels collapse with each other (and promote); a11y stays
156        // in its own bucket so a widget can hold both flavours on
157        // one source without one clobbering the other.
158        let incoming_a11y = is_a11y_only(binding.level);
159        if let Some(existing) = group
160            .bindings
161            .iter_mut()
162            .find(|(wid, lvl)| *wid == binding.widget_id && is_a11y_only(*lvl) == incoming_a11y)
163        {
164            existing.1 = promote_level(existing.1, binding.level);
165            return;
166        }
167        group.bindings.push((binding.widget_id, binding.level));
168    }
169
170    /// Drop every binding targeting `widget_id`. Called by the widget
171    /// tree before a widget rebuilds (so `build()` can re-register a
172    /// fresh, deduplicated set) and on destroy (so a dead widget's
173    /// bindings no longer keep source-signal references alive or
174    /// accumulate across the lifetime of the app).
175    ///
176    /// A group left with no bindings is **kept** here and reclaimed
177    /// later by [`reclaim_empty_groups`](Self::reclaim_empty_groups),
178    /// which the tree calls once at the end of each reconcile pass.
179    /// Dropping it immediately would throw away the group's
180    /// `last_seen`, and a rebuild is exactly unregister-then-register:
181    /// a widget that is the only binder of a source would come out of
182    /// its own rebuild with `last_seen` re-seeded at *now*, silently
183    /// swallowing any write made in between — and `build()` commonly
184    /// writes before it re-binds (`SceneView` bumps `reconcile_dirty`
185    /// from the item-change observer its dynamic-bounds refresh fires,
186    /// several lines before re-registering it). Deferring reclamation
187    /// to end-of-pass keeps the ledger across a rebuild while still
188    /// reclaiming it for a widget that was genuinely destroyed.
189    pub(crate) fn unregister_for_widget(&self, widget_id: WidgetId) {
190        let mut by_source = self.by_source.borrow_mut();
191        for group in by_source.values_mut() {
192            group.bindings.retain(|(wid, _)| *wid != widget_id);
193        }
194    }
195
196    /// Drop every group that no longer has any bindings, reclaiming
197    /// its `source_id` slot and releasing its reference to the source
198    /// signal. Called once per reconcile pass, after rebuilds have had
199    /// their chance to re-register — see
200    /// [`unregister_for_widget`](Self::unregister_for_widget) for why
201    /// reclamation is deferred rather than immediate.
202    pub(crate) fn reclaim_empty_groups(&self) {
203        self.by_source
204            .borrow_mut()
205            .retain(|_src, group| !group.bindings.is_empty());
206    }
207
208    /// Number of live bindings. Exposed for tests that verify
209    /// cleanup does not accumulate entries across rebuilds. Equals
210    /// the total count of `(widget_id, level)` entries across all
211    /// source groups.
212    #[cfg(test)]
213    pub(crate) fn len(&self) -> usize {
214        self.by_source
215            .borrow()
216            .values()
217            .map(|g| g.bindings.len())
218            .sum()
219    }
220
221    /// Drain dirty bindings in one pass — return both the visual
222    /// dirty list (per-widget at the highest visual level seen) and
223    /// the tree-wide accessibility-dirty flag.
224    ///
225    /// "Dirty" is `source generation != the generation this registry
226    /// last acted on`, and acting on it advances only THIS registry's
227    /// `last_seen` — nothing on the signal is mutated. Two consequences
228    /// worth stating, because the previous shared-`bool` design got both
229    /// wrong:
230    ///
231    /// - A signal bound at both `AccessibilityOnly` and some visual
232    ///   level (e.g. `Button::label` registers at `RepaintOnly` for the
233    ///   inner TextWidget AND at `AccessibilityOnly` for the AT name)
234    ///   lands in one group with one `last_seen`, so both buckets see
235    ///   the same generation in the same pass. There is no longer an
236    ///   ordering hazard to design around: nothing is consumed, so no
237    ///   bucket can starve another.
238    /// - Another `WidgetTree`'s registry flushing the same shared signal
239    ///   has no effect here at all. That is the whole point — see
240    ///   [`BindingGroup::last_seen`].
241    ///
242    /// Cost is O(S) generation polls (S = unique sources) plus O(D)
243    /// widget-level promotions (D = bindings on dirty sources).
244    /// Pre-optimization it was O(N) polls (N = all bindings); on the
245    /// catalog scene S≈30-40, N≈100-300.
246    pub(crate) fn flush_all_dirty(&self) -> (Vec<(WidgetId, BindingLevel)>, bool) {
247        let by_source = self.by_source.borrow();
248        let mut dirty_map: HashMap<WidgetId, BindingLevel> = HashMap::new();
249        let mut a11y_dirty = false;
250        for group in by_source.values() {
251            let generation = (group.generation)();
252            if generation == group.last_seen.get() {
253                continue;
254            }
255            group.last_seen.set(generation);
256            for &(wid, level) in &group.bindings {
257                match level {
258                    BindingLevel::AccessibilityOnly => {
259                        a11y_dirty = true;
260                    }
261                    BindingLevel::RepaintOnly
262                    | BindingLevel::SubtreeRepaint
263                    | BindingLevel::Relayout
264                    | BindingLevel::Rebuild => {
265                        let entry = dirty_map.entry(wid).or_insert(level);
266                        *entry = promote_level(*entry, level);
267                    }
268                }
269            }
270        }
271        (dirty_map.into_iter().collect(), a11y_dirty)
272    }
273
274    /// Whether any bound source has advanced past what this registry
275    /// last acted on — i.e. whether a `flush_all_dirty` right now would
276    /// return anything.
277    ///
278    /// O(S) `u64` comparisons over unique sources, with no arena walk,
279    /// no rebuilds and no layout. Read-only: unlike the flush it does
280    /// NOT advance `last_seen`, so asking is free of consequence and can
281    /// be repeated.
282    ///
283    /// Exists because a poll-based design can otherwise only answer
284    /// "does this tree have pending reactive work?" by running the whole
285    /// reconcile pass. Cross-window schedulers care about that question
286    /// — see `teksilo_app::WindowManager::request_redraw_needing_render`.
287    pub fn any_dirty(&self) -> bool {
288        self.by_source
289            .borrow()
290            .values()
291            .any(|group| (group.generation)() != group.last_seen.get())
292    }
293
294    /// Visual-only flush. Wrapper around [`flush_all_dirty`] that
295    /// discards the accessibility flag — kept for tests that drive
296    /// the registry directly. Production code (the widget tree's
297    /// `process_state_changes`) calls `flush_all_dirty` so both
298    /// buckets stay coherent in one pass.
299    #[cfg(test)]
300    pub(crate) fn flush_dirty(&self) -> Vec<(WidgetId, BindingLevel)> {
301        self.flush_all_dirty().0
302    }
303
304    /// Accessibility-only flush. Wrapper around [`flush_all_dirty`].
305    /// Same caveat as [`flush_dirty`]: production code should call
306    /// `flush_all_dirty` once instead of pairing this with
307    /// `flush_dirty`, since both share one walk and one clear pass.
308    #[cfg(test)]
309    pub(crate) fn flush_accessibility_dirty(&self) -> bool {
310        self.flush_all_dirty().1
311    }
312}
313
314/// Priority order for visual binding levels — `Rebuild` dominates
315/// `Relayout` dominates `SubtreeRepaint` dominates `RepaintOnly`.
316/// `AccessibilityOnly` lives in its own bucket and is never compared
317/// against visual levels.
318///
319/// `SubtreeRepaint` is treated as strictly more work than
320/// `RepaintOnly` because it covers a wider area (one node vs. a whole
321/// subtree); when both happen on the same node `SubtreeRepaint` wins.
322fn promote_level(existing: BindingLevel, incoming: BindingLevel) -> BindingLevel {
323    use BindingLevel::*;
324    match (existing, incoming) {
325        (Rebuild, _) | (_, Rebuild) => Rebuild,
326        (Relayout, _) | (_, Relayout) => Relayout,
327        (SubtreeRepaint, _) | (_, SubtreeRepaint) => SubtreeRepaint,
328        (RepaintOnly, _) | (_, RepaintOnly) => RepaintOnly,
329        (AccessibilityOnly, AccessibilityOnly) => AccessibilityOnly,
330    }
331}
332
333fn is_a11y_only(level: BindingLevel) -> bool {
334    matches!(level, BindingLevel::AccessibilityOnly)
335}
336
337impl std::fmt::Debug for BindingRegistry {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        let by_source = self.by_source.borrow();
340        let total: usize = by_source.values().map(|g| g.bindings.len()).sum();
341        f.debug_struct("BindingRegistry")
342            .field("sources", &by_source.len())
343            .field("bindings", &total)
344            .finish()
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::signal::Signal;
352    use std::cell::Cell;
353    use std::rc::Rc;
354
355    /// A binding over a hand-driven generation counter, standing in for
356    /// a real signal's. Bump the counter to simulate a write.
357    fn make_binding(level: BindingLevel, generation: Rc<Cell<u64>>) -> Binding {
358        let read = {
359            let g = generation.clone();
360            Rc::new(move || g.get()) as Rc<dyn Fn() -> u64>
361        };
362        // WidgetId value doesn't matter for these tests; slotmap
363        // default gives us a well-formed id.
364        let id: WidgetId = slotmap::KeyData::from_ffi(1).into();
365        Binding {
366            widget_id: id,
367            level,
368            generation: read,
369            // Bindings sharing one counter share one source id, so
370            // passing the same `Rc` twice exercises the group path and
371            // passing a fresh one exercises distinct sources.
372            source_id: Rc::as_ptr(&generation) as *const () as usize,
373        }
374    }
375
376    #[test]
377    fn register_dedups_same_widget_same_signal_same_bucket() {
378        use crate::signal::Signal;
379        let reg = BindingRegistry::new();
380        let sig = Signal::new(0_i32);
381        let id: WidgetId = slotmap::KeyData::from_ffi(7).into();
382
383        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
384        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
385        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
386
387        assert_eq!(
388            reg.len(),
389            1,
390            "three identical bind_to calls must collapse to one entry"
391        );
392    }
393
394    #[test]
395    fn register_promotes_level_on_dedup() {
396        use crate::signal::Signal;
397        let reg = BindingRegistry::new();
398        let sig = Signal::new(0_i32);
399        let id: WidgetId = slotmap::KeyData::from_ffi(7).into();
400
401        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
402        sig.bind_to(id, &reg, BindingLevel::Relayout);
403        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
404
405        assert_eq!(reg.len(), 1, "dedup still collapses across calls");
406        // Signal must now be marked dirty so flush_dirty sees it.
407        sig.set(1);
408        let visual = reg.flush_dirty();
409        assert_eq!(visual.len(), 1);
410        assert_eq!(
411            visual[0].1,
412            BindingLevel::Relayout,
413            "the merged entry reflects the highest-priority visual level seen"
414        );
415    }
416
417    #[test]
418    fn register_does_not_collapse_a11y_and_visual_buckets() {
419        use crate::signal::Signal;
420        let reg = BindingRegistry::new();
421        let sig = Signal::new(0_i32);
422        let id: WidgetId = slotmap::KeyData::from_ffi(7).into();
423
424        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
425        sig.bind_to(id, &reg, BindingLevel::AccessibilityOnly);
426
427        assert_eq!(
428            reg.len(),
429            2,
430            "visual and a11y-only bindings live in distinct buckets"
431        );
432    }
433
434    #[test]
435    fn register_distinct_signals_remain_distinct() {
436        use crate::signal::Signal;
437        let reg = BindingRegistry::new();
438        let a = Signal::new(0_i32);
439        let b = Signal::new(0_i32);
440        let id: WidgetId = slotmap::KeyData::from_ffi(7).into();
441
442        a.bind_to(id, &reg, BindingLevel::Relayout);
443        b.bind_to(id, &reg, BindingLevel::Relayout);
444
445        assert_eq!(
446            reg.len(),
447            2,
448            "different signals must not collapse into one binding"
449        );
450    }
451
452    #[test]
453    fn flush_dirty_excludes_accessibility_only_from_visual_map() {
454        // AccessibilityOnly bindings flow through the a11y bucket of
455        // `flush_all_dirty`, never the visual map.
456        let reg = BindingRegistry::new();
457        let generation = Rc::new(Cell::new(0_u64));
458        reg.register(make_binding(
459            BindingLevel::AccessibilityOnly,
460            generation.clone(),
461        ));
462        generation.set(1);
463
464        let (visual, a11y_dirty) = reg.flush_all_dirty();
465        assert!(
466            visual.is_empty(),
467            "AccessibilityOnly must not appear in the visual dirty map"
468        );
469        assert!(a11y_dirty, "AccessibilityOnly binding must set a11y flag");
470        assert!(
471            !reg.any_dirty(),
472            "the flush advanced this registry's last-seen generation"
473        );
474    }
475
476    #[test]
477    fn flush_accessibility_dirty_returns_true_then_settles() {
478        let reg = BindingRegistry::new();
479        let generation = Rc::new(Cell::new(0_u64));
480        reg.register(make_binding(
481            BindingLevel::AccessibilityOnly,
482            generation.clone(),
483        ));
484        generation.set(1);
485
486        assert!(reg.flush_accessibility_dirty());
487        assert_eq!(
488            generation.get(),
489            1,
490            "the source generation is never reset by a flush — only the \
491             registry's own last-seen advances"
492        );
493        assert!(
494            !reg.flush_accessibility_dirty(),
495            "second drain returns false (nothing new)"
496        );
497    }
498
499    #[test]
500    fn flush_all_dirty_drains_visual_and_a11y_in_one_pass() {
501        // A Signal bound at both RepaintOnly and AccessibilityOnly
502        // shares one source group. A single `flush_all_dirty` call must
503        // surface both sides — under the old read-and-clear design this
504        // needed a deliberate "collect everything before clearing"
505        // dance; with generations there is nothing to consume.
506        let reg = BindingRegistry::new();
507        let shared = Rc::new(Cell::new(0_u64));
508        reg.register(make_binding(BindingLevel::RepaintOnly, shared.clone()));
509        reg.register(make_binding(
510            BindingLevel::AccessibilityOnly,
511            shared.clone(),
512        ));
513        shared.set(1);
514
515        let (visual, a11y_dirty) = reg.flush_all_dirty();
516        assert_eq!(visual.len(), 1, "visual binding must fire");
517        assert!(a11y_dirty, "a11y binding must fire from the same source");
518
519        let (visual, a11y_dirty) = reg.flush_all_dirty();
520        assert!(visual.is_empty() && !a11y_dirty, "second pass is clean");
521    }
522
523    // ─── Per-consumer dirty tracking (the cross-window fix) ──────────
524
525    #[test]
526    fn two_registries_on_one_source_each_see_the_change() {
527        // THE regression. Two independent `WidgetTree`s (each owning
528        // its own registry) bound to ONE shared Signal: a single write
529        // must be visible to BOTH, in either flush order. The previous
530        // `dirty: bool` lived on the signal and was cleared by whoever
531        // flushed first, so the second window silently — and
532        // permanently — skipped its binding.
533        let a = BindingRegistry::new();
534        let b = BindingRegistry::new();
535        let sig = Signal::new(0_i32);
536        let id: WidgetId = slotmap::KeyData::from_ffi(3).into();
537        sig.bind_to(id, &a, BindingLevel::Relayout);
538        sig.bind_to(id, &b, BindingLevel::Relayout);
539
540        sig.set(1);
541
542        assert_eq!(a.flush_dirty().len(), 1, "first registry to flush fires");
543        assert_eq!(
544            b.flush_dirty().len(),
545            1,
546            "and the second one fires too — the first flush consumed nothing"
547        );
548        assert!(
549            a.flush_dirty().is_empty(),
550            "neither re-fires without a write"
551        );
552        assert!(b.flush_dirty().is_empty());
553    }
554
555    #[test]
556    fn many_registries_on_one_source_all_see_every_change() {
557        // Same property at N > 2, and across successive writes, so a
558        // fix that merely swapped which single consumer wins would fail
559        // here.
560        let regs: Vec<BindingRegistry> = (0..4).map(|_| BindingRegistry::new()).collect();
561        let sig = Signal::new(0_i32);
562        let id: WidgetId = slotmap::KeyData::from_ffi(4).into();
563        for reg in &regs {
564            sig.bind_to(id, reg, BindingLevel::RepaintOnly);
565        }
566
567        for round in 1..=3 {
568            sig.set(round);
569            for (i, reg) in regs.iter().enumerate() {
570                assert_eq!(
571                    reg.flush_dirty().len(),
572                    1,
573                    "registry {i} missed the write in round {round}"
574                );
575            }
576        }
577    }
578
579    #[test]
580    fn a_registry_that_never_flushes_does_not_starve_the_others() {
581        // The asymmetric case: one window is minimised / never
582        // reconciles for many writes. Its backlog must neither block
583        // the others nor accumulate into more than one fire when it
584        // finally does flush — a generation compare collapses N missed
585        // writes into "you are behind", which is exactly right for a
586        // repaint.
587        let live = BindingRegistry::new();
588        let asleep = BindingRegistry::new();
589        let sig = Signal::new(0_i32);
590        let id: WidgetId = slotmap::KeyData::from_ffi(5).into();
591        sig.bind_to(id, &live, BindingLevel::RepaintOnly);
592        sig.bind_to(id, &asleep, BindingLevel::RepaintOnly);
593
594        for round in 1..=5 {
595            sig.set(round);
596            assert_eq!(live.flush_dirty().len(), 1, "live registry keeps up");
597        }
598
599        assert!(asleep.any_dirty(), "the sleeper is behind, and knows it");
600        assert_eq!(
601            asleep.flush_dirty().len(),
602            1,
603            "it catches up in one fire, not five"
604        );
605        assert!(!asleep.any_dirty());
606    }
607
608    #[test]
609    fn a_binding_registered_after_a_write_is_not_retroactively_dirty() {
610        // A widget that binds during `build()` has just read the current
611        // value, so it must not fire on the next flush for a write that
612        // predates it.
613        let reg = BindingRegistry::new();
614        let sig = Signal::new(0_i32);
615        let id: WidgetId = slotmap::KeyData::from_ffi(6).into();
616
617        sig.set(1);
618        sig.bind_to(id, &reg, BindingLevel::Relayout);
619
620        assert!(
621            reg.flush_dirty().is_empty(),
622            "registration seeds last-seen at the current generation"
623        );
624        sig.set(2);
625        assert_eq!(reg.flush_dirty().len(), 1, "but the NEXT write does fire");
626    }
627
628    #[test]
629    fn joining_an_existing_group_does_not_swallow_its_pending_change() {
630        // One widget rebuilding (and re-registering) must not seed a
631        // fresh last-seen for the whole group — the OTHER widgets bound
632        // to that source have not reconciled yet.
633        let reg = BindingRegistry::new();
634        let sig = Signal::new(0_i32);
635        let first: WidgetId = slotmap::KeyData::from_ffi(8).into();
636        let second: WidgetId = slotmap::KeyData::from_ffi(9).into();
637        sig.bind_to(first, &reg, BindingLevel::RepaintOnly);
638
639        sig.set(1);
640        // A second widget binds the same source after the write.
641        sig.bind_to(second, &reg, BindingLevel::RepaintOnly);
642
643        let dirty = reg.flush_dirty();
644        assert_eq!(
645            dirty.len(),
646            2,
647            "the group keeps its older last-seen, so the widget that had \
648             NOT yet reconciled still fires (the newcomer's extra repaint \
649             is the accepted cost)"
650        );
651    }
652
653    #[test]
654    fn any_dirty_is_read_only() {
655        let reg = BindingRegistry::new();
656        let sig = Signal::new(0_i32);
657        let id: WidgetId = slotmap::KeyData::from_ffi(10).into();
658        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
659
660        assert!(!reg.any_dirty(), "nothing written yet");
661        sig.set(1);
662        assert!(reg.any_dirty());
663        assert!(reg.any_dirty(), "asking twice must not consume the answer");
664        assert_eq!(reg.flush_dirty().len(), 1, "the flush still fires");
665        assert!(!reg.any_dirty());
666    }
667
668    #[test]
669    fn signal_bind_to_accessibility_only_propagates_via_registry() {
670        // End-to-end: a real Signal<T> bound at AccessibilityOnly
671        // fires the a11y flag from `flush_all_dirty` without
672        // appearing in the visual dirty map.
673        let reg = BindingRegistry::new();
674        let sig = Signal::new(0_u64);
675        let id: WidgetId = slotmap::KeyData::from_ffi(7).into();
676        sig.bind_to(id, &reg, BindingLevel::AccessibilityOnly);
677
678        // Fresh binding is not dirty yet.
679        let (visual, a11y) = reg.flush_all_dirty();
680        assert!(visual.is_empty());
681        assert!(!a11y);
682
683        sig.set(1);
684        let (visual, a11y) = reg.flush_all_dirty();
685        assert!(visual.is_empty(), "a11y flips must not leak to visual");
686        assert!(a11y);
687        // Subsequent drain is clean again.
688        assert!(!reg.flush_accessibility_dirty());
689    }
690
691    // ─── Source-indexed registry semantics ───────────────────────────
692
693    #[test]
694    fn phase4_register_dedup_same_source_same_widget() {
695        // Identical (widget, source, bucket) triples collapse to a
696        // single entry inside the source group — no double-fire.
697        use crate::signal::Signal;
698        let reg = BindingRegistry::new();
699        let sig = Signal::new(0_i32);
700        let id: WidgetId = slotmap::KeyData::from_ffi(11).into();
701
702        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
703        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
704        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
705
706        assert_eq!(reg.len(), 1, "three identical bind_to calls collapse");
707
708        sig.set(1);
709        let (visual, _a11y) = reg.flush_all_dirty();
710        assert_eq!(
711            visual.len(),
712            1,
713            "deduplicated binding must fire exactly once"
714        );
715    }
716
717    #[test]
718    fn phase4_one_widget_can_hold_visual_and_a11y_on_one_source() {
719        // (widget_id, source_id, AccessibilityOnly) is a separate
720        // bucket from (widget_id, source_id, visual). Both must coexist.
721        use crate::signal::Signal;
722        let reg = BindingRegistry::new();
723        let sig = Signal::new(0_i32);
724        let id: WidgetId = slotmap::KeyData::from_ffi(13).into();
725
726        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
727        sig.bind_to(id, &reg, BindingLevel::AccessibilityOnly);
728        assert_eq!(reg.len(), 2, "different buckets stay distinct");
729
730        sig.set(1);
731        let (visual, a11y) = reg.flush_all_dirty();
732        assert_eq!(visual.len(), 1);
733        assert!(a11y);
734    }
735
736    #[test]
737    fn phase4_one_signal_many_widgets_one_dirty_check() {
738        // Source group folds multiple widget bindings under one
739        // dirty closure — visible from the outside as: setting the
740        // signal once dirties N widgets in one flush.
741        use crate::signal::Signal;
742        let reg = BindingRegistry::new();
743        let sig = Signal::new(0_i32);
744        let ids: Vec<WidgetId> = (0..5)
745            .map(|i| slotmap::KeyData::from_ffi(20 + i).into())
746            .collect();
747        for id in &ids {
748            sig.bind_to(*id, &reg, BindingLevel::Relayout);
749        }
750        assert_eq!(reg.len(), 5);
751
752        sig.set(1);
753        let (visual, _a11y) = reg.flush_all_dirty();
754        assert_eq!(visual.len(), 5, "every binding on the source fires");
755    }
756
757    #[test]
758    fn phase4_level_promotion_preserved() {
759        // Re-registering at a higher level promotes; lower or equal
760        // is a no-op. The priority order must be preserved
761        // (Rebuild > Relayout > RepaintOnly).
762        use crate::signal::Signal;
763        let reg = BindingRegistry::new();
764        let sig = Signal::new(0_i32);
765        let id: WidgetId = slotmap::KeyData::from_ffi(33).into();
766
767        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
768        sig.bind_to(id, &reg, BindingLevel::Relayout);
769        sig.bind_to(id, &reg, BindingLevel::RepaintOnly); // shouldn't demote
770
771        sig.set(1);
772        let (visual, _) = reg.flush_all_dirty();
773        assert_eq!(visual.len(), 1);
774        assert_eq!(visual[0].1, BindingLevel::Relayout);
775    }
776
777    #[test]
778    fn phase4_unregister_for_widget_drops_empty_groups_at_end_of_pass() {
779        // After unregistering the only widget on a source, the group
780        // is reclaimed — but at the END of the reconcile pass, not
781        // instantly, so a rebuild can re-register into it first (see
782        // the next test). Source slots are still reused without memory
783        // growth across rebuilds.
784        use crate::signal::Signal;
785        let reg = BindingRegistry::new();
786        let sig = Signal::new(0_i32);
787        let id: WidgetId = slotmap::KeyData::from_ffi(44).into();
788
789        sig.bind_to(id, &reg, BindingLevel::RepaintOnly);
790        assert_eq!(reg.by_source.borrow().len(), 1);
791
792        reg.unregister_for_widget(id);
793        assert_eq!(reg.len(), 0, "the binding itself is gone immediately");
794
795        reg.reclaim_empty_groups();
796        assert_eq!(
797            reg.by_source.borrow().len(),
798            0,
799            "empty groups must be reclaimed by the end-of-pass sweep"
800        );
801    }
802
803    #[test]
804    fn a_rebuild_does_not_swallow_a_write_its_own_build_made() {
805        // A widget's rebuild is unregister → `build()` → re-register,
806        // and `build()` commonly writes a signal BEFORE re-binding it
807        // (`SceneView` bumps `reconcile_dirty` from the item-change
808        // observer that its dynamic-bounds refresh fires, well above
809        // its own `bind_to` call). If dropping the emptied group also
810        // dropped its `last_seen`, re-registration would re-seed at the
811        // post-write generation and that write would vanish — the
812        // widget's follow-up rebuild would simply never be armed.
813        use crate::signal::Signal;
814        let reg = BindingRegistry::new();
815        let sig = Signal::new(0_i32);
816        let id: WidgetId = slotmap::KeyData::from_ffi(45).into();
817        sig.bind_to(id, &reg, BindingLevel::Rebuild);
818        // The pass that decided to rebuild already drained the flush.
819        sig.set(1);
820        assert_eq!(reg.flush_dirty().len(), 1);
821
822        // ── the rebuild ──
823        reg.unregister_for_widget(id);
824        sig.set(2); // `build()` writes...
825        sig.bind_to(id, &reg, BindingLevel::Rebuild); // ...then re-binds
826        reg.reclaim_empty_groups(); // end of pass
827
828        assert_eq!(
829            reg.flush_dirty().len(),
830            1,
831            "the write made during build() must still arm the next rebuild"
832        );
833    }
834
835    #[test]
836    fn a_destroyed_widgets_group_is_still_reclaimed() {
837        // The other side of deferring: a widget that unregisters and
838        // does NOT come back must not leave its group (and its strong
839        // reference to the source signal) behind.
840        use crate::signal::Signal;
841        let reg = BindingRegistry::new();
842        let sig = Signal::new(0_i32);
843        let gone: WidgetId = slotmap::KeyData::from_ffi(46).into();
844        let stays: WidgetId = slotmap::KeyData::from_ffi(47).into();
845        sig.bind_to(gone, &reg, BindingLevel::RepaintOnly);
846        sig.bind_to(stays, &reg, BindingLevel::RepaintOnly);
847
848        reg.unregister_for_widget(gone);
849        reg.reclaim_empty_groups();
850        assert_eq!(
851            reg.by_source.borrow().len(),
852            1,
853            "a group with a surviving binding is kept"
854        );
855
856        reg.unregister_for_widget(stays);
857        reg.reclaim_empty_groups();
858        assert_eq!(reg.by_source.borrow().len(), 0, "now it is reclaimed");
859    }
860}