Skip to main content

retroglyph_ui/state/
list.rs

1/// How [`ListState::select_next`]/[`select_previous`](ListState::select_previous) behave when the
2/// selection is already at the first/last item.
3///
4/// Defaults to [`Clamp`](Self::Clamp), matching ratatui's `ListState` (`select_next`/
5/// `select_previous` `saturating_add`/clamp at the ends; wraparound is left to the caller, e.g.
6/// via `(selected + 1) % len`). Older `tui-rs`-style wraparound is available via [`Wrap`](Self::Wrap)
7/// for callers that want circular menu navigation instead.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum SelectionWrap {
11    /// Stop at the first/last item: `select_next` past the last item stays on the last item, and
12    /// `select_previous` before the first item stays on the first.
13    #[default]
14    Clamp,
15    /// Wrap around: `select_next` past the last item lands on the first, and `select_previous`
16    /// before the first item lands on the last.
17    Wrap,
18}
19
20/// Selection index and scroll offset for a selectable, scrollable list.
21///
22/// Holds no reference to the list's actual items: `len` is passed in to each selection-movement
23/// method, so `select_next`/`select_previous`/`select_first`/`select_last` and the scroll offset
24/// stay valid across lists that change size (menus, reward pools, deck views, ...). A selection
25/// set directly with [`select`](Self::select) is the exception: it is stored unchecked and can
26/// outlive a shrunk list. See that method.
27///
28/// Selection movement clamps at `len`'s ends by default; see [`SelectionWrap`] (set via
29/// [`ListState::set_wrap`]) to switch to wraparound instead. Scrolling is a separate,
30/// unbounded-above counter (clamped only at zero) since only the caller knows the content length
31/// and viewport height needed to clamp it from above.
32///
33/// `offset` is always a whole row: there's no momentum, velocity, or sub-row position here. For
34/// continuous/pixel-ish scrolling with momentum and rubber-banding (a smoothly-scrolled log or
35/// panel, not a discrete item list), reach for [`crate::state::ScrollState`] instead, and drive this
36/// type's `offset` from [`crate::state::ScrollState::integer_offset`] if a list needs both a selection
37/// cursor and smooth scrolling together.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub struct ListState {
40    selected: Option<usize>,
41    offset: usize,
42    wrap: SelectionWrap,
43}
44
45impl ListState {
46    /// An empty state: nothing selected, no scroll.
47    #[must_use]
48    pub const fn new() -> Self {
49        Self {
50            selected: None,
51            offset: 0,
52            wrap: SelectionWrap::Clamp,
53        }
54    }
55
56    /// The currently selected index, if any.
57    #[must_use]
58    pub const fn selected(&self) -> Option<usize> {
59        self.selected
60    }
61
62    /// The current scroll offset (index of the first visible item/line).
63    #[must_use]
64    pub const fn offset(&self) -> usize {
65        self.offset
66    }
67
68    /// How `select_next`/`select_previous` behave at the ends of the list. Defaults to
69    /// [`SelectionWrap::Clamp`].
70    #[must_use]
71    pub const fn wrap(&self) -> SelectionWrap {
72        self.wrap
73    }
74
75    /// Sets how `select_next`/`select_previous` behave at the ends of the list.
76    pub const fn set_wrap(&mut self, wrap: SelectionWrap) {
77        self.wrap = wrap;
78    }
79
80    /// Select an explicit index, or clear the selection with `None`.
81    ///
82    /// The index is stored verbatim and is not bounds-checked against any list length: unlike
83    /// `select_next`/`select_previous`, this does not clamp. If the list later shrinks below a
84    /// stored index, `selected()` keeps returning that now-out-of-range index (`ensure_visible`
85    /// will not fix it, since it never sees `len`). Callers indexing their items by
86    /// `selected()` must bound-check it against the current length first, or re-anchor with
87    /// `select_first`/`select_last` after the list changes size.
88    pub const fn select(&mut self, index: Option<usize>) {
89        self.selected = index;
90    }
91
92    /// Set the scroll offset directly.
93    pub const fn set_offset(&mut self, offset: usize) {
94        self.offset = offset;
95    }
96
97    /// Clear both the selection and the scroll offset, e.g. after the
98    /// underlying list has been replaced with different content.
99    pub const fn reset(&mut self) {
100        self.selected = None;
101        self.offset = 0;
102    }
103
104    /// Nudge the scroll offset by the minimum amount needed to bring
105    /// `selected` into the `visible_height`-row window starting at `offset`.
106    ///
107    /// A no-op if nothing is selected, `visible_height` is zero, or the
108    /// selection is already visible. Call this once per frame before
109    /// rendering (with the actual, current viewport height, since that can
110    /// change on terminal resize) rather than only after moving the
111    /// selection: it's cheap and idempotent, so redoing it every frame
112    /// costs nothing and needs no special-casing for resize.
113    pub const fn ensure_visible(&mut self, visible_height: usize) {
114        let Some(selected) = self.selected else {
115            return;
116        };
117        if visible_height == 0 {
118            return;
119        }
120        if selected < self.offset {
121            self.offset = selected;
122        } else if selected >= self.offset.saturating_add(visible_height) {
123            self.offset = selected.saturating_add(1).saturating_sub(visible_height);
124        }
125    }
126
127    /// Move the scroll offset by `delta`, clamped at zero. There is no upper
128    /// clamp here: only the caller knows the content length and viewport
129    /// height needed to bound it from above.
130    pub fn scroll_by(&mut self, delta: i32) {
131        let next = i64::from(delta).saturating_add(i64::try_from(self.offset).unwrap_or(i64::MAX));
132        self.offset = next.max(0).try_into().unwrap_or(usize::MAX);
133    }
134
135    /// Select the next item. Past the last item, clamps (stays on the last item) or wraps to the
136    /// first, per [`wrap()`](Self::wrap). Selects index 0 if nothing was selected yet. No-op
137    /// (clears the selection) if `len` is zero.
138    pub fn select_next(&mut self, len: usize) {
139        self.selected = Self::stepped(self.selected, 1, len, self.wrap);
140    }
141
142    /// Select the previous item. Before the first item, clamps (stays on the first item) or
143    /// wraps to the last, per [`wrap()`](Self::wrap). Selects the last item if nothing was
144    /// selected yet. No-op (clears the selection) if `len` is zero.
145    pub fn select_previous(&mut self, len: usize) {
146        self.selected = Self::stepped(self.selected, -1, len, self.wrap);
147    }
148
149    /// Select the first item, or clear the selection if `len` is zero.
150    pub fn select_first(&mut self, len: usize) {
151        self.selected = (len > 0).then_some(0);
152    }
153
154    /// Select the last item, or clear the selection if `len` is zero.
155    pub fn select_last(&mut self, len: usize) {
156        self.selected = (len > 0).then(|| len - 1);
157    }
158
159    /// Shared step math for `select_next`/`select_previous`. `delta` is `1` or `-1`; a missing
160    /// selection picks the end opposite the direction of travel (so the first press lands
161    /// somewhere sensible) independent of `mode`, since there's no prior index to clamp or wrap
162    /// from yet.
163    fn stepped(
164        current: Option<usize>,
165        delta: i32,
166        len: usize,
167        mode: SelectionWrap,
168    ) -> Option<usize> {
169        if len == 0 {
170            return None;
171        }
172        let Some(i) = current else {
173            return Some(if delta > 0 { 0 } else { len - 1 });
174        };
175        let Ok(len) = i32::try_from(len) else {
176            return current; // absurdly large len; leave selection alone
177        };
178        let next = i32::try_from(i).unwrap_or(0) + delta;
179        let idx = match mode {
180            SelectionWrap::Wrap => next.rem_euclid(len),
181            SelectionWrap::Clamp => next.clamp(0, len - 1),
182        };
183        usize::try_from(idx).ok()
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn starts_empty() {
193        let s = ListState::new();
194        assert_eq!(s.selected(), None);
195        assert_eq!(s.offset(), 0);
196    }
197
198    #[test]
199    fn next_from_none_selects_first() {
200        let mut s = ListState::new();
201        s.select_next(3);
202        assert_eq!(s.selected(), Some(0));
203    }
204
205    #[test]
206    fn previous_from_none_selects_last() {
207        let mut s = ListState::new();
208        s.select_previous(3);
209        assert_eq!(s.selected(), Some(2));
210    }
211
212    #[test]
213    fn next_clamps_at_the_end_by_default() {
214        let mut s = ListState::new();
215        assert_eq!(s.wrap(), SelectionWrap::Clamp);
216        s.select(Some(2));
217        s.select_next(3);
218        assert_eq!(s.selected(), Some(2)); // stays on the last item, does not wrap to 0
219    }
220
221    #[test]
222    fn previous_clamps_at_the_start_by_default() {
223        let mut s = ListState::new();
224        s.select(Some(0));
225        s.select_previous(3);
226        assert_eq!(s.selected(), Some(0)); // stays on the first item, does not wrap to 2
227    }
228
229    #[test]
230    fn next_wraps_past_the_end_when_wrap_is_set() {
231        let mut s = ListState::new();
232        s.set_wrap(SelectionWrap::Wrap);
233        s.select(Some(2));
234        s.select_next(3);
235        assert_eq!(s.selected(), Some(0));
236    }
237
238    #[test]
239    fn previous_wraps_past_the_start_when_wrap_is_set() {
240        let mut s = ListState::new();
241        s.set_wrap(SelectionWrap::Wrap);
242        s.select(Some(0));
243        s.select_previous(3);
244        assert_eq!(s.selected(), Some(2));
245    }
246
247    #[test]
248    fn zero_length_clears_selection() {
249        let mut s = ListState::new();
250        s.select(Some(0));
251        s.select_next(0);
252        assert_eq!(s.selected(), None);
253        s.select(Some(0));
254        s.select_previous(0);
255        assert_eq!(s.selected(), None);
256    }
257
258    #[test]
259    fn select_first_and_last() {
260        let mut s = ListState::new();
261        s.select_last(5);
262        assert_eq!(s.selected(), Some(4));
263        s.select_first(5);
264        assert_eq!(s.selected(), Some(0));
265        s.select_first(0);
266        assert_eq!(s.selected(), None);
267    }
268
269    #[test]
270    fn ensure_visible_is_a_no_op_when_already_in_view() {
271        let mut s = ListState::new();
272        s.select(Some(3));
273        s.set_offset(2);
274        s.ensure_visible(5); // window is [2, 7); 3 is inside it
275        assert_eq!(s.offset(), 2);
276    }
277
278    #[test]
279    fn ensure_visible_scrolls_down_to_reveal_a_later_selection() {
280        let mut s = ListState::new();
281        s.select(Some(10));
282        s.set_offset(0);
283        s.ensure_visible(4); // window is [0, 4); 10 is below it
284        assert_eq!(s.offset(), 7); // [7, 11) puts 10 as the last visible row
285        assert!(s.offset() <= 10 && 10 < s.offset() + 4);
286    }
287
288    #[test]
289    fn ensure_visible_scrolls_up_to_reveal_an_earlier_selection() {
290        let mut s = ListState::new();
291        s.select(Some(1));
292        s.set_offset(5);
293        s.ensure_visible(3); // window is [5, 8); 1 is above it
294        assert_eq!(s.offset(), 1);
295    }
296
297    #[test]
298    fn ensure_visible_does_not_overflow_at_usize_max_selection() {
299        // retroglyph#729: `selected + 1 - visible_height` used to overflow on the add for a
300        // selection near `usize::MAX`.
301        let mut s = ListState::new();
302        s.select(Some(usize::MAX));
303        s.set_offset(0);
304        s.ensure_visible(4);
305        assert_eq!(s.offset(), usize::MAX - 4);
306    }
307
308    #[test]
309    fn ensure_visible_is_a_no_op_with_nothing_selected_or_zero_height() {
310        let mut s = ListState::new();
311        s.set_offset(5);
312        s.ensure_visible(10); // nothing selected
313        assert_eq!(s.offset(), 5);
314
315        s.select(Some(20));
316        s.ensure_visible(0); // zero-height viewport
317        assert_eq!(s.offset(), 5);
318    }
319
320    #[test]
321    fn reset_clears_selection_and_offset() {
322        let mut s = ListState::new();
323        s.select(Some(2));
324        s.set_offset(5);
325        s.reset();
326        assert_eq!(s.selected(), None);
327        assert_eq!(s.offset(), 0);
328    }
329
330    #[test]
331    fn scroll_by_clamps_at_zero() {
332        let mut s = ListState::new();
333        s.scroll_by(-5);
334        assert_eq!(s.offset(), 0);
335        s.scroll_by(3);
336        assert_eq!(s.offset(), 3);
337        s.scroll_by(-1);
338        assert_eq!(s.offset(), 2);
339    }
340}