Skip to main content

wisp/view/
selection.rs

1use ratatui::layout::Rect;
2
3/// Cursor and scroll offset for a list of `len` items.
4///
5/// `len` is supplied per call rather than stored, so one state can outlive the
6/// collection it indexes: filters, reloads, and live updates all change `len`
7/// without invalidating the selection.
8///
9/// Scrolling the selection into view belongs to whoever draws the rows, because
10/// only it knows how many fit; [`ListView`](crate::view::list_view::ListView) writes
11/// the window it settled on back through [`SelectionState::set_offset`].
12#[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    /// Records the window the rows were actually drawn from, so the next frame
35    /// scrolls against it and a click hit-tests against the same rows.
36    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    /// Records where the rows were drawn, so clicks can be mapped back to an
55    /// index without every caller re-deriving the headers and borders above
56    /// them. Called from rendering.
57    pub fn set_rows_area(&mut self, area: Rect) {
58        self.rows_area = area;
59    }
60
61    /// Where the rows were last drawn, for hit-testing a click against a pane.
62    pub fn rows_area(&self) -> Rect {
63        self.rows_area
64    }
65
66    /// Selects the row drawn at terminal `row`, reporting whether one was hit.
67    /// Rows outside the last drawn area leave the selection alone.
68    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    /// Wrapping move to the nearest index in `direction` for which `selectable`
78    /// holds. Leaves the selection untouched when no index qualifies, so panes
79    /// whose rows are a mix of headers and entries never land on a header.
80    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    /// Like [`Self::step`], but stops at the ends instead of wrapping. Suits
99    /// long lists that are scanned rather than cycled.
100    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
133/// `offset` moved the least it can to bring `row` inside a viewport `height`
134/// rows tall.
135///
136/// The panes that scroll a document rather than a [`List`](ratatui::widgets::List)
137/// keep their own offset, and all of them want this same nudge.
138pub 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
148/// Shifts `value` by `amount` rows in `direction`, stopping at zero and `max`.
149pub 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}