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}