From 368442ba29cf6cbb4b6f6017bcc6cb0a4a9286c6 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 5 Oct 2026 15:49:52 +0200 Subject: [PATCH] feat: add Vim-style J line joining --- README.md | 3 ++ src/editor.rs | 110 +++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 2 +- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e5c8c79..e40ee49 100644 --- a/README.md +++ b/README.md @@ -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` (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 | `J` (Shift+j) join the current line with the next line | | 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 | | 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]`. `: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. diff --git a/src/editor.rs b/src/editor.rs index 64618cf..8143123 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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(); diff --git a/src/main.rs b/src/main.rs index 8d17fe4..06c9c4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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]