feat(maestro-tui): InputKey canonicalisation + ChordState (NAP-277)

- `InputKey` — hashable variant covering chars-with-modifiers, specials, and
  mouse events (kind + tagged region). `canonical()`/`parse()` round-trip the
  TOML form (`ctrl-w h`, `mouse:click(pane)`).
- `canonicalise()` — `crossterm::Event` → `InputKey`, with a region-resolver
  callback supplied by the renderer (NAP-280 will wire `RegionMap` to it).
- `ChordState` — small state machine that holds a key when it strictly
  prefixes a known binding, resolves otherwise. Drops stale held keys past
  `timeout`.

11 unit tests passing.
This commit is contained in:
2026-05-28 19:49:59 +00:00
parent a4252438cd
commit 67870bb9e8
2 changed files with 478 additions and 0 deletions
+477
View File
@@ -0,0 +1,477 @@
//! `InputKey` — canonical, hashable representation of one keyboard or mouse
//! event. Used as the key in the keymap table (so users can write
//! `"ctrl-w h"` or `"mouse:click(pane)"` in TOML and we match the same string
//! at runtime).
//!
//! Also home to [`ChordState`], the small state machine that holds keys when
//! they extend a known prefix (`g` waiting for `t` to make `g t`).
use std::collections::HashSet;
use std::time::{Duration, Instant};
use crossterm::event::{
Event as CtEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
/// Canonical input event. `Char` carries modifier flags; specials get their
/// own variant. Mouse events carry a resolved `Region` so the keymap can bind
/// per-region (`mouse:click(pane)` vs `mouse:click(sidebar-row)`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InputKey {
Char {
c: char,
ctrl: bool,
alt: bool,
shift: bool,
},
Esc,
Enter,
Tab,
Backspace,
Up,
Down,
Left,
Right,
Mouse {
kind: MouseKind,
region: Region,
col: u16,
row: u16,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseKind {
Click,
DblClick,
Drag,
ScrollUp,
ScrollDown,
}
/// Regions the renderer tags so the input router can bind mouse events to
/// specific UI surfaces. The render layer fills in the `RegionMap` (added in
/// NAP-280); for now only the enum is needed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
Sidebar,
SidebarRow,
Tab,
Pane,
SplitDivider,
Modal,
Unknown,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InputParseError {
#[error("malformed input key {0:?}")]
Malformed(String),
#[error("unknown mouse kind {0:?}")]
UnknownMouseKind(String),
#[error("unknown region {0:?}")]
UnknownRegion(String),
}
impl InputKey {
/// Construct a bare-character key (no modifiers).
pub fn char(c: char) -> Self {
Self::Char {
c,
ctrl: false,
alt: false,
shift: false,
}
}
/// Construct a `ctrl-<c>` key.
pub fn ctrl_char(c: char) -> Self {
Self::Char {
c,
ctrl: true,
alt: false,
shift: false,
}
}
/// Canonical-string form used by the keymap table. Round-trips with
/// [`InputKey::parse`].
pub fn canonical(&self) -> String {
match self {
InputKey::Char {
c,
ctrl,
alt,
shift,
} => {
let mut out = String::new();
if *ctrl {
out.push_str("ctrl-");
}
if *alt {
out.push_str("alt-");
}
if *shift {
out.push_str("shift-");
}
out.push(*c);
out
}
InputKey::Esc => "esc".into(),
InputKey::Enter => "enter".into(),
InputKey::Tab => "tab".into(),
InputKey::Backspace => "backspace".into(),
InputKey::Up => "up".into(),
InputKey::Down => "down".into(),
InputKey::Left => "left".into(),
InputKey::Right => "right".into(),
InputKey::Mouse { kind, region, .. } => {
let k = match kind {
MouseKind::Click => "click",
MouseKind::DblClick => "dblclick",
MouseKind::Drag => "drag",
MouseKind::ScrollUp => "scrollup",
MouseKind::ScrollDown => "scrolldown",
};
let r = match region {
Region::Sidebar => "sidebar",
Region::SidebarRow => "sidebar-row",
Region::Tab => "tab",
Region::Pane => "pane",
Region::SplitDivider => "split-divider",
Region::Modal => "modal",
Region::Unknown => "unknown",
};
format!("mouse:{k}({r})")
}
}
}
/// Parse a canonical string into an [`InputKey`]. Mouse events parse with
/// `col = row = 0`; in practice mouse keys are only constructed from real
/// events, but parsing is needed so [`Keymap::set`](crate::keymap::Keymap::set)
/// can validate keymap entries written in TOML.
pub fn parse(s: &str) -> Result<Self, InputParseError> {
if let Some(rest) = s.strip_prefix("mouse:") {
let (k, rest) = rest
.split_once('(')
.ok_or_else(|| InputParseError::Malformed(s.into()))?;
let r = rest
.strip_suffix(')')
.ok_or_else(|| InputParseError::Malformed(s.into()))?;
let kind = match k {
"click" => MouseKind::Click,
"dblclick" => MouseKind::DblClick,
"drag" => MouseKind::Drag,
"scrollup" => MouseKind::ScrollUp,
"scrolldown" => MouseKind::ScrollDown,
other => return Err(InputParseError::UnknownMouseKind(other.into())),
};
let region = match r {
"sidebar" => Region::Sidebar,
"sidebar-row" => Region::SidebarRow,
"tab" => Region::Tab,
"pane" => Region::Pane,
"split-divider" => Region::SplitDivider,
"modal" => Region::Modal,
"unknown" => Region::Unknown,
other => return Err(InputParseError::UnknownRegion(other.into())),
};
return Ok(InputKey::Mouse {
kind,
region,
col: 0,
row: 0,
});
}
match s {
"esc" => return Ok(InputKey::Esc),
"enter" => return Ok(InputKey::Enter),
"tab" => return Ok(InputKey::Tab),
"backspace" => return Ok(InputKey::Backspace),
"up" => return Ok(InputKey::Up),
"down" => return Ok(InputKey::Down),
"left" => return Ok(InputKey::Left),
"right" => return Ok(InputKey::Right),
_ => {}
}
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
let mut ctrl = false;
let mut alt = false;
let mut shift = false;
let mut rest = s;
loop {
if let Some(r) = rest.strip_prefix("ctrl-") {
ctrl = true;
rest = r;
continue;
}
if let Some(r) = rest.strip_prefix("alt-") {
alt = true;
rest = r;
continue;
}
if let Some(r) = rest.strip_prefix("shift-") {
shift = true;
rest = r;
continue;
}
break;
}
let mut chars = rest.chars();
let c = chars
.next()
.ok_or_else(|| InputParseError::Malformed(s.into()))?;
if chars.next().is_some() {
return Err(InputParseError::Malformed(s.into()));
}
Ok(InputKey::Char {
c,
ctrl,
alt,
shift,
})
}
}
/// Convert a raw `crossterm` event into a canonical [`InputKey`]. `region_for`
/// is the renderer's hit-test closure (resolves a screen coordinate to one
/// tagged region).
pub fn canonicalise<F>(ev: &CtEvent, region_for: F) -> Option<InputKey>
where
F: FnOnce(u16, u16) -> Region,
{
match ev {
CtEvent::Key(KeyEvent {
code, modifiers, ..
}) => {
let ctrl = modifiers.contains(KeyModifiers::CONTROL);
let alt = modifiers.contains(KeyModifiers::ALT);
let shift = modifiers.contains(KeyModifiers::SHIFT);
Some(match code {
KeyCode::Char(c) => InputKey::Char {
c: *c,
ctrl,
alt,
shift,
},
KeyCode::Esc => InputKey::Esc,
KeyCode::Enter => InputKey::Enter,
KeyCode::Tab => InputKey::Tab,
KeyCode::Backspace => InputKey::Backspace,
KeyCode::Up => InputKey::Up,
KeyCode::Down => InputKey::Down,
KeyCode::Left => InputKey::Left,
KeyCode::Right => InputKey::Right,
_ => return None,
})
}
CtEvent::Mouse(MouseEvent {
kind, column, row, ..
}) => {
let mk = match kind {
MouseEventKind::Down(MouseButton::Left) => MouseKind::Click,
MouseEventKind::Drag(MouseButton::Left) => MouseKind::Drag,
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
_ => return None,
};
let region = region_for(*column, *row);
Some(InputKey::Mouse {
kind: mk,
region,
col: *column,
row: *row,
})
}
_ => None,
}
}
/// Result of feeding one key into [`ChordState`]. `Pending` = held because the
/// current sequence is a strict prefix of some known binding; `Resolved` =
/// dispatch this canonical string against the keymap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChordResult {
Pending,
Resolved(String),
}
/// Tracks the partial-chord state ("the user has pressed `g`, waiting up to
/// `timeout` for the next key"). Per-cycle: when a key comes in, decide
/// whether to hold or resolve.
#[derive(Debug)]
pub struct ChordState {
held: Vec<String>,
last_press: Option<Instant>,
timeout: Duration,
}
impl ChordState {
pub fn new(timeout: Duration) -> Self {
Self {
held: Vec::new(),
last_press: None,
timeout,
}
}
/// Feed one canonical key.
///
/// * `Pending` — the held sequence is a strict prefix of at least one
/// binding; wait for the next key.
/// * `Resolved(s)` — `s` is the canonical chord (or single key) to look
/// up in the keymap. The held buffer is cleared.
pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet<String>) -> ChordResult {
if let Some(t) = self.last_press {
if t.elapsed() > self.timeout {
self.held.clear();
}
}
let key_s = key.canonical();
let candidate = if self.held.is_empty() {
key_s.clone()
} else {
format!("{} {}", self.held.join(" "), key_s)
};
// A "strict prefix" check: is there a known binding that starts with
// `candidate + ' '`? If so, we should hold and wait for more.
let prefix_marker = format!("{candidate} ");
let is_prefix = known_bindings
.iter()
.any(|kb| kb.starts_with(&prefix_marker));
self.last_press = Some(Instant::now());
if is_prefix {
self.held.push(key_s);
ChordResult::Pending
} else {
self.held.clear();
ChordResult::Resolved(candidate)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips_simple_chars() {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
#[test]
fn canonical_round_trips_specials() {
for s in ["esc", "enter", "tab", "backspace", "up", "down", "left", "right"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
#[test]
fn canonical_round_trips_mouse() {
let s = "mouse:click(pane)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
let s = "mouse:dblclick(sidebar-row)";
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s);
}
#[test]
fn parse_rejects_unknown_modifier() {
assert_eq!(
InputKey::parse("ctrl-glorb").unwrap_err(),
InputParseError::Malformed("ctrl-glorb".into()),
);
}
#[test]
fn parse_rejects_unknown_mouse_kind() {
assert_eq!(
InputKey::parse("mouse:zoom(pane)").unwrap_err(),
InputParseError::UnknownMouseKind("zoom".into()),
);
}
#[test]
fn parse_rejects_unknown_region() {
assert_eq!(
InputKey::parse("mouse:click(margin)").unwrap_err(),
InputParseError::UnknownRegion("margin".into()),
);
}
#[test]
fn canonicalise_key_event() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
assert_eq!(k, InputKey::ctrl_char('a'));
}
#[test]
fn canonicalise_mouse_event_uses_region_callback() {
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
let ev = CtEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 5,
modifiers: KeyModifiers::NONE,
});
let k = canonicalise(&ev, |c, r| {
assert_eq!((c, r), (10, 5));
Region::Pane
})
.unwrap();
assert!(matches!(
k,
InputKey::Mouse {
kind: MouseKind::Click,
region: Region::Pane,
col: 10,
row: 5,
}
));
}
#[test]
fn chord_state_holds_known_prefix() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["g t".into()].into_iter().collect();
let r = chord.feed(&InputKey::char('g'), &known);
assert_eq!(r, ChordResult::Pending);
let r = chord.feed(&InputKey::char('t'), &known);
assert_eq!(r, ChordResult::Resolved("g t".into()));
}
#[test]
fn chord_state_drops_unknown_continuation() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["g t".into()].into_iter().collect();
chord.feed(&InputKey::char('g'), &known);
let r = chord.feed(&InputKey::char('x'), &known);
// 'g' was a prefix candidate, but 'g x' isn't a binding;
// resolver drops 'g' and treats 'x' as a fresh single-key resolve.
assert_eq!(r, ChordResult::Resolved("g x".into()));
}
#[test]
fn chord_state_lone_key_resolves_immediately() {
let mut chord = ChordState::new(Duration::from_secs(10));
let known: HashSet<String> = ["i".into(), "g t".into()].into_iter().collect();
let r = chord.feed(&InputKey::char('i'), &known);
// 'i' is a complete binding, not a strict prefix of anything → resolve.
assert_eq!(r, ChordResult::Resolved("i".into()));
}
}
+1
View File
@@ -10,6 +10,7 @@
pub mod action;
pub mod cli;
pub mod input;
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
/// `TuiArgs` plus any global flags.