feat(core): Add initial fuzzy filtering and viewport navigation.
This commit is contained in:
356
crates/pikl-core/src/query/navigation.rs
Normal file
356
crates/pikl-core/src/query/navigation.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user