feat: add configurable Space leader bindings
This commit is contained in:
+614
-19
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user