Skip to main content

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