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        let mut st = self.state.borrow_mut();
310        dispatch_container_event(&mut st.children, event)
311    }
312
313    fn debug_name(&self) -> &'static str {
314        "ReactiveList"
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::container::Container;
322    use crate::context::reset_layout_runtime;
323    use reactive_core::signal;
324
325    fn leaf() -> Result<Box<dyn LayoutItem>, LayoutError> {
326        Ok(Box::new(Container::new(
327            LayoutStyle::new().width(10.0).height(10.0),
328            vec![],
329        )?))
330    }
331
332    // The effect runs once at construction, so the list is populated from the initial source.
333    #[test]
334    fn builds_initial_items() {
335        reset_layout_runtime();
336        let items = signal(vec![1, 2, 3]);
337        let src = items.clone();
338        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
339        assert_eq!(list.state.borrow().children.len(), 3);
340    }
341
342    // A reorder-plus-remove reuses the persisting items' nodes (keyed) and drops the gone one.
343    #[test]
344    fn reconcile_reuses_nodes_on_reorder_and_remove() {
345        reset_layout_runtime();
346        let items = signal(vec![1, 2, 3]);
347        let src = items.clone();
348        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
349        let v1: Vec<NodeId> = list
350            .state
351            .borrow()
352            .children
353            .iter()
354            .map(|c| c.node())
355            .collect();
356        assert_eq!(v1.len(), 3);
357
358        // Outside a batch, `set` flushes the effect immediately → reconcile runs now.
359        items.set(vec![3, 1]);
360
361        let st = list.state.borrow();
362        assert_eq!(st.children.len(), 2, "item 2 should be dropped");
363        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
364        assert_eq!(v2[0], v1[2], "item 3 keeps its node, moved to front");
365        assert_eq!(v2[1], v1[0], "item 1 keeps its node");
366    }
367
368    // The full runtime flow: after a signal change, the new item is reconciled AND laid out (non-zero
369    // rect) once the runtime relayouts — proving relayout_if_dirty picks up a deep reactive change.
370    #[test]
371    fn added_item_gets_laid_out_after_relayout() {
372        use crate::context::{compute_layout, relayout_if_dirty, track_layout};
373        use layout_core::AvailableSpace;
374
375        reset_layout_runtime();
376        let items = signal(vec![1i32, 2]);
377        let src = items.clone();
378        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
379        let list_node = list.layout_node();
380        compute_layout(
381            list_node,
382            AvailableSpace::Definite(200.0),
383            AvailableSpace::Definite(200.0),
384        )
385        .unwrap();
386        assert!(
387            track_layout(list.state.borrow().children[0].node())
388                .unwrap()
389                .get()
390                .height
391                > 0.0,
392            "initial items should be laid out"
393        );
394
395        // A data change: the effect reconciles (adds item 3, dirtying the container up to the root).
396        items.set(vec![1, 2, 3]);
397        assert_eq!(list.state.borrow().children.len(), 3, "item added");
398
399        // The runtime relayouts every known root (the list node among them), which picks up the new item.
400        relayout_if_dirty();
401
402        let n2 = list.state.borrow().children[2].node();
403        assert!(
404            track_layout(n2).unwrap().get().height > 0.0,
405            "the newly added item must be laid out after relayout_if_dirty"
406        );
407    }
408
409    // Adding an item keeps the existing nodes and appends a fresh one.
410    #[test]
411    fn reconcile_appends_new_item() {
412        reset_layout_runtime();
413        let items = signal(vec![1, 2]);
414        let src = items.clone();
415        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 0.0).unwrap();
416        let v1: Vec<NodeId> = list
417            .state
418            .borrow()
419            .children
420            .iter()
421            .map(|c| c.node())
422            .collect();
423
424        items.set(vec![1, 2, 3]);
425
426        let st = list.state.borrow();
427        assert_eq!(st.children.len(), 3);
428        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
429        assert_eq!(&v2[..2], &v1[..], "existing items keep their nodes");
430    }
431
432    // `with_gap` lays out the parent container's flex-column gap, so item N+1 sits `item_height + gap`
433    // below item N instead of flush.
434    #[test]
435    fn with_gap_spaces_items_in_layout() {
436        use crate::context::compute_layout;
437        use layout_core::AvailableSpace;
438
439        reset_layout_runtime();
440        let items = signal(vec![1i32, 2]);
441        let src = items.clone();
442        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf(), 8.0).unwrap();
443        let list_node = list.layout_node();
444        compute_layout(
445            list_node,
446            AvailableSpace::Definite(200.0),
447            AvailableSpace::Definite(200.0),
448        )
449        .unwrap();
450
451        let st = list.state.borrow();
452        let y0 = track_layout(st.children[0].node()).unwrap().get().y;
453        let y1 = track_layout(st.children[1].node()).unwrap().get().y;
454        assert_eq!(
455            y1 - y0,
456            18.0,
457            "each leaf is 10px tall; an 8px gap pushes the second item to 18px, not flush at 10px"
458        );
459    }
460
461    // A keyless reactive list (`for item in $items`, no `key` clause) reconciles by position: the item
462    // previously at index 0/1 keeps its node when a third item is appended past the end.
463    #[test]
464    fn positional_reuses_nodes_on_append() {
465        reset_layout_runtime();
466        let items = signal(vec![1, 2]);
467        let src = items.clone();
468        let list = ReactiveList::positional(move || src.get(), |_| leaf(), 0.0).unwrap();
469        let v1: Vec<NodeId> = list
470            .state
471            .borrow()
472            .children
473            .iter()
474            .map(|c| c.node())
475            .collect();
476
477        items.set(vec![1, 2, 3]);
478
479        let st = list.state.borrow();
480        assert_eq!(st.children.len(), 3);
481        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
482        assert_eq!(
483            &v2[..2],
484            &v1[..],
485            "the first two positions keep their nodes"
486        );
487    }
488
489    /// The trap this exists to remove: with an owned snapshot a persisting key reuses the widget and the new
490    /// value is thrown away, so a row that must follow its data has to be keyed on that data — which rebuilds
491    /// it, and takes its local state with it. `keyed` lets the row keep its widget *and* see the new value.
492    #[test]
493    fn a_keyed_row_keeps_its_widget_and_still_sees_its_new_value() {
494        reset_layout_runtime();
495        reactive_core::reset_runtime();
496
497        #[derive(Clone)]
498        struct Row {
499            id: u32,
500            text: &'static str,
501        }
502
503        let rows = signal(vec![Row {
504            id: 1,
505            text: "before",
506        }]);
507        let seen = Rc::new(RefCell::new(Vec::<&'static str>::new()));
508        let builds = Rc::new(RefCell::new(0usize));
509
510        let (sink, counter) = (Rc::clone(&seen), Rc::clone(&builds));
511        let source = rows.clone();
512        let list = ReactiveList::keyed(
513            move || source.get(),
514            |row: &Row| row.id,
515            move |held: reactive_core::ReadSignal<Row>| {
516                *counter.borrow_mut() += 1;
517                let sink = Rc::clone(&sink);
518                // Reading the handle inside an effect is what a real row's text/style closure does.
519                let watch = effect(move || sink.borrow_mut().push(held.get().text));
520                Ok(Box::new(crate::Container::column(vec![])?.keeping(watch))
521                    as Box<dyn LayoutItem>)
522            },
523        )
524        .unwrap();
525
526        let first = list.state.borrow().children[0].node();
527        rows.set(vec![Row {
528            id: 1,
529            text: "after",
530        }]);
531
532        assert_eq!(*builds.borrow(), 1, "the row was built once, not rebuilt");
533        assert_eq!(
534            list.state.borrow().children[0].node(),
535            first,
536            "and kept the very node it had"
537        );
538        assert_eq!(
539            *seen.borrow(),
540            vec!["before", "after"],
541            "while still seeing what it now says"
542        );
543    }
544}