Skip to main content

max / alloy_tui

4.8 KB · 150 lines History Blame Raw
1 //! A list cursor: a bounded, clamping selection over a list whose length
2 //! changes underneath it.
3 //!
4 //! Distinct from [`FocusRing`](crate::FocusRing), and deliberately so. A focus
5 //! ring moves between a fixed set of panes and wraps, because wrapping past
6 //! the last pane back to the first is what a user means by Tab. A list cursor
7 //! moves over rows that appear and disappear on refresh, and it clamps: a user
8 //! holding `j` at the bottom of a list expects to stay there, not to leap to
9 //! the top.
10 //!
11 //! The clamp-on-resize case is the one worth centralizing. Every console view
12 //! re-fetches its list and can find it shorter than it was, and a cursor left
13 //! pointing past the end renders a selection nobody can see.
14
15 /// A selection index over a list of `len` rows.
16 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17 pub struct Cursor {
18 index: usize,
19 len: usize,
20 }
21
22 impl Cursor {
23 /// A cursor over an empty list. Views construct this before their first
24 /// fetch and call [`resize`](Self::resize) once the data lands.
25 pub const fn new() -> Self {
26 Self { index: 0, len: 0 }
27 }
28
29 /// The selected row, or `None` when the list is empty.
30 ///
31 /// Returning `Option` rather than a bare index is what keeps an empty list
32 /// from reporting row 0 as selected, which renders as a selection marker
33 /// on a row that does not exist.
34 pub const fn selected(&self) -> Option<usize> {
35 if self.len == 0 { None } else { Some(self.index) }
36 }
37
38 pub const fn len(&self) -> usize {
39 self.len
40 }
41
42 pub const fn is_empty(&self) -> bool {
43 self.len == 0
44 }
45
46 /// Tell the cursor how long the list is now, clamping the selection into
47 /// range. Call this on every refresh, before rendering.
48 pub const fn resize(&mut self, len: usize) {
49 self.len = len;
50 if len == 0 {
51 self.index = 0;
52 } else if self.index >= len {
53 self.index = len - 1;
54 }
55 }
56
57 /// Move by `delta` rows, clamped at both ends.
58 pub const fn move_by(&mut self, delta: isize) {
59 if self.len == 0 {
60 return;
61 }
62 let last = self.len - 1;
63 if delta < 0 {
64 // `unsigned_abs` rather than `-delta`: negating isize::MIN
65 // overflows, and a held key that somehow accumulated one should
66 // saturate at the top like any other large step.
67 self.index = self.index.saturating_sub(delta.unsigned_abs());
68 } else {
69 // `Ord::min` is not const yet, so this is spelled out.
70 let target = self.index.saturating_add(delta as usize);
71 self.index = if target > last { last } else { target };
72 }
73 }
74
75 pub const fn next(&mut self) {
76 self.move_by(1);
77 }
78
79 pub const fn prev(&mut self) {
80 self.move_by(-1);
81 }
82 }
83
84 #[cfg(test)]
85 mod tests {
86 use super::*;
87
88 #[test]
89 fn empty_cursor_selects_nothing() {
90 let mut cursor = Cursor::new();
91 assert_eq!(cursor.selected(), None);
92 cursor.next();
93 cursor.prev();
94 assert_eq!(cursor.selected(), None, "movement on an empty list is inert");
95 }
96
97 #[test]
98 fn clamps_at_both_ends_instead_of_wrapping() {
99 let mut cursor = Cursor::new();
100 cursor.resize(3);
101 cursor.prev();
102 assert_eq!(cursor.selected(), Some(0), "no wrap to the end from the top");
103 cursor.move_by(99);
104 assert_eq!(cursor.selected(), Some(2), "no wrap to the top from the end");
105 }
106
107 // The case this type exists for: a refresh returns fewer rows than the
108 // cursor is sitting on.
109 #[test]
110 fn resize_pulls_the_selection_into_range() {
111 let mut cursor = Cursor::new();
112 cursor.resize(5);
113 cursor.move_by(4);
114 cursor.resize(2);
115 assert_eq!(cursor.selected(), Some(1), "clamped to the new last row");
116 }
117
118 // A list emptying entirely must report no selection rather than row 0.
119 #[test]
120 fn resize_to_empty_clears_the_selection() {
121 let mut cursor = Cursor::new();
122 cursor.resize(4);
123 cursor.move_by(3);
124 cursor.resize(0);
125 assert_eq!(cursor.selected(), None);
126 assert!(cursor.is_empty());
127 }
128
129 // A growing list must not move the cursor; the row under it is still the
130 // row the user selected.
131 #[test]
132 fn growing_the_list_leaves_the_selection_alone() {
133 let mut cursor = Cursor::new();
134 cursor.resize(3);
135 cursor.move_by(1);
136 cursor.resize(10);
137 assert_eq!(cursor.selected(), Some(1));
138 }
139
140 // isize::MIN has no positive counterpart; negating it panics in debug.
141 #[test]
142 fn extreme_negative_delta_saturates_rather_than_overflowing() {
143 let mut cursor = Cursor::new();
144 cursor.resize(3);
145 cursor.move_by(2);
146 cursor.move_by(isize::MIN);
147 assert_eq!(cursor.selected(), Some(0));
148 }
149 }
150