Skip to main content

teksilo_widgets/table_view/
selection.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Selection types for `TableView` and `TreeTableView`.
5//!
6//! For row selection (`SingleRow` / `MultiRow`) the table re-uses the
7//! existing `teksilo_data::SelectionModel` keyed by visible row index.
8//!
9//! For cell selection (`SingleCell` / `MultiCell`) the table uses
10//! [`CellSelectionModel`] which tracks `(row, col)` pairs as a
11//! `Signal<BTreeSet<(usize, usize)>>`. Anchor-rectangle extension supports
12//! Excel-style Shift-Arrow / Shift-Click semantics.
13
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::BTreeSet;
17use std::rc::Rc;
18
19use teksilo_core::signal::Signal;
20
21/// Selection mode for a `TableView` or `TreeTableView`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum TableSelectionMode {
24    /// No selection allowed.
25    None,
26    /// At most one row selected at a time.
27    SingleRow,
28    /// Multiple rows selectable; Ctrl-click toggles, Shift-click extends.
29    /// **Default.**
30    #[default]
31    MultiRow,
32    /// Excel-style: at most one cell selected at a time.
33    SingleCell,
34    /// Excel-style: rectangular cell selection.
35    MultiCell,
36}
37
38impl TableSelectionMode {
39    /// Whether the mode operates on cells rather than entire rows.
40    pub fn is_cell_mode(self) -> bool {
41        matches!(self, Self::SingleCell | Self::MultiCell)
42    }
43
44    /// Whether the mode allows more than one entry to be selected.
45    pub fn is_multi(self) -> bool {
46        matches!(self, Self::MultiRow | Self::MultiCell)
47    }
48}
49
50/// Cell-level selection state for `TableSelectionMode::SingleCell` /
51/// `MultiCell`. Tracks `(row, col)` pairs in visible-index space.
52///
53/// Mirrors `teksilo_data::SelectionModel`'s API surface (signal-backed,
54/// auto-adjustable on data mutations) but keyed by `(row, col)` instead of
55/// `row` alone.
56pub struct CellSelectionModel {
57    mode: TableSelectionMode,
58    selection: Signal<BTreeSet<(usize, usize)>>,
59    anchor: Rc<Cell<Option<(usize, usize)>>>,
60    /// Cells committed by prior clicks/toggles, kept *separate* from the live
61    /// Shift-drag rectangle. `extend_to` recomputes the selection as
62    /// `base ∪ rectangle(anchor, target)` each time, so a Shift+click to a
63    /// smaller rectangle *shrinks* it (Excel semantics) instead of only ever
64    /// growing it, while Ctrl-committed cells survive.
65    base: Rc<RefCell<BTreeSet<(usize, usize)>>>,
66}
67
68impl CellSelectionModel {
69    /// Construct a model. **Panics** if `mode` is not a cell mode —
70    /// callers in row mode should use `teksilo_data::SelectionModel`.
71    pub fn new(mode: TableSelectionMode) -> Self {
72        assert!(
73            mode.is_cell_mode(),
74            "CellSelectionModel requires SingleCell or MultiCell mode (got {mode:?})"
75        );
76        Self {
77            mode,
78            selection: Signal::new(BTreeSet::new()),
79            anchor: Rc::new(Cell::new(None)),
80            base: Rc::new(RefCell::new(BTreeSet::new())),
81        }
82    }
83
84    pub fn mode(&self) -> TableSelectionMode {
85        self.mode
86    }
87
88    pub fn selection_signal(&self) -> Signal<BTreeSet<(usize, usize)>> {
89        self.selection.clone()
90    }
91
92    pub fn is_selected(&self, row: usize, col: usize) -> bool {
93        self.selection.get().contains(&(row, col))
94    }
95
96    pub fn count(&self) -> usize {
97        self.selection.get().len()
98    }
99
100    /// Replace the selection with the single cell `(row, col)` and set
101    /// the anchor.
102    pub fn select(&self, row: usize, col: usize) {
103        if self.mode == TableSelectionMode::None {
104            return;
105        }
106        let mut s = BTreeSet::new();
107        s.insert((row, col));
108        self.selection.set(s);
109        self.anchor.set(Some((row, col)));
110        // A plain click starts a fresh range: nothing committed beneath the
111        // (about-to-be-dragged) rectangle.
112        self.base.borrow_mut().clear();
113    }
114
115    /// Toggle the cell `(row, col)` (Ctrl-click). In `SingleCell` mode
116    /// this behaves like [`select`](Self::select).
117    pub fn toggle(&self, row: usize, col: usize) {
118        match self.mode {
119            TableSelectionMode::None => {}
120            TableSelectionMode::SingleCell => self.select(row, col),
121            TableSelectionMode::MultiCell => {
122                let mut s = self.selection.get();
123                if !s.insert((row, col)) {
124                    s.remove(&(row, col));
125                }
126                self.selection.set(s.clone());
127                self.anchor.set(Some((row, col)));
128                // Ctrl-click commits the whole current selection as the base,
129                // so a subsequent Shift-extend keeps it while the new
130                // rectangle (anchored here) can still grow and shrink.
131                *self.base.borrow_mut() = s;
132            }
133            TableSelectionMode::SingleRow | TableSelectionMode::MultiRow => {}
134        }
135    }
136
137    /// Extend the selection to include the rectangular range from the
138    /// anchor to `(row, col)`. In `SingleCell` mode this falls back to
139    /// [`select`](Self::select).
140    pub fn extend_to(&self, row: usize, col: usize) {
141        match self.mode {
142            TableSelectionMode::None => {}
143            TableSelectionMode::SingleCell => self.select(row, col),
144            TableSelectionMode::MultiCell => {
145                let anchor = self.anchor.get().unwrap_or((row, col));
146                let r0 = anchor.0.min(row);
147                let r1 = anchor.0.max(row);
148                let c0 = anchor.1.min(col);
149                let c1 = anchor.1.max(col);
150                // Recompute from the committed base ∪ the current rectangle,
151                // rather than merging into the previous selection — so moving
152                // the Shift target inward SHRINKS the rectangle (Excel
153                // semantics) instead of only ever accreting cells.
154                let mut s = self.base.borrow().clone();
155                for r in r0..=r1 {
156                    for c in c0..=c1 {
157                        s.insert((r, c));
158                    }
159                }
160                self.selection.set(s);
161                // Anchor stays in place.
162            }
163            TableSelectionMode::SingleRow | TableSelectionMode::MultiRow => {}
164        }
165    }
166
167    /// Replace the selection with an arbitrary set of cells, committing it as
168    /// the base a following Shift range extends around.
169    ///
170    /// Backs the two spreadsheet chords a rectangle cannot express: Ctrl+Space
171    /// selects a column and Shift+Space a row, neither of which is an
172    /// anchor-to-cursor block.
173    /// Declines outside `MultiCell` for the reason [`select_all`](Self::select_all)
174    /// declines outside a cell mode: a set of cells is not something a
175    /// single-selection or row-selection model can hold, and quietly storing
176    /// one would break the mode's own invariant.
177    pub fn select_cells(&self, cells: impl IntoIterator<Item = (usize, usize)>) {
178        if self.mode != TableSelectionMode::MultiCell {
179            return;
180        }
181        let set: BTreeSet<(usize, usize)> = cells.into_iter().collect();
182        self.selection.set(set.clone());
183        *self.base.borrow_mut() = set;
184        self.anchor.set(None);
185    }
186
187    pub fn select_all(&self, row_count: usize, col_count: usize) {
188        if self.mode == TableSelectionMode::None {
189            return;
190        }
191        let mut s = BTreeSet::new();
192        for r in 0..row_count {
193            for c in 0..col_count {
194                s.insert((r, c));
195            }
196        }
197        self.selection.set(s.clone());
198        // Treat select-all as a committed base, so a following Shift-extend
199        // keeps it rather than collapsing to the bare rectangle.
200        *self.base.borrow_mut() = s;
201    }
202
203    pub fn clear(&self) {
204        self.selection.set(BTreeSet::new());
205        self.anchor.set(None);
206        self.base.borrow_mut().clear();
207    }
208
209    /// Re-key the committed `base` set with the same transform applied to the
210    /// live selection on a row/column insert or remove, so a later Shift-extend
211    /// unions a correctly-shifted base rather than stale coordinates.
212    fn remap_base(&self, f: impl Fn((usize, usize)) -> Option<(usize, usize)>) {
213        let mut b = self.base.borrow_mut();
214        if b.is_empty() {
215            return;
216        }
217        *b = b.iter().filter_map(|&cell| f(cell)).collect();
218    }
219
220    /// Adjust selection after `count` rows are inserted starting at
221    /// `at_row`. Existing selections at indices `>= at_row` shift up.
222    pub fn adjust_for_row_insert(&self, at_row: usize, count: usize) {
223        let old = self.selection.get();
224        let mut new = BTreeSet::new();
225        for &(r, c) in &old {
226            if r >= at_row {
227                new.insert((r + count, c));
228            } else {
229                new.insert((r, c));
230            }
231        }
232        if new != old {
233            self.selection.set(new);
234        }
235        self.remap_base(|(r, c)| Some(if r >= at_row { (r + count, c) } else { (r, c) }));
236        if let Some((r, c)) = self.anchor.get()
237            && r >= at_row
238        {
239            self.anchor.set(Some((r + count, c)));
240        }
241    }
242
243    /// Adjust selection after `count` rows starting at `at_row` are
244    /// removed. Selections within the removed range are dropped; later
245    /// rows shift down.
246    pub fn adjust_for_row_remove(&self, at_row: usize, count: usize) {
247        let old = self.selection.get();
248        let end = at_row + count;
249        let mut new = BTreeSet::new();
250        for &(r, c) in &old {
251            if r < at_row {
252                new.insert((r, c));
253            } else if r >= end {
254                new.insert((r - count, c));
255            }
256            // r in [at_row, end) is dropped
257        }
258        if new != old {
259            self.selection.set(new);
260        }
261        self.remap_base(|(r, c)| {
262            if r < at_row {
263                Some((r, c))
264            } else if r >= end {
265                Some((r - count, c))
266            } else {
267                None
268            }
269        });
270        if let Some((r, c)) = self.anchor.get() {
271            if r >= end {
272                self.anchor.set(Some((r - count, c)));
273            } else if r >= at_row {
274                self.anchor.set(None);
275            }
276        }
277    }
278
279    /// Adjust selection after a block of `count` rows moved from `from` to
280    /// `to` (a post-removal index, matching `ListModel::move_item`). Selected
281    /// cells follow their rows; columns are untouched.
282    pub fn adjust_for_row_move(&self, from: usize, to: usize, count: usize) {
283        if from == to || count == 0 {
284            return;
285        }
286        let map = |r: usize| teksilo_data::map_index_after_move(r, from, to, count);
287        let old = self.selection.get();
288        let new: BTreeSet<(usize, usize)> = old.iter().map(|&(r, c)| (map(r), c)).collect();
289        if new != old {
290            self.selection.set(new);
291        }
292        self.remap_base(|(r, c)| Some((map(r), c)));
293        if let Some((r, c)) = self.anchor.get() {
294            self.anchor.set(Some((map(r), c)));
295        }
296    }
297
298    /// Adjust selection after `count` columns are inserted at `at_col`.
299    ///
300    /// Reserved for future dynamic-column support. `TableView`/`TreeTableView`
301    /// columns are declared once via `.add_column()`/`.columns()` and are
302    /// static for the widget's lifetime — there is no runtime insert/remove
303    /// API today, so nothing calls this. A column *reorder* or pin-toggle
304    /// permutes positions instead (see `remap_columns`),
305    /// which is what the current views actually use. Kept (not removed) as
306    /// public API in case a future dynamic-column feature needs the
307    /// offset-shift semantics this and [`adjust_for_column_remove`](Self::adjust_for_column_remove)
308    /// already implement and test.
309    pub fn adjust_for_column_insert(&self, at_col: usize, count: usize) {
310        let old = self.selection.get();
311        let mut new = BTreeSet::new();
312        for &(r, c) in &old {
313            if c >= at_col {
314                new.insert((r, c + count));
315            } else {
316                new.insert((r, c));
317            }
318        }
319        if new != old {
320            self.selection.set(new);
321        }
322        self.remap_base(|(r, c)| Some(if c >= at_col { (r, c + count) } else { (r, c) }));
323        if let Some((r, c)) = self.anchor.get()
324            && c >= at_col
325        {
326            self.anchor.set(Some((r, c + count)));
327        }
328    }
329
330    /// Adjust selection after `count` columns starting at `at_col` are
331    /// removed.
332    ///
333    /// Reserved for future dynamic-column support — see the doc comment on
334    /// [`adjust_for_column_insert`](Self::adjust_for_column_insert); nothing
335    /// calls this today for the same reason.
336    pub fn adjust_for_column_remove(&self, at_col: usize, count: usize) {
337        let old = self.selection.get();
338        let end = at_col + count;
339        let mut new = BTreeSet::new();
340        for &(r, c) in &old {
341            if c < at_col {
342                new.insert((r, c));
343            } else if c >= end {
344                new.insert((r, c - count));
345            }
346        }
347        if new != old {
348            self.selection.set(new);
349        }
350        self.remap_base(|(r, c)| {
351            if c < at_col {
352                Some((r, c))
353            } else if c >= end {
354                Some((r, c - count))
355            } else {
356                None
357            }
358        });
359        if let Some((r, c)) = self.anchor.get() {
360            if c >= end {
361                self.anchor.set(Some((r, c - count)));
362            } else if c >= at_col {
363                self.anchor.set(None);
364            }
365        }
366    }
367
368    /// Remap the column half of every stored `(row, col)` pair through
369    /// `old_to_new` — `old_to_new[old_col]` gives that column's new display
370    /// position, or `None` if it dropped out of the visible set. Rows are
371    /// untouched.
372    ///
373    /// A column reorder or pin toggle permutes display positions rather than
374    /// shifting a contiguous run, so it can't reuse
375    /// `adjust_for_column_insert`/`remove`'s offset arithmetic — the caller
376    /// (a rebuild that recomputed display order) hands over the full
377    /// old-position -> new-position mapping instead.
378    pub(crate) fn remap_columns(&self, old_to_new: &[Option<usize>]) {
379        let map = |c: usize| old_to_new.get(c).copied().flatten();
380        let old = self.selection.get();
381        let new: BTreeSet<(usize, usize)> = old
382            .iter()
383            .filter_map(|&(r, c)| map(c).map(|nc| (r, nc)))
384            .collect();
385        if new != old {
386            self.selection.set(new);
387        }
388        self.remap_base(|(r, c)| map(c).map(|nc| (r, nc)));
389        if let Some((r, c)) = self.anchor.get() {
390            self.anchor.set(map(c).map(|nc| (r, nc)));
391        }
392    }
393}
394
395impl Clone for CellSelectionModel {
396    fn clone(&self) -> Self {
397        Self {
398            mode: self.mode,
399            selection: self.selection.clone(),
400            anchor: self.anchor.clone(),
401            base: self.base.clone(),
402        }
403    }
404}
405
406impl std::fmt::Debug for CellSelectionModel {
407    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408        f.debug_struct("CellSelectionModel")
409            .field("mode", &self.mode)
410            .field("selected_count", &self.selection.get().len())
411            .finish()
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn select_replaces_and_sets_anchor() {
421        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
422        m.select(2, 3);
423        assert!(m.is_selected(2, 3));
424        assert_eq!(m.count(), 1);
425        m.select(5, 5);
426        assert!(m.is_selected(5, 5));
427        assert!(!m.is_selected(2, 3));
428    }
429
430    #[test]
431    fn toggle_in_multi_cell_adds_and_removes() {
432        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
433        m.toggle(0, 0);
434        m.toggle(1, 1);
435        assert_eq!(m.count(), 2);
436        m.toggle(0, 0);
437        assert_eq!(m.count(), 1);
438    }
439
440    #[test]
441    fn extend_in_multi_cell_fills_rectangle() {
442        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
443        m.select(2, 1);
444        m.extend_to(4, 3);
445        // 3 rows × 3 cols = 9 cells.
446        assert_eq!(m.count(), 9);
447        assert!(m.is_selected(3, 2));
448    }
449
450    #[test]
451    fn extend_shrinks_when_target_moves_inward() {
452        // Excel semantics: a second Shift extend to a smaller rectangle must
453        // SHRINK the selection, not keep the larger one.
454        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
455        m.select(0, 0);
456        m.extend_to(2, 2); // 3×3 = 9
457        assert_eq!(m.count(), 9);
458        m.extend_to(1, 1); // 2×2 = 4
459        assert_eq!(m.count(), 4, "rectangle must shrink, not accrete");
460        assert!(
461            !m.is_selected(2, 2),
462            "the dropped corner must be deselected"
463        );
464    }
465
466    #[test]
467    fn ctrl_committed_cells_survive_a_later_shift_extend() {
468        // Ctrl-click commits a base; a subsequent Shift-extend keeps it while
469        // the new rectangle can still shrink.
470        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
471        m.select(0, 0); // {(0,0)}
472        m.toggle(5, 5); // Ctrl-click → base now {(0,0),(5,5)}, anchor (5,5)
473        m.extend_to(6, 6); // base ∪ rect((5,5),(6,6))
474        assert!(m.is_selected(0, 0), "Ctrl-committed cell must survive");
475        assert!(m.is_selected(6, 6));
476        m.extend_to(5, 5); // shrink the rect back to a single cell
477        assert!(m.is_selected(0, 0), "committed cell still there");
478        assert!(!m.is_selected(6, 6), "shrunk-away cell gone");
479        assert_eq!(m.count(), 2); // (0,0) committed + (5,5) rect
480    }
481
482    #[test]
483    fn select_all_in_multi_cell() {
484        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
485        m.select_all(3, 4);
486        assert_eq!(m.count(), 12);
487    }
488
489    #[test]
490    fn single_cell_mode_keeps_one_selection() {
491        let m = CellSelectionModel::new(TableSelectionMode::SingleCell);
492        m.select(1, 1);
493        m.toggle(2, 2);
494        assert_eq!(m.count(), 1);
495        assert!(m.is_selected(2, 2));
496    }
497
498    #[test]
499    fn adjust_for_row_insert_shifts_higher_rows() {
500        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
501        m.select(2, 0);
502        m.toggle(4, 0);
503        m.adjust_for_row_insert(3, 2);
504        assert!(m.is_selected(2, 0));
505        assert!(m.is_selected(6, 0));
506    }
507
508    #[test]
509    fn adjust_for_row_remove_drops_in_range() {
510        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
511        m.select(1, 0);
512        m.toggle(3, 0);
513        m.toggle(5, 0);
514        m.adjust_for_row_remove(2, 2);
515        // Row 1 stays, rows 2..4 are removed (so row 3 is dropped),
516        // and row 5 shifts down by 2 to 3.
517        assert!(m.is_selected(1, 0));
518        // After the shift, row 3 is now occupied by what used to be row 5.
519        assert!(m.is_selected(3, 0));
520        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
521        assert_eq!(v, vec![(1, 0), (3, 0)]);
522    }
523
524    #[test]
525    fn adjust_for_row_move_follows_cells() {
526        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
527        m.select(0, 1); // cell in row 0
528        m.toggle(1, 2); // cell in row 1
529        // Move row 0 to index 2: rows [B,C,A] — A's cell follows to row 2,
530        // B's cell shifts down to row 0.
531        m.adjust_for_row_move(0, 2, 1);
532        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
533        assert_eq!(v, vec![(0, 2), (2, 1)]);
534    }
535
536    #[test]
537    fn adjust_for_column_insert_and_remove() {
538        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
539        m.select(0, 1);
540        m.toggle(0, 4);
541        m.adjust_for_column_insert(2, 2);
542        // col 1 stays, col 4 → 6
543        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
544        assert_eq!(v, vec![(0, 1), (0, 6)]);
545
546        m.adjust_for_column_remove(0, 2);
547        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
548        // col 1 dropped (in range), col 6 shifts to 4.
549        assert_eq!(v, vec![(0, 4)]);
550    }
551
552    #[test]
553    fn remap_columns_follows_reorder_and_drops_removed_columns() {
554        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
555        m.select(0, 0); // anchor + base cleared by `select`
556        m.toggle(1, 2); // Ctrl-click: commits {(0,0),(1,2)} as base, anchor (1,2)
557        // Column 0 moves to display position 2, column 2 moves to 0; column 1
558        // (unselected, but exercised via `extend_to` below) drops out.
559        let old_to_new = vec![Some(2), None, Some(0)];
560        m.remap_columns(&old_to_new);
561        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
562        assert_eq!(v, vec![(0, 2), (1, 0)], "columns follow their new position");
563        // The anchor moved with column 2 -> 0; a subsequent extend must build
564        // its rectangle from the remapped anchor, not the stale one.
565        m.extend_to(1, 1);
566        assert!(
567            m.is_selected(1, 0),
568            "remapped anchor (1,0) must survive into the next extend"
569        );
570    }
571
572    #[test]
573    fn remap_columns_drops_selection_in_a_removed_column() {
574        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
575        m.select(3, 1);
576        // Column 1 (the only selected one) is gone from the new order.
577        m.remap_columns(&[Some(0), None]);
578        assert_eq!(m.count(), 0);
579    }
580
581    #[test]
582    #[should_panic]
583    fn cell_model_rejects_row_mode() {
584        let _ = CellSelectionModel::new(TableSelectionMode::MultiRow);
585    }
586
587    #[test]
588    fn mode_is_cell_mode() {
589        assert!(TableSelectionMode::SingleCell.is_cell_mode());
590        assert!(TableSelectionMode::MultiCell.is_cell_mode());
591        assert!(!TableSelectionMode::MultiRow.is_cell_mode());
592    }
593}