feat: add Vim-style J line joining
This commit is contained in:
+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();
|
||||
|
||||
Reference in New Issue
Block a user