fix: yank whole lines with yy and preserve paste shape

This commit is contained in:
Valdemar Lorenzen
2026-10-13 12:59:07 +02:00
parent c4f72a45ba
commit c912abaf17
7 changed files with 417 additions and 12 deletions
+16
View File
@@ -0,0 +1,16 @@
use vamp::editor::{Editor, Effect, Input};
#[test]
fn yy_copies_current_whole_line_for_paste_below_it() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor
.apply(vec![Effect::Replace("first\nsecond\n".into())])
.unwrap();
for key in "llyy".chars() {
editor.handle(Input::Char(key));
}
assert_eq!(editor.cursor().column, 2);
assert_eq!(editor.context().text, "first\nsecond\n");
editor.handle(Input::Char('p'));
assert_eq!(editor.context().text, "first\nfirst\nsecond\n");
}
+229
View File
@@ -0,0 +1,229 @@
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use vamp::editor::{Cursor, Editor, Input, Mode};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-paste-shapes-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn editor(&self, text: &str) -> Editor {
let path = self.0.join("buffer.txt");
fs::write(&path, text).unwrap();
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn keys(editor: &mut Editor, text: &str) {
for ch in text.chars() {
editor.handle(Input::Char(ch));
}
}
#[test]
fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() {
let source = " \t界e\u{301}👩‍🚀\nother\nlast\n";
for (paste, expected, row) in [
('p', " \t界e\u{301}👩‍🚀\nother\n \t界e\u{301}👩‍🚀\nlast\n", 2),
('P', " \t界e\u{301}👩‍🚀\n \t界e\u{301}👩‍🚀\nother\nlast\n", 1),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, "3l");
let yank_caret = editor.cursor();
keys(&mut editor, "yy");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), yank_caret);
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
keys(&mut editor, "j2l");
let paste_caret = editor.cursor();
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "{paste}");
assert_eq!(editor.cursor(), Cursor { row, column: 2 });
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), paste_caret);
assert!(!editor.dirty());
}
}
#[test]
fn whole_line_paste_handles_unterminated_rows_empty_rows_and_the_final_empty_row() {
for (source, position, paste, expected, row) in [
("top\n last", "j2l", 'p', "top\n last\n last", 2),
("top\n last", "j2l", 'P', "top\n last\n last", 1),
("top\n last\n", "j2l", 'p', "top\n last\n last\n", 2),
("top\n last\n", "j2l", 'P', "top\n last\n last\n", 1),
("top\n\nend", "j", 'p', "top\n\n\nend", 2),
("top\n\nend", "j", 'P', "top\n\n\nend", 1),
("top\n", "j", 'p', "top\n\n", 2),
("top\n", "j", 'P', "top\n\n", 1),
("", "", 'p', "\n", 1),
("", "", 'P', "\n", 0),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, position);
let caret = editor.cursor();
keys(&mut editor, "yy");
editor.handle(Input::Char(paste));
assert_eq!(
editor.context().text,
expected,
"{source:?} {position}yy{paste}"
);
assert_eq!(
editor.cursor(),
Cursor {
row,
column: caret.column
}
);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
}
}
#[test]
fn counted_line_yanks_multiply_prefixes_and_clamp_at_the_last_row() {
let source = "one\ntwo\nthree\nfour\nfive\nsix\nseven";
for (command, expected) in [
("2yy", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
("y2y", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
(
"2y3y",
"one\none\ntwo\nthree\nfour\nfive\nsix\ntwo\nthree\nfour\nfive\nsix\nseven",
),
(
"9yy",
"one\none\ntwo\nthree\nfour\nfive\nsix\nseven\ntwo\nthree\nfour\nfive\nsix\nseven",
),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, command);
assert_eq!(editor.context().text, source, "{command}");
keys(&mut editor, "p");
assert_eq!(editor.context().text, expected, "{command}p");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
}
#[test]
fn visual_character_yank_pastes_before_or_after_whole_extended_graphemes() {
let source = "ae\u{301}👩‍🚀z\nx👨‍👩‍👧‍👦q\n";
for (paste, expected, column) in [
('p', "ae\u{301}👩‍🚀z\nx👨‍👩‍👧‍👦e\u{301}👩‍🚀q\n", 3),
('P', "ae\u{301}👩‍🚀z\nxe\u{301}👩‍🚀👨‍👩‍👧‍👦q\n", 2),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, "lvly");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
keys(&mut editor, "j0l");
let caret = editor.cursor();
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "{paste}");
assert_eq!(editor.cursor(), Cursor { row: 1, column });
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
editor.handle(Input::Char(paste));
assert_eq!(
editor.context().text,
expected,
"register survives undo and paste"
);
}
}
#[test]
fn dot_keeps_the_pasted_line_shape_without_replacing_the_new_yank_register() {
let fixture = Fixture::new();
let source = " a\nb\nc";
let mut editor = fixture.editor(source);
keys(&mut editor, "yyp");
assert_eq!(editor.context().text, " a\n a\nb\nc");
keys(&mut editor, "jyy.");
assert_eq!(editor.context().text, " a\n a\nb\n a\nc");
keys(&mut editor, "u");
assert_eq!(editor.context().text, " a\n a\nb\nc");
keys(&mut editor, "p");
assert_eq!(editor.context().text, " a\n a\nb\nb\nc");
keys(&mut editor, "u");
assert_eq!(editor.context().text, " a\n a\nb\nc");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
#[test]
fn native_multicarets_yank_matching_whole_lines_and_paste_in_one_undo_step() {
let fixture = Fixture::new();
let source = " a\n\tb\nc";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
assert_eq!(carets.len(), 2);
keys(&mut editor, "yy");
assert_eq!(editor.carets(), carets);
keys(&mut editor, "p");
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
assert_eq!(
editor.carets(),
vec![Cursor { row: 1, column: 1 }, Cursor { row: 3, column: 1 }],
);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.carets(), carets);
assert!(!editor.dirty());
keys(&mut editor, "P");
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
}
#[test]
fn a_visual_yank_ending_in_a_newline_stays_characterwise() {
let fixture = Fixture::new();
let source = "\nxy";
let mut editor = fixture.editor(source);
keys(&mut editor, "vyj0p");
assert_eq!(editor.context().text, "\nx\ny");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
keys(&mut editor, "P");
assert_eq!(editor.context().text, "\n\nxy");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}