use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::config::KeybindingConfig;
pub type Chord = (KeyCode, KeyModifiers);
pub type ChordSeq = Vec<Chord>;
pub trait Command: Copy + 'static {
#[allow(clippy::type_complexity)]
const ALL: &'static [(Self, &'static str, &'static [Chord])];
fn from_command(name: &str) -> Option<Self> {
let norm = name.trim().to_ascii_lowercase().replace('-', "_");
Self::ALL
.iter()
.find(|(_, cmd, _)| *cmd == norm)
.map(|(a, _, _)| *a)
}
fn command_list() -> String {
Self::ALL
.iter()
.map(|(_, c, _)| *c)
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyAction {
ToggleReasoning,
Expand,
ScrollPageUp,
ScrollPageDown,
ScrollToTop,
ScrollToBottom,
NextChat,
PrevChat,
CloseChat,
KillSubagent,
DropQueue,
CyclePrompt,
}
impl Command for KeyAction {
const ALL: &'static [(KeyAction, &'static str, &'static [Chord])] = &[
(
KeyAction::ToggleReasoning,
"toggle_reasoning",
&[(KeyCode::Char('r'), KeyModifiers::CONTROL)],
),
(
KeyAction::Expand,
"expand",
&[(KeyCode::Char('o'), KeyModifiers::CONTROL)],
),
(
KeyAction::ScrollPageUp,
"scroll_page_up",
&[(KeyCode::PageUp, KeyModifiers::NONE)],
),
(
KeyAction::ScrollPageDown,
"scroll_page_down",
&[(KeyCode::PageDown, KeyModifiers::NONE)],
),
(
KeyAction::ScrollToTop,
"scroll_to_top",
&[(KeyCode::Home, KeyModifiers::CONTROL)],
),
(
KeyAction::ScrollToBottom,
"scroll_to_bottom",
&[(KeyCode::End, KeyModifiers::CONTROL)],
),
(
KeyAction::NextChat,
"next_chat",
&[(KeyCode::Char('n'), KeyModifiers::CONTROL)],
),
(
KeyAction::PrevChat,
"prev_chat",
&[(KeyCode::Char('p'), KeyModifiers::CONTROL)],
),
(
KeyAction::CloseChat,
"close_chat",
&[(KeyCode::Char('x'), KeyModifiers::CONTROL)],
),
(
KeyAction::KillSubagent,
"kill_subagent",
&[(KeyCode::Char('k'), KeyModifiers::CONTROL)],
),
(
KeyAction::DropQueue,
"drop_queue",
&[(KeyCode::Char('x'), KeyModifiers::ALT)],
),
(
KeyAction::CyclePrompt,
"cycle_prompt",
&[(KeyCode::Tab, KeyModifiers::SHIFT)],
),
];
}
#[derive(Debug, Clone)]
pub struct Bindings<A: Command> {
map: HashMap<ChordSeq, A>,
}
impl<A: Command> Default for Bindings<A> {
fn default() -> Self {
Self {
map: HashMap::new(),
}
}
}
fn normalize_chord(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
if code == KeyCode::BackTab {
(KeyCode::Tab, KeyModifiers::SHIFT)
} else {
(code, modifiers)
}
}
impl<A: Command> Bindings<A> {
pub fn defaults() -> Self {
let mut map = HashMap::new();
for (action, _, chords) in A::ALL {
for chord in *chords {
map.insert(vec![*chord], *action);
}
}
Self { map }
}
pub fn resolve(&self, key: &KeyEvent) -> Option<A> {
let chord = normalize_chord(key.code, key.modifiers);
self.map.get(&[chord][..]).copied()
}
pub fn resolve_lenient(&self, key: &KeyEvent) -> Option<A> {
if let Some(action) = self.resolve(key) {
return Some(action);
}
if key.modifiers.contains(KeyModifiers::SHIFT) {
let without_shift = key.modifiers - KeyModifiers::SHIFT;
return self.map.get(&[(key.code, without_shift)][..]).copied();
}
None
}
fn is_strict_prefix(&self, seq: &[Chord]) -> bool {
self.map
.keys()
.any(|k| k.len() > seq.len() && k.starts_with(seq))
}
#[cfg(test)]
pub fn insert(&mut self, chord: Chord, action: A) {
self.map.insert(vec![chord], action);
}
}
impl Bindings<KeyAction> {
pub fn classify_seq(&self, seq: &[Chord]) -> SeqClass {
if seq.len() >= 2
&& let Some(action) = self.map.get(seq).copied()
{
return SeqClass::Exact(action);
}
if self.is_strict_prefix(seq) {
SeqClass::Prefix
} else {
SeqClass::NoMatch
}
}
}
pub type Keymap = Bindings<KeyAction>;
pub type InputKeymap = Bindings<InputAction>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeqClass {
Exact(KeyAction),
Prefix,
NoMatch,
}
fn chord_label(chord: &Chord) -> String {
let (code, mods) = chord;
let mut s = String::new();
if mods.contains(KeyModifiers::CONTROL) {
s.push_str("ctrl-");
}
if mods.contains(KeyModifiers::ALT) {
s.push_str("alt-");
}
if mods.contains(KeyModifiers::SHIFT) {
s.push_str("shift-");
}
let key = match code {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "enter".to_string(),
KeyCode::Esc => "esc".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Delete => "delete".to_string(),
KeyCode::Insert => "insert".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "pageup".to_string(),
KeyCode::PageDown => "pagedown".to_string(),
KeyCode::F(n) => format!("f{n}"),
other => format!("{other:?}").to_ascii_lowercase(),
};
s.push_str(&key);
s
}
pub fn chord_seq_label(seq: &[Chord]) -> String {
seq.iter().map(chord_label).collect::<Vec<_>>().join(" ")
}
#[derive(Debug, Clone, Default)]
pub struct Keymaps {
pub global: Keymap,
pub input: InputKeymap,
}
impl Keymaps {
pub fn from_config(bindings: Option<&[KeybindingConfig]>) -> (Self, Vec<String>) {
let mut global = Keymap::defaults();
let mut input = InputKeymap::defaults();
let mut warnings = Vec::new();
for b in bindings.unwrap_or(&[]) {
let Some(seq) = parse_chord_sequence(&b.key) else {
warnings.push(format!("keybindings: unrecognized key {:?}", b.key));
continue;
};
let cmd = b.command.trim().to_ascii_lowercase().replace('-', "_");
if matches!(cmd.as_str(), "none" | "noop" | "unbind" | "") {
global.map.remove(&seq);
input.map.remove(&seq);
continue;
}
if let Some(action) = KeyAction::from_command(&cmd) {
global.map.remove(&seq);
input.map.remove(&seq);
global.map.insert(seq, action);
} else if let Some(action) = InputAction::from_command(&cmd) {
global.map.remove(&seq);
input.map.remove(&seq);
input.map.insert(seq, action);
} else {
warnings.push(format!(
"keybindings: unknown command {:?} for key {:?} (valid: {}, {})",
b.command,
b.key,
KeyAction::command_list(),
InputAction::command_list()
));
}
}
let prefixed: Vec<ChordSeq> = global
.map
.keys()
.filter(|k| k.len() == 1 && global.is_strict_prefix(k))
.cloned()
.collect();
for k in prefixed {
global.map.remove(&k);
warnings.push(format!(
"keybindings: {} starts a chord sequence; its single-key binding is disabled",
chord_seq_label(&k)
));
}
let input_seqs: Vec<ChordSeq> = input.map.keys().filter(|k| k.len() > 1).cloned().collect();
for k in input_seqs {
input.map.remove(&k);
warnings.push(format!(
"keybindings: chord sequences ({}) are only supported for global commands, \
not input-editor commands",
chord_seq_label(&k)
));
}
(Keymaps { global, input }, warnings)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputAction {
CursorLineStart,
CursorLineEnd,
CursorLeft,
CursorRight,
WordLeft,
WordRight,
DeleteCharBack,
DeleteCharForward,
KillToLineEnd,
KillToLineStart,
KillWordBack,
DeleteWordBack,
DeleteWordForward,
Yank,
YankPop,
HistoryPrev,
HistoryNext,
ReverseSearch,
LineUp,
LineDown,
Undo,
}
impl Command for InputAction {
const ALL: &'static [(InputAction, &'static str, &'static [Chord])] = &[
(
InputAction::CursorLineStart,
"cursor_line_start",
&[
(KeyCode::Char('a'), KeyModifiers::CONTROL),
(KeyCode::Home, KeyModifiers::NONE),
],
),
(
InputAction::CursorLineEnd,
"cursor_line_end",
&[
(KeyCode::Char('e'), KeyModifiers::CONTROL),
(KeyCode::End, KeyModifiers::NONE),
],
),
(
InputAction::CursorLeft,
"cursor_left",
&[
(KeyCode::Char('b'), KeyModifiers::CONTROL),
(KeyCode::Left, KeyModifiers::NONE),
],
),
(
InputAction::CursorRight,
"cursor_right",
&[(KeyCode::Right, KeyModifiers::NONE)],
),
(
InputAction::WordLeft,
"word_left",
&[
(KeyCode::Char('b'), KeyModifiers::ALT),
(KeyCode::Left, KeyModifiers::ALT),
],
),
(
InputAction::WordRight,
"word_right",
&[
(KeyCode::Char('f'), KeyModifiers::ALT),
(KeyCode::Right, KeyModifiers::ALT),
],
),
(
InputAction::DeleteCharBack,
"delete_char_back",
&[(KeyCode::Char('h'), KeyModifiers::CONTROL)],
),
(
InputAction::DeleteCharForward,
"delete_char_forward",
&[(KeyCode::Char('d'), KeyModifiers::CONTROL)],
),
(
InputAction::KillToLineEnd,
"kill_to_line_end",
&[(KeyCode::Char('k'), KeyModifiers::CONTROL)],
),
(
InputAction::KillToLineStart,
"kill_to_line_start",
&[(KeyCode::Char('u'), KeyModifiers::CONTROL)],
),
(
InputAction::KillWordBack,
"kill_word_back",
&[(KeyCode::Char('w'), KeyModifiers::CONTROL)],
),
(
InputAction::DeleteWordBack,
"delete_word_back",
&[(KeyCode::Backspace, KeyModifiers::ALT)],
),
(
InputAction::DeleteWordForward,
"delete_word_forward",
&[(KeyCode::Char('d'), KeyModifiers::ALT)],
),
(
InputAction::Yank,
"yank",
&[(KeyCode::Char('y'), KeyModifiers::CONTROL)],
),
(
InputAction::YankPop,
"yank_pop",
&[(KeyCode::Char('y'), KeyModifiers::ALT)],
),
(
InputAction::HistoryPrev,
"history_prev",
&[(KeyCode::Char('p'), KeyModifiers::CONTROL)],
),
(
InputAction::HistoryNext,
"history_next",
&[(KeyCode::Char('n'), KeyModifiers::CONTROL)],
),
(
InputAction::ReverseSearch,
"reverse_search",
&[(KeyCode::Char('f'), KeyModifiers::CONTROL)],
),
(
InputAction::LineUp,
"line_up",
&[(KeyCode::Up, KeyModifiers::NONE)],
),
(
InputAction::LineDown,
"line_down",
&[(KeyCode::Down, KeyModifiers::NONE)],
),
(
InputAction::Undo,
"undo",
&[(KeyCode::Char('z'), KeyModifiers::CONTROL)],
),
];
}
pub fn parse_chord_sequence(spec: &str) -> Option<ChordSeq> {
let chords: Option<ChordSeq> = spec.split_whitespace().map(parse_chord).collect();
let chords = chords?;
if chords.is_empty() {
None
} else {
Some(chords)
}
}
pub fn parse_chord(spec: &str) -> Option<(KeyCode, KeyModifiers)> {
let spec = spec.trim().to_ascii_lowercase();
if spec.is_empty() {
return None;
}
let parts: Vec<&str> = spec.split(['-', '+']).filter(|s| !s.is_empty()).collect();
let (key_part, mod_parts) = parts.split_last()?;
let mut modifiers = KeyModifiers::NONE;
for m in mod_parts {
match *m {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"alt" | "meta" | "option" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
_ => return None,
}
}
let code = match *key_part {
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"tab" => KeyCode::Tab,
"backtab" => {
modifiers |= KeyModifiers::SHIFT;
KeyCode::Tab
}
"backspace" | "bs" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"insert" | "ins" => KeyCode::Insert,
"space" => KeyCode::Char(' '),
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" | "pagedn" => KeyCode::PageDown,
f if f.starts_with('f') && f.len() >= 2 && f[1..].chars().all(|c| c.is_ascii_digit()) => {
let suffix = &f[1..];
if suffix.len() > 1 && suffix.starts_with('0') {
return None;
}
let n: u8 = suffix.parse().ok()?;
if (1..=12).contains(&n) {
KeyCode::F(n)
} else {
return None;
}
}
s if s.chars().count() == 1 => KeyCode::Char(s.chars().next().unwrap()),
_ => return None,
};
Some((code, modifiers))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(key: &str, command: &str) -> KeybindingConfig {
KeybindingConfig {
key: key.to_string(),
command: command.to_string(),
}
}
fn ev(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn global_from(bindings: &[KeybindingConfig]) -> (Keymap, Vec<String>) {
let (kms, warns) = Keymaps::from_config(Some(bindings));
(kms.global, warns)
}
#[test]
fn defaults_resolve() {
let km = Keymap::defaults();
assert_eq!(
km.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
Some(KeyAction::ToggleReasoning)
);
assert_eq!(
km.resolve(&ev(KeyCode::PageUp, KeyModifiers::NONE)),
Some(KeyAction::ScrollPageUp)
);
assert_eq!(
km.resolve(&ev(KeyCode::Char('a'), KeyModifiers::NONE)),
None
);
}
#[test]
fn alt_x_drops_queue_ctrl_x_closes_chat() {
let km = Keymap::defaults();
assert_eq!(
km.resolve(&ev(KeyCode::Char('x'), KeyModifiers::ALT)),
Some(KeyAction::DropQueue)
);
assert_eq!(
km.resolve(&ev(KeyCode::Char('x'), KeyModifiers::CONTROL)),
Some(KeyAction::CloseChat)
);
assert_eq!(
KeyAction::from_command("drop_queue"),
Some(KeyAction::DropQueue)
);
}
#[test]
fn home_end_scroll_is_ctrl_only() {
let km = Keymap::defaults();
assert_eq!(km.resolve(&ev(KeyCode::Home, KeyModifiers::NONE)), None);
assert_eq!(km.resolve(&ev(KeyCode::End, KeyModifiers::NONE)), None);
assert_eq!(
km.resolve(&ev(KeyCode::Home, KeyModifiers::CONTROL)),
Some(KeyAction::ScrollToTop)
);
assert_eq!(
km.resolve(&ev(KeyCode::End, KeyModifiers::CONTROL)),
Some(KeyAction::ScrollToBottom)
);
}
#[test]
fn parse_chord_forms() {
assert_eq!(
parse_chord("ctrl-r"),
Some((KeyCode::Char('r'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_chord("Ctrl+T"),
Some((KeyCode::Char('t'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_chord("pageup"),
Some((KeyCode::PageUp, KeyModifiers::NONE))
);
assert_eq!(
parse_chord("ctrl-shift-x"),
Some((
KeyCode::Char('x'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
))
);
assert_eq!(parse_chord("f5"), Some((KeyCode::F(5), KeyModifiers::NONE)));
assert_eq!(parse_chord("boguskey"), None);
assert_eq!(parse_chord("ctrl-"), None);
assert_eq!(parse_chord("f99"), None);
}
#[test]
fn parse_chord_is_the_one_grammar() {
assert_eq!(
parse_chord("f01"),
None,
"strict f-keys reject leading zero"
);
assert_eq!(
parse_chord("bs"),
Some((KeyCode::Backspace, KeyModifiers::NONE)),
"the `bs` alias is accepted"
);
assert_eq!(
parse_chord("ctrl+x"),
Some((KeyCode::Char('x'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_chord("option-f"),
Some((KeyCode::Char('f'), KeyModifiers::ALT))
);
}
#[test]
fn shift_tab_and_backtab_parse_to_tab_shift() {
assert_eq!(
parse_chord("shift-tab"),
Some((KeyCode::Tab, KeyModifiers::SHIFT))
);
assert_eq!(
parse_chord("backtab"),
Some((KeyCode::Tab, KeyModifiers::SHIFT))
);
}
#[test]
fn backtab_keyevent_resolves_a_shift_tab_binding() {
let (km, warns) = global_from(&[cfg("shift-tab", "toggle_reasoning")]);
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
km.resolve(&ev(KeyCode::BackTab, KeyModifiers::NONE)),
Some(KeyAction::ToggleReasoning)
);
assert_eq!(
km.resolve(&ev(KeyCode::BackTab, KeyModifiers::SHIFT)),
Some(KeyAction::ToggleReasoning)
);
}
#[test]
fn cycle_prompt_command_resolves() {
assert_eq!(
KeyAction::from_command("cycle_prompt"),
Some(KeyAction::CyclePrompt)
);
assert_eq!(
KeyAction::from_command("cycle-prompt"),
Some(KeyAction::CyclePrompt)
);
}
#[test]
fn cycle_prompt_is_the_default_shift_tab_binding() {
let km = Keymap::defaults();
assert_eq!(
km.resolve(&ev(KeyCode::BackTab, KeyModifiers::NONE)),
Some(KeyAction::CyclePrompt)
);
assert_eq!(
km.resolve(&ev(KeyCode::Tab, KeyModifiers::SHIFT)),
Some(KeyAction::CyclePrompt)
);
}
#[test]
fn override_rebinds_and_keeps_other_defaults() {
let (km, warns) = global_from(&[cfg("ctrl-t", "toggle_reasoning")]);
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
km.resolve(&ev(KeyCode::Char('t'), KeyModifiers::CONTROL)),
Some(KeyAction::ToggleReasoning)
);
assert_eq!(
km.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
Some(KeyAction::ToggleReasoning)
);
assert_eq!(
km.resolve(&ev(KeyCode::Char('n'), KeyModifiers::CONTROL)),
Some(KeyAction::NextChat)
);
}
#[test]
fn override_on_an_occupied_chord_replaces_it() {
let (km, warns) = global_from(&[cfg("ctrl-r", "next_chat")]);
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
km.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
Some(KeyAction::NextChat)
);
}
#[test]
fn unbind_removes_a_default() {
let (km, _) = global_from(&[cfg("ctrl-r", "none")]);
assert_eq!(
km.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
None
);
}
#[test]
fn rebind_across_contexts_overrides_without_explicit_unbind() {
let (kms, warns) = Keymaps::from_config(Some(&[cfg("ctrl-r", "reverse_search")]));
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
Some(InputAction::ReverseSearch),
);
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
None,
"the shadowing global default must be cleared by the rebind",
);
}
#[test]
fn rebind_input_default_to_global_clears_the_input_default() {
let (kms, warns) = Keymaps::from_config(Some(&[cfg("ctrl-a", "next_chat")]));
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('a'), KeyModifiers::CONTROL)),
Some(KeyAction::NextChat),
);
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('a'), KeyModifiers::CONTROL)),
None,
);
}
#[test]
fn invalid_chord_and_unknown_command_warn() {
let (_, warns) = global_from(&[
cfg("kaboom", "toggle_reasoning"),
cfg("ctrl-y", "do_a_barrel_roll"),
]);
assert_eq!(warns.len(), 2, "{warns:?}");
assert!(warns[0].contains("unrecognized key"));
assert!(warns[1].contains("unknown command"));
}
#[test]
fn input_defaults_resolve_historical_chords() {
let km = InputKeymap::defaults();
let cases = [
(
(KeyCode::Char('a'), KeyModifiers::CONTROL),
InputAction::CursorLineStart,
),
(
(KeyCode::Home, KeyModifiers::NONE),
InputAction::CursorLineStart,
),
(
(KeyCode::Char('e'), KeyModifiers::CONTROL),
InputAction::CursorLineEnd,
),
(
(KeyCode::End, KeyModifiers::NONE),
InputAction::CursorLineEnd,
),
(
(KeyCode::Char('b'), KeyModifiers::CONTROL),
InputAction::CursorLeft,
),
((KeyCode::Left, KeyModifiers::NONE), InputAction::CursorLeft),
(
(KeyCode::Right, KeyModifiers::NONE),
InputAction::CursorRight,
),
(
(KeyCode::Char('b'), KeyModifiers::ALT),
InputAction::WordLeft,
),
((KeyCode::Left, KeyModifiers::ALT), InputAction::WordLeft),
(
(KeyCode::Char('f'), KeyModifiers::ALT),
InputAction::WordRight,
),
((KeyCode::Right, KeyModifiers::ALT), InputAction::WordRight),
(
(KeyCode::Char('d'), KeyModifiers::CONTROL),
InputAction::DeleteCharForward,
),
(
(KeyCode::Char('k'), KeyModifiers::CONTROL),
InputAction::KillToLineEnd,
),
(
(KeyCode::Char('u'), KeyModifiers::CONTROL),
InputAction::KillToLineStart,
),
(
(KeyCode::Char('w'), KeyModifiers::CONTROL),
InputAction::KillWordBack,
),
(
(KeyCode::Backspace, KeyModifiers::ALT),
InputAction::DeleteWordBack,
),
(
(KeyCode::Char('d'), KeyModifiers::ALT),
InputAction::DeleteWordForward,
),
(
(KeyCode::Char('y'), KeyModifiers::CONTROL),
InputAction::Yank,
),
(
(KeyCode::Char('y'), KeyModifiers::ALT),
InputAction::YankPop,
),
(
(KeyCode::Char('p'), KeyModifiers::CONTROL),
InputAction::HistoryPrev,
),
(
(KeyCode::Char('n'), KeyModifiers::CONTROL),
InputAction::HistoryNext,
),
(
(KeyCode::Char('f'), KeyModifiers::CONTROL),
InputAction::ReverseSearch,
),
((KeyCode::Up, KeyModifiers::NONE), InputAction::LineUp),
((KeyCode::Down, KeyModifiers::NONE), InputAction::LineDown),
];
for ((code, mods), want) in cases {
assert_eq!(km.resolve(&ev(code, mods)), Some(want), "{code:?}+{mods:?}");
}
}
#[test]
fn input_resolve_treats_shift_as_insignificant_on_miss() {
let km = InputKeymap::defaults();
assert_eq!(
km.resolve_lenient(&ev(KeyCode::Left, KeyModifiers::SHIFT)),
Some(InputAction::CursorLeft)
);
assert_eq!(
km.resolve_lenient(&ev(KeyCode::Home, KeyModifiers::SHIFT)),
Some(InputAction::CursorLineStart)
);
assert_eq!(
km.resolve_lenient(&ev(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
)),
Some(InputAction::CursorLineStart)
);
let mut km2 = InputKeymap::defaults();
km2.insert((KeyCode::Left, KeyModifiers::SHIFT), InputAction::WordLeft);
assert_eq!(
km2.resolve_lenient(&ev(KeyCode::Left, KeyModifiers::SHIFT)),
Some(InputAction::WordLeft)
);
}
#[test]
fn input_intrinsic_keys_are_unbound() {
let km = InputKeymap::defaults();
for (code, mods) in [
(KeyCode::Char('a'), KeyModifiers::NONE),
(KeyCode::Backspace, KeyModifiers::NONE),
(KeyCode::Delete, KeyModifiers::NONE),
(KeyCode::Enter, KeyModifiers::NONE),
(KeyCode::Tab, KeyModifiers::NONE),
] {
assert_eq!(km.resolve(&ev(code, mods)), None, "{code:?}");
}
}
#[test]
fn every_input_command_name_round_trips() {
for (action, name, _) in InputAction::ALL {
assert_eq!(InputAction::from_command(name), Some(*action), "{name}");
}
assert_eq!(InputAction::from_command("not_a_command"), None);
for (_, name, _) in InputAction::ALL {
assert_eq!(KeyAction::from_command(name), None, "collision on {name}");
}
for (_, name, _) in KeyAction::ALL {
assert_eq!(InputAction::from_command(name), None, "collision on {name}");
}
}
#[test]
fn config_rebinds_an_input_command() {
let (kms, warns) = Keymaps::from_config(Some(&[cfg("ctrl-z", "kill_to_line_start")]));
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('z'), KeyModifiers::CONTROL)),
Some(InputAction::KillToLineStart)
);
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('u'), KeyModifiers::CONTROL)),
Some(InputAction::KillToLineStart)
);
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
Some(KeyAction::ToggleReasoning)
);
}
#[test]
fn config_routes_global_and_input_in_one_array() {
let (kms, warns) = Keymaps::from_config(Some(&[
cfg("ctrl-t", "toggle_reasoning"), cfg("alt-a", "cursor_line_start"), ]));
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('t'), KeyModifiers::CONTROL)),
Some(KeyAction::ToggleReasoning)
);
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('a'), KeyModifiers::ALT)),
Some(InputAction::CursorLineStart)
);
}
#[test]
fn unbind_clears_both_contexts() {
let (kms, _) = Keymaps::from_config(Some(&[cfg("ctrl-n", "none")]));
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('n'), KeyModifiers::CONTROL)),
None
);
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('n'), KeyModifiers::CONTROL)),
None
);
}
#[test]
fn unknown_command_warns_with_both_namespaces() {
let (_, warns) = Keymaps::from_config(Some(&[cfg("ctrl-y", "do_a_barrel_roll")]));
assert_eq!(warns.len(), 1, "{warns:?}");
assert!(warns[0].contains("unknown command"));
assert!(warns[0].contains("toggle_reasoning"));
assert!(warns[0].contains("cursor_line_start"));
}
#[test]
fn parse_chord_sequence_forms() {
assert_eq!(
parse_chord_sequence("ctrl-r"),
Some(vec![(KeyCode::Char('r'), KeyModifiers::CONTROL)])
);
assert_eq!(
parse_chord_sequence("ctrl-x ctrl-s"),
Some(vec![
(KeyCode::Char('x'), KeyModifiers::CONTROL),
(KeyCode::Char('s'), KeyModifiers::CONTROL),
])
);
assert_eq!(
parse_chord_sequence("ctrl-space"),
Some(vec![(KeyCode::Char(' '), KeyModifiers::CONTROL)])
);
assert_eq!(parse_chord_sequence(" "), None);
assert_eq!(parse_chord_sequence("ctrl-x boguskey"), None);
}
#[test]
fn plugin_then_user_precedence_user_wins() {
let plugin = [
cfg("ctrl-t", "toggle_reasoning"), cfg("ctrl-r", "next_chat"), ];
let user = [cfg("ctrl-r", "scroll_to_top")];
let merged: Vec<KeybindingConfig> = plugin.iter().chain(user.iter()).cloned().collect();
let (kms, warns) = Keymaps::from_config(Some(&merged));
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('t'), KeyModifiers::CONTROL)),
Some(KeyAction::ToggleReasoning)
);
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('r'), KeyModifiers::CONTROL)),
Some(KeyAction::ScrollToTop)
);
}
#[test]
fn plugin_can_rebind_an_input_command_and_unbind() {
let plugin = [cfg("ctrl-t", "kill_to_line_end"), cfg("ctrl-k", "none")];
let (kms, warns) = Keymaps::from_config(Some(&plugin));
assert!(warns.is_empty(), "{warns:?}");
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('t'), KeyModifiers::CONTROL)),
Some(InputAction::KillToLineEnd)
);
assert_eq!(
kms.input
.resolve(&ev(KeyCode::Char('k'), KeyModifiers::CONTROL)),
None
);
}
fn ctrl(c: char) -> Chord {
(KeyCode::Char(c), KeyModifiers::CONTROL)
}
#[test]
fn sequence_binding_disables_its_terminal_prefix() {
let (kms, warns) = Keymaps::from_config(Some(&[cfg("ctrl-x ctrl-s", "toggle_reasoning")]));
assert_eq!(
kms.global.map.get(&vec![ctrl('x'), ctrl('s')]),
Some(&KeyAction::ToggleReasoning)
);
assert_eq!(
kms.global
.resolve(&ev(KeyCode::Char('x'), KeyModifiers::CONTROL)),
None
);
assert!(
warns.iter().any(|w| w.contains("starts a chord sequence")),
"{warns:?}"
);
}
#[test]
fn classify_seq_holds_prefix_then_fires_exact() {
let (kms, _) = Keymaps::from_config(Some(&[cfg("ctrl-x ctrl-s", "scroll_to_top")]));
let km = &kms.global;
assert_eq!(km.classify_seq(&[ctrl('x')]), SeqClass::Prefix);
assert_eq!(
km.classify_seq(&[ctrl('x'), ctrl('s')]),
SeqClass::Exact(KeyAction::ScrollToTop)
);
assert_eq!(km.classify_seq(&[ctrl('x'), ctrl('a')]), SeqClass::NoMatch);
assert_eq!(km.classify_seq(&[ctrl('r')]), SeqClass::NoMatch);
}
#[test]
fn single_key_bindings_never_classify_as_exact() {
let km = Keymap::defaults();
assert_eq!(km.classify_seq(&[ctrl('r')]), SeqClass::NoMatch);
}
#[test]
fn input_command_sequence_is_rejected_with_warning() {
let (kms, warns) = Keymaps::from_config(Some(&[cfg("ctrl-x ctrl-s", "kill_to_line_end")]));
assert!(
kms.input.map.keys().all(|k| k.len() == 1),
"no input sequences kept"
);
assert!(
warns
.iter()
.any(|w| w.contains("only supported for global commands")),
"{warns:?}"
);
}
#[test]
fn chord_labels_round_trip_the_grammar() {
assert_eq!(chord_label(&ctrl('x')), "ctrl-x");
assert_eq!(chord_label(&(KeyCode::Home, KeyModifiers::NONE)), "home");
assert_eq!(
chord_label(&(KeyCode::Char('f'), KeyModifiers::ALT | KeyModifiers::SHIFT)),
"alt-shift-f"
);
assert_eq!(chord_seq_label(&[ctrl('x'), ctrl('s')]), "ctrl-x ctrl-s");
}
}