feat: add configurable Space leader bindings

This commit is contained in:
Valdemar Lorenzen
2026-10-08 13:54:48 +02:00
parent 5d2d67ab09
commit 5a67bf64a3
8 changed files with 958 additions and 28 deletions
+20 -5
View File
@@ -18,6 +18,8 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
| Mode | Keys |
| --- | --- |
| All modes | Ctrl+p opens the command palette; type to filter, Up/Down or Ctrl+k/Ctrl+n select, Enter accepts, Esc/Ctrl+p cancels |
| Normal, Visual | Space Space opens the palette through the default leader |
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
@@ -46,7 +48,7 @@ Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`,
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported.
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader prefixes retain the count until their leaf action resolves. Other commands consume the prefix and execute once, including line-start/end, edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and Vim operator grammar such as `3dw` is not supported.
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. Visual selection is characterwise and inclusive of the endpoints; its read-only ranges are end-exclusive. `$`/End can select the newline after a non-final row; empty-line newlines are selectable too. Selected newlines appear as a blue blank cell after the line. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets.
@@ -97,11 +99,21 @@ Accepting a core action executes it in the underlying mode, including Visual edi
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; navigation and cancellation inside the palette are fixed. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
### Leader key
Space starts a leader sequence in Normal/Visual when it has no direct binding in that mode. Leader bindings use `leader` followed by whitespace-separated single-key names, for example `config.bind "leader b n", "bnext"`. Nested prefixes wait for the next key without a timeout. The status bar shows the actual pending sequence before the file path; the palette advertises effective sequences alongside ordinary bindings.
Set `config.leader = ","` to change the trigger for every default and configured leader route. Suffix keys stay unchanged: the default palette binding becomes comma then Space, save becomes comma then `w`, and buffer-next becomes comma then `b` then `n`. Choose an unused single key in the existing notation; Escape, ASCII digits, malformed keys, and a new trigger already directly bound in Normal/Visual are rejected without replacing the current choice. A direct binding on the current trigger—including `nil`—takes precedence per mode and hides unreachable leader routes from the palette.
Esc cancels a pending sequence and count without leaving Visual or collapsing carets. Unknown suffixes consume the key and cancel safely, never falling through to an edit. Paste, external command execution, buffer switching, and binding/leader changes clear pending state. A count before a leader motion applies normally; digits inside a sequence are literal suffix keys. Plugins execute once. Outside Normal/Visual, the leader has no prefix behavior, so Space still types normally in Insert and prompts.
Leader bindings require Normal/Visual and the target's existing mode eligibility; Ex/plugin targets remain Normal-only. A sequence cannot be both a complete binding and another binding's prefix: ambiguous declarations are refused atomically. Disabled leaves remain reserved. Only leader-prefixed sequences are supported—not arbitrary multi-key mappings, timers, or macros.
## Module seams
One crate, four modules, no workspace or frontend-specific types in the editing backend:
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard fragments, action metadata and command-palette state/filtering, key notation and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard fragments, action metadata and command-palette state/filtering, key notation, leader/count prefixes and binding resolution, modal paste policy, 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 once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure.
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, 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.
@@ -111,6 +123,7 @@ Frontends drive `EditorSession::handle` or `execute` and inspect `editor()`; the
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
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.
@@ -129,8 +142,10 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con
```ruby
Vamp.configure do |config|
config.leader = "space"
config.plugin "plugins/greeting.rb"
config.bind "g", "greet"
config.bind "leader g", "greet"
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
@@ -143,7 +158,7 @@ Vamp.configure do |config|
end
```
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`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. Omitting `mode` selects Normal.
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 or leader-prefixed sequence; available modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. Omitting `mode` selects Normal. The leader is session-wide, defaults to `"space"`, and is applied before configured bindings.
`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.
@@ -153,7 +168,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
- 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.
Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ctrl+m` with `enter`; these names address the same binding, including inside leader sequences. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, 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 | Methods on `config.core` |
| --- | --- |
@@ -182,7 +197,7 @@ Vamp.configure do |config|
end
```
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.
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous leader prefixes, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—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 single-key and leader 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/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults.