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
+11 -4
View File
@@ -59,14 +59,21 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con
Vamp.configure do |config| Vamp.configure do |config|
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
config.bind "g", "greet" config.bind "g", "greet"
config.bind "<C-r>", "core.redo" config.bind "ctrl+r", "core.redo"
config.bind "<C-c>", "core.normal", mode: "insert" config.bind "shift+j", "core.join_lines"
config.bind "ctrl+c", "core.normal", mode: "insert"
end end
``` ```
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: "normal"` overrides one key in `normal`, `insert`, or `command` mode; the mode defaults to `normal`. Targets are the built-in actions below or exact registered Ex/plugin command names (`"w"`, `"greet"`, etc., without `:` or arguments). Ex/plugin commands can only be bound in Normal mode. Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: "normal"` overrides one key in `normal`, `insert`, or `command` mode; the mode defaults to `normal`. Targets are the built-in actions below or exact registered Ex/plugin command names (`"w"`, `"greet"`, etc., without `:` or arguments). Ex/plugin commands can only be bound in Normal mode.
Keys are single non-control Unicode characters (case-sensitive), `<Esc>`, `<Enter>`, `<Backspace>`, `<Delete>`, `<Left>`, `<Right>`, `<Up>`, `<Down>`, `<Home>`, `<End>`, `<Tab>`, `<Space>`, or `<C-a>` through `<C-z>`. Ctrl letters are case-insensitive. Terminals share `<C-h>` with Backspace, `<C-i>` with Tab, and `<C-j>`/`<C-m>` with Enter; these names address the same binding. Key sequences and Alt/Super combinations are not supported. Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
- Modifiers: `ctrl+a` through `ctrl+z`, and `shift+a` through `shift+z`. `shift+j` and `J` address the same key.
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ctrl+m` with `enter`; these names address the same binding. Only a single modifier on an ASCII letter is supported. Key sequences, prefix mode, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
| Available modes | Built-in targets | | Available modes | Built-in targets |
| --- | --- | | --- | --- |
@@ -83,7 +90,7 @@ Adding a binding keeps the original key. Pass `nil` to disable a key, including
Vamp.configure do |config| Vamp.configure do |config|
config.bind "n", "core.insert" config.bind "n", "core.insert"
config.bind "i", nil config.bind "i", nil
config.bind "<Space>", "core.command" config.bind "space", "core.command"
config.bind ":", nil config.bind ":", nil
end end
``` ```
+2 -2
View File
@@ -1,6 +1,6 @@
Vamp.configure do |config| Vamp.configure do |config|
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
config.bind "g", "greet" config.bind "g", "greet"
config.bind "<C-r>", "core.redo" config.bind "ctrl+r", "core.redo"
config.bind "<C-c>", "core.normal", mode: "insert" config.bind "ctrl+c", "core.normal", mode: "insert"
end end
+114 -32
View File
@@ -154,42 +154,60 @@ enum Binding {
} }
fn parse_key(key: &str) -> Result<Input, String> { fn parse_key(key: &str) -> Result<Input, String> {
let mut chars = key.chars(); let character = |name: &str| {
if let Some(ch) = chars.next() let mut chars = name.chars();
&& chars.next().is_none() chars
&& !ch.is_control() .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)); return Ok(Input::Char(ch));
} }
Ok(match key { let invalid = || format!("Invalid binding key: {key:?}");
"<Esc>" => Input::Escape, let (modifier, name) = key
"<Enter>" => Input::Enter, .split_once('+')
"<Backspace>" => Input::Backspace, .map_or((None, key), |(modifier, name)| (Some(modifier), name));
"<Delete>" => Input::Delete, let input = if let Some(ch) = character(name) {
"<Left>" => Input::Left, Input::Char(ch)
"<Right>" => Input::Right, } else {
"<Up>" => Input::Up, [
"<Down>" => Input::Down, ("esc", Input::Escape),
"<Home>" => Input::Home, ("enter", Input::Enter),
"<End>" => Input::End, ("backspace", Input::Backspace),
"<Tab>" => Input::Char('\t'), ("delete", Input::Delete),
"<Space>" => Input::Char(' '), ("left", Input::Left),
_ => { ("right", Input::Right),
if let Some(control) = key ("up", Input::Up),
.strip_prefix("<C-") ("down", Input::Down),
.and_then(|key| key.strip_suffix('>')) ("home", Input::Home),
{ ("end", Input::End),
let mut chars = control.chars(); ("tab", Input::Char('\t')),
if let Some(ch) = chars.next() ("space", Input::Char(' ')),
&& ch.is_ascii_alphabetic() ("minus", Input::Char('-')),
&& chars.next().is_none() ("comma", Input::Char(',')),
{ ("ampersand", Input::Char('&')),
return Ok(control_key(ch)); ("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 { fn control_key(ch: char) -> Input {
@@ -1394,6 +1412,70 @@ mod tests {
assert!(editor.message().contains("core [core]")); 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] #[test]
fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() { fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() {
let mut editor = editor(); let mut editor = editor();
+11 -11
View File
@@ -331,23 +331,23 @@ Vamp.configure do |config|
config.plugin "healthy.rb" config.plugin "healthy.rb"
config.bind "n", "core.insert_first_nonblank" config.bind "n", "core.insert_first_nonblank"
config.bind "i", nil config.bind "i", nil
config.bind "<C-r>", "core.redo" config.bind "ctrl+r", "core.redo"
config.bind "<C-c>", "core.normal", mode: "insert" config.bind "ctrl+c", "core.normal", mode: "insert"
config.bind "<Esc>", nil, mode: "insert" config.bind "esc", nil, mode: "insert"
config.bind "!", "core.newline", 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 ":", nil
config.bind "<Space>", "core.command" config.bind "space", "core.command"
config.bind "<Enter>", nil, mode: "command" config.bind "enter", nil, mode: "command"
config.bind "<C-x>", "core.submit", mode: "command" config.bind "ctrl+x", "core.submit", mode: "command"
config.bind "<C-c>", "core.normal", mode: "command" config.bind "ctrl+c", "core.normal", mode: "command"
config.bind "<C-h>", "core.backspace", mode: "command" config.bind "ctrl+h", "core.backspace", mode: "command"
config.bind "g", "core.undo" config.bind "g", "core.undo"
config.bind "g", "healthy" config.bind "g", "healthy"
config.bind "n", "core.missing" config.bind "n", "core.missing"
config.bind "n", "core.undo", mode: "missing" config.bind "n", "core.undo", mode: "missing"
config.bind "<Unknown>", "core.undo" config.bind "unknown", "core.undo"
config.bind "<C-f>", "healthy", mode: "insert" config.bind "ctrl+f", "healthy", mode: "insert"
end end
"#, "#,
); );