Implement the desktop command palette
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,8 +1204,19 @@ impl App {
|
||||
.min_size(220)
|
||||
.on_resize(8, Message::PaneResized);
|
||||
|
||||
container(
|
||||
column![
|
||||
let shortcut = if cfg!(target_os = "macos") {
|
||||
"⌘K"
|
||||
} else {
|
||||
"Ctrl+K"
|
||||
};
|
||||
let command_input = text_input(
|
||||
&format!("Search commands ({shortcut})"),
|
||||
self.palette.query(),
|
||||
)
|
||||
.id(command_palette_input_id())
|
||||
.on_input(Message::PaletteQueryChanged)
|
||||
.width(Length::Fixed(280.0));
|
||||
let mut chrome = column![
|
||||
row![
|
||||
text(authentication),
|
||||
text(
|
||||
@@ -1100,16 +1227,20 @@ impl App {
|
||||
})
|
||||
)
|
||||
.size(13),
|
||||
command_input,
|
||||
text(&self.status).size(14),
|
||||
text("Tab changes pane focus").size(12),
|
||||
]
|
||||
.spacing(16)
|
||||
.padding(10),
|
||||
platform_menu_bar(self),
|
||||
panes,
|
||||
]
|
||||
.height(Length::Fill),
|
||||
)
|
||||
];
|
||||
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();
|
||||
|
||||
@@ -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
186
apps/desktop/src/palette.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user