Files
IronStorage/apps/desktop/src/palette.rs

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