Skip to main content

guise/data/tableview/
state.rs

1//! Pure sort and selection logic for [`TableView`](super::TableView).
2//!
3//! Sorting produces a *display order* — a stable permutation of source-row
4//! indices — so the source data is never reordered. Selection is a set of
5//! source indices, which is why it survives resorting unchanged.
6//!
7//! ```ignore
8//! let order = sorted_order(&rows, SortDir::Asc, &|a, b| a.name.cmp(&b.name));
9//! let mut sel = SelectionState::default();
10//! sel.click(SelectionMode::Multi, &order, 0, false, false);
11//! sel.click(SelectionMode::Multi, &order, 3, false, true); // shift: rows 0..=3
12//! ```
13
14use std::cmp::Ordering;
15use std::collections::BTreeSet;
16
17/// Sort direction of a table column.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SortDir {
20    Asc,
21    Desc,
22}
23
24/// How rows respond to clicks and arrow keys.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum SelectionMode {
27    /// Rows are not selectable.
28    #[default]
29    None,
30    /// At most one row selected at a time.
31    Single,
32    /// Many rows: cmd-click toggles, shift-click selects a range.
33    Multi,
34}
35
36/// Header-click cycling: none → asc → desc → none on the same column; a click
37/// on a different column starts fresh at ascending.
38pub fn cycle_sort(current: Option<(usize, SortDir)>, column: usize) -> Option<(usize, SortDir)> {
39    match current {
40        Some((col, SortDir::Asc)) if col == column => Some((column, SortDir::Desc)),
41        Some((col, SortDir::Desc)) if col == column => None,
42        _ => Some((column, SortDir::Asc)),
43    }
44}
45
46/// The unsorted display order: `0..len`.
47pub fn identity_order(len: usize) -> Vec<usize> {
48    (0..len).collect()
49}
50
51/// A stable sort of row indices by `cmp`; the source slice is never mutated.
52/// `Desc` flips the comparator arguments (rather than reversing the result),
53/// so equal rows keep their source order in both directions.
54pub fn sorted_order<T>(rows: &[T], dir: SortDir, cmp: &dyn Fn(&T, &T) -> Ordering) -> Vec<usize> {
55    let mut order = identity_order(rows.len());
56    order.sort_by(|&a, &b| match dir {
57        SortDir::Asc => cmp(&rows[a], &rows[b]),
58        SortDir::Desc => cmp(&rows[b], &rows[a]),
59    });
60    order
61}
62
63/// Where `source` currently sits in the display order.
64fn position_of(order: &[usize], source: usize) -> Option<usize> {
65    order.iter().position(|&s| s == source)
66}
67
68/// Row selection over **source** indices. The shift anchor and keyboard cursor
69/// are also source indices, mapped through the display order at use, so all
70/// three survive resorting.
71#[derive(Debug, Clone, Default)]
72pub struct SelectionState {
73    selected: BTreeSet<usize>,
74    anchor: Option<usize>,
75    cursor: Option<usize>,
76}
77
78impl SelectionState {
79    /// The selected source indices, ascending.
80    pub fn selected(&self) -> Vec<usize> {
81        self.selected.iter().copied().collect()
82    }
83
84    pub fn is_selected(&self, source: usize) -> bool {
85        self.selected.contains(&source)
86    }
87
88    /// The keyboard cursor (a source index), if any.
89    pub fn cursor(&self) -> Option<usize> {
90        self.cursor
91    }
92
93    /// Drop everything. Returns whether the selected set changed.
94    pub fn clear(&mut self) -> bool {
95        self.anchor = None;
96        self.cursor = None;
97        if self.selected.is_empty() {
98            return false;
99        }
100        self.selected.clear();
101        true
102    }
103
104    /// Prune indices that fell off the end after a row-count change. Returns
105    /// whether the selected set changed.
106    pub fn retain_below(&mut self, len: usize) -> bool {
107        let before = self.selected.len();
108        self.selected.retain(|&s| s < len);
109        self.anchor = self.anchor.filter(|&s| s < len);
110        self.cursor = self.cursor.filter(|&s| s < len);
111        self.selected.len() != before
112    }
113
114    /// A mouse click on the row at `display` position. `toggle` is cmd-click,
115    /// `range` is shift-click (range wins when both are held).
116    pub fn click(
117        &mut self,
118        mode: SelectionMode,
119        order: &[usize],
120        display: usize,
121        toggle: bool,
122        range: bool,
123    ) {
124        let Some(&source) = order.get(display) else {
125            return;
126        };
127        match mode {
128            SelectionMode::None => {}
129            SelectionMode::Single => {
130                if toggle && self.selected.contains(&source) {
131                    self.selected.clear();
132                } else {
133                    self.selected.clear();
134                    self.selected.insert(source);
135                }
136                self.anchor = Some(source);
137                self.cursor = Some(source);
138            }
139            SelectionMode::Multi => {
140                if range {
141                    let from = match self.anchor.and_then(|a| position_of(order, a)) {
142                        Some(pos) => pos,
143                        // No live anchor (first interaction, or its row is
144                        // gone): this click starts and anchors the range.
145                        None => {
146                            self.anchor = Some(source);
147                            display
148                        }
149                    };
150                    self.select_span(order, from, display);
151                } else if toggle {
152                    if !self.selected.remove(&source) {
153                        self.selected.insert(source);
154                    }
155                    self.anchor = Some(source);
156                } else {
157                    self.selected.clear();
158                    self.selected.insert(source);
159                    self.anchor = Some(source);
160                }
161                self.cursor = Some(source);
162            }
163        }
164    }
165
166    /// Move the cursor by `delta` display positions (arrow keys), selecting
167    /// the row it lands on. `extend` (shift) grows the range from the anchor
168    /// in `Multi` mode. Returns the new cursor's display position so the
169    /// caller can scroll it into view.
170    pub fn step(
171        &mut self,
172        mode: SelectionMode,
173        order: &[usize],
174        delta: isize,
175        extend: bool,
176    ) -> Option<usize> {
177        if matches!(mode, SelectionMode::None) || order.is_empty() {
178            return None;
179        }
180        let last = order.len() - 1;
181        let display = match self.cursor.and_then(|c| position_of(order, c)) {
182            Some(pos) => (pos as isize + delta).clamp(0, last as isize) as usize,
183            // Nothing focused yet: Down enters at the top, Up at the bottom.
184            None if delta < 0 => last,
185            None => 0,
186        };
187        let source = order[display];
188        if extend && matches!(mode, SelectionMode::Multi) {
189            let from = match self.anchor.and_then(|a| position_of(order, a)) {
190                Some(pos) => pos,
191                // No live anchor yet: the row this step lands on anchors the
192                // range, so further shift-steps grow from it.
193                None => {
194                    self.anchor = Some(source);
195                    display
196                }
197            };
198            self.select_span(order, from, display);
199        } else {
200            self.selected.clear();
201            self.selected.insert(source);
202            self.anchor = Some(source);
203        }
204        self.cursor = Some(source);
205        Some(display)
206    }
207
208    /// Select exactly the display range `a..=b` (either direction).
209    fn select_span(&mut self, order: &[usize], a: usize, b: usize) {
210        let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
211        self.selected.clear();
212        self.selected.extend(order[lo..=hi].iter().copied());
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn sort_cycles_asc_desc_none() {
222        let s1 = cycle_sort(None, 2);
223        assert_eq!(s1, Some((2, SortDir::Asc)));
224        let s2 = cycle_sort(s1, 2);
225        assert_eq!(s2, Some((2, SortDir::Desc)));
226        assert_eq!(cycle_sort(s2, 2), None);
227    }
228
229    #[test]
230    fn sort_switching_column_restarts_ascending() {
231        let current = Some((0, SortDir::Desc));
232        assert_eq!(cycle_sort(current, 1), Some((1, SortDir::Asc)));
233    }
234
235    #[test]
236    fn sorted_order_never_touches_the_source() {
237        let rows = vec![3, 1, 2];
238        let order = sorted_order(&rows, SortDir::Asc, &|a, b| a.cmp(b));
239        assert_eq!(order, vec![1, 2, 0]);
240        assert_eq!(rows, vec![3, 1, 2]);
241    }
242
243    #[test]
244    fn sorted_order_is_stable_in_both_directions() {
245        // Equal keys (by first tuple field) must keep source order.
246        let rows = vec![(1, "a"), (0, "b"), (1, "c"), (0, "d")];
247        let cmp = |a: &(i32, &str), b: &(i32, &str)| a.0.cmp(&b.0);
248        assert_eq!(sorted_order(&rows, SortDir::Asc, &cmp), vec![1, 3, 0, 2]);
249        assert_eq!(sorted_order(&rows, SortDir::Desc, &cmp), vec![0, 2, 1, 3]);
250    }
251
252    #[test]
253    fn single_mode_holds_one_row_and_cmd_click_deselects() {
254        let order = identity_order(4);
255        let mut sel = SelectionState::default();
256        sel.click(SelectionMode::Single, &order, 1, false, false);
257        assert_eq!(sel.selected(), vec![1]);
258        sel.click(SelectionMode::Single, &order, 3, false, false);
259        assert_eq!(sel.selected(), vec![3]);
260        sel.click(SelectionMode::Single, &order, 3, true, false);
261        assert_eq!(sel.selected(), Vec::<usize>::new());
262    }
263
264    #[test]
265    fn none_mode_ignores_clicks_and_steps() {
266        let order = identity_order(3);
267        let mut sel = SelectionState::default();
268        sel.click(SelectionMode::None, &order, 0, false, false);
269        assert!(sel.selected().is_empty());
270        assert_eq!(sel.step(SelectionMode::None, &order, 1, false), None);
271    }
272
273    #[test]
274    fn multi_mode_cmd_click_toggles() {
275        let order = identity_order(4);
276        let mut sel = SelectionState::default();
277        sel.click(SelectionMode::Multi, &order, 0, false, false);
278        sel.click(SelectionMode::Multi, &order, 2, true, false);
279        assert_eq!(sel.selected(), vec![0, 2]);
280        sel.click(SelectionMode::Multi, &order, 0, true, false);
281        assert_eq!(sel.selected(), vec![2]);
282    }
283
284    #[test]
285    fn multi_mode_shift_click_selects_a_range_from_the_anchor() {
286        let order = identity_order(6);
287        let mut sel = SelectionState::default();
288        sel.click(SelectionMode::Multi, &order, 1, false, false);
289        sel.click(SelectionMode::Multi, &order, 4, false, true);
290        assert_eq!(sel.selected(), vec![1, 2, 3, 4]);
291        // Shift again re-ranges from the same anchor, upward this time.
292        sel.click(SelectionMode::Multi, &order, 0, false, true);
293        assert_eq!(sel.selected(), vec![0, 1]);
294    }
295
296    #[test]
297    fn shift_click_first_establishes_the_anchor() {
298        let order = identity_order(6);
299        let mut sel = SelectionState::default();
300        // Fresh table: a shift-click anchors at the clicked row...
301        sel.click(SelectionMode::Multi, &order, 2, false, true);
302        assert_eq!(sel.selected(), vec![2]);
303        // ...so the next shift-click grows a range from it.
304        sel.click(SelectionMode::Multi, &order, 5, false, true);
305        assert_eq!(sel.selected(), vec![2, 3, 4, 5]);
306    }
307
308    #[test]
309    fn shift_step_first_extends_from_the_entry_row() {
310        let order = identity_order(4);
311        let mut sel = SelectionState::default();
312        // Fresh table: shift+down enters at the top and anchors there...
313        assert_eq!(sel.step(SelectionMode::Multi, &order, 1, true), Some(0));
314        assert_eq!(sel.selected(), vec![0]);
315        // ...so the next shift+down extends instead of moving the selection.
316        assert_eq!(sel.step(SelectionMode::Multi, &order, 1, true), Some(1));
317        assert_eq!(sel.selected(), vec![0, 1]);
318    }
319
320    #[test]
321    fn shift_range_follows_display_order_but_stores_source_indices() {
322        // Display order reversed: display 0 shows source 3, etc.
323        let order = vec![3, 2, 1, 0];
324        let mut sel = SelectionState::default();
325        sel.click(SelectionMode::Multi, &order, 0, false, false);
326        assert_eq!(sel.selected(), vec![3]);
327        sel.click(SelectionMode::Multi, &order, 2, false, true);
328        assert_eq!(sel.selected(), vec![1, 2, 3]);
329    }
330
331    #[test]
332    fn selection_survives_resorting() {
333        let mut sel = SelectionState::default();
334        sel.click(SelectionMode::Single, &identity_order(4), 2, false, false);
335        assert_eq!(sel.selected(), vec![2]);
336        // Order flips; source 2 is still the selected row.
337        assert!(sel.is_selected(2));
338        // Stepping down from it moves through the *new* display order.
339        let order = vec![3, 2, 1, 0]; // source 2 is at display 1
340        let display = sel.step(SelectionMode::Single, &order, 1, false);
341        assert_eq!(display, Some(2));
342        assert_eq!(sel.selected(), vec![1]);
343    }
344
345    #[test]
346    fn step_enters_at_the_edges_and_clamps() {
347        let order = identity_order(3);
348        let mut sel = SelectionState::default();
349        assert_eq!(sel.step(SelectionMode::Single, &order, 1, false), Some(0));
350        assert_eq!(sel.step(SelectionMode::Single, &order, -1, false), Some(0));
351        assert_eq!(sel.selected(), vec![0]);
352
353        let mut sel = SelectionState::default();
354        assert_eq!(sel.step(SelectionMode::Single, &order, -1, false), Some(2));
355        assert_eq!(sel.selected(), vec![2]);
356    }
357
358    #[test]
359    fn shift_step_extends_from_the_anchor() {
360        let order = identity_order(5);
361        let mut sel = SelectionState::default();
362        sel.click(SelectionMode::Multi, &order, 2, false, false);
363        sel.step(SelectionMode::Multi, &order, 1, true);
364        sel.step(SelectionMode::Multi, &order, 1, true);
365        assert_eq!(sel.selected(), vec![2, 3, 4]);
366        // Un-extending walks the range back.
367        sel.step(SelectionMode::Multi, &order, -1, true);
368        assert_eq!(sel.selected(), vec![2, 3]);
369    }
370
371    #[test]
372    fn clear_and_retain_report_changes() {
373        let order = identity_order(4);
374        let mut sel = SelectionState::default();
375        assert!(!sel.clear());
376        sel.click(SelectionMode::Multi, &order, 1, false, false);
377        sel.click(SelectionMode::Multi, &order, 3, true, false);
378        assert!(sel.retain_below(2)); // drops source 3
379        assert_eq!(sel.selected(), vec![1]);
380        assert!(!sel.retain_below(2));
381        assert!(sel.clear());
382        assert_eq!(sel.cursor(), None);
383    }
384}