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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user