1use ratatui::layout::Rect;
2
3#[derive(Clone, Debug, Default, Eq, PartialEq)]
13pub struct SelectionState {
14 selected: Option<usize>,
15 offset: usize,
16 rows_area: Rect,
17}
18
19impl SelectionState {
20 pub fn new(len: usize) -> Self {
21 let mut state = Self::default();
22 state.select_first(len);
23 state
24 }
25
26 pub fn selected(&self) -> Option<usize> {
27 self.selected
28 }
29
30 pub fn offset(&self) -> usize {
31 self.offset
32 }
33
34 pub fn set_offset(&mut self, offset: usize) {
37 self.offset = offset;
38 }
39
40 pub fn select(&mut self, selected: Option<usize>, len: usize) {
41 self.selected = selected.filter(|index| *index < len);
42 self.clamp(len);
43 }
44
45 pub fn select_first(&mut self, len: usize) {
46 self.selected = (len > 0).then_some(0);
47 self.clamp(len);
48 }
49
50 pub fn select_row(&mut self, visible_row: usize, len: usize) {
51 self.select(self.offset().checked_add(visible_row), len);
52 }
53
54 pub fn set_rows_area(&mut self, area: Rect) {
58 self.rows_area = area;
59 }
60
61 pub fn rows_area(&self) -> Rect {
63 self.rows_area
64 }
65
66 pub fn select_at(&mut self, row: u16, len: usize) -> bool {
69 let rows_area = self.rows_area();
70 if row < rows_area.y || row >= rows_area.bottom() {
71 return false;
72 }
73 self.select_row(usize::from(row - rows_area.y), len);
74 self.selected().is_some()
75 }
76
77 pub fn step(&mut self, len: usize, direction: Direction, selectable: impl Fn(usize) -> bool) {
81 if len == 0 {
82 self.selected = None;
83 return;
84 }
85 let mut index = self.selected().unwrap_or_default().min(len - 1);
86 for _ in 0..len {
87 index = match direction {
88 Direction::Backward => index.checked_sub(1).unwrap_or(len - 1),
89 Direction::Forward => (index + 1) % len,
90 };
91 if selectable(index) {
92 self.selected = Some(index);
93 return;
94 }
95 }
96 }
97
98 pub fn step_clamped(&mut self, len: usize, direction: Direction, selectable: impl Fn(usize) -> bool) {
101 let Some(current) = self.selected().filter(|index| *index < len) else {
102 self.step(len, direction, selectable);
103 return;
104 };
105 let next = match direction {
106 Direction::Backward => (0..current).rev().find(|&index| selectable(index)),
107 Direction::Forward => (current + 1..len).find(|&index| selectable(index)),
108 };
109 if let Some(next) = next {
110 self.selected = Some(next);
111 }
112 }
113
114 pub fn clamp(&mut self, len: usize) {
115 if len == 0 {
116 self.selected = None;
117 self.offset = 0;
118 } else if self.selected().is_none_or(|selected| selected >= len) {
119 self.selected = Some(len - 1);
120 }
121 if self.offset >= len {
122 self.offset = len.saturating_sub(1);
123 }
124 }
125}
126
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum Direction {
129 Backward,
130 Forward,
131}
132
133pub fn scroll_into_view(offset: usize, row: usize, height: usize) -> usize {
139 if row < offset {
140 row
141 } else if height > 0 && row >= offset + height {
142 row + 1 - height
143 } else {
144 offset
145 }
146}
147
148pub fn step_clamped(value: usize, direction: Direction, amount: usize, max: usize) -> usize {
150 match direction {
151 Direction::Backward => value.saturating_sub(amount),
152 Direction::Forward => value.saturating_add(amount).min(max),
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::{Direction, SelectionState, scroll_into_view};
159
160 #[test]
161 fn scroll_into_view_moves_the_least_it_can() {
162 assert_eq!(scroll_into_view(10, 12, 5), 10, "already visible");
163 assert_eq!(scroll_into_view(10, 4, 5), 4, "above the viewport");
164 assert_eq!(scroll_into_view(10, 20, 5), 16, "below the viewport");
165 assert_eq!(scroll_into_view(10, 14, 5), 10, "the last visible row stays put");
166 assert_eq!(scroll_into_view(3, 99, 0), 3, "a zero-height viewport shows nothing to scroll to");
167 }
168
169 fn step(state: &mut SelectionState, len: usize, direction: Direction) {
170 state.step(len, direction, |_| true);
171 }
172
173 #[test]
174 fn wraps_and_handles_empty_collections() {
175 let mut state = SelectionState::new(3);
176 step(&mut state, 3, Direction::Backward);
177 assert_eq!(state.selected(), Some(2));
178 step(&mut state, 3, Direction::Forward);
179 assert_eq!(state.selected(), Some(0));
180 state.select(Some(2), 3);
181 step(&mut state, 3, Direction::Forward);
182 assert_eq!(state.selected(), Some(0));
183 state.clamp(0);
184 assert_eq!(state.selected(), None);
185 }
186
187 #[test]
188 fn visible_rows_include_list_offset() {
189 let mut state = SelectionState::new(10);
190 state.set_offset(4);
191 state.select_row(2, 10);
192 assert_eq!(state.selected(), Some(6));
193 }
194
195 #[test]
196 fn step_skips_unselectable_rows_and_wraps() {
197 let selectable = |index: usize| index % 2 == 1;
198 let mut state = SelectionState::new(5);
199 state.select(Some(1), 5);
200
201 state.step(5, Direction::Forward, selectable);
202 assert_eq!(state.selected(), Some(3));
203 state.step(5, Direction::Forward, selectable);
204 assert_eq!(state.selected(), Some(1), "wraps past the trailing unselectable row");
205 state.step(5, Direction::Backward, selectable);
206 assert_eq!(state.selected(), Some(3));
207 }
208
209 #[test]
210 fn step_clamped_stops_at_the_ends() {
211 let mut state = SelectionState::new(3);
212 state.select(Some(2), 3);
213 state.step_clamped(3, Direction::Forward, |_| true);
214 assert_eq!(state.selected(), Some(2), "does not wrap past the last item");
215 state.select(Some(0), 3);
216 state.step_clamped(3, Direction::Backward, |_| true);
217 assert_eq!(state.selected(), Some(0), "does not wrap past the first item");
218 }
219
220 #[test]
221 fn step_leaves_selection_untouched_when_nothing_qualifies() {
222 let mut state = SelectionState::new(4);
223 state.select(Some(2), 4);
224 state.step(4, Direction::Forward, |_| false);
225 assert_eq!(state.selected(), Some(2));
226 }
227}