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
+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