//! 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 { ACTIONS.iter().filter(move |spec| spec.group == group) } pub fn shortcut_label(action: UiAction) -> Option { 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 { 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::>(); 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) ); } } }