feat: use readable keybinding notation
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user