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