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