diff --git a/README.md b/README.md index 0e16344..b218f97 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,21 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con Vamp.configure do |config| config.plugin "plugins/greeting.rb" config.bind "g", "greet" - config.bind "", "core.redo" - config.bind "", "core.normal", mode: "insert" + config.bind "ctrl+r", "core.redo" + config.bind "shift+j", "core.join_lines" + config.bind "ctrl+c", "core.normal", mode: "insert" 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. -Keys are single non-control Unicode characters (case-sensitive), ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, or `` through ``. Ctrl letters are case-insensitive. Terminals share `` with Backspace, `` with Tab, and ``/`` 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 | | --- | --- | @@ -83,7 +90,7 @@ Adding a binding keeps the original key. Pass `nil` to disable a key, including Vamp.configure do |config| config.bind "n", "core.insert" config.bind "i", nil - config.bind "", "core.command" + config.bind "space", "core.command" config.bind ":", nil end ``` diff --git a/examples/config.rb b/examples/config.rb index 350c430..4fabda6 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -1,6 +1,6 @@ Vamp.configure do |config| config.plugin "plugins/greeting.rb" config.bind "g", "greet" - config.bind "", "core.redo" - config.bind "", "core.normal", mode: "insert" + config.bind "ctrl+r", "core.redo" + config.bind "ctrl+c", "core.normal", mode: "insert" end diff --git a/src/editor.rs b/src/editor.rs index 2f77945..a3c7677 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -154,42 +154,60 @@ enum Binding { } fn parse_key(key: &str) -> Result { - 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 { - "" => Input::Escape, - "" => Input::Enter, - "" => Input::Backspace, - "" => Input::Delete, - "" => Input::Left, - "" => Input::Right, - "" => Input::Up, - "" => Input::Down, - "" => Input::Home, - "" => Input::End, - "" => Input::Char('\t'), - "" => Input::Char(' '), - _ => { - if let Some(control) = key - .strip_prefix("')) - { - 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 [ + "", + "", + "", + "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(); diff --git a/src/session.rs b/src/session.rs index 8a600f4..07d29fc 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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 "", "core.redo" - config.bind "", "core.normal", mode: "insert" - config.bind "", 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 "", "core.command" - config.bind "", nil, mode: "command" - config.bind "", "core.submit", mode: "command" - config.bind "", "core.normal", mode: "command" - config.bind "", "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 "", "core.undo" - config.bind "", "healthy", mode: "insert" + config.bind "unknown", "core.undo" + config.bind "ctrl+f", "healthy", mode: "insert" end "#, );