Skip to main content

teksilo_data/
list_model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ListModel<T>` — concrete reactive list backed by a `Vec<T>`.
5//!
6//! `ListModel<T>` stores items in a heap-allocated `Vec<T>` behind
7//! `Rc<RefCell<…>>`. Cloning a handle shares the same underlying data — there
8//! is no deep copy. Every mutation method (`push`, `insert`, `remove`, `set`,
9//! `move_item`, `replace_all`, `clear`) drops the internal borrow before
10//! notifying observers, so observer callbacks may safely call read methods
11//! (`len`, `with_item`) without a re-entrant borrow.
12//!
13//! `ListModel<T>` implements [`ListDataSource`] directly, so it can be handed
14//! to any `ListView` / `TableView` without adaption. For lists too large to
15//! hold in memory, implement [`ListDataSource`] directly on your own type
16//! (paged database cursor, windowed feed, etc.).
17//!
18//! ## When to use
19//!
20//! Use `ListModel<T>` when the full list fits in memory and you want automatic
21//! change notifications with no extra setup. Use a custom [`ListDataSource`]
22//! when the source is external, huge, or lazy-loaded.
23//!
24//! ## Notifications
25//!
26//! Observers registered via [`ListModel::observe_changes`] receive a
27//! [`DataChange`] describing the minimal change: `ItemsInserted`,
28//! `ItemsRemoved`, `ItemUpdated`, `ItemsMoved`, or `Reset`. The
29//! [`ObserverHandle`] returned is RAII — dropping
30//! it unregisters the callback immediately.
31//!
32//! ```rust
33//! # use teksilo_data::ListModel;
34//! let model: ListModel<&str> = ListModel::new();
35//! model.push("alpha");
36//! model.push("beta");
37//! model.push("gamma");
38//! assert_eq!(model.len(), 3);
39//! let second = model.with_item(1, |s| *s);
40//! assert_eq!(second, Some("beta"));
41//! model.set(0, "ALPHA");
42//! model.remove(2);
43//! assert_eq!(model.len(), 2);
44//! ```
45
46use std::cell::RefCell;
47use std::collections::HashSet;
48use std::hash::Hash;
49use std::ops::Range;
50use std::rc::Rc;
51
52use teksilo_core::ObserverHandle;
53
54use crate::data_change::DataChange;
55use crate::dnd_types::{
56    DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
57};
58use crate::list_data_source::ListDataSource;
59
60struct ObserverEntry {
61    id: u64,
62    callback: Rc<dyn Fn(&DataChange)>,
63}
64
65struct ListModelInner<T> {
66    items: Vec<T>,
67    observers: Vec<ObserverEntry>,
68    next_observer_id: u64,
69    /// Strong handle to the debug-registry adapter for this model.
70    /// Owned here so that the registration drops automatically when
71    /// the inner is freed (the adapter holds only a `Weak` to inner,
72    /// breaking the cycle). `None` until `.debug_named()` is called.
73    /// Compiled out in release.
74    #[cfg(debug_assertions)]
75    debug_adapter: Option<Rc<dyn crate::debug_registry::ModelDebug>>,
76}
77
78/// A concrete reactive list that stores items in a `Vec<T>`.
79///
80/// `ListModel<T>` is `Clone` — cloning produces a second handle to the same
81/// data. Multiple widgets can hold clones and all see the same items.
82///
83/// Every mutation method modifies the internal Vec, drops the mutable borrow,
84/// then notifies observers. By the time any observer runs, the borrow is
85/// released and shared borrows (`len()`, `with_item()`) are safe.
86pub struct ListModel<T: 'static> {
87    inner: Rc<RefCell<ListModelInner<T>>>,
88}
89
90impl<T: 'static> ListModel<T> {
91    /// Create an empty list model.
92    pub fn new() -> Self {
93        Self {
94            inner: Rc::new(RefCell::new(ListModelInner {
95                items: Vec::new(),
96                observers: Vec::new(),
97                next_observer_id: 1,
98                #[cfg(debug_assertions)]
99                debug_adapter: None,
100            })),
101        }
102    }
103
104    /// Create a list model from an existing vector.
105    pub fn from_vec(items: Vec<T>) -> Self {
106        Self {
107            inner: Rc::new(RefCell::new(ListModelInner {
108                items,
109                observers: Vec::new(),
110                next_observer_id: 1,
111                #[cfg(debug_assertions)]
112                debug_adapter: None,
113            })),
114        }
115    }
116
117    /// Number of items in the list.
118    pub fn len(&self) -> usize {
119        self.inner.borrow().items.len()
120    }
121
122    /// Whether the list is empty.
123    pub fn is_empty(&self) -> bool {
124        self.inner.borrow().items.is_empty()
125    }
126
127    /// Access an item by index via a callback. Returns `None` if out of bounds.
128    ///
129    /// The callback pattern avoids returning a reference that would need to
130    /// outlive the `RefCell` borrow guard.
131    pub fn with_item<R>(&self, index: usize, f: impl FnOnce(&T) -> R) -> Option<R> {
132        let guard = self.inner.borrow();
133        guard.items.get(index).map(f)
134    }
135
136    /// Append an item to the end of the list.
137    pub fn push(&self, item: T) {
138        let index = {
139            let mut guard = self.inner.borrow_mut();
140            let index = guard.items.len();
141            guard.items.push(item);
142            index
143        };
144        self.notify(DataChange::ItemsInserted {
145            range: index..index + 1,
146        });
147    }
148
149    /// Insert an item at the given index.
150    ///
151    /// # Panics
152    /// Panics if `index > len()`.
153    pub fn insert(&self, index: usize, item: T) {
154        {
155            let mut guard = self.inner.borrow_mut();
156            guard.items.insert(index, item);
157        }
158        self.notify(DataChange::ItemsInserted {
159            range: index..index + 1,
160        });
161    }
162
163    /// Remove and return the item at the given index.
164    ///
165    /// # Panics
166    /// Panics if `index >= len()`.
167    pub fn remove(&self, index: usize) -> T {
168        let item = {
169            let mut guard = self.inner.borrow_mut();
170            guard.items.remove(index)
171        };
172        self.notify(DataChange::ItemsRemoved {
173            range: index..index + 1,
174        });
175        item
176    }
177
178    /// Replace the item at the given index.
179    ///
180    /// # Panics
181    /// Panics if `index >= len()`.
182    pub fn set(&self, index: usize, item: T) {
183        {
184            let mut guard = self.inner.borrow_mut();
185            guard.items[index] = item;
186        }
187        self.notify(DataChange::ItemUpdated { index });
188    }
189
190    /// Move an item from one index to another.
191    ///
192    /// The item at `from` is removed, then inserted at `to` (post-removal index).
193    ///
194    /// # Panics
195    /// Panics if either index is out of bounds.
196    pub fn move_item(&self, from: usize, to: usize) {
197        if from == to {
198            return;
199        }
200        {
201            let mut guard = self.inner.borrow_mut();
202            let item = guard.items.remove(from);
203            guard.items.insert(to, item);
204        }
205        self.notify(DataChange::ItemsMoved { from, to, count: 1 });
206    }
207
208    /// Move a set of items so they land **contiguously** at a drop gap,
209    /// preserving their relative order — the multi-row same-view reorder
210    /// commit. `indices` are the items' current positions (any order;
211    /// out-of-range entries are ignored); `insert_gap` is the destination in
212    /// `0..=len` expressed in the pre-move indexing (i.e. "land before the item
213    /// currently at `insert_gap`"; `len` = at the end).
214    ///
215    /// Returns whether anything moved (`false` if `indices` held no in-range
216    /// entry). A **contiguous** source block emits a single
217    /// [`DataChange::ItemsMoved`] — so index-based selection follows the moved
218    /// rows; a non-contiguous set emits [`DataChange::Reset`] (that permutation
219    /// is not expressible as one `ItemsMoved`, and selection is dropped). For a
220    /// single index prefer [`move_item`](Self::move_item).
221    pub fn move_items(&self, indices: &[usize], insert_gap: usize) -> bool {
222        let len = self.len();
223        let mut idx: Vec<usize> = indices.iter().copied().filter(|&i| i < len).collect();
224        idx.sort_unstable();
225        idx.dedup();
226        if idx.is_empty() {
227            return false;
228        }
229        let contiguous = idx.windows(2).all(|w| w[1] == w[0] + 1);
230        let from0 = idx[0];
231        let count = idx.len();
232        let at;
233        {
234            let mut guard = self.inner.borrow_mut();
235            // Remove from the back so earlier indices stay valid, then restore
236            // ascending (original) order.
237            let mut block: Vec<T> = idx.iter().rev().map(|&i| guard.items.remove(i)).collect();
238            block.reverse();
239            let removed_before = idx.iter().filter(|&&i| i < insert_gap).count();
240            at = insert_gap
241                .saturating_sub(removed_before)
242                .min(guard.items.len());
243            for (off, item) in block.into_iter().enumerate() {
244                guard.items.insert(at + off, item);
245            }
246        }
247        if contiguous && from0 != at {
248            self.notify(DataChange::ItemsMoved {
249                from: from0,
250                to: at,
251                count,
252            });
253        } else if contiguous {
254            // No net movement (block landed where it started).
255        } else {
256            self.notify(DataChange::Reset);
257        }
258        true
259    }
260
261    /// Replace the entire list contents.
262    pub fn replace_all(&self, items: Vec<T>) {
263        {
264            let mut guard = self.inner.borrow_mut();
265            guard.items = items;
266        }
267        self.notify(DataChange::Reset);
268    }
269
270    /// Remove all items from the list.
271    pub fn clear(&self) {
272        {
273            let mut guard = self.inner.borrow_mut();
274            guard.items.clear();
275        }
276        self.notify(DataChange::Reset);
277    }
278
279    /// Register an observer that is called on every mutation.
280    /// Returns an `ObserverHandle` — dropping it removes the callback.
281    pub fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle {
282        let mut guard = self.inner.borrow_mut();
283        let id = guard.next_observer_id;
284        guard.next_observer_id += 1;
285        guard.observers.push(ObserverEntry {
286            id,
287            callback: Rc::new(f),
288        });
289        let inner = self.inner.clone();
290        ObserverHandle::new(
291            self.inner.clone(),
292            id,
293            Rc::new(move |observer_id| {
294                inner.borrow_mut().observers.retain(|e| e.id != observer_id);
295            }),
296        )
297    }
298
299    fn notify(&self, change: DataChange) {
300        let callbacks: Vec<Rc<dyn Fn(&DataChange)>> = self
301            .inner
302            .borrow()
303            .observers
304            .iter()
305            .map(|e| e.callback.clone())
306            .collect();
307        for cb in &callbacks {
308            cb(&change);
309        }
310    }
311}
312
313impl<T: PartialEq + 'static> ListModel<T> {
314    /// Reconcile the list's contents with `new_items`, matching old and new
315    /// rows **by key** (`key_fn`) instead of wholesale-replacing them, and
316    /// emitting the minimal set of granular [`DataChange`]s needed to reach
317    /// that state — never [`DataChange::Reset`].
318    ///
319    /// This is the primitive a live view needs when a peer process (or any
320    /// other out-of-band writer) reloads a backing file and the merged
321    /// result must land in a `ListModel` that a `ListView` is *currently
322    /// displaying*, without wiping the user's selection or keyboard focus
323    /// mid-interaction. `replace_all`/`clear` always emit `Reset`, and a
324    /// `Reset` unconditionally clears a positional `SelectionModel`
325    /// (`RowSelection::from_index`) — `reconcile_by_key` is how a caller
326    /// avoids that.
327    ///
328    /// # Change order
329    ///
330    /// The changes are emitted in two stages, coalescing contiguous runs into
331    /// a single event each:
332    ///
333    /// 1. **Removals first.** One [`DataChange::ItemsRemoved`] per contiguous
334    ///    run of keys present in the old list but absent from `new_items`,
335    ///    emitted back to front so every range is still valid, in the old
336    ///    indices, at the moment it is removed.
337    /// 2. **One left-to-right walk over the target order.** Each surviving
338    ///    row is handled when the walk reaches it: a [`DataChange::ItemsMoved`]
339    ///    (a single-row block) if it is not yet in its target slot — skipped
340    ///    for rows already in place, so an append-only or remove-only reload
341    ///    emits **no** moves — then a [`DataChange::ItemUpdated`] if its
342    ///    content differs (`T: PartialEq`; the stored value is replaced with
343    ///    the incoming one). Brand-new keys are buffered and flushed as one
344    ///    [`DataChange::ItemsInserted`] run just before the next survivor, or
345    ///    at the end of the walk.
346    ///
347    /// So after the removals, moves, updates and inserts are **interleaved in
348    /// walk order**, not grouped by kind: reconciling `[(1,"a"),(2,"b")]`
349    /// into `[(1,"a"),(2,"B"),(3,"c")]` emits `ItemUpdated { index: 1 }` and
350    /// then `ItemsInserted { range: 2..3 }`.
351    ///
352    /// Every change's indices are valid against the list **as it stands
353    /// after the previous change** (a sequential index space), not against
354    /// the list before `reconcile_by_key` or after it. A consumer that
355    /// applies the changes one at a time, in the order delivered, stays
356    /// correct; one that batches them by kind (all removals, then all moves,
357    /// …) or replays them out of order does not.
358    ///
359    /// If `new_items` is identical (same keys, same order, same content, by
360    /// `PartialEq`) to the current contents, **no** change is emitted and no
361    /// observer runs — reconciling with unchanged data is silent.
362    ///
363    /// # Preconditions
364    /// `key_fn` must be a pure, stable function of an item's identity (not
365    /// its content) and keys must be **unique** within both the current list
366    /// and `new_items`. See `# Panics` below — an unstable `key_fn` or a
367    /// duplicate within `new_items` panics; a duplicate within the current
368    /// list is silently tolerated and leaves the extra copies behind, which
369    /// is worse.
370    ///
371    /// # Panics
372    /// Panics (via an internal `.expect`) if `key_fn` is not stable — it
373    /// returns a different key for the same item across the two calls this
374    /// method makes to it (once while snapshotting the current list's keys,
375    /// once while re-deriving a key during the write pass) — or if a key is
376    /// **duplicated** within the current list or within `new_items`. Both
377    /// break the same invariant the write pass relies on: "the item that
378    /// was accounted for under this key is still findable at or after the
379    /// write cursor." A duplicate key means two different items raced to
380    /// claim one key slot, so by the time the second one is processed the
381    /// slot the accounting expected is already gone. This is this crate's
382    /// usual documented-panic-on-contract-violation style (see e.g.
383    /// [`TreeModel::remove`](crate::TreeModel::remove)) — a caller-side bug
384    /// surfaced immediately as a panic, not silently wrong data.
385    ///
386    /// # Complexity
387    /// Re-ordering is a straightforward left-to-right pass that moves each
388    /// out-of-place survivor into its target slot; it is correct and always
389    /// granular, but is not guaranteed to emit the mathematically fewest
390    /// possible `ItemsMoved` events for an adversarial permutation (an
391    /// LIS-based scheme could do slightly better there). For the common
392    /// case this primitive targets — a peer append/remove/edit merged back
393    /// in — the existing relative order of untouched rows is preserved
394    /// as-is, so no moves are emitted at all.
395    pub fn reconcile_by_key<K: Eq + Hash>(&self, new_items: Vec<T>, key_fn: impl Fn(&T) -> K) {
396        let mut changes: Vec<DataChange> = Vec::new();
397        {
398            let mut guard = self.inner.borrow_mut();
399            reconcile_vec(&mut guard.items, new_items, &key_fn, &mut changes);
400        }
401        for change in changes {
402            self.notify(change);
403        }
404    }
405}
406
407/// Diff `items` (current contents) against `new_items` by key, mutating
408/// `items` in place to match `new_items` exactly and pushing one
409/// [`DataChange`] per atomic step taken. Kept as a free function (rather
410/// than inlined into `reconcile_by_key`) so it can be exercised directly —
411/// and so it operates on the bare `Vec<T>` while the caller still holds the
412/// `RefCell` borrow, before any observer notification fires.
413fn reconcile_vec<T: PartialEq, K: Eq + Hash>(
414    items: &mut Vec<T>,
415    new_items: Vec<T>,
416    key_fn: &impl Fn(&T) -> K,
417    out: &mut Vec<DataChange>,
418) {
419    // Pair every incoming item with its key up front: we need the key
420    // before we decide anything, and this avoids re-deriving ownership of
421    // the item later.
422    let new_pairs: Vec<(K, T)> = new_items.into_iter().map(|it| (key_fn(&it), it)).collect();
423    let new_key_set: HashSet<&K> = new_pairs.iter().map(|(k, _)| k).collect();
424
425    // ---- Phase 1: drop old items whose key no longer exists in the
426    // incoming list. Computed against a pre-removal key snapshot so later
427    // removals don't perturb earlier indices; applied back-to-front so
428    // each removed range's indices stay valid at the point it's removed. ----
429    let old_keys: Vec<K> = items.iter().map(key_fn).collect();
430    let remove_idxs: Vec<usize> = (0..items.len())
431        .filter(|&i| !new_key_set.contains(&old_keys[i]))
432        .collect();
433    for range in coalesce_ranges(&remove_idxs).into_iter().rev() {
434        items.drain(range.clone());
435        out.push(DataChange::ItemsRemoved { range });
436    }
437
438    // From here on, `items` holds exactly the surviving ("common") rows, in
439    // their original relative order.
440    let remaining_keys: HashSet<K> = items.iter().map(key_fn).collect();
441
442    // ---- Phase 2/3: walk the target order left to right. A common key is
443    // moved into place (if it isn't already there) and its content updated
444    // in place if it changed; a brand-new key is buffered and flushed as a
445    // single contiguous `ItemsInserted` run as soon as a common key (or the
446    // end of the list) is reached. ----
447    let mut cursor = 0usize;
448    let mut pending_inserts: Vec<T> = Vec::new();
449
450    for (key, new_item) in new_pairs {
451        if remaining_keys.contains(&key) {
452            flush_inserts(items, &mut cursor, &mut pending_inserts, out);
453
454            // Invariant: items[0..cursor] already matches the target
455            // prefix, so this key — being common and not yet placed — must
456            // sit somewhere at or after `cursor`.
457            let pos = items
458                .iter()
459                .skip(cursor)
460                .position(|it| key_fn(it) == key)
461                .expect(
462                    "reconcile_by_key: key reported common but not found — \
463                     key_fn must be stable and keys unique",
464                );
465            let actual = cursor + pos;
466            if actual != cursor {
467                let val = items.remove(actual);
468                items.insert(cursor, val);
469                out.push(DataChange::ItemsMoved {
470                    from: actual,
471                    to: cursor,
472                    count: 1,
473                });
474            }
475            if items[cursor] != new_item {
476                items[cursor] = new_item;
477                out.push(DataChange::ItemUpdated { index: cursor });
478            }
479            cursor += 1;
480        } else {
481            pending_inserts.push(new_item);
482        }
483    }
484    flush_inserts(items, &mut cursor, &mut pending_inserts, out);
485}
486
487/// Splice any buffered new rows into `items` at `*cursor` as one contiguous
488/// block, emit the single `ItemsInserted` covering them, and advance the
489/// cursor past them. No-op (no event) if nothing is pending.
490fn flush_inserts<T>(
491    items: &mut Vec<T>,
492    cursor: &mut usize,
493    pending: &mut Vec<T>,
494    out: &mut Vec<DataChange>,
495) {
496    if pending.is_empty() {
497        return;
498    }
499    let start = *cursor;
500    let n = pending.len();
501    items.splice(start..start, pending.drain(..));
502    out.push(DataChange::ItemsInserted {
503        range: start..start + n,
504    });
505    *cursor += n;
506}
507
508/// Group a sorted, deduplicated slice of indices into maximal contiguous
509/// ranges, e.g. `[1, 2, 3, 7, 8]` → `[1..4, 7..9]`.
510fn coalesce_ranges(sorted_idxs: &[usize]) -> Vec<Range<usize>> {
511    let mut ranges = Vec::new();
512    let mut iter = sorted_idxs.iter().peekable();
513    while let Some(&start) = iter.next() {
514        let mut end = start + 1;
515        while iter.peek().is_some_and(|&&n| n == end) {
516            end += 1;
517            iter.next();
518        }
519        ranges.push(start..end);
520    }
521    ranges
522}
523
524impl<T: std::fmt::Debug + 'static> ListModel<T> {
525    /// Register this model with the debug inspector under `name`. In
526    /// release builds (`!cfg(debug_assertions)`) this is a no-op
527    /// pass-through so call sites stay free of `#[cfg]` lines.
528    ///
529    /// Idempotent on repeated calls — the latest registration wins.
530    /// The registration drops automatically when the last `ListModel`
531    /// handle is freed (the adapter the registry holds is `Weak`).
532    pub fn debug_named(self, _name: impl Into<String>) -> Self {
533        #[cfg(debug_assertions)]
534        {
535            let weak = Rc::downgrade(&self.inner);
536            let adapter: Rc<dyn crate::debug_registry::ModelDebug> =
537                Rc::new(ListModelDebug::<T> { weak });
538            let name = _name.into();
539            crate::debug_registry::register(name, Rc::downgrade(&adapter));
540            self.inner.borrow_mut().debug_adapter = Some(adapter);
541        }
542        self
543    }
544}
545
546#[cfg(debug_assertions)]
547struct ListModelDebug<T> {
548    weak: std::rc::Weak<RefCell<ListModelInner<T>>>,
549}
550
551#[cfg(debug_assertions)]
552impl<T: std::fmt::Debug + 'static> crate::debug_registry::ModelDebug for ListModelDebug<T> {
553    fn kind(&self) -> &'static str {
554        "ListModel"
555    }
556    fn len(&self) -> usize {
557        self.weak
558            .upgrade()
559            .map(|inner| inner.borrow().items.len())
560            .unwrap_or(0)
561    }
562    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
563        let Some(inner) = self.weak.upgrade() else {
564            return;
565        };
566        let guard = inner.borrow();
567        for (i, item) in guard.items.iter().enumerate() {
568            let _ = writeln!(out, "[{}] {:?}", i, item);
569        }
570    }
571}
572
573impl<T: 'static> Default for ListModel<T> {
574    fn default() -> Self {
575        Self::new()
576    }
577}
578
579impl<T: 'static> Clone for ListModel<T> {
580    fn clone(&self) -> Self {
581        Self {
582            inner: self.inner.clone(),
583        }
584    }
585}
586
587impl<T: std::fmt::Debug + 'static> std::fmt::Debug for ListModel<T> {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        f.debug_struct("ListModel")
590            .field("len", &self.inner.borrow().items.len())
591            .finish()
592    }
593}
594
595/// `ListModel` is the built-in fully-resident, in-memory `ListDataSource`.
596/// Identity is positional (`Key = usize`); a `SameView` drop reorders via
597/// `move_item`. `Into` and `Foreign` drops are rejected (a flat list does not
598/// nest, and a bare model knows no foreign payloads).
599impl<T: 'static> ListDataSource for ListModel<T> {
600    type Item = T;
601    type Key = usize;
602
603    fn len(&self) -> usize {
604        ListModel::len(self)
605    }
606
607    fn with_item<R>(&self, index: usize, f: impl FnOnce(&T) -> R) -> Option<R> {
608        ListModel::with_item(self, index, f)
609    }
610
611    fn key_at(&self, index: usize) -> Option<usize> {
612        (index < ListModel::len(self)).then_some(index)
613    }
614
615    fn index_of(&self, key: &usize) -> Option<usize> {
616        (*key < ListModel::len(self)).then_some(*key)
617    }
618
619    fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle {
620        ListModel::observe_changes(self, f)
621    }
622
623    fn drag(&self, _key: &usize) -> DragEligibility {
624        DragEligibility::CanDrag
625    }
626
627    fn can_accept(&self, query: &DropQuery<'_, usize>) -> DropResponse {
628        match &query.source {
629            DragSource::SameView { .. } => match query.position {
630                DropPosition::Into => DropResponse::Reject,
631                DropPosition::Before | DropPosition::After => DropResponse::Accept,
632            },
633            DragSource::Foreign { .. } => DropResponse::Reject,
634        }
635    }
636
637    fn accept_drop(&self, commit: DropCommit<'_, usize>) -> bool {
638        let DragSource::SameView { key: from } = commit.source else {
639            return false;
640        };
641        let len = ListModel::len(self);
642        if from >= len {
643            return false;
644        }
645        let target = commit.target;
646        // move_item removes `from` before inserting, so an insertion point above
647        // the source's old slot shifts down by one.
648        let shift = if from < target { 1 } else { 0 };
649        let to = match commit.position {
650            DropPosition::Before => target.saturating_sub(shift),
651            DropPosition::After => (target + 1).saturating_sub(shift),
652            DropPosition::Into => return false,
653        };
654        let to = to.min(len.saturating_sub(1));
655        self.move_item(from, to);
656        true
657    }
658
659    fn reorder_within(&self, sources: &[usize], target: &usize, position: DropPosition) -> bool {
660        // A `ListModel`'s key IS the index, so the stable-key default (which
661        // re-anchors on a just-moved key) would corrupt after the first move —
662        // route the multi-row case through the index-safe block move instead.
663        // A single row keeps the finer-grained `accept_drop`/`move_item` path.
664        if sources.len() <= 1 {
665            let Some(&from) = sources.first() else {
666                return false;
667            };
668            return self.accept_drop(DropCommit {
669                source: DragSource::SameView { key: from },
670                target: *target,
671                position,
672            });
673        }
674        let gap = match position {
675            DropPosition::Before => *target,
676            DropPosition::After => *target + 1,
677            DropPosition::Into => return false,
678        };
679        self.move_items(sources, gap)
680    }
681
682    fn on_drag_out(&self, key: &usize) {
683        // Source-side completion for a foreign move: drop the row that was
684        // accepted elsewhere. Callers remove in descending index order so
685        // earlier keys stay valid across a multi-row transfer.
686        if *key < ListModel::len(self) {
687            let _ = self.remove(*key);
688        }
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use std::cell::Cell;
695
696    use super::*;
697
698    #[test]
699    fn new_is_empty() {
700        let model: ListModel<String> = ListModel::new();
701        assert!(model.is_empty());
702        assert_eq!(model.len(), 0);
703    }
704
705    #[test]
706    fn from_vec() {
707        let model = ListModel::from_vec(vec![10, 20, 30]);
708        assert_eq!(model.len(), 3);
709        assert_eq!(model.with_item(1, |v| *v), Some(20));
710    }
711
712    #[test]
713    fn push_emits_inserted() {
714        let model = ListModel::new();
715        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
716        let c = changes.clone();
717        let _handle = model.observe_changes(move |change| {
718            c.borrow_mut().push(change.clone());
719        });
720
721        model.push("a");
722        model.push("b");
723
724        let log = changes.borrow();
725        assert_eq!(log.len(), 2);
726        assert_eq!(log[0], DataChange::ItemsInserted { range: 0..1 });
727        assert_eq!(log[1], DataChange::ItemsInserted { range: 1..2 });
728    }
729
730    #[test]
731    fn insert_emits_inserted() {
732        let model = ListModel::from_vec(vec![1, 2, 3]);
733        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
734        let c = changes.clone();
735        let _handle = model.observe_changes(move |change| {
736            c.borrow_mut().push(change.clone());
737        });
738
739        model.insert(1, 99);
740
741        assert_eq!(model.len(), 4);
742        assert_eq!(model.with_item(1, |v| *v), Some(99));
743        let log = changes.borrow();
744        assert_eq!(log.len(), 1, "insert should emit exactly one change");
745        assert_eq!(log[0], DataChange::ItemsInserted { range: 1..2 });
746    }
747
748    #[test]
749    fn remove_emits_removed() {
750        let model = ListModel::from_vec(vec!["a", "b", "c"]);
751        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
752        let c = changes.clone();
753        let _handle = model.observe_changes(move |change| {
754            c.borrow_mut().push(change.clone());
755        });
756
757        let removed = model.remove(1);
758        assert_eq!(removed, "b");
759        assert_eq!(model.len(), 2);
760        let log = changes.borrow();
761        assert_eq!(log.len(), 1, "remove should emit exactly one change");
762        assert_eq!(log[0], DataChange::ItemsRemoved { range: 1..2 });
763    }
764
765    #[test]
766    fn set_emits_updated() {
767        let model = ListModel::from_vec(vec![10, 20, 30]);
768        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
769        let c = changes.clone();
770        let _handle = model.observe_changes(move |change| {
771            c.borrow_mut().push(change.clone());
772        });
773
774        model.set(2, 99);
775        assert_eq!(model.with_item(2, |v| *v), Some(99));
776        let log = changes.borrow();
777        assert_eq!(log.len(), 1, "set should emit exactly one change");
778        assert_eq!(log[0], DataChange::ItemUpdated { index: 2 });
779    }
780
781    #[test]
782    fn move_item_emits_moved() {
783        let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
784        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
785        let c = changes.clone();
786        let _handle = model.observe_changes(move |change| {
787            c.borrow_mut().push(change.clone());
788        });
789
790        model.move_item(0, 2);
791        // After: ["b", "c", "a", "d"]
792        assert_eq!(model.with_item(0, |v| *v), Some("b"));
793        assert_eq!(model.with_item(2, |v| *v), Some("a"));
794        let log = changes.borrow();
795        assert_eq!(log.len(), 1, "move_item should emit exactly one change");
796        assert_eq!(
797            log[0],
798            DataChange::ItemsMoved {
799                from: 0,
800                to: 2,
801                count: 1
802            }
803        );
804    }
805
806    #[test]
807    fn move_item_same_index_is_noop() {
808        let model = ListModel::from_vec(vec![1, 2, 3]);
809        let count = Rc::new(Cell::new(0));
810        let c = count.clone();
811        let _handle = model.observe_changes(move |_| {
812            c.set(c.get() + 1);
813        });
814
815        model.move_item(1, 1);
816        assert_eq!(count.get(), 0);
817    }
818
819    #[test]
820    fn replace_all_emits_reset() {
821        let model = ListModel::from_vec(vec![1, 2]);
822        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
823        let c = changes.clone();
824        let _handle = model.observe_changes(move |change| {
825            c.borrow_mut().push(change.clone());
826        });
827
828        model.replace_all(vec![10, 20, 30]);
829        assert_eq!(model.len(), 3);
830        let log = changes.borrow();
831        assert_eq!(log[0], DataChange::Reset);
832    }
833
834    #[test]
835    fn clear_emits_reset() {
836        let model = ListModel::from_vec(vec![1, 2, 3]);
837        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
838        let c = changes.clone();
839        let _handle = model.observe_changes(move |change| {
840            c.borrow_mut().push(change.clone());
841        });
842
843        model.clear();
844        assert!(model.is_empty());
845        let log = changes.borrow();
846        assert_eq!(log[0], DataChange::Reset);
847    }
848
849    #[test]
850    fn observer_removed_on_handle_drop() {
851        let model = ListModel::new();
852        let count = Rc::new(Cell::new(0));
853        let c = count.clone();
854        let handle = model.observe_changes(move |_| {
855            c.set(c.get() + 1);
856        });
857
858        model.push(1);
859        assert_eq!(count.get(), 1);
860
861        drop(handle);
862        model.push(2);
863        assert_eq!(count.get(), 1); // Not called again
864    }
865
866    #[test]
867    fn multiple_observers() {
868        let model = ListModel::new();
869        let count = Rc::new(Cell::new(0));
870        let c1 = count.clone();
871        let c2 = count.clone();
872        let _h1 = model.observe_changes(move |_| c1.set(c1.get() + 1));
873        let _h2 = model.observe_changes(move |_| c2.set(c2.get() + 1));
874
875        model.push(42);
876        assert_eq!(count.get(), 2);
877    }
878
879    #[test]
880    fn clone_shares_data() {
881        let model = ListModel::from_vec(vec![1, 2, 3]);
882        let clone = model.clone();
883
884        model.push(4);
885        assert_eq!(clone.len(), 4);
886        assert_eq!(clone.with_item(3, |v| *v), Some(4));
887    }
888
889    #[test]
890    fn clone_shares_observers() {
891        let model = ListModel::from_vec(vec![1, 2]);
892        let count = Rc::new(Cell::new(0));
893        let c = count.clone();
894        let _handle = model.observe_changes(move |_| c.set(c.get() + 1));
895
896        let clone = model.clone();
897        clone.push(3); // mutation on clone triggers observer registered on original
898        assert_eq!(count.get(), 1);
899    }
900
901    #[test]
902    fn with_item_out_of_bounds_returns_none() {
903        let model = ListModel::from_vec(vec![1, 2]);
904        assert_eq!(model.with_item(5, |v| *v), None);
905    }
906
907    #[test]
908    #[should_panic]
909    fn remove_out_of_bounds_panics() {
910        let model = ListModel::from_vec(vec![1]);
911        model.remove(5);
912    }
913
914    // ── ListDataSource capability protocol ──────────────────────────────
915
916    fn order<T: Clone>(model: &ListModel<T>) -> Vec<T> {
917        (0..model.len())
918            .map(|i| model.with_item(i, |v| v.clone()).unwrap())
919            .collect()
920    }
921
922    #[test]
923    fn list_source_accept_drop_after_reorders() {
924        // [a,b,c,d]; drag index 0 (a) After index 2 (c) → [b,c,a,d]
925        let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
926        assert!(model.accept_drop(DropCommit {
927            source: DragSource::SameView { key: 0 },
928            target: 2,
929            position: DropPosition::After,
930        }));
931        assert_eq!(order(&model), vec!["b", "c", "a", "d"]);
932    }
933
934    #[test]
935    fn list_source_accept_drop_before_reorders() {
936        // [a,b,c,d]; drag index 3 (d) Before index 1 (b) → [a,d,b,c]
937        let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
938        assert!(model.accept_drop(DropCommit {
939            source: DragSource::SameView { key: 3 },
940            target: 1,
941            position: DropPosition::Before,
942        }));
943        assert_eq!(order(&model), vec!["a", "d", "b", "c"]);
944    }
945
946    #[test]
947    fn list_source_can_accept_rejects_into_accepts_sibling() {
948        let model = ListModel::from_vec(vec![1, 2, 3]);
949        // A flat list does not nest → Into is forbidden.
950        assert_eq!(
951            model.can_accept(&DropQuery {
952                source: DragSource::SameView { key: 0 },
953                target: 1,
954                position: DropPosition::Into,
955            }),
956            DropResponse::Reject
957        );
958        // Sibling reorder is allowed.
959        assert_eq!(
960            model.can_accept(&DropQuery {
961                source: DragSource::SameView { key: 0 },
962                target: 1,
963                position: DropPosition::After,
964            }),
965            DropResponse::Accept
966        );
967    }
968
969    #[test]
970    fn list_source_key_is_positional_identity() {
971        let model = ListModel::from_vec(vec![10, 20, 30]);
972        assert_eq!(model.key_at(1), Some(1));
973        assert_eq!(model.index_of(&2), Some(2));
974        assert_eq!(model.key_at(5), None);
975        assert_eq!(model.index_of(&9), None);
976    }
977
978    fn snapshot<T: Clone>(model: &ListModel<T>) -> Vec<T> {
979        (0..model.len())
980            .filter_map(|i| model.with_item(i, |v| v.clone()))
981            .collect()
982    }
983
984    #[test]
985    fn move_items_block_move_contiguous_and_ordered() {
986        // Move a non-contiguous set {A(0), C(2), E(4)} to land at gap 3
987        // (before the item originally at index 3, i.e. D).
988        let model = ListModel::from_vec(vec!['A', 'B', 'C', 'D', 'E', 'F']);
989        model.move_items(&[0, 2, 4], 3);
990        // gap 3 = "before the original item at index 3" (D). Remove A,C,E →
991        // [B,D,F]; two removed before the gap → insert the block before D.
992        assert_eq!(snapshot(&model), vec!['B', 'A', 'C', 'E', 'D', 'F']);
993    }
994
995    #[test]
996    fn move_items_to_end() {
997        let model = ListModel::from_vec(vec![1, 2, 3, 4]);
998        model.move_items(&[0, 1], 4);
999        assert_eq!(snapshot(&model), vec![3, 4, 1, 2]);
1000    }
1001
1002    #[test]
1003    fn move_items_ignores_out_of_range_and_dedups() {
1004        let model = ListModel::from_vec(vec![1, 2, 3]);
1005        model.move_items(&[1, 1, 9], 3);
1006        assert_eq!(snapshot(&model), vec![1, 3, 2]);
1007    }
1008
1009    #[test]
1010    fn move_items_single_matches_move_item() {
1011        let a = ListModel::from_vec(vec![1, 2, 3, 4, 5]);
1012        let b = ListModel::from_vec(vec![1, 2, 3, 4, 5]);
1013        // move_items([1], gap 4) == move a block of one from 1 to before-4.
1014        a.move_items(&[1], 4);
1015        // Equivalent single move: remove index 1, insert at post-removal index 3.
1016        b.move_item(1, 3);
1017        assert_eq!(snapshot(&a), snapshot(&b));
1018    }
1019
1020    #[test]
1021    fn reorder_within_multi_row_lands_contiguously() {
1022        // Exercise the ListModel `reorder_within` override (multi → block move).
1023        let model = ListModel::from_vec(vec![0, 1, 2, 3, 4, 5]);
1024        // Drag rows {0,1} to drop After index 4.
1025        assert!(model.reorder_within(&[0, 1], &4, DropPosition::After));
1026        assert_eq!(snapshot(&model), vec![2, 3, 4, 0, 1, 5]);
1027    }
1028
1029    #[test]
1030    fn reorder_within_single_row_uses_accept_drop() {
1031        let model = ListModel::from_vec(vec![10, 20, 30]);
1032        assert!(model.reorder_within(&[0], &2, DropPosition::After));
1033        assert_eq!(snapshot(&model), vec![20, 30, 10]);
1034    }
1035
1036    #[test]
1037    fn reorder_within_into_is_rejected_for_flat_list() {
1038        let model = ListModel::from_vec(vec![1, 2, 3]);
1039        assert!(!model.reorder_within(&[0, 1], &2, DropPosition::Into));
1040        assert_eq!(snapshot(&model), vec![1, 2, 3]);
1041    }
1042
1043    #[test]
1044    fn on_drag_out_removes_the_moved_row() {
1045        let model = ListModel::from_vec(vec!['a', 'b', 'c']);
1046        model.on_drag_out(&1);
1047        assert_eq!(snapshot(&model), vec!['a', 'c']);
1048    }
1049
1050    #[test]
1051    fn move_items_contiguous_emits_moved_non_contiguous_resets() {
1052        let model = ListModel::from_vec(vec![0, 1, 2, 3, 4]);
1053        let log: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
1054        let l = log.clone();
1055        let _h = model.observe_changes(move |c| l.borrow_mut().push(c.clone()));
1056        // Contiguous block → ItemsMoved (so index selection can follow).
1057        assert!(model.move_items(&[1, 2], 5));
1058        assert!(matches!(
1059            log.borrow().last(),
1060            Some(DataChange::ItemsMoved { .. })
1061        ));
1062        log.borrow_mut().clear();
1063        // Non-contiguous set → Reset.
1064        assert!(model.move_items(&[0, 2], 0));
1065        assert!(matches!(log.borrow().last(), Some(DataChange::Reset)));
1066    }
1067
1068    #[test]
1069    fn reorder_within_and_move_items_report_no_move_for_out_of_range() {
1070        let model = ListModel::from_vec(vec![1, 2, 3]);
1071        assert!(!model.reorder_within(&[99, 100], &0, DropPosition::Before));
1072        assert!(!model.move_items(&[99, 100], 0));
1073        assert_eq!(snapshot(&model), vec![1, 2, 3]);
1074    }
1075
1076    // ── reconcile_by_key ─────────────────────────────────────────────────
1077
1078    #[derive(Debug, Clone, PartialEq, Eq)]
1079    struct Row {
1080        id: u64,
1081        val: &'static str,
1082    }
1083
1084    fn row(id: u64, val: &'static str) -> Row {
1085        Row { id, val }
1086    }
1087
1088    fn key(r: &Row) -> u64 {
1089        r.id
1090    }
1091
1092    fn ids(model: &ListModel<Row>) -> Vec<u64> {
1093        (0..model.len())
1094            .map(|i| model.with_item(i, |r| r.id).unwrap())
1095            .collect()
1096    }
1097
1098    fn vals(model: &ListModel<Row>) -> Vec<&'static str> {
1099        (0..model.len())
1100            .map(|i| model.with_item(i, |r| r.val).unwrap())
1101            .collect()
1102    }
1103
1104    fn record_changes(model: &ListModel<Row>) -> (Rc<RefCell<Vec<DataChange>>>, ObserverHandle) {
1105        let log: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
1106        let l = log.clone();
1107        let handle = model.observe_changes(move |c| l.borrow_mut().push(c.clone()));
1108        (log, handle)
1109    }
1110
1111    #[test]
1112    fn reconcile_pure_insert_emits_one_coalesced_range() {
1113        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
1114        let (log, _h) = record_changes(&model);
1115
1116        model.reconcile_by_key(
1117            vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")],
1118            key,
1119        );
1120
1121        assert_eq!(ids(&model), vec![1, 2, 3, 4]);
1122        assert_eq!(vals(&model), vec!["a", "b", "c", "d"]);
1123        let entries = log.borrow();
1124        assert_eq!(entries.len(), 1, "contiguous inserts coalesce: {entries:?}");
1125        assert_eq!(entries[0], DataChange::ItemsInserted { range: 2..4 });
1126    }
1127
1128    #[test]
1129    fn reconcile_insert_at_front_and_middle() {
1130        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
1131        let (log, _h) = record_changes(&model);
1132
1133        // 0 is new (front), 1 stays, 5 is new (middle), 2 stays.
1134        model.reconcile_by_key(
1135            vec![row(0, "z"), row(1, "a"), row(5, "m"), row(2, "b")],
1136            key,
1137        );
1138
1139        assert_eq!(ids(&model), vec![0, 1, 5, 2]);
1140        let entries = log.borrow();
1141        // Two non-adjacent insert runs, no move needed (1 and 2 already in
1142        // relative order).
1143        assert_eq!(entries.len(), 2, "{entries:?}");
1144        assert_eq!(entries[0], DataChange::ItemsInserted { range: 0..1 });
1145        assert_eq!(entries[1], DataChange::ItemsInserted { range: 2..3 });
1146    }
1147
1148    #[test]
1149    fn reconcile_pure_remove_emits_one_coalesced_range() {
1150        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1151        let (log, _h) = record_changes(&model);
1152
1153        model.reconcile_by_key(vec![row(1, "a"), row(4, "d")], key);
1154
1155        assert_eq!(ids(&model), vec![1, 4]);
1156        let entries = log.borrow();
1157        assert_eq!(entries.len(), 1, "contiguous removes coalesce: {entries:?}");
1158        assert_eq!(entries[0], DataChange::ItemsRemoved { range: 1..3 });
1159    }
1160
1161    #[test]
1162    fn reconcile_remove_scattered_emits_separate_ranges() {
1163        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1164        let (log, _h) = record_changes(&model);
1165
1166        model.reconcile_by_key(vec![row(1, "a"), row(3, "c")], key);
1167
1168        assert_eq!(ids(&model), vec![1, 3]);
1169        let entries = log.borrow();
1170        assert_eq!(entries.len(), 2, "{entries:?}");
1171        // Emitted back-to-front so earlier indices stay valid at removal time.
1172        assert_eq!(entries[0], DataChange::ItemsRemoved { range: 3..4 });
1173        assert_eq!(entries[1], DataChange::ItemsRemoved { range: 1..2 });
1174    }
1175
1176    #[test]
1177    fn reconcile_reorder_emits_only_moves() {
1178        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1179        let (log, _h) = record_changes(&model);
1180
1181        model.reconcile_by_key(vec![row(3, "c"), row(2, "b"), row(1, "a")], key);
1182
1183        assert_eq!(ids(&model), vec![3, 2, 1]);
1184        let entries = log.borrow();
1185        assert!(
1186            entries
1187                .iter()
1188                .all(|c| matches!(c, DataChange::ItemsMoved { .. })),
1189            "a pure reorder must only emit moves: {entries:?}"
1190        );
1191        assert!(!entries.is_empty());
1192    }
1193
1194    #[test]
1195    fn reconcile_no_reorder_when_relative_order_already_matches() {
1196        // Peer removed the middle row but didn't touch the order of the rest —
1197        // this must NOT emit any ItemsMoved, only the removal.
1198        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1199        let (log, _h) = record_changes(&model);
1200
1201        model.reconcile_by_key(vec![row(1, "a"), row(3, "c"), row(4, "d")], key);
1202
1203        assert_eq!(ids(&model), vec![1, 3, 4]);
1204        let entries = log.borrow();
1205        assert!(
1206            entries
1207                .iter()
1208                .all(|c| !matches!(c, DataChange::ItemsMoved { .. })),
1209            "untouched relative order must not emit moves: {entries:?}"
1210        );
1211    }
1212
1213    #[test]
1214    fn reconcile_in_place_update_emits_item_updated() {
1215        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1216        let (log, _h) = record_changes(&model);
1217
1218        model.reconcile_by_key(vec![row(1, "a"), row(2, "CHANGED"), row(3, "c")], key);
1219
1220        assert_eq!(vals(&model), vec!["a", "CHANGED", "c"]);
1221        let entries = log.borrow();
1222        assert_eq!(entries.len(), 1, "{entries:?}");
1223        assert_eq!(entries[0], DataChange::ItemUpdated { index: 1 });
1224    }
1225
1226    #[test]
1227    fn reconcile_combined_insert_remove_reorder_update() {
1228        // old: [1,2,3,4] -> new: [4, 2*, 5, 1]
1229        //  - 3 removed
1230        //  - 4 moves to front
1231        //  - 2's content changes in place
1232        //  - 5 is inserted
1233        //  - 1 stays last (relative order of 1 vs 2 vs 4 reshuffled)
1234        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1235        let (log, _h) = record_changes(&model);
1236
1237        model.reconcile_by_key(
1238            vec![row(4, "d"), row(2, "B!"), row(5, "e"), row(1, "a")],
1239            key,
1240        );
1241
1242        assert_eq!(ids(&model), vec![4, 2, 5, 1]);
1243        assert_eq!(vals(&model), vec!["d", "B!", "e", "a"]);
1244        let entries = log.borrow();
1245        assert!(!entries.is_empty());
1246        assert!(
1247            entries
1248                .iter()
1249                .any(|c| matches!(c, DataChange::ItemsMoved { .. })),
1250            "{entries:?}"
1251        );
1252        assert!(
1253            entries
1254                .iter()
1255                .any(|c| matches!(c, DataChange::ItemsInserted { .. })),
1256            "{entries:?}"
1257        );
1258        assert!(
1259            entries
1260                .iter()
1261                .any(|c| matches!(c, DataChange::ItemsRemoved { .. })),
1262            "{entries:?}"
1263        );
1264        assert!(
1265            entries
1266                .iter()
1267                .any(|c| matches!(c, DataChange::ItemUpdated { .. })),
1268            "{entries:?}"
1269        );
1270        assert!(
1271            !entries.iter().any(|c| matches!(c, DataChange::Reset)),
1272            "{entries:?}"
1273        );
1274    }
1275
1276    #[test]
1277    fn reconcile_emits_in_walk_order_with_sequential_indices() {
1278        // Update then insert, interleaved in walk order (not grouped by kind).
1279        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
1280        let (log, _h) = record_changes(&model);
1281        model.reconcile_by_key(vec![row(1, "a"), row(2, "B"), row(3, "c")], key);
1282        assert_eq!(
1283            *log.borrow(),
1284            vec![
1285                DataChange::ItemUpdated { index: 1 },
1286                DataChange::ItemsInserted { range: 2..3 },
1287            ]
1288        );
1289
1290        // Remove first (old indices), then per survivor: move, update, with a
1291        // pending insert flushed ahead of the next survivor.
1292        // old [1,2,3,4] -> new [4, 9(new), 2*, 1]
1293        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1294        let (log, _h) = record_changes(&model);
1295        model.reconcile_by_key(
1296            vec![row(4, "d"), row(9, "n"), row(2, "B"), row(1, "a")],
1297            key,
1298        );
1299        assert_eq!(ids(&model), vec![4, 9, 2, 1]);
1300        assert_eq!(
1301            *log.borrow(),
1302            vec![
1303                // 3 leaves: [1,2,4]
1304                DataChange::ItemsRemoved { range: 2..3 },
1305                // 4 walks to slot 0: [4,1,2]
1306                DataChange::ItemsMoved {
1307                    from: 2,
1308                    to: 0,
1309                    count: 1
1310                },
1311                // 9 is flushed before the next survivor: [4,9,1,2]
1312                DataChange::ItemsInserted { range: 1..2 },
1313                // 2 walks to slot 2: [4,9,2,1]
1314                DataChange::ItemsMoved {
1315                    from: 3,
1316                    to: 2,
1317                    count: 1
1318                },
1319                DataChange::ItemUpdated { index: 2 },
1320            ]
1321        );
1322    }
1323
1324    #[test]
1325    fn reconcile_empty_to_full() {
1326        let model: ListModel<Row> = ListModel::new();
1327        let (log, _h) = record_changes(&model);
1328
1329        model.reconcile_by_key(vec![row(1, "a"), row(2, "b"), row(3, "c")], key);
1330
1331        assert_eq!(ids(&model), vec![1, 2, 3]);
1332        let entries = log.borrow();
1333        assert_eq!(entries.len(), 1, "{entries:?}");
1334        assert_eq!(entries[0], DataChange::ItemsInserted { range: 0..3 });
1335    }
1336
1337    #[test]
1338    fn reconcile_full_to_empty() {
1339        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1340        let (log, _h) = record_changes(&model);
1341
1342        model.reconcile_by_key(vec![], key);
1343
1344        assert!(model.is_empty());
1345        let entries = log.borrow();
1346        assert_eq!(entries.len(), 1, "{entries:?}");
1347        assert_eq!(entries[0], DataChange::ItemsRemoved { range: 0..3 });
1348    }
1349
1350    #[test]
1351    fn reconcile_identical_input_emits_nothing() {
1352        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1353        let (log, _h) = record_changes(&model);
1354
1355        model.reconcile_by_key(vec![row(1, "a"), row(2, "b"), row(3, "c")], key);
1356
1357        assert_eq!(ids(&model), vec![1, 2, 3]);
1358        assert!(
1359            log.borrow().is_empty(),
1360            "identical reconcile must not notify: {:?}",
1361            log.borrow()
1362        );
1363    }
1364
1365    #[test]
1366    fn reconcile_never_emits_reset() {
1367        // Exercise every scenario above (plus a full rewrite / total
1368        // replacement, the case most tempted to fall back to `Reset`) and
1369        // assert `Reset` is never among the emitted changes.
1370        let scenarios: Vec<(Vec<Row>, Vec<Row>)> = vec![
1371            (
1372                vec![row(1, "a"), row(2, "b")],
1373                vec![row(1, "a"), row(2, "b"), row(3, "c")],
1374            ),
1375            (
1376                vec![row(1, "a"), row(2, "b"), row(3, "c")],
1377                vec![row(1, "a")],
1378            ),
1379            (
1380                vec![row(1, "a"), row(2, "b"), row(3, "c")],
1381                vec![row(3, "c"), row(1, "a"), row(2, "b")],
1382            ),
1383            (vec![], vec![row(1, "a"), row(2, "b")]),
1384            (vec![row(1, "a"), row(2, "b")], vec![]),
1385            (
1386                vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")],
1387                vec![row(9, "x"), row(8, "y"), row(7, "z")],
1388            ),
1389        ];
1390        for (before, after) in scenarios {
1391            let model = ListModel::from_vec(before.clone());
1392            let (log, _h) = record_changes(&model);
1393            model.reconcile_by_key(after.clone(), key);
1394            assert!(
1395                !log.borrow().iter().any(|c| matches!(c, DataChange::Reset)),
1396                "reconcile must never emit Reset — before {before:?}, after {after:?}, got {:?}",
1397                log.borrow()
1398            );
1399        }
1400    }
1401
1402    #[test]
1403    #[should_panic(expected = "reconcile_by_key")]
1404    fn reconcile_duplicate_key_in_new_items_panics() {
1405        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
1406        // Two incoming rows both claim key 1 — the second can't be found at
1407        // or after the write cursor once the first has already consumed
1408        // that key's slot (see `# Panics` on `reconcile_by_key`).
1409        model.reconcile_by_key(vec![row(1, "a"), row(1, "a-dup"), row(2, "b")], key);
1410    }
1411}