diff --git a/crates/maestro-tui/src/keymap.rs b/crates/maestro-tui/src/keymap.rs new file mode 100644 index 0000000..57cb1be --- /dev/null +++ b/crates/maestro-tui/src/keymap.rs @@ -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, + insert: HashMap, +} + +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 { + 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 { + self.table(mode).keys().cloned().collect() + } + + fn table(&self, mode: Mode) -> &HashMap { + match mode { + Mode::Normal => &self.normal, + Mode::Insert => &self.insert, + } + } + + fn table_mut(&mut self, mode: Mode) -> &mut HashMap { + 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)); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index ae0b044..0c73992 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -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.