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 — slot identity is preserved
42//!
43//! An adapter row's identity is its **slot**: data index *i* owns one
44//! [`ItemId`], and that id survives a change to the row's content and a change
45//! to the rows around it. It is retired only when the row itself goes away.
46//!
47//! That matters because the id is what everything else keys on —
48//! [`SceneSelection`](crate::SceneSelection), magnets, logical-AT parenting,
49//! and any side map the app keeps (a [`SceneItem`] has no downcast, so a side
50//! map is the *only* way to reach item-specific state). An adapter that retired
51//! and re-minted ids on every source change would take all of it with them; a
52//! one-row edit would silently deselect the list.
53//!
54//! So every reconciliation goes through [`Scene::replace_item`](crate::Scene::replace_item), which swaps
55//! the item box **inside** the existing entry — keeping the id, the z, the
56//! layer, the parent, the flags, the handlers, the magnets and the AT
57//! decorations — and emits one
58//! [`ItemChange::ItemReplaced`](crate::ItemChange::ItemReplaced) rather than a
59//! removal and an insertion:
60//!
61//! - **`ItemUpdated { index }`** (single-row content change, no structural
62//!   shift) replaces that one row's item in place. No other row is touched.
63//! - **Structural changes** (insert / remove / move / reset, and a windowed
64//!   source's `WindowLoaded`) re-read the source start to finish and reconcile
65//!   slot by slot: slot *i* keeps its id and takes the new content, a slot past
66//!   the old length gets a fresh item, a slot past the new length is removed.
67//!   The re-read is still O(n) — the delegate takes an `index`, and an insert
68//!   at the front changes what every later row renders — but the **ids** are
69//!   stable for every slot that still exists.
70//!
71//!   Slot-positional rather than domain-keyed, because this adapter has no
72//!   domain key to work from: its delegate is `Fn(&T, usize)` and `T` need not
73//!   be identifiable. An adapter over a source with stable keys should follow
74//!   `TreeDataSlice`'s pattern and key on the domain id.
75//!
76//! - **`WindowLoaded { range }`** is a structural change for the same reason a
77//!   reset is: a row for which [`ListDataSource::with_item`] returns `None`
78//!   (not yet loaded) has *no* scene item at all — there is no
79//!   adapter-agnostic placeholder to substitute — so a partially-loaded window
80//!   can only be positionally correct if the whole data-index → slot alignment
81//!   is rederived. The id table tracks unloaded rows as `None` slots, so a row
82//!   that loads later lands at its correct index with a fresh id.
83//!
84//! Every reconciliation runs inside one
85//! [`SceneTransaction`](crate::SceneTransaction), stamped
86//! [`ChangeSource::Programmatic`](crate::ChangeSource) — a data-model refresh is
87//! not a user edit — so a data layer watching the scene sees one grouped change
88//! per source change rather than N unrelated ones.
89//!
90//! ## Borrow discipline
91//!
92//! Every reconciliation reads the source data (via the erased
93//! `with_item_fn`, which takes its own short-lived borrow per row) and
94//! builds every `Box<dyn SceneItem>` into a local `Vec` **first**, then
95//! mutates the [`SceneModel`] (`replace_item` / `add_boxed_item` / `remove`)
96//! only after all reads are done. `SceneModel`'s mutators internally `borrow_mut` the
97//! shared `RefCell<Scene>`; interleaving a read and a scene mutation inside
98//! the same borrow would panic (or, worse, silently reenter) if the reader
99//! and the mutator ever aliased the same `RefCell`. Mirrors `ListView`'s
100//! "collect owned data, drop the borrow, then mutate" contract.
101//!
102//! ## Example
103//!
104//! ```ignore
105//! use teksilo_data::ListModel;
106//! use teksilo_scene::{RectItem, SceneListAdapter, SceneModel};
107//! use teksilo_canvas::Rect;
108//! use teksilo_tokens::Color;
109//!
110//! struct Card { x: f32, y: f32, color: Color }
111//!
112//! let scene = SceneModel::new();
113//! let cards = ListModel::from_vec(vec![
114//!     Card { x: 0.0, y: 0.0, color: Color::RED },
115//!     Card { x: 140.0, y: 0.0, color: Color::BLUE },
116//! ]);
117//!
118//! // Kept alive by the caller for as long as the sync should run.
119//! let adapter = SceneListAdapter::from_model(&cards, scene.clone(), |card, _index| {
120//!     Box::new(
121//!         RectItem::new(Rect::new(card.x, card.y, 120.0, 80.0)).fill(card.color),
122//!     )
123//! });
124//!
125//! assert_eq!(adapter.len(), 2);
126//! cards.push(Card { x: 280.0, y: 0.0, color: Color::GREEN });
127//! assert_eq!(adapter.len(), 3);
128//! ```
129
130use std::cell::RefCell;
131use std::marker::PhantomData;
132use std::rc::Rc;
133
134use teksilo_canvas::Point;
135use teksilo_core::signal::ObserverHandle;
136use teksilo_data::{DataChange, ListDataSource, ListModel};
137
138use crate::item::{ItemId, SceneItem};
139use crate::journal::{ChangeSource, HistoryMode};
140use crate::scene_model::SceneModel;
141
142/// Erased `Fn(&T, usize) -> Box<dyn SceneItem>` delegate, shared between the
143/// initial materialisation and every later reconciliation.
144type Delegate<T> = Rc<dyn Fn(&T, usize) -> Box<dyn SceneItem>>;
145/// Erased row-count reader over the underlying source.
146type LenFn = Rc<dyn Fn() -> usize>;
147/// Erased single-row reader: invokes the callback with `&T` if row `index`
148/// is resident, otherwise does nothing (mirrors
149/// [`ListDataSource::with_item`] returning `None`).
150type WithItemFn<T> = Rc<dyn Fn(usize, &mut dyn FnMut(&T))>;
151/// Per-data-index scene item id. `None` marks a row with no materialised
152/// item yet (an unloaded row of a windowed [`ListDataSource`]).
153type IdSlots = Rc<RefCell<Vec<Option<ItemId>>>>;
154
155/// Keeps a set of lightweight [`SceneItem`]s in sync with a
156/// `teksilo_data::ListModel<T>` / `ListDataSource<Item = T>`.
157///
158/// Not a `Widget` — a plain handle you construct once (typically from a
159/// composing widget's `build()` or app setup code) and keep alive for as
160/// long as the sync should run. See the module docs for the delegate
161/// contract, reconciliation policy, and borrow discipline.
162///
163/// ## Dropping
164///
165/// Dropping a `SceneListAdapter` drops its [`ObserverHandle`], which stops
166/// the adapter from reacting to further source changes. It deliberately
167/// does **not** remove the adapter's items from the scene — running scene
168/// mutations from inside a `Drop` impl risks a re-entrant borrow of the
169/// shared `RefCell<Scene>` if the drop happens while some other code
170/// already holds a borrow (e.g. mid-notification). Call
171/// [`clear`](Self::clear) first if you want the items gone before dropping.
172pub struct SceneListAdapter<T: 'static> {
173    model: SceneModel,
174    ids: IdSlots,
175    _handle: ObserverHandle,
176    _marker: PhantomData<T>,
177}
178
179impl<T: 'static> SceneListAdapter<T> {
180    /// Track `model`'s rows as scene items in `scene`, built by `delegate`.
181    ///
182    /// Materialises every current row immediately (as if a [`DataChange::Reset`]
183    /// had just fired), then keeps the scene in sync via
184    /// [`ListModel::observe_changes`] for as long as the returned adapter is
185    /// alive. See the module docs for the delegate contract and
186    /// reconciliation policy.
187    pub fn from_model(
188        model: &ListModel<T>,
189        scene: SceneModel,
190        delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static,
191    ) -> Self {
192        let len_model = model.clone();
193        let item_model = model.clone();
194        let observe_model = model.clone();
195        let len_fn: LenFn = Rc::new(move || len_model.len());
196        let with_item_fn: WithItemFn<T> = Rc::new(move |index, f| {
197            item_model.with_item(index, |item| f(item));
198        });
199        Self::build_from(scene, len_fn, with_item_fn, delegate, move |cb| {
200            observe_model.observe_changes(move |change| cb(change))
201        })
202    }
203
204    /// Track an external [`ListDataSource`]'s rows as scene items in `scene`,
205    /// built by `delegate`.
206    ///
207    /// Takes `source` as an `Rc<S>` (rather than by value) so the caller can
208    /// keep its own handle to the same source alongside the adapter — the
209    /// same convention as `ListView::from_source` / `TableView`'s erasure.
210    /// See [`Self::from_model`] for the materialisation + reconciliation
211    /// behaviour, which is identical for both constructors.
212    pub fn from_source<S: ListDataSource<Item = T> + 'static>(
213        source: Rc<S>,
214        scene: SceneModel,
215        delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static,
216    ) -> Self {
217        let len_source = source.clone();
218        let item_source = source.clone();
219        let observe_source = source.clone();
220        let len_fn: LenFn = Rc::new(move || len_source.len());
221        let with_item_fn: WithItemFn<T> = Rc::new(move |index, f| {
222            item_source.with_item(index, |item| f(item));
223        });
224        Self::build_from(scene, len_fn, with_item_fn, delegate, move |cb| {
225            observe_source.observe_changes(move |change| cb(change))
226        })
227    }
228
229    /// Shared construction path for both public constructors: materialise
230    /// the current rows, then register the reconciling observer.
231    fn build_from(
232        scene: SceneModel,
233        len_fn: LenFn,
234        with_item_fn: WithItemFn<T>,
235        delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static,
236        observe_register: impl FnOnce(Box<dyn Fn(&DataChange)>) -> ObserverHandle,
237    ) -> Self {
238        let delegate: Delegate<T> = Rc::new(delegate);
239        let ids: IdSlots = Rc::new(RefCell::new(Vec::new()));
240
241        // Materialise all current rows, as if a `Reset` had just fired.
242        Self::reconcile_all(&scene, &ids, &len_fn, &with_item_fn, &delegate);
243
244        let obs_scene = scene.clone();
245        let obs_ids = ids.clone();
246        let obs_len_fn = len_fn.clone();
247        let obs_with_item_fn = with_item_fn.clone();
248        let obs_delegate = delegate.clone();
249
250        let handle = observe_register(Box::new(move |change| match change {
251            // A single row's content changed in place — no structural shift,
252            // so only that row's item is swapped, keeping its id.
253            DataChange::ItemUpdated { index } => {
254                Self::reconcile_one(
255                    &obs_scene,
256                    &obs_ids,
257                    *index,
258                    &obs_with_item_fn,
259                    &obs_delegate,
260                );
261            }
262            // Every other variant either shifts indices (Inserted / Removed /
263            // Moved), discards all content (Reset), or can only be applied
264            // correctly by rederiving the whole data-index -> slot alignment
265            // (WindowLoaded — see the module docs). A full slot-by-slot
266            // reconcile is always correct for all of these, and keeps the id of
267            // every slot that still has a row.
268            DataChange::ItemsInserted { .. }
269            | DataChange::ItemsRemoved { .. }
270            | DataChange::ItemsMoved { .. }
271            | DataChange::WindowLoaded { .. }
272            | DataChange::Reset => {
273                Self::reconcile_all(
274                    &obs_scene,
275                    &obs_ids,
276                    &obs_len_fn,
277                    &obs_with_item_fn,
278                    &obs_delegate,
279                );
280            }
281        }));
282
283        Self {
284            model: scene,
285            ids,
286            _handle: handle,
287            _marker: PhantomData,
288        }
289    }
290
291    /// Reconcile every slot against the current source contents, **keeping
292    /// each surviving slot's [`ItemId`]**.
293    ///
294    /// Reads every resident row and builds its item *before* touching the
295    /// scene (see the module docs' borrow-discipline section), then walks the
296    /// slots: a slot that had an item and still has one is
297    /// [`replace_item`](SceneModel::replace_item)d, a slot that gained one is
298    /// added, a slot that lost one (or ran off the end) is removed.
299    ///
300    /// The whole walk is one `Programmatic` transaction, so a consumer sees one
301    /// grouped change per source change.
302    fn reconcile_all(
303        scene: &SceneModel,
304        ids: &IdSlots,
305        len_fn: &LenFn,
306        with_item_fn: &WithItemFn<T>,
307        delegate: &Delegate<T>,
308    ) {
309        let len = (len_fn)();
310        let mut built: Vec<Option<Box<dyn SceneItem>>> = Vec::with_capacity(len);
311        for index in 0..len {
312            let mut out: Option<Box<dyn SceneItem>> = None;
313            (with_item_fn)(index, &mut |item: &T| {
314                out = Some((delegate)(item, index));
315            });
316            built.push(out);
317        }
318
319        // The borrow ends here, well before any scene mutation.
320        let old_ids: Vec<Option<ItemId>> = ids.borrow().clone();
321
322        let _txn = scene.transaction(ChangeSource::Programmatic, HistoryMode::Record);
323        let mut new_ids: Vec<Option<ItemId>> = Vec::with_capacity(len);
324        for (index, item) in built.into_iter().enumerate() {
325            let existing = old_ids.get(index).copied().flatten();
326            new_ids.push(Self::place(scene, existing, item));
327        }
328        // Slots the source no longer has.
329        for id in old_ids.into_iter().skip(len).flatten() {
330            scene.remove(id);
331        }
332        *ids.borrow_mut() = new_ids;
333    }
334
335    /// Reconcile the single slot at data `index`, keeping its [`ItemId`].
336    ///
337    /// No-op if `index` is outside the currently tracked slot count
338    /// (defensive — a well-behaved source only emits `ItemUpdated` /
339    /// `WindowLoaded` for in-range indices).
340    fn reconcile_one(
341        scene: &SceneModel,
342        ids: &IdSlots,
343        index: usize,
344        with_item_fn: &WithItemFn<T>,
345        delegate: &Delegate<T>,
346    ) {
347        let old_id = {
348            let guard = ids.borrow();
349            match guard.get(index) {
350                Some(slot) => *slot,
351                None => return,
352            }
353        };
354
355        let mut built: Option<Box<dyn SceneItem>> = None;
356        (with_item_fn)(index, &mut |item: &T| {
357            built = Some((delegate)(item, index));
358        });
359
360        let _txn = scene.transaction(ChangeSource::Programmatic, HistoryMode::Record);
361        let new_id = Self::place(scene, old_id, built);
362        ids.borrow_mut()[index] = new_id;
363    }
364
365    /// Put `item` in the slot currently holding `existing`, and say which id
366    /// the slot holds afterwards.
367    ///
368    /// The one place this adapter decides between replace, add and remove, so
369    /// the identity rule — a slot keeps its id for as long as it has an item —
370    /// is stated once.
371    ///
372    /// A `replace_item` that fails is not reachable from here — the id came out
373    /// of this adapter's own table and names a lightweight item — but the
374    /// fallback keeps the row rather than losing it: the refusal hands the item
375    /// back, so the slot takes a fresh id instead of going empty. Losing the
376    /// row would be the quiet kind of failure, visible only as a marker that
377    /// stopped being drawn.
378    fn place(
379        scene: &SceneModel,
380        existing: Option<ItemId>,
381        item: Option<Box<dyn SceneItem>>,
382    ) -> Option<ItemId> {
383        match (existing, item) {
384            (Some(id), Some(item)) => match scene.replace_item(id, item) {
385                Ok(_previous) => Some(id),
386                Err(rejected) => {
387                    scene.remove(id);
388                    Some(scene.add_boxed_item(rejected.item, Point::ZERO))
389                }
390            },
391            (Some(id), None) => {
392                scene.remove(id);
393                None
394            }
395            (None, Some(item)) => Some(scene.add_boxed_item(item, Point::ZERO)),
396            (None, None) => None,
397        }
398    }
399
400    /// The scene item id materialised for data row `index`, or `None` if
401    /// `index` is out of range or the row has no materialised item (an
402    /// unloaded row of a windowed source).
403    pub fn item_id_at(&self, index: usize) -> Option<ItemId> {
404        self.ids.borrow().get(index).copied().flatten()
405    }
406
407    /// All ids currently materialised by this adapter, in data order
408    /// (rows with no materialised item are omitted, so this may be shorter
409    /// than the source's row count).
410    pub fn ids(&self) -> Vec<ItemId> {
411        self.ids.borrow().iter().filter_map(|slot| *slot).collect()
412    }
413
414    /// Number of scene items this adapter currently owns.
415    pub fn len(&self) -> usize {
416        self.ids
417            .borrow()
418            .iter()
419            .filter(|slot| slot.is_some())
420            .count()
421    }
422
423    /// Whether this adapter currently owns no scene items.
424    pub fn is_empty(&self) -> bool {
425        self.len() == 0
426    }
427
428    /// Remove every scene item this adapter owns from the scene and forget
429    /// them. The adapter keeps observing the source afterward — a later
430    /// source change re-materialises rows as usual.
431    pub fn clear(&self) {
432        let old_ids: Vec<Option<ItemId>> = self.ids.borrow_mut().drain(..).collect();
433        let _txn = self
434            .model
435            .transaction(ChangeSource::Programmatic, HistoryMode::Record);
436        for id in old_ids.into_iter().flatten() {
437            self.model.remove(id);
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::items::RectItem;
446    use teksilo_canvas::Rect;
447
448    #[derive(Debug, Clone)]
449    struct Row {
450        n: i32,
451    }
452
453    fn row(n: i32) -> Row {
454        Row { n }
455    }
456
457    fn delegate(row: &Row, _index: usize) -> Box<dyn SceneItem> {
458        Box::new(RectItem::new(Rect::new(row.n as f32, 0.0, 10.0, 10.0)))
459    }
460
461    #[test]
462    fn construction_materialises_current_rows() {
463        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
464        let scene = SceneModel::new();
465        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
466
467        assert_eq!(adapter.len(), 3);
468        assert_eq!(scene.len(), 3);
469        assert!(adapter.item_id_at(0).is_some());
470        assert!(adapter.item_id_at(1).is_some());
471        assert!(adapter.item_id_at(2).is_some());
472        assert!(adapter.item_id_at(3).is_none());
473    }
474
475    #[test]
476    fn push_and_remove_track_len() {
477        let model = ListModel::from_vec(vec![row(1), row(2)]);
478        let scene = SceneModel::new();
479        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
480        assert_eq!(adapter.len(), 2);
481
482        model.push(row(3));
483        assert_eq!(adapter.len(), 3);
484        assert_eq!(scene.len(), 3);
485
486        model.remove(0);
487        assert_eq!(adapter.len(), 2);
488        assert_eq!(scene.len(), 2);
489    }
490
491    #[test]
492    fn replace_all_keeps_the_id_of_every_surviving_slot() {
493        let model = ListModel::from_vec(vec![row(1), row(2)]);
494        let scene = SceneModel::new();
495        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
496        let before = adapter.ids();
497
498        model.replace_all(vec![row(10), row(20), row(30)]);
499
500        assert_eq!(adapter.len(), 3);
501        assert_eq!(scene.len(), 3);
502        let after = adapter.ids();
503        assert_eq!(after.len(), 3);
504        // Slots 0 and 1 existed before and keep their ids, so anything keyed on
505        // them — selection, magnets, AT parenting, an app side map — survives a
506        // wholesale content replacement. Slot 2 is new.
507        assert_eq!(after[0], before[0]);
508        assert_eq!(after[1], before[1]);
509        assert!(!before.contains(&after[2]));
510        // And the content really did change: slot 0's item now draws row 10.
511        assert_eq!(scene.local_bounds(after[0]).unwrap().x, 10.0);
512    }
513
514    #[test]
515    fn shrinking_the_source_retires_only_the_slots_that_went_away() {
516        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
517        let scene = SceneModel::new();
518        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
519        let before = adapter.ids();
520
521        model.replace_all(vec![row(7)]);
522
523        assert_eq!(adapter.len(), 1);
524        assert_eq!(scene.len(), 1);
525        assert_eq!(adapter.ids(), vec![before[0]]);
526        // The two dropped slots are gone from the scene, not merely forgotten.
527        assert!(scene.local_bounds(before[1]).is_none());
528        assert!(scene.local_bounds(before[2]).is_none());
529    }
530
531    #[test]
532    fn set_replaces_only_the_updated_row_and_keeps_its_id() {
533        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
534        let scene = SceneModel::new();
535        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
536
537        let id0_before = adapter.item_id_at(0).unwrap();
538        let id1_before = adapter.item_id_at(1).unwrap();
539        let id2_before = adapter.item_id_at(2).unwrap();
540
541        model.set(1, row(99));
542
543        assert_eq!(adapter.len(), 3);
544        assert_eq!(scene.len(), 3);
545        assert_eq!(adapter.item_id_at(0).unwrap(), id0_before);
546        // The updated row keeps its id — this is the §1.10 fix: a one-row edit
547        // used to retire the id and take the row's selection membership,
548        // magnets and AT parenting with it.
549        assert_eq!(adapter.item_id_at(1).unwrap(), id1_before);
550        assert_eq!(adapter.item_id_at(2).unwrap(), id2_before);
551        // …and the item really was swapped, not left stale.
552        assert_eq!(scene.local_bounds(id1_before).unwrap().x, 99.0);
553    }
554
555    #[test]
556    fn inserting_a_row_does_not_churn_the_ids_of_the_rows_it_shifts() {
557        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
558        let scene = SceneModel::new();
559        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
560        let before = adapter.ids();
561
562        model.insert(0, row(0));
563
564        assert_eq!(adapter.len(), 4);
565        // Slots 0..2 keep their ids even though every one of them now renders a
566        // different row: the slot is the identity, and an insert at the front
567        // must not deselect the whole list.
568        let after = adapter.ids();
569        assert_eq!(after[0], before[0]);
570        assert_eq!(after[1], before[1]);
571        assert_eq!(after[2], before[2]);
572        assert!(!before.contains(&after[3]));
573        // Slot 0 now draws the inserted row.
574        assert_eq!(scene.local_bounds(after[0]).unwrap().x, 0.0);
575        assert_eq!(scene.local_bounds(after[1]).unwrap().x, 1.0);
576    }
577
578    #[test]
579    fn a_source_change_is_one_transaction() {
580        use crate::journal::{ChangeSource, SceneEdit, SceneTransactionRecord};
581        use std::cell::RefCell;
582
583        let model = ListModel::from_vec(vec![row(1), row(2)]);
584        let scene = SceneModel::new();
585        let records: Rc<RefCell<Vec<SceneTransactionRecord>>> = Rc::new(RefCell::new(Vec::new()));
586        let sink = records.clone();
587        scene.set_edit_sink(move |record| sink.borrow_mut().push(record));
588
589        let _adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
590        records.borrow_mut().clear();
591
592        model.set(0, row(42));
593        model.push(row(3));
594
595        let seen = records.borrow();
596        assert_eq!(seen.len(), 2, "one transaction per source change");
597        for record in seen.iter() {
598            assert_eq!(
599                record.source,
600                ChangeSource::Programmatic,
601                "a data-model refresh is not a user edit"
602            );
603        }
604        // The one-row update is one replacement, not a removal plus an add.
605        assert_eq!(seen[0].edits.len(), 1);
606        assert!(matches!(
607            seen[0].edits[0],
608            SceneEdit::Change(crate::scene::ItemChange::ItemReplaced { .. })
609        ));
610    }
611
612    #[test]
613    fn a_refused_replacement_keeps_the_row_rather_than_losing_it() {
614        // Not reachable through the observer — the ids in the table are this
615        // adapter's own lightweight items — but it is the one branch where a
616        // row could disappear silently, so the rule is pinned rather than
617        // argued. The refusal hands the item back, so the slot takes a fresh
618        // id instead of going empty.
619        let scene = SceneModel::new();
620        let stale = {
621            let id = scene.add_boxed_item(Box::new(RectItem::new(Rect::ZERO)), Point::ZERO);
622            scene.remove(id);
623            id
624        };
625        let placed = SceneListAdapter::<Row>::place(
626            &scene,
627            Some(stale),
628            Some(Box::new(RectItem::new(Rect::new(5.0, 0.0, 10.0, 10.0)))),
629        );
630        let placed = placed.expect("the row must survive a refused replacement");
631        assert_ne!(placed, stale);
632        assert_eq!(scene.local_bounds(placed).unwrap().x, 5.0);
633    }
634
635    #[test]
636    fn clear_removes_all_adapter_items_from_the_scene() {
637        let model = ListModel::from_vec(vec![row(1), row(2), row(3)]);
638        let scene = SceneModel::new();
639        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
640        assert_eq!(scene.len(), 3);
641
642        adapter.clear();
643
644        assert_eq!(adapter.len(), 0);
645        assert!(adapter.is_empty());
646        assert_eq!(scene.len(), 0);
647    }
648
649    #[test]
650    fn dropping_the_adapter_stops_observing() {
651        let model = ListModel::from_vec(vec![row(1), row(2)]);
652        let scene = SceneModel::new();
653        let adapter = SceneListAdapter::from_model(&model, scene.clone(), delegate);
654        assert_eq!(scene.len(), 2);
655
656        drop(adapter);
657
658        model.push(row(3));
659        // No live adapter to react — the scene is untouched by the push.
660        assert_eq!(scene.len(), 2);
661    }
662
663    #[test]
664    fn from_source_tracks_a_list_data_source() {
665        let model = ListModel::from_vec(vec![row(1), row(2)]);
666        let source = Rc::new(model.clone());
667        let scene = SceneModel::new();
668        let adapter = SceneListAdapter::from_source(source, scene.clone(), delegate);
669
670        assert_eq!(adapter.len(), 2);
671        assert_eq!(scene.len(), 2);
672
673        model.push(row(3));
674        assert_eq!(adapter.len(), 3);
675        assert_eq!(scene.len(), 3);
676    }
677}