use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::str::FromStr;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Action {
ScrollUp,
ScrollDown,
ScrollPageUp,
ScrollPageDown,
ScrollToTop,
ScrollToBottom,
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
MoveWordLeft,
MoveWordRight,
MoveLineStart,
MoveLineEnd,
MoveDocStart,
MoveDocEnd,
InsertChar(char),
InsertTab,
Newline,
DeleteCharBack,
DeleteCharForward,
DeleteWordBack,
DeleteWordForward,
DeleteLine,
Cut,
Copy,
Paste,
BoldSelection,
ItalicizeSelection,
InlineCodeSelection,
StrikethroughSelection,
HighlightSelection,
SelectLeft,
SelectRight,
SelectUp,
SelectDown,
SelectAll,
Undo,
Redo,
Save,
SaveAs,
Open,
EnterEditMode,
ExitToPreview,
ToggleRawMode,
Quit,
ToggleCheckbox,
TableNextCell,
TablePrevCell,
TableNextRow,
TablePrevRow,
TableMoveRowUp,
TableMoveRowDown,
TableMoveColumnLeft,
TableMoveColumnRight,
TableInsertRowAbove,
TableInsertRowBelow,
TableInsertColumnLeft,
TableInsertColumnRight,
TableDeleteRow,
TableDeleteColumn,
TableInsertBreak,
FollowLinkUnderCursor,
NavigateBack,
NavigateForward,
ShowCommandPalette,
ShowMarkdownCheatSheet,
BrowseTips,
OpenDoc(crate::docs::DocId),
OpenSettings,
OpenWelcome,
OpenKeybinds,
SwitchTheme,
CreateCustomTheme,
OpenConfigFolder,
ExportHtml,
OpenInExternalEditor,
ToggleTableButtons,
ToggleBigH1,
ToggleLineNumbers,
ToggleBlinkCursor,
ToggleAutosave,
ToggleVisualLineNav,
ToggleVimMode,
ToggleLimitWidth,
ToggleDiffOnChange,
InsertTable,
InsertImage,
InsertLink,
PasteImage,
InsertFootnote,
DeleteFootnote,
RenumberFootnotes,
FixListNumbering,
ShowAbout,
CheckForUpdates,
GoToSection,
OpenSearch,
SearchNext,
SearchPrev,
SearchReplace,
SearchReplaceAll,
SearchExit,
DiffNext,
DiffPrev,
DiffAcceptHunk,
DiffRejectHunk,
DiffAcceptAll,
DiffRejectAll,
DiffResetHunk,
DiffExit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoalesceKind {
Insert,
BackDelete,
ForwardDelete,
}
impl Action {
pub fn coalesce_kind(&self) -> Option<CoalesceKind> {
match self {
Action::InsertChar(_) => Some(CoalesceKind::Insert),
Action::DeleteCharBack => Some(CoalesceKind::BackDelete),
Action::DeleteCharForward => Some(CoalesceKind::ForwardDelete),
_ => None,
}
}
}
macro_rules! action_variants {
($( $variant:ident ),* $(,)?) => {
#[cfg(test)]
pub(crate) const EVERY_UNIT_ACTION: &[Action] = &[ $( Action::$variant, )* ];
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s: &str = match self {
$( Action::$variant => stringify!($variant), )*
Action::InsertChar(_) => "InsertChar",
Action::OpenDoc(_) => "OpenDoc",
};
f.write_str(s)
}
}
impl FromStr for Action {
type Err = KeyMapError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
$( stringify!($variant) => Ok(Action::$variant), )*
other => Err(KeyMapError::UnknownAction(other.to_owned())),
}
}
}
};
}
action_variants! {
ScrollUp, ScrollDown, ScrollPageUp, ScrollPageDown, ScrollToTop, ScrollToBottom,
MoveLeft, MoveRight, MoveUp, MoveDown,
MoveWordLeft, MoveWordRight,
MoveLineStart, MoveLineEnd,
MoveDocStart, MoveDocEnd,
InsertTab, Newline,
DeleteCharBack, DeleteCharForward, DeleteWordBack, DeleteWordForward, DeleteLine,
Cut, Copy, Paste,
BoldSelection, ItalicizeSelection,
InlineCodeSelection, StrikethroughSelection, HighlightSelection,
SelectLeft, SelectRight, SelectUp, SelectDown, SelectAll,
Undo, Redo,
Save, SaveAs, Open,
EnterEditMode, ExitToPreview, ToggleRawMode, Quit,
ToggleCheckbox,
TableNextCell, TablePrevCell, TableNextRow, TablePrevRow,
TableMoveRowUp, TableMoveRowDown, TableMoveColumnLeft, TableMoveColumnRight,
TableInsertRowAbove, TableInsertRowBelow,
TableInsertColumnLeft, TableInsertColumnRight,
TableDeleteRow, TableDeleteColumn,
TableInsertBreak,
FollowLinkUnderCursor,
NavigateBack, NavigateForward,
ShowCommandPalette, ShowMarkdownCheatSheet, BrowseTips, ShowAbout, CheckForUpdates,
OpenSettings, OpenWelcome, OpenKeybinds, OpenConfigFolder, SwitchTheme, CreateCustomTheme,
ExportHtml, OpenInExternalEditor,
ToggleTableButtons, InsertTable, InsertImage, InsertLink, PasteImage,
ToggleBigH1, ToggleLineNumbers, ToggleBlinkCursor, ToggleAutosave,
ToggleVisualLineNav, ToggleVimMode, ToggleLimitWidth, ToggleDiffOnChange,
InsertFootnote, DeleteFootnote, RenumberFootnotes,
FixListNumbering,
GoToSection,
OpenSearch, SearchNext, SearchPrev,
SearchReplace, SearchReplaceAll, SearchExit,
DiffNext, DiffPrev,
DiffAcceptHunk, DiffRejectHunk,
DiffAcceptAll, DiffRejectAll, DiffResetHunk,
DiffExit,
}
#[derive(Debug, Error)]
pub enum KeyMapError {
#[error("unknown action name: '{0}'")]
UnknownAction(String),
#[error("unparseable key string: '{0}'")]
UnparseableKey(String),
#[error("'{key}' is already bound to {action}")]
ConflictingBinding { key: String, action: String },
}
fn parse_modifier(part: &str) -> Option<KeyModifiers> {
match part {
"ctrl" => Some(KeyModifiers::CONTROL),
"alt" => Some(KeyModifiers::ALT),
"shift" => Some(KeyModifiers::SHIFT),
_ => None,
}
}
fn parse_key_code(key_part: &str) -> Option<KeyCode> {
let code = match key_part {
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"page_up" | "pageup" | "pgup" => KeyCode::PageUp,
"page_down" | "pagedown" | "pgdn" => KeyCode::PageDown,
"enter" | "return" => KeyCode::Enter,
"backspace" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"escape" | "esc" => KeyCode::Esc,
"tab" => KeyCode::Tab,
"backtab" => KeyCode::BackTab,
"insert" => KeyCode::Insert,
"space" => KeyCode::Char(' '),
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
_ => {
let mut chars = key_part.chars();
let first = chars.next()?;
if chars.next().is_some() {
return None;
}
KeyCode::Char(first)
}
};
Some(code)
}
pub fn parse_key(s: &str) -> Result<KeyEvent, KeyMapError> {
let lower = s.to_lowercase();
let (modifier_parts, key_part): (Vec<&str>, &str) = if lower == "+" {
(Vec::new(), "+")
} else if let Some(prefix) = lower.strip_suffix("++") {
(prefix.split('+').collect(), "+")
} else {
let mut parts: Vec<&str> = lower.split('+').collect();
let key = parts.pop().unwrap_or("");
(parts, key)
};
if key_part.is_empty() {
return Err(KeyMapError::UnparseableKey(s.to_owned()));
}
let mut modifiers = KeyModifiers::NONE;
for part in modifier_parts {
let m = parse_modifier(part).ok_or_else(|| KeyMapError::UnparseableKey(s.to_owned()))?;
modifiers |= m;
}
let code = parse_key_code(key_part).ok_or_else(|| KeyMapError::UnparseableKey(s.to_owned()))?;
Ok(KeyEvent::new(code, modifiers))
}
fn keycode_glyph(code: KeyCode) -> Option<&'static str> {
Some(match code {
KeyCode::Up => "↑",
KeyCode::Down => "↓",
KeyCode::Left => "←",
KeyCode::Right => "→",
KeyCode::Enter => "↵",
KeyCode::Tab | KeyCode::BackTab => "⇥",
KeyCode::Backspace => "⌫",
KeyCode::Delete => "Del",
KeyCode::Esc => "Esc",
KeyCode::Home => "Home",
KeyCode::End => "End",
KeyCode::PageUp => "PgUp",
KeyCode::PageDown => "PgDn",
KeyCode::Insert => "Ins",
_ => return None,
})
}
fn keycode_word(code: KeyCode) -> Option<&'static str> {
Some(match code {
KeyCode::Up => "Up",
KeyCode::Down => "Down",
KeyCode::Left => "Left",
KeyCode::Right => "Right",
KeyCode::Home => "Home",
KeyCode::End => "End",
KeyCode::PageUp => "PgUp",
KeyCode::PageDown => "PgDn",
KeyCode::Enter => "Enter",
KeyCode::Backspace => "Backspace",
KeyCode::Delete => "Delete",
KeyCode::Esc => "Esc",
KeyCode::Tab => "Tab",
KeyCode::BackTab => "Shift-Tab",
KeyCode::Insert => "Insert",
_ => return None,
})
}
fn format_keycode(code: KeyCode, lookup: fn(KeyCode) -> Option<&'static str>) -> String {
if let Some(s) = lookup(code) {
return s.to_owned();
}
match code {
KeyCode::Char(' ') => "Space".to_owned(),
KeyCode::Char(c) if c.is_ascii_alphabetic() => c.to_ascii_uppercase().to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::F(n) => format!("F{n}"),
other => format!("{:?}", other),
}
}
pub fn format_key_compact(ev: &KeyEvent) -> String {
let mut out = String::new();
if ev.modifiers.contains(KeyModifiers::CONTROL) {
out.push('^');
}
if ev.modifiers.contains(KeyModifiers::ALT) {
out.push('⌥');
}
let shift = ev.modifiers.contains(KeyModifiers::SHIFT) || ev.code == KeyCode::BackTab;
if shift {
out.push('⇧');
}
out.push_str(&format_keycode(ev.code, keycode_glyph));
out
}
pub fn format_key_parseable(ev: &KeyEvent) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if ev.modifiers.contains(KeyModifiers::CONTROL) {
parts.push("ctrl".into());
}
if ev.modifiers.contains(KeyModifiers::ALT) {
parts.push("alt".into());
}
if ev.modifiers.contains(KeyModifiers::SHIFT) || ev.code == KeyCode::BackTab {
parts.push("shift".into());
}
let code_str: String = match ev.code {
KeyCode::Char(' ') => "space".into(),
KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
KeyCode::Up => "up".into(),
KeyCode::Down => "down".into(),
KeyCode::Left => "left".into(),
KeyCode::Right => "right".into(),
KeyCode::Home => "home".into(),
KeyCode::End => "end".into(),
KeyCode::PageUp => "page_up".into(),
KeyCode::PageDown => "page_down".into(),
KeyCode::Enter => "enter".into(),
KeyCode::Backspace => "backspace".into(),
KeyCode::Delete => "delete".into(),
KeyCode::Esc => "escape".into(),
KeyCode::Tab | KeyCode::BackTab => "tab".into(),
KeyCode::Insert => "insert".into(),
KeyCode::F(n) => format!("f{n}"),
_ => return None,
};
parts.push(code_str);
Some(parts.join("+"))
}
pub fn format_key(ev: &KeyEvent) -> String {
let mut parts: Vec<String> = Vec::new();
if ev.modifiers.contains(KeyModifiers::CONTROL) {
parts.push("Ctrl".into());
}
if ev.modifiers.contains(KeyModifiers::ALT) {
parts.push("Alt".into());
}
if ev.modifiers.contains(KeyModifiers::SHIFT) {
parts.push("Shift".into());
}
parts.push(format_keycode(ev.code, keycode_word));
parts.join("-")
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct KeyBindingOverrides(pub HashMap<String, String>);
impl KeyBindingOverrides {
pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
use anyhow::Context;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let toml_str =
toml::to_string_pretty(self).context("Failed to serialize keybindings to TOML")?;
std::fs::write(path, toml_str)
.with_context(|| format!("Failed to write keybindings file: {}", path.display()))?;
Ok(())
}
}
fn alt_word_motion_alias(event: &KeyEvent) -> Option<KeyEvent> {
if event.modifiers != KeyModifiers::ALT {
return None;
}
let code = match event.code {
KeyCode::Char('b') => KeyCode::Left,
KeyCode::Char('f') => KeyCode::Right,
_ => return None,
};
Some(KeyEvent::new(code, KeyModifiers::ALT))
}
#[derive(Debug, Clone)]
pub struct KeyMap {
bindings: HashMap<KeyEvent, Action>,
}
impl KeyMap {
pub fn build(overrides: &KeyBindingOverrides) -> Result<Self, KeyMapError> {
let mut map = Self::default_bindings();
for (action_str, key_str) in &overrides.0 {
let action = Action::from_str(action_str)?;
let key = parse_key(key_str)?;
map.bindings.insert(key, action);
}
Ok(map)
}
pub fn first_key_for(&self, action: &Action) -> Option<String> {
self.bindings
.iter()
.find(|(_, a)| *a == action)
.map(|(k, _)| format_key(k))
}
pub fn first_key_event_for(&self, action: &Action) -> Option<KeyEvent> {
self.bindings
.iter()
.find(|(_, a)| *a == action)
.map(|(k, _)| *k)
}
pub fn rebind(
&mut self,
action: &Action,
new_key: &str,
overrides: &mut KeyBindingOverrides,
) -> Result<(), KeyMapError> {
let parsed = parse_key(new_key)?;
if let Some(existing) = self.bindings.get(&parsed) {
if existing != action {
return Err(KeyMapError::ConflictingBinding {
key: new_key.to_owned(),
action: existing.to_string(),
});
}
return Ok(());
}
self.bindings.retain(|_, a| a != action);
self.bindings.insert(parsed, action.clone());
overrides.0.insert(action.to_string(), new_key.to_owned());
Ok(())
}
pub fn action_for(&self, event: &KeyEvent) -> Option<&Action> {
let normalized = KeyEvent::new(event.code, event.modifiers);
if let Some(action) = self.bindings.get(&normalized) {
return Some(action);
}
if event.code == KeyCode::BackTab {
let fallback = KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
return self.bindings.get(&fallback);
}
if let Some(alias) = alt_word_motion_alias(&normalized) {
return self.bindings.get(&alias);
}
None
}
fn default_bindings() -> Self {
let mut b: HashMap<KeyEvent, Action> = HashMap::new();
macro_rules! bind {
($key:expr, $action:expr) => {
if let Ok(k) = parse_key($key) {
b.insert(k, $action);
}
};
}
bind!("ctrl+q", Action::Quit);
bind!("up", Action::MoveUp);
bind!("down", Action::MoveDown);
bind!("left", Action::MoveLeft);
bind!("right", Action::MoveRight);
bind!("ctrl+left", Action::MoveWordLeft);
bind!("ctrl+right", Action::MoveWordRight);
bind!("ctrl+a", Action::SelectAll);
bind!("ctrl+e", Action::MoveLineEnd);
bind!("ctrl+home", Action::MoveDocStart);
bind!("ctrl+end", Action::MoveDocEnd);
bind!("page_up", Action::ScrollPageUp);
bind!("page_down", Action::ScrollPageDown);
bind!("home", Action::ScrollToTop);
bind!("end", Action::ScrollToBottom);
bind!("enter", Action::Newline);
bind!("tab", Action::InsertTab);
bind!("backspace", Action::DeleteCharBack);
bind!("delete", Action::DeleteCharForward);
bind!("ctrl+backspace", Action::DeleteWordBack);
bind!("ctrl+delete", Action::DeleteWordForward);
bind!("ctrl+d", Action::DeleteLine);
bind!("ctrl+z", Action::Undo);
bind!("ctrl+shift+z", Action::Redo);
bind!("ctrl+r", Action::Redo);
bind!("ctrl+c", Action::Copy);
bind!("ctrl+x", Action::Cut);
bind!("ctrl+v", Action::Paste);
bind!("ctrl+b", Action::BoldSelection);
bind!("ctrl+i", Action::ItalicizeSelection);
bind!("ctrl+s", Action::Save);
bind!("escape", Action::ExitToPreview);
bind!("ctrl+`", Action::ToggleRawMode);
bind!("shift+left", Action::SelectLeft);
bind!("shift+right", Action::SelectRight);
bind!("shift+up", Action::SelectUp);
bind!("shift+down", Action::SelectDown);
bind!("ctrl+space", Action::ToggleCheckbox);
bind!("alt+up", Action::TableMoveRowUp);
bind!("alt+down", Action::TableMoveRowDown);
bind!("alt+left", Action::TableMoveColumnLeft);
bind!("alt+right", Action::TableMoveColumnRight);
bind!("alt+shift+up", Action::TableInsertRowAbove);
bind!("alt+shift+down", Action::TableInsertRowBelow);
bind!("alt+shift+left", Action::TableInsertColumnLeft);
bind!("alt+shift+right", Action::TableInsertColumnRight);
bind!("alt+backspace", Action::TableDeleteRow);
bind!("alt+shift+backspace", Action::TableDeleteColumn);
bind!("shift+tab", Action::TablePrevCell);
bind!("shift+enter", Action::TableInsertBreak);
bind!("ctrl+enter", Action::FollowLinkUnderCursor);
bind!("ctrl+p", Action::ShowCommandPalette);
bind!("ctrl+g", Action::GoToSection);
bind!("ctrl+f", Action::OpenSearch);
bind!("ctrl+shift+t", Action::InsertTable);
Self { bindings: b }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_bindings_are_pinned_for_the_docs() {
let km = KeyMap::default_bindings();
let mut rows: Vec<String> = km
.bindings
.iter()
.map(|(ev, action)| {
let chord = format_key_parseable(ev)
.unwrap_or_else(|| panic!("default binding {ev:?} has no parseable spelling"));
format!("{chord} = {action:?}")
})
.collect();
rows.sort();
insta::assert_snapshot!(rows.join("\n"));
}
#[test]
fn open_stays_unbound_while_it_is_a_stub() {
let km = KeyMap::default_bindings();
assert!(
!km.bindings.values().any(|a| *a == Action::Open),
"Action::Open is a stub but has a default binding; either implement it \
(and update docs/keybindings.md) or leave it unbound"
);
}
#[test]
fn default_keymap_has_quit() {
let km = KeyMap::build(&KeyBindingOverrides::default()).unwrap();
let key = parse_key("ctrl+q").unwrap();
assert_eq!(km.action_for(&key), Some(&Action::Quit));
}
#[test]
fn ctrl_c_is_copy_not_quit() {
let km = KeyMap::build(&KeyBindingOverrides::default()).unwrap();
let key = parse_key("ctrl+c").unwrap();
assert_eq!(km.action_for(&key), Some(&Action::Copy));
}
#[test]
fn override_changes_binding() {
let mut overrides = KeyBindingOverrides::default();
overrides.0.insert("Quit".into(), "ctrl+x".into());
let km = KeyMap::build(&overrides).unwrap();
let key = parse_key("ctrl+x").unwrap();
assert_eq!(km.action_for(&key), Some(&Action::Quit));
}
#[test]
fn unknown_action_is_error() {
let mut overrides = KeyBindingOverrides::default();
overrides.0.insert("TypoAction".into(), "ctrl+x".into());
assert!(KeyMap::build(&overrides).is_err());
}
#[test]
fn unparseable_key_is_error() {
let mut overrides = KeyBindingOverrides::default();
overrides.0.insert("Quit".into(), "superkey+q".into());
assert!(KeyMap::build(&overrides).is_err());
}
#[test]
fn backtab_maps_to_shift_tab_binding() {
let km = KeyMap::build(&KeyBindingOverrides::default()).unwrap();
let backtab_no_mod = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
assert_eq!(km.action_for(&backtab_no_mod), Some(&Action::TablePrevCell));
let backtab_shift = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
assert_eq!(km.action_for(&backtab_shift), Some(&Action::TablePrevCell));
}
#[test]
fn alt_b_and_alt_f_reach_the_alt_arrow_bindings() {
let km = KeyMap::build(&KeyBindingOverrides::default()).unwrap();
let alt_b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT);
let alt_f = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT);
assert_eq!(
km.action_for(&alt_b),
km.action_for(&parse_key("alt+left").unwrap())
);
assert_eq!(
km.action_for(&alt_f),
km.action_for(&parse_key("alt+right").unwrap())
);
assert_eq!(km.action_for(&alt_b), Some(&Action::TableMoveColumnLeft));
assert_eq!(km.action_for(&alt_f), Some(&Action::TableMoveColumnRight));
}
#[test]
fn alt_arrow_alias_follows_a_rebound_alt_left() {
let mut overrides = KeyBindingOverrides::default();
overrides.0.insert("NavigateBack".into(), "alt+left".into());
let km = KeyMap::build(&overrides).unwrap();
let alt_b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT);
assert_eq!(km.action_for(&alt_b), Some(&Action::NavigateBack));
}
#[test]
fn explicit_alt_b_binding_wins_over_the_arrow_alias() {
let mut overrides = KeyBindingOverrides::default();
overrides.0.insert("Save".into(), "alt+b".into());
let km = KeyMap::build(&overrides).unwrap();
let alt_b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT);
assert_eq!(km.action_for(&alt_b), Some(&Action::Save));
let km = KeyMap::build(&KeyBindingOverrides::default()).unwrap();
let alt_shift_b =
KeyEvent::new(KeyCode::Char('B'), KeyModifiers::ALT | KeyModifiers::SHIFT);
assert_eq!(km.action_for(&alt_shift_b), None);
let plain_b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
assert_eq!(km.action_for(&plain_b), None);
}
#[test]
fn action_lookup_ignores_kitty_state_flags() {
use crossterm::event::{KeyEventKind, KeyEventState};
let km = KeyMap::build(&KeyBindingOverrides::default()).unwrap();
let ctrl_q_with_state = KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::KEYPAD,
};
assert_eq!(km.action_for(&ctrl_q_with_state), Some(&Action::Quit));
}
#[test]
fn parse_key_variants() {
assert!(parse_key("up").is_ok());
assert!(parse_key("page_down").is_ok());
assert!(parse_key("ctrl+s").is_ok());
assert!(parse_key("ctrl+shift+z").is_ok());
assert!(parse_key("escape").is_ok());
assert!(parse_key("space").is_ok());
assert!(parse_key("ctrl+space").is_ok());
}
#[test]
fn literal_plus_and_hyphen_round_trip() {
for chord in ["+", "-", "ctrl++", "ctrl+-", "ctrl+shift++"] {
let ev = parse_key(chord).unwrap_or_else(|_| panic!("parse {chord}"));
let re = format_key_parseable(&ev).expect("supported key");
assert_eq!(parse_key(&re).unwrap(), ev, "round-trip {chord} → {re}");
}
}
#[test]
fn ctrl_space_maps_to_toggle_checkbox() {
let km = KeyMap::build(&KeyBindingOverrides::default()).unwrap();
let key = parse_key("ctrl+space").unwrap();
assert_eq!(km.action_for(&key), Some(&Action::ToggleCheckbox));
}
}