From c5f80f27a63a046cc1aea6aff4ef007d4474b1b8 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Wed, 14 Oct 2026 12:26:01 +0200 Subject: [PATCH] fix: replace selected ranges instead of ignoring visual paste --- README.md | 1 + docs/configuration.md | 4 +- docs/editing.md | 7 ++- src/editor.rs | 100 +++++++++++++++++++++++++++++++++++++++--- src/main.rs | 2 +- tests/paste_shapes.rs | 56 +++++++++++++++++++++++ 6 files changed, 159 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0b93133..5077d55 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. | Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous | | Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual | | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | +| Paste over selected text | `p` / `P` in Visual replaces every selection, preserving the clipboard | | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Buffer picker in Normal | Space then `b l` or `:buffers`; type to search, arrows select, Enter switches, Esc cancels | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | diff --git a/docs/configuration.md b/docs/configuration.md index 215d19b..e838f7c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -219,8 +219,8 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through | Normal | `jump_back`, `jump_forward` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Insert | `lsp.completion`, `snippet.next`, `snippet.previous` | -| Normal | `search`, `paste_before`, `paste_after` | -| Normal | `paste_system_before`, `paste_system_after` | +| Normal | `search` | +| Normal, Visual | `paste_before`, `paste_after`, `paste_system_before`, `paste_system_after` | | Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` | | Normal, Visual | `repeat_last_edit` | | Normal, Visual | `file_picker`, `file_explorer` | diff --git a/docs/editing.md b/docs/editing.md index 8ed967e..344f7d8 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -44,6 +44,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete | Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret | | Normal, Visual | Ctrl+k clones above; Ctrl+e clones below | | Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels | +| Visual | `p` / `P` replaces each selection with the configured clipboard, without overwriting it | | Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences | | Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret outside snippets; Esc returns to Normal, retaining carets | | Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open | @@ -127,6 +128,8 @@ Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred termin Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. Characterwise `p`/`P` paste after/before each current grapheme, including multiline text. Linewise clipboard fragments paste complete rows below/above each target row, including at end of file. Internal fragments retain their shape; system clipboard shape is retained only while its bytes match the editor's last publication, and unrelated external clipboard text is characterwise. Characterwise paste lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline; linewise paste lands at the first nonblank of the first inserted row. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard. +In Visual and Visual Line, both `p` and `P` replace each complete selected range rather than inserting beside its caret. Clipboard fragments are inserted literally, including their newlines: a characterwise fragment replacing a whole-line selection does not gain a newline, and a linewise fragment replacing characters keeps its newline. The edit returns to Normal, is one undo group across all selections, and supports dot repeat with the captured clipboard and selection intent. Neither clipboard is overwritten by the replaced text. Failed clipboard reads, invalid text, empty clipboard, or mismatched fragment counts leave the selection and document intact. + Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and 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 only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline. Visual `r` replaces every selected grapheme with the literal character, preserving newline separators and the clipboard. It returns to Normal at each selection start as one undo action and supports `.` repeat. Esc while waiting for the character cancels the replacement without clearing selections. Identical replacements and newline-only selections do not add history or discard redo. @@ -137,9 +140,9 @@ Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut ## Clipboard policy -`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either. +`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal/Visual `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either. -Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations: +Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal/Visual) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations: ```ruby config.clipboard_mode = "separate" diff --git a/src/editor.rs b/src/editor.rs index 0a827c3..1b2867a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -336,6 +336,7 @@ enum EditStart { after: bool, fragments: Vec, linewise: bool, + selector: Option, }, } @@ -591,17 +592,25 @@ core_actions![ Action::ChangeWordBackward, &[Mode::Normal] ), - ("core.paste_before", Action::PasteBefore, &[Mode::Normal]), - ("core.paste_after", Action::PasteAfter, &[Mode::Normal]), + ( + "core.paste_before", + Action::PasteBefore, + &[Mode::Normal, Mode::Visual] + ), + ( + "core.paste_after", + Action::PasteAfter, + &[Mode::Normal, Mode::Visual] + ), ( "core.paste_system_before", Action::PasteSystemBefore, - &[Mode::Normal], + &[Mode::Normal, Mode::Visual], ), ( "core.paste_system_after", Action::PasteSystemAfter, - &[Mode::Normal], + &[Mode::Normal, Mode::Visual], ), ( "core.left", @@ -1270,6 +1279,8 @@ fn default_bindings() -> HashMap> { (Input::Char('d'), Cut), (Input::Char('c'), Change), (Input::Char('x'), Cut), + (Input::Char('p'), PasteAfter), + (Input::Char('P'), PasteBefore), (Input::Delete, Cut), (Input::Char('I'), InsertFirstNonblank), (Input::Char('A'), AppendLineEnd), @@ -8802,6 +8813,8 @@ impl Editor { self.set_message("Clipboard fragment count does not match caret count"); } else { let before = self.snapshot(); + let ranges = self.selections(); + let selector = (!ranges.is_empty()).then(|| self.selection_intent(&ranges)); if let Err(error) = self.paste_fragments(after, &fragments, linewise) { self.set_message(error); if !system { @@ -8814,6 +8827,7 @@ impl Editor { start: EditStart::Paste { after, linewise, + selector, fragments: if system { std::mem::take(&mut fragments) } else { @@ -8837,6 +8851,32 @@ impl Editor { linewise: bool, ) -> Result<(), &'static str> { let offsets = line_offsets(&self.active.lines); + if self.active.view.mode == Mode::Visual { + let edits = self + .active + .view + .targets + .iter() + .enumerate() + .map(|(target, item)| { + let range = self.target_range(*item); + let text = &fragments[if fragments.len() == 1 { 0 } else { target }]; + Edit { + target: Some(target), + start: cursor_offset(&self.active.lines, &offsets, range.start), + end: cursor_offset(&self.active.lines, &offsets, range.end), + text: Cow::Borrowed(text), + caret: text.len(), + round_up: true, + } + }) + .collect(); + self.active.view.mode = Mode::Insert; + self.active.view.linewise = false; + self.apply_edits(edits); + self.leave_insert(); + return Ok(()); + } if linewise { if fragments.len() != 1 && self @@ -9241,7 +9281,24 @@ impl Editor { after, fragments, linewise, - } => self.paste_fragments(*after, fragments, *linewise)?, + selector, + } => { + if let Some(selector) = selector { + let count = match selector { + SelectionIntent::Spans(spans) => spans.len(), + SelectionIntent::Rows(rows) => rows.len(), + SelectionIntent::Motions { .. } => 1, + }; + if self.active.view.mode != Mode::Visual + && count != 1 + && count != self.active.view.targets.len() + { + return Err("Repeat span count does not match target count"); + } + self.replay_ranges(selector); + } + self.paste_fragments(*after, fragments, *linewise)?; + } } for step in &recipe.steps { match step { @@ -16367,6 +16424,38 @@ mod tests { ) } + #[test] + fn visual_system_paste_replaces_only_after_successful_read_without_publishing() { + for (key, action) in [ + ('p', None), + ('P', None), + ('z', Some("core.paste_system_before")), + ('z', Some("core.paste_system_after")), + ] { + let fixture = Fixture::new(); + let mut editor = fixture.editor(); + if let Some(action) = action { + editor.bind("visual", "z", Some(action.into())).unwrap(); + } + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + fixture.external("X"); + type_text(&mut editor, "vl"); + fixture.fail("fail-read", true); + fixture.fail("fail-write", true); + let before = state(&editor); + editor.handle(Input::Char(key)); + assert_eq!(state(&editor), before); + fixture.fail("fail-read", false); + editor.handle(Input::Char(key)); + assert_eq!(editor.context().text, "Xc"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(fixture.bytes("state"), b"X"); + assert!(fixture.bytes("writes").is_empty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "abc"); + } + } + #[test] fn linewise_owned_system_bytes_keep_shape_external_changes_do_not_guess_it() { let fixture = Fixture::new(); @@ -16632,7 +16721,6 @@ mod tests { fixture.fail("fail-read", false); fixture.fail("fail-write", false); for action in ["core.paste_system_before", "core.paste_system_after"] { - assert!(editor.bind("visual", "z", Some(action.into())).is_err()); assert!(editor.bind("insert", "z", Some(action.into())).is_err()); editor.bind("normal", "z", Some(action.into())).unwrap(); } diff --git a/src/main.rs b/src/main.rs index 39c21dc..24fad61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,7 +37,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it Esc keeps only the primary caret -Visual: motions extend selections, y copy, d/x/Delete cut, c change +Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections r replaces every selected grapheme, preserving newlines; one undo action I/A insert at each selected line's first nonblank/end Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all diff --git a/tests/paste_shapes.rs b/tests/paste_shapes.rs index ef607ad..4f06f8b 100644 --- a/tests/paste_shapes.rs +++ b/tests/paste_shapes.rs @@ -42,6 +42,62 @@ fn keys(editor: &mut Editor, text: &str) { } } +#[test] +fn visual_paste_replaces_selection_and_preserves_clipboard_and_undo() { + for paste in ['p', 'P'] { + for (yank, select, expected) in [ + ("vly", "j0vl", "XY\nXYc\nend"), + ("vly", "j0lvh", "XY\nXYc\nend"), + ("yy", "j0vl", "XY\nXY\nc\nend"), + ("yy", "jV", "XY\nXY\nend"), + ("vly", "jV", "XY\nXYend"), + ] { + let fixture = Fixture::new(); + let source = "XY\nabc\nend"; + let mut editor = fixture.editor(source); + keys(&mut editor, yank); + keys(&mut editor, select); + editor.handle(Input::Char(paste)); + assert_eq!(editor.context().text, expected, "{yank} {select} {paste}"); + assert_eq!(editor.mode(), Mode::Normal); + keys(&mut editor, "u"); + assert_eq!(editor.context().text, source); + keys(&mut editor, "U"); + assert_eq!(editor.context().text, expected); + keys( + &mut editor, + if select.ends_with("vh") { + "u0l." + } else { + "u0." + }, + ); + assert_eq!( + editor.context().text, + expected, + "repeat: {yank} {select} {paste}" + ); + keys(&mut editor, "u"); + keys(&mut editor, select.trim_start_matches('j')); + editor.handle(Input::Char(paste)); + assert_eq!(editor.context().text, expected, "clipboard stays unchanged"); + } + } +} + +#[test] +fn visual_paste_replaces_every_selected_range_in_one_undo_group() { + let fixture = Fixture::new(); + let source = "XY\n界e\u{301}\n👩‍🚀z"; + let mut editor = fixture.editor(source); + keys(&mut editor, "vlyj0vl"); + editor.handle(Input::Ctrl('e')); + keys(&mut editor, "p"); + assert_eq!(editor.context().text, "XY\nXY\nXY"); + keys(&mut editor, "u"); + assert_eq!(editor.context().text, source); +} + #[test] fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() { let source = " \t界e\u{301}👩‍🚀\nother\nlast\n";