feat: add Visual change with grouped replacement undo
This commit is contained in:
@@ -25,7 +25,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
|||||||
| 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 |
|
||||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||||
| Normal | `v` starts Visual selection; `p`/`P` paste internal clipboard after/before the cursor |
|
| Normal | `v` starts Visual selection; `p`/`P` paste internal clipboard after/before the cursor |
|
||||||
| Visual | Cursor/word motions and `n`/`N` extend selection; `y` copies; `d`/`x`/Delete cuts; `v`/Esc cancels |
|
| Visual | Cursor/word motions and `n`/`N` extend selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selection; `v`/Esc cancels |
|
||||||
| 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 |
|
||||||
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels |
|
||||||
@@ -42,6 +42,10 @@ Visual selection is characterwise and inclusive of both endpoints, normalized wh
|
|||||||
|
|
||||||
Copy and cut return to Normal mode at the selection start; cancellation keeps the focus cursor. Both clamp the cursor to a valid Normal-mode position. `p`/`P` paste after/before the current grapheme, including multiline text. The cursor lands on the last inserted grapheme, or the new row's start when the clipboard ends in a newline. Cut and each paste are separate undo actions. Copy, selection movement, and cancellation do not add history or clear redo. Undo/redo restore text and cursor, not the selection or clipboard.
|
Copy and cut return to Normal mode at the selection start; cancellation keeps the focus cursor. Both clamp the cursor to a valid Normal-mode position. `p`/`P` paste after/before the current grapheme, including multiline text. The cursor lands on the last inserted grapheme, or the new row's start when the clipboard ends in a newline. Cut and each paste are separate undo actions. Copy, selection movement, and cancellation do not add history or clear redo. Undo/redo restore text and cursor, not the selection or clipboard.
|
||||||
|
|
||||||
|
Visual-mode `c` removes the selection into the internal clipboard and enters Insert mode at its start. Deletion and subsequent replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing an empty final-row selection enters Insert mode without overwriting the clipboard.
|
||||||
|
|
||||||
|
Bracketed paste retains its existing separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode.
|
||||||
|
|
||||||
The clipboard is internal to the editor session and independent of the system clipboard. Empty clipboard paste does not edit text; copying/cutting an empty final-row selection does not overwrite an existing clipboard. Open a new search or enter Ex commands after leaving Visual mode. Successful text effects or explicit command execution also leave Visual mode; rejected effects preserve the selection.
|
The clipboard is internal to the editor session and independent of the system clipboard. Empty clipboard paste does not edit text; copying/cutting an empty final-row selection does not overwrite an existing clipboard. Open a new search or enter Ex commands after leaving Visual mode. Successful text effects or explicit command execution also leave Visual mode; rejected effects preserve the selection.
|
||||||
|
|
||||||
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
|
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow.
|
||||||
@@ -89,6 +93,7 @@ Vamp.configure do |config|
|
|||||||
config.bind "ctrl+v", config.core.select
|
config.bind "ctrl+v", config.core.select
|
||||||
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
|
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
|
||||||
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
|
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
|
||||||
|
config.bind "ctrl+r", config.core.change, mode: config.modes.visual
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ Terminals share `ctrl+h` with `backspace`, `ctrl+i` with `tab`, and `ctrl+j`/`ct
|
|||||||
| Normal, Insert, Visual | `left`, `down`, `up`, `right`, `line_start`, `line_end` |
|
| Normal, Insert, Visual | `left`, `down`, `up`, `right`, `line_start`, `line_end` |
|
||||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||||
| Normal, Insert | `delete` |
|
| Normal, Insert | `delete` |
|
||||||
| Visual | `copy`, `cut` |
|
| Visual | `copy`, `cut`, `change` |
|
||||||
| Insert, Command, Search, Visual | `normal` |
|
| Insert, Command, Search, Visual | `normal` |
|
||||||
| Insert, Command, Search | `backspace` |
|
| Insert, Command, Search | `backspace` |
|
||||||
| Insert | `newline` |
|
| Insert | `newline` |
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ Vamp.configure do |config|
|
|||||||
config.bind "ctrl+v", config.core.select
|
config.bind "ctrl+v", config.core.select
|
||||||
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
|
config.bind "ctrl+c", config.core.copy, mode: config.modes.visual
|
||||||
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
|
config.bind "ctrl+x", config.core.cut, mode: config.modes.visual
|
||||||
|
config.bind "ctrl+r", config.core.change, mode: config.modes.visual
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ o
|
|||||||
aaklsjdad
|
aaklsjdad
|
||||||
|
|
||||||
ads dasdkaljdalkjsda
|
ads dasdkaljdalkjsda
|
||||||
sdalksdjaldskjadkjsla
|
swwdalksdjaldskjadkjsla
|
||||||
+133
-11
@@ -146,6 +146,7 @@ enum Action {
|
|||||||
Select,
|
Select,
|
||||||
Copy,
|
Copy,
|
||||||
Cut,
|
Cut,
|
||||||
|
Change,
|
||||||
PasteBefore,
|
PasteBefore,
|
||||||
PasteAfter,
|
PasteAfter,
|
||||||
}
|
}
|
||||||
@@ -170,6 +171,7 @@ impl Action {
|
|||||||
(Mode::Normal | Mode::Visual, "core.select") => Select,
|
(Mode::Normal | Mode::Visual, "core.select") => Select,
|
||||||
(Mode::Visual, "core.copy") => Copy,
|
(Mode::Visual, "core.copy") => Copy,
|
||||||
(Mode::Visual, "core.cut") => Cut,
|
(Mode::Visual, "core.cut") => Cut,
|
||||||
|
(Mode::Visual, "core.change") => Change,
|
||||||
(Mode::Normal, "core.paste_before") => PasteBefore,
|
(Mode::Normal, "core.paste_before") => PasteBefore,
|
||||||
(Mode::Normal, "core.paste_after") => PasteAfter,
|
(Mode::Normal, "core.paste_after") => PasteAfter,
|
||||||
(Mode::Normal | Mode::Insert | Mode::Visual, "core.left") => Left,
|
(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 [
|
for (key, action) in [
|
||||||
(Input::Char('y'), Copy),
|
(Input::Char('y'), Copy),
|
||||||
(Input::Char('d'), Cut),
|
(Input::Char('d'), Cut),
|
||||||
|
(Input::Char('c'), Change),
|
||||||
(Input::Char('x'), Cut),
|
(Input::Char('x'), Cut),
|
||||||
(Input::Delete, Cut),
|
(Input::Delete, Cut),
|
||||||
] {
|
] {
|
||||||
@@ -723,8 +726,7 @@ impl Editor {
|
|||||||
self.message.clear();
|
self.message.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Copy => self.copy_selection(false),
|
Action::Copy | Action::Cut | Action::Change => self.edit_selection(action),
|
||||||
Action::Cut => self.copy_selection(true),
|
|
||||||
Action::PasteBefore => self.paste_clipboard(false),
|
Action::PasteBefore => self.paste_clipboard(false),
|
||||||
Action::PasteAfter => self.paste_clipboard(true),
|
Action::PasteAfter => self.paste_clipboard(true),
|
||||||
Action::Normal => {
|
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 {
|
let Some(selection) = self.selection() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let cut = matches!(action, Action::Cut | Action::Change);
|
||||||
|
let changing = matches!(action, Action::Change);
|
||||||
if selection.start == selection.end {
|
if selection.start == selection.end {
|
||||||
self.clear_selection();
|
self.clear_selection();
|
||||||
self.set_message("Nothing selected");
|
if changing {
|
||||||
|
self.begin_insert();
|
||||||
|
self.message.clear();
|
||||||
|
} else {
|
||||||
|
self.set_message("Nothing selected");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let start = selection.start;
|
let start = selection.start;
|
||||||
@@ -1006,16 +1015,25 @@ impl Editor {
|
|||||||
tail.replace_range(..end_byte, &self.lines[start.row][..start_byte]);
|
tail.replace_range(..end_byte, &self.lines[start.row][..start_byte]);
|
||||||
self.lines[start.row] = tail;
|
self.lines[start.row] = tail;
|
||||||
}
|
}
|
||||||
|
self.search_match = None;
|
||||||
|
}
|
||||||
|
if changing {
|
||||||
|
self.mode = Mode::Insert;
|
||||||
}
|
}
|
||||||
self.clamp_cursor();
|
self.clamp_cursor();
|
||||||
if let Some(before) = before {
|
if changing {
|
||||||
self.record(before);
|
self.insert_start = before;
|
||||||
}
|
self.message.clear();
|
||||||
self.set_message(if cut {
|
|
||||||
"Cut selection"
|
|
||||||
} else {
|
} 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) {
|
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]
|
#[test]
|
||||||
fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() {
|
fn visual_ranges_copy_both_directions_and_cut_and_paste_multiline_graphemes() {
|
||||||
let mut editor = editor();
|
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
|
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
||||||
u undo, U redo, : command, / search, n/N next/previous match
|
u undo, U redo, : command, / search, n/N next/previous match
|
||||||
v select, p/P paste after/before cursor
|
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
|
Insert: Esc returns to normal
|
||||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ module Vamp
|
|||||||
normal newline backspace submit
|
normal newline backspace submit
|
||||||
search search_next search_previous
|
search search_next search_previous
|
||||||
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
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|
|
].each do |name|
|
||||||
action = Action.new("core.#{name}".freeze).freeze
|
action = Action.new("core.#{name}".freeze).freeze
|
||||||
define_singleton_method(name) { action }
|
define_singleton_method(name) { action }
|
||||||
|
|||||||
@@ -404,6 +404,47 @@ end
|
|||||||
assert!(session.editor().should_quit());
|
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]
|
#[test]
|
||||||
fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() {
|
fn typed_visual_actions_copy_cut_paste_and_cancel_without_splitting_history() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user