feat(core): Add initial fuzzy filtering and viewport navigation.

This commit is contained in:
2026-03-13 21:56:06 -04:00
parent 9ed8e898a5
commit d62b136a64
3 changed files with 592 additions and 0 deletions

View 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
}
}

View File

@@ -0,0 +1,2 @@
pub mod filter;
pub mod navigation;

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