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.
This commit is contained in:
@@ -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<Self, ActionParseError> {
|
||||
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<Direction, String> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user