Build the Mutt-style TUI shell and action model
This commit is contained in:
185
apps/tui/src/action.rs
Normal file
185
apps/tui/src/action.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
//! 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,
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct KeyBinding {
|
||||
pub code: KeyCode,
|
||||
pub modifiers: KeyModifiers,
|
||||
pub display: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ActionSpec {
|
||||
pub action: Action,
|
||||
pub label: &'static str,
|
||||
pub command: &'static str,
|
||||
pub binding: KeyBinding,
|
||||
modes: &'static [Mode],
|
||||
}
|
||||
|
||||
impl ActionSpec {
|
||||
pub fn is_available(self, mode: Mode) -> bool {
|
||||
self.modes.contains(&mode)
|
||||
}
|
||||
}
|
||||
|
||||
const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer];
|
||||
const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor];
|
||||
const OVERLAYS: &[Mode] = &[Mode::Help, Mode::Command, Mode::Dialog];
|
||||
#[cfg(test)]
|
||||
const ALL: &[Mode] = &[
|
||||
Mode::Browser,
|
||||
Mode::Viewer,
|
||||
Mode::Editor,
|
||||
Mode::Dialog,
|
||||
Mode::Help,
|
||||
Mode::Command,
|
||||
Mode::Locked,
|
||||
];
|
||||
|
||||
pub static ACTIONS: &[ActionSpec] = &[
|
||||
ActionSpec {
|
||||
action: Action::Quit,
|
||||
label: "quit",
|
||||
command: "quit",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char('q'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "q",
|
||||
},
|
||||
modes: BROWSER_LIKE,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Help,
|
||||
label: "help",
|
||||
command: "help",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char('?'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "?",
|
||||
},
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Command,
|
||||
label: "command",
|
||||
command: "command",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char(':'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: ":",
|
||||
},
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Cancel,
|
||||
label: "back",
|
||||
command: "cancel",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "Esc",
|
||||
},
|
||||
modes: OVERLAYS,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Lock,
|
||||
label: "lock",
|
||||
command: "lock",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
display: "C-l",
|
||||
},
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::FocusNext,
|
||||
label: "next pane",
|
||||
command: "focus-next",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
display: "Tab",
|
||||
},
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::FocusPrevious,
|
||||
label: "previous pane",
|
||||
command: "focus-previous",
|
||||
binding: KeyBinding {
|
||||
code: KeyCode::BackTab,
|
||||
modifiers: KeyModifiers::SHIFT,
|
||||
display: "S-Tab",
|
||||
},
|
||||
modes: UNLOCKED,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
|
||||
ACTIONS
|
||||
.iter()
|
||||
.find(|spec| {
|
||||
spec.is_available(mode)
|
||||
&& spec.binding.code == code
|
||||
&& spec.binding.modifiers == modifiers
|
||||
})
|
||||
.map(|spec| spec.action)
|
||||
}
|
||||
|
||||
pub fn available_actions(mode: Mode) -> impl Iterator<Item = &'static ActionSpec> {
|
||||
ACTIONS.iter().filter(move |spec| spec.is_available(mode))
|
||||
}
|
||||
|
||||
#[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) {
|
||||
assert_ne!(action.binding, other.binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_and_help_consume_the_same_registry() {
|
||||
for mode in ALL {
|
||||
for spec in available_actions(*mode) {
|
||||
assert_eq!(
|
||||
resolve_key(*mode, spec.binding.code, spec.binding.modifiers),
|
||||
Some(spec.action)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user