Skip to main content

teksilo_data/
keyed_selection_model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `KeyedSelectionModel<K>` — identity-based selection for collection widgets.
5//!
6//! [`KeyedSelectionModel<K>`](KeyedSelectionModel) stores selection as a set of
7//! source-defined **keys** rather than visible **indices**. This is what
8//! [`SelectionModel`](crate::SelectionModel) cannot do: survive lazy
9//! window-slides and external reorders, and stay consistent across two views of
10//! the same source that scroll/sort/filter independently (selection is a set of
11//! identities, not positions). It coexists with the index-based
12//! [`SelectionModel`](crate::SelectionModel) — views opt into one or the other.
13//!
14//! Shift+click range extension is index-ordered by nature, so `extend_to` takes
15//! the current visible key order from the caller (the projection) at click
16//! time; the anchor is stored as a *key* so it survives scrolling out of the
17//! resident window. The selection is exposed as a reactive
18//! `Signal<HashSet<K>>` via `selection_signal()`.
19//!
20//! ## When to use
21//!
22//! Use [`KeyedSelectionModel`] when rows are identified by a stable domain key
23//! (entity id, file path, UUID) that survives reorders, sorts, and lazy-loading
24//! evictions. Use [`SelectionModel`](crate::SelectionModel) when rows are
25//! identified by their current visible index (simple in-memory lists).
26//!
27//! ```rust
28//! # use teksilo_data::KeyedSelectionModel;
29//! # use teksilo_data::SelectionMode;
30//! let sel: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
31//! sel.select(10);
32//! sel.toggle(20);
33//! sel.toggle(30);
34//! assert_eq!(sel.count(), 3);
35//! sel.toggle(10); // deselect
36//! assert!(!sel.is_selected(&10));
37//! sel.clear();
38//! assert_eq!(sel.count(), 0);
39//! ```
40
41use std::cell::RefCell;
42use std::collections::HashSet;
43use std::rc::Rc;
44
45use teksilo_core::signal::Signal;
46
47use crate::dnd_types::ItemKey;
48use crate::selection_model::SelectionMode;
49
50/// Selection state keyed by source-defined identity rather than visible index.
51///
52/// The selection set is exposed as a `Signal<HashSet<K>>` (via
53/// [`selection_signal`](KeyedSelectionModel::selection_signal)) so widgets
54/// observe it reactively without polling. Cloning the model shares the same
55/// selection and anchor across all handles. The Shift+click anchor is stored as
56/// a `K` so it survives lazy-window evictions and visible-order changes.
57pub struct KeyedSelectionModel<K: ItemKey> {
58    mode: SelectionMode,
59    selection: Signal<HashSet<K>>,
60    anchor: Rc<RefCell<Option<K>>>,
61    /// The selection as it stood when the current Shift gesture began — the
62    /// set a range extension is unioned *with*, so reversing the gesture
63    /// shrinks the range instead of growing it. Committed by every mutator
64    /// that is not itself an extension; see `SelectionModel::base`, which
65    /// carries the full rationale.
66    base: Rc<RefCell<HashSet<K>>>,
67    /// Whether a range extension is already in progress, so
68    /// [`KeyedSelectionModel::extend_to_additive`] captures its base once per
69    /// gesture rather than on every keystroke.
70    extending: Rc<std::cell::Cell<bool>>,
71    /// Strong holder for the debug-registry adapter; shared across clones.
72    /// Compiled out in release.
73    #[cfg(debug_assertions)]
74    debug_adapter_holder: Rc<RefCell<Option<Rc<dyn crate::debug_registry::ModelDebug>>>>,
75}
76
77impl<K: ItemKey> KeyedSelectionModel<K> {
78    /// Create a new keyed selection model with the given mode.
79    pub fn new(mode: SelectionMode) -> Self {
80        Self {
81            mode,
82            selection: Signal::new(HashSet::new()),
83            anchor: Rc::new(RefCell::new(None)),
84            base: Rc::new(RefCell::new(HashSet::new())),
85            extending: Rc::new(std::cell::Cell::new(false)),
86            #[cfg(debug_assertions)]
87            debug_adapter_holder: Rc::new(RefCell::new(None)),
88        }
89    }
90
91    /// Commit `base` and end any range gesture in progress.
92    fn commit_base(&self, base: HashSet<K>) {
93        *self.base.borrow_mut() = base;
94        self.extending.set(false);
95    }
96
97    /// The selection mode.
98    pub fn mode(&self) -> SelectionMode {
99        self.mode
100    }
101
102    /// A clone of the selection signal for reactive binding.
103    pub fn selection_signal(&self) -> Signal<HashSet<K>> {
104        self.selection.clone()
105    }
106
107    /// Whether `key` is currently selected (O(1)).
108    pub fn is_selected(&self, key: &K) -> bool {
109        self.selection.get().contains(key)
110    }
111
112    /// The currently selected keys (unordered snapshot).
113    pub fn selected_keys(&self) -> Vec<K> {
114        self.selection.get().into_iter().collect()
115    }
116
117    /// Number of selected items.
118    pub fn count(&self) -> usize {
119        self.selection.get().len()
120    }
121
122    /// Select a single key, clearing previous selection and setting the anchor.
123    pub fn select(&self, key: K) {
124        if self.mode == SelectionMode::None {
125            return;
126        }
127        let mut set = HashSet::new();
128        set.insert(key.clone());
129        self.selection.set(set);
130        *self.anchor.borrow_mut() = Some(key);
131        self.commit_base(HashSet::new());
132    }
133
134    /// Toggle a key (Ctrl+click in Multi mode; acts as `select` in Single).
135    pub fn toggle(&self, key: K) {
136        match self.mode {
137            SelectionMode::None => {}
138            SelectionMode::Single => self.select(key),
139            SelectionMode::Multi => {
140                let mut set = self.selection.get();
141                if set.contains(&key) {
142                    set.remove(&key);
143                } else {
144                    set.insert(key.clone());
145                }
146                self.selection.set(set.clone());
147                // The anchor moves to the toggled key in either direction, so
148                // a following Shift range runs from the row just picked.
149                *self.anchor.borrow_mut() = Some(key);
150                self.commit_base(set);
151            }
152        }
153    }
154
155    /// Extend the selection from the anchor to `target` over the current visible
156    /// key order (Shift+click). `ordered_keys` is the projection's visible order
157    /// at click time. If the anchor isn't currently visible (scrolled out /
158    /// evicted), falls back to a single-key select.
159    pub fn extend_to(&self, target: K, ordered_keys: &[K]) {
160        self.extend_from_base(target, ordered_keys, false);
161    }
162
163    /// Extend from the anchor to `target`, keeping whatever was selected when
164    /// this gesture began (Ctrl+Shift+navigation).
165    ///
166    /// The keyed twin of
167    /// [`SelectionModel::extend_to_additive`](crate::SelectionModel::extend_to_additive):
168    /// the range is unioned with the live selection captured on the gesture's
169    /// first keystroke, so a second disjoint range can be built without losing
170    /// the first, and the range still shrinks when reversed.
171    pub fn extend_to_additive(&self, target: K, ordered_keys: &[K]) {
172        self.extend_from_base(target, ordered_keys, true);
173    }
174
175    fn extend_from_base(&self, target: K, ordered_keys: &[K], additive: bool) {
176        match self.mode {
177            SelectionMode::None => {}
178            SelectionMode::Single => self.select(target),
179            SelectionMode::Multi => {
180                let anchor = self.anchor.borrow().clone();
181                let Some(anchor) = anchor else {
182                    self.select(target);
183                    return;
184                };
185                let a = ordered_keys.iter().position(|k| *k == anchor);
186                let t = ordered_keys.iter().position(|k| *k == target);
187                match (a, t) {
188                    (Some(a), Some(t)) => {
189                        if additive && !self.extending.get() {
190                            *self.base.borrow_mut() = self.selection.get();
191                        }
192                        let (lo, hi) = (a.min(t), a.max(t));
193                        // Recomputed from the base rather than unioned into the
194                        // live selection, so shrinking the range gives back the
195                        // rows it took.
196                        let mut set = self.base.borrow().clone();
197                        for k in &ordered_keys[lo..=hi] {
198                            set.insert(k.clone());
199                        }
200                        self.selection.set(set);
201                        self.extending.set(true);
202                        // Anchor stays put.
203                    }
204                    _ => self.select(target),
205                }
206            }
207        }
208    }
209
210    /// Replace the selection with `keys` (or, when `additive`, union them in).
211    /// Used by rubber-band selection. In `Single` mode an arbitrary one wins.
212    pub fn select_keys(&self, keys: impl IntoIterator<Item = K>, additive: bool) {
213        if self.mode == SelectionMode::None {
214            return;
215        }
216        let mut set = if additive {
217            self.selection.get()
218        } else {
219            HashSet::new()
220        };
221        set.extend(keys);
222        if self.mode == SelectionMode::Single && set.len() > 1 {
223            let keep = set.iter().next().cloned();
224            set = keep.into_iter().collect();
225        }
226        self.selection.set(set.clone());
227        self.commit_base(if additive { set } else { HashSet::new() });
228    }
229
230    /// Clear the selection and anchor.
231    pub fn clear(&self) {
232        self.selection.set(HashSet::new());
233        *self.anchor.borrow_mut() = None;
234        self.commit_base(HashSet::new());
235    }
236
237    /// Drop any selected key (and the anchor) for which `exists` returns false.
238    /// Call after a removal/reset to prune deleted rows — the index-based
239    /// `adjust_for_insert`/`adjust_for_remove` are unnecessary here because keys
240    /// are stable across inserts, moves, sorts and filters.
241    pub fn prune_missing(&self, exists: impl Fn(&K) -> bool) {
242        let old = self.selection.get();
243        let new: HashSet<K> = old.iter().filter(|k| exists(k)).cloned().collect();
244        if new.len() != old.len() {
245            self.selection.set(new);
246        }
247        let drop_anchor = self.anchor.borrow().as_ref().is_some_and(|a| !exists(a));
248        if drop_anchor {
249            *self.anchor.borrow_mut() = None;
250        }
251        // The gesture base is selection state too, so a deleted row has to
252        // leave it as well or the next Shift range resurrects the key.
253        let mut base = self.base.borrow_mut();
254        if !base.is_empty() {
255            base.retain(|k| exists(k));
256        }
257    }
258}
259
260impl<K: ItemKey> Clone for KeyedSelectionModel<K> {
261    fn clone(&self) -> Self {
262        Self {
263            mode: self.mode,
264            selection: self.selection.clone(),
265            anchor: self.anchor.clone(),
266            base: self.base.clone(),
267            extending: self.extending.clone(),
268            #[cfg(debug_assertions)]
269            debug_adapter_holder: self.debug_adapter_holder.clone(),
270        }
271    }
272}
273
274impl<K: ItemKey> KeyedSelectionModel<K> {
275    /// Register this model with the debug inspector under `name`; no-op in
276    /// release builds (`!cfg(debug_assertions)`). Returns `self` for chaining.
277    pub fn debug_named(self, _name: impl Into<String>) -> Self {
278        #[cfg(debug_assertions)]
279        {
280            let adapter: Rc<dyn crate::debug_registry::ModelDebug> =
281                Rc::new(KeyedSelectionModelDebug {
282                    selection: self.selection.clone(),
283                    mode: self.mode,
284                });
285            crate::debug_registry::register(_name.into(), Rc::downgrade(&adapter));
286            *self.debug_adapter_holder.borrow_mut() = Some(adapter);
287        }
288        self
289    }
290}
291
292#[cfg(debug_assertions)]
293struct KeyedSelectionModelDebug<K: ItemKey> {
294    selection: Signal<HashSet<K>>,
295    mode: SelectionMode,
296}
297
298#[cfg(debug_assertions)]
299impl<K: ItemKey> crate::debug_registry::ModelDebug for KeyedSelectionModelDebug<K> {
300    fn kind(&self) -> &'static str {
301        "KeyedSelectionModel"
302    }
303    fn len(&self) -> usize {
304        self.selection.get().len()
305    }
306    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
307        let _ = writeln!(out, "mode = {:?}", self.mode);
308        let sel = self.selection.get();
309        if sel.is_empty() {
310            let _ = writeln!(out, "(empty)");
311            return;
312        }
313        for k in sel.iter() {
314            let _ = writeln!(out, "{:?}", k);
315        }
316    }
317}
318
319impl<K: ItemKey> std::fmt::Debug for KeyedSelectionModel<K> {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        f.debug_struct("KeyedSelectionModel")
322            .field("mode", &self.mode)
323            .field("selected_count", &self.selection.get().len())
324            .finish()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn single_select_by_key() {
334        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Single);
335        m.select(10);
336        assert!(m.is_selected(&10));
337        m.select(20);
338        assert!(!m.is_selected(&10));
339        assert!(m.is_selected(&20));
340    }
341
342    #[test]
343    fn multi_toggle_and_count() {
344        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
345        m.toggle(1);
346        m.toggle(3);
347        assert_eq!(m.count(), 2);
348        m.toggle(1);
349        assert!(!m.is_selected(&1));
350        assert!(m.is_selected(&3));
351    }
352
353    #[test]
354    fn reversing_a_keyed_shift_gesture_shrinks_the_range() {
355        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
356        let order = vec![10_u64, 20, 30, 40, 50];
357        m.select(20);
358        m.extend_to(50, &order);
359        let mut got = m.selected_keys();
360        got.sort();
361        assert_eq!(got, vec![20, 30, 40, 50]);
362        m.extend_to(30, &order);
363        let mut got = m.selected_keys();
364        got.sort();
365        assert_eq!(got, vec![20, 30]);
366    }
367
368    #[test]
369    fn a_keyed_toggle_survives_the_next_shift_range() {
370        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
371        let order = vec![10_u64, 20, 30, 40, 50];
372        m.select(10);
373        m.toggle(30);
374        m.extend_to(50, &order);
375        let mut got = m.selected_keys();
376        got.sort();
377        assert_eq!(got, vec![10, 30, 40, 50]);
378    }
379
380    #[test]
381    fn a_keyed_additive_extend_keeps_the_earlier_range() {
382        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
383        let order = vec![10_u64, 20, 30, 40, 50, 60];
384        m.select(10);
385        m.extend_to(20, &order);
386        m.toggle(40);
387        m.extend_to_additive(60, &order);
388        let mut got = m.selected_keys();
389        got.sort();
390        assert_eq!(got, vec![10, 20, 40, 50, 60]);
391        m.extend_to_additive(50, &order);
392        let mut got = m.selected_keys();
393        got.sort();
394        assert_eq!(got, vec![10, 20, 40, 50]);
395    }
396
397    #[test]
398    fn pruning_a_deleted_key_also_drops_it_from_the_gesture_base() {
399        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
400        let order = vec![10_u64, 20, 30, 40];
401        m.select(10);
402        m.toggle(20); // base = {10, 20}
403        m.prune_missing(|k| *k != 10);
404        m.extend_to(40, &order);
405        let mut got = m.selected_keys();
406        got.sort();
407        // Key 10 is gone; the range from the anchor at 20 must not revive it.
408        assert_eq!(got, vec![20, 30, 40]);
409    }
410
411    #[test]
412    fn extend_to_over_visible_order() {
413        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
414        let order = vec![10_u64, 20, 30, 40, 50];
415        m.select(20); // anchor at key 20
416        m.extend_to(40, &order);
417        let mut got = m.selected_keys();
418        got.sort();
419        assert_eq!(got, vec![20, 30, 40]);
420    }
421
422    #[test]
423    fn selection_survives_reorder_of_visible_order() {
424        // The whole point: selection is by identity, so reordering the
425        // projection does not change which keys are selected.
426        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
427        m.toggle(30);
428        m.toggle(10);
429        // Visible order changes (e.g. a sort) — selection unaffected.
430        assert!(m.is_selected(&10));
431        assert!(m.is_selected(&30));
432        assert!(!m.is_selected(&20));
433    }
434
435    #[test]
436    fn prune_missing_drops_deleted_keys_and_anchor() {
437        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
438        m.toggle(1);
439        m.toggle(2);
440        m.toggle(3); // selection {1,2,3}, anchor = 3
441        // Keys 2 and 3 no longer exist.
442        let live: HashSet<u64> = [1_u64, 4, 5].into_iter().collect();
443        m.prune_missing(|k| live.contains(k));
444        assert!(m.is_selected(&1));
445        assert!(!m.is_selected(&2));
446        assert!(!m.is_selected(&3));
447        // Anchor (3) was pruned: extend_to now falls back to single-select.
448        m.extend_to(5, &[1, 4, 5]);
449        assert!(m.is_selected(&5));
450    }
451
452    #[test]
453    fn anchor_not_visible_falls_back_to_single() {
454        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
455        m.select(99); // anchor 99, not in the visible order below
456        m.extend_to(20, &[10, 20, 30]);
457        // Anchor wasn't visible → single-select target.
458        assert_eq!(m.selected_keys(), vec![20]);
459    }
460
461    #[test]
462    fn none_mode_ignores() {
463        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::None);
464        m.select(1);
465        m.toggle(2);
466        assert_eq!(m.count(), 0);
467    }
468}