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
+1
View File
@@ -25,6 +25,7 @@
- [x] better fuzzy search when searching revisions and bookmarks (or the git alternatives) in before and after picker for diff view.
- [x] git blame (enabled by default).
- [x] when jumping to lsp definitions, that line should be center of the buffer (currently placed at the top of the buffer view)
- [ ] diff view: add ability to mark files as reviewed. this should just put a check mark left of the file modification indicator (e.g., M, A, D, maybe others)
- [ ] buffer picker (with search).
- [ ] pull a pr description and display it in a buffer:
- open a selector that allows you to pick a pr from remote
+2 -2
View File
@@ -151,7 +151,7 @@ Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab a
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
Normal `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans. Leading and post-operator counts multiply. Bind `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, or `config.core.delete_word_backward` to another key or sequence for another route. A configured/disabled `d` or `c`, a configured sequence starting with that key, or choosing it as the leader overrides its built-in operator prefix. These are dedicated actions, not general operator–motion grammar.
Normal `yy` copies complete rows without moving the carets; `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans. Leading and post-operator counts multiply. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, or `config.core.delete_word_backward` to another key or sequence for another route. A configured/disabled operator key, a configured sequence starting with that key, or choosing it as the leader overrides its built-in operator prefix. These are dedicated actions, not general operator–motion grammar.
Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `config.core.change_to_line_end`, `config.core.delete_to_line_end`, and `config.core.copy_to_line_end`. Normal `r` waits for a literal replacement character through `config.core.replace_character`; `s` deletes the current grapheme and enters Insert through `config.core.substitute_character`. Rebinding these actions preserves their native counts, Unicode boundaries, undo, and read-only guards.
@@ -164,7 +164,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
| Normal | `insert`, `append`, `open_below`, `open_above` |
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
| Normal | `join_lines`, `undo`, `redo`, `command` |
| Normal | `delete_line`, `change_line` |
| Normal | `copy_line`, `delete_line`, `change_line` |
| Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `replace_character`, `substitute_character`, `delete_word_forward`, `delete_word_backward` |
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
+5 -3
View File
@@ -26,7 +26,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
| Normal | `J` (Shift+j) joins each targeted line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
| Normal | `dd` deletes whole lines; `cc` replaces them and enters Insert; leading counts work (`3dd`, `2cc`) |
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
| Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
| Normal | `r<char>` replaces the current grapheme; `s` deletes it and enters Insert |
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
@@ -94,7 +94,9 @@ The status bar displays a pending count. Esc cancels it without moving, collapsi
`dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row.
The first `d` or `c` waits for its second key and appears in the status bar. `d` accepts `d`, `w`, or `b`; `c` accepts `c`. Counts before and after the operator multiply: `2d3w` deletes six forward word spans, and `d2d` deletes two rows. Escape cancels only the pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
`yy` copies the entire current line into the configured linewise clipboard without moving any caret, changing text, or replacing undo/redo and dot-repeat history. Counts before and after `y` multiply (`2yy`, `y2y`, `2y3y`), selecting rows downward and clamping at EOF. Empty and unterminated final rows retain linewise shape. `p` pastes these complete rows below the current line; `P` pastes above it. The caret lands on the first nonblank of the first inserted row. Characterwise yanks still paste after/before the current grapheme. Bind `config.core.copy_line` for another Normal-mode route; historical read-only buffers permit yanking but reject paste.
The first `d`, `c`, or `y` waits for its second key and appears in the status bar. `d` accepts `d`, `w`, or `b`; `c` accepts `c`; `y` accepts `y`. Counts before and after the operator multiply: `2d3w` deletes six forward word spans, and `d2d` deletes two rows. Escape cancels only the pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys.
@@ -114,7 +116,7 @@ Copy and cut return to Normal at each selection start; Visual cancellation keeps
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.
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `dd`, `cc`, `dw`, and `db`, but not general operator grammar such as `cw` or `yy`; blockwise Visual is also unsupported. `c` and `d` type literal text in Insert mode.
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, and `db`, but not general operator grammar such as `cw`; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode.
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
+162 -6
View File
@@ -524,6 +524,7 @@ core_actions![
("core.change", Action::Change, &[Mode::Visual]),
("core.delete_line", Action::DeleteLine, &[Mode::Normal]),
("core.change_line", Action::ChangeLine, &[Mode::Normal]),
("core.copy_line", Action::CopyLine, &[Mode::Normal]),
(
"core.change_to_line_end",
Action::ChangeToLineEnd,
@@ -5885,10 +5886,10 @@ impl Editor {
return None;
}
let action = match input {
Input::Char(ch) if ch == operator => Some(if operator == 'd' {
Action::DeleteLine
} else {
Action::ChangeLine
Input::Char(ch) if ch == operator => Some(match operator {
'd' => Action::DeleteLine,
'y' => Action::CopyLine,
_ => Action::ChangeLine,
}),
Input::Char('w') if operator == 'd' => Some(Action::DeleteWordForward),
Input::Char('b') if operator == 'd' => Some(Action::DeleteWordBackward),
@@ -5967,7 +5968,7 @@ impl Editor {
return None;
}
if self.active.view.mode == Mode::Normal
&& let Input::Char(operator @ ('c' | 'd')) = input
&& let Input::Char(operator @ ('c' | 'd' | 'y')) = input
{
self.line_pending = Some((operator, self.count.take().unwrap_or(1)));
return None;
@@ -6197,6 +6198,7 @@ impl Editor {
Action::DeleteLine | Action::ChangeLine => {
self.edit_lines(matches!(action, Action::ChangeLine), count);
}
Action::CopyLine => self.copy_lines(count),
Action::ReplaceCharacter => {
self.line_pending = Some(('r', count));
}
@@ -7912,6 +7914,29 @@ impl Editor {
self.selections()
}
fn copy_lines(&mut self, count: usize) {
let targets = self.active.view.targets.clone();
let primary = self.active.view.primary;
let mode = self.active.view.mode;
let linewise = self.active.view.linewise;
let visual_motions = self.active.view.visual_motions.take();
let ranges = self.line_selections(count);
let fragments = self.selection_fragments(&ranges);
self.active.view.targets = targets;
self.active.view.primary = primary;
self.active.view.mode = mode;
self.active.view.linewise = linewise;
self.active.view.visual_motions = visual_motions;
match self.store_clipboard(
fragments,
self.clipboard_mode == ClipboardMode::System,
true,
) {
Ok(()) => self.set_message("Copied lines"),
Err(error) => self.set_message(error),
}
}
fn edit_lines(&mut self, change: bool, count: usize) {
let targets = self.active.view.targets.clone();
let primary = self.active.view.primary;
@@ -10284,6 +10309,93 @@ mod tests {
editor.set_clipboard_mode("separate").unwrap();
editor
}
#[test]
fn copy_line_action_and_y_prefix_respect_bindings_and_cancellation() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("one\ntwo".into())])
.unwrap();
editor
.bind("normal", "q", Some("core.copy_line".into()))
.unwrap();
assert!(
editor
.bind("visual", "q", Some("core.copy_line".into()))
.is_err()
);
assert!(
editor
.bind("insert", "q", Some("core.copy_line".into()))
.is_err()
);
type_text(&mut editor, "q");
assert_eq!(editor.clipboard, ["one\n"]);
type_text(&mut editor, "2y3");
assert_eq!(editor.line_operator_pending(), Some('y'));
assert_eq!(editor.count(), Some(6));
editor.handle(Input::Escape);
assert_eq!(editor.line_operator_pending(), None);
assert_eq!(editor.count(), None);
editor
.bind("normal", "y", Some("core.down".into()))
.unwrap();
type_text(&mut editor, "y");
assert_eq!(editor.cursor().row, 1);
assert_eq!(editor.line_operator_pending(), None);
editor.bind("normal", "y", None).unwrap();
type_text(&mut editor, "yy");
assert_eq!(editor.clipboard, ["one\n"]);
}
#[test]
fn copy_line_restores_overlapping_multicarets_and_preserves_redo_and_repeat() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("one\ntwo\nthree".into())])
.unwrap();
type_text(&mut editor, "iX");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
editor.active.view.targets = vec![
Target {
anchor: Cursor { row: 0, column: 1 },
active: Cursor { row: 0, column: 1 },
preferred_columns: Some((1, 1)),
},
Target {
anchor: Cursor { row: 1, column: 2 },
active: Cursor { row: 1, column: 2 },
preferred_columns: Some((2, 2)),
},
];
editor.active.view.primary = 1;
let targets = editor.active.view.targets.clone();
let history = format!(
"{:?}",
(&editor.active.undo, &editor.active.redo, &editor.last_edit)
);
let revision = editor.text_revision();
type_text(&mut editor, "2yy");
assert_eq!(editor.clipboard, ["one\ntwo\nthree\n"]);
assert!(editor.clipboard_linewise);
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.active.view.primary, 1);
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.active.view.linewise);
assert_eq!(editor.text_revision(), revision);
assert_eq!(
format!(
"{:?}",
(&editor.active.undo, &editor.active.redo, &editor.last_edit)
),
history
);
editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, "XXone\ntwo\nthree");
}
fn type_text(editor: &mut Editor, text: &str) {
for ch in text.chars() {
editor.handle(if ch == '\n' {
@@ -12895,6 +13007,16 @@ mod tests {
let id = editor.buffer_id();
type_text(&mut editor, "2w");
assert_eq!(editor.cursor().column, 11);
let targets = editor.active.view.targets.clone();
let revision = editor.text_revision();
type_text(&mut editor, "yy");
assert_eq!(editor.clipboard, ["alpha beta alpha\n"]);
assert!(editor.clipboard_linewise);
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.text_revision(), revision);
editor.handle(Input::Char('p'));
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.text_revision(), revision);
type_text(&mut editor, "0v4ly");
assert_eq!(editor.clipboard, ["alpha"]);
assert_eq!(editor.mode(), Mode::Normal);
@@ -15207,7 +15329,7 @@ mod tests {
let carets = editor.carets();
let revision = editor.text_revision();
fixture.fail("fail-write", true);
for keys in ["2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b"] {
for keys in ["yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b"] {
type_text(&mut editor, keys);
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
assert_eq!(editor.carets(), carets);
@@ -15221,6 +15343,40 @@ mod tests {
assert_eq!(editor.context().text, "XXone\ntXwo\nthree");
}
#[test]
fn copy_line_publication_failure_keeps_owned_shape_register_and_full_state() {
let fixture = Fixture::new();
let mut editor = fixture.editor();
editor
.apply(vec![Effect::Replace("one\ntwo\n".into())])
.unwrap();
fixture.fail("fail-write", true);
let initial = state(&editor);
type_text(&mut editor, "yy");
assert_eq!(state(&editor), initial);
assert!(editor.owned_system_clipboard.is_none());
fixture.fail("fail-write", false);
type_text(&mut editor, "yy");
assert_eq!(fixture.bytes("state"), b"one\n");
assert_eq!(
editor.owned_system_clipboard,
Some(("one\n".into(), vec!["one\n".into()], true))
);
editor.handle(Input::Char('j'));
fixture.fail("fail-write", true);
let before = state(&editor);
type_text(&mut editor, "2yy");
assert_eq!(state(&editor), before);
assert_eq!(fixture.bytes("state"), b"one\n");
fixture.fail("fail-write", false);
type_text(&mut editor, "jyy");
assert_eq!(fixture.bytes("state"), b"\n");
assert_eq!(
editor.owned_system_clipboard,
Some(("\n".into(), vec!["\n".into()], true))
);
}
// Includes every replay-owned state, not the intentionally changed error message.
fn state(editor: &Editor) -> String {
format!(
+2 -1
View File
@@ -28,7 +28,8 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
Normal/Visual: . repeats last native edit at current targets (one undo group)
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
+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());
}