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