Implement the desktop command palette

This commit is contained in:
2026-08-10 17:39:27 +02:00
parent 491d7b1557
commit 44e1b86f47
4 changed files with 595 additions and 25 deletions

View File

@@ -19,6 +19,7 @@ pub enum UiAction {
CopyEditedField,
Paste,
Find,
CommandPalette,
TogglePaneFocus,
Refresh,
ReloadEntry,
@@ -47,6 +48,7 @@ impl UiAction {
Self::CopyEditedField => "copy-edited-field",
Self::Paste => "paste",
Self::Find => "find",
Self::CommandPalette => "command-palette",
Self::TogglePaneFocus => "toggle-pane-focus",
Self::Refresh => "refresh",
Self::ReloadEntry => "reload-entry",
@@ -107,6 +109,7 @@ pub struct ActionContext {
pub dirty: bool,
pub saving: bool,
pub switching_vault: bool,
pub modal_open: bool,
pub focused_field: bool,
pub focused_sensitive: bool,
pub entry_path: bool,
@@ -156,6 +159,12 @@ pub const ACTIONS: &[ActionSpec] = &[
),
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
spec(
UiAction::CommandPalette,
MenuGroup::View,
"Command Palette…",
Some("⌘K"),
),
spec(
UiAction::TogglePaneFocus,
MenuGroup::View,
@@ -228,6 +237,7 @@ pub fn shortcut_label(action: UiAction) -> Option<String> {
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
match action {
UiAction::About | UiAction::Settings | UiAction::Help => true,
UiAction::CommandPalette => !context.modal_open,
// Entry creation is not valid until the dedicated workflow exists.
UiAction::NewEntry => false,
UiAction::OpenFolder => {
@@ -282,6 +292,95 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
}
}
pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'static str> {
if enabled(action, context) {
return None;
}
Some(match action {
UiAction::NewEntry => "Entry creation is not available yet",
UiAction::OpenFolder if !context.storage_ready => "Shared configuration is unavailable",
UiAction::OpenFolder if context.saving => "Wait for the active save",
UiAction::OpenFolder => "Wait for vault validation",
UiAction::OpenEntry if !context.storage_ready => "Shared configuration is unavailable",
UiAction::OpenEntry if !context.entry_path => "Enter or select an entry path",
UiAction::OpenEntry if context.saving => "Wait for the active save",
UiAction::OpenEntry => "Wait for vault validation",
UiAction::Save if !context.unlocked => "Unlock the current entry first",
UiAction::Save if !context.editing => "Open an entry editor first",
UiAction::Save if !context.dirty => "The editor has no changes",
UiAction::Save if context.saving => "A save is already running",
UiAction::Save => "Wait for vault validation",
UiAction::CloseWindow | UiAction::Quit => "Wait for vault validation",
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => {
"Use the focused native text field"
}
UiAction::CopyField | UiAction::CopyEditedField if !context.unlocked => {
"Unlock an entry first"
}
UiAction::CopyField | UiAction::CopyEditedField if !context.document_open => {
"Open an entry first"
}
UiAction::CopyField | UiAction::CopyEditedField if !context.focused_field => {
"Select a field first"
}
UiAction::CopyField | UiAction::CopyEditedField if context.switching_vault => {
"Wait for vault validation"
}
UiAction::CopyField => "Use Copy Edited Field while editing",
UiAction::CopyEditedField => "Open the entry editor first",
UiAction::Refresh if !context.storage_ready => "Shared configuration is unavailable",
UiAction::Refresh if context.tree_loading => "A refresh is already running",
UiAction::Refresh => "Wait for vault validation",
UiAction::ReloadEntry if !context.unlocked => "Unlock an entry first",
UiAction::ReloadEntry if !context.document_open => "Open an entry first",
UiAction::ReloadEntry if context.saving => "Wait for the active save",
UiAction::ReloadEntry => "Wait for vault validation",
UiAction::EditEntry if !context.unlocked => "Unlock an entry first",
UiAction::EditEntry if !context.document_open => "Open an entry first",
UiAction::EditEntry if context.editing => "The entry editor is already open",
UiAction::EditEntry => "Wait for vault validation",
UiAction::ToggleReveal if !context.unlocked => "Unlock an entry first",
UiAction::ToggleReveal if !context.document_open => "Open an entry first",
UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first",
UiAction::ToggleReveal => "Wait for vault validation",
UiAction::Lock => "The password store is already locked",
UiAction::CommandPalette => "Finish the current confirmation first",
UiAction::About
| UiAction::Settings
| UiAction::TogglePaneFocus
| UiAction::Minimize
| UiAction::Help => "Action is unavailable",
})
}
pub const fn aliases(action: UiAction) -> &'static [&'static str] {
match action {
UiAction::About => &["version", "license", "credits"],
UiAction::Settings => &["preferences", "configuration", "config"],
UiAction::NewEntry => &["insert", "add password", "create entry"],
UiAction::OpenFolder => &["open vault", "open store", "choose folder"],
UiAction::OpenEntry => &["show entry", "view password"],
UiAction::Save => &["write", "save entry"],
UiAction::CloseWindow => &["close"],
UiAction::Quit => &["exit"],
UiAction::Undo => &["revert edit"],
UiAction::Redo => &["repeat edit"],
UiAction::Cut => &["remove selection"],
UiAction::CopyField | UiAction::CopyEditedField => &["copy value", "clipboard"],
UiAction::Paste => &["insert clipboard"],
UiAction::Find => &["search text"],
UiAction::CommandPalette => &["commands", "actions", "search commands"],
UiAction::TogglePaneFocus => &["next pane", "switch pane", "focus"],
UiAction::Refresh => &["reload vault", "refresh tree"],
UiAction::ReloadEntry => &["revert entry", "refresh entry"],
UiAction::EditEntry => &["modify entry"],
UiAction::ToggleReveal => &["show password", "hide password", "reveal field"],
UiAction::Lock => &["secure", "log out", "relock"],
UiAction::Minimize => &["hide window"],
UiAction::Help => &["shortcuts", "documentation"],
}
}
pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<UiAction> {
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
return Some(UiAction::TogglePaneFocus);
@@ -305,6 +404,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O
keyboard::Key::Character("c" | "C") => Some(UiAction::CopyField),
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
keyboard::Key::Character("k" | "K") => Some(UiAction::CommandPalette),
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
@@ -328,6 +428,7 @@ mod tests {
dirty: true,
saving: false,
switching_vault: false,
modal_open: false,
focused_field: true,
focused_sensitive: true,
entry_path: true,
@@ -417,6 +518,32 @@ mod tests {
] {
assert!(!enabled(action, switching), "{action:?}");
}
for spec in ACTIONS {
if !enabled(spec.action, ready) {
assert!(
disabled_reason(spec.action, ready).is_some(),
"{:?}",
spec.action
);
}
}
assert!(!enabled(
UiAction::CommandPalette,
ActionContext {
modal_open: true,
..ready
}
));
assert_eq!(
disabled_reason(
UiAction::CommandPalette,
ActionContext {
modal_open: true,
..ready
}
),
Some("Finish the current confirmation first")
);
}
#[test]
@@ -426,6 +553,7 @@ mod tests {
("o", UiAction::OpenFolder),
("s", UiAction::Save),
("f", UiAction::Find),
("k", UiAction::CommandPalette),
("n", UiAction::NewEntry),
("w", UiAction::CloseWindow),
("q", UiAction::Quit),

View File

@@ -7,6 +7,7 @@ mod folder_picker;
#[cfg(target_os = "macos")]
mod native_menu;
mod navigation;
mod palette;
use std::{
path::PathBuf,
@@ -43,6 +44,7 @@ use ironstorage::{
write::WriteOutcome,
};
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
use palette::CommandPalette;
use zeroize::Zeroizing;
use action::{ActionContext, MenuGroup, UiAction};
@@ -58,6 +60,9 @@ enum Message {
Action(UiAction),
FieldAction(EntryFieldId, UiAction),
ToggleMenu(MenuGroup),
PaletteQueryChanged(String),
PaletteCancel,
PaletteInvoke(UiAction),
DismissUtility,
WindowResolved(UiAction, Option<window::Id>),
FolderPicked(Result<Option<PathBuf>, String>),
@@ -186,6 +191,7 @@ struct App {
status: String,
open_menu: Option<MenuGroup>,
utility: Option<UtilityView>,
palette: CommandPalette,
#[cfg(target_os = "macos")]
native_menu: Option<NativeMenu>,
}
@@ -292,6 +298,7 @@ impl App {
status: "Loading shared configuration…".to_owned(),
open_menu: None,
utility: None,
palette: CommandPalette::default(),
#[cfg(target_os = "macos")]
native_menu: None,
};
@@ -310,7 +317,16 @@ impl App {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Action(action) => return self.invoke_action(action),
Message::Action(UiAction::CommandPalette) => return self.toggle_palette(),
Message::Action(action) => {
if self.palette.is_open() {
self.palette.close();
let restore = self.restore_focus();
let action = self.invoke_action(action);
return Task::batch([restore, action]);
}
return self.invoke_action(action);
}
Message::FieldAction(id, action) => {
if let Some(editor) = self.editor.as_mut() {
editor.select(id);
@@ -320,6 +336,21 @@ impl App {
Message::ToggleMenu(group) => {
self.open_menu = (self.open_menu != Some(group)).then_some(group);
}
Message::PaletteQueryChanged(query) => {
self.palette.update_query(query);
return iced::widget::operation::snap_to(
command_palette_scroll_id(),
scrollable::RelativeOffset::START,
);
}
Message::PaletteCancel => {
self.touch_user_activity();
if self.palette.is_open() {
self.palette.close();
return self.restore_focus();
}
}
Message::PaletteInvoke(action) => return self.invoke_palette_action(action),
Message::DismissUtility => self.utility = None,
Message::WindowResolved(action, id) => {
let Some(id) = id else {
@@ -436,6 +467,33 @@ impl App {
}
Message::SidebarNavigate(key) => {
self.touch_user_activity();
if self.palette.is_open() {
return match key {
NavigationKey::Previous => {
self.palette
.move_selection(false, self.palette.results().len());
self.scroll_palette_selection()
}
NavigationKey::Next => {
self.palette
.move_selection(true, self.palette.results().len());
self.scroll_palette_selection()
}
NavigationKey::Activate => self
.palette
.selected_action()
.map_or_else(Task::none, |action| self.invoke_palette_action(action)),
NavigationKey::First => {
self.palette.select_first();
self.scroll_palette_selection()
}
NavigationKey::Last => {
self.palette.select_last(self.palette.results().len());
self.scroll_palette_selection()
}
NavigationKey::Collapse | NavigationKey::Expand => Task::none(),
};
}
match self.pane_focus {
PaneFocus::Sidebar => {
let intent = self.navigation.navigate(key);
@@ -708,6 +766,7 @@ impl App {
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
saving: self.saving,
switching_vault: self.switching_vault,
modal_open: self.confirmation.is_some() || self.generate_confirmation.is_some(),
focused_field: focused.is_some(),
focused_sensitive: focused
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
@@ -740,6 +799,7 @@ impl App {
UiAction::About => self.utility = Some(UtilityView::About),
UiAction::Settings => self.utility = Some(UtilityView::Settings),
UiAction::Help => self.utility = Some(UtilityView::Help),
UiAction::CommandPalette => return self.toggle_palette(),
UiAction::OpenFolder => {
let initial = self
.storage
@@ -778,6 +838,62 @@ impl App {
Task::none()
}
fn toggle_palette(&mut self) -> Task<Message> {
if self.palette.is_open() {
self.palette.close();
return self.restore_focus();
}
let context = self.action_context();
if let Some(reason) = action::disabled_reason(UiAction::CommandPalette, context) {
self.status = reason.to_owned();
return Task::none();
}
self.open_menu = None;
self.utility = None;
self.palette.open();
iced::widget::operation::focus(command_palette_input_id())
}
fn invoke_palette_action(&mut self, selected: UiAction) -> Task<Message> {
if !self.palette.is_open() {
return Task::none();
}
let context = self.action_context();
if let Some(reason) = action::disabled_reason(selected, context) {
self.status = format!("{}: {reason}.", action::spec_for(selected).label);
return Task::none();
}
self.palette.close();
let restore = self.restore_focus();
let action = self.invoke_action(selected);
Task::batch([restore, action])
}
fn scroll_palette_selection(&self) -> Task<Message> {
let result_count = self.palette.results().len();
let y = if result_count <= 1 {
0.0
} else {
self.palette.selected() as f32 / (result_count - 1) as f32
};
iced::widget::operation::snap_to(
command_palette_scroll_id(),
scrollable::RelativeOffset { x: 0.0, y },
)
}
fn restore_focus(&self) -> Task<Message> {
match self.pane_focus {
PaneFocus::Sidebar => iced::widget::operation::focus_next(),
PaneFocus::Content => iced::widget::operation::focus(
self.editor
.as_ref()
.and_then(EntryEditor::focused)
.map_or_else(content_focus_id, editor_field_input_id),
),
}
}
fn begin_tree_refresh(&mut self) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
return Task::none();
@@ -1088,31 +1204,46 @@ impl App {
.min_size(220)
.on_resize(8, Message::PaneResized);
container(
column![
row![
text(authentication),
text(
self.storage
.as_ref()
.map_or("No configured vault".to_owned(), |storage| {
storage.vault().display().to_string()
})
)
.size(13),
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
.spacing(16)
.padding(10),
platform_menu_bar(self),
panes,
]
.height(Length::Fill),
let shortcut = if cfg!(target_os = "macos") {
"⌘K"
} else {
"Ctrl+K"
};
let command_input = text_input(
&format!("Search commands ({shortcut})"),
self.palette.query(),
)
.width(Length::Fill)
.height(Length::Fill)
.into()
.id(command_palette_input_id())
.on_input(Message::PaletteQueryChanged)
.width(Length::Fixed(280.0));
let mut chrome = column![
row![
text(authentication),
text(
self.storage
.as_ref()
.map_or("No configured vault".to_owned(), |storage| {
storage.vault().display().to_string()
})
)
.size(13),
command_input,
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
.spacing(16)
.padding(10),
platform_menu_bar(self),
];
if self.palette.is_open() {
chrome = chrome.push(command_palette_results(self));
}
chrome = chrome.push(panes);
container(chrome.height(Length::Fill))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
@@ -1124,6 +1255,66 @@ fn viewer_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-entry-viewer")
}
fn command_palette_input_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-command-palette")
}
fn command_palette_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-command-palette-results")
}
fn content_focus_id() -> iced::widget::Id {
iced::widget::Id::new("desktop-entry-path")
}
fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id {
format!("desktop-entry-field-{}", id.value()).into()
}
fn command_palette_results(app: &App) -> Element<'_, Message> {
let context = app.action_context();
let results = app.palette.results();
if results.is_empty() {
return container(text("No matching commands"))
.padding([8, 12])
.width(Length::Fill)
.into();
}
let mut rows = column![].spacing(2).padding([4, 8]);
for (index, action) in results.into_iter().enumerate() {
let spec = action::spec_for(action);
let shortcut = action::shortcut_label(action).unwrap_or_default();
let reason = action::disabled_reason(action, context);
let availability = reason.unwrap_or("Available");
let content = row![
text(spec.label).width(Length::Fill),
text(shortcut).size(13),
text(availability).size(13).width(Length::Fixed(250.0)),
]
.spacing(12);
let item = button(content)
.width(Length::Fill)
.style(if index == app.palette.selected() {
button::primary
} else {
button::text
});
rows = rows.push(if reason.is_none() {
item.on_press(Message::PaletteInvoke(action))
} else {
item
});
}
container(
scrollable(rows)
.id(command_palette_scroll_id())
.height(Length::Fixed(240.0)),
)
.width(Length::Fill)
.into()
}
fn platform_menu_bar(app: &App) -> Element<'_, Message> {
if cfg!(target_os = "macos") {
return container(row![]).height(Length::Fixed(0.0)).into();
@@ -1318,6 +1509,7 @@ fn content_view(app: &App) -> Element<'_, Message> {
})
.size(20),
text_input("Entry path", &app.entry_path)
.id(content_focus_id())
.on_input(Message::EntryPathChanged)
.on_submit(Message::Action(UiAction::OpenEntry)),
button("Open").on_press(Message::Action(UiAction::OpenEntry)),
@@ -1549,6 +1741,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
},
value.unwrap_or(""),
)
.id(editor_field_input_id(id))
.secure(sensitive && !editor.is_revealed(id))
.on_input_maybe(
value
@@ -1696,6 +1889,9 @@ fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
fn event_message(event: &Event) -> Option<Message> {
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
if key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Escape) {
return Some(Message::PaletteCancel);
}
if let Some(action) = action::shortcut_action(key, *modifiers) {
return Some(Message::Action(action));
}
@@ -1940,6 +2136,7 @@ mod tests {
status: String::new(),
open_menu: None,
utility: None,
palette: CommandPalette::default(),
#[cfg(target_os = "macos")]
native_menu: None,
}
@@ -2195,6 +2392,64 @@ mod tests {
assert!(app.status.contains("Wait for vault validation"));
}
#[test]
fn command_palette_focus_matching_execution_cancellation_and_availability_are_shared() {
let mut app = test_app(None);
app.pane_focus = PaneFocus::Content;
let _focus = app.update(Message::Action(UiAction::CommandPalette));
assert!(app.palette.is_open());
assert_eq!(app.palette.selected(), 0);
let _task = app.update(Message::SidebarNavigate(NavigationKey::Next));
assert_eq!(app.palette.selected(), 1);
let _task = app.update(Message::SidebarNavigate(NavigationKey::Previous));
assert_eq!(app.palette.selected(), 0);
let _task = app.update(Message::PaletteQueryChanged("about".to_owned()));
assert_eq!(app.palette.selected_action(), Some(UiAction::About));
let _task = app.update(Message::SidebarNavigate(NavigationKey::Activate));
assert!(!app.palette.is_open());
assert_eq!(app.utility, Some(UtilityView::About));
assert_eq!(app.pane_focus, PaneFocus::Content);
app.utility = None;
let _task = app.update(Message::Action(UiAction::CommandPalette));
let _task = app.update(Message::PaletteQueryChanged("lock".to_owned()));
assert_eq!(app.palette.selected_action(), Some(UiAction::Lock));
let _task = app.update(Message::SidebarNavigate(NavigationKey::Activate));
assert!(app.palette.is_open());
assert!(app.status.contains("already locked"));
let _restore = app.update(Message::PaletteCancel);
assert!(!app.palette.is_open());
assert!(app.palette.query().is_empty());
assert_eq!(app.pane_focus, PaneFocus::Content);
app.confirmation = Some(PendingAction::CloseWindow(window::Id::unique()));
let _task = app.update(Message::Action(UiAction::CommandPalette));
assert!(!app.palette.is_open());
assert!(app.status.contains("Finish the current confirmation"));
app.confirmation = None;
let _task = app.update(Message::Action(UiAction::CommandPalette));
let _task = app.update(Message::PaletteInvoke(UiAction::Settings));
assert!(!app.palette.is_open());
assert_eq!(app.utility, Some(UtilityView::Settings));
let escape = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::NONE,
text: None,
repeat: false,
});
assert!(matches!(
event_message(&escape),
Some(Message::PaletteCancel)
));
}
#[test]
fn vault_switch_result_replaces_state_only_after_storage_success() {
let (_temporary, storage) = fixture_storage();

View File

@@ -125,6 +125,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
UiAction::Cut => (command, Code::KeyX),
UiAction::Paste => (command, Code::KeyV),
UiAction::Find => (command, Code::KeyF),
UiAction::CommandPalette => (command, Code::KeyK),
UiAction::Refresh => (command, Code::KeyR),
UiAction::Lock => (command, Code::KeyL),
UiAction::Help => (Modifiers::empty(), Code::F1),

186
apps/desktop/src/palette.rs Normal file
View File

@@ -0,0 +1,186 @@
//! 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(""),
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));
}
}