From 44e1b86f47e2e93659bb0ed99e5e049e6c60303a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 17:39:27 +0200 Subject: [PATCH] Implement the desktop command palette --- apps/desktop/src/action.rs | 128 ++++++++++++++ apps/desktop/src/main.rs | 305 +++++++++++++++++++++++++++++--- apps/desktop/src/native_menu.rs | 1 + apps/desktop/src/palette.rs | 186 +++++++++++++++++++ 4 files changed, 595 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/palette.rs diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index e9584fe..f2eff1a 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -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 { 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 { 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), diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 4959cb5..c6286a4 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -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), FolderPicked(Result, String>), @@ -186,6 +191,7 @@ struct App { status: String, open_menu: Option, utility: Option, + palette: CommandPalette, #[cfg(target_os = "macos")] native_menu: Option, } @@ -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 { 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 { + 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 { + 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 { + 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 { + 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 { 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( fn event_message(event: &Event) -> Option { 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(); diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index 7da36e1..387b221 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -125,6 +125,7 @@ fn accelerator(action: UiAction) -> Option { 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), diff --git a/apps/desktop/src/palette.rs b/apps/desktop/src/palette.rs new file mode 100644 index 0000000..1bc9df0 --- /dev/null +++ b/apps/desktop/src/palette.rs @@ -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 { + 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(""), + 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)); + } +}