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:
2026-05-28 19:50:41 +00:00
parent 67870bb9e8
commit 231032aa85
2 changed files with 162 additions and 0 deletions
+161
View File
@@ -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));
}
}
+1
View File
@@ -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.