diff --git a/crates/pikl-core/src/query/filter.rs b/crates/pikl-core/src/query/filter.rs new file mode 100644 index 0000000..55bf042 --- /dev/null +++ b/crates/pikl-core/src/query/filter.rs @@ -0,0 +1,234 @@ +//! Item filtering. Currently just fuzzy matching via nucleo, +//! but the [`Filter`] trait is here so we can swap in regex, +//! exact, or custom strategies later. + +use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32String}; + +/// Trait for incremental filter strategies. Items are pushed +/// in once, queries are updated, and results are read back +/// by position. Implementations own their item text and +/// match state. +pub trait Filter: Send { + /// Notify the filter about a new item. Called once per + /// item at insertion time. + fn push(&mut self, index: usize, label: &str); + + /// Update the query. Implementations may defer actual + /// matching until results are read. + fn set_query(&mut self, query: &str); + + /// Number of items matching the current query. + fn matched_count(&self) -> usize; + + /// Get the original item index for the nth match + /// (sorted by relevance, best first). + fn matched_index(&self, match_position: usize) -> Option; +} + +/// Fuzzy matching powered by nucleo. Smart case, smart +/// unicode normalization. Results sorted by score, best +/// match first. Supports incremental narrowing: if the new +/// query extends the previous one, only items that matched +/// before are re-scored. +pub struct FuzzyFilter { + matcher: Matcher, + items: Vec<(usize, Utf32String)>, + last_query: String, + results: Vec<(usize, u32)>, +} + +impl Default for FuzzyFilter { + fn default() -> Self { + Self::new() + } +} + +impl FuzzyFilter { + /// Create a new fuzzy filter with default nucleo config. + pub fn new() -> Self { + Self { + matcher: Matcher::new(Config::DEFAULT), + items: Vec::new(), + last_query: String::new(), + results: Vec::new(), + } + } + + /// Re-score a set of (index, haystack) pairs against a pattern. + fn score_items<'a>( + matcher: &mut Matcher, + pattern: &Pattern, + candidates: impl Iterator, + ) -> Vec<(usize, u32)> { + let mut matches: Vec<(usize, u32)> = candidates + .filter_map(|(idx, haystack)| { + pattern + .score(haystack.slice(..), matcher) + .map(|score| (idx, score)) + }) + .collect(); + matches.sort_by(|a, b| b.1.cmp(&a.1)); + matches + } +} + +impl Filter for FuzzyFilter { + fn push(&mut self, index: usize, label: &str) { + debug_assert_eq!( + index, + self.items.len(), + "FuzzyFilter::push requires sequential indices starting from 0" + ); + let haystack = Utf32String::from(label); + self.items.push((index, haystack)); + } + + fn set_query(&mut self, query: &str) { + if query.is_empty() { + // Empty query matches everything in insertion order. + self.results = self.items.iter().map(|(idx, _)| (*idx, 0)).collect(); + self.last_query = String::new(); + return; + } + + let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart); + + // Incremental: if the new query extends the previous one, + // only re-score items that already matched. + if !self.last_query.is_empty() && query.starts_with(&self.last_query) { + let prev_results = std::mem::take(&mut self.results); + let candidates = prev_results.into_iter().filter_map(|(idx, _)| { + // Items are pushed sequentially (enforced by debug_assert in push), + // so idx == position in self.items. Direct index is O(1). + self.items.get(idx).map(|(_, h)| (idx, h)) + }); + self.results = Self::score_items(&mut self.matcher, &pattern, candidates); + } else { + let candidates = self.items.iter().map(|(idx, h)| (*idx, h)); + self.results = Self::score_items(&mut self.matcher, &pattern, candidates); + } + + self.last_query = query.to_string(); + } + + fn matched_count(&self) -> usize { + self.results.len() + } + + fn matched_index(&self, match_position: usize) -> Option { + self.results.get(match_position).map(|(idx, _)| *idx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_text_items(f: &mut FuzzyFilter, labels: &[&str]) { + for (i, label) in labels.iter().enumerate() { + f.push(i, label); + } + } + + #[test] + fn empty_query_returns_all() { + let mut f = FuzzyFilter::new(); + push_text_items(&mut f, &["apple", "banana", "cherry"]); + f.set_query(""); + assert_eq!(f.matched_count(), 3); + assert_eq!(f.matched_index(0), Some(0)); + assert_eq!(f.matched_index(1), Some(1)); + assert_eq!(f.matched_index(2), Some(2)); + } + + #[test] + fn fuzzy_match() { + let mut f = FuzzyFilter::new(); + push_text_items(&mut f, &["apple", "banana", "apricot"]); + f.set_query("ap"); + assert!(f.matched_count() >= 2); + let indices: Vec = (0..f.matched_count()) + .filter_map(|i| f.matched_index(i)) + .collect(); + assert!(indices.contains(&0)); // apple + assert!(indices.contains(&2)); // apricot + } + + #[test] + fn no_matches() { + let mut f = FuzzyFilter::new(); + push_text_items(&mut f, &["apple", "banana"]); + f.set_query("xyz"); + assert_eq!(f.matched_count(), 0); + } + + #[test] + fn empty_items() { + let mut f = FuzzyFilter::new(); + f.set_query("test"); + assert_eq!(f.matched_count(), 0); + } + + #[test] + fn incremental_narrowing() { + let mut f = FuzzyFilter::new(); + push_text_items(&mut f, &["apple", "banana", "apricot", "avocado"]); + + f.set_query("a"); + let count_a = f.matched_count(); + assert!(count_a >= 3); // apple, apricot, avocado at minimum + + // Extending the query should narrow results + f.set_query("ap"); + assert!(f.matched_count() <= count_a); + assert!(f.matched_count() >= 2); // apple, apricot + } + + #[test] + fn incremental_narrowing_large_set() { + let mut f = FuzzyFilter::new(); + // Push 1000 items, all starting with "item-" + for i in 0..1000 { + f.push(i, &format!("item-{i:04}")); + } + + f.set_query("item"); + let count_broad = f.matched_count(); + assert_eq!(count_broad, 1000); + + // Incremental narrowing: "item-00" should match ~10 items + f.set_query("item-00"); + assert!(f.matched_count() < count_broad); + assert!(f.matched_count() >= 10); + + // Further narrowing + f.set_query("item-001"); + assert!(f.matched_count() >= 1); + } + + #[test] + #[should_panic(expected = "sequential indices")] + fn non_sequential_push_panics_in_debug() { + let mut f = FuzzyFilter::new(); + f.push(0, "first"); + f.push(5, "non-sequential"); // should panic in debug + } + + #[test] + fn non_incremental_new_query() { + let mut f = FuzzyFilter::new(); + push_text_items(&mut f, &["apple", "banana", "cherry"]); + + f.set_query("ap"); + assert!(f.matched_count() >= 1); + + // Completely different query, not incremental + f.set_query("ban"); + assert!(f.matched_count() >= 1); + let indices: Vec = (0..f.matched_count()) + .filter_map(|i| f.matched_index(i)) + .collect(); + assert!(indices.contains(&1)); // banana + } +} diff --git a/crates/pikl-core/src/query/mod.rs b/crates/pikl-core/src/query/mod.rs new file mode 100644 index 0000000..68b94eb --- /dev/null +++ b/crates/pikl-core/src/query/mod.rs @@ -0,0 +1,2 @@ +pub mod filter; +pub mod navigation; diff --git a/crates/pikl-core/src/query/navigation.rs b/crates/pikl-core/src/query/navigation.rs new file mode 100644 index 0000000..8bbeb7f --- /dev/null +++ b/crates/pikl-core/src/query/navigation.rs @@ -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 { + 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); + } +}