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` 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
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user