feat: use readable keybinding notation

This commit is contained in:
Valdemar Lorenzen
2026-10-05 16:07:24 +02:00
parent 3fd89fcbed
commit bb242b35b6
4 changed files with 138 additions and 49 deletions
+114 -32
View File
@@ -154,42 +154,60 @@ enum Binding {
}
fn parse_key(key: &str) -> Result<Input, String> {
let mut chars = key.chars();
if let Some(ch) = chars.next()
&& chars.next().is_none()
&& !ch.is_control()
{
let character = |name: &str| {
let mut chars = name.chars();
chars
.next()
.filter(|ch| !ch.is_control() && chars.next().is_none())
};
// Keep literal punctuation, especially "+", usable without escaping.
if let Some(ch) = character(key) {
return Ok(Input::Char(ch));
}
Ok(match key {
"<Esc>" => Input::Escape,
"<Enter>" => Input::Enter,
"<Backspace>" => Input::Backspace,
"<Delete>" => Input::Delete,
"<Left>" => Input::Left,
"<Right>" => Input::Right,
"<Up>" => Input::Up,
"<Down>" => Input::Down,
"<Home>" => Input::Home,
"<End>" => Input::End,
"<Tab>" => Input::Char('\t'),
"<Space>" => Input::Char(' '),
_ => {
if let Some(control) = key
.strip_prefix("<C-")
.and_then(|key| key.strip_suffix('>'))
{
let mut chars = control.chars();
if let Some(ch) = chars.next()
&& ch.is_ascii_alphabetic()
&& chars.next().is_none()
{
return Ok(control_key(ch));
}
let invalid = || format!("Invalid binding key: {key:?}");
let (modifier, name) = key
.split_once('+')
.map_or((None, key), |(modifier, name)| (Some(modifier), name));
let input = if let Some(ch) = character(name) {
Input::Char(ch)
} else {
[
("esc", Input::Escape),
("enter", Input::Enter),
("backspace", Input::Backspace),
("delete", Input::Delete),
("left", Input::Left),
("right", Input::Right),
("up", Input::Up),
("down", Input::Down),
("home", Input::Home),
("end", Input::End),
("tab", Input::Char('\t')),
("space", Input::Char(' ')),
("minus", Input::Char('-')),
("comma", Input::Char(',')),
("ampersand", Input::Char('&')),
("plus", Input::Char('+')),
("backtick", Input::Char('`')),
]
.into_iter()
.find(|(key, _)| name.eq_ignore_ascii_case(key))
.map(|(_, input)| input)
.ok_or_else(invalid)?
};
match (modifier, input) {
(None, input) => Ok(input),
(Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => {
if modifier.eq_ignore_ascii_case("ctrl") {
Ok(control_key(ch))
} else if modifier.eq_ignore_ascii_case("shift") {
Ok(Input::Char(ch.to_ascii_uppercase()))
} else {
Err(invalid())
}
return Err(format!("Invalid binding key: {key:?}"));
}
})
_ => Err(invalid()),
}
}
fn control_key(ch: char) -> Input {
@@ -1394,6 +1412,70 @@ mod tests {
assert!(editor.message().contains("core [core]"));
}
#[test]
fn readable_key_aliases_share_bindings_and_invalid_chords_leave_them_intact() {
let mut editor = editor();
editor.bind("normal", "shift+i", None).unwrap();
editor.handle(Input::Char('I'));
assert_eq!(editor.mode(), Mode::Normal);
editor
.bind("normal", "I", Some("core.insert".into()))
.unwrap();
editor.handle(Input::Char('I'));
assert_eq!(editor.mode(), Mode::Insert);
editor
.bind("insert", "plus", Some("core.newline".into()))
.unwrap();
editor.bind("insert", "+", None).unwrap();
editor.handle(Input::Char('+'));
type_text(&mut editor, "界");
assert_eq!(editor.lines(), &["界"]);
editor
.bind("insert", "plus", Some("core.newline".into()))
.unwrap();
editor.handle(Input::Char('+'));
assert_eq!(editor.lines(), &["界", ""]);
editor.bind("insert", "CTRL+I", None).unwrap();
editor.handle(Input::Char('\t'));
assert_eq!(editor.lines(), &["界", ""]);
editor
.bind("insert", "TAB", Some("core.newline".into()))
.unwrap();
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.lines(), &["界", "", ""]);
editor
.bind("insert", "shift+j", Some("core.normal".into()))
.unwrap();
for key in [
"",
"<C-r>",
"<Esc>",
"ctrl+",
"+r",
"ctrl++r",
"ctrl+shift+j",
"shift+tab",
"shift+1",
"alt+j",
"cmd+j",
"ctrl+界",
] {
assert!(
editor
.bind("insert", key, Some("core.newline".into()))
.is_err(),
"{key}"
);
}
editor.handle(Input::Char('J'));
assert_eq!(editor.mode(), Mode::Normal);
editor.handle(Input::Char('u'));
assert_eq!(editor.lines(), &[String::new()]);
}
#[test]
fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() {
let mut editor = editor();
+11 -11
View File
@@ -331,23 +331,23 @@ Vamp.configure do |config|
config.plugin "healthy.rb"
config.bind "n", "core.insert_first_nonblank"
config.bind "i", nil
config.bind "<C-r>", "core.redo"
config.bind "<C-c>", "core.normal", mode: "insert"
config.bind "<Esc>", nil, mode: "insert"
config.bind "ctrl+r", "core.redo"
config.bind "ctrl+c", "core.normal", mode: "insert"
config.bind "esc", nil, mode: "insert"
config.bind "!", "core.newline", mode: "insert"
config.bind "X", nil, mode: "insert"
config.bind "shift+x", nil, mode: "insert"
config.bind ":", nil
config.bind "<Space>", "core.command"
config.bind "<Enter>", nil, mode: "command"
config.bind "<C-x>", "core.submit", mode: "command"
config.bind "<C-c>", "core.normal", mode: "command"
config.bind "<C-h>", "core.backspace", mode: "command"
config.bind "space", "core.command"
config.bind "enter", nil, mode: "command"
config.bind "ctrl+x", "core.submit", mode: "command"
config.bind "ctrl+c", "core.normal", mode: "command"
config.bind "ctrl+h", "core.backspace", mode: "command"
config.bind "g", "core.undo"
config.bind "g", "healthy"
config.bind "n", "core.missing"
config.bind "n", "core.undo", mode: "missing"
config.bind "<Unknown>", "core.undo"
config.bind "<C-f>", "healthy", mode: "insert"
config.bind "unknown", "core.undo"
config.bind "ctrl+f", "healthy", mode: "insert"
end
"#,
);