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
+118 -21
View File
@@ -325,6 +325,10 @@ enum EditStart {
target: char,
count: usize,
},
ReplaceSelection {
target: char,
selector: SelectionIntent,
},
Delete,
Join,
Paste {
@@ -559,7 +563,7 @@ core_actions![
(
"core.replace_character",
Action::ReplaceCharacter,
&[Mode::Normal]
&[Mode::Normal, Mode::Visual]
),
(
"core.substitute_character",
@@ -1165,7 +1169,6 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('C', ChangeToLineEnd),
('D', DeleteToLineEnd),
('Y', CopyToLineEnd),
('r', ReplaceCharacter),
('s', SubstituteCharacter),
('u', Undo),
('U', Redo),
@@ -1218,6 +1221,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('v', Select),
('V', SelectLinewise),
('.', RepeatLastEdit),
('r', ReplaceCharacter),
] {
bindings
.entry(mode)
@@ -6217,7 +6221,7 @@ impl Editor {
}
if let Some((operator, prefix)) = self.line_pending.take() {
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
&& (!target.is_control() || target == '\t')
{
@@ -6225,14 +6229,24 @@ impl Editor {
self.set_message("Diff pane is read-only");
} else {
let before = self.snapshot();
self.replace_characters(target, prefix);
self.record_native(
before,
EditRecipe {
start: EditStart::Replace {
let (ranges, start) = if self.active.view.mode == Mode::Visual {
let ranges = self.selections();
let selector = self.selection_intent(&ranges);
(ranges, EditStart::ReplaceSelection { target, selector })
} else {
(
self.normal_ranges(Action::SubstituteCharacter, prefix),
EditStart::Replace {
target,
count: prefix,
},
)
};
self.replace_characters(target, &ranges);
self.record_native(
before,
EditRecipe {
start,
steps: Vec::new(),
},
);
@@ -8462,8 +8476,7 @@ impl Editor {
self.reveal_caret();
}
fn replace_characters(&mut self, replacement: char, count: usize) {
let ranges = self.normal_ranges(Action::SubstituteCharacter, count);
fn replace_characters(&mut self, replacement: char, ranges: &[Selection]) {
let offsets = line_offsets(&self.active.lines);
let mut bytes = [0; 4];
let replacement: &str = replacement.encode_utf8(&mut bytes);
@@ -8471,20 +8484,52 @@ impl Editor {
.iter()
.enumerate()
.filter(|(_, range)| range.start != range.end)
.map(|(target, range)| Edit {
target: Some(target),
start: cursor_offset(&self.active.lines, &offsets, range.start),
end: cursor_offset(&self.active.lines, &offsets, range.end),
text: if range.end.column - range.start.column == 1 {
Cow::Borrowed(replacement)
.map(|(target, range)| {
let start = cursor_offset(&self.active.lines, &offsets, range.start);
let end = cursor_offset(&self.active.lines, &offsets, range.end);
let text = if range.start.row == range.end.row {
let count = range.end.column - range.start.column;
if count == 1 {
Cow::Borrowed(replacement)
} else {
Cow::Owned(replacement.repeat(count))
}
} else {
Cow::Owned(replacement.repeat(range.end.column - range.start.column))
},
caret: 0,
round_up: false,
let mut text = String::with_capacity(end - start);
for row in range.start.row..=range.end.row {
let first = if row == range.start.row {
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();
self.apply_edits(edits);
self.active.view.visual_motions = None;
self.active.view.linewise = false;
self.active.view.mode = Mode::Normal;
self.normalize_targets();
}
@@ -9021,10 +9066,18 @@ impl Editor {
EditStart::Selection {
selector: SelectionIntent::Spans(spans),
..
}
| EditStart::ReplaceSelection {
selector: SelectionIntent::Spans(spans),
..
} if self.active.view.mode != Mode::Visual => Some(spans.len()),
EditStart::Selection {
selector: SelectionIntent::Rows(rows),
..
}
| EditStart::ReplaceSelection {
selector: SelectionIntent::Rows(rows),
..
} if self.active.view.mode != Mode::Visual => Some(rows.len()),
_ => None,
};
@@ -9117,7 +9170,14 @@ impl Editor {
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 } => {
let ranges = self.line_selections(*count);
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) {
editor.active.view.mode = Mode::Normal;
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
Esc keeps only the primary caret
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
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