feat: add Vim-style J line joining

This commit is contained in:
Valdemar Lorenzen
2026-10-05 15:49:52 +02:00
parent f81405a5ed
commit 368442ba29
3 changed files with 112 additions and 3 deletions
+108 -2
View File
@@ -259,7 +259,7 @@ impl Editor {
}
pub fn bind(&mut self, key: char, command: String) -> Result<(), String> {
if key.is_control() || "iIaAoOhjklx0$uU:".contains(key) {
if key.is_control() || "iIaAoOhjJklx0$uU:".contains(key) {
return Err(format!("Key {key:?} is reserved for editing"));
}
if self.owner(&command).is_none() {
@@ -300,6 +300,7 @@ impl Editor {
Input::Char('h') | Input::Left => self.move_horizontal(false),
Input::Char('l') | Input::Right => self.move_horizontal(true),
Input::Char('j') | Input::Down => self.move_vertical(true),
Input::Char('J') => self.join_lines(),
Input::Char('k') | Input::Up => self.move_vertical(false),
Input::Char('0') | Input::Home => self.cursor.column = 0,
Input::Char('$') | Input::End => self.move_end(),
@@ -642,6 +643,42 @@ impl Editor {
}
}
fn join_lines(&mut self) {
let row = self.cursor.row;
if row + 1 >= self.lines.len() {
return;
}
let before = self.snapshot();
let next = self.lines.remove(row + 1);
let next = next.trim_start_matches([' ', '\t']);
let line = &mut self.lines[row];
let join_byte = line.len();
let separator = if !line.is_empty()
&& !next.is_empty()
&& !line.ends_with([' ', '\t'])
&& !next.starts_with(')')
{
if line.ends_with(['.', '?', '!']) {
" "
} else {
" "
}
} else {
""
};
line.reserve(separator.len() + next.len());
line.push_str(separator);
line.push_str(next);
// A Normal-mode cursor stays on a whole grapheme, even if joining
// a leading combining mark merges it with the preceding character.
self.cursor.column = line
.grapheme_indices(true)
.take_while(|(byte, _)| *byte <= join_byte)
.count()
.saturating_sub(1);
self.record(before);
}
fn backspace(&mut self) {
let row = self.cursor.row;
if self.cursor.column > 0 {
@@ -822,6 +859,75 @@ mod tests {
}
}
#[test]
fn join_lines_matches_vim_spacing_and_grapheme_cursor_positions() {
for (original, expected, column) in [
("alpha\n \tbeta\ntail", "alpha beta\ntail", 5),
("alpha \n\tbeta", "alpha beta", 7),
("alpha\t\n beta", "alpha\tbeta", 6),
("\n beta", "beta", 0),
("alpha\n \t", "alpha", 4),
("\n", "", 0),
("alpha\n )tail", "alpha)tail", 5),
("Sentence.\n next", "Sentence. next", 9),
("界e\u{301}\n next", "界e\u{301} next", 2),
("x \n\u{301}y", "x \u{301}y", 1),
] {
let mut editor = editor();
editor
.apply(vec![Effect::Replace(original.into())])
.unwrap();
editor.handle(Input::Char('J'));
assert_eq!(editor.context().text, expected);
assert_eq!(editor.cursor(), Cursor { row: 0, column });
assert_eq!(editor.mode(), Mode::Normal);
}
}
#[test]
fn join_is_one_undo_action_on_the_current_row_and_restores_cursor() {
let mut editor = editor();
let original = "above\nalpha\n beta\nbelow";
editor
.apply(vec![Effect::Replace(original.into())])
.unwrap();
editor.handle(Input::Down);
editor.handle(Input::Right);
let before = editor.cursor();
editor.handle(Input::Char('J'));
assert_eq!(editor.context().text, "above\nalpha beta\nbelow");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 5 });
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, original);
assert_eq!(editor.cursor(), before);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "above\nalpha beta\nbelow");
editor.handle(Input::Char('x'));
assert_eq!(editor.context().text, "above\nalphabeta\nbelow");
}
#[test]
fn join_at_eof_preserves_history_and_insert_mode_j_is_literal() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("single".into())])
.unwrap();
editor.handle(Input::Char('u'));
editor.handle(Input::Char('J'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "single");
editor.handle(Input::Char('J'));
assert_eq!(editor.context().text, "single");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('U'));
editor.handle(Input::Char('i'));
editor.handle(Input::Char('J'));
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "Jsingle");
}
#[test]
fn uppercase_u_redoes_and_control_r_no_longer_does() {
let mut editor = editor();
@@ -1080,7 +1186,7 @@ mod tests {
fn bindings_dispatch_only_normal_mode_and_essential_keys_stay_protected() {
let mut editor = editor();
editor.register(plugin("greeting", &["greet"])).unwrap();
for key in "iIaAoOhjklx0$uU:\u{1b}\r".chars() {
for key in "iIaAoOhjJklx0$uU:\u{1b}\r".chars() {
assert!(editor.bind(key, "greet".into()).is_err());
}
editor.bind('g', "greet".into()).unwrap();
+1 -1
View File
@@ -7,7 +7,7 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins
Usage: vamp [--config PATH | --no-config] [FILE]
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
h/j/k/l move, 0/$ line ends, x delete, J join lines
u undo, U redo, : command
Insert: Esc returns to normal
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]