ctrl-k now opens the command bar in both modes (#153), taking readline's kill-to-end-of-line away from every pane. Leader then ctrl-k — ctrl-a ctrl-k by default — sends the literal 0x0B byte to the focused pane instead, built from the configured leader so rebinding it rebinds the chord too. Chording a second binding off the leader exposed a gap in ChordState: a key bound both alone and as a chord prefix (ctrl-a alone, and ctrl-a ctrl-k) would get stuck waiting on the next keystroke, discarding its own meaning the moment that keystroke didn't continue the chord. Feed now resolves the held prefix to its own binding when that happens and hands the breaking key back via take_replay for the caller to process fresh — otherwise, pressing leader then any other key would never leave Insert mode again.
812 lines
27 KiB
Rust
812 lines
27 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,
|
|
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-<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::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<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,
|
|
"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<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::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<String>,
|
|
last_press: Option<Instant>,
|
|
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<InputKey>,
|
|
}
|
|
|
|
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<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);
|
|
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<InputKey> {
|
|
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<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()));
|
|
}
|
|
|
|
/// 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<String> = ["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<String> = ["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<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()));
|
|
}
|
|
}
|