204 lines
6.3 KiB
Rust
204 lines
6.3 KiB
Rust
//! 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<UiAction> {
|
|
matches(&self.query)
|
|
}
|
|
|
|
pub fn selected_action(&self) -> Option<UiAction> {
|
|
self.results().get(self.selected).copied()
|
|
}
|
|
}
|
|
|
|
pub fn matches(query: &str) -> Vec<UiAction> {
|
|
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::<Vec<_>>();
|
|
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<usize> {
|
|
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<usize> {
|
|
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::<Vec<_>>();
|
|
let mut previous = (0..=right.len()).collect::<Vec<_>>();
|
|
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::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|