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