Implement desktop menus and shortcuts
This commit is contained in:
376
apps/desktop/src/action.rs
Normal file
376
apps/desktop/src/action.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user