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    /// Emits, in this order, coalescing contiguous runs into a single event
329    /// each:
330    /// - [`DataChange::ItemsRemoved`] for keys present in the old list but
331    ///   absent from `new_items`;
332    /// - [`DataChange::ItemsMoved`] (single-row blocks) to re-order the
333    ///   surviving rows into `new_items`'s relative order — skipped
334    ///   entirely for rows already in the right place, so an append-only or
335    ///   remove-only reload emits **no** moves at all;
336    /// - [`DataChange::ItemsInserted`] for keys present in `new_items` but
337    ///   not in the old list;
338    /// - [`DataChange::ItemUpdated`] for a row whose key is unchanged but
339    ///   whose content differs (`T: PartialEq`) — the row's stored value is
340    ///   replaced with the incoming one.
341    ///
342    /// If `new_items` is identical (same keys, same order, same content, by
343    /// `PartialEq`) to the current contents, **no** change is emitted and no
344    /// observer runs — reconciling with unchanged data is silent.
345    ///
346    /// # Preconditions
347    /// `key_fn` must be a pure, stable function of an item's identity (not
348    /// its content) and keys must be **unique** within both the current list
349    /// and `new_items`. See `# Panics` below — violating either is a caller
350    /// bug, not a silently-tolerated edge case.
351    ///
352    /// # Panics
353    /// Panics (via an internal `.expect`) if `key_fn` is not stable — it
354    /// returns a different key for the same item across the two calls this
355    /// method makes to it (once while snapshotting the current list's keys,
356    /// once while re-deriving a key during the write pass) — or if a key is
357    /// **duplicated** within the current list or within `new_items`. Both
358    /// break the same invariant the write pass relies on: "the item that
359    /// was accounted for under this key is still findable at or after the
360    /// write cursor." A duplicate key means two different items raced to
361    /// claim one key slot, so by the time the second one is processed the
362    /// slot the accounting expected is already gone. This is this crate's
363    /// usual documented-panic-on-contract-violation style (see e.g.
364    /// [`TreeModel::remove`](crate::TreeModel::remove)) — a caller-side bug
365    /// surfaced immediately as a panic, not silently wrong data.
366    ///
367    /// # Complexity
368    /// Re-ordering is a straightforward left-to-right pass that moves each
369    /// out-of-place survivor into its target slot; it is correct and always
370    /// granular, but is not guaranteed to emit the mathematically fewest
371    /// possible `ItemsMoved` events for an adversarial permutation (an
372    /// LIS-based scheme could do slightly better there). For the common
373    /// case this primitive targets — a peer append/remove/edit merged back
374    /// in — the existing relative order of untouched rows is preserved
375    /// as-is, so no moves are emitted at all.
376    pub fn reconcile_by_key<K: Eq + Hash>(&self, new_items: Vec<T>, key_fn: impl Fn(&T) -> K) {
377        let mut changes: Vec<DataChange> = Vec::new();
378        {
379            let mut guard = self.inner.borrow_mut();
380            reconcile_vec(&mut guard.items, new_items, &key_fn, &mut changes);
381        }
382        for change in changes {
383            self.notify(change);
384        }
385    }
386}
387
388/// Diff `items` (current contents) against `new_items` by key, mutating
389/// `items` in place to match `new_items` exactly and pushing one
390/// [`DataChange`] per atomic step taken. Kept as a free function (rather
391/// than inlined into `reconcile_by_key`) so it can be exercised directly —
392/// and so it operates on the bare `Vec<T>` while the caller still holds the
393/// `RefCell` borrow, before any observer notification fires.
394fn reconcile_vec<T: PartialEq, K: Eq + Hash>(
395    items: &mut Vec<T>,
396    new_items: Vec<T>,
397    key_fn: &impl Fn(&T) -> K,
398    out: &mut Vec<DataChange>,
399) {
400    // Pair every incoming item with its key up front: we need the key
401    // before we decide anything, and this avoids re-deriving ownership of
402    // the item later.
403    let new_pairs: Vec<(K, T)> = new_items.into_iter().map(|it| (key_fn(&it), it)).collect();
404    let new_key_set: HashSet<&K> = new_pairs.iter().map(|(k, _)| k).collect();
405
406    // ---- Phase 1: drop old items whose key no longer exists in the
407    // incoming list. Computed against a pre-removal key snapshot so later
408    // removals don't perturb earlier indices; applied back-to-front so
409    // each removed range's indices stay valid at the point it's removed. ----
410    let old_keys: Vec<K> = items.iter().map(key_fn).collect();
411    let remove_idxs: Vec<usize> = (0..items.len())
412        .filter(|&i| !new_key_set.contains(&old_keys[i]))
413        .collect();
414    for range in coalesce_ranges(&remove_idxs).into_iter().rev() {
415        items.drain(range.clone());
416        out.push(DataChange::ItemsRemoved { range });
417    }
418
419    // From here on, `items` holds exactly the surviving ("common") rows, in
420    // their original relative order.
421    let remaining_keys: HashSet<K> = items.iter().map(key_fn).collect();
422
423    // ---- Phase 2/3: walk the target order left to right. A common key is
424    // moved into place (if it isn't already there) and its content updated
425    // in place if it changed; a brand-new key is buffered and flushed as a
426    // single contiguous `ItemsInserted` run as soon as a common key (or the
427    // end of the list) is reached. ----
428    let mut cursor = 0usize;
429    let mut pending_inserts: Vec<T> = Vec::new();
430
431    for (key, new_item) in new_pairs {
432        if remaining_keys.contains(&key) {
433            flush_inserts(items, &mut cursor, &mut pending_inserts, out);
434
435            // Invariant: items[0..cursor] already matches the target
436            // prefix, so this key — being common and not yet placed — must
437            // sit somewhere at or after `cursor`.
438            let pos = items
439                .iter()
440                .skip(cursor)
441                .position(|it| key_fn(it) == key)
442                .expect(
443                    "reconcile_by_key: key reported common but not found — \
444                     key_fn must be stable and keys unique",
445                );
446            let actual = cursor + pos;
447            if actual != cursor {
448                let val = items.remove(actual);
449                items.insert(cursor, val);
450                out.push(DataChange::ItemsMoved {
451                    from: actual,
452                    to: cursor,
453                    count: 1,
454                });
455            }
456            if items[cursor] != new_item {
457                items[cursor] = new_item;
458                out.push(DataChange::ItemUpdated { index: cursor });
459            }
460            cursor += 1;
461        } else {
462            pending_inserts.push(new_item);
463        }
464    }
465    flush_inserts(items, &mut cursor, &mut pending_inserts, out);
466}
467
468/// Splice any buffered new rows into `items` at `*cursor` as one contiguous
469/// block, emit the single `ItemsInserted` covering them, and advance the
470/// cursor past them. No-op (no event) if nothing is pending.
471fn flush_inserts<T>(
472    items: &mut Vec<T>,
473    cursor: &mut usize,
474    pending: &mut Vec<T>,
475    out: &mut Vec<DataChange>,
476) {
477    if pending.is_empty() {
478        return;
479    }
480    let start = *cursor;
481    let n = pending.len();
482    items.splice(start..start, pending.drain(..));
483    out.push(DataChange::ItemsInserted {
484        range: start..start + n,
485    });
486    *cursor += n;
487}
488
489/// Group a sorted, deduplicated slice of indices into maximal contiguous
490/// ranges, e.g. `[1, 2, 3, 7, 8]` → `[1..4, 7..9]`.
491fn coalesce_ranges(sorted_idxs: &[usize]) -> Vec<Range<usize>> {
492    let mut ranges = Vec::new();
493    let mut iter = sorted_idxs.iter().peekable();
494    while let Some(&start) = iter.next() {
495        let mut end = start + 1;
496        while iter.peek().is_some_and(|&&n| n == end) {
497            end += 1;
498            iter.next();
499        }
500        ranges.push(start..end);
501    }
502    ranges
503}
504
505impl<T: std::fmt::Debug + 'static> ListModel<T> {
506    /// Register this model with the debug inspector under `name`. In
507    /// release builds (`!cfg(debug_assertions)`) this is a no-op
508    /// pass-through so call sites stay free of `#[cfg]` lines.
509    ///
510    /// Idempotent on repeated calls — the latest registration wins.
511    /// The registration drops automatically when the last `ListModel`
512    /// handle is freed (the adapter the registry holds is `Weak`).
513    pub fn debug_named(self, _name: impl Into<String>) -> Self {
514        #[cfg(debug_assertions)]
515        {
516            let weak = Rc::downgrade(&self.inner);
517            let adapter: Rc<dyn crate::debug_registry::ModelDebug> =
518                Rc::new(ListModelDebug::<T> { weak });
519            let name = _name.into();
520            crate::debug_registry::register(name, Rc::downgrade(&adapter));
521            self.inner.borrow_mut().debug_adapter = Some(adapter);
522        }
523        self
524    }
525}
526
527#[cfg(debug_assertions)]
528struct ListModelDebug<T> {
529    weak: std::rc::Weak<RefCell<ListModelInner<T>>>,
530}
531
532#[cfg(debug_assertions)]
533impl<T: std::fmt::Debug + 'static> crate::debug_registry::ModelDebug for ListModelDebug<T> {
534    fn kind(&self) -> &'static str {
535        "ListModel"
536    }
537    fn len(&self) -> usize {
538        self.weak
539            .upgrade()
540            .map(|inner| inner.borrow().items.len())
541            .unwrap_or(0)
542    }
543    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
544        let Some(inner) = self.weak.upgrade() else {
545            return;
546        };
547        let guard = inner.borrow();
548        for (i, item) in guard.items.iter().enumerate() {
549            let _ = writeln!(out, "[{}] {:?}", i, item);
550        }
551    }
552}
553
554impl<T: 'static> Default for ListModel<T> {
555    fn default() -> Self {
556        Self::new()
557    }
558}
559
560impl<T: 'static> Clone for ListModel<T> {
561    fn clone(&self) -> Self {
562        Self {
563            inner: self.inner.clone(),
564        }
565    }
566}
567
568impl<T: std::fmt::Debug + 'static> std::fmt::Debug for ListModel<T> {
569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        f.debug_struct("ListModel")
571            .field("len", &self.inner.borrow().items.len())
572            .finish()
573    }
574}
575
576/// `ListModel` is the built-in fully-resident, in-memory `ListDataSource`.
577/// Identity is positional (`Key = usize`); a `SameView` drop reorders via
578/// `move_item`. `Into` and `Foreign` drops are rejected (a flat list does not
579/// nest, and a bare model knows no foreign payloads).
580impl<T: 'static> ListDataSource for ListModel<T> {
581    type Item = T;
582    type Key = usize;
583
584    fn len(&self) -> usize {
585        ListModel::len(self)
586    }
587
588    fn with_item<R>(&self, index: usize, f: impl FnOnce(&T) -> R) -> Option<R> {
589        ListModel::with_item(self, index, f)
590    }
591
592    fn key_at(&self, index: usize) -> Option<usize> {
593        (index < ListModel::len(self)).then_some(index)
594    }
595
596    fn index_of(&self, key: &usize) -> Option<usize> {
597        (*key < ListModel::len(self)).then_some(*key)
598    }
599
600    fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle {
601        ListModel::observe_changes(self, f)
602    }
603
604    fn drag(&self, _key: &usize) -> DragEligibility {
605        DragEligibility::CanDrag
606    }
607
608    fn can_accept(&self, query: &DropQuery<'_, usize>) -> DropResponse {
609        match &query.source {
610            DragSource::SameView { .. } => match query.position {
611                DropPosition::Into => DropResponse::Reject,
612                DropPosition::Before | DropPosition::After => DropResponse::Accept,
613            },
614            DragSource::Foreign { .. } => DropResponse::Reject,
615        }
616    }
617
618    fn accept_drop(&self, commit: DropCommit<'_, usize>) -> bool {
619        let DragSource::SameView { key: from } = commit.source else {
620            return false;
621        };
622        let len = ListModel::len(self);
623        if from >= len {
624            return false;
625        }
626        let target = commit.target;
627        // move_item removes `from` before inserting, so an insertion point above
628        // the source's old slot shifts down by one.
629        let shift = if from < target { 1 } else { 0 };
630        let to = match commit.position {
631            DropPosition::Before => target.saturating_sub(shift),
632            DropPosition::After => (target + 1).saturating_sub(shift),
633            DropPosition::Into => return false,
634        };
635        let to = to.min(len.saturating_sub(1));
636        self.move_item(from, to);
637        true
638    }
639
640    fn reorder_within(&self, sources: &[usize], target: &usize, position: DropPosition) -> bool {
641        // A `ListModel`'s key IS the index, so the stable-key default (which
642        // re-anchors on a just-moved key) would corrupt after the first move —
643        // route the multi-row case through the index-safe block move instead.
644        // A single row keeps the finer-grained `accept_drop`/`move_item` path.
645        if sources.len() <= 1 {
646            let Some(&from) = sources.first() else {
647                return false;
648            };
649            return self.accept_drop(DropCommit {
650                source: DragSource::SameView { key: from },
651                target: *target,
652                position,
653            });
654        }
655        let gap = match position {
656            DropPosition::Before => *target,
657            DropPosition::After => *target + 1,
658            DropPosition::Into => return false,
659        };
660        self.move_items(sources, gap)
661    }
662
663    fn on_drag_out(&self, key: &usize) {
664        // Source-side completion for a foreign move: drop the row that was
665        // accepted elsewhere. Callers remove in descending index order so
666        // earlier keys stay valid across a multi-row transfer.
667        if *key < ListModel::len(self) {
668            let _ = self.remove(*key);
669        }
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use std::cell::Cell;
676
677    use super::*;
678
679    #[test]
680    fn new_is_empty() {
681        let model: ListModel<String> = ListModel::new();
682        assert!(model.is_empty());
683        assert_eq!(model.len(), 0);
684    }
685
686    #[test]
687    fn from_vec() {
688        let model = ListModel::from_vec(vec![10, 20, 30]);
689        assert_eq!(model.len(), 3);
690        assert_eq!(model.with_item(1, |v| *v), Some(20));
691    }
692
693    #[test]
694    fn push_emits_inserted() {
695        let model = ListModel::new();
696        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
697        let c = changes.clone();
698        let _handle = model.observe_changes(move |change| {
699            c.borrow_mut().push(change.clone());
700        });
701
702        model.push("a");
703        model.push("b");
704
705        let log = changes.borrow();
706        assert_eq!(log.len(), 2);
707        assert_eq!(log[0], DataChange::ItemsInserted { range: 0..1 });
708        assert_eq!(log[1], DataChange::ItemsInserted { range: 1..2 });
709    }
710
711    #[test]
712    fn insert_emits_inserted() {
713        let model = ListModel::from_vec(vec![1, 2, 3]);
714        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
715        let c = changes.clone();
716        let _handle = model.observe_changes(move |change| {
717            c.borrow_mut().push(change.clone());
718        });
719
720        model.insert(1, 99);
721
722        assert_eq!(model.len(), 4);
723        assert_eq!(model.with_item(1, |v| *v), Some(99));
724        let log = changes.borrow();
725        assert_eq!(log.len(), 1, "insert should emit exactly one change");
726        assert_eq!(log[0], DataChange::ItemsInserted { range: 1..2 });
727    }
728
729    #[test]
730    fn remove_emits_removed() {
731        let model = ListModel::from_vec(vec!["a", "b", "c"]);
732        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
733        let c = changes.clone();
734        let _handle = model.observe_changes(move |change| {
735            c.borrow_mut().push(change.clone());
736        });
737
738        let removed = model.remove(1);
739        assert_eq!(removed, "b");
740        assert_eq!(model.len(), 2);
741        let log = changes.borrow();
742        assert_eq!(log.len(), 1, "remove should emit exactly one change");
743        assert_eq!(log[0], DataChange::ItemsRemoved { range: 1..2 });
744    }
745
746    #[test]
747    fn set_emits_updated() {
748        let model = ListModel::from_vec(vec![10, 20, 30]);
749        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
750        let c = changes.clone();
751        let _handle = model.observe_changes(move |change| {
752            c.borrow_mut().push(change.clone());
753        });
754
755        model.set(2, 99);
756        assert_eq!(model.with_item(2, |v| *v), Some(99));
757        let log = changes.borrow();
758        assert_eq!(log.len(), 1, "set should emit exactly one change");
759        assert_eq!(log[0], DataChange::ItemUpdated { index: 2 });
760    }
761
762    #[test]
763    fn move_item_emits_moved() {
764        let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
765        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
766        let c = changes.clone();
767        let _handle = model.observe_changes(move |change| {
768            c.borrow_mut().push(change.clone());
769        });
770
771        model.move_item(0, 2);
772        // After: ["b", "c", "a", "d"]
773        assert_eq!(model.with_item(0, |v| *v), Some("b"));
774        assert_eq!(model.with_item(2, |v| *v), Some("a"));
775        let log = changes.borrow();
776        assert_eq!(log.len(), 1, "move_item should emit exactly one change");
777        assert_eq!(
778            log[0],
779            DataChange::ItemsMoved {
780                from: 0,
781                to: 2,
782                count: 1
783            }
784        );
785    }
786
787    #[test]
788    fn move_item_same_index_is_noop() {
789        let model = ListModel::from_vec(vec![1, 2, 3]);
790        let count = Rc::new(Cell::new(0));
791        let c = count.clone();
792        let _handle = model.observe_changes(move |_| {
793            c.set(c.get() + 1);
794        });
795
796        model.move_item(1, 1);
797        assert_eq!(count.get(), 0);
798    }
799
800    #[test]
801    fn replace_all_emits_reset() {
802        let model = ListModel::from_vec(vec![1, 2]);
803        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
804        let c = changes.clone();
805        let _handle = model.observe_changes(move |change| {
806            c.borrow_mut().push(change.clone());
807        });
808
809        model.replace_all(vec![10, 20, 30]);
810        assert_eq!(model.len(), 3);
811        let log = changes.borrow();
812        assert_eq!(log[0], DataChange::Reset);
813    }
814
815    #[test]
816    fn clear_emits_reset() {
817        let model = ListModel::from_vec(vec![1, 2, 3]);
818        let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
819        let c = changes.clone();
820        let _handle = model.observe_changes(move |change| {
821            c.borrow_mut().push(change.clone());
822        });
823
824        model.clear();
825        assert!(model.is_empty());
826        let log = changes.borrow();
827        assert_eq!(log[0], DataChange::Reset);
828    }
829
830    #[test]
831    fn observer_removed_on_handle_drop() {
832        let model = ListModel::new();
833        let count = Rc::new(Cell::new(0));
834        let c = count.clone();
835        let handle = model.observe_changes(move |_| {
836            c.set(c.get() + 1);
837        });
838
839        model.push(1);
840        assert_eq!(count.get(), 1);
841
842        drop(handle);
843        model.push(2);
844        assert_eq!(count.get(), 1); // Not called again
845    }
846
847    #[test]
848    fn multiple_observers() {
849        let model = ListModel::new();
850        let count = Rc::new(Cell::new(0));
851        let c1 = count.clone();
852        let c2 = count.clone();
853        let _h1 = model.observe_changes(move |_| c1.set(c1.get() + 1));
854        let _h2 = model.observe_changes(move |_| c2.set(c2.get() + 1));
855
856        model.push(42);
857        assert_eq!(count.get(), 2);
858    }
859
860    #[test]
861    fn clone_shares_data() {
862        let model = ListModel::from_vec(vec![1, 2, 3]);
863        let clone = model.clone();
864
865        model.push(4);
866        assert_eq!(clone.len(), 4);
867        assert_eq!(clone.with_item(3, |v| *v), Some(4));
868    }
869
870    #[test]
871    fn clone_shares_observers() {
872        let model = ListModel::from_vec(vec![1, 2]);
873        let count = Rc::new(Cell::new(0));
874        let c = count.clone();
875        let _handle = model.observe_changes(move |_| c.set(c.get() + 1));
876
877        let clone = model.clone();
878        clone.push(3); // mutation on clone triggers observer registered on original
879        assert_eq!(count.get(), 1);
880    }
881
882    #[test]
883    fn with_item_out_of_bounds_returns_none() {
884        let model = ListModel::from_vec(vec![1, 2]);
885        assert_eq!(model.with_item(5, |v| *v), None);
886    }
887
888    #[test]
889    #[should_panic]
890    fn remove_out_of_bounds_panics() {
891        let model = ListModel::from_vec(vec![1]);
892        model.remove(5);
893    }
894
895    // ── ListDataSource capability protocol ──────────────────────────────
896
897    fn order<T: Clone>(model: &ListModel<T>) -> Vec<T> {
898        (0..model.len())
899            .map(|i| model.with_item(i, |v| v.clone()).unwrap())
900            .collect()
901    }
902
903    #[test]
904    fn list_source_accept_drop_after_reorders() {
905        // [a,b,c,d]; drag index 0 (a) After index 2 (c) → [b,c,a,d]
906        let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
907        assert!(model.accept_drop(DropCommit {
908            source: DragSource::SameView { key: 0 },
909            target: 2,
910            position: DropPosition::After,
911        }));
912        assert_eq!(order(&model), vec!["b", "c", "a", "d"]);
913    }
914
915    #[test]
916    fn list_source_accept_drop_before_reorders() {
917        // [a,b,c,d]; drag index 3 (d) Before index 1 (b) → [a,d,b,c]
918        let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
919        assert!(model.accept_drop(DropCommit {
920            source: DragSource::SameView { key: 3 },
921            target: 1,
922            position: DropPosition::Before,
923        }));
924        assert_eq!(order(&model), vec!["a", "d", "b", "c"]);
925    }
926
927    #[test]
928    fn list_source_can_accept_rejects_into_accepts_sibling() {
929        let model = ListModel::from_vec(vec![1, 2, 3]);
930        // A flat list does not nest → Into is forbidden.
931        assert_eq!(
932            model.can_accept(&DropQuery {
933                source: DragSource::SameView { key: 0 },
934                target: 1,
935                position: DropPosition::Into,
936            }),
937            DropResponse::Reject
938        );
939        // Sibling reorder is allowed.
940        assert_eq!(
941            model.can_accept(&DropQuery {
942                source: DragSource::SameView { key: 0 },
943                target: 1,
944                position: DropPosition::After,
945            }),
946            DropResponse::Accept
947        );
948    }
949
950    #[test]
951    fn list_source_key_is_positional_identity() {
952        let model = ListModel::from_vec(vec![10, 20, 30]);
953        assert_eq!(model.key_at(1), Some(1));
954        assert_eq!(model.index_of(&2), Some(2));
955        assert_eq!(model.key_at(5), None);
956        assert_eq!(model.index_of(&9), None);
957    }
958
959    fn snapshot<T: Clone>(model: &ListModel<T>) -> Vec<T> {
960        (0..model.len())
961            .filter_map(|i| model.with_item(i, |v| v.clone()))
962            .collect()
963    }
964
965    #[test]
966    fn move_items_block_move_contiguous_and_ordered() {
967        // Move a non-contiguous set {A(0), C(2), E(4)} to land at gap 3
968        // (before the item originally at index 3, i.e. D).
969        let model = ListModel::from_vec(vec!['A', 'B', 'C', 'D', 'E', 'F']);
970        model.move_items(&[0, 2, 4], 3);
971        // gap 3 = "before the original item at index 3" (D). Remove A,C,E →
972        // [B,D,F]; two removed before the gap → insert the block before D.
973        assert_eq!(snapshot(&model), vec!['B', 'A', 'C', 'E', 'D', 'F']);
974    }
975
976    #[test]
977    fn move_items_to_end() {
978        let model = ListModel::from_vec(vec![1, 2, 3, 4]);
979        model.move_items(&[0, 1], 4);
980        assert_eq!(snapshot(&model), vec![3, 4, 1, 2]);
981    }
982
983    #[test]
984    fn move_items_ignores_out_of_range_and_dedups() {
985        let model = ListModel::from_vec(vec![1, 2, 3]);
986        model.move_items(&[1, 1, 9], 3);
987        assert_eq!(snapshot(&model), vec![1, 3, 2]);
988    }
989
990    #[test]
991    fn move_items_single_matches_move_item() {
992        let a = ListModel::from_vec(vec![1, 2, 3, 4, 5]);
993        let b = ListModel::from_vec(vec![1, 2, 3, 4, 5]);
994        // move_items([1], gap 4) == move a block of one from 1 to before-4.
995        a.move_items(&[1], 4);
996        // Equivalent single move: remove index 1, insert at post-removal index 3.
997        b.move_item(1, 3);
998        assert_eq!(snapshot(&a), snapshot(&b));
999    }
1000
1001    #[test]
1002    fn reorder_within_multi_row_lands_contiguously() {
1003        // Exercise the ListModel `reorder_within` override (multi → block move).
1004        let model = ListModel::from_vec(vec![0, 1, 2, 3, 4, 5]);
1005        // Drag rows {0,1} to drop After index 4.
1006        assert!(model.reorder_within(&[0, 1], &4, DropPosition::After));
1007        assert_eq!(snapshot(&model), vec![2, 3, 4, 0, 1, 5]);
1008    }
1009
1010    #[test]
1011    fn reorder_within_single_row_uses_accept_drop() {
1012        let model = ListModel::from_vec(vec![10, 20, 30]);
1013        assert!(model.reorder_within(&[0], &2, DropPosition::After));
1014        assert_eq!(snapshot(&model), vec![20, 30, 10]);
1015    }
1016
1017    #[test]
1018    fn reorder_within_into_is_rejected_for_flat_list() {
1019        let model = ListModel::from_vec(vec![1, 2, 3]);
1020        assert!(!model.reorder_within(&[0, 1], &2, DropPosition::Into));
1021        assert_eq!(snapshot(&model), vec![1, 2, 3]);
1022    }
1023
1024    #[test]
1025    fn on_drag_out_removes_the_moved_row() {
1026        let model = ListModel::from_vec(vec!['a', 'b', 'c']);
1027        model.on_drag_out(&1);
1028        assert_eq!(snapshot(&model), vec!['a', 'c']);
1029    }
1030
1031    #[test]
1032    fn move_items_contiguous_emits_moved_non_contiguous_resets() {
1033        let model = ListModel::from_vec(vec![0, 1, 2, 3, 4]);
1034        let log: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
1035        let l = log.clone();
1036        let _h = model.observe_changes(move |c| l.borrow_mut().push(c.clone()));
1037        // Contiguous block → ItemsMoved (so index selection can follow).
1038        assert!(model.move_items(&[1, 2], 5));
1039        assert!(matches!(
1040            log.borrow().last(),
1041            Some(DataChange::ItemsMoved { .. })
1042        ));
1043        log.borrow_mut().clear();
1044        // Non-contiguous set → Reset.
1045        assert!(model.move_items(&[0, 2], 0));
1046        assert!(matches!(log.borrow().last(), Some(DataChange::Reset)));
1047    }
1048
1049    #[test]
1050    fn reorder_within_and_move_items_report_no_move_for_out_of_range() {
1051        let model = ListModel::from_vec(vec![1, 2, 3]);
1052        assert!(!model.reorder_within(&[99, 100], &0, DropPosition::Before));
1053        assert!(!model.move_items(&[99, 100], 0));
1054        assert_eq!(snapshot(&model), vec![1, 2, 3]);
1055    }
1056
1057    // ── reconcile_by_key ─────────────────────────────────────────────────
1058
1059    #[derive(Debug, Clone, PartialEq, Eq)]
1060    struct Row {
1061        id: u64,
1062        val: &'static str,
1063    }
1064
1065    fn row(id: u64, val: &'static str) -> Row {
1066        Row { id, val }
1067    }
1068
1069    fn key(r: &Row) -> u64 {
1070        r.id
1071    }
1072
1073    fn ids(model: &ListModel<Row>) -> Vec<u64> {
1074        (0..model.len())
1075            .map(|i| model.with_item(i, |r| r.id).unwrap())
1076            .collect()
1077    }
1078
1079    fn vals(model: &ListModel<Row>) -> Vec<&'static str> {
1080        (0..model.len())
1081            .map(|i| model.with_item(i, |r| r.val).unwrap())
1082            .collect()
1083    }
1084
1085    fn record_changes(model: &ListModel<Row>) -> (Rc<RefCell<Vec<DataChange>>>, ObserverHandle) {
1086        let log: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
1087        let l = log.clone();
1088        let handle = model.observe_changes(move |c| l.borrow_mut().push(c.clone()));
1089        (log, handle)
1090    }
1091
1092    #[test]
1093    fn reconcile_pure_insert_emits_one_coalesced_range() {
1094        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
1095        let (log, _h) = record_changes(&model);
1096
1097        model.reconcile_by_key(
1098            vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")],
1099            key,
1100        );
1101
1102        assert_eq!(ids(&model), vec![1, 2, 3, 4]);
1103        assert_eq!(vals(&model), vec!["a", "b", "c", "d"]);
1104        let entries = log.borrow();
1105        assert_eq!(entries.len(), 1, "contiguous inserts coalesce: {entries:?}");
1106        assert_eq!(entries[0], DataChange::ItemsInserted { range: 2..4 });
1107    }
1108
1109    #[test]
1110    fn reconcile_insert_at_front_and_middle() {
1111        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
1112        let (log, _h) = record_changes(&model);
1113
1114        // 0 is new (front), 1 stays, 5 is new (middle), 2 stays.
1115        model.reconcile_by_key(
1116            vec![row(0, "z"), row(1, "a"), row(5, "m"), row(2, "b")],
1117            key,
1118        );
1119
1120        assert_eq!(ids(&model), vec![0, 1, 5, 2]);
1121        let entries = log.borrow();
1122        // Two non-adjacent insert runs, no move needed (1 and 2 already in
1123        // relative order).
1124        assert_eq!(entries.len(), 2, "{entries:?}");
1125        assert_eq!(entries[0], DataChange::ItemsInserted { range: 0..1 });
1126        assert_eq!(entries[1], DataChange::ItemsInserted { range: 2..3 });
1127    }
1128
1129    #[test]
1130    fn reconcile_pure_remove_emits_one_coalesced_range() {
1131        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1132        let (log, _h) = record_changes(&model);
1133
1134        model.reconcile_by_key(vec![row(1, "a"), row(4, "d")], key);
1135
1136        assert_eq!(ids(&model), vec![1, 4]);
1137        let entries = log.borrow();
1138        assert_eq!(entries.len(), 1, "contiguous removes coalesce: {entries:?}");
1139        assert_eq!(entries[0], DataChange::ItemsRemoved { range: 1..3 });
1140    }
1141
1142    #[test]
1143    fn reconcile_remove_scattered_emits_separate_ranges() {
1144        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1145        let (log, _h) = record_changes(&model);
1146
1147        model.reconcile_by_key(vec![row(1, "a"), row(3, "c")], key);
1148
1149        assert_eq!(ids(&model), vec![1, 3]);
1150        let entries = log.borrow();
1151        assert_eq!(entries.len(), 2, "{entries:?}");
1152        // Emitted back-to-front so earlier indices stay valid at removal time.
1153        assert_eq!(entries[0], DataChange::ItemsRemoved { range: 3..4 });
1154        assert_eq!(entries[1], DataChange::ItemsRemoved { range: 1..2 });
1155    }
1156
1157    #[test]
1158    fn reconcile_reorder_emits_only_moves() {
1159        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1160        let (log, _h) = record_changes(&model);
1161
1162        model.reconcile_by_key(vec![row(3, "c"), row(2, "b"), row(1, "a")], key);
1163
1164        assert_eq!(ids(&model), vec![3, 2, 1]);
1165        let entries = log.borrow();
1166        assert!(
1167            entries
1168                .iter()
1169                .all(|c| matches!(c, DataChange::ItemsMoved { .. })),
1170            "a pure reorder must only emit moves: {entries:?}"
1171        );
1172        assert!(!entries.is_empty());
1173    }
1174
1175    #[test]
1176    fn reconcile_no_reorder_when_relative_order_already_matches() {
1177        // Peer removed the middle row but didn't touch the order of the rest —
1178        // this must NOT emit any ItemsMoved, only the removal.
1179        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1180        let (log, _h) = record_changes(&model);
1181
1182        model.reconcile_by_key(vec![row(1, "a"), row(3, "c"), row(4, "d")], key);
1183
1184        assert_eq!(ids(&model), vec![1, 3, 4]);
1185        let entries = log.borrow();
1186        assert!(
1187            entries
1188                .iter()
1189                .all(|c| !matches!(c, DataChange::ItemsMoved { .. })),
1190            "untouched relative order must not emit moves: {entries:?}"
1191        );
1192    }
1193
1194    #[test]
1195    fn reconcile_in_place_update_emits_item_updated() {
1196        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1197        let (log, _h) = record_changes(&model);
1198
1199        model.reconcile_by_key(vec![row(1, "a"), row(2, "CHANGED"), row(3, "c")], key);
1200
1201        assert_eq!(vals(&model), vec!["a", "CHANGED", "c"]);
1202        let entries = log.borrow();
1203        assert_eq!(entries.len(), 1, "{entries:?}");
1204        assert_eq!(entries[0], DataChange::ItemUpdated { index: 1 });
1205    }
1206
1207    #[test]
1208    fn reconcile_combined_insert_remove_reorder_update() {
1209        // old: [1,2,3,4] -> new: [4, 2*, 5, 1]
1210        //  - 3 removed
1211        //  - 4 moves to front
1212        //  - 2's content changes in place
1213        //  - 5 is inserted
1214        //  - 1 stays last (relative order of 1 vs 2 vs 4 reshuffled)
1215        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
1216        let (log, _h) = record_changes(&model);
1217
1218        model.reconcile_by_key(
1219            vec![row(4, "d"), row(2, "B!"), row(5, "e"), row(1, "a")],
1220            key,
1221        );
1222
1223        assert_eq!(ids(&model), vec![4, 2, 5, 1]);
1224        assert_eq!(vals(&model), vec!["d", "B!", "e", "a"]);
1225        let entries = log.borrow();
1226        assert!(!entries.is_empty());
1227        assert!(
1228            entries
1229                .iter()
1230                .any(|c| matches!(c, DataChange::ItemsMoved { .. })),
1231            "{entries:?}"
1232        );
1233        assert!(
1234            entries
1235                .iter()
1236                .any(|c| matches!(c, DataChange::ItemsInserted { .. })),
1237            "{entries:?}"
1238        );
1239        assert!(
1240            entries
1241                .iter()
1242                .any(|c| matches!(c, DataChange::ItemsRemoved { .. })),
1243            "{entries:?}"
1244        );
1245        assert!(
1246            entries
1247                .iter()
1248                .any(|c| matches!(c, DataChange::ItemUpdated { .. })),
1249            "{entries:?}"
1250        );
1251        assert!(
1252            !entries.iter().any(|c| matches!(c, DataChange::Reset)),
1253            "{entries:?}"
1254        );
1255    }
1256
1257    #[test]
1258    fn reconcile_empty_to_full() {
1259        let model: ListModel<Row> = ListModel::new();
1260        let (log, _h) = record_changes(&model);
1261
1262        model.reconcile_by_key(vec![row(1, "a"), row(2, "b"), row(3, "c")], key);
1263
1264        assert_eq!(ids(&model), vec![1, 2, 3]);
1265        let entries = log.borrow();
1266        assert_eq!(entries.len(), 1, "{entries:?}");
1267        assert_eq!(entries[0], DataChange::ItemsInserted { range: 0..3 });
1268    }
1269
1270    #[test]
1271    fn reconcile_full_to_empty() {
1272        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1273        let (log, _h) = record_changes(&model);
1274
1275        model.reconcile_by_key(vec![], key);
1276
1277        assert!(model.is_empty());
1278        let entries = log.borrow();
1279        assert_eq!(entries.len(), 1, "{entries:?}");
1280        assert_eq!(entries[0], DataChange::ItemsRemoved { range: 0..3 });
1281    }
1282
1283    #[test]
1284    fn reconcile_identical_input_emits_nothing() {
1285        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
1286        let (log, _h) = record_changes(&model);
1287
1288        model.reconcile_by_key(vec![row(1, "a"), row(2, "b"), row(3, "c")], key);
1289
1290        assert_eq!(ids(&model), vec![1, 2, 3]);
1291        assert!(
1292            log.borrow().is_empty(),
1293            "identical reconcile must not notify: {:?}",
1294            log.borrow()
1295        );
1296    }
1297
1298    #[test]
1299    fn reconcile_never_emits_reset() {
1300        // Exercise every scenario above (plus a full rewrite / total
1301        // replacement, the case most tempted to fall back to `Reset`) and
1302        // assert `Reset` is never among the emitted changes.
1303        let scenarios: Vec<(Vec<Row>, Vec<Row>)> = vec![
1304            (
1305                vec![row(1, "a"), row(2, "b")],
1306                vec![row(1, "a"), row(2, "b"), row(3, "c")],
1307            ),
1308            (
1309                vec![row(1, "a"), row(2, "b"), row(3, "c")],
1310                vec![row(1, "a")],
1311            ),
1312            (
1313                vec![row(1, "a"), row(2, "b"), row(3, "c")],
1314                vec![row(3, "c"), row(1, "a"), row(2, "b")],
1315            ),
1316            (vec![], vec![row(1, "a"), row(2, "b")]),
1317            (vec![row(1, "a"), row(2, "b")], vec![]),
1318            (
1319                vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")],
1320                vec![row(9, "x"), row(8, "y"), row(7, "z")],
1321            ),
1322        ];
1323        for (before, after) in scenarios {
1324            let model = ListModel::from_vec(before.clone());
1325            let (log, _h) = record_changes(&model);
1326            model.reconcile_by_key(after.clone(), key);
1327            assert!(
1328                !log.borrow().iter().any(|c| matches!(c, DataChange::Reset)),
1329                "reconcile must never emit Reset — before {before:?}, after {after:?}, got {:?}",
1330                log.borrow()
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    #[should_panic(expected = "reconcile_by_key")]
1337    fn reconcile_duplicate_key_in_new_items_panics() {
1338        let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
1339        // Two incoming rows both claim key 1 — the second can't be found at
1340        // or after the write cursor once the first has already consumed
1341        // that key's slot (see `# Panics` on `reconcile_by_key`).
1342        model.reconcile_by_key(vec![row(1, "a"), row(1, "a-dup"), row(2, "b")], key);
1343    }
1344}