Skip to main content

teksilo_widgets/
repeater.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Repeater — non-virtualized dynamic widget list driven by a `ListModel<T>`.
5//!
6//! `Repeater` creates one child widget per item in a [`ListModel<T>`](teksilo_data::ListModel)
7//! using a caller-supplied factory closure, arranging them along one axis
8//! ([`RepeaterLayout::Vertical`] by default) or as a wrapping flow
9//! ([`RepeaterLayout::Wrap`]). It is **not virtualized**: every item has a live
10//! widget at all times. That is a deliberate trade — it is what lets the
11//! children keep real, stateful widgets (text editors, forms) mounted, which a
12//! virtualizing [`ListView`](crate::ListView) cannot do because it recycles
13//! off-screen rows.
14//!
15//! # [`Repeater::new`] — reconciling (the default)
16//!
17//! The factory takes `&item` and each child widget is **reused across model
18//! changes**. When the model mutates, `Repeater` reads the
19//! [`DataChange`] it emits and applies the *minimal* edit to its child set: an
20//! insert builds one new widget, a remove reaps one, a move reorders, an
21//! in-place update rebuilds only that item — every other child keeps its
22//! existing widget, and with it its focus, selection, caret, scroll offset,
23//! in-flight text edit, and undo history.
24//!
25//! This makes `Repeater` a fit for a **stack of editors** — e.g. a document
26//! rendered as a column of [`RichTextEditor`](crate::rich_text::RichTextEditor)s,
27//! one per scene/block:
28//!
29//! ```rust,ignore
30//! Repeater::new(scenes, |scene| {
31//!     Box::new(RichTextEditor::editor(scene.document()))
32//! })
33//! ```
34//!
35//! Inserting, deleting, or reordering a scene costs one widget's worth of work
36//! instead of reshaping every editor in the document, and the editor the user is
37//! typing in keeps its caret. Because the factory has no index, position shifts
38//! are safe by construction: reuse can never leave a widget showing content
39//! derived from a stale position. The one requirement is that an item's
40//! *content* only changes through the model (via `set`/`replace_all`), which is
41//! always true for a `ListModel`.
42//!
43//! ```rust
44//! # use teksilo_widgets::Repeater;
45//! # use teksilo_widgets::primitives::TextWidget;
46//! # use teksilo_data::ListModel;
47//! # use teksilo_i18n::lit;
48//! let model: ListModel<u32> = ListModel::from_vec(vec![1, 2, 3]);
49//! let _w = Repeater::new(model, |item| {
50//!     Box::new(TextWidget::new(lit!(format!("item {item}"))))
51//! })
52//! .spacing(4.0);
53//! ```
54//!
55//! # [`Repeater::indexed`] — full rebuild (position-in-content)
56//!
57//! When the content genuinely depends on position — a numbered list, "N of M",
58//! a ranking that must renumber on reorder — use [`indexed`](Repeater::indexed).
59//! Its factory takes `(index, &item)`, and on **any** model change the whole
60//! child subtree is torn down and rebuilt, so the index every widget shows is
61//! always current. This is the right pick for cheap, stateless, position-derived
62//! rows; it does **not** preserve per-child state across changes (that is the
63//! reason to prefer [`new`](Repeater::new) whenever the index isn't content).
64//!
65//! # Accessibility
66//!
67//! `Repeater` imposes **no** accessibility semantics of its own — it is a
68//! transparent layout wrapper, so its children surface directly into the
69//! surrounding AT subtree and their own roles decide how they read. When the
70//! children genuinely form a named list, menu, or toolbar, opt in with the
71//! standard builder overrides that every widget supports — these stay
72//! locale-reactive:
73//!
74//! ```rust,ignore
75//! use teksilo_core::accesskit::Role;
76//! Repeater::new(tags, factory)
77//!     .access_role(Role::List)
78//!     .access_label(tr!(tags()))
79//! ```
80
81use std::cell::RefCell;
82use std::rc::Rc;
83
84use teksilo_canvas::{Rect, SizeProposal};
85
86use teksilo_core::binding::BindingLevel;
87use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
88use teksilo_core::widget_id::WidgetId;
89
90use teksilo_data::{DataChange, ListModel};
91
92use crate::primitives::{HStack, VStack, Wrap};
93
94/// How a [`Repeater`] arranges its item widgets.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum RepeaterLayout {
97    /// A vertical column, top to bottom (default). Gap = [`Repeater::spacing`].
98    #[default]
99    Vertical,
100    /// A horizontal row, leading to trailing (RTL-aware via `HStack`).
101    /// Gap = [`Repeater::spacing`].
102    Horizontal,
103    /// A horizontal flow that wraps to the next line when items exceed the
104    /// available width — chip rows, badge lists. [`Repeater::spacing`] is the
105    /// inter-item gap, [`Repeater::line_spacing`] the inter-line gap.
106    Wrap,
107}
108
109/// The caller-supplied widget factory, in one of the two build-mode shapes.
110enum RepeaterFactory<T> {
111    /// `&item` — used by [`Repeater::new`]; position-independent, so the widget
112    /// can be reused when items shift (reconciling mode).
113    Keyless(Rc<dyn Fn(&T) -> Box<dyn Widget>>),
114    /// `(index, &item)` — used by [`Repeater::indexed`]; the whole subtree is
115    /// rebuilt on every change so the index is always current.
116    Indexed(Rc<dyn Fn(usize, &T) -> Box<dyn Widget>>),
117}
118
119/// One entry in the reconciliation table (reconciling mode only). Parallel to
120/// the model: `slots[i]` describes the widget for model item `i`.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum ItemSlot {
123    /// No widget yet — `build()` will construct one from the model item.
124    Vacant,
125    /// Reuse this already-mounted widget; its state is preserved.
126    Filled(WidgetId),
127}
128
129/// A non-virtualized dynamic collection that creates one child widget per item in a `ListModel<T>`.
130///
131/// See the [module-level docs](self) for the two build modes, layout options,
132/// and accessibility guidance.
133pub struct Repeater<T: 'static> {
134    model: ListModel<T>,
135    factory: RepeaterFactory<T>,
136    layout: RepeaterLayout,
137    spacing: f32,
138    line_spacing: f32,
139    /// Reconciliation table — `Some` in reconciling mode ([`Repeater::new`]),
140    /// `None` in full-rebuild mode ([`Repeater::indexed`]). Shared with the
141    /// model-change observer, which applies each [`DataChange`] to it so the
142    /// next `build()` can reuse surviving widgets. `Rc<RefCell<…>>` because the
143    /// observer runs outside `build()`; the handle persists across rebuilds.
144    slots: Option<Rc<RefCell<Vec<ItemSlot>>>>,
145    // Internal state (set during build)
146    container_id: Option<WidgetId>,
147}
148
149impl<T: 'static> Repeater<T> {
150    /// Create a Repeater in **reconciling** mode (the default): item widgets are
151    /// reused across model changes, so each child keeps its state (focus, caret,
152    /// selection, scroll, undo history) when siblings are inserted, removed, or
153    /// reordered.
154    ///
155    /// The `factory` receives `&item` only — it must not depend on the item's
156    /// position, which is what makes reuse safe when items shift. This is the
157    /// mode for a stack of stateful widgets such as `RichTextEditor`s. If the
158    /// content genuinely depends on position (a numbered list), use
159    /// [`Repeater::indexed`] instead. See the [module-level docs](self) for the
160    /// full rationale.
161    pub fn new(model: ListModel<T>, factory: impl Fn(&T) -> Box<dyn Widget> + 'static) -> Self {
162        Self {
163            model,
164            factory: RepeaterFactory::Keyless(Rc::new(factory)),
165            layout: RepeaterLayout::Vertical,
166            spacing: 0.0,
167            line_spacing: 0.0,
168            slots: Some(Rc::new(RefCell::new(Vec::new()))),
169            container_id: None,
170        }
171    }
172
173    /// Create a Repeater in **full-rebuild** mode: the `factory` receives
174    /// `(index, &item)` and the entire child subtree is rebuilt on any model
175    /// change, so position-derived content stays current.
176    ///
177    /// Use this only when the content depends on the item's position (row
178    /// numbers, "N of M", a ranking that renumbers on reorder). It does **not**
179    /// preserve per-child state across changes — prefer [`Repeater::new`]
180    /// whenever the index isn't part of what each item renders.
181    pub fn indexed(
182        model: ListModel<T>,
183        factory: impl Fn(usize, &T) -> Box<dyn Widget> + 'static,
184    ) -> Self {
185        Self {
186            model,
187            factory: RepeaterFactory::Indexed(Rc::new(factory)),
188            layout: RepeaterLayout::Vertical,
189            spacing: 0.0,
190            line_spacing: 0.0,
191            slots: None,
192            container_id: None,
193        }
194    }
195
196    /// Choose how items are arranged (default [`RepeaterLayout::Vertical`]).
197    pub fn layout(mut self, layout: RepeaterLayout) -> Self {
198        self.layout = layout;
199        self
200    }
201
202    /// Arrange items horizontally — shorthand for `.layout(RepeaterLayout::Horizontal)`.
203    pub fn horizontal(self) -> Self {
204        self.layout(RepeaterLayout::Horizontal)
205    }
206
207    /// Arrange items as a wrapping flow — shorthand for `.layout(RepeaterLayout::Wrap)`.
208    pub fn wrap(self) -> Self {
209        self.layout(RepeaterLayout::Wrap)
210    }
211
212    /// Set the gap between items along the main axis (default 0.0). For
213    /// [`RepeaterLayout::Wrap`] this is the inter-item (horizontal) gap.
214    pub fn spacing(mut self, spacing: f32) -> Self {
215        self.spacing = spacing;
216        self
217    }
218
219    /// Set the gap between lines for [`RepeaterLayout::Wrap`] (default 0.0).
220    /// Ignored by the single-axis layouts.
221    pub fn line_spacing(mut self, line_spacing: f32) -> Self {
222        self.line_spacing = line_spacing;
223        self
224    }
225
226    /// Build the item widgets for `build()`, returning their ids in model order.
227    ///
228    /// In reconciling mode the reconciliation table is first squared with the
229    /// current model length, then each `Filled` slot is reused as-is and each
230    /// `Vacant` slot is constructed and recorded — so only genuinely new /
231    /// changed items pay a build. In indexed mode every item is (re)built.
232    fn build_item_ids(&self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
233        let count = self.model.len();
234        let mut ids = Vec::with_capacity(count);
235
236        match &self.factory {
237            RepeaterFactory::Keyless(factory) => {
238                let factory = factory.clone();
239                let slots_rc = self
240                    .slots
241                    .clone()
242                    .expect("reconciling mode always has a reconciliation table");
243                let mut slots = slots_rc.borrow_mut();
244
245                // Square the table with the model. Normally the observer keeps it
246                // in lock-step; this handles the first build (empty table) and is
247                // a defensive backstop against any missed notification. Surplus
248                // `Filled` slots are dropped here — their widgets fall out of the
249                // returned set and the reconciling rebuild reaps them.
250                if slots.len() < count {
251                    slots.resize(count, ItemSlot::Vacant);
252                } else if slots.len() > count {
253                    slots.truncate(count);
254                }
255
256                for i in 0..count {
257                    let id = match slots[i] {
258                        ItemSlot::Filled(id) => id,
259                        ItemSlot::Vacant => {
260                            let widget = self
261                                .model
262                                .with_item(i, |item| factory(item))
263                                .expect("index < len() so with_item yields Some");
264                            let id = ctx.add_boxed(widget);
265                            slots[i] = ItemSlot::Filled(id);
266                            id
267                        }
268                    };
269                    ids.push(id);
270                }
271            }
272            RepeaterFactory::Indexed(factory) => {
273                let factory = factory.clone();
274                for i in 0..count {
275                    // `i < count == len()`, so `with_item` always yields `Some`;
276                    // the guard is a total-safety fallback, never taken here.
277                    if let Some(widget) = self.model.with_item(i, |item| factory(i, item)) {
278                        ids.push(ctx.add_boxed(widget));
279                    }
280                }
281            }
282        }
283
284        ids
285    }
286
287    /// Wrap the ordered item ids in the container primitive for this layout.
288    fn build_container(
289        &self,
290        ctx: &mut teksilo_core::build_context::BuildContext,
291        item_ids: &[WidgetId],
292    ) -> WidgetId {
293        match self.layout {
294            RepeaterLayout::Vertical => {
295                let mut container = VStack::new().spacing(self.spacing);
296                for &id in item_ids {
297                    container = container.add_child(id);
298                }
299                ctx.add(container)
300            }
301            RepeaterLayout::Horizontal => {
302                let mut container = HStack::new().spacing(self.spacing);
303                for &id in item_ids {
304                    container = container.add_child(id);
305                }
306                ctx.add(container)
307            }
308            RepeaterLayout::Wrap => {
309                let mut container = Wrap::new()
310                    .spacing(self.spacing)
311                    .line_spacing(self.line_spacing);
312                for &id in item_ids {
313                    container = container.add_child(id);
314                }
315                ctx.add(container)
316            }
317        }
318    }
319}
320
321/// Fold a single [`DataChange`] into the reconciliation table so the next
322/// `build()` reuses surviving widgets and rebuilds only what actually changed.
323/// All index arithmetic is bounds-clamped: a malformed range can never panic
324/// here, only under- or over-reconcile (which `build_item_ids` then squares up).
325fn apply_data_change(slots: &mut Vec<ItemSlot>, change: &DataChange) {
326    match change {
327        DataChange::ItemsInserted { range } => {
328            let start = range.start.min(slots.len());
329            let n = range.len();
330            slots.splice(start..start, std::iter::repeat_n(ItemSlot::Vacant, n));
331        }
332        DataChange::ItemsRemoved { range } => {
333            let start = range.start.min(slots.len());
334            let end = range.end.min(slots.len());
335            if start < end {
336                slots.drain(start..end);
337            }
338        }
339        DataChange::ItemsMoved { from, to, count } => {
340            let (from, to, count) = (*from, *to, *count);
341            if count == 0 || from >= slots.len() {
342                return;
343            }
344            // Remove the block at `from`, then reinsert so its first item lands
345            // at `to` (a post-removal index) — mirrors `ListModel::move_item`.
346            let end = (from + count).min(slots.len());
347            let moved: Vec<ItemSlot> = slots.drain(from..end).collect();
348            let insert_at = to.min(slots.len());
349            slots.splice(insert_at..insert_at, moved);
350        }
351        DataChange::ItemUpdated { index } => {
352            // Content changed in place — rebuild just this widget. Dropping the
353            // old id from the table lets the reconciling rebuild reap it.
354            if *index < slots.len() {
355                slots[*index] = ItemSlot::Vacant;
356            }
357        }
358        DataChange::WindowLoaded { range } => {
359            // A `ListModel` never emits this (only windowed `ListDataSource`s do),
360            // but a `Repeater` can be pointed at one via a wrapper — treat the
361            // window as needing (re)build.
362            for i in range.clone() {
363                if i < slots.len() {
364                    slots[i] = ItemSlot::Vacant;
365                }
366            }
367        }
368        DataChange::Reset => {
369            // Discard everything; `build_item_ids` re-fills to the new length.
370            slots.clear();
371        }
372    }
373}
374
375impl<T: 'static> std::fmt::Debug for Repeater<T> {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        f.debug_struct("Repeater")
378            .field("item_count", &self.model.len())
379            .field("layout", &self.layout)
380            .field("spacing", &self.spacing)
381            .field(
382                "mode",
383                &if self.slots.is_some() {
384                    "reconciling"
385                } else {
386                    "indexed"
387                },
388            )
389            .finish()
390    }
391}
392
393impl<T: 'static> Widget for Repeater<T> {
394    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
395        // A version counter bound at `Rebuild` level: every model mutation bumps
396        // it, dirtying this widget for a rebuild on the next pass.
397        let version = ctx.signal(0_u64);
398        version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
399
400        // Observe model changes. In reconciling mode the observer *also* folds
401        // each change into the reconciliation table so the upcoming rebuild
402        // reuses surviving widgets; in indexed mode it just bumps the version.
403        let slots_for_observer = self.slots.clone();
404        let version_for_observer = version.clone();
405        let handle = self.model.observe_changes(move |change| {
406            if let Some(slots) = &slots_for_observer {
407                apply_data_change(&mut slots.borrow_mut(), change);
408            }
409            version_for_observer.set(version_for_observer.get().wrapping_add(1));
410        });
411        ctx.own_handle(handle);
412
413        let item_ids = self.build_item_ids(ctx);
414        let root = self.build_container(ctx, &item_ids);
415        self.container_id = Some(root);
416        vec![root]
417    }
418
419    /// Reconciling mode reuses item widgets across rebuilds by re-attaching them
420    /// to a freshly-built container. Returning `true` makes the reconciling
421    /// rebuild path preserve any child still present after `build()` instead of
422    /// tearing the whole subtree down first — the reused widgets survive with
423    /// their state, and only the items the new build dropped are reaped.
424    fn preserves_children_on_rebuild(&self) -> bool {
425        self.slots.is_some()
426    }
427
428    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
429        self.container_id
430            .and_then(|id| ctx.child_size(id, proposal))
431            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
432            .into()
433    }
434
435    fn place_children(
436        &self,
437        bounds: Rect,
438        _proposal: SizeProposal,
439        children: &mut [WidgetPlacement],
440        _ctx: &LayoutContext,
441    ) {
442        // Exactly one child (the container); fill this widget's bounds with it.
443        for child in children.iter_mut() {
444            child.origin = bounds.origin();
445            child.size = bounds.size();
446        }
447    }
448
449    fn children(&self) -> Vec<WidgetId> {
450        self.container_id.into_iter().collect()
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use std::cell::Cell;
458    use teksilo_canvas::Size;
459    use teksilo_core::widget_tree::WidgetTree;
460
461    #[derive(Debug)]
462    struct FixedLeaf(f32, f32);
463    impl Widget for FixedLeaf {
464        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
465            Size::new(self.0, self.1).into()
466        }
467    }
468
469    /// A leaf that increments a shared counter each time it is *constructed*.
470    /// Lets a test tell a rebuilt widget from a reused one.
471    #[derive(Debug)]
472    struct CountingLeaf {
473        _tag: u32,
474    }
475    impl CountingLeaf {
476        fn new(tag: u32, builds: &Rc<Cell<u32>>) -> Self {
477            builds.set(builds.get() + 1);
478            Self { _tag: tag }
479        }
480    }
481    impl Widget for CountingLeaf {
482        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
483            Size::new(50.0, 20.0).into()
484        }
485    }
486
487    /// The item widgets are the grandchildren: Repeater -> container -> items.
488    fn item_ids(tree: &WidgetTree, repeater_id: WidgetId) -> Vec<WidgetId> {
489        let container = tree.children(repeater_id)[0];
490        tree.children(container)
491    }
492
493    /// A reconciling Repeater over `&str`, counting factory invocations.
494    fn counting_repeater(
495        model: &ListModel<&'static str>,
496        builds: &Rc<Cell<u32>>,
497    ) -> Repeater<&'static str> {
498        let builds_f = builds.clone();
499        Repeater::new(model.clone(), move |item: &&str| {
500            Box::new(CountingLeaf::new(item.len() as u32, &builds_f))
501        })
502    }
503
504    // ---- Structure & layout (reconciling `new`) -------------------------
505
506    #[test]
507    fn creates_children_from_model() {
508        let model = ListModel::from_vec(vec!["a", "b", "c"]);
509        let mut tree = WidgetTree::new();
510
511        let repeater_id = tree.add(Repeater::new(model, |_item| {
512            Box::new(FixedLeaf(100.0, 30.0))
513        }));
514        tree.layout(SizeProposal::exact(200.0, 400.0));
515
516        let repeater_children = tree.children(repeater_id);
517        assert_eq!(repeater_children.len(), 1); // the container
518        assert_eq!(item_ids(&tree, repeater_id).len(), 3);
519    }
520
521    #[test]
522    fn empty_model_creates_no_children() {
523        let model: ListModel<&str> = ListModel::new();
524        let mut tree = WidgetTree::new();
525
526        let repeater_id = tree.add(Repeater::new(model, |_item| {
527            Box::new(FixedLeaf(100.0, 30.0))
528        }));
529        tree.layout(SizeProposal::exact(200.0, 400.0));
530        assert_eq!(item_ids(&tree, repeater_id).len(), 0);
531    }
532
533    #[test]
534    fn push_adds_a_child() {
535        let model = ListModel::from_vec(vec!["a", "b"]);
536        let mut tree = WidgetTree::new();
537
538        let repeater_id = tree.add(Repeater::new(model.clone(), |_item| {
539            Box::new(FixedLeaf(100.0, 30.0))
540        }));
541        tree.layout(SizeProposal::exact(200.0, 400.0));
542        assert_eq!(item_ids(&tree, repeater_id).len(), 2);
543
544        model.push("c");
545        tree.layout(SizeProposal::exact(200.0, 400.0));
546        assert_eq!(item_ids(&tree, repeater_id).len(), 3);
547    }
548
549    #[test]
550    fn spacing_is_applied() {
551        let model = ListModel::from_vec(vec!["a", "b", "c"]);
552        let mut tree = WidgetTree::new();
553
554        let repeater_id =
555            tree.add(Repeater::new(model, |_item| Box::new(FixedLeaf(100.0, 20.0))).spacing(10.0));
556        tree.layout(SizeProposal::exact(200.0, 400.0));
557
558        let children = item_ids(&tree, repeater_id);
559        assert_eq!(children.len(), 3);
560        let y0 = tree.bounds(children[0]).y;
561        let y1 = tree.bounds(children[1]).y;
562        let y2 = tree.bounds(children[2]).y;
563        assert!((y1 - y0 - 30.0).abs() < 0.01); // 20 height + 10 spacing
564        assert!((y2 - y1 - 30.0).abs() < 0.01);
565    }
566
567    #[test]
568    fn horizontal_layout_places_children_across() {
569        let model = ListModel::from_vec(vec!["a", "b", "c"]);
570        let mut tree = WidgetTree::new();
571
572        let repeater_id = tree.add(
573            Repeater::new(model, |_item| Box::new(FixedLeaf(40.0, 20.0)))
574                .horizontal()
575                .spacing(10.0),
576        );
577        tree.layout(SizeProposal::exact(400.0, 100.0));
578
579        let children = item_ids(&tree, repeater_id);
580        assert_eq!(children.len(), 3);
581        let x0 = tree.bounds(children[0]).x;
582        let x1 = tree.bounds(children[1]).x;
583        let x2 = tree.bounds(children[2]).x;
584        // Same row, advancing by width + spacing.
585        assert!((tree.bounds(children[0]).y - tree.bounds(children[1]).y).abs() < 0.01);
586        assert!((x1 - x0 - 50.0).abs() < 0.01); // 40 width + 10 spacing
587        assert!((x2 - x1 - 50.0).abs() < 0.01);
588    }
589
590    #[test]
591    fn wrap_layout_flows_to_next_line() {
592        let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
593        let mut tree = WidgetTree::new();
594
595        // Width fits two 40 px items per line (with 10 px gap) but not three.
596        let repeater_id = tree.add(
597            Repeater::new(model, |_item| Box::new(FixedLeaf(40.0, 20.0)))
598                .wrap()
599                .spacing(10.0)
600                .line_spacing(6.0),
601        );
602        tree.layout(SizeProposal::exact(100.0, 200.0));
603
604        let children = item_ids(&tree, repeater_id);
605        assert_eq!(children.len(), 4);
606        let y_first = tree.bounds(children[0]).y;
607        assert!(
608            tree.bounds(children[2]).y > y_first + 0.01,
609            "third item should wrap to the next line"
610        );
611    }
612
613    // ---- Reconciliation: state-preserving `new` -------------------------
614
615    #[test]
616    fn reuses_widget_ids_on_insert() {
617        let model = ListModel::from_vec(vec!["a", "b", "c"]);
618        let builds = Rc::new(Cell::new(0));
619        let mut tree = WidgetTree::new();
620
621        let repeater_id = tree.add(counting_repeater(&model, &builds));
622        tree.layout(SizeProposal::exact(200.0, 400.0));
623        let before = item_ids(&tree, repeater_id);
624        assert_eq!(before.len(), 3);
625        assert_eq!(builds.get(), 3, "each item built once");
626
627        // Insert at the front: a, b, c must keep their widget ids.
628        model.insert(0, "z");
629        tree.layout(SizeProposal::exact(200.0, 400.0));
630
631        let after = item_ids(&tree, repeater_id);
632        assert_eq!(after.len(), 4);
633        assert_eq!(builds.get(), 4, "only the inserted item built anew");
634        assert_eq!(&after[1..], &before[..], "survivors keep their widgets");
635        assert!(!before.contains(&after[0]), "index 0 is a fresh widget");
636    }
637
638    #[test]
639    fn reorder_preserves_all_widgets() {
640        let model = ListModel::from_vec(vec!["a", "b", "c"]);
641        let builds = Rc::new(Cell::new(0));
642        let mut tree = WidgetTree::new();
643
644        let repeater_id = tree.add(counting_repeater(&model, &builds));
645        tree.layout(SizeProposal::exact(200.0, 400.0));
646        let before = item_ids(&tree, repeater_id);
647        assert_eq!(builds.get(), 3);
648
649        model.move_item(0, 2);
650        tree.layout(SizeProposal::exact(200.0, 400.0));
651
652        let after = item_ids(&tree, repeater_id);
653        assert_eq!(builds.get(), 3, "reorder builds nothing");
654        assert_eq!(after, vec![before[1], before[2], before[0]]);
655    }
656
657    #[test]
658    fn remove_reaps_only_removed() {
659        let model = ListModel::from_vec(vec!["a", "b", "c"]);
660        let builds = Rc::new(Cell::new(0));
661        let mut tree = WidgetTree::new();
662
663        let repeater_id = tree.add(counting_repeater(&model, &builds));
664        tree.layout(SizeProposal::exact(200.0, 400.0));
665        let before = item_ids(&tree, repeater_id);
666
667        model.remove(1);
668        tree.layout(SizeProposal::exact(200.0, 400.0));
669
670        let after = item_ids(&tree, repeater_id);
671        assert_eq!(builds.get(), 3, "remove builds nothing");
672        assert_eq!(after, vec![before[0], before[2]]);
673        assert!(!tree.is_active(before[1]), "removed widget reaped");
674    }
675
676    #[test]
677    fn update_rebuilds_only_that_item() {
678        let model = ListModel::from_vec(vec!["a", "b", "c"]);
679        let builds = Rc::new(Cell::new(0));
680        let mut tree = WidgetTree::new();
681
682        let repeater_id = tree.add(counting_repeater(&model, &builds));
683        tree.layout(SizeProposal::exact(200.0, 400.0));
684        let before = item_ids(&tree, repeater_id);
685
686        model.set(1, "beta");
687        tree.layout(SizeProposal::exact(200.0, 400.0));
688
689        let after = item_ids(&tree, repeater_id);
690        assert_eq!(builds.get(), 4, "exactly one extra build for the update");
691        assert_eq!(after[0], before[0], "unchanged neighbours reused");
692        assert_eq!(after[2], before[2], "unchanged neighbours reused");
693        assert_ne!(after[1], before[1], "updated item is a fresh widget");
694        assert!(!tree.is_active(before[1]), "stale widget reaped");
695    }
696
697    #[test]
698    fn reset_rebuilds_all() {
699        let model = ListModel::from_vec(vec!["a", "b"]);
700        let builds = Rc::new(Cell::new(0));
701        let mut tree = WidgetTree::new();
702
703        let repeater_id = tree.add(counting_repeater(&model, &builds));
704        tree.layout(SizeProposal::exact(200.0, 400.0));
705        let before = item_ids(&tree, repeater_id);
706        assert_eq!(builds.get(), 2);
707
708        model.replace_all(vec!["x", "y", "z"]);
709        tree.layout(SizeProposal::exact(200.0, 400.0));
710
711        let after = item_ids(&tree, repeater_id);
712        assert_eq!(after.len(), 3);
713        assert_eq!(builds.get(), 5, "all three rebuilt after a reset");
714        for id in &before {
715            assert!(!after.contains(id), "no widget survives a reset");
716        }
717    }
718
719    #[test]
720    fn preserves_child_signal_state_across_insert() {
721        // The point of reconciling mode: a child that owns mutable state keeps
722        // it when a sibling is inserted. We model "state" as a signal the child
723        // holds; reuse ⟺ the same signal value survives.
724        use teksilo_core::signal::Signal;
725
726        #[derive(Debug)]
727        struct Stateful {
728            state: Signal<u32>,
729        }
730        impl Widget for Stateful {
731            fn build(
732                &mut self,
733                ctx: &mut teksilo_core::build_context::BuildContext,
734            ) -> Vec<WidgetId> {
735                // Relayout when the state changes, so a mid-test `set` actually
736                // re-measures. The binding rides on this widget's own id and thus
737                // survives the Repeater's reconciling rebuild (the child is reused,
738                // not rebuilt, so its bindings are never torn down).
739                self.state.bind_to(
740                    ctx.self_id(),
741                    ctx.binding_registry(),
742                    BindingLevel::Relayout,
743                );
744                vec![]
745            }
746            fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
747                // Encode the live state into the height so the tree can read it.
748                Size::new(20.0, self.state.get() as f32).into()
749            }
750        }
751
752        let seeds: Rc<RefCell<Vec<Signal<u32>>>> = Rc::new(RefCell::new(Vec::new()));
753        let model = ListModel::from_vec(vec![1_u32, 2, 3]);
754        let mut tree = WidgetTree::new();
755
756        let seeds_f = seeds.clone();
757        let repeater_id = tree.add(Repeater::new(model.clone(), move |item: &u32| {
758            let state = Signal::new(*item);
759            seeds_f.borrow_mut().push(state.clone());
760            Box::new(Stateful { state })
761        }));
762
763        tree.layout(SizeProposal::exact(200.0, 400.0));
764        let before = item_ids(&tree, repeater_id);
765        // Mutate the middle child's live state to a sentinel.
766        seeds.borrow()[1].set(999);
767        tree.layout(SizeProposal::exact(200.0, 400.0));
768        assert!((tree.bounds(before[1]).height - 999.0).abs() < 0.01);
769
770        // Insert at the front; the middle child (now at index 2) must keep 999.
771        model.insert(0, 0);
772        tree.layout(SizeProposal::exact(200.0, 400.0));
773
774        let after = item_ids(&tree, repeater_id);
775        assert_eq!(after[2], before[1], "the stateful child was reused");
776        assert!(
777            (tree.bounds(after[2]).height - 999.0).abs() < 0.01,
778            "reused child kept its mutated state"
779        );
780    }
781
782    #[test]
783    fn preserves_focus_across_insert() {
784        // The Skribisto guarantee: the editor the user is in keeps focus when a
785        // scene is inserted elsewhere in the document.
786        use teksilo_core::widget_builder::WidgetBuilder;
787
788        let model = ListModel::from_vec(vec!["a", "b", "c"]);
789        let mut tree = WidgetTree::new();
790
791        let repeater_id = tree.add(Repeater::new(model.clone(), |_item: &&str| {
792            Box::new(FixedLeaf(50.0, 20.0).focusable(true))
793        }));
794
795        tree.layout(SizeProposal::exact(200.0, 400.0));
796        let before = item_ids(&tree, repeater_id);
797
798        // Focus the middle child, then insert a sibling above it.
799        tree.focus(before[1]);
800        assert_eq!(tree.focused(), Some(before[1]));
801
802        model.insert(0, "z");
803        tree.layout(SizeProposal::exact(200.0, 400.0));
804
805        let after = item_ids(&tree, repeater_id);
806        assert_eq!(after[2], before[1], "the focused child was reused");
807        assert_eq!(
808            tree.focused(),
809            Some(before[1]),
810            "focus stays on the same widget across the insert"
811        );
812    }
813
814    // ---- Full-rebuild `indexed` -----------------------------------------
815
816    #[test]
817    fn indexed_factory_receives_index_and_item() {
818        let model = ListModel::from_vec(vec![10.0_f32, 20.0, 30.0]);
819        let mut tree = WidgetTree::new();
820
821        let repeater_id = tree.add(Repeater::indexed(model, |i, item| {
822            // Width encodes the index, height encodes the item value.
823            Box::new(FixedLeaf(i as f32, *item))
824        }));
825        tree.layout(SizeProposal::exact(200.0, 400.0));
826
827        let children = item_ids(&tree, repeater_id);
828        assert_eq!(children.len(), 3);
829        assert!((tree.bounds(children[0]).height - 10.0).abs() < 0.01);
830        assert!((tree.bounds(children[1]).height - 20.0).abs() < 0.01);
831        assert!((tree.bounds(children[2]).height - 30.0).abs() < 0.01);
832    }
833
834    #[test]
835    fn indexed_rebuilds_every_child_on_change() {
836        // Indexed mode does NOT preserve widgets — every child is rebuilt on any
837        // change, which is what keeps position-derived content correct.
838        let model = ListModel::from_vec(vec!["a", "b", "c"]);
839        let builds = Rc::new(Cell::new(0));
840        let mut tree = WidgetTree::new();
841
842        let builds_f = builds.clone();
843        let repeater_id = tree.add(Repeater::indexed(model.clone(), move |_i, item: &&str| {
844            Box::new(CountingLeaf::new(item.len() as u32, &builds_f))
845        }));
846
847        tree.layout(SizeProposal::exact(200.0, 400.0));
848        let before = item_ids(&tree, repeater_id);
849        assert_eq!(builds.get(), 3);
850
851        // A single insert rebuilds the whole subtree (4 fresh widgets), and none
852        // of the old widget ids survive.
853        model.insert(0, "z");
854        tree.layout(SizeProposal::exact(200.0, 400.0));
855
856        let after = item_ids(&tree, repeater_id);
857        assert_eq!(after.len(), 4);
858        assert_eq!(builds.get(), 7, "3 initial + 4 rebuilt");
859        for id in &before {
860            assert!(!after.contains(id), "no widget is reused in indexed mode");
861        }
862    }
863
864    // ---- Accessibility: transparent by default, opt-in via overrides ----
865
866    #[test]
867    fn accepts_standard_access_overrides() {
868        use teksilo_core::accesskit::Role;
869        use teksilo_core::widget_builder::WidgetBuilder;
870        use teksilo_i18n::lit;
871
872        let model = ListModel::from_vec(vec!["a", "b"]);
873        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
874
875        let repeater_id = tree.add(
876            Repeater::new(model, |_item| Box::new(FixedLeaf(100.0, 20.0)))
877                .access_role(Role::List)
878                .access_label(lit!("Tags")),
879        );
880        tree.layout(SizeProposal::exact(200.0, 200.0));
881
882        let node = tree.accessibility_node(repeater_id);
883        assert_eq!(node.role(), Role::List);
884        assert_eq!(node.name(), Some("Tags"));
885    }
886}