feat: add normal-mode change yank replace and word delete actions
This commit is contained in:
@@ -18,9 +18,14 @@
|
||||
- Space v cycles split/unified; configurable startup default, dual line numbers, native side focus/editing, and shared highlighting/wrap/excerpts.
|
||||
- [x] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||
- Space s flips orientation; configurable startup default and shared pane geometry/directional focus.
|
||||
- [ ] add `C` and `D` for deleting line from current cursor position (and entering insert mode: `C`); add `Y` for copying line from current cursor position; add `r` for replacing character at cursor position; add `s` for deleting character at current cursor position and entering insert mode; add `d<count>w` for deleting whole words; add `d<count>b` for deleting backwards (same as `d<count>w` but backwards).
|
||||
- [x] add `C` and `D` for deleting line from current cursor position (and entering insert mode: `C`); add `Y` for copying line from current cursor position; add `r` for replacing character at cursor position; add `s` for deleting character at current cursor position and entering insert mode; add `d<count>w` for deleting whole words; add `d<count>b` for deleting backwards (same as `d<count>w` but backwards).
|
||||
- [ ] diff view: make foreground of etched areas (where no lines exist) the same dimmed color as the normal gutter text
|
||||
- [ ] copying is pretty slow; optimize performance to reduce latency.
|
||||
- [ ] pull a pr description and display it in a buffer:
|
||||
- open a selector that allows you to pick a pr from remote
|
||||
- selector should be searchable
|
||||
- selected pr description is opened in a separate buffer/tab.
|
||||
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||
|
||||
@@ -146,7 +146,9 @@ Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab a
|
||||
|
||||
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
|
||||
|
||||
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to another key or sequence for another route. A configured/disabled `d` or `c`, a configured sequence starting with that key, or choosing it as the leader overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar.
|
||||
Normal `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans. Leading and post-operator counts multiply. Bind `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 `d` or `c`, 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.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -158,6 +160,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `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` |
|
||||
| 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 | `follow_link` |
|
||||
|
||||
+12
-4
@@ -27,6 +27,9 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| Normal | `J` (Shift+j) joins each targeted line with the next line |
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal | `dd` deletes whole lines; `cc` replaces them and enters Insert; leading counts work (`3dd`, `2cc`) |
|
||||
| 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 | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
|
||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret |
|
||||
@@ -85,16 +88,21 @@ Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, `f<char>`/`F<
|
||||
|
||||
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
|
||||
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and `dd`/`cc` prefixes retain the count until their action resolves. Line actions use that count; other non-motion commands consume it and execute once, including line-start/end, other edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and general Vim operator grammar such as `3dw` is not supported.
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and editing prefixes retain the count until their action resolves. Line/word deletion, character replacement, and substitution use counts; suffix actions and other non-motion commands consume it and execute once. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history; only the dedicated operators described below are supported.
|
||||
|
||||
## Normal line actions
|
||||
## Normal editing actions
|
||||
|
||||
`dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row.
|
||||
|
||||
The first `d` or `c` waits for its matching second key and appears in the status bar. Escape cancels only that pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Only leading counts are supported, not forms such as `d2d` or `d{motion}`. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
|
||||
The first `d` or `c` waits for its second key and appears in the status bar. `d` accepts `d`, `w`, or `b`; `c` accepts `c`. Counts before and after the operator multiply: `2d3w` deletes six forward word spans, and `d2d` deletes two rows. Escape cancels only the pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
|
||||
|
||||
System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys.
|
||||
|
||||
`D` cuts from each caret through its logical line end; `C` cuts the same suffix and enters Insert. `Y` copies that suffix characterwise without moving the carets. These actions leave line separators intact and ignore counts. `C` and subsequent typed replacement form one undo group.
|
||||
|
||||
`r<char>` 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.
|
||||
|
||||
## Selections and multi-caret editing
|
||||
|
||||
@@ -106,7 +114,7 @@ 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.
|
||||
|
||||
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 `dd` and `cc`, but not general operator grammar such as `cw`, `dw`, or `yy`; blockwise Visual is also unsupported. `c` and `d` 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 `dd`, `cc`, `dw`, and `db`, but not general operator grammar such as `cw` or `yy`; blockwise Visual is also unsupported. `c` and `d` 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.
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use vamp::editor::{Cursor, Editor, Input, Mode};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-normal-editing-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let path = self.0.join("buffer.txt");
|
||||
fs::write(&path, text).unwrap();
|
||||
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
|
||||
for operation in ["C", "D", "9C", "9D"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀tail\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "2l");
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, "ae\u{301}\nnext\n", "{operation}");
|
||||
if operation.ends_with('C') {
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "replacement");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "ae\u{301}replacement\nnext\n");
|
||||
} else {
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_commands_at_last_grapheme_and_empty_lines_never_remove_newlines() {
|
||||
for operation in ["C", "D"] {
|
||||
for source in ["a👩🚀\nnext\n", "a👩🚀\nnext"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "$");
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, source.replacen("a👩🚀", "a", 1));
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("\nnext\n");
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, "\nnext\n");
|
||||
assert_eq!(
|
||||
editor.mode(),
|
||||
if operation == "C" {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::Normal
|
||||
}
|
||||
);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_line_suffix_is_characterwise_and_does_not_create_an_undo_entry() {
|
||||
for operation in ["Y", "8Y"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "a👩🚀e\u{301}tail\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "j0P");
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
"a👩🚀e\u{301}tail\n👩🚀e\u{301}tailnext\n"
|
||||
);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_and_cancelled_replace_preserve_the_previous_repeatable_edit() {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc\nxyz\n");
|
||||
keys(&mut editor, "xY3r");
|
||||
editor.handle(Input::Escape);
|
||||
keys(&mut editor, "j0.");
|
||||
assert_eq!(editor.context().text, "bc\nyz\n");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "bc\nxyz\n");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "abc\nxyz\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_consumes_a_literal_character_and_replaces_whole_counted_graphemes() {
|
||||
for (command, expected) in [
|
||||
("rx", "ax👩🚀z\nnext\n"),
|
||||
("r2", "a2👩🚀z\nnext\n"),
|
||||
("r界", "a界👩🚀z\nnext\n"),
|
||||
("r\t", "a\t👩🚀z\nnext\n"),
|
||||
("2rQ", "aQQz\nnext\n"),
|
||||
("9rQ", "aQQQ\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀z\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, expected, "{command:?}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("\nnext\n");
|
||||
keys(&mut editor, "rQ");
|
||||
assert_eq!(editor.context().text, "\nnext\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_deletes_counted_whole_graphemes_and_groups_replacement_with_undo() {
|
||||
for (command, after_delete, expected) in [
|
||||
("s", "a👩🚀z\nnext\n", "aQ👩🚀z\nnext\n"),
|
||||
("2s", "az\nnext\n", "aQz\nnext\n"),
|
||||
("9s", "a\nnext\n", "aQ\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀z\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, after_delete, "{command}");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, expected);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_counts_before_and_after_operator_multiply_in_both_directions() {
|
||||
let source = "one two three four five six seven eight nine\n";
|
||||
for (position, command, expected) in [
|
||||
("", "dw", "two three four five six seven eight nine\n"),
|
||||
("", "d2w", "three four five six seven eight nine\n"),
|
||||
("", "2dw", "three four five six seven eight nine\n"),
|
||||
("", "2d3w", "seven eight nine\n"),
|
||||
("2w", "db", "one three four five six seven eight nine\n"),
|
||||
("4w", "d2b", "one two five six seven eight nine\n"),
|
||||
("4w", "2db", "one two five six seven eight nine\n"),
|
||||
("6w", "2d3b", "seven eight nine\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}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation_and_rows() {
|
||||
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);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, expected, "{command} on {source:?}");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() {
|
||||
let source = "e\u{301}lan 世界";
|
||||
for (command, expected) in [
|
||||
("d2w".to_owned(), ""),
|
||||
(format!("{}d2w", usize::MAX), ""),
|
||||
("$d3b".to_owned(), "界"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, &command);
|
||||
assert_eq!(editor.context().text, expected, "{command}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_operator_and_replace_cancellation_clear_counts_without_editing() {
|
||||
for prefix in ["3d", "d2", "2d3", "3c", "c2", "3r"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "abcd\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, prefix);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, source, "{prefix}");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "x");
|
||||
assert_eq!(editor.context().text, "bcd\nnext\n", "{prefix}");
|
||||
}
|
||||
for input in [
|
||||
Input::Enter,
|
||||
Input::Char('\n'),
|
||||
Input::Paste("ignored".into()),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abcd\nnext\n");
|
||||
keys(&mut editor, "3r");
|
||||
editor.handle(input);
|
||||
keys(&mut editor, "lx");
|
||||
assert_eq!(editor.context().text, "acd\nnext\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_edit_recipes_repeat_against_the_current_text_and_undo_as_single_changes() {
|
||||
for (source, first, after_first, position, expected) in [
|
||||
(
|
||||
"prefix one\nlongprefix two\n",
|
||||
"7lCnew",
|
||||
"prefix new\nlongprefix two\n",
|
||||
"j011l",
|
||||
"prefix new\nlongprefix new\n",
|
||||
),
|
||||
(
|
||||
"prefix one\nlongprefix two\n",
|
||||
"7lD",
|
||||
"prefix \nlongprefix two\n",
|
||||
"j011l",
|
||||
"prefix \nlongprefix \n",
|
||||
),
|
||||
(
|
||||
"e\u{301}abc\n👩🚀xyz\n",
|
||||
"rQ",
|
||||
"Qabc\n👩🚀xyz\n",
|
||||
"j0",
|
||||
"Qabc\nQxyz\n",
|
||||
),
|
||||
(
|
||||
"e\u{301}abc\n👩🚀xyz\n",
|
||||
"sQ",
|
||||
"Qabc\n👩🚀xyz\n",
|
||||
"j0",
|
||||
"Qabc\nQxyz\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"d2w",
|
||||
"keep\nthree four stay\n",
|
||||
"j0",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"2wd2b",
|
||||
"keep\nthree four stay\n",
|
||||
"j02w",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, first);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, after_first, "{first}");
|
||||
keys(&mut editor, position);
|
||||
keys(&mut editor, ".");
|
||||
assert_eq!(editor.context().text, expected, "{first} then {position}.");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, after_first);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_and_cc_keep_linewise_behavior_with_counts_after_the_operator() {
|
||||
let source = "1\n2\n3\n4\n5\n6\n7\n8\n9\n";
|
||||
for (command, remaining) in [
|
||||
("dd", "2\n3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2dd", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("d2d", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2d3d", "7\n8\n9\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, remaining, "{command}");
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, source, "{command} linewise paste");
|
||||
}
|
||||
for (command, remaining) in [
|
||||
("cc", "2\n3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2cc", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("c2c", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2c3c", "7\n8\n9\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert_eq!(editor.context().text, format!("\n{remaining}"), "{command}");
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, format!("Q\n{remaining}"));
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_multicarets_edit_whole_graphemes_and_yank_matching_suffix_fragments() {
|
||||
let source = "a👩🚀tail\nbe\u{301}rest\n";
|
||||
for (command, expected) in [
|
||||
("D", "a\nb\n"),
|
||||
("CQ", "aQ\nbQ\n"),
|
||||
("rQ", "aQtail\nbQrest\n"),
|
||||
("sQ", "aQtail\nbQrest\n"),
|
||||
("YP", "a👩🚀tail👩🚀tail\nbe\u{301}reste\u{301}rest\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
assert_eq!(carets.len(), 2);
|
||||
keys(&mut editor, command);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, expected, "{command}");
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
}
|
||||
for command in ["d2w", "2wd2b"] {
|
||||
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.selection_count(), 2);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_core_edit_actions_keep_counts_pending_input_and_clipboard_semantics() {
|
||||
for (action, original_key, position, command, expected) in [
|
||||
("core.change_to_line_end", "C", "l", "qX", "aX\nnext\n"),
|
||||
("core.delete_to_line_end", "D", "l", "q", "a\nnext\n"),
|
||||
(
|
||||
"core.copy_to_line_end",
|
||||
"Y",
|
||||
"l",
|
||||
"qj0P",
|
||||
"abc def ghi\nbc def ghinext\n",
|
||||
),
|
||||
(
|
||||
"core.replace_character",
|
||||
"r",
|
||||
"l",
|
||||
"qZ",
|
||||
"aZc def ghi\nnext\n",
|
||||
),
|
||||
(
|
||||
"core.substitute_character",
|
||||
"s",
|
||||
"l",
|
||||
"qZ",
|
||||
"aZc def ghi\nnext\n",
|
||||
),
|
||||
("core.delete_word_forward", "d", "", "2q", "ghi\nnext\n"),
|
||||
("core.delete_word_backward", "d", "2w", "2q", "ghi\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc def ghi\nnext\n");
|
||||
editor.bind("normal", original_key, None).unwrap();
|
||||
editor.bind("normal", "q", Some(action.into())).unwrap();
|
||||
keys(&mut editor, position);
|
||||
keys(&mut editor, command);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, expected, "{action}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user