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