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