//! 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, InitializeStore, NewFolder, NewEntry, OpenFolder, OpenEntry, Save, CloseWindow, Quit, Undo, Redo, Cut, CopyField, CopyEditedField, Paste, Find, CommandPalette, TogglePaneFocus, Refresh, ReloadEntry, EditEntry, GeneratePassword, ToggleReveal, Lock, Minimize, Help, } impl UiAction { pub const fn id(self) -> &'static str { match self { Self::About => "about", Self::Settings => "settings", Self::InitializeStore => "initialize-store", Self::NewFolder => "new-folder", Self::NewEntry => "new-entry", Self::OpenFolder => "open-folder", 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::CommandPalette => "command-palette", Self::TogglePaneFocus => "toggle-pane-focus", Self::Refresh => "refresh", Self::ReloadEntry => "reload-entry", Self::EditEntry => "edit-entry", Self::GeneratePassword => "generate-password", 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 switching_vault: bool, pub modal_open: bool, pub focused_field: bool, pub focused_sensitive: bool, pub focused_generatable: 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::InitializeStore, MenuGroup::File, "Initialize Store…", None, ), spec(UiAction::NewFolder, MenuGroup::File, "New Folder…", None), spec( UiAction::Quit, MenuGroup::App, "Quit IronStorage", Some("⌘Q"), ), spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")), spec( UiAction::OpenFolder, MenuGroup::File, "Open Folder…", Some("⌘O"), ), spec(UiAction::OpenEntry, MenuGroup::Entry, "Open Entry", None), 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::CommandPalette, MenuGroup::View, "Command Palette…", Some("⌘K"), ), 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::GeneratePassword, MenuGroup::Entry, "Generate Password…", 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::Help => !context.modal_open, UiAction::Settings => { context.storage_ready && !context.saving && !context.switching_vault && !context.modal_open } UiAction::CommandPalette => !context.modal_open, UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry => { context.storage_ready && !context.dirty && !context.saving && !context.switching_vault && !context.modal_open } UiAction::OpenFolder => { context.storage_ready && !context.saving && !context.switching_vault } UiAction::OpenEntry => { context.storage_ready && context.entry_path && !context.saving && !context.switching_vault } UiAction::Save => { context.unlocked && context.editing && context.dirty && !context.saving && !context.switching_vault } UiAction::CloseWindow | UiAction::Quit => !context.switching_vault, UiAction::Minimize => true, UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false, UiAction::CopyField => { context.unlocked && context.document_open && !context.editing && !context.switching_vault && context.focused_field } UiAction::CopyEditedField => { context.unlocked && context.editing && !context.switching_vault && context.focused_field } UiAction::TogglePaneFocus => !context.modal_open, UiAction::Refresh => { context.storage_ready && !context.tree_loading && !context.switching_vault } UiAction::ReloadEntry => { context.unlocked && context.document_open && !context.saving && !context.switching_vault } UiAction::EditEntry => { context.unlocked && context.document_open && !context.editing && !context.switching_vault } UiAction::GeneratePassword => { context.unlocked && context.editing && context.focused_generatable && !context.switching_vault && !context.modal_open } UiAction::ToggleReveal => { context.unlocked && context.document_open && !context.switching_vault && context.focused_sensitive } UiAction::Lock => context.unlocked, } } pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'static str> { if enabled(action, context) { return None; } Some(match action { UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if !context.storage_ready => { "Shared configuration is unavailable" } UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if context.dirty => { "Save or discard the current draft first" } UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if context.saving => { "Wait for the active save" } UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry if context.switching_vault => { "Wait for vault validation" } UiAction::InitializeStore | UiAction::NewFolder | UiAction::NewEntry => { "Close the current screen first" } 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::GeneratePassword if !context.unlocked => "Unlock an entry first", UiAction::GeneratePassword if !context.editing => "Open the entry editor first", UiAction::GeneratePassword if !context.focused_generatable => { "Select a password or other generatable secret field first" } UiAction::GeneratePassword if context.switching_vault => "Wait for vault validation", UiAction::GeneratePassword => "Close the current screen first", 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::Settings if !context.storage_ready => "Shared configuration is unavailable", UiAction::Settings if context.saving => "Wait for the active save", UiAction::Settings if context.switching_vault => "Wait for vault validation", UiAction::About | UiAction::Settings | UiAction::TogglePaneFocus | UiAction::Help => { "Close the current screen first" } UiAction::Minimize => "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::InitializeStore => &["pass init", "recipients", "gpg id"], UiAction::NewFolder => &["create folder", "directory", "nested recipients"], 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::GeneratePassword => &["random password", "replace password", "generate"], 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 { 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::OpenFolder), 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("k" | "K") => Some(UiAction::CommandPalette), 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, switching_vault: false, modal_open: false, focused_field: true, focused_sensitive: true, focused_generatable: 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::GeneratePassword, 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)); assert!(enabled(UiAction::InitializeStore, viewing)); assert!(enabled(UiAction::NewFolder, viewing)); assert!(enabled(UiAction::NewEntry, viewing)); let locked = ActionContext { unlocked: false, ..ready }; for action in [ UiAction::Save, UiAction::CopyField, UiAction::CopyEditedField, UiAction::ToggleReveal, UiAction::GeneratePassword, UiAction::Lock, ] { assert!(!enabled(action, locked)); } assert!(!enabled( UiAction::Refresh, ActionContext { tree_loading: true, ..ready } )); assert!(!enabled( UiAction::Save, ActionContext { dirty: false, ..ready } )); assert!(!enabled( UiAction::OpenFolder, ActionContext { switching_vault: true, ..ready } )); let switching = ActionContext { switching_vault: true, ..ready }; for action in [ UiAction::Settings, UiAction::Save, UiAction::CloseWindow, UiAction::Quit, UiAction::CopyField, UiAction::CopyEditedField, UiAction::Refresh, UiAction::ReloadEntry, UiAction::EditEntry, UiAction::GeneratePassword, UiAction::ToggleReveal, ] { 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") ); let modal = ActionContext { modal_open: true, ..ready }; for action in [ UiAction::About, UiAction::Settings, UiAction::TogglePaneFocus, UiAction::Help, ] { assert!(!enabled(action, modal), "{action:?}"); } assert!(!enabled( UiAction::Settings, ActionContext { storage_ready: false, ..ready } )); } #[test] fn conventional_shortcuts_resolve_to_the_registered_actions() { let primary = keyboard::Modifiers::COMMAND; for (key, expected) in [ ("o", UiAction::OpenFolder), ("s", UiAction::Save), ("f", UiAction::Find), ("k", UiAction::CommandPalette), ("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) ); } assert_eq!( shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE), Some(UiAction::Help) ); assert_eq!(shortcut_label(UiAction::Help).as_deref(), Some("F1")); } }