Files
pikl/crates/pikl-core/src/query/navigation.rs

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);
}
}