feat(core): Add initial fuzzy filtering and viewport navigation.
This commit is contained in:
234
crates/pikl-core/src/query/filter.rs
Normal file
234
crates/pikl-core/src/query/filter.rs
Normal file
@@ -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<usize>;
|
||||
}
|
||||
|
||||
/// 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<Item = (usize, &'a Utf32String)>,
|
||||
) -> 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<usize> {
|
||||
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<usize> = (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<usize> = (0..f.matched_count())
|
||||
.filter_map(|i| f.matched_index(i))
|
||||
.collect();
|
||||
assert!(indices.contains(&1)); // banana
|
||||
}
|
||||
}
|
||||
2
crates/pikl-core/src/query/mod.rs
Normal file
2
crates/pikl-core/src/query/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod filter;
|
||||
pub mod navigation;
|
||||
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