feat: add counted cc and dd line actions

This commit is contained in:
2026-10-10 21:15:21 +02:00
parent 232be6a032
commit 4e9378816e
7 changed files with 310 additions and 15 deletions
+285 -9
View File
@@ -246,6 +246,10 @@ enum EditStart {
change: bool,
selector: SelectionIntent,
},
Lines {
change: bool,
count: usize,
},
Delete,
Join,
Paste {
@@ -303,6 +307,8 @@ enum Action {
Normal,
Newline,
Backspace,
DeleteLine,
ChangeLine,
Submit,
Search,
SearchNext,
@@ -407,6 +413,8 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
("core.copy_system", Action::CopySystem, &[Mode::Visual]),
("core.cut", Action::Cut, &[Mode::Visual]),
("core.change", Action::Change, &[Mode::Visual]),
("core.delete_line", Action::DeleteLine, &[Mode::Normal]),
("core.change_line", Action::ChangeLine, &[Mode::Normal]),
("core.paste_before", Action::PasteBefore, &[Mode::Normal]),
("core.paste_after", Action::PasteAfter, &[Mode::Normal]),
(
@@ -1003,6 +1011,7 @@ pub struct Editor {
leader_suffix: Vec<Input>,
leader_display: String,
g_pending: bool,
line_pending: Option<char>,
palette: Option<Palette>,
hover: Option<Hover>,
completion: Option<CompletionState>,
@@ -1069,6 +1078,7 @@ impl Editor {
leader_suffix: Vec::new(),
leader_display: String::new(),
g_pending: false,
line_pending: None,
palette: None,
hover: None,
completion: None,
@@ -2132,14 +2142,19 @@ impl Editor {
self.g_pending
}
pub fn line_operator_pending(&self) -> Option<char> {
self.line_pending
}
fn clear_leader(&mut self) {
self.g_pending = false;
self.line_pending = None;
self.leader_suffix.clear();
self.leader_display.clear();
}
fn cancel_leader(&mut self) {
if self.leader_pending().is_some() || self.g_pending {
if self.leader_pending().is_some() || self.g_pending || self.line_pending.is_some() {
self.count = None;
self.clear_leader();
}
@@ -2546,6 +2561,8 @@ impl Editor {
.collect();
let native = match action {
Some(Action::FollowLink) if self.active.mode == Mode::Normal => Some("gf"),
Some(Action::DeleteLine) if self.active.mode == Mode::Normal => Some("dd"),
Some(Action::ChangeLine) if self.active.mode == Mode::Normal => Some("cc"),
Some(Action::DocumentStart)
if matches!(self.active.mode, Mode::Normal | Mode::Visual) =>
{
@@ -2558,14 +2575,16 @@ impl Editor {
}
_ => None,
};
if let Some(native) = native
&& self.leader != Input::Char('g')
&& !self
.bindings
.get(&self.active.mode)
.is_some_and(|keys| keys.contains_key(&Input::Char('g')))
{
bindings.push(native.into());
if let Some(native) = native {
let prefix = Input::Char(native.as_bytes()[0] as char);
if self.leader != prefix
&& !self
.bindings
.get(&self.active.mode)
.is_some_and(|keys| keys.contains_key(&prefix))
{
bindings.push(native.into());
}
}
if !self
.bindings
@@ -2877,6 +2896,19 @@ impl Editor {
if self.palette.is_some() {
return self.handle_palette(input);
}
if let Some(operator) = self.line_pending.take() {
if self.active.mode == Mode::Normal && input == Input::Char(operator) {
return self.act(if operator == 'd' {
Action::DeleteLine
} else {
Action::ChangeLine
});
}
self.count = None;
if input == Input::Escape {
return None;
}
}
// Paste is literal input, never a configurable key or command.
if let Input::Paste(text) = input {
self.count = None;
@@ -2942,6 +2974,12 @@ impl Editor {
self.g_pending = true;
return None;
}
if self.active.mode == Mode::Normal
&& let Input::Char(operator @ ('c' | 'd')) = input
{
self.line_pending = Some(operator);
return None;
}
self.count = None;
match (self.active.mode, input) {
(Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => {
@@ -3031,6 +3069,9 @@ impl Editor {
}
Action::JumpBack => self.traverse_jumps(false, count),
Action::JumpForward => self.traverse_jumps(true, count),
Action::DeleteLine | Action::ChangeLine => {
self.edit_lines(matches!(action, Action::ChangeLine), count);
}
Action::LspDefinition => return self.execute("lsp.definition"),
Action::LspHover => return self.execute("lsp.hover"),
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
@@ -4198,6 +4239,72 @@ impl Editor {
self.message.clear();
}
fn line_selections(&mut self, count: usize) -> Vec<Selection> {
self.active.mode = Mode::Visual;
self.active.linewise = true;
self.active.visual_motions = None;
let last = self.active.lines.len() - 1;
for target in &mut self.active.targets {
target.anchor = Cursor {
row: target.active.row,
column: 0,
};
target.active = Cursor {
row: target
.anchor
.row
.saturating_add(count.saturating_sub(1))
.min(last),
column: 0,
};
target.preferred_columns = None;
}
self.normalize_targets();
self.selections()
}
fn edit_lines(&mut self, change: bool, count: usize) {
let targets = self.active.targets.clone();
let primary = self.active.primary;
let ranges = self.line_selections(count);
let fragments = self.selection_fragments(&ranges);
if let Err(error) = self.store_clipboard(
fragments,
self.clipboard_mode == ClipboardMode::System,
true,
) {
self.active.targets = targets;
self.active.primary = primary;
self.active.mode = Mode::Normal;
self.active.linewise = false;
self.set_message(error);
return;
}
let before = Snapshot {
lines: self.active.lines.clone(),
targets,
primary,
};
let start = EditStart::Lines { change, count };
if change {
self.start_insert(before, start);
self.edit_ranges(Action::Change, &ranges);
} else {
self.edit_ranges(Action::Cut, &ranges);
self.active.mode = Mode::Normal;
self.normalize_targets();
self.record_native(
before,
EditRecipe {
start,
steps: Vec::new(),
},
);
}
self.message.clear();
self.reveal_caret();
}
fn edit_selection(&mut self, action: Action) {
let ranges = self.selections();
let fragments = self.selection_fragments(&ranges);
@@ -4803,6 +4910,15 @@ impl Editor {
let mut clipboard = None;
match &recipe.start {
EditStart::Insert(action) => self.place_insert(*action),
EditStart::Lines { change, count } => {
let ranges = self.line_selections(*count);
clipboard = Some((self.selection_fragments(&ranges), true));
self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges);
if !*change {
self.active.mode = Mode::Normal;
self.normalize_targets();
}
}
EditStart::Selection { change, selector } => {
let ranges = self.replay_ranges(selector);
clipboard = Some((self.selection_fragments(&ranges), self.visual_linewise()));
@@ -6059,6 +6175,137 @@ mod tests {
});
}
}
#[test]
fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() {
let mut editor = editor();
let source = "zero\n αone\n\tlast\nend";
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
type_text(&mut editor, "jll2dd");
assert_eq!(editor.context().text, "zero\nend");
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
type_text(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 });
type_text(&mut editor, "UP");
assert_eq!(editor.context().text, source);
}
#[test]
fn line_actions_counted_change_groups_typing_and_repeats_resolved_rows() {
let mut editor = editor();
let source = "keep\n one\ntwo\nend";
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
type_text(&mut editor, "j2cc");
assert_eq!(editor.context().text, "keep\n\nend");
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 });
type_text(&mut editor, "new");
editor.handle(Input::Escape);
type_text(&mut editor, "u");
assert_eq!(editor.context().text, source);
type_text(&mut editor, "Uj.");
assert_eq!(editor.context().text, "keep\nnew\nnew");
assert_eq!(editor.mode(), Mode::Normal);
type_text(&mut editor, "u");
assert_eq!(editor.context().text, "keep\nnew\nend");
}
#[test]
fn line_actions_eof_empty_rows_and_repeat_keep_the_requested_count() {
for (source, keys, expected) in [
("a\nb", "jdd", "a"),
("a\nb\n", "jdd", "a\n"),
("a\nb\n", "gbdd", "a\nb"),
("a", "dd", ""),
("", "dd", ""),
("a\nb\nc", "2dd", "c"),
("a\nb", "jcc", "a\n"),
] {
let mut editor = editor();
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
type_text(&mut editor, keys);
assert_eq!(editor.context().text, expected, "{keys} on {source:?}");
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
if source != expected {
type_text(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
}
let directory = Directory::new();
let target = directory.path("repeat");
fs::write(&target, "1\n2\n3\n4\n5").unwrap();
let mut editor = editor();
editor
.apply(vec![Effect::Replace("a\nb\nc".into())])
.unwrap();
type_text(&mut editor, "gb3dd");
assert_eq!(editor.context().text, "a\nb");
editor.execute(&format!("edit {}", target.display()));
type_text(&mut editor, ".");
assert_eq!(editor.context().text, "4\n5");
type_text(&mut editor, "u");
assert_eq!(editor.context().text, "1\n2\n3\n4\n5");
type_text(&mut editor, &format!("{}dd", usize::MAX));
assert_eq!(editor.context().text, "");
}
#[test]
fn line_actions_merge_overlaps_but_keep_adjacent_change_carets() {
let mut editor = editor();
let source = "aa\nbb\ncc\ndd\nee";
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
type_text(&mut editor, "2dd");
assert_eq!(editor.context().text, "dd\nee");
type_text(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.carets(), carets);
type_text(&mut editor, "UP");
assert_eq!(editor.context().text, source);
editor.handle(Input::Escape);
editor.handle(Input::Ctrl('e'));
type_text(&mut editor, "ccX");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "X\nX\ncc\ndd\nee");
type_text(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
#[test]
fn line_actions_prefix_cancellation_bindings_and_insert_literals_are_independent() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("a\nb\nc\nd".into())])
.unwrap();
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
type_text(&mut editor, "2d");
editor.handle(Input::Escape);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.context().text, "a\nb\nc\nd");
editor.handle(Input::Escape);
type_text(&mut editor, "gtdj");
assert_eq!(editor.cursor().row, 1);
editor.bind("normal", "d", None).unwrap();
type_text(&mut editor, "dd");
assert_eq!(editor.context().text, "a\nb\nc\nd");
editor
.bind("normal", "z", Some("core.delete_line".into()))
.unwrap();
type_text(&mut editor, "2z");
assert_eq!(editor.context().text, "a\nd");
editor
.bind("normal", "q", Some("core.change_line".into()))
.unwrap();
type_text(&mut editor, "qccdd");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "a\nccdd");
}
fn project_editor(directory: &Directory) -> Editor {
let mut editor = editor();
editor.project_root = directory.0.clone();
@@ -7237,6 +7484,35 @@ mod tests {
}
}
#[test]
fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() {
let fixture = Fixture::new();
fixture.external("keep");
let mut editor = fixture.editor();
editor
.apply(vec![Effect::Replace("one\ntwo\nthree".into())])
.unwrap();
type_text(&mut editor, "iX");
editor.handle(Input::Escape);
type_text(&mut editor, "l");
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
let revision = editor.text_revision();
fixture.fail("fail-write", true);
for keys in ["2dd", "cc"] {
type_text(&mut editor, keys);
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
assert_eq!(editor.carets(), carets);
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.text_revision(), revision);
assert_eq!(fixture.bytes("state"), b"keep");
}
type_text(&mut editor, "u");
assert_eq!(editor.context().text, "one\ntwo\nthree");
type_text(&mut editor, "U.");
assert_eq!(editor.context().text, "XXone\ntXwo\nthree");
}
// Includes every replay-owned state, not the intentionally changed error message.
fn state(editor: &Editor) -> String {
format!(
+1
View File
@@ -22,6 +22,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
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
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
+3
View File
@@ -746,6 +746,9 @@ fn draw(
if editor.g_pending() {
status.push_str(" g");
}
if let Some(operator) = editor.line_operator_pending() {
write!(status, " {operator}").unwrap();
}
if editor.buffer_count() > 1 {
write!(
status,