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;
42#[cfg(debug_assertions)]
43use std::cell::RefCell;
44use std::collections::BTreeSet;
45use std::rc::Rc;
46
47use teksilo_core::signal::Signal;
48
49/// Selection behavior mode.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum SelectionMode {
52    /// No selection allowed.
53    None,
54    /// At most one item selected at a time.
55    Single,
56    /// Multiple items can be selected (Ctrl+click toggles, Shift+click extends).
57    Multi,
58}
59
60/// Manages selection state for a collection widget.
61///
62/// The selection is exposed as a `Signal<BTreeSet<usize>>` so widgets can
63/// observe changes reactively.
64pub struct SelectionModel {
65    mode: SelectionMode,
66    selection: Signal<BTreeSet<usize>>,
67    /// Anchor index for Shift+click range extension.
68    /// Shared via Rc so clones see the same anchor state.
69    anchor: Rc<Cell<Option<usize>>>,
70    /// Strong holder for the debug-registry adapter. Shared across
71    /// clones; once all `SelectionModel` handles drop, the holder Rc
72    /// reaches zero and the adapter is freed, marking the registry
73    /// entry dead. `None` until `.debug_named()` is called.
74    /// Compiled out in release.
75    #[cfg(debug_assertions)]
76    debug_adapter_holder: Rc<RefCell<Option<Rc<dyn crate::debug_registry::ModelDebug>>>>,
77}
78
79impl SelectionModel {
80    /// Create a new selection model with the given mode.
81    pub fn new(mode: SelectionMode) -> Self {
82        Self {
83            mode,
84            selection: Signal::new(BTreeSet::new()),
85            anchor: Rc::new(Cell::new(None)),
86            #[cfg(debug_assertions)]
87            debug_adapter_holder: Rc::new(RefCell::new(None)),
88        }
89    }
90
91    /// The selection mode.
92    pub fn mode(&self) -> SelectionMode {
93        self.mode
94    }
95
96    /// Get a clone of the selection signal for reactive binding.
97    pub fn selection_signal(&self) -> Signal<BTreeSet<usize>> {
98        self.selection.clone()
99    }
100
101    /// Whether the given index is currently selected.
102    pub fn is_selected(&self, index: usize) -> bool {
103        self.selection.get().contains(&index)
104    }
105
106    /// The currently selected indices, sorted.
107    pub fn selected_indices(&self) -> Vec<usize> {
108        self.selection.get().into_iter().collect()
109    }
110
111    /// Number of selected items.
112    pub fn count(&self) -> usize {
113        self.selection.get().len()
114    }
115
116    /// Select a single index. In Single mode, clears previous selection.
117    /// In Multi mode, clears previous and selects just this one (use `toggle`
118    /// for Ctrl+click behavior). Sets the anchor for subsequent Shift+click.
119    pub fn select(&self, index: usize) {
120        if self.mode == SelectionMode::None {
121            return;
122        }
123        let mut set = BTreeSet::new();
124        set.insert(index);
125        self.selection.set(set);
126        self.anchor.set(Some(index));
127    }
128
129    /// Toggle selection of a single index (for Ctrl+click in Multi mode).
130    /// In Single mode, behaves like `select()`.
131    pub fn toggle(&self, index: usize) {
132        match self.mode {
133            SelectionMode::None => {}
134            SelectionMode::Single => self.select(index),
135            SelectionMode::Multi => {
136                let mut set = self.selection.get();
137                if set.contains(&index) {
138                    set.remove(&index);
139                } else {
140                    set.insert(index);
141                }
142                self.selection.set(set);
143                self.anchor.set(Some(index));
144            }
145        }
146    }
147
148    /// Extend the selection from the anchor to the given index (for Shift+click).
149    /// In Single mode, behaves like `select()`.
150    pub fn extend_to(&self, index: usize) {
151        match self.mode {
152            SelectionMode::None => {}
153            SelectionMode::Single => self.select(index),
154            SelectionMode::Multi => {
155                let anchor = self.anchor.get().unwrap_or(index);
156                let start = anchor.min(index);
157                let end = anchor.max(index);
158                let mut set = self.selection.get();
159                for i in start..=end {
160                    set.insert(i);
161                }
162                self.selection.set(set);
163                // Anchor stays at the original position
164            }
165        }
166    }
167
168    /// Replace the selection with `indices` (or, when `additive`, union them
169    /// into the current selection). Used by rubber-band / marquee selection,
170    /// where the selected set is an arbitrary subset rather than a range. In
171    /// `Single` mode the highest index wins; `None` mode is a no-op.
172    pub fn select_indices(&self, indices: impl IntoIterator<Item = usize>, additive: bool) {
173        if self.mode == SelectionMode::None {
174            return;
175        }
176        let mut set = if additive {
177            self.selection.get()
178        } else {
179            BTreeSet::new()
180        };
181        set.extend(indices);
182        if self.mode == SelectionMode::Single {
183            let last = set.iter().next_back().copied();
184            set = last.into_iter().collect();
185        }
186        self.selection.set(set);
187    }
188
189    /// Select all indices from 0 to count-1.
190    ///
191    /// A no-op in `None` mode, and also in `Single` mode — "select all" has
192    /// no coherent meaning for a control that holds at most one item, and
193    /// silently selecting one arbitrary row would be worse than doing
194    /// nothing. This mirrors what the gated call sites already do
195    /// (`ListView`'s Ctrl+A handler, which documents it as "Multi selection
196    /// only — a no-op for Single / None, matching every list control", and
197    /// `TableView`'s `select_all` helper, which matches only the Multi
198    /// modes). Enforcing it here too keeps an ungated caller — `GridView`'s
199    /// Ctrl+A handler is one — from breaking the `Single` invariant that
200    /// every other mutator on this type upholds.
201    pub fn select_all(&self, count: usize) {
202        if self.mode == SelectionMode::None || self.mode == SelectionMode::Single {
203            return;
204        }
205        let set: BTreeSet<usize> = (0..count).collect();
206        self.selection.set(set);
207    }
208
209    /// Clear the selection.
210    pub fn clear(&self) {
211        self.selection.set(BTreeSet::new());
212        self.anchor.set(None);
213    }
214
215    /// Adjust selection indices after items are inserted.
216    /// Indices >= `start` are shifted up by `count`.
217    pub fn adjust_for_insert(&self, start: usize, count: usize) {
218        let old = self.selection.get();
219        let mut new_set = BTreeSet::new();
220        for &idx in &old {
221            if idx >= start {
222                new_set.insert(idx + count);
223            } else {
224                new_set.insert(idx);
225            }
226        }
227        if new_set != old {
228            self.selection.set(new_set);
229        }
230        if let Some(a) = self.anchor.get()
231            && a >= start
232        {
233            self.anchor.set(Some(a + count));
234        }
235    }
236
237    /// Adjust selection indices after items are removed.
238    /// Indices in `start..start+count` are deselected; indices above are shifted down.
239    pub fn adjust_for_remove(&self, start: usize, count: usize) {
240        let old = self.selection.get();
241        let end = start + count;
242        let mut new_set = BTreeSet::new();
243        for &idx in &old {
244            if idx < start {
245                new_set.insert(idx);
246            } else if idx >= end {
247                new_set.insert(idx - count);
248            }
249            // Indices in start..end are dropped
250        }
251        if new_set != old {
252            self.selection.set(new_set);
253        }
254        if let Some(a) = self.anchor.get() {
255            if a >= end {
256                self.anchor.set(Some(a - count));
257            } else if a >= start {
258                self.anchor.set(None);
259            }
260        }
261    }
262
263    /// Adjust selection indices after a block of `count` items moved from
264    /// `from` to `to` (a post-removal index, matching `ListModel::move_item`).
265    /// Selected indices follow their items, so a dragged row stays selected.
266    pub fn adjust_for_move(&self, from: usize, to: usize, count: usize) {
267        if from == to || count == 0 {
268            return;
269        }
270        let old = self.selection.get();
271        let new_set: BTreeSet<usize> = old
272            .iter()
273            .map(|&idx| crate::map_index_after_move(idx, from, to, count))
274            .collect();
275        if new_set != old {
276            self.selection.set(new_set);
277        }
278        if let Some(a) = self.anchor.get() {
279            self.anchor
280                .set(Some(crate::map_index_after_move(a, from, to, count)));
281        }
282    }
283}
284
285impl Clone for SelectionModel {
286    fn clone(&self) -> Self {
287        Self {
288            mode: self.mode,
289            selection: self.selection.clone(),
290            anchor: self.anchor.clone(),
291            #[cfg(debug_assertions)]
292            debug_adapter_holder: self.debug_adapter_holder.clone(),
293        }
294    }
295}
296
297impl SelectionModel {
298    /// Register this selection model with the debug inspector under
299    /// `name`. In release builds (`!cfg(debug_assertions)`) this is a
300    /// no-op pass-through so call sites stay free of `#[cfg]` lines.
301    ///
302    /// Idempotent on repeated calls — the latest registration wins.
303    /// The registration drops automatically when the last
304    /// `SelectionModel` handle is freed (the strong adapter `Rc` lives
305    /// inside a shared holder; the registry holds only a `Weak`).
306    pub fn debug_named(self, _name: impl Into<String>) -> Self {
307        #[cfg(debug_assertions)]
308        {
309            let adapter: Rc<dyn crate::debug_registry::ModelDebug> = Rc::new(SelectionModelDebug {
310                selection: self.selection.clone(),
311                mode: self.mode,
312            });
313            crate::debug_registry::register(_name.into(), Rc::downgrade(&adapter));
314            *self.debug_adapter_holder.borrow_mut() = Some(adapter);
315        }
316        self
317    }
318}
319
320#[cfg(debug_assertions)]
321struct SelectionModelDebug {
322    selection: Signal<BTreeSet<usize>>,
323    mode: SelectionMode,
324}
325
326#[cfg(debug_assertions)]
327impl crate::debug_registry::ModelDebug for SelectionModelDebug {
328    fn kind(&self) -> &'static str {
329        "SelectionModel"
330    }
331    fn len(&self) -> usize {
332        self.selection.get().len()
333    }
334    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
335        let _ = writeln!(out, "mode = {:?}", self.mode);
336        let sel = self.selection.get();
337        if sel.is_empty() {
338            let _ = writeln!(out, "(empty)");
339            return;
340        }
341        for i in sel.iter() {
342            let _ = writeln!(out, "[{}]", i);
343        }
344    }
345}
346
347impl std::fmt::Debug for SelectionModel {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        f.debug_struct("SelectionModel")
350            .field("mode", &self.mode)
351            .field("selected_count", &self.selection.get().len())
352            .finish()
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn single_select() {
362        let model = SelectionModel::new(SelectionMode::Single);
363        model.select(2);
364        assert!(model.is_selected(2));
365        assert!(!model.is_selected(0));
366        assert_eq!(model.selected_indices(), vec![2]);
367
368        model.select(5);
369        assert!(!model.is_selected(2));
370        assert!(model.is_selected(5));
371    }
372
373    #[test]
374    fn multi_select_toggle() {
375        let model = SelectionModel::new(SelectionMode::Multi);
376        model.toggle(1);
377        model.toggle(3);
378        assert!(model.is_selected(1));
379        assert!(model.is_selected(3));
380        assert_eq!(model.count(), 2);
381
382        model.toggle(1);
383        assert!(!model.is_selected(1));
384        assert!(model.is_selected(3));
385    }
386
387    #[test]
388    fn multi_select_extend_range() {
389        let model = SelectionModel::new(SelectionMode::Multi);
390        model.select(2); // anchor at 2
391        model.extend_to(5); // extend from 2 to 5
392        assert_eq!(model.selected_indices(), vec![2, 3, 4, 5]);
393    }
394
395    #[test]
396    fn extend_backwards() {
397        let model = SelectionModel::new(SelectionMode::Multi);
398        model.select(5);
399        model.extend_to(2);
400        assert_eq!(model.selected_indices(), vec![2, 3, 4, 5]);
401    }
402
403    #[test]
404    fn select_all() {
405        let model = SelectionModel::new(SelectionMode::Multi);
406        model.select_all(5);
407        assert_eq!(model.selected_indices(), vec![0, 1, 2, 3, 4]);
408    }
409
410    #[test]
411    fn select_indices_replaces_then_adds() {
412        let model = SelectionModel::new(SelectionMode::Multi);
413        model.select(1);
414        // Non-additive replaces.
415        model.select_indices([4, 5], false);
416        assert_eq!(model.selected_indices(), vec![4, 5]);
417        // Additive unions.
418        model.select_indices([2], true);
419        assert_eq!(model.selected_indices(), vec![2, 4, 5]);
420    }
421
422    #[test]
423    fn clear() {
424        let model = SelectionModel::new(SelectionMode::Multi);
425        model.select_all(3);
426        model.clear();
427        assert_eq!(model.count(), 0);
428    }
429
430    #[test]
431    fn none_mode_ignores_all() {
432        let model = SelectionModel::new(SelectionMode::None);
433        model.select(1);
434        assert_eq!(model.count(), 0);
435        model.toggle(2);
436        assert_eq!(model.count(), 0);
437        model.select_all(10);
438        assert_eq!(model.count(), 0);
439    }
440
441    #[test]
442    fn adjust_for_insert() {
443        let model = SelectionModel::new(SelectionMode::Multi);
444        model.toggle(1);
445        model.toggle(3);
446        // Insert 2 items at index 2
447        model.adjust_for_insert(2, 2);
448        // 1 stays, 3 shifts to 5
449        assert_eq!(model.selected_indices(), vec![1, 5]);
450    }
451
452    #[test]
453    fn adjust_for_remove() {
454        let model = SelectionModel::new(SelectionMode::Multi);
455        model.toggle(1);
456        model.toggle(3);
457        model.toggle(5);
458        // Remove 1 item at index 3
459        model.adjust_for_remove(3, 1);
460        // 1 stays, 3 removed, 5 shifts to 4
461        assert_eq!(model.selected_indices(), vec![1, 4]);
462    }
463
464    #[test]
465    fn adjust_for_move_follows_the_moved_item() {
466        // [A,B,C,D], select A(0). move A from 0 to 2 -> [B,C,A,D].
467        let model = SelectionModel::new(SelectionMode::Multi);
468        model.toggle(0);
469        model.adjust_for_move(0, 2, 1);
470        assert_eq!(model.selected_indices(), vec![2], "selection followed A");
471    }
472
473    #[test]
474    fn adjust_for_move_shifts_a_bystander_selection() {
475        // [A,B,C,D], select B(1). move A from 0 to 2 -> [B,C,A,D]; B is now 0.
476        let model = SelectionModel::new(SelectionMode::Multi);
477        model.toggle(1);
478        model.adjust_for_move(0, 2, 1);
479        assert_eq!(model.selected_indices(), vec![0], "B shifted down to 0");
480    }
481
482    #[test]
483    fn adjust_for_move_backwards() {
484        // [A,B,C,D], select D(3). move D from 3 to 1 -> [A,D,B,C].
485        let model = SelectionModel::new(SelectionMode::Multi);
486        model.toggle(3);
487        model.adjust_for_move(3, 1, 1);
488        assert_eq!(model.selected_indices(), vec![1]);
489    }
490
491    #[test]
492    fn signal_reactivity() {
493        use std::cell::Cell;
494        use std::rc::Rc;
495
496        let model = SelectionModel::new(SelectionMode::Single);
497        let signal = model.selection_signal();
498        let changed = Rc::new(Cell::new(false));
499        let c = changed.clone();
500        let _handle = signal.observe(move |_| c.set(true));
501
502        model.select(3);
503        assert!(changed.get());
504    }
505
506    #[test]
507    fn single_mode_extend_acts_as_select() {
508        let model = SelectionModel::new(SelectionMode::Single);
509        model.select(1);
510        model.extend_to(5);
511        // In single mode, extend_to just selects
512        assert_eq!(model.selected_indices(), vec![5]);
513    }
514}