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();
+1 -1
View File
@@ -12,7 +12,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
u undo, U redo, : command, / search, n/N next/previous match
v select, p/P paste after/before cursor
Visual: motions extend selection, y copy, d/x/Delete cut, v/Esc cancel
Visual: motions extend selection, y copy, d/x/Delete cut, c change, v/Esc cancel
Insert: Esc returns to normal
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
+1 -1
View File
@@ -28,7 +28,7 @@ module Vamp
normal newline backspace submit
search search_next search_previous
word_next word_previous word_end big_word_next big_word_previous big_word_end
select copy cut paste_before paste_after
select copy cut change paste_before paste_after
].each do |name|
action = Action.new("core.#{name}".freeze).freeze
define_singleton_method(name) { action }
+41
View File
@@ -404,6 +404,47 @@ end
assert!(session.editor().should_quit());
}
#[test]
fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc DEF tail");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind "c", nil, mode: config.modes.visual
config.bind "ctrl+r", config.core.change, mode: config.modes.visual
config.bind "ctrl+v", config.core.select
config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual
end
"#,
);
session.handle(Input::Char('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert!(!session.editor().dirty());
session.handle(Input::Ctrl('v'));
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
assert_eq!(session.editor().lines(), &["abc DEF tail"]);
session.handle(Input::Ctrl('r'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Insert);
assert_eq!(session.editor().cursor().column, 0);
assert_eq!(session.editor().lines(), &[" DEF tail"]);
for ch in "cZ".chars() {
session.handle(Input::Char(ch));
}
session.execute("w");
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"cZ DEF tail"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["abc DEF tail"]);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["ababcc DEF tail"]);
}
#[test]
fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() {
let fixture = Fixture::new();