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}