mod chord;
mod config;
pub use chord::KeyChord;
use config::override_keys;
pub use config::{KeyBinding, warn_unknown};
use std::collections::BTreeMap;
use crossterm::event::KeyEvent;
pub trait Action: Copy + Eq + 'static {
fn all() -> impl Iterator<Item = Self> + Clone;
fn config_name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn default_keys(&self) -> &'static [&'static str];
fn overlaps(&self, other: &Self) -> bool {
let _ = other;
true
}
fn from_config_name(name: &str) -> Option<Self> {
Self::all().find(|action| action.config_name() == name)
}
}
#[derive(Debug, Clone)]
pub struct Conflict<A> {
pub key: String,
pub action: A,
pub claimed_by: A,
}
#[derive(Debug, Clone)]
pub struct Keymap<A> {
entries: Vec<(KeyChord, A)>,
conflicts: Vec<Conflict<A>>,
}
impl<A: Action> Default for Keymap<A> {
fn default() -> Self {
Self::from_overrides(&BTreeMap::new())
}
}
impl<A: Action> Keymap<A> {
#[must_use]
pub fn from_overrides(overrides: &BTreeMap<String, Vec<String>>) -> Self {
Self::for_actions(A::all(), overrides)
}
#[must_use]
pub fn for_actions(
actions: impl IntoIterator<Item = A>,
overrides: &BTreeMap<String, Vec<String>>,
) -> Self {
let mut map = Keymap {
entries: Vec::new(),
conflicts: Vec::new(),
};
for action in actions {
for key in override_keys(overrides, action) {
map.bind(action, &key);
}
}
map
}
#[must_use]
pub fn action_for(&self, key: &KeyEvent) -> Option<A> {
self.action_for_where(key, |_| true)
}
#[must_use]
pub fn action_for_where(
&self,
key: &KeyEvent,
allow: impl Fn(&A) -> bool,
) -> Option<A> {
self.entries
.iter()
.find(|(chord, action)| allow(action) && chord.matches(key))
.map(|(_, action)| *action)
}
#[must_use]
pub fn keys_for(&self, action: A) -> Vec<String> {
self.entries
.iter()
.filter(|(_, bound)| *bound == action)
.map(|(chord, _)| chord.display())
.collect()
}
#[must_use]
pub fn conflicts(&self) -> &[Conflict<A>] {
&self.conflicts
}
#[must_use]
pub fn hints(&self, actions: &[A]) -> Vec<(String, String)> {
actions
.iter()
.filter_map(|&action| {
let keys = self.keys_for(action).join("/");
if keys.is_empty() {
return None;
}
Some((keys, action.description().to_string()))
})
.collect()
}
fn bind(&mut self, action: A, key: &str) {
let Some(chord) = KeyChord::parse(key) else {
log::warn!("invalid key '{key}' for '{}'", action.config_name());
return;
};
let owner = self.entries.iter().find(|(existing, owner)| {
*existing == chord && owner.overlaps(&action)
});
if let Some((_, owner)) = owner {
log::warn!(
"key '{}' for '{}' is already bound to '{}', ignoring",
chord.display(),
action.config_name(),
owner.config_name(),
);
self.conflicts.push(Conflict {
key: chord.display(),
action,
claimed_by: *owner,
});
return;
}
self.entries.push((chord, action));
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyModifiers};
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Act {
Up,
Down,
Quit,
Extend,
}
impl Action for Act {
fn all() -> impl Iterator<Item = Self> + Clone {
[Act::Up, Act::Down, Act::Quit, Act::Extend].into_iter()
}
fn config_name(&self) -> &'static str {
match self {
Act::Up => "up",
Act::Down => "down",
Act::Quit => "quit",
Act::Extend => "extend",
}
}
fn description(&self) -> &'static str {
"test action"
}
fn default_keys(&self) -> &'static [&'static str] {
match self {
Act::Up => &["up", "k"],
Act::Down => &["down", "j"],
Act::Quit => &["ctrl+q", "q"],
Act::Extend => &["shift+up"],
}
}
}
fn event(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn chord(text: &str) -> KeyChord {
KeyChord::parse(text).expect("a valid literal chord")
}
#[test]
fn parses_modifiers_keys_and_function_keys() {
assert_eq!(chord("a").display(), "a");
assert_eq!(chord("G").display(), "G");
assert_eq!(chord("ctrl+q").display(), "ctrl+q");
assert_eq!(chord("control+q").display(), "ctrl+q");
assert_eq!(chord("alt+up").display(), "alt+up");
assert_eq!(chord("option+up").display(), "alt+up");
assert_eq!(chord("shift+left").display(), "shift+left");
assert_eq!(chord("f2").display(), "f2");
assert_eq!(chord("pgup").display(), "pgup");
assert_eq!(chord("pageup").display(), "pgup");
assert_eq!(chord("space").display(), "space");
assert_eq!(chord("ctrl+alt+del").display(), "ctrl+alt+del");
}
#[test]
fn rejects_an_unknown_modifier_or_a_word_key() {
assert!(KeyChord::parse("hyper+a").is_none());
assert!(KeyChord::parse("cmd+a").is_none());
assert!(KeyChord::parse("arrows").is_none());
assert!(KeyChord::parse("f13").is_none());
assert!(KeyChord::parse("").is_none());
}
#[test]
fn chords_round_trip_through_parse_and_display() {
for text in [
"a",
"G",
"ctrl+q",
"alt+up",
"shift+left",
"f2",
"pgup",
"pgdn",
"space",
"enter",
"esc",
"tab",
"del",
"home",
"end",
"backspace",
"ctrl+alt+left",
] {
let parsed = chord(text);
assert_eq!(parsed.display(), text, "{text} must round-trip");
assert_eq!(chord(&parsed.display()), parsed);
}
}
#[test]
fn altgr_does_not_trigger_a_ctrl_chord() {
let ctrl_q = chord("ctrl+q");
assert!(
ctrl_q.matches(&event(KeyCode::Char('q'), KeyModifiers::CONTROL))
);
assert!(!ctrl_q.matches(&event(
KeyCode::Char('q'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
)));
}
#[test]
fn a_plain_chord_does_not_match_a_modified_key() {
let plain = chord("q");
assert!(plain.matches(&event(KeyCode::Char('q'), KeyModifiers::NONE)));
assert!(
!plain.matches(&event(KeyCode::Char('q'), KeyModifiers::CONTROL))
);
assert!(!plain.matches(&event(KeyCode::Char('q'), KeyModifiers::ALT)));
}
#[test]
fn shift_distinguishes_non_character_chords() {
let plain = chord("left");
let shifted = chord("shift+left");
assert!(plain.matches(&event(KeyCode::Left, KeyModifiers::NONE)));
assert!(!plain.matches(&event(KeyCode::Left, KeyModifiers::SHIFT)));
assert!(shifted.matches(&event(KeyCode::Left, KeyModifiers::SHIFT)));
assert!(!shifted.matches(&event(KeyCode::Left, KeyModifiers::NONE)));
}
#[test]
fn shift_is_ignored_for_a_character_chord() {
let upper = chord("G");
assert!(upper.matches(&event(KeyCode::Char('G'), KeyModifiers::SHIFT)));
assert!(upper.matches(&event(KeyCode::Char('G'), KeyModifiers::NONE)));
assert!(!upper.matches(&event(KeyCode::Char('g'), KeyModifiers::NONE)));
}
#[test]
fn defaults_resolve_every_action() {
let map: Keymap<Act> = Keymap::from_overrides(&BTreeMap::new());
assert!(map.conflicts().is_empty());
assert_eq!(
map.action_for(&event(KeyCode::Char('k'), KeyModifiers::NONE)),
Some(Act::Up)
);
assert_eq!(
map.action_for(&event(KeyCode::Char('q'), KeyModifiers::CONTROL)),
Some(Act::Quit)
);
assert_eq!(map.keys_for(Act::Up), vec!["up", "k"]);
assert_eq!(
map.action_for(&event(KeyCode::Char('z'), KeyModifiers::NONE)),
None
);
}
#[test]
fn a_shifted_arrow_resolves_to_its_own_action() {
let map: Keymap<Act> = Keymap::from_overrides(&BTreeMap::new());
assert_eq!(
map.action_for(&event(KeyCode::Up, KeyModifiers::NONE)),
Some(Act::Up)
);
assert_eq!(
map.action_for(&event(KeyCode::Up, KeyModifiers::SHIFT)),
Some(Act::Extend)
);
}
#[test]
fn an_override_replaces_the_defaults() {
let mut overrides = BTreeMap::new();
overrides.insert("up".to_string(), vec!["ctrl+p".to_string()]);
let map: Keymap<Act> = Keymap::from_overrides(&overrides);
assert_eq!(
map.action_for(&event(KeyCode::Char('p'), KeyModifiers::CONTROL)),
Some(Act::Up)
);
assert_eq!(
map.action_for(&event(KeyCode::Char('k'), KeyModifiers::NONE)),
None
);
}
#[test]
fn a_contested_key_keeps_the_earlier_action_and_is_reported() {
let mut overrides = BTreeMap::new();
overrides.insert("down".to_string(), vec!["k".to_string()]);
let map: Keymap<Act> = Keymap::from_overrides(&overrides);
assert_eq!(
map.action_for(&event(KeyCode::Char('k'), KeyModifiers::NONE)),
Some(Act::Up)
);
let conflicts = map.conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].key, "k");
assert_eq!(conflicts[0].action, Act::Down);
assert_eq!(conflicts[0].claimed_by, Act::Up);
}
#[test]
fn an_unparseable_override_is_skipped_not_fatal() {
let mut overrides = BTreeMap::new();
overrides.insert(
"quit".to_string(),
vec!["nonsense+x".to_string(), "f4".to_string()],
);
let map: Keymap<Act> = Keymap::from_overrides(&overrides);
assert_eq!(
map.action_for(&event(KeyCode::F(4), KeyModifiers::NONE)),
Some(Act::Quit)
);
}
#[test]
fn action_for_where_narrows_the_lookup() {
let mut overrides = BTreeMap::new();
overrides.insert("up".to_string(), vec!["x".to_string()]);
let map: Keymap<Act> = Keymap::from_overrides(&overrides);
let key = event(KeyCode::Char('x'), KeyModifiers::NONE);
assert_eq!(map.action_for(&key), Some(Act::Up));
assert_eq!(map.action_for_where(&key, |a| *a != Act::Up), None);
}
#[test]
fn for_actions_restricts_the_map_to_a_subset() {
let map: Keymap<Act> =
Keymap::for_actions([Act::Quit], &BTreeMap::new());
assert_eq!(
map.action_for(&event(KeyCode::Char('q'), KeyModifiers::NONE)),
Some(Act::Quit)
);
assert_eq!(
map.action_for(&event(KeyCode::Char('k'), KeyModifiers::NONE)),
None
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scoped {
Submit,
Rename,
Quit,
}
impl Scoped {
fn screen(self) -> Option<u8> {
match self {
Scoped::Submit => Some(1),
Scoped::Rename => Some(2),
Scoped::Quit => None,
}
}
}
impl Action for Scoped {
fn all() -> impl Iterator<Item = Self> + Clone {
[Scoped::Submit, Scoped::Rename, Scoped::Quit].into_iter()
}
fn config_name(&self) -> &'static str {
match self {
Scoped::Submit => "submit",
Scoped::Rename => "rename",
Scoped::Quit => "quit",
}
}
fn description(&self) -> &'static str {
"scoped action"
}
fn default_keys(&self) -> &'static [&'static str] {
match self {
Scoped::Submit | Scoped::Rename => &["enter"],
Scoped::Quit => &["q"],
}
}
fn overlaps(&self, other: &Self) -> bool {
match (self.screen(), other.screen()) {
(None, _) | (_, None) => true,
(Some(a), Some(b)) => a == b,
}
}
}
#[test]
fn a_chord_shared_across_disjoint_scopes_is_not_a_conflict() {
let map: Keymap<Scoped> = Keymap::from_overrides(&BTreeMap::new());
assert!(
map.conflicts().is_empty(),
"disjoint scopes must not collide: {:?}",
map.conflicts()
);
assert_eq!(map.keys_for(Scoped::Submit), vec!["enter"]);
assert_eq!(map.keys_for(Scoped::Rename), vec!["enter"]);
let enter = event(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(
map.action_for_where(&enter, |a| a.screen() != Some(2)),
Some(Scoped::Submit)
);
assert_eq!(
map.action_for_where(&enter, |a| a.screen() != Some(1)),
Some(Scoped::Rename)
);
}
#[test]
fn a_chord_shared_with_a_global_action_is_still_a_conflict() {
let mut overrides = BTreeMap::new();
overrides.insert("rename".to_string(), vec!["q".to_string()]);
let map: Keymap<Scoped> = Keymap::from_overrides(&overrides);
let conflicts = map.conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].action, Scoped::Quit);
assert_eq!(conflicts[0].claimed_by, Scoped::Rename);
}
#[test]
fn to_key_round_trips_and_always_matches() {
for text in ["a", "G", "ctrl+q", "alt+up", "shift+left", "f2", "space"]
{
let parsed = chord(text);
let key = parsed.to_key();
assert!(parsed.matches(&key), "{text} must match its own key");
assert_eq!(KeyChord::from_key(key), parsed, "{text} round-trips");
}
}
#[test]
fn hints_join_several_keys_and_skip_unbound_actions() {
let mut overrides = BTreeMap::new();
overrides.insert("quit".to_string(), Vec::new());
let map: Keymap<Act> = Keymap::from_overrides(&overrides);
assert_eq!(
map.hints(&[Act::Up, Act::Quit]),
vec![("up/k".to_string(), "test action".to_string())]
);
}
#[test]
fn from_config_name_resolves_every_action() {
for action in Act::all() {
assert_eq!(
Act::from_config_name(action.config_name()),
Some(action)
);
}
assert_eq!(Act::from_config_name("nope"), None);
}
#[test]
fn a_key_binding_reads_both_the_scalar_and_the_list_form() {
assert_eq!(
KeyBinding::One("ctrl+s".to_string()).into_keys(),
vec!["ctrl+s".to_string()]
);
assert_eq!(
KeyBinding::Many(vec!["a".to_string(), "b".to_string()])
.into_keys(),
vec!["a".to_string(), "b".to_string()]
);
}
}