telar-ui-core 0.1.0

Widget kernel for Telar: containers, text, input handling, scrolling and canvas primitives.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::rc::Rc;

use geometry_core::Rect;
use layout_core::{LayoutError, LayoutStyle, NodeId};
use platform_core::Event;
use reactive_core::{Effect, RwSignal, effect, signal};
use ui_tree::{Component, EventResult, RenderNode};

use crate::context::{new_container, remove_node, set_children, track_layout};
use crate::layout_item::{Child, LayoutItem, TrackedChildren, make_child};
use crate::pointer::dispatch_container_event;

/// A key erased to a `u64` so the list state stays non-generic. A collision would reuse the wrong item's
/// node, but a 64-bit hash makes that astronomically unlikely for the small, distinct keys a list uses.
fn hash_key<K: Hash>(k: &K) -> u64 {
    let mut h = DefaultHasher::new();
    k.hash(&mut h);
    h.finish()
}

/// Shared reconciliation state: the container node plus the current items in order and their key hashes.
/// Mutated by the reconcile effect (during the reactive flush) and read by `view`/`on_event` (during
/// render/dispatch) — never concurrently, so the `RefCell` never double-borrows.
struct ListState {
    node: NodeId,
    children: TrackedChildren,
    keys: Vec<u64>,
}

/// A reactive list: `for item in $items key id` (or, keyless, `for item in $items`) in `.rsx`. Re-runs its
/// source reactively and reconciles the item widgets — reused keys/positions keep their node/widget, new
/// ones are built, gone ones are disposed, and the layout children are reordered — instead of rebuilding the
/// whole block on every change. `new`/`with_gap` reconcile by key (identity-stable); `positional`/
/// `positional_with_gap` reconcile by index (no `key` clause needed, cheap append/truncate).
pub struct ReactiveList {
    node: NodeId,
    rect: RwSignal<Rect>,
    state: Rc<RefCell<ListState>>,
    // Bumped on every reconcile so `view()` (which reads it) re-emits the new/reordered child group.
    version: RwSignal<u64>,
    // Keeps the reconcile effect alive for the widget's lifetime.
    _effect: Effect,
}

impl ReactiveList {
    /// `source` reads the reactive item collection; `key` extracts a stable identity per item; `build`
    /// constructs one widget per item, creating its nodes against the live (thread-local) layout tree
    /// from inside the reconcile effect.
    pub fn new<Item, Key, S, K, B>(source: S, key: K, build: B) -> Result<Self, LayoutError>
    where
        Key: Hash + 'static,
        Item: 'static,
        S: Fn() -> Vec<Item> + 'static,
        K: Fn(&Item) -> Key + 'static,
        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
    {
        Self::build(
            LayoutStyle::new().flex_column(),
            source,
            build,
            move |item: &Item, _idx: usize| hash_key(&key(item)),
        )
    }

    /// Same as `new`, but with a `gap` (px) laid out between item containers — `for … key … gap:N` in `.rsx`.
    pub fn with_gap<Item, Key, S, K, B>(
        source: S,
        key: K,
        build: B,
        gap: f32,
    ) -> Result<Self, LayoutError>
    where
        Key: Hash + 'static,
        Item: 'static,
        S: Fn() -> Vec<Item> + 'static,
        K: Fn(&Item) -> Key + 'static,
        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
    {
        Self::build(
            LayoutStyle::new().flex_column().gap(gap),
            source,
            build,
            move |item: &Item, _idx: usize| hash_key(&key(item)),
        )
    }

    /// Keyed like [`new`](Self::new)/[`with_gap`](Self::with_gap), but the caller supplies the container's
    /// [`LayoutStyle`] — flex direction, gap, alignment. Use it for a horizontal reactive row (e.g. a bar's
    /// workspace chips), which the column-oriented constructors can't express.
    pub fn with_style<Item, Key, S, K, B>(
        container_style: LayoutStyle,
        source: S,
        key: K,
        build: B,
    ) -> Result<Self, LayoutError>
    where
        Key: Hash + 'static,
        Item: 'static,
        S: Fn() -> Vec<Item> + 'static,
        K: Fn(&Item) -> Key + 'static,
        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
    {
        Self::build(
            container_style,
            source,
            build,
            move |item: &Item, _idx: usize| hash_key(&key(item)),
        )
    }

    /// A keyless reactive list: `for item in $items` with no `key` clause. Reconciles by POSITION — the
    /// item at index `i` always reuses the node previously at index `i`, so an append/truncate reuses every
    /// surviving node cheaply, but a reorder rebuilds rather than moving nodes (no per-item identity without
    /// a key).
    pub fn positional<Item, S, B>(source: S, build: B) -> Result<Self, LayoutError>
    where
        Item: 'static,
        S: Fn() -> Vec<Item> + 'static,
        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
    {
        Self::build(
            LayoutStyle::new().flex_column(),
            source,
            build,
            |_item: &Item, idx: usize| idx as u64,
        )
    }

    /// `positional` with an item gap — `for item in $items gap:N` (no `key`).
    pub fn positional_with_gap<Item, S, B>(
        source: S,
        build: B,
        gap: f32,
    ) -> Result<Self, LayoutError>
    where
        Item: 'static,
        S: Fn() -> Vec<Item> + 'static,
        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
    {
        Self::build(
            LayoutStyle::new().flex_column().gap(gap),
            source,
            build,
            |_item: &Item, idx: usize| idx as u64,
        )
    }

    /// Shared constructor: `keyer` erases both reconciliation modes (hashed key, or plain index) to a
    /// `u64` so `reconcile` doesn't need to know which mode produced it.
    fn build<Item, S, B, KeyFn>(
        container_style: LayoutStyle,
        source: S,
        build: B,
        keyer: KeyFn,
    ) -> Result<Self, LayoutError>
    where
        Item: 'static,
        S: Fn() -> Vec<Item> + 'static,
        B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError> + 'static,
        KeyFn: Fn(&Item, usize) -> u64 + 'static,
    {
        let node = new_container(container_style, &[])?;
        let rect = track_layout(node).expect("list container is registered");
        let state = Rc::new(RefCell::new(ListState {
            node,
            children: Vec::new(),
            keys: Vec::new(),
        }));
        let version = signal(0u64);

        let eff_state = Rc::clone(&state);
        let eff_version = version.clone();
        // Runs once now (builds the initial list) and again on every change to a signal `source` reads.
        let _effect = effect(move || {
            let items = source();
            reconcile(&eff_state, items, &keyer, &build);
            eff_version.update(|v| *v = v.wrapping_add(1));
        });

        Ok(Self {
            node,
            rect,
            state,
            version,
            _effect,
        })
    }
}

fn reconcile<Item, KeyFn, B>(
    state: &Rc<RefCell<ListState>>,
    items: Vec<Item>,
    keyer: &KeyFn,
    build: &B,
) where
    KeyFn: Fn(&Item, usize) -> u64,
    B: Fn(Item) -> Result<Box<dyn LayoutItem>, LayoutError>,
{
    let mut st = state.borrow_mut();
    let container = st.node;

    // Index the current children by key hash so a persisting key reuses its widget/node.
    let old_keys = std::mem::take(&mut st.keys);
    let old_children = std::mem::take(&mut st.children);
    let mut old: HashMap<u64, Child> = HashMap::new();
    for (k, child) in old_keys.into_iter().zip(old_children) {
        old.entry(k).or_insert(child);
    }

    let mut children: TrackedChildren = Vec::with_capacity(items.len());
    let mut keys: Vec<u64> = Vec::with_capacity(items.len());
    let mut nodes: Vec<NodeId> = Vec::with_capacity(items.len());

    for (idx, item) in items.into_iter().enumerate() {
        let k = keyer(&item, idx);
        let child = match old.remove(&k) {
            Some(existing) => existing,
            None => make_child(build(item).expect("reactive list item build")),
        };
        nodes.push(child.node());
        children.push(child);
        keys.push(k);
    }

    st.children = children;
    st.keys = keys;
    drop(st);

    // Reorder/insert/drop in the layout tree, then free the nodes of items that went away. set_children
    // first so the disposed nodes are detached before remove_node frees them.
    let _ = set_children(container, &nodes);
    for (_, child) in old {
        remove_node(child.node());
    }
}

impl LayoutItem for ReactiveList {
    fn layout_node(&self) -> NodeId {
        self.node
    }
}

impl Component for ReactiveList {
    fn view(&self) -> RenderNode {
        // Subscribe to reconciles so the child group re-emits when items are added/removed/reordered.
        self.version.get();
        let _ = self.rect.get();
        let st = self.state.borrow();
        RenderNode::group(st.children.iter().map(|c| c.segment.boundary()))
    }

    fn on_event(&mut self, event: &Event) -> EventResult {
        let mut st = self.state.borrow_mut();
        dispatch_container_event(&mut st.children, event)
    }

    fn debug_name(&self) -> &'static str {
        "ReactiveList"
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::container::Container;
    use crate::context::reset_layout_runtime;
    use reactive_core::signal;

    fn leaf() -> Result<Box<dyn LayoutItem>, LayoutError> {
        Ok(Box::new(Container::new(
            LayoutStyle::new().width(10.0).height(10.0),
            vec![],
        )?))
    }

    // The effect runs once at construction, so the list is populated from the initial source.
    #[test]
    fn builds_initial_items() {
        reset_layout_runtime();
        let items = signal(vec![1, 2, 3]);
        let src = items.clone();
        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf()).unwrap();
        assert_eq!(list.state.borrow().children.len(), 3);
    }

    // A reorder-plus-remove reuses the persisting items' nodes (keyed) and drops the gone one.
    #[test]
    fn reconcile_reuses_nodes_on_reorder_and_remove() {
        reset_layout_runtime();
        let items = signal(vec![1, 2, 3]);
        let src = items.clone();
        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf()).unwrap();
        let v1: Vec<NodeId> = list
            .state
            .borrow()
            .children
            .iter()
            .map(|c| c.node())
            .collect();
        assert_eq!(v1.len(), 3);

        // Outside a batch, `set` flushes the effect immediately → reconcile runs now.
        items.set(vec![3, 1]);

        let st = list.state.borrow();
        assert_eq!(st.children.len(), 2, "item 2 should be dropped");
        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
        assert_eq!(v2[0], v1[2], "item 3 keeps its node, moved to front");
        assert_eq!(v2[1], v1[0], "item 1 keeps its node");
    }

    // The full runtime flow: after a signal change, the new item is reconciled AND laid out (non-zero
    // rect) once the runtime relayouts — proving relayout_if_dirty picks up a deep reactive change.
    #[test]
    fn added_item_gets_laid_out_after_relayout() {
        use crate::context::{compute_layout, relayout_if_dirty, track_layout};
        use layout_core::AvailableSpace;

        reset_layout_runtime();
        let items = signal(vec![1i32, 2]);
        let src = items.clone();
        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf()).unwrap();
        let list_node = list.layout_node();
        compute_layout(
            list_node,
            AvailableSpace::Definite(200.0),
            AvailableSpace::Definite(200.0),
        )
        .unwrap();
        assert!(
            track_layout(list.state.borrow().children[0].node())
                .unwrap()
                .get()
                .height
                > 0.0,
            "initial items should be laid out"
        );

        // A data change: the effect reconciles (adds item 3, dirtying the container up to the root).
        items.set(vec![1, 2, 3]);
        assert_eq!(list.state.borrow().children.len(), 3, "item added");

        // The runtime relayouts every known root (the list node among them), which picks up the new item.
        relayout_if_dirty();

        let n2 = list.state.borrow().children[2].node();
        assert!(
            track_layout(n2).unwrap().get().height > 0.0,
            "the newly added item must be laid out after relayout_if_dirty"
        );
    }

    // Adding an item keeps the existing nodes and appends a fresh one.
    #[test]
    fn reconcile_appends_new_item() {
        reset_layout_runtime();
        let items = signal(vec![1, 2]);
        let src = items.clone();
        let list = ReactiveList::new(move || src.get(), |n: &i32| *n, |_| leaf()).unwrap();
        let v1: Vec<NodeId> = list
            .state
            .borrow()
            .children
            .iter()
            .map(|c| c.node())
            .collect();

        items.set(vec![1, 2, 3]);

        let st = list.state.borrow();
        assert_eq!(st.children.len(), 3);
        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
        assert_eq!(&v2[..2], &v1[..], "existing items keep their nodes");
    }

    // `with_gap` lays out the parent container's flex-column gap, so item N+1 sits `item_height + gap`
    // below item N instead of flush.
    #[test]
    fn with_gap_spaces_items_in_layout() {
        use crate::context::compute_layout;
        use layout_core::AvailableSpace;

        reset_layout_runtime();
        let items = signal(vec![1i32, 2]);
        let src = items.clone();
        let list =
            ReactiveList::with_gap(move || src.get(), |n: &i32| *n, |_| leaf(), 8.0).unwrap();
        let list_node = list.layout_node();
        compute_layout(
            list_node,
            AvailableSpace::Definite(200.0),
            AvailableSpace::Definite(200.0),
        )
        .unwrap();

        let st = list.state.borrow();
        let y0 = track_layout(st.children[0].node()).unwrap().get().y;
        let y1 = track_layout(st.children[1].node()).unwrap().get().y;
        assert_eq!(
            y1 - y0,
            18.0,
            "each leaf is 10px tall; an 8px gap pushes the second item to 18px, not flush at 10px"
        );
    }

    // A keyless reactive list (`for item in $items`, no `key` clause) reconciles by position: the item
    // previously at index 0/1 keeps its node when a third item is appended past the end.
    #[test]
    fn positional_reuses_nodes_on_append() {
        reset_layout_runtime();
        let items = signal(vec![1, 2]);
        let src = items.clone();
        let list = ReactiveList::positional(move || src.get(), |_| leaf()).unwrap();
        let v1: Vec<NodeId> = list
            .state
            .borrow()
            .children
            .iter()
            .map(|c| c.node())
            .collect();

        items.set(vec![1, 2, 3]);

        let st = list.state.borrow();
        assert_eq!(st.children.len(), 3);
        let v2: Vec<NodeId> = st.children.iter().map(|c| c.node()).collect();
        assert_eq!(
            &v2[..2],
            &v1[..],
            "the first two positions keep their nodes"
        );
    }
}