From 983f43e710cdecb7f852e0fcb2ad7c6dea911075 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 13 Oct 2026 17:30:00 +0200 Subject: [PATCH] fix: keep counted word deletions within the current line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- TODO.md | 4 +-- docs/configuration.md | 2 +- docs/editing.md | 2 +- src/editor.rs | 39 +++++++++++----------- tests/normal_editing.rs | 71 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 89 insertions(+), 29 deletions(-) diff --git a/TODO.md b/TODO.md index a1ddc20..a7449d1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ -- [ ] noticed that `dw` 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 `dw` 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 `cw` and `cb` 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) - colored symbols: - new: `+` diff --git a/docs/configuration.md b/docs/configuration.md index d2899c9..f4fa4a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. -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. diff --git a/docs/editing.md b/docs/editing.md index 7dc0280..dd3f730 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -105,7 +105,7 @@ System clipboard publication must succeed before any deletion. Failure preserves `r` 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 diff --git a/src/editor.rs b/src/editor.rs index 8fd3a7b..0c9fae8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -8220,11 +8220,8 @@ impl Editor { .map(|(index, target)| { let cursor = target.active; let end = match action { - Action::DeleteWordForward => { - self.word_destination(cursor, Action::WordNext, count) - } - Action::DeleteWordBackward => { - self.word_destination(cursor, Action::WordPrevious, count) + Action::DeleteWordForward | Action::DeleteWordBackward => { + self.word_destination(cursor, action, count) } Action::SubstituteCharacter => Cursor { row: cursor.row, @@ -9313,9 +9310,16 @@ impl Editor { action, Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd ); - let backward = matches!(action, Action::WordPrevious | Action::BigWordPrevious); - let start = if backward { 0 } else { row }; - let end = if backward { + let backward = matches!( + action, + 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 } else { self.active.lines.len() @@ -9325,17 +9329,12 @@ impl Editor { .map(move |(_, byte, kind)| (source_row, byte, kind)) }); let destination = match action { - Action::WordPrevious | Action::BigWordPrevious => { + Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward => { let mut tokens = tokens .rev() .skip_while(|(r, b, _)| (*r, *b) >= (row, byte)) .peekable(); - let fallback = ( - self.navigable_rows(0, self.active.lines.len()) - .next() - .unwrap_or(row), - 0, - ); + let fallback = (self.navigable_rows(start, end).next().unwrap_or(row), 0); let mut destination = fallback; for _ in 0..count { let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace) @@ -9360,13 +9359,13 @@ impl Editor { return cursor; }; let mut current = first.2; - let last = self - .navigable_rows(0, self.active.lines.len()) - .next_back() - .unwrap_or(row); + let last = self.navigable_rows(start, end).next_back().unwrap_or(row); let mut destination = (last, self.active.lines[last].len()); 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 { while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) { tokens.next(); diff --git a/tests/normal_editing.rs b/tests/normal_editing.rs index 19a372e..647e48c 100644 --- a/tests/normal_editing.rs +++ b/tests/normal_editing.rs @@ -218,15 +218,13 @@ fn word_delete_counts_before_and_after_operator_multiply_in_both_directions() { } #[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 [ ("e\u{301}lan 世界 fin\n", "d2w", "fin\n"), ("e\u{301}lan 世界 fin\n", "2wd2b", "fin\n"), ("alpha... beta gamma\n", "d2w", "beta gamma\n"), ("alpha... beta gamma\n", "2wd2b", "beta gamma\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 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] fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() { 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", "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 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.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 source = "one two keep\nthree four stay\n"; let mut editor = fixture.editor(source); editor.handle(Input::Ctrl('e')); 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); keys(&mut editor, "u"); assert_eq!(editor.context().text, source);