feat: add normal-mode change yank replace and word delete actions

This commit is contained in:
Valdemar Lorenzen
2026-10-13 10:49:07 +02:00
parent 6c254f9b0d
commit d7fbf9a144
5 changed files with 853 additions and 19 deletions
+360 -13
View File
@@ -300,6 +300,14 @@ enum EditStart {
change: bool,
count: usize,
},
Normal {
action: Action,
count: usize,
},
Replace {
target: char,
count: usize,
},
Delete,
Join,
Paste {
@@ -515,6 +523,41 @@ core_actions![
("core.change", Action::Change, &[Mode::Visual]),
("core.delete_line", Action::DeleteLine, &[Mode::Normal]),
("core.change_line", Action::ChangeLine, &[Mode::Normal]),
(
"core.change_to_line_end",
Action::ChangeToLineEnd,
&[Mode::Normal]
),
(
"core.delete_to_line_end",
Action::DeleteToLineEnd,
&[Mode::Normal]
),
(
"core.copy_to_line_end",
Action::CopyToLineEnd,
&[Mode::Normal]
),
(
"core.replace_character",
Action::ReplaceCharacter,
&[Mode::Normal]
),
(
"core.substitute_character",
Action::SubstituteCharacter,
&[Mode::Normal]
),
(
"core.delete_word_forward",
Action::DeleteWordForward,
&[Mode::Normal]
),
(
"core.delete_word_backward",
Action::DeleteWordBackward,
&[Mode::Normal]
),
("core.paste_before", Action::PasteBefore, &[Mode::Normal]),
("core.paste_after", Action::PasteAfter, &[Mode::Normal]),
(
@@ -871,6 +914,12 @@ impl Action {
| Self::Delete
| Self::DeleteLine
| Self::ChangeLine
| Self::ChangeToLineEnd
| Self::DeleteToLineEnd
| Self::ReplaceCharacter
| Self::SubstituteCharacter
| Self::DeleteWordForward
| Self::DeleteWordBackward
| Self::Cut
| Self::Change
| Self::PasteBefore
@@ -1060,6 +1109,11 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
('O', OpenAbove),
('J', JoinLines),
('x', Delete),
('C', ChangeToLineEnd),
('D', DeleteToLineEnd),
('Y', CopyToLineEnd),
('r', ReplaceCharacter),
('s', SubstituteCharacter),
('u', Undo),
('U', Redo),
(':', Command),
@@ -1652,7 +1706,7 @@ pub struct Editor {
pending_sequence: BindingSequence,
sequence_display: String,
g_pending: bool,
line_pending: Option<char>,
line_pending: Option<(char, usize)>,
seek_pending: Option<(bool, usize)>,
palette: Option<Palette>,
pane_close_picker_origin: Option<(usize, usize, Option<u64>)>,
@@ -4441,9 +4495,13 @@ impl Editor {
pub(crate) fn set_project_root(&mut self, root: PathBuf) {
self.project_root = root;
}
/// Pending decimal prefix for the next Normal/Visual motion.
/// Pending decimal prefix for the next Normal/Visual motion or editing action.
pub fn count(&self) -> Option<usize> {
self.count
match self.line_pending {
Some((_, prefix)) => Some(prefix.saturating_mul(self.count.unwrap_or(1)))
.filter(|count| *count > 1 || self.count.is_some()),
None => self.count,
}
}
/// Actual pending key sequence, cached until the next input.
pub fn sequence_pending(&self) -> Option<&str> {
@@ -4455,8 +4513,9 @@ impl Editor {
self.g_pending
}
/// Native `d`/`c` operator or `r` replacement awaiting its next key.
pub fn line_operator_pending(&self) -> Option<char> {
self.line_pending
self.line_pending.map(|(operator, _)| operator)
}
/// Native character seek awaiting a literal target.
@@ -5623,13 +5682,59 @@ impl Editor {
if self.palette.is_some() {
return self.handle_palette(input);
}
if let Some(operator) = self.line_pending.take() {
if self.active.view.mode == Mode::Normal && input == Input::Char(operator) {
return self.act(if operator == 'd' {
Action::DeleteLine
} else {
Action::ChangeLine
});
if let Some((operator, prefix)) = self.line_pending.take() {
if operator == 'r' {
if self.active.view.mode == Mode::Normal
&& let Input::Char(target) = input
&& (!target.is_control() || target == '\t')
{
if self.mutation_blocked() {
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 {
target,
count: prefix,
},
steps: Vec::new(),
},
);
}
}
self.count = None;
return None;
}
if self.active.view.mode == Mode::Normal {
if let Input::Char(ch @ '0'..='9') = input
&& (ch != '0' || self.count.is_some())
{
self.count = Some(
self.count
.unwrap_or(0)
.saturating_mul(10)
.saturating_add(usize::from(ch as u8 - b'0')),
);
self.line_pending = Some((operator, prefix));
return None;
}
let action = match input {
Input::Char(ch) if ch == operator => Some(if operator == 'd' {
Action::DeleteLine
} else {
Action::ChangeLine
}),
Input::Char('w') if operator == 'd' => Some(Action::DeleteWordForward),
Input::Char('b') if operator == 'd' => Some(Action::DeleteWordBackward),
_ => None,
};
if let Some(action) = action {
self.count = Some(prefix.saturating_mul(self.count.unwrap_or(1)));
return self.act(action);
}
}
self.count = None;
if input == Input::Escape {
@@ -5701,7 +5806,7 @@ impl Editor {
if self.active.view.mode == Mode::Normal
&& let Input::Char(operator @ ('c' | 'd')) = input
{
self.line_pending = Some(operator);
self.line_pending = Some((operator, self.count.take().unwrap_or(1)));
return None;
}
self.count = None;
@@ -5928,6 +6033,15 @@ impl Editor {
Action::DeleteLine | Action::ChangeLine => {
self.edit_lines(matches!(action, Action::ChangeLine), count);
}
Action::ReplaceCharacter => {
self.line_pending = Some(('r', count));
}
Action::ChangeToLineEnd
| Action::DeleteToLineEnd
| Action::CopyToLineEnd
| Action::SubstituteCharacter
| Action::DeleteWordForward
| Action::DeleteWordBackward => self.edit_normal(action, count),
Action::LspCompletion => {
if self.active.view.mode == Mode::Insert {
return Some(Invocation {
@@ -7660,6 +7774,126 @@ impl Editor {
self.reveal_caret();
}
fn normal_ranges(&mut self, action: Action, count: usize) -> Vec<Selection> {
// Insert boundaries include EOF; Normal word motions clamp it to the last grapheme.
self.active.view.mode = Mode::Insert;
self.active.view.linewise = false;
let ordered = self
.active
.view
.targets
.iter()
.copied()
.enumerate()
.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::SubstituteCharacter => Cursor {
row: cursor.row,
column: cursor
.column
.saturating_add(count)
.min(grapheme_count(&self.active.lines[cursor.row])),
},
_ => Cursor {
row: cursor.row,
column: grapheme_count(&self.active.lines[cursor.row]),
},
};
(
target,
index == self.active.view.primary,
Selection {
start: cursor.min(end),
end: cursor.max(end),
},
)
})
.collect();
self.merge_target_ranges(ordered, true)
}
fn edit_normal(&mut self, action: Action, count: usize) {
let copy = action == Action::CopyToLineEnd;
let saved = copy.then(|| (self.active.view.targets.clone(), self.active.view.primary));
let before = (!copy).then(|| self.snapshot());
let ranges = self.normal_ranges(action, count);
let result = self.store_clipboard(
self.selection_fragments(&ranges),
self.clipboard_mode == ClipboardMode::System,
false,
);
if copy || result.is_err() {
let (targets, primary) = saved.unwrap_or_else(|| {
let before = before.unwrap();
(before.targets, before.primary)
});
self.active.view.targets = targets;
self.active.view.primary = primary;
self.active.view.mode = Mode::Normal;
if let Err(error) = result {
self.set_message(error);
} else {
self.set_message("Copied line suffix");
}
return;
}
let before = before.unwrap();
let start = EditStart::Normal { action, count };
if matches!(
action,
Action::ChangeToLineEnd | Action::SubstituteCharacter
) {
self.start_insert(before, start);
self.edit_ranges(Action::Change, &ranges);
} else {
self.edit_ranges(Action::Cut, &ranges);
self.active.view.mode = Mode::Normal;
self.normalize_targets();
self.record_native(
before,
EditRecipe {
start,
steps: Vec::new(),
},
);
}
self.reveal_caret();
}
fn replace_characters(&mut self, replacement: char, count: usize) {
let ranges = self.normal_ranges(Action::SubstituteCharacter, count);
let offsets = line_offsets(&self.active.lines);
let mut bytes = [0; 4];
let replacement: &str = replacement.encode_utf8(&mut bytes);
let edits = ranges
.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)
} else {
Cow::Owned(replacement.repeat(range.end.column - range.start.column))
},
caret: 0,
round_up: false,
})
.collect();
self.apply_edits(edits);
self.active.view.mode = Mode::Normal;
self.normalize_targets();
}
fn edit_selection(&mut self, action: Action) {
let ranges = self.selections();
let fragments = self.selection_fragments(&ranges);
@@ -8272,6 +8506,20 @@ impl Editor {
let mut clipboard = None;
match &recipe.start {
EditStart::Insert(action) => self.place_insert(*action),
EditStart::Normal { action, count } => {
let ranges = self.normal_ranges(*action, *count);
clipboard = Some((self.selection_fragments(&ranges), false));
let change = matches!(
action,
Action::ChangeToLineEnd | Action::SubstituteCharacter
);
self.edit_ranges(if change { Action::Change } else { Action::Cut }, &ranges);
if !change {
self.active.view.mode = Mode::Normal;
self.normalize_targets();
}
}
EditStart::Replace { target, count } => self.replace_characters(*target, *count),
EditStart::Lines { change, count } => {
let ranges = self.line_selections(*count);
clipboard = Some((self.selection_fragments(&ranges), true));
@@ -14780,7 +15028,7 @@ mod tests {
let carets = editor.carets();
let revision = editor.text_revision();
fixture.fail("fail-write", true);
for keys in ["2dd", "cc"] {
for keys in ["2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b"] {
type_text(&mut editor, keys);
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
assert_eq!(editor.carets(), carets);
@@ -18266,6 +18514,105 @@ mod tests {
}
}
#[test]
fn normal_edits_merge_overlapping_ranges_and_replay_without_source_keys() {
for (keys, positions, expected) in [
("C", [(0, 0), (0, 4)], "!\nkeep"),
("D", [(0, 0), (0, 4)], "\nkeep"),
("4s", [(0, 0), (0, 2)], "!o three four\nkeep"),
("d2w", [(0, 0), (0, 4)], "four\nkeep"),
("d2b", [(0, 8), (0, 14)], "four\nkeep"),
("4rX", [(0, 0), (0, 2)], "XXXXXXo three four\nkeep"),
] {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("one two three four\nkeep".into())])
.unwrap();
set_carets(&mut editor, &positions, 1);
let original = editor.active.view.targets.clone();
let undo = editor.active.undo.len();
type_text(&mut editor, keys);
if editor.mode() == Mode::Insert {
type_text(&mut editor, "!");
editor.handle(Input::Escape);
}
assert_eq!(editor.context().text, expected, "{keys}");
assert_eq!(editor.active.undo.len(), undo + 1, "{keys}");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "one two three four\nkeep", "{keys}");
assert_eq!(editor.active.view.targets, original, "{keys}");
assert_eq!(editor.active.view.primary, 1, "{keys}");
for key in ["C", "D", "s", "r", "w", "b"] {
editor
.bind("normal", key, Some("core.right".into()))
.unwrap();
}
editor.handle(Input::Char('.'));
assert_eq!(editor.context().text, expected, "{keys}");
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.selection_count(), 1);
}
}
#[test]
fn normal_yank_merges_suffix_fragments_without_merging_carets_or_history() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("abcdef\nkeep".into())])
.unwrap();
set_carets(&mut editor, &[(0, 1), (0, 3)], 1);
let targets = editor.active.view.targets.clone();
let undo = editor.active.undo.len();
editor.handle(Input::Char('Y'));
assert_eq!(editor.clipboard, ["bcdef"]);
assert!(!editor.clipboard_linewise);
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.active.view.primary, 1);
assert_eq!(editor.active.undo.len(), undo);
assert!(editor.last_edit.is_none());
}
#[test]
fn readonly_normal_edit_actions_and_pending_replace_preserve_document_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'));
}
let text = editor.context().text;
let targets = editor.active.view.targets.clone();
let undo = editor.active.undo.len();
let clipboard = editor.clipboard.clone();
for keys in ["C", "D", "s", "2r", "d2w", "d2b"] {
type_text(&mut editor, keys);
assert_eq!(editor.context().text, text, "{keys}");
assert_eq!(editor.active.view.targets, targets, "{keys}");
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.count(), None);
assert_eq!(editor.line_operator_pending(), None);
assert_eq!(editor.clipboard, clipboard);
assert_eq!(editor.active.undo.len(), undo);
}
// A pending replacement must still guard mutation if editability changed.
editor.line_pending = Some(('r', 2));
editor.handle(Input::Char('X'));
assert_eq!(editor.context().text, text);
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.active.undo.len(), undo);
assert_eq!(editor.line_operator_pending(), None);
editor.handle(Input::Char('Y'));
assert_eq!(editor.clipboard, [text]);
assert!(!editor.clipboard_linewise);
}
}
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
editor.active.view.mode = Mode::Normal;
editor.active.view.visual_motions = None;