fix: keep counted word deletions within the current line

Word deletion reused cross-line word motion destinations. Constrain token traversal and boundary fallbacks to each caret’s current row while preserving cross-line navigation.
This commit is contained in:
2026-10-13 17:30:00 +02:00
parent 99fa5a2aff
commit 983f43e710
5 changed files with 89 additions and 29 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
- [ ] noticed that `d<count>w` also removes the newline and as such merges lines. this is not intended behavior and should be fixed. `dw` should not affect other lines (and neither should `db`). - [x] noticed that `d<count>w` also removes the newline and as such merges lines. this is not intended behavior and should be fixed. `dw` should not affect other lines (and neither should `db`).
- [ ] add `c<count>w` and `c<count>b` actions mimicking the `d` equivalent, but also entering insert mode. - [ ] add `c<count>w` and `c<count>b` actions mimicking the `d` equivalent, but also entering insert mode.
- [ ] add git gutter info: - [ ] add git/jj gutter info:
- fat, vertical, colored bar on inner side of gutter closer to gutter than to text (default) - fat, vertical, colored bar on inner side of gutter closer to gutter than to text (default)
- colored symbols: - colored symbols:
- new: `+` - new: `+`
+1 -1
View File
@@ -156,7 +156,7 @@ The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visua
With a decimal prefix, `g` instead jumps immediately to that one-based source line: `42g` goes to line 42, clamped at EOF, at first nonblank. Normal moves all carets; Visual extends selections. In diff text panes, explicit line jumps reveal folded source lines rather than counting only displayed excerpts. `g` without a count still begins `gt`, `gb`, or `gf`; a count followed by `g` is complete and does not await a suffix. Rebind the Normal/Visual native action `config.core.go_to_line` to use the numeric prefix with another key. Existing `g` bindings and prefixes override this default route. With a decimal prefix, `g` instead jumps immediately to that one-based source line: `42g` goes to line 42, clamped at EOF, at first nonblank. Normal moves all carets; Visual extends selections. In diff text panes, explicit line jumps reveal folded source lines rather than counting only displayed excerpts. `g` without a count still begins `gt`, `gb`, or `gf`; a count followed by `g` is complete and does not await a suffix. Rebind the Normal/Visual native action `config.core.go_to_line` to use the numeric prefix with another key. Existing `g` bindings and prefixes override this default route.
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 `yy` copies complete rows without moving the carets; `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans within each caret's current logical line, preserving newlines. Leading and post-operator counts multiply and word deletions stop at the line end/start. 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. 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.
+1 -1
View File
@@ -105,7 +105,7 @@ System clipboard publication must succeed before any deletion. Failure preserves
`r<char>` replaces one grapheme per caret and stays in Normal; `s` cuts one grapheme and enters Insert. Counts select that many graphemes, clamped to the current line. Combining sequences and ZWJ emoji are never split. Replacement accepts a printable character or Tab literally; Escape or other non-character input cancels it without editing. Empty-line replacement is a no-op; substitution can still enter Insert. `s` and replacement typing form one undo group. `r<char>` replaces one grapheme per caret and stays in Normal; `s` cuts one grapheme and enters Insert. Counts select that many graphemes, clamped to the current line. Combining sequences and ZWJ emoji are never split. Replacement accepts a printable character or Tab literally; Escape or other non-character input cancels it without editing. Empty-line replacement is a no-op; substitution can still enter Insert. `s` and replacement typing form one undo group.
`dw` / `db` cut characterwise spans from each caret to the forward/backward word-start boundary, using the same Unicode word boundaries as `w` / `b`. Counts repeat the boundary motion; backward spans exclude the original caret character. These are dedicated actions, not arbitrary operator–motion grammar. Suffix, substitution, and word cuts share the configured clipboard's failure-before-edit protection; all native edits support undo and dot repeat. `dw` / `db` cut characterwise spans from each caret to the forward/backward word-start boundary, using the same Unicode word boundaries as `w` / `b`, but confined to that caret's current logical line. Counts repeat the boundary motion, stopping at the line end/start without deleting its newline or touching neighboring lines; ordinary `w` / `b` navigation still crosses lines. Backward spans exclude the original caret character. Empty spans do not edit text. These are dedicated actions, not arbitrary operator–motion grammar. Suffix, substitution, and word cuts share the configured clipboard's failure-before-edit protection; all native edits support undo and dot repeat.
## Selections and multi-caret editing ## Selections and multi-caret editing
+19 -20
View File
@@ -8220,11 +8220,8 @@ impl Editor {
.map(|(index, target)| { .map(|(index, target)| {
let cursor = target.active; let cursor = target.active;
let end = match action { let end = match action {
Action::DeleteWordForward => { Action::DeleteWordForward | Action::DeleteWordBackward => {
self.word_destination(cursor, Action::WordNext, count) self.word_destination(cursor, action, count)
}
Action::DeleteWordBackward => {
self.word_destination(cursor, Action::WordPrevious, count)
} }
Action::SubstituteCharacter => Cursor { Action::SubstituteCharacter => Cursor {
row: cursor.row, row: cursor.row,
@@ -9313,9 +9310,16 @@ impl Editor {
action, action,
Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd
); );
let backward = matches!(action, Action::WordPrevious | Action::BigWordPrevious); let backward = matches!(
let start = if backward { 0 } else { row }; action,
let end = if backward { Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward
);
let line_local = matches!(
action,
Action::DeleteWordForward | Action::DeleteWordBackward
);
let start = if line_local || !backward { row } else { 0 };
let end = if line_local || backward {
row + 1 row + 1
} else { } else {
self.active.lines.len() self.active.lines.len()
@@ -9325,17 +9329,12 @@ impl Editor {
.map(move |(_, byte, kind)| (source_row, byte, kind)) .map(move |(_, byte, kind)| (source_row, byte, kind))
}); });
let destination = match action { let destination = match action {
Action::WordPrevious | Action::BigWordPrevious => { Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward => {
let mut tokens = tokens let mut tokens = tokens
.rev() .rev()
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte)) .skip_while(|(r, b, _)| (*r, *b) >= (row, byte))
.peekable(); .peekable();
let fallback = ( let fallback = (self.navigable_rows(start, end).next().unwrap_or(row), 0);
self.navigable_rows(0, self.active.lines.len())
.next()
.unwrap_or(row),
0,
);
let mut destination = fallback; let mut destination = fallback;
for _ in 0..count { for _ in 0..count {
let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace) let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace)
@@ -9360,13 +9359,13 @@ impl Editor {
return cursor; return cursor;
}; };
let mut current = first.2; let mut current = first.2;
let last = self let last = self.navigable_rows(start, end).next_back().unwrap_or(row);
.navigable_rows(0, self.active.lines.len())
.next_back()
.unwrap_or(row);
let mut destination = (last, self.active.lines[last].len()); let mut destination = (last, self.active.lines[last].len());
for _ in 0..count { for _ in 0..count {
let next_start = matches!(action, Action::WordNext | Action::BigWordNext); let next_start = matches!(
action,
Action::WordNext | Action::BigWordNext | Action::DeleteWordForward
);
if next_start && current != WordKind::EmptyLine { if next_start && current != WordKind::EmptyLine {
while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) {
tokens.next(); tokens.next();
+66 -5
View File
@@ -218,15 +218,13 @@ fn word_delete_counts_before_and_after_operator_multiply_in_both_directions() {
} }
#[test] #[test]
fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation_and_rows() { fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation() {
for (source, command, expected) in [ for (source, command, expected) in [
("e\u{301}lan 世界 fin\n", "d2w", "fin\n"), ("e\u{301}lan 世界 fin\n", "d2w", "fin\n"),
("e\u{301}lan 世界 fin\n", "2wd2b", "fin\n"), ("e\u{301}lan 世界 fin\n", "2wd2b", "fin\n"),
("alpha... beta gamma\n", "d2w", "beta gamma\n"), ("alpha... beta gamma\n", "d2w", "beta gamma\n"),
("alpha... beta gamma\n", "2wd2b", "beta gamma\n"), ("alpha... beta gamma\n", "2wd2b", "beta gamma\n"),
("👩‍🚀!!! next\n", "dw", "next\n"), ("👩‍🚀!!! next\n", "dw", "next\n"),
("one\ntwo three\n", "d2w", "three\n"),
("one\ntwo three\n", "2wd2b", "three\n"),
] { ] {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut editor = fixture.editor(source); let mut editor = fixture.editor(source);
@@ -237,6 +235,56 @@ fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation_and_rows()
} }
} }
#[test]
fn word_delete_stops_at_current_line_boundaries() {
for (source, position, command, expected) in [
("one\ntwo three\n", "", "dw", "\ntwo three\n"),
("one\ntwo three\n", "", "d2w", "\ntwo three\n"),
("one two\nthree four\n", "w", "2d3w", "one \nthree four\n"),
("one\ntwo three\n", "2w", "d2b", "one\nthree\n"),
("one\n two three\n", "jw", "2d3b", "one\ntwo three\n"),
("one \ntwo\n", "", "d9w", "\ntwo\n"),
("one\n \ntwo\n", "j", "dw", "one\n\ntwo\n"),
("one\n\ntwo\n", "j", "d9w", "one\n\ntwo\n"),
("one\n\ntwo\n", "j", "d9b", "one\n\ntwo\n"),
("one\ntwo\n", "j0", "db", "one\ntwo\n"),
("one\n two\n", "jl", "db", "one\n two\n"),
(
"one\ne\u{301}lan 世界\nnext\n",
"j0",
"d9w",
"one\n\nnext\n",
),
(
"one\ne\u{301}lan 世界\nnext\n",
"j$",
"d9b",
"one\n界\nnext\n",
),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, position);
let caret = editor.cursor();
keys(&mut editor, command);
assert_eq!(
editor.context().text,
expected,
"{position} {command} on {source:?}"
);
assert_eq!(editor.mode(), Mode::Normal);
if source == expected {
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
} else {
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
}
}
}
#[test] #[test]
fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() { fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() {
let source = "e\u{301}lan 世界"; let source = "e\u{301}lan 世界";
@@ -331,6 +379,14 @@ fn new_edit_recipes_repeat_against_the_current_text_and_undo_as_single_changes()
"j02w", "j02w",
"keep\nstay\n", "keep\nstay\n",
), ),
("one\nthree four\n", "d9w", "\nthree four\n", "j0", "\n\n"),
(
"one keep\nthree four stay\n",
"wd9b",
"keep\nthree four stay\n",
"j02w",
"keep\nstay\n",
),
] { ] {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut editor = fixture.editor(source); let mut editor = fixture.editor(source);
@@ -413,13 +469,18 @@ fn native_multicarets_edit_whole_graphemes_and_yank_matching_suffix_fragments()
assert_eq!(editor.context().text, source); assert_eq!(editor.context().text, source);
assert_eq!(editor.carets(), carets); assert_eq!(editor.carets(), carets);
} }
for command in ["d2w", "2wd2b"] { for (command, expected) in [
("d2w", "keep\nstay\n"),
("2wd2b", "keep\nstay\n"),
("d9w", "\n\n"),
("2wd9b", "keep\nstay\n"),
] {
let fixture = Fixture::new(); let fixture = Fixture::new();
let source = "one two keep\nthree four stay\n"; let source = "one two keep\nthree four stay\n";
let mut editor = fixture.editor(source); let mut editor = fixture.editor(source);
editor.handle(Input::Ctrl('e')); editor.handle(Input::Ctrl('e'));
keys(&mut editor, command); keys(&mut editor, command);
assert_eq!(editor.context().text, "keep\nstay\n", "{command}"); assert_eq!(editor.context().text, expected, "{command}");
assert_eq!(editor.selection_count(), 2); assert_eq!(editor.selection_count(), 2);
keys(&mut editor, "u"); keys(&mut editor, "u");
assert_eq!(editor.context().text, source); assert_eq!(editor.context().text, source);