//! Central action metadata shared by dispatch, contextual help, and command mode. use crossterm::event::{KeyCode, KeyModifiers}; use crate::app::Mode; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Action { Quit, Help, Command, Cancel, Lock, Unlock, Refresh, Next, Previous, PageDown, PageUp, First, Last, Parent, Child, Activate, Filter, NextMatch, PreviousMatch, FocusNext, FocusPrevious, Copy, ScrollDown, ScrollUp, CloseEntry, EditEntry, BeginInput, SaveEditor, AddField, RemoveField, MoveFieldUp, MoveFieldDown, Generate, ConfirmDiscard, KeepEditing, Initialize, InsertEntry, GenerateEntry, Grep, ImportKdbx, RemoveEntry, MoveEntry, CopyEntry, GitPull, GitPush, OtpCode, OtpCopyCode, OtpUri, OtpCopyUri, OtpQr, OtpInsert, OtpAppend, OtpValidate, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WorkflowAction { Initialize, InsertEntry, GenerateEntry, Grep, ImportKdbx, RemoveEntry, MoveEntry, CopyEntry, GitPull, GitPush, } impl Action { pub fn workflow(self) -> Option { match self { Self::Initialize => Some(WorkflowAction::Initialize), Self::InsertEntry => Some(WorkflowAction::InsertEntry), Self::GenerateEntry => Some(WorkflowAction::GenerateEntry), Self::Grep => Some(WorkflowAction::Grep), Self::ImportKdbx => Some(WorkflowAction::ImportKdbx), Self::RemoveEntry => Some(WorkflowAction::RemoveEntry), Self::MoveEntry => Some(WorkflowAction::MoveEntry), Self::CopyEntry => Some(WorkflowAction::CopyEntry), Self::GitPull => Some(WorkflowAction::GitPull), Self::GitPush => Some(WorkflowAction::GitPush), _ => None, } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct KeyBinding { pub code: KeyCode, pub modifiers: KeyModifiers, pub then: Option<(KeyCode, KeyModifiers)>, pub display: &'static str, } impl KeyBinding { fn is_single(self) -> bool { self.then.is_none() } fn starts_with(self, code: KeyCode, modifiers: KeyModifiers) -> bool { self.code == code && self.modifiers == modifiers } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ActionSpec { pub action: Action, pub label: &'static str, pub command: &'static str, pub bindings: &'static [KeyBinding], modes: &'static [Mode], } impl ActionSpec { pub fn is_available(self, mode: Mode) -> bool { self.modes.contains(&mode) } pub fn help(self) -> &'static str { match self.action { Action::RemoveEntry => "destructive; opens confirmation (y accepts, n/Esc cancels)", Action::ConfirmDiscard => "confirm the destructive dialog choice", Action::KeepEditing => "decline the destructive dialog choice", Action::GitPull | Action::GitPush => "two-key Git sequence", Action::Lock => "manual lock; unsaved secrets are discarded", Action::Command => "other Git operations are available through command mode", _ => "", } } pub fn is_compact(self) -> bool { matches!( self.action, Action::Quit | Action::Help | Action::Cancel | Action::Lock | Action::Next | Action::Previous | Action::Activate | Action::Filter | Action::FocusNext | Action::Copy | Action::EditEntry | Action::BeginInput | Action::SaveEditor | Action::ConfirmDiscard | Action::KeepEditing | Action::Unlock ) } } const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer]; const ENTRY_CONTEXT: &[Mode] = &[Mode::Browser, Mode::Viewer]; const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor]; const COMMANDABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor, Mode::Locked]; const QUITTABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Locked]; const LOCKABLE: &[Mode] = &[ Mode::Browser, Mode::Viewer, Mode::Editor, Mode::Dialog, Mode::Help, Mode::Command, ]; #[cfg(test)] const ALL: &[Mode] = &[ Mode::Browser, Mode::Viewer, Mode::Editor, Mode::Dialog, Mode::Help, Mode::Command, Mode::Locked, ]; macro_rules! keys { ($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => { &[$(KeyBinding { code: $code, modifiers: $modifiers, then: None, display: $display, }),+] }; } macro_rules! sequences { ($(($code:expr, $modifiers:expr, $then_code:expr, $then_modifiers:expr, $display:expr)),+ $(,)?) => { &[$(KeyBinding { code: $code, modifiers: $modifiers, then: Some(($then_code, $then_modifiers)), display: $display, }),+] }; } pub static ACTIONS: &[ActionSpec] = &[ ActionSpec { action: Action::Quit, label: "quit", command: "quit", bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")), modes: QUITTABLE, }, ActionSpec { action: Action::Help, label: "help", command: "help", bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")), modes: UNLOCKED, }, ActionSpec { action: Action::Unlock, label: "unlock", command: "unlock", bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")), modes: &[Mode::Locked], }, ActionSpec { action: Action::Command, label: "command", command: "command", bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")), modes: COMMANDABLE, }, ActionSpec { action: Action::Cancel, label: "back", command: "cancel", bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), modes: &[ Mode::Browser, Mode::Editor, Mode::Help, Mode::Command, Mode::Dialog, ], }, ActionSpec { action: Action::Lock, label: "lock", command: "lock", bindings: keys!( (KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l"), (KeyCode::Char('z'), KeyModifiers::CONTROL, "C-z"), ), modes: LOCKABLE, }, ActionSpec { action: Action::Refresh, label: "refresh", command: "refresh", bindings: keys!((KeyCode::Char('r'), KeyModifiers::NONE, "r")), modes: BROWSER_LIKE, }, ActionSpec { action: Action::Next, label: "next", command: "next", bindings: keys!( (KeyCode::Char('j'), KeyModifiers::NONE, "j"), (KeyCode::Down, KeyModifiers::NONE, "↓"), ), modes: BROWSER_LIKE, }, ActionSpec { action: Action::Previous, label: "previous", command: "previous", bindings: keys!( (KeyCode::Char('k'), KeyModifiers::NONE, "k"), (KeyCode::Up, KeyModifiers::NONE, "↑"), ), modes: BROWSER_LIKE, }, ActionSpec { action: Action::PageDown, label: "page down", command: "page-down", bindings: keys!((KeyCode::PageDown, KeyModifiers::NONE, "PgDn")), modes: &[Mode::Browser], }, ActionSpec { action: Action::PageUp, label: "page up", command: "page-up", bindings: keys!((KeyCode::PageUp, KeyModifiers::NONE, "PgUp")), modes: &[Mode::Browser], }, ActionSpec { action: Action::First, label: "first", command: "first", bindings: keys!((KeyCode::Home, KeyModifiers::NONE, "Home")), modes: &[Mode::Browser], }, ActionSpec { action: Action::Last, label: "last", command: "last", bindings: keys!((KeyCode::End, KeyModifiers::NONE, "End")), modes: &[Mode::Browser], }, ActionSpec { action: Action::Parent, label: "parent", command: "parent", bindings: keys!( (KeyCode::Char('h'), KeyModifiers::NONE, "h"), (KeyCode::Left, KeyModifiers::NONE, "←"), ), modes: &[Mode::Browser], }, ActionSpec { action: Action::Child, label: "child", command: "child", bindings: keys!( (KeyCode::Char('l'), KeyModifiers::NONE, "l"), (KeyCode::Right, KeyModifiers::NONE, "→"), ), modes: &[Mode::Browser], }, ActionSpec { action: Action::Activate, label: "open/toggle", command: "open", bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")), modes: &[Mode::Browser], }, ActionSpec { action: Action::Filter, label: "filter", command: "filter", bindings: keys!((KeyCode::Char('/'), KeyModifiers::NONE, "/")), modes: &[Mode::Browser], }, ActionSpec { action: Action::NextMatch, label: "next match", command: "next-match", bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")), modes: &[Mode::Browser], }, ActionSpec { action: Action::PreviousMatch, label: "previous match", command: "previous-match", bindings: keys!((KeyCode::Char('N'), KeyModifiers::SHIFT, "N")), modes: &[Mode::Browser], }, ActionSpec { action: Action::FocusNext, label: "next field/pane", command: "focus-next", bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")), modes: UNLOCKED, }, ActionSpec { action: Action::FocusPrevious, label: "previous field/pane", command: "focus-previous", bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")), modes: UNLOCKED, }, ActionSpec { action: Action::Copy, label: "copy field", command: "copy", bindings: keys!((KeyCode::Char('y'), KeyModifiers::NONE, "y")), modes: &[Mode::Viewer], }, ActionSpec { action: Action::ScrollDown, label: "scroll down", command: "scroll-down", bindings: keys!((KeyCode::PageDown, KeyModifiers::NONE, "PgDn")), modes: &[Mode::Viewer], }, ActionSpec { action: Action::ScrollUp, label: "scroll up", command: "scroll-up", bindings: keys!((KeyCode::PageUp, KeyModifiers::NONE, "PgUp")), modes: &[Mode::Viewer], }, ActionSpec { action: Action::CloseEntry, label: "close entry", command: "close-entry", bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), modes: &[Mode::Viewer], }, ActionSpec { action: Action::EditEntry, label: "edit entry", command: "edit-entry", bindings: keys!((KeyCode::Char('e'), KeyModifiers::NONE, "e")), modes: &[Mode::Viewer], }, ActionSpec { action: Action::BeginInput, label: "edit field", command: "edit-field", bindings: keys!((KeyCode::Char('i'), KeyModifiers::NONE, "i")), modes: &[Mode::Editor], }, ActionSpec { action: Action::SaveEditor, label: "save", command: "save", bindings: keys!((KeyCode::Char('s'), KeyModifiers::CONTROL, "C-s")), modes: &[Mode::Editor], }, ActionSpec { action: Action::AddField, label: "add field", command: "add-field", bindings: keys!((KeyCode::Char('a'), KeyModifiers::NONE, "a")), modes: &[Mode::Editor], }, ActionSpec { action: Action::RemoveField, label: "remove field", command: "remove-field", bindings: keys!((KeyCode::Char('d'), KeyModifiers::NONE, "d")), modes: &[Mode::Editor], }, ActionSpec { action: Action::MoveFieldUp, label: "move field up", command: "move-field-up", bindings: keys!((KeyCode::Char('K'), KeyModifiers::SHIFT, "K")), modes: &[Mode::Editor], }, ActionSpec { action: Action::MoveFieldDown, label: "move field down", command: "move-field-down", bindings: keys!((KeyCode::Char('J'), KeyModifiers::SHIFT, "J")), modes: &[Mode::Editor], }, ActionSpec { action: Action::Generate, label: "generate field", command: "generate-field", bindings: keys!((KeyCode::Char('g'), KeyModifiers::NONE, "g")), modes: &[Mode::Editor], }, ActionSpec { action: Action::ConfirmDiscard, label: "discard edits", command: "discard-edits", bindings: keys!((KeyCode::Char('y'), KeyModifiers::NONE, "y")), modes: &[Mode::Dialog], }, ActionSpec { action: Action::KeepEditing, label: "keep editing", command: "keep-editing", bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")), modes: &[Mode::Dialog], }, ActionSpec { action: Action::Initialize, label: "initialize recipients", command: "init", bindings: keys!((KeyCode::Char('I'), KeyModifiers::SHIFT, "I")), modes: &[Mode::Browser], }, ActionSpec { action: Action::InsertEntry, label: "insert entry", command: "insert", bindings: keys!((KeyCode::Char('i'), KeyModifiers::NONE, "i")), modes: &[Mode::Browser], }, ActionSpec { action: Action::GenerateEntry, label: "generate entry", command: "generate", bindings: keys!((KeyCode::Char('p'), KeyModifiers::NONE, "p")), modes: &[Mode::Browser], }, ActionSpec { action: Action::Grep, label: "grep decrypted entries", command: "grep", bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")), modes: BROWSER_LIKE, }, ActionSpec { action: Action::ImportKdbx, label: "import KeePass KDBX", command: "import-kdbx", bindings: keys!((KeyCode::Char('K'), KeyModifiers::SHIFT, "K")), modes: BROWSER_LIKE, }, ActionSpec { action: Action::RemoveEntry, label: "remove entry", command: "remove-entry", bindings: sequences!(( KeyCode::Char('d'), KeyModifiers::NONE, KeyCode::Char('d'), KeyModifiers::NONE, "d d" )), modes: ENTRY_CONTEXT, }, ActionSpec { action: Action::MoveEntry, label: "move entry", command: "move-entry", bindings: keys!((KeyCode::Char('m'), KeyModifiers::NONE, "m")), modes: ENTRY_CONTEXT, }, ActionSpec { action: Action::CopyEntry, label: "copy entry", command: "copy-entry", bindings: keys!((KeyCode::Char('c'), KeyModifiers::NONE, "c")), modes: ENTRY_CONTEXT, }, ActionSpec { action: Action::GitPull, label: "Git pull", command: "git-pull", bindings: sequences!(( KeyCode::Char('g'), KeyModifiers::NONE, KeyCode::Char('p'), KeyModifiers::NONE, "g p" )), modes: BROWSER_LIKE, }, ActionSpec { action: Action::GitPush, label: "Git push", command: "git-push", bindings: sequences!(( KeyCode::Char('g'), KeyModifiers::NONE, KeyCode::Char('P'), KeyModifiers::SHIFT, "g P" )), modes: BROWSER_LIKE, }, ActionSpec { action: Action::OtpCode, label: "generate OTP code", command: "otp-code", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('c'), KeyModifiers::NONE, "o c" )), modes: &[Mode::Viewer], }, ActionSpec { action: Action::OtpCopyCode, label: "copy OTP code", command: "otp-copy-code", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('y'), KeyModifiers::NONE, "o y" )), modes: &[Mode::Viewer], }, ActionSpec { action: Action::OtpUri, label: "show OTP URI", command: "otp-uri", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('u'), KeyModifiers::NONE, "o u" )), modes: &[Mode::Viewer], }, ActionSpec { action: Action::OtpCopyUri, label: "copy OTP URI", command: "otp-copy-uri", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('x'), KeyModifiers::NONE, "o x" )), modes: &[Mode::Viewer], }, ActionSpec { action: Action::OtpQr, label: "show OTP QR", command: "otp-qr", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('q'), KeyModifiers::NONE, "o q" )), modes: &[Mode::Viewer], }, ActionSpec { action: Action::OtpInsert, label: "insert OTP URI", command: "otp-insert", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('i'), KeyModifiers::NONE, "o i" )), modes: &[Mode::Browser], }, ActionSpec { action: Action::OtpAppend, label: "append OTP URI", command: "otp-append", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('a'), KeyModifiers::NONE, "o a" )), modes: ENTRY_CONTEXT, }, ActionSpec { action: Action::OtpValidate, label: "validate OTP URI", command: "otp-validate", bindings: sequences!(( KeyCode::Char('o'), KeyModifiers::NONE, KeyCode::Char('v'), KeyModifiers::NONE, "o v" )), modes: BROWSER_LIKE, }, ]; pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option { let modifiers = normalize_modifiers(code, modifiers); ACTIONS .iter() .find(|spec| { spec.is_available(mode) && spec .bindings .iter() .any(|binding| binding.is_single() && binding.starts_with(code, modifiers)) }) .map(|spec| spec.action) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum KeyResolution { Action(Action), Pending, Unavailable, } #[derive(Debug, Default)] pub struct KeyResolver { mode: Option, pending: Option<(KeyCode, KeyModifiers)>, } impl KeyResolver { pub fn feed(&mut self, mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> KeyResolution { if self.mode != Some(mode) { self.pending = None; self.mode = Some(mode); } let modifiers = normalize_modifiers(code, modifiers); if let Some((first_code, first_modifiers)) = self.pending.take() && code != KeyCode::Esc { return ACTIONS .iter() .filter(|spec| spec.is_available(mode)) .find_map(|spec| { spec.bindings .iter() .any(|binding| { binding.starts_with(first_code, first_modifiers) && binding.then == Some((code, modifiers)) }) .then_some(KeyResolution::Action(spec.action)) }) .unwrap_or(KeyResolution::Unavailable); } if let Some(action) = resolve_key(mode, code, modifiers) { return KeyResolution::Action(action); } if ACTIONS .iter() .filter(|spec| spec.is_available(mode)) .flat_map(|spec| spec.bindings) .any(|binding| !binding.is_single() && binding.starts_with(code, modifiers)) { self.pending = Some((code, modifiers)); KeyResolution::Pending } else { KeyResolution::Unavailable } } pub fn reset(&mut self) { self.pending = None; self.mode = None; } } fn normalize_modifiers(code: KeyCode, modifiers: KeyModifiers) -> KeyModifiers { let mut normalized = modifiers & (KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER); if code == KeyCode::BackTab { normalized.insert(KeyModifiers::SHIFT); } normalized } pub fn available_actions(mode: Mode) -> impl Iterator { ACTIONS.iter().filter(move |spec| spec.is_available(mode)) } pub fn context_actions(mode: Mode) -> impl Iterator { available_actions(mode).filter(|spec| spec.is_compact()) } pub fn help_actions(context: Mode) -> impl Iterator { ACTIONS .iter() .filter(move |spec| spec.is_available(context) || spec.is_available(Mode::Help)) } #[cfg(test)] mod tests { use super::*; #[test] fn registry_has_unique_commands_and_bindings_per_mode() { for (index, action) in ACTIONS.iter().enumerate() { assert!(!action.command.is_empty()); assert!(!action.label.is_empty()); assert!(!action.modes.is_empty()); for other in &ACTIONS[index + 1..] { assert_ne!(action.command, other.command); for mode in ALL { if action.is_available(*mode) && other.is_available(*mode) { for binding in action.bindings { for other_binding in other.bindings { assert_ne!(binding, other_binding); assert!( !(binding .starts_with(other_binding.code, other_binding.modifiers) && (binding.is_single() || other_binding.is_single())), "a single binding may not prefix another binding in {mode:?}" ); } } } } } } } #[test] fn resolution_and_help_consume_the_same_registry() { for mode in ALL { for spec in available_actions(*mode) { for binding in spec.bindings { let mut resolver = KeyResolver::default(); let first = resolver.feed(*mode, binding.code, binding.modifiers); if let Some((code, modifiers)) = binding.then { assert_eq!(first, KeyResolution::Pending); assert_eq!( resolver.feed(*mode, code, modifiers), KeyResolution::Action(spec.action) ); } else { assert_eq!(first, KeyResolution::Action(spec.action)); } } } } assert_eq!( resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE), None ); } #[test] fn mode_precedence_and_terminal_key_normalization_are_explicit() { let mut resolver = KeyResolver::default(); assert_eq!( resolver.feed(Mode::Browser, KeyCode::Char('i'), KeyModifiers::NONE), KeyResolution::Action(Action::InsertEntry) ); assert_eq!( resolver.feed(Mode::Editor, KeyCode::Char('i'), KeyModifiers::NONE), KeyResolution::Action(Action::BeginInput) ); assert_eq!( resolver.feed(Mode::Editor, KeyCode::BackTab, KeyModifiers::NONE), KeyResolution::Action(Action::FocusPrevious) ); assert_eq!( resolver.feed(Mode::Viewer, KeyCode::Down, KeyModifiers::NONE), KeyResolution::Action(Action::Next) ); assert_eq!( resolver.feed(Mode::Viewer, KeyCode::Char('k'), KeyModifiers::NONE), KeyResolution::Action(Action::Previous) ); } #[test] fn repeated_sequences_unavailable_keys_and_escape_are_deterministic() { let mut resolver = KeyResolver::default(); assert_eq!( resolver.feed(Mode::Browser, KeyCode::Char('d'), KeyModifiers::NONE), KeyResolution::Pending ); assert_eq!( resolver.feed(Mode::Browser, KeyCode::Char('d'), KeyModifiers::NONE), KeyResolution::Action(Action::RemoveEntry) ); assert_eq!( resolver.feed(Mode::Viewer, KeyCode::Char('g'), KeyModifiers::NONE), KeyResolution::Pending ); assert_eq!( resolver.feed(Mode::Viewer, KeyCode::Esc, KeyModifiers::NONE), KeyResolution::Action(Action::CloseEntry) ); assert_eq!( resolver.feed(Mode::Browser, KeyCode::Char('g'), KeyModifiers::NONE), KeyResolution::Pending ); assert_eq!( resolver.feed(Mode::Browser, KeyCode::Char('x'), KeyModifiers::NONE), KeyResolution::Unavailable ); assert_eq!( resolver.feed(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE), KeyResolution::Unavailable ); } #[test] fn confirmation_shortcuts_are_mode_scoped_and_discoverable() { let mut resolver = KeyResolver::default(); assert_eq!( resolver.feed(Mode::Dialog, KeyCode::Char('y'), KeyModifiers::NONE), KeyResolution::Action(Action::ConfirmDiscard) ); assert_eq!( resolver.feed(Mode::Dialog, KeyCode::Char('n'), KeyModifiers::NONE), KeyResolution::Action(Action::KeepEditing) ); assert!(help_actions(Mode::Dialog).any(|spec| spec.action == Action::ConfirmDiscard)); assert!(help_actions(Mode::Dialog).any(|spec| spec.action == Action::KeepEditing)); } }