extern crate self as escriba_keymap;
use escriba_search::{CaretMove, Direction as SearchDirection};
use std::collections::HashMap;
use escriba_core::{Action, CountedAction, Mode, Motion, Operator, TextObject};
use escriba_mode::ModalState;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Key {
Char(char),
Esc,
Enter,
Tab,
Backspace,
Delete,
Left,
Right,
Up,
Down,
PageUp,
PageDown,
Home,
End,
Ctrl(char),
Alt(char),
F(u8),
Chord(awase::Hotkey),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Binding {
pub action: Action,
pub description: String,
}
impl Binding {
#[must_use]
pub fn new(action: Action, description: impl Into<String>) -> Self {
Self {
action,
description: description.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Collision {
Reserved {
mode: Mode,
key: String,
description: String,
why: String,
},
Displaced {
mode: Mode,
key: String,
replaced: String,
with: String,
},
}
impl Collision {
#[must_use]
pub fn report(&self) -> String {
match self {
Self::Reserved {
mode,
key,
description,
why,
} => format!("{mode:?} {key} ({description}) — {why}"),
Self::Displaced {
mode,
key,
replaced,
with,
} => format!("{mode:?} {key} — \"{replaced}\" was replaced by \"{with}\""),
}
}
#[must_use]
pub const fn is_fatal(&self) -> bool {
matches!(self, Self::Reserved { .. })
}
}
#[derive(Debug, Clone)]
pub struct Keymap {
bindings: HashMap<(Mode, Key), Binding>,
sequences: HashMap<(Mode, Vec<Key>), Binding>,
leader: Key,
reserved: awase::Reserved,
collisions: Vec<Collision>,
}
impl Default for Keymap {
fn default() -> Self {
Self {
bindings: HashMap::new(),
sequences: HashMap::new(),
reserved: awase::Reserved::fleet_darwin(),
collisions: Vec::new(),
leader: Key::Char(','),
}
}
}
impl Keymap {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn default_vim() -> Self {
let mut m = Self::new();
let nm = |m: &mut Keymap, k: Key, a: Action, d: &'static str| m.bind(Mode::Normal, k, a, d);
nm(
&mut m,
Key::Char('h'),
Action::Move(Motion::Left),
"move left",
);
nm(
&mut m,
Key::Char('l'),
Action::Move(Motion::Right),
"move right",
);
nm(
&mut m,
Key::Char('j'),
Action::Move(Motion::Down),
"move down",
);
nm(&mut m, Key::Char('k'), Action::Move(Motion::Up), "move up");
nm(
&mut m,
Key::Char('w'),
Action::Move(Motion::WordStartNext),
"word forward",
);
nm(
&mut m,
Key::Char('b'),
Action::Move(Motion::WordStartPrev),
"word back",
);
nm(
&mut m,
Key::Char('0'),
Action::Move(Motion::LineStart),
"line start",
);
nm(
&mut m,
Key::Char('$'),
Action::Move(Motion::LineEnd),
"line end",
);
nm(
&mut m,
Key::Char('G'),
Action::Move(Motion::DocEnd),
"doc end",
);
nm(
&mut m,
Key::Char('d'),
Action::Operator(Operator::Delete),
"delete (operator)",
);
nm(
&mut m,
Key::Char('c'),
Action::Operator(Operator::Change),
"change (operator)",
);
nm(
&mut m,
Key::Char('y'),
Action::Operator(Operator::Yank),
"yank (operator)",
);
nm(
&mut m,
Key::Alt('f'),
Action::Move(Motion::ForwardSexp),
"forward sexp",
);
nm(
&mut m,
Key::Alt('b'),
Action::Move(Motion::BackwardSexp),
"backward sexp",
);
nm(
&mut m,
Key::Alt('u'),
Action::Move(Motion::UpList),
"up list",
);
nm(
&mut m,
Key::Alt('d'),
Action::Move(Motion::DownList),
"down list",
);
nm(
&mut m,
Key::Char('i'),
Action::ChangeMode(Mode::Insert),
"insert",
);
nm(
&mut m,
Key::Char('v'),
Action::ChangeMode(Mode::Visual),
"visual",
);
nm(
&mut m,
Key::Char('V'),
Action::ChangeMode(Mode::VisualLine),
"visual line",
);
nm(
&mut m,
Key::Char(':'),
Action::ChangeMode(Mode::Command),
"command",
);
nm(&mut m, Key::Char('u'), Action::Undo, "undo");
nm(
&mut m,
Key::Char('.'),
Action::RepeatLastChange,
"repeat last change",
);
nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
m.bind(
Mode::Insert,
Key::Esc,
Action::ChangeMode(Mode::Normal),
"to normal",
);
m.bind(
Mode::Command,
Key::Esc,
Action::ChangeMode(Mode::Normal),
"abort",
);
m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
m.bind(
Mode::Command,
Key::Up,
Action::PromptHistory { back: true },
"older search",
);
m.bind(
Mode::Command,
Key::Down,
Action::PromptHistory { back: false },
"newer search",
);
m.bind(
Mode::Command,
Key::Backspace,
Action::PromptBackspace,
"erase one char",
);
m.bind(
Mode::Command,
Key::Delete,
Action::PromptDelete,
"delete char at caret",
);
m.bind(
Mode::Command,
Key::Left,
Action::PromptCaret {
to: CaretMove::Left,
},
"caret left",
);
m.bind(
Mode::Command,
Key::Right,
Action::PromptCaret {
to: CaretMove::Right,
},
"caret right",
);
m.bind(
Mode::Command,
Key::Home,
Action::PromptCaret {
to: CaretMove::Start,
},
"caret to start",
);
m.bind(
Mode::Command,
Key::End,
Action::PromptCaret { to: CaretMove::End },
"caret to end",
);
m.bind(
Mode::Command,
Key::Ctrl('w'),
Action::PromptDeleteWord,
"delete word before caret",
);
m.bind(
Mode::Command,
Key::Ctrl('g'),
Action::SearchPreviewStep { forward: true },
"preview next match",
);
m.bind(
Mode::Command,
Key::Ctrl('t'),
Action::SearchPreviewStep { forward: false },
"preview previous match",
);
m.bind(
Mode::Command,
Key::Ctrl('u'),
Action::PromptClearToStart,
"clear to start",
);
nm(
&mut m,
Key::Char('/'),
Action::SearchOpen(SearchDirection::Forward),
"search forward",
);
nm(
&mut m,
Key::Char('?'),
Action::SearchOpen(SearchDirection::Backward),
"search backward",
);
nm(
&mut m,
Key::Char('n'),
Action::Move(Motion::SearchNext),
"next match",
);
nm(
&mut m,
Key::Char('N'),
Action::Move(Motion::SearchPrev),
"previous match",
);
m.bind_sequence(
Mode::Normal,
vec![Key::Char('g'), Key::Char('n')],
Action::TextObject(TextObject::NextMatch),
"next match (object)",
);
m.bind_sequence(
Mode::Normal,
vec![Key::Char('g'), Key::Char('N')],
Action::TextObject(TextObject::PrevMatch),
"previous match (object)",
);
nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
nm(
&mut m,
Key::Char('*'),
Action::SearchWord { reverse: false },
"search word forward",
);
nm(
&mut m,
Key::Char('#'),
Action::SearchWord { reverse: true },
"search word backward",
);
m.bind(
Mode::Visual,
Key::Esc,
Action::ChangeMode(Mode::Normal),
"to normal",
);
m.bind(
Mode::VisualLine,
Key::Esc,
Action::ChangeMode(Mode::Normal),
"to normal",
);
m
}
pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
let binding = Binding::new(action, desc);
self.note_collisions(mode, std::slice::from_ref(&key), &binding);
self.bindings.insert((mode, key), binding);
}
fn note_collisions(&mut self, mode: Mode, keys: &[Key], binding: &Binding) {
let Some(first) = keys.first() else { return };
let spelled = format!("{keys:?}");
if let Some(hk) = to_hotkey(first) {
if let Some(why) = self.reserved.refuse(&hk) {
self.collisions.push(Collision::Reserved {
mode,
key: spelled.clone(),
description: binding.description.clone(),
why,
});
}
}
let existing = if keys.len() == 1 {
self.bindings
.get(&(mode, first.clone()))
.map(|b| &b.description)
} else {
self.sequences
.get(&(mode, keys.to_vec()))
.map(|b| &b.description)
};
if let Some(replaced) = existing {
self.collisions.push(Collision::Displaced {
mode,
key: spelled,
replaced: replaced.clone(),
with: binding.description.clone(),
});
}
}
#[must_use]
pub fn collisions(&self) -> &[Collision] {
&self.collisions
}
pub fn fatal_collisions(&self) -> impl Iterator<Item = &Collision> {
self.collisions.iter().filter(|c| c.is_fatal())
}
#[must_use]
pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
self.bindings.get(&(mode, key.clone()))
}
#[must_use]
pub fn leader(&self) -> &Key {
&self.leader
}
pub fn set_leader(&mut self, key: Key) {
self.leader = key;
}
pub fn bind_sequence(
&mut self,
mode: Mode,
keys: Vec<Key>,
action: Action,
desc: impl Into<String>,
) {
match keys.as_slice() {
[] => {}
[single] => self.bind(mode, single.clone(), action, desc),
_ => {
let binding = Binding::new(action, desc);
self.note_collisions(mode, &keys, &binding);
self.sequences.insert((mode, keys), binding);
}
}
}
#[must_use]
pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
self.sequences.get(&(mode, keys.to_vec()))
}
#[must_use]
pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
self.sequences
.keys()
.any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
}
#[must_use]
pub fn sequences_extending(&self, mode: Mode, prefix: &[Key]) -> Vec<(&[Key], &Binding)> {
let mut v: Vec<(&[Key], &Binding)> = self
.sequences
.iter()
.filter(|((m, seq), _)| {
*m == mode && seq.len() > prefix.len() && seq.starts_with(prefix)
})
.map(|((_, seq), b)| (seq.as_slice(), b))
.collect();
v.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
v
}
#[must_use]
pub fn sequence_len(&self) -> usize {
self.sequences.len()
}
#[must_use]
pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
let mode = state.mode();
if mode == Mode::Normal {
if let Key::Char(c) = key {
if c.is_ascii_digit() && *c != '0' {
return CountedAction::once(Action::Pending);
}
if *c == '0' && state.pending_count().is_some() {
return CountedAction::once(Action::Pending);
}
}
}
if mode == Mode::Insert {
if let Key::Char(c) = key {
return CountedAction::once(Action::InsertChar(*c));
}
if matches!(key, Key::Enter) {
return CountedAction::once(Action::InsertChar('\n'));
}
}
if mode == Mode::Command {
if let Key::Char(c) = key {
return CountedAction::once(Action::InsertChar(*c));
}
}
if let Some(b) = self.lookup(mode, key) {
return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
}
CountedAction::once(Action::Pending)
}
#[must_use]
pub fn len(&self) -> usize {
self.bindings.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.bindings.is_empty()
}
#[must_use]
pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
v.sort_by(|a, b| {
(a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
});
v
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_vim_has_bindings() {
let k = Keymap::default_vim();
assert!(k.len() > 10);
assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
}
#[test]
fn dispatch_normal_motion() {
let k = Keymap::default_vim();
let s = ModalState::new();
let a = k.dispatch(&s, &Key::Char('h'));
assert_eq!(a.count, 1);
assert_eq!(a.action, Action::Move(Motion::Left));
}
#[test]
fn dispatch_count_prefix_pends() {
let k = Keymap::default_vim();
let s = ModalState::new();
assert!(matches!(
k.dispatch(&s, &Key::Char('5')).action,
Action::Pending
));
}
#[test]
fn dispatch_insert_char() {
let k = Keymap::default_vim();
let mut s = ModalState::new();
s.enter(Mode::Insert);
let a = k.dispatch(&s, &Key::Char('a'));
assert_eq!(a.action, Action::InsertChar('a'));
}
#[test]
fn lisp_structural_motions_bound() {
let k = Keymap::default_vim();
assert_eq!(
k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
Action::Move(Motion::ForwardSexp)
);
}
#[test]
fn default_leader_is_comma() {
assert_eq!(Keymap::new().leader(), &Key::Char(','));
}
#[test]
fn bind_sequence_stores_multikey_and_resolves() {
let mut k = Keymap::new();
let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
k.bind_sequence(
Mode::Normal,
seq.clone(),
Action::Command {
name: "picker.files".into(),
args: vec![],
},
"find files",
);
let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
assert_eq!(k.sequence_len(), 1);
}
#[test]
fn bind_sequence_length_one_delegates_to_single() {
let mut k = Keymap::new();
k.bind_sequence(
Mode::Normal,
vec![Key::Char('x')],
Action::Undo,
"x is undo",
);
assert_eq!(k.sequence_len(), 0);
assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
}
}
#[must_use]
pub fn to_hotkey(key: &Key) -> Option<awase::Hotkey> {
use awase::{Hotkey, Key as AK, Modifiers as M};
let named = |c: char| match c {
' ' => Some(AK::Space),
c => AK::from_name(&c.to_ascii_lowercase().to_string()),
};
Some(match key {
Key::Char(c) => Hotkey::new(M::NONE, named(*c)?),
Key::Ctrl(c) => Hotkey::new(M::CTRL, named(*c)?),
Key::Alt(c) => Hotkey::new(M::ALT, named(*c)?),
Key::F(n) => Hotkey::new(M::NONE, AK::from_name(&format!("f{n}"))?),
Key::Chord(h) => *h,
Key::Esc => Hotkey::new(M::NONE, AK::Escape),
Key::Enter => Hotkey::new(M::NONE, AK::Return),
Key::Tab => Hotkey::new(M::NONE, AK::Tab),
Key::Backspace => Hotkey::new(M::NONE, AK::Backspace),
Key::Delete => Hotkey::new(M::NONE, AK::Delete),
Key::Left => Hotkey::new(M::NONE, AK::Left),
Key::Right => Hotkey::new(M::NONE, AK::Right),
Key::Up => Hotkey::new(M::NONE, AK::Up),
Key::Down => Hotkey::new(M::NONE, AK::Down),
Key::PageUp => Hotkey::new(M::NONE, AK::PageUp),
Key::PageDown => Hotkey::new(M::NONE, AK::PageDown),
Key::Home => Hotkey::new(M::NONE, AK::Home),
Key::End => Hotkey::new(M::NONE, AK::End),
})
}
#[cfg(test)]
mod fleet_vocabulary {
use super::*;
#[test]
fn modifiers_survive_the_conversion() {
let h = to_hotkey(&Key::Ctrl('w')).expect("ctrl+w maps");
assert!(h.modifiers.contains(awase::Modifiers::CTRL));
assert_eq!(h.key, awase::Key::W);
}
#[test]
fn named_keys_map_to_their_fleet_spelling() {
assert_eq!(
to_hotkey(&Key::Esc).map(|h| h.key),
Some(awase::Key::Escape)
);
assert_eq!(
to_hotkey(&Key::Enter).map(|h| h.key),
Some(awase::Key::Return)
);
}
#[test]
fn every_variant_of_escribas_key_has_a_fleet_spelling() {
let all = [
Key::Char('a'),
Key::Ctrl('a'),
Key::Alt('a'),
Key::Esc,
Key::Enter,
Key::Tab,
Key::Backspace,
Key::Delete,
Key::Left,
Key::Right,
Key::Up,
Key::Down,
Key::PageUp,
Key::PageDown,
Key::Home,
Key::End,
];
for k in all {
assert!(to_hotkey(&k).is_some(), "{k:?} has no fleet spelling");
}
}
#[test]
fn sequences_can_now_be_enumerated() {
let k = Keymap::default_vim();
let all = k.sequences_extending(Mode::Normal, &[]);
assert!(!all.is_empty(), "the default keymap binds sequences");
let g = k.sequences_extending(Mode::Normal, &[Key::Char('g')]);
assert!(
g.iter()
.all(|(seq, _)| seq.first() == Some(&Key::Char('g'))),
"a prefix query returns only its own continuations",
);
}
}
#[cfg(test)]
mod collision_detection {
use super::*;
#[test]
fn a_reserved_chord_is_recorded_at_bind_time() {
let mut m = Keymap::new();
m.bind(Mode::Normal, Key::Alt('j'), Action::Undo, "focus down?");
let c = m.collisions();
assert_eq!(c.len(), 1, "{c:?}");
assert!(c[0].is_fatal(), "a chord the world owns can never fire");
assert!(
c[0].report().contains("window manager"),
"{}",
c[0].report()
);
}
#[test]
fn a_displaced_binding_is_recorded_but_not_fatal() {
let mut m = Keymap::new();
m.bind(Mode::Normal, Key::Char('x'), Action::Undo, "first");
m.bind(Mode::Normal, Key::Char('x'), Action::Redo, "second");
let c = m.collisions();
assert_eq!(c.len(), 1);
assert!(!c[0].is_fatal());
let r = c[0].report();
assert!(r.contains("first") && r.contains("second"), "{r}");
}
#[test]
#[allow(non_snake_case)]
fn a_sequence_whose_OPENER_is_reserved_is_caught() {
let mut m = Keymap::new();
m.bind_sequence(
Mode::Normal,
vec![Key::Alt('j'), Key::Char('x')],
Action::Undo,
"dead sequence",
);
assert!(m.fatal_collisions().count() == 1, "{:?}", m.collisions(),);
}
#[test]
fn an_ordinary_keymap_records_nothing() {
let mut m = Keymap::new();
m.bind(Mode::Normal, Key::Char('h'), Action::Undo, "left");
m.bind(Mode::Normal, Key::Ctrl('w'), Action::Redo, "window prefix");
assert!(m.collisions().is_empty(), "{:?}", m.collisions());
}
#[test]
fn the_shipped_default_keymap_is_clean() {
let m = Keymap::default_vim();
assert!(
m.collisions().is_empty(),
"escriba's own defaults must not collide:\n {}",
m.collisions()
.iter()
.map(Collision::report)
.collect::<Vec<_>>()
.join("\n "),
);
}
}