use nucleo_matcher::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo_matcher::{Config, Matcher, Utf32Str};
#[derive(Debug, Clone)]
pub struct PickerItem {
pub name: String,
pub description: Option<String>,
pub data: Option<String>,
}
impl PickerItem {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
data: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[allow(dead_code)]
pub fn with_data(mut self, data: impl Into<String>) -> Self {
self.data = Some(data.into());
self
}
}
#[derive(Debug, Clone)]
pub struct FilteredItem {
pub index: usize,
pub score: i64,
pub positions: Vec<usize>,
}
pub struct PickerState {
items: Vec<PickerItem>,
filtered: Vec<FilteredItem>,
filter: String,
cursor: usize,
scroll_offset: usize,
visible_height: usize,
matcher: Matcher,
}
impl std::fmt::Debug for PickerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PickerState")
.field("items", &self.items)
.field("filtered", &self.filtered)
.field("filter", &self.filter)
.field("cursor", &self.cursor)
.field("scroll_offset", &self.scroll_offset)
.field("visible_height", &self.visible_height)
.finish_non_exhaustive()
}
}
impl Clone for PickerState {
fn clone(&self) -> Self {
Self {
items: self.items.clone(),
filtered: self.filtered.clone(),
filter: self.filter.clone(),
cursor: self.cursor,
scroll_offset: self.scroll_offset,
visible_height: self.visible_height,
matcher: Matcher::new(Config::DEFAULT),
}
}
}
impl PickerState {
pub fn new(items: Vec<PickerItem>) -> Self {
let filtered: Vec<FilteredItem> = items
.iter()
.enumerate()
.map(|(i, _)| FilteredItem {
index: i,
score: 0,
positions: Vec::new(),
})
.collect();
Self {
items,
filtered,
filter: String::new(),
cursor: 0,
scroll_offset: 0,
visible_height: 10,
matcher: Matcher::new(Config::DEFAULT),
}
}
pub fn with_visible_height(mut self, height: usize) -> Self {
self.visible_height = height;
self
}
pub fn filter(&self) -> &str {
&self.filter
}
pub fn selected(&self) -> Option<&PickerItem> {
self.filtered.get(self.cursor).map(|f| &self.items[f.index])
}
pub fn visible_items(&self) -> impl Iterator<Item = VisibleItem<'_>> {
let start = self.scroll_offset;
let end = (self.scroll_offset + self.visible_height).min(self.filtered.len());
self.filtered[start..end]
.iter()
.enumerate()
.map(move |(i, filtered)| VisibleItem {
item: &self.items[filtered.index],
is_selected: start + i == self.cursor,
positions: &filtered.positions,
})
}
pub fn has_more_above(&self) -> bool {
self.scroll_offset > 0
}
pub fn has_more_below(&self) -> bool {
self.scroll_offset + self.visible_height < self.filtered.len()
}
pub fn filtered_count(&self) -> usize {
self.filtered.len()
}
#[allow(dead_code)]
pub fn total_count(&self) -> usize {
self.items.len()
}
pub fn type_char(&mut self, c: char) {
self.filter.push(c);
self.apply_filter();
}
pub fn backspace(&mut self) {
self.filter.pop();
self.apply_filter();
}
#[allow(dead_code)]
pub fn clear_filter(&mut self) {
self.filter.clear();
self.apply_filter();
}
pub fn move_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.ensure_cursor_visible();
}
}
pub fn move_down(&mut self) {
if self.cursor + 1 < self.filtered.len() {
self.cursor += 1;
self.ensure_cursor_visible();
}
}
fn apply_filter(&mut self) {
if self.filter.is_empty() {
self.filtered = self
.items
.iter()
.enumerate()
.map(|(i, _)| FilteredItem {
index: i,
score: 0,
positions: Vec::new(),
})
.collect();
} else {
let pattern = fuzzy_pattern(&self.filter);
let mut haystack_buf = Vec::new();
let filtered = self
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
let name_match =
fuzzy_indices(&mut self.matcher, &mut haystack_buf, &item.name, &pattern);
let desc_match = item.description.as_ref().and_then(|d| {
fuzzy_match(&mut self.matcher, &mut haystack_buf, d, &pattern)
});
match (name_match, desc_match) {
(Some((name_score, positions)), Some(desc_score)) => Some(FilteredItem {
index: i,
score: name_score.max(desc_score),
positions,
}),
(Some((score, positions)), None) => Some(FilteredItem {
index: i,
score,
positions,
}),
(None, Some(score)) => Some(FilteredItem {
index: i,
score,
positions: Vec::new(),
}),
(None, None) => None,
}
})
.collect();
self.filtered = filtered;
self.filtered
.sort_by_key(|item| std::cmp::Reverse(item.score));
}
self.cursor = 0;
self.scroll_offset = 0;
}
fn ensure_cursor_visible(&mut self) {
if self.cursor < self.scroll_offset {
self.scroll_offset = self.cursor;
} else if self.cursor >= self.scroll_offset + self.visible_height {
self.scroll_offset = self.cursor.saturating_sub(self.visible_height - 1);
}
}
}
fn fuzzy_pattern(needle: &str) -> Atom {
Atom::new(
needle,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
false,
)
}
fn fuzzy_match(
matcher: &mut Matcher,
haystack_buf: &mut Vec<char>,
haystack: &str,
pattern: &Atom,
) -> Option<i64> {
pattern
.score(Utf32Str::new(haystack, haystack_buf), matcher)
.map(i64::from)
}
fn fuzzy_indices(
matcher: &mut Matcher,
haystack_buf: &mut Vec<char>,
haystack: &str,
pattern: &Atom,
) -> Option<(i64, Vec<usize>)> {
let mut indices = Vec::new();
pattern
.indices(Utf32Str::new(haystack, haystack_buf), matcher, &mut indices)
.map(|score| {
indices.sort_unstable();
indices.dedup();
(
i64::from(score),
indices.into_iter().map(|index| index as usize).collect(),
)
})
}
#[derive(Debug)]
pub struct VisibleItem<'a> {
pub item: &'a PickerItem,
pub is_selected: bool,
pub positions: &'a [usize],
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_picker_basic() {
let items = vec![
PickerItem::new("node").with_description("Node.js runtime"),
PickerItem::new("python").with_description("Python interpreter"),
PickerItem::new("ruby").with_description("Ruby interpreter"),
];
let picker = PickerState::new(items);
assert_eq!(picker.filtered_count(), 3);
assert_eq!(picker.selected().unwrap().name, "node");
}
#[test]
fn test_picker_filter() {
let items = vec![
PickerItem::new("node"),
PickerItem::new("python"),
PickerItem::new("ruby"),
PickerItem::new("nodenv"),
];
let mut picker = PickerState::new(items);
picker.type_char('n');
picker.type_char('o');
picker.type_char('d');
assert_eq!(picker.filtered_count(), 2);
let selected = picker.selected().unwrap();
assert!(selected.name == "node" || selected.name == "nodenv");
}
#[test]
fn test_picker_navigation() {
let items = vec![
PickerItem::new("a"),
PickerItem::new("b"),
PickerItem::new("c"),
];
let mut picker = PickerState::new(items);
assert_eq!(picker.selected().unwrap().name, "a");
picker.move_down();
assert_eq!(picker.selected().unwrap().name, "b");
picker.move_down();
assert_eq!(picker.selected().unwrap().name, "c");
picker.move_down(); assert_eq!(picker.selected().unwrap().name, "c");
picker.move_up();
assert_eq!(picker.selected().unwrap().name, "b");
}
#[test]
fn test_picker_backspace() {
let items = vec![PickerItem::new("node"), PickerItem::new("python")];
let mut picker = PickerState::new(items);
picker.type_char('p');
picker.type_char('y');
assert_eq!(picker.filtered_count(), 1);
picker.backspace();
picker.backspace();
assert_eq!(picker.filtered_count(), 2);
}
}