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    /// Select every cell in `0..row_count × 0..col_count`.
168    pub fn select_all(&self, row_count: usize, col_count: usize) {
169        if self.mode == TableSelectionMode::None {
170            return;
171        }
172        let mut s = BTreeSet::new();
173        for r in 0..row_count {
174            for c in 0..col_count {
175                s.insert((r, c));
176            }
177        }
178        self.selection.set(s.clone());
179        // Treat select-all as a committed base, so a following Shift-extend
180        // keeps it rather than collapsing to the bare rectangle.
181        *self.base.borrow_mut() = s;
182    }
183
184    pub fn clear(&self) {
185        self.selection.set(BTreeSet::new());
186        self.anchor.set(None);
187        self.base.borrow_mut().clear();
188    }
189
190    /// Re-key the committed `base` set with the same transform applied to the
191    /// live selection on a row/column insert or remove, so a later Shift-extend
192    /// unions a correctly-shifted base rather than stale coordinates.
193    fn remap_base(&self, f: impl Fn((usize, usize)) -> Option<(usize, usize)>) {
194        let mut b = self.base.borrow_mut();
195        if b.is_empty() {
196            return;
197        }
198        *b = b.iter().filter_map(|&cell| f(cell)).collect();
199    }
200
201    /// Adjust selection after `count` rows are inserted starting at
202    /// `at_row`. Existing selections at indices `>= at_row` shift up.
203    pub fn adjust_for_row_insert(&self, at_row: usize, count: usize) {
204        let old = self.selection.get();
205        let mut new = BTreeSet::new();
206        for &(r, c) in &old {
207            if r >= at_row {
208                new.insert((r + count, c));
209            } else {
210                new.insert((r, c));
211            }
212        }
213        if new != old {
214            self.selection.set(new);
215        }
216        self.remap_base(|(r, c)| Some(if r >= at_row { (r + count, c) } else { (r, c) }));
217        if let Some((r, c)) = self.anchor.get()
218            && r >= at_row
219        {
220            self.anchor.set(Some((r + count, c)));
221        }
222    }
223
224    /// Adjust selection after `count` rows starting at `at_row` are
225    /// removed. Selections within the removed range are dropped; later
226    /// rows shift down.
227    pub fn adjust_for_row_remove(&self, at_row: usize, count: usize) {
228        let old = self.selection.get();
229        let end = at_row + count;
230        let mut new = BTreeSet::new();
231        for &(r, c) in &old {
232            if r < at_row {
233                new.insert((r, c));
234            } else if r >= end {
235                new.insert((r - count, c));
236            }
237            // r in [at_row, end) is dropped
238        }
239        if new != old {
240            self.selection.set(new);
241        }
242        self.remap_base(|(r, c)| {
243            if r < at_row {
244                Some((r, c))
245            } else if r >= end {
246                Some((r - count, c))
247            } else {
248                None
249            }
250        });
251        if let Some((r, c)) = self.anchor.get() {
252            if r >= end {
253                self.anchor.set(Some((r - count, c)));
254            } else if r >= at_row {
255                self.anchor.set(None);
256            }
257        }
258    }
259
260    /// Adjust selection after a block of `count` rows moved from `from` to
261    /// `to` (a post-removal index, matching `ListModel::move_item`). Selected
262    /// cells follow their rows; columns are untouched.
263    pub fn adjust_for_row_move(&self, from: usize, to: usize, count: usize) {
264        if from == to || count == 0 {
265            return;
266        }
267        let map = |r: usize| teksilo_data::map_index_after_move(r, from, to, count);
268        let old = self.selection.get();
269        let new: BTreeSet<(usize, usize)> = old.iter().map(|&(r, c)| (map(r), c)).collect();
270        if new != old {
271            self.selection.set(new);
272        }
273        self.remap_base(|(r, c)| Some((map(r), c)));
274        if let Some((r, c)) = self.anchor.get() {
275            self.anchor.set(Some((map(r), c)));
276        }
277    }
278
279    /// Adjust selection after `count` columns are inserted at `at_col`.
280    ///
281    /// Reserved for future dynamic-column support. `TableView`/`TreeTableView`
282    /// columns are declared once via `.add_column()`/`.columns()` and are
283    /// static for the widget's lifetime — there is no runtime insert/remove
284    /// API today, so nothing calls this. A column *reorder* or pin-toggle
285    /// permutes positions instead (see `remap_columns`),
286    /// which is what the current views actually use. Kept (not removed) as
287    /// public API in case a future dynamic-column feature needs the
288    /// offset-shift semantics this and [`adjust_for_column_remove`](Self::adjust_for_column_remove)
289    /// already implement and test.
290    pub fn adjust_for_column_insert(&self, at_col: usize, count: usize) {
291        let old = self.selection.get();
292        let mut new = BTreeSet::new();
293        for &(r, c) in &old {
294            if c >= at_col {
295                new.insert((r, c + count));
296            } else {
297                new.insert((r, c));
298            }
299        }
300        if new != old {
301            self.selection.set(new);
302        }
303        self.remap_base(|(r, c)| Some(if c >= at_col { (r, c + count) } else { (r, c) }));
304        if let Some((r, c)) = self.anchor.get()
305            && c >= at_col
306        {
307            self.anchor.set(Some((r, c + count)));
308        }
309    }
310
311    /// Adjust selection after `count` columns starting at `at_col` are
312    /// removed.
313    ///
314    /// Reserved for future dynamic-column support — see the doc comment on
315    /// [`adjust_for_column_insert`](Self::adjust_for_column_insert); nothing
316    /// calls this today for the same reason.
317    pub fn adjust_for_column_remove(&self, at_col: usize, count: usize) {
318        let old = self.selection.get();
319        let end = at_col + count;
320        let mut new = BTreeSet::new();
321        for &(r, c) in &old {
322            if c < at_col {
323                new.insert((r, c));
324            } else if c >= end {
325                new.insert((r, c - count));
326            }
327        }
328        if new != old {
329            self.selection.set(new);
330        }
331        self.remap_base(|(r, c)| {
332            if c < at_col {
333                Some((r, c))
334            } else if c >= end {
335                Some((r, c - count))
336            } else {
337                None
338            }
339        });
340        if let Some((r, c)) = self.anchor.get() {
341            if c >= end {
342                self.anchor.set(Some((r, c - count)));
343            } else if c >= at_col {
344                self.anchor.set(None);
345            }
346        }
347    }
348
349    /// Remap the column half of every stored `(row, col)` pair through
350    /// `old_to_new` — `old_to_new[old_col]` gives that column's new display
351    /// position, or `None` if it dropped out of the visible set. Rows are
352    /// untouched.
353    ///
354    /// A column reorder or pin toggle permutes display positions rather than
355    /// shifting a contiguous run, so it can't reuse
356    /// `adjust_for_column_insert`/`remove`'s offset arithmetic — the caller
357    /// (a rebuild that recomputed display order) hands over the full
358    /// old-position -> new-position mapping instead.
359    pub(crate) fn remap_columns(&self, old_to_new: &[Option<usize>]) {
360        let map = |c: usize| old_to_new.get(c).copied().flatten();
361        let old = self.selection.get();
362        let new: BTreeSet<(usize, usize)> = old
363            .iter()
364            .filter_map(|&(r, c)| map(c).map(|nc| (r, nc)))
365            .collect();
366        if new != old {
367            self.selection.set(new);
368        }
369        self.remap_base(|(r, c)| map(c).map(|nc| (r, nc)));
370        if let Some((r, c)) = self.anchor.get() {
371            self.anchor.set(map(c).map(|nc| (r, nc)));
372        }
373    }
374}
375
376impl Clone for CellSelectionModel {
377    fn clone(&self) -> Self {
378        Self {
379            mode: self.mode,
380            selection: self.selection.clone(),
381            anchor: self.anchor.clone(),
382            base: self.base.clone(),
383        }
384    }
385}
386
387impl std::fmt::Debug for CellSelectionModel {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        f.debug_struct("CellSelectionModel")
390            .field("mode", &self.mode)
391            .field("selected_count", &self.selection.get().len())
392            .finish()
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn select_replaces_and_sets_anchor() {
402        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
403        m.select(2, 3);
404        assert!(m.is_selected(2, 3));
405        assert_eq!(m.count(), 1);
406        m.select(5, 5);
407        assert!(m.is_selected(5, 5));
408        assert!(!m.is_selected(2, 3));
409    }
410
411    #[test]
412    fn toggle_in_multi_cell_adds_and_removes() {
413        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
414        m.toggle(0, 0);
415        m.toggle(1, 1);
416        assert_eq!(m.count(), 2);
417        m.toggle(0, 0);
418        assert_eq!(m.count(), 1);
419    }
420
421    #[test]
422    fn extend_in_multi_cell_fills_rectangle() {
423        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
424        m.select(2, 1);
425        m.extend_to(4, 3);
426        // 3 rows × 3 cols = 9 cells.
427        assert_eq!(m.count(), 9);
428        assert!(m.is_selected(3, 2));
429    }
430
431    #[test]
432    fn extend_shrinks_when_target_moves_inward() {
433        // Excel semantics: a second Shift extend to a smaller rectangle must
434        // SHRINK the selection, not keep the larger one.
435        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
436        m.select(0, 0);
437        m.extend_to(2, 2); // 3×3 = 9
438        assert_eq!(m.count(), 9);
439        m.extend_to(1, 1); // 2×2 = 4
440        assert_eq!(m.count(), 4, "rectangle must shrink, not accrete");
441        assert!(
442            !m.is_selected(2, 2),
443            "the dropped corner must be deselected"
444        );
445    }
446
447    #[test]
448    fn ctrl_committed_cells_survive_a_later_shift_extend() {
449        // Ctrl-click commits a base; a subsequent Shift-extend keeps it while
450        // the new rectangle can still shrink.
451        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
452        m.select(0, 0); // {(0,0)}
453        m.toggle(5, 5); // Ctrl-click → base now {(0,0),(5,5)}, anchor (5,5)
454        m.extend_to(6, 6); // base ∪ rect((5,5),(6,6))
455        assert!(m.is_selected(0, 0), "Ctrl-committed cell must survive");
456        assert!(m.is_selected(6, 6));
457        m.extend_to(5, 5); // shrink the rect back to a single cell
458        assert!(m.is_selected(0, 0), "committed cell still there");
459        assert!(!m.is_selected(6, 6), "shrunk-away cell gone");
460        assert_eq!(m.count(), 2); // (0,0) committed + (5,5) rect
461    }
462
463    #[test]
464    fn select_all_in_multi_cell() {
465        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
466        m.select_all(3, 4);
467        assert_eq!(m.count(), 12);
468    }
469
470    #[test]
471    fn single_cell_mode_keeps_one_selection() {
472        let m = CellSelectionModel::new(TableSelectionMode::SingleCell);
473        m.select(1, 1);
474        m.toggle(2, 2);
475        assert_eq!(m.count(), 1);
476        assert!(m.is_selected(2, 2));
477    }
478
479    #[test]
480    fn adjust_for_row_insert_shifts_higher_rows() {
481        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
482        m.select(2, 0);
483        m.toggle(4, 0);
484        m.adjust_for_row_insert(3, 2);
485        assert!(m.is_selected(2, 0));
486        assert!(m.is_selected(6, 0));
487    }
488
489    #[test]
490    fn adjust_for_row_remove_drops_in_range() {
491        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
492        m.select(1, 0);
493        m.toggle(3, 0);
494        m.toggle(5, 0);
495        m.adjust_for_row_remove(2, 2);
496        // Row 1 stays, rows 2..4 are removed (so row 3 is dropped),
497        // and row 5 shifts down by 2 to 3.
498        assert!(m.is_selected(1, 0));
499        // After the shift, row 3 is now occupied by what used to be row 5.
500        assert!(m.is_selected(3, 0));
501        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
502        assert_eq!(v, vec![(1, 0), (3, 0)]);
503    }
504
505    #[test]
506    fn adjust_for_row_move_follows_cells() {
507        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
508        m.select(0, 1); // cell in row 0
509        m.toggle(1, 2); // cell in row 1
510        // Move row 0 to index 2: rows [B,C,A] — A's cell follows to row 2,
511        // B's cell shifts down to row 0.
512        m.adjust_for_row_move(0, 2, 1);
513        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
514        assert_eq!(v, vec![(0, 2), (2, 1)]);
515    }
516
517    #[test]
518    fn adjust_for_column_insert_and_remove() {
519        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
520        m.select(0, 1);
521        m.toggle(0, 4);
522        m.adjust_for_column_insert(2, 2);
523        // col 1 stays, col 4 → 6
524        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
525        assert_eq!(v, vec![(0, 1), (0, 6)]);
526
527        m.adjust_for_column_remove(0, 2);
528        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
529        // col 1 dropped (in range), col 6 shifts to 4.
530        assert_eq!(v, vec![(0, 4)]);
531    }
532
533    #[test]
534    fn remap_columns_follows_reorder_and_drops_removed_columns() {
535        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
536        m.select(0, 0); // anchor + base cleared by `select`
537        m.toggle(1, 2); // Ctrl-click: commits {(0,0),(1,2)} as base, anchor (1,2)
538        // Column 0 moves to display position 2, column 2 moves to 0; column 1
539        // (unselected, but exercised via `extend_to` below) drops out.
540        let old_to_new = vec![Some(2), None, Some(0)];
541        m.remap_columns(&old_to_new);
542        let v: Vec<_> = m.selection_signal().get().into_iter().collect();
543        assert_eq!(v, vec![(0, 2), (1, 0)], "columns follow their new position");
544        // The anchor moved with column 2 -> 0; a subsequent extend must build
545        // its rectangle from the remapped anchor, not the stale one.
546        m.extend_to(1, 1);
547        assert!(
548            m.is_selected(1, 0),
549            "remapped anchor (1,0) must survive into the next extend"
550        );
551    }
552
553    #[test]
554    fn remap_columns_drops_selection_in_a_removed_column() {
555        let m = CellSelectionModel::new(TableSelectionMode::MultiCell);
556        m.select(3, 1);
557        // Column 1 (the only selected one) is gone from the new order.
558        m.remap_columns(&[Some(0), None]);
559        assert_eq!(m.count(), 0);
560    }
561
562    #[test]
563    #[should_panic]
564    fn cell_model_rejects_row_mode() {
565        let _ = CellSelectionModel::new(TableSelectionMode::MultiRow);
566    }
567
568    #[test]
569    fn mode_is_cell_mode() {
570        assert!(TableSelectionMode::SingleCell.is_cell_mode());
571        assert!(TableSelectionMode::MultiCell.is_cell_mode());
572        assert!(!TableSelectionMode::MultiRow.is_cell_mode());
573    }
574}