//! Cursor and scroll state for a list of filtered items. //! Pure logic, no rendering, no channels. The menu owns a //! [`Viewport`] and calls its methods in response to //! movement actions. /// Tracks cursor position and scroll offset within a /// filtered item list. Height comes from the frontend /// (terminal rows minus chrome). Filtered count comes /// from the filter engine. Everything else is derived. pub struct Viewport { cursor: usize, scroll_offset: usize, height: usize, filtered_count: usize, } impl Default for Viewport { fn default() -> Self { Self::new() } } impl Viewport { /// Create a viewport with everything zeroed out. Call /// [`set_height`](Self::set_height) and /// [`set_filtered_count`](Self::set_filtered_count) to /// initialize. pub fn new() -> Self { Self { cursor: 0, scroll_offset: 0, height: 0, filtered_count: 0, } } /// Current cursor position in the filtered item list. pub fn cursor(&self) -> usize { self.cursor } /// Index of the first visible item in the viewport. pub fn scroll_offset(&self) -> usize { self.scroll_offset } /// Set the viewport height (visible rows). Clamps cursor /// and scroll offset if they fall outside the new bounds. pub fn set_height(&mut self, height: usize) { self.height = height; self.clamp(); } /// Update the total number of filtered items. Resets /// cursor and scroll to the top. pub fn set_filtered_count(&mut self, count: usize) { self.filtered_count = count; self.cursor = 0; self.scroll_offset = 0; } /// Move cursor up by `n` items. Clamps to the top. /// Scrolls the viewport if the cursor leaves the visible /// range. pub fn move_up(&mut self, n: usize) { if self.cursor > 0 { self.cursor = self.cursor.saturating_sub(n); if self.cursor < self.scroll_offset { self.scroll_offset = self.cursor; } } } /// Move cursor down by `n` items. Clamps to the last /// item. Scrolls the viewport if needed. pub fn move_down(&mut self, n: usize) { if self.filtered_count > 0 { self.cursor = (self.cursor + n).min(self.filtered_count - 1); if self.height > 0 && self.cursor >= self.scroll_offset + self.height { self.scroll_offset = self.cursor - self.height + 1; } } } /// Jump cursor to the first item and reset scroll. pub fn move_to_top(&mut self) { self.cursor = 0; self.scroll_offset = 0; } /// Jump cursor to the last item and scroll to show it. pub fn move_to_bottom(&mut self) { if self.filtered_count > 0 { self.cursor = self.filtered_count - 1; if self.height > 0 && self.cursor >= self.height { self.scroll_offset = self.cursor - self.height + 1; } else { self.scroll_offset = 0; } } } /// Move cursor up by `n` pages (viewport height each). /// Clamps to the top of the list. pub fn page_up(&mut self, n: usize) { if self.height == 0 { return; } let distance = self.height.saturating_mul(n); self.cursor = self.cursor.saturating_sub(distance); if self.cursor < self.scroll_offset { self.scroll_offset = self.cursor; } } /// Move cursor up by `n` half-pages (half viewport height /// each). Clamps to the top. pub fn half_page_up(&mut self, n: usize) { if self.height == 0 { return; } let half = (self.height / 2).max(1); let distance = half.saturating_mul(n); self.cursor = self.cursor.saturating_sub(distance); if self.cursor < self.scroll_offset { self.scroll_offset = self.cursor; } } /// Move cursor down by `n` half-pages (half viewport height /// each). Clamps to the last item. pub fn half_page_down(&mut self, n: usize) { if self.height == 0 || self.filtered_count == 0 { return; } let half = (self.height / 2).max(1); let distance = half.saturating_mul(n); self.cursor = (self.cursor + distance).min(self.filtered_count - 1); if self.cursor >= self.scroll_offset + self.height { self.scroll_offset = self.cursor - self.height + 1; } } /// Move cursor down by `n` pages (viewport height each). /// Clamps to the last item. pub fn page_down(&mut self, n: usize) { if self.height == 0 || self.filtered_count == 0 { return; } let distance = self.height.saturating_mul(n); self.cursor = (self.cursor + distance).min(self.filtered_count - 1); if self.cursor >= self.scroll_offset + self.height { self.scroll_offset = self.cursor - self.height + 1; } } /// Range of indices into the filtered list that are /// currently visible. Returns `0..0` if height or count /// is zero. pub fn visible_range(&self) -> std::ops::Range { if self.height == 0 || self.filtered_count == 0 { return 0..0; } let end = (self.scroll_offset + self.height).min(self.filtered_count); self.scroll_offset..end } /// Clamp cursor and scroll offset to valid positions after /// a height or count change. fn clamp(&mut self) { if self.filtered_count == 0 { self.cursor = 0; self.scroll_offset = 0; return; } if self.cursor >= self.filtered_count { self.cursor = self.filtered_count - 1; } if self.height > 0 && self.cursor >= self.scroll_offset + self.height { self.scroll_offset = self.cursor - self.height + 1; } if self.cursor < self.scroll_offset { self.scroll_offset = self.cursor; } } } #[cfg(test)] mod tests { use super::*; fn viewport(height: usize, count: usize) -> Viewport { let mut v = Viewport::new(); v.set_height(height); v.set_filtered_count(count); v } #[test] fn initial_state() { let v = viewport(10, 20); assert_eq!(v.cursor(), 0); assert_eq!(v.scroll_offset(), 0); assert_eq!(v.visible_range(), 0..10); } #[test] fn move_down_basic() { let mut v = viewport(5, 10); v.move_down(1); assert_eq!(v.cursor(), 1); assert_eq!(v.scroll_offset(), 0); } #[test] fn move_down_scrolls() { let mut v = viewport(3, 10); for _ in 0..4 { v.move_down(1); } assert_eq!(v.cursor(), 4); assert_eq!(v.scroll_offset(), 2); assert_eq!(v.visible_range(), 2..5); } #[test] fn move_down_at_bottom() { let mut v = viewport(5, 3); v.move_down(1); v.move_down(1); v.move_down(1); // should be no-op assert_eq!(v.cursor(), 2); } #[test] fn move_up_basic() { let mut v = viewport(5, 10); v.move_down(1); v.move_down(1); v.move_up(1); assert_eq!(v.cursor(), 1); } #[test] fn move_up_at_top() { let mut v = viewport(5, 10); v.move_up(1); // no-op assert_eq!(v.cursor(), 0); } #[test] fn move_up_scrolls() { let mut v = viewport(3, 10); // Go down to trigger scroll for _ in 0..5 { v.move_down(1); } assert_eq!(v.scroll_offset(), 3); // Now go back up past scroll offset for _ in 0..3 { v.move_up(1); } assert_eq!(v.cursor(), 2); assert_eq!(v.scroll_offset(), 2); } #[test] fn move_to_top() { let mut v = viewport(5, 20); for _ in 0..10 { v.move_down(1); } v.move_to_top(); assert_eq!(v.cursor(), 0); assert_eq!(v.scroll_offset(), 0); } #[test] fn move_to_bottom() { let mut v = viewport(5, 20); v.move_to_bottom(); assert_eq!(v.cursor(), 19); assert_eq!(v.scroll_offset(), 15); } #[test] fn move_to_bottom_small_list() { let mut v = viewport(10, 3); v.move_to_bottom(); assert_eq!(v.cursor(), 2); assert_eq!(v.scroll_offset(), 0); } #[test] fn page_down() { let mut v = viewport(5, 20); v.page_down(1); assert_eq!(v.cursor(), 5); assert_eq!(v.scroll_offset(), 1); } #[test] fn page_down_near_end() { let mut v = viewport(5, 8); v.page_down(1); assert_eq!(v.cursor(), 5); v.page_down(1); assert_eq!(v.cursor(), 7); // clamped to last item } #[test] fn page_up() { let mut v = viewport(5, 20); // Go to middle for _ in 0..3 { v.page_down(1); } let cursor_before = v.cursor(); v.page_up(1); assert_eq!(v.cursor(), cursor_before - 5); } #[test] fn page_up_near_top() { let mut v = viewport(5, 20); v.page_down(1); v.page_up(1); assert_eq!(v.cursor(), 0); } #[test] fn empty_list() { let v = viewport(10, 0); assert_eq!(v.cursor(), 0); assert_eq!(v.visible_range(), 0..0); } #[test] fn empty_list_movement() { let mut v = viewport(10, 0); v.move_down(1); v.move_up(1); v.page_down(1); v.page_up(1); v.move_to_top(); v.move_to_bottom(); assert_eq!(v.cursor(), 0); } #[test] fn zero_height() { let v = viewport(0, 10); assert_eq!(v.visible_range(), 0..0); } #[test] fn height_larger_than_count() { let v = viewport(20, 5); assert_eq!(v.visible_range(), 0..5); } #[test] fn set_filtered_count_resets_cursor() { let mut v = viewport(5, 20); for _ in 0..10 { v.move_down(1); } assert_eq!(v.cursor(), 10); v.set_filtered_count(5); assert_eq!(v.cursor(), 0); assert_eq!(v.scroll_offset(), 0); } #[test] fn set_height_clamps() { let mut v = viewport(10, 20); for _ in 0..8 { v.move_down(1); } // Shrink viewport, cursor should remain visible v.set_height(3); assert!(v.cursor() < v.scroll_offset() + 3); } // -- half_page unit tests -- #[test] fn half_page_down_basic() { let mut v = viewport(10, 30); v.half_page_down(1); assert_eq!(v.cursor(), 5); assert_eq!(v.scroll_offset(), 0); } #[test] fn half_page_down_scrolls() { let mut v = viewport(10, 30); // Move cursor near viewport edge, then half page down v.half_page_down(1); // cursor 5 v.half_page_down(1); // cursor 10, should scroll assert_eq!(v.cursor(), 10); assert_eq!(v.scroll_offset(), 1); } #[test] fn half_page_down_clamps() { let mut v = viewport(10, 12); v.half_page_down(1); // cursor 5 v.half_page_down(1); // cursor 10 v.half_page_down(1); // clamps to 11 assert_eq!(v.cursor(), 11); } #[test] fn half_page_down_n_multiplier() { let mut v = viewport(10, 30); v.half_page_down(3); // (10/2)*3 = 15 assert_eq!(v.cursor(), 15); } #[test] fn half_page_up_basic() { let mut v = viewport(10, 30); v.half_page_down(2); // cursor 10 v.half_page_up(1); // cursor 5 assert_eq!(v.cursor(), 5); } #[test] fn half_page_up_clamps_at_top() { let mut v = viewport(10, 30); v.half_page_down(1); // cursor 5 v.half_page_up(1); // cursor 0 assert_eq!(v.cursor(), 0); v.half_page_up(1); // still 0 assert_eq!(v.cursor(), 0); } #[test] fn half_page_up_scrolls() { let mut v = viewport(10, 30); // Scroll down far enough that offset > 0 v.half_page_down(3); // cursor 15, offset 6 assert!(v.scroll_offset() > 0); // Now half page up should track cursor back v.half_page_up(1); // cursor 10 v.half_page_up(1); // cursor 5 assert_eq!(v.cursor(), 5); // Offset should have followed cursor if it went above assert!(v.scroll_offset() <= v.cursor()); } #[test] fn half_page_height_one() { let mut v = viewport(1, 10); // max(1/2, 1) = 1, moves 1 item v.half_page_down(1); assert_eq!(v.cursor(), 1); } #[test] fn half_page_height_two() { let mut v = viewport(2, 10); // 2/2 = 1, moves 1 item v.half_page_down(1); assert_eq!(v.cursor(), 1); } #[test] fn half_page_zero_height() { let mut v = viewport(0, 10); v.half_page_down(1); assert_eq!(v.cursor(), 0); v.half_page_up(1); assert_eq!(v.cursor(), 0); } #[test] fn half_page_empty_list() { let mut v = viewport(10, 0); v.half_page_down(1); assert_eq!(v.cursor(), 0); v.half_page_up(1); assert_eq!(v.cursor(), 0); } }