#![forbid(unsafe_code)]
use web_time::{Duration, Instant};
use crate::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
pub const DEFAULT_ESC_SEQ_TIMEOUT_MS: u64 = 250;
pub const MIN_ESC_SEQ_TIMEOUT_MS: u64 = 150;
pub const MAX_ESC_SEQ_TIMEOUT_MS: u64 = 400;
pub const DEFAULT_ESC_DEBOUNCE_MS: u64 = 50;
pub const MIN_ESC_DEBOUNCE_MS: u64 = 0;
pub const MAX_ESC_DEBOUNCE_MS: u64 = 100;
#[derive(Debug, Clone)]
pub struct SequenceConfig {
pub esc_seq_timeout: Duration,
pub esc_debounce: Duration,
pub disable_sequences: bool,
}
impl Default for SequenceConfig {
fn default() -> Self {
Self {
esc_seq_timeout: Duration::from_millis(DEFAULT_ESC_SEQ_TIMEOUT_MS),
esc_debounce: Duration::from_millis(DEFAULT_ESC_DEBOUNCE_MS),
disable_sequences: false,
}
}
}
impl SequenceConfig {
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.esc_seq_timeout = timeout;
self
}
#[must_use]
pub fn with_debounce(mut self, debounce: Duration) -> Self {
self.esc_debounce = debounce;
self
}
#[must_use]
pub fn disable_sequences(mut self) -> Self {
self.disable_sequences = true;
self
}
#[must_use]
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(val) = std::env::var("FTUI_ESC_SEQ_TIMEOUT_MS")
&& let Ok(ms) = val.parse::<u64>()
{
config.esc_seq_timeout = Duration::from_millis(ms);
}
if let Ok(val) = std::env::var("FTUI_ESC_DEBOUNCE_MS")
&& let Ok(ms) = val.parse::<u64>()
{
config.esc_debounce = Duration::from_millis(ms);
}
if let Ok(val) = std::env::var("FTUI_DISABLE_ESC_SEQ") {
config.disable_sequences = val == "1" || val.eq_ignore_ascii_case("true");
}
config.validated()
}
#[must_use]
pub fn validated(mut self) -> Self {
let timeout_ms = self.esc_seq_timeout.as_millis() as u64;
let clamped_timeout = timeout_ms.clamp(MIN_ESC_SEQ_TIMEOUT_MS, MAX_ESC_SEQ_TIMEOUT_MS);
self.esc_seq_timeout = Duration::from_millis(clamped_timeout);
let debounce_ms = self.esc_debounce.as_millis() as u64;
let clamped_debounce = debounce_ms.clamp(MIN_ESC_DEBOUNCE_MS, MAX_ESC_DEBOUNCE_MS);
let final_debounce = clamped_debounce.min(clamped_timeout);
self.esc_debounce = Duration::from_millis(final_debounce);
self
}
#[must_use]
pub fn is_valid(&self) -> bool {
let timeout_ms = self.esc_seq_timeout.as_millis() as u64;
let debounce_ms = self.esc_debounce.as_millis() as u64;
(MIN_ESC_SEQ_TIMEOUT_MS..=MAX_ESC_SEQ_TIMEOUT_MS).contains(&timeout_ms)
&& (MIN_ESC_DEBOUNCE_MS..=MAX_ESC_DEBOUNCE_MS).contains(&debounce_ms)
&& debounce_ms <= timeout_ms
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SequenceOutput {
Pending,
Esc,
EscEsc,
PassThrough,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectorState {
Idle,
AwaitingSecondEsc { first_esc_time: Instant },
}
#[derive(Debug)]
pub struct SequenceDetector {
config: SequenceConfig,
state: DetectorState,
}
impl SequenceDetector {
#[must_use]
pub fn new(config: SequenceConfig) -> Self {
Self {
config,
state: DetectorState::Idle,
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(SequenceConfig::default())
}
pub fn feed(&mut self, event: &KeyEvent, now: Instant) -> SequenceOutput {
if event.kind != KeyEventKind::Press {
return SequenceOutput::PassThrough;
}
if self.config.disable_sequences {
return if event.code == KeyCode::Escape {
SequenceOutput::Esc
} else {
SequenceOutput::PassThrough
};
}
match self.state {
DetectorState::Idle => {
if event.code == KeyCode::Escape {
self.state = DetectorState::AwaitingSecondEsc {
first_esc_time: now,
};
SequenceOutput::Pending
} else {
SequenceOutput::PassThrough
}
}
DetectorState::AwaitingSecondEsc { first_esc_time } => {
let elapsed = now.saturating_duration_since(first_esc_time);
if event.code == KeyCode::Escape {
if elapsed <= self.config.esc_seq_timeout {
self.state = DetectorState::Idle;
SequenceOutput::EscEsc
} else {
self.state = DetectorState::AwaitingSecondEsc {
first_esc_time: now,
};
SequenceOutput::Esc
}
} else {
self.state = DetectorState::Idle;
SequenceOutput::Esc
}
}
}
}
pub fn check_timeout(&mut self, now: Instant) -> Option<SequenceOutput> {
if let DetectorState::AwaitingSecondEsc { first_esc_time } = self.state {
let elapsed = now.saturating_duration_since(first_esc_time);
if elapsed > self.config.esc_seq_timeout {
self.state = DetectorState::Idle;
return Some(SequenceOutput::Esc);
}
}
None
}
#[must_use]
pub fn is_pending(&self) -> bool {
matches!(self.state, DetectorState::AwaitingSecondEsc { .. })
}
pub fn reset(&mut self) {
self.state = DetectorState::Idle;
}
#[must_use]
pub fn config(&self) -> &SequenceConfig {
&self.config
}
pub fn set_config(&mut self, config: SequenceConfig) {
self.config = config;
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AppState {
pub input_nonempty: bool,
pub task_running: bool,
pub modal_open: bool,
pub view_overlay: bool,
}
impl AppState {
#[must_use]
pub const fn new() -> Self {
Self {
input_nonempty: false,
task_running: false,
modal_open: false,
view_overlay: false,
}
}
#[must_use]
pub const fn with_input(mut self, nonempty: bool) -> Self {
self.input_nonempty = nonempty;
self
}
#[must_use]
pub const fn with_task(mut self, running: bool) -> Self {
self.task_running = running;
self
}
#[must_use]
pub const fn with_modal(mut self, open: bool) -> Self {
self.modal_open = open;
self
}
#[must_use]
pub const fn with_overlay(mut self, active: bool) -> Self {
self.view_overlay = active;
self
}
#[must_use]
pub const fn is_idle(&self) -> bool {
!self.input_nonempty && !self.task_running && !self.modal_open
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
ClearInput,
CancelTask,
DismissModal,
CloseOverlay,
ToggleTreeView,
Quit,
SoftQuit,
HardQuit,
Bell,
PassThrough,
}
impl Action {
#[must_use]
pub const fn consumes_event(&self) -> bool {
!matches!(self, Action::PassThrough)
}
#[must_use]
pub const fn is_quit(&self) -> bool {
matches!(self, Action::Quit | Action::SoftQuit | Action::HardQuit)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CtrlCIdleAction {
#[default]
Quit,
Noop,
Bell,
}
impl CtrlCIdleAction {
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"quit" => Some(Self::Quit),
"noop" | "none" | "ignore" => Some(Self::Noop),
"bell" | "beep" => Some(Self::Bell),
_ => None,
}
}
#[must_use]
pub const fn to_action(self) -> Option<Action> {
match self {
Self::Quit => Some(Action::Quit),
Self::Noop => None,
Self::Bell => Some(Action::Bell),
}
}
}
#[derive(Debug, Clone)]
pub struct ActionConfig {
pub sequence_config: SequenceConfig,
pub ctrl_c_idle_action: CtrlCIdleAction,
}
impl Default for ActionConfig {
fn default() -> Self {
Self {
sequence_config: SequenceConfig::default(),
ctrl_c_idle_action: CtrlCIdleAction::Quit,
}
}
}
impl ActionConfig {
#[must_use]
pub fn with_sequence_config(mut self, config: SequenceConfig) -> Self {
self.sequence_config = config;
self
}
#[must_use]
pub fn with_ctrl_c_idle(mut self, action: CtrlCIdleAction) -> Self {
self.ctrl_c_idle_action = action;
self
}
#[must_use]
pub fn from_env() -> Self {
let mut config = Self {
sequence_config: SequenceConfig::from_env(),
ctrl_c_idle_action: CtrlCIdleAction::Quit,
};
if let Ok(val) = std::env::var("FTUI_CTRL_C_IDLE_ACTION")
&& let Some(action) = CtrlCIdleAction::from_str_opt(&val)
{
config.ctrl_c_idle_action = action;
}
config
}
#[must_use]
pub fn validated(mut self) -> Self {
self.sequence_config = self.sequence_config.validated();
self
}
}
#[derive(Debug)]
pub struct ActionMapper {
config: ActionConfig,
sequence_detector: SequenceDetector,
}
impl ActionMapper {
#[must_use]
pub fn new(config: ActionConfig) -> Self {
let sequence_detector = SequenceDetector::new(config.sequence_config.clone());
Self {
config,
sequence_detector,
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(ActionConfig::default())
}
#[must_use]
pub fn from_env() -> Self {
Self::new(ActionConfig::from_env())
}
pub fn map(&mut self, event: &KeyEvent, state: &AppState, now: Instant) -> Option<Action> {
if event.kind != KeyEventKind::Press {
return Some(Action::PassThrough);
}
if event.modifiers.contains(Modifiers::CTRL)
&& let KeyCode::Char(c) = event.code
{
match c.to_ascii_lowercase() {
'c' => return self.resolve_ctrl_c(state),
'd' => return Some(Action::SoftQuit),
'q' => return Some(Action::HardQuit),
_ => {}
}
}
if event.code == KeyCode::Escape && event.modifiers == Modifiers::NONE {
return self.handle_esc_sequence(state, now);
}
let seq_output = self.sequence_detector.feed(event, now);
match seq_output {
SequenceOutput::Esc => {
self.resolve_single_esc(state)
}
SequenceOutput::Pending => {
Some(Action::PassThrough)
}
SequenceOutput::EscEsc => {
Some(Action::ToggleTreeView)
}
SequenceOutput::PassThrough => Some(Action::PassThrough),
}
}
fn handle_esc_sequence(&mut self, state: &AppState, now: Instant) -> Option<Action> {
let esc_event = KeyEvent::new(KeyCode::Escape);
let output = self.sequence_detector.feed(&esc_event, now);
match output {
SequenceOutput::Pending => {
None
}
SequenceOutput::Esc => {
self.resolve_single_esc(state)
}
SequenceOutput::EscEsc => {
Some(Action::ToggleTreeView)
}
SequenceOutput::PassThrough => {
Some(Action::PassThrough)
}
}
}
fn resolve_ctrl_c(&self, state: &AppState) -> Option<Action> {
if state.modal_open {
return Some(Action::DismissModal);
}
if state.input_nonempty {
return Some(Action::ClearInput);
}
if state.task_running {
return Some(Action::CancelTask);
}
self.config.ctrl_c_idle_action.to_action()
}
fn resolve_single_esc(&self, state: &AppState) -> Option<Action> {
if state.modal_open {
return Some(Action::DismissModal);
}
if state.view_overlay {
return Some(Action::CloseOverlay);
}
if state.input_nonempty {
return Some(Action::ClearInput);
}
if state.task_running {
return Some(Action::CancelTask);
}
Some(Action::PassThrough)
}
pub fn check_timeout(&mut self, state: &AppState, now: Instant) -> Option<Action> {
if let Some(SequenceOutput::Esc) = self.sequence_detector.check_timeout(now) {
return self.resolve_single_esc(state);
}
None
}
#[must_use]
pub fn is_pending_esc(&self) -> bool {
self.sequence_detector.is_pending()
}
pub fn reset(&mut self) {
self.sequence_detector.reset();
}
#[must_use]
pub fn config(&self) -> &ActionConfig {
&self.config
}
pub fn set_config(&mut self, config: ActionConfig) {
self.sequence_detector
.set_config(config.sequence_config.clone());
self.config = config;
}
}
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyParseError {
EmptyKey,
UnknownKey(String),
UnknownModifier(String),
TooManyKeys(usize),
}
impl fmt::Display for KeyParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyKey => f.write_str("empty key"),
Self::UnknownKey(name) => write!(f, "unknown key `{name}`"),
Self::UnknownModifier(name) => write!(f, "unknown modifier `{name}`"),
Self::TooManyKeys(n) => {
write!(f, "chord has {n} keys; the maximum is {}", Chord::MAX_LEN)
}
}
}
}
impl std::error::Error for KeyParseError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyCombo {
pub code: KeyCode,
pub modifiers: Modifiers,
}
impl KeyCombo {
#[must_use]
pub fn new(code: KeyCode, modifiers: Modifiers) -> Self {
match code {
KeyCode::Char(c) if c.is_alphabetic() && c.is_uppercase() => Self {
code: KeyCode::Char(c.to_lowercase().next().unwrap_or(c)),
modifiers: modifiers | Modifiers::SHIFT,
},
_ => Self { code, modifiers },
}
}
#[must_use]
pub fn key(code: KeyCode) -> Self {
Self::new(code, Modifiers::NONE)
}
#[must_use]
pub fn from_event(event: &KeyEvent) -> Self {
Self::new(event.code, event.modifiers)
}
#[must_use]
pub fn matches(&self, event: &KeyEvent) -> bool {
Self::from_event(event) == *self
}
}
impl fmt::Display for KeyCombo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut modifiers = self.modifiers;
let key = match self.code {
KeyCode::Char(c) if c.is_alphabetic() && modifiers.contains(Modifiers::SHIFT) => {
modifiers.remove(Modifiers::SHIFT);
c.to_uppercase().collect::<String>()
}
KeyCode::Char(' ') => "Space".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Escape => "Esc".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::BackTab => "BackTab".to_string(),
KeyCode::Delete => "Delete".to_string(),
KeyCode::Insert => "Insert".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PageUp".to_string(),
KeyCode::PageDown => "PageDown".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::F(n) => format!("F{n}"),
KeyCode::Null => "Null".to_string(),
KeyCode::MediaPlayPause => "MediaPlayPause".to_string(),
KeyCode::MediaStop => "MediaStop".to_string(),
KeyCode::MediaNextTrack => "MediaNextTrack".to_string(),
KeyCode::MediaPrevTrack => "MediaPrevTrack".to_string(),
};
for (flag, name) in [
(Modifiers::CTRL, "Ctrl"),
(Modifiers::ALT, "Alt"),
(Modifiers::SHIFT, "Shift"),
(Modifiers::SUPER, "Super"),
] {
if modifiers.contains(flag) {
write!(f, "{name}+")?;
}
}
f.write_str(&key)
}
}
fn parse_key_name(name: &str) -> Result<KeyCode, KeyParseError> {
let mut chars = name.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
return Ok(KeyCode::Char(c));
}
let lower = name.to_ascii_lowercase();
let code = match lower.as_str() {
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Escape,
"backspace" => KeyCode::Backspace,
"tab" => KeyCode::Tab,
"backtab" => KeyCode::BackTab,
"delete" | "del" => KeyCode::Delete,
"insert" | "ins" => KeyCode::Insert,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" => KeyCode::PageDown,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"space" => KeyCode::Char(' '),
"null" => KeyCode::Null,
"mediaplaypause" => KeyCode::MediaPlayPause,
"mediastop" => KeyCode::MediaStop,
"medianexttrack" => KeyCode::MediaNextTrack,
"mediaprevtrack" => KeyCode::MediaPrevTrack,
other => {
if let Some(digits) = other.strip_prefix('f')
&& let Ok(n) = digits.parse::<u8>()
&& (1..=24).contains(&n)
{
KeyCode::F(n)
} else {
return Err(KeyParseError::UnknownKey(name.to_string()));
}
}
};
Ok(code)
}
impl FromStr for KeyCombo {
type Err = KeyParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
if s.is_empty() {
return Err(KeyParseError::EmptyKey);
}
let (modifier_part, key_part) = if s == "+" {
("", "+")
} else if let Some(stripped) = s.strip_suffix('+') {
(stripped.trim_end_matches('+'), "+")
} else if let Some((modifiers, key)) = s.rsplit_once('+') {
(modifiers, key)
} else {
("", s)
};
let mut modifiers = Modifiers::NONE;
for part in modifier_part.split('+').filter(|p| !p.is_empty()) {
modifiers |= match part.to_ascii_lowercase().as_str() {
"ctrl" | "control" => Modifiers::CTRL,
"alt" | "opt" | "option" => Modifiers::ALT,
"shift" => Modifiers::SHIFT,
"super" | "cmd" | "meta" | "win" => Modifiers::SUPER,
_ => return Err(KeyParseError::UnknownModifier(part.to_string())),
};
}
if key_part.is_empty() {
return Err(KeyParseError::EmptyKey);
}
Ok(Self::new(parse_key_name(key_part)?, modifiers))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Chord(Vec<KeyCombo>);
impl Chord {
pub const MAX_LEN: usize = 4;
#[must_use]
pub fn single(combo: KeyCombo) -> Self {
Self(vec![combo])
}
pub fn new(combos: Vec<KeyCombo>) -> Result<Self, KeyParseError> {
if combos.is_empty() {
Err(KeyParseError::EmptyKey)
} else if combos.len() > Self::MAX_LEN {
Err(KeyParseError::TooManyKeys(combos.len()))
} else {
Ok(Self(combos))
}
}
pub fn parse(s: &str) -> Result<Self, KeyParseError> {
s.parse()
}
#[must_use]
pub fn combos(&self) -> &[KeyCombo] {
&self.0
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn is_prefix_of(&self, other: &Self) -> bool {
self.0.len() < other.0.len() && other.0.starts_with(&self.0)
}
}
impl FromStr for Chord {
type Err = KeyParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let combos = s
.split_whitespace()
.map(str::parse)
.collect::<Result<Vec<KeyCombo>, _>>()?;
Self::new(combos)
}
}
impl fmt::Display for Chord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, combo) in self.0.iter().enumerate() {
if i > 0 {
f.write_str(" ")?;
}
write!(f, "{combo}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Priority {
#[default]
Global = 0,
Mode = 1,
Widget = 2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ContextId(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BindingId(pub u32);
impl fmt::Display for BindingId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{}", self.0)
}
}
#[derive(Debug, Clone)]
pub struct Binding<A> {
pub id: BindingId,
pub chord: Chord,
pub action: A,
pub priority: Priority,
pub context: Option<ContextId>,
pub label: Option<String>,
}
pub const MIN_CHORD_TIMEOUT_MS: u64 = 200;
pub const MAX_CHORD_TIMEOUT_MS: u64 = 5000;
pub const DEFAULT_CHORD_TIMEOUT_MS: u64 = 1000;
#[derive(Debug, Clone)]
pub struct KeyMapConfig {
pub chord_timeout: Duration,
pub esc: SequenceConfig,
}
impl Default for KeyMapConfig {
fn default() -> Self {
Self {
chord_timeout: Duration::from_millis(DEFAULT_CHORD_TIMEOUT_MS),
esc: SequenceConfig::default(),
}
}
}
impl KeyMapConfig {
#[must_use]
pub fn with_chord_timeout(mut self, timeout: Duration) -> Self {
let ms = timeout.as_millis().clamp(
u128::from(MIN_CHORD_TIMEOUT_MS),
u128::from(MAX_CHORD_TIMEOUT_MS),
);
self.chord_timeout = Duration::from_millis(ms as u64);
self
}
#[must_use]
pub fn with_esc(mut self, esc: SequenceConfig) -> Self {
self.esc = esc;
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct Lookup<'a, A> {
pub exact: Option<&'a Binding<A>>,
pub longer: usize,
}
impl<A> Lookup<'_, A> {
#[must_use]
pub fn is_none(&self) -> bool {
self.exact.is_none() && self.longer == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Conflict {
Shadowed {
winner: BindingId,
loser: BindingId,
chord: Chord,
},
PrefixCollision {
short: BindingId,
long: BindingId,
short_chord: Chord,
long_chord: Chord,
},
Duplicate {
first: BindingId,
second: BindingId,
chord: Chord,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConflictReport {
pub items: Vec<Conflict>,
}
impl ConflictReport {
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
}
impl fmt::Display for ConflictReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for item in &self.items {
match item {
Conflict::Shadowed {
winner,
loser,
chord,
} => writeln!(
f,
"warning: binding {winner} shadows binding {loser} on `{chord}` (higher priority)"
)?,
Conflict::PrefixCollision {
short,
long,
short_chord,
long_chord,
} => writeln!(
f,
"warning: binding {short} (`{short_chord}`) is a prefix of binding {long} (`{long_chord}`); it fires only after the chord timeout or a non-extending key"
)?,
Conflict::Duplicate {
first,
second,
chord,
} => writeln!(
f,
"warning: bindings {first} and {second} both bind `{chord}` at the same priority; the later one wins"
)?,
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct KeyMap<A> {
bindings: Vec<Binding<A>>,
contexts: Vec<String>,
config: KeyMapConfig,
next_id: u32,
}
impl<A> Default for KeyMap<A> {
fn default() -> Self {
Self::new()
}
}
impl<A> KeyMap<A> {
#[must_use]
pub fn new() -> Self {
Self::with_config(KeyMapConfig::default())
}
#[must_use]
pub fn with_config(config: KeyMapConfig) -> Self {
Self {
bindings: Vec::new(),
contexts: Vec::new(),
config,
next_id: 0,
}
}
#[must_use]
pub fn config(&self) -> &KeyMapConfig {
&self.config
}
pub fn context(&mut self, name: &str) -> ContextId {
if let Some(index) = self.contexts.iter().position(|n| n == name) {
return ContextId(index as u32);
}
self.contexts.push(name.to_string());
ContextId((self.contexts.len() - 1) as u32)
}
#[must_use]
pub fn context_name(&self, id: ContextId) -> Option<&str> {
self.contexts.get(id.0 as usize).map(String::as_str)
}
pub fn bind(&mut self, chord: Chord, action: A) -> BindingId {
self.bind_in(chord, action, Priority::Global, None)
}
pub fn bind_in(
&mut self,
chord: Chord,
action: A,
priority: Priority,
context: Option<ContextId>,
) -> BindingId {
let id = BindingId(self.next_id);
self.next_id += 1;
self.bindings.push(Binding {
id,
chord,
action,
priority,
context,
label: None,
});
id
}
pub fn set_label(&mut self, id: BindingId, label: impl Into<String>) -> bool {
match self.bindings.iter_mut().find(|b| b.id == id) {
Some(binding) => {
binding.label = Some(label.into());
true
}
None => false,
}
}
pub fn unbind(&mut self, id: BindingId) -> Option<Binding<A>> {
let index = self.bindings.iter().position(|b| b.id == id)?;
Some(self.bindings.remove(index))
}
#[must_use]
pub fn bindings(&self) -> &[Binding<A>] {
&self.bindings
}
#[must_use]
pub fn get(&self, id: BindingId) -> Option<&Binding<A>> {
self.bindings.iter().find(|b| b.id == id)
}
#[must_use]
pub fn len(&self) -> usize {
self.bindings.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.bindings.is_empty()
}
fn applies(binding: &Binding<A>, active: &[ContextId]) -> bool {
binding
.context
.is_none_or(|context| active.contains(&context))
}
fn rank(binding: &Binding<A>) -> (bool, Priority, BindingId) {
(binding.context.is_some(), binding.priority, binding.id)
}
#[must_use]
pub fn lookup(&self, chord: &Chord, active: &[ContextId]) -> Lookup<'_, A> {
let mut exact: Option<&Binding<A>> = None;
let mut longer = 0;
for binding in &self.bindings {
if !Self::applies(binding, active) {
continue;
}
if binding.chord == *chord {
if exact.is_none_or(|current| Self::rank(binding) > Self::rank(current)) {
exact = Some(binding);
}
} else if chord.is_prefix_of(&binding.chord) {
longer += 1;
}
}
Lookup { exact, longer }
}
#[must_use]
pub fn conflicts(&self) -> ConflictReport {
let mut items = Vec::new();
for (i, a) in self.bindings.iter().enumerate() {
for b in &self.bindings[i + 1..] {
if a.chord == b.chord {
if a.context != b.context {
continue;
}
if a.priority == b.priority {
items.push(Conflict::Duplicate {
first: a.id,
second: b.id,
chord: a.chord.clone(),
});
} else {
let (winner, loser) = if a.priority > b.priority {
(a.id, b.id)
} else {
(b.id, a.id)
};
items.push(Conflict::Shadowed {
winner,
loser,
chord: a.chord.clone(),
});
}
} else if a.chord.is_prefix_of(&b.chord) {
items.push(Conflict::PrefixCollision {
short: a.id,
long: b.id,
short_chord: a.chord.clone(),
long_chord: b.chord.clone(),
});
} else if b.chord.is_prefix_of(&a.chord) {
items.push(Conflict::PrefixCollision {
short: b.id,
long: a.id,
short_chord: b.chord.clone(),
long_chord: a.chord.clone(),
});
}
}
}
ConflictReport { items }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Dispatch<A> {
Action {
action: A,
binding: BindingId,
chord: Chord,
},
Pending { prefix: Chord },
Unbound(KeyEvent),
Expired { prefix: Chord },
Esc(SequenceOutput),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DispatchStats {
pub dispatched: u64,
pub pending: u64,
pub expired: u64,
pub unbound: u64,
pub esc: u64,
}
fn action_dispatch<A: Clone>(binding: &Binding<A>, chord: Chord) -> Dispatch<A> {
Dispatch::Action {
action: binding.action.clone(),
binding: binding.id,
chord,
}
}
#[derive(Debug)]
pub struct KeyDispatcher<A> {
map: KeyMap<A>,
pending: Vec<KeyCombo>,
pending_since: Option<Instant>,
esc: SequenceDetector,
active_contexts: Vec<ContextId>,
stats: DispatchStats,
}
impl<A: Clone> KeyDispatcher<A> {
#[must_use]
pub fn new(map: KeyMap<A>) -> Self {
let esc = SequenceDetector::new(map.config().esc.clone());
Self {
map,
pending: Vec::new(),
pending_since: None,
esc,
active_contexts: Vec::new(),
stats: DispatchStats::default(),
}
}
#[must_use]
pub fn map(&self) -> &KeyMap<A> {
&self.map
}
pub fn map_mut(&mut self) -> &mut KeyMap<A> {
&mut self.map
}
pub fn set_active_contexts(&mut self, contexts: &[ContextId]) {
self.active_contexts.clear();
self.active_contexts.extend_from_slice(contexts);
}
#[must_use]
pub fn active_contexts(&self) -> &[ContextId] {
&self.active_contexts
}
#[must_use]
pub fn pending_prefix(&self) -> Option<Chord> {
Chord::new(self.pending.clone()).ok()
}
#[must_use]
pub fn stats(&self) -> DispatchStats {
self.stats
}
pub fn reset(&mut self) {
self.pending.clear();
self.pending_since = None;
self.esc.reset();
}
pub fn feed(&mut self, key: &KeyEvent, now: Instant) -> Vec<Dispatch<A>> {
let mut out = Vec::with_capacity(2);
if key.code == KeyCode::Escape {
if key.kind == KeyEventKind::Press {
self.flush_pending(&mut out, false);
}
match self.esc.feed(key, now) {
SequenceOutput::PassThrough => {}
output => {
self.dispatch_esc(output, &mut out);
return out;
}
}
}
match key.kind {
KeyEventKind::Release => {
self.stats.unbound += 1;
out.push(Dispatch::Unbound(*key));
return out;
}
KeyEventKind::Repeat => {
let single = Chord::single(KeyCombo::from_event(key));
let fired = if self.pending.is_empty() {
self.map
.lookup(&single, &self.active_contexts)
.exact
.map(|binding| action_dispatch(binding, single))
} else {
None
};
match fired {
Some(dispatch) => {
self.stats.dispatched += 1;
out.push(dispatch);
}
None => {
self.stats.unbound += 1;
out.push(Dispatch::Unbound(*key));
}
}
return out;
}
KeyEventKind::Press => {}
}
let combo = KeyCombo::from_event(key);
if self.try_extend(combo, now, &mut out) {
return out;
}
if !self.pending.is_empty() {
self.flush_pending(&mut out, false);
if self.try_extend(combo, now, &mut out) {
return out;
}
}
self.stats.unbound += 1;
out.push(Dispatch::Unbound(*key));
out
}
pub fn tick(&mut self, now: Instant) -> Vec<Dispatch<A>> {
let mut out = Vec::new();
if let Some(since) = self.pending_since
&& now.saturating_duration_since(since) >= self.map.config.chord_timeout
{
self.flush_pending(&mut out, true);
}
if let Some(output) = self.esc.check_timeout(now) {
self.dispatch_esc(output, &mut out);
}
out
}
fn try_extend(&mut self, combo: KeyCombo, now: Instant, out: &mut Vec<Dispatch<A>>) -> bool {
if self.pending.len() >= Chord::MAX_LEN {
return false;
}
let mut candidate = self.pending.clone();
candidate.push(combo);
let chord = Chord(candidate);
let lookup = self.map.lookup(&chord, &self.active_contexts);
if let Some(binding) = lookup.exact
&& lookup.longer == 0
{
let dispatch = action_dispatch(binding, chord);
self.pending.clear();
self.pending_since = None;
self.stats.dispatched += 1;
out.push(dispatch);
return true;
}
if lookup.exact.is_some() || lookup.longer > 0 {
self.pending.clone_from(&chord.0);
self.pending_since = Some(now);
self.stats.pending += 1;
out.push(Dispatch::Pending { prefix: chord });
return true;
}
false
}
fn flush_pending(&mut self, out: &mut Vec<Dispatch<A>>, timed_out: bool) {
if self.pending.is_empty() {
return;
}
let prefix = Chord(std::mem::take(&mut self.pending));
self.pending_since = None;
let fired = self
.map
.lookup(&prefix, &self.active_contexts)
.exact
.map(|binding| action_dispatch(binding, prefix.clone()));
match fired {
Some(dispatch) => {
if timed_out {
self.stats.expired += 1;
out.push(Dispatch::Expired { prefix });
}
self.stats.dispatched += 1;
out.push(dispatch);
}
None => {
self.stats.expired += 1;
out.push(Dispatch::Expired { prefix });
}
}
}
fn dispatch_esc(&mut self, output: SequenceOutput, out: &mut Vec<Dispatch<A>>) {
let esc = KeyCombo::key(KeyCode::Escape);
let bound = match output {
SequenceOutput::Esc => Some(Chord::single(esc)),
SequenceOutput::EscEsc => Chord::new(vec![esc, esc]).ok(),
SequenceOutput::Pending | SequenceOutput::PassThrough => None,
};
let fired = bound.and_then(|chord| {
self.map
.lookup(&chord, &self.active_contexts)
.exact
.map(|binding| action_dispatch(binding, chord.clone()))
});
match fired {
Some(dispatch) => {
self.stats.dispatched += 1;
out.push(dispatch);
}
None => {
self.stats.esc += 1;
out.push(Dispatch::Esc(output));
}
}
}
}
#[cfg(feature = "serde")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyMapFile<A> {
#[serde(default = "default_chord_timeout_ms")]
pub chord_timeout_ms: u64,
#[serde(default = "Vec::new")]
pub bindings: Vec<BindingFile<A>>,
}
#[cfg(feature = "serde")]
fn default_chord_timeout_ms() -> u64 {
DEFAULT_CHORD_TIMEOUT_MS
}
#[cfg(feature = "serde")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BindingFile<A> {
pub chord: String,
pub action: A,
#[serde(default)]
pub priority: Priority,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[cfg(feature = "serde")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyMapFileError {
Chord {
index: usize,
chord: String,
source: KeyParseError,
},
}
#[cfg(feature = "serde")]
impl fmt::Display for KeyMapFileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Chord {
index,
chord,
source,
} => write!(f, "binding {index} (`{chord}`): {source}"),
}
}
}
#[cfg(feature = "serde")]
impl std::error::Error for KeyMapFileError {}
#[cfg(feature = "serde")]
impl<A: Clone> KeyMap<A> {
#[must_use]
pub fn to_file(&self) -> KeyMapFile<A> {
KeyMapFile {
chord_timeout_ms: self.config.chord_timeout.as_millis() as u64,
bindings: self
.bindings
.iter()
.map(|binding| BindingFile {
chord: binding.chord.to_string(),
action: binding.action.clone(),
priority: binding.priority,
context: binding
.context
.and_then(|id| self.context_name(id))
.map(str::to_string),
label: binding.label.clone(),
})
.collect(),
}
}
pub fn from_file(file: KeyMapFile<A>) -> Result<Self, KeyMapFileError> {
let config = KeyMapConfig::default()
.with_chord_timeout(Duration::from_millis(file.chord_timeout_ms));
let mut map = Self::with_config(config);
for (index, entry) in file.bindings.into_iter().enumerate() {
let chord = Chord::parse(&entry.chord).map_err(|source| KeyMapFileError::Chord {
index,
chord: entry.chord.clone(),
source,
})?;
let context = entry.context.as_deref().map(|name| map.context(name));
let id = map.bind_in(chord, entry.action, entry.priority, context);
if let Some(label) = entry.label {
map.set_label(id, label);
}
}
Ok(map)
}
}
#[cfg(feature = "serde")]
impl<A: Clone + serde::Serialize> serde::Serialize for KeyMap<A> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.to_file().serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de, A: Clone + serde::Deserialize<'de>> serde::Deserialize<'de> for KeyMap<A> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let file = KeyMapFile::<A>::deserialize(deserializer)?;
Self::from_file(file).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod keymap_tests {
use super::*;
use proptest::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Act {
GoTop,
Help,
Save,
Quit,
Submit,
Newline,
Global,
Mode,
Widget,
Down,
}
fn press(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c))
}
fn kind(mut event: KeyEvent, kind: KeyEventKind) -> KeyEvent {
event.kind = kind;
event
}
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
fn chord(s: &str) -> Chord {
Chord::parse(s).unwrap_or_else(|e| panic!("{s}: {e}"))
}
fn actions<A: Clone>(dispatches: &[Dispatch<A>]) -> Vec<A> {
dispatches
.iter()
.filter_map(|d| match d {
Dispatch::Action { action, .. } => Some(action.clone()),
_ => None,
})
.collect()
}
#[test]
fn combo_parse_display_and_normalization() {
let ctrl_x: KeyCombo = "Ctrl+x".parse().unwrap();
assert_eq!(ctrl_x, KeyCombo::new(KeyCode::Char('x'), Modifiers::CTRL));
assert_eq!(ctrl_x.to_string(), "Ctrl+x");
let shift_a: KeyCombo = "shift+a".parse().unwrap();
assert_eq!(shift_a, "A".parse().unwrap());
assert_eq!(shift_a, KeyCombo::new(KeyCode::Char('A'), Modifiers::SHIFT));
assert_eq!(shift_a.to_string(), "A");
assert_eq!("F12".parse::<KeyCombo>().unwrap().code, KeyCode::F(12));
assert_eq!(
"Space".parse::<KeyCombo>().unwrap().code,
KeyCode::Char(' ')
);
assert_eq!(
"Ctrl+Alt+Delete".parse::<KeyCombo>().unwrap().to_string(),
"Ctrl+Alt+Delete"
);
assert_eq!(
"Shift+Tab".parse::<KeyCombo>().unwrap().to_string(),
"Shift+Tab"
);
assert_eq!("+".parse::<KeyCombo>().unwrap().code, KeyCode::Char('+'));
let ctrl_plus: KeyCombo = "Ctrl++".parse().unwrap();
assert_eq!(
ctrl_plus,
KeyCombo::new(KeyCode::Char('+'), Modifiers::CTRL)
);
assert_eq!(
"Hyper+x".parse::<KeyCombo>(),
Err(KeyParseError::UnknownModifier("Hyper".into()))
);
assert_eq!(
"Banana".parse::<KeyCombo>(),
Err(KeyParseError::UnknownKey("Banana".into()))
);
assert_eq!("".parse::<KeyCombo>(), Err(KeyParseError::EmptyKey));
assert_eq!(
"F0".parse::<KeyCombo>(),
Err(KeyParseError::UnknownKey("F0".into()))
);
}
#[test]
fn chord_parse_prefix_and_limits() {
let gg = chord("g g");
let g = chord("g");
assert_eq!(gg.len(), 2);
assert_eq!(gg.to_string(), "g g");
assert!(g.is_prefix_of(&gg));
assert!(!gg.is_prefix_of(&g));
assert!(!g.is_prefix_of(&g), "a chord is not its own prefix");
assert_eq!(chord("Ctrl+x Ctrl+s").to_string(), "Ctrl+x Ctrl+s");
assert_eq!(Chord::parse(""), Err(KeyParseError::EmptyKey));
assert_eq!(
Chord::parse("a b c d e"),
Err(KeyParseError::TooManyKeys(5))
);
}
#[test]
fn chord_completes_within_timeout() {
let mut map = KeyMap::new();
map.bind(chord("g g"), Act::GoTop);
map.bind(chord("x"), Act::Save);
let mut dispatcher = KeyDispatcher::new(map);
let t0 = Instant::now();
let first = dispatcher.feed(&press('g'), t0);
assert_eq!(first, vec![Dispatch::Pending { prefix: chord("g") }]);
assert_eq!(dispatcher.pending_prefix(), Some(chord("g")));
assert!(
dispatcher.tick(t0 + ms(300)).is_empty(),
"still inside the timeout"
);
let second = dispatcher.feed(&press('g'), t0 + ms(300));
assert_eq!(actions(&second), vec![Act::GoTop]);
assert_eq!(dispatcher.pending_prefix(), None);
assert_eq!(dispatcher.stats().dispatched, 1);
assert_eq!(dispatcher.stats().pending, 1);
}
#[test]
fn chord_expires_after_timeout() {
let mut map = KeyMap::new();
map.bind(chord("g g"), Act::GoTop);
map.bind(chord("g"), Act::Help);
let mut dispatcher = KeyDispatcher::new(map);
let t0 = Instant::now();
assert_eq!(
dispatcher.feed(&press('g'), t0),
vec![Dispatch::Pending { prefix: chord("g") }]
);
assert!(dispatcher.tick(t0 + ms(999)).is_empty());
let expired = dispatcher.tick(t0 + ms(1000));
assert_eq!(expired[0], Dispatch::Expired { prefix: chord("g") });
assert_eq!(actions(&expired), vec![Act::Help]);
assert_eq!(dispatcher.stats().expired, 1);
let mut map = KeyMap::new();
map.bind(chord("g g"), Act::GoTop);
let mut dispatcher = KeyDispatcher::new(map);
dispatcher.feed(&press('g'), t0);
assert_eq!(
dispatcher.tick(t0 + ms(5000)),
vec![Dispatch::Expired { prefix: chord("g") }]
);
assert_eq!(dispatcher.pending_prefix(), None);
}
#[test]
fn single_key_fires_while_chord_pending() {
let mut map = KeyMap::new();
map.bind(chord("g g"), Act::GoTop);
map.bind(chord("x"), Act::Save);
let mut dispatcher = KeyDispatcher::new(map);
let t0 = Instant::now();
dispatcher.feed(&press('g'), t0);
let out = dispatcher.feed(&press('x'), t0 + ms(10));
assert_eq!(out[0], Dispatch::Expired { prefix: chord("g") });
assert_eq!(
actions(&out),
vec![Act::Save],
"x is never blocked by the pending g"
);
let mut map = KeyMap::new();
map.bind(chord("g g"), Act::GoTop);
map.bind(chord("g"), Act::Help);
map.bind(chord("x"), Act::Save);
let mut dispatcher = KeyDispatcher::new(map);
dispatcher.feed(&press('g'), t0);
let out = dispatcher.feed(&press('x'), t0 + ms(10));
assert_eq!(actions(&out), vec![Act::Help, Act::Save]);
let mut map = KeyMap::new();
map.bind(chord("g g"), Act::GoTop);
map.bind(chord("z z"), Act::Quit);
let mut dispatcher = KeyDispatcher::new(map);
dispatcher.feed(&press('g'), t0);
let out = dispatcher.feed(&press('z'), t0 + ms(10));
assert_eq!(
out,
vec![
Dispatch::Expired { prefix: chord("g") },
Dispatch::Pending { prefix: chord("z") }
]
);
}
#[test]
fn prefix_with_own_binding_fires_on_flush() {
let mut map = KeyMap::new();
let one = map.bind(chord("g"), Act::Help);
map.bind(chord("g g"), Act::GoTop);
let mut dispatcher = KeyDispatcher::new(map);
let t0 = Instant::now();
assert_eq!(
dispatcher.feed(&press('g'), t0),
vec![Dispatch::Pending { prefix: chord("g") }]
);
let out = dispatcher.feed(&press('x'), t0 + ms(10));
assert_eq!(
out,
vec![
Dispatch::Action {
action: Act::Help,
binding: one,
chord: chord("g"),
},
Dispatch::Unbound(press('x')),
]
);
assert_eq!(dispatcher.pending_prefix(), None);
assert_eq!(
dispatcher.stats().expired,
0,
"a bound prefix flushed by a non-extending key does not expire"
);
assert_eq!(dispatcher.stats().dispatched, 1);
}
#[test]
fn widget_beats_mode_beats_global() {
let mut map = KeyMap::new();
let g = map.bind_in(chord("s"), Act::Global, Priority::Global, None);
let m = map.bind_in(chord("s"), Act::Mode, Priority::Mode, None);
let w = map.bind_in(chord("s"), Act::Widget, Priority::Widget, None);
let lookup = map.lookup(&chord("s"), &[]);
assert_eq!(lookup.exact.map(|b| b.id), Some(w));
assert_eq!(lookup.longer, 0);
let mut dispatcher = KeyDispatcher::new(map);
assert_eq!(
actions(&dispatcher.feed(&press('s'), Instant::now())),
vec![Act::Widget]
);
let report = dispatcher.map().conflicts();
assert_eq!(report.len(), 3, "{report}");
assert!(report.items.contains(&Conflict::Shadowed {
winner: w,
loser: g,
chord: chord("s")
}));
assert!(report.items.contains(&Conflict::Shadowed {
winner: m,
loser: g,
chord: chord("s")
}));
assert!(report.items.contains(&Conflict::Shadowed {
winner: w,
loser: m,
chord: chord("s")
}));
assert_eq!(report.to_string().lines().count(), 3);
dispatcher.map_mut().unbind(w);
assert_eq!(
actions(&dispatcher.feed(&press('s'), Instant::now())),
vec![Act::Mode]
);
}
#[test]
fn active_context_beats_contextless_even_at_lower_priority() {
let mut map = KeyMap::new();
let text_input = map.context("text_input");
assert_eq!(map.context("text_input"), text_input, "interned once");
assert_eq!(map.context_name(text_input), Some("text_input"));
map.bind_in(chord("Enter"), Act::Submit, Priority::Widget, None);
map.bind_in(
chord("Enter"),
Act::Newline,
Priority::Global,
Some(text_input),
);
assert!(
map.conflicts().is_empty(),
"a context override is not a conflict"
);
let mut dispatcher = KeyDispatcher::new(map);
let enter = KeyEvent::new(KeyCode::Enter);
let t0 = Instant::now();
assert_eq!(actions(&dispatcher.feed(&enter, t0)), vec![Act::Submit]);
dispatcher.set_active_contexts(&[text_input]);
assert_eq!(actions(&dispatcher.feed(&enter, t0)), vec![Act::Newline]);
dispatcher.set_active_contexts(&[]);
assert_eq!(actions(&dispatcher.feed(&enter, t0)), vec![Act::Submit]);
}
#[test]
fn conflicts_reports_shadowed_prefix_and_duplicate() {
let mut map = KeyMap::new();
let long = map.bind(chord("g g"), Act::GoTop);
let short = map.bind(chord("g"), Act::Help);
let q1 = map.bind(chord("q"), Act::Quit);
let q2 = map.bind(chord("q"), Act::Quit);
map.set_label(q2, "quit");
assert_eq!(map.get(q2).and_then(|b| b.label.as_deref()), Some("quit"));
let report = map.conflicts();
assert_eq!(report.len(), 2, "{report}");
assert_eq!(
report.items[0],
Conflict::PrefixCollision {
short,
long,
short_chord: chord("g"),
long_chord: chord("g g"),
}
);
assert_eq!(
report.items[1],
Conflict::Duplicate {
first: q1,
second: q2,
chord: chord("q")
}
);
let text = report.to_string();
assert_eq!(text.lines().count(), 2);
assert!(
text.contains("warning: binding #1 (`g`) is a prefix of binding #0 (`g g`)"),
"{text}"
);
assert!(text.contains("the later one wins"), "{text}");
assert_eq!(map.lookup(&chord("q"), &[]).exact.map(|b| b.id), Some(q2));
}
#[test]
fn repeat_refires_single_key_binding_but_never_extends_a_chord() {
let mut map = KeyMap::new();
map.bind(chord("j"), Act::Down);
map.bind(chord("g g"), Act::GoTop);
let mut dispatcher = KeyDispatcher::new(map);
let t0 = Instant::now();
let held = kind(press('j'), KeyEventKind::Repeat);
assert_eq!(actions(&dispatcher.feed(&held, t0)), vec![Act::Down]);
dispatcher.feed(&press('g'), t0);
let repeat_g = kind(press('g'), KeyEventKind::Repeat);
assert_eq!(
dispatcher.feed(&repeat_g, t0 + ms(10)),
vec![Dispatch::Unbound(repeat_g)]
);
assert_eq!(
dispatcher.pending_prefix(),
Some(chord("g")),
"repeat left the prefix alone"
);
let released = kind(press('j'), KeyEventKind::Release);
assert_eq!(
dispatcher.feed(&released, t0 + ms(20)),
vec![Dispatch::Unbound(released)]
);
}
#[test]
fn esc_goes_through_the_sequence_detector() {
let mut map = KeyMap::new();
map.bind(chord("Esc"), Act::Quit);
map.bind(chord("Esc Esc"), Act::Help);
map.bind(chord("g g"), Act::GoTop);
let mut dispatcher = KeyDispatcher::new(map);
let esc = KeyEvent::new(KeyCode::Escape);
let t0 = Instant::now();
assert_eq!(
dispatcher.feed(&esc, t0),
vec![Dispatch::Esc(SequenceOutput::Pending)]
);
assert_eq!(actions(&dispatcher.tick(t0 + ms(300))), vec![Act::Quit]);
let t1 = t0 + ms(1000);
dispatcher.feed(&esc, t1);
assert_eq!(
actions(&dispatcher.feed(&esc, t1 + ms(100))),
vec![Act::Help]
);
let t2 = t0 + ms(3000);
dispatcher.feed(&press('g'), t2);
let out = dispatcher.feed(&esc, t2 + ms(10));
assert_eq!(out[0], Dispatch::Expired { prefix: chord("g") });
assert_eq!(dispatcher.pending_prefix(), None);
let mut plain = KeyDispatcher::new(KeyMap::<Act>::new());
plain.feed(&esc, t0);
assert_eq!(
plain.tick(t0 + ms(300)),
vec![Dispatch::Esc(SequenceOutput::Esc)]
);
}
#[cfg(feature = "serde")]
#[test]
fn keymap_round_trips_through_toml_and_json() {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
enum Action {
Quit,
Save,
Newline,
}
let mut map = KeyMap::with_config(KeyMapConfig::default().with_chord_timeout(ms(750)));
let editor = map.context("editor");
let quit = map.bind(chord("q"), Action::Quit);
map.set_label(quit, "quit");
map.bind_in(chord("Ctrl+x Ctrl+s"), Action::Save, Priority::Mode, None);
map.bind_in(
chord("Enter"),
Action::Newline,
Priority::Widget,
Some(editor),
);
let text = toml::to_string(&map).expect("serialize to TOML");
assert!(text.contains("chord_timeout_ms = 750"), "{text}");
assert!(text.contains("chord = \"Ctrl+x Ctrl+s\""), "{text}");
assert!(text.contains("context = \"editor\""), "{text}");
assert!(text.contains("label = \"quit\""), "{text}");
let back: KeyMap<Action> = toml::from_str(&text).expect("parse TOML");
assert_eq!(back.config().chord_timeout, ms(750));
assert_eq!(back.len(), 3);
assert_eq!(back.bindings()[0].label.as_deref(), Some("quit"));
assert_eq!(back.bindings()[1].priority, Priority::Mode);
assert_eq!(back.bindings()[1].chord, chord("Ctrl+x Ctrl+s"));
let editor_back = back.bindings()[2].context.expect("context restored");
assert_eq!(back.context_name(editor_back), Some("editor"));
assert_eq!(
back.lookup(&chord("Enter"), &[editor_back])
.exact
.map(|b| &b.action),
Some(&Action::Newline)
);
assert!(
back.lookup(&chord("Enter"), &[]).is_none(),
"the context binding stays inactive outside its context"
);
let json = serde_json::to_string(&map).expect("serialize to JSON");
let back_json: KeyMap<Action> = serde_json::from_str(&json).expect("parse JSON");
assert_eq!(back_json.len(), 3);
assert_eq!(back_json.bindings()[2].action, Action::Newline);
let bad =
"chord_timeout_ms = 500\n\n[[bindings]]\nchord = \"Hyper+q\"\naction = \"Quit\"\n";
let err = toml::from_str::<KeyMap<Action>>(bad)
.expect_err("bad chord must fail")
.to_string();
assert!(err.contains("binding 0") && err.contains("Hyper"), "{err}");
let minimal: KeyMap<Action> =
toml::from_str("[[bindings]]\nchord = \"q\"\naction = \"Quit\"\n")
.expect("defaults fill in");
assert_eq!(minimal.config().chord_timeout, ms(DEFAULT_CHORD_TIMEOUT_MS));
assert_eq!(minimal.bindings()[0].priority, Priority::Global);
}
#[cfg(feature = "serde")]
#[test]
fn toml_rejects_unknown_field_and_bad_chord() {
#[derive(Debug, Clone, serde::Deserialize)]
enum Action {
Quit,
}
let unknown_top =
toml::from_str::<KeyMap<Action>>("chord_timeout_ms = 500\ntypo_field = 3\n")
.expect_err("an unknown top-level field must be rejected")
.to_string();
assert!(unknown_top.contains("typo_field"), "{unknown_top}");
let unknown_binding = toml::from_str::<KeyMap<Action>>(
"[[bindings]]\nchord = \"q\"\naction = \"Quit\"\nchrod = \"x\"\n",
)
.expect_err("an unknown binding field must be rejected")
.to_string();
assert!(unknown_binding.contains("chrod"), "{unknown_binding}");
let bad_chord = toml::from_str::<KeyMap<Action>>(
"[[bindings]]\nchord = \"Nope+q\"\naction = \"Quit\"\n",
)
.expect_err("a bad chord must be rejected")
.to_string();
assert!(
bad_chord.contains("binding 0") && bad_chord.contains("Nope"),
"{bad_chord}"
);
}
#[cfg(feature = "serde")]
#[test]
fn toml_example_in_docs_parses() {
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
enum Action {
Save,
Newline,
Top,
}
const EXAMPLE: &str = include_str!("../tests/fixtures/keymap_example.toml");
let map: KeyMap<Action> =
toml::from_str(EXAMPLE).expect("documented keymap example must parse");
assert_eq!(map.config().chord_timeout, ms(750));
assert_eq!(map.len(), 3);
let save = &map.bindings()[0];
assert_eq!(save.action, Action::Save);
assert_eq!(save.chord, chord("Ctrl+x Ctrl+s"));
assert_eq!(save.priority, Priority::Mode);
assert_eq!(save.label.as_deref(), Some("save"));
let newline = &map.bindings()[1];
assert_eq!(newline.action, Action::Newline);
assert_eq!(newline.priority, Priority::Widget);
let editor = newline.context.expect("editor context restored");
assert_eq!(map.context_name(editor), Some("editor"));
assert_eq!(
map.lookup(&chord("Enter"), &[editor])
.exact
.map(|binding| &binding.action),
Some(&Action::Newline)
);
assert!(
map.lookup(&chord("Enter"), &[]).is_none(),
"the editor binding stays inactive outside its context"
);
assert_eq!(map.bindings()[2].chord, chord("g g"));
assert_eq!(map.bindings()[2].action, Action::Top);
let report = map.conflicts();
assert!(report.is_empty(), "{report}");
}
fn arb_code() -> impl Strategy<Value = KeyCode> {
prop_oneof![
prop::sample::select(vec![
'a', 'b', 'q', 'x', 'z', 'A', 'Q', '1', '9', '+', '-', '.', '/', ' ',
])
.prop_map(KeyCode::Char),
(1u8..=24).prop_map(KeyCode::F),
prop::sample::select(vec![
KeyCode::Enter,
KeyCode::Escape,
KeyCode::Backspace,
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Delete,
KeyCode::Insert,
KeyCode::Home,
KeyCode::End,
KeyCode::PageUp,
KeyCode::PageDown,
KeyCode::Up,
KeyCode::Down,
KeyCode::Left,
KeyCode::Right,
KeyCode::Null,
KeyCode::MediaPlayPause,
KeyCode::MediaStop,
KeyCode::MediaNextTrack,
KeyCode::MediaPrevTrack,
]),
]
}
fn arb_mods() -> impl Strategy<Value = Modifiers> {
(0u8..16).prop_map(|bits| {
let mut modifiers = Modifiers::NONE;
if bits & 0b0001 != 0 {
modifiers |= Modifiers::CTRL;
}
if bits & 0b0010 != 0 {
modifiers |= Modifiers::ALT;
}
if bits & 0b0100 != 0 {
modifiers |= Modifiers::SHIFT;
}
if bits & 0b1000 != 0 {
modifiers |= Modifiers::SUPER;
}
modifiers
})
}
fn arb_small_chord() -> impl Strategy<Value = Chord> {
prop::collection::vec(
prop::sample::select(vec!['a', 'b', 'c', 'd'])
.prop_map(|c| KeyCombo::key(KeyCode::Char(c))),
1..=3usize,
)
.prop_map(|combos| Chord::new(combos).expect("1..=3 combos is a valid chord"))
}
fn arb_key() -> impl Strategy<Value = KeyEvent> {
let code = prop_oneof![
Just(KeyCode::Char('a')),
Just(KeyCode::Char('b')),
Just(KeyCode::Char('c')),
Just(KeyCode::Enter),
Just(KeyCode::Escape),
];
let kind = prop_oneof![
Just(KeyEventKind::Press),
Just(KeyEventKind::Repeat),
Just(KeyEventKind::Release),
];
(code, kind).prop_map(|(code, kind)| KeyEvent {
code,
modifiers: Modifiers::NONE,
kind,
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn every_fed_key_yields_a_dispatch(
keys in proptest::collection::vec(arb_key(), 1..20),
gaps in proptest::collection::vec(0u64..1500, 1..20),
) {
let mut map = KeyMap::new();
map.bind(chord("a b"), Act::GoTop);
map.bind(chord("a"), Act::Help);
map.bind(chord("c c c"), Act::Save);
map.bind(chord("Enter"), Act::Submit);
let mut dispatcher = KeyDispatcher::new(map);
let mut now = Instant::now();
for (key, gap) in keys.iter().zip(gaps.iter().cycle()) {
now += ms(*gap);
let out = dispatcher.feed(key, now);
prop_assert!(!out.is_empty(), "{key:?} produced nothing");
let _ = dispatcher.tick(now);
}
let _ = dispatcher.tick(now + ms(10_000));
prop_assert_eq!(dispatcher.pending_prefix(), None);
let stats = dispatcher.stats();
prop_assert!(
stats.dispatched + stats.pending + stats.expired + stats.unbound + stats.esc > 0
);
}
#[test]
fn combo_display_parse_round_trip(code in arb_code(), mods in arb_mods()) {
let combo = KeyCombo::new(code, mods);
let text = combo.to_string();
let parsed: KeyCombo = text
.parse()
.unwrap_or_else(|e| panic!("`{text}` did not re-parse: {e}"));
prop_assert_eq!(parsed, combo, "text = `{}`", text);
}
#[test]
fn lookup_is_deterministic_under_shuffle(
raw in prop::collection::vec((arb_small_chord(), any::<u64>()), 1..12),
queries in prop::collection::vec(arb_small_chord(), 1..8),
) {
let mut seen = std::collections::BTreeSet::new();
let mut entries: Vec<(Chord, u64)> = Vec::new();
for (ch, key) in raw {
if seen.insert(ch.to_string()) {
entries.push((ch, key));
}
}
let build = |order: &[(Chord, u64)]| {
let mut map = KeyMap::new();
for (ch, _) in order {
map.bind(ch.clone(), ch.to_string());
}
map
};
let in_order = build(&entries);
let mut shuffled = entries.clone();
shuffled.sort_by_key(|(_, key)| *key);
let reordered = build(&shuffled);
for query in &queries {
let a = in_order.lookup(query, &[]);
let b = reordered.lookup(query, &[]);
prop_assert_eq!(
a.exact.map(|binding| binding.action.clone()),
b.exact.map(|binding| binding.action.clone()),
"winner changed for `{}`",
query
);
prop_assert_eq!(a.longer, b.longer, "longer count changed for `{}`", query);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> Instant {
Instant::now()
}
fn esc_press() -> KeyEvent {
KeyEvent::new(KeyCode::Escape)
}
fn key_press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code)
}
fn esc_release() -> KeyEvent {
KeyEvent::new(KeyCode::Escape).with_kind(KeyEventKind::Release)
}
const MS_50: Duration = Duration::from_millis(50);
const MS_100: Duration = Duration::from_millis(100);
const MS_200: Duration = Duration::from_millis(200);
const MS_300: Duration = Duration::from_millis(300);
#[test]
fn single_esc_returns_pending() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let output = detector.feed(&esc_press(), t);
assert_eq!(output, SequenceOutput::Pending);
assert!(detector.is_pending());
}
#[test]
fn esc_esc_within_timeout() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + MS_100);
assert_eq!(output, SequenceOutput::EscEsc);
assert!(!detector.is_pending());
}
#[test]
fn esc_esc_at_timeout_boundary() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + Duration::from_millis(250));
assert_eq!(output, SequenceOutput::EscEsc);
}
#[test]
fn esc_esc_past_timeout() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + Duration::from_millis(251));
assert_eq!(output, SequenceOutput::Esc);
assert!(detector.is_pending()); }
#[test]
fn timeout_check_emits_pending_esc() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
assert!(detector.check_timeout(t + MS_200).is_none());
assert!(detector.is_pending());
let output = detector.check_timeout(t + Duration::from_millis(251));
assert_eq!(output, Some(SequenceOutput::Esc));
assert!(!detector.is_pending());
}
#[test]
fn other_key_interrupts_sequence() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::Char('a')), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
assert!(!detector.is_pending());
}
#[test]
fn non_esc_key_passes_through() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let output = detector.feed(&key_press(KeyCode::Char('x')), t);
assert_eq!(output, SequenceOutput::PassThrough);
}
#[test]
fn release_event_passes_through() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let output = detector.feed(&esc_release(), t);
assert_eq!(output, SequenceOutput::PassThrough);
assert!(!detector.is_pending());
}
#[test]
fn release_during_pending_passes_through() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_release(), t + MS_50);
assert_eq!(output, SequenceOutput::PassThrough);
assert!(detector.is_pending());
}
#[test]
fn custom_timeout() {
let config = SequenceConfig::default().with_timeout(Duration::from_millis(100));
let mut detector = SequenceDetector::new(config);
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&esc_press(), t + Duration::from_millis(150));
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn disabled_sequences() {
let config = SequenceConfig::default().disable_sequences();
let mut detector = SequenceDetector::new(config);
let t = now();
let output = detector.feed(&esc_press(), t);
assert_eq!(output, SequenceOutput::Esc);
assert!(!detector.is_pending());
let output = detector.feed(&esc_press(), t + MS_50);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn disabled_sequences_passthrough() {
let config = SequenceConfig::default().disable_sequences();
let mut detector = SequenceDetector::new(config);
let t = now();
let output = detector.feed(&key_press(KeyCode::Char('a')), t);
assert_eq!(output, SequenceOutput::PassThrough);
}
#[test]
fn config_default_values() {
let config = SequenceConfig::default();
assert_eq!(config.esc_seq_timeout, Duration::from_millis(250));
assert_eq!(config.esc_debounce, Duration::from_millis(50));
assert!(!config.disable_sequences);
}
#[test]
fn config_builder_chain() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(300))
.with_debounce(Duration::from_millis(100))
.disable_sequences();
assert_eq!(config.esc_seq_timeout, Duration::from_millis(300));
assert_eq!(config.esc_debounce, Duration::from_millis(100));
assert!(config.disable_sequences);
}
#[test]
fn reset_clears_pending() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
assert!(detector.is_pending());
detector.reset();
assert!(!detector.is_pending());
let output = detector.feed(&esc_press(), t + MS_100);
assert_eq!(output, SequenceOutput::Pending);
}
#[test]
fn reset_discards_pending_esc() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
detector.reset();
assert!(detector.check_timeout(t + MS_300).is_none());
}
#[test]
fn rapid_triple_esc() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
let out1 = detector.feed(&esc_press(), t);
assert_eq!(out1, SequenceOutput::Pending);
let out2 = detector.feed(&esc_press(), t + MS_50);
assert_eq!(out2, SequenceOutput::EscEsc);
let out3 = detector.feed(&esc_press(), t + MS_100);
assert_eq!(out3, SequenceOutput::Pending);
}
#[test]
fn alternating_esc_and_key() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let out1 = detector.feed(&key_press(KeyCode::Char('a')), t + MS_50);
assert_eq!(out1, SequenceOutput::Esc);
let out2 = detector.feed(&esc_press(), t + MS_100);
assert_eq!(out2, SequenceOutput::Pending);
let out3 = detector.feed(&key_press(KeyCode::Char('b')), t + MS_200);
assert_eq!(out3, SequenceOutput::Esc);
}
#[test]
fn enter_key_interrupts() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::Enter), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn function_key_interrupts() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::F(1)), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn arrow_key_interrupts() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
let output = detector.feed(&key_press(KeyCode::Up), t + MS_100);
assert_eq!(output, SequenceOutput::Esc);
}
#[test]
fn config_getter_and_setter() {
let mut detector = SequenceDetector::with_defaults();
assert_eq!(
detector.config().esc_seq_timeout,
Duration::from_millis(250)
);
let new_config = SequenceConfig::default().with_timeout(Duration::from_millis(500));
detector.set_config(new_config);
assert_eq!(
detector.config().esc_seq_timeout,
Duration::from_millis(500)
);
}
#[test]
fn set_config_preserves_pending_state() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
detector.feed(&esc_press(), t);
assert!(detector.is_pending());
detector.set_config(SequenceConfig::default().with_timeout(Duration::from_millis(500)));
assert!(detector.is_pending());
let output = detector.feed(&esc_press(), t + MS_300);
assert_eq!(output, SequenceOutput::EscEsc); }
#[test]
fn debug_format() {
let detector = SequenceDetector::with_defaults();
let dbg = format!("{:?}", detector);
assert!(dbg.contains("SequenceDetector"));
}
#[test]
fn config_debug_format() {
let config = SequenceConfig::default();
let dbg = format!("{:?}", config);
assert!(dbg.contains("SequenceConfig"));
}
#[test]
fn output_debug_and_eq() {
assert_eq!(SequenceOutput::Pending, SequenceOutput::Pending);
assert_eq!(SequenceOutput::Esc, SequenceOutput::Esc);
assert_eq!(SequenceOutput::EscEsc, SequenceOutput::EscEsc);
assert_eq!(SequenceOutput::PassThrough, SequenceOutput::PassThrough);
assert_ne!(SequenceOutput::Esc, SequenceOutput::EscEsc);
let dbg = format!("{:?}", SequenceOutput::EscEsc);
assert!(dbg.contains("EscEsc"));
}
#[test]
fn no_stuck_state() {
let mut detector = SequenceDetector::with_defaults();
let t = now();
for i in 0..100 {
let offset = Duration::from_millis(i * 10);
if i % 3 == 0 {
detector.feed(&esc_press(), t + offset);
} else {
detector.feed(&key_press(KeyCode::Char('x')), t + offset);
}
}
detector.check_timeout(t + Duration::from_secs(2));
assert!(!detector.is_pending());
}
#[test]
fn deterministic_output() {
let config = SequenceConfig::default();
let t = now();
let mut d1 = SequenceDetector::new(config.clone());
let mut d2 = SequenceDetector::new(config);
let events = [
(esc_press(), t),
(esc_press(), t + MS_100),
(key_press(KeyCode::Char('a')), t + MS_200),
(esc_press(), t + MS_300),
];
for (event, time) in &events {
let out1 = d1.feed(event, *time);
let out2 = d2.feed(event, *time);
assert_eq!(out1, out2);
}
}
mod action_mapper_tests {
use super::*;
use crate::event::Modifiers;
fn ctrl_c() -> KeyEvent {
KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL)
}
fn ctrl_d() -> KeyEvent {
KeyEvent::new(KeyCode::Char('d')).with_modifiers(Modifiers::CTRL)
}
fn ctrl_q() -> KeyEvent {
KeyEvent::new(KeyCode::Char('q')).with_modifiers(Modifiers::CTRL)
}
fn idle_state() -> AppState {
AppState::default()
}
fn input_state() -> AppState {
AppState::new().with_input(true)
}
fn task_state() -> AppState {
AppState::new().with_task(true)
}
fn modal_state() -> AppState {
AppState::new().with_modal(true)
}
fn overlay_state() -> AppState {
AppState::new().with_overlay(true)
}
#[test]
fn test_ctrl_c_clears_nonempty_input() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &input_state(), t);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_ctrl_c_cancels_running_task() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &task_state(), t);
assert_eq!(action, Some(Action::CancelTask));
}
#[test]
fn test_ctrl_c_quits_when_idle() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &idle_state(), t);
assert_eq!(action, Some(Action::Quit));
}
#[test]
fn test_ctrl_c_dismisses_modal() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_c(), &modal_state(), t);
assert_eq!(action, Some(Action::DismissModal));
}
#[test]
fn test_ctrl_c_modal_priority_over_input() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let state = AppState::new().with_modal(true).with_input(true);
let action = mapper.map(&ctrl_c(), &state, t);
assert_eq!(action, Some(Action::DismissModal));
}
#[test]
fn test_ctrl_c_input_priority_over_task() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let state = AppState::new().with_input(true).with_task(true);
let action = mapper.map(&ctrl_c(), &state, t);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_ctrl_c_idle_config_noop() {
let config = ActionConfig::default().with_ctrl_c_idle(CtrlCIdleAction::Noop);
let mut mapper = ActionMapper::new(config);
let t = now();
let action = mapper.map(&ctrl_c(), &idle_state(), t);
assert_eq!(action, None); }
#[test]
fn test_ctrl_c_idle_config_bell() {
let config = ActionConfig::default().with_ctrl_c_idle(CtrlCIdleAction::Bell);
let mut mapper = ActionMapper::new(config);
let t = now();
let action = mapper.map(&ctrl_c(), &idle_state(), t);
assert_eq!(action, Some(Action::Bell));
}
#[test]
fn test_ctrl_d_soft_quit() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_d(), &idle_state(), t);
assert_eq!(action, Some(Action::SoftQuit));
}
#[test]
fn test_ctrl_d_ignores_state() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_d(), &modal_state(), t);
assert_eq!(action, Some(Action::SoftQuit));
let action = mapper.map(&ctrl_d(), &input_state(), t);
assert_eq!(action, Some(Action::SoftQuit));
}
#[test]
fn test_ctrl_q_hard_quit() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_q(), &idle_state(), t);
assert_eq!(action, Some(Action::HardQuit));
}
#[test]
fn test_ctrl_q_ignores_state() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&ctrl_q(), &modal_state(), t);
assert_eq!(action, Some(Action::HardQuit));
}
#[test]
fn test_esc_dismisses_modal() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action1 = mapper.map(&esc_press(), &modal_state(), t);
assert_eq!(action1, None);
let action2 = mapper.check_timeout(&modal_state(), t + MS_300);
assert_eq!(action2, Some(Action::DismissModal));
}
#[test]
fn test_esc_clears_input_no_modal() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &input_state(), t);
let action = mapper.check_timeout(&input_state(), t + MS_300);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_esc_cancels_task_empty_input() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &task_state(), t);
let action = mapper.check_timeout(&task_state(), t + MS_300);
assert_eq!(action, Some(Action::CancelTask));
}
#[test]
fn test_esc_closes_overlay() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &overlay_state(), t);
let action = mapper.check_timeout(&overlay_state(), t + MS_300);
assert_eq!(action, Some(Action::CloseOverlay));
}
#[test]
fn test_esc_modal_priority_over_overlay() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let state = AppState::new().with_modal(true).with_overlay(true);
mapper.map(&esc_press(), &state, t);
let action = mapper.check_timeout(&state, t + MS_300);
assert_eq!(action, Some(Action::DismissModal));
}
#[test]
fn test_esc_passthrough_when_idle() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &idle_state(), t);
let action = mapper.check_timeout(&idle_state(), t + MS_300);
assert_eq!(action, Some(Action::PassThrough));
}
#[test]
fn test_esc_esc_within_timeout() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &idle_state(), t);
let action = mapper.map(&esc_press(), &idle_state(), t + MS_100);
assert_eq!(action, Some(Action::ToggleTreeView));
}
#[test]
fn test_esc_esc_ignores_state() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &modal_state(), t);
let action = mapper.map(&esc_press(), &modal_state(), t + MS_100);
assert_eq!(action, Some(Action::ToggleTreeView));
}
#[test]
fn test_esc_esc_timeout_expired() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &input_state(), t);
let action = mapper.map(&esc_press(), &input_state(), t + MS_300);
assert_eq!(action, Some(Action::ClearInput));
assert!(mapper.is_pending_esc());
}
#[test]
fn test_esc_then_other_key() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &input_state(), t);
let action = mapper.map(&key_press(KeyCode::Char('a')), &input_state(), t + MS_50);
assert_eq!(action, Some(Action::ClearInput));
}
#[test]
fn test_regular_key_passthrough() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let action = mapper.map(&key_press(KeyCode::Char('x')), &idle_state(), t);
assert_eq!(action, Some(Action::PassThrough));
}
#[test]
fn test_release_event_passthrough() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let release = KeyEvent::new(KeyCode::Char('x')).with_kind(KeyEventKind::Release);
let action = mapper.map(&release, &idle_state(), t);
assert_eq!(action, Some(Action::PassThrough));
}
#[test]
fn test_app_state_builders() {
let state = AppState::new()
.with_input(true)
.with_task(true)
.with_modal(true)
.with_overlay(true);
assert!(state.input_nonempty);
assert!(state.task_running);
assert!(state.modal_open);
assert!(state.view_overlay);
assert!(!state.is_idle());
}
#[test]
fn test_app_state_is_idle() {
assert!(AppState::default().is_idle());
assert!(!AppState::new().with_input(true).is_idle());
assert!(!AppState::new().with_task(true).is_idle());
assert!(!AppState::new().with_modal(true).is_idle());
assert!(AppState::new().with_overlay(true).is_idle());
}
#[test]
fn test_action_consumes_event() {
assert!(Action::ClearInput.consumes_event());
assert!(Action::CancelTask.consumes_event());
assert!(Action::Quit.consumes_event());
assert!(!Action::PassThrough.consumes_event());
}
#[test]
fn test_action_is_quit() {
assert!(Action::Quit.is_quit());
assert!(Action::SoftQuit.is_quit());
assert!(Action::HardQuit.is_quit());
assert!(!Action::ClearInput.is_quit());
assert!(!Action::PassThrough.is_quit());
}
#[test]
fn test_ctrl_c_idle_action_from_str() {
assert_eq!(
CtrlCIdleAction::from_str_opt("quit"),
Some(CtrlCIdleAction::Quit)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("QUIT"),
Some(CtrlCIdleAction::Quit)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("noop"),
Some(CtrlCIdleAction::Noop)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("none"),
Some(CtrlCIdleAction::Noop)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("ignore"),
Some(CtrlCIdleAction::Noop)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("bell"),
Some(CtrlCIdleAction::Bell)
);
assert_eq!(
CtrlCIdleAction::from_str_opt("beep"),
Some(CtrlCIdleAction::Bell)
);
assert_eq!(CtrlCIdleAction::from_str_opt("invalid"), None);
}
#[test]
fn test_ctrl_c_idle_action_to_action() {
assert_eq!(CtrlCIdleAction::Quit.to_action(), Some(Action::Quit));
assert_eq!(CtrlCIdleAction::Noop.to_action(), None);
assert_eq!(CtrlCIdleAction::Bell.to_action(), Some(Action::Bell));
}
#[test]
fn test_action_config_builder() {
let config = ActionConfig::default()
.with_sequence_config(SequenceConfig::default().with_timeout(MS_100))
.with_ctrl_c_idle(CtrlCIdleAction::Bell);
assert_eq!(config.sequence_config.esc_seq_timeout, MS_100);
assert_eq!(config.ctrl_c_idle_action, CtrlCIdleAction::Bell);
}
#[test]
fn test_mapper_reset() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
mapper.map(&esc_press(), &idle_state(), t);
assert!(mapper.is_pending_esc());
mapper.reset();
assert!(!mapper.is_pending_esc());
}
#[test]
fn test_deterministic_action_mapping() {
let t = now();
let mut m1 = ActionMapper::with_defaults();
let mut m2 = ActionMapper::with_defaults();
let events = [
(ctrl_c(), input_state()),
(ctrl_d(), modal_state()),
(ctrl_q(), idle_state()),
];
for (event, state) in &events {
let a1 = m1.map(event, state, t);
let a2 = m2.map(event, state, t);
assert_eq!(a1, a2);
}
}
#[test]
fn test_uppercase_ctrl_keys() {
let mut mapper = ActionMapper::with_defaults();
let t = now();
let ctrl_c_upper = KeyEvent::new(KeyCode::Char('C')).with_modifiers(Modifiers::CTRL);
let action = mapper.map(&ctrl_c_upper, &idle_state(), t);
assert_eq!(action, Some(Action::Quit));
}
#[test]
fn test_sequence_config_validation_clamps_high_timeout() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(1000)) .validated();
assert_eq!(config.esc_seq_timeout.as_millis(), 400);
}
#[test]
fn test_sequence_config_validation_clamps_low_timeout() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(50)) .validated();
assert_eq!(config.esc_seq_timeout.as_millis(), 150);
}
#[test]
fn test_sequence_config_validation_clamps_high_debounce() {
let config = SequenceConfig::default()
.with_debounce(Duration::from_millis(200)) .validated();
assert_eq!(config.esc_debounce.as_millis(), 100);
}
#[test]
fn test_sequence_config_validation_debounce_not_exceeds_timeout() {
let config = SequenceConfig::default()
.with_timeout(Duration::from_millis(150))
.with_debounce(Duration::from_millis(200)) .validated();
assert!(config.esc_debounce <= config.esc_seq_timeout);
}
#[test]
fn test_sequence_config_is_valid() {
assert!(SequenceConfig::default().is_valid());
let invalid = SequenceConfig::default().with_timeout(Duration::from_millis(500));
assert!(!invalid.is_valid());
assert!(invalid.validated().is_valid());
}
#[test]
fn test_sequence_config_constants() {
assert_eq!(DEFAULT_ESC_SEQ_TIMEOUT_MS, 250);
assert_eq!(MIN_ESC_SEQ_TIMEOUT_MS, 150);
assert_eq!(MAX_ESC_SEQ_TIMEOUT_MS, 400);
assert_eq!(DEFAULT_ESC_DEBOUNCE_MS, 50);
assert_eq!(MIN_ESC_DEBOUNCE_MS, 0);
assert_eq!(MAX_ESC_DEBOUNCE_MS, 100);
}
#[test]
fn test_action_config_validated() {
let config = ActionConfig::default()
.with_sequence_config(
SequenceConfig::default().with_timeout(Duration::from_millis(1000)),
)
.validated();
assert_eq!(config.sequence_config.esc_seq_timeout.as_millis(), 400);
}
}
}