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    /// Strong holder for the debug-registry adapter; shared across clones.
62    /// Compiled out in release.
63    #[cfg(debug_assertions)]
64    debug_adapter_holder: Rc<RefCell<Option<Rc<dyn crate::debug_registry::ModelDebug>>>>,
65}
66
67impl<K: ItemKey> KeyedSelectionModel<K> {
68    /// Create a new keyed selection model with the given mode.
69    pub fn new(mode: SelectionMode) -> Self {
70        Self {
71            mode,
72            selection: Signal::new(HashSet::new()),
73            anchor: Rc::new(RefCell::new(None)),
74            #[cfg(debug_assertions)]
75            debug_adapter_holder: Rc::new(RefCell::new(None)),
76        }
77    }
78
79    /// The selection mode.
80    pub fn mode(&self) -> SelectionMode {
81        self.mode
82    }
83
84    /// A clone of the selection signal for reactive binding.
85    pub fn selection_signal(&self) -> Signal<HashSet<K>> {
86        self.selection.clone()
87    }
88
89    /// Whether `key` is currently selected (O(1)).
90    pub fn is_selected(&self, key: &K) -> bool {
91        self.selection.get().contains(key)
92    }
93
94    /// The currently selected keys (unordered snapshot).
95    pub fn selected_keys(&self) -> Vec<K> {
96        self.selection.get().into_iter().collect()
97    }
98
99    /// Number of selected items.
100    pub fn count(&self) -> usize {
101        self.selection.get().len()
102    }
103
104    /// Select a single key, clearing previous selection and setting the anchor.
105    pub fn select(&self, key: K) {
106        if self.mode == SelectionMode::None {
107            return;
108        }
109        let mut set = HashSet::new();
110        set.insert(key.clone());
111        self.selection.set(set);
112        *self.anchor.borrow_mut() = Some(key);
113    }
114
115    /// Toggle a key (Ctrl+click in Multi mode; acts as `select` in Single).
116    pub fn toggle(&self, key: K) {
117        match self.mode {
118            SelectionMode::None => {}
119            SelectionMode::Single => self.select(key),
120            SelectionMode::Multi => {
121                let mut set = self.selection.get();
122                if set.contains(&key) {
123                    set.remove(&key);
124                } else {
125                    set.insert(key.clone());
126                }
127                self.selection.set(set);
128                *self.anchor.borrow_mut() = Some(key);
129            }
130        }
131    }
132
133    /// Extend the selection from the anchor to `target` over the current visible
134    /// key order (Shift+click). `ordered_keys` is the projection's visible order
135    /// at click time. If the anchor isn't currently visible (scrolled out /
136    /// evicted), falls back to a single-key select.
137    pub fn extend_to(&self, target: K, ordered_keys: &[K]) {
138        match self.mode {
139            SelectionMode::None => {}
140            SelectionMode::Single => self.select(target),
141            SelectionMode::Multi => {
142                let anchor = self.anchor.borrow().clone();
143                let Some(anchor) = anchor else {
144                    self.select(target);
145                    return;
146                };
147                let a = ordered_keys.iter().position(|k| *k == anchor);
148                let t = ordered_keys.iter().position(|k| *k == target);
149                match (a, t) {
150                    (Some(a), Some(t)) => {
151                        let (lo, hi) = (a.min(t), a.max(t));
152                        let mut set = self.selection.get();
153                        for k in &ordered_keys[lo..=hi] {
154                            set.insert(k.clone());
155                        }
156                        self.selection.set(set);
157                        // Anchor stays put.
158                    }
159                    _ => self.select(target),
160                }
161            }
162        }
163    }
164
165    /// Replace the selection with `keys` (or, when `additive`, union them in).
166    /// Used by rubber-band selection. In `Single` mode an arbitrary one wins.
167    pub fn select_keys(&self, keys: impl IntoIterator<Item = K>, additive: bool) {
168        if self.mode == SelectionMode::None {
169            return;
170        }
171        let mut set = if additive {
172            self.selection.get()
173        } else {
174            HashSet::new()
175        };
176        set.extend(keys);
177        if self.mode == SelectionMode::Single && set.len() > 1 {
178            let keep = set.iter().next().cloned();
179            set = keep.into_iter().collect();
180        }
181        self.selection.set(set);
182    }
183
184    /// Clear the selection and anchor.
185    pub fn clear(&self) {
186        self.selection.set(HashSet::new());
187        *self.anchor.borrow_mut() = None;
188    }
189
190    /// Drop any selected key (and the anchor) for which `exists` returns false.
191    /// Call after a removal/reset to prune deleted rows — the index-based
192    /// `adjust_for_insert`/`adjust_for_remove` are unnecessary here because keys
193    /// are stable across inserts, moves, sorts and filters.
194    pub fn prune_missing(&self, exists: impl Fn(&K) -> bool) {
195        let old = self.selection.get();
196        let new: HashSet<K> = old.iter().filter(|k| exists(k)).cloned().collect();
197        if new.len() != old.len() {
198            self.selection.set(new);
199        }
200        let drop_anchor = self.anchor.borrow().as_ref().is_some_and(|a| !exists(a));
201        if drop_anchor {
202            *self.anchor.borrow_mut() = None;
203        }
204    }
205}
206
207impl<K: ItemKey> Clone for KeyedSelectionModel<K> {
208    fn clone(&self) -> Self {
209        Self {
210            mode: self.mode,
211            selection: self.selection.clone(),
212            anchor: self.anchor.clone(),
213            #[cfg(debug_assertions)]
214            debug_adapter_holder: self.debug_adapter_holder.clone(),
215        }
216    }
217}
218
219impl<K: ItemKey> KeyedSelectionModel<K> {
220    /// Register this model with the debug inspector under `name`; no-op in
221    /// release builds (`!cfg(debug_assertions)`). Returns `self` for chaining.
222    pub fn debug_named(self, _name: impl Into<String>) -> Self {
223        #[cfg(debug_assertions)]
224        {
225            let adapter: Rc<dyn crate::debug_registry::ModelDebug> =
226                Rc::new(KeyedSelectionModelDebug {
227                    selection: self.selection.clone(),
228                    mode: self.mode,
229                });
230            crate::debug_registry::register(_name.into(), Rc::downgrade(&adapter));
231            *self.debug_adapter_holder.borrow_mut() = Some(adapter);
232        }
233        self
234    }
235}
236
237#[cfg(debug_assertions)]
238struct KeyedSelectionModelDebug<K: ItemKey> {
239    selection: Signal<HashSet<K>>,
240    mode: SelectionMode,
241}
242
243#[cfg(debug_assertions)]
244impl<K: ItemKey> crate::debug_registry::ModelDebug for KeyedSelectionModelDebug<K> {
245    fn kind(&self) -> &'static str {
246        "KeyedSelectionModel"
247    }
248    fn len(&self) -> usize {
249        self.selection.get().len()
250    }
251    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
252        let _ = writeln!(out, "mode = {:?}", self.mode);
253        let sel = self.selection.get();
254        if sel.is_empty() {
255            let _ = writeln!(out, "(empty)");
256            return;
257        }
258        for k in sel.iter() {
259            let _ = writeln!(out, "{:?}", k);
260        }
261    }
262}
263
264impl<K: ItemKey> std::fmt::Debug for KeyedSelectionModel<K> {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        f.debug_struct("KeyedSelectionModel")
267            .field("mode", &self.mode)
268            .field("selected_count", &self.selection.get().len())
269            .finish()
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn single_select_by_key() {
279        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Single);
280        m.select(10);
281        assert!(m.is_selected(&10));
282        m.select(20);
283        assert!(!m.is_selected(&10));
284        assert!(m.is_selected(&20));
285    }
286
287    #[test]
288    fn multi_toggle_and_count() {
289        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
290        m.toggle(1);
291        m.toggle(3);
292        assert_eq!(m.count(), 2);
293        m.toggle(1);
294        assert!(!m.is_selected(&1));
295        assert!(m.is_selected(&3));
296    }
297
298    #[test]
299    fn extend_to_over_visible_order() {
300        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
301        let order = vec![10_u64, 20, 30, 40, 50];
302        m.select(20); // anchor at key 20
303        m.extend_to(40, &order);
304        let mut got = m.selected_keys();
305        got.sort();
306        assert_eq!(got, vec![20, 30, 40]);
307    }
308
309    #[test]
310    fn selection_survives_reorder_of_visible_order() {
311        // The whole point: selection is by identity, so reordering the
312        // projection does not change which keys are selected.
313        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
314        m.toggle(30);
315        m.toggle(10);
316        // Visible order changes (e.g. a sort) — selection unaffected.
317        assert!(m.is_selected(&10));
318        assert!(m.is_selected(&30));
319        assert!(!m.is_selected(&20));
320    }
321
322    #[test]
323    fn prune_missing_drops_deleted_keys_and_anchor() {
324        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
325        m.toggle(1);
326        m.toggle(2);
327        m.toggle(3); // selection {1,2,3}, anchor = 3
328        // Keys 2 and 3 no longer exist.
329        let live: HashSet<u64> = [1_u64, 4, 5].into_iter().collect();
330        m.prune_missing(|k| live.contains(k));
331        assert!(m.is_selected(&1));
332        assert!(!m.is_selected(&2));
333        assert!(!m.is_selected(&3));
334        // Anchor (3) was pruned: extend_to now falls back to single-select.
335        m.extend_to(5, &[1, 4, 5]);
336        assert!(m.is_selected(&5));
337    }
338
339    #[test]
340    fn anchor_not_visible_falls_back_to_single() {
341        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi);
342        m.select(99); // anchor 99, not in the visible order below
343        m.extend_to(20, &[10, 20, 30]);
344        // Anchor wasn't visible → single-select target.
345        assert_eq!(m.selected_keys(), vec![20]);
346    }
347
348    #[test]
349    fn none_mode_ignores() {
350        let m: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::None);
351        m.select(1);
352        m.toggle(2);
353        assert_eq!(m.count(), 0);
354    }
355}