357 lines
9.4 KiB
Rust
357 lines
9.4 KiB
Rust
//! 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 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<usize> {
|
|
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);
|
|
}
|
|
}
|