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
+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);
}
}