//! A list cursor: a bounded, clamping selection over a list whose length //! changes underneath it. //! //! Distinct from [`FocusRing`](crate::FocusRing), and deliberately so. A focus //! ring moves between a fixed set of panes and wraps, because wrapping past //! the last pane back to the first is what a user means by Tab. A list cursor //! moves over rows that appear and disappear on refresh, and it clamps: a user //! holding `j` at the bottom of a list expects to stay there, not to leap to //! the top. //! //! The clamp-on-resize case is the one worth centralizing. Every console view //! re-fetches its list and can find it shorter than it was, and a cursor left //! pointing past the end renders a selection nobody can see. /// A selection index over a list of `len` rows. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Cursor { index: usize, len: usize, } impl Cursor { /// A cursor over an empty list. Views construct this before their first /// fetch and call [`resize`](Self::resize) once the data lands. pub const fn new() -> Self { Self { index: 0, len: 0 } } /// The selected row, or `None` when the list is empty. /// /// Returning `Option` rather than a bare index is what keeps an empty list /// from reporting row 0 as selected, which renders as a selection marker /// on a row that does not exist. pub const fn selected(&self) -> Option { if self.len == 0 { None } else { Some(self.index) } } pub const fn len(&self) -> usize { self.len } pub const fn is_empty(&self) -> bool { self.len == 0 } /// Tell the cursor how long the list is now, clamping the selection into /// range. Call this on every refresh, before rendering. pub const fn resize(&mut self, len: usize) { self.len = len; if len == 0 { self.index = 0; } else if self.index >= len { self.index = len - 1; } } /// Move by `delta` rows, clamped at both ends. pub const fn move_by(&mut self, delta: isize) { if self.len == 0 { return; } let last = self.len - 1; if delta < 0 { // `unsigned_abs` rather than `-delta`: negating isize::MIN // overflows, and a held key that somehow accumulated one should // saturate at the top like any other large step. self.index = self.index.saturating_sub(delta.unsigned_abs()); } else { // `Ord::min` is not const yet, so this is spelled out. let target = self.index.saturating_add(delta as usize); self.index = if target > last { last } else { target }; } } pub const fn next(&mut self) { self.move_by(1); } pub const fn prev(&mut self) { self.move_by(-1); } } #[cfg(test)] mod tests { use super::*; #[test] fn empty_cursor_selects_nothing() { let mut cursor = Cursor::new(); assert_eq!(cursor.selected(), None); cursor.next(); cursor.prev(); assert_eq!( cursor.selected(), None, "movement on an empty list is inert" ); } #[test] fn clamps_at_both_ends_instead_of_wrapping() { let mut cursor = Cursor::new(); cursor.resize(3); cursor.prev(); assert_eq!( cursor.selected(), Some(0), "no wrap to the end from the top" ); cursor.move_by(99); assert_eq!( cursor.selected(), Some(2), "no wrap to the top from the end" ); } // The case this type exists for: a refresh returns fewer rows than the // cursor is sitting on. #[test] fn resize_pulls_the_selection_into_range() { let mut cursor = Cursor::new(); cursor.resize(5); cursor.move_by(4); cursor.resize(2); assert_eq!(cursor.selected(), Some(1), "clamped to the new last row"); } // A list emptying entirely must report no selection rather than row 0. #[test] fn resize_to_empty_clears_the_selection() { let mut cursor = Cursor::new(); cursor.resize(4); cursor.move_by(3); cursor.resize(0); assert_eq!(cursor.selected(), None); assert!(cursor.is_empty()); } // A growing list must not move the cursor; the row under it is still the // row the user selected. #[test] fn growing_the_list_leaves_the_selection_alone() { let mut cursor = Cursor::new(); cursor.resize(3); cursor.move_by(1); cursor.resize(10); assert_eq!(cursor.selected(), Some(1)); } // isize::MIN has no positive counterpart; negating it panics in debug. #[test] fn extreme_negative_delta_saturates_rather_than_overflowing() { let mut cursor = Cursor::new(); cursor.resize(3); cursor.move_by(2); cursor.move_by(isize::MIN); assert_eq!(cursor.selected(), Some(0)); } }