Skip to main content

telar_ui_core/
reactive_list.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use std::rc::Rc;
6
7use geometry_core::Rect;
8use layout_core::{LayoutError, LayoutStyle, NodeId};
9use platform_core::Event;
10use reactive_core::{Effect, RwSignal, effect, signal};
11use ui_tree::{Component, EventResult, RenderNode};
12
13use crate::context::{new_container, remove_node, set_children, track_layout};
14use crate::layout_item::{Child, LayoutItem, TrackedChildren, make_child};
15use crate::pointer::dispatch_container_event;
16
17/// A key erased to a `u64` so the list state stays non-generic. A collision would reuse the wrong item's
18/// node, but a 64-bit hash makes that astronomically unlikely for the small, distinct keys a list uses.
19fn hash_key<K: Hash>(k: &K) -> u64 {
20    let mut h = DefaultHasher::new();
21    k.hash(&mut h);
22    h.finish()
23}
24
25/// Shared reconciliation state: the container node plus the current items in order and their key hashes.
26/// Mutated by the reconcile effect (during the reactive flush) and read by `view`/`on_event` (during
27/// render/dispatch) — never concurrently, so the `RefCell` never double-borrows.
28struct ListState {
29    node: NodeId,
30    children: TrackedChildren,
31    keys: Vec<u64>,
32}
33
34/// A reactive list: `for item in $items key id` (or, keyless, `for item in $items`) in `.rsx`. Re-runs its
35/// source reactively and reconciles the item widgets — reused keys/positions keep their node/widget, new
36/// ones are built, gone ones are disposed, and the layout children are reordered — instead of rebuilding the
37/// whole block on every change. `new`/`with_gap` reconcile by key (identity-stable); `positional`/
38/// `positional_with_gap` reconcile by index (no `key` clause needed, cheap append/truncate).
39pub struct ReactiveList {
40    node: NodeId,
41    rect: RwSignal<Rect>,
42    state: Rc<RefCell<ListState>>,
43    // Bumped on every reconcile so `view()` (which reads it) re-emits the new/reordered child group.
44    version: RwSignal<u64>,
45    // Keeps the reconcile effect alive for the widget's lifetime.
46    _effect: Effect,
47}
48
49impl ReactiveList {
50    /// Runs the reconciled items along the horizontal axis instead of stacking them.
51    ///
52    /// Every constructor builds a column, because a list's own node exists before it is attached and
53    /// cannot ask a parent it does not have yet. A `for` written inside a `row` reconciles into that row
54    /// as a transparent fragment and never reaches here; one that *cannot* — inside a reactive `if`, say,
55    /// which owns a node of its own — is boxed, and this is how it learns which way its items run.
56    pub fn as_row(self) -> Self {
57        crate::context::set_container_row(self.node);
58        self
59    }
60
61    /// `source` reads the reactive item collection; `key` extracts a stable identity per item; `build`
62    /// constructs one widget per item, creating its nodes against the live (thread-local) layout tree
63    /// from inside the reconcile effect.
64    pub fn new<Item, Key, S, K, B>(
65        source: S,
66        key: K,
67        build: B,
68        gap: f32,
69    ) -> Result<Self, LayoutError>
70    where
71        Key: Hash + 'static,
72        Item: 'static,
73        S: Fn() -> Vec<Item> + 'static,
74        K: Fn(&Item) -> Key + 'static,
75        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
76    {
77        Self::build(
78            LayoutStyle::new().flex_column().gap(gap),
79            source,
80            build,
81            move |item: &Item, _idx: usize| hash_key(&key(item)),
82        )
83    }
84
85    /// Keyed like [`new`](Self::new)/[`with_gap`](Self::with_gap), but the caller supplies the container's
86    /// [`LayoutStyle`] — flex direction, gap, alignment. Use it for a horizontal reactive row (e.g. a bar's
87    /// workspace chips), which the column-oriented constructors can't express.
88    pub fn with_style<Item, Key, S, K, B>(
89        container_style: LayoutStyle,
90        source: S,
91        key: K,
92        build: B,
93    ) -> Result<Self, LayoutError>
94    where
95        Key: Hash + 'static,
96        Item: 'static,
97        S: Fn() -> Vec<Item> + 'static,
98        K: Fn(&Item) -> Key + 'static,
99        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
100    {
101        Self::build(
102            container_style,
103            source,
104            build,
105            move |item: &Item, _idx: usize| hash_key(&key(item)),
106        )
107    }
108
109    /// A keyless reactive list: `for item in $items` with no `key` clause. Reconciles by POSITION — the
110    /// item at index `i` always reuses the node previously at index `i`, so an append/truncate reuses every
111    /// surviving node cheaply, but a reorder rebuilds rather than moving nodes (no per-item identity without
112    /// a key).
113    pub fn positional<Item, S, B>(source: S, build: B, gap: f32) -> Result<Self, LayoutError>
114    where
115        Item: 'static,
116        S: Fn() -> Vec<Item> + 'static,
117        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
118    {
119        Self::build(
120            LayoutStyle::new().flex_column().gap(gap),
121            source,
122            build,
123            |_item: &Item, idx: usize| idx as u64,
124        )
125    }
126
127    /// A keyed list whose builder receives a **live handle** to its item rather than a copy of it.
128    ///
129    /// The difference decides what a row is allowed to be. With an owned snapshot, a key that persists reuses
130    /// the widget and the new value is discarded — so a row that must follow its data has to be keyed on that
131    /// data, which rebuilds it and throws away whatever local state it held: a caret, a drag in progress, a
132    /// scroll position. Keying on identity and reading the value through a handle separates the two questions —
133    /// the key decides whether this is still the same row, the handle carries what that row now says.
134    ///
135    /// Use [`Self::new`] where a row genuinely *is* its value, which most lists are, and this where a row
136    /// outlives its contents.
137    pub fn keyed<Item, Key, S, K, B>(source: S, key: K, build: B) -> Result<Self, LayoutError>
138    where
139        Key: Hash + 'static,
140        Item: Clone + 'static,
141        S: Fn() -> Vec<Item> + 'static,
142        K: Fn(&Item) -> Key + 'static,
143        B: Fn(reactive_core::ReadSignal<Item>) -> Result<Box<dyn LayoutItem>, LayoutError>
144            + 'static,
145    {
146        // One signal per live key, held outside `ListState` so that stays non-generic. `sync` runs for every
147        // item on every reconcile and *before* the reuse decision, so a persisting row's handle already holds
148        // the new value by the time anything reads it, and a new row's handle exists for `build` to read.
149        let values: Rc<RefCell<HashMap<u64, RwSignal<Item>>>> =
150            Rc::new(RefCell::new(HashMap::new()));
151
152        let sync_values = Rc::clone(&values);
153        let sync = move |item: &Item, k: u64| {
154            let mut held = sync_values.borrow_mut();
155            match held.get(&k) {
156                Some(existing) => existing.set(item.clone()),
157                None => {
158                    held.insert(k, signal(item.clone()));
159                }
160            }
161        };
162
163        let key = Rc::new(key);
164        let key_for_build = Rc::clone(&key);
165        let build_values = Rc::clone(&values);
166        Self::build_with_sync(
167            LayoutStyle::new().flex_column(),
168            source,
169            move |item: Item| {
170                let held = build_values
171                    .borrow()
172                    .get(&hash_key(&key_for_build(&item)))
173                    .cloned()
174                    .expect("sync inserts a handle for every item before build runs");
175                build(held.read_only())
176            },
177            move |item: &Item, _idx: usize| hash_key(&key(item)),
178            sync,
179        )
180    }
181
182    /// Shared constructor: `keyer` erases both reconciliation modes (hashed key, or plain index) to a
183    /// `u64` so `reconcile` doesn't need to know which mode produced it.
184    fn build<Item, S, B, KeyFn>(
185        container_style: LayoutStyle,
186        source: S,
187        build: B,
188        keyer: KeyFn,
189    ) -> Result<Self, LayoutError>
190    where
191        Item: 'static,
192        S: Fn() -> Vec<Item> + 'static,
193        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
194        KeyFn: Fn(&Item, usize) -> u64 + 'static,
195    {
196        Self::build_with_sync(container_style, source, build, keyer, |_, _| {})
197    }
198
199    fn build_with_sync<Item, S, B, KeyFn, Sync>(
200        container_style: LayoutStyle,
201        source: S,
202        build: B,
203        keyer: KeyFn,
204        sync: Sync,
205    ) -> Result<Self, LayoutError>
206    where
207        Item: 'static,
208        S: Fn() -> Vec<Item> + 'static,
209        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
210        KeyFn: Fn(&Item, usize) -> u64 + 'static,
211        Sync: Fn(&Item, u64) + 'static,
212    {
213        let node = new_container(container_style, &[])?;
214        let rect = track_layout(node).expect("list container is registered");
215        let state = Rc::new(RefCell::new(ListState {
216            node,
217            children: Vec::new(),
218            keys: Vec::new(),
219        }));
220        let version = signal(0u64);
221
222        let eff_state = Rc::clone(&state);
223        let eff_version = version.clone();
224        // Runs once now (builds the initial list) and again on every change to a signal `source` reads.
225        let _effect = effect(move || {
226            let items = source();
227            reconcile(&eff_state, items, &keyer, &build, &sync);
228            eff_version.update(|v| *v = v.wrapping_add(1));
229        });
230
231        Ok(Self {
232            node,
233            rect,
234            state,
235            version,
236            _effect,
237        })
238    }
239}
240
241fn reconcile<Item, KeyFn, B, Sync>(
242    state: &Rc<RefCell<ListState>>,
243    items: Vec<Item>,
244    keyer: &KeyFn,
245    build: &B,
246    sync: &Sync,
247) where
248    KeyFn: Fn(&Item, usize) -> u64,
249    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError>,
250    Sync: Fn(&Item, u64),
251{
252    let mut st = state.borrow_mut();
253    let container = st.node;
254
255    // Index the current children by key hash so a persisting key reuses its widget/node.
256    let old_keys = std::mem::take(&mut st.keys);
257    let old_children = std::mem::take(&mut st.children);
258    let mut old: HashMap<u64, Child> = HashMap::new();
259    for (k, child) in old_keys.into_iter().zip(old_children) {
260        old.entry(k).or_insert(child);
261    }
262
263    let mut children: TrackedChildren = Vec::with_capacity(items.len());
264    let mut keys: Vec<u64> = Vec::with_capacity(items.len());
265    let mut nodes: Vec<NodeId> = Vec::with_capacity(items.len());
266
267    for (idx, item) in items.into_iter().enumerate() {
268        let k = keyer(&item, idx);
269        // Before the reuse decision, so a row that keeps its key still sees the value it now carries. The
270        // snapshot constructors pass a no-op here and keep the old behaviour exactly.
271        sync(&item, k);
272        let child = match old.remove(&k) {
273            Some(existing) => existing,
274            None => make_child(build(item).expect("reactive list item build")),
275        };
276        nodes.push(child.node());
277        children.push(child);
278        keys.push(k);
279    }
280
281    st.children = children;
282    st.keys = keys;
283    drop(st);
284
285    // Reorder/insert/drop in the layout tree, then free the nodes of items that went away. set_children
286    // first so the disposed nodes are detached before remove_node frees them.
287    let _ = set_children(container, &nodes);
288    for (_, child) in old {
289        remove_node(child.node());
290    }
291}
292
293impl LayoutItem for ReactiveList {
294    fn layout_node(&self) -> NodeId {
295        self.node
296    }
297}
298
299impl Component for ReactiveList {
300    fn view(&self) -> RenderNode {
301        // Subscribe to reconciles so the child group re-emits when items are added/removed/reordered.
302        self.version.get();
303        let _ = self.rect.get();
304        let st = self.state.borrow();
305        RenderNode::group(st.children.iter().map(|c| c.segment.boundary()))
306    }
307
308    fn on_event(&mut self, event: &Event) -> EventResult {
309        // The children are snapshotted and the borrow released *before* dispatch, because a handler is
310        // allowed to change the list. A row that deletes itself, a strip that commits a drag-to-reorder — each
311        // writes a signal this list's source reads, and the write flushes the reconcile effect synchronously,
312        // which needs this same `RefCell`. Holding it across dispatch made every one of those a panic.
313        //
314        // Cloning is a handful of `Rc` bumps, and it is what makes the re-entrancy safe rather than merely
315        // quiet: a child removed by the reconcile is still owned by this snapshot, so it finishes the event it
316        // is in the middle of instead of being dropped underneath itself.
317        let mut children = self.state.borrow().children.clone();
318        dispatch_container_event(&mut children, event)
319    }
320
321    fn debug_name(&self) -> &'static str {
322        "ReactiveList"
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::container::Container;
330    use crate::context::reset_layout_runtime;
331    use reactive_core::signal;
332
333    fn leaf() -> Result<Box<dyn LayoutItem>, LayoutError> {
334        Ok(Box::new(Container::new(
335            LayoutStyle::new().width(10.0).height(10.0),
336            vec![],
337        )?))
338    }
339
340    // The effect runs once at construction, so the list is populated from the initial source.
341    #[test]
342    fn builds_initial_items() {
343        reset_layout_runtime();
344        let items = signal(vec![1, 2, 3]);
345        let src = items.clone();
346        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
347        assert_eq!(list.state.borrow().children.len(), 3);
348    }
349
350    // A reorder-plus-remove reuses the persisting items' nodes (keyed) and drops the gone one.
351    #[test]
352    fn reconcile_reuses_nodes_on_reorder_and_remove() {
353        reset_layout_runtime();
354        let items = signal(vec![1, 2, 3]);
355        let src = items.clone();
356        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
357        let v1: Vec<NodeId> = list
358            .state
359            .borrow()
360            .children
361            .iter()
362            .map(|c| c.node())
363            .collect();
364        assert_eq!(v1.len(), 3);
365
366        // Outside a batch, `set` flushes the effect immediately → reconcile runs now.
367        items.set(vec![3, 1]);
368
369        let st = list.state.borrow();
370        assert_eq!(st.children.len(), 2, "item 2 should be dropped");
371        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
372        assert_eq!(v2[0], v1[2], "item 3 keeps its node, moved to front");
373        assert_eq!(v2[1], v1[0], "item 1 keeps its node");
374    }
375
376    // The full runtime flow: after a signal change, the new item is reconciled AND laid out (non-zero
377    // rect) once the runtime relayouts — proving relayout_if_dirty picks up a deep reactive change.
378    #[test]
379    fn added_item_gets_laid_out_after_relayout() {
380        use crate::context::{compute_layout, relayout_if_dirty, track_layout};
381        use layout_core::AvailableSpace;
382
383        reset_layout_runtime();
384        let items = signal(vec![1i32, 2]);
385        let src = items.clone();
386        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
387        let list_node = list.layout_node();
388        compute_layout(
389            list_node,
390            AvailableSpace::Definite(200.0),
391            AvailableSpace::Definite(200.0),
392        )
393        .unwrap();
394        assert!(
395            track_layout(list.state.borrow().children[0].node())
396                .unwrap()
397                .get()
398                .height
399                > 0.0,
400            "initial items should be laid out"
401        );
402
403        // A data change: the effect reconciles (adds item 3, dirtying the container up to the root).
404        items.set(vec![1, 2, 3]);
405        assert_eq!(list.state.borrow().children.len(), 3, "item added");
406
407        // The runtime relayouts every known root (the list node among them), which picks up the new item.
408        relayout_if_dirty();
409
410        let n2 = list.state.borrow().children[2].node();
411        assert!(
412            track_layout(n2).unwrap().get().height > 0.0,
413            "the newly added item must be laid out after relayout_if_dirty"
414        );
415    }
416
417    // Adding an item keeps the existing nodes and appends a fresh one.
418    #[test]
419    fn reconcile_appends_new_item() {
420        reset_layout_runtime();
421        let items = signal(vec![1, 2]);
422        let src = items.clone();
423        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
424        let v1: Vec<NodeId> = list
425            .state
426            .borrow()
427            .children
428            .iter()
429            .map(|c| c.node())
430            .collect();
431
432        items.set(vec![1, 2, 3]);
433
434        let st = list.state.borrow();
435        assert_eq!(st.children.len(), 3);
436        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
437        assert_eq!(&v2[..2], &v1[..], "existing items keep their nodes");
438    }
439
440    // `with_gap` lays out the parent container's flex-column gap, so item N+1 sits `item_height + gap`
441    // below item N instead of flush.
442    #[test]
443    fn with_gap_spaces_items_in_layout() {
444        use crate::context::compute_layout;
445        use layout_core::AvailableSpace;
446
447        reset_layout_runtime();
448        let items = signal(vec![1i32, 2]);
449        let src = items.clone();
450        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 8.0).unwrap();
451        let list_node = list.layout_node();
452        compute_layout(
453            list_node,
454            AvailableSpace::Definite(200.0),
455            AvailableSpace::Definite(200.0),
456        )
457        .unwrap();
458
459        let st = list.state.borrow();
460        let y0 = track_layout(st.children[0].node()).unwrap().get().y;
461        let y1 = track_layout(st.children[1].node()).unwrap().get().y;
462        assert_eq!(
463            y1 - y0,
464            18.0,
465            "each leaf is 10px tall; an 8px gap pushes the second item to 18px, not flush at 10px"
466        );
467    }
468
469    // A keyless reactive list (`for item in $items`, no `key` clause) reconciles by position: the item
470    // previously at index 0/1 keeps its node when a third item is appended past the end.
471    #[test]
472    fn positional_reuses_nodes_on_append() {
473        reset_layout_runtime();
474        let items = signal(vec![1, 2]);
475        let src = items.clone();
476        let list = ReactiveList::positional(move || src.get(), |_| leaf(), 0.0).unwrap();
477        let v1: Vec<NodeId> = list
478            .state
479            .borrow()
480            .children
481            .iter()
482            .map(|c| c.node())
483            .collect();
484
485        items.set(vec![1, 2, 3]);
486
487        let st = list.state.borrow();
488        assert_eq!(st.children.len(), 3);
489        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
490        assert_eq!(
491            &v2[..2],
492            &v1[..],
493            "the first two positions keep their nodes"
494        );
495    }
496
497    /// The trap this exists to remove: with an owned snapshot a persisting key reuses the widget and the new
498    /// value is thrown away, so a row that must follow its data has to be keyed on that data — which rebuilds
499    /// it, and takes its local state with it. `keyed` lets the row keep its widget *and* see the new value.
500    #[test]
501    fn a_keyed_row_keeps_its_widget_and_still_sees_its_new_value() {
502        reset_layout_runtime();
503        reactive_core::reset_runtime();
504
505        #[derive(Clone)]
506        struct Row {
507            id: u32,
508            text: &'static str,
509        }
510
511        let rows = signal(vec![Row {
512            id: 1,
513            text: "before",
514        }]);
515        let seen = Rc::new(RefCell::new(Vec::<&'static str>::new()));
516        let builds = Rc::new(RefCell::new(0usize));
517
518        let (sink, counter) = (Rc::clone(&seen), Rc::clone(&builds));
519        let source = rows.clone();
520        let list = ReactiveList::keyed(
521            move || source.get(),
522            |row: &Row| row.id,
523            move |held: reactive_core::ReadSignal<Row>| {
524                *counter.borrow_mut() += 1;
525                let sink = Rc::clone(&sink);
526                // Reading the handle inside an effect is what a real row's text/style closure does.
527                let watch = effect(move || sink.borrow_mut().push(held.get().text));
528                Ok(Box::new(crate::Container::column(vec![])?.keeping(watch))
529                    as Box<dyn LayoutItem>)
530            },
531        )
532        .unwrap();
533
534        let first = list.state.borrow().children[0].node();
535        rows.set(vec![Row {
536            id: 1,
537            text: "after",
538        }]);
539
540        assert_eq!(*builds.borrow(), 1, "the row was built once, not rebuilt");
541        assert_eq!(
542            list.state.borrow().children[0].node(),
543            first,
544            "and kept the very node it had"
545        );
546        assert_eq!(
547            *seen.borrow(),
548            vec!["before", "after"],
549            "while still seeing what it now says"
550        );
551    }
552
553    /// A child handler is allowed to change the list it is in — a row that deletes itself, a strip that
554    /// commits a drag-to-reorder. Both write a signal the source reads, which reconciles synchronously from
555    /// inside this list's own `on_event`; holding the state borrow (or reading a node back through a widget
556    /// that is mid-dispatch) made every one of those a panic rather than a feature.
557    #[test]
558    fn a_child_may_remove_itself_from_inside_its_own_handler() {
559        use crate::context::compute_layout;
560        use crate::styled_container::StyledContainer;
561        use layout_core::AvailableSpace;
562        use platform_core::{Event, PointerButton, PointerSource};
563
564        reset_layout_runtime();
565        let items = signal(vec![1i32, 2, 3]);
566        let src = items.clone();
567        let pressed = items.clone();
568        let mut list = ReactiveList::new(
569            move || src.get(),
570            |n: &i32| *n,
571            move |n: i32| {
572                let items = pressed.clone();
573                Ok(Box::new(
574                    StyledContainer::new(
575                        LayoutStyle::new().width(50.0).height(20.0),
576                        |_| Default::default(),
577                        vec![],
578                    )?
579                    .on_press(move || {
580                        items.set(items.peek().into_iter().filter(|x| *x != n).collect())
581                    }),
582                ) as Box<dyn LayoutItem>)
583            },
584            0.0,
585        )
586        .unwrap();
587        compute_layout(
588            list.layout_node(),
589            AvailableSpace::Definite(200.0),
590            AvailableSpace::Definite(200.0),
591        )
592        .unwrap();
593
594        // The second row: 20px tall each, so y=30 is inside it.
595        let at = |y: f64| (25.0, y);
596        let (x, y) = at(30.0);
597        list.on_event(&Event::PointerPressed {
598            x,
599            y,
600            button: PointerButton::Primary,
601            source: PointerSource::Mouse,
602        });
603        list.on_event(&Event::PointerReleased {
604            x,
605            y,
606            button: PointerButton::Primary,
607            source: PointerSource::Mouse,
608        });
609
610        assert_eq!(items.peek(), vec![1, 3]);
611        assert_eq!(list.state.borrow().children.len(), 2);
612    }
613}