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