Skip to main content

teksilo_data/
selection_model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! SelectionModel — index-based selection state for collection widgets.
5//!
6//! [`SelectionModel`] manages which flat indices are selected in a
7//! `ListView`, `TreeView`, `TableView`, or `GridView`. It is a
8//! share-by-clone handle (`Rc<RefCell<…>>` internally): pass a clone to
9//! each view that should share selection state. The current selection is
10//! exposed as a reactive `Signal<BTreeSet<usize>>` so widgets can bind to
11//! it without polling.
12//!
13//! Three selection behaviours are available via [`SelectionMode`]: `None`
14//! (read-only / no interaction), `Single` (at most one item), and `Multi`
15//! (Ctrl+click toggle + Shift+click range extension via an internal anchor).
16//! Mutators automatically notify all `Signal` observers after every change,
17//! and the helper methods `adjust_for_insert` / `adjust_for_remove` /
18//! `adjust_for_move` keep selected indices consistent when the underlying
19//! source mutates.
20//!
21//! ## When to use `SelectionModel` vs `KeyedSelectionModel`
22//!
23//! Use `SelectionModel` (this type) for views that are backed by a plain
24//! `ListModel<T>` or a `SortFilterListModel<T>` where *position* is the
25//! natural identity. Use [`crate::KeyedSelectionModel`] when items carry a
26//! stable app-defined key (e.g. a `NodeId` or a UUID) and selection must
27//! survive sort/filter rebuilds or window slides that renumber visible indices.
28//!
29//! ```rust
30//! # use teksilo_data::{SelectionModel, SelectionMode};
31//! let sel = SelectionModel::new(SelectionMode::Multi);
32//! sel.select(2);         // clear-and-select index 2, anchor = 2
33//! sel.toggle(5);         // add index 5 (Ctrl+click behaviour)
34//! sel.extend_to(8);      // extend from anchor 5 to 8 (Shift+click behaviour)
35//! assert!(sel.is_selected(2));
36//! assert_eq!(sel.count(), 5); // 2, 5, 6, 7, 8
37//! sel.clear();
38//! assert_eq!(sel.count(), 0);
39//! ```
40
41use std::cell::{Cell, RefCell};
42use std::collections::BTreeSet;
43use std::rc::Rc;
44
45use teksilo_core::signal::Signal;
46
47/// Selection behavior mode.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum SelectionMode {
50    /// No selection allowed.
51    None,
52    /// At most one item selected at a time.
53    Single,
54    /// Multiple items can be selected (Ctrl+click toggles, Shift+click extends).
55    Multi,
56}
57
58/// Manages selection state for a collection widget.
59///
60/// The selection is exposed as a `Signal<BTreeSet<usize>>` so widgets can
61/// observe changes reactively.
62pub struct SelectionModel {
63    mode: SelectionMode,
64    selection: Signal<BTreeSet<usize>>,
65    /// Anchor index for Shift+click range extension.
66    /// Shared via Rc so clones see the same anchor state.
67    anchor: Rc<Cell<Option<usize>>>,
68    /// The selection as it stood when the current Shift gesture began — the
69    /// set a range extension is unioned *with*.
70    ///
71    /// Without it, `extend_to` can only grow: `Shift+End` followed by
72    /// `Shift+Home` would leave the whole collection selected instead of
73    /// reversing, because every extension would union into the previous one.
74    /// Committing a base at each non-extending mutation makes the extension a
75    /// pure function of `(base, anchor, target)`, so shrinking a range
76    /// deselects what it shrank past.
77    ///
78    /// The commit rule is what makes the disjoint workflow work: `select`
79    /// clears it (a plain click replaces everything), while `toggle` commits
80    /// the resulting selection (so a Ctrl+click followed by a Shift+click
81    /// keeps the earlier picks and adds the range). Same design as
82    /// `CellSelectionModel`, which has had it since it shipped.
83    base: Rc<RefCell<BTreeSet<usize>>>,
84    /// Whether a range extension is already in progress, so
85    /// [`SelectionModel::extend_to_additive`] captures its base once per
86    /// gesture rather than on every keystroke.
87    extending: Rc<Cell<bool>>,
88    /// Strong holder for the debug-registry adapter. Shared across
89    /// clones; once all `SelectionModel` handles drop, the holder Rc
90    /// reaches zero and the adapter is freed, marking the registry
91    /// entry dead. `None` until `.debug_named()` is called.
92    /// Compiled out in release.
93    #[cfg(debug_assertions)]
94    debug_adapter_holder: Rc<RefCell<Option<Rc<dyn crate::debug_registry::ModelDebug>>>>,
95}
96
97impl SelectionModel {
98    /// Create a new selection model with the given mode.
99    pub fn new(mode: SelectionMode) -> Self {
100        Self {
101            mode,
102            selection: Signal::new(BTreeSet::new()),
103            anchor: Rc::new(Cell::new(None)),
104            base: Rc::new(RefCell::new(BTreeSet::new())),
105            extending: Rc::new(Cell::new(false)),
106            #[cfg(debug_assertions)]
107            debug_adapter_holder: Rc::new(RefCell::new(None)),
108        }
109    }
110
111    /// Commit `base` and end any range gesture in progress.
112    ///
113    /// Every mutator that is not itself an extension calls this, so the next
114    /// `Shift` gesture starts from a known set rather than from whatever the
115    /// last extension happened to leave behind.
116    fn commit_base(&self, base: BTreeSet<usize>) {
117        *self.base.borrow_mut() = base;
118        self.extending.set(false);
119    }
120
121    /// The selection mode.
122    pub fn mode(&self) -> SelectionMode {
123        self.mode
124    }
125
126    /// Get a clone of the selection signal for reactive binding.
127    pub fn selection_signal(&self) -> Signal<BTreeSet<usize>> {
128        self.selection.clone()
129    }
130
131    /// Whether the given index is currently selected.
132    pub fn is_selected(&self, index: usize) -> bool {
133        self.selection.get().contains(&index)
134    }
135
136    /// The currently selected indices, sorted.
137    pub fn selected_indices(&self) -> Vec<usize> {
138        self.selection.get().into_iter().collect()
139    }
140
141    /// Number of selected items.
142    pub fn count(&self) -> usize {
143        self.selection.get().len()
144    }
145
146    /// Select a single index. In Single mode, clears previous selection.
147    /// In Multi mode, clears previous and selects just this one (use `toggle`
148    /// for Ctrl+click behavior). Sets the anchor for subsequent Shift+click.
149    pub fn select(&self, index: usize) {
150        if self.mode == SelectionMode::None {
151            return;
152        }
153        let mut set = BTreeSet::new();
154        set.insert(index);
155        self.selection.set(set);
156        self.anchor.set(Some(index));
157        // A plain click replaces the selection, so a Shift gesture starting
158        // here has nothing to preserve.
159        self.commit_base(BTreeSet::new());
160    }
161
162    /// Toggle selection of a single index (for Ctrl+click in Multi mode).
163    /// In Single mode, behaves like `select()`.
164    pub fn toggle(&self, index: usize) {
165        match self.mode {
166            SelectionMode::None => {}
167            SelectionMode::Single => self.select(index),
168            SelectionMode::Multi => {
169                let mut set = self.selection.get();
170                if set.contains(&index) {
171                    set.remove(&index);
172                } else {
173                    set.insert(index);
174                }
175                self.selection.set(set.clone());
176                // The anchor moves to the toggled item in *either* direction —
177                // the clause almost every reimplementation misses, and the one
178                // that makes "Ctrl+arrow away, Ctrl+Space, then Shift+arrow"
179                // extend from the new region rather than from wherever the
180                // user started.
181                self.anchor.set(Some(index));
182                // Ctrl+click keeps what is already picked, so a Shift gesture
183                // starting here extends *around* it.
184                self.commit_base(set);
185            }
186        }
187    }
188
189    /// Extend the selection from the anchor to the given index (for Shift+click
190    /// and Shift+navigation). In Single mode, behaves like `select()`.
191    ///
192    /// The result is `base ∪ anchor..=index`, where `base` is the selection as
193    /// it stood at the last non-extending mutation — **not** the current
194    /// selection. So the range tracks the anchor in both directions: reversing
195    /// a Shift gesture shrinks it, and `Shift+End` followed by `Shift+Home`
196    /// leaves one row selected rather than the whole collection. The anchor
197    /// itself does not move.
198    pub fn extend_to(&self, index: usize) {
199        self.extend_from_base(index, false);
200    }
201
202    /// Extend from the anchor to `index`, keeping whatever was selected when
203    /// this gesture began (Ctrl+Shift+navigation).
204    ///
205    /// The difference from [`extend_to`](Self::extend_to) is only which set the
206    /// range is unioned with: a plain gesture starts from the base committed by
207    /// the last click or toggle, while this one captures the live selection on
208    /// its first keystroke, so a second disjoint range can be built without
209    /// losing the first. Subsequent keystrokes in the same gesture reuse that
210    /// capture, so the range still shrinks when reversed.
211    pub fn extend_to_additive(&self, index: usize) {
212        self.extend_from_base(index, true);
213    }
214
215    fn extend_from_base(&self, index: usize, additive: bool) {
216        match self.mode {
217            SelectionMode::None => {}
218            SelectionMode::Single => self.select(index),
219            SelectionMode::Multi => {
220                if additive && !self.extending.get() {
221                    *self.base.borrow_mut() = self.selection.get();
222                }
223                let anchor = self.anchor.get().unwrap_or(index);
224                let start = anchor.min(index);
225                let end = anchor.max(index);
226                let mut set = self.base.borrow().clone();
227                set.extend(start..=end);
228                self.selection.set(set);
229                self.extending.set(true);
230                // Anchor stays at the original position
231            }
232        }
233    }
234
235    /// Replace the selection with `indices` (or, when `additive`, union them
236    /// into the current selection). Used by rubber-band / marquee selection,
237    /// where the selected set is an arbitrary subset rather than a range. In
238    /// `Single` mode the highest index wins; `None` mode is a no-op.
239    pub fn select_indices(&self, indices: impl IntoIterator<Item = usize>, additive: bool) {
240        if self.mode == SelectionMode::None {
241            return;
242        }
243        let mut set = if additive {
244            self.selection.get()
245        } else {
246            BTreeSet::new()
247        };
248        set.extend(indices);
249        if self.mode == SelectionMode::Single {
250            let last = set.iter().next_back().copied();
251            set = last.into_iter().collect();
252        }
253        self.selection.set(set.clone());
254        // A marquee that replaces reads like a click; one that adds reads like
255        // a Ctrl+click, and a Shift gesture after it must keep what it caught.
256        self.commit_base(if additive { set } else { BTreeSet::new() });
257    }
258
259    /// Select all indices from 0 to count-1.
260    ///
261    /// A no-op in `None` mode, and also in `Single` mode — "select all" has
262    /// no coherent meaning for a control that holds at most one item, and
263    /// silently selecting one arbitrary row would be worse than doing
264    /// nothing. This mirrors what the gated call sites already do
265    /// (`ListView`'s Ctrl+A handler, which documents it as "Multi selection
266    /// only — a no-op for Single / None, matching every list control", and
267    /// `TableView`'s `select_all` helper, which matches only the Multi
268    /// modes). Enforcing it here too keeps an ungated caller — `GridView`'s
269    /// Ctrl+A handler is one — from breaking the `Single` invariant that
270    /// every other mutator on this type upholds.
271    pub fn select_all(&self, count: usize) {
272        if self.mode == SelectionMode::None || self.mode == SelectionMode::Single {
273            return;
274        }
275        let set: BTreeSet<usize> = (0..count).collect();
276        self.selection.set(set.clone());
277        self.commit_base(set);
278    }
279
280    /// Clear the selection.
281    pub fn clear(&self) {
282        self.selection.set(BTreeSet::new());
283        self.anchor.set(None);
284        self.commit_base(BTreeSet::new());
285    }
286
287    /// Adjust selection indices after items are inserted.
288    /// Indices >= `start` are shifted up by `count`.
289    pub fn adjust_for_insert(&self, start: usize, count: usize) {
290        let old = self.selection.get();
291        let mut new_set = BTreeSet::new();
292        for &idx in &old {
293            if idx >= start {
294                new_set.insert(idx + count);
295            } else {
296                new_set.insert(idx);
297            }
298        }
299        if new_set != old {
300            self.selection.set(new_set);
301        }
302        if let Some(a) = self.anchor.get()
303            && a >= start
304        {
305            self.anchor.set(Some(a + count));
306        }
307        // The gesture base is index-space state exactly like the selection is,
308        // so it has to follow the same shift — otherwise the next Shift
309        // extension unions in rows the user never picked.
310        self.remap_base(|idx| Some(if idx >= start { idx + count } else { idx }));
311    }
312
313    /// Adjust selection indices after items are removed.
314    /// Indices in `start..start+count` are deselected; indices above are shifted down.
315    pub fn adjust_for_remove(&self, start: usize, count: usize) {
316        let old = self.selection.get();
317        let end = start + count;
318        let mut new_set = BTreeSet::new();
319        for &idx in &old {
320            if idx < start {
321                new_set.insert(idx);
322            } else if idx >= end {
323                new_set.insert(idx - count);
324            }
325            // Indices in start..end are dropped
326        }
327        if new_set != old {
328            self.selection.set(new_set);
329        }
330        if let Some(a) = self.anchor.get() {
331            if a >= end {
332                self.anchor.set(Some(a - count));
333            } else if a >= start {
334                self.anchor.set(None);
335            }
336        }
337        self.remap_base(|idx| {
338            if idx < start {
339                Some(idx)
340            } else if idx >= end {
341                Some(idx - count)
342            } else {
343                None
344            }
345        });
346    }
347
348    /// Adjust selection indices after a block of `count` items moved from
349    /// `from` to `to` (a post-removal index, matching `ListModel::move_item`).
350    /// Selected indices follow their items, so a dragged row stays selected.
351    pub fn adjust_for_move(&self, from: usize, to: usize, count: usize) {
352        if from == to || count == 0 {
353            return;
354        }
355        let old = self.selection.get();
356        let new_set: BTreeSet<usize> = old
357            .iter()
358            .map(|&idx| crate::map_index_after_move(idx, from, to, count))
359            .collect();
360        if new_set != old {
361            self.selection.set(new_set);
362        }
363        if let Some(a) = self.anchor.get() {
364            self.anchor
365                .set(Some(crate::map_index_after_move(a, from, to, count)));
366        }
367        self.remap_base(|idx| Some(crate::map_index_after_move(idx, from, to, count)));
368    }
369
370    /// Rewrite the gesture base through the same index map the selection just
371    /// took, dropping the entries the map answers `None` for.
372    fn remap_base(&self, map: impl Fn(usize) -> Option<usize>) {
373        let mut base = self.base.borrow_mut();
374        if base.is_empty() {
375            return;
376        }
377        *base = base.iter().filter_map(|&idx| map(idx)).collect();
378    }
379
380    /// Drop the range anchor when a projection has renumbered the rows under
381    /// it, so the next `Shift` gesture starts from the cursor rather than from
382    /// a row that has since moved.
383    ///
384    /// A sort/filter proxy signals a blanket reset rather than a per-row
385    /// delta, so `adjust_for_*` never runs and an index anchor silently comes
386    /// to mean a different row. Views that read
387    /// `first_changed_index()` from `SortFilterListModel` / `TreeSlice` /
388    /// `TreeDataSlice` / `SortFilterTreeModel` call this with it: everything
389    /// before that index still means what it meant, so an anchor there
390    /// survives. Qt hit the same bug and fixed it by making the anchor a
391    /// persistent index; `KeyedSelectionModel` avoids it by construction.
392    pub fn invalidate_anchor_from(&self, first_changed: usize) {
393        if self.anchor.get().is_some_and(|a| a >= first_changed) {
394            self.anchor.set(None);
395        }
396        self.remap_base(|idx| (idx < first_changed).then_some(idx));
397    }
398}
399
400impl Clone for SelectionModel {
401    fn clone(&self) -> Self {
402        Self {
403            mode: self.mode,
404            selection: self.selection.clone(),
405            anchor: self.anchor.clone(),
406            base: self.base.clone(),
407            extending: self.extending.clone(),
408            #[cfg(debug_assertions)]
409            debug_adapter_holder: self.debug_adapter_holder.clone(),
410        }
411    }
412}
413
414impl SelectionModel {
415    /// Register this selection model with the debug inspector under
416    /// `name`. In release builds (`!cfg(debug_assertions)`) this is a
417    /// no-op pass-through so call sites stay free of `#[cfg]` lines.
418    ///
419    /// Idempotent on repeated calls — the latest registration wins.
420    /// The registration drops automatically when the last
421    /// `SelectionModel` handle is freed (the strong adapter `Rc` lives
422    /// inside a shared holder; the registry holds only a `Weak`).
423    pub fn debug_named(self, _name: impl Into<String>) -> Self {
424        #[cfg(debug_assertions)]
425        {
426            let adapter: Rc<dyn crate::debug_registry::ModelDebug> = Rc::new(SelectionModelDebug {
427                selection: self.selection.clone(),
428                mode: self.mode,
429            });
430            crate::debug_registry::register(_name.into(), Rc::downgrade(&adapter));
431            *self.debug_adapter_holder.borrow_mut() = Some(adapter);
432        }
433        self
434    }
435}
436
437#[cfg(debug_assertions)]
438struct SelectionModelDebug {
439    selection: Signal<BTreeSet<usize>>,
440    mode: SelectionMode,
441}
442
443#[cfg(debug_assertions)]
444impl crate::debug_registry::ModelDebug for SelectionModelDebug {
445    fn kind(&self) -> &'static str {
446        "SelectionModel"
447    }
448    fn len(&self) -> usize {
449        self.selection.get().len()
450    }
451    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
452        let _ = writeln!(out, "mode = {:?}", self.mode);
453        let sel = self.selection.get();
454        if sel.is_empty() {
455            let _ = writeln!(out, "(empty)");
456            return;
457        }
458        for i in sel.iter() {
459            let _ = writeln!(out, "[{}]", i);
460        }
461    }
462}
463
464impl std::fmt::Debug for SelectionModel {
465    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466        f.debug_struct("SelectionModel")
467            .field("mode", &self.mode)
468            .field("selected_count", &self.selection.get().len())
469            .finish()
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn single_select() {
479        let model = SelectionModel::new(SelectionMode::Single);
480        model.select(2);
481        assert!(model.is_selected(2));
482        assert!(!model.is_selected(0));
483        assert_eq!(model.selected_indices(), vec![2]);
484
485        model.select(5);
486        assert!(!model.is_selected(2));
487        assert!(model.is_selected(5));
488    }
489
490    #[test]
491    fn multi_select_toggle() {
492        let model = SelectionModel::new(SelectionMode::Multi);
493        model.toggle(1);
494        model.toggle(3);
495        assert!(model.is_selected(1));
496        assert!(model.is_selected(3));
497        assert_eq!(model.count(), 2);
498
499        model.toggle(1);
500        assert!(!model.is_selected(1));
501        assert!(model.is_selected(3));
502    }
503
504    #[test]
505    fn multi_select_extend_range() {
506        let model = SelectionModel::new(SelectionMode::Multi);
507        model.select(2); // anchor at 2
508        model.extend_to(5); // extend from 2 to 5
509        assert_eq!(model.selected_indices(), vec![2, 3, 4, 5]);
510    }
511
512    #[test]
513    fn extend_backwards() {
514        let model = SelectionModel::new(SelectionMode::Multi);
515        model.select(5);
516        model.extend_to(2);
517        assert_eq!(model.selected_indices(), vec![2, 3, 4, 5]);
518    }
519
520    #[test]
521    fn reversing_a_shift_gesture_shrinks_the_range() {
522        // Shift+Down four times then Shift+Up twice must give back the rows it
523        // took, not keep them. Unioning into the live selection instead of
524        // recomputing from the anchor is what made this grow-only.
525        let model = SelectionModel::new(SelectionMode::Multi);
526        model.select(2);
527        model.extend_to(6);
528        assert_eq!(model.selected_indices(), vec![2, 3, 4, 5, 6]);
529        model.extend_to(4);
530        assert_eq!(model.selected_indices(), vec![2, 3, 4]);
531        model.extend_to(2);
532        assert_eq!(model.selected_indices(), vec![2]);
533    }
534
535    #[test]
536    fn extending_across_the_anchor_replaces_rather_than_unions() {
537        let model = SelectionModel::new(SelectionMode::Multi);
538        model.select(5);
539        model.extend_to(8);
540        assert_eq!(model.selected_indices(), vec![5, 6, 7, 8]);
541        // Crossing back past the anchor drops the far side entirely.
542        model.extend_to(3);
543        assert_eq!(model.selected_indices(), vec![3, 4, 5]);
544    }
545
546    #[test]
547    fn shift_end_then_shift_home_selects_one_row_not_the_whole_list() {
548        // The user-visible shape of the same bug: End then Home used to leave
549        // everything selected.
550        let model = SelectionModel::new(SelectionMode::Multi);
551        model.select(4);
552        model.extend_to(9); // Shift+End
553        model.extend_to(0); // Shift+Home
554        assert_eq!(model.selected_indices(), vec![0, 1, 2, 3, 4]);
555    }
556
557    #[test]
558    fn a_ctrl_toggle_moves_the_anchor_and_survives_the_next_shift_range() {
559        // The Explorer disjoint workflow: pick 1, Ctrl-pick 5, then Shift to 7.
560        // The range runs from the *toggled* row, and row 1 stays selected.
561        let model = SelectionModel::new(SelectionMode::Multi);
562        model.select(1);
563        model.toggle(5);
564        model.extend_to(7);
565        assert_eq!(model.selected_indices(), vec![1, 5, 6, 7]);
566        // And it still shrinks, without eating the disjoint pick.
567        model.extend_to(6);
568        assert_eq!(model.selected_indices(), vec![1, 5, 6]);
569    }
570
571    #[test]
572    fn the_anchor_moves_when_a_toggle_deselects_too() {
573        let model = SelectionModel::new(SelectionMode::Multi);
574        model.select(3);
575        model.toggle(3); // now empty, anchor at 3
576        model.extend_to(5);
577        assert_eq!(model.selected_indices(), vec![3, 4, 5]);
578    }
579
580    #[test]
581    fn an_additive_extend_keeps_the_range_built_by_the_previous_gesture() {
582        // Ctrl+Shift builds a second range without losing the first.
583        let model = SelectionModel::new(SelectionMode::Multi);
584        model.select(0);
585        model.extend_to(2);
586        assert_eq!(model.selected_indices(), vec![0, 1, 2]);
587        model.toggle(6); // Ctrl+Space moves the cursor's anchor
588        model.extend_to_additive(8);
589        assert_eq!(model.selected_indices(), vec![0, 1, 2, 6, 7, 8]);
590        // Still shrinks within the gesture, and still keeps the first range.
591        model.extend_to_additive(7);
592        assert_eq!(model.selected_indices(), vec![0, 1, 2, 6, 7]);
593    }
594
595    #[test]
596    fn a_plain_extend_after_a_click_discards_everything_else() {
597        let model = SelectionModel::new(SelectionMode::Multi);
598        model.select_indices([1, 2, 8], false);
599        model.select(4); // a plain click replaces
600        model.extend_to(6);
601        assert_eq!(model.selected_indices(), vec![4, 5, 6]);
602    }
603
604    #[test]
605    fn an_additive_marquee_is_kept_by_a_following_shift_range() {
606        let model = SelectionModel::new(SelectionMode::Multi);
607        model.select(0);
608        model.select_indices([7, 8], true);
609        model.toggle(2);
610        model.extend_to(4);
611        assert_eq!(model.selected_indices(), vec![0, 2, 3, 4, 7, 8]);
612    }
613
614    #[test]
615    fn the_gesture_base_follows_an_insert_and_a_remove() {
616        let model = SelectionModel::new(SelectionMode::Multi);
617        model.select(1);
618        model.toggle(5); // base = {1, 5}, anchor = 5
619        model.adjust_for_insert(0, 2); // everything shifts up by two
620        model.extend_to(9); // anchor is now 7
621        assert_eq!(model.selected_indices(), vec![3, 7, 8, 9]);
622
623        let model = SelectionModel::new(SelectionMode::Multi);
624        model.select(1);
625        model.toggle(5);
626        model.adjust_for_remove(0, 1); // base {1,5} -> {0,4}
627        model.extend_to(6);
628        assert_eq!(model.selected_indices(), vec![0, 4, 5, 6]);
629    }
630
631    #[test]
632    fn a_reprojection_drops_an_anchor_it_has_renumbered() {
633        let model = SelectionModel::new(SelectionMode::Multi);
634        model.select(2);
635        model.toggle(6);
636        // A sort/filter proxy reports that everything from row 4 changed.
637        model.invalidate_anchor_from(4);
638        // The next Shift starts from the target itself rather than from a row
639        // that now means something else, and the stale half of the base is gone.
640        model.extend_to(8);
641        assert_eq!(model.selected_indices(), vec![2, 8]);
642    }
643
644    #[test]
645    fn single_mode_ignores_the_additive_extend_like_every_other_mutator() {
646        let model = SelectionModel::new(SelectionMode::Single);
647        model.select(3);
648        model.extend_to_additive(7);
649        assert_eq!(model.selected_indices(), vec![7]);
650    }
651
652    #[test]
653    fn select_all() {
654        let model = SelectionModel::new(SelectionMode::Multi);
655        model.select_all(5);
656        assert_eq!(model.selected_indices(), vec![0, 1, 2, 3, 4]);
657    }
658
659    #[test]
660    fn select_indices_replaces_then_adds() {
661        let model = SelectionModel::new(SelectionMode::Multi);
662        model.select(1);
663        // Non-additive replaces.
664        model.select_indices([4, 5], false);
665        assert_eq!(model.selected_indices(), vec![4, 5]);
666        // Additive unions.
667        model.select_indices([2], true);
668        assert_eq!(model.selected_indices(), vec![2, 4, 5]);
669    }
670
671    #[test]
672    fn clear() {
673        let model = SelectionModel::new(SelectionMode::Multi);
674        model.select_all(3);
675        model.clear();
676        assert_eq!(model.count(), 0);
677    }
678
679    #[test]
680    fn none_mode_ignores_all() {
681        let model = SelectionModel::new(SelectionMode::None);
682        model.select(1);
683        assert_eq!(model.count(), 0);
684        model.toggle(2);
685        assert_eq!(model.count(), 0);
686        model.select_all(10);
687        assert_eq!(model.count(), 0);
688    }
689
690    #[test]
691    fn adjust_for_insert() {
692        let model = SelectionModel::new(SelectionMode::Multi);
693        model.toggle(1);
694        model.toggle(3);
695        // Insert 2 items at index 2
696        model.adjust_for_insert(2, 2);
697        // 1 stays, 3 shifts to 5
698        assert_eq!(model.selected_indices(), vec![1, 5]);
699    }
700
701    #[test]
702    fn adjust_for_remove() {
703        let model = SelectionModel::new(SelectionMode::Multi);
704        model.toggle(1);
705        model.toggle(3);
706        model.toggle(5);
707        // Remove 1 item at index 3
708        model.adjust_for_remove(3, 1);
709        // 1 stays, 3 removed, 5 shifts to 4
710        assert_eq!(model.selected_indices(), vec![1, 4]);
711    }
712
713    #[test]
714    fn adjust_for_move_follows_the_moved_item() {
715        // [A,B,C,D], select A(0). move A from 0 to 2 -> [B,C,A,D].
716        let model = SelectionModel::new(SelectionMode::Multi);
717        model.toggle(0);
718        model.adjust_for_move(0, 2, 1);
719        assert_eq!(model.selected_indices(), vec![2], "selection followed A");
720    }
721
722    #[test]
723    fn adjust_for_move_shifts_a_bystander_selection() {
724        // [A,B,C,D], select B(1). move A from 0 to 2 -> [B,C,A,D]; B is now 0.
725        let model = SelectionModel::new(SelectionMode::Multi);
726        model.toggle(1);
727        model.adjust_for_move(0, 2, 1);
728        assert_eq!(model.selected_indices(), vec![0], "B shifted down to 0");
729    }
730
731    #[test]
732    fn adjust_for_move_backwards() {
733        // [A,B,C,D], select D(3). move D from 3 to 1 -> [A,D,B,C].
734        let model = SelectionModel::new(SelectionMode::Multi);
735        model.toggle(3);
736        model.adjust_for_move(3, 1, 1);
737        assert_eq!(model.selected_indices(), vec![1]);
738    }
739
740    #[test]
741    fn signal_reactivity() {
742        use std::cell::Cell;
743        use std::rc::Rc;
744
745        let model = SelectionModel::new(SelectionMode::Single);
746        let signal = model.selection_signal();
747        let changed = Rc::new(Cell::new(false));
748        let c = changed.clone();
749        let _handle = signal.observe(move |_| c.set(true));
750
751        model.select(3);
752        assert!(changed.get());
753    }
754
755    #[test]
756    fn single_mode_extend_acts_as_select() {
757        let model = SelectionModel::new(SelectionMode::Single);
758        model.select(1);
759        model.extend_to(5);
760        // In single mode, extend_to just selects
761        assert_eq!(model.selected_indices(), vec![5]);
762    }
763}