feat: make keybindings configurable
This commit is contained in:
@@ -13,7 +13,7 @@ cargo run -- --config examples/config.rb notes.txt
|
||||
|
||||
Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty buffer. Use `:w path` to save it. `cargo run -- --help` lists the command-line options.
|
||||
|
||||
### Editing
|
||||
### Default editing keys
|
||||
|
||||
| Mode | Keys |
|
||||
| --- | --- |
|
||||
@@ -37,7 +37,7 @@ Paste is backend input: it is ignored in Normal and Command modes, and inserts l
|
||||
|
||||
One crate, four modules, no workspace or frontend-specific types in the editing backend:
|
||||
|
||||
- `editor`: owns buffer, cursor, modes, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `editor`: owns buffer, cursor, modes, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure.
|
||||
- `tui`: translates terminal events to session input, renders read-only editor state, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
@@ -47,7 +47,7 @@ Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; the
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
|
||||
Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, disable core, or bind over essential modal editing keys. `:plugins` lists active features and frontend capabilities.
|
||||
Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, or disable core. User configuration can override any keybinding without replacing the core implementation. `:plugins` lists active features and frontend capabilities.
|
||||
|
||||
A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions.
|
||||
|
||||
@@ -59,10 +59,38 @@ 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 "<C-r>", "core.redo"
|
||||
config.bind "<C-c>", "core.normal", mode: "insert"
|
||||
end
|
||||
```
|
||||
|
||||
Plugin paths resolve relative to the config file, not the shell's working directory. Bindings are single-character Normal-mode keys targeting registered command names. Configuration errors fall back to built-in editing and appear in the status message; one plugin's load failure does not prevent other plugins loading.
|
||||
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.
|
||||
|
||||
| Available modes | Built-in targets |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
|
||||
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 "i", nil
|
||||
config.bind "<Space>", "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.
|
||||
|
||||
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.
|
||||
|
||||
## Ruby plugins
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Vamp.configure do |config|
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
config.bind "<C-r>", "core.redo"
|
||||
config.bind "<C-c>", "core.normal", mode: "insert"
|
||||
end
|
||||
|
||||
+318
-108
@@ -13,7 +13,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum Input {
|
||||
Char(char),
|
||||
Ctrl(char),
|
||||
@@ -30,7 +30,7 @@ pub enum Input {
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum Mode {
|
||||
Normal,
|
||||
Insert,
|
||||
@@ -92,6 +92,181 @@ struct Snapshot {
|
||||
cursor: Cursor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Action {
|
||||
Insert,
|
||||
InsertFirstNonblank,
|
||||
Append,
|
||||
AppendLineEnd,
|
||||
OpenBelow,
|
||||
OpenAbove,
|
||||
Left,
|
||||
Right,
|
||||
Down,
|
||||
Up,
|
||||
JoinLines,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
Delete,
|
||||
Undo,
|
||||
Redo,
|
||||
Command,
|
||||
Normal,
|
||||
Newline,
|
||||
Backspace,
|
||||
Submit,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn named(mode: Mode, name: &str) -> Option<Self> {
|
||||
use Action::*;
|
||||
Some(match (mode, name) {
|
||||
(Mode::Normal, "core.insert") => Insert,
|
||||
(Mode::Normal, "core.insert_first_nonblank") => InsertFirstNonblank,
|
||||
(Mode::Normal, "core.append") => Append,
|
||||
(Mode::Normal, "core.append_line_end") => AppendLineEnd,
|
||||
(Mode::Normal, "core.open_below") => OpenBelow,
|
||||
(Mode::Normal, "core.open_above") => OpenAbove,
|
||||
(Mode::Normal, "core.join_lines") => JoinLines,
|
||||
(Mode::Normal, "core.undo") => Undo,
|
||||
(Mode::Normal, "core.redo") => Redo,
|
||||
(Mode::Normal, "core.command") => Command,
|
||||
(Mode::Normal | Mode::Insert, "core.left") => Left,
|
||||
(Mode::Normal | Mode::Insert, "core.right") => Right,
|
||||
(Mode::Normal | Mode::Insert, "core.down") => Down,
|
||||
(Mode::Normal | Mode::Insert, "core.up") => Up,
|
||||
(Mode::Normal | Mode::Insert, "core.line_start") => LineStart,
|
||||
(Mode::Normal | Mode::Insert, "core.line_end") => LineEnd,
|
||||
(Mode::Normal | Mode::Insert, "core.delete") => Delete,
|
||||
(Mode::Insert | Mode::Command, "core.normal") => Normal,
|
||||
(Mode::Insert, "core.newline") => Newline,
|
||||
(Mode::Insert | Mode::Command, "core.backspace") => Backspace,
|
||||
(Mode::Command, "core.submit") => Submit,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum Binding {
|
||||
Action(Action),
|
||||
Command(String),
|
||||
Disabled,
|
||||
}
|
||||
|
||||
fn parse_key(key: &str) -> Result<Input, String> {
|
||||
let mut chars = key.chars();
|
||||
if let Some(ch) = chars.next()
|
||||
&& chars.next().is_none()
|
||||
&& !ch.is_control()
|
||||
{
|
||||
return Ok(Input::Char(ch));
|
||||
}
|
||||
Ok(match key {
|
||||
"<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(' '),
|
||||
_ => {
|
||||
if let Some(control) = key
|
||||
.strip_prefix("<C-")
|
||||
.and_then(|key| key.strip_suffix('>'))
|
||||
{
|
||||
let mut chars = control.chars();
|
||||
if let Some(ch) = chars.next()
|
||||
&& ch.is_ascii_alphabetic()
|
||||
&& chars.next().is_none()
|
||||
{
|
||||
return Ok(control_key(ch));
|
||||
}
|
||||
}
|
||||
return Err(format!("Invalid binding key: {key:?}"));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn control_key(ch: char) -> Input {
|
||||
// Terminals encode these controls as named keys.
|
||||
match ch.to_ascii_lowercase() {
|
||||
'h' => Input::Backspace,
|
||||
'i' => Input::Char('\t'),
|
||||
'j' | 'm' => Input::Enter,
|
||||
ch => Input::Ctrl(ch),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
use Action::*;
|
||||
let mut bindings: HashMap<Mode, HashMap<Input, Binding>> = HashMap::new();
|
||||
for (key, action) in [
|
||||
('i', Insert),
|
||||
('I', InsertFirstNonblank),
|
||||
('a', Append),
|
||||
('A', AppendLineEnd),
|
||||
('o', OpenBelow),
|
||||
('O', OpenAbove),
|
||||
('h', Left),
|
||||
('j', Down),
|
||||
('k', Up),
|
||||
('l', Right),
|
||||
('J', JoinLines),
|
||||
('0', LineStart),
|
||||
('$', LineEnd),
|
||||
('x', Delete),
|
||||
('u', Undo),
|
||||
('U', Redo),
|
||||
(':', Command),
|
||||
] {
|
||||
bindings
|
||||
.entry(Mode::Normal)
|
||||
.or_default()
|
||||
.insert(Input::Char(key), Binding::Action(action));
|
||||
}
|
||||
for mode in [Mode::Normal, Mode::Insert] {
|
||||
for (key, action) in [
|
||||
(Input::Left, Left),
|
||||
(Input::Right, Right),
|
||||
(Input::Down, Down),
|
||||
(Input::Up, Up),
|
||||
(Input::Home, LineStart),
|
||||
(Input::End, LineEnd),
|
||||
(Input::Delete, Delete),
|
||||
] {
|
||||
bindings
|
||||
.entry(mode)
|
||||
.or_default()
|
||||
.insert(key, Binding::Action(action));
|
||||
}
|
||||
}
|
||||
for mode in [Mode::Insert, Mode::Command] {
|
||||
for (key, action) in [
|
||||
(Input::Escape, Normal),
|
||||
(Input::Backspace, Backspace),
|
||||
(
|
||||
Input::Enter,
|
||||
if mode == Mode::Insert {
|
||||
Newline
|
||||
} else {
|
||||
Submit
|
||||
},
|
||||
),
|
||||
] {
|
||||
bindings
|
||||
.entry(mode)
|
||||
.or_default()
|
||||
.insert(key, Binding::Action(action));
|
||||
}
|
||||
}
|
||||
bindings
|
||||
}
|
||||
|
||||
pub struct Editor {
|
||||
lines: Vec<String>,
|
||||
saved_lines: Vec<String>,
|
||||
@@ -103,7 +278,7 @@ pub struct Editor {
|
||||
quit: bool,
|
||||
capabilities: Vec<String>,
|
||||
features: HashMap<String, Feature>,
|
||||
bindings: HashMap<char, String>,
|
||||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
||||
// ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files.
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
@@ -133,7 +308,7 @@ impl Editor {
|
||||
quit: false,
|
||||
capabilities,
|
||||
features: HashMap::new(),
|
||||
bindings: HashMap::new(),
|
||||
bindings: default_bindings(),
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
insert_start: None,
|
||||
@@ -252,120 +427,157 @@ impl Editor {
|
||||
return;
|
||||
}
|
||||
if let Some(feature) = self.features.remove(name) {
|
||||
self.bindings
|
||||
.retain(|_, command| !feature.commands.contains(command));
|
||||
for bindings in self.bindings.values_mut() {
|
||||
bindings.retain(|_, binding| {
|
||||
!matches!(binding, Binding::Command(command) if feature.commands.contains(command))
|
||||
});
|
||||
}
|
||||
}
|
||||
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
||||
}
|
||||
|
||||
pub fn bind(&mut self, key: char, command: String) -> Result<(), String> {
|
||||
if key.is_control() || "iIaAoOhjJklx0$uU:".contains(key) {
|
||||
return Err(format!("Key {key:?} is reserved for editing"));
|
||||
}
|
||||
if self.owner(&command).is_none() {
|
||||
return Err(format!("Unknown command: {command}"));
|
||||
}
|
||||
self.bindings.insert(key, command);
|
||||
/// Overrides one mode's binding. `None` disables the key, including literal
|
||||
/// typing in Insert/Command mode. Invalid overrides leave the binding intact.
|
||||
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
|
||||
let mode = match mode {
|
||||
"normal" => Mode::Normal,
|
||||
"insert" => Mode::Insert,
|
||||
"command" => Mode::Command,
|
||||
_ => return Err(format!("Unknown binding mode: {mode}")),
|
||||
};
|
||||
let key = parse_key(key)?;
|
||||
let binding = match target {
|
||||
None => Binding::Disabled,
|
||||
Some(target) if target.starts_with("core.") => Binding::Action(
|
||||
Action::named(mode, &target)
|
||||
.ok_or_else(|| format!("Unknown action for {mode}: {target}"))?,
|
||||
),
|
||||
Some(command) => {
|
||||
if mode != Mode::Normal {
|
||||
return Err("Ex/plugin commands can only be bound in Normal mode".into());
|
||||
}
|
||||
if self.owner(&command).is_none() {
|
||||
return Err(format!("Unknown command: {command}"));
|
||||
}
|
||||
Binding::Command(command)
|
||||
}
|
||||
};
|
||||
self.bindings.entry(mode).or_default().insert(key, binding);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) -> Option<Invocation> {
|
||||
match self.mode {
|
||||
Mode::Normal => match input {
|
||||
Input::Char('i') => self.begin_insert(),
|
||||
Input::Char('I') => {
|
||||
self.begin_insert();
|
||||
self.cursor.column = self.lines[self.cursor.row]
|
||||
.graphemes(true)
|
||||
.take_while(|grapheme| matches!(*grapheme, " " | "\t"))
|
||||
.count();
|
||||
// Paste is literal input, never a configurable key or command.
|
||||
if let Input::Paste(text) = input {
|
||||
if self.mode == Mode::Insert {
|
||||
self.paste(&text);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// Normalize terminal aliases for every frontend.
|
||||
let input = match input {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
match self
|
||||
.bindings
|
||||
.get(&self.mode)
|
||||
.and_then(|keys| keys.get(&input))
|
||||
{
|
||||
Some(Binding::Action(action)) => return self.act(*action),
|
||||
Some(Binding::Command(command)) => {
|
||||
let command = command.clone();
|
||||
return self.execute(&command);
|
||||
}
|
||||
Some(Binding::Disabled) => return None,
|
||||
None => {}
|
||||
}
|
||||
match (self.mode, input) {
|
||||
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
|
||||
let mut bytes = [0; 4];
|
||||
self.insert_text(ch.encode_utf8(&mut bytes));
|
||||
}
|
||||
(Mode::Command, Input::Char(ch)) if !ch.is_control() => self.command_line.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn act(&mut self, action: Action) -> Option<Invocation> {
|
||||
match action {
|
||||
Action::Insert => self.begin_insert(),
|
||||
Action::InsertFirstNonblank => {
|
||||
self.begin_insert();
|
||||
self.cursor.column = self.lines[self.cursor.row]
|
||||
.graphemes(true)
|
||||
.take_while(|grapheme| matches!(*grapheme, " " | "\t"))
|
||||
.count();
|
||||
}
|
||||
Action::Append => {
|
||||
self.begin_insert();
|
||||
self.cursor.column =
|
||||
(self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row]));
|
||||
}
|
||||
Action::AppendLineEnd => {
|
||||
self.begin_insert();
|
||||
self.move_end();
|
||||
}
|
||||
Action::OpenBelow | Action::OpenAbove => {
|
||||
self.begin_insert();
|
||||
if matches!(action, Action::OpenBelow) {
|
||||
self.cursor.row += 1;
|
||||
}
|
||||
Input::Char('a') => {
|
||||
self.begin_insert();
|
||||
self.cursor.column =
|
||||
(self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row]));
|
||||
self.lines.insert(self.cursor.row, String::new());
|
||||
self.cursor.column = 0;
|
||||
}
|
||||
Action::Left => self.move_horizontal(false),
|
||||
Action::Right => self.move_horizontal(true),
|
||||
Action::Down => self.move_vertical(true),
|
||||
Action::Up => self.move_vertical(false),
|
||||
Action::JoinLines => self.join_lines(),
|
||||
Action::LineStart => self.cursor.column = 0,
|
||||
Action::LineEnd => self.move_end(),
|
||||
Action::Delete => {
|
||||
if self.mode == Mode::Insert {
|
||||
self.delete_forward();
|
||||
} else if self.cursor.column < grapheme_count(&self.lines[self.cursor.row]) {
|
||||
let before = self.snapshot();
|
||||
self.delete_forward();
|
||||
self.clamp_cursor();
|
||||
self.record(before);
|
||||
}
|
||||
Input::Char('A') => {
|
||||
self.begin_insert();
|
||||
self.move_end();
|
||||
}
|
||||
Input::Char('o') | Input::Char('O') => {
|
||||
self.begin_insert();
|
||||
if input == Input::Char('o') {
|
||||
self.cursor.row += 1;
|
||||
}
|
||||
self.lines.insert(self.cursor.row, String::new());
|
||||
self.cursor.column = 0;
|
||||
}
|
||||
Input::Char('h') | Input::Left => self.move_horizontal(false),
|
||||
Input::Char('l') | Input::Right => self.move_horizontal(true),
|
||||
Input::Char('j') | Input::Down => self.move_vertical(true),
|
||||
Input::Char('J') => self.join_lines(),
|
||||
Input::Char('k') | Input::Up => self.move_vertical(false),
|
||||
Input::Char('0') | Input::Home => self.cursor.column = 0,
|
||||
Input::Char('$') | Input::End => self.move_end(),
|
||||
Input::Char('x') | Input::Delete => {
|
||||
if self.cursor.column < grapheme_count(&self.lines[self.cursor.row]) {
|
||||
let before = self.snapshot();
|
||||
self.delete_forward();
|
||||
self.clamp_cursor();
|
||||
self.record(before);
|
||||
}
|
||||
}
|
||||
Input::Char('u') => self.undo(),
|
||||
Input::Char('U') => self.redo(),
|
||||
Input::Char(':') => {
|
||||
self.mode = Mode::Command;
|
||||
self.command_line.clear();
|
||||
}
|
||||
Input::Char(key) => {
|
||||
if let Some(command) = self.bindings.get(&key).cloned() {
|
||||
return self.execute(&command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Mode::Insert => match input {
|
||||
Input::Escape => {
|
||||
}
|
||||
Action::Undo => self.undo(),
|
||||
Action::Redo => self.redo(),
|
||||
Action::Command => {
|
||||
self.mode = Mode::Command;
|
||||
self.command_line.clear();
|
||||
}
|
||||
Action::Normal => {
|
||||
if self.mode == Mode::Insert {
|
||||
self.finish_insert();
|
||||
self.mode = Mode::Normal;
|
||||
self.cursor.column = self.cursor.column.saturating_sub(1);
|
||||
self.clamp_cursor();
|
||||
}
|
||||
Input::Char(ch) if !ch.is_control() || ch == '\t' => {
|
||||
let mut bytes = [0; 4];
|
||||
self.insert_text(ch.encode_utf8(&mut bytes));
|
||||
}
|
||||
Input::Paste(text) => self.paste(&text),
|
||||
Input::Enter => self.insert_text("\n"),
|
||||
Input::Backspace => self.backspace(),
|
||||
Input::Delete => self.delete_forward(),
|
||||
Input::Left => self.move_horizontal(false),
|
||||
Input::Right => self.move_horizontal(true),
|
||||
Input::Down => self.move_vertical(true),
|
||||
Input::Up => self.move_vertical(false),
|
||||
Input::Home => self.cursor.column = 0,
|
||||
Input::End => self.move_end(),
|
||||
_ => {}
|
||||
},
|
||||
Mode::Command => match input {
|
||||
Input::Escape => {
|
||||
} else {
|
||||
self.command_line.clear();
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
Input::Enter => {
|
||||
let command = std::mem::take(&mut self.command_line);
|
||||
self.mode = Mode::Normal;
|
||||
return self.execute(&command);
|
||||
}
|
||||
Action::Newline => self.insert_text("\n"),
|
||||
Action::Backspace => {
|
||||
if self.mode == Mode::Insert {
|
||||
self.backspace();
|
||||
} else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back()
|
||||
{
|
||||
self.command_line.truncate(byte);
|
||||
}
|
||||
Input::Backspace => {
|
||||
if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back() {
|
||||
self.command_line.truncate(byte);
|
||||
}
|
||||
}
|
||||
Input::Char(ch) if !ch.is_control() => self.command_line.push(ch),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Action::Submit => {
|
||||
let command = std::mem::take(&mut self.command_line);
|
||||
self.mode = Mode::Normal;
|
||||
return self.execute(&command);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1140,13 +1352,13 @@ mod tests {
|
||||
let mut feature = plugin("graphics", &["draw"]);
|
||||
feature.required_capabilities.push("graphics".into());
|
||||
assert!(editor.register(feature).is_err());
|
||||
assert!(editor.bind('g', "draw".into()).is_err());
|
||||
assert!(editor.bind("normal", "g", Some("draw".into())).is_err());
|
||||
assert!(
|
||||
editor
|
||||
.register(plugin("duplicate", &["newcmd", "w"]))
|
||||
.is_err()
|
||||
);
|
||||
assert!(editor.bind('g', "newcmd".into()).is_err());
|
||||
assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err());
|
||||
assert!(
|
||||
editor
|
||||
.register(plugin("repeat", &["same", "same"]))
|
||||
@@ -1183,13 +1395,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_dispatch_only_normal_mode_and_essential_keys_stay_protected() {
|
||||
fn plugin_bindings_dispatch_only_normal_mode_and_retire_with_owner() {
|
||||
let mut editor = editor();
|
||||
editor.register(plugin("greeting", &["greet"])).unwrap();
|
||||
for key in "iIaAoOhjJklx0$uU:\u{1b}\r".chars() {
|
||||
assert!(editor.bind(key, "greet".into()).is_err());
|
||||
}
|
||||
editor.bind('g', "greet".into()).unwrap();
|
||||
assert!(editor.bind("insert", "g", Some("greet".into())).is_err());
|
||||
editor.bind("normal", "g", Some("greet".into())).unwrap();
|
||||
assert_eq!(
|
||||
editor.handle(Input::Char('g')),
|
||||
Some(Invocation {
|
||||
@@ -1204,7 +1414,7 @@ mod tests {
|
||||
editor.handle(Input::Escape);
|
||||
editor.disable_plugin("greeting", "crashed\u{1b}[31m");
|
||||
assert_eq!(editor.handle(Input::Char('g')), None);
|
||||
assert!(editor.bind('g', "greet".into()).is_err());
|
||||
assert!(editor.bind("normal", "g", Some("greet".into())).is_err());
|
||||
assert!(!editor.message().contains('\u{1b}'));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
||||
|
||||
Usage: vamp [--config PATH | --no-config] [FILE]
|
||||
|
||||
Default keys (configurable with config.bind):
|
||||
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||
u undo, U redo, : command
|
||||
|
||||
+7
-16
@@ -16,7 +16,7 @@ const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Config {
|
||||
pub plugins: Vec<PathBuf>,
|
||||
pub bindings: Vec<(char, String)>,
|
||||
pub bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
/// Relative plugin paths are resolved against the configuration file's directory.
|
||||
@@ -37,16 +37,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
}
|
||||
config.plugins.push(directory.join(plugin));
|
||||
}
|
||||
for binding in bindings {
|
||||
let mut chars = binding.key.chars();
|
||||
let key = chars
|
||||
.next()
|
||||
.ok_or("Ruby binding key must be one character")?;
|
||||
if chars.next().is_some() || binding.command.is_empty() {
|
||||
return Err("Ruby binding requires one character and a nonempty command".into());
|
||||
}
|
||||
config.bindings.push((key, binding.command));
|
||||
}
|
||||
config.bindings = bindings;
|
||||
Ok(config)
|
||||
}
|
||||
Response::Error {
|
||||
@@ -224,11 +215,12 @@ impl Response {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Binding {
|
||||
key: String,
|
||||
command: String,
|
||||
pub struct Binding {
|
||||
pub mode: String,
|
||||
pub key: String,
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -546,7 +538,6 @@ end
|
||||
);
|
||||
let loaded = load_config(&config).unwrap();
|
||||
assert_eq!(loaded.plugins, [fixture.0.join("plugins/greeting.rb")]);
|
||||
assert_eq!(loaded.bindings, [('g', "greet".into())]);
|
||||
let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }");
|
||||
assert!(
|
||||
load_config(&invalid)
|
||||
|
||||
+6
-5
@@ -31,12 +31,13 @@ module Vamp
|
||||
nil
|
||||
end
|
||||
|
||||
def bind(key, command)
|
||||
def bind(key, command, mode: "normal")
|
||||
key = Vamp.string!(key, "binding key")
|
||||
command = Vamp.string!(command, "binding command")
|
||||
raise ArgumentError, "binding key must be one character" unless key.length == 1
|
||||
raise ArgumentError, "binding command must not be empty" if command.empty?
|
||||
@bindings << { "key" => key, "command" => command }
|
||||
mode = Vamp.string!(mode, "binding mode")
|
||||
command = Vamp.string!(command, "binding command") unless command.nil?
|
||||
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 }
|
||||
nil
|
||||
end
|
||||
|
||||
|
||||
+100
-4
@@ -48,13 +48,16 @@ impl EditorSession {
|
||||
Err(error) => notices.push(format!("{}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
for (key, command) in config.bindings {
|
||||
if let Err(error) = editor.bind(key, command) {
|
||||
notices.push(format!("binding {key:?}: {error}"));
|
||||
for binding in config.bindings {
|
||||
if let Err(error) = editor.bind(&binding.mode, &binding.key, binding.command) {
|
||||
notices.push(format!(
|
||||
"binding {} {:?}: {error}",
|
||||
binding.mode, binding.key
|
||||
));
|
||||
}
|
||||
}
|
||||
if notices.is_empty() {
|
||||
editor.set_message("i: insert • Esc: normal • :w save • :q quit • :plugins");
|
||||
editor.set_message("Ready • --no-config uses default keybindings");
|
||||
} else {
|
||||
editor.set_message(notices.join(" | "));
|
||||
}
|
||||
@@ -318,6 +321,99 @@ end
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_bindings_preserve_modal_editing_history_and_literal_paste() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.healthy();
|
||||
let mut session = fixture.open(
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.plugin "healthy.rb"
|
||||
config.bind "n", "core.insert_first_nonblank"
|
||||
config.bind "i", nil
|
||||
config.bind "<C-r>", "core.redo"
|
||||
config.bind "<C-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 ":", nil
|
||||
config.bind "<Space>", "core.command"
|
||||
config.bind "<Enter>", nil, mode: "command"
|
||||
config.bind "<C-x>", "core.submit", mode: "command"
|
||||
config.bind "<C-c>", "core.normal", mode: "command"
|
||||
config.bind "<C-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 "<Unknown>", "core.undo"
|
||||
config.bind "<C-f>", "healthy", mode: "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'));
|
||||
for ch in "界Xab!".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
assert_eq!(session.editor().lines(), &["界ab", ""]);
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
|
||||
session.handle(Input::Paste("X!".into()));
|
||||
assert_eq!(session.editor().lines(), &["界ab", "X!"]);
|
||||
session.handle(Input::Ctrl('C'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &["界ab", ""]);
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().lines(), &[String::new()]);
|
||||
session.handle(Input::Ctrl('r'));
|
||||
session.handle(Input::Ctrl('r'));
|
||||
assert_eq!(session.editor().lines(), &["界ab", "X!"]);
|
||||
session.handle(Input::Char(':'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
session.handle(Input::Char(' '));
|
||||
for ch in "w界".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Backspace);
|
||||
assert_eq!(session.editor().command_line(), "w");
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Command);
|
||||
session.handle(Input::Ctrl('x'));
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"界ab\nX!"
|
||||
);
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('q'));
|
||||
session.handle(Input::Ctrl('c'));
|
||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||
assert!(session.editor().command_line().is_empty());
|
||||
assert!(!session.editor().should_quit());
|
||||
session.handle(Input::Char('g'));
|
||||
assert_eq!(session.editor().lines(), &["healthy "]);
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('q'));
|
||||
session.handle(Input::Ctrl('x'));
|
||||
assert!(!session.editor().should_quit());
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('q'));
|
||||
session.handle(Input::Char('!'));
|
||||
session.handle(Input::Ctrl('x'));
|
||||
assert!(session.editor().should_quit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_config_falls_back_to_core_without_failing_the_session() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
Reference in New Issue
Block a user