Define the Mutt-like TUI keymap

This commit is contained in:
Hermes Agent committed 2026-08-10 08:03:20 +00:00
1 parent c82c792699
commit 639ae0d08e
4 files changed
+519 -80

No files matched your search

+352 -7
View File
@@ -43,15 +43,65 @@ pub enum Action {
Generate,
ConfirmDiscard,
KeepEditing,
Initialize,
InsertEntry,
GenerateEntry,
Grep,
RemoveEntry,
MoveEntry,
CopyEntry,
GitPull,
GitPush,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkflowAction {
Initialize,
InsertEntry,
GenerateEntry,
Grep,
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::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,
@@ -65,10 +115,46 @@ 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 QUITTABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Locked];
const LOCKABLE: &[Mode] = &[
Mode::Browser,
Mode::Viewer,
@@ -91,7 +177,23 @@ const ALL: &[Mode] = &[
macro_rules! keys {
($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => {
&[$(KeyBinding { code: $code, modifiers: $modifiers, display: $display }),+]
&[$(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,
}),+]
};
}
@@ -101,7 +203,7 @@ pub static ACTIONS: &[ActionSpec] = &[
label: "quit",
command: "quit",
bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")),
modes: BROWSER_LIKE,
modes: QUITTABLE,
},
ActionSpec {
action: Action::Help,
@@ -378,9 +480,91 @@ pub static ACTIONS: &[ActionSpec] = &[
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: &[Mode::Browser],
},
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,
},
];
pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
let modifiers = normalize_modifiers(code, modifiers);
ACTIONS
.iter()
.find(|spec| {
@@ -388,15 +572,94 @@ pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option
&& spec
.bindings
.iter()
.any(|binding| binding.code == code && binding.modifiers == modifiers)
.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::*;
@@ -414,6 +677,12 @@ mod tests {
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:?}"
);
}
}
}
@@ -427,10 +696,17 @@ mod tests {
for mode in ALL {
for spec in available_actions(*mode) {
for binding in spec.bindings {
assert_eq!(
resolve_key(*mode, binding.code, binding.modifiers),
Some(spec.action)
);
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));
}
}
}
}
@@ -439,4 +715,73 @@ mod tests {
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::ScrollDown)
);
}
#[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));
}
}