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, ®, BindingLevel::RepaintOnly);
384 sig.bind_to(id, ®, BindingLevel::RepaintOnly);
385 sig.bind_to(id, ®, 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, ®, BindingLevel::RepaintOnly);
402 sig.bind_to(id, ®, BindingLevel::Relayout);
403 sig.bind_to(id, ®, 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, ®, BindingLevel::RepaintOnly);
425 sig.bind_to(id, ®, 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, ®, BindingLevel::Relayout);
443 b.bind_to(id, ®, 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 ®s {
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, ®, 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, ®, BindingLevel::RepaintOnly);
638
639 sig.set(1);
640 // A second widget binds the same source after the write.
641 sig.bind_to(second, ®, 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, ®, 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, ®, 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, ®, BindingLevel::RepaintOnly);
703 sig.bind_to(id, ®, BindingLevel::RepaintOnly);
704 sig.bind_to(id, ®, 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, ®, BindingLevel::RepaintOnly);
727 sig.bind_to(id, ®, 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, ®, 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, ®, BindingLevel::RepaintOnly);
768 sig.bind_to(id, ®, BindingLevel::Relayout);
769 sig.bind_to(id, ®, 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, ®, 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, ®, 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, ®, 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, ®, BindingLevel::RepaintOnly);
846 sig.bind_to(stays, ®, 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}