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    ///
109    /// Read through a borrow rather than [`Signal::get`], which clones the
110    /// whole set: a view asks this once per position it shows, and a copy
111    /// per question made that the item count times the selection.
112    pub fn is_selected(&self, key: &K) -> bool {
113        self.selection.get_ref().contains(key)
114    }
115
116    /// The currently selected keys (unordered snapshot).
117    pub fn selected_keys(&self) -> Vec<K> {
118        self.selection.get().into_iter().collect()
119    }
120
121    /// Number of selected items (O(1), through a borrow, as
122    /// [`is_selected`](Self::is_selected) reads).
123    pub fn count(&self) -> usize {
124        self.selection.get_ref().len()
125    }
126
127    /// Select a single key, clearing previous selection and setting the anchor.
128    pub fn select(&self, key: K) {
129        if self.mode == SelectionMode::None {
130            return;
131        }
132        let mut set = HashSet::new();
133        set.insert(key.clone());
134        self.selection.set(set);
135        *self.anchor.borrow_mut() = Some(key);
136        self.commit_base(HashSet::new());
137    }
138
139    /// Toggle a key (Ctrl+click in Multi mode; acts as `select` in Single).
140    pub fn toggle(&self, key: K) {
141        match self.mode {
142            SelectionMode::None => {}
143            SelectionMode::Single => self.select(key),
144            SelectionMode::Multi => {
145                let mut set = self.selection.get();
146                if set.contains(&key) {
147                    set.remove(&key);
148                } else {
149                    set.insert(key.clone());
150                }
151                self.selection.set(set.clone());
152                // The anchor moves to the toggled key in either direction, so
153                // a following Shift range runs from the row just picked.
154                *self.anchor.borrow_mut() = Some(key);
155                self.commit_base(set);
156            }
157        }
158    }
159
160    /// Extend the selection from the anchor to `target` over the current visible
161    /// key order (Shift+click). `ordered_keys` is the projection's visible order
162    /// at click time. If the anchor isn't currently visible (scrolled out /
163    /// evicted), falls back to a single-key select.
164    pub fn extend_to(&self, target: K, ordered_keys: &[K]) {
165        self.extend_from_base(target, ordered_keys, false);
166    }
167
168    /// Extend from the anchor to `target`, keeping whatever was selected when
169    /// this gesture began (Ctrl+Shift+navigation).
170    ///
171    /// The keyed twin of
172    /// [`SelectionModel::extend_to_additive`](crate::SelectionModel::extend_to_additive):
173    /// the range is unioned with the live selection captured on the gesture's
174    /// first keystroke, so a second disjoint range can be built without losing
175    /// the first, and the range still shrinks when reversed.
176    pub fn extend_to_additive(&self, target: K, ordered_keys: &[K]) {
177        self.extend_from_base(target, ordered_keys, true);
178    }
179
180    fn extend_from_base(&self, target: K, ordered_keys: &[K], additive: bool) {
181        match self.mode {
182            SelectionMode::None => {}
183            SelectionMode::Single => self.select(target),
184            SelectionMode::Multi => {
185                let anchor = self.anchor.borrow().clone();
186                let Some(anchor) = anchor else {
187                    self.select(target);
188                    return;
189                };
190                let a = ordered_keys.iter().position(|k| *k == anchor);
191                let t = ordered_keys.iter().position(|k| *k == target);
192                match (a, t) {
193                    (Some(a), Some(t)) => {
194                        if additive && !self.extending.get() {
195                            *self.base.borrow_mut() = self.selection.get();
196                        }
197                        let (lo, hi) = (a.min(t), a.max(t));
198                        // Recomputed from the base rather than unioned into the
199                        // live selection, so shrinking the range gives back the
200                        // rows it took.
201                        let mut set = self.base.borrow().clone();
202                        for k in &ordered_keys[lo..=hi] {
203                            set.insert(k.clone());
204                        }
205                        self.selection.set(set);
206                        self.extending.set(true);
207                        // Anchor stays put.
208                    }
209                    _ => self.select(target),
210                }
211            }
212        }
213    }
214
215    /// Replace the selection with `keys` (or, when `additive`, union them in).
216    /// Used by rubber-band selection. In `Single` mode an arbitrary one wins.
217    pub fn select_keys(&self, keys: impl IntoIterator<Item = K>, additive: bool) {
218        if self.mode == SelectionMode::None {
219            return;
220        }
221        let mut set = if additive {
222            self.selection.get()
223        } else {
224            HashSet::new()
225        };
226        set.extend(keys);
227        if self.mode == SelectionMode::Single && set.len() > 1 {
228            let keep = set.iter().next().cloned();
229            set = keep.into_iter().collect();
230        }
231        self.selection.set(set.clone());
232        self.commit_base(if additive { set } else { HashSet::new() });
233    }
234
235    /// Clear the selection and anchor.
236    pub fn clear(&self) {
237        self.selection.set(HashSet::new());
238        *self.anchor.borrow_mut() = None;
239        self.commit_base(HashSet::new());
240    }
241
242    /// Drop any selected key (and the anchor) for which `exists` returns false.
243    /// Call after a removal/reset to prune deleted rows — the index-based
244    /// `adjust_for_insert`/`adjust_for_remove` are unnecessary here because keys
245    /// are stable across inserts, moves, sorts and filters.
246    pub fn prune_missing(&self, exists: impl Fn(&K) -> bool) {
247        let old = self.selection.get();
248        let new: HashSet<K> = old.iter().filter(|k| exists(k)).cloned().collect();
249        if new.len() != old.len() {
250            self.selection.set(new);
251        }
252        let drop_anchor = self.anchor.borrow().as_ref().is_some_and(|a| !exists(a));
253        if drop_anchor {
254            *self.anchor.borrow_mut() = None;
255        }
256        // The gesture base is selection state too, so a deleted row has to
257        // leave it as well or the next Shift range resurrects the key.
258        let mut base = self.base.borrow_mut();
259        if !base.is_empty() {
260            base.retain(|k| exists(k));
261        }
262    }
263}
264
265impl<K: ItemKey> Clone for KeyedSelectionModel<K> {
266    fn clone(&self) -> Self {
267        Self {
268            mode: self.mode,
269            selection: self.selection.clone(),
270            anchor: self.anchor.clone(),
271            base: self.base.clone(),
272            extending: self.extending.clone(),
273            #[cfg(debug_assertions)]
274            debug_adapter_holder: self.debug_adapter_holder.clone(),
275        }
276    }
277}
278
279impl<K: ItemKey> KeyedSelectionModel<K> {
280    /// Register this model with the debug inspector under `name`; no-op in
281    /// release builds (`!cfg(debug_assertions)`). Returns `self` for chaining.
282    pub fn debug_named(self, _name: impl Into<String>) -> Self {
283        #[cfg(debug_assertions)]
284        {
285            let adapter: Rc<dyn crate::debug_registry::ModelDebug> =
286                Rc::new(KeyedSelectionModelDebug {
287                    selection: self.selection.clone(),
288                    mode: self.mode,
289                });
290            crate::debug_registry::register(_name.into(), Rc::downgrade(&adapter));
291            *self.debug_adapter_holder.borrow_mut() = Some(adapter);
292        }
293        self
294    }
295}
296
297#[cfg(debug_assertions)]
298struct KeyedSelectionModelDebug<K: ItemKey> {
299    selection: Signal<HashSet<K>>,
300    mode: SelectionMode,
301}
302
303#[cfg(debug_assertions)]
304impl<K: ItemKey> crate::debug_registry::ModelDebug for KeyedSelectionModelDebug<K> {
305    fn kind(&self) -> &'static str {
306        "KeyedSelectionModel"
307    }
308    fn len(&self) -> usize {
309        self.selection.get().len()
310    }
311    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
312        let _ = writeln!(out, "mode = {:?}", self.mode);
313        let sel = self.selection.get();
314        if sel.is_empty() {
315            let _ = writeln!(out, "(empty)");
316            return;
317        }
318        for k in sel.iter() {
319            let _ = writeln!(out, "{:?}", k);
320        }
321    }
322}
323
324impl<K: ItemKey> std::fmt::Debug for KeyedSelectionModel<K> {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.debug_struct("KeyedSelectionModel")
327            .field("mode", &self.mode)
328            .field("selected_count", &self.selection.get().len())
329            .finish()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn single_select_by_key() {
339        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Single);
340        m.select(10);
341        assert!(m.is_selected(&10));
342        m.select(20);
343        assert!(!m.is_selected(&10));
344        assert!(m.is_selected(&20));
345    }
346
347    #[test]
348    fn multi_toggle_and_count() {
349        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
350        m.toggle(1);
351        m.toggle(3);
352        assert_eq!(m.count(), 2);
353        m.toggle(1);
354        assert!(!m.is_selected(&1));
355        assert!(m.is_selected(&3));
356    }
357
358    #[test]
359    fn reversing_a_keyed_shift_gesture_shrinks_the_range() {
360        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
361        let order = vec![10_u64, 20, 30, 40, 50];
362        m.select(20);
363        m.extend_to(50, &order);
364        let mut got = m.selected_keys();
365        got.sort();
366        assert_eq!(got, vec![20, 30, 40, 50]);
367        m.extend_to(30, &order);
368        let mut got = m.selected_keys();
369        got.sort();
370        assert_eq!(got, vec![20, 30]);
371    }
372
373    #[test]
374    fn a_keyed_toggle_survives_the_next_shift_range() {
375        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
376        let order = vec![10_u64, 20, 30, 40, 50];
377        m.select(10);
378        m.toggle(30);
379        m.extend_to(50, &order);
380        let mut got = m.selected_keys();
381        got.sort();
382        assert_eq!(got, vec![10, 30, 40, 50]);
383    }
384
385    #[test]
386    fn a_keyed_additive_extend_keeps_the_earlier_range() {
387        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
388        let order = vec![10_u64, 20, 30, 40, 50, 60];
389        m.select(10);
390        m.extend_to(20, &order);
391        m.toggle(40);
392        m.extend_to_additive(60, &order);
393        let mut got = m.selected_keys();
394        got.sort();
395        assert_eq!(got, vec![10, 20, 40, 50, 60]);
396        m.extend_to_additive(50, &order);
397        let mut got = m.selected_keys();
398        got.sort();
399        assert_eq!(got, vec![10, 20, 40, 50]);
400    }
401
402    #[test]
403    fn pruning_a_deleted_key_also_drops_it_from_the_gesture_base() {
404        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
405        let order = vec![10_u64, 20, 30, 40];
406        m.select(10);
407        m.toggle(20); // base = {10, 20}
408        m.prune_missing(|k| *k != 10);
409        m.extend_to(40, &order);
410        let mut got = m.selected_keys();
411        got.sort();
412        // Key 10 is gone; the range from the anchor at 20 must not revive it.
413        assert_eq!(got, vec![20, 30, 40]);
414    }
415
416    #[test]
417    fn extend_to_over_visible_order() {
418        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
419        let order = vec![10_u64, 20, 30, 40, 50];
420        m.select(20); // anchor at key 20
421        m.extend_to(40, &order);
422        let mut got = m.selected_keys();
423        got.sort();
424        assert_eq!(got, vec![20, 30, 40]);
425    }
426
427    #[test]
428    fn selection_survives_reorder_of_visible_order() {
429        // The whole point: selection is by identity, so reordering the
430        // projection does not change which keys are selected.
431        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
432        m.toggle(30);
433        m.toggle(10);
434        // Visible order changes (e.g. a sort) — selection unaffected.
435        assert!(m.is_selected(&10));
436        assert!(m.is_selected(&30));
437        assert!(!m.is_selected(&20));
438    }
439
440    #[test]
441    fn prune_missing_drops_deleted_keys_and_anchor() {
442        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
443        m.toggle(1);
444        m.toggle(2);
445        m.toggle(3); // selection {1,2,3}, anchor = 3
446        // Keys 2 and 3 no longer exist.
447        let live: HashSet<u64> = [1_u64, 4, 5].into_iter().collect();
448        m.prune_missing(|k| live.contains(k));
449        assert!(m.is_selected(&1));
450        assert!(!m.is_selected(&2));
451        assert!(!m.is_selected(&3));
452        // Anchor (3) was pruned: extend_to now falls back to single-select.
453        m.extend_to(5, &[1, 4, 5]);
454        assert!(m.is_selected(&5));
455    }
456
457    #[test]
458    fn anchor_not_visible_falls_back_to_single() {
459        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
460        m.select(99); // anchor 99, not in the visible order below
461        m.extend_to(20, &[10, 20, 30]);
462        // Anchor wasn't visible → single-select target.
463        assert_eq!(m.selected_keys(), vec![20]);
464    }
465
466    #[test]
467    fn none_mode_ignores() {
468        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::None);
469        m.select(1);
470        m.toggle(2);
471        assert_eq!(m.count(), 0);
472    }
473}