feat(maestro-tui): Keymap table with input/action validation (NAP-277)
Two-level `(mode → input → action)` lookup. `set` validates each whitespace- separated chord token via `InputKey::parse` and the action name via `Action::parse`, so a bad TOML entry surfaces a precise diagnostic at TUI startup instead of silently dropping a binding. `known_bindings(mode)` feeds `ChordState` so a key only holds when it strictly prefixes a real binding. 8 unit tests passing.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
//! `Keymap` — two-level `(mode → input → action)` table.
|
||||
//!
|
||||
//! Inputs and actions are validated against [`InputKey::parse`] and
|
||||
//! [`Action::parse`] at `set` time, so a bad TOML entry is rejected at
|
||||
//! TUI startup with a precise diagnostic (offending line in the caller's
|
||||
//! report).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::action::{Action, ActionParseError};
|
||||
use crate::input::{InputKey, InputParseError};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Mode {
|
||||
Normal,
|
||||
Insert,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum KeymapError {
|
||||
#[error("bad input key: {0}")]
|
||||
BadInput(#[from] InputParseError),
|
||||
#[error("bad action: {0}")]
|
||||
BadAction(#[from] ActionParseError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Keymap {
|
||||
normal: HashMap<String, Action>,
|
||||
insert: HashMap<String, Action>,
|
||||
}
|
||||
|
||||
impl Keymap {
|
||||
/// Bind a canonical input string to an action under a mode. Validates
|
||||
/// every token of the input chord and the action name; either failure
|
||||
/// surfaces as a [`KeymapError`].
|
||||
pub fn set(&mut self, mode: Mode, input: &str, action: &str) -> Result<(), KeymapError> {
|
||||
// Validate each whitespace-separated token of the chord.
|
||||
for tok in input.split_whitespace() {
|
||||
InputKey::parse(tok)?;
|
||||
}
|
||||
let action = Action::parse(action)?;
|
||||
let table = self.table_mut(mode);
|
||||
table.insert(input.to_string(), action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up the action bound to a fully-resolved chord (post-[`ChordState`]
|
||||
/// resolution). Returns `None` if nothing is bound.
|
||||
pub fn resolve(&self, mode: Mode, canonical: &str) -> Option<Action> {
|
||||
self.table(mode).get(canonical).cloned()
|
||||
}
|
||||
|
||||
/// All bound chord strings for the given mode. [`ChordState`] uses this
|
||||
/// to decide whether to hold a key as a chord prefix.
|
||||
pub fn known_bindings(&self, mode: Mode) -> HashSet<String> {
|
||||
self.table(mode).keys().cloned().collect()
|
||||
}
|
||||
|
||||
fn table(&self, mode: Mode) -> &HashMap<String, Action> {
|
||||
match mode {
|
||||
Mode::Normal => &self.normal,
|
||||
Mode::Insert => &self.insert,
|
||||
}
|
||||
}
|
||||
|
||||
fn table_mut(&mut self, mode: Mode) -> &mut HashMap<String, Action> {
|
||||
match mode {
|
||||
Mode::Normal => &mut self.normal,
|
||||
Mode::Insert => &mut self.insert,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::Direction;
|
||||
|
||||
fn build(entries: &[(Mode, &str, &str)]) -> Keymap {
|
||||
let mut k = Keymap::default();
|
||||
for (mode, input, action) in entries {
|
||||
k.set(*mode, input, action).unwrap();
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_single_key() {
|
||||
let k = build(&[(Mode::Normal, "i", "EnterInsert")]);
|
||||
assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::EnterInsert));
|
||||
assert_eq!(k.resolve(Mode::Insert, "i"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_chord() {
|
||||
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
|
||||
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_argful_action() {
|
||||
let k = build(&[(Mode::Normal, "ctrl-w h", "FocusPane(Left)")]);
|
||||
assert_eq!(
|
||||
k.resolve(Mode::Normal, "ctrl-w h"),
|
||||
Some(Action::FocusPane(Direction::Left)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_bindings_sees_everything() {
|
||||
let k = build(&[
|
||||
(Mode::Normal, "g t", "NextTab"),
|
||||
(Mode::Normal, "i", "EnterInsert"),
|
||||
]);
|
||||
let kb = k.known_bindings(Mode::Normal);
|
||||
assert!(kb.contains("g t"));
|
||||
assert!(kb.contains("i"));
|
||||
assert_eq!(kb.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_are_independent() {
|
||||
let k = build(&[
|
||||
(Mode::Normal, "i", "EnterInsert"),
|
||||
(Mode::Insert, "ctrl-a", "LeaveInsert"),
|
||||
]);
|
||||
assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::EnterInsert));
|
||||
assert_eq!(k.resolve(Mode::Normal, "ctrl-a"), None);
|
||||
assert_eq!(k.resolve(Mode::Insert, "ctrl-a"), Some(Action::LeaveInsert));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_bad_input_at_load() {
|
||||
let mut k = Keymap::default();
|
||||
let err = k.set(Mode::Normal, "ctrl-glorb", "EnterInsert").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, KeymapError::BadInput(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(format!("{err}").contains("ctrl-glorb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_bad_action_at_load() {
|
||||
let mut k = Keymap::default();
|
||||
let err = k.set(Mode::Normal, "i", "Frobnicate").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, KeymapError::BadAction(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(format!("{err}").contains("Frobnicate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_replaces_previous() {
|
||||
let mut k = build(&[(Mode::Normal, "i", "EnterInsert")]);
|
||||
k.set(Mode::Normal, "i", "ShowHelp").unwrap();
|
||||
assert_eq!(k.resolve(Mode::Normal, "i"), Some(Action::ShowHelp));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
pub mod action;
|
||||
pub mod cli;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
|
||||
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
|
||||
/// `TuiArgs` plus any global flags.
|
||||
|
||||
Reference in New Issue
Block a user