//! Deterministic fuzzy matching over the shared desktop action registry. use crate::action::{self, UiAction}; #[derive(Debug, Default)] pub struct CommandPalette { open: bool, query: String, selected: usize, } impl CommandPalette { pub fn is_open(&self) -> bool { self.open } pub fn query(&self) -> &str { &self.query } pub fn selected(&self) -> usize { self.selected } pub fn open(&mut self) { self.open = true; self.query.clear(); self.selected = 0; } pub fn close(&mut self) { self.open = false; self.query.clear(); self.selected = 0; } pub fn update_query(&mut self, query: String) { self.open = true; self.query = query; self.selected = 0; } pub fn move_selection(&mut self, forward: bool, result_count: usize) { if result_count == 0 { self.selected = 0; } else if forward { self.selected = (self.selected + 1) % result_count; } else { self.selected = self.selected.checked_sub(1).unwrap_or(result_count - 1); } } pub fn select_first(&mut self) { self.selected = 0; } pub fn select_last(&mut self, result_count: usize) { self.selected = result_count.saturating_sub(1); } pub fn results(&self) -> Vec { matches(&self.query) } pub fn selected_action(&self) -> Option { self.results().get(self.selected).copied() } } pub fn matches(query: &str) -> Vec { let query = normalize(query); let mut matches = action::ACTIONS .iter() .enumerate() .filter(|(_, spec)| spec.action != UiAction::CommandPalette) .filter_map(|(order, spec)| { std::iter::once(spec.label) .chain(std::iter::once(spec.action.id())) .chain(action::aliases(spec.action).iter().copied()) .filter_map(|candidate| fuzzy_score(&query, &normalize(candidate))) .min() .map(|score| (score, order, spec.action)) }) .collect::>(); matches.sort_by_key(|(score, order, _)| (*score, *order)); matches.into_iter().map(|(_, _, action)| action).collect() } fn normalize(value: &str) -> String { value .chars() .flat_map(char::to_lowercase) .filter(|character| character.is_alphanumeric()) .collect() } fn fuzzy_score(query: &str, candidate: &str) -> Option { if query.is_empty() { return Some(0); } if query == candidate { return Some(0); } if candidate.starts_with(query) { return Some(10 + candidate.len().saturating_sub(query.len())); } if let Some(index) = candidate.find(query) { return Some(30 + index + candidate.len().saturating_sub(query.len())); } if let Some(gaps) = subsequence_gaps(query, candidate) { return Some(60 + gaps); } let distance = edit_distance(query, candidate); let tolerance = query.chars().count().div_ceil(3).clamp(1, 3); (distance <= tolerance).then_some(100 + distance * 10 + candidate.len()) } fn subsequence_gaps(query: &str, candidate: &str) -> Option { let mut positions = candidate.char_indices(); let mut previous = None; let mut gaps = 0; for expected in query.chars() { let (position, _) = positions.find(|(_, actual)| *actual == expected)?; if let Some(previous) = previous { gaps += position.saturating_sub(previous + 1); } previous = Some(position); } Some(gaps + candidate.len().saturating_sub(query.len())) } fn edit_distance(left: &str, right: &str) -> usize { let right = right.chars().collect::>(); let mut previous = (0..=right.len()).collect::>(); for (left_index, left_character) in left.chars().enumerate() { let mut current = Vec::with_capacity(right.len() + 1); current.push(left_index + 1); for (right_index, right_character) in right.iter().enumerate() { current.push( (previous[right_index + 1] + 1) .min(current[right_index] + 1) .min(previous[right_index] + usize::from(left_character != *right_character)), ); } previous = current; } previous[right.len()] } #[cfg(test)] mod tests { use super::*; #[test] fn matching_handles_misspellings_subsequences_aliases_and_stable_order() { assert_eq!(matches("setings").first(), Some(&UiAction::Settings)); assert_eq!(matches("rfsh").first(), Some(&UiAction::Refresh)); assert_eq!(matches("open vault").first(), Some(&UiAction::OpenFolder)); assert_eq!( matches("pass init").first(), Some(&UiAction::InitializeStore) ); assert_eq!(matches("create folder").first(), Some(&UiAction::NewFolder)); assert_eq!(matches("insert").first(), Some(&UiAction::NewEntry)); assert_eq!( matches("generate").first(), Some(&UiAction::GeneratePassword) ); assert_eq!(matches("grep").first(), Some(&UiAction::SearchContents)); assert_eq!(matches("rename").first(), Some(&UiAction::MoveEntry)); assert_eq!( matches("duplicate entry").first(), Some(&UiAction::CopyEntry) ); assert_eq!(matches("rm").first(), Some(&UiAction::DeleteEntry)); assert_eq!( matches(""), action::ACTIONS .iter() .filter_map(|spec| { (spec.action != UiAction::CommandPalette).then_some(spec.action) }) .collect::>() ); } #[test] fn selection_wraps_and_query_changes_reset_it() { let mut palette = CommandPalette::default(); palette.open(); palette.move_selection(false, 3); assert_eq!(palette.selected(), 2); palette.move_selection(true, 3); assert_eq!(palette.selected(), 0); palette.select_last(3); assert_eq!(palette.selected(), 2); palette.select_first(); assert_eq!(palette.selected(), 0); palette.update_query("lock".to_owned()); assert_eq!(palette.selected(), 0); assert_eq!(palette.selected_action(), Some(UiAction::Lock)); } }