Skip to main content

telar_ui_core/
child_host.rs

1//! Transparent reactive regions inside a container.
2//!
3//! A container's children are a sequence of [`ChildSlot`]s, each either a fixed widget or a reactive
4//! *fragment* — a keyed, reconciling region that has **no box of its own**. A fragment reconciles its
5//! items directly into the *host container's* layout node, so the items are real siblings of the static
6//! children: they inherit the host's flex direction, gap and alignment, resolved at layout time (like the
7//! web's transparent `array.map(...)`, which Taffy can't express via `display:contents`). This is what
8//! makes a reactive `for`/`if` flow horizontally inside a `row` without a wrapper imposing a column axis.
9//!
10//! Reconciliation reuses the host node via [`set_children`] (which replaces all of a node's children in
11//! order), re-flattening every slot on each change — so several fragments and static siblings interleave
12//! correctly. Compare [`crate::reactive_list::ReactiveList`], which is the boxed, standalone variant.
13
14use std::cell::RefCell;
15use std::collections::HashMap;
16use std::collections::hash_map::DefaultHasher;
17use std::hash::{Hash, Hasher};
18use std::rc::Rc;
19
20use layout_core::{LayoutError, NodeId};
21use platform_core::Event;
22use reactive_core::{Effect, RwSignal, effect, signal};
23use ui_tree::{EventResult, RenderNode};
24
25use crate::context::{container_is_row, remove_node, set_children, set_leading_margin};
26use crate::layout_item::{Child, LayoutItem, make_child};
27use crate::pointer::dispatch_container_event;
28
29fn hash_key<K: Hash>(k: &K) -> u64 {
30    let mut h = DefaultHasher::new();
31    k.hash(&mut h);
32    h.finish()
33}
34
35/// One child position in a container: a fixed widget, or a reactive fragment (built lazily once the host
36/// node exists). Produced by [`ChildSlot::stat`] / [`fragment`] / [`fragment_positional`].
37pub enum ChildSlot {
38    Static(Box<dyn LayoutItem>),
39    Dynamic(FragmentSpec),
40}
41
42impl ChildSlot {
43    /// A fixed widget slot — the transpiler's `Slot::stat(box_item(x))` for a static child of a container
44    /// that also holds a reactive region.
45    pub fn stat(item: Box<dyn LayoutItem>) -> Self {
46        ChildSlot::Static(item)
47    }
48}
49
50/// A generic-erased reactive region. Captures `source`/`key`/`build` behind one closure that, given the
51/// shared host state and this slot's index, wires the reconcile [`Effect`]. Erasing here keeps
52/// [`HostState`] non-generic (a container can hold fragments over different item types). `gap` is the
53/// per-item spacing (`0.0` = none), applied as a main-axis leading margin so the region stays transparent.
54pub struct FragmentSpec {
55    install: Box<dyn FnOnce(Rc<RefCell<HostState>>, usize) -> Effect>,
56    gap: f32,
57}
58
59/// A keyed reactive region — `for item in $items key <expr>` (identity-stable reconciliation).
60pub fn fragment<Item, Key, S, K, B>(source: S, key: K, build: B) -> ChildSlot
61where
62    Key: Hash + 'static,
63    Item: 'static,
64    S: Fn() -> Vec<Item> + 'static,
65    K: Fn(&Item) -> Key + 'static,
66    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
67{
68    make_fragment(
69        source,
70        move |item: &Item, _idx: usize| hash_key(&key(item)),
71        build,
72        0.0,
73    )
74}
75
76/// A keyed reactive region with per-item spacing — `for item in $items key <expr> gap:N`. The gap is laid
77/// out as a main-axis leading margin between consecutive items (see [`reconcile_slot`]), so the region
78/// still flows transparently in the host's direction (horizontal in a `row`) instead of a boxed list.
79pub fn fragment_gap<Item, Key, S, K, B>(source: S, key: K, build: B, gap: f32) -> ChildSlot
80where
81    Key: Hash + 'static,
82    Item: 'static,
83    S: Fn() -> Vec<Item> + 'static,
84    K: Fn(&Item) -> Key + 'static,
85    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
86{
87    make_fragment(
88        source,
89        move |item: &Item, _idx: usize| hash_key(&key(item)),
90        build,
91        gap,
92    )
93}
94
95/// A keyless reactive region — `for item in $items` (reconciles by position).
96pub fn fragment_positional<Item, S, B>(source: S, build: B) -> ChildSlot
97where
98    Item: 'static,
99    S: Fn() -> Vec<Item> + 'static,
100    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
101{
102    make_fragment(source, |_item: &Item, idx: usize| idx as u64, build, 0.0)
103}
104
105/// A keyless reactive region with per-item spacing — `for item in $items gap:N` (reconciles by position).
106pub fn fragment_positional_gap<Item, S, B>(source: S, build: B, gap: f32) -> ChildSlot
107where
108    Item: 'static,
109    S: Fn() -> Vec<Item> + 'static,
110    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
111{
112    make_fragment(source, |_item: &Item, idx: usize| idx as u64, build, gap)
113}
114
115fn make_fragment<Item, S, B, KeyFn>(source: S, keyer: KeyFn, build: B, gap: f32) -> ChildSlot
116where
117    Item: 'static,
118    S: Fn() -> Vec<Item> + 'static,
119    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
120    KeyFn: Fn(&Item, usize) -> u64 + 'static,
121{
122    let install = Box::new(
123        move |state: Rc<RefCell<HostState>>, index: usize| -> Effect {
124            // Runs once now (builds the initial items) and again on every change to a signal `source` reads.
125            effect(move || {
126                let items = source();
127                reconcile_slot(&state, index, items, &keyer, &build);
128            })
129        },
130    );
131    ChildSlot::Dynamic(FragmentSpec { install, gap })
132}
133
134/// The reconciled items of one dynamic slot, plus their key hashes (mirrors `ReactiveList`'s `ListState`).
135/// `gap` (px) is spaced between consecutive items as a main-axis leading margin — `left` when `is_row`, else
136/// `top`; both are captured once from the host when the slot is built (`0.0` gap = no margin work).
137#[derive(Default)]
138struct DynState {
139    items: Vec<Child>,
140    keys: Vec<u64>,
141    gap: f32,
142    is_row: bool,
143}
144
145enum SlotState {
146    Static(Child),
147    Dynamic(DynState),
148}
149
150/// Shared host state: the container's layout node plus its slots in order. Mutated by fragment reconcile
151/// effects (during the reactive flush) and read by the container's `view`/`on_event` (during
152/// render/dispatch) — never concurrently, so the `RefCell` never double-borrows.
153struct HostState {
154    node: NodeId,
155    slots: Vec<SlotState>,
156    version: RwSignal<u64>,
157}
158
159fn flatten_nodes(slots: &[SlotState]) -> Vec<NodeId> {
160    let mut nodes = Vec::new();
161    for slot in slots {
162        match slot {
163            SlotState::Static(child) => nodes.push(child.node()),
164            SlotState::Dynamic(dyn_state) => nodes.extend(dyn_state.items.iter().map(Child::node)),
165        }
166    }
167    nodes
168}
169
170fn collect_children(slots: &[SlotState]) -> Vec<Child> {
171    let mut out = Vec::new();
172    for slot in slots {
173        match slot {
174            SlotState::Static(child) => out.push(child.clone()),
175            SlotState::Dynamic(dyn_state) => out.extend(dyn_state.items.iter().cloned()),
176        }
177    }
178    out
179}
180
181fn reconcile_slot<Item, KeyFn, B>(
182    state: &Rc<RefCell<HostState>>,
183    index: usize,
184    items: Vec<Item>,
185    keyer: &KeyFn,
186    build: &B,
187) where
188    KeyFn: Fn(&Item, usize) -> u64,
189    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError>,
190{
191    let mut st = state.borrow_mut();
192
193    // Index this slot's current items by key hash so a persisting key reuses its widget/node.
194    let (old_items, old_keys) = match &mut st.slots[index] {
195        SlotState::Dynamic(dyn_state) => (
196            std::mem::take(&mut dyn_state.items),
197            std::mem::take(&mut dyn_state.keys),
198        ),
199        SlotState::Static(_) => unreachable!("a fragment slot is never static"),
200    };
201    let mut old: HashMap<u64, Child> = HashMap::new();
202    for (k, child) in old_keys.into_iter().zip(old_items) {
203        old.entry(k).or_insert(child);
204    }
205
206    let mut new_items: Vec<Child> = Vec::with_capacity(items.len());
207    let mut keys: Vec<u64> = Vec::with_capacity(items.len());
208    for (idx, item) in items.into_iter().enumerate() {
209        let k = keyer(&item, idx);
210        let child = match old.remove(&k) {
211            Some(existing) => existing,
212            None => make_child(build(item).expect("fragment item build")),
213        };
214        new_items.push(child);
215        keys.push(k);
216    }
217
218    // Capture this slot's item nodes (in order) and its gap/axis before writing them back, so the gap can be
219    // re-applied as a per-item margin below without re-borrowing.
220    let (gap, is_row, item_nodes) = if let SlotState::Dynamic(dyn_state) = &mut st.slots[index] {
221        let item_nodes: Vec<NodeId> = new_items.iter().map(Child::node).collect();
222        dyn_state.items = new_items;
223        dyn_state.keys = keys;
224        (dyn_state.gap, dyn_state.is_row, item_nodes)
225    } else {
226        (0.0, false, Vec::new())
227    };
228
229    // Reorder/insert/drop across the whole host node, then free the nodes of items that went away.
230    let nodes = flatten_nodes(&st.slots);
231    let node = st.node;
232    let version = st.version.clone();
233    drop(st);
234
235    let _ = set_children(node, &nodes);
236    // A `for … gap:N` has no box to carry a container gap, so the spacing lives on the items: every item but
237    // the first gets a leading main-axis margin of `gap`. Re-applied each reconcile (not baked at build) so a
238    // reordered item that lands first loses its margin and one that leaves the front gains it.
239    if gap != 0.0 {
240        for (i, &item) in item_nodes.iter().enumerate() {
241            set_leading_margin(item, is_row, if i == 0 { 0.0 } else { gap });
242        }
243    }
244    for (_, child) in old {
245        remove_node(child.node());
246    }
247    version.update(|v| *v = v.wrapping_add(1));
248}
249
250/// The dynamic child store a container embeds when it holds at least one reactive fragment. Owns the
251/// slots and keeps the reconcile effects alive; the container delegates render/hit-test to it.
252pub(crate) struct DynHost {
253    state: Rc<RefCell<HostState>>,
254    version: RwSignal<u64>,
255    _effects: Vec<Effect>,
256}
257
258impl DynHost {
259    /// `node` is the already-registered container node; `slots` are its children in order. Fragments
260    /// reconcile their items straight into `node`.
261    pub(crate) fn build(node: NodeId, slots: Vec<ChildSlot>) -> Result<Self, LayoutError> {
262        let version = signal(0u64);
263        let state = Rc::new(RefCell::new(HostState {
264            node,
265            slots: Vec::with_capacity(slots.len()),
266            version: version.clone(),
267        }));
268
269        // The host's flex axis is fixed by now (its style, class-driven direction included, was set when the
270        // node was created), so a gap fragment can capture which margin edge to space its items on, once.
271        let host_is_row = container_is_row(node);
272
273        // Materialize every slot in order first (statics as children, dynamics as empty placeholders) so
274        // that when a fragment effect runs it flattens the complete slot structure, keeping sibling order.
275        let mut specs: Vec<(usize, FragmentSpec)> = Vec::new();
276        {
277            let mut st = state.borrow_mut();
278            for slot in slots {
279                let index = st.slots.len();
280                match slot {
281                    ChildSlot::Static(item) => st.slots.push(SlotState::Static(make_child(item))),
282                    ChildSlot::Dynamic(spec) => {
283                        st.slots.push(SlotState::Dynamic(DynState {
284                            gap: spec.gap,
285                            is_row: host_is_row,
286                            ..Default::default()
287                        }));
288                        specs.push((index, spec));
289                    }
290                }
291            }
292        }
293
294        let effects: Vec<Effect> = specs
295            .into_iter()
296            .map(|(index, spec)| (spec.install)(state.clone(), index))
297            .collect();
298
299        let nodes = flatten_nodes(&state.borrow().slots);
300        let _ = set_children(node, &nodes);
301
302        Ok(Self {
303            state,
304            version,
305            _effects: effects,
306        })
307    }
308
309    /// The current children's render boundaries, in order. Subscribes to reconciles so the container's
310    /// `view()` re-emits the new/reordered set.
311    pub(crate) fn child_boundaries(&self) -> Vec<RenderNode> {
312        self.version.get();
313        let st = self.state.borrow();
314        let mut out = Vec::new();
315        for slot in &st.slots {
316            match slot {
317                SlotState::Static(child) => out.push(child.segment.boundary()),
318                SlotState::Dynamic(dyn_state) => {
319                    out.extend(dyn_state.items.iter().map(|c| c.segment.boundary()))
320                }
321            }
322        }
323        out
324    }
325
326    /// Dispatch an event to the current children (cheap `Rc`-clone flatten, so hit-testing sees every
327    /// live item in order).
328    pub(crate) fn dispatch(&self, event: &Event) -> EventResult {
329        let mut children = collect_children(&self.state.borrow().slots);
330        dispatch_container_event(&mut children, event)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::container::Container;
338    use crate::context::{compute_layout, reset_layout_runtime, track_layout};
339    use layout_core::{AvailableSpace, LayoutStyle};
340    use reactive_core::signal;
341    use ui_tree::Component;
342
343    fn leaf10() -> Box<dyn LayoutItem> {
344        Box::new(Container::new(LayoutStyle::new().width(10.0).height(10.0), vec![]).unwrap())
345    }
346
347    // Returns a 10×10 leaf together with its layout node, so a test can read its laid-out rect.
348    fn leaf10_node() -> (NodeId, Box<dyn LayoutItem>) {
349        let c = Container::new(LayoutStyle::new().width(10.0).height(10.0), vec![]).unwrap();
350        (c.layout_node(), Box::new(c))
351    }
352
353    fn group_len(node: &RenderNode) -> usize {
354        match node {
355            RenderNode::Group { children, .. } => children.len(),
356            _ => panic!("expected Group"),
357        }
358    }
359
360    // A fragment's items flatten between the static siblings and reconcile on source change: the host's
361    // `view()` group holds `static + dynamic + static`, growing and shrinking with the signal.
362    #[test]
363    fn fragment_children_flatten_and_reconcile() {
364        reset_layout_runtime();
365        let items = signal(vec![1u32, 2, 3]);
366        let src = items.clone();
367        let container = Container::from_slots(
368            LayoutStyle::new().flex_row(),
369            vec![
370                ChildSlot::stat(leaf10()),
371                fragment(move || src.get(), |n: &u32| *n, |_n| Ok(leaf10())),
372                ChildSlot::stat(leaf10()),
373            ],
374        )
375        .unwrap();
376
377        assert_eq!(group_len(&container.view()), 5, "2 static + 3 dynamic");
378
379        // Outside a batch, `set` flushes the reconcile effect immediately.
380        items.set(vec![9]);
381        assert_eq!(group_len(&container.view()), 3, "2 static + 1 dynamic");
382
383        items.set(vec![9, 8, 7, 6]);
384        assert_eq!(group_len(&container.view()), 6, "2 static + 4 dynamic");
385    }
386
387    // The load-bearing property of C: the fragment's items are laid out as real siblings of the static
388    // children, IN THE HOST'S ROW DIRECTION and BETWEEN the two statics — not stacked in a private column.
389    #[test]
390    fn fragment_items_flow_in_host_direction_between_statics() {
391        reset_layout_runtime();
392        let (s0, static0) = leaf10_node();
393        let (s1, static1) = leaf10_node();
394        let built: Rc<RefCell<Vec<NodeId>>> = Rc::new(RefCell::new(Vec::new()));
395        let sink = built.clone();
396        let items = signal(vec![1u32, 2, 3]);
397        let src = items.clone();
398
399        let container = Container::from_slots(
400            LayoutStyle::new().flex_row(),
401            vec![
402                ChildSlot::stat(static0),
403                fragment(
404                    move || src.get(),
405                    |n: &u32| *n,
406                    move |_n| {
407                        let (node, item) = leaf10_node();
408                        sink.borrow_mut().push(node);
409                        Ok(item)
410                    },
411                ),
412                ChildSlot::stat(static1),
413            ],
414        )
415        .unwrap();
416
417        compute_layout(
418            container.layout_node(),
419            AvailableSpace::Definite(500.0),
420            AvailableSpace::Definite(50.0),
421        )
422        .unwrap();
423
424        let frag = built.borrow().clone();
425        assert_eq!(frag.len(), 3);
426        let x = |node: NodeId| track_layout(node).unwrap().get().x;
427        // static0 → frag[0] → frag[1] → frag[2] → static1, strictly left-to-right: horizontal + interleaved.
428        let xs = [x(s0), x(frag[0]), x(frag[1]), x(frag[2]), x(s1)];
429        for pair in xs.windows(2) {
430            assert!(
431                pair[1] > pair[0],
432                "children must advance along the row (got x sequence {xs:?})"
433            );
434        }
435        // All share the row's top: same y, so they are siblings on one line, not a nested column.
436        let y = |node: NodeId| track_layout(node).unwrap().get().y;
437        assert!((y(frag[0]) - y(s0)).abs() < 0.01 && (y(s1) - y(frag[2])).abs() < 0.01);
438    }
439
440    // A fragment chip added AFTER the initial layout (workspace chips built when the socket answers, well
441    // after the bar first laid out) must still fire its handler. Mirrors the workspaces shape: a `from_slots`
442    // row is the component root, hosting a `fragment_gap` whose single-`box` body is a bare `StyledContainer`.
443    #[test]
444    fn pressing_a_fragment_chip_added_after_layout_fires_its_handler() {
445        use crate::context::{new_container, relayout_if_dirty};
446        use crate::styled_container::StyledContainer;
447        use layout_core::{AlignItems, JustifyContent};
448        use platform_core::{Event, PointerButton, PointerSource};
449        use renderer_core::RectStyle;
450
451        reset_layout_runtime();
452        let fired = Rc::new(std::cell::Cell::new(0i32));
453        let ids = signal(Vec::<i32>::new()); // empty at first, like the snapshot before the socket answers
454        let src = ids.clone();
455        let sink = fired.clone();
456
457        // The `row` is the module root itself (the fixed `generate_root` returns the branch element bare).
458        let mut row = Container::from_slots(
459            LayoutStyle::new()
460                .flex_row()
461                .align_items(AlignItems::CENTER),
462            vec![fragment_gap(
463                move || src.get(),
464                |id: &i32| *id as u64,
465                move |id| {
466                    let sink = sink.clone();
467                    let chip = StyledContainer::new(
468                        LayoutStyle::new()
469                            .flex_column()
470                            .width(24.0)
471                            .height(24.0)
472                            .align_items(AlignItems::CENTER)
473                            .justify_content(JustifyContent::CENTER),
474                        move |_| RectStyle::default(),
475                        vec![],
476                    )?
477                    .on_press(move || sink.set(id));
478                    Ok(Box::new(chip) as Box<dyn LayoutItem>)
479                },
480                8.0,
481            )],
482        )
483        .unwrap();
484
485        let root = new_container(
486            LayoutStyle::new().flex_row().width(200.0).height(24.0),
487            &[row.layout_node()],
488        )
489        .unwrap();
490        compute_layout(
491            root,
492            AvailableSpace::Definite(200.0),
493            AvailableSpace::Definite(24.0),
494        )
495        .unwrap();
496
497        // Data arrives after layout: the fragment reconciles (builds 3 chips) and the runtime relayouts them.
498        ids.set(vec![7, 8, 9]);
499        relayout_if_dirty();
500
501        // Click the first chip: no leading gap, so it sits at x∈[0,24), y∈[0,24). Press then release inside it.
502        let press = |x: f64, y: f64| Event::PointerPressed {
503            x,
504            y,
505            button: PointerButton::Primary,
506            source: PointerSource::Mouse,
507        };
508        let release = |x: f64, y: f64| Event::PointerReleased {
509            x,
510            y,
511            button: PointerButton::Primary,
512            source: PointerSource::Mouse,
513        };
514        row.on_event(&press(12.0, 12.0));
515        row.on_event(&release(12.0, 12.0));
516
517        assert_eq!(
518            fired.get(),
519            7,
520            "clicking the first workspace-style chip should fire its on_press"
521        );
522    }
523
524    // Regression (workspaces "chips don't fill the bar height"): a `from_slots` stretch row of bare
525    // `StyledContainer` chips, placed in a STRETCH zone, must stretch each chip to the full zone height —
526    // no injected flex-column around the root or the chips to trap them at content height.
527    #[test]
528    fn stretch_row_of_fragment_chips_fills_the_zone_height() {
529        use crate::context::{new_container, relayout_if_dirty};
530        use crate::styled_container::StyledContainer;
531        use layout_core::AlignItems;
532        use renderer_core::RectStyle;
533
534        reset_layout_runtime();
535        let built: Rc<RefCell<Vec<NodeId>>> = Rc::new(RefCell::new(Vec::new()));
536        let sink = built.clone();
537        let ids = signal(Vec::<i32>::new());
538        let src = ids.clone();
539
540        let row = Container::from_slots(
541            LayoutStyle::new()
542                .flex_row()
543                .align_items(AlignItems::STRETCH),
544            vec![fragment_gap(
545                move || src.get(),
546                |id: &i32| *id as u64,
547                move |_id| {
548                    let sink = sink.clone();
549                    let chip = StyledContainer::new(
550                        LayoutStyle::new().flex_column().padding_horizontal(10.0),
551                        move |_| RectStyle::default(),
552                        vec![Box::new(Container::new(
553                            LayoutStyle::new().width(10.0).height(13.0),
554                            vec![],
555                        )?)],
556                    )?;
557                    let node = chip.layout_node();
558                    sink.borrow_mut().push(node);
559                    Ok(Box::new(chip) as Box<dyn LayoutItem>)
560                },
561                8.0,
562            )],
563        )
564        .unwrap();
565
566        let root = new_container(
567            LayoutStyle::new()
568                .flex_row()
569                .align_items(AlignItems::STRETCH)
570                .width(200.0)
571                .height(34.0),
572            &[row.layout_node()],
573        )
574        .unwrap();
575        compute_layout(
576            root,
577            AvailableSpace::Definite(200.0),
578            AvailableSpace::Definite(34.0),
579        )
580        .unwrap();
581
582        ids.set(vec![1, 2, 3]);
583        relayout_if_dirty();
584
585        let chips = built.borrow().clone();
586        assert_eq!(chips.len(), 3, "three chips built");
587        for node in chips {
588            let h = track_layout(node).unwrap().get().height;
589            assert!(
590                (h - 34.0).abs() < 0.01,
591                "each chip must stretch to the 34px zone height, got {h} (content height ~13 means a \
592                 collapsing wrapper crept back in)"
593            );
594        }
595    }
596
597    // `for … gap:N` inside a `row` stays transparent AND spaced: the items flow horizontally, `gap` apart,
598    // carried as a per-item leading margin (no box of its own). 10px leaves + 8px gap → x 0, 18, 36.
599    #[test]
600    fn fragment_gap_spaces_items_along_the_row() {
601        reset_layout_runtime();
602        let built: Rc<RefCell<Vec<NodeId>>> = Rc::new(RefCell::new(Vec::new()));
603        let sink = built.clone();
604        let items = signal(vec![1u32, 2, 3]);
605        let src = items.clone();
606        let container = Container::from_slots(
607            LayoutStyle::new().flex_row(),
608            vec![fragment_gap(
609                move || src.get(),
610                |n: &u32| *n,
611                move |_n| {
612                    let (node, item) = leaf10_node();
613                    sink.borrow_mut().push(node);
614                    Ok(item)
615                },
616                8.0,
617            )],
618        )
619        .unwrap();
620        compute_layout(
621            container.layout_node(),
622            AvailableSpace::Definite(500.0),
623            AvailableSpace::Definite(50.0),
624        )
625        .unwrap();
626
627        let frag = built.borrow().clone();
628        assert_eq!(frag.len(), 3);
629        let x = |node: NodeId| track_layout(node).unwrap().get().x;
630        assert!(x(frag[0]).abs() < 0.01, "first item flush: {}", x(frag[0]));
631        assert!(
632            (x(frag[1]) - 18.0).abs() < 0.01,
633            "10px item + 8px gap → 18: {}",
634            x(frag[1])
635        );
636        assert!(
637            (x(frag[2]) - 36.0).abs() < 0.01,
638            "two 10px items + two 8px gaps → 36: {}",
639            x(frag[2])
640        );
641    }
642
643    // The gap margin is re-applied per reconcile, not baked at build: when a keyed item moves to the front it
644    // must LOSE its leading margin (else it would be pushed off by a stale gap), and the former first gains one.
645    #[test]
646    fn fragment_gap_reorder_moves_gap_off_the_new_first_item() {
647        reset_layout_runtime();
648        let built: Rc<RefCell<Vec<NodeId>>> = Rc::new(RefCell::new(Vec::new()));
649        let sink = built.clone();
650        let items = signal(vec![1u32, 2, 3]);
651        let src = items.clone();
652        let container = Container::from_slots(
653            LayoutStyle::new().flex_row(),
654            vec![fragment_gap(
655                move || src.get(),
656                |n: &u32| *n,
657                move |_n| {
658                    let (node, item) = leaf10_node();
659                    sink.borrow_mut().push(node);
660                    Ok(item)
661                },
662                8.0,
663            )],
664        )
665        .unwrap();
666        let root = container.layout_node();
667        let space = || {
668            (
669                AvailableSpace::Definite(500.0),
670                AvailableSpace::Definite(50.0),
671            )
672        };
673        compute_layout(root, space().0, space().1).unwrap();
674
675        // Initial builds, in source order: [key1, key2, key3]. No reorder rebuilds a persisting key, so these
676        // node ids stay valid across the reconcile below.
677        let (n1, n3) = {
678            let b = built.borrow();
679            (b[0], b[2])
680        };
681        let x = |node: NodeId| track_layout(node).unwrap().get().x;
682        assert!(x(n1).abs() < 0.01, "key1 first, flush: {}", x(n1));
683        assert!((x(n3) - 36.0).abs() < 0.01, "key3 last, at 36: {}", x(n3));
684
685        // key3 moves to the front: it drops its gap margin (→ 0), key1 becomes second (→ 18).
686        items.set(vec![3, 1, 2]);
687        compute_layout(root, space().0, space().1).unwrap();
688        assert!(
689            x(n3).abs() < 0.01,
690            "reordered-to-front item drops its gap margin: {}",
691            x(n3)
692        );
693        assert!(
694            (x(n1) - 18.0).abs() < 0.01,
695            "former-first item now second, at 18: {}",
696            x(n1)
697        );
698    }
699}