//! `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, /// Shift-Tab. crossterm reports it as its own key code, not a modified Tab. BackTab, Backspace, Up, Down, Left, Right, Home, End, PageUp, PageDown, Delete, /// Bracketed paste: crossterm delivers the whole payload as one event, so /// text fields can insert it as a single operation. Paste(String), 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 { SidebarRow, Pane, 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-"); } // Uppercase already implies shift; crossterm sets the flag // on every such char, so skip the redundant prefix (#70). if *shift && !c.is_uppercase() { out.push_str("shift-"); } out.push(*c); out } InputKey::Esc => "esc".into(), InputKey::Enter => "enter".into(), InputKey::Tab => "tab".into(), InputKey::BackTab => "backtab".into(), InputKey::Backspace => "backspace".into(), InputKey::Up => "up".into(), InputKey::Down => "down".into(), InputKey::Left => "left".into(), InputKey::Right => "right".into(), InputKey::Home => "home".into(), InputKey::End => "end".into(), InputKey::PageUp => "pageup".into(), InputKey::PageDown => "pagedown".into(), InputKey::Delete => "delete".into(), // Pastes are not bindable; the canonical form exists only to // satisfy exhaustiveness and never round-trips through `parse`. InputKey::Paste(_) => "paste".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::SidebarRow => "sidebar-row", Region::Pane => "pane", 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-row" => Region::SidebarRow, "pane" => Region::Pane, "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), "backtab" => return Ok(InputKey::BackTab), "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), "home" => return Ok(InputKey::Home), "end" => return Ok(InputKey::End), "pageup" => return Ok(InputKey::PageUp), "pagedown" => return Ok(InputKey::PageDown), "delete" => return Ok(InputKey::Delete), _ => {} } // 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::BackTab => InputKey::BackTab, KeyCode::Backspace => InputKey::Backspace, KeyCode::Up => InputKey::Up, KeyCode::Down => InputKey::Down, KeyCode::Left => InputKey::Left, KeyCode::Right => InputKey::Right, KeyCode::Home => InputKey::Home, KeyCode::End => InputKey::End, KeyCode::PageUp => InputKey::PageUp, KeyCode::PageDown => InputKey::PageDown, KeyCode::Delete => InputKey::Delete, _ => return None, }) } CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())), 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, } } /// How long after a click a second click at the same cell counts as a /// double-click. Matches the common desktop default. pub const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(400); /// Remembers the last click so a second one at the same cell can be promoted to /// [`MouseKind::DblClick`]. /// /// Kept out of [`canonicalise`], which stays a pure function of one event. #[derive(Debug, Default)] pub struct ClickTracker { last: Option<(u16, u16, Instant)>, } impl ClickTracker { pub fn new() -> Self { Self::default() } /// Upgrade a `Click` to a `DblClick` when it *immediately* follows another /// click at the same cell inside [`DOUBLE_CLICK_WINDOW`]. /// /// Anything else passes through and clears the pending click: two clicks /// with a drag or a keystroke between them are two clicks, however fast. /// /// A promoted click also resets the tracker, so three rapid clicks are a /// click then a double-click, not two double-clicks. pub fn promote(&mut self, key: InputKey, now: Instant) -> InputKey { let InputKey::Mouse { kind: MouseKind::Click, region, col, row, } = key else { self.last = None; return key; }; let is_double = matches!( self.last, Some((c, r, at)) if c == col && r == row && now.duration_since(at) <= DOUBLE_CLICK_WINDOW ); if is_double { self.last = None; return InputKey::Mouse { kind: MouseKind::DblClick, region, col, row, }; } self.last = Some((col, row, now)); key } } /// 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, /// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting /// to be replayed as a fresh keystroke by the caller. replay: Option, } impl ChordState { pub fn new(timeout: Duration) -> Self { Self { held: Vec::new(), last_press: None, timeout, replay: None, } } /// 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. /// /// A held prefix that is itself directly bound (e.g. a leader key bound /// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves /// to that binding the moment the next key fails to extend it, rather /// than swallowing the held key and losing its meaning — the new key /// never belonged to the chord and is handed back via [`Self::take_replay`] /// so the caller can process it as its own keystroke. 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); return ChordResult::Pending; } if known_bindings.contains(&candidate) { self.held.clear(); return ChordResult::Resolved(candidate); } if !self.held.is_empty() { let held_str = self.held.join(" "); self.held.clear(); if known_bindings.contains(&held_str) { self.replay = Some(key.clone()); return ChordResult::Resolved(held_str); } } ChordResult::Resolved(candidate) } /// Take the key bumped out of an abandoned chord by the last [`Self::feed`] /// call, if any. The caller feeds it through again as a brand new /// keystroke. pub fn take_replay(&mut self) -> Option { self.replay.take() } } #[cfg(test)] mod tests { use super::*; fn click(col: u16, row: u16) -> InputKey { InputKey::Mouse { kind: MouseKind::Click, region: Region::SidebarRow, col, row, } } /// The shipped default binds `mouse:dblclick(sidebar-row)`. Nothing could /// produce a DblClick, so that binding could never fire. #[test] fn a_second_click_at_the_same_cell_becomes_a_double_click() { let mut t = ClickTracker::new(); let t0 = Instant::now(); assert_eq!(t.promote(click(3, 4), t0), click(3, 4)); assert_eq!( t.promote(click(3, 4), t0 + Duration::from_millis(100)), InputKey::Mouse { kind: MouseKind::DblClick, region: Region::SidebarRow, col: 3, row: 4, } ); } #[test] fn a_slow_second_click_stays_a_click() { let mut t = ClickTracker::new(); let t0 = Instant::now(); t.promote(click(3, 4), t0); let late = t0 + DOUBLE_CLICK_WINDOW + Duration::from_millis(1); assert_eq!(t.promote(click(3, 4), late), click(3, 4)); } #[test] fn a_second_click_elsewhere_stays_a_click() { let mut t = ClickTracker::new(); let t0 = Instant::now(); t.promote(click(3, 4), t0); assert_eq!( t.promote(click(9, 4), t0 + Duration::from_millis(10)), click(9, 4) ); } /// Three fast clicks are click, double-click, click — not two doubles. #[test] fn a_promoted_click_resets_the_tracker() { let mut t = ClickTracker::new(); let t0 = Instant::now(); t.promote(click(1, 1), t0); let second = t.promote(click(1, 1), t0 + Duration::from_millis(50)); assert!(matches!( second, InputKey::Mouse { kind: MouseKind::DblClick, .. } )); let third = t.promote(click(1, 1), t0 + Duration::from_millis(100)); assert_eq!(third, click(1, 1)); } #[test] fn non_click_events_pass_through_untouched() { let mut t = ClickTracker::new(); assert_eq!(t.promote(InputKey::Enter, Instant::now()), InputKey::Enter); } /// A drag between two clicks means the user dragged, not double-clicked — /// even at the same cell and well inside the window. #[test] fn a_drag_between_two_clicks_breaks_the_double() { let mut t = ClickTracker::new(); let t0 = Instant::now(); t.promote(click(3, 4), t0); let drag = InputKey::Mouse { kind: MouseKind::Drag, region: Region::SidebarRow, col: 3, row: 4, }; assert_eq!( t.promote(drag.clone(), t0 + Duration::from_millis(20)), drag ); assert_eq!( t.promote(click(3, 4), t0 + Duration::from_millis(40)), click(3, 4), "a drag must clear the pending click" ); } #[test] fn a_keystroke_between_two_clicks_breaks_the_double() { let mut t = ClickTracker::new(); let t0 = Instant::now(); t.promote(click(3, 4), t0); t.promote(InputKey::Enter, t0 + Duration::from_millis(20)); assert_eq!( t.promote(click(3, 4), t0 + Duration::from_millis(40)), click(3, 4) ); } /// A scroll also counts as an intervening event. #[test] fn a_scroll_between_two_clicks_breaks_the_double() { let mut t = ClickTracker::new(); let t0 = Instant::now(); t.promote(click(3, 4), t0); let scroll = InputKey::Mouse { kind: MouseKind::ScrollUp, region: Region::SidebarRow, col: 3, row: 4, }; t.promote(scroll, t0 + Duration::from_millis(20)); assert_eq!( t.promote(click(3, 4), t0 + Duration::from_millis(40)), click(3, 4) ); } #[test] fn back_tab_round_trips_through_the_canonical_form() { assert_eq!(InputKey::BackTab.canonical(), "backtab"); assert_eq!(InputKey::parse("backtab").unwrap(), InputKey::BackTab); } /// The parser must not accept region names the renderer cannot produce. #[test] fn unproducible_region_names_are_rejected() { assert!(InputKey::parse("mouse:click(sidebar)").is_err()); assert!(InputKey::parse("mouse:click(split-divider)").is_err()); assert!(InputKey::parse("mouse:click(sidebar-row)").is_ok()); } #[test] fn canonical_round_trips_simple_chars() { for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] { let k = InputKey::parse(s).unwrap(); assert_eq!(k.canonical(), s, "round-trip {s:?}"); } } /// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char, /// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its /// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` — /// the uppercase letter already says "shifted"; a redundant prefix would /// make every uppercase binding (the default `N`) unreachable from the /// keyboard (#70). #[test] fn shift_flag_is_redundant_for_an_already_uppercase_char() { let k = InputKey::Char { c: 'S', ctrl: false, alt: false, shift: true, }; assert_eq!(k.canonical(), "S"); } #[test] fn canonical_round_trips_specials() { for s in [ "esc", "enter", "tab", "backspace", "up", "down", "left", "right", "home", "end", "pageup", "pagedown", "delete", ] { let k = InputKey::parse(s).unwrap(); assert_eq!(k.canonical(), s, "round-trip {s:?}"); } } /// A bracketed paste must arrive as one key carrying the whole payload, /// not a burst of per-character keys. #[test] fn canonicalise_paste_event_carries_the_whole_payload() { use crossterm::event::Event as CtEvent; let ev = CtEvent::Paste("/srv/work/maestro".into()); let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap(); assert_eq!(k, InputKey::Paste("/srv/work/maestro".into())); } #[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())); } /// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and /// `ctrl-a ctrl-k`) must still fire its own binding when the next key /// doesn't complete the chord — the held key's meaning isn't lost, and /// the new key comes back via `take_replay` for the caller to process on /// its own (#156). #[test] fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() { let mut chord = ChordState::new(Duration::from_secs(10)); let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] .into_iter() .collect(); let r = chord.feed(&InputKey::ctrl_char('a'), &known); assert_eq!(r, ChordResult::Pending); let r = chord.feed(&InputKey::char('j'), &known); assert_eq!(r, ChordResult::Resolved("ctrl-a".into())); assert_eq!(chord.take_replay(), Some(InputKey::char('j'))); } /// The full chord still resolves normally when the next key completes /// it — no replay, no early fire of the shorter binding. #[test] fn a_completed_chord_off_a_bound_prefix_has_no_replay() { let mut chord = ChordState::new(Duration::from_secs(10)); let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] .into_iter() .collect(); chord.feed(&InputKey::ctrl_char('a'), &known); let r = chord.feed(&InputKey::ctrl_char('k'), &known); assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into())); assert_eq!(chord.take_replay(), None); } #[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())); } }