Implement desktop menus and shortcuts

This commit is contained in:
2026-08-10 16:52:51 +02:00
parent f6e74cbb1d
commit c301d9a3df
8 changed files with 1376 additions and 67 deletions

376
apps/desktop/src/action.rs Normal file
View File

@@ -0,0 +1,376 @@
//! One desktop action registry shared by every presentation entry point.
use iced::keyboard::{self, key::Named};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum UiAction {
About,
Settings,
NewEntry,
OpenEntry,
Save,
CloseWindow,
Quit,
Undo,
Redo,
Cut,
CopyField,
CopyEditedField,
Paste,
Find,
TogglePaneFocus,
Refresh,
ReloadEntry,
EditEntry,
ToggleReveal,
Lock,
Minimize,
Help,
}
impl UiAction {
pub const fn id(self) -> &'static str {
match self {
Self::About => "about",
Self::Settings => "settings",
Self::NewEntry => "new-entry",
Self::OpenEntry => "open-entry",
Self::Save => "save",
Self::CloseWindow => "close-window",
Self::Quit => "quit",
Self::Undo => "undo",
Self::Redo => "redo",
Self::Cut => "cut",
Self::CopyField => "copy-field",
Self::CopyEditedField => "copy-edited-field",
Self::Paste => "paste",
Self::Find => "find",
Self::TogglePaneFocus => "toggle-pane-focus",
Self::Refresh => "refresh",
Self::ReloadEntry => "reload-entry",
Self::EditEntry => "edit-entry",
Self::ToggleReveal => "toggle-reveal",
Self::Lock => "lock",
Self::Minimize => "minimize",
Self::Help => "help",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MenuGroup {
App,
File,
Edit,
View,
Entry,
Tools,
Window,
Help,
}
impl MenuGroup {
pub const ALL: [Self; 8] = [
Self::App,
Self::File,
Self::Edit,
Self::View,
Self::Entry,
Self::Tools,
Self::Window,
Self::Help,
];
pub const fn label(self) -> &'static str {
match self {
Self::App => "IronStorage",
Self::File => "File",
Self::Edit => "Edit",
Self::View => "View",
Self::Entry => "Entry",
Self::Tools => "Tools",
Self::Window => "Window",
Self::Help => "Help",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionContext {
pub storage_ready: bool,
pub tree_loading: bool,
pub unlocked: bool,
pub document_open: bool,
pub editing: bool,
pub dirty: bool,
pub saving: bool,
pub focused_field: bool,
pub focused_sensitive: bool,
pub entry_path: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionSpec {
pub action: UiAction,
pub group: MenuGroup,
pub label: &'static str,
pub shortcut: Option<&'static str>,
}
pub const ACTIONS: &[ActionSpec] = &[
spec(UiAction::About, MenuGroup::App, "About IronStorage", None),
spec(UiAction::Settings, MenuGroup::App, "Settings…", Some("⌘,")),
spec(
UiAction::Quit,
MenuGroup::App,
"Quit IronStorage",
Some("⌘Q"),
),
spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")),
spec(UiAction::OpenEntry, MenuGroup::File, "Open", Some("⌘O")),
spec(UiAction::Save, MenuGroup::File, "Save", Some("⌘S")),
spec(
UiAction::CloseWindow,
MenuGroup::File,
"Close Window",
Some("⌘W"),
),
spec(UiAction::Undo, MenuGroup::Edit, "Undo", Some("⌘Z")),
spec(UiAction::Redo, MenuGroup::Edit, "Redo", Some("⇧⌘Z")),
spec(UiAction::Cut, MenuGroup::Edit, "Cut", Some("⌘X")),
spec(UiAction::CopyField, MenuGroup::Entry, "Copy Field", None),
spec(
UiAction::CopyEditedField,
MenuGroup::Entry,
"Copy Edited Field",
None,
),
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
spec(
UiAction::TogglePaneFocus,
MenuGroup::View,
"Next Pane",
Some("Tab"),
),
spec(UiAction::Refresh, MenuGroup::View, "Refresh", Some("⌘R")),
spec(
UiAction::ReloadEntry,
MenuGroup::Entry,
"Reload Entry",
None,
),
spec(UiAction::EditEntry, MenuGroup::Entry, "Edit Entry", None),
spec(
UiAction::ToggleReveal,
MenuGroup::Entry,
"Reveal or Hide Field",
None,
),
spec(UiAction::Lock, MenuGroup::Tools, "Lock", Some("⌘L")),
spec(
UiAction::Minimize,
MenuGroup::Window,
"Minimize",
Some("⌘M"),
),
spec(
UiAction::Help,
MenuGroup::Help,
"IronStorage Help",
Some("F1"),
),
];
const fn spec(
action: UiAction,
group: MenuGroup,
label: &'static str,
shortcut: Option<&'static str>,
) -> ActionSpec {
ActionSpec {
action,
group,
label,
shortcut,
}
}
pub fn spec_for(action: UiAction) -> &'static ActionSpec {
ACTIONS
.iter()
.find(|spec| spec.action == action)
.expect("every UI action is registered")
}
pub fn actions_in(group: MenuGroup) -> impl Iterator<Item = &'static ActionSpec> {
ACTIONS.iter().filter(move |spec| spec.group == group)
}
pub fn shortcut_label(action: UiAction) -> Option<String> {
let shortcut = spec_for(action).shortcut?;
if cfg!(target_os = "macos") {
Some(shortcut.to_owned())
} else {
Some(shortcut.replace('⇧', "Shift+").replace('⌘', "Ctrl+"))
}
}
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
match action {
UiAction::About | UiAction::Settings | UiAction::Help => true,
// Entry creation is not valid until the dedicated workflow exists.
UiAction::NewEntry => false,
UiAction::OpenEntry => context.storage_ready && context.entry_path && !context.saving,
UiAction::Save => context.unlocked && context.editing && context.dirty && !context.saving,
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => true,
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false,
UiAction::CopyField => {
context.unlocked && context.document_open && !context.editing && context.focused_field
}
UiAction::CopyEditedField => context.unlocked && context.editing && context.focused_field,
UiAction::TogglePaneFocus => true,
UiAction::Refresh => context.storage_ready && !context.tree_loading,
UiAction::ReloadEntry => context.unlocked && context.document_open && !context.saving,
UiAction::EditEntry => context.unlocked && context.document_open && !context.editing,
UiAction::ToggleReveal => {
context.unlocked && context.document_open && context.focused_sensitive
}
UiAction::Lock => context.unlocked,
}
}
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);
}
if key.as_ref() == keyboard::Key::Named(Named::F1) {
return Some(UiAction::Help);
}
if !modifiers.command() {
return None;
}
match key.as_ref() {
keyboard::Key::Character(",") => Some(UiAction::Settings),
keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry),
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenEntry),
keyboard::Key::Character("s" | "S") => Some(UiAction::Save),
keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow),
keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit),
keyboard::Key::Character("z" | "Z") if modifiers.shift() => Some(UiAction::Redo),
keyboard::Key::Character("z" | "Z") => Some(UiAction::Undo),
keyboard::Key::Character("x" | "X") => Some(UiAction::Cut),
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("r" | "R") => Some(UiAction::Refresh),
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
fn context() -> ActionContext {
ActionContext {
storage_ready: true,
tree_loading: false,
unlocked: true,
document_open: true,
editing: true,
dirty: true,
saving: false,
focused_field: true,
focused_sensitive: true,
entry_path: true,
}
}
#[test]
fn registry_is_complete_unique_and_grouped_for_every_platform_menu() {
let actions = ACTIONS
.iter()
.map(|spec| spec.action)
.collect::<BTreeSet<_>>();
assert_eq!(actions.len(), ACTIONS.len());
for group in MenuGroup::ALL {
assert!(actions_in(group).next().is_some(), "missing {group:?} menu");
}
for action in actions {
assert_eq!(spec_for(action).action, action);
}
}
#[test]
fn enablement_closes_authentication_dirty_and_loading_bypasses() {
let ready = context();
assert!(!enabled(UiAction::NewEntry, ready));
assert!(enabled(UiAction::Save, ready));
assert!(enabled(UiAction::CopyEditedField, ready));
assert!(!enabled(UiAction::CopyField, ready));
assert!(enabled(UiAction::ToggleReveal, ready));
let viewing = ActionContext {
editing: false,
dirty: false,
..ready
};
assert!(enabled(UiAction::CopyField, viewing));
assert!(!enabled(UiAction::CopyEditedField, viewing));
let locked = ActionContext {
unlocked: false,
..ready
};
for action in [
UiAction::Save,
UiAction::CopyField,
UiAction::CopyEditedField,
UiAction::ToggleReveal,
UiAction::Lock,
] {
assert!(!enabled(action, locked));
}
assert!(!enabled(
UiAction::Refresh,
ActionContext {
tree_loading: true,
..ready
}
));
assert!(!enabled(
UiAction::Save,
ActionContext {
dirty: false,
..ready
}
));
}
#[test]
fn conventional_shortcuts_resolve_to_the_registered_actions() {
let primary = keyboard::Modifiers::COMMAND;
for (key, expected) in [
("o", UiAction::OpenEntry),
("s", UiAction::Save),
("f", UiAction::Find),
("n", UiAction::NewEntry),
("w", UiAction::CloseWindow),
("q", UiAction::Quit),
("x", UiAction::Cut),
("c", UiAction::CopyField),
("v", UiAction::Paste),
(",", UiAction::Settings),
] {
assert_eq!(
shortcut_action(&keyboard::Key::Character(key.into()), primary),
Some(expected)
);
}
}
}