Skip to main content

shuyi_ui/widgets/
list.rs

1//! A cursor over a list: the focus that moves up and down `n` rows, and
2//! the window of rows a box shows when the list is longer than the box.
3//!
4//! The [`Menu`](super::Menu) keeps its focus in one; a screen with its own
5//! list (a shop, a level select, a save list) keeps one too, instead of
6//! stepping an index by hand. The cursor knows nothing of what the rows
7//! are: whether a row can take the focus is a predicate the caller passes.
8
9use core::ops::Range;
10
11/// How the window follows the focus.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum Scroll {
14    /// The window moves only when the focus would leave it, and then only
15    /// as far as it must: by one row when the focus steps past an edge.
16    #[default]
17    KeepInView,
18    /// The focus sits in the middle of the window, except near the ends of
19    /// the list, where the window stops at the first or the last row.
20    Centred,
21}
22
23/// A focus over `len` rows and the window of rows shown around it.
24///
25/// The window's place lives in the cursor, so it doesn't jump from frame
26/// to frame: ask for it with [`window`](Self::window) (rows of one height)
27/// or [`window_by_heights`](Self::window_by_heights) each time the list is
28/// shown.
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct ListCursor {
31    len: usize,
32    focus: usize,
33    /// The first row of the window the last time it was asked for.
34    first: usize,
35    scroll: Scroll,
36}
37
38impl ListCursor {
39    /// A cursor on the first of `len` rows, keeping the focus in view.
40    pub fn new(len: usize) -> Self {
41        Self {
42            len,
43            ..Self::default()
44        }
45    }
46
47    /// The same cursor, its window following the focus by `scroll`.
48    #[must_use]
49    pub fn scrolling(mut self, scroll: Scroll) -> Self {
50        self.scroll = scroll;
51        self
52    }
53
54    /// The same cursor on row `index` (the last row, if there is no such
55    /// row).
56    #[must_use]
57    pub fn at(mut self, index: usize) -> Self {
58        self.focus = index.min(self.len.saturating_sub(1));
59        self
60    }
61
62    /// The focused row. With no rows it is 0, which isn't a row.
63    pub fn focus(&self) -> usize {
64        self.focus
65    }
66
67    /// How many rows there are.
68    pub fn len(&self) -> usize {
69        self.len
70    }
71
72    /// Whether there are no rows.
73    pub fn is_empty(&self) -> bool {
74        self.len == 0
75    }
76
77    /// How the window follows the focus.
78    pub fn scroll(&self) -> Scroll {
79        self.scroll
80    }
81
82    /// The first row of the window, as the last [`window`](Self::window)
83    /// or [`window_by_heights`](Self::window_by_heights) left it.
84    pub fn first_shown(&self) -> usize {
85        self.first
86    }
87
88    /// Moves the focus to the next (`forward`) or previous row that
89    /// `enabled` accepts, going round the ends if `wrap`, else stopping at
90    /// them. Returns whether the focus moved, so the caller knows to play
91    /// its move sound. With no such row it stays put.
92    pub fn step(&mut self, forward: bool, wrap: bool, enabled: impl Fn(usize) -> bool) -> bool {
93        let (n, at) = (self.len, self.focus);
94        let next = if wrap {
95            (1..n)
96                .map(|k| {
97                    if forward {
98                        (at + k) % n
99                    } else {
100                        (at + n - k) % n
101                    }
102                })
103                .find(|&i| enabled(i))
104        } else if forward {
105            (at + 1..n).find(|&i| enabled(i))
106        } else {
107            (0..at.min(n)).rev().find(|&i| enabled(i))
108        };
109        match next {
110            Some(i) => {
111                self.focus = i;
112                true
113            }
114            None => false,
115        }
116    }
117
118    /// Moves the focus off a row `enabled` rejects: to the next row it
119    /// accepts, else the nearest one before. Returns whether it moved. For
120    /// after the rows changed (a row was used up, or removed).
121    pub fn settle(&mut self, enabled: impl Fn(usize) -> bool) -> bool {
122        if self.is_empty() || enabled(self.focus) {
123            return false;
124        }
125        self.step(true, false, &enabled) || self.step(false, false, &enabled)
126    }
127
128    /// Sets the number of rows, after some were added or removed. A focus
129    /// past the new end moves to the last row; the window is clamped the
130    /// next time it is asked for.
131    pub fn set_len(&mut self, len: usize) {
132        self.len = len;
133        self.focus = self.focus.min(len.saturating_sub(1));
134    }
135
136    /// The rows a box of `rows_shown` rows shows: always the focus, placed
137    /// by the cursor's [`Scroll`] rule, and never past the end of the list
138    /// while there are rows before it to show instead. A box of no rows
139    /// still shows the focused one. With no rows it is empty.
140    pub fn window(&mut self, rows_shown: usize) -> Range<usize> {
141        if self.is_empty() {
142            self.first = 0;
143            return 0..0;
144        }
145        let rows = rows_shown.max(1).min(self.len);
146        let last_first = self.len - rows;
147        let first = match self.scroll {
148            // As little as it takes: the focus no higher than the top row
149            // and no lower than the bottom one.
150            Scroll::KeepInView => self
151                .first
152                .clamp((self.focus + 1).saturating_sub(rows), self.focus),
153            Scroll::Centred => self.focus.saturating_sub((rows - 1) / 2),
154        };
155        self.first = first.min(last_first);
156        self.first..self.first + rows
157    }
158
159    /// The rows a box `room` tall shows when the rows have different
160    /// heights (`heights[i]` is row `i`'s, in the box's units: a backlog of
161    /// wrapped lines). As [`window`](Self::window): always the focus, even
162    /// a focused row taller than the box, placed by the [`Scroll`] rule,
163    /// and as many rows as fit. `heights` has one entry per row.
164    pub fn window_by_heights(&mut self, heights: &[usize], room: usize) -> Range<usize> {
165        if self.is_empty() {
166            self.first = 0;
167            return 0..0;
168        }
169        let (focus, len) = (self.focus, self.len);
170        // A run of rows is taller the further it reaches, so each search
171        // below takes the first row that fits.
172        let height = |r: Range<usize>| -> usize { heights[r].iter().sum() };
173        let first = match self.scroll {
174            Scroll::KeepInView => self.first.min(focus),
175            // As many rows above the focus as fill half the room left.
176            Scroll::Centred => {
177                let half = room.saturating_sub(heights[focus]) / 2;
178                (0..focus)
179                    .find(|&f| height(f..focus) <= half)
180                    .unwrap_or(focus)
181            }
182        };
183        // The focus in view: drop rows from the top until it fits.
184        let first = (first..focus)
185            .find(|&f| height(f..focus + 1) <= room)
186            .unwrap_or(focus);
187        // No empty room at the bottom while there are rows above to show.
188        let first = (0..first)
189            .find(|&f| height(f..len) <= room)
190            .unwrap_or(first);
191        // As many rows after the focus as fit.
192        let end = (focus + 1..len)
193            .find(|&e| height(first..e + 1) > room)
194            .unwrap_or(len);
195        self.first = first;
196        first..end
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use proptest::prelude::*;
204
205    fn all(_: usize) -> bool {
206        true
207    }
208
209    #[test]
210    fn stepping_wraps_or_stops_at_the_ends() {
211        let mut c = ListCursor::new(3);
212        assert!(c.step(false, true, all));
213        assert_eq!(c.focus(), 2, "wrapped to the end");
214        assert!(c.step(true, true, all));
215        assert_eq!(c.focus(), 0, "wrapped to the start");
216        assert!(!c.step(false, false, all), "stops at the start");
217        assert_eq!(c.focus(), 0);
218        let mut c = c.at(2);
219        assert!(!c.step(true, false, all), "stops at the end");
220        assert_eq!(c.focus(), 2);
221        assert!(c.step(false, false, all));
222        assert_eq!(c.focus(), 1);
223    }
224
225    #[test]
226    fn disabled_rows_are_skipped() {
227        let on = [true, false, false, true, false];
228        let enabled = |i: usize| on[i];
229        let mut c = ListCursor::new(5);
230        assert!(c.step(true, true, enabled));
231        assert_eq!(c.focus(), 3);
232        assert!(c.step(true, true, enabled));
233        assert_eq!(c.focus(), 0);
234        // Without wrap, nothing enabled after 3: it stays.
235        let mut c = c.at(3);
236        assert!(!c.step(true, false, enabled));
237        assert_eq!(c.focus(), 3);
238    }
239
240    #[test]
241    fn every_row_disabled_but_one_keeps_the_focus() {
242        let enabled = |i: usize| i == 2;
243        let mut c = ListCursor::new(5).at(2);
244        for (forward, wrap) in [(true, true), (false, true), (true, false), (false, false)] {
245            assert!(!c.step(forward, wrap, enabled));
246            assert_eq!(c.focus(), 2);
247        }
248        // From elsewhere, both ways find it.
249        let mut c = ListCursor::new(5).at(4);
250        assert!(c.step(true, true, enabled));
251        assert_eq!(c.focus(), 2);
252    }
253
254    #[test]
255    fn an_empty_list_never_moves() {
256        let mut c = ListCursor::new(0);
257        assert!(!c.step(true, true, all));
258        assert!(!c.step(false, false, all));
259        assert!(!c.settle(all));
260        assert_eq!(c.focus(), 0);
261        assert_eq!(c.window(4), 0..0);
262        assert_eq!(c.window_by_heights(&[], 4), 0..0);
263        assert!(c.is_empty(), "{c:?}");
264    }
265
266    #[test]
267    fn a_removal_at_the_end_clamps_the_focus() {
268        let mut c = ListCursor::new(6).at(5);
269        assert_eq!(c.window(3), 3..6);
270        c.set_len(4);
271        assert_eq!((c.focus(), c.len()), (3, 4));
272        assert_eq!(c.window(3), 1..4);
273        c.set_len(0);
274        assert_eq!(c.focus(), 0);
275        assert_eq!(c.window(3), 0..0);
276    }
277
278    #[test]
279    fn settle_leaves_a_disabled_row() {
280        let on = [true, false, true, false];
281        let mut c = ListCursor::new(4).at(1);
282        assert!(c.settle(|i| on[i]));
283        assert_eq!(c.focus(), 2, "the next enabled row first");
284        let mut c = c.at(3);
285        assert!(c.settle(|i| on[i]));
286        assert_eq!(c.focus(), 2, "else the one before");
287        assert!(!c.settle(|i| on[i]), "already on one");
288        assert!(!c.settle(|_| false), "nowhere to go");
289    }
290
291    #[test]
292    fn keep_in_view_moves_the_window_by_one_at_its_edge() {
293        let mut c = ListCursor::new(10);
294        assert_eq!(c.window(4), 0..4);
295        for _ in 0..3 {
296            c.step(true, true, all);
297            assert_eq!(c.window(4), 0..4, "inside: the window stays");
298        }
299        c.step(true, true, all);
300        assert_eq!(c.window(4), 1..5, "past the bottom edge: by one");
301        c.step(true, true, all);
302        assert_eq!(c.window(4), 2..6);
303        // Back up inside the window: it stays, until the top edge.
304        for _ in 0..3 {
305            c.step(false, true, all);
306            assert_eq!(c.window(4), 2..6);
307        }
308        c.step(false, true, all);
309        assert_eq!((c.focus(), c.window(4)), (1, 1..5));
310        assert_eq!(c.first_shown(), 1);
311        // Wrapping round jumps to the other end.
312        c.step(false, true, all);
313        c.step(false, true, all);
314        assert_eq!((c.focus(), c.window(4)), (9, 6..10));
315    }
316
317    #[test]
318    fn centred_keeps_the_focus_in_the_middle() {
319        let mut c = ListCursor::new(20).scrolling(Scroll::Centred);
320        assert_eq!(c.scroll(), Scroll::Centred);
321        assert_eq!(c.window(5), 0..5, "the top stops at the first row");
322        let mut c = c.at(10);
323        assert_eq!(c.window(5), 8..13);
324        c.step(true, true, all);
325        assert_eq!(c.window(5), 9..14);
326        let mut c = c.at(19);
327        assert_eq!(c.window(5), 15..20, "the bottom stops at the last row");
328        // An even box puts the focus just above the middle.
329        let mut c = c.at(10);
330        assert_eq!(c.window(6), 8..14);
331    }
332
333    #[test]
334    fn a_short_list_shows_every_row() {
335        let mut c = ListCursor::new(3).at(2);
336        assert_eq!(c.window(6), 0..3);
337        assert_eq!(c.window(0), 2..3, "a box of no rows still shows the focus");
338    }
339
340    #[test]
341    fn rows_of_different_heights() {
342        let heights = [1, 3, 2, 1, 4, 1];
343        let mut c = ListCursor::new(6);
344        assert_eq!(c.window_by_heights(&heights, 6), 0..3);
345        c = c.at(3);
346        assert_eq!(c.window_by_heights(&heights, 6), 1..4, "drops the top row");
347        c = c.at(4);
348        assert_eq!(c.window_by_heights(&heights, 6), 3..6);
349        // Back up inside: the window stays.
350        c = c.at(3);
351        assert_eq!(c.window_by_heights(&heights, 6), 3..6);
352        c = c.at(2);
353        assert_eq!(c.window_by_heights(&heights, 6), 2..4);
354        // A row taller than the box is still shown, alone.
355        c = c.at(4);
356        assert_eq!(c.window_by_heights(&heights, 3), 4..5);
357        // The whole list fits a tall box.
358        assert_eq!(c.window_by_heights(&heights, 20), 0..6);
359        // A box that grows pulls the window back up, to an exact fit.
360        let mut c = ListCursor::new(3).at(2);
361        assert_eq!(c.window_by_heights(&[1, 1, 1], 1), 2..3);
362        assert_eq!(c.window_by_heights(&[1, 1, 1], 3), 0..3);
363    }
364
365    #[test]
366    fn rows_of_different_heights_centred() {
367        let heights = [1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1];
368        let mut c = ListCursor::new(11).scrolling(Scroll::Centred).at(5);
369        // Room 7, the focus 3 tall: 2 above, 2 below.
370        assert_eq!(c.window_by_heights(&heights, 7), 3..8);
371        let mut c = c.at(0);
372        assert_eq!(c.window_by_heights(&heights, 7), 0..5);
373    }
374
375    proptest! {
376        #[test]
377        fn the_focus_is_in_the_window_and_on_an_enabled_row(
378            on in prop::collection::vec(any::<bool>(), 0..30),
379            heights in prop::collection::vec(1usize..5, 30),
380            moves in prop::collection::vec((any::<bool>(), any::<bool>()), 0..40),
381            rows in 0usize..10,
382            room in 0usize..15,
383            centred in any::<bool>(),
384            removed in 0usize..30,
385        ) {
386            let enabled = |i: usize| on[i];
387            let scroll = if centred { Scroll::Centred } else { Scroll::KeepInView };
388            let mut c = ListCursor::new(on.len()).scrolling(scroll);
389            c.settle(enabled);
390            let check = |c: &mut ListCursor| -> Result<(), TestCaseError> {
391                if c.is_empty() {
392                    return Ok(());
393                }
394                prop_assert!(c.focus() < c.len());
395                let w = c.window(rows);
396                prop_assert!(w.contains(&c.focus()));
397                prop_assert!(w.len() <= rows.max(1));
398                let w = c.window_by_heights(&heights[..c.len()], room);
399                prop_assert!(w.contains(&c.focus()));
400                if on[..c.len()].iter().any(|&e| e) {
401                    prop_assert!(on[c.focus()]);
402                }
403                Ok(())
404            };
405            check(&mut c)?;
406            for (forward, wrap) in moves {
407                c.step(forward, wrap, enabled);
408                check(&mut c)?;
409            }
410            c.set_len(on.len().saturating_sub(removed));
411            c.settle(enabled);
412            check(&mut c)?;
413        }
414    }
415}