819 lines
27 KiB
Rust
819 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,
|
|
Hover,
|
|
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::Hover => "hover",
|
|
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,
|
|
"hover" => MouseKind::Hover,
|
|
"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::Moved => MouseKind::Hover,
|
|
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);
|
|
let s = "mouse:hover(pane)";
|
|
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()));
|
|
}
|
|
}
|