pub struct ListFilter {
pub query: String,
pub cursor_chars: usize,
pub matched_indices: Vec<usize>,
pub selected: Option<usize>,
pub input_active: bool,
}
impl Default for ListFilter {
fn default() -> Self {
Self::new()
}
}
impl ListFilter {
pub fn new() -> Self {
Self {
query: String::new(),
cursor_chars: 0,
matched_indices: Vec::new(),
selected: None,
input_active: true,
}
}
pub fn apply<T>(&mut self, items: &[T], matches: impl Fn(&T, &str) -> bool) {
let query_lower = self.query.to_lowercase();
self.matched_indices = if query_lower.is_empty() {
(0..items.len()).collect()
} else {
items
.iter()
.enumerate()
.filter(|(_, item)| matches(item, &query_lower))
.map(|(i, _)| i)
.collect()
};
}
pub fn sync_selection(&mut self) -> Option<usize> {
self.selected = if self.matched_indices.is_empty() {
None
} else {
Some(
self.selected
.unwrap_or(0)
.min(self.matched_indices.len() - 1),
)
};
self.selected.map(|s| self.matched_indices[s])
}
pub fn insert_char(&mut self, c: char) {
let byte_pos = self
.query
.char_indices()
.nth(self.cursor_chars)
.map(|(i, _)| i)
.unwrap_or(self.query.len());
self.query.insert(byte_pos, c);
self.cursor_chars += 1;
}
pub fn delete_char(&mut self) {
if self.cursor_chars == 0 {
return;
}
self.cursor_chars -= 1;
let byte_pos = self
.query
.char_indices()
.nth(self.cursor_chars)
.map(|(i, _)| i)
.unwrap_or(self.query.len());
let next_byte = self
.query
.char_indices()
.nth(self.cursor_chars + 1)
.map(|(i, _)| i)
.unwrap_or(self.query.len());
self.query.replace_range(byte_pos..next_byte, "");
}
pub fn clear_query(&mut self) {
self.query.clear();
self.cursor_chars = 0;
}
pub fn navigate_up(&mut self) -> Option<usize> {
if let Some(sel) = self.selected {
if sel > 0 {
self.selected = Some(sel - 1);
}
}
self.selected.map(|s| self.matched_indices[s])
}
pub fn navigate_down(&mut self) -> Option<usize> {
if let Some(sel) = self.selected {
if sel + 1 < self.matched_indices.len() {
self.selected = Some(sel + 1);
}
}
self.selected.map(|s| self.matched_indices[s])
}
pub fn current_original_index(&self) -> Option<usize> {
self.selected.map(|s| self.matched_indices[s])
}
pub fn has_query(&self) -> bool {
!self.query.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_filter_is_input_active() {
let filter = ListFilter::new();
assert!(filter.input_active);
assert!(filter.query.is_empty());
assert_eq!(filter.cursor_chars, 0);
assert!(filter.matched_indices.is_empty());
assert_eq!(filter.selected, None);
}
#[test]
fn test_apply_empty_query_returns_all() {
let items = vec!["foo", "bar", "baz"];
let mut filter = ListFilter::new();
filter.apply(&items, |item, _q| item.contains(_q));
assert_eq!(filter.matched_indices, vec![0, 1, 2]);
}
#[test]
fn test_apply_filters_correctly() {
let items = vec!["hello", "world", "help", "test"];
let mut filter = ListFilter::new();
filter.query = "hel".to_string();
filter.apply(&items, |item, q| item.to_lowercase().contains(q));
assert_eq!(filter.matched_indices, vec![0, 2]);
}
#[test]
fn test_apply_case_insensitive() {
let items = vec!["Hello", "WORLD", "help"];
let mut filter = ListFilter::new();
filter.query = "HEL".to_string();
filter.apply(&items, |item, q| item.to_lowercase().contains(q));
assert_eq!(filter.matched_indices, vec![0, 2]);
}
#[test]
fn test_apply_no_matches() {
let items = vec!["foo", "bar"];
let mut filter = ListFilter::new();
filter.query = "xyz".to_string();
filter.apply(&items, |item, q| item.to_lowercase().contains(q));
assert!(filter.matched_indices.is_empty());
}
#[test]
fn test_sync_selection_with_matches() {
let mut filter = ListFilter::new();
filter.matched_indices = vec![2, 5, 8];
filter.selected = None;
let idx = filter.sync_selection();
assert_eq!(filter.selected, Some(0));
assert_eq!(idx, Some(2));
}
#[test]
fn test_sync_selection_clamps() {
let mut filter = ListFilter::new();
filter.matched_indices = vec![1, 3];
filter.selected = Some(10);
let idx = filter.sync_selection();
assert_eq!(filter.selected, Some(1)); assert_eq!(idx, Some(3));
}
#[test]
fn test_sync_selection_empty() {
let mut filter = ListFilter::new();
filter.matched_indices = vec![];
filter.selected = Some(0);
let idx = filter.sync_selection();
assert_eq!(filter.selected, None);
assert_eq!(idx, None);
}
#[test]
fn test_insert_char() {
let mut filter = ListFilter::new();
filter.insert_char('a');
filter.insert_char('b');
assert_eq!(filter.query, "ab");
assert_eq!(filter.cursor_chars, 2);
}
#[test]
fn test_insert_char_unicode() {
let mut filter = ListFilter::new();
filter.insert_char('日');
filter.insert_char('本');
assert_eq!(filter.query, "日本");
assert_eq!(filter.cursor_chars, 2);
}
#[test]
fn test_delete_char() {
let mut filter = ListFilter::new();
filter.query = "abc".to_string();
filter.cursor_chars = 3;
filter.delete_char();
assert_eq!(filter.query, "ab");
assert_eq!(filter.cursor_chars, 2);
}
#[test]
fn test_delete_char_at_start() {
let mut filter = ListFilter::new();
filter.query = "abc".to_string();
filter.cursor_chars = 0;
filter.delete_char();
assert_eq!(filter.query, "abc"); assert_eq!(filter.cursor_chars, 0);
}
#[test]
fn test_delete_char_unicode() {
let mut filter = ListFilter::new();
filter.query = "日本語".to_string();
filter.cursor_chars = 3;
filter.delete_char();
assert_eq!(filter.query, "日本");
assert_eq!(filter.cursor_chars, 2);
}
#[test]
fn test_clear_query() {
let mut filter = ListFilter::new();
filter.query = "hello".to_string();
filter.cursor_chars = 3;
filter.clear_query();
assert!(filter.query.is_empty());
assert_eq!(filter.cursor_chars, 0);
}
#[test]
fn test_navigate_down() {
let mut filter = ListFilter::new();
filter.matched_indices = vec![0, 2, 4];
filter.selected = Some(0);
assert_eq!(filter.navigate_down(), Some(2));
assert_eq!(filter.selected, Some(1));
assert_eq!(filter.navigate_down(), Some(4));
assert_eq!(filter.selected, Some(2));
assert_eq!(filter.navigate_down(), Some(4));
assert_eq!(filter.selected, Some(2));
}
#[test]
fn test_navigate_up() {
let mut filter = ListFilter::new();
filter.matched_indices = vec![0, 2, 4];
filter.selected = Some(2);
assert_eq!(filter.navigate_up(), Some(2));
assert_eq!(filter.selected, Some(1));
assert_eq!(filter.navigate_up(), Some(0));
assert_eq!(filter.selected, Some(0));
assert_eq!(filter.navigate_up(), Some(0));
assert_eq!(filter.selected, Some(0));
}
#[test]
fn test_navigate_with_no_selection() {
let mut filter = ListFilter::new();
filter.matched_indices = vec![];
filter.selected = None;
assert_eq!(filter.navigate_down(), None);
assert_eq!(filter.navigate_up(), None);
}
#[test]
fn test_has_query() {
let mut filter = ListFilter::new();
assert!(!filter.has_query());
filter.query = "x".to_string();
assert!(filter.has_query());
}
#[test]
fn test_full_workflow() {
let items = vec!["alpha", "beta", "gamma", "delta"];
let mut filter = ListFilter::new();
filter.insert_char('a');
filter.apply(&items, |item, q| item.to_lowercase().contains(q));
let idx = filter.sync_selection();
assert_eq!(filter.matched_indices, vec![0, 1, 2, 3]); assert_eq!(idx, Some(0));
let idx = filter.navigate_down();
assert_eq!(idx, Some(1));
filter.insert_char('l');
filter.apply(&items, |item, q| item.to_lowercase().contains(q));
let idx = filter.sync_selection();
assert_eq!(filter.matched_indices, vec![0]); assert_eq!(filter.selected, Some(0)); assert_eq!(idx, Some(0));
}
}