From ba8f4e9a92000fc4311793942659167c580edac3 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 11 Jul 2026 20:03:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(editor):=20add=20v0.3=20editing=20?= =?UTF-8?q?=E2=80=94=20register,=20undo/redo,=20and=20dot-repeat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yank/paste via one unnamed register: `y` operator (yy/yw/yiw/y$), `p`/`P`, with `x`/`d`/`c` also filling it so `dd`…`p` moves a line. Undo/redo (`u`, `Ctrl-r`) as bounded full-buffer snapshots — one Insert session is one undo group. `.` replays the last change from its recorded keystrokes, so it repeats insert sessions like `ciwfoo`. --- editor/src/lib.rs | 589 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 581 insertions(+), 8 deletions(-) diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 48cc977..3031398 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -89,11 +89,13 @@ pub enum Effect { Pull, } -/// A pending operator awaiting a motion or text object (`d`elete / `c`hange). +/// A pending operator awaiting a motion or text object (`d`elete / `c`hange / +/// `y`ank). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Op { Delete, Change, + Yank, } /// The editor state: buffer, caret, mode, viewport, and pending command state. @@ -131,8 +133,42 @@ pub struct Editor { /// is fmt → save → commit → push. Defaults on; the v0.5 `.typoena.toml` /// `format_on_save` key will drive it. format_on_save: bool, + /// The unnamed register: the last yanked or deleted text, replayed by + /// `p`/`P`. `y`, `d`, `c`, and `x` all fill it (vim's unnamed register), so + /// `dd`…`p` moves a line. There is one register — no named registers yet. + register: String, + /// Whether [`register`](Self::register) holds whole **lines** (from `yy`/`dd`, + /// stored with a trailing `\n`) rather than a character span (`yw`/`x`). It + /// decides how `p`/`P` reinsert: linewise pastes open a new line, charwise + /// paste inline next to the caret. + register_linewise: bool, + /// Undo history: `(text, caret)` snapshots, one per change-group, oldest + /// first. We snapshot the whole buffer rather than journal diffs — prose + /// notes are small and PSRAM is ample (8 MB), so a full copy per edit is + /// cheap and far simpler to reason about. Bounded to [`UNDO_DEPTH`] groups. + undo: Vec<(String, usize)>, + /// Redo history: states popped by `u`, replayable with `Ctrl-r`. Cleared the + /// moment a fresh edit records a new undo baseline (a new branch of history). + redo: Vec<(String, usize)>, + /// The last completed change, as the exact key sequence that produced it — + /// replayed verbatim by `.`. Recording keystrokes (rather than a structured + /// op) is what lets `.` repeat an insert session like `ciwfoo`. + dot: Vec, + /// The change currently being recorded, if one is in progress (from the + /// initiating key through the key that completes it). Committed to [`dot`] on + /// completion. `None` between changes. + dot_recording: Option>, + /// True while `.` is replaying [`dot`], so the replayed keys are neither + /// re-recorded nor able to re-trigger `.`. + replaying: bool, } +/// Maximum undo depth (change-groups). A full-buffer snapshot per group means +/// worst-case memory is `UNDO_DEPTH × buffer size`; for note-sized files on the +/// 8 MB PSRAM this is negligible, and prose editing rarely nears 100 groups +/// between saves anyway. +const UNDO_DEPTH: usize = 100; + /// One wrapped display line: its text and the buffer offset of its first char. struct Line { start: usize, @@ -155,6 +191,13 @@ impl Editor { keyboard_present: false, notice: None, format_on_save: true, + register: String::new(), + register_linewise: false, + undo: Vec::new(), + redo: Vec::new(), + dot: Vec::new(), + dot_recording: None, + replaying: false, } } @@ -221,7 +264,28 @@ impl Editor { // moment you move on — no timed repaint (which on e-ink would cost a // full ~630 ms flash just to erase text). self.notice = None; - match self.mode { + + // `.` repeats the last change — intercepted before dispatch (in Normal, + // not mid-command, not already replaying) so the '.' keystroke itself is + // never inserted or recorded. In Insert mode '.' falls through as a + // literal character. + if !self.replaying + && self.mode == Mode::Normal + && self.pending_op.is_none() + && self.pending_obj.is_none() + && self.dot_recording.is_none() + && key == Key::Char('.') + { + self.repeat_last_change(); + return Effect::None; + } + + // State before dispatch, so `record_dot` can read the transition a key + // caused (entered Insert, started an operator, …). + let before_mode = self.mode; + let before_pending = self.pending_op.is_some() || self.pending_obj.is_some(); + + let effect = match self.mode { Mode::Insert => { self.insert_key(key); Effect::None @@ -235,7 +299,64 @@ impl Editor { Effect::None } Mode::Command => self.command_key(key), + }; + + if !self.replaying { + self.record_dot(key, before_mode, before_pending); } + effect + } + + /// Record `key` into the in-progress change for `.`. Called after dispatch + /// with the mode/operator state as it was *before*. A change is recorded + /// from its initiating key (an edit `x`/`p`/`P`, an operator `d`/`c`, or any + /// key that enters Insert) through the key that completes it — an operator + /// resolving back to Normal, or `Esc` ending an insert session. Yank (`y`) + /// and pure motions never start a recording, so `.` ignores them. The leading + /// count is not captured (so `3x` then `.` deletes one), but a count *inside* + /// an operator is (`d2w` records in full). + fn record_dot(&mut self, key: Key, before_mode: Mode, before_pending: bool) { + if self.dot_recording.is_some() { + self.dot_recording.as_mut().unwrap().push(key); + if self.change_complete() { + self.dot = self.dot_recording.take().unwrap(); + } + return; + } + // Not yet recording: does this key begin a change? Only from a clean + // Normal state (no operator already pending — that key belongs to an + // in-progress command we'd have been recording already). + if before_mode == Mode::Normal && !before_pending { + let starts = matches!(key, Key::Char('x') | Key::Char('p') | Key::Char('P')) + || self.mode == Mode::Insert + || matches!(self.pending_op, Some(Op::Delete) | Some(Op::Change)); + if starts { + self.dot_recording = Some(vec![key]); + if self.change_complete() { + self.dot = self.dot_recording.take().unwrap(); + } + } + } + } + + /// A recorded change is complete once we're back in Normal with no operator + /// still pending (an immediate edit, a resolved operator, or a finished + /// insert session). + fn change_complete(&self) -> bool { + self.mode == Mode::Normal && self.pending_op.is_none() && self.pending_obj.is_none() + } + + /// `.` — replay the last recorded change. Sets [`replaying`](Self::replaying) + /// so the replayed keys are not themselves recorded and cannot recurse. + fn repeat_last_change(&mut self) { + if self.dot.is_empty() { + return; + } + self.replaying = true; + for k in self.dot.clone() { + self.handle(k); + } + self.replaying = false; } // --- Insert mode ------------------------------------------------------- @@ -250,8 +371,8 @@ impl Editor { Key::DeleteLine => self.delete_to_line_start(), // Half-page scroll is a navigation gesture — Normal/View only. In // Insert it's a no-op rather than yanking the caret off the text - // you're typing. - Key::HalfPageDown | Key::HalfPageUp => {} + // you're typing. Redo (Ctrl-r) is likewise Normal-only here. + Key::HalfPageDown | Key::HalfPageUp | Key::Redo => {} Key::Escape => { self.mode = Mode::Normal; // vim drops the caret onto the last inserted char. @@ -280,6 +401,12 @@ impl Editor { self.move_display_rows(-(HALF_PAGE as isize)); return; } + // Ctrl-r redo: like any non-motion key it abandons a pending command. + Key::Redo => { + self.reset_pending(); + self.redo(); + return; + } // Esc and other non-character events cancel any pending command. _ => { self.reset_pending(); @@ -316,8 +443,13 @@ impl Editor { self.count = 0; return; } - 'd' if op == Op::Delete => (0..n).for_each(|_| self.delete_current_line()), + 'd' if op == Op::Delete => { + self.checkpoint(); // one snapshot for the whole `ndd` + self.register_lines(n); // yank the lines before removing them + (0..n).for_each(|_| self.delete_current_line()); + } 'c' if op == Op::Change => self.change_current_line(), + 'y' if op == Op::Yank => self.register_lines(n), // `yy` — caret stays put 'w' => { let mut t = self.caret; (0..n).for_each(|_| t = self.word_forward_pos(t)); @@ -374,7 +506,24 @@ impl Editor { self.pending_g = true; return; } - 'x' => (0..n).for_each(|_| self.delete_at_caret()), + 'x' => { + self.checkpoint(); + // Yank the chars we're about to delete (charwise), so `x`…`p` + // works. `x` never crosses the line end. + let s = self.caret; + let le = self.line_end(s); + let mut e = s; + for _ in 0..n { + if e >= le { + break; + } + e = self.next_char(e); + } + self.register = self.text[s..e].to_string(); + self.register_linewise = false; + (0..n).for_each(|_| self.delete_at_caret()); + } + 'u' => self.undo(), 'd' => { self.pending_op = Some(Op::Delete); return; @@ -383,25 +532,41 @@ impl Editor { self.pending_op = Some(Op::Change); return; } - 'i' => self.mode = Mode::Insert, + 'y' => { + self.pending_op = Some(Op::Yank); + return; + } + 'p' => self.paste_after(n), + 'P' => self.paste_before(n), + // Entering Insert snapshots once here; the whole session (up to Esc) + // is one undo group, so `u` reverts an entire typed run at a time. + 'i' => { + self.checkpoint(); + self.mode = Mode::Insert; + } 'a' => { + self.checkpoint(); self.move_right_append(); self.mode = Mode::Insert; } 'A' => { + self.checkpoint(); self.caret = self.line_end(self.caret); self.mode = Mode::Insert; } 'I' => { + self.checkpoint(); self.caret = self.line_start(self.caret); self.mode = Mode::Insert; } 'o' => { + self.checkpoint(); self.caret = self.line_end(self.caret); self.insert_char('\n'); self.mode = Mode::Insert; } 'O' => { + self.checkpoint(); let p = self.line_start(self.caret); self.text.insert(p, '\n'); self.caret = p; @@ -492,6 +657,7 @@ impl Editor { /// lines, strip trailing whitespace) and keep the caret on roughly the same /// line (buffer length changes, so exact restoration isn't possible). fn format_buffer(&mut self) { + self.checkpoint(); // `:fmt` (and format-on-save) is undoable let row = self.text[..self.caret].bytes().filter(|&b| b == b'\n').count(); self.text = format_markdown(&self.text); // Land the caret at the start of the same logical line, clamped. @@ -522,6 +688,55 @@ impl Editor { self.pending_g = false; } + // --- Undo / redo ------------------------------------------------------- + + /// Record the current `(text, caret)` as an undo baseline, at the *start* of + /// a change-group, and drop the redo history (a new edit forks the timeline). + /// Called once per change: on entering Insert (the whole session undoes + /// together), and before each Normal-mode edit (`x`, `dd`, operators, paste, + /// `:fmt`). If the buffer is unchanged since the last baseline it is a no-op, + /// so calling it more than once before a mutation records only one group. + fn checkpoint(&mut self) { + if self.undo.last().is_some_and(|(t, _)| t == &self.text) { + return; // nothing changed since the last baseline + } + self.undo.push((self.text.clone(), self.caret)); + if self.undo.len() > UNDO_DEPTH { + self.undo.remove(0); // drop the oldest group + } + self.redo.clear(); + } + + /// `u` — restore the most recent undo baseline, pushing the current state to + /// the redo stack. Lands in Normal mode with the caret clamped onto a char + /// boundary. No-op with nothing to undo. + fn undo(&mut self) { + if let Some((text, caret)) = self.undo.pop() { + self.redo.push((self.text.clone(), self.caret)); + self.restore(text, caret); + } + } + + /// `Ctrl-r` — reapply the most recently undone state. No-op with nothing to + /// redo. + fn redo(&mut self) { + if let Some((text, caret)) = self.redo.pop() { + self.undo.push((self.text.clone(), self.caret)); + self.restore(text, caret); + } + } + + /// Swap in a snapshot's buffer + caret, landing in Normal on a char boundary. + fn restore(&mut self, text: String, caret: usize) { + self.text = text; + self.caret = caret.min(self.text.len()); + while self.caret > 0 && !self.text.is_char_boundary(self.caret) { + self.caret -= 1; + } + self.mode = Mode::Normal; + self.reset_pending(); + } + // --- View mode --------------------------------------------------------- fn view_key(&mut self, key: Key) { @@ -782,18 +997,100 @@ impl Editor { /// `cc` — clear the current line's text and drop into insert. fn change_current_line(&mut self) { + self.checkpoint(); let ls = self.line_start(self.caret); let le = self.line_end(self.caret); + self.register = format!("{}\n", &self.text[ls..le]); // linewise, like dd + self.register_linewise = true; self.text.replace_range(ls..le, ""); self.caret = ls; self.mode = Mode::Insert; } + /// Yank `n` logical lines from the caret's line into the register, linewise + /// (each line carries its trailing `\n`, synthesised for a final line that + /// lacks one). Backs both `yy`/`nyy` and `dd`/`ndd`'s register capture; does + /// not move the caret or change the buffer. + fn register_lines(&mut self, n: usize) { + let ls = self.line_start(self.caret); + let mut e = ls; + for _ in 0..n { + let le = self.line_end(e); + e = if le < self.text.len() { le + 1 } else { le }; + } + let mut block = self.text[ls..e].to_string(); + if !block.ends_with('\n') { + block.push('\n'); // the last line has no trailing newline; add one + } + self.register = block; + self.register_linewise = true; + } + + /// `p` — paste the register `n` times after the caret. Linewise content + /// opens new line(s) below the current line (caret to the first pasted + /// line); charwise content goes in just after the caret char (caret on the + /// last pasted char). No-op on an empty register. + fn paste_after(&mut self, n: usize) { + if self.register.is_empty() { + return; + } + self.checkpoint(); + let content = self.register.repeat(n); + if self.register_linewise { + let le = self.line_end(self.caret); + if le < self.text.len() { + let at = le + 1; // start of the following line + self.text.insert_str(at, &content); + self.caret = at; + } else { + // Last line has no trailing newline: prefix one, drop the + // block's trailing newline so we don't leave a blank line. + let block = content.strip_suffix('\n').unwrap_or(&content); + self.text.insert_str(le, &format!("\n{block}")); + self.caret = le + 1; + } + } else { + let at = if self.text.is_empty() { 0 } else { self.next_char(self.caret) }; + self.text.insert_str(at, &content); + self.caret = self.prev_char(at + content.len()); // onto the last char + } + } + + /// `P` — paste the register `n` times before the caret. Linewise content + /// opens new line(s) above the current line; charwise content goes in at the + /// caret (caret on the last pasted char). No-op on an empty register. + fn paste_before(&mut self, n: usize) { + if self.register.is_empty() { + return; + } + self.checkpoint(); + let content = self.register.repeat(n); + if self.register_linewise { + let ls = self.line_start(self.caret); + self.text.insert_str(ls, &content); + self.caret = ls; + } else { + let at = self.caret; + self.text.insert_str(at, &content); + self.caret = self.prev_char(at + content.len()); // onto the last char + } + } + /// Apply a pending operator over the buffer range `[start, end)` (order - /// independent). Delete removes it; Change removes it and enters insert. + /// independent). All three fill the unnamed register (charwise) with the + /// range. Yank copies and leaves the text; Delete removes it; Change removes + /// it and enters insert. Yank leaves the caret at the range start (vim `yw`), + /// the others land it there because the text collapses to that point. fn apply_op(&mut self, op: Op, start: usize, end: usize) { let s = start.min(end); let e = start.max(end).min(self.text.len()); + self.register = self.text[s..e].to_string(); + self.register_linewise = false; + if op == Op::Yank { + self.caret = s; + return; + } + self.checkpoint(); // Delete/Change mutate — snapshot for undo self.text.replace_range(s..e, ""); self.caret = s.min(self.text.len()); if op == Op::Change { @@ -1236,6 +1533,7 @@ impl Editor { match self.pending_op { Some(Op::Delete) => s.push('d'), Some(Op::Change) => s.push('c'), + Some(Op::Yank) => s.push('y'), None => {} } match self.pending_obj { @@ -1858,6 +2156,281 @@ mod tests { assert_eq!(e.mode(), Mode::Command); } + // ---- Register + yank / paste (v0.3) ---- + + #[test] + fn yy_then_p_opens_a_copy_of_the_line_below() { + let mut e = Editor::with_text("foo\nbar".to_string()); + e.handle(Key::Char('g')); + e.handle(Key::Char('g')); // gg -> caret on line "foo" + e.handle(Key::Char('y')); + e.handle(Key::Char('y')); // yank the line, linewise + e.handle(Key::Char('p')); // paste it after the current line + assert_eq!(e.text(), "foo\nfoo\nbar"); + } + + #[test] + fn yy_then_capital_p_pastes_the_line_above() { + let mut e = Editor::with_text("foo\nbar".to_string()); // caret on line "bar" + e.handle(Key::Char('y')); + e.handle(Key::Char('y')); + e.handle(Key::Char('P')); + assert_eq!(e.text(), "foo\nbar\nbar"); + } + + #[test] + fn dd_then_p_moves_a_line_down() { + let mut e = Editor::with_text("one\ntwo\nthree".to_string()); + e.handle(Key::Char('g')); + e.handle(Key::Char('g')); // caret on "one" + e.handle(Key::Char('d')); + e.handle(Key::Char('d')); // cut "one" into the register + e.handle(Key::Char('p')); // paste after "two" + assert_eq!(e.text(), "two\none\nthree"); + } + + #[test] + fn count_dd_captures_all_lines_for_paste() { + let mut e = Editor::with_text("a\nb\nc\nd".to_string()); + e.handle(Key::Char('g')); + e.handle(Key::Char('g')); + e.handle(Key::Char('3')); + e.handle(Key::Char('d')); + e.handle(Key::Char('d')); // 3dd — cut three lines + assert_eq!(e.text(), "d"); + e.handle(Key::Char('p')); // paste all three back below "d" + assert_eq!(e.text(), "d\na\nb\nc"); + } + + #[test] + fn x_then_p_replays_the_deleted_char_after_the_caret() { + let mut e = Editor::with_text("abc".to_string()); + e.handle(Key::Char('0')); // caret on 'a' + e.handle(Key::Char('x')); // delete 'a' -> "bc", register = "a" (charwise) + e.handle(Key::Char('p')); // paste after 'b' + assert_eq!(e.text(), "bac"); + } + + #[test] + fn yw_yanks_charwise_and_p_inserts_after_the_caret() { + let mut e = Editor::with_text("foo bar".to_string()); + e.handle(Key::Char('0')); + e.handle(Key::Char('y')); + e.handle(Key::Char('w')); // yank "foo " (word + trailing space), caret stays put + e.handle(Key::Char('p')); + assert_eq!(e.text(), "ffoo oo bar"); // charwise paste after the cursor char + } + + #[test] + fn capital_p_pastes_a_char_before_the_caret() { + let mut e = Editor::with_text("abc".to_string()); + e.handle(Key::Char('0')); + e.handle(Key::Char('x')); // register = "a", text "bc", caret on 'b' + e.handle(Key::Char('l')); // caret on 'c' + e.handle(Key::Char('P')); // paste "a" before 'c' + assert_eq!(e.text(), "bac"); + } + + #[test] + fn paste_with_an_empty_register_is_a_noop() { + let mut e = Editor::with_text("abc".to_string()); + e.handle(Key::Char('p')); + e.handle(Key::Char('P')); + assert_eq!(e.text(), "abc"); + } + + // ---- Undo / redo (v0.3) ---- + + #[test] + fn undo_reverts_a_whole_insert_session_at_once() { + let mut e = Editor::new(); + e.handle(Key::Char('i')); + for c in "hello".chars() { + e.handle(Key::Char(c)); + } + e.handle(Key::Escape); + assert_eq!(e.text(), "hello"); + e.handle(Key::Char('u')); + assert_eq!(e.text(), ""); // the entire typed run, not one char + assert_eq!(e.mode(), Mode::Normal); // undo always lands in Normal + } + + #[test] + fn redo_reapplies_an_undone_change() { + let mut e = Editor::new(); + e.handle(Key::Char('i')); + e.handle(Key::Char('x')); + e.handle(Key::Escape); // "x" + e.handle(Key::Char('u')); + assert_eq!(e.text(), ""); + e.handle(Key::Redo); // Ctrl-r + assert_eq!(e.text(), "x"); + } + + #[test] + fn undo_reverts_dd() { + let mut e = Editor::with_text("one\ntwo".to_string()); + e.handle(Key::Char('g')); + e.handle(Key::Char('g')); + e.handle(Key::Char('d')); + e.handle(Key::Char('d')); + assert_eq!(e.text(), "two"); + e.handle(Key::Char('u')); + assert_eq!(e.text(), "one\ntwo"); + } + + #[test] + fn undo_reverts_x_and_restores_the_caret() { + let mut e = Editor::with_text("abc".to_string()); // caret on 'c' + e.handle(Key::Char('x')); + assert_eq!(e.text(), "ab"); + e.handle(Key::Char('u')); + assert_eq!(e.text(), "abc"); + assert_eq!(e.caret, 2); // caret came back to where the change began + } + + #[test] + fn undo_reverts_a_paste() { + let mut e = Editor::with_text("foo\nbar".to_string()); + e.handle(Key::Char('g')); + e.handle(Key::Char('g')); + e.handle(Key::Char('y')); + e.handle(Key::Char('y')); + e.handle(Key::Char('p')); + assert_eq!(e.text(), "foo\nfoo\nbar"); + e.handle(Key::Char('u')); + assert_eq!(e.text(), "foo\nbar"); + } + + #[test] + fn a_fresh_edit_after_undo_clears_the_redo_history() { + let mut e = Editor::new(); + e.handle(Key::Char('i')); + e.handle(Key::Char('a')); + e.handle(Key::Escape); // "a" + e.handle(Key::Char('u')); // -> "" + e.handle(Key::Char('i')); + e.handle(Key::Char('b')); + e.handle(Key::Escape); // new branch: "b" + e.handle(Key::Redo); // nothing to redo — the "a" branch is gone + assert_eq!(e.text(), "b"); + } + + #[test] + fn successive_undos_walk_the_history_back() { + let mut e = Editor::new(); + e.handle(Key::Char('i')); + e.handle(Key::Char('a')); + e.handle(Key::Escape); // "a" + e.handle(Key::Char('A')); + e.handle(Key::Char('b')); + e.handle(Key::Escape); // "ab" + e.handle(Key::Char('u')); + assert_eq!(e.text(), "a"); + e.handle(Key::Char('u')); + assert_eq!(e.text(), ""); + } + + #[test] + fn undo_with_empty_history_is_a_noop() { + let mut e = Editor::with_text("x".to_string()); + e.handle(Key::Char('u')); + assert_eq!(e.text(), "x"); + e.handle(Key::Redo); + assert_eq!(e.text(), "x"); + } + + // ---- `.` repeat (v0.3) ---- + + #[test] + fn dot_repeats_x() { + let mut e = Editor::with_text("abcde".to_string()); + e.handle(Key::Char('0')); + e.handle(Key::Char('x')); // "bcde" + e.handle(Key::Char('.')); // "cde" + e.handle(Key::Char('.')); // "de" + assert_eq!(e.text(), "de"); + } + + #[test] + fn dot_repeats_dd() { + let mut e = Editor::with_text("a\nb\nc\nd".to_string()); + e.handle(Key::Char('g')); + e.handle(Key::Char('g')); + e.handle(Key::Char('d')); + e.handle(Key::Char('d')); // delete "a" + e.handle(Key::Char('.')); // delete "b" + assert_eq!(e.text(), "c\nd"); + } + + #[test] + fn dot_repeats_dw() { + let mut e = Editor::with_text("foo bar baz".to_string()); + e.handle(Key::Char('0')); + e.handle(Key::Char('d')); + e.handle(Key::Char('w')); // "bar baz" + e.handle(Key::Char('.')); // "baz" + assert_eq!(e.text(), "baz"); + } + + #[test] + fn dot_repeats_a_change_operator_with_its_inserted_text() { + // The reason `.` records keystrokes: it must replay `ciw` *and* the text + // typed in the insert session that followed. + let mut e = Editor::with_text("foo bar".to_string()); + e.handle(Key::Char('0')); + e.handle(Key::Char('c')); + e.handle(Key::Char('i')); + e.handle(Key::Char('w')); + e.handle(Key::Char('X')); + e.handle(Key::Escape); // "X bar" + assert_eq!(e.text(), "X bar"); + e.handle(Key::Char('w')); // caret onto "bar" + e.handle(Key::Char('.')); // repeat: change that word to "X" too + assert_eq!(e.text(), "X X"); + } + + #[test] + fn dot_repeats_a_paste() { + let mut e = Editor::with_text("x\na\nb".to_string()); + e.handle(Key::Char('g')); + e.handle(Key::Char('g')); + e.handle(Key::Char('y')); + e.handle(Key::Char('y')); // yank line "x" + e.handle(Key::Char('p')); // "x\nx\na\nb" + e.handle(Key::Char('.')); // paste again below + assert_eq!(e.text(), "x\nx\nx\na\nb"); + } + + #[test] + fn dot_ignores_pure_motions() { + let mut e = Editor::with_text("abc".to_string()); + e.handle(Key::Char('0')); + e.handle(Key::Char('l')); // motions only — nothing to repeat + e.handle(Key::Char('.')); + assert_eq!(e.text(), "abc"); + } + + #[test] + fn a_yank_does_not_become_the_dot_change() { + // `y` is not a `.`-repeatable change; the prior `x` must remain the dot. + let mut e = Editor::with_text("abcdef".to_string()); + e.handle(Key::Char('0')); + e.handle(Key::Char('x')); // dot = x; "bcdef" + e.handle(Key::Char('y')); + e.handle(Key::Char('w')); // yank — must not overwrite the dot + e.handle(Key::Char('.')); // repeat the x, not the yank + assert_eq!(e.text(), "cdef"); + } + + #[test] + fn dot_in_insert_mode_is_a_literal_character() { + let mut e = Editor::new(); + e.handle(Key::Char('i')); + e.handle(Key::Char('.')); + assert_eq!(e.text(), "."); // '.' only repeats from Normal + } + #[test] fn text_getter_reflects_edits() { let e = typed("hello");