feat: add configurable Space leader bindings
This commit is contained in:
@@ -18,6 +18,8 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
|||||||
| Mode | Keys |
|
| 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 |
|
| 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 | `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, 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 |
|
| 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.
|
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.
|
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.
|
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
|
## Module seams
|
||||||
|
|
||||||
One crate, four modules, no workspace or frontend-specific types in the editing backend:
|
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.
|
- `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.
|
- `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.
|
- `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 `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 `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 `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.
|
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.
|
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
|
```ruby
|
||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
|
config.leader = "space"
|
||||||
config.plugin "plugins/greeting.rb"
|
config.plugin "plugins/greeting.rb"
|
||||||
config.bind "g", "greet"
|
config.bind "g", "greet"
|
||||||
|
config.bind "leader g", "greet"
|
||||||
config.bind "ctrl+r", config.core.redo
|
config.bind "ctrl+r", config.core.redo
|
||||||
config.bind "shift+j", config.core.join_lines
|
config.bind "shift+j", config.core.join_lines
|
||||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||||
@@ -143,7 +158,7 @@ Vamp.configure do |config|
|
|||||||
end
|
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.
|
`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.
|
- 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.
|
- 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` |
|
| Available modes | Methods on `config.core` |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -182,7 +197,7 @@ Vamp.configure do |config|
|
|||||||
end
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
|
config.leader = "space"
|
||||||
config.plugin "plugins/greeting.rb"
|
config.plugin "plugins/greeting.rb"
|
||||||
config.bind "g", "greet"
|
config.bind "g", "greet"
|
||||||
|
config.bind "leader g", "greet"
|
||||||
config.bind "ctrl+r", config.core.redo
|
config.bind "ctrl+r", config.core.redo
|
||||||
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
|
||||||
config.bind "ctrl+f", config.core.search
|
config.bind "ctrl+f", config.core.search
|
||||||
|
|||||||
+614
-19
@@ -438,21 +438,28 @@ fn control_key(ch: char) -> Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn display_key(input: &Input) -> String {
|
fn display_key(input: &Input) -> String {
|
||||||
|
let mut display = String::new();
|
||||||
|
append_key(&mut display, input);
|
||||||
|
display
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_key(display: &mut String, input: &Input) {
|
||||||
|
use std::fmt::Write as _;
|
||||||
match input {
|
match input {
|
||||||
Input::Char('\t') => "Tab".into(),
|
Input::Char('\t') => display.push_str("Tab"),
|
||||||
Input::Char(' ') => "Space".into(),
|
Input::Char(' ') => display.push_str("Space"),
|
||||||
Input::Char(ch) => ch.to_string(),
|
Input::Char(ch) => display.push(*ch),
|
||||||
Input::Ctrl(ch) => format!("Ctrl+{ch}"),
|
Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"),
|
||||||
Input::Escape => "Esc".into(),
|
Input::Escape => display.push_str("Esc"),
|
||||||
Input::Enter => "Enter".into(),
|
Input::Enter => display.push_str("Enter"),
|
||||||
Input::Backspace => "Backspace".into(),
|
Input::Backspace => display.push_str("Backspace"),
|
||||||
Input::Delete => "Delete".into(),
|
Input::Delete => display.push_str("Delete"),
|
||||||
Input::Left => "Left".into(),
|
Input::Left => display.push_str("Left"),
|
||||||
Input::Right => "Right".into(),
|
Input::Right => display.push_str("Right"),
|
||||||
Input::Up => "Up".into(),
|
Input::Up => display.push_str("Up"),
|
||||||
Input::Down => "Down".into(),
|
Input::Down => display.push_str("Down"),
|
||||||
Input::Home => "Home".into(),
|
Input::Home => display.push_str("Home"),
|
||||||
Input::End => "End".into(),
|
Input::End => display.push_str("End"),
|
||||||
Input::Paste(_) => unreachable!("paste cannot be bound"),
|
Input::Paste(_) => unreachable!("paste cannot be bound"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,6 +678,10 @@ pub struct Editor {
|
|||||||
capabilities: Vec<String>,
|
capabilities: Vec<String>,
|
||||||
features: HashMap<String, Feature>,
|
features: HashMap<String, Feature>,
|
||||||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
||||||
|
leader: Input,
|
||||||
|
leader_bindings: HashMap<Mode, HashMap<Vec<Input>, Binding>>,
|
||||||
|
leader_suffix: Vec<Input>,
|
||||||
|
leader_display: String,
|
||||||
palette: Option<Palette>,
|
palette: Option<Palette>,
|
||||||
count: Option<usize>,
|
count: Option<usize>,
|
||||||
}
|
}
|
||||||
@@ -689,6 +700,10 @@ impl Editor {
|
|||||||
capabilities,
|
capabilities,
|
||||||
features: HashMap::new(),
|
features: HashMap::new(),
|
||||||
bindings: default_bindings(),
|
bindings: default_bindings(),
|
||||||
|
leader: Input::Char(' '),
|
||||||
|
leader_bindings: HashMap::new(),
|
||||||
|
leader_suffix: Vec::new(),
|
||||||
|
leader_display: String::new(),
|
||||||
palette: None,
|
palette: None,
|
||||||
count: None,
|
count: None,
|
||||||
};
|
};
|
||||||
@@ -709,6 +724,19 @@ impl Editor {
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.map_err(io::Error::other)?;
|
.map_err(io::Error::other)?;
|
||||||
|
for (mode, key, target) in [
|
||||||
|
("normal", "leader space", "core.palette"),
|
||||||
|
("visual", "leader space", "core.palette"),
|
||||||
|
("normal", "leader w", "w"),
|
||||||
|
("normal", "leader b n", "bnext"),
|
||||||
|
("normal", "leader b p", "bprev"),
|
||||||
|
("normal", "leader b l", "buffers"),
|
||||||
|
("normal", "leader b d", "bdelete"),
|
||||||
|
] {
|
||||||
|
editor
|
||||||
|
.bind(mode, key, Some(target.into()))
|
||||||
|
.map_err(io::Error::other)?;
|
||||||
|
}
|
||||||
Ok(editor)
|
Ok(editor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,6 +783,46 @@ impl Editor {
|
|||||||
pub fn count(&self) -> Option<usize> {
|
pub fn count(&self) -> Option<usize> {
|
||||||
self.count
|
self.count
|
||||||
}
|
}
|
||||||
|
/// Actual pending leader prefix, cached until the next input.
|
||||||
|
pub fn leader_pending(&self) -> Option<&str> {
|
||||||
|
(!self.leader_display.is_empty()).then_some(self.leader_display.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_leader(&mut self) {
|
||||||
|
self.leader_suffix.clear();
|
||||||
|
self.leader_display.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_leader(&mut self) {
|
||||||
|
if self.leader_pending().is_some() {
|
||||||
|
self.count = None;
|
||||||
|
self.clear_leader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the symbolic leader without moving its bindings.
|
||||||
|
pub fn set_leader(&mut self, key: &str) -> Result<(), String> {
|
||||||
|
let leader = parse_key(key)?;
|
||||||
|
if leader == Input::Escape || matches!(leader, Input::Char(ch) if ch.is_ascii_digit()) {
|
||||||
|
return Err("Leader cannot be Escape or an ASCII digit".into());
|
||||||
|
}
|
||||||
|
if leader != self.leader
|
||||||
|
&& [Mode::Normal, Mode::Visual].into_iter().any(|mode| {
|
||||||
|
self.bindings
|
||||||
|
.get(&mode)
|
||||||
|
.is_some_and(|keys| keys.contains_key(&leader))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Leader key already bound: {}",
|
||||||
|
display_key(&leader)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.cancel_leader();
|
||||||
|
self.leader = leader;
|
||||||
|
self.refresh_palette();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
/// Editable Ex/search prompt, without its `:` or `/` prefix.
|
/// Editable Ex/search prompt, without its `:` or `/` prefix.
|
||||||
pub fn command_line(&self) -> &str {
|
pub fn command_line(&self) -> &str {
|
||||||
&self.command_line
|
&self.command_line
|
||||||
@@ -867,6 +935,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn disable_plugin(&mut self, name: &str, reason: &str) {
|
pub fn disable_plugin(&mut self, name: &str, reason: &str) {
|
||||||
|
self.cancel_leader();
|
||||||
if self.features.get(name).is_some_and(|feature| feature.core) {
|
if self.features.get(name).is_some_and(|feature| feature.core) {
|
||||||
self.set_message("Core features cannot be disabled");
|
self.set_message("Core features cannot be disabled");
|
||||||
return;
|
return;
|
||||||
@@ -877,13 +946,18 @@ impl Editor {
|
|||||||
!matches!(binding, Binding::Command(command) if feature.commands.contains(command))
|
!matches!(binding, Binding::Command(command) if feature.commands.contains(command))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for bindings in self.leader_bindings.values_mut() {
|
||||||
|
bindings.retain(|_, binding| {
|
||||||
|
!matches!(binding, Binding::Command(command) if feature.commands.contains(command))
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.refresh_palette();
|
self.refresh_palette();
|
||||||
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
self.set_message(format!("Plugin {name} disabled: {reason}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overrides one mode's binding. `None` disables the key, including literal
|
/// Overrides one mode's single key or symbolic `leader` sequence. `None`
|
||||||
/// typing in Insert/Command mode. Invalid overrides leave the binding intact.
|
/// disables it. Invalid overrides leave existing bindings intact.
|
||||||
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
|
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
|
||||||
let mode = match mode {
|
let mode = match mode {
|
||||||
"normal" => Mode::Normal,
|
"normal" => Mode::Normal,
|
||||||
@@ -893,7 +967,35 @@ impl Editor {
|
|||||||
"visual" => Mode::Visual,
|
"visual" => Mode::Visual,
|
||||||
_ => return Err(format!("Unknown binding mode: {mode}")),
|
_ => return Err(format!("Unknown binding mode: {mode}")),
|
||||||
};
|
};
|
||||||
let key = parse_key(key)?;
|
let mut tokens = key.split_whitespace();
|
||||||
|
let suffix = if tokens
|
||||||
|
.next()
|
||||||
|
.is_some_and(|token| token.eq_ignore_ascii_case("leader"))
|
||||||
|
{
|
||||||
|
if !matches!(mode, Mode::Normal | Mode::Visual) {
|
||||||
|
return Err("Leader sequences can only be bound in Normal or Visual mode".into());
|
||||||
|
}
|
||||||
|
let suffix: Vec<_> = tokens.map(parse_key).collect::<Result<_, _>>()?;
|
||||||
|
if suffix.is_empty() {
|
||||||
|
return Err("Leader binding requires at least one suffix key".into());
|
||||||
|
}
|
||||||
|
if self.leader_bindings.get(&mode).is_some_and(|bindings| {
|
||||||
|
bindings.keys().any(|existing| {
|
||||||
|
*existing != suffix
|
||||||
|
&& (existing.starts_with(&suffix) || suffix.starts_with(existing))
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
return Err(format!("Ambiguous leader binding: {key:?}"));
|
||||||
|
}
|
||||||
|
Some(suffix)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let input = if suffix.is_none() {
|
||||||
|
Some(parse_key(key)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let binding = match target {
|
let binding = match target {
|
||||||
None => Binding::Disabled,
|
None => Binding::Disabled,
|
||||||
Some(target) if target.starts_with("core.") => Binding::Action(
|
Some(target) if target.starts_with("core.") => Binding::Action(
|
||||||
@@ -910,7 +1012,18 @@ impl Editor {
|
|||||||
Binding::Command(command)
|
Binding::Command(command)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.bindings.entry(mode).or_default().insert(key, binding);
|
self.cancel_leader();
|
||||||
|
if let Some(suffix) = suffix {
|
||||||
|
self.leader_bindings
|
||||||
|
.entry(mode)
|
||||||
|
.or_default()
|
||||||
|
.insert(suffix, binding);
|
||||||
|
} else {
|
||||||
|
self.bindings
|
||||||
|
.entry(mode)
|
||||||
|
.or_default()
|
||||||
|
.insert(input.expect("single-key binding"), binding);
|
||||||
|
}
|
||||||
self.refresh_palette();
|
self.refresh_palette();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -929,6 +1042,31 @@ impl Editor {
|
|||||||
})
|
})
|
||||||
.map(|(input, _)| display_key(input))
|
.map(|(input, _)| display_key(input))
|
||||||
.collect();
|
.collect();
|
||||||
|
if !self
|
||||||
|
.bindings
|
||||||
|
.get(&self.active.mode)
|
||||||
|
.is_some_and(|keys| keys.contains_key(&self.leader))
|
||||||
|
{
|
||||||
|
for (suffix, binding) in self
|
||||||
|
.leader_bindings
|
||||||
|
.get(&self.active.mode)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|keys| keys.iter())
|
||||||
|
{
|
||||||
|
if match (action, binding) {
|
||||||
|
(Some(action), Binding::Action(bound)) => action == *bound,
|
||||||
|
(None, Binding::Command(bound)) => command == bound,
|
||||||
|
_ => false,
|
||||||
|
} {
|
||||||
|
let mut display = display_key(&self.leader);
|
||||||
|
for input in suffix {
|
||||||
|
display.push(' ');
|
||||||
|
append_key(&mut display, input);
|
||||||
|
}
|
||||||
|
bindings.push(display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
bindings.sort();
|
bindings.sort();
|
||||||
bindings
|
bindings
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1180,7 @@ impl Editor {
|
|||||||
// Paste is literal input, never a configurable key or command.
|
// Paste is literal input, never a configurable key or command.
|
||||||
if let Input::Paste(text) = input {
|
if let Input::Paste(text) = input {
|
||||||
self.count = None;
|
self.count = None;
|
||||||
|
self.clear_leader();
|
||||||
if self.active.mode == Mode::Insert {
|
if self.active.mode == Mode::Insert {
|
||||||
self.paste(&text);
|
self.paste(&text);
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1191,13 @@ impl Editor {
|
|||||||
Input::Ctrl(ch) => control_key(ch),
|
Input::Ctrl(ch) => control_key(ch),
|
||||||
input => input,
|
input => input,
|
||||||
};
|
};
|
||||||
|
if self.leader_pending().is_some() {
|
||||||
|
if input == Input::Escape {
|
||||||
|
self.cancel_leader();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return self.handle_leader(input);
|
||||||
|
}
|
||||||
if input == Input::Escape && self.count.take().is_some() {
|
if input == Input::Escape && self.count.take().is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1092,6 +1238,10 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
if matches!(self.active.mode, Mode::Normal | Mode::Visual) && input == self.leader {
|
||||||
|
append_key(&mut self.leader_display, &input);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
self.count = None;
|
self.count = None;
|
||||||
match (self.active.mode, input) {
|
match (self.active.mode, input) {
|
||||||
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
|
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
|
||||||
@@ -1108,8 +1258,38 @@ impl Editor {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_leader(&mut self, input: Input) -> Option<Invocation> {
|
||||||
|
self.leader_display.push(' ');
|
||||||
|
append_key(&mut self.leader_display, &input);
|
||||||
|
self.leader_suffix.push(input);
|
||||||
|
let bindings = self.leader_bindings.get(&self.active.mode);
|
||||||
|
match bindings.and_then(|keys| keys.get(self.leader_suffix.as_slice())) {
|
||||||
|
Some(Binding::Action(action)) => return self.act(*action),
|
||||||
|
Some(Binding::Command(command)) => {
|
||||||
|
let command = command.clone();
|
||||||
|
return self.execute(&command);
|
||||||
|
}
|
||||||
|
Some(Binding::Disabled) => {
|
||||||
|
self.cancel_leader();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
// ponytail: scan finite leader routes; use a trie if catalogs grow large.
|
||||||
|
if bindings.is_some_and(|keys| {
|
||||||
|
keys.keys()
|
||||||
|
.any(|suffix| suffix.starts_with(&self.leader_suffix))
|
||||||
|
}) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.set_message(format!("Unknown leader sequence: {}", self.leader_display));
|
||||||
|
self.cancel_leader();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn act(&mut self, action: Action) -> Option<Invocation> {
|
fn act(&mut self, action: Action) -> Option<Invocation> {
|
||||||
let count = self.count.take().unwrap_or(1);
|
let count = self.count.take().unwrap_or(1);
|
||||||
|
self.clear_leader();
|
||||||
match action {
|
match action {
|
||||||
Action::Palette => {
|
Action::Palette => {
|
||||||
self.palette = Some(Palette {
|
self.palette = Some(Palette {
|
||||||
@@ -1322,6 +1502,7 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
self.command_line.clear();
|
self.command_line.clear();
|
||||||
self.count = None;
|
self.count = None;
|
||||||
|
self.clear_leader();
|
||||||
self.palette = None;
|
self.palette = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,6 +1668,7 @@ impl Editor {
|
|||||||
|
|
||||||
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
|
||||||
self.count = None;
|
self.count = None;
|
||||||
|
self.clear_leader();
|
||||||
self.palette = None;
|
self.palette = None;
|
||||||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||||||
let (name, args) = command
|
let (name, args) = command
|
||||||
@@ -1592,6 +1774,7 @@ impl Editor {
|
|||||||
.then(|| self.snapshot());
|
.then(|| self.snapshot());
|
||||||
if before.is_some() {
|
if before.is_some() {
|
||||||
self.count = None;
|
self.count = None;
|
||||||
|
self.clear_leader();
|
||||||
self.clear_selection();
|
self.clear_selection();
|
||||||
self.collapse_primary();
|
self.collapse_primary();
|
||||||
// Plugin inserts use one primary insertion point, not the selection set.
|
// Plugin inserts use one primary insertion point, not the selection set.
|
||||||
@@ -2796,6 +2979,415 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_leader_routes_save_switch_list_delete_and_open_palette() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.path("first");
|
||||||
|
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||||
|
type_text(&mut editor, "ihello");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
type_text(&mut editor, " w");
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
|
||||||
|
assert!(!editor.dirty());
|
||||||
|
editor.execute(&format!("edit {}", directory.path("second").display()));
|
||||||
|
type_text(&mut editor, " b");
|
||||||
|
assert_eq!(editor.leader_pending(), Some("Space b"));
|
||||||
|
editor.handle(Input::Char('n'));
|
||||||
|
assert_eq!(editor.buffer_id(), 1);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
type_text(&mut editor, " bp");
|
||||||
|
assert_eq!(editor.buffer_id(), 2);
|
||||||
|
type_text(&mut editor, " bl");
|
||||||
|
assert!(editor.message().contains("first"));
|
||||||
|
assert!(editor.message().contains("second"));
|
||||||
|
type_text(&mut editor, " bd");
|
||||||
|
assert!(!editor.has_buffer(2));
|
||||||
|
assert_eq!(editor.buffer_id(), 1);
|
||||||
|
for mode in [Mode::Normal, Mode::Visual] {
|
||||||
|
if mode == Mode::Visual {
|
||||||
|
editor.handle(Input::Char('v'));
|
||||||
|
}
|
||||||
|
editor.handle(Input::Char(' '));
|
||||||
|
assert_eq!(editor.leader_pending(), Some("Space"));
|
||||||
|
editor.handle(Input::Char(' '));
|
||||||
|
assert!(editor.palette().is_some());
|
||||||
|
assert_eq!(editor.mode(), mode);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_leader_dispatches_plugin_once_with_count_and_multiple_carets() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("aa\nbb".into())])
|
||||||
|
.unwrap();
|
||||||
|
set_carets(&mut editor, &[(0, 0), (1, 0)], 1);
|
||||||
|
editor.register(plugin("fixture", &["greet"])).unwrap();
|
||||||
|
editor
|
||||||
|
.bind("normal", "LeAdEr g ctrl+r 7 shift+i", Some("greet".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, "8 g");
|
||||||
|
editor.handle(Input::Ctrl('R'));
|
||||||
|
assert_eq!(editor.leader_pending(), Some("Space g Ctrl+r"));
|
||||||
|
editor.handle(Input::Char('7'));
|
||||||
|
assert_eq!(editor.count(), Some(8));
|
||||||
|
assert_eq!(editor.leader_pending(), Some("Space g Ctrl+r 7"));
|
||||||
|
assert_eq!(
|
||||||
|
editor.handle(Input::Char('I')),
|
||||||
|
Some(Invocation {
|
||||||
|
plugin: "fixture".into(),
|
||||||
|
command: "greet".into(),
|
||||||
|
args: String::new(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(editor.selection_count(), 2);
|
||||||
|
assert_eq!(editor.context().text, "aa\nbb");
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
assert_eq!(
|
||||||
|
palette_entry(&editor, "greet").bindings,
|
||||||
|
vec!["Space g Ctrl+r 7 I"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_leader_is_symbolic_and_ordinary_trigger_bindings_preempt_by_mode() {
|
||||||
|
let mut editor = editor();
|
||||||
|
type_text(&mut editor, "3 b");
|
||||||
|
editor.set_leader("comma").unwrap();
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
assert_eq!(palette_entry(&editor, "bnext").bindings, vec![", b n"]);
|
||||||
|
editor
|
||||||
|
.bind("normal", ",", Some("core.command".into()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(palette_entry(&editor, "bnext").bindings.is_empty());
|
||||||
|
editor.set_leader(",").unwrap();
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char(','));
|
||||||
|
assert_eq!(editor.mode(), Mode::Command);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('v'));
|
||||||
|
type_text(&mut editor, ", ");
|
||||||
|
assert!(editor.palette().is_some());
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.bind("normal", ",", None).unwrap();
|
||||||
|
type_text(&mut editor, "4,");
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor.set_leader("\\").unwrap();
|
||||||
|
type_text(&mut editor, "\\b");
|
||||||
|
assert_eq!(editor.leader_pending(), Some("\\ b"));
|
||||||
|
editor.handle(Input::Char('n'));
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
assert_eq!(palette_entry(&editor, "bnext").bindings, vec!["\\ b n"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leader_prefix_conflicts_and_invalid_replacements_are_atomic() {
|
||||||
|
let mut editor = editor();
|
||||||
|
assert!(editor.bind("normal", "leader b", None).is_err());
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader z", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(editor.bind("normal", "leader z n", None).is_err());
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader z", Some("missing".into()))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("a\nb\nc".into())])
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, " z");
|
||||||
|
assert_eq!(editor.cursor().row, 1);
|
||||||
|
editor.bind("normal", "leader z", None).unwrap();
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader z n", Some("core.down".into()))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
type_text(&mut editor, "2 z");
|
||||||
|
assert_eq!(editor.cursor().row, 1);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
editor.bind("normal", "leader g n", None).unwrap();
|
||||||
|
assert!(editor.bind("normal", "leader g", None).is_err());
|
||||||
|
for key in ["leader", "leader ctrl+", "leader b ctrl++r", "leader <Esc>"] {
|
||||||
|
assert!(editor.bind("normal", key, None).is_err(), "{key}");
|
||||||
|
}
|
||||||
|
editor
|
||||||
|
.bind("normal", " LEADER\tz ", Some("core.up".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, " z");
|
||||||
|
assert_eq!(editor.cursor().row, 0);
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.leader_bindings
|
||||||
|
.get(&Mode::Normal)
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&vec![Input::Char('b'), Input::Char('n')])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leader_validation_preserves_choice_and_reuses_action_mode_eligibility() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.register(plugin("fixture", &["greet"])).unwrap();
|
||||||
|
for mode in ["insert", "command", "search", "unknown"] {
|
||||||
|
assert!(editor.bind(mode, "leader z", None).is_err(), "{mode}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.bind("visual", "leader z", Some("core.insert".into()))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.bind("visual", "leader z", Some("greet".into()))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
editor
|
||||||
|
.bind("visual", "leader z", Some("core.right".into()))
|
||||||
|
.unwrap();
|
||||||
|
editor.bind("normal", "comma", None).unwrap();
|
||||||
|
editor.bind("visual", "\\", None).unwrap();
|
||||||
|
for key in [
|
||||||
|
"", "esc", "ESC", "0", "9", "i", "ctrl+p", ",", "\\", "leader b", "alt+j",
|
||||||
|
] {
|
||||||
|
assert!(editor.set_leader(key).is_err(), "{key}");
|
||||||
|
assert_eq!(editor.leader, Input::Char(' '));
|
||||||
|
}
|
||||||
|
editor.bind("insert", "界", None).unwrap();
|
||||||
|
editor.set_leader("界").unwrap();
|
||||||
|
editor.handle(Input::Char('界'));
|
||||||
|
assert_eq!(editor.leader_pending(), Some("界"));
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.set_leader("space").unwrap();
|
||||||
|
editor.bind("normal", "space", None).unwrap();
|
||||||
|
editor.set_leader("SPACE").unwrap();
|
||||||
|
assert_eq!(editor.leader, Input::Char(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leader_escape_unknown_and_paste_cancel_count_without_changing_selections() {
|
||||||
|
for visual in [false, true] {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("abcd\nabcd".into())])
|
||||||
|
.unwrap();
|
||||||
|
set_carets(&mut editor, &[(0, 1), (1, 1)], 1);
|
||||||
|
if visual {
|
||||||
|
editor
|
||||||
|
.bind("visual", "leader b n", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, "vl");
|
||||||
|
}
|
||||||
|
let mode = editor.mode();
|
||||||
|
let carets = editor.carets();
|
||||||
|
let selections = editor.selections();
|
||||||
|
for prefix in [" b", "3 b"] {
|
||||||
|
type_text(&mut editor, prefix);
|
||||||
|
assert_eq!(editor.leader_pending(), Some("Space b"));
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.mode(), mode);
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
assert_eq!(editor.selections(), selections);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
}
|
||||||
|
type_text(&mut editor, "4 b");
|
||||||
|
editor.handle(Input::Char('x'));
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.message()
|
||||||
|
.contains("Unknown leader sequence: Space b x")
|
||||||
|
);
|
||||||
|
assert_eq!(editor.context().text, "abcd\nabcd");
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
assert_eq!(editor.selections(), selections);
|
||||||
|
assert_eq!(editor.mode(), mode);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
type_text(&mut editor, "5 b");
|
||||||
|
editor.handle(Input::Paste("n".into()));
|
||||||
|
assert_eq!(editor.context().text, "abcd\nabcd");
|
||||||
|
assert_eq!(editor.carets(), carets);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
assert_eq!(editor.selection_count(), if visual { 2 } else { 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leader_digit_suffix_uses_pending_count_for_remapped_motion_not_new_count() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace(["abcd"; 12].join("\n"))])
|
||||||
|
.unwrap();
|
||||||
|
set_carets(&mut editor, &[(0, 0), (1, 0)], 1);
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader g 0 2", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, "3 g0");
|
||||||
|
assert_eq!(editor.count(), Some(3));
|
||||||
|
assert_eq!(editor.leader_pending(), Some("Space g 0"));
|
||||||
|
editor.handle(Input::Char('2'));
|
||||||
|
assert_eq!(
|
||||||
|
editor.carets(),
|
||||||
|
vec![Cursor { row: 3, column: 0 }, Cursor { row: 4, column: 0 }]
|
||||||
|
);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor
|
||||||
|
.bind("visual", "leader 7", Some("core.right".into()))
|
||||||
|
.unwrap();
|
||||||
|
editor.handle(Input::Char('v'));
|
||||||
|
type_text(&mut editor, "2 7");
|
||||||
|
assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 });
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
assert_eq!(editor.selection_count(), 2);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leader_keys_are_literal_in_insert_prompts_and_palette_queries() {
|
||||||
|
for (key, ch) in [("space", ' '), ("comma", ',')] {
|
||||||
|
for mode_key in ['i', ':', '/'] {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.set_leader(key).unwrap();
|
||||||
|
editor.handle(Input::Char(mode_key));
|
||||||
|
let text = format!("{ch}b n{ch}");
|
||||||
|
type_text(&mut editor, &text);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
if mode_key == 'i' {
|
||||||
|
assert_eq!(editor.context().text, text);
|
||||||
|
assert_eq!(editor.mode(), Mode::Insert);
|
||||||
|
} else {
|
||||||
|
assert_eq!(editor.command_line(), text);
|
||||||
|
assert_eq!(
|
||||||
|
editor.mode(),
|
||||||
|
if mode_key == ':' {
|
||||||
|
Mode::Command
|
||||||
|
} else {
|
||||||
|
Mode::Search
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mode = editor.mode();
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
type_text(&mut editor, "core word next ");
|
||||||
|
assert_eq!(editor.palette().unwrap().query, "core word next ");
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert_eq!(editor.mode(), mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leader_routes_retire_with_plugins_and_binding_changes_cancel_pending() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.register(plugin("fixture", &["greet"])).unwrap();
|
||||||
|
editor.bind("normal", "g", Some("greet".into())).unwrap();
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader g n", Some("greet".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, "3 g");
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader g n", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
type_text(&mut editor, "2 g");
|
||||||
|
editor.bind("normal", "z", None).unwrap();
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader g n", Some("greet".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, "4 g");
|
||||||
|
editor.disable_plugin("fixture", "retired");
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
assert_eq!(editor.handle(Input::Char('g')), None);
|
||||||
|
type_text(&mut editor, " g");
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert!(editor.message().contains("Unknown leader sequence"));
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader g n", Some("greet".into()))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
assert!(
|
||||||
|
!editor
|
||||||
|
.palette()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.name == "greet")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_leader_never_survives_external_commands_or_buffer_transitions() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.apply(vec![Effect::Replace("abcd".into())]).unwrap();
|
||||||
|
editor.execute(&format!("edit {}", directory.path("other").display()));
|
||||||
|
editor.set_leader(",").unwrap();
|
||||||
|
for command in [
|
||||||
|
"buffer 1",
|
||||||
|
"buffer 1",
|
||||||
|
"bnext",
|
||||||
|
"bprev",
|
||||||
|
"buffers",
|
||||||
|
"buffer 999",
|
||||||
|
] {
|
||||||
|
type_text(&mut editor, "3,b");
|
||||||
|
assert_eq!(editor.leader_pending(), Some(", b"));
|
||||||
|
editor.execute(command);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
editor.handle(Input::Char(','));
|
||||||
|
assert_eq!(editor.leader_pending(), Some(","));
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
}
|
||||||
|
editor.execute("buffer 1");
|
||||||
|
type_text(&mut editor, "vl");
|
||||||
|
let selections = editor.selections();
|
||||||
|
type_text(&mut editor, "2,");
|
||||||
|
editor.execute("buffer 2");
|
||||||
|
editor.execute("buffer 1");
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
assert_eq!(editor.selections(), selections);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
assert_eq!(editor.count(), None);
|
||||||
|
editor.handle(Input::Char(','));
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("replacement".into())])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
editor
|
||||||
|
.bind("normal", "leader i", Some("core.insert".into()))
|
||||||
|
.unwrap();
|
||||||
|
type_text(&mut editor, ",i");
|
||||||
|
assert_eq!(editor.mode(), Mode::Insert);
|
||||||
|
assert_eq!(editor.leader_pending(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() {
|
fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
@@ -3331,7 +3923,10 @@ mod tests {
|
|||||||
assert_eq!(entry.owner, "core");
|
assert_eq!(entry.owner, "core");
|
||||||
assert!(entry.enabled);
|
assert!(entry.enabled);
|
||||||
}
|
}
|
||||||
assert_eq!(palette_entry(&editor, "bnext").bindings, vec!["z"]);
|
assert_eq!(
|
||||||
|
palette_entry(&editor, "bnext").bindings,
|
||||||
|
vec!["Space b n", "z"]
|
||||||
|
);
|
||||||
editor.disable_plugin("fixture", "failure");
|
editor.disable_plugin("fixture", "failure");
|
||||||
assert!(
|
assert!(
|
||||||
!editor
|
!editor
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Usage: vamp [--config PATH | --no-config] [FILE]
|
|||||||
|
|
||||||
Default keys (configurable with config.bind):
|
Default keys (configurable with config.bind):
|
||||||
All modes: Ctrl+p command palette; type to filter, Up/Down select, Enter accept, Esc cancel
|
All modes: Ctrl+p command palette; type to filter, Up/Down select, Enter accept, Esc cancel
|
||||||
|
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
|
||||||
|
Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer
|
||||||
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
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
|
h/j/k/l move, 0/$ line ends, x delete, J join lines
|
||||||
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||||
|
|||||||
+53
-2
@@ -13,10 +13,21 @@ const MAX_FRAME: usize = 4 * 1024 * 1024;
|
|||||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
|
const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub plugins: Vec<PathBuf>,
|
pub plugins: Vec<PathBuf>,
|
||||||
pub bindings: Vec<Binding>,
|
pub bindings: Vec<Binding>,
|
||||||
|
pub leader: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
plugins: Vec::new(),
|
||||||
|
bindings: Vec::new(),
|
||||||
|
leader: "space".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Relative plugin paths are resolved against the configuration file's directory.
|
/// Relative plugin paths are resolved against the configuration file's directory.
|
||||||
@@ -27,7 +38,10 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
let response = host.receive(deadline)?;
|
let response = host.receive(deadline)?;
|
||||||
match response {
|
match response {
|
||||||
Response::Config {
|
Response::Config {
|
||||||
plugins, bindings, ..
|
plugins,
|
||||||
|
bindings,
|
||||||
|
leader,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
let directory = path.parent().expect("absolute file has a parent");
|
let directory = path.parent().expect("absolute file has a parent");
|
||||||
@@ -38,6 +52,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
config.plugins.push(directory.join(plugin));
|
config.plugins.push(directory.join(plugin));
|
||||||
}
|
}
|
||||||
config.bindings = bindings;
|
config.bindings = bindings;
|
||||||
|
config.leader = leader;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
Response::Error {
|
Response::Error {
|
||||||
@@ -182,6 +197,7 @@ enum Response {
|
|||||||
version: u8,
|
version: u8,
|
||||||
plugins: Vec<String>,
|
plugins: Vec<String>,
|
||||||
bindings: Vec<Binding>,
|
bindings: Vec<Binding>,
|
||||||
|
leader: String,
|
||||||
},
|
},
|
||||||
#[serde(rename = "result")]
|
#[serde(rename = "result")]
|
||||||
Result {
|
Result {
|
||||||
@@ -550,6 +566,41 @@ end
|
|||||||
assert!(RubyPlugin::start(&invalid).is_err());
|
assert!(RubyPlugin::start(&invalid).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_leader_defaults_custom_keys_validation_and_required_wire_field() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let defaults = Config::default();
|
||||||
|
assert_eq!(defaults.leader, "space");
|
||||||
|
assert!(defaults.plugins.is_empty() && defaults.bindings.is_empty());
|
||||||
|
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
|
||||||
|
assert_eq!(load_config(&path).unwrap().leader, "space");
|
||||||
|
for (source, key) in [
|
||||||
|
(r#"Vamp.configure { |config| config.leader = "," }"#, ","),
|
||||||
|
(r#"Vamp.configure { |config| config.leader = "\\" }"#, "\\"),
|
||||||
|
] {
|
||||||
|
let path = fixture.file("config.rb", source);
|
||||||
|
assert_eq!(load_config(&path).unwrap().leader, key);
|
||||||
|
}
|
||||||
|
for (value, error) in [
|
||||||
|
("7", "leader key must be a String"),
|
||||||
|
("nil", "leader key must be a String"),
|
||||||
|
(r#""""#, "leader key must not be empty"),
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.leader = {value} }}"),
|
||||||
|
);
|
||||||
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
|
}
|
||||||
|
for frame in [
|
||||||
|
r#"{"type":"config","version":1,"plugins":[],"bindings":[]}"#,
|
||||||
|
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null}"#,
|
||||||
|
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7}"#,
|
||||||
|
] {
|
||||||
|
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crash_hang_callback_failure_and_fresh_host_recovery() {
|
fn crash_hang_callback_failure_and_fresh_host_recovery() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
+9
-1
@@ -50,6 +50,7 @@ module Vamp
|
|||||||
def initialize
|
def initialize
|
||||||
@plugins = []
|
@plugins = []
|
||||||
@bindings = []
|
@bindings = []
|
||||||
|
@leader = "space"
|
||||||
end
|
end
|
||||||
|
|
||||||
def plugin(path)
|
def plugin(path)
|
||||||
@@ -59,6 +60,12 @@ module Vamp
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def leader=(key)
|
||||||
|
key = Vamp.string!(key, "leader key")
|
||||||
|
raise ArgumentError, "leader key must not be empty" if key.empty?
|
||||||
|
@leader = key
|
||||||
|
end
|
||||||
|
|
||||||
def core
|
def core
|
||||||
Core
|
Core
|
||||||
end
|
end
|
||||||
@@ -89,7 +96,8 @@ module Vamp
|
|||||||
end
|
end
|
||||||
|
|
||||||
def response
|
def response
|
||||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "bindings" => @bindings }
|
{ "version" => 1, "type" => "config", "plugins" => @plugins,
|
||||||
|
"bindings" => @bindings, "leader" => @leader }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+207
-1
@@ -33,6 +33,9 @@ impl EditorSession {
|
|||||||
}),
|
}),
|
||||||
None => Config::default(),
|
None => Config::default(),
|
||||||
};
|
};
|
||||||
|
if let Err(error) = editor.set_leader(&config.leader) {
|
||||||
|
notices.push(format!("leader {:?}: {error}", config.leader));
|
||||||
|
}
|
||||||
let mut plugins = BTreeMap::new();
|
let mut plugins = BTreeMap::new();
|
||||||
for path in config.plugins {
|
for path in config.plugins {
|
||||||
match RubyPlugin::start(&path) {
|
match RubyPlugin::start(&path) {
|
||||||
@@ -155,6 +158,195 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_config_uses_space_leader_for_palette_and_write() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let buffer = fixture.file("buffer.txt", "base");
|
||||||
|
let mut session =
|
||||||
|
EditorSession::open(Some(buffer.clone()), vec!["text".into()], None).unwrap();
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
assert_eq!(session.editor().leader_pending(), Some("Space"));
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
assert!(session.editor().palette().is_some());
|
||||||
|
assert!(session.editor().leader_pending().is_none());
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.handle(Input::Char('i'));
|
||||||
|
session.handle(Input::Char('!'));
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
session.handle(Input::Char('w'));
|
||||||
|
assert_eq!(fs::read_to_string(buffer).unwrap(), "!base");
|
||||||
|
assert!(!session.editor().dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ruby_leader_sequences_share_one_config_and_plugin_host_across_buffers() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.file("buffer.txt", "alpha");
|
||||||
|
let second = fixture.file("second.txt", "beta");
|
||||||
|
fixture.file(
|
||||||
|
"shared.rb",
|
||||||
|
r##"
|
||||||
|
Vamp.plugin "shared", requires: ["text"]
|
||||||
|
calls = 0
|
||||||
|
Vamp.command("stamp") do |editor, _|
|
||||||
|
calls += 1
|
||||||
|
editor.insert "#{calls}:#{File.basename(editor.path)}:"
|
||||||
|
end
|
||||||
|
Vamp.command("fail") { |_, _| raise "shared host failure" }
|
||||||
|
"##,
|
||||||
|
);
|
||||||
|
let mut session = fixture.open(
|
||||||
|
r#"
|
||||||
|
File.open("loads.txt", "a") { |file| file.puts "loaded" }
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.leader = ","
|
||||||
|
config.plugin "shared.rb"
|
||||||
|
config.bind "leader z n", "stamp"
|
||||||
|
config.bind "leader z f", "fail"
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
session.handle(Input::Char(','));
|
||||||
|
session.handle(Input::Char('z'));
|
||||||
|
assert_eq!(session.editor().leader_pending(), Some(", z"));
|
||||||
|
assert_eq!(session.editor().lines(), &["alpha"]);
|
||||||
|
session.handle(Input::Char('n'));
|
||||||
|
assert_eq!(session.editor().lines(), &["1:buffer.txt:alpha"]);
|
||||||
|
assert!(session.editor().leader_pending().is_none());
|
||||||
|
session.handle(Input::Char(','));
|
||||||
|
session.handle(Input::Char('z'));
|
||||||
|
session.execute(&format!("edit {}", second.display()));
|
||||||
|
assert!(session.editor().leader_pending().is_none());
|
||||||
|
for key in [',', 'z', 'n'] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
assert_eq!(session.editor().lines(), &["2:second.txt:beta"]);
|
||||||
|
for key in [',', 'b', 'p'] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
assert_eq!(session.editor().buffer_id(), 1);
|
||||||
|
session.handle(Input::Home);
|
||||||
|
for key in [',', 'z', 'n'] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().lines(),
|
||||||
|
&["3:buffer.txt:1:buffer.txt:alpha"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.0.join("loads.txt")).unwrap(),
|
||||||
|
"loaded\n"
|
||||||
|
);
|
||||||
|
for key in [',', 'z', 'f'] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains("Plugin shared disabled:")
|
||||||
|
);
|
||||||
|
for key in [',', 'z', 'n'] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().lines(),
|
||||||
|
&["3:buffer.txt:1:buffer.txt:alpha"]
|
||||||
|
);
|
||||||
|
session.handle(Input::Ctrl('p'));
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.editor()
|
||||||
|
.palette()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.owner == "shared")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ruby_disabled_leader_suffix_and_cancellation_keep_counts_and_selections_safe() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.file("buffer.txt", "abcdef");
|
||||||
|
fixture.healthy();
|
||||||
|
let mut session = fixture.open(
|
||||||
|
r#"
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.leader = ","
|
||||||
|
config.plugin "healthy.rb"
|
||||||
|
config.bind "leader z n", "healthy"
|
||||||
|
config.bind "leader z x", nil
|
||||||
|
config.bind "leader z h", config.core.left, mode: config.modes.visual
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
for last in ['x', 'd'] {
|
||||||
|
for key in ['3', ',', 'z', last] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
assert!(session.editor().leader_pending().is_none());
|
||||||
|
assert_eq!(session.editor().lines(), &["abcdef"]);
|
||||||
|
session.handle(Input::Char('l'));
|
||||||
|
}
|
||||||
|
assert_eq!(session.editor().cursor().column, 2);
|
||||||
|
for key in ['3', ',', 'z'] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
session.handle(Input::Paste("n:q!\n".into()));
|
||||||
|
assert!(session.editor().leader_pending().is_none());
|
||||||
|
session.handle(Input::Char('l'));
|
||||||
|
assert_eq!(session.editor().cursor().column, 3);
|
||||||
|
assert_eq!(session.editor().lines(), &["abcdef"]);
|
||||||
|
session.handle(Input::Char('v'));
|
||||||
|
let selected = session.editor().selections();
|
||||||
|
for key in ['3', ',', 'z'] {
|
||||||
|
session.handle(Input::Char(key));
|
||||||
|
}
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
|
||||||
|
assert_eq!(session.editor().selections(), selected);
|
||||||
|
assert!(session.editor().leader_pending().is_none());
|
||||||
|
session.handle(Input::Char('h'));
|
||||||
|
assert_eq!(session.editor().cursor().column, 2);
|
||||||
|
assert_eq!(session.editor().lines(), &["abcdef"]);
|
||||||
|
assert!(!session.editor().should_quit());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_backend_leader_retains_space_and_installs_valid_config_bindings() {
|
||||||
|
for key in ["not-a-key", "esc", "7", "i"] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.file("buffer.txt", "base");
|
||||||
|
fixture.healthy();
|
||||||
|
let mut session = fixture.open(&format!(
|
||||||
|
r#"
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.leader = "{key}"
|
||||||
|
config.plugin "healthy.rb"
|
||||||
|
config.bind "ctrl+f", config.core.right
|
||||||
|
config.bind "leader z n", "healthy"
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.message()
|
||||||
|
.contains(&format!("leader {key:?}:"))
|
||||||
|
);
|
||||||
|
assert!(!session.editor().message().contains("config disabled"));
|
||||||
|
session.handle(Input::Ctrl('f'));
|
||||||
|
assert_eq!(session.editor().cursor().column, 1);
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
assert_eq!(session.editor().leader_pending(), Some("Space"));
|
||||||
|
session.handle(Input::Char('z'));
|
||||||
|
session.handle(Input::Char('n'));
|
||||||
|
assert_eq!(session.editor().lines(), &["healthy "]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() {
|
fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -546,6 +738,8 @@ end
|
|||||||
session.handle(Input::Char(':'));
|
session.handle(Input::Char(':'));
|
||||||
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
|
||||||
session.handle(Input::Char(' '));
|
session.handle(Input::Char(' '));
|
||||||
|
assert_eq!(session.editor().mode(), crate::editor::Mode::Command);
|
||||||
|
assert!(session.editor().leader_pending().is_none());
|
||||||
for ch in "w界".chars() {
|
for ch in "w界".chars() {
|
||||||
session.handle(Input::Char(ch));
|
session.handle(Input::Char(ch));
|
||||||
}
|
}
|
||||||
@@ -765,6 +959,9 @@ end
|
|||||||
for (expression, error) in [
|
for (expression, error) in [
|
||||||
("config.core.reod", "NoMethodError"),
|
("config.core.reod", "NoMethodError"),
|
||||||
("config.modes.insret", "NoMethodError"),
|
("config.modes.insret", "NoMethodError"),
|
||||||
|
("config.leader = 7", "leader key must be a String"),
|
||||||
|
("config.leader = nil", "leader key must be a String"),
|
||||||
|
(r#"config.leader = """#, "leader key must not be empty"),
|
||||||
(r#"config.bind "n", "core.insert""#, "TypeError"),
|
(r#"config.bind "n", "core.insert""#, "TypeError"),
|
||||||
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
|
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
|
||||||
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
|
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
|
||||||
@@ -784,7 +981,7 @@ end
|
|||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let mut session = fixture.open(&format!(
|
let mut session = fixture.open(&format!(
|
||||||
"Vamp.configure do |config|\n config.bind \"i\", nil\n {expression}\nend"
|
"Vamp.configure do |config|\n config.leader = \",\"\n config.bind \"i\", nil\n {expression}\nend"
|
||||||
));
|
));
|
||||||
assert!(session.editor().message().contains(error), "{expression}");
|
assert!(session.editor().message().contains(error), "{expression}");
|
||||||
session.handle(Input::Char('i'));
|
session.handle(Input::Char('i'));
|
||||||
@@ -794,6 +991,15 @@ end
|
|||||||
assert_eq!(session.editor().lines(), &["界"], "{expression}");
|
assert_eq!(session.editor().lines(), &["界"], "{expression}");
|
||||||
session.handle(Input::Char('u'));
|
session.handle(Input::Char('u'));
|
||||||
assert_eq!(session.editor().lines(), &[String::new()], "{expression}");
|
assert_eq!(session.editor().lines(), &[String::new()], "{expression}");
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().leader_pending(),
|
||||||
|
Some("Space"),
|
||||||
|
"{expression}"
|
||||||
|
);
|
||||||
|
session.handle(Input::Char(' '));
|
||||||
|
assert!(session.editor().palette().is_some(), "{expression}");
|
||||||
|
session.handle(Input::Escape);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
@@ -341,6 +341,9 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
|||||||
.map(|c| if c.is_control() { '�' } else { c })
|
.map(|c| if c.is_control() { '�' } else { c })
|
||||||
.collect();
|
.collect();
|
||||||
let mut status = format!(" {}", editor.mode());
|
let mut status = format!(" {}", editor.mode());
|
||||||
|
if let Some(sequence) = editor.leader_pending() {
|
||||||
|
write!(status, " leader: {sequence}").unwrap();
|
||||||
|
}
|
||||||
if editor.buffer_count() > 1 {
|
if editor.buffer_count() > 1 {
|
||||||
write!(
|
write!(
|
||||||
status,
|
status,
|
||||||
@@ -545,6 +548,54 @@ mod tests {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_leader_is_visible_before_long_paths_and_unknown_keys_do_not_edit() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.file(&"long-path-".repeat(12), "keep");
|
||||||
|
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
|
||||||
|
let (mut top, mut left) = (0, 0);
|
||||||
|
editor.handle(Input::Char(' '));
|
||||||
|
editor.handle(Input::Char('b'));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("leader: Space b"));
|
||||||
|
editor.handle(Input::Char('x'));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!screen_text(&terminal).contains("leader:"));
|
||||||
|
assert_eq!(editor.lines(), &["keep"]);
|
||||||
|
assert_eq!(editor.cursor(), crate::editor::Cursor::default());
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn palette_advertises_effective_leader_sequences_after_remapping() {
|
||||||
|
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||||
|
editor.set_leader(",").unwrap();
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
for ch in "bnext".chars() {
|
||||||
|
editor.handle(Input::Char(ch));
|
||||||
|
}
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
|
||||||
|
let (mut top, mut left) = (0, 0);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains(", b n"));
|
||||||
|
assert!(!screen_text(&terminal).contains("Space b n"));
|
||||||
|
editor
|
||||||
|
.bind("normal", ",", Some("core.command".into()))
|
||||||
|
.unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
|
.unwrap();
|
||||||
|
assert!(screen_text(&terminal).contains("unbound"));
|
||||||
|
assert!(!screen_text(&terminal).contains(", b n"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() {
|
fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user