896 lines
26 KiB
Rust
896 lines
26 KiB
Rust
//! 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<WorkflowAction> {
|
|
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<Action> {
|
|
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<Mode>,
|
|
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<Item = &'static ActionSpec> {
|
|
ACTIONS.iter().filter(move |spec| spec.is_available(mode))
|
|
}
|
|
|
|
pub fn context_actions(mode: Mode) -> impl Iterator<Item = &'static ActionSpec> {
|
|
available_actions(mode).filter(|spec| spec.is_compact())
|
|
}
|
|
|
|
pub fn help_actions(context: Mode) -> impl Iterator<Item = &'static ActionSpec> {
|
|
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));
|
|
}
|
|
}
|