diff --git a/README.md b/README.md index 66c5b3a..7f780a9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty | Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count | | 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, 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 Visual selections; `p`/`P` paste internal clipboard after/before each caret; Esc keeps only the primary caret | | Normal, Visual | Ctrl+k clones above; Ctrl+e clones below | @@ -63,6 +64,20 @@ Bracketed paste broadcasts the same literal text to all Insert carets in a separ The clipboard is internal to the editor session and independent of 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 state. Empty clipboard paste does not edit text; copying/cutting only empty final-row selections does not overwrite the clipboard. 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. +### Repeat last edit + +`.` repeats the session's last successful native edit in Normal/Visual. Insertion placements (`i`/`I`/`a`/`A`/`o`/`O`), Insert typing and edit-context motions/newline/backspace/delete, Visual change/cut, Normal deletion, clipboard/literal paste, and line joining are recorded as resolved edit operations—not keystrokes, command invocations, or old byte offsets. Remapping keys afterward does not change the recorded operation. Insert `.` and popup query `.` remain literal. A count before dot is consumed and repeats once, consistent with other non-motion commands. + +In Normal, Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges replace the stored targeting. Target-building commands themselves do not recreate old cursors. + +Clipboard paste captures the fragments actually pasted, so a later yank does not change repeat's payload. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. A refusal or cardinality change during replay leaves text, caret/primary state, clipboard, and history unchanged. + +Each repeat returns to Normal and creates one undo entry restoring the full caret set and primary. Original typing/paste/typing boundaries remain separate; dot replays the selected committed group as one transaction. Typed text retains scalar insertion boundaries, preserving combining/ZWJ interactions with surrounding graphemes; bracketed paste remains one bulk literal operation. Trailing Insert movement is not repeated. + +The last recipe is session-wide across buffers, but pending Insert groups and undo/redo remain buffer-local. No-op/identical edits, rejected input, navigation, yank, explorer filtering, saving, undo/redo, and plugin effects do not replace it. Successful buffer parking and ordinary explicit Ex execution commit pending native typing at existing boundaries; failed buffer requests do not split that group. Plugins never execute through dot. Repeat does not consume or recursively replace its recipe. Rebind with `config.core.repeat_last_edit`, including Normal/Visual leader routes. + +### Search + Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow. Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again. @@ -242,6 +257,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati | Normal | `join_lines`, `undo`, `redo`, `command` | | Normal | `search`, `paste_before`, `paste_after` | | Normal, Visual | `select`, `search_next`, `search_previous` | +| Normal, Visual | `repeat_last_edit` | | Normal, Visual | `file_picker`, `file_explorer` | | Normal, Insert, Visual | `line_start`, `line_end` | | Normal, Insert, Visual, Tree | `left`, `right` | diff --git a/examples/config.rb b/examples/config.rb index b75f528..1ff459c 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -15,6 +15,7 @@ Vamp.configure do |config| config.bind "g", "greet" config.bind "leader g", "greet" config.bind "ctrl+r", config.core.redo + config.bind "leader r", config.core.repeat_last_edit config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "ctrl+f", config.core.search config.bind "ctrl+c", config.core.normal, mode: config.modes.search diff --git a/src/editor.rs b/src/editor.rs index a9e1eaa..aa93304 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -158,6 +158,40 @@ struct Edit<'a> { round_up: bool, } +// Recipes contain resolved native intent, never input keys or source-buffer offsets. +struct EditRecipe { + start: EditStart, + steps: Vec, +} + +enum EditStart { + Insert(Action), + Selection { + change: bool, + selector: SelectionIntent, + }, + Delete, + Join, + Paste { + after: bool, + fragments: Vec, + }, +} + +enum SelectionIntent { + Motions(Vec<(Action, usize)>), + // Whole graphemes, with a newline counting as one boundary. + Spans(Vec), +} + +enum InsertStep { + Typed(String), + Bulk(String), + Move(Action, usize), + Delete(Vec), + Backspace(Vec), +} + #[derive(Clone, Copy, Eq, PartialEq)] enum Action { Insert, @@ -176,6 +210,7 @@ enum Action { Delete, Undo, Redo, + RepeatLastEdit, Command, Normal, Newline, @@ -226,6 +261,11 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[ ("core.join_lines", Action::JoinLines, &[Mode::Normal]), ("core.undo", Action::Undo, &[Mode::Normal]), ("core.redo", Action::Redo, &[Mode::Normal]), + ( + "core.repeat_last_edit", + Action::RepeatLastEdit, + &[Mode::Normal, Mode::Visual], + ), ("core.command", Action::Command, &[Mode::Normal]), ("core.search", Action::Search, &[Mode::Normal]), ( @@ -517,6 +557,7 @@ fn default_bindings() -> HashMap> { ('n', SearchNext), ('N', SearchPrevious), ('v', Select), + ('.', RepeatLastEdit), ] { bindings .entry(mode) @@ -642,6 +683,8 @@ struct Buffer { undo: Vec, redo: Vec, insert_start: Option, + pending_edit: Option, + visual_motions: Option>, } impl Buffer { @@ -677,6 +720,8 @@ impl Buffer { undo: Vec::new(), redo: Vec::new(), insert_start: None, + pending_edit: None, + visual_motions: None, }) } @@ -700,6 +745,7 @@ pub struct Editor { next_buffer_id: usize, command_line: String, clipboard: Vec, + last_edit: Option, message: String, quit: bool, capabilities: Vec, @@ -732,6 +778,7 @@ impl Editor { next_buffer_id: 2, command_line: String::new(), clipboard: Vec::new(), + last_edit: None, message: String::new(), quit: false, capabilities, @@ -1412,6 +1459,9 @@ impl Editor { } self.command_line = format!("{} ", entry.name); self.palette = None; + if self.active.mode == Mode::Insert { + self.finish_insert(); + } self.active.mode = Mode::Command; } Input::Backspace => { @@ -1606,7 +1656,7 @@ impl Editor { match (self.active.mode, input) { (Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => { let mut bytes = [0; 4]; - self.insert_text(ch.encode_utf8(&mut bytes)); + self.type_native(ch.encode_utf8(&mut bytes)); } (Mode::Command | Mode::Search, Input::Char(ch)) if !ch.is_control() || (self.active.mode == Mode::Search && ch == '\t') => @@ -1662,37 +1712,18 @@ impl Editor { } Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), - Action::Insert => self.begin_insert(), - Action::InsertFirstNonblank | Action::AppendLineEnd => { + Action::Insert + | Action::InsertFirstNonblank + | Action::AppendLineEnd + | Action::Append + | Action::OpenBelow + | Action::OpenAbove => { + let before = self.snapshot(); if self.active.mode == Mode::Visual { - let before = self.snapshot(); self.split_rows(matches!(action, Action::AppendLineEnd), true); - self.begin_insert(); - self.active.insert_start = Some(before); - } else { - self.begin_insert(); - for target in &mut self.active.targets { - let line = &self.active.lines[target.active.row]; - target.active.column = if matches!(action, Action::AppendLineEnd) { - grapheme_count(line) - } else { - first_nonblank(line) - }; - } - self.normalize_targets(); } - } - Action::Append => { - self.begin_insert(); - for target in &mut self.active.targets { - target.active.column = (target.active.column + 1) - .min(grapheme_count(&self.active.lines[target.active.row])); - } - self.normalize_targets(); - } - Action::OpenBelow | Action::OpenAbove => { - self.begin_insert(); - self.open_lines(matches!(action, Action::OpenBelow)); + self.start_insert(before, EditStart::Insert(action)); + self.place_insert(action); } Action::Left | Action::Right @@ -1705,17 +1736,46 @@ impl Editor { | Action::WordEnd | Action::BigWordNext | Action::BigWordPrevious - | Action::BigWordEnd => self.move_targets(action, count), - Action::JoinLines => self.join_lines(), + | Action::BigWordEnd => { + if self.active.mode == Mode::Visual { + if let Some(motions) = &mut self.active.visual_motions { + motions.push((action, count)); + } + } else if self.active.mode == Mode::Insert { + self.push_insert_step(InsertStep::Move(action, count)); + } + self.move_targets(action, count); + } + Action::JoinLines => { + let before = self.snapshot(); + self.join_lines(); + self.record_native( + before, + EditRecipe { + start: EditStart::Join, + steps: Vec::new(), + }, + ); + } Action::Delete => { let before = (self.active.mode != Mode::Insert).then(|| self.snapshot()); - self.delete_forward(); + let mask = self.deletion_mask(false); + self.delete_forward(&mask); if let Some(before) = before { - self.record(before); + self.record_native( + before, + EditRecipe { + start: EditStart::Delete, + steps: Vec::new(), + }, + ); + } else if mask.iter().any(|active| *active) { + self.push_insert_step(InsertStep::Delete(mask)); } } Action::Undo => self.undo(), Action::Redo => self.redo(), + Action::RepeatLastEdit => self.repeat_last_edit(), Action::Command | Action::Search => { self.active.mode = if matches!(action, Action::Search) { Mode::Search @@ -1734,6 +1794,7 @@ impl Editor { target.anchor = target.active; } self.active.mode = Mode::Visual; + self.active.visual_motions = Some(Vec::new()); self.normalize_targets(); self.message.clear(); } @@ -1752,21 +1813,22 @@ impl Editor { self.collapse_primary(); } else if self.active.mode == Mode::Insert { self.finish_insert(); - self.active.mode = Mode::Normal; - for target in &mut self.active.targets { - target.active.column = target.active.column.saturating_sub(1); - } - self.normalize_targets(); + self.leave_insert(); } else { self.command_line.clear(); + self.active.visual_motions = None; self.active.mode = Mode::Normal; self.normalize_targets(); } } - Action::Newline => self.insert_text("\n"), + Action::Newline => self.type_native("\n"), Action::Backspace => { if self.active.mode == Mode::Insert { - self.backspace(); + let mask = self.deletion_mask(true); + self.backspace(&mask); + if mask.iter().any(|active| *active) { + self.push_insert_step(InsertStep::Backspace(mask)); + } } else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back() { self.command_line.truncate(byte); @@ -1848,6 +1910,7 @@ impl Editor { }); } } + self.active.visual_motions = None; self.normalize_targets(); self.message.clear(); } @@ -2167,7 +2230,7 @@ impl Editor { self.record(before); } if inserting { - self.active.insert_start = Some(self.snapshot()); + self.begin_insert(); } if self.active.mode != previous_mode { self.refresh_palette(); @@ -2229,6 +2292,13 @@ impl Editor { } } + fn record_native(&mut self, before: Snapshot, recipe: EditRecipe) { + if before.lines != self.active.lines { + self.last_edit = Some(recipe); + } + self.record(before); + } + fn target_range(&self, target: Target) -> Selection { let start = target.anchor.min(target.active); let end = self.next_boundary(target.anchor.max(target.active)); @@ -2292,7 +2362,7 @@ impl Editor { } self.active.targets[index] = target; } - let mut ordered: Vec<_> = self + let ordered: Vec<_> = self .active .targets .iter() @@ -2310,6 +2380,15 @@ impl Editor { (target, index == self.active.primary, range) }) .collect(); + self.merge_target_ranges(ordered, false); + } + + fn merge_target_ranges( + &mut self, + mut ordered: Vec<(Target, bool, Selection)>, + keep_ranges: bool, + ) -> Vec { + let count = ordered.len(); ordered.sort_by_key(|(_, _, range)| (range.start, range.end)); let mut merged: Vec<(Target, bool, Selection)> = Vec::with_capacity(ordered.len()); for (target, primary, range) in ordered { @@ -2334,6 +2413,14 @@ impl Editor { } merged.push((target, primary, range)); } + if merged.len() != count { + self.active.visual_motions = None; + } + let ranges = if keep_ranges { + merged.iter().map(|(_, _, range)| *range).collect() + } else { + Vec::new() + }; self.active.primary = merged .iter() .position(|(_, primary, _)| *primary) @@ -2342,6 +2429,7 @@ impl Editor { self.active .targets .extend(merged.into_iter().map(|(target, _, _)| target)); + ranges } fn collapse_primary(&mut self) { @@ -2353,12 +2441,14 @@ impl Editor { fn clear_selection(&mut self) { if self.active.mode == Mode::Visual { + self.active.visual_motions = None; self.active.mode = Mode::Normal; self.normalize_targets(); } } fn split_rows(&mut self, end: bool, insert: bool) { + self.active.visual_motions = None; let ranges = self.selections(); let primary_row = self.cursor().row; let mut carets = Vec::new(); @@ -2388,6 +2478,7 @@ impl Editor { } fn clone_vertical(&mut self, down: bool) { + self.active.visual_motions = None; let count = self.active.targets.len(); let primary = self.active.primary; for index in 0..count { @@ -2519,6 +2610,7 @@ impl Editor { self.active.targets.push(target); } } + self.active.visual_motions = None; self.normalize_targets(); self.message.clear(); } @@ -2527,7 +2619,38 @@ impl Editor { let ranges = self.selections(); let cut = matches!(action, Action::Cut | Action::Change); let changing = matches!(action, Action::Change); + let recipe = cut.then(|| EditRecipe { + start: EditStart::Selection { + change: changing, + selector: self.selection_intent(&ranges), + }, + steps: Vec::new(), + }); let before = cut.then(|| self.snapshot()); + if changing { + self.start_insert(before.unwrap(), recipe.unwrap().start); + self.edit_ranges(action, &ranges); + self.message.clear(); + return; + } + let nonempty = self.edit_ranges(action, &ranges); + self.active.mode = Mode::Normal; + self.normalize_targets(); + if let Some(before) = before { + self.record_native(before, recipe.unwrap()); + } + self.set_message(if !nonempty { + "Nothing selected" + } else if cut { + "Cut selections" + } else { + "Copied selections" + }); + } + + fn edit_ranges(&mut self, action: Action, ranges: &[Selection]) -> bool { + let cut = matches!(action, Action::Cut | Action::Change); + self.active.visual_motions = None; let text = self.active.lines.join("\n"); let offsets = line_offsets(&self.active.lines); let fragments: Vec<_> = ranges @@ -2542,43 +2665,27 @@ impl Editor { if nonempty { self.clipboard = fragments; } - let edits = ranges - .iter() - .enumerate() - .map(|(target, range)| Edit { - target, - start: cursor_offset(&self.active.lines, &offsets, range.start), - end: cursor_offset(&self.active.lines, &offsets, range.end), - text: Cow::Borrowed(""), - caret: 0, - round_up: true, - }) - .collect(); self.active.mode = Mode::Insert; if cut { + let edits = ranges + .iter() + .enumerate() + .map(|(target, range)| Edit { + target, + start: cursor_offset(&self.active.lines, &offsets, range.start), + end: cursor_offset(&self.active.lines, &offsets, range.end), + text: Cow::Borrowed(""), + caret: 0, + round_up: true, + }) + .collect(); self.apply_edits(edits); } else { - for (target, range) in self.active.targets.iter_mut().zip(&ranges) { + for (target, range) in self.active.targets.iter_mut().zip(ranges) { target.active = range.start; } } - if changing { - self.active.insert_start = before; - self.message.clear(); - } else { - self.active.mode = Mode::Normal; - self.normalize_targets(); - if let Some(before) = before { - self.record(before); - } - self.set_message(if !nonempty { - "Nothing selected" - } else if cut { - "Cut selections" - } else { - "Copied selections" - }); - } + nonempty } fn paste_clipboard(&mut self, after: bool) { @@ -2592,6 +2699,22 @@ impl Editor { } let before = self.snapshot(); let clipboard = std::mem::take(&mut self.clipboard); + self.paste_fragments(after, &clipboard); + if before.lines != self.active.lines { + self.last_edit = Some(EditRecipe { + start: EditStart::Paste { + after, + fragments: clipboard.clone(), + }, + steps: Vec::new(), + }); + } + self.clipboard = clipboard; + self.record(before); + self.message.clear(); + } + + fn paste_fragments(&mut self, after: bool, fragments: &[String]) { let offsets = line_offsets(&self.active.lines); let edits = self .active @@ -2605,7 +2728,7 @@ impl Editor { (cursor.column + 1).min(grapheme_count(&self.active.lines[cursor.row])); } let start = cursor_offset(&self.active.lines, &offsets, cursor); - let text = &clipboard[if clipboard.len() == 1 { 0 } else { target }]; + let text = &fragments[if fragments.len() == 1 { 0 } else { target }]; Edit { target, start, @@ -2618,24 +2741,273 @@ impl Editor { .collect(); self.active.mode = Mode::Insert; self.apply_edits(edits); - self.clipboard = clipboard; + self.leave_insert(); + } + + fn begin_insert(&mut self) { + self.start_insert(self.snapshot(), EditStart::Insert(Action::Insert)); + } + + fn start_insert(&mut self, before: Snapshot, start: EditStart) { + self.active.insert_start = Some(before); + self.active.pending_edit = Some(EditRecipe { + start, + steps: Vec::new(), + }); + self.active.visual_motions = None; + self.active.mode = Mode::Insert; + } + + fn finish_insert(&mut self) { + let recipe = self.active.pending_edit.take(); + if let Some(before) = self.active.insert_start.take() { + if let Some(mut recipe) = recipe { + while matches!(recipe.steps.last(), Some(InsertStep::Move(_, _))) { + recipe.steps.pop(); + } + self.record_native(before, recipe); + } else { + self.record(before); + } + } + } + + fn leave_insert(&mut self) { for target in &mut self.active.targets { target.active.column = target.active.column.saturating_sub(1); } self.active.mode = Mode::Normal; self.normalize_targets(); - self.record(before); - self.message.clear(); } - fn begin_insert(&mut self) { - self.active.insert_start = Some(self.snapshot()); + fn place_insert(&mut self, action: Action) { self.active.mode = Mode::Insert; + if matches!(action, Action::OpenBelow | Action::OpenAbove) { + self.open_lines(matches!(action, Action::OpenBelow)); + return; + } + for target in &mut self.active.targets { + let line = &self.active.lines[target.active.row]; + target.active.column = match action { + Action::InsertFirstNonblank => first_nonblank(line), + Action::AppendLineEnd => grapheme_count(line), + Action::Append => (target.active.column + 1).min(grapheme_count(line)), + _ => target.active.column, + }; + } + self.normalize_targets(); } - fn finish_insert(&mut self) { - if let Some(before) = self.active.insert_start.take() { - self.record(before); + fn push_insert_step(&mut self, step: InsertStep) { + if let Some(recipe) = &mut self.active.pending_edit { + recipe.steps.push(step); + } + } + + fn type_native(&mut self, text: &str) { + self.insert_text(text); + if let Some(recipe) = &mut self.active.pending_edit { + if let Some(InsertStep::Typed(typed)) = recipe.steps.last_mut() { + typed.push_str(text); + } else { + recipe.steps.push(InsertStep::Typed(text.to_owned())); + } + } + } + + fn deletion_mask(&self, backwards: bool) -> Vec { + let mut mask: Vec<_> = self + .active + .targets + .iter() + .map(|target| { + let cursor = target.active; + if backwards { + cursor.row != 0 || cursor.column != 0 + } else { + cursor.column < grapheme_count(&self.active.lines[cursor.row]) + || (self.active.mode == Mode::Insert + && cursor.row + 1 < self.active.lines.len()) + } + }) + .collect(); + if mask.iter().all(|active| *active) { + mask.truncate(1); + } + mask + } + + fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent { + if let Some(motions) = self.active.visual_motions.take() { + return SelectionIntent::Motions(motions); + } + let mut spans: Vec<_> = ranges + .iter() + .map(|range| { + let mut cursor = range.start; + let mut span = 0; + while cursor < range.end { + cursor = self.next_boundary(cursor); + span += 1; + } + span + }) + .collect(); + if spans.iter().all(|span| *span == spans[0]) { + spans.truncate(1); + } + SelectionIntent::Spans(spans) + } + + fn repeat_last_edit(&mut self) { + let Some(recipe) = self.last_edit.take() else { + self.set_message("Nothing to repeat"); + return; + }; + let cardinality = match &recipe.start { + EditStart::Paste { fragments, .. } => Some(fragments.len()), + EditStart::Selection { + selector: SelectionIntent::Spans(spans), + .. + } if self.active.mode != Mode::Visual => Some(spans.len()), + _ => None, + }; + if cardinality.is_some_and(|count| count != 1 && count != self.active.targets.len()) { + self.set_message("Repeat fragment/span count does not match target count"); + self.last_edit = Some(recipe); + return; + } + let before = self.snapshot(); + let mode = self.active.mode; + let selector = self.active.visual_motions.take(); + let search_match = self.active.search_match; + // Moving the clipboard avoids copying it, and lets a failed selection replay roll back. + let clipboard = matches!(&recipe.start, EditStart::Selection { .. }) + .then(|| std::mem::take(&mut self.clipboard)); + match self.replay_edit(&recipe) { + Ok(()) => { + if self.clipboard.is_empty() + && let Some(clipboard) = clipboard + { + self.clipboard = clipboard; + } + self.active.visual_motions = None; + self.record(before); + self.message.clear(); + } + Err(error) => { + self.active.lines = before.lines; + self.active.targets = before.targets; + self.active.primary = before.primary; + self.active.mode = mode; + self.active.visual_motions = selector; + self.active.search_match = search_match; + if let Some(clipboard) = clipboard { + self.clipboard = clipboard; + } + self.set_message(error); + } + } + self.last_edit = Some(recipe); + } + + fn replay_edit(&mut self, recipe: &EditRecipe) -> Result<(), &'static str> { + match &recipe.start { + EditStart::Insert(action) => self.place_insert(*action), + EditStart::Selection { change, selector } => { + let ranges = self.replay_ranges(selector); + self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); + if !*change { + self.active.mode = Mode::Normal; + self.normalize_targets(); + } + } + EditStart::Delete => { + self.active.mode = Mode::Normal; + self.delete_forward(&[true]); + } + EditStart::Join => { + self.active.mode = Mode::Normal; + self.join_lines(); + } + EditStart::Paste { after, fragments } => self.paste_fragments(*after, fragments), + } + for step in &recipe.steps { + match step { + InsertStep::Typed(text) => { + // Scalar boundaries matter when an insertion merges with its right neighbor. + for ch in text.chars() { + let mut bytes = [0; 4]; + self.insert_text(ch.encode_utf8(&mut bytes)); + } + } + InsertStep::Bulk(text) => self.insert_text(text), + InsertStep::Move(action, count) => self.move_targets(*action, *count), + InsertStep::Delete(mask) | InsertStep::Backspace(mask) => { + // Earlier native steps may have merged carets. Refuse the entire transaction + // rather than pairing a recorded partial deletion with different targets. + if mask.len() != 1 && mask.len() != self.active.targets.len() { + return Err("Repeat deletion count does not match target count"); + } + if matches!(step, InsertStep::Delete(_)) { + self.delete_forward(mask); + } else { + self.backspace(mask); + } + } + } + } + if self.active.mode == Mode::Insert { + self.leave_insert(); + } else { + self.active.mode = Mode::Normal; + self.normalize_targets(); + } + Ok(()) + } + + fn replay_ranges(&mut self, selector: &SelectionIntent) -> Vec { + if self.active.mode == Mode::Visual { + return self.selections(); + } + self.active.mode = Mode::Visual; + match selector { + SelectionIntent::Motions(motions) => { + for target in &mut self.active.targets { + target.anchor = target.active; + } + for (action, count) in motions { + self.move_targets(*action, *count); + } + self.normalize_targets(); + self.selections() + } + SelectionIntent::Spans(spans) => { + let ordered = self + .active + .targets + .iter() + .enumerate() + .map(|(index, target)| { + let start = target.active; + let mut end = start; + for _ in 0..spans[if spans.len() == 1 { 0 } else { index }] { + let next = self.next_boundary(end); + if next == end { + break; + } + end = next; + } + let range = Selection { start, end }; + ( + self.range_target(range, true), + index == self.active.primary, + range, + ) + }) + .collect(); + self.merge_target_ranges(ordered, true) + } } } @@ -2651,12 +3023,19 @@ impl Editor { } // Preserve the separate typing / paste / typing undo groups. self.finish_insert(); - let before = (!text.is_empty()).then(|| self.snapshot()); - self.insert_text(&normalize_newlines(text)); - if let Some(before) = before { - self.record(before); + let text = normalize_newlines(text); + if !text.is_empty() { + let before = self.snapshot(); + self.insert_text(&text); + self.record_native( + before, + EditRecipe { + start: EditStart::Insert(Action::Insert), + steps: vec![InsertStep::Bulk(text.into_owned())], + }, + ); } - self.active.insert_start = Some(self.snapshot()); + self.begin_insert(); } fn restore(&mut self, snapshot: Snapshot) { @@ -2665,6 +3044,7 @@ impl Editor { self.active.primary = snapshot.primary; self.active.mode = Mode::Normal; self.active.search_match = None; + self.active.visual_motions = None; self.normalize_targets(); } @@ -2935,7 +3315,7 @@ impl Editor { self.apply_edits(edits); } - fn delete_forward(&mut self) { + fn delete_forward(&mut self, mask: &[bool]) { let offsets = line_offsets(&self.active.lines); let edits = self .active @@ -2943,6 +3323,9 @@ impl Editor { .iter() .enumerate() .filter_map(|(target, item)| { + if !mask[if mask.len() == 1 { 0 } else { target }] { + return None; + } let cursor = item.active; let start = cursor_offset(&self.active.lines, &offsets, cursor); let end = if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { @@ -2968,7 +3351,6 @@ impl Editor { } fn join_lines(&mut self) { - let before = self.snapshot(); let offsets = line_offsets(&self.active.lines); let edits = self .active @@ -3005,10 +3387,9 @@ impl Editor { }) .collect(); self.apply_edits(edits); - self.record(before); } - fn backspace(&mut self) { + fn backspace(&mut self, mask: &[bool]) { let offsets = line_offsets(&self.active.lines); let edits = self .active @@ -3016,6 +3397,9 @@ impl Editor { .iter() .enumerate() .filter_map(|(target, item)| { + if !mask[if mask.len() == 1 { 0 } else { target }] { + return None; + } let cursor = item.active; if cursor.column == 0 && cursor.row == 0 { return None; @@ -5452,6 +5836,7 @@ mod tests { fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { editor.active.mode = Mode::Normal; + editor.active.visual_motions = None; editor.active.targets = positions .iter() .map(|&(row, column)| Target::caret(Cursor { row, column })) @@ -5462,6 +5847,7 @@ mod tests { fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { editor.active.mode = Mode::Visual; + editor.active.visual_motions = None; editor.active.targets = ranges .iter() .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) @@ -5470,6 +5856,625 @@ mod tests { editor.normalize_targets(); } + #[test] + fn repeat_visual_word_change_resolves_fresh_words_for_every_caret_and_ignores_remaps() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("foo\nlonger".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vecitem"); + editor.handle(Input::Escape); + editor + .bind("visual", "e", Some("core.right".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace("tiny\nextraordinary".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + let undo = editor.active.undo.len(); + type_text(&mut editor, "9."); + assert_eq!(editor.context().text, "item\nitem"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor().row, 1); + assert_eq!(editor.count(), None); + assert_eq!(editor.clipboard, vec!["tiny", "extraordinary"]); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "tiny\nextraordinary"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 1, column: 0 }] + ); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "item\nitem"); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "item\nitem"); + } + + #[test] + fn repeat_visual_current_ranges_supersede_unequal_recorded_spans_and_cardinality() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nlonger".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 5 }), + ], + 1, + ); + type_text(&mut editor, "cQ"); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("alpha beta\nsecond\nthird".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 10 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 6 }), + (Cursor { row: 2, column: 0 }, Cursor { row: 2, column: 5 }), + ], + 2, + ); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Q\nQ\nQ"); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.selection_count(), 3); + assert_eq!(editor.cursor().row, 2); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "alpha beta\nsecond\nthird"); + } + + #[test] + fn repeat_cut_retargets_fresh_unequal_words_without_recreating_cloned_carets() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("cat\nlongword".into())]) + .unwrap(); + type_text(&mut editor, "ve"); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Char('d')); + editor + .apply(vec![Effect::Replace("small\nhugeword\nlast".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + // Cloning invalidated the selector; both recorded spans have length three. + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "ll\neword\nt"); + assert_eq!(editor.selection_count(), 3); + editor + .apply(vec![Effect::Replace("cat\nlongword".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "ved"); + editor + .apply(vec![Effect::Replace("tiny\nenormous".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "\n"); + assert_eq!(editor.clipboard, vec!["tiny", "enormous"]); + } + + #[test] + fn repeat_relative_spans_count_graphemes_and_newlines_and_keep_zero_spans_empty() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a界\n👩‍🚀x".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[(Cursor { row: 0, column: 1 }, Cursor { row: 1, column: 1 })], + 0, + ); + editor.handle(Input::Char('d')); + editor + .apply(vec![Effect::Replace("e\u{301}Z\n\n👨‍💻tail".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "\n👨‍💻tail"); + assert_eq!(editor.clipboard, vec!["e\u{301}Z\n"]); + editor.apply(vec![Effect::Replace("".into())]).unwrap(); + set_ranges(&mut editor, &[(Cursor::default(), Cursor::default())], 0); + type_text(&mut editor, "cQ"); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Qabc"); + } + + #[test] + fn repeat_unequal_span_distribution_refuses_atomically_and_recipe_survives() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nlonger".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), + ], + 1, + ); + editor.handle(Input::Char('d')); + editor + .apply(vec![Effect::Replace("1234\n56789".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1)], 0); + let undo = editor.active.undo.len(); + let redo = editor.active.redo.len(); + let clipboard = editor.clipboard.clone(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "1234\n56789"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.active.redo.len(), redo); + assert_eq!(editor.clipboard, clipboard); + assert!(editor.message().contains("count")); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "34\n89"); + } + + #[test] + fn repeat_insert_placements_recalculate_for_current_multi_caret_lines() { + for key in ['i', 'I', 'a', 'A', 'o', 'O'] { + let mut recorded = editor(); + recorded + .apply(vec![Effect::Replace("short".into())]) + .unwrap(); + recorded.handle(Input::Char(key)); + type_text(&mut recorded, "X\nY"); + recorded.handle(Input::Escape); + let text = " alpha\n\tbeta\nz"; + recorded.apply(vec![Effect::Replace(text.into())]).unwrap(); + set_carets(&mut recorded, &[(0, 2), (1, 1), (2, 0)], 1); + let mut expected = editor(); + expected.apply(vec![Effect::Replace(text.into())]).unwrap(); + set_carets(&mut expected, &[(0, 2), (1, 1), (2, 0)], 1); + expected.handle(Input::Char(key)); + type_text(&mut expected, "X\nY"); + expected.handle(Input::Escape); + let undo = recorded.active.undo.len(); + recorded.handle(Input::Char('.')); + assert_eq!(recorded.context().text, expected.context().text, "{key}"); + assert_eq!(recorded.carets(), expected.carets(), "{key}"); + assert_eq!(recorded.cursor(), expected.cursor(), "{key}"); + assert_eq!(recorded.mode(), Mode::Normal); + assert_eq!(recorded.active.undo.len(), undo + 1); + recorded.handle(Input::Char('u')); + assert_eq!(recorded.context().text, text, "{key}"); + assert_eq!(recorded.selection_count(), 3); + } + } + + #[test] + fn repeat_visual_line_insert_uses_current_carets_without_rebuilding_old_row_targets() { + for (key, expected) in [('I', " !alpha\n !beta"), ('A', " alpha!\n beta!")] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, "v2j"); + editor.handle(Input::Char(key)); + type_text(&mut editor, "!"); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 3); + editor + .apply(vec![Effect::Replace(" alpha\n beta".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 2), (1, 2)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, expected); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.cursor().row, 1); + } + } + + #[test] + fn repeat_counted_visual_motion_uses_resolved_action_and_normalizes_overlaps() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("one two three".into())]) + .unwrap(); + type_text(&mut editor, "v2ecQ"); + editor.handle(Input::Escape); + editor + .bind("visual", "e", Some("core.left".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace("a enormous tail".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (0, 2)], 1); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Q"); + assert_eq!(editor.selection_count(), 1); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a enormous tail"); + assert_eq!( + editor.carets(), + vec![Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }] + ); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 }); + } + + #[test] + fn repeat_insert_native_motion_delete_backspace_and_newline_are_one_edit() { + fn edit(editor: &mut Editor) { + editor.handle(Input::Char('i')); + editor.handle(Input::Right); + type_text(editor, "XY"); + editor.handle(Input::Left); + editor.handle(Input::Backspace); + editor.handle(Input::Delete); + editor.handle(Input::Enter); + type_text(editor, "Z"); + editor.handle(Input::Right); // Trailing navigation is not part of the edit. + editor.handle(Input::Escape); + } + let mut recorded = editor(); + recorded + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + edit(&mut recorded); + recorded + .apply(vec![Effect::Replace("123456\nlongertext".into())]) + .unwrap(); + set_carets(&mut recorded, &[(0, 1), (1, 2)], 1); + let mut expected = editor(); + expected + .apply(vec![Effect::Replace("123456\nlongertext".into())]) + .unwrap(); + set_carets(&mut expected, &[(0, 1), (1, 2)], 1); + edit(&mut expected); + let undo = recorded.active.undo.len(); + recorded.handle(Input::Char('.')); + assert_eq!(recorded.context().text, expected.context().text); + assert_eq!(recorded.active.undo.len(), undo + 1); + recorded.handle(Input::Char('u')); + assert_eq!(recorded.context().text, "123456\nlongertext"); + } + + #[test] + fn repeat_omits_noop_insert_deletions_and_preserves_partial_noop_carets() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + editor.handle(Input::Backspace); + editor.handle(Input::Delete); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("abcd".into())]).unwrap(); + set_carets(&mut editor, &[(0, 2)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "abXcd"); + editor.apply(vec![Effect::Replace("a\nab".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 1)], 1); + editor.handle(Input::Char('i')); + editor.handle(Input::Backspace); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("cde\nfgh".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 2), (1, 2)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "cdXe\nfXh"); + } + + #[test] + fn repeat_partial_deletion_mismatch_rolls_back_visual_change_and_clipboard() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\ncd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + type_text(&mut editor, "vc"); + editor.handle(Input::Backspace); + type_text(&mut editor, "X"); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("aa\nbb\ncc".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + editor.handle(Input::Char('v')); + let ranges = editor.selections(); + let targets = editor.active.targets.clone(); + let primary = editor.active.primary; + let clipboard = editor.clipboard.clone(); + let undo = editor.active.undo.len(); + let redo = editor.active.redo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "aa\nbb\ncc"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.selections(), ranges); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.primary, primary); + assert_eq!(editor.clipboard, clipboard); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.active.redo.len(), redo); + assert!(editor.message().contains("count")); + editor.handle(Input::Escape); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "XaXb\ncc"); + } + + #[test] + fn repeat_typed_scalars_keep_right_neighbor_combining_and_zwj_boundaries() { + for (typed, neighbor, result) in + [("ab", "\u{301}", "a\u{301}b"), ("👩x", "\u{200d}🚀", "👩‍🚀x")] + { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, typed); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace(neighbor.into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, result); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, neighbor); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, result); + } + } + + #[test] + fn repeat_literal_paste_is_bulk_literal_and_keeps_original_typing_groups() { + let mut editor = editor(); + type_text(&mut editor, "iX"); + editor.handle(Input::Paste("ab\r\n:q!".into())); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "Xab\n:q!Y"); + for expected in ["Xab\n:q!", "X", ""] { + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, expected); + } + editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Yfresh"); + type_text(&mut editor, "i"); + editor.handle(Input::Paste("ab".into())); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("\u{301}".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "ab\u{301}"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "\u{301}"); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste(":q!\r\n".into())); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("target".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, ":q!\ntarget"); + assert!(!editor.should_quit()); + } + + #[test] + fn repeat_clipboard_captures_fragments_pairs_broadcasts_and_refuses_mismatch() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ab\nxyz".into())]) + .unwrap(); + set_ranges( + &mut editor, + &[ + (Cursor { row: 0, column: 0 }, Cursor { row: 0, column: 2 }), + (Cursor { row: 1, column: 0 }, Cursor { row: 1, column: 3 }), + ], + 1, + ); + editor.handle(Input::Char('y')); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('P')); + editor.apply(vec![Effect::Replace("OTHER".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "vey"); + assert_eq!(editor.clipboard, vec!["OTHER"]); + editor + .apply(vec![Effect::Replace("1\n2\n3".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + let undo = editor.active.undo.len(); + let targets = editor.active.targets.clone(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "1\n2\n3"); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.undo.len(), undo); + assert_eq!(editor.clipboard, vec!["OTHER"]); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "ab1\nxyz2\n3"); + assert_eq!(editor.clipboard, vec!["OTHER"]); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "1\n2\n3"); + editor.clipboard = vec!["!".into()]; + editor.handle(Input::Char('p')); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "a!\nb!\nc!"); + } + + #[test] + fn repeat_join_and_normal_delete_are_native_and_noops_keep_the_previous_recipe() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("a\n b".into())]).unwrap(); + editor.handle(Input::Char('J')); + editor + .apply(vec![Effect::Replace("Done.\n again\nlast".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + let undo = editor.active.undo.len(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Done. again\nlast"); + assert_eq!(editor.active.undo.len(), undo + 1); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "Done.\n again\nlast"); + editor.apply(vec![Effect::Replace("".into())]).unwrap(); + editor.handle(Input::Char('J')); + editor.handle(Input::Char('x')); + editor + .apply(vec![Effect::Replace("one\n two".into())]) + .unwrap(); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "one two"); + editor.handle(Input::Char('x')); + editor + .apply(vec![Effect::Replace("界e\u{301}\n👩‍🚀z".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0), (1, 0)], 1); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "e\u{301}\nz"); + assert_eq!(editor.selection_count(), 2); + } + + #[test] + fn repeat_identical_change_empty_insert_rejected_paste_and_history_keep_recipe_and_redo() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + type_text(&mut editor, "vecabc"); + editor.handle(Input::Escape); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "i"); + editor.handle(Input::Backspace); + editor.handle(Input::Escape); + let redo = editor.active.redo.len(); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("bad\0".into())); + editor.handle(Input::Escape); + assert_eq!(editor.active.redo.len(), redo); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "Xabc"); + editor.handle(Input::Char('u')); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Xabc"); + } + + #[test] + fn repeat_is_session_wide_and_plugin_effects_commit_but_never_record_native_intent() { + let directory = Directory::new(); + let other = directory.path("other"); + fs::write(&other, "base").unwrap(); + let mut editor = editor(); + type_text(&mut editor, "iN"); + editor.execute(&format!("edit {}", other.display())); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Nbase"); + editor.register(plugin("fixture", &["greet"])).unwrap(); + assert_eq!(editor.execute("greet").unwrap().command, "greet"); + editor + .apply(vec![ + Effect::Replace("plugin".into()), + Effect::Insert("!".into()), + ]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + assert_eq!(editor.handle(Input::Char('.')), None); + assert_eq!(editor.context().text, "N!plugin"); + editor.handle(Input::Char('i')); + type_text(&mut editor, "T"); + editor.apply(vec![Effect::Insert("P".into())]).unwrap(); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "Tfresh"); + editor.execute("buffer 1"); + assert_eq!(editor.context().text, "N"); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "TN"); + } + + #[test] + fn repeat_action_no_recipe_remaps_and_literal_queries_do_not_record_or_execute_edits() { + let directory = Directory::new(); + fs::write(directory.path("file.txt"), "file").unwrap(); + let mut editor = project_editor(&directory); + editor.handle(Input::Char('.')); + assert_eq!(editor.message(), "Nothing to repeat"); + assert_eq!(editor.context().text, ""); + assert!(editor.active.undo.is_empty()); + editor + .bind("normal", "r", Some("core.repeat_last_edit".into())) + .unwrap(); + editor + .bind("visual", "r", Some("core.repeat_last_edit".into())) + .unwrap(); + assert!( + editor + .bind("insert", "r", Some("core.repeat_last_edit".into())) + .is_err() + ); + type_text(&mut editor, "iX"); + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Char('i')); + type_text(&mut editor, "."); + assert_eq!(editor.file_picker().unwrap().query, "."); + assert_eq!(editor.context().text, "X"); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "."); + assert_eq!(editor.palette().unwrap().query, "."); + editor.handle(Input::Escape); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("fresh".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('r')); + assert_eq!(editor.context().text, "XYfresh"); + type_text(&mut editor, "i."); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Replace("target".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('r')); + assert_eq!(editor.context().text, ".target"); + type_text(&mut editor, "/."); + assert_eq!(editor.command_line(), "."); + editor.handle(Input::Escape); + type_text(&mut editor, ":."); + assert_eq!(editor.command_line(), "."); + editor.handle(Input::Escape); + editor.apply(vec![Effect::Replace("again".into())]).unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('r')); + assert_eq!(editor.context().text, ".again"); + } + #[test] fn buffers_keep_independent_history_saved_text_carets_primary_and_search() { let directory = Directory::new(); diff --git a/src/main.rs b/src/main.rs index c7e4369..17c53d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count u undo, U redo, : command, / search, n/N next/previous match + Normal/Visual: . repeats last native edit at current targets (one undo group) v select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below Esc keeps only the primary caret Visual: motions extend selections, y copy, d/x/Delete cut, c change diff --git a/src/ruby_host.rb b/src/ruby_host.rb index ffe3694..01798c2 100644 --- a/src/ruby_host.rb +++ b/src/ruby_host.rb @@ -25,7 +25,7 @@ module Vamp %w[ insert insert_first_nonblank append append_line_end open_below open_above left right down up join_lines line_start line_end delete undo redo command - normal newline backspace submit palette + normal newline backspace submit palette repeat_last_edit search search_next search_previous word_next word_previous word_end big_word_next big_word_previous big_word_end select copy cut change paste_before paste_after diff --git a/src/session.rs b/src/session.rs index baff1ea..d27957a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1006,6 +1006,377 @@ end assert!(session.editor().should_quit()); } + #[test] + fn default_dot_replaces_fresh_words_and_each_repeat_has_one_undo() { + let fixture = Fixture::new(); + let buffer = fixture.file("buffer.txt", "old elephant zebra"); + let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap(); + for key in ['v', 'e', 'c'] { + session.handle(Input::Char(key)); + } + for ch in "item".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["item elephant zebra"]); + for key in ['w', '3', '.'] { + session.handle(Input::Char(key)); + } + assert_eq!(session.editor().lines(), &["item item zebra"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item elephant zebra"]); + session.handle(Input::Home); + for key in ['w', 'w', 'v', 'e', '.'] { + session.handle(Input::Char(key)); + } + assert_eq!(session.editor().lines(), &["item elephant item"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item elephant zebra"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["old elephant zebra"]); + assert!(!session.editor().dirty()); + } + + #[test] + fn typed_repeat_remaps_use_resolved_motions_and_current_visual_ranges() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "old enormous tail end"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.bind ".", nil + config.bind ".", nil, mode: config.modes.visual + config.bind "r", config.core.repeat_last_edit + config.bind "r", config.core.repeat_last_edit, mode: config.modes.visual + config.bind "e", config.core.left, mode: config.modes.visual + config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual +end +"#, + ); + session.handle(Input::Char('v')); + session.handle(Input::Ctrl('f')); + session.handle(Input::Char('c')); + for ch in "item".chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Escape); + session.handle(Input::Char('w')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Char('r')); + assert_eq!(session.editor().lines(), &["item item tail end"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Home); + for key in ['w', 'v', '2'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Ctrl('f')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().mode(), crate::editor::Mode::Visual); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Char('r')); + assert_eq!(session.editor().lines(), &["item item end"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item enormous tail end"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["old enormous tail end"]); + } + + #[test] + fn repeat_recipe_crosses_buffers_but_pending_insert_and_undo_stay_per_buffer() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let second = fixture.file("second.txt", "beta"); + let mut session = fixture.open(""); + session.handle(Input::Char('i')); + for ch in "one".chars() { + session.handle(Input::Char(ch)); + } + session.execute(&format!("edit {}", second.display())); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(session.editor().lines(), &["beta"]); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["onebeta"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["beta"]); + session.execute("buffer 1"); + assert_eq!(session.editor().lines(), &["onealpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha"]); + session.handle(Input::Char('U')); + assert_eq!(session.editor().lines(), &["onealpha"]); + session.execute("buffer 2"); + assert_eq!(session.editor().lines(), &["beta"]); + session.handle(Input::Char('U')); + assert_eq!(session.editor().lines(), &["onebeta"]); + session.handle(Input::Home); + session.handle(Input::Char('i')); + for ch in "two".chars() { + session.handle(Input::Char(ch)); + } + session.execute("buffer 1"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["twoonealpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["onealpha"]); + session.execute("buffer 2"); + assert_eq!(session.editor().lines(), &["twoonebeta"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["onebeta"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["beta"]); + } + + #[test] + fn plugin_effects_commit_pending_typing_without_replacing_or_reinvoking_native_recipe() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "base"); + fixture.file( + "effects.rb", + r##" +Vamp.plugin "effects", requires: ["text"] +calls = 0 +Vamp.command("effect") do |editor, args| + calls += 1 + File.open("calls.txt", "a") { |file| file.puts "#{calls} #{args}" } + case args + when "replace" + editor.replace "plugin" + when "insert" + editor.insert "?" + end + editor.message "calls=#{calls}" +end +"##, + ); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.plugin "effects.rb" +end +"#, + ); + session.handle(Input::Char('i')); + session.handle(Input::Char('!')); + session.execute("effect replace"); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(session.editor().lines(), &["plugin"]); + assert_eq!(session.editor().message(), "calls=1"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["!plugin"]); + assert_eq!( + fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), + "1 replace\n" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["plugin"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["!base"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["base"]); + session.handle(Input::Home); + session.execute("effect insert"); + assert_eq!(session.editor().lines(), &["?base"]); + assert_eq!(session.editor().message(), "calls=2"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["!?base"]); + assert_eq!( + fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), + "1 replace\n2 insert\n" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["?base"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["base"]); + session.execute("effect message"); + assert_eq!(session.editor().message(), "calls=3"); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["!base"]); + assert_eq!( + fs::read_to_string(fixture.0.join("calls.txt")).unwrap(), + "1 replace\n2 insert\n3 message\n" + ); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["base"]); + } + + #[test] + fn explicit_save_commits_pending_change_and_repeat_does_not_save_again() { + let fixture = Fixture::new(); + let buffer = fixture.file("buffer.txt", "old elephant"); + let mut session = fixture.open(""); + for key in ['v', 'e', 'c'] { + session.handle(Input::Char(key)); + } + for ch in "item".chars() { + session.handle(Input::Char(ch)); + } + session.execute("w"); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); + session.handle(Input::Home); + session.handle(Input::Char('w')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["item item"]); + assert!(session.editor().dirty()); + assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant"); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["item elephant"]); + assert!(!session.editor().dirty()); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["old elephant"]); + } + + #[test] + fn dots_in_insert_and_focused_queries_are_literal_and_excluded_from_recipe() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "alpha"); + let mut session = fixture.open( + r#" +Vamp.configure do |config| + config.file_explorer_view = "list" + config.file_picker_view = "list" + config.bind "ctrl+f", config.core.file_explorer + config.bind "ctrl+o", config.core.file_picker +end +"#, + ); + session.handle(Input::Char('i')); + session.handle(Input::Char('X')); + session.handle(Input::Escape); + session.handle(Input::Home); + session.handle(Input::Ctrl('f')); + for ch in ".query".chars() { + session.handle(Input::Char(ch)); + } + assert_eq!(session.editor().file_picker().unwrap().query, ".query"); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Escape); + assert!(!session.editor().file_picker().unwrap().focused); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + session.handle(Input::Ctrl('o')); + session.handle(Input::Ctrl('u')); + session.handle(Input::Char('.')); + assert_eq!(session.editor().file_picker().unwrap().query, "."); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Escape); + assert!(session.editor().file_picker().is_none()); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["XXalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Char('.')); + session.handle(Input::Ctrl('p')); + assert!( + session + .editor() + .palette() + .unwrap() + .entries + .iter() + .any(|entry| entry.name == "core.repeat_last_edit") + ); + session.handle(Input::Char('.')); + assert_eq!(session.editor().palette().unwrap().query, "."); + assert_eq!(session.editor().lines(), &[".Xalpha"]); + session.handle(Input::Escape); + session.handle(Input::Char('!')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &[".!Xalpha"]); + for prompt in [':', '/'] { + session.handle(Input::Char(prompt)); + session.handle(Input::Char('.')); + assert_eq!(session.editor().command_line(), "."); + assert_eq!(session.editor().lines(), &[".!Xalpha"]); + session.handle(Input::Escape); + } + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &[".!.!Xalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[".!Xalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["Xalpha"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["alpha"]); + } + + #[test] + fn repeated_clipboard_paste_uses_captured_text_after_a_different_yank() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "one two three"); + let mut session = fixture.open(""); + for key in ['v', 'e', 'y'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Home); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["oneone two three"]); + for key in ['w', 'v', 'e', 'y'] { + session.handle(Input::Char(key)); + } + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["oneoneone two three"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["oneone two three"]); + session.handle(Input::Home); + session.handle(Input::Char('P')); + assert_eq!(session.editor().lines(), &["twooneone two three"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["oneone two three"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["one two three"]); + } + + #[test] + fn literal_paste_repeats_without_dispatch_and_preserves_native_input_groups() { + let fixture = Fixture::new(); + fixture.file("buffer.txt", "seed"); + let mut session = fixture.open(""); + session.handle(Input::Char('i')); + session.handle(Input::Char('a')); + session.handle(Input::Paste(".:q!\r\n界".into())); + session.handle(Input::Char('b')); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &["a.:q!", "界bseed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["a.:q!", "界seed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["aseed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["seed"]); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &["bseed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["seed"]); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste(".:q!\r\n界".into())); + session.handle(Input::Escape); + assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); + session.handle(Input::Home); + session.handle(Input::Char('.')); + assert_eq!(session.editor().lines(), &[".:q!", ".:q!", "界界seed"]); + assert_eq!(session.editor().mode(), crate::editor::Mode::Normal); + assert!(!session.editor().should_quit()); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &[".:q!", "界seed"]); + session.handle(Input::Char('u')); + assert_eq!(session.editor().lines(), &["seed"]); + } + #[test] fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() { let fixture = Fixture::new();