Skip to main content

max / alloy_tui

5.0 KB · 166 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 {
36 None
37 } else {
38 Some(self.index)
39 }
40 }
41
42 pub const fn len(&self) -> usize {
43 self.len
44 }
45
46 pub const fn is_empty(&self) -> bool {
47 self.len == 0
48 }
49
50 /// Tell the cursor how long the list is now, clamping the selection into
51 /// range. Call this on every refresh, before rendering.
52 pub const fn resize(&mut self, len: usize) {
53 self.len = len;
54 if len == 0 {
55 self.index = 0;
56 } else if self.index >= len {
57 self.index = len - 1;
58 }
59 }
60
61 /// Move by `delta` rows, clamped at both ends.
62 pub const fn move_by(&mut self, delta: isize) {
63 if self.len == 0 {
64 return;
65 }
66 let last = self.len - 1;
67 if delta < 0 {
68 // `unsigned_abs` rather than `-delta`: negating isize::MIN
69 // overflows, and a held key that somehow accumulated one should
70 // saturate at the top like any other large step.
71 self.index = self.index.saturating_sub(delta.unsigned_abs());
72 } else {
73 // `Ord::min` is not const yet, so this is spelled out.
74 let target = self.index.saturating_add(delta as usize);
75 self.index = if target > last { last } else { target };
76 }
77 }
78
79 pub const fn next(&mut self) {
80 self.move_by(1);
81 }
82
83 pub const fn prev(&mut self) {
84 self.move_by(-1);
85 }
86 }
87
88 #[cfg(test)]
89 mod tests {
90 use super::*;
91
92 #[test]
93 fn empty_cursor_selects_nothing() {
94 let mut cursor = Cursor::new();
95 assert_eq!(cursor.selected(), None);
96 cursor.next();
97 cursor.prev();
98 assert_eq!(
99 cursor.selected(),
100 None,
101 "movement on an empty list is inert"
102 );
103 }
104
105 #[test]
106 fn clamps_at_both_ends_instead_of_wrapping() {
107 let mut cursor = Cursor::new();
108 cursor.resize(3);
109 cursor.prev();
110 assert_eq!(
111 cursor.selected(),
112 Some(0),
113 "no wrap to the end from the top"
114 );
115 cursor.move_by(99);
116 assert_eq!(
117 cursor.selected(),
118 Some(2),
119 "no wrap to the top from the end"
120 );
121 }
122
123 // The case this type exists for: a refresh returns fewer rows than the
124 // cursor is sitting on.
125 #[test]
126 fn resize_pulls_the_selection_into_range() {
127 let mut cursor = Cursor::new();
128 cursor.resize(5);
129 cursor.move_by(4);
130 cursor.resize(2);
131 assert_eq!(cursor.selected(), Some(1), "clamped to the new last row");
132 }
133
134 // A list emptying entirely must report no selection rather than row 0.
135 #[test]
136 fn resize_to_empty_clears_the_selection() {
137 let mut cursor = Cursor::new();
138 cursor.resize(4);
139 cursor.move_by(3);
140 cursor.resize(0);
141 assert_eq!(cursor.selected(), None);
142 assert!(cursor.is_empty());
143 }
144
145 // A growing list must not move the cursor; the row under it is still the
146 // row the user selected.
147 #[test]
148 fn growing_the_list_leaves_the_selection_alone() {
149 let mut cursor = Cursor::new();
150 cursor.resize(3);
151 cursor.move_by(1);
152 cursor.resize(10);
153 assert_eq!(cursor.selected(), Some(1));
154 }
155
156 // isize::MIN has no positive counterpart; negating it panics in debug.
157 #[test]
158 fn extreme_negative_delta_saturates_rather_than_overflowing() {
159 let mut cursor = Cursor::new();
160 cursor.resize(3);
161 cursor.move_by(2);
162 cursor.move_by(isize::MIN);
163 assert_eq!(cursor.selected(), Some(0));
164 }
165 }
166