feat: repeat last native edit with dot

This commit is contained in:
Valdemar Lorenzen
2026-10-09 10:06:22 +02:00
parent 4fe3accc56
commit d61f0b750b
6 changed files with 1485 additions and 91 deletions
+16
View File
@@ -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` |
+1
View File
@@ -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
+1095 -90
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
+1 -1
View File
@@ -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
+371
View File
@@ -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();