1use crate::view::input::{Nav, UiEvent};
2use crossterm::event::KeyCode;
3use ratatui::layout::Rect;
4
5#[derive(Clone, Debug, Default, Eq, PartialEq)]
15pub struct SelectionState {
16 selected: Option<usize>,
17 offset: usize,
18 rows_area: Rect,
19 clamped: bool,
20}
21
22impl SelectionState {
23 pub fn new(len: usize) -> Self {
24 let mut state = Self::default();
25 state.select_first(len);
26 state
27 }
28
29 pub fn clamped(self) -> Self {
30 Self { clamped: true, ..self }
31 }
32
33 pub fn selected(&self) -> Option<usize> {
34 self.selected
35 }
36
37 pub fn offset(&self) -> usize {
38 self.offset
39 }
40
41 pub fn set_offset(&mut self, offset: usize) {
44 self.offset = offset;
45 }
46
47 pub fn select(&mut self, selected: Option<usize>, len: usize) {
48 self.selected = selected.filter(|index| *index < len);
49 self.clamp(len);
50 if selected.is_none() {
51 self.selected = None;
52 }
53 }
54
55 pub fn select_first(&mut self, len: usize) {
56 self.selected = (len > 0).then_some(0);
57 self.clamp(len);
58 }
59
60 pub fn select_row(&mut self, visible_row: usize, len: usize) {
61 self.select(self.offset().checked_add(visible_row), len);
62 }
63
64 pub fn set_rows_area(&mut self, area: Rect) {
68 self.rows_area = area;
69 }
70
71 pub fn rows_area(&self) -> Rect {
73 self.rows_area
74 }
75
76 pub fn select_at(&mut self, row: u16, len: usize, selectable: impl Fn(usize) -> bool) -> bool {
77 let rows_area = self.rows_area();
78 if row < rows_area.y || row >= rows_area.bottom() {
79 return false;
80 }
81 let previous = self.selected;
82 self.select_row(usize::from(row - rows_area.y), len);
83 let hit = self.selected.is_some_and(selectable);
84 if !hit {
85 self.select(previous, len);
86 }
87 hit
88 }
89
90 pub fn step(&mut self, len: usize, direction: Direction, selectable: impl Fn(usize) -> bool) {
91 if self.clamped
92 && let Some(current) = self.selected.filter(|index| *index < len)
93 {
94 let next = match direction {
95 Direction::Backward => (0..current).rev().find(|&index| selectable(index)),
96 Direction::Forward => (current + 1..len).find(|&index| selectable(index)),
97 };
98 if let Some(next) = next {
99 self.selected = Some(next);
100 }
101 return;
102 }
103 if len == 0 {
104 self.selected = None;
105 return;
106 }
107 let mut index = self.selected().unwrap_or_default().min(len - 1);
108 for _ in 0..len {
109 index = match direction {
110 Direction::Backward => index.checked_sub(1).unwrap_or(len - 1),
111 Direction::Forward => (index + 1) % len,
112 };
113 if selectable(index) {
114 self.selected = Some(index);
115 return;
116 }
117 }
118 }
119
120 pub(crate) fn on_nav_event(&mut self, event: &UiEvent, len: usize, selectable: impl Fn(usize) -> bool) -> Nav {
121 let direction = match event {
122 UiEvent::Key(key) => match key.code {
123 KeyCode::Esc => return Nav::Close,
124 KeyCode::Enter => return Nav::Activate,
125 KeyCode::Up => Direction::Backward,
126 KeyCode::Down => Direction::Forward,
127 _ => return Nav::Unhandled,
128 },
129 UiEvent::Paste(_) => return Nav::Unhandled,
130 UiEvent::Mouse(action, (_, row)) => match action.direction() {
131 Some(direction) => direction,
132 None if self.select_at(*row, len, selectable) => return Nav::Clicked,
133 None => return Nav::Unhandled,
134 },
135 };
136 self.step(len, direction, selectable);
137 Nav::Moved
138 }
139
140 pub fn clamp(&mut self, len: usize) {
141 if len == 0 {
142 self.selected = None;
143 self.offset = 0;
144 } else if self.selected().is_none_or(|selected| selected >= len) {
145 self.selected = Some(len - 1);
146 }
147 if self.offset >= len {
148 self.offset = len.saturating_sub(1);
149 }
150 }
151}
152
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub enum Direction {
155 Backward,
156 Forward,
157}
158
159pub fn scroll_into_view(offset: usize, row: usize, height: usize) -> usize {
165 if row < offset {
166 row
167 } else if height > 0 && row >= offset + height {
168 row + 1 - height
169 } else {
170 offset
171 }
172}
173
174pub fn step_clamped(value: usize, direction: Direction, amount: usize, max: usize) -> usize {
176 match direction {
177 Direction::Backward => value.saturating_sub(amount),
178 Direction::Forward => value.saturating_add(amount).min(max),
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::{Direction, SelectionState, scroll_into_view};
185
186 #[test]
187 fn scroll_into_view_moves_the_least_it_can() {
188 assert_eq!(scroll_into_view(10, 12, 5), 10, "already visible");
189 assert_eq!(scroll_into_view(10, 4, 5), 4, "above the viewport");
190 assert_eq!(scroll_into_view(10, 20, 5), 16, "below the viewport");
191 assert_eq!(scroll_into_view(10, 14, 5), 10, "the last visible row stays put");
192 assert_eq!(scroll_into_view(3, 99, 0), 3, "a zero-height viewport shows nothing to scroll to");
193 }
194
195 fn step(state: &mut SelectionState, len: usize, direction: Direction) {
196 state.step(len, direction, |_| true);
197 }
198
199 #[test]
200 fn wraps_and_handles_empty_collections() {
201 let mut state = SelectionState::new(3);
202 step(&mut state, 3, Direction::Backward);
203 assert_eq!(state.selected(), Some(2));
204 step(&mut state, 3, Direction::Forward);
205 assert_eq!(state.selected(), Some(0));
206 state.select(Some(2), 3);
207 step(&mut state, 3, Direction::Forward);
208 assert_eq!(state.selected(), Some(0));
209 state.clamp(0);
210 assert_eq!(state.selected(), None);
211 }
212
213 #[test]
214 fn visible_rows_include_list_offset() {
215 let mut state = SelectionState::new(10);
216 state.set_offset(4);
217 state.select_row(2, 10);
218 assert_eq!(state.selected(), Some(6));
219 }
220
221 #[test]
222 fn step_skips_unselectable_rows_and_wraps() {
223 let selectable = |index: usize| index % 2 == 1;
224 let mut state = SelectionState::new(5);
225 state.select(Some(1), 5);
226
227 state.step(5, Direction::Forward, selectable);
228 assert_eq!(state.selected(), Some(3));
229 state.step(5, Direction::Forward, selectable);
230 assert_eq!(state.selected(), Some(1), "wraps past the trailing unselectable row");
231 state.step(5, Direction::Backward, selectable);
232 assert_eq!(state.selected(), Some(3));
233 }
234
235 #[test]
236 fn clamped_step_stops_at_the_ends() {
237 let mut state = SelectionState::new(3).clamped();
238 state.select(Some(2), 3);
239 step(&mut state, 3, Direction::Forward);
240 assert_eq!(state.selected(), Some(2), "does not wrap past the last item");
241 state.select(Some(0), 3);
242 step(&mut state, 3, Direction::Backward);
243 assert_eq!(state.selected(), Some(0), "does not wrap past the first item");
244 }
245
246 #[test]
247 fn step_leaves_selection_untouched_when_nothing_qualifies() {
248 let mut state = SelectionState::new(4);
249 state.select(Some(2), 4);
250 state.step(4, Direction::Forward, |_| false);
251 assert_eq!(state.selected(), Some(2));
252 }
253}