Skip to main content

telar_ui_tree/
segment.rs

1//! Fine-grained reactive segments (T-1.1 / F010).
2//!
3//! Today the whole app is one effect that re-runs `app.root().view()` — recursing every component —
4//! on any tracked signal, so a single hover/animation costs O(tree). A `Segment` instead mounts a
5//! component with its OWN effect that flattens only that component's `view()` into its own command
6//! buffer. A parent references a child via `RenderNode::Boundary` (a cheap `Rc` clone) instead of
7//! calling `child.view()`, so the parent's effect never re-runs the child, and a child's signal
8//! change re-runs only the child. The flat command list is composed lazily at collect time.
9
10use std::cell::{Cell, Ref, RefCell};
11use std::rc::Rc;
12
13use geometry_core::Rect;
14use reactive_core::{Effect, RwSignal, effect, signal};
15use renderer_core::DrawCommand;
16
17use crate::component::Component;
18use crate::render_node::RenderNode;
19
20reactive_core::surface_local! {
21    /// A per-surface force-tick signal, subscribed by every segment on that surface. Bumped after each event
22    /// so segments re-run even when their view reads signals the effect cannot auto-track — notably the
23    /// binary-side root segment under hot reload, whose view reads signals created in the app dylib
24    /// (cross-boundary tracking is unreliable, so the force-tick makes it re-read current values, e.g. the
25    /// real viewport). Per-surface so one surface's event does not force-render the others; a global change
26    /// (theme) re-renders all surfaces via its own shared signal, not this tick.
27    slot FORCE_TICK: RwSignal<u64> = signal(0);
28    access with_force_tick, with_force_tick_ref;
29    context ForceTickContext, ForceTickGuard;
30}
31
32/// The active surface's force-tick signal (cloned out of the slot so callers never hold the slot borrow
33/// across a `.set()`, whose flush would re-enter the slot to read the tick).
34fn force_tick() -> RwSignal<u64> {
35    with_force_tick_ref(|s| s.clone())
36}
37
38/// Forces every segment subscribed to the active surface's `FORCE_TICK` to re-run on the next flush.
39pub fn bump_force_ticks() {
40    let tick = force_tick();
41    tick.set(tick.peek().wrapping_add(1));
42}
43
44/// (index into `own` where the child's commands splice, child segment, whether inside an `Overlay`).
45type ChildSlots = Vec<(usize, Rc<Segment>, bool)>;
46
47/// One entry on the flatten work stack: a node to process, or a marker that closes the current overlay
48/// region (pushed after an `Overlay`'s children so the region's end position is recorded once they are all
49/// flattened). Kept private to the flatten walk. The `Node` variant dwarfs `EndOverlay`, but boxing it
50/// would add an allocation on the hot flatten path for no real memory win (the stack is short-lived).
51#[allow(clippy::large_enum_variant)]
52enum Step {
53    Node(RenderNode),
54    EndOverlay,
55}
56
57pub struct Segment {
58    // Human-readable widget type name, captured at mount for the devtools tree inspector.
59    name: &'static str,
60    // This component's own flattened commands, excluding children (spliced in at compose time).
61    own_commands: Rc<RefCell<Vec<DrawCommand>>>,
62    // Parallel to `own_commands`: whether each command belongs to an `Overlay` region (hoisted to the top
63    // layer at compose time). Same length as `own_commands`.
64    own_overlay: Rc<RefCell<Vec<bool>>>,
65    // Child splice points in emission order (see [`ChildSlots`]).
66    child_slots: Rc<RefCell<ChildSlots>>,
67    // Set by the effect when this segment's output changes; cleared when composed. This lives on the Segment object (not a thread-local) so it works across the hot-reload dylib boundary: a dylib segment's effect sets it and the binary's compose/dirty-check read the same `Cell` — whereas a thread-local generation would be a separate duplicated instance per side.
68    is_dirty: Rc<Cell<bool>>,
69    _effect: Effect,
70}
71
72/// A node emitted by [`Segment::walk`]: one mounted component, with its pre-order id, widget name,
73/// nesting depth, and the bounding rect of its own draw commands unioned with all descendants'.
74#[derive(Clone, Debug)]
75pub struct SegmentNodeInfo {
76    pub id: u64,
77    pub name: &'static str,
78    pub depth: usize,
79    pub rect: Rect,
80}
81
82/// Unions two rects, treating any zero/negative-area rect as empty so `empty ∪ r == r` — a leaf with
83/// no draw commands must not drag its parent's box to the origin.
84fn union_nonempty(a: Rect, b: Rect) -> Rect {
85    let a_empty = a.width <= 0.0 || a.height <= 0.0;
86    let b_empty = b.width <= 0.0 || b.height <= 0.0;
87    match (a_empty, b_empty) {
88        (true, _) => b,
89        (_, true) => a,
90        _ => a.union(b),
91    }
92}
93
94impl Segment {
95    /// Mounts `component` as a reactive segment with its own effect: the effect re-runs (and bumps
96    /// the thread-local render generation) only when a signal read by this component's `view()`
97    /// changes — so a leaf's signal change costs O(this component), not O(tree).
98    pub fn mount<C: Component + 'static>(component: C) -> Rc<Segment> {
99        let name = component.debug_name();
100        let component = Rc::new(RefCell::new(component));
101        Self::mount_fn_named(name, move || component.try_borrow().ok().map(|c| c.view()))
102    }
103
104    /// As `mount`, but takes an already-shared component so a parent can also hold it for event
105    /// dispatch. The view path borrows it immutably; events borrow it mutably. These normally never
106    /// overlap (dispatch is batched, so flushes happen after it), but a re-entrant flush during
107    /// dispatch would otherwise panic, so the render is skipped when the component is borrowed.
108    pub fn mount_dyn(component: Rc<RefCell<dyn Component>>) -> Rc<Segment> {
109        let name = component
110            .try_borrow()
111            .map(|c| c.debug_name())
112            .unwrap_or("Component");
113        Self::mount_fn_named(name, move || component.try_borrow().ok().map(|c| c.view()))
114    }
115
116    /// Core mount: `render` produces this segment's `RenderNode`, or `None` to keep the previous
117    /// render unchanged (used when the underlying widget is mid event-dispatch and cannot be
118    /// borrowed — borrowing it then would panic, so we leave the last frame's commands in place and
119    /// a later flush re-runs us).
120    pub fn mount_fn(render: impl Fn() -> Option<RenderNode> + 'static) -> Rc<Segment> {
121        Self::mount_fn_named("Component", render)
122    }
123
124    /// As `mount_fn`, but records a human-readable widget `name` for the devtools tree inspector.
125    pub fn mount_fn_named(
126        name: &'static str,
127        render: impl Fn() -> Option<RenderNode> + 'static,
128    ) -> Rc<Segment> {
129        let own_commands: Rc<RefCell<Vec<DrawCommand>>> = Default::default();
130        let own_overlay: Rc<RefCell<Vec<bool>>> = Default::default();
131        let child_slots: Rc<RefCell<ChildSlots>> = Default::default();
132        let stack: Rc<RefCell<Vec<Step>>> = Default::default();
133        // Starts dirty so the first compose includes this segment.
134        let is_dirty = Rc::new(Cell::new(true));
135
136        let own_c = Rc::clone(&own_commands);
137        let overlay_c = Rc::clone(&own_overlay);
138        let slots_c = Rc::clone(&child_slots);
139        let dirty_c = Rc::clone(&is_dirty);
140        let _effect = effect(move || {
141            force_tick().get(); // re-run on force-tick (cross-boundary inputs / hot reload)
142            let Some(node) = render() else {
143                return; // widget is mutably borrowed (event dispatch); keep last render
144            };
145            let mut own = own_c.borrow_mut();
146            let mut overlay = overlay_c.borrow_mut();
147            let mut stk = stack.borrow_mut();
148            let mut new_slots: ChildSlots = Vec::new();
149            let own_changed =
150                flatten_segment(node, &mut own, &mut overlay, &mut new_slots, &mut stk);
151            drop(stk);
152            drop(own);
153            drop(overlay);
154            let mut slots = slots_c.borrow_mut();
155            // The boundary structure also changes the output, even if own commands are identical.
156            let slots_changed = slots.len() != new_slots.len()
157                || slots
158                    .iter()
159                    .zip(new_slots.iter())
160                    .any(|(a, b)| a.0 != b.0 || a.2 != b.2 || !Rc::ptr_eq(&a.1, &b.1));
161            if own_changed || slots_changed {
162                *slots = new_slots;
163                dirty_c.set(true);
164            }
165        });
166
167        Rc::new(Segment {
168            name,
169            own_commands,
170            own_overlay,
171            child_slots,
172            is_dirty,
173            _effect,
174        })
175    }
176
177    /// A reference to this segment for a parent's `view()`. Cheap: clones an `Rc`, does not flatten.
178    pub fn boundary(self: &Rc<Self>) -> RenderNode {
179        RenderNode::Boundary {
180            child: Rc::clone(self),
181        }
182    }
183
184    /// Human-readable widget type name captured at mount.
185    pub fn name(&self) -> &'static str {
186        self.name
187    }
188
189    /// Emits this segment's subtree in pre-order (parent before children) into `out`. See
190    /// [`Segment::collect`] for how ids, depth, and bounding rects are computed.
191    pub fn walk(&self, out: &mut Vec<SegmentNodeInfo>) {
192        self.collect(0, out);
193    }
194
195    /// Recursively appends one [`SegmentNodeInfo`] per segment in pre-order. `id` is the pre-order
196    /// index, so a consumer can select by both row index and canvas hit-test. Returns this subtree's
197    /// bounding rect (own draw commands unioned with all descendants') so a container highlights its
198    /// whole subtree, not just its own commands.
199    fn collect(&self, depth: usize, out: &mut Vec<SegmentNodeInfo>) -> Rect {
200        let idx = out.len();
201        // Push before recursing so the parent precedes its children and keeps the pre-order id.
202        out.push(SegmentNodeInfo {
203            id: idx as u64,
204            name: self.name,
205            depth,
206            rect: Rect::default(),
207        });
208
209        let mut bounds = Rect::default();
210        for cmd in self.own_commands.borrow().iter() {
211            let rect = match cmd {
212                DrawCommand::Rect { rect, .. } => *rect,
213                DrawCommand::Text { rect, .. } => *rect,
214                DrawCommand::Image { rect, .. } => *rect,
215                DrawCommand::PushClip { rect, .. } => *rect,
216                _ => continue,
217            };
218            bounds = union_nonempty(bounds, rect);
219        }
220
221        for (_, child, _) in self.child_slots.borrow().iter() {
222            bounds = union_nonempty(bounds, child.collect(depth + 1, out));
223        }
224
225        out[idx].rect = bounds;
226        bounds
227    }
228}
229
230/// Like `flatten_view`, but `RenderNode::Boundary` records a child-splice point instead of emitting
231/// the child's commands. Returns whether the own command list changed in place.
232fn flatten_segment(
233    root: RenderNode,
234    out: &mut Vec<DrawCommand>,
235    overlay: &mut Vec<bool>,
236    slots: &mut ChildSlots,
237    stack: &mut Vec<Step>,
238) -> bool {
239    stack.clear();
240    stack.push(Step::Node(root));
241    let mut pos: usize = 0;
242    let mut changed = false;
243    // Nesting depth of `Overlay` regions; > 0 means the commands/children emitted now are hoisted content.
244    let mut overlay_depth: usize = 0;
245    // Rebuilt fresh each flatten (parallel to `out`), then compared with the stored flags to detect a
246    // pure overlay-membership change (same commands, different layering).
247    let mut new_overlay: Vec<bool> = Vec::with_capacity(out.len());
248
249    macro_rules! emit_command {
250        ($command:expr) => {{
251            let command = $command;
252            if pos < out.len() {
253                if out[pos] != command {
254                    out[pos] = command;
255                    changed = true;
256                }
257            } else {
258                out.push(command);
259                changed = true;
260            }
261            new_overlay.push(overlay_depth > 0);
262            pos += 1;
263        }};
264    }
265
266    while let Some(step) = stack.pop() {
267        let node = match step {
268            Step::EndOverlay => {
269                overlay_depth -= 1;
270                continue;
271            }
272            Step::Node(node) => node,
273        };
274        match node {
275            RenderNode::Empty => {}
276            RenderNode::Primitive(cmd) => emit_command!(cmd),
277            RenderNode::Group { children } => {
278                for child in children.into_iter().rev() {
279                    stack.push(Step::Node(child));
280                }
281            }
282            RenderNode::Transform { matrix, children } => {
283                stack.push(Step::Node(RenderNode::Primitive(DrawCommand::PopMatrix)));
284                for child in children.into_iter().rev() {
285                    stack.push(Step::Node(child));
286                }
287                emit_command!(DrawCommand::PushMatrix { matrix });
288            }
289            RenderNode::Clip {
290                rect,
291                radius,
292                children,
293            } => {
294                stack.push(Step::Node(RenderNode::Primitive(DrawCommand::PopClip)));
295                for child in children.into_iter().rev() {
296                    stack.push(Step::Node(child));
297                }
298                emit_command!(DrawCommand::PushClip { rect, radius });
299            }
300            RenderNode::Layer {
301                opacity,
302                backdrop_blur,
303                children,
304            } => {
305                stack.push(Step::Node(RenderNode::Primitive(DrawCommand::PopLayer)));
306                for child in children.into_iter().rev() {
307                    stack.push(Step::Node(child));
308                }
309                emit_command!(DrawCommand::PushLayer {
310                    opacity,
311                    backdrop_blur
312                });
313            }
314            // Everything emitted until the matching EndOverlay marker is overlay content (hoisted at compose).
315            RenderNode::Overlay { children } => {
316                overlay_depth += 1;
317                stack.push(Step::EndOverlay);
318                for child in children.into_iter().rev() {
319                    stack.push(Step::Node(child));
320                }
321            }
322            // The child's commands are owned by its own segment; record where they splice in, plus whether
323            // this splice point sits inside an overlay region.
324            RenderNode::Boundary { child } => slots.push((pos, child, overlay_depth > 0)),
325        }
326    }
327
328    if pos != out.len() {
329        out.truncate(pos);
330        changed = true;
331    }
332    if *overlay != new_overlay {
333        *overlay = new_overlay;
334        changed = true;
335    }
336    changed
337}
338
339/// Lazily composes a segment subtree into a flat command list, splicing each child's current
340/// commands at its recorded position. O(total commands) but only cheap clones — the expensive
341/// `view()` + flatten already ran (per segment) and is skipped for unchanged segments.
342/// Composes a segment subtree into `out`, routing any command that belongs to an `Overlay` region into
343/// `overlay_out` instead — so overlays land at the end of the final list (drawn on top, free of any
344/// ancestor clip/transform). `in_overlay` propagates that state into child segments spliced within an
345/// overlay. See [`SegmentRoot::commands`] for the final `out ++ overlay_out` concatenation.
346pub(crate) fn compose_into(
347    seg: &Segment,
348    out: &mut Vec<DrawCommand>,
349    overlay_out: &mut Vec<DrawCommand>,
350    in_overlay: bool,
351) {
352    seg.is_dirty.set(false);
353    let own_commands = seg.own_commands.borrow();
354    let own_overlay = seg.own_overlay.borrow();
355    let slots = seg.child_slots.borrow();
356    let mut si = 0;
357    for (i, cmd) in own_commands.iter().enumerate() {
358        while si < slots.len() && slots[si].0 == i {
359            compose_into(&slots[si].1, out, overlay_out, in_overlay || slots[si].2);
360            si += 1;
361        }
362        if in_overlay || own_overlay.get(i).copied().unwrap_or(false) {
363            overlay_out.push(cmd.clone());
364        } else {
365            out.push(cmd.clone());
366        }
367    }
368    while si < slots.len() {
369        compose_into(&slots[si].1, out, overlay_out, in_overlay || slots[si].2);
370        si += 1;
371    }
372}
373
374/// Whether any segment in the subtree has changed since the last compose. O(segments) — cheaper than
375/// a full O(commands) recompose, so it gates whether a recompose is needed.
376fn any_dirty(seg: &Segment) -> bool {
377    if seg.is_dirty.get() {
378        return true;
379    }
380    seg.child_slots
381        .borrow()
382        .iter()
383        .any(|(_, child, _)| any_dirty(child))
384}
385
386/// Top-level holder for a segment tree (analog of `ComponentList`): exposes the composed commands.
387/// Change detection uses per-segment dirty flags (shared across the hot-reload boundary) rather than
388/// a thread-local generation, which would be duplicated per side.
389pub struct SegmentRoot {
390    root: Rc<Segment>,
391    cached: RefCell<Vec<DrawCommand>>,
392    // Bumped each time the composed output is rebuilt; consumers use it for an O(1) "did content change" compare (e.g. the HW idle-blit).
393    compose_generation: Cell<u64>,
394    cache_valid: Cell<bool>,
395}
396
397impl SegmentRoot {
398    pub fn mount<C: Component + 'static>(component: C) -> Self {
399        Self::from_segment(Segment::mount(component))
400    }
401
402    pub fn from_segment(root: Rc<Segment>) -> Self {
403        SegmentRoot {
404            root,
405            cached: RefCell::new(Vec::new()),
406            compose_generation: Cell::new(0),
407            cache_valid: Cell::new(false),
408        }
409    }
410
411    pub fn generation(&self) -> u64 {
412        self.compose_generation.get()
413    }
414
415    /// Emits the whole segment tree in pre-order for the devtools inspector. See [`Segment::walk`].
416    pub fn walk(&self, out: &mut Vec<SegmentNodeInfo>) {
417        self.root.walk(out);
418    }
419
420    /// Whether any segment changed since the last `commands()` (which clears the dirty flags).
421    pub fn is_dirty(&self) -> bool {
422        !self.cache_valid.get() || any_dirty(&self.root)
423    }
424
425    pub fn commands(&self) -> Ref<'_, Vec<DrawCommand>> {
426        if !self.cache_valid.get() || any_dirty(&self.root) {
427            let mut cached = self.cached.borrow_mut();
428            cached.clear();
429            // Overlay content is routed aside during compose, then appended so it draws on top of (and
430            // outside any clip of) the main tree.
431            let mut overlay: Vec<DrawCommand> = Vec::new();
432            compose_into(&self.root, &mut cached, &mut overlay, false); // clears dirty flags as it walks
433            cached.extend(overlay);
434            drop(cached);
435            self.compose_generation
436                .set(self.compose_generation.get().wrapping_add(1));
437            self.cache_valid.set(true);
438        }
439        self.cached.borrow()
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use geometry_core::Rect;
446    use reactive_core::{RwSignal, signal};
447    use renderer_core::{Color, RectStyle, ShapeStyle};
448
449    use super::*;
450
451    fn rect(x: f32) -> RenderNode {
452        RenderNode::rect(
453            Rect::new(x, 0.0, 10.0, 10.0),
454            RectStyle::default().with_fill(Color::BLACK),
455        )
456    }
457
458    struct Leaf {
459        x: RwSignal<f32>,
460    }
461    impl Component for Leaf {
462        fn view(&self) -> RenderNode {
463            RenderNode::group([rect(self.x.get()), rect(self.x.get() + 5.0)])
464        }
465    }
466
467    struct Parent {
468        children: Vec<Rc<Segment>>,
469    }
470    impl Component for Parent {
471        fn view(&self) -> RenderNode {
472            RenderNode::group(self.children.iter().map(|s| s.boundary()))
473        }
474    }
475
476    struct Nested;
477    impl Component for Nested {
478        fn view(&self) -> RenderNode {
479            RenderNode::group([
480                rect(0.0),
481                RenderNode::group([rect(1.0), RenderNode::Empty, RenderNode::group([rect(2.0)])]),
482                rect(3.0),
483            ])
484        }
485    }
486
487    #[test]
488    fn flatten_nested_groups_and_empties() {
489        let root = SegmentRoot::mount(Nested);
490        // 4 rects; Empty and nested groups contribute nothing structural.
491        assert_eq!(root.commands().len(), 4);
492    }
493
494    #[test]
495    fn composes_children_in_order() {
496        let a = signal(0.0f32);
497        let b = signal(100.0f32);
498        let (sa, sb) = (a.clone(), b.clone());
499        let children = vec![
500            Segment::mount(Leaf { x: sa }),
501            Segment::mount(Leaf { x: sb }),
502        ];
503        let root = SegmentRoot::mount(Parent { children });
504        // 2 children × 2 rects each.
505        assert_eq!(root.commands().len(), 4);
506    }
507
508    fn cmd_x(c: &DrawCommand) -> f32 {
509        match c {
510            DrawCommand::Rect { rect, .. } => rect.x,
511            _ => -1.0,
512        }
513    }
514
515    struct WithOverlay;
516    impl Component for WithOverlay {
517        fn view(&self) -> RenderNode {
518            RenderNode::group([rect(1.0), RenderNode::overlay([rect(2.0)]), rect(3.0)])
519        }
520    }
521
522    #[test]
523    fn overlay_hoists_to_end() {
524        let root = SegmentRoot::mount(WithOverlay);
525        let cmds = root.commands();
526        let xs: Vec<f32> = cmds.iter().map(cmd_x).collect();
527        // The overlay's rect(2) is emitted between rect(1) and rect(3) but composes last (drawn on top).
528        assert_eq!(xs, vec![1.0, 3.0, 2.0]);
529    }
530
531    struct OverlayParent {
532        child: Rc<Segment>,
533    }
534    impl Component for OverlayParent {
535        fn view(&self) -> RenderNode {
536            RenderNode::group([rect(1.0), RenderNode::overlay([self.child.boundary()])])
537        }
538    }
539
540    #[test]
541    fn overlay_hoists_child_segment() {
542        // An overlay whose content is a child segment: the child's commands must hoist too.
543        let child = Segment::mount(Leaf { x: signal(9.0) }); // emits rect(9), rect(14)
544        let root = SegmentRoot::mount(OverlayParent { child });
545        let cmds = root.commands();
546        let xs: Vec<f32> = cmds.iter().map(cmd_x).collect();
547        assert_eq!(xs, vec![1.0, 9.0, 14.0]);
548    }
549
550    #[test]
551    fn child_change_updates_output_without_parent_rerun() {
552        let a = signal(0.0f32);
553        let sa = a.clone();
554        let children = vec![Segment::mount(Leaf { x: sa })];
555        let root = SegmentRoot::mount(Parent { children });
556        let g0 = root.generation();
557        let first_x = match &root.commands()[0] {
558            DrawCommand::Rect { rect, .. } => rect.x,
559            _ => unreachable!(),
560        };
561        assert_eq!(first_x, 0.0);
562
563        a.set(42.0);
564        assert_ne!(root.generation(), g0, "child change must bump generation");
565        let new_x = match &root.commands()[0] {
566            DrawCommand::Rect { rect, .. } => rect.x,
567            _ => unreachable!(),
568        };
569        assert_eq!(new_x, 42.0, "composed output reflects the child update");
570    }
571
572    struct MemoLeaf {
573        double: reactive_core::Memo<i32>,
574    }
575    impl Component for MemoLeaf {
576        fn view(&self) -> RenderNode {
577            rect(self.double.get() as f32)
578        }
579    }
580
581    #[test]
582    fn signal_dependent_segment_updates_with_runner_batching() {
583        use reactive_core::{begin_batch, end_batch};
584        let a = signal(0.0f32);
585        let sa = a.clone();
586        let root = SegmentRoot::mount(Leaf { x: sa });
587        assert_eq!(animated_rect_x(&root), 0.0);
588        begin_batch();
589        a.set(42.0);
590        end_batch();
591        begin_batch();
592        let mid = animated_rect_x(&root);
593        end_batch();
594        assert_eq!(
595            mid, 42.0,
596            "signal-reading segment must reflect the batched set"
597        );
598    }
599
600    // Regression probe for the sandbox counter's frozen "Double:" memo: replicates the runner's exact batch bracketing (new_events begin → on_event set → handled end/begin flush → commands → about_to_wait end) around a memo-reading segment.
601    #[test]
602    fn memo_dependent_segment_updates_with_runner_batching() {
603        use reactive_core::{begin_batch, end_batch, memo};
604        let count = signal(0i32);
605        let count_mv = count.clone();
606        let double = memo(move || count_mv.get() * 2);
607        let root = SegmentRoot::mount(MemoLeaf {
608            double: double.clone(),
609        });
610        assert_eq!(animated_rect_x(&root), 0.0);
611
612        begin_batch();
613        count.set(3);
614        end_batch();
615        begin_batch();
616        let mid = animated_rect_x(&root);
617        end_batch();
618        assert_eq!(
619            mid, 6.0,
620            "memo-reading segment must reflect the flushed memo"
621        );
622    }
623
624    // A widget whose view() color tracks `theme` and whose on_event writes `sel` — like a nav button
625    // reading the theme and flipping its own hover/selection state on a pointer event.
626    struct ThemedButton {
627        theme: RwSignal<f32>,
628        sel: RwSignal<i32>,
629    }
630    impl Component for ThemedButton {
631        fn view(&self) -> RenderNode {
632            let c = self.theme.get(); // subscribe to theme
633            self.sel.get(); // subscribe to sel
634            RenderNode::rect(
635                Rect::new(0.0, 0.0, 10.0, 10.0),
636                RectStyle::default().with_fill(Color::rgba(c, c, c, 1.0)),
637            )
638        }
639        fn on_event(&mut self, _event: &platform_core::Event) -> crate::component::EventResult {
640            self.sel.update(|n| *n += 1); // a handler write, like is_hovered/selected
641            crate::component::EventResult::Handled
642        }
643    }
644
645    fn first_rect_r(root: &SegmentRoot) -> f32 {
646        match &root.commands()[0] {
647            DrawCommand::Rect { style, .. } => style.fill.unwrap().solid_color().r,
648            _ => unreachable!(),
649        }
650    }
651
652    // A segment must keep its reactive subscriptions across event dispatch. The one hard invariant that
653    // guarantees it: dispatch must be BATCHED, so a signal written by a handler flushes only after the
654    // widget's borrow is released. If dispatch runs UNBATCHED, the write flushes synchronously while the
655    // widget is still borrowed — the segment's effect can't borrow it to re-render, skips, and drops its
656    // theme subscription (the hot-reload theme-freeze: the app dylib's runtime was never batched). This
657    // pins both halves: unbatched loses the subscription; batched preserves it.
658    #[test]
659    fn dispatch_must_be_batched_or_segment_drops_subscriptions() {
660        use reactive_core::{batch, signal};
661
662        // Unbatched dispatch: the handler's write flushes mid-borrow → subscription to `theme` is lost.
663        {
664            let theme = signal(0.2f32);
665            let sel = signal(0i32);
666            let widget = Rc::new(RefCell::new(ThemedButton {
667                theme: theme.clone(),
668                sel: sel.clone(),
669            }));
670            let render = {
671                let w = Rc::clone(&widget);
672                move || w.try_borrow().ok().map(|c| c.view())
673            };
674            let root = SegmentRoot::from_segment(Segment::mount_fn(render));
675            assert!((first_rect_r(&root) - 0.2).abs() < 1e-6);
676
677            widget
678                .borrow_mut()
679                .on_event(&platform_core::Event::CursorLeft); // UNBATCHED write mid-borrow
680            theme.set(0.9);
681            assert!(
682                (first_rect_r(&root) - 0.2).abs() < 1e-6,
683                "unbatched dispatch must drop the theme subscription (frozen at old value)"
684            );
685        }
686
687        // Batched dispatch (what the fix guarantees for the app dylib's runtime): subscription is preserved.
688        {
689            let theme = signal(0.2f32);
690            let sel = signal(0i32);
691            let widget = Rc::new(RefCell::new(ThemedButton {
692                theme: theme.clone(),
693                sel: sel.clone(),
694            }));
695            let render = {
696                let w = Rc::clone(&widget);
697                move || w.try_borrow().ok().map(|c| c.view())
698            };
699            let root = SegmentRoot::from_segment(Segment::mount_fn(render));
700            assert!((first_rect_r(&root) - 0.2).abs() < 1e-6);
701
702            batch(|| {
703                widget
704                    .borrow_mut()
705                    .on_event(&platform_core::Event::CursorLeft)
706            });
707            theme.set(0.9);
708            assert!(
709                (first_rect_r(&root) - 0.9).abs() < 1e-6,
710                "batched dispatch must preserve the theme subscription (tracks new value)"
711            );
712        }
713    }
714
715    struct AnimatedLeaf {
716        x: motion_core::Animated<f32>,
717    }
718    impl Component for AnimatedLeaf {
719        fn view(&self) -> RenderNode {
720            rect(self.x.get())
721        }
722    }
723
724    fn animated_rect_x(root: &SegmentRoot) -> f32 {
725        match &root.commands()[0] {
726            DrawCommand::Rect { rect, .. } => rect.x,
727            _ => unreachable!(),
728        }
729    }
730
731    // T-5.2: a segment reading `Animated::get()` must see the ticker's interpolated value in the
732    // SAME `commands()` call once `motion_core::tick` has run — mirroring the runner, which flushes
733    // right after tick() so tree.commands() reflects the tick within one frame (docs/animations.md
734    // "Ticker integration in the runner"). No sleeps: a fixed base `Instant` advanced by explicit
735    // `Duration`s drives the tween deterministically.
736    #[test]
737    fn animated_get_reflects_tick_in_commands_and_settles() {
738        use std::time::{Duration, Instant};
739
740        // Isolate this test's ticker state: the registry is thread-local and other tests on a
741        // reused libtest thread must not leak active animations into this one (mirrors the
742        // `fresh()` helper in motion-core's own tests).
743        motion_core::reset();
744        motion_core::set_scale(1.0);
745
746        let anim = motion_core::Animated::new(
747            0.0f32,
748            motion_core::tween(Duration::from_millis(100), motion_core::Easing::Linear),
749        );
750        let root = SegmentRoot::mount(AnimatedLeaf { x: anim.clone() });
751
752        // Baseline compose at the resting value.
753        assert_eq!(animated_rect_x(&root), 0.0);
754        let g0 = root.generation();
755
756        anim.retarget(10.0);
757        assert!(
758            motion_core::has_active(),
759            "retarget must register an active animation"
760        );
761
762        let base = Instant::now();
763        // First tick only establishes t0 (no dt to integrate yet); nothing should change or recompose.
764        motion_core::tick(base);
765        assert_eq!(
766            root.generation(),
767            g0,
768            "the t0-establishing tick must not recompose"
769        );
770        assert_eq!(animated_rect_x(&root), 0.0);
771
772        // Halfway through the tween: commands() must reflect the interpolated value in this same tick.
773        // generation() only bumps inside commands()'s lazy recompose, so read the value first and
774        // capture the generation right after — capturing it beforehand would still show the stale
775        // pre-tick generation and make the `assert_ne!` below vacuous.
776        motion_core::tick(base + Duration::from_millis(50));
777        let mid_x = animated_rect_x(&root);
778        let g1 = root.generation();
779        assert!(
780            (mid_x - 5.0).abs() < 1e-3,
781            "expected the midpoint of the tween, got {mid_x}"
782        );
783        assert_ne!(g1, g0, "an in-flight tick must bump the compose generation");
784
785        // Full duration: the tween settles and deregisters.
786        motion_core::tick(base + Duration::from_millis(100));
787        let end_x = animated_rect_x(&root);
788        let g2 = root.generation();
789        assert_eq!(end_x, 10.0);
790        assert_ne!(g2, g1, "the settling tick must still bump the generation");
791        assert!(
792            !motion_core::has_active(),
793            "a settled tween must deregister"
794        );
795
796        // An extra tick after settling integrates nothing and must not recompose again.
797        motion_core::tick(base + Duration::from_millis(200));
798        assert_eq!(animated_rect_x(&root), 10.0);
799        assert_eq!(
800            root.generation(),
801            g2,
802            "a tick with no active animations must not bump the generation"
803        );
804    }
805}