feat: expose typed action and mode configuration values

This commit is contained in:
Valdemar Lorenzen
2026-10-05 16:21:01 +02:00
parent bb242b35b6
commit 714f1ff4de
5 changed files with 125 additions and 45 deletions
+16 -14
View File
@@ -59,13 +59,15 @@ 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 "ctrl+r", "core.redo"
config.bind "shift+j", "core.join_lines"
config.bind "ctrl+c", "core.normal", mode: "insert"
config.bind "ctrl+r", config.core.redo
config.bind "shift+j", config.core.join_lines
config.bind "ctrl+c", config.core.normal, mode: config.modes.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.
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key; available modes are `config.modes.normal`, `config.modes.insert`, and `config.modes.command`. Omitting `mode` selects Normal.
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
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:
@@ -75,27 +77,27 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
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 | Methods on `config.core` |
| --- | --- |
| Normal | `core.insert`, `core.insert_first_nonblank`, `core.append`, `core.append_line_end`, `core.open_below`, `core.open_above` |
| Normal | `core.join_lines`, `core.undo`, `core.redo`, `core.command` |
| Normal, Insert | `core.left`, `core.down`, `core.up`, `core.right`, `core.line_start`, `core.line_end`, `core.delete` |
| Insert, Command | `core.normal`, `core.backspace` |
| Insert | `core.newline` |
| Command | `core.submit` |
| Normal | `insert`, `insert_first_nonblank`, `append`, `append_line_end`, `open_below`, `open_above` |
| Normal | `join_lines`, `undo`, `redo`, `command` |
| Normal, Insert | `left`, `down`, `up`, `right`, `line_start`, `line_end`, `delete` |
| Insert, Command | `normal`, `backspace` |
| Insert | `newline` |
| Command | `submit` |
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
```ruby
Vamp.configure do |config|
config.bind "n", "core.insert"
config.bind "n", config.core.insert
config.bind "i", nil
config.bind "space", "core.command"
config.bind "space", config.core.command
config.bind ":", nil
end
```
The last valid declaration for a key/mode wins. Invalid keys, modes, or targets leave that binding unchanged and appear in the status message; other valid bindings still load. Ruby syntax/type errors disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its bindings, without restoring any default keys they replaced.
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. Ruby syntax errors, unknown methods, and type errors disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its bindings, without restoring any default keys they replaced.
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command mode or entering commands, restart with `--no-config` to restore defaults.
+2 -2
View File
@@ -1,6 +1,6 @@
Vamp.configure do |config|
config.plugin "plugins/greeting.rb"
config.bind "g", "greet"
config.bind "ctrl+r", "core.redo"
config.bind "ctrl+c", "core.normal", mode: "insert"
config.bind "ctrl+r", config.core.redo
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
end
+7
View File
@@ -0,0 +1,7 @@
sjdalkjdas dsasda sdklam dalkmsd adadkajsdlakj
o
aaklsjdad
ads
dasdkaljdalkjsda
sdalksdjaldskjadkjsla
+47 -4
View File
@@ -18,6 +18,30 @@ module Vamp
value.dup.force_encoding(Encoding::UTF_8)
end
Action = Struct.new(:name)
Mode = Struct.new(:name)
module Core
%w[
insert insert_first_nonblank append append_line_end open_below open_above
left right down up join_lines line_start line_end delete undo redo command
normal newline backspace submit
].each do |name|
action = Action.new("core.#{name}".freeze).freeze
define_singleton_method(name) { action }
end
end
module Modes
%w[normal insert command].each do |name|
mode = Mode.new(name.freeze).freeze
define_singleton_method(name) { mode }
end
end
Action.private_class_method :new
Mode.private_class_method :new
class Config
def initialize
@plugins = []
@@ -31,13 +55,32 @@ module Vamp
nil
end
def bind(key, command, mode: "normal")
def core
Core
end
def modes
Modes
end
def bind(key, command, mode: Modes.normal)
key = Vamp.string!(key, "binding key")
mode = Vamp.string!(mode, "binding mode")
command = Vamp.string!(command, "binding command") unless command.nil?
raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode)
command = case command
when Action
command.name
when String
name = Vamp.string!(command, "binding command")
raise TypeError, "use config.core for built-in actions" if name.start_with?("core.")
name
when nil
nil
else
raise TypeError, "binding command must be an Action, String, or nil"
end
raise ArgumentError, "binding key must not be empty" if key.empty?
raise ArgumentError, "binding command must not be empty" if command == ""
@bindings << { "mode" => mode, "key" => key, "command" => command }
@bindings << { "mode" => mode.name, "key" => key, "command" => command }
nil
end
+53 -25
View File
@@ -329,37 +329,27 @@ end
r#"
Vamp.configure do |config|
config.plugin "healthy.rb"
config.bind "n", "core.insert_first_nonblank"
config.bind "n", config.core.insert_first_nonblank
config.bind "i", nil
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 "shift+x", nil, mode: "insert"
config.bind "ctrl+r", config.core.redo
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
config.bind "esc", nil, mode: config.modes.insert
config.bind "!", config.core.newline, mode: config.modes.insert
config.bind "shift+x", nil, mode: config.modes.insert
config.bind ":", nil
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 "space", config.core.command
config.bind "enter", nil, mode: config.modes.command
config.bind "ctrl+x", config.core.submit, mode: config.modes.command
config.bind "ctrl+c", config.core.normal, mode: config.modes.command
config.bind "ctrl+h", config.core.backspace, mode: config.modes.command
config.bind "g", config.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 "ctrl+f", "healthy", mode: "insert"
config.bind "n", config.core.newline
config.bind "unknown", config.core.undo
config.bind "ctrl+f", "healthy", mode: config.modes.insert
end
"#,
);
let message = session.editor().message();
for error in [
"Unknown action for NORMAL: core.missing",
"Unknown binding mode: missing",
"Invalid binding key",
"Ex/plugin commands can only be bound in Normal mode",
] {
assert!(message.contains(error), "{message}");
}
session.handle(Input::Char('i'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('n'));
@@ -414,6 +404,44 @@ end
assert!(session.editor().should_quit());
}
#[test]
fn typed_config_errors_discard_partial_bindings_and_leave_default_editing_available() {
let fixture = Fixture::new();
for (expression, error) in [
("config.core.reod", "NoMethodError"),
("config.modes.insret", "NoMethodError"),
(r#"config.bind "n", "core.insert""#, "TypeError"),
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
(
r#"config.bind "n", nil, mode: config.core.insert"#,
"TypeError",
),
(r#"config.core.redo.name = "core.undo""#, "FrozenError"),
(
r#"config.core.redo.name.replace("core.undo")"#,
"FrozenError",
),
(r#"config.modes.insert.name = "normal""#, "FrozenError"),
(
r#"config.modes.insert.name.replace("normal")"#,
"FrozenError",
),
] {
let mut session = fixture.open(&format!(
"Vamp.configure do |config|\n config.bind \"i\", nil\n {expression}\nend"
));
assert!(session.editor().message().contains(error), "{expression}");
session.handle(Input::Char('i'));
session.handle(Input::Char('界'));
session.handle(Input::Escape);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().lines(), &["界"], "{expression}");
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[String::new()], "{expression}");
}
}
#[test]
fn broken_config_falls_back_to_core_without_failing_the_session() {
let fixture = Fixture::new();