Skip to main content

teksilo_scene/
scene_list_adapter.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SceneListAdapter`] — keep lightweight scene items in sync with a
5//! `teksilo_data` list model or data source.
6//!
7//! A scene's lightweight tier ([`SceneItem`]) has no arena-backed identity
8//! and no built-in notion of "one item per row of some data collection" —
9//! unlike `ListView`/`TableView`, which rebuild their child widgets from a
10//! `ListModel<T>` / `ListDataSource<Item = T>` automatically. `SceneListAdapter`
11//! is the scene-tier equivalent: give it a data source and a delegate
12//! (`Fn(&T, usize) -> Box<dyn SceneItem>`), and it materialises one scene
13//! item per row, then reconciles them whenever the source changes.
14//!
15//! `SceneListAdapter` is a **plain struct**, not a `Widget` — it owns no
16//! arena node. Build one from a handler or `build()`, keep it alive for as
17//! long as you want the items tracked (typically stashed in the owning
18//! widget), and it does its work purely through [`SceneModel`] mutations and
19//! a `teksilo_data` change observer.
20//!
21//! ## Delegate contract
22//!
23//! The delegate's return value — a `Box<dyn SceneItem>` — carries its own
24//! **absolute scene position** via [`SceneItem::local_bounds`] (exactly like
25//! any other item constructed with `RectItem::new(Rect::new(x, y, w, h))`
26//! and typically placed at `Point::ZERO`). `SceneListAdapter` always inserts
27//! the delegate's item at [`Point::ZERO`] via
28//! [`SceneModel::add_boxed_item`] — it never re-positions the item. If rows
29//! should be laid out (grid, list, freeform), the delegate itself computes
30//! each row's `local_bounds` from its `index` (or from data on `T`) before
31//! returning the boxed item.
32//!
33//! **The delegate must not mutate the source model.** It is invoked *inside*
34//! the source's row-read (`ListModel::with_item`, which holds the model's
35//! `RefCell` borrow across the callback), so calling `push` / `set` / `remove`
36//! / `clear` on the same model from within the delegate panics with
37//! `RefCell already borrowed`. Treat the delegate as a pure
38//! `(&T, index) -> item` projection; drive data changes from outside it. (This
39//! is the same contract `ListView`'s delegate has, for the same reason.)
40//!
41//! ## Reconciliation policy
42//!
43//! A lightweight item has no inherent identity beyond the id the Scene
44//! mints for it — there is nothing to "patch" in place, only remove-and-add.
45//! `SceneListAdapter` picks the simplest policy that is always correct:
46//!
47//! - **Structural changes** (insert / remove / move / reset — and a windowed
48//!   source's `WindowLoaded`, see below) rebuild **every** item: every
49//!   adapter-owned id is removed from the scene, then the current source
50//!   is re-read start to finish and one item is built per row. This is
51//!   O(n) but never leaks an item and never desyncs data-index → item
52//!   mapping, even when the delegate's output depends on `index` (which
53//!   shifts on insert/remove/move). Incremental insert/remove that spares
54//!   unaffected rows is a possible future optimisation, not implemented here.
55//! - **`ItemUpdated { index }`** (single-row content change, no structural
56//!   shift) rebuilds only that one row: the old scene item is removed and a
57//!   fresh one built from the current data at `index` replaces it.
58//! - **`WindowLoaded { range }`** is treated as a structural change (full
59//!   rebuild), not a per-row patch. A row for which
60//!   [`ListDataSource::with_item`] returns `None` (not yet loaded) has *no*
61//!   scene item at all — there is no adapter-agnostic placeholder item to
62//!   substitute — so a partially-loaded window can only be positionally
63//!   correct if data-index → adapter-slot alignment is rederived from
64//!   scratch. Since `WindowLoaded` fires rarely (after a batch fetch, not
65//!   per frame), the O(n) cost is a non-issue; internally the id table
66//!   tracks unloaded rows as `None` slots so a later full rebuild always
67//!   lands loaded rows back at their correct index.
68//!
69//! ## Borrow discipline
70//!
71//! Every reconciliation reads the source data (via the erased
72//! `with_item_fn`, which takes its own short-lived borrow per row) and
73//! builds every `Box<dyn SceneItem>` into a local `Vec` **first**, then
74//! mutates the [`SceneModel`] (`remove` / `add_boxed_item`) only after all
75//! reads are done. `SceneModel`'s mutators internally `borrow_mut` the
76//! shared `RefCell<Scene>`; interleaving a read and a scene mutation inside
77//! the same borrow would panic (or, worse, silently reenter) if the reader
78//! and the mutator ever aliased the same `RefCell`. Mirrors `ListView`'s
79//! "collect owned data, drop the borrow, then mutate" contract.
80//!
81//! ## Example
82//!
83//! ```ignore
84//! use teksilo_data::ListModel;
85//! use teksilo_scene::{RectItem, SceneListAdapter, SceneModel};
86//! use teksilo_canvas::Rect;
87//! use teksilo_tokens::Color;
88//!
89//! struct Card { x: f32, y: f32, color: Color }
90//!
91//! let scene = SceneModel::new();
92//! let cards = ListModel::from_vec(vec![
93//!     Card { x: 0.0, y: 0.0, color: Color::RED },
94//!     Card { x: 140.0, y: 0.0, color: Color::BLUE },
95//! ]);
96//!
97//! // Kept alive by the caller for as long as the sync should run.
98//! let adapter = SceneListAdapter::from_model(&cards, scene.clone(), |card, _index| {
99//!     Box::new(
100//!         RectItem::new(Rect::new(card.x, card.y, 120.0, 80.0)).fill(card.color),
101//!     )
102//! });
103//!
104//! assert_eq!(adapter.len(), 2);
105//! cards.push(Card { x: 280.0, y: 0.0, color: Color::GREEN });
106//! assert_eq!(adapter.len(), 3);
107//! ```
108
109use std::cell::RefCell;
110use std::marker::PhantomData;
111use std::rc::Rc;
112
113use teksilo_canvas::Point;
114use teksilo_core::signal::ObserverHandle;
115use teksilo_data::{DataChange, ListDataSource, ListModel};
116
117use crate::item::{ItemId, SceneItem};
118use crate::scene_model::SceneModel;
119
120/// Erased `Fn(&T, usize) -> Box<dyn SceneItem>` delegate, shared between the
121/// initial materialisation and every later reconciliation.
122type Delegate<T> = Rc<dyn Fn(&T, usize) -> Box<dyn SceneItem>>;
123/// Erased row-count reader over the underlying source.
124type LenFn = Rc<dyn Fn() -> usize>;
125/// Erased single-row reader: invokes the callback with `&T` if row `index`
126/// is resident, otherwise does nothing (mirrors
127/// [`ListDataSource::with_item`] returning `None`).
128type WithItemFn<T> = Rc<dyn Fn(usize, &mut dyn FnMut(&T))>;
129/// Per-data-index scene item id. `None` marks a row with no materialised
130/// item yet (an unloaded row of a windowed [`ListDataSource`]).
131type IdSlots = Rc<RefCell<Vec<Option<ItemId>>>>;
132
133/// Keeps a set of lightweight [`SceneItem`]s in sync with a
134/// `teksilo_data::ListModel<T>` / `ListDataSource<Item = T>`.
135///
136/// Not a `Widget` — a plain handle you construct once (typically from a
137/// composing widget's `build()` or app setup code) and keep alive for as
138/// long as the sync should run. See the module docs for the delegate
139/// contract, reconciliation policy, and borrow discipline.
140///
141/// ## Dropping
142///
143/// Dropping a `SceneListAdapter` drops its [`ObserverHandle`], which stops
144/// the adapter from reacting to further source changes. It deliberately
145/// does **not** remove the adapter's items from the scene — running scene
146/// mutations from inside a `Drop` impl risks a re-entrant borrow of the
147/// shared `RefCell<Scene>` if the drop happens while some other code
148/// already holds a borrow (e.g. mid-notification). Call
149/// [`clear`](Self::clear) first if you want the items gone before dropping.
150pub struct SceneListAdapter<T: 'static> {
151    model: SceneModel,
152    ids: IdSlots,
153    _handle: ObserverHandle,
154    _marker: PhantomData<T>,
155}
156
157impl<T: 'static> SceneListAdapter<T> {
158    /// Track `model`'s rows as scene items in `scene`, built by `delegate`.
159    ///
160    /// Materialises every current row immediately (as if a [`DataChange::Reset`]
161    /// had just fired), then keeps the scene in sync via
162    /// [`ListModel::observe_changes`] for as long as the returned adapter is
163    /// alive. See the module docs for the delegate contract and
164    /// reconciliation policy.
165    pub fn from_model(
166        model: &ListModel<T>,
167        scene: SceneModel,
168        delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static,
169    ) -> Self {
170        let len_model = model.clone();
171        let item_model = model.clone();
172        let observe_model = model.clone();
173        let len_fn: LenFn = Rc::new(move || len_model.len());
174        let with_item_fn: WithItemFn<T> = Rc::new(move |index, f| {
175            item_model.with_item(index, |item| f(item));
176        });
177        Self::build_from(scene, len_fn, with_item_fn, delegate, move |cb| {
178            observe_model.observe_changes(move |change| cb(change))
179        })
180    }
181
182    /// Track an external [`ListDataSource`]'s rows as scene items in `scene`,
183    /// built by `delegate`.
184    ///
185    /// Takes `source` as an `Rc<S>` (rather than by value) so the caller can
186    /// keep its own handle to the same source alongside the adapter — the
187    /// same convention as `ListView::from_source` / `TableView`'s erasure.
188    /// See [`Self::from_model`] for the materialisation + reconciliation
189    /// behaviour, which is identical for both constructors.
190    pub fn from_source<S: ListDataSource<Item = T> + 'static>(
191        source: Rc<S>,
192        scene: SceneModel,
193        delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static,
194    ) -> Self {
195        let len_source = source.clone();
196        let item_source = source.clone();
197        let observe_source = source.clone();
198        let len_fn: LenFn = Rc::new(move || len_source.len());
199        let with_item_fn: WithItemFn<T> = Rc::new(move |index, f| {
200            item_source.with_item(index, |item| f(item));
201        });
202        Self::build_from(scene, len_fn, with_item_fn, delegate, move |cb| {
203            observe_source.observe_changes(move |change| cb(change))
204        })
205    }
206
207    /// Shared construction path for both public constructors: materialise
208    /// the current rows, then register the reconciling observer.
209    fn build_from(
210        scene: SceneModel,
211        len_fn: LenFn,
212        with_item_fn: WithItemFn<T>,
213        delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static,
214        observe_register: impl FnOnce(Box<dyn Fn(&DataChange)>) -> ObserverHandle,
215    ) -> Self {
216        let delegate: Delegate<T> = Rc::new(delegate);
217        let ids: IdSlots = Rc::new(RefCell::new(Vec::new()));
218
219        // Materialise all current rows, as if a `Reset` had just fired.
220        Self::rebuild_all(&scene, &ids, &len_fn, &with_item_fn, &delegate);
221
222        let obs_scene = scene.clone();
223        let obs_ids = ids.clone();
224        let obs_len_fn = len_fn.clone();
225        let obs_with_item_fn = with_item_fn.clone();
226        let obs_delegate = delegate.clone();
227
228        let handle = observe_register(Box::new(move |change| match change {
229            // A single row's content changed in place — no structural shift,
230            // so only that row needs a fresh item.
231            DataChange::ItemUpdated { index } => {
232                Self::rebuild_one(
233                    &obs_scene,
234                    &obs_ids,
235                    *index,
236                    &obs_with_item_fn,
237                    &obs_delegate,
238                );
239            }
240            // Every other variant either shifts indices (Inserted / Removed /
241            // Moved), discards all state (Reset), or can only be applied
242            // correctly by rederiving the whole data-index -> id mapping from
243            // scratch (WindowLoaded — see the module docs). Rebuild-all is
244            // always correct for all of these.
245            DataChange::ItemsInserted { .. }
246            | DataChange::ItemsRemoved { .. }
247            | DataChange::ItemsMoved { .. }
248            | DataChange::WindowLoaded { .. }
249            | DataChange::Reset => {
250                Self::rebuild_all(
251                    &obs_scene,
252                    &obs_ids,
253                    &obs_len_fn,
254                    &obs_with_item_fn,
255                    &obs_delegate,
256                );
257            }
258        }));
259
260        Self {
261            model: scene,
262            ids,
263            _handle: handle,
264            _marker: PhantomData,
265        }
266    }
267
268    /// Rebuild every adapter-owned scene item from the current source
269    /// contents. Reads every resident row and builds its item *before*
270    /// touching the scene (see the module docs' borrow-discipline section),
271    /// then removes every previously-owned id and adds the freshly built
272    /// ones in data order.
273    fn rebuild_all(
274        scene: &SceneModel,
275        ids: &IdSlots,
276        len_fn: &LenFn,
277        with_item_fn: &WithItemFn<T>,
278        delegate: &Delegate<T>,
279    ) {
280        let len = (len_fn)();
281        let mut built: Vec<Option<Box<dyn SceneItem>>> = Vec::with_capacity(len);
282        for index in 0..len {
283            let mut out: Option<Box<dyn SceneItem>> = None;
284            (with_item_fn)(index, &mut |item: &T| {
285                out = Some((delegate)(item, index));
286            });
287            built.push(out);
288        }
289
290        // Drop every id this adapter currently owns before re-adding — the
291        // borrow ends with `drain`/`collect`, well before any scene mutation.
292        let old_ids: Vec<Option<ItemId>> = ids.borrow_mut().drain(..).collect();
293        for id in old_ids.into_iter().flatten() {
294            scene.remove(id);
295        }
296
297        let new_ids: Vec<Option<ItemId>> = built
298            .into_iter()
299            .map(|item| item.map(|item| scene.add_boxed_item(item, Point::ZERO)))
300            .collect();
301        *ids.borrow_mut() = new_ids;
302    }
303
304    /// Rebuild the single scene item at data `index`: remove the old one (if
305    /// any) and, if the row is currently resident, add a fresh one built
306    /// from the current data. No-op if `index` is outside the currently
307    /// tracked slot count (defensive — a well-behaved source only emits
308    /// `ItemUpdated`/`WindowLoaded` for in-range indices).
309    fn rebuild_one(
310        scene: &SceneModel,
311        ids: &IdSlots,
312        index: usize,
313        with_item_fn: &WithItemFn<T>,
314        delegate: &Delegate<T>,
315    ) {
316        let old_id = {
317            let guard = ids.borrow();
318            match guard.get(index) {
319                Some(slot) => *slot,
320                None => return,
321            }
322        };
323
324        let mut built: Option<Box<dyn SceneItem>> = None;
325        (with_item_fn)(index, &mut |item: &T| {
326            built = Some((delegate)(item, index));
327        });
328
329        if let Some(old_id) = old_id {
330            scene.remove(old_id);
331        }
332        let new_id = built.map(|item| scene.add_boxed_item(item, Point::ZERO));
333        ids.borrow_mut()[index] = new_id;
334    }
335
336    /// The scene item id materialised for data row `index`, or `None` if
337    /// `index` is out of range or the row has no materialised item (an
338    /// unloaded row of a windowed source).
339    pub fn item_id_at(&self, index: usize) -> Option<ItemId> {
340        self.ids.borrow().get(index).copied().flatten()
341    }
342
343    /// All ids currently materialised by this adapter, in data order
344    /// (rows with no materialised item are omitted, so this may be shorter
345    /// than the source's row count).
346    pub fn ids(&self) -> Vec<ItemId> {
347        self.ids.borrow().iter().filter_map(|slot| *slot).collect()
348    }
349
350    /// Number of scene items this adapter currently owns.
351    pub fn len(&self) -> usize {
352        self.ids
353            .borrow()
354            .iter()
355            .filter(|slot| slot.is_some())
356            .count()
357    }
358
359    /// Whether this adapter currently owns no scene items.
360    pub fn is_empty(&self) -> bool {
361        self.len() == 0
362    }
363
364    /// Remove every scene item this adapter owns from the scene and forget
365    /// them. The adapter keeps observing the source afterward — a later
366    /// source change re-materialises rows as usual.
367    pub fn clear(&self) {
368        let old_ids: Vec<Option<ItemId>> = self.ids.borrow_mut().drain(..).collect();
369        for id in old_ids.into_iter().flatten() {
370            self.model.remove(id);
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::items::RectItem;
379    use teksilo_canvas::Rect;
380
381    #[derive(Debug, Clone)]
382    struct Row {
383        n: i32,
384    }
385
386    fn row(n: i32) -> Row {
387        Row { n }
388    }
389
390    fn delegate(row: &Row, _index: usize) -> Box<dyn SceneItem> {
391        Box::new(RectItem::new(Rect::new(row.n as f32, 0.0, 10.0, 10.0)))
392    }
393
394    #[test]
395    fn construction_materialises_current_rows() {
396        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
397        let scene = SceneModel::new();
398        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
399
400        assert_eq!(adapter.len(), 3);
401        assert_eq!(scene.len(), 3);
402        assert!(adapter.item_id_at(0).is_some());
403        assert!(adapter.item_id_at(1).is_some());
404        assert!(adapter.item_id_at(2).is_some());
405        assert!(adapter.item_id_at(3).is_none());
406    }
407
408    #[test]
409    fn push_and_remove_track_len() {
410        let model = ListModel::from_vec(vec![row(1), row(2)]);
411        let scene = SceneModel::new();
412        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
413        assert_eq!(adapter.len(), 2);
414
415        model.push(row(3));
416        assert_eq!(adapter.len(), 3);
417        assert_eq!(scene.len(), 3);
418
419        model.remove(0);
420        assert_eq!(adapter.len(), 2);
421        assert_eq!(scene.len(), 2);
422    }
423
424    #[test]
425    fn replace_all_rebuilds_with_fresh_ids() {
426        let model = ListModel::from_vec(vec![row(1), row(2)]);
427        let scene = SceneModel::new();
428        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
429        let before = adapter.ids();
430
431        model.replace_all(vec![row(10), row(20), row(30)]);
432
433        assert_eq!(adapter.len(), 3);
434        assert_eq!(scene.len(), 3);
435        let after = adapter.ids();
436        assert_eq!(after.len(), 3);
437        // Every id is fresh — none of the old ones survive a Reset rebuild.
438        for id in &after {
439            assert!(!before.contains(id));
440        }
441    }
442
443    #[test]
444    fn set_rebuilds_only_the_updated_row() {
445        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
446        let scene = SceneModel::new();
447        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
448
449        let id0_before = adapter.item_id_at(0).unwrap();
450        let id1_before = adapter.item_id_at(1).unwrap();
451        let id2_before = adapter.item_id_at(2).unwrap();
452
453        model.set(1, row(99));
454
455        assert_eq!(adapter.len(), 3);
456        assert_eq!(scene.len(), 3);
457        assert_eq!(adapter.item_id_at(0).unwrap(), id0_before);
458        assert_ne!(adapter.item_id_at(1).unwrap(), id1_before);
459        assert_eq!(adapter.item_id_at(2).unwrap(), id2_before);
460    }
461
462    #[test]
463    fn clear_removes_all_adapter_items_from_the_scene() {
464        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
465        let scene = SceneModel::new();
466        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
467        assert_eq!(scene.len(), 3);
468
469        adapter.clear();
470
471        assert_eq!(adapter.len(), 0);
472        assert!(adapter.is_empty());
473        assert_eq!(scene.len(), 0);
474    }
475
476    #[test]
477    fn dropping_the_adapter_stops_observing() {
478        let model = ListModel::from_vec(vec![row(1), row(2)]);
479        let scene = SceneModel::new();
480        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
481        assert_eq!(scene.len(), 2);
482
483        drop(adapter);
484
485        model.push(row(3));
486        // No live adapter to react — the scene is untouched by the push.
487        assert_eq!(scene.len(), 2);
488    }
489
490    #[test]
491    fn from_source_tracks_a_list_data_source() {
492        let model = ListModel::from_vec(vec![row(1), row(2)]);
493        let source = Rc::new(model.clone());
494        let scene = SceneModel::new();
495        let adapter = SceneListAdapter::from_source(source, scene.clone(), delegate);
496
497        assert_eq!(adapter.len(), 2);
498        assert_eq!(scene.len(), 2);
499
500        model.push(row(3));
501        assert_eq!(adapter.len(), 3);
502        assert_eq!(scene.len(), 3);
503    }
504}