fix: replace selected characters with visual r

This commit is contained in:
2026-10-13 23:43:49 +02:00
parent 11b31d2621
commit cd427f74dc
5 changed files with 209 additions and 24 deletions
+3 -2
View File
@@ -185,7 +185,7 @@ With a decimal prefix, `g` instead jumps immediately to that one-based source li
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. `cw` / `cb` cut the same spans and enter Insert. Leading and post-operator counts multiply and word edits stop at the line end/start. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, `config.core.delete_word_backward`, `config.core.change_word_forward`, or `config.core.change_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. `cw` / `cb` cut the same spans and enter Insert. Leading and post-operator counts multiply and word edits stop at the line end/start. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, `config.core.delete_word_backward`, `config.core.change_word_forward`, or `config.core.change_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 and Visual `r` wait for a literal replacement character through `config.core.replace_character`: Normal honors the count; Visual replaces each selected grapheme without replacing newlines. Normal `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.
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands. `f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
@@ -197,7 +197,8 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` | | Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
| Normal | `join_lines`, `undo`, `redo`, `command` | | Normal | `join_lines`, `undo`, `redo`, `command` |
| Normal | `copy_line`, `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`, `change_word_forward`, `change_word_backward` | | Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` |
| Normal, Visual | `replace_character` |
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` | | 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` | | Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
| Normal | `follow_link` | | Normal | `follow_link` |
+4 -1
View File
@@ -35,7 +35,8 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode | | Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `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 | `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, Visual | `r<char>` replaces the current grapheme in Normal or every selected grapheme in Visual |
| Normal | `s` deletes the current grapheme and enters Insert |
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` | | Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
| Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` | | Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` |
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group | | Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
@@ -126,6 +127,8 @@ 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. 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.
Visual `r<char>` replaces every selected grapheme with the literal character, preserving newline separators and the clipboard. It returns to Normal at each selection start as one undo action and supports `.` repeat. Esc while waiting for the character cancels the replacement without clearing selections. Identical replacements and newline-only selections do not add history or discard redo.
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`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; blockwise Visual is also unsupported. `c`, `d`, and `y` 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`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; 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. 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.
+118 -21
View File
@@ -325,6 +325,10 @@ enum EditStart {
target: char, target: char,
count: usize, count: usize,
}, },
ReplaceSelection {
target: char,
selector: SelectionIntent,
},
Delete, Delete,
Join, Join,
Paste { Paste {
@@ -559,7 +563,7 @@ core_actions![
( (
"core.replace_character", "core.replace_character",
Action::ReplaceCharacter, Action::ReplaceCharacter,
&[Mode::Normal] &[Mode::Normal, Mode::Visual]
), ),
( (
"core.substitute_character", "core.substitute_character",
@@ -1165,7 +1169,6 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('C', ChangeToLineEnd), ('C', ChangeToLineEnd),
('D', DeleteToLineEnd), ('D', DeleteToLineEnd),
('Y', CopyToLineEnd), ('Y', CopyToLineEnd),
('r', ReplaceCharacter),
('s', SubstituteCharacter), ('s', SubstituteCharacter),
('u', Undo), ('u', Undo),
('U', Redo), ('U', Redo),
@@ -1218,6 +1221,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('v', Select), ('v', Select),
('V', SelectLinewise), ('V', SelectLinewise),
('.', RepeatLastEdit), ('.', RepeatLastEdit),
('r', ReplaceCharacter),
] { ] {
bindings bindings
.entry(mode) .entry(mode)
@@ -6217,7 +6221,7 @@ impl Editor {
} }
if let Some((operator, prefix)) = self.line_pending.take() { if let Some((operator, prefix)) = self.line_pending.take() {
if operator == 'r' { if operator == 'r' {
if self.active.view.mode == Mode::Normal if matches!(self.active.view.mode, Mode::Normal | Mode::Visual)
&& let Input::Char(target) = input && let Input::Char(target) = input
&& (!target.is_control() || target == '\t') && (!target.is_control() || target == '\t')
{ {
@@ -6225,14 +6229,24 @@ impl Editor {
self.set_message("Diff pane is read-only"); self.set_message("Diff pane is read-only");
} else { } else {
let before = self.snapshot(); let before = self.snapshot();
self.replace_characters(target, prefix); let (ranges, start) = if self.active.view.mode == Mode::Visual {
self.record_native( let ranges = self.selections();
before, let selector = self.selection_intent(&ranges);
EditRecipe { (ranges, EditStart::ReplaceSelection { target, selector })
start: EditStart::Replace { } else {
(
self.normal_ranges(Action::SubstituteCharacter, prefix),
EditStart::Replace {
target, target,
count: prefix, count: prefix,
}, },
)
};
self.replace_characters(target, &ranges);
self.record_native(
before,
EditRecipe {
start,
steps: Vec::new(), steps: Vec::new(),
}, },
); );
@@ -8462,8 +8476,7 @@ impl Editor {
self.reveal_caret(); self.reveal_caret();
} }
fn replace_characters(&mut self, replacement: char, count: usize) { fn replace_characters(&mut self, replacement: char, ranges: &[Selection]) {
let ranges = self.normal_ranges(Action::SubstituteCharacter, count);
let offsets = line_offsets(&self.active.lines); let offsets = line_offsets(&self.active.lines);
let mut bytes = [0; 4]; let mut bytes = [0; 4];
let replacement: &str = replacement.encode_utf8(&mut bytes); let replacement: &str = replacement.encode_utf8(&mut bytes);
@@ -8471,20 +8484,52 @@ impl Editor {
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, range)| range.start != range.end) .filter(|(_, range)| range.start != range.end)
.map(|(target, range)| Edit { .map(|(target, range)| {
target: Some(target), let start = cursor_offset(&self.active.lines, &offsets, range.start);
start: cursor_offset(&self.active.lines, &offsets, range.start), let end = cursor_offset(&self.active.lines, &offsets, range.end);
end: cursor_offset(&self.active.lines, &offsets, range.end), let text = if range.start.row == range.end.row {
text: if range.end.column - range.start.column == 1 { let count = range.end.column - range.start.column;
Cow::Borrowed(replacement) if count == 1 {
Cow::Borrowed(replacement)
} else {
Cow::Owned(replacement.repeat(count))
}
} else { } else {
Cow::Owned(replacement.repeat(range.end.column - range.start.column)) let mut text = String::with_capacity(end - start);
}, for row in range.start.row..=range.end.row {
caret: 0, let first = if row == range.start.row {
round_up: false, range.start.column
} else {
0
};
let last = if row == range.end.row {
range.end.column
} else {
grapheme_count(&self.active.lines[row])
};
text.reserve((last - first) * replacement.len());
for _ in first..last {
text.push_str(replacement);
}
if row < range.end.row {
text.push('\n');
}
}
Cow::Owned(text)
};
Edit {
target: Some(target),
start,
end,
text,
caret: 0,
round_up: false,
}
}) })
.collect(); .collect();
self.apply_edits(edits); self.apply_edits(edits);
self.active.view.visual_motions = None;
self.active.view.linewise = false;
self.active.view.mode = Mode::Normal; self.active.view.mode = Mode::Normal;
self.normalize_targets(); self.normalize_targets();
} }
@@ -9021,10 +9066,18 @@ impl Editor {
EditStart::Selection { EditStart::Selection {
selector: SelectionIntent::Spans(spans), selector: SelectionIntent::Spans(spans),
.. ..
}
| EditStart::ReplaceSelection {
selector: SelectionIntent::Spans(spans),
..
} if self.active.view.mode != Mode::Visual => Some(spans.len()), } if self.active.view.mode != Mode::Visual => Some(spans.len()),
EditStart::Selection { EditStart::Selection {
selector: SelectionIntent::Rows(rows), selector: SelectionIntent::Rows(rows),
.. ..
}
| EditStart::ReplaceSelection {
selector: SelectionIntent::Rows(rows),
..
} if self.active.view.mode != Mode::Visual => Some(rows.len()), } if self.active.view.mode != Mode::Visual => Some(rows.len()),
_ => None, _ => None,
}; };
@@ -9117,7 +9170,14 @@ impl Editor {
self.normalize_targets(); self.normalize_targets();
} }
} }
EditStart::Replace { target, count } => self.replace_characters(*target, *count), EditStart::Replace { target, count } => {
let ranges = self.normal_ranges(Action::SubstituteCharacter, *count);
self.replace_characters(*target, &ranges);
}
EditStart::ReplaceSelection { target, selector } => {
let ranges = self.replay_ranges(selector);
self.replace_characters(*target, &ranges);
}
EditStart::Lines { change, count } => { EditStart::Lines { change, count } => {
let ranges = self.line_selections(*count); let ranges = self.line_selections(*count);
clipboard = Some((self.selection_fragments(&ranges), true)); clipboard = Some((self.selection_fragments(&ranges), true));
@@ -19937,6 +19997,43 @@ mod tests {
} }
} }
#[test]
fn readonly_visual_replacement_preserves_snapshot_selections_and_history() {
let directory = Directory::new();
let mut editor = editor();
historical_diff(
&mut editor,
&directory.0,
vec!["one two three".into()],
vec!["four five six".into()],
);
for focus in [Focus::Before, Focus::After] {
if editor.diff().unwrap().focus != focus {
editor.handle(Input::Char('\t'));
}
type_text(&mut editor, "vl");
let text = editor.context().text;
let targets = editor.active.view.targets.clone();
let undo = editor.active.undo.len();
let clipboard = editor.clipboard.clone();
for pending in [false, true] {
if pending {
editor.line_pending = Some(('r', 1));
} else {
editor.handle(Input::Char('r'));
}
editor.handle(Input::Char('X'));
assert_eq!(editor.context().text, text);
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.line_operator_pending(), None);
assert_eq!(editor.clipboard, clipboard);
assert_eq!(editor.active.undo.len(), undo);
}
editor.handle(Input::Escape);
}
}
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
editor.active.view.mode = Mode::Normal; editor.active.view.mode = Mode::Normal;
editor.active.view.visual_motions = None; editor.active.view.visual_motions = None;
+1
View File
@@ -38,6 +38,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
Esc keeps only the primary caret Esc keeps only the primary caret
Visual: motions extend selections, y copy, d/x/Delete cut, c change Visual: motions extend selections, y copy, d/x/Delete cut, c change
r<char> replaces every selected grapheme, preserving newlines; one undo action
I/A insert at each selected line's first nonblank/end I/A insert at each selected line's first nonblank/end
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
v/V switches selection shape; same selector or Esc cancels retaining carets v/V switches selection shape; same selector or Esc cancels retaining carets
+83
View File
@@ -42,6 +42,89 @@ fn keys(editor: &mut Editor, text: &str) {
} }
} }
#[test]
fn visual_r_replaces_selected_graphemes_as_one_undo_edit() {
let fixture = Fixture::new();
let source = "ae\u{301}👩‍🚀z\nrest\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "lv2l");
assert_eq!(editor.mode(), Mode::Visual);
keys(&mut editor, "r界");
assert_eq!(editor.context().text, "a界界界\nrest\n");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
keys(&mut editor, "U");
assert_eq!(editor.context().text, "a界界界\nrest\n");
}
#[test]
fn visual_r_preserves_selected_newlines_in_both_directions_and_shapes() {
for (source, selection, expected) in [
("ae\u{301}👩‍🚀z\n\nbc\nlast\n", "v2j", "QQQQ\n\nQc\nlast\n"),
("ae\u{301}👩‍🚀z\n\nbc\nlast\n", "2jv2k", "QQQQ\n\nQc\nlast\n"),
("ae\u{301}👩‍🚀z\n\nbc\nlast\n", "V2j", "QQQQ\n\nQQ\nlast\n"),
("ab\ncd", "Vj", "QQ\nQQ"),
("\n\n", "Vj", "\n\n"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, selection);
keys(&mut editor, "rQ");
assert_eq!(editor.context().text, expected, "{selection}");
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.dirty(), source != expected);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
}
#[test]
fn visual_r_replaces_every_selection_and_restores_carets_on_undo() {
let fixture = Fixture::new();
let source = "abcd\nwxyz\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "vl");
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
assert_eq!(editor.selection_count(), 2);
keys(&mut editor, "r界");
assert_eq!(editor.context().text, "界界cd\n界界yz\n");
assert_eq!(editor.selection_count(), 2);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.carets(), carets);
}
#[test]
fn visual_r_repeats_selection_intent_without_overwriting_the_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\nuvwxyz\n");
keys(&mut editor, "yyvlrQj0.");
assert_eq!(editor.context().text, "QQc\nQQwxyz\n");
keys(&mut editor, "4lv.");
assert_eq!(editor.context().text, "QQc\nQQwxQz\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, "QQc\nabc\nQQwxQz\n");
}
#[test]
fn cancelled_and_identical_visual_replacements_preserve_redo() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\n");
keys(&mut editor, "rQulvr");
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.line_operator_pending(), None);
keys(&mut editor, "rb");
assert_eq!(editor.context().text, "abc\n");
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
keys(&mut editor, "U");
assert_eq!(editor.context().text, "Qbc\n");
}
#[test] #[test]
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() { fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
for operation in ["C", "D", "9C", "9D"] { for operation in ["C", "D", "9C", "9D"] {