feat: add Visual change with grouped replacement undo

This commit is contained in:
Valdemar Lorenzen
2026-10-06 13:18:57 +02:00
parent f775ff3b59
commit 53fcd7a1f2
7 changed files with 185 additions and 16 deletions
+133 -11
View File
@@ -146,6 +146,7 @@ enum Action {
Select,
Copy,
Cut,
Change,
PasteBefore,
PasteAfter,
}
@@ -170,6 +171,7 @@ impl Action {
(Mode::Normal | Mode::Visual, "core.select") => Select,
(Mode::Visual, "core.copy") => Copy,
(Mode::Visual, "core.cut") => Cut,
(Mode::Visual, "core.change") => Change,
(Mode::Normal, "core.paste_before") => PasteBefore,
(Mode::Normal, "core.paste_after") => PasteAfter,
(Mode::Normal | Mode::Insert | Mode::Visual, "core.left") => Left,
@@ -345,6 +347,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
for (key, action) in [
(Input::Char('y'), Copy),
(Input::Char('d'), Cut),
(Input::Char('c'), Change),
(Input::Char('x'), Cut),
(Input::Delete, Cut),
] {
@@ -723,8 +726,7 @@ impl Editor {
self.message.clear();
}
}
Action::Copy => self.copy_selection(false),
Action::Cut => self.copy_selection(true),
Action::Copy | Action::Cut | Action::Change => self.edit_selection(action),
Action::PasteBefore => self.paste_clipboard(false),
Action::PasteAfter => self.paste_clipboard(true),
Action::Normal => {
@@ -957,13 +959,20 @@ impl Editor {
}
}
fn copy_selection(&mut self, cut: bool) {
fn edit_selection(&mut self, action: Action) {
let Some(selection) = self.selection() else {
return;
};
let cut = matches!(action, Action::Cut | Action::Change);
let changing = matches!(action, Action::Change);
if selection.start == selection.end {
self.clear_selection();
self.set_message("Nothing selected");
if changing {
self.begin_insert();
self.message.clear();
} else {
self.set_message("Nothing selected");
}
return;
}
let start = selection.start;
@@ -1006,16 +1015,25 @@ impl Editor {
tail.replace_range(..end_byte, &self.lines[start.row][..start_byte]);
self.lines[start.row] = tail;
}
self.search_match = None;
}
if changing {
self.mode = Mode::Insert;
}
self.clamp_cursor();
if let Some(before) = before {
self.record(before);
}
self.set_message(if cut {
"Cut selection"
if changing {
self.insert_start = before;
self.message.clear();
} else {
"Copied selection"
});
if let Some(before) = before {
self.record(before);
}
self.set_message(if cut {
"Cut selection"
} else {
"Copied selection"
});
}
}
fn paste_clipboard(&mut self, after: bool) {
@@ -1527,6 +1545,110 @@ mod tests {
}
}
#[test]
fn visual_change_replaces_reversed_multiline_graphemes_in_one_undo_group() {
let mut editor = editor();
let original = "pre界e\u{301}\n\n👩‍🚀tail\npost";
editor
.apply(vec![Effect::Replace(original.into())])
.unwrap();
submit_search(&mut editor, "👩‍🚀");
assert_eq!(editor.search_match().unwrap().start.row, 2);
editor.handle(Input::Char('v'));
editor.handle(Input::Up);
editor.handle(Input::Up);
for _ in 0..3 {
editor.handle(Input::Right);
}
editor.handle(Input::Char('c'));
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.selection(), None);
assert_eq!(editor.search_match(), None);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 });
assert_eq!(editor.context().text, "pretail\npost");
editor.handle(Input::Paste("\0".into()));
assert_eq!(editor.context().text, "pretail\npost");
type_text(&mut editor, "α\nβ");
editor.handle(Input::Escape);
let replacement = "preα\nβtail\npost";
assert_eq!(editor.context().text, replacement);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, original);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, replacement);
editor.handle(Input::Char('u'));
editor.handle(Input::Char('P'));
assert_eq!(
editor.context().text,
"pre界e\u{301}\n\n👩‍🚀界e\u{301}\n\n👩‍🚀tail\npost"
);
}
#[test]
fn visual_change_at_eof_and_empty_ranges_preserves_insertion_and_clipboard_semantics() {
let mut editor = editor();
editor.apply(vec![Effect::Replace("abX".into())]).unwrap();
editor.handle(Input::End);
editor.handle(Input::Char('v'));
editor.handle(Input::Char('c'));
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 });
type_text(&mut editor, "YZ");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "abYZ");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "abX");
editor.handle(Input::Char('v'));
editor.handle(Input::Char('c'));
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "ab");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "abX");
editor.handle(Input::Char('P'));
assert_eq!(editor.context().text, "abXX");
editor.apply(vec![Effect::Replace("".into())]).unwrap();
editor.handle(Input::Char('p'));
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('v'));
editor.handle(Input::Char('c'));
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.cursor(), Cursor::default());
editor.handle(Input::Escape);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "X");
editor.handle(Input::Char('u'));
editor.handle(Input::Char('v'));
editor.handle(Input::Char('c'));
type_text(&mut editor, "new");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "new");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
editor.handle(Input::Char('p'));
assert_eq!(editor.context().text, "X");
}
#[test]
fn visual_change_with_identical_replacement_preserves_redo() {
let mut editor = editor();
editor.apply(vec![Effect::Replace("abc".into())]).unwrap();
editor.handle(Input::Char('i'));
type_text(&mut editor, "X");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "abc");
editor.handle(Input::Char('v'));
editor.handle(Input::Char('e'));
editor.handle(Input::Char('c'));
type_text(&mut editor, "abc");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "abc");
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "Xabc");
}
#[test]
fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() {
let mut editor = editor();