feat: add normal-mode change yank replace and word delete actions
This commit is contained in:
+360
-13
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user