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}