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
+614 -19
View File
@@ -438,21 +438,28 @@ fn control_key(ch: char) -> Input {
}
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 {
Input::Char('\t') => "Tab".into(),
Input::Char(' ') => "Space".into(),
Input::Char(ch) => ch.to_string(),
Input::Ctrl(ch) => format!("Ctrl+{ch}"),
Input::Escape => "Esc".into(),
Input::Enter => "Enter".into(),
Input::Backspace => "Backspace".into(),
Input::Delete => "Delete".into(),
Input::Left => "Left".into(),
Input::Right => "Right".into(),
Input::Up => "Up".into(),
Input::Down => "Down".into(),
Input::Home => "Home".into(),
Input::End => "End".into(),
Input::Char('\t') => display.push_str("Tab"),
Input::Char(' ') => display.push_str("Space"),
Input::Char(ch) => display.push(*ch),
Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"),
Input::Escape => display.push_str("Esc"),
Input::Enter => display.push_str("Enter"),
Input::Backspace => display.push_str("Backspace"),
Input::Delete => display.push_str("Delete"),
Input::Left => display.push_str("Left"),
Input::Right => display.push_str("Right"),
Input::Up => display.push_str("Up"),
Input::Down => display.push_str("Down"),
Input::Home => display.push_str("Home"),
Input::End => display.push_str("End"),
Input::Paste(_) => unreachable!("paste cannot be bound"),
}
}
@@ -671,6 +678,10 @@ pub struct Editor {
capabilities: Vec<String>,
features: HashMap<String, Feature>,
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>,
count: Option<usize>,
}
@@ -689,6 +700,10 @@ impl Editor {
capabilities,
features: HashMap::new(),
bindings: default_bindings(),
leader: Input::Char(' '),
leader_bindings: HashMap::new(),
leader_suffix: Vec::new(),
leader_display: String::new(),
palette: None,
count: None,
};
@@ -709,6 +724,19 @@ impl Editor {
true,
)
.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)
}
@@ -755,6 +783,46 @@ impl Editor {
pub fn count(&self) -> Option<usize> {
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.
pub fn command_line(&self) -> &str {
&self.command_line
@@ -867,6 +935,7 @@ impl Editor {
}
pub fn disable_plugin(&mut self, name: &str, reason: &str) {
self.cancel_leader();
if self.features.get(name).is_some_and(|feature| feature.core) {
self.set_message("Core features cannot be disabled");
return;
@@ -877,13 +946,18 @@ impl Editor {
!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.set_message(format!("Plugin {name} disabled: {reason}"));
}
/// Overrides one mode's binding. `None` disables the key, including literal
/// typing in Insert/Command mode. Invalid overrides leave the binding intact.
/// Overrides one mode's single key or symbolic `leader` sequence. `None`
/// disables it. Invalid overrides leave existing bindings intact.
pub fn bind(&mut self, mode: &str, key: &str, target: Option<String>) -> Result<(), String> {
let mode = match mode {
"normal" => Mode::Normal,
@@ -893,7 +967,35 @@ impl Editor {
"visual" => Mode::Visual,
_ => 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 {
None => Binding::Disabled,
Some(target) if target.starts_with("core.") => Binding::Action(
@@ -910,7 +1012,18 @@ impl Editor {
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();
Ok(())
}
@@ -929,6 +1042,31 @@ impl Editor {
})
.map(|(input, _)| display_key(input))
.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
}
@@ -1042,6 +1180,7 @@ impl Editor {
// Paste is literal input, never a configurable key or command.
if let Input::Paste(text) = input {
self.count = None;
self.clear_leader();
if self.active.mode == Mode::Insert {
self.paste(&text);
}
@@ -1052,6 +1191,13 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch),
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() {
return None;
}
@@ -1092,6 +1238,10 @@ impl Editor {
}
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;
match (self.active.mode, input) {
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
@@ -1108,8 +1258,38 @@ impl Editor {
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> {
let count = self.count.take().unwrap_or(1);
self.clear_leader();
match action {
Action::Palette => {
self.palette = Some(Palette {
@@ -1322,6 +1502,7 @@ impl Editor {
}
self.command_line.clear();
self.count = None;
self.clear_leader();
self.palette = None;
}
@@ -1487,6 +1668,7 @@ impl Editor {
pub fn execute(&mut self, command: &str) -> Option<Invocation> {
self.count = None;
self.clear_leader();
self.palette = None;
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
let (name, args) = command
@@ -1592,6 +1774,7 @@ impl Editor {
.then(|| self.snapshot());
if before.is_some() {
self.count = None;
self.clear_leader();
self.clear_selection();
self.collapse_primary();
// 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]
fn counted_cursor_motions_apply_to_all_carets_and_clamp_through_short_rows() {
let mut editor = editor();
@@ -3331,7 +3923,10 @@ mod tests {
assert_eq!(entry.owner, "core");
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");
assert!(
!editor
+2
View File
@@ -8,6 +8,8 @@ Usage: vamp [--config PATH | --no-config] [FILE]
Default keys (configurable with config.bind):
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
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
+53 -2
View File
@@ -13,10 +13,21 @@ const MAX_FRAME: usize = 4 * 1024 * 1024;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct Config {
pub plugins: Vec<PathBuf>,
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.
@@ -27,7 +38,10 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
let response = host.receive(deadline)?;
match response {
Response::Config {
plugins, bindings, ..
plugins,
bindings,
leader,
..
} => {
let mut config = Config::default();
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.bindings = bindings;
config.leader = leader;
Ok(config)
}
Response::Error {
@@ -182,6 +197,7 @@ enum Response {
version: u8,
plugins: Vec<String>,
bindings: Vec<Binding>,
leader: String,
},
#[serde(rename = "result")]
Result {
@@ -550,6 +566,41 @@ end
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]
fn crash_hang_callback_failure_and_fresh_host_recovery() {
let fixture = Fixture::new();
+9 -1
View File
@@ -50,6 +50,7 @@ module Vamp
def initialize
@plugins = []
@bindings = []
@leader = "space"
end
def plugin(path)
@@ -59,6 +60,12 @@ module Vamp
nil
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
Core
end
@@ -89,7 +96,8 @@ module Vamp
end
def response
{ "version" => 1, "type" => "config", "plugins" => @plugins, "bindings" => @bindings }
{ "version" => 1, "type" => "config", "plugins" => @plugins,
"bindings" => @bindings, "leader" => @leader }
end
end
+207 -1
View File
@@ -33,6 +33,9 @@ impl EditorSession {
}),
None => Config::default(),
};
if let Err(error) = editor.set_leader(&config.leader) {
notices.push(format!("leader {:?}: {error}", config.leader));
}
let mut plugins = BTreeMap::new();
for path in config.plugins {
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]
fn buffers_share_config_and_plugin_host_but_keep_history_and_protect_hidden_edits() {
let fixture = Fixture::new();
@@ -546,6 +738,8 @@ end
session.handle(Input::Char(':'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
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() {
session.handle(Input::Char(ch));
}
@@ -765,6 +959,9 @@ end
for (expression, error) in [
("config.core.reod", "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", nil, mode: "insert""#, "TypeError"),
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
@@ -784,7 +981,7 @@ end
),
] {
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}");
session.handle(Input::Char('i'));
@@ -794,6 +991,15 @@ end
assert_eq!(session.editor().lines(), &["界"], "{expression}");
session.handle(Input::Char('u'));
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
View File
@@ -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 })
.collect();
let mut status = format!(" {}", editor.mode());
if let Some(sequence) = editor.leader_pending() {
write!(status, " leader: {sequence}").unwrap();
}
if editor.buffer_count() > 1 {
write!(
status,
@@ -545,6 +548,54 @@ mod tests {
.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]
fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() {
let directory = Directory::new();