crossterm attaches KeyModifiers::SHIFT to every uppercase char, so a real
Shift+S delivered Char { c: 'S', shift: true }. canonical() turned that
into "shift-S", which the default "S" binding never matched, so
FastForwardSpawn was unreachable from the keyboard.
canonical() now drops the shift- prefix when the char is already
uppercase, since the case already says "shifted". Regression test drives
an actual crossterm KeyEvent through canonicalise() and the keymap,
rather than asserting on the binding string, per #70.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
738 lines
24 KiB
Rust
738 lines
24 KiB
Rust
//! `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,
|
|
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,
|
|
Tab,
|
|
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-<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-");
|
|
}
|
|
// 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::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::Tab => "tab",
|
|
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<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-row" => Region::SidebarRow,
|
|
"tab" => Region::Tab,
|
|
"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),
|
|
"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<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::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::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<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::*;
|
|
|
|
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 the default `S` binding 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",
|
|
"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<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()));
|
|
}
|
|
}
|