diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs new file mode 100644 index 0000000..e99ec44 --- /dev/null +++ b/crates/maestro-tui/src/input.rs @@ -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-` 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 { + 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(ev: &CtEvent, region_for: F) -> Option +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, + last_press: Option, + 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) -> 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 = ["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 = ["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 = ["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())); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index ec0dc97..ae0b044 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -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.