From 53fcd7a1f25bf6a8c307e1b997e2932af50ae502 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Tue, 6 Oct 2026 13:18:57 +0200 Subject: [PATCH] feat: add Visual change with grouped replacement undo --- README.md | 9 ++- examples/config.rb | 1 + notes.txt | 2 +- src/editor.rs | 144 +++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 2 +- src/ruby_host.rb | 2 +- src/session.rs | 41 +++++++++++++ 7 files changed, 185 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 593058f..0e734ba 100644 --- a/README.md +++ b/README.md @@ -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 | `/` 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 | -| 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 | | Command | Enter executes; 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. +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. 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+c", config.core.copy, 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 ``` @@ -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 | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` | | Normal, Insert | `delete` | -| Visual | `copy`, `cut` | +| Visual | `copy`, `cut`, `change` | | Insert, Command, Search, Visual | `normal` | | Insert, Command, Search | `backspace` | | Insert | `newline` | diff --git a/examples/config.rb b/examples/config.rb index 0289c18..4aaff8c 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -10,4 +10,5 @@ Vamp.configure do |config| config.bind "ctrl+v", config.core.select 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+r", config.core.change, mode: config.modes.visual end diff --git a/notes.txt b/notes.txt index 6656de2..77b6a5b 100644 --- a/notes.txt +++ b/notes.txt @@ -3,4 +3,4 @@ o aaklsjdad ads dasdkaljdalkjsda -sdalksdjaldskjadkjsla \ No newline at end of file +swwdalksdjaldskjadkjsla \ No newline at end of file diff --git a/src/editor.rs b/src/editor.rs index 2b2f24f..5fed22c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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> { 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(); diff --git a/src/main.rs b/src/main.rs index 9df469e..15f3c30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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] diff --git a/src/ruby_host.rb b/src/ruby_host.rb index b41d596..3943f21 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -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 } diff --git a/src/session.rs b/src/session.rs index c998786..9e9e581 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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();