Skip to main content

wisp/view/
selection.rs

1use crate::view::input::{Nav, UiEvent};
2use crossterm::event::KeyCode;
3use ratatui::layout::Rect;
4
5/// Cursor and scroll offset for a list of `len` items.
6///
7/// `len` is supplied per call rather than stored, so one state can outlive the
8/// collection it indexes: filters, reloads, and live updates all change `len`
9/// without invalidating the selection.
10///
11/// Scrolling the selection into view belongs to whoever draws the rows, because
12/// only it knows how many fit; [`ListView`](crate::view::list_view::ListView) writes
13/// the window it settled on back through [`SelectionState::set_offset`].
14#[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    /// Records the window the rows were actually drawn from, so the next frame
42    /// scrolls against it and a click hit-tests against the same rows.
43    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    /// Records where the rows were drawn, so clicks can be mapped back to an
65    /// index without every caller re-deriving the headers and borders above
66    /// them. Called from rendering.
67    pub fn set_rows_area(&mut self, area: Rect) {
68        self.rows_area = area;
69    }
70
71    /// Where the rows were last drawn, for hit-testing a click against a pane.
72    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
159/// `offset` moved the least it can to bring `row` inside a viewport `height`
160/// rows tall.
161///
162/// The panes that scroll a document rather than a [`List`](ratatui::widgets::List)
163/// keep their own offset, and all of them want this same nudge.
164pub 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
174/// Shifts `value` by `amount` rows in `direction`, stopping at zero and `max`.
175pub 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}