feat: add Vim-style J line joining
This commit is contained in:
@@ -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.
|
||||
|
||||
+108
-2
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user