feat: expose typed action and mode configuration values
This commit is contained in:
@@ -59,13 +59,15 @@ 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 "ctrl+r", "core.redo"
|
config.bind "ctrl+r", config.core.redo
|
||||||
config.bind "shift+j", "core.join_lines"
|
config.bind "shift+j", config.core.join_lines
|
||||||
config.bind "ctrl+c", "core.normal", mode: "insert"
|
config.bind "ctrl+c", config.core.normal, mode: config.modes.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: 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:
|
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.
|
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 | `insert`, `insert_first_nonblank`, `append`, `append_line_end`, `open_below`, `open_above` |
|
||||||
| Normal | `core.join_lines`, `core.undo`, `core.redo`, `core.command` |
|
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||||
| Normal, Insert | `core.left`, `core.down`, `core.up`, `core.right`, `core.line_start`, `core.line_end`, `core.delete` |
|
| Normal, Insert | `left`, `down`, `up`, `right`, `line_start`, `line_end`, `delete` |
|
||||||
| Insert, Command | `core.normal`, `core.backspace` |
|
| Insert, Command | `normal`, `backspace` |
|
||||||
| Insert | `core.newline` |
|
| Insert | `newline` |
|
||||||
| Command | `core.submit` |
|
| Command | `submit` |
|
||||||
|
|
||||||
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
|
Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
config.bind "n", "core.insert"
|
config.bind "n", config.core.insert
|
||||||
config.bind "i", nil
|
config.bind "i", nil
|
||||||
config.bind "space", "core.command"
|
config.bind "space", config.core.command
|
||||||
config.bind ":", nil
|
config.bind ":", nil
|
||||||
end
|
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.
|
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
@@ -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 "ctrl+r", "core.redo"
|
config.bind "ctrl+r", config.core.redo
|
||||||
config.bind "ctrl+c", "core.normal", mode: "insert"
|
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
sjdalkjdas dsasda sdklam dalkmsd adadkajsdlakj
|
||||||
|
o
|
||||||
|
aaklsjdad
|
||||||
|
|
||||||
|
ads
|
||||||
|
dasdkaljdalkjsda
|
||||||
|
sdalksdjaldskjadkjsla
|
||||||
+47
-4
@@ -18,6 +18,30 @@ module Vamp
|
|||||||
value.dup.force_encoding(Encoding::UTF_8)
|
value.dup.force_encoding(Encoding::UTF_8)
|
||||||
end
|
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
|
class Config
|
||||||
def initialize
|
def initialize
|
||||||
@plugins = []
|
@plugins = []
|
||||||
@@ -31,13 +55,32 @@ module Vamp
|
|||||||
nil
|
nil
|
||||||
end
|
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")
|
key = Vamp.string!(key, "binding key")
|
||||||
mode = Vamp.string!(mode, "binding mode")
|
raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode)
|
||||||
command = Vamp.string!(command, "binding command") unless command.nil?
|
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 key must not be empty" if key.empty?
|
||||||
raise ArgumentError, "binding command must not be empty" if command == ""
|
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
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+53
-25
@@ -329,37 +329,27 @@ end
|
|||||||
r#"
|
r#"
|
||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
config.plugin "healthy.rb"
|
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 "i", nil
|
||||||
config.bind "ctrl+r", "core.redo"
|
config.bind "ctrl+r", config.core.redo
|
||||||
config.bind "ctrl+c", "core.normal", mode: "insert"
|
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||||
config.bind "esc", nil, mode: "insert"
|
config.bind "esc", nil, mode: config.modes.insert
|
||||||
config.bind "!", "core.newline", mode: "insert"
|
config.bind "!", config.core.newline, mode: config.modes.insert
|
||||||
config.bind "shift+x", nil, mode: "insert"
|
config.bind "shift+x", nil, mode: config.modes.insert
|
||||||
config.bind ":", nil
|
config.bind ":", nil
|
||||||
config.bind "space", "core.command"
|
config.bind "space", config.core.command
|
||||||
config.bind "enter", nil, mode: "command"
|
config.bind "enter", nil, mode: config.modes.command
|
||||||
config.bind "ctrl+x", "core.submit", mode: "command"
|
config.bind "ctrl+x", config.core.submit, mode: config.modes.command
|
||||||
config.bind "ctrl+c", "core.normal", mode: "command"
|
config.bind "ctrl+c", config.core.normal, mode: config.modes.command
|
||||||
config.bind "ctrl+h", "core.backspace", mode: "command"
|
config.bind "ctrl+h", config.core.backspace, mode: config.modes.command
|
||||||
config.bind "g", "core.undo"
|
config.bind "g", config.core.undo
|
||||||
config.bind "g", "healthy"
|
config.bind "g", "healthy"
|
||||||
config.bind "n", "core.missing"
|
config.bind "n", config.core.newline
|
||||||
config.bind "n", "core.undo", mode: "missing"
|
config.bind "unknown", config.core.undo
|
||||||
config.bind "unknown", "core.undo"
|
config.bind "ctrl+f", "healthy", mode: config.modes.insert
|
||||||
config.bind "ctrl+f", "healthy", mode: "insert"
|
|
||||||
end
|
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'));
|
session.handle(Input::Char('i'));
|
||||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||||
session.handle(Input::Char('n'));
|
session.handle(Input::Char('n'));
|
||||||
@@ -414,6 +404,44 @@ end
|
|||||||
assert!(session.editor().should_quit());
|
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]
|
#[test]
|
||||||
fn broken_config_falls_back_to_core_without_failing_the_session() {
|
fn broken_config_falls_back_to_core_without_failing_the_session() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user