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
+3
View File
@@ -20,10 +20,13 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
| Normal | `i` insert before cursor; `a` insert after cursor; `o`/`O` open line below/above | | Normal | `i` insert before cursor; `a` insert after cursor; `o`/`O` open line below/above |
| Normal | `I` (Shift+i) insert before the first nonblank, skipping spaces/tabs; `A` (Shift+a) insert at line end | | Normal | `I` (Shift+i) insert before the first nonblank, skipping spaces/tabs; `A` (Shift+a) insert at line end |
| Normal | `h`/`j`/`k`/`l` or arrows; `0`/`$` or Home/End | | Normal | `h`/`j`/`k`/`l` or arrows; `0`/`$` or Home/End |
| Normal | `J` (Shift+j) join the current line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode | | Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal | | Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal |
| Command | Enter executes; Backspace edits; Esc cancels | | Command | Enter executes; Backspace edits; Esc cancels |
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`. Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`.
`:q` refuses unsaved changes. `:wq` quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. `:q` refuses unsaved changes. `:wq` quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
+108 -2
View File
@@ -259,7 +259,7 @@ impl Editor {
} }
pub fn bind(&mut self, key: char, command: String) -> Result<(), String> { 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")); return Err(format!("Key {key:?} is reserved for editing"));
} }
if self.owner(&command).is_none() { if self.owner(&command).is_none() {
@@ -300,6 +300,7 @@ impl Editor {
Input::Char('h') | Input::Left => self.move_horizontal(false), Input::Char('h') | Input::Left => self.move_horizontal(false),
Input::Char('l') | Input::Right => self.move_horizontal(true), Input::Char('l') | Input::Right => self.move_horizontal(true),
Input::Char('j') | Input::Down => self.move_vertical(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('k') | Input::Up => self.move_vertical(false),
Input::Char('0') | Input::Home => self.cursor.column = 0, Input::Char('0') | Input::Home => self.cursor.column = 0,
Input::Char('$') | Input::End => self.move_end(), 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) { fn backspace(&mut self) {
let row = self.cursor.row; let row = self.cursor.row;
if self.cursor.column > 0 { 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] #[test]
fn uppercase_u_redoes_and_control_r_no_longer_does() { fn uppercase_u_redoes_and_control_r_no_longer_does() {
let mut editor = editor(); let mut editor = editor();
@@ -1080,7 +1186,7 @@ mod tests {
fn bindings_dispatch_only_normal_mode_and_essential_keys_stay_protected() { fn bindings_dispatch_only_normal_mode_and_essential_keys_stay_protected() {
let mut editor = editor(); let mut editor = editor();
editor.register(plugin("greeting", &["greet"])).unwrap(); 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()); assert!(editor.bind(key, "greet".into()).is_err());
} }
editor.bind('g', "greet".into()).unwrap(); 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] Usage: vamp [--config PATH | --no-config] [FILE]
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete h/j/k/l move, 0/$ line ends, x delete, J join lines
u undo, U redo, : command u undo, U redo, : command
Insert: Esc returns to normal Insert: Esc returns to normal
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS] Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]