feat: add Visual change with grouped replacement undo
This commit is contained in:
+133
-11
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user