From a4252438cda4aea24b91a6355c1733ab520621d5 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Thu, 28 May 2026 19:48:10 +0000 Subject: [PATCH] feat(maestro-tui): Action enum with canonical-string parser (NAP-277) Closed list of TUI verbs. The string form (`EnterInsert`, `FocusPane(Left)`, `GotoTab(3)`) is what users write in `[tui.keymap.normal]`; the enum is what the event loop matches on. Parser surfaces `Unknown`, `BadArg`, and `Malformed` diagnostics so a bad keymap entry is rejected at load time with a specific reason. Display round- trips so the canonical form is stable. 9 unit tests passing. --- crates/maestro-tui/src/action.rs | 204 +++++++++++++++++++++++++++++++ crates/maestro-tui/src/lib.rs | 1 + 2 files changed, 205 insertions(+) create mode 100644 crates/maestro-tui/src/action.rs diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs new file mode 100644 index 0000000..8f334e0 --- /dev/null +++ b/crates/maestro-tui/src/action.rs @@ -0,0 +1,204 @@ +//! `Action` — the closed list of verbs the TUI knows about, plus the canonical +//! string form used by the keymap config. +//! +//! Every binding in `[tui.keymap.normal]` / `[tui.keymap.insert]` resolves to +//! one of these variants. The string form is what the user types in the TOML; +//! the enum is what the dispatcher matches on. + +use std::fmt; + +/// Geometric direction for focus motion and resize. Used inside [`Action`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Direction { + Left, + Right, + Up, + Down, +} + +/// Closed set of verbs the TUI dispatch loop understands. New verbs require a +/// matching arm in `Action::parse`, the `Display` impl, and the dispatcher. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Action { + /// No-op. Bind to a key to suppress a default binding. + Noop, + + // Mode + EnterInsert, + LeaveInsert, + + // Focus + FocusSidebar, + FocusPane(Direction), + + // Catalog actions on the highlighted sidebar row + AttachHighlighted, + ToggleAttachHighlighted, + OpenSpawnModal, + KillHighlighted, + + // Tabs + NewTab, + CloseTab, + NextTab, + PrevTab, + GotoTab(u8), + + // Chrome + ToggleSidebar, + ShowHelp, + Quit, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ActionParseError { + #[error("unknown action {0:?}")] + Unknown(String), + #[error("bad arg for {action}: {detail}")] + BadArg { action: String, detail: String }, + #[error("malformed action string {0:?}")] + Malformed(String), +} + +impl Action { + /// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`, + /// or `"GotoTab(3)"`. + pub fn parse(s: &str) -> Result { + let s = s.trim(); + match s { + "Noop" => return Ok(Action::Noop), + "EnterInsert" => return Ok(Action::EnterInsert), + "LeaveInsert" => return Ok(Action::LeaveInsert), + "FocusSidebar" => return Ok(Action::FocusSidebar), + "AttachHighlighted" => return Ok(Action::AttachHighlighted), + "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), + "OpenSpawnModal" => return Ok(Action::OpenSpawnModal), + "KillHighlighted" => return Ok(Action::KillHighlighted), + "NewTab" => return Ok(Action::NewTab), + "CloseTab" => return Ok(Action::CloseTab), + "NextTab" => return Ok(Action::NextTab), + "PrevTab" => return Ok(Action::PrevTab), + "ToggleSidebar" => return Ok(Action::ToggleSidebar), + "ShowHelp" => return Ok(Action::ShowHelp), + "Quit" => return Ok(Action::Quit), + _ => {} + } + // Arg actions: Name(arg) + let (name, rest) = s + .split_once('(') + .ok_or_else(|| ActionParseError::Unknown(s.to_string()))?; + let arg = rest + .strip_suffix(')') + .ok_or_else(|| ActionParseError::Malformed(s.to_string()))?; + match name { + "FocusPane" => { + let dir = parse_direction(arg).map_err(|d| ActionParseError::BadArg { + action: name.to_string(), + detail: d, + })?; + Ok(Action::FocusPane(dir)) + } + "GotoTab" => { + let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg { + action: name.to_string(), + detail: format!("expected u8 0..=255, got {arg:?}"), + })?; + Ok(Action::GotoTab(n)) + } + _ => Err(ActionParseError::Unknown(name.to_string())), + } + } +} + +fn parse_direction(s: &str) -> Result { + match s { + "Left" => Ok(Direction::Left), + "Right" => Ok(Direction::Right), + "Up" => Ok(Direction::Up), + "Down" => Ok(Direction::Down), + other => Err(format!("expected Left/Right/Up/Down, got {other:?}")), + } +} + +impl fmt::Display for Action { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Action::FocusPane(d) => write!(f, "FocusPane({d:?})"), + Action::GotoTab(n) => write!(f, "GotoTab({n})"), + other => write!(f, "{other:?}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_no_arg_actions() { + assert_eq!(Action::parse("EnterInsert").unwrap(), Action::EnterInsert); + assert_eq!(Action::parse("Quit").unwrap(), Action::Quit); + assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop); + } + + #[test] + fn parses_focus_pane_directions() { + assert_eq!( + Action::parse("FocusPane(Left)").unwrap(), + Action::FocusPane(Direction::Left), + ); + assert_eq!( + Action::parse("FocusPane(Down)").unwrap(), + Action::FocusPane(Direction::Down), + ); + } + + #[test] + fn parses_goto_tab() { + assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3)); + assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0)); + } + + #[test] + fn rejects_unknown_action() { + let err = Action::parse("Frobnicate").unwrap_err(); + assert_eq!(err, ActionParseError::Unknown("Frobnicate".into())); + } + + #[test] + fn rejects_bad_direction_arg() { + let err = Action::parse("FocusPane(Sideways)").unwrap_err(); + assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "FocusPane")); + } + + #[test] + fn rejects_non_numeric_tab_arg() { + let err = Action::parse("GotoTab(abc)").unwrap_err(); + assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab")); + } + + #[test] + fn rejects_overflow_tab_arg() { + let err = Action::parse("GotoTab(9999)").unwrap_err(); + assert!(matches!(err, ActionParseError::BadArg { .. })); + } + + #[test] + fn rejects_unclosed_paren() { + let err = Action::parse("GotoTab(3").unwrap_err(); + assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into())); + } + + #[test] + fn display_round_trips() { + for a in [ + Action::EnterInsert, + Action::Quit, + Action::FocusPane(Direction::Right), + Action::GotoTab(7), + ] { + let s = a.to_string(); + assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}"); + } + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 4cb1e6d..ec0dc97 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -8,6 +8,7 @@ #![deny(missing_debug_implementations)] +pub mod action; pub mod cli; /// Inputs for [`run`]. Built by the umbrella binary from the parsed