Implement the desktop command palette
This commit is contained in:
@@ -19,6 +19,7 @@ pub enum UiAction {
|
|||||||
CopyEditedField,
|
CopyEditedField,
|
||||||
Paste,
|
Paste,
|
||||||
Find,
|
Find,
|
||||||
|
CommandPalette,
|
||||||
TogglePaneFocus,
|
TogglePaneFocus,
|
||||||
Refresh,
|
Refresh,
|
||||||
ReloadEntry,
|
ReloadEntry,
|
||||||
@@ -47,6 +48,7 @@ impl UiAction {
|
|||||||
Self::CopyEditedField => "copy-edited-field",
|
Self::CopyEditedField => "copy-edited-field",
|
||||||
Self::Paste => "paste",
|
Self::Paste => "paste",
|
||||||
Self::Find => "find",
|
Self::Find => "find",
|
||||||
|
Self::CommandPalette => "command-palette",
|
||||||
Self::TogglePaneFocus => "toggle-pane-focus",
|
Self::TogglePaneFocus => "toggle-pane-focus",
|
||||||
Self::Refresh => "refresh",
|
Self::Refresh => "refresh",
|
||||||
Self::ReloadEntry => "reload-entry",
|
Self::ReloadEntry => "reload-entry",
|
||||||
@@ -107,6 +109,7 @@ pub struct ActionContext {
|
|||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
pub saving: bool,
|
pub saving: bool,
|
||||||
pub switching_vault: bool,
|
pub switching_vault: bool,
|
||||||
|
pub modal_open: bool,
|
||||||
pub focused_field: bool,
|
pub focused_field: bool,
|
||||||
pub focused_sensitive: bool,
|
pub focused_sensitive: bool,
|
||||||
pub entry_path: bool,
|
pub entry_path: bool,
|
||||||
@@ -156,6 +159,12 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
),
|
),
|
||||||
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
|
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
|
||||||
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
|
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
|
||||||
|
spec(
|
||||||
|
UiAction::CommandPalette,
|
||||||
|
MenuGroup::View,
|
||||||
|
"Command Palette…",
|
||||||
|
Some("⌘K"),
|
||||||
|
),
|
||||||
spec(
|
spec(
|
||||||
UiAction::TogglePaneFocus,
|
UiAction::TogglePaneFocus,
|
||||||
MenuGroup::View,
|
MenuGroup::View,
|
||||||
@@ -228,6 +237,7 @@ pub fn shortcut_label(action: UiAction) -> Option<String> {
|
|||||||
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
||||||
match action {
|
match action {
|
||||||
UiAction::About | UiAction::Settings | UiAction::Help => true,
|
UiAction::About | UiAction::Settings | UiAction::Help => true,
|
||||||
|
UiAction::CommandPalette => !context.modal_open,
|
||||||
// Entry creation is not valid until the dedicated workflow exists.
|
// Entry creation is not valid until the dedicated workflow exists.
|
||||||
UiAction::NewEntry => false,
|
UiAction::NewEntry => false,
|
||||||
UiAction::OpenFolder => {
|
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> {
|
pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<UiAction> {
|
||||||
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
|
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
|
||||||
return Some(UiAction::TogglePaneFocus);
|
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("c" | "C") => Some(UiAction::CopyField),
|
||||||
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
|
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
|
||||||
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
|
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("r" | "R") => Some(UiAction::Refresh),
|
||||||
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
|
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
|
||||||
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
|
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
|
||||||
@@ -328,6 +428,7 @@ mod tests {
|
|||||||
dirty: true,
|
dirty: true,
|
||||||
saving: false,
|
saving: false,
|
||||||
switching_vault: false,
|
switching_vault: false,
|
||||||
|
modal_open: false,
|
||||||
focused_field: true,
|
focused_field: true,
|
||||||
focused_sensitive: true,
|
focused_sensitive: true,
|
||||||
entry_path: true,
|
entry_path: true,
|
||||||
@@ -417,6 +518,32 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
assert!(!enabled(action, switching), "{action:?}");
|
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]
|
#[test]
|
||||||
@@ -426,6 +553,7 @@ mod tests {
|
|||||||
("o", UiAction::OpenFolder),
|
("o", UiAction::OpenFolder),
|
||||||
("s", UiAction::Save),
|
("s", UiAction::Save),
|
||||||
("f", UiAction::Find),
|
("f", UiAction::Find),
|
||||||
|
("k", UiAction::CommandPalette),
|
||||||
("n", UiAction::NewEntry),
|
("n", UiAction::NewEntry),
|
||||||
("w", UiAction::CloseWindow),
|
("w", UiAction::CloseWindow),
|
||||||
("q", UiAction::Quit),
|
("q", UiAction::Quit),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod folder_picker;
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod native_menu;
|
mod native_menu;
|
||||||
mod navigation;
|
mod navigation;
|
||||||
|
mod palette;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
@@ -43,6 +44,7 @@ use ironstorage::{
|
|||||||
write::WriteOutcome,
|
write::WriteOutcome,
|
||||||
};
|
};
|
||||||
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
|
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
|
||||||
|
use palette::CommandPalette;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use action::{ActionContext, MenuGroup, UiAction};
|
use action::{ActionContext, MenuGroup, UiAction};
|
||||||
@@ -58,6 +60,9 @@ enum Message {
|
|||||||
Action(UiAction),
|
Action(UiAction),
|
||||||
FieldAction(EntryFieldId, UiAction),
|
FieldAction(EntryFieldId, UiAction),
|
||||||
ToggleMenu(MenuGroup),
|
ToggleMenu(MenuGroup),
|
||||||
|
PaletteQueryChanged(String),
|
||||||
|
PaletteCancel,
|
||||||
|
PaletteInvoke(UiAction),
|
||||||
DismissUtility,
|
DismissUtility,
|
||||||
WindowResolved(UiAction, Option<window::Id>),
|
WindowResolved(UiAction, Option<window::Id>),
|
||||||
FolderPicked(Result<Option<PathBuf>, String>),
|
FolderPicked(Result<Option<PathBuf>, String>),
|
||||||
@@ -186,6 +191,7 @@ struct App {
|
|||||||
status: String,
|
status: String,
|
||||||
open_menu: Option<MenuGroup>,
|
open_menu: Option<MenuGroup>,
|
||||||
utility: Option<UtilityView>,
|
utility: Option<UtilityView>,
|
||||||
|
palette: CommandPalette,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
native_menu: Option<NativeMenu>,
|
native_menu: Option<NativeMenu>,
|
||||||
}
|
}
|
||||||
@@ -292,6 +298,7 @@ impl App {
|
|||||||
status: "Loading shared configuration…".to_owned(),
|
status: "Loading shared configuration…".to_owned(),
|
||||||
open_menu: None,
|
open_menu: None,
|
||||||
utility: None,
|
utility: None,
|
||||||
|
palette: CommandPalette::default(),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
native_menu: None,
|
native_menu: None,
|
||||||
};
|
};
|
||||||
@@ -310,7 +317,16 @@ impl App {
|
|||||||
|
|
||||||
fn update(&mut self, message: Message) -> Task<Message> {
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
match 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) => {
|
Message::FieldAction(id, action) => {
|
||||||
if let Some(editor) = self.editor.as_mut() {
|
if let Some(editor) = self.editor.as_mut() {
|
||||||
editor.select(id);
|
editor.select(id);
|
||||||
@@ -320,6 +336,21 @@ impl App {
|
|||||||
Message::ToggleMenu(group) => {
|
Message::ToggleMenu(group) => {
|
||||||
self.open_menu = (self.open_menu != Some(group)).then_some(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::DismissUtility => self.utility = None,
|
||||||
Message::WindowResolved(action, id) => {
|
Message::WindowResolved(action, id) => {
|
||||||
let Some(id) = id else {
|
let Some(id) = id else {
|
||||||
@@ -436,6 +467,33 @@ impl App {
|
|||||||
}
|
}
|
||||||
Message::SidebarNavigate(key) => {
|
Message::SidebarNavigate(key) => {
|
||||||
self.touch_user_activity();
|
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 {
|
match self.pane_focus {
|
||||||
PaneFocus::Sidebar => {
|
PaneFocus::Sidebar => {
|
||||||
let intent = self.navigation.navigate(key);
|
let intent = self.navigation.navigate(key);
|
||||||
@@ -708,6 +766,7 @@ impl App {
|
|||||||
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
|
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
|
||||||
saving: self.saving,
|
saving: self.saving,
|
||||||
switching_vault: self.switching_vault,
|
switching_vault: self.switching_vault,
|
||||||
|
modal_open: self.confirmation.is_some() || self.generate_confirmation.is_some(),
|
||||||
focused_field: focused.is_some(),
|
focused_field: focused.is_some(),
|
||||||
focused_sensitive: focused
|
focused_sensitive: focused
|
||||||
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
|
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
|
||||||
@@ -740,6 +799,7 @@ impl App {
|
|||||||
UiAction::About => self.utility = Some(UtilityView::About),
|
UiAction::About => self.utility = Some(UtilityView::About),
|
||||||
UiAction::Settings => self.utility = Some(UtilityView::Settings),
|
UiAction::Settings => self.utility = Some(UtilityView::Settings),
|
||||||
UiAction::Help => self.utility = Some(UtilityView::Help),
|
UiAction::Help => self.utility = Some(UtilityView::Help),
|
||||||
|
UiAction::CommandPalette => return self.toggle_palette(),
|
||||||
UiAction::OpenFolder => {
|
UiAction::OpenFolder => {
|
||||||
let initial = self
|
let initial = self
|
||||||
.storage
|
.storage
|
||||||
@@ -778,6 +838,62 @@ impl App {
|
|||||||
Task::none()
|
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> {
|
fn begin_tree_refresh(&mut self) -> Task<Message> {
|
||||||
let Some(storage) = self.storage.clone() else {
|
let Some(storage) = self.storage.clone() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -1088,8 +1204,19 @@ impl App {
|
|||||||
.min_size(220)
|
.min_size(220)
|
||||||
.on_resize(8, Message::PaneResized);
|
.on_resize(8, Message::PaneResized);
|
||||||
|
|
||||||
container(
|
let shortcut = if cfg!(target_os = "macos") {
|
||||||
column![
|
"⌘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![
|
row![
|
||||||
text(authentication),
|
text(authentication),
|
||||||
text(
|
text(
|
||||||
@@ -1100,16 +1227,20 @@ impl App {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.size(13),
|
.size(13),
|
||||||
|
command_input,
|
||||||
text(&self.status).size(14),
|
text(&self.status).size(14),
|
||||||
text("Tab changes pane focus").size(12),
|
text("Tab changes pane focus").size(12),
|
||||||
]
|
]
|
||||||
.spacing(16)
|
.spacing(16)
|
||||||
.padding(10),
|
.padding(10),
|
||||||
platform_menu_bar(self),
|
platform_menu_bar(self),
|
||||||
panes,
|
];
|
||||||
]
|
if self.palette.is_open() {
|
||||||
.height(Length::Fill),
|
chrome = chrome.push(command_palette_results(self));
|
||||||
)
|
}
|
||||||
|
chrome = chrome.push(panes);
|
||||||
|
|
||||||
|
container(chrome.height(Length::Fill))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
@@ -1124,6 +1255,66 @@ fn viewer_scroll_id() -> iced::widget::Id {
|
|||||||
iced::widget::Id::new("desktop-entry-viewer")
|
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> {
|
fn platform_menu_bar(app: &App) -> Element<'_, Message> {
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
return container(row![]).height(Length::Fixed(0.0)).into();
|
return container(row![]).height(Length::Fixed(0.0)).into();
|
||||||
@@ -1318,6 +1509,7 @@ fn content_view(app: &App) -> Element<'_, Message> {
|
|||||||
})
|
})
|
||||||
.size(20),
|
.size(20),
|
||||||
text_input("Entry path", &app.entry_path)
|
text_input("Entry path", &app.entry_path)
|
||||||
|
.id(content_focus_id())
|
||||||
.on_input(Message::EntryPathChanged)
|
.on_input(Message::EntryPathChanged)
|
||||||
.on_submit(Message::Action(UiAction::OpenEntry)),
|
.on_submit(Message::Action(UiAction::OpenEntry)),
|
||||||
button("Open").on_press(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(""),
|
value.unwrap_or(""),
|
||||||
)
|
)
|
||||||
|
.id(editor_field_input_id(id))
|
||||||
.secure(sensitive && !editor.is_revealed(id))
|
.secure(sensitive && !editor.is_revealed(id))
|
||||||
.on_input_maybe(
|
.on_input_maybe(
|
||||||
value
|
value
|
||||||
@@ -1696,6 +1889,9 @@ fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
|
|||||||
|
|
||||||
fn event_message(event: &Event) -> Option<Message> {
|
fn event_message(event: &Event) -> Option<Message> {
|
||||||
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
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) {
|
if let Some(action) = action::shortcut_action(key, *modifiers) {
|
||||||
return Some(Message::Action(action));
|
return Some(Message::Action(action));
|
||||||
}
|
}
|
||||||
@@ -1940,6 +2136,7 @@ mod tests {
|
|||||||
status: String::new(),
|
status: String::new(),
|
||||||
open_menu: None,
|
open_menu: None,
|
||||||
utility: None,
|
utility: None,
|
||||||
|
palette: CommandPalette::default(),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
native_menu: None,
|
native_menu: None,
|
||||||
}
|
}
|
||||||
@@ -2195,6 +2392,64 @@ mod tests {
|
|||||||
assert!(app.status.contains("Wait for vault validation"));
|
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]
|
#[test]
|
||||||
fn vault_switch_result_replaces_state_only_after_storage_success() {
|
fn vault_switch_result_replaces_state_only_after_storage_success() {
|
||||||
let (_temporary, storage) = fixture_storage();
|
let (_temporary, storage) = fixture_storage();
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
|||||||
UiAction::Cut => (command, Code::KeyX),
|
UiAction::Cut => (command, Code::KeyX),
|
||||||
UiAction::Paste => (command, Code::KeyV),
|
UiAction::Paste => (command, Code::KeyV),
|
||||||
UiAction::Find => (command, Code::KeyF),
|
UiAction::Find => (command, Code::KeyF),
|
||||||
|
UiAction::CommandPalette => (command, Code::KeyK),
|
||||||
UiAction::Refresh => (command, Code::KeyR),
|
UiAction::Refresh => (command, Code::KeyR),
|
||||||
UiAction::Lock => (command, Code::KeyL),
|
UiAction::Lock => (command, Code::KeyL),
|
||||||
UiAction::Help => (Modifiers::empty(), Code::F1),
|
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