Skip to main content

hjkl_engine/
vim.rs

1//! Vim-mode engine.
2//!
3//! Implements a command grammar of the form
4//!
5//! ```text
6//! Command := count? (operator count? (motion | text-object)
7//!                   | motion
8//!                   | insert-entry
9//!                   | misc)
10//! ```
11//!
12//! The parser is a small state machine driven by one `Input` at a time.
13//! Motions and text objects produce a [`Range`] (with inclusive/exclusive
14//! / linewise classification). A single [`Operator`] implementation
15//! applies a range — so `dw`, `d$`, `daw`, and visual `d` all go through
16//! the same code path.
17//!
18//! The most recent mutating command is stored in
19//! [`VimState::last_change`] so `.` can replay it.
20//!
21//! # Roadmap
22//!
23//! Tracked in the original plan at
24//! `~/.claude/plans/look-at-the-vim-curried-fern.md`. Phases still
25//! outstanding — each one can land as an isolated PR.
26//!
27//! ## P3 — Registers & marks
28//!
29//! - TODO: `RegisterBank` indexed by char:
30//!     - unnamed `""`, last-yank `"0`, small-delete `"-`
31//!     - named `"a-"z` (uppercase `"A-"Z` appends instead of overwriting)
32//!     - blackhole `"_`
33//!     - system clipboard `"+` / `"*` (wire to `crate::clipboard::Clipboard`)
34//!     - read-only `":`, `".`, `"%` — surface in `:reg` output
35//! - TODO: route every yank / cut / paste through the bank. Parser needs
36//!   a `"{reg}` prefix state that captures the target register before a
37//!   count / operator.
38//! - TODO: `m{a-z}` sets a mark in a `HashMap<char, (buffer_id, row, col)>`;
39//!   `'x` jumps to the line (FirstNonBlank), `` `x `` to the exact cell.
40//!   Uppercase marks are global across tabs; lowercase are per-buffer.
41//! - TODO: `''` and `` `` `` jump to the last-jump position; `'[` `']`
42//!   `'<` `'>` bound the last change / visual region.
43//! - TODO: `:reg` and `:marks` ex commands.
44//!
45//! ## P4 — Macros
46//!
47//! - TODO: `q{a-z}` starts recording raw `Input`s into the register;
48//!   next `q` stops.
49//! - TODO: `@{a-z}` replays the register by re-feeding inputs through
50//!   `step`. `@@` repeats the last macro. Nested macros need a sane
51//!   depth cap (e.g. 100) to avoid runaway loops.
52//! - TODO: ensure recording doesn't capture the initial `q{a-z}` itself.
53//!
54//! ## P6 — Polish (still outstanding)
55//!
56//! - TODO: indent operators `>` / `<` (with line + text-object targets).
57//! - TODO: format operator `=` — map to whatever SQL formatter we wire
58//!   up; for now stub that returns the range unchanged with a toast.
59//! - TODO: case operators `gU` / `gu` / `g~` on a range (already have
60//!   single-char `~`).
61//! - TODO: screen motions `H` / `M` / `L` once we track the render
62//!   viewport height inside Editor.
63//! - TODO: scroll-to-cursor motions `zz` / `zt` / `zb`.
64//!
65//! ## Known substrate / divergence notes
66//!
67//! - TODO: insert-mode indent helpers — `Ctrl-t` / `Ctrl-d` (increase /
68//!   decrease indent on current line) and `Ctrl-r <reg>` (paste from a
69//!   register). `Ctrl-r` needs the `RegisterBank` from P3 to be useful.
70//! - TODO: `/` and `?` search prompts still live in `the host/src/lib.rs`.
71//!   The plan calls for moving them into the editor (so the editor owns
72//!   `last_search_pattern` rather than the TUI loop). Safe to defer.
73
74use crate::VimMode;
75use crate::input::{Input, Key};
76
77use crate::editor::Editor;
78
79// ─── Modes & parser state ───────────────────────────────────────────────────
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum Mode {
83    #[default]
84    Normal,
85    Insert,
86    Visual,
87    VisualLine,
88    /// Column-oriented selection (`Ctrl-V`). Unlike the other visual
89    /// modes this one doesn't use tui-textarea's single-range selection
90    /// — the block corners live in [`VimState::block_anchor`] and the
91    /// live cursor. Operators read the rectangle off those two points.
92    VisualBlock,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Default)]
96enum Pending {
97    #[default]
98    None,
99    /// Operator seen; still waiting for a motion / text-object / double-op.
100    /// `count1` is any count pressed before the operator.
101    Op { op: Operator, count1: usize },
102    /// Operator + 'i' or 'a' seen; waiting for the text-object character.
103    OpTextObj {
104        op: Operator,
105        count1: usize,
106        inner: bool,
107    },
108    /// Operator + 'g' seen (for `dgg`).
109    OpG { op: Operator, count1: usize },
110    /// Bare `g` seen in normal/visual — looking for `g`, `e`, `E`, …
111    G,
112    /// Bare `f`/`F`/`t`/`T` — looking for the target char.
113    Find { forward: bool, till: bool },
114    /// Operator + `f`/`F`/`t`/`T` — looking for target char.
115    OpFind {
116        op: Operator,
117        count1: usize,
118        forward: bool,
119        till: bool,
120    },
121    /// `r` pressed — waiting for the replacement char.
122    Replace,
123    /// Visual mode + `i` or `a` pressed — waiting for the text-object
124    /// character to extend the selection over.
125    VisualTextObj { inner: bool },
126    /// Bare `z` seen — looking for `z` (center), `t` (top), `b` (bottom).
127    Z,
128    /// `m` pressed — waiting for the mark letter to set.
129    SetMark,
130    /// `'` pressed — waiting for the mark letter to jump to its line
131    /// (lands on first non-blank, linewise for operators).
132    GotoMarkLine,
133    /// `` ` `` pressed — waiting for the mark letter to jump to the
134    /// exact `(row, col)` stored at set time (charwise for operators).
135    GotoMarkChar,
136    /// `"` pressed — waiting for the register selector. The next char
137    /// (`a`–`z`, `A`–`Z`, `0`–`9`, or `"`) sets `pending_register`.
138    SelectRegister,
139    /// `q` pressed (not currently recording) — waiting for the macro
140    /// register name. The macro records every key after the chord
141    /// resolves, until a bare `q` ends the recording.
142    RecordMacroTarget,
143    /// `@` pressed — waiting for the macro register name to play.
144    /// `count` is the prefix multiplier (`3@a` plays the macro 3
145    /// times); 0 means "no prefix" and is treated as 1.
146    PlayMacroTarget { count: usize },
147}
148
149// ─── Operator / Motion / TextObject ────────────────────────────────────────
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum Operator {
153    Delete,
154    Change,
155    Yank,
156    /// `gU{motion}` — uppercase the range. Entered via the `g` prefix
157    /// in normal mode or `U` in visual mode.
158    Uppercase,
159    /// `gu{motion}` — lowercase the range. `u` in visual mode.
160    Lowercase,
161    /// `g~{motion}` — toggle case of the range. `~` in visual mode
162    /// (character at the cursor for the single-char `~` command stays
163    /// its own code path in normal mode).
164    ToggleCase,
165    /// `>{motion}` — indent the line range by `shiftwidth` spaces.
166    /// Always linewise, even when the motion is char-wise — mirrors
167    /// vim's behaviour where `>w` indents the current line, not the
168    /// word on it.
169    Indent,
170    /// `<{motion}` — outdent the line range (remove up to
171    /// `shiftwidth` leading spaces per line).
172    Outdent,
173    /// `zf{motion}` / `zf{textobj}` / Visual `zf` — create a closed
174    /// fold spanning the row range. Doesn't mutate the buffer text;
175    /// cursor restores to the operator's start position.
176    Fold,
177    /// `gq{motion}` — reflow the row range to `settings.textwidth`.
178    /// Greedy word-wrap: collapses each paragraph (blank-line-bounded
179    /// run) into space-separated words, then re-emits lines whose
180    /// width stays under `textwidth`. Always linewise, like indent.
181    Reflow,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum Motion {
186    Left,
187    Right,
188    Up,
189    Down,
190    WordFwd,
191    BigWordFwd,
192    WordBack,
193    BigWordBack,
194    WordEnd,
195    BigWordEnd,
196    /// `ge` — backward word end.
197    WordEndBack,
198    /// `gE` — backward WORD end.
199    BigWordEndBack,
200    LineStart,
201    FirstNonBlank,
202    LineEnd,
203    FileTop,
204    FileBottom,
205    Find {
206        ch: char,
207        forward: bool,
208        till: bool,
209    },
210    FindRepeat {
211        reverse: bool,
212    },
213    MatchBracket,
214    WordAtCursor {
215        forward: bool,
216        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
217        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
218        whole_word: bool,
219    },
220    /// `n` / `N` — repeat the last `/` or `?` search.
221    SearchNext {
222        reverse: bool,
223    },
224    /// `H` — cursor to viewport top (plus `count - 1` rows down).
225    ViewportTop,
226    /// `M` — cursor to viewport middle.
227    ViewportMiddle,
228    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
229    ViewportBottom,
230    /// `g_` — last non-blank char on the line.
231    LastNonBlank,
232    /// `gM` — cursor to the middle char column of the current line
233    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
234    LineMiddle,
235    /// `{` — previous paragraph (preceding blank line, or top).
236    ParagraphPrev,
237    /// `}` — next paragraph (following blank line, or bottom).
238    ParagraphNext,
239    /// `(` — previous sentence boundary.
240    SentencePrev,
241    /// `)` — next sentence boundary.
242    SentenceNext,
243    /// `gj` — `count` visual rows down (one screen segment per step
244    /// under `:set wrap`; falls back to `Down` otherwise).
245    ScreenDown,
246    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
247    ScreenUp,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum TextObject {
252    Word {
253        big: bool,
254    },
255    Quote(char),
256    Bracket(char),
257    Paragraph,
258    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
259    /// content between `>` and `</`; `inner = false` covers the open
260    /// tag through the close tag inclusive.
261    XmlTag,
262    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
263    /// followed by whitespace or end-of-line. `inner = true` covers
264    /// the sentence text only; `inner = false` includes trailing
265    /// whitespace.
266    Sentence,
267}
268
269/// Classification determines how operators treat the range end.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum MotionKind {
272    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
273    Exclusive,
274    /// Range end is inclusive. Typical: e, f, t, %.
275    Inclusive,
276    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
277    Linewise,
278}
279
280// ─── Dot-repeat storage ────────────────────────────────────────────────────
281
282/// Information needed to replay a mutating change via `.`.
283#[derive(Debug, Clone)]
284enum LastChange {
285    /// Operator over a motion.
286    OpMotion {
287        op: Operator,
288        motion: Motion,
289        count: usize,
290        inserted: Option<String>,
291    },
292    /// Operator over a text-object.
293    OpTextObj {
294        op: Operator,
295        obj: TextObject,
296        inner: bool,
297        inserted: Option<String>,
298    },
299    /// `dd`, `cc`, `yy` with a count.
300    LineOp {
301        op: Operator,
302        count: usize,
303        inserted: Option<String>,
304    },
305    /// `x`, `X` with a count.
306    CharDel { forward: bool, count: usize },
307    /// `r<ch>` with a count.
308    ReplaceChar { ch: char, count: usize },
309    /// `~` with a count.
310    ToggleCase { count: usize },
311    /// `J` with a count.
312    JoinLine { count: usize },
313    /// `p` / `P` with a count.
314    Paste { before: bool, count: usize },
315    /// `D` (delete to EOL).
316    DeleteToEol { inserted: Option<String> },
317    /// `o` / `O` + the inserted text.
318    OpenLine { above: bool, inserted: String },
319    /// `i`/`I`/`a`/`A` + inserted text.
320    InsertAt {
321        entry: InsertEntry,
322        inserted: String,
323        count: usize,
324    },
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328enum InsertEntry {
329    I,
330    A,
331    ShiftI,
332    ShiftA,
333}
334
335// ─── VimState ──────────────────────────────────────────────────────────────
336
337#[derive(Default)]
338pub struct VimState {
339    mode: Mode,
340    pending: Pending,
341    count: usize,
342    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
343    last_find: Option<(char, bool, bool)>,
344    last_change: Option<LastChange>,
345    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
346    insert_session: Option<InsertSession>,
347    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
348    /// to compute the highlight range and the operator range without
349    /// relying on tui-textarea's live selection.
350    pub(super) visual_anchor: (usize, usize),
351    /// Row anchor for VisualLine mode.
352    pub(super) visual_line_anchor: usize,
353    /// (row, col) anchor for VisualBlock mode. The live cursor is the
354    /// opposite corner.
355    pub(super) block_anchor: (usize, usize),
356    /// Intended "virtual" column for the block's active corner. j/k
357    /// clamp cursor.col to shorter rows, which would collapse the
358    /// block across ragged content — so we remember the desired column
359    /// separately and use it for block bounds / insert-column
360    /// computations. Updated by h/l only.
361    pub(super) block_vcol: usize,
362    /// Vim's "sticky column" (curswant). `None` before the first
363    /// motion — the next vertical motion bootstraps from the current
364    /// cursor column. Horizontal motions refresh this to the new
365    /// cursor column; vertical motions *read* it to restore the
366    /// cursor on the destination row when that row is long enough,
367    /// so bouncing through a shorter or empty line doesn't drag the
368    /// cursor back to column 0.
369    pub(super) sticky_col: Option<usize>,
370    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
371    pub(super) yank_linewise: bool,
372    /// Active register selector — set by `"reg` prefix, consumed by
373    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
374    pub(super) pending_register: Option<char>,
375    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
376    /// While `Some`, every consumed `Input` is appended to
377    /// `recording_keys`.
378    pub(super) recording_macro: Option<char>,
379    /// Keys recorded into the in-progress macro. On `q` finish, these
380    /// are encoded via [`crate::input::encode_macro`] and written to
381    /// the matching named register slot, so macros and yanks share a
382    /// single store.
383    pub(super) recording_keys: Vec<crate::input::Input>,
384    /// Set during `@reg` replay so the recorder doesn't capture the
385    /// replayed keystrokes a second time.
386    pub(super) replaying_macro: bool,
387    /// Last register played via `@reg`. `@@` re-plays this one.
388    pub(super) last_macro: Option<char>,
389    /// Position of the most recent buffer mutation. Surfaced via
390    /// the `'.` / `` `. `` marks for quick "back to last edit".
391    pub(super) last_edit_pos: Option<(usize, usize)>,
392    /// Bounded ring of recent edit positions (newest at the back).
393    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
394    /// at [`CHANGE_LIST_MAX`].
395    pub(super) change_list: Vec<(usize, usize)>,
396    /// Index into `change_list` while walking. `None` outside a walk —
397    /// any new edit clears it (and trims forward entries past it).
398    pub(super) change_list_cursor: Option<usize>,
399    /// Snapshot of the last visual selection for `gv` re-entry.
400    /// Stored on every Visual / VisualLine / VisualBlock exit.
401    pub(super) last_visual: Option<LastVisual>,
402    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
403    /// pass doesn't override the user's explicit viewport pinning.
404    /// Cleared every step.
405    pub(super) viewport_pinned: bool,
406    /// Set while replaying `.` / last-change so we don't re-record it.
407    replaying: bool,
408    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
409    /// normal-mode command we return to Insert.
410    one_shot_normal: bool,
411    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
412    pub(super) search_prompt: Option<SearchPrompt>,
413    /// Most recent committed search pattern. Surfaced to host apps via
414    /// [`Editor::last_search`] so their status line can render a hint
415    /// and so `n` / `N` have something to repeat.
416    pub(super) last_search: Option<String>,
417    /// Direction of the last committed search. `n` repeats this; `N`
418    /// inverts it. Defaults to forward so a never-searched buffer's
419    /// `n` still walks downward.
420    pub(super) last_search_forward: bool,
421    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
422    /// with the pre-motion cursor when a "big jump" motion fires
423    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
424    /// `?`). Capped at 100 entries.
425    pub(super) jump_back: Vec<(usize, usize)>,
426    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
427    /// jump, matching vim's "branch off trims forward history" rule.
428    pub(super) jump_fwd: Vec<(usize, usize)>,
429    /// Buffer-local lowercase marks. `m{a-z}` stores the current
430    /// cursor `(row, col)` under the letter; `'{a-z}` and `` `{a-z} ``
431    /// read it back. Uppercase / global marks aren't supported
432    /// (single-buffer model).
433    pub(super) marks: std::collections::HashMap<char, (usize, usize)>,
434    /// Set by `Ctrl-R` in insert mode while waiting for the register
435    /// selector. The next typed char names the register; its contents
436    /// are inserted inline at the cursor and the flag clears.
437    pub(super) insert_pending_register: bool,
438    /// Bounded history of committed `/` / `?` search patterns. Newest
439    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
440    /// avoid unbounded growth on long sessions.
441    pub(super) search_history: Vec<String>,
442    /// Index into `search_history` while the user walks past patterns
443    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
444    /// — typing or backspacing in the prompt resets it so the next
445    /// `Ctrl-P` starts from the most recent entry again.
446    pub(super) search_history_cursor: Option<usize>,
447}
448
449const SEARCH_HISTORY_MAX: usize = 100;
450pub(crate) const CHANGE_LIST_MAX: usize = 100;
451
452/// Active `/` or `?` search prompt. Text mutations drive the textarea's
453/// live search pattern so matches highlight as the user types.
454#[derive(Debug, Clone)]
455pub struct SearchPrompt {
456    pub text: String,
457    pub cursor: usize,
458    pub forward: bool,
459}
460
461#[derive(Debug, Clone)]
462struct InsertSession {
463    count: usize,
464    /// Min/max row visited during this session. Widens on every key.
465    row_min: usize,
466    row_max: usize,
467    /// Snapshot of the full buffer at session entry. Used to diff the
468    /// affected row window at finish without being fooled by cursor
469    /// navigation through rows the user never edited.
470    before_lines: Vec<String>,
471    reason: InsertReason,
472}
473
474#[derive(Debug, Clone)]
475enum InsertReason {
476    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
477    Enter(InsertEntry),
478    /// Entry via `o`/`O` — records OpenLine on Esc.
479    Open { above: bool },
480    /// Entry via an operator's change side-effect. Retro-fills the
481    /// stored last-change's `inserted` field on Esc.
482    AfterChange,
483    /// Entry via `C` (delete to EOL + insert).
484    DeleteToEol,
485    /// Entry via an insert triggered during dot-replay — don't touch
486    /// last_change because the outer replay will restore it.
487    ReplayOnly,
488    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
489    /// every row in `top..=bot`. `col` is the start column for `I`, the
490    /// one-past-block-end column for `A`.
491    BlockEdge { top: usize, bot: usize, col: usize },
492    /// `R` — Replace mode. Each typed char overwrites the cell under
493    /// the cursor instead of inserting; at end-of-line the session
494    /// falls through to insert (same as vim).
495    Replace,
496}
497
498/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
499/// visual selection). `mode` carries which visual flavour to
500/// restore; `anchor` / `cursor` mean different things per flavour:
501///
502/// - `Visual`     — `anchor` is the char-wise visual anchor.
503/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
504///   `anchor.1` is unused.
505/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
506///   sticky vcol that survives j/k clamping.
507#[derive(Debug, Clone, Copy)]
508pub(super) struct LastVisual {
509    pub mode: Mode,
510    pub anchor: (usize, usize),
511    pub cursor: (usize, usize),
512    pub block_vcol: usize,
513}
514
515impl VimState {
516    pub fn public_mode(&self) -> VimMode {
517        match self.mode {
518            Mode::Normal => VimMode::Normal,
519            Mode::Insert => VimMode::Insert,
520            Mode::Visual => VimMode::Visual,
521            Mode::VisualLine => VimMode::VisualLine,
522            Mode::VisualBlock => VimMode::VisualBlock,
523        }
524    }
525
526    pub fn force_normal(&mut self) {
527        self.mode = Mode::Normal;
528        self.pending = Pending::None;
529        self.count = 0;
530        self.insert_session = None;
531    }
532
533    pub fn is_visual(&self) -> bool {
534        matches!(
535            self.mode,
536            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
537        )
538    }
539
540    pub fn is_visual_char(&self) -> bool {
541        self.mode == Mode::Visual
542    }
543
544    pub fn enter_visual(&mut self, anchor: (usize, usize)) {
545        self.visual_anchor = anchor;
546        self.mode = Mode::Visual;
547    }
548}
549
550// ─── Entry point ───────────────────────────────────────────────────────────
551
552/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
553/// live search highlight until the user commits a query. `last_search`
554/// is preserved so an empty `<CR>` can re-run the previous pattern.
555fn enter_search(ed: &mut Editor<'_>, forward: bool) {
556    ed.vim.search_prompt = Some(SearchPrompt {
557        text: String::new(),
558        cursor: 0,
559        forward,
560    });
561    ed.vim.search_history_cursor = None;
562    ed.buffer_mut().set_search_pattern(None);
563}
564
565/// Compile `pattern` into a regex and push it onto the migration
566/// buffer's search state. Invalid patterns clear the highlight (the
567/// user is mid-typing a regex like `[` and we don't want to flash an
568/// error).
569fn push_search_pattern(ed: &mut Editor<'_>, pattern: &str) {
570    let compiled = if pattern.is_empty() {
571        None
572    } else {
573        // `:set ignorecase` flips every search pattern to case-insensitive
574        // unless the user already prefixed an explicit `(?i)` / `(?-i)`
575        // (regex crate honours those even when we layer another `(?i)`).
576        let effective: std::borrow::Cow<'_, str> = if ed.settings().ignore_case {
577            std::borrow::Cow::Owned(format!("(?i){pattern}"))
578        } else {
579            std::borrow::Cow::Borrowed(pattern)
580        };
581        regex::Regex::new(&effective).ok()
582    };
583    ed.buffer_mut().set_search_pattern(compiled);
584}
585
586fn step_search_prompt(ed: &mut Editor<'_>, input: Input) -> bool {
587    // Ctrl-P / Ctrl-N (and Up / Down) walk the search history. Handled
588    // before the regular char/backspace branches so `Ctrl-P` doesn't
589    // type a literal `p`.
590    let history_dir = match (input.key, input.ctrl) {
591        (Key::Char('p'), true) | (Key::Up, _) => Some(-1),
592        (Key::Char('n'), true) | (Key::Down, _) => Some(1),
593        _ => None,
594    };
595    if let Some(dir) = history_dir {
596        walk_search_history(ed, dir);
597        return true;
598    }
599    match input.key {
600        Key::Esc => {
601            // Cancel. Drop the prompt but keep the highlighted matches
602            // so `n` / `N` can repeat whatever was typed.
603            let text = ed
604                .vim
605                .search_prompt
606                .take()
607                .map(|p| p.text)
608                .unwrap_or_default();
609            if !text.is_empty() {
610                ed.vim.last_search = Some(text);
611            }
612            ed.vim.search_history_cursor = None;
613        }
614        Key::Enter => {
615            let prompt = ed.vim.search_prompt.take();
616            if let Some(p) = prompt {
617                // Empty `/<CR>` (or `?<CR>`) re-runs the previous search
618                // pattern in the prompt's direction — vim parity.
619                let pattern = if p.text.is_empty() {
620                    ed.vim.last_search.clone()
621                } else {
622                    Some(p.text.clone())
623                };
624                if let Some(pattern) = pattern {
625                    push_search_pattern(ed, &pattern);
626                    let pre = ed.cursor();
627                    if p.forward {
628                        ed.buffer_mut().search_forward(true);
629                    } else {
630                        ed.buffer_mut().search_backward(true);
631                    }
632                    ed.push_buffer_cursor_to_textarea();
633                    if ed.cursor() != pre {
634                        push_jump(ed, pre);
635                    }
636                    record_search_history(ed, &pattern);
637                    ed.vim.last_search = Some(pattern);
638                    ed.vim.last_search_forward = p.forward;
639                }
640            }
641            ed.vim.search_history_cursor = None;
642        }
643        Key::Backspace => {
644            ed.vim.search_history_cursor = None;
645            let new_text = ed.vim.search_prompt.as_mut().and_then(|p| {
646                if p.text.pop().is_some() {
647                    p.cursor = p.text.chars().count();
648                    Some(p.text.clone())
649                } else {
650                    None
651                }
652            });
653            if let Some(text) = new_text {
654                push_search_pattern(ed, &text);
655            }
656        }
657        Key::Char(c) => {
658            ed.vim.search_history_cursor = None;
659            let new_text = ed.vim.search_prompt.as_mut().map(|p| {
660                p.text.push(c);
661                p.cursor = p.text.chars().count();
662                p.text.clone()
663            });
664            if let Some(text) = new_text {
665                push_search_pattern(ed, &text);
666            }
667        }
668        _ => {}
669    }
670    true
671}
672
673/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
674/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
675/// the ends of the ring; off-ring positions are silently ignored.
676fn walk_change_list(ed: &mut Editor<'_>, dir: isize, count: usize) {
677    if ed.vim.change_list.is_empty() {
678        return;
679    }
680    let len = ed.vim.change_list.len();
681    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
682        (None, -1) => len as isize - 1,
683        (None, 1) => return, // already past the newest entry
684        (Some(i), -1) => i as isize - 1,
685        (Some(i), 1) => i as isize + 1,
686        _ => return,
687    };
688    for _ in 1..count {
689        let next = idx + dir;
690        if next < 0 || next >= len as isize {
691            break;
692        }
693        idx = next;
694    }
695    if idx < 0 || idx >= len as isize {
696        return;
697    }
698    let idx = idx as usize;
699    ed.vim.change_list_cursor = Some(idx);
700    let (row, col) = ed.vim.change_list[idx];
701    ed.jump_cursor(row, col);
702}
703
704/// Push `pattern` onto the search history. Skips the push when the
705/// most recent entry already matches (consecutive dedupe) and trims
706/// the oldest entries beyond [`SEARCH_HISTORY_MAX`].
707fn record_search_history(ed: &mut Editor<'_>, pattern: &str) {
708    if pattern.is_empty() {
709        return;
710    }
711    if ed.vim.search_history.last().map(String::as_str) == Some(pattern) {
712        return;
713    }
714    ed.vim.search_history.push(pattern.to_string());
715    let len = ed.vim.search_history.len();
716    if len > SEARCH_HISTORY_MAX {
717        ed.vim.search_history.drain(0..len - SEARCH_HISTORY_MAX);
718    }
719}
720
721/// Replace the prompt text with the next entry in the search history.
722/// `dir = -1` walks toward older entries (`Ctrl-P` / `Up`); `dir = 1`
723/// toward newer ones (`Ctrl-N` / `Down`). Stops at the ends of the
724/// history; the user can keep pressing the key without effect rather
725/// than wrapping around.
726fn walk_search_history(ed: &mut Editor<'_>, dir: isize) {
727    if ed.vim.search_history.is_empty() || ed.vim.search_prompt.is_none() {
728        return;
729    }
730    let len = ed.vim.search_history.len();
731    let next_idx = match (ed.vim.search_history_cursor, dir) {
732        (None, -1) => Some(len - 1),
733        (None, 1) => return, // already past the newest entry
734        (Some(i), -1) => i.checked_sub(1),
735        (Some(i), 1) if i + 1 < len => Some(i + 1),
736        _ => None,
737    };
738    let Some(idx) = next_idx else {
739        return;
740    };
741    ed.vim.search_history_cursor = Some(idx);
742    let text = ed.vim.search_history[idx].clone();
743    if let Some(prompt) = ed.vim.search_prompt.as_mut() {
744        prompt.cursor = text.chars().count();
745        prompt.text = text.clone();
746    }
747    push_search_pattern(ed, &text);
748}
749
750pub fn step(ed: &mut Editor<'_>, input: Input) -> bool {
751    // Phase 7f port: any cursor / content the host changed between
752    // steps (mouse jumps, paste, programmatic set_content, …) needs
753    // to land in the migration buffer before motion handlers that
754    // call into `Buffer::move_*` see a stale state.
755    ed.sync_buffer_content_from_textarea();
756    // Macro stop: a bare `q` ends an active recording before any
757    // other handler sees the key (so `q` itself doesn't get
758    // recorded). Replays don't trigger this — they finish on their
759    // own when the captured key list runs out.
760    if ed.vim.recording_macro.is_some()
761        && !ed.vim.replaying_macro
762        && matches!(ed.vim.pending, Pending::None)
763        && ed.vim.mode != Mode::Insert
764        && input.key == Key::Char('q')
765        && !input.ctrl
766        && !input.alt
767    {
768        let reg = ed.vim.recording_macro.take().unwrap();
769        let keys = std::mem::take(&mut ed.vim.recording_keys);
770        let text = crate::input::encode_macro(&keys);
771        ed.set_named_register_text(reg.to_ascii_lowercase(), text);
772        return true;
773    }
774    // Search prompt eats all keys until Enter / Esc.
775    if ed.vim.search_prompt.is_some() {
776        return step_search_prompt(ed, input);
777    }
778    // Snapshot whether this step is consuming the register-name half
779    // of a macro chord. The recorder hook below uses this to skip
780    // the chord's bookkeeping keys (`q{reg}` open and `@{reg}` open).
781    let pending_was_macro_chord = matches!(
782        ed.vim.pending,
783        Pending::RecordMacroTarget | Pending::PlayMacroTarget { .. }
784    );
785    let was_insert = ed.vim.mode == Mode::Insert;
786    // Capture pre-step visual snapshot so a visual → normal transition
787    // can stash the selection for `gv` re-entry.
788    let pre_visual_snapshot = match ed.vim.mode {
789        Mode::Visual => Some(LastVisual {
790            mode: Mode::Visual,
791            anchor: ed.vim.visual_anchor,
792            cursor: ed.cursor(),
793            block_vcol: 0,
794        }),
795        Mode::VisualLine => Some(LastVisual {
796            mode: Mode::VisualLine,
797            anchor: (ed.vim.visual_line_anchor, 0),
798            cursor: ed.cursor(),
799            block_vcol: 0,
800        }),
801        Mode::VisualBlock => Some(LastVisual {
802            mode: Mode::VisualBlock,
803            anchor: ed.vim.block_anchor,
804            cursor: ed.cursor(),
805            block_vcol: ed.vim.block_vcol,
806        }),
807        _ => None,
808    };
809    let consumed = match ed.vim.mode {
810        Mode::Insert => step_insert(ed, input),
811        _ => step_normal(ed, input),
812    };
813    if let Some(snap) = pre_visual_snapshot
814        && !matches!(
815            ed.vim.mode,
816            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
817        )
818    {
819        ed.vim.last_visual = Some(snap);
820    }
821    // Ctrl-o in insert mode queues a single normal-mode command; once
822    // that command finishes (pending cleared, not in operator / visual),
823    // drop back to insert without replaying the insert session.
824    if !was_insert
825        && ed.vim.one_shot_normal
826        && ed.vim.mode == Mode::Normal
827        && matches!(ed.vim.pending, Pending::None)
828    {
829        ed.vim.one_shot_normal = false;
830        ed.vim.mode = Mode::Insert;
831    }
832    // Phase 7c: every step ends with the migration buffer mirroring
833    // the textarea's content + cursor + viewport. Edit-emitting paths
834    // (insert_char, delete_char, …) inside `step_insert` /
835    // `step_normal` thus all flow through here without each call
836    // site needing to remember to sync.
837    ed.sync_buffer_content_from_textarea();
838    // Scroll viewport to keep cursor on-screen, honouring the same
839    // `SCROLLOFF` margin the mouse-driven scroll uses. Skip when
840    // the user just pinned the viewport with `zz` / `zt` / `zb`.
841    if !ed.vim.viewport_pinned {
842        ed.ensure_cursor_in_scrolloff();
843    }
844    ed.vim.viewport_pinned = false;
845    // Recorder hook: append every consumed input to the active
846    // recording (if any) so the replay reproduces the same sequence.
847    // Skip the chord that started the recording (`q{reg}` open) and
848    // skip during replay so a macro doesn't capture itself.
849    if ed.vim.recording_macro.is_some()
850        && !ed.vim.replaying_macro
851        && input.key != Key::Char('q')
852        && !pending_was_macro_chord
853    {
854        ed.vim.recording_keys.push(input);
855    }
856    consumed
857}
858
859// ─── Insert mode ───────────────────────────────────────────────────────────
860
861fn step_insert(ed: &mut Editor<'_>, input: Input) -> bool {
862    // `Ctrl-R {reg}` paste — the previous keystroke armed the wait. Any
863    // non-char key cancels (matches vim, which beeps on selectors like
864    // Esc and re-emits the literal text otherwise).
865    if ed.vim.insert_pending_register {
866        ed.vim.insert_pending_register = false;
867        if let Key::Char(c) = input.key
868            && !input.ctrl
869        {
870            insert_register_text(ed, c);
871        }
872        return true;
873    }
874
875    if input.key == Key::Esc {
876        finish_insert_session(ed);
877        ed.vim.mode = Mode::Normal;
878        // Vim convention: pull the cursor back one cell on exit when
879        // possible. Sticky column then mirrors the *visible* post-Back
880        // column so the next vertical motion lands where the user
881        // actually sees the cursor — not one cell to the right.
882        let col = ed.cursor().1;
883        if col > 0 {
884            ed.buffer_mut().move_left(1);
885            ed.push_buffer_cursor_to_textarea();
886        }
887        ed.vim.sticky_col = Some(ed.cursor().1);
888        return true;
889    }
890
891    // Ctrl-prefixed insert-mode shortcuts.
892    if input.ctrl {
893        match input.key {
894            Key::Char('w') => {
895                use hjkl_buffer::{Edit, MotionKind};
896                ed.sync_buffer_content_from_textarea();
897                let cursor = ed.buffer().cursor();
898                if cursor.row == 0 && cursor.col == 0 {
899                    return true;
900                }
901                // Find the previous word start by stepping the buffer
902                // cursor (vim `b` semantics) and snapshot it.
903                ed.buffer_mut().move_word_back(false, 1);
904                let word_start = ed.buffer().cursor();
905                if word_start == cursor {
906                    return true;
907                }
908                ed.buffer_mut().set_cursor(cursor);
909                ed.mutate_edit(Edit::DeleteRange {
910                    start: word_start,
911                    end: cursor,
912                    kind: MotionKind::Char,
913                });
914                ed.push_buffer_cursor_to_textarea();
915                return true;
916            }
917            Key::Char('u') => {
918                use hjkl_buffer::{Edit, MotionKind, Position};
919                ed.sync_buffer_content_from_textarea();
920                let cursor = ed.buffer().cursor();
921                if cursor.col > 0 {
922                    ed.mutate_edit(Edit::DeleteRange {
923                        start: Position::new(cursor.row, 0),
924                        end: cursor,
925                        kind: MotionKind::Char,
926                    });
927                    ed.push_buffer_cursor_to_textarea();
928                }
929                return true;
930            }
931            Key::Char('h') => {
932                use hjkl_buffer::{Edit, MotionKind, Position};
933                ed.sync_buffer_content_from_textarea();
934                let cursor = ed.buffer().cursor();
935                if cursor.col > 0 {
936                    ed.mutate_edit(Edit::DeleteRange {
937                        start: Position::new(cursor.row, cursor.col - 1),
938                        end: cursor,
939                        kind: MotionKind::Char,
940                    });
941                } else if cursor.row > 0 {
942                    let prev_row = cursor.row - 1;
943                    let prev_chars = ed
944                        .buffer()
945                        .line(prev_row)
946                        .map(|l| l.chars().count())
947                        .unwrap_or(0);
948                    ed.mutate_edit(Edit::JoinLines {
949                        row: prev_row,
950                        count: 1,
951                        with_space: false,
952                    });
953                    ed.buffer_mut()
954                        .set_cursor(Position::new(prev_row, prev_chars));
955                }
956                ed.push_buffer_cursor_to_textarea();
957                return true;
958            }
959            Key::Char('o') => {
960                // One-shot normal: leave insert mode for the next full
961                // normal-mode command, then come back.
962                ed.vim.one_shot_normal = true;
963                ed.vim.mode = Mode::Normal;
964                return true;
965            }
966            Key::Char('r') => {
967                // Arm the register selector — the next typed char picks
968                // a slot and pastes its text inline.
969                ed.vim.insert_pending_register = true;
970                return true;
971            }
972            Key::Char('t') => {
973                // Insert-mode indent: prepend one shiftwidth to the
974                // current line's leading whitespace. Cursor shifts
975                // right by the same amount so the user keeps typing
976                // at their logical position.
977                let (row, col) = ed.cursor();
978                let sw = ed.settings().shiftwidth;
979                indent_rows(ed, row, row, 1);
980                ed.jump_cursor(row, col + sw);
981                return true;
982            }
983            Key::Char('d') => {
984                // Insert-mode outdent: drop up to one shiftwidth of
985                // leading whitespace. Cursor shifts left by the amount
986                // actually stripped.
987                let (row, col) = ed.cursor();
988                let before_len = ed.buffer().lines()[row].len();
989                outdent_rows(ed, row, row, 1);
990                let after_len = ed.buffer().lines()[row].len();
991                let stripped = before_len.saturating_sub(after_len);
992                let new_col = col.saturating_sub(stripped);
993                ed.jump_cursor(row, new_col);
994                return true;
995            }
996            _ => {}
997        }
998    }
999
1000    // Widen the session's visited row window *before* handling the key
1001    // so navigation-only keystrokes (arrow keys) still extend the range.
1002    let (row, _) = ed.cursor();
1003    if let Some(ref mut session) = ed.vim.insert_session {
1004        session.row_min = session.row_min.min(row);
1005        session.row_max = session.row_max.max(row);
1006    }
1007    let mutated = handle_insert_key(ed, input);
1008    if mutated {
1009        ed.mark_content_dirty();
1010        let (row, _) = ed.cursor();
1011        if let Some(ref mut session) = ed.vim.insert_session {
1012            session.row_min = session.row_min.min(row);
1013            session.row_max = session.row_max.max(row);
1014        }
1015    }
1016    true
1017}
1018
1019/// `Ctrl-R {reg}` body — insert the named register's contents at the
1020/// cursor as charwise text. Embedded newlines split lines naturally via
1021/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
1022/// stray keystrokes don't mutate the buffer.
1023fn insert_register_text(ed: &mut Editor<'_>, selector: char) {
1024    use hjkl_buffer::{Edit, Position};
1025    let text = match ed.registers().read(selector) {
1026        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
1027        _ => return,
1028    };
1029    ed.sync_buffer_content_from_textarea();
1030    let cursor = ed.buffer().cursor();
1031    ed.mutate_edit(Edit::InsertStr {
1032        at: cursor,
1033        text: text.clone(),
1034    });
1035    // Advance cursor to the end of the inserted payload — multi-line
1036    // pastes land on the last inserted row at the post-text column.
1037    let mut row = cursor.row;
1038    let mut col = cursor.col;
1039    for ch in text.chars() {
1040        if ch == '\n' {
1041            row += 1;
1042            col = 0;
1043        } else {
1044            col += 1;
1045        }
1046    }
1047    ed.buffer_mut().set_cursor(Position::new(row, col));
1048    ed.push_buffer_cursor_to_textarea();
1049    ed.mark_content_dirty();
1050    if let Some(ref mut session) = ed.vim.insert_session {
1051        session.row_min = session.row_min.min(row);
1052        session.row_max = session.row_max.max(row);
1053    }
1054}
1055
1056/// Insert-mode key dispatcher backed by the migration buffer. Replaces
1057/// the historical `textarea.input(input)` call so the textarea field
1058/// can be ripped at the end of Phase 7f. PageUp / PageDown still flow
1059/// through the textarea (they're scroll-only with no buffer side
1060/// effect); every other navigation + edit key lands on `Buffer`.
1061/// Returns true when the buffer mutated.
1062fn handle_insert_key(ed: &mut Editor<'_>, input: Input) -> bool {
1063    use hjkl_buffer::{Edit, MotionKind, Position};
1064    ed.sync_buffer_content_from_textarea();
1065    let cursor = ed.buffer().cursor();
1066    let line_chars = ed
1067        .buffer()
1068        .line(cursor.row)
1069        .map(|l| l.chars().count())
1070        .unwrap_or(0);
1071    // Replace mode: overstrike the cell at the cursor instead of
1072    // inserting. At end-of-line, fall through to plain insert (vim
1073    // appends past the line).
1074    let in_replace = matches!(
1075        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1076        Some(InsertReason::Replace)
1077    );
1078    let mutated = match input.key {
1079        Key::Char(c) if in_replace && cursor.col < line_chars => {
1080            ed.mutate_edit(Edit::DeleteRange {
1081                start: cursor,
1082                end: Position::new(cursor.row, cursor.col + 1),
1083                kind: MotionKind::Char,
1084            });
1085            ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1086            true
1087        }
1088        Key::Char(c) => {
1089            ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1090            true
1091        }
1092        Key::Enter => {
1093            ed.mutate_edit(Edit::InsertStr {
1094                at: cursor,
1095                text: "\n".into(),
1096            });
1097            true
1098        }
1099        Key::Tab => {
1100            ed.mutate_edit(Edit::InsertChar {
1101                at: cursor,
1102                ch: '\t',
1103            });
1104            true
1105        }
1106        Key::Backspace => {
1107            if cursor.col > 0 {
1108                ed.mutate_edit(Edit::DeleteRange {
1109                    start: Position::new(cursor.row, cursor.col - 1),
1110                    end: cursor,
1111                    kind: MotionKind::Char,
1112                });
1113                true
1114            } else if cursor.row > 0 {
1115                let prev_row = cursor.row - 1;
1116                let prev_chars = ed
1117                    .buffer()
1118                    .line(prev_row)
1119                    .map(|l| l.chars().count())
1120                    .unwrap_or(0);
1121                ed.mutate_edit(Edit::JoinLines {
1122                    row: prev_row,
1123                    count: 1,
1124                    with_space: false,
1125                });
1126                ed.buffer_mut()
1127                    .set_cursor(Position::new(prev_row, prev_chars));
1128                true
1129            } else {
1130                false
1131            }
1132        }
1133        Key::Delete => {
1134            if cursor.col < line_chars {
1135                ed.mutate_edit(Edit::DeleteRange {
1136                    start: cursor,
1137                    end: Position::new(cursor.row, cursor.col + 1),
1138                    kind: MotionKind::Char,
1139                });
1140                true
1141            } else if cursor.row + 1 < ed.buffer().row_count() {
1142                ed.mutate_edit(Edit::JoinLines {
1143                    row: cursor.row,
1144                    count: 1,
1145                    with_space: false,
1146                });
1147                ed.buffer_mut().set_cursor(cursor);
1148                true
1149            } else {
1150                false
1151            }
1152        }
1153        Key::Left => {
1154            ed.buffer_mut().move_left(1);
1155            false
1156        }
1157        Key::Right => {
1158            // Insert mode allows the cursor one past the last char so the
1159            // next typed letter appends — use the operator-context move.
1160            ed.buffer_mut().move_right_to_end(1);
1161            false
1162        }
1163        Key::Up => {
1164            ed.buffer_mut().move_up(1);
1165            false
1166        }
1167        Key::Down => {
1168            ed.buffer_mut().move_down(1);
1169            false
1170        }
1171        Key::Home => {
1172            ed.buffer_mut().move_line_start();
1173            false
1174        }
1175        Key::End => {
1176            ed.buffer_mut().move_line_end();
1177            false
1178        }
1179        Key::PageUp => {
1180            // Vim default: PageUp scrolls a full window up, cursor
1181            // tracks. Reuse the Ctrl-b scroll helper so behavior
1182            // matches the normal-mode equivalent.
1183            let rows = viewport_full_rows(ed, 1) as isize;
1184            scroll_cursor_rows(ed, -rows);
1185            return false;
1186        }
1187        Key::PageDown => {
1188            let rows = viewport_full_rows(ed, 1) as isize;
1189            scroll_cursor_rows(ed, rows);
1190            return false;
1191        }
1192        // F-keys, mouse scroll, copy/cut/paste virtual keys, Null —
1193        // no insert-mode behaviour.
1194        _ => false,
1195    };
1196    ed.push_buffer_cursor_to_textarea();
1197    mutated
1198}
1199
1200fn finish_insert_session(ed: &mut Editor<'_>) {
1201    let Some(session) = ed.vim.insert_session.take() else {
1202        return;
1203    };
1204    let lines = ed.buffer().lines();
1205    // Clamp both slices to their respective bounds — the buffer may have
1206    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1207    // the session, so row_max can overshoot either side.
1208    let after_end = session.row_max.min(lines.len().saturating_sub(1));
1209    let before_end = session
1210        .row_max
1211        .min(session.before_lines.len().saturating_sub(1));
1212    let before = if before_end >= session.row_min && session.row_min < session.before_lines.len() {
1213        session.before_lines[session.row_min..=before_end].join("\n")
1214    } else {
1215        String::new()
1216    };
1217    let after = if after_end >= session.row_min && session.row_min < lines.len() {
1218        lines[session.row_min..=after_end].join("\n")
1219    } else {
1220        String::new()
1221    };
1222    let inserted = extract_inserted(&before, &after);
1223    if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
1224        use hjkl_buffer::{Edit, Position};
1225        for _ in 0..session.count - 1 {
1226            let (row, col) = ed.cursor();
1227            ed.mutate_edit(Edit::InsertStr {
1228                at: Position::new(row, col),
1229                text: inserted.clone(),
1230            });
1231        }
1232    }
1233    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1234        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1235            use hjkl_buffer::{Edit, Position};
1236            for r in (top + 1)..=bot {
1237                let line_len = ed.buffer().line(r).map(|l| l.chars().count()).unwrap_or(0);
1238                if col > line_len {
1239                    // Pad short rows with spaces up to the block edge
1240                    // column so the inserted text lands at `col`.
1241                    let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1242                    ed.mutate_edit(Edit::InsertStr {
1243                        at: Position::new(r, line_len),
1244                        text: pad,
1245                    });
1246                }
1247                ed.mutate_edit(Edit::InsertStr {
1248                    at: Position::new(r, col),
1249                    text: inserted.clone(),
1250                });
1251            }
1252            ed.buffer_mut().set_cursor(Position::new(top, col));
1253            ed.push_buffer_cursor_to_textarea();
1254        }
1255        return;
1256    }
1257    if ed.vim.replaying {
1258        return;
1259    }
1260    match session.reason {
1261        InsertReason::Enter(entry) => {
1262            ed.vim.last_change = Some(LastChange::InsertAt {
1263                entry,
1264                inserted,
1265                count: session.count,
1266            });
1267        }
1268        InsertReason::Open { above } => {
1269            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1270        }
1271        InsertReason::AfterChange => {
1272            if let Some(
1273                LastChange::OpMotion { inserted: ins, .. }
1274                | LastChange::OpTextObj { inserted: ins, .. }
1275                | LastChange::LineOp { inserted: ins, .. },
1276            ) = ed.vim.last_change.as_mut()
1277            {
1278                *ins = Some(inserted);
1279            }
1280        }
1281        InsertReason::DeleteToEol => {
1282            ed.vim.last_change = Some(LastChange::DeleteToEol {
1283                inserted: Some(inserted),
1284            });
1285        }
1286        InsertReason::ReplayOnly => {}
1287        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1288        InsertReason::Replace => {
1289            // Record overstrike sessions as DeleteToEol-style — replay
1290            // re-types each character but doesn't try to restore prior
1291            // content (vim's R has its own replay path; this is the
1292            // pragmatic approximation).
1293            ed.vim.last_change = Some(LastChange::DeleteToEol {
1294                inserted: Some(inserted),
1295            });
1296        }
1297    }
1298}
1299
1300fn begin_insert(ed: &mut Editor<'_>, count: usize, reason: InsertReason) {
1301    let record = !matches!(reason, InsertReason::ReplayOnly);
1302    if record {
1303        ed.push_undo();
1304    }
1305    let reason = if ed.vim.replaying {
1306        InsertReason::ReplayOnly
1307    } else {
1308        reason
1309    };
1310    let (row, _) = ed.cursor();
1311    ed.vim.insert_session = Some(InsertSession {
1312        count,
1313        row_min: row,
1314        row_max: row,
1315        before_lines: ed.buffer().lines().to_vec(),
1316        reason,
1317    });
1318    ed.vim.mode = Mode::Insert;
1319}
1320
1321// ─── Normal / Visual / Operator-pending dispatcher ─────────────────────────
1322
1323fn step_normal(ed: &mut Editor<'_>, input: Input) -> bool {
1324    // Consume digits first — except '0' at start of count (that's LineStart).
1325    if let Key::Char(d @ '0'..='9') = input.key
1326        && !input.ctrl
1327        && !input.alt
1328        && !matches!(
1329            ed.vim.pending,
1330            Pending::Replace
1331                | Pending::Find { .. }
1332                | Pending::OpFind { .. }
1333                | Pending::VisualTextObj { .. }
1334        )
1335        && (d != '0' || ed.vim.count > 0)
1336    {
1337        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
1338        return true;
1339    }
1340
1341    // Handle pending two-key sequences first.
1342    match std::mem::take(&mut ed.vim.pending) {
1343        Pending::Replace => return handle_replace(ed, input),
1344        Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
1345        Pending::OpFind {
1346            op,
1347            count1,
1348            forward,
1349            till,
1350        } => return handle_op_find_target(ed, input, op, count1, forward, till),
1351        Pending::G => return handle_after_g(ed, input),
1352        Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
1353        Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
1354        Pending::OpTextObj { op, count1, inner } => {
1355            return handle_text_object(ed, input, op, count1, inner);
1356        }
1357        Pending::VisualTextObj { inner } => {
1358            return handle_visual_text_obj(ed, input, inner);
1359        }
1360        Pending::Z => return handle_after_z(ed, input),
1361        Pending::SetMark => return handle_set_mark(ed, input),
1362        Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
1363        Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
1364        Pending::SelectRegister => return handle_select_register(ed, input),
1365        Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
1366        Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
1367        Pending::None => {}
1368    }
1369
1370    let count = take_count(&mut ed.vim);
1371
1372    // Common normal / visual keys.
1373    match input.key {
1374        Key::Esc => {
1375            ed.vim.force_normal();
1376            return true;
1377        }
1378        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1379            ed.vim.visual_anchor = ed.cursor();
1380            ed.vim.mode = Mode::Visual;
1381            return true;
1382        }
1383        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1384            let (row, _) = ed.cursor();
1385            ed.vim.visual_line_anchor = row;
1386            ed.vim.mode = Mode::VisualLine;
1387            return true;
1388        }
1389        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::VisualLine => {
1390            ed.vim.visual_anchor = ed.cursor();
1391            ed.vim.mode = Mode::Visual;
1392            return true;
1393        }
1394        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Visual => {
1395            let (row, _) = ed.cursor();
1396            ed.vim.visual_line_anchor = row;
1397            ed.vim.mode = Mode::VisualLine;
1398            return true;
1399        }
1400        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::Normal => {
1401            let cur = ed.cursor();
1402            ed.vim.block_anchor = cur;
1403            ed.vim.block_vcol = cur.1;
1404            ed.vim.mode = Mode::VisualBlock;
1405            return true;
1406        }
1407        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::VisualBlock => {
1408            // Second Ctrl-v exits block mode back to Normal.
1409            ed.vim.mode = Mode::Normal;
1410            return true;
1411        }
1412        // `o` in visual modes — swap anchor and cursor so the user
1413        // can extend the other end of the selection.
1414        Key::Char('o') if !input.ctrl => match ed.vim.mode {
1415            Mode::Visual => {
1416                let cur = ed.cursor();
1417                let anchor = ed.vim.visual_anchor;
1418                ed.vim.visual_anchor = cur;
1419                ed.jump_cursor(anchor.0, anchor.1);
1420                return true;
1421            }
1422            Mode::VisualLine => {
1423                let cur_row = ed.cursor().0;
1424                let anchor_row = ed.vim.visual_line_anchor;
1425                ed.vim.visual_line_anchor = cur_row;
1426                ed.jump_cursor(anchor_row, 0);
1427                return true;
1428            }
1429            Mode::VisualBlock => {
1430                let cur = ed.cursor();
1431                let anchor = ed.vim.block_anchor;
1432                ed.vim.block_anchor = cur;
1433                ed.vim.block_vcol = anchor.1;
1434                ed.jump_cursor(anchor.0, anchor.1);
1435                return true;
1436            }
1437            _ => {}
1438        },
1439        _ => {}
1440    }
1441
1442    // Visual mode: operators act on the current selection.
1443    if ed.vim.is_visual()
1444        && let Some(op) = visual_operator(&input)
1445    {
1446        apply_visual_operator(ed, op);
1447        return true;
1448    }
1449
1450    // VisualBlock: extra commands beyond the standard y/d/c/x — `r`
1451    // replaces the block with a single char, `I` / `A` enter insert
1452    // mode at the block's left / right edge and repeat on every row.
1453    if ed.vim.mode == Mode::VisualBlock && !input.ctrl {
1454        match input.key {
1455            Key::Char('r') => {
1456                ed.vim.pending = Pending::Replace;
1457                return true;
1458            }
1459            Key::Char('I') => {
1460                let (top, bot, left, _right) = block_bounds(ed);
1461                ed.jump_cursor(top, left);
1462                ed.vim.mode = Mode::Normal;
1463                begin_insert(
1464                    ed,
1465                    1,
1466                    InsertReason::BlockEdge {
1467                        top,
1468                        bot,
1469                        col: left,
1470                    },
1471                );
1472                return true;
1473            }
1474            Key::Char('A') => {
1475                let (top, bot, _left, right) = block_bounds(ed);
1476                let line_len = ed.buffer().lines()[top].chars().count();
1477                let col = (right + 1).min(line_len);
1478                ed.jump_cursor(top, col);
1479                ed.vim.mode = Mode::Normal;
1480                begin_insert(ed, 1, InsertReason::BlockEdge { top, bot, col });
1481                return true;
1482            }
1483            _ => {}
1484        }
1485    }
1486
1487    // Visual mode: `i` / `a` start a text-object extension.
1488    if matches!(ed.vim.mode, Mode::Visual | Mode::VisualLine)
1489        && !input.ctrl
1490        && matches!(input.key, Key::Char('i') | Key::Char('a'))
1491    {
1492        let inner = matches!(input.key, Key::Char('i'));
1493        ed.vim.pending = Pending::VisualTextObj { inner };
1494        return true;
1495    }
1496
1497    // Ctrl-prefixed scrolling + misc. Vim semantics: Ctrl-d / Ctrl-u
1498    // move the cursor by half a window, Ctrl-f / Ctrl-b by a full
1499    // window. Viewport follows the cursor. Cursor lands on the first
1500    // non-blank of the target row (matches vim).
1501    if input.ctrl
1502        && let Key::Char(c) = input.key
1503    {
1504        match c {
1505            'd' => {
1506                scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
1507                return true;
1508            }
1509            'u' => {
1510                scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
1511                return true;
1512            }
1513            'f' => {
1514                scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
1515                return true;
1516            }
1517            'b' => {
1518                scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
1519                return true;
1520            }
1521            'r' => {
1522                do_redo(ed);
1523                return true;
1524            }
1525            'a' if ed.vim.mode == Mode::Normal => {
1526                adjust_number(ed, count.max(1) as i64);
1527                return true;
1528            }
1529            'x' if ed.vim.mode == Mode::Normal => {
1530                adjust_number(ed, -(count.max(1) as i64));
1531                return true;
1532            }
1533            'o' if ed.vim.mode == Mode::Normal => {
1534                for _ in 0..count.max(1) {
1535                    jump_back(ed);
1536                }
1537                return true;
1538            }
1539            'i' if ed.vim.mode == Mode::Normal => {
1540                for _ in 0..count.max(1) {
1541                    jump_forward(ed);
1542                }
1543                return true;
1544            }
1545            _ => {}
1546        }
1547    }
1548
1549    // `Tab` in normal mode is also `Ctrl-i` — vim aliases them.
1550    if !input.ctrl && input.key == Key::Tab && ed.vim.mode == Mode::Normal {
1551        for _ in 0..count.max(1) {
1552            jump_forward(ed);
1553        }
1554        return true;
1555    }
1556
1557    // Motion-only commands.
1558    if let Some(motion) = parse_motion(&input) {
1559        execute_motion(ed, motion.clone(), count);
1560        // Block mode: maintain the virtual column across j/k clamps.
1561        if ed.vim.mode == Mode::VisualBlock {
1562            update_block_vcol(ed, &motion);
1563        }
1564        if let Motion::Find { ch, forward, till } = motion {
1565            ed.vim.last_find = Some((ch, forward, till));
1566        }
1567        return true;
1568    }
1569
1570    // Mode transitions + pure normal-mode commands (not applicable in visual).
1571    if ed.vim.mode == Mode::Normal && handle_normal_only(ed, &input, count) {
1572        return true;
1573    }
1574
1575    // Operator triggers in normal mode.
1576    if ed.vim.mode == Mode::Normal
1577        && let Key::Char(op_ch) = input.key
1578        && !input.ctrl
1579        && let Some(op) = char_to_operator(op_ch)
1580    {
1581        ed.vim.pending = Pending::Op { op, count1: count };
1582        return true;
1583    }
1584
1585    // `f`/`F`/`t`/`T` entry.
1586    if ed.vim.mode == Mode::Normal
1587        && let Some((forward, till)) = find_entry(&input)
1588    {
1589        ed.vim.count = count;
1590        ed.vim.pending = Pending::Find { forward, till };
1591        return true;
1592    }
1593
1594    // `g` prefix.
1595    if !input.ctrl && input.key == Key::Char('g') && ed.vim.mode == Mode::Normal {
1596        ed.vim.count = count;
1597        ed.vim.pending = Pending::G;
1598        return true;
1599    }
1600
1601    // `z` prefix (zz / zt / zb — cursor-relative viewport scrolls).
1602    if !input.ctrl
1603        && input.key == Key::Char('z')
1604        && matches!(
1605            ed.vim.mode,
1606            Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock
1607        )
1608    {
1609        ed.vim.pending = Pending::Z;
1610        return true;
1611    }
1612
1613    // Mark set / jump entries. `m` arms the set-mark pending state;
1614    // `'` and `` ` `` arm the goto states (linewise vs charwise). The
1615    // mark letter is consumed on the next keystroke.
1616    if !input.ctrl && ed.vim.mode == Mode::Normal {
1617        match input.key {
1618            Key::Char('m') => {
1619                ed.vim.pending = Pending::SetMark;
1620                return true;
1621            }
1622            Key::Char('\'') => {
1623                ed.vim.pending = Pending::GotoMarkLine;
1624                return true;
1625            }
1626            Key::Char('`') => {
1627                ed.vim.pending = Pending::GotoMarkChar;
1628                return true;
1629            }
1630            Key::Char('"') => {
1631                // Open the register-selector chord. The next char picks
1632                // a register that the next y/d/c/p uses.
1633                ed.vim.pending = Pending::SelectRegister;
1634                return true;
1635            }
1636            Key::Char('@') => {
1637                // Open the macro-play chord. Next char names the
1638                // register; `@@` re-plays the last-played macro.
1639                // Stash any count so the chord can multiply replays.
1640                ed.vim.pending = Pending::PlayMacroTarget { count };
1641                return true;
1642            }
1643            Key::Char('q') if ed.vim.recording_macro.is_none() => {
1644                // Open the macro-record chord. The bare-q stop is
1645                // handled at the top of `step` so it's not consumed
1646                // as another open. Recording-in-progress falls through
1647                // here and is treated as a no-op (matches vim).
1648                ed.vim.pending = Pending::RecordMacroTarget;
1649                return true;
1650            }
1651            _ => {}
1652        }
1653    }
1654
1655    // Unknown key — swallow so it doesn't bubble into the TUI layer.
1656    true
1657}
1658
1659fn handle_set_mark(ed: &mut Editor<'_>, input: Input) -> bool {
1660    if let Key::Char(c) = input.key {
1661        let pos = ed.cursor();
1662        if c.is_ascii_lowercase() {
1663            ed.vim.marks.insert(c, pos);
1664        } else if c.is_ascii_uppercase() {
1665            // Uppercase marks survive set_content so they persist
1666            // across tab swaps within the same Editor.
1667            ed.file_marks.insert(c, pos);
1668        }
1669    }
1670    true
1671}
1672
1673/// `"reg` — store the register selector for the next y / d / c / p.
1674/// Accepts `a`–`z`, `A`–`Z`, `0`–`9`, `"`, and the system-clipboard
1675/// selectors `+` / `*`. Anything else cancels silently.
1676fn handle_select_register(ed: &mut Editor<'_>, input: Input) -> bool {
1677    if let Key::Char(c) = input.key
1678        && (c.is_ascii_alphanumeric() || matches!(c, '"' | '+' | '*'))
1679    {
1680        ed.vim.pending_register = Some(c);
1681    }
1682    true
1683}
1684
1685/// `q{reg}` — start recording into `reg`. The recording session
1686/// captures every consumed `Input` until a bare `q` ends it (handled
1687/// inline at the top of `step`). Capital letters append to the
1688/// matching lowercase register, mirroring named-register semantics.
1689fn handle_record_macro_target(ed: &mut Editor<'_>, input: Input) -> bool {
1690    if let Key::Char(c) = input.key
1691        && (c.is_ascii_alphabetic() || c.is_ascii_digit())
1692    {
1693        ed.vim.recording_macro = Some(c);
1694        // For `qA` (capital), seed the buffer with the existing
1695        // lowercase recording so the new keystrokes append.
1696        if c.is_ascii_uppercase() {
1697            let lower = c.to_ascii_lowercase();
1698            // Seed `recording_keys` with the existing register's text
1699            // decoded back to inputs, so capital-register append
1700            // continues from where the previous recording left off.
1701            let text = ed
1702                .registers()
1703                .read(lower)
1704                .map(|s| s.text.clone())
1705                .unwrap_or_default();
1706            ed.vim.recording_keys = crate::input::decode_macro(&text);
1707        } else {
1708            ed.vim.recording_keys.clear();
1709        }
1710    }
1711    true
1712}
1713
1714/// `@{reg}` — replay the macro recorded under `reg`. `@@` re-plays
1715/// the last-played macro. The replay re-feeds each captured `Input`
1716/// through `step`, with `replaying_macro` flagged so the recorder
1717/// (if active) doesn't double-capture. Honours the count prefix:
1718/// `3@a` plays the macro three times.
1719fn handle_play_macro_target(ed: &mut Editor<'_>, input: Input, count: usize) -> bool {
1720    let reg = match input.key {
1721        Key::Char('@') => ed.vim.last_macro,
1722        Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
1723            Some(c.to_ascii_lowercase())
1724        }
1725        _ => None,
1726    };
1727    let Some(reg) = reg else {
1728        return true;
1729    };
1730    // Read the macro text from the named register and decode back to
1731    // an Input stream. Empty / unset registers replay nothing.
1732    let text = match ed.registers().read(reg) {
1733        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
1734        _ => return true,
1735    };
1736    let keys = crate::input::decode_macro(&text);
1737    ed.vim.last_macro = Some(reg);
1738    let times = count.max(1);
1739    let was_replaying = ed.vim.replaying_macro;
1740    ed.vim.replaying_macro = true;
1741    for _ in 0..times {
1742        for k in keys.iter().copied() {
1743            step(ed, k);
1744        }
1745    }
1746    ed.vim.replaying_macro = was_replaying;
1747    true
1748}
1749
1750fn handle_goto_mark(ed: &mut Editor<'_>, input: Input, linewise: bool) -> bool {
1751    let Key::Char(c) = input.key else {
1752        return true;
1753    };
1754    // Resolve the mark target. Lowercase letters look up the user
1755    // marks set via `m{a..z}`; the special chars below come from
1756    // automatic state vim maintains:
1757    //   `'` / `` ` `` — position before the most recent big jump
1758    //                  (peeks `jump_back` without popping).
1759    //   `.`           — the last edit's position.
1760    let target = match c {
1761        'a'..='z' => ed.vim.marks.get(&c).copied(),
1762        'A'..='Z' => ed.file_marks.get(&c).copied(),
1763        '\'' | '`' => ed.vim.jump_back.last().copied(),
1764        '.' => ed.vim.last_edit_pos,
1765        _ => None,
1766    };
1767    let Some((row, col)) = target else {
1768        return true;
1769    };
1770    let pre = ed.cursor();
1771    let (r, c_clamped) = clamp_pos(ed, (row, col));
1772    if linewise {
1773        ed.buffer_mut().set_cursor(hjkl_buffer::Position::new(r, 0));
1774        ed.push_buffer_cursor_to_textarea();
1775        move_first_non_whitespace(ed);
1776    } else {
1777        ed.buffer_mut()
1778            .set_cursor(hjkl_buffer::Position::new(r, c_clamped));
1779        ed.push_buffer_cursor_to_textarea();
1780    }
1781    if ed.cursor() != pre {
1782        push_jump(ed, pre);
1783    }
1784    ed.vim.sticky_col = Some(ed.cursor().1);
1785    true
1786}
1787
1788fn take_count(vim: &mut VimState) -> usize {
1789    if vim.count > 0 {
1790        let n = vim.count;
1791        vim.count = 0;
1792        n
1793    } else {
1794        1
1795    }
1796}
1797
1798fn char_to_operator(c: char) -> Option<Operator> {
1799    match c {
1800        'd' => Some(Operator::Delete),
1801        'c' => Some(Operator::Change),
1802        'y' => Some(Operator::Yank),
1803        '>' => Some(Operator::Indent),
1804        '<' => Some(Operator::Outdent),
1805        _ => None,
1806    }
1807}
1808
1809fn visual_operator(input: &Input) -> Option<Operator> {
1810    if input.ctrl {
1811        return None;
1812    }
1813    match input.key {
1814        Key::Char('y') => Some(Operator::Yank),
1815        Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
1816        Key::Char('c') | Key::Char('s') => Some(Operator::Change),
1817        // Case operators — shift forms apply to the active selection.
1818        Key::Char('U') => Some(Operator::Uppercase),
1819        Key::Char('u') => Some(Operator::Lowercase),
1820        Key::Char('~') => Some(Operator::ToggleCase),
1821        // Indent operators on selection.
1822        Key::Char('>') => Some(Operator::Indent),
1823        Key::Char('<') => Some(Operator::Outdent),
1824        _ => None,
1825    }
1826}
1827
1828fn find_entry(input: &Input) -> Option<(bool, bool)> {
1829    if input.ctrl {
1830        return None;
1831    }
1832    match input.key {
1833        Key::Char('f') => Some((true, false)),
1834        Key::Char('F') => Some((false, false)),
1835        Key::Char('t') => Some((true, true)),
1836        Key::Char('T') => Some((false, true)),
1837        _ => None,
1838    }
1839}
1840
1841// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
1842
1843/// Max jumplist depth. Matches vim default.
1844const JUMPLIST_MAX: usize = 100;
1845
1846/// Record a pre-jump cursor position. Called *before* a big-jump
1847/// motion runs (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, `/`?
1848/// commit, `:{nr}`). Making a new jump while the forward stack had
1849/// entries trims them — branching off the history clears the "redo".
1850fn push_jump(ed: &mut Editor<'_>, from: (usize, usize)) {
1851    ed.vim.jump_back.push(from);
1852    if ed.vim.jump_back.len() > JUMPLIST_MAX {
1853        ed.vim.jump_back.remove(0);
1854    }
1855    ed.vim.jump_fwd.clear();
1856}
1857
1858/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
1859/// the current cursor onto the forward stack so `Ctrl-i` can return.
1860fn jump_back(ed: &mut Editor<'_>) {
1861    let Some(target) = ed.vim.jump_back.pop() else {
1862        return;
1863    };
1864    let cur = ed.cursor();
1865    ed.vim.jump_fwd.push(cur);
1866    let (r, c) = clamp_pos(ed, target);
1867    ed.jump_cursor(r, c);
1868    ed.vim.sticky_col = Some(c);
1869}
1870
1871/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
1872/// onto the back stack.
1873fn jump_forward(ed: &mut Editor<'_>) {
1874    let Some(target) = ed.vim.jump_fwd.pop() else {
1875        return;
1876    };
1877    let cur = ed.cursor();
1878    ed.vim.jump_back.push(cur);
1879    if ed.vim.jump_back.len() > JUMPLIST_MAX {
1880        ed.vim.jump_back.remove(0);
1881    }
1882    let (r, c) = clamp_pos(ed, target);
1883    ed.jump_cursor(r, c);
1884    ed.vim.sticky_col = Some(c);
1885}
1886
1887/// Clamp a stored `(row, col)` to the live buffer in case edits
1888/// shrunk the document between push and pop.
1889fn clamp_pos(ed: &Editor<'_>, pos: (usize, usize)) -> (usize, usize) {
1890    let last_row = ed.buffer().lines().len().saturating_sub(1);
1891    let r = pos.0.min(last_row);
1892    let line_len = ed.buffer().line(r).map(|l| l.chars().count()).unwrap_or(0);
1893    let c = pos.1.min(line_len.saturating_sub(1));
1894    (r, c)
1895}
1896
1897/// True for motions that vim treats as jumps (pushed onto the jumplist).
1898fn is_big_jump(motion: &Motion) -> bool {
1899    matches!(
1900        motion,
1901        Motion::FileTop
1902            | Motion::FileBottom
1903            | Motion::MatchBracket
1904            | Motion::WordAtCursor { .. }
1905            | Motion::SearchNext { .. }
1906            | Motion::ViewportTop
1907            | Motion::ViewportMiddle
1908            | Motion::ViewportBottom
1909    )
1910}
1911
1912// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
1913
1914/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
1915/// viewports still step by a single row. `count` multiplies.
1916fn viewport_half_rows(ed: &Editor<'_>, count: usize) -> usize {
1917    let h = ed.viewport_height_value() as usize;
1918    (h / 2).max(1).saturating_mul(count.max(1))
1919}
1920
1921/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
1922/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
1923fn viewport_full_rows(ed: &Editor<'_>, count: usize) -> usize {
1924    let h = ed.viewport_height_value() as usize;
1925    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
1926}
1927
1928/// Move the cursor by `delta` rows (positive = down, negative = up),
1929/// clamp to the document, then land at the first non-blank on the new
1930/// row. The textarea viewport auto-scrolls to keep the cursor visible
1931/// when the cursor pushes off-screen.
1932fn scroll_cursor_rows(ed: &mut Editor<'_>, delta: isize) {
1933    if delta == 0 {
1934        return;
1935    }
1936    ed.sync_buffer_content_from_textarea();
1937    let (row, _) = ed.cursor();
1938    let last_row = ed.buffer().row_count().saturating_sub(1);
1939    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
1940    ed.buffer_mut()
1941        .set_cursor(hjkl_buffer::Position::new(target, 0));
1942    ed.buffer_mut().move_first_non_blank();
1943    ed.push_buffer_cursor_to_textarea();
1944    ed.vim.sticky_col = Some(ed.buffer().cursor().col);
1945}
1946
1947// ─── Motion parsing ────────────────────────────────────────────────────────
1948
1949fn parse_motion(input: &Input) -> Option<Motion> {
1950    if input.ctrl {
1951        return None;
1952    }
1953    match input.key {
1954        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
1955        Key::Char('l') | Key::Right => Some(Motion::Right),
1956        Key::Char('j') | Key::Down | Key::Enter => Some(Motion::Down),
1957        Key::Char('k') | Key::Up => Some(Motion::Up),
1958        Key::Char('w') => Some(Motion::WordFwd),
1959        Key::Char('W') => Some(Motion::BigWordFwd),
1960        Key::Char('b') => Some(Motion::WordBack),
1961        Key::Char('B') => Some(Motion::BigWordBack),
1962        Key::Char('e') => Some(Motion::WordEnd),
1963        Key::Char('E') => Some(Motion::BigWordEnd),
1964        Key::Char('0') | Key::Home => Some(Motion::LineStart),
1965        Key::Char('^') => Some(Motion::FirstNonBlank),
1966        Key::Char('$') | Key::End => Some(Motion::LineEnd),
1967        Key::Char('G') => Some(Motion::FileBottom),
1968        Key::Char('%') => Some(Motion::MatchBracket),
1969        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
1970        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
1971        Key::Char('*') => Some(Motion::WordAtCursor {
1972            forward: true,
1973            whole_word: true,
1974        }),
1975        Key::Char('#') => Some(Motion::WordAtCursor {
1976            forward: false,
1977            whole_word: true,
1978        }),
1979        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
1980        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
1981        Key::Char('H') => Some(Motion::ViewportTop),
1982        Key::Char('M') => Some(Motion::ViewportMiddle),
1983        Key::Char('L') => Some(Motion::ViewportBottom),
1984        Key::Char('{') => Some(Motion::ParagraphPrev),
1985        Key::Char('}') => Some(Motion::ParagraphNext),
1986        Key::Char('(') => Some(Motion::SentencePrev),
1987        Key::Char(')') => Some(Motion::SentenceNext),
1988        _ => None,
1989    }
1990}
1991
1992// ─── Motion execution ──────────────────────────────────────────────────────
1993
1994fn execute_motion(ed: &mut Editor<'_>, motion: Motion, count: usize) {
1995    let count = count.max(1);
1996    // FindRepeat needs the stored direction.
1997    let motion = match motion {
1998        Motion::FindRepeat { reverse } => match ed.vim.last_find {
1999            Some((ch, forward, till)) => Motion::Find {
2000                ch,
2001                forward: if reverse { !forward } else { forward },
2002                till,
2003            },
2004            None => return,
2005        },
2006        other => other,
2007    };
2008    let pre_pos = ed.cursor();
2009    let pre_col = pre_pos.1;
2010    apply_motion_cursor(ed, &motion, count);
2011    let post_pos = ed.cursor();
2012    if is_big_jump(&motion) && pre_pos != post_pos {
2013        push_jump(ed, pre_pos);
2014    }
2015    apply_sticky_col(ed, &motion, pre_col);
2016    // Phase 7b: keep the migration buffer's cursor + viewport in
2017    // lockstep with the textarea after every motion. Once 7c lands
2018    // (motions ported onto the buffer's API), this flips: the
2019    // buffer becomes authoritative and the textarea mirrors it.
2020    ed.sync_buffer_from_textarea();
2021}
2022
2023/// Restore the cursor to the sticky column after vertical motions and
2024/// sync the sticky column to the current column after horizontal ones.
2025/// `pre_col` is the cursor column captured *before* the motion — used
2026/// to bootstrap the sticky value on the very first motion.
2027fn apply_sticky_col(ed: &mut Editor<'_>, motion: &Motion, pre_col: usize) {
2028    if is_vertical_motion(motion) {
2029        let want = ed.vim.sticky_col.unwrap_or(pre_col);
2030        // Record the desired column so the next vertical motion sees
2031        // it even if we currently clamped to a shorter row.
2032        ed.vim.sticky_col = Some(want);
2033        let (row, _) = ed.cursor();
2034        let line_len = ed.buffer().lines()[row].chars().count();
2035        // Clamp to the last char on non-empty lines (vim normal-mode
2036        // never parks the cursor one past end of line). Empty lines
2037        // collapse to col 0.
2038        let max_col = line_len.saturating_sub(1);
2039        let target = want.min(max_col);
2040        ed.jump_cursor(row, target);
2041    } else {
2042        // Horizontal motion or non-motion: sticky column tracks the
2043        // new cursor column so the *next* vertical motion aims there.
2044        ed.vim.sticky_col = Some(ed.cursor().1);
2045    }
2046}
2047
2048fn is_vertical_motion(motion: &Motion) -> bool {
2049    // Only j / k preserve the sticky column. Everything else (search,
2050    // gg / G, word jumps, etc.) lands at the match's own column so the
2051    // sticky value should sync to the new cursor column.
2052    matches!(
2053        motion,
2054        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
2055    )
2056}
2057
2058fn apply_motion_cursor(ed: &mut Editor<'_>, motion: &Motion, count: usize) {
2059    apply_motion_cursor_ctx(ed, motion, count, false)
2060}
2061
2062fn apply_motion_cursor_ctx(ed: &mut Editor<'_>, motion: &Motion, count: usize, as_operator: bool) {
2063    match motion {
2064        Motion::Left => {
2065            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
2066            ed.buffer_mut().move_left(count);
2067            ed.push_buffer_cursor_to_textarea();
2068        }
2069        Motion::Right => {
2070            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
2071            // one past the last char so the range includes it; cursor
2072            // context clamps at the last char.
2073            if as_operator {
2074                ed.buffer_mut().move_right_to_end(count);
2075            } else {
2076                ed.buffer_mut().move_right_in_line(count);
2077            }
2078            ed.push_buffer_cursor_to_textarea();
2079        }
2080        Motion::Up => {
2081            // Final col is set by `apply_sticky_col` below — push the
2082            // post-move row to the textarea and let sticky tracking
2083            // finish the work.
2084            ed.buffer_mut().move_up(count);
2085            ed.push_buffer_cursor_to_textarea();
2086        }
2087        Motion::Down => {
2088            ed.buffer_mut().move_down(count);
2089            ed.push_buffer_cursor_to_textarea();
2090        }
2091        Motion::ScreenUp => {
2092            ed.buffer_mut().move_screen_up(count);
2093            ed.push_buffer_cursor_to_textarea();
2094        }
2095        Motion::ScreenDown => {
2096            ed.buffer_mut().move_screen_down(count);
2097            ed.push_buffer_cursor_to_textarea();
2098        }
2099        Motion::WordFwd => {
2100            ed.buffer_mut().move_word_fwd(false, count);
2101            ed.push_buffer_cursor_to_textarea();
2102        }
2103        Motion::WordBack => {
2104            ed.buffer_mut().move_word_back(false, count);
2105            ed.push_buffer_cursor_to_textarea();
2106        }
2107        Motion::WordEnd => {
2108            ed.buffer_mut().move_word_end(false, count);
2109            ed.push_buffer_cursor_to_textarea();
2110        }
2111        Motion::BigWordFwd => {
2112            ed.buffer_mut().move_word_fwd(true, count);
2113            ed.push_buffer_cursor_to_textarea();
2114        }
2115        Motion::BigWordBack => {
2116            ed.buffer_mut().move_word_back(true, count);
2117            ed.push_buffer_cursor_to_textarea();
2118        }
2119        Motion::BigWordEnd => {
2120            ed.buffer_mut().move_word_end(true, count);
2121            ed.push_buffer_cursor_to_textarea();
2122        }
2123        Motion::WordEndBack => {
2124            ed.buffer_mut().move_word_end_back(false, count);
2125            ed.push_buffer_cursor_to_textarea();
2126        }
2127        Motion::BigWordEndBack => {
2128            ed.buffer_mut().move_word_end_back(true, count);
2129            ed.push_buffer_cursor_to_textarea();
2130        }
2131        Motion::LineStart => {
2132            ed.buffer_mut().move_line_start();
2133            ed.push_buffer_cursor_to_textarea();
2134        }
2135        Motion::FirstNonBlank => {
2136            ed.buffer_mut().move_first_non_blank();
2137            ed.push_buffer_cursor_to_textarea();
2138        }
2139        Motion::LineEnd => {
2140            // Vim normal-mode `$` lands on the last char, not one past it.
2141            ed.buffer_mut().move_line_end();
2142            ed.push_buffer_cursor_to_textarea();
2143        }
2144        Motion::FileTop => {
2145            // `count gg` jumps to line `count` (first non-blank);
2146            // bare `gg` lands at the top.
2147            if count > 1 {
2148                ed.buffer_mut().move_bottom(count);
2149            } else {
2150                ed.buffer_mut().move_top();
2151            }
2152            ed.push_buffer_cursor_to_textarea();
2153        }
2154        Motion::FileBottom => {
2155            // `count G` jumps to line `count`; bare `G` lands at
2156            // the buffer bottom (`Buffer::move_bottom(0)`).
2157            if count > 1 {
2158                ed.buffer_mut().move_bottom(count);
2159            } else {
2160                ed.buffer_mut().move_bottom(0);
2161            }
2162            ed.push_buffer_cursor_to_textarea();
2163        }
2164        Motion::Find { ch, forward, till } => {
2165            for _ in 0..count {
2166                if !find_char_on_line(ed, *ch, *forward, *till) {
2167                    break;
2168                }
2169            }
2170        }
2171        Motion::FindRepeat { .. } => {} // already resolved upstream
2172        Motion::MatchBracket => {
2173            let _ = matching_bracket(ed);
2174        }
2175        Motion::WordAtCursor {
2176            forward,
2177            whole_word,
2178        } => {
2179            word_at_cursor_search(ed, *forward, *whole_word, count);
2180        }
2181        Motion::SearchNext { reverse } => {
2182            // Re-push the last query so the buffer's search state is
2183            // correct even if the host happened to clear it (e.g. while
2184            // a Visual mode draw was in progress).
2185            if let Some(pattern) = ed.vim.last_search.clone() {
2186                push_search_pattern(ed, &pattern);
2187            }
2188            if ed.buffer().search_pattern().is_none() {
2189                return;
2190            }
2191            // `n` repeats the last search in its committed direction;
2192            // `N` inverts. So a `?` search makes `n` walk backward and
2193            // `N` walk forward.
2194            let forward = ed.vim.last_search_forward != *reverse;
2195            for _ in 0..count.max(1) {
2196                if forward {
2197                    ed.buffer_mut().search_forward(true);
2198                } else {
2199                    ed.buffer_mut().search_backward(true);
2200                }
2201            }
2202            ed.push_buffer_cursor_to_textarea();
2203        }
2204        Motion::ViewportTop => {
2205            ed.buffer_mut().move_viewport_top(count.saturating_sub(1));
2206            ed.push_buffer_cursor_to_textarea();
2207        }
2208        Motion::ViewportMiddle => {
2209            ed.buffer_mut().move_viewport_middle();
2210            ed.push_buffer_cursor_to_textarea();
2211        }
2212        Motion::ViewportBottom => {
2213            ed.buffer_mut()
2214                .move_viewport_bottom(count.saturating_sub(1));
2215            ed.push_buffer_cursor_to_textarea();
2216        }
2217        Motion::LastNonBlank => {
2218            ed.buffer_mut().move_last_non_blank();
2219            ed.push_buffer_cursor_to_textarea();
2220        }
2221        Motion::LineMiddle => {
2222            let row = ed.cursor().0;
2223            let line_chars = ed
2224                .buffer()
2225                .line(row)
2226                .map(|l| l.chars().count())
2227                .unwrap_or(0);
2228            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
2229            // lines stay at col 0.
2230            let target = line_chars / 2;
2231            ed.jump_cursor(row, target);
2232        }
2233        Motion::ParagraphPrev => {
2234            ed.buffer_mut().move_paragraph_prev(count);
2235            ed.push_buffer_cursor_to_textarea();
2236        }
2237        Motion::ParagraphNext => {
2238            ed.buffer_mut().move_paragraph_next(count);
2239            ed.push_buffer_cursor_to_textarea();
2240        }
2241        Motion::SentencePrev => {
2242            for _ in 0..count.max(1) {
2243                if let Some((row, col)) = sentence_boundary(ed, false) {
2244                    ed.jump_cursor(row, col);
2245                }
2246            }
2247        }
2248        Motion::SentenceNext => {
2249            for _ in 0..count.max(1) {
2250                if let Some((row, col)) = sentence_boundary(ed, true) {
2251                    ed.jump_cursor(row, col);
2252                }
2253            }
2254        }
2255    }
2256}
2257
2258fn move_first_non_whitespace(ed: &mut Editor<'_>) {
2259    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
2260    // mutates the textarea content, so the migration buffer hasn't
2261    // seen the new lines OR new cursor yet. Mirror the full content
2262    // across before delegating, then push the result back so the
2263    // textarea reflects the resolved column too.
2264    ed.sync_buffer_content_from_textarea();
2265    ed.buffer_mut().move_first_non_blank();
2266    ed.push_buffer_cursor_to_textarea();
2267}
2268
2269fn find_char_on_line(ed: &mut Editor<'_>, ch: char, forward: bool, till: bool) -> bool {
2270    let moved = ed.buffer_mut().find_char_on_line(ch, forward, till);
2271    if moved {
2272        ed.push_buffer_cursor_to_textarea();
2273    }
2274    moved
2275}
2276
2277fn matching_bracket(ed: &mut Editor<'_>) -> bool {
2278    let moved = ed.buffer_mut().match_bracket();
2279    if moved {
2280        ed.push_buffer_cursor_to_textarea();
2281    }
2282    moved
2283}
2284
2285fn word_at_cursor_search(ed: &mut Editor<'_>, forward: bool, whole_word: bool, count: usize) {
2286    let (row, col) = ed.cursor();
2287    let line: String = ed.buffer().line(row).unwrap_or("").to_string();
2288    let chars: Vec<char> = line.chars().collect();
2289    if chars.is_empty() {
2290        return;
2291    }
2292    // Expand around cursor to a word boundary.
2293    let is_word = |c: char| c.is_alphanumeric() || c == '_';
2294    let mut start = col.min(chars.len().saturating_sub(1));
2295    while start > 0 && is_word(chars[start - 1]) {
2296        start -= 1;
2297    }
2298    let mut end = start;
2299    while end < chars.len() && is_word(chars[end]) {
2300        end += 1;
2301    }
2302    if end <= start {
2303        return;
2304    }
2305    let word: String = chars[start..end].iter().collect();
2306    let escaped = regex_escape(&word);
2307    let pattern = if whole_word {
2308        format!(r"\b{escaped}\b")
2309    } else {
2310        escaped
2311    };
2312    push_search_pattern(ed, &pattern);
2313    if ed.buffer().search_pattern().is_none() {
2314        return;
2315    }
2316    // Remember the query so `n` / `N` keep working after the jump.
2317    ed.vim.last_search = Some(pattern);
2318    ed.vim.last_search_forward = forward;
2319    for _ in 0..count.max(1) {
2320        if forward {
2321            ed.buffer_mut().search_forward(true);
2322        } else {
2323            ed.buffer_mut().search_backward(true);
2324        }
2325    }
2326    ed.push_buffer_cursor_to_textarea();
2327}
2328
2329fn regex_escape(s: &str) -> String {
2330    let mut out = String::with_capacity(s.len());
2331    for c in s.chars() {
2332        if matches!(
2333            c,
2334            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
2335        ) {
2336            out.push('\\');
2337        }
2338        out.push(c);
2339    }
2340    out
2341}
2342
2343// ─── Operator application ──────────────────────────────────────────────────
2344
2345fn handle_after_op(ed: &mut Editor<'_>, input: Input, op: Operator, count1: usize) -> bool {
2346    // Inner count after operator (e.g. d3w): accumulate in state.count.
2347    if let Key::Char(d @ '0'..='9') = input.key
2348        && !input.ctrl
2349        && (d != '0' || ed.vim.count > 0)
2350    {
2351        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
2352        ed.vim.pending = Pending::Op { op, count1 };
2353        return true;
2354    }
2355
2356    // Esc cancels.
2357    if input.key == Key::Esc {
2358        ed.vim.count = 0;
2359        return true;
2360    }
2361
2362    // Same-letter: dd / cc / yy / gUU / guu / g~~ / >> / <<. Fold has
2363    // no doubled form in vim — `zfzf` is two `zf` chords, not a line
2364    // op — so skip the branch entirely.
2365    let double_ch = match op {
2366        Operator::Delete => Some('d'),
2367        Operator::Change => Some('c'),
2368        Operator::Yank => Some('y'),
2369        Operator::Indent => Some('>'),
2370        Operator::Outdent => Some('<'),
2371        Operator::Uppercase => Some('U'),
2372        Operator::Lowercase => Some('u'),
2373        Operator::ToggleCase => Some('~'),
2374        Operator::Fold => None,
2375        // `gqq` reflows the current line — vim's doubled form for the
2376        // reflow operator is the second `q` after `gq`.
2377        Operator::Reflow => Some('q'),
2378    };
2379    if let Key::Char(c) = input.key
2380        && !input.ctrl
2381        && Some(c) == double_ch
2382    {
2383        let count2 = take_count(&mut ed.vim);
2384        let total = count1.max(1) * count2.max(1);
2385        execute_line_op(ed, op, total);
2386        if !ed.vim.replaying {
2387            ed.vim.last_change = Some(LastChange::LineOp {
2388                op,
2389                count: total,
2390                inserted: None,
2391            });
2392        }
2393        return true;
2394    }
2395
2396    // Text object: `i` or `a`.
2397    if let Key::Char('i') | Key::Char('a') = input.key
2398        && !input.ctrl
2399    {
2400        let inner = matches!(input.key, Key::Char('i'));
2401        ed.vim.pending = Pending::OpTextObj { op, count1, inner };
2402        return true;
2403    }
2404
2405    // `g` — awaiting `g` for `gg`.
2406    if input.key == Key::Char('g') && !input.ctrl {
2407        ed.vim.pending = Pending::OpG { op, count1 };
2408        return true;
2409    }
2410
2411    // `f`/`F`/`t`/`T` with pending target.
2412    if let Some((forward, till)) = find_entry(&input) {
2413        ed.vim.pending = Pending::OpFind {
2414            op,
2415            count1,
2416            forward,
2417            till,
2418        };
2419        return true;
2420    }
2421
2422    // Motion.
2423    let count2 = take_count(&mut ed.vim);
2424    let total = count1.max(1) * count2.max(1);
2425    if let Some(motion) = parse_motion(&input) {
2426        let motion = match motion {
2427            Motion::FindRepeat { reverse } => match ed.vim.last_find {
2428                Some((ch, forward, till)) => Motion::Find {
2429                    ch,
2430                    forward: if reverse { !forward } else { forward },
2431                    till,
2432                },
2433                None => return true,
2434            },
2435            // Vim quirk: `cw` / `cW` are `ce` / `cE` — don't include
2436            // trailing whitespace so the user's replacement text lands
2437            // before the following word's leading space.
2438            Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
2439            Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
2440            m => m,
2441        };
2442        apply_op_with_motion(ed, op, &motion, total);
2443        if let Motion::Find { ch, forward, till } = &motion {
2444            ed.vim.last_find = Some((*ch, *forward, *till));
2445        }
2446        if !ed.vim.replaying && op_is_change(op) {
2447            ed.vim.last_change = Some(LastChange::OpMotion {
2448                op,
2449                motion,
2450                count: total,
2451                inserted: None,
2452            });
2453        }
2454        return true;
2455    }
2456
2457    // Unknown — cancel the operator.
2458    true
2459}
2460
2461fn handle_op_after_g(ed: &mut Editor<'_>, input: Input, op: Operator, count1: usize) -> bool {
2462    if input.ctrl {
2463        return true;
2464    }
2465    let count2 = take_count(&mut ed.vim);
2466    let total = count1.max(1) * count2.max(1);
2467    // Case-op linewise form: `gUgU`, `gugu`, `g~g~` — same effect as
2468    // `gUU` / `guu` / `g~~`. The leading `g` was consumed into
2469    // `Pending::OpG`, so here we see the trailing U / u / ~.
2470    if matches!(
2471        op,
2472        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
2473    ) {
2474        let op_char = match op {
2475            Operator::Uppercase => 'U',
2476            Operator::Lowercase => 'u',
2477            Operator::ToggleCase => '~',
2478            _ => unreachable!(),
2479        };
2480        if input.key == Key::Char(op_char) {
2481            execute_line_op(ed, op, total);
2482            if !ed.vim.replaying {
2483                ed.vim.last_change = Some(LastChange::LineOp {
2484                    op,
2485                    count: total,
2486                    inserted: None,
2487                });
2488            }
2489            return true;
2490        }
2491    }
2492    let motion = match input.key {
2493        Key::Char('g') => Motion::FileTop,
2494        Key::Char('e') => Motion::WordEndBack,
2495        Key::Char('E') => Motion::BigWordEndBack,
2496        Key::Char('j') => Motion::ScreenDown,
2497        Key::Char('k') => Motion::ScreenUp,
2498        _ => return true,
2499    };
2500    apply_op_with_motion(ed, op, &motion, total);
2501    if !ed.vim.replaying && op_is_change(op) {
2502        ed.vim.last_change = Some(LastChange::OpMotion {
2503            op,
2504            motion,
2505            count: total,
2506            inserted: None,
2507        });
2508    }
2509    true
2510}
2511
2512fn handle_after_g(ed: &mut Editor<'_>, input: Input) -> bool {
2513    let count = take_count(&mut ed.vim);
2514    match input.key {
2515        Key::Char('g') => {
2516            // gg — top / jump to line count.
2517            let pre = ed.cursor();
2518            if count > 1 {
2519                ed.jump_cursor(count - 1, 0);
2520            } else {
2521                ed.jump_cursor(0, 0);
2522            }
2523            move_first_non_whitespace(ed);
2524            if ed.cursor() != pre {
2525                push_jump(ed, pre);
2526            }
2527        }
2528        Key::Char('e') => execute_motion(ed, Motion::WordEndBack, count),
2529        Key::Char('E') => execute_motion(ed, Motion::BigWordEndBack, count),
2530        // `g_` — last non-blank on the line.
2531        Key::Char('_') => execute_motion(ed, Motion::LastNonBlank, count),
2532        // `gM` — middle char column of the current line.
2533        Key::Char('M') => execute_motion(ed, Motion::LineMiddle, count),
2534        // `gv` — re-enter the last visual selection.
2535        Key::Char('v') => {
2536            if let Some(snap) = ed.vim.last_visual {
2537                match snap.mode {
2538                    Mode::Visual => {
2539                        ed.vim.visual_anchor = snap.anchor;
2540                        ed.vim.mode = Mode::Visual;
2541                    }
2542                    Mode::VisualLine => {
2543                        ed.vim.visual_line_anchor = snap.anchor.0;
2544                        ed.vim.mode = Mode::VisualLine;
2545                    }
2546                    Mode::VisualBlock => {
2547                        ed.vim.block_anchor = snap.anchor;
2548                        ed.vim.block_vcol = snap.block_vcol;
2549                        ed.vim.mode = Mode::VisualBlock;
2550                    }
2551                    _ => {}
2552                }
2553                ed.jump_cursor(snap.cursor.0, snap.cursor.1);
2554            }
2555        }
2556        // `gj` / `gk` — display-line down / up. Walks one screen
2557        // segment at a time under `:set wrap`; falls back to `j`/`k`
2558        // when wrap is off (Buffer::move_screen_* handles the branch).
2559        Key::Char('j') => execute_motion(ed, Motion::ScreenDown, count),
2560        Key::Char('k') => execute_motion(ed, Motion::ScreenUp, count),
2561        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
2562        // so the next input is treated as the motion / text object /
2563        // shorthand double (`gUU`, `guu`, `g~~`).
2564        Key::Char('U') => {
2565            ed.vim.pending = Pending::Op {
2566                op: Operator::Uppercase,
2567                count1: count,
2568            };
2569        }
2570        Key::Char('u') => {
2571            ed.vim.pending = Pending::Op {
2572                op: Operator::Lowercase,
2573                count1: count,
2574            };
2575        }
2576        Key::Char('~') => {
2577            ed.vim.pending = Pending::Op {
2578                op: Operator::ToggleCase,
2579                count1: count,
2580            };
2581        }
2582        Key::Char('q') => {
2583            // `gq{motion}` — text reflow operator. Subsequent motion
2584            // / textobj rides the same operator pipeline.
2585            ed.vim.pending = Pending::Op {
2586                op: Operator::Reflow,
2587                count1: count,
2588            };
2589        }
2590        Key::Char('J') => {
2591            // `gJ` — join line below without inserting a space.
2592            for _ in 0..count.max(1) {
2593                ed.push_undo();
2594                join_line_raw(ed);
2595            }
2596            if !ed.vim.replaying {
2597                ed.vim.last_change = Some(LastChange::JoinLine {
2598                    count: count.max(1),
2599                });
2600            }
2601        }
2602        Key::Char('d') => {
2603            // `gd` — goto definition. hjkl-engine doesn't run an LSP
2604            // itself; raise an intent the host drains and routes to
2605            // `sqls`. The cursor stays put here — the host moves it
2606            // once it has the target location.
2607            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
2608        }
2609        // `g;` / `g,` — walk the change list. `g;` toward older
2610        // entries, `g,` toward newer.
2611        Key::Char(';') => walk_change_list(ed, -1, count.max(1)),
2612        Key::Char(',') => walk_change_list(ed, 1, count.max(1)),
2613        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
2614        // boundary anchors), so the cursor on `foo` finds it inside
2615        // `foobar` too.
2616        Key::Char('*') => execute_motion(
2617            ed,
2618            Motion::WordAtCursor {
2619                forward: true,
2620                whole_word: false,
2621            },
2622            count,
2623        ),
2624        Key::Char('#') => execute_motion(
2625            ed,
2626            Motion::WordAtCursor {
2627                forward: false,
2628                whole_word: false,
2629            },
2630            count,
2631        ),
2632        _ => {}
2633    }
2634    true
2635}
2636
2637fn handle_after_z(ed: &mut Editor<'_>, input: Input) -> bool {
2638    use crate::editor::CursorScrollTarget;
2639    let row = ed.cursor().0;
2640    match input.key {
2641        Key::Char('z') => {
2642            ed.scroll_cursor_to(CursorScrollTarget::Center);
2643            ed.vim.viewport_pinned = true;
2644        }
2645        Key::Char('t') => {
2646            ed.scroll_cursor_to(CursorScrollTarget::Top);
2647            ed.vim.viewport_pinned = true;
2648        }
2649        Key::Char('b') => {
2650            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
2651            ed.vim.viewport_pinned = true;
2652        }
2653        // Folds — operate on the fold under the cursor (or the
2654        // whole buffer for `R` / `M`).
2655        Key::Char('o') => {
2656            ed.buffer_mut().open_fold_at(row);
2657        }
2658        Key::Char('c') => {
2659            ed.buffer_mut().close_fold_at(row);
2660        }
2661        Key::Char('a') => {
2662            ed.buffer_mut().toggle_fold_at(row);
2663        }
2664        Key::Char('R') => {
2665            ed.buffer_mut().open_all_folds();
2666        }
2667        Key::Char('M') => {
2668            ed.buffer_mut().close_all_folds();
2669        }
2670        Key::Char('E') => {
2671            ed.buffer_mut().clear_all_folds();
2672        }
2673        Key::Char('d') => {
2674            ed.buffer_mut().remove_fold_at(row);
2675        }
2676        Key::Char('f') => {
2677            if matches!(
2678                ed.vim.mode,
2679                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
2680            ) {
2681                // `zf` over a Visual selection creates a fold spanning
2682                // anchor → cursor.
2683                let anchor_row = match ed.vim.mode {
2684                    Mode::VisualLine => ed.vim.visual_line_anchor,
2685                    Mode::VisualBlock => ed.vim.block_anchor.0,
2686                    _ => ed.vim.visual_anchor.0,
2687                };
2688                let cur = ed.cursor().0;
2689                let top = anchor_row.min(cur);
2690                let bot = anchor_row.max(cur);
2691                ed.buffer_mut().add_fold(top, bot, true);
2692                ed.vim.mode = Mode::Normal;
2693            } else {
2694                // `zf{motion}` / `zf{textobj}` — route through the
2695                // operator pipeline. `Operator::Fold` reuses every
2696                // motion / text-object / `g`-prefix branch the other
2697                // operators get.
2698                let count = take_count(&mut ed.vim);
2699                ed.vim.pending = Pending::Op {
2700                    op: Operator::Fold,
2701                    count1: count,
2702                };
2703            }
2704        }
2705        _ => {}
2706    }
2707    true
2708}
2709
2710fn handle_replace(ed: &mut Editor<'_>, input: Input) -> bool {
2711    if let Key::Char(ch) = input.key {
2712        if ed.vim.mode == Mode::VisualBlock {
2713            block_replace(ed, ch);
2714            return true;
2715        }
2716        let count = take_count(&mut ed.vim);
2717        replace_char(ed, ch, count.max(1));
2718        if !ed.vim.replaying {
2719            ed.vim.last_change = Some(LastChange::ReplaceChar {
2720                ch,
2721                count: count.max(1),
2722            });
2723        }
2724    }
2725    true
2726}
2727
2728fn handle_find_target(ed: &mut Editor<'_>, input: Input, forward: bool, till: bool) -> bool {
2729    let Key::Char(ch) = input.key else {
2730        return true;
2731    };
2732    let count = take_count(&mut ed.vim);
2733    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
2734    ed.vim.last_find = Some((ch, forward, till));
2735    true
2736}
2737
2738fn handle_op_find_target(
2739    ed: &mut Editor<'_>,
2740    input: Input,
2741    op: Operator,
2742    count1: usize,
2743    forward: bool,
2744    till: bool,
2745) -> bool {
2746    let Key::Char(ch) = input.key else {
2747        return true;
2748    };
2749    let count2 = take_count(&mut ed.vim);
2750    let total = count1.max(1) * count2.max(1);
2751    let motion = Motion::Find { ch, forward, till };
2752    apply_op_with_motion(ed, op, &motion, total);
2753    ed.vim.last_find = Some((ch, forward, till));
2754    if !ed.vim.replaying && op_is_change(op) {
2755        ed.vim.last_change = Some(LastChange::OpMotion {
2756            op,
2757            motion,
2758            count: total,
2759            inserted: None,
2760        });
2761    }
2762    true
2763}
2764
2765fn handle_text_object(
2766    ed: &mut Editor<'_>,
2767    input: Input,
2768    op: Operator,
2769    _count1: usize,
2770    inner: bool,
2771) -> bool {
2772    let Key::Char(ch) = input.key else {
2773        return true;
2774    };
2775    let obj = match ch {
2776        'w' => TextObject::Word { big: false },
2777        'W' => TextObject::Word { big: true },
2778        '"' | '\'' | '`' => TextObject::Quote(ch),
2779        '(' | ')' | 'b' => TextObject::Bracket('('),
2780        '[' | ']' => TextObject::Bracket('['),
2781        '{' | '}' | 'B' => TextObject::Bracket('{'),
2782        '<' | '>' => TextObject::Bracket('<'),
2783        'p' => TextObject::Paragraph,
2784        't' => TextObject::XmlTag,
2785        's' => TextObject::Sentence,
2786        _ => return true,
2787    };
2788    apply_op_with_text_object(ed, op, obj, inner);
2789    if !ed.vim.replaying && op_is_change(op) {
2790        ed.vim.last_change = Some(LastChange::OpTextObj {
2791            op,
2792            obj,
2793            inner,
2794            inserted: None,
2795        });
2796    }
2797    true
2798}
2799
2800fn handle_visual_text_obj(ed: &mut Editor<'_>, input: Input, inner: bool) -> bool {
2801    let Key::Char(ch) = input.key else {
2802        return true;
2803    };
2804    let obj = match ch {
2805        'w' => TextObject::Word { big: false },
2806        'W' => TextObject::Word { big: true },
2807        '"' | '\'' | '`' => TextObject::Quote(ch),
2808        '(' | ')' | 'b' => TextObject::Bracket('('),
2809        '[' | ']' => TextObject::Bracket('['),
2810        '{' | '}' | 'B' => TextObject::Bracket('{'),
2811        '<' | '>' => TextObject::Bracket('<'),
2812        'p' => TextObject::Paragraph,
2813        't' => TextObject::XmlTag,
2814        's' => TextObject::Sentence,
2815        _ => return true,
2816    };
2817    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
2818        return true;
2819    };
2820    // Anchor + cursor position the char-wise highlight / operator range;
2821    // for linewise text-objects we switch into VisualLine with the
2822    // appropriate row anchor.
2823    match kind {
2824        MotionKind::Linewise => {
2825            ed.vim.visual_line_anchor = start.0;
2826            ed.vim.mode = Mode::VisualLine;
2827            ed.jump_cursor(end.0, 0);
2828        }
2829        _ => {
2830            ed.vim.mode = Mode::Visual;
2831            ed.vim.visual_anchor = (start.0, start.1);
2832            let (er, ec) = retreat_one(ed, end);
2833            ed.jump_cursor(er, ec);
2834        }
2835    }
2836    true
2837}
2838
2839/// Move `pos` back by one character, clamped to (0, 0).
2840fn retreat_one(ed: &Editor<'_>, pos: (usize, usize)) -> (usize, usize) {
2841    let (r, c) = pos;
2842    if c > 0 {
2843        (r, c - 1)
2844    } else if r > 0 {
2845        let prev_len = ed.buffer().lines()[r - 1].len();
2846        (r - 1, prev_len)
2847    } else {
2848        (0, 0)
2849    }
2850}
2851
2852fn op_is_change(op: Operator) -> bool {
2853    matches!(op, Operator::Delete | Operator::Change)
2854}
2855
2856// ─── Normal-only commands (not motion, not operator) ───────────────────────
2857
2858fn handle_normal_only(ed: &mut Editor<'_>, input: &Input, count: usize) -> bool {
2859    if input.ctrl {
2860        return false;
2861    }
2862    match input.key {
2863        Key::Char('i') => {
2864            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
2865            true
2866        }
2867        Key::Char('I') => {
2868            move_first_non_whitespace(ed);
2869            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
2870            true
2871        }
2872        Key::Char('a') => {
2873            ed.buffer_mut().move_right_to_end(1);
2874            ed.push_buffer_cursor_to_textarea();
2875            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
2876            true
2877        }
2878        Key::Char('A') => {
2879            ed.buffer_mut().move_line_end();
2880            ed.buffer_mut().move_right_to_end(1);
2881            ed.push_buffer_cursor_to_textarea();
2882            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
2883            true
2884        }
2885        Key::Char('R') => {
2886            // Replace mode — overstrike each typed cell. Reuses the
2887            // insert-mode key handler with a Replace-flavoured session.
2888            begin_insert(ed, count.max(1), InsertReason::Replace);
2889            true
2890        }
2891        Key::Char('o') => {
2892            use hjkl_buffer::{Edit, Position};
2893            ed.push_undo();
2894            // Snapshot BEFORE the newline so replay sees "\n<text>" as the
2895            // delta and produces one fresh line per iteration.
2896            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
2897            ed.sync_buffer_content_from_textarea();
2898            let row = ed.buffer().cursor().row;
2899            let line_chars = ed
2900                .buffer()
2901                .line(row)
2902                .map(|l| l.chars().count())
2903                .unwrap_or(0);
2904            ed.mutate_edit(Edit::InsertStr {
2905                at: Position::new(row, line_chars),
2906                text: "\n".to_string(),
2907            });
2908            ed.push_buffer_cursor_to_textarea();
2909            true
2910        }
2911        Key::Char('O') => {
2912            use hjkl_buffer::{Edit, Position};
2913            ed.push_undo();
2914            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
2915            ed.sync_buffer_content_from_textarea();
2916            let row = ed.buffer().cursor().row;
2917            ed.mutate_edit(Edit::InsertStr {
2918                at: Position::new(row, 0),
2919                text: "\n".to_string(),
2920            });
2921            // After insert, cursor sits on the surviving content one row
2922            // down — step back up onto the freshly-empty line.
2923            ed.buffer_mut().move_up(1);
2924            ed.push_buffer_cursor_to_textarea();
2925            true
2926        }
2927        Key::Char('x') => {
2928            do_char_delete(ed, true, count.max(1));
2929            if !ed.vim.replaying {
2930                ed.vim.last_change = Some(LastChange::CharDel {
2931                    forward: true,
2932                    count: count.max(1),
2933                });
2934            }
2935            true
2936        }
2937        Key::Char('X') => {
2938            do_char_delete(ed, false, count.max(1));
2939            if !ed.vim.replaying {
2940                ed.vim.last_change = Some(LastChange::CharDel {
2941                    forward: false,
2942                    count: count.max(1),
2943                });
2944            }
2945            true
2946        }
2947        Key::Char('~') => {
2948            for _ in 0..count.max(1) {
2949                ed.push_undo();
2950                toggle_case_at_cursor(ed);
2951            }
2952            if !ed.vim.replaying {
2953                ed.vim.last_change = Some(LastChange::ToggleCase {
2954                    count: count.max(1),
2955                });
2956            }
2957            true
2958        }
2959        Key::Char('J') => {
2960            for _ in 0..count.max(1) {
2961                ed.push_undo();
2962                join_line(ed);
2963            }
2964            if !ed.vim.replaying {
2965                ed.vim.last_change = Some(LastChange::JoinLine {
2966                    count: count.max(1),
2967                });
2968            }
2969            true
2970        }
2971        Key::Char('D') => {
2972            ed.push_undo();
2973            delete_to_eol(ed);
2974            // Vim parks the cursor on the new last char.
2975            ed.buffer_mut().move_left(1);
2976            ed.push_buffer_cursor_to_textarea();
2977            if !ed.vim.replaying {
2978                ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
2979            }
2980            true
2981        }
2982        Key::Char('Y') => {
2983            // Vim 8 default: `Y` yanks to end of line (same as `y$`).
2984            apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
2985            true
2986        }
2987        Key::Char('C') => {
2988            ed.push_undo();
2989            delete_to_eol(ed);
2990            begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
2991            true
2992        }
2993        Key::Char('s') => {
2994            use hjkl_buffer::{Edit, MotionKind, Position};
2995            ed.push_undo();
2996            ed.sync_buffer_content_from_textarea();
2997            for _ in 0..count.max(1) {
2998                let cursor = ed.buffer().cursor();
2999                let line_chars = ed
3000                    .buffer()
3001                    .line(cursor.row)
3002                    .map(|l| l.chars().count())
3003                    .unwrap_or(0);
3004                if cursor.col >= line_chars {
3005                    break;
3006                }
3007                ed.mutate_edit(Edit::DeleteRange {
3008                    start: cursor,
3009                    end: Position::new(cursor.row, cursor.col + 1),
3010                    kind: MotionKind::Char,
3011                });
3012            }
3013            ed.push_buffer_cursor_to_textarea();
3014            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3015            // `s` == `cl` — record as such.
3016            if !ed.vim.replaying {
3017                ed.vim.last_change = Some(LastChange::OpMotion {
3018                    op: Operator::Change,
3019                    motion: Motion::Right,
3020                    count: count.max(1),
3021                    inserted: None,
3022                });
3023            }
3024            true
3025        }
3026        Key::Char('p') => {
3027            do_paste(ed, false, count.max(1));
3028            if !ed.vim.replaying {
3029                ed.vim.last_change = Some(LastChange::Paste {
3030                    before: false,
3031                    count: count.max(1),
3032                });
3033            }
3034            true
3035        }
3036        Key::Char('P') => {
3037            do_paste(ed, true, count.max(1));
3038            if !ed.vim.replaying {
3039                ed.vim.last_change = Some(LastChange::Paste {
3040                    before: true,
3041                    count: count.max(1),
3042                });
3043            }
3044            true
3045        }
3046        Key::Char('u') => {
3047            do_undo(ed);
3048            true
3049        }
3050        Key::Char('r') => {
3051            ed.vim.count = count;
3052            ed.vim.pending = Pending::Replace;
3053            true
3054        }
3055        Key::Char('/') => {
3056            enter_search(ed, true);
3057            true
3058        }
3059        Key::Char('?') => {
3060            enter_search(ed, false);
3061            true
3062        }
3063        Key::Char('.') => {
3064            replay_last_change(ed, count);
3065            true
3066        }
3067        _ => false,
3068    }
3069}
3070
3071/// Variant of begin_insert that doesn't push_undo (caller already did).
3072fn begin_insert_noundo(ed: &mut Editor<'_>, count: usize, reason: InsertReason) {
3073    let reason = if ed.vim.replaying {
3074        InsertReason::ReplayOnly
3075    } else {
3076        reason
3077    };
3078    let (row, _) = ed.cursor();
3079    ed.vim.insert_session = Some(InsertSession {
3080        count,
3081        row_min: row,
3082        row_max: row,
3083        before_lines: ed.buffer().lines().to_vec(),
3084        reason,
3085    });
3086    ed.vim.mode = Mode::Insert;
3087}
3088
3089// ─── Operator × Motion application ─────────────────────────────────────────
3090
3091fn apply_op_with_motion(ed: &mut Editor<'_>, op: Operator, motion: &Motion, count: usize) {
3092    let start = ed.cursor();
3093    // Tentatively apply motion to find the endpoint. Operator context
3094    // so `l` on the last char advances past-last (standard vim
3095    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
3096    // `yl` to cover the final char.
3097    apply_motion_cursor_ctx(ed, motion, count, true);
3098    let end = ed.cursor();
3099    let kind = motion_kind(motion);
3100    // Restore cursor before selecting (so Yank leaves cursor at start).
3101    ed.jump_cursor(start.0, start.1);
3102    run_operator_over_range(ed, op, start, end, kind);
3103}
3104
3105fn apply_op_with_text_object(ed: &mut Editor<'_>, op: Operator, obj: TextObject, inner: bool) {
3106    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3107        return;
3108    };
3109    ed.jump_cursor(start.0, start.1);
3110    run_operator_over_range(ed, op, start, end, kind);
3111}
3112
3113fn motion_kind(motion: &Motion) -> MotionKind {
3114    match motion {
3115        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => MotionKind::Linewise,
3116        Motion::FileTop | Motion::FileBottom => MotionKind::Linewise,
3117        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
3118            MotionKind::Linewise
3119        }
3120        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
3121            MotionKind::Inclusive
3122        }
3123        Motion::Find { .. } => MotionKind::Inclusive,
3124        Motion::MatchBracket => MotionKind::Inclusive,
3125        // `$` now lands on the last char — operator ranges include it.
3126        Motion::LineEnd => MotionKind::Inclusive,
3127        _ => MotionKind::Exclusive,
3128    }
3129}
3130
3131fn run_operator_over_range(
3132    ed: &mut Editor<'_>,
3133    op: Operator,
3134    start: (usize, usize),
3135    end: (usize, usize),
3136    kind: MotionKind,
3137) {
3138    let (top, bot) = order(start, end);
3139    if top == bot {
3140        return;
3141    }
3142
3143    match op {
3144        Operator::Yank => {
3145            let text = read_vim_range(ed, top, bot, kind);
3146            if !text.is_empty() {
3147                ed.last_yank = Some(text.clone());
3148                ed.record_yank(text, matches!(kind, MotionKind::Linewise));
3149            }
3150            ed.buffer_mut()
3151                .set_cursor(hjkl_buffer::Position::new(top.0, top.1));
3152            ed.push_buffer_cursor_to_textarea();
3153        }
3154        Operator::Delete => {
3155            ed.push_undo();
3156            cut_vim_range(ed, top, bot, kind);
3157            ed.vim.mode = Mode::Normal;
3158        }
3159        Operator::Change => {
3160            ed.push_undo();
3161            cut_vim_range(ed, top, bot, kind);
3162            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3163        }
3164        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3165            apply_case_op_to_selection(ed, op, top, bot, kind);
3166        }
3167        Operator::Indent | Operator::Outdent => {
3168            // Indent / outdent are always linewise even when triggered
3169            // by a char-wise motion (e.g. `>w` indents the whole line).
3170            ed.push_undo();
3171            if op == Operator::Indent {
3172                indent_rows(ed, top.0, bot.0, 1);
3173            } else {
3174                outdent_rows(ed, top.0, bot.0, 1);
3175            }
3176            ed.vim.mode = Mode::Normal;
3177        }
3178        Operator::Fold => {
3179            // Always linewise — fold the spanned rows regardless of the
3180            // motion's natural kind. Cursor lands on `top.0` to mirror
3181            // the visual `zf` path.
3182            if bot.0 >= top.0 {
3183                ed.buffer_mut().add_fold(top.0, bot.0, true);
3184            }
3185            ed.buffer_mut()
3186                .set_cursor(hjkl_buffer::Position::new(top.0, top.1));
3187            ed.push_buffer_cursor_to_textarea();
3188            ed.vim.mode = Mode::Normal;
3189        }
3190        Operator::Reflow => {
3191            ed.push_undo();
3192            reflow_rows(ed, top.0, bot.0);
3193            ed.vim.mode = Mode::Normal;
3194        }
3195    }
3196}
3197
3198/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
3199/// Splits on blank-line boundaries so paragraph structure is
3200/// preserved. Each paragraph's words are joined with single spaces
3201/// before re-wrapping.
3202fn reflow_rows(ed: &mut Editor<'_>, top: usize, bot: usize) {
3203    let width = ed.settings().textwidth.max(1);
3204    let mut lines: Vec<String> = ed.buffer().lines().to_vec();
3205    let bot = bot.min(lines.len().saturating_sub(1));
3206    if top > bot {
3207        return;
3208    }
3209    let original = lines[top..=bot].to_vec();
3210    let mut wrapped: Vec<String> = Vec::new();
3211    let mut paragraph: Vec<String> = Vec::new();
3212    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
3213        if para.is_empty() {
3214            return;
3215        }
3216        let words = para.join(" ");
3217        let mut current = String::new();
3218        for word in words.split_whitespace() {
3219            let extra = if current.is_empty() {
3220                word.chars().count()
3221            } else {
3222                current.chars().count() + 1 + word.chars().count()
3223            };
3224            if extra > width && !current.is_empty() {
3225                out.push(std::mem::take(&mut current));
3226                current.push_str(word);
3227            } else if current.is_empty() {
3228                current.push_str(word);
3229            } else {
3230                current.push(' ');
3231                current.push_str(word);
3232            }
3233        }
3234        if !current.is_empty() {
3235            out.push(current);
3236        }
3237        para.clear();
3238    };
3239    for line in &original {
3240        if line.trim().is_empty() {
3241            flush(&mut paragraph, &mut wrapped, width);
3242            wrapped.push(String::new());
3243        } else {
3244            paragraph.push(line.clone());
3245        }
3246    }
3247    flush(&mut paragraph, &mut wrapped, width);
3248
3249    // Splice back. push_undo above means `u` reverses.
3250    let after: Vec<String> = lines.split_off(bot + 1);
3251    lines.truncate(top);
3252    lines.extend(wrapped);
3253    lines.extend(after);
3254    ed.restore(lines, (top, 0));
3255    ed.mark_content_dirty();
3256}
3257
3258/// Transform the range `[top, bot]` (vim `MotionKind`) in place with
3259/// the given case operator. Cursor lands on `top` afterward — vim
3260/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
3261/// Preserves the textarea yank buffer (vim's case operators don't
3262/// touch registers).
3263fn apply_case_op_to_selection(
3264    ed: &mut Editor<'_>,
3265    op: Operator,
3266    top: (usize, usize),
3267    bot: (usize, usize),
3268    kind: MotionKind,
3269) {
3270    use hjkl_buffer::{Edit, Position};
3271    ed.push_undo();
3272    let saved_yank = ed.yank().to_string();
3273    let saved_yank_linewise = ed.vim.yank_linewise;
3274    let selection = cut_vim_range(ed, top, bot, kind);
3275    let transformed = match op {
3276        Operator::Uppercase => selection.to_uppercase(),
3277        Operator::Lowercase => selection.to_lowercase(),
3278        Operator::ToggleCase => toggle_case_str(&selection),
3279        _ => unreachable!(),
3280    };
3281    if !transformed.is_empty() {
3282        let cursor = ed.buffer().cursor();
3283        ed.mutate_edit(Edit::InsertStr {
3284            at: cursor,
3285            text: transformed,
3286        });
3287    }
3288    ed.buffer_mut().set_cursor(Position::new(top.0, top.1));
3289    ed.push_buffer_cursor_to_textarea();
3290    ed.set_yank(saved_yank);
3291    ed.vim.yank_linewise = saved_yank_linewise;
3292    ed.vim.mode = Mode::Normal;
3293}
3294
3295/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
3296/// Rows that are empty are skipped (vim leaves blank lines alone when
3297/// indenting). `shiftwidth` is read from `editor.settings()` so
3298/// `:set shiftwidth=N` takes effect on the next operation.
3299fn indent_rows(ed: &mut Editor<'_>, top: usize, bot: usize, count: usize) {
3300    ed.sync_buffer_content_from_textarea();
3301    let width = ed.settings().shiftwidth * count.max(1);
3302    let pad: String = " ".repeat(width);
3303    let mut lines: Vec<String> = ed.buffer().lines().to_vec();
3304    let bot = bot.min(lines.len().saturating_sub(1));
3305    for line in lines.iter_mut().take(bot + 1).skip(top) {
3306        if !line.is_empty() {
3307            line.insert_str(0, &pad);
3308        }
3309    }
3310    // Restore cursor to first non-blank of the top row so the next
3311    // vertical motion aims sensibly — matches vim's `>>` convention.
3312    ed.restore(lines, (top, 0));
3313    move_first_non_whitespace(ed);
3314}
3315
3316/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
3317/// each row in `[top, bot]`. Rows with less leading whitespace have
3318/// all their indent stripped, not clipped to zero length.
3319fn outdent_rows(ed: &mut Editor<'_>, top: usize, bot: usize, count: usize) {
3320    ed.sync_buffer_content_from_textarea();
3321    let width = ed.settings().shiftwidth * count.max(1);
3322    let mut lines: Vec<String> = ed.buffer().lines().to_vec();
3323    let bot = bot.min(lines.len().saturating_sub(1));
3324    for line in lines.iter_mut().take(bot + 1).skip(top) {
3325        let strip: usize = line
3326            .chars()
3327            .take(width)
3328            .take_while(|c| *c == ' ' || *c == '\t')
3329            .count();
3330        if strip > 0 {
3331            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
3332            line.drain(..byte_len);
3333        }
3334    }
3335    ed.restore(lines, (top, 0));
3336    move_first_non_whitespace(ed);
3337}
3338
3339fn toggle_case_str(s: &str) -> String {
3340    s.chars()
3341        .map(|c| {
3342            if c.is_lowercase() {
3343                c.to_uppercase().next().unwrap_or(c)
3344            } else if c.is_uppercase() {
3345                c.to_lowercase().next().unwrap_or(c)
3346            } else {
3347                c
3348            }
3349        })
3350        .collect()
3351}
3352
3353fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
3354    if a <= b { (a, b) } else { (b, a) }
3355}
3356
3357// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
3358
3359fn execute_line_op(ed: &mut Editor<'_>, op: Operator, count: usize) {
3360    let (row, col) = ed.cursor();
3361    let total = ed.buffer().lines().len();
3362    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
3363
3364    match op {
3365        Operator::Yank => {
3366            // yy must not move the cursor.
3367            let text = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3368            if !text.is_empty() {
3369                ed.last_yank = Some(text.clone());
3370                ed.record_yank(text, true);
3371            }
3372            ed.buffer_mut()
3373                .set_cursor(hjkl_buffer::Position::new(row, col));
3374            ed.push_buffer_cursor_to_textarea();
3375            ed.vim.mode = Mode::Normal;
3376        }
3377        Operator::Delete => {
3378            ed.push_undo();
3379            let deleted_through_last = end_row + 1 >= total;
3380            cut_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3381            // Vim's `dd` / `Ndd` leaves the cursor on the *first
3382            // non-blank* of the line that now occupies `row` — or, if
3383            // the deletion consumed the last line, the line above it.
3384            let total_after = ed.buffer().row_count();
3385            let target_row = if deleted_through_last {
3386                row.saturating_sub(1).min(total_after.saturating_sub(1))
3387            } else {
3388                row.min(total_after.saturating_sub(1))
3389            };
3390            ed.buffer_mut()
3391                .set_cursor(hjkl_buffer::Position::new(target_row, 0));
3392            ed.push_buffer_cursor_to_textarea();
3393            move_first_non_whitespace(ed);
3394            ed.vim.mode = Mode::Normal;
3395        }
3396        Operator::Change => {
3397            // `cc` / `3cc`: wipe contents of the covered lines but leave
3398            // a single blank line so insert-mode opens on it. Done as two
3399            // edits: drop rows past the first, then clear row `row`.
3400            use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
3401            ed.push_undo();
3402            ed.sync_buffer_content_from_textarea();
3403            // Read the cut payload first so yank reflects every line.
3404            let payload = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3405            if end_row > row {
3406                ed.mutate_edit(Edit::DeleteRange {
3407                    start: Position::new(row + 1, 0),
3408                    end: Position::new(end_row, 0),
3409                    kind: BufKind::Line,
3410                });
3411            }
3412            let line_chars = ed
3413                .buffer()
3414                .line(row)
3415                .map(|l| l.chars().count())
3416                .unwrap_or(0);
3417            if line_chars > 0 {
3418                ed.mutate_edit(Edit::DeleteRange {
3419                    start: Position::new(row, 0),
3420                    end: Position::new(row, line_chars),
3421                    kind: BufKind::Char,
3422                });
3423            }
3424            if !payload.is_empty() {
3425                ed.last_yank = Some(payload.clone());
3426                ed.record_delete(payload, true);
3427            }
3428            ed.buffer_mut().set_cursor(Position::new(row, 0));
3429            ed.push_buffer_cursor_to_textarea();
3430            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3431        }
3432        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3433            // `gUU` / `guu` / `g~~` — linewise case transform over
3434            // [row, end_row]. Preserve cursor on `row` (first non-blank
3435            // lines up with vim's behaviour).
3436            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), MotionKind::Linewise);
3437            // After case-op on a linewise range vim puts the cursor on
3438            // the first non-blank of the starting line.
3439            move_first_non_whitespace(ed);
3440        }
3441        Operator::Indent | Operator::Outdent => {
3442            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
3443            ed.push_undo();
3444            if op == Operator::Indent {
3445                indent_rows(ed, row, end_row, 1);
3446            } else {
3447                outdent_rows(ed, row, end_row, 1);
3448            }
3449            ed.vim.mode = Mode::Normal;
3450        }
3451        // No doubled form — `zfzf` is two consecutive `zf` chords.
3452        Operator::Fold => unreachable!("Fold has no line-op double"),
3453        Operator::Reflow => {
3454            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
3455            ed.push_undo();
3456            reflow_rows(ed, row, end_row);
3457            ed.vim.mode = Mode::Normal;
3458        }
3459    }
3460}
3461
3462// ─── Visual mode operators ─────────────────────────────────────────────────
3463
3464fn apply_visual_operator(ed: &mut Editor<'_>, op: Operator) {
3465    match ed.vim.mode {
3466        Mode::VisualLine => {
3467            let cursor_row = ed.buffer().cursor().row;
3468            let top = cursor_row.min(ed.vim.visual_line_anchor);
3469            let bot = cursor_row.max(ed.vim.visual_line_anchor);
3470            ed.vim.yank_linewise = true;
3471            match op {
3472                Operator::Yank => {
3473                    let text = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
3474                    if !text.is_empty() {
3475                        ed.last_yank = Some(text.clone());
3476                        ed.record_yank(text, true);
3477                    }
3478                    ed.buffer_mut()
3479                        .set_cursor(hjkl_buffer::Position::new(top, 0));
3480                    ed.push_buffer_cursor_to_textarea();
3481                    ed.vim.mode = Mode::Normal;
3482                }
3483                Operator::Delete => {
3484                    ed.push_undo();
3485                    cut_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
3486                    ed.vim.mode = Mode::Normal;
3487                }
3488                Operator::Change => {
3489                    // Vim `Vc`: wipe the line contents but leave a blank
3490                    // line in place so insert-mode starts on an empty row.
3491                    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
3492                    ed.push_undo();
3493                    ed.sync_buffer_content_from_textarea();
3494                    let payload = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
3495                    if bot > top {
3496                        ed.mutate_edit(Edit::DeleteRange {
3497                            start: Position::new(top + 1, 0),
3498                            end: Position::new(bot, 0),
3499                            kind: BufKind::Line,
3500                        });
3501                    }
3502                    let line_chars = ed
3503                        .buffer()
3504                        .line(top)
3505                        .map(|l| l.chars().count())
3506                        .unwrap_or(0);
3507                    if line_chars > 0 {
3508                        ed.mutate_edit(Edit::DeleteRange {
3509                            start: Position::new(top, 0),
3510                            end: Position::new(top, line_chars),
3511                            kind: BufKind::Char,
3512                        });
3513                    }
3514                    if !payload.is_empty() {
3515                        ed.last_yank = Some(payload.clone());
3516                        ed.record_delete(payload, true);
3517                    }
3518                    ed.buffer_mut().set_cursor(Position::new(top, 0));
3519                    ed.push_buffer_cursor_to_textarea();
3520                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3521                }
3522                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3523                    let bot = ed.buffer().cursor().row.max(ed.vim.visual_line_anchor);
3524                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), MotionKind::Linewise);
3525                    move_first_non_whitespace(ed);
3526                }
3527                Operator::Indent | Operator::Outdent => {
3528                    ed.push_undo();
3529                    let (cursor_row, _) = ed.cursor();
3530                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
3531                    if op == Operator::Indent {
3532                        indent_rows(ed, top, bot, 1);
3533                    } else {
3534                        outdent_rows(ed, top, bot, 1);
3535                    }
3536                    ed.vim.mode = Mode::Normal;
3537                }
3538                Operator::Reflow => {
3539                    ed.push_undo();
3540                    let (cursor_row, _) = ed.cursor();
3541                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
3542                    reflow_rows(ed, top, bot);
3543                    ed.vim.mode = Mode::Normal;
3544                }
3545                // Visual `zf` is handled inline in `handle_after_z`,
3546                // never routed through this dispatcher.
3547                Operator::Fold => unreachable!("Visual zf takes its own path"),
3548            }
3549        }
3550        Mode::Visual => {
3551            ed.vim.yank_linewise = false;
3552            let anchor = ed.vim.visual_anchor;
3553            let cursor = ed.cursor();
3554            let (top, bot) = order(anchor, cursor);
3555            match op {
3556                Operator::Yank => {
3557                    let text = read_vim_range(ed, top, bot, MotionKind::Inclusive);
3558                    if !text.is_empty() {
3559                        ed.last_yank = Some(text.clone());
3560                        ed.record_yank(text, false);
3561                    }
3562                    ed.buffer_mut()
3563                        .set_cursor(hjkl_buffer::Position::new(top.0, top.1));
3564                    ed.push_buffer_cursor_to_textarea();
3565                    ed.vim.mode = Mode::Normal;
3566                }
3567                Operator::Delete => {
3568                    ed.push_undo();
3569                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
3570                    ed.vim.mode = Mode::Normal;
3571                }
3572                Operator::Change => {
3573                    ed.push_undo();
3574                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
3575                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3576                }
3577                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3578                    // Anchor stays where the visual selection started.
3579                    let anchor = ed.vim.visual_anchor;
3580                    let cursor = ed.cursor();
3581                    let (top, bot) = order(anchor, cursor);
3582                    apply_case_op_to_selection(ed, op, top, bot, MotionKind::Inclusive);
3583                }
3584                Operator::Indent | Operator::Outdent => {
3585                    ed.push_undo();
3586                    let anchor = ed.vim.visual_anchor;
3587                    let cursor = ed.cursor();
3588                    let (top, bot) = order(anchor, cursor);
3589                    if op == Operator::Indent {
3590                        indent_rows(ed, top.0, bot.0, 1);
3591                    } else {
3592                        outdent_rows(ed, top.0, bot.0, 1);
3593                    }
3594                    ed.vim.mode = Mode::Normal;
3595                }
3596                Operator::Reflow => {
3597                    ed.push_undo();
3598                    let anchor = ed.vim.visual_anchor;
3599                    let cursor = ed.cursor();
3600                    let (top, bot) = order(anchor, cursor);
3601                    reflow_rows(ed, top.0, bot.0);
3602                    ed.vim.mode = Mode::Normal;
3603                }
3604                Operator::Fold => unreachable!("Visual zf takes its own path"),
3605            }
3606        }
3607        Mode::VisualBlock => apply_block_operator(ed, op),
3608        _ => {}
3609    }
3610}
3611
3612/// Compute `(top_row, bot_row, left_col, right_col)` for the current
3613/// VisualBlock selection. Columns are inclusive on both ends. Uses the
3614/// tracked virtual column (updated by h/l, preserved across j/k) so
3615/// ragged / empty rows don't collapse the block's width.
3616fn block_bounds(ed: &Editor<'_>) -> (usize, usize, usize, usize) {
3617    let (ar, ac) = ed.vim.block_anchor;
3618    let (cr, _) = ed.cursor();
3619    let cc = ed.vim.block_vcol;
3620    let top = ar.min(cr);
3621    let bot = ar.max(cr);
3622    let left = ac.min(cc);
3623    let right = ac.max(cc);
3624    (top, bot, left, right)
3625}
3626
3627/// Update the virtual column after a motion in VisualBlock mode.
3628/// Horizontal motions sync `block_vcol` to the new cursor column;
3629/// vertical / non-h/l motions leave it alone so the intended column
3630/// survives clamping to shorter lines.
3631fn update_block_vcol(ed: &mut Editor<'_>, motion: &Motion) {
3632    match motion {
3633        Motion::Left
3634        | Motion::Right
3635        | Motion::WordFwd
3636        | Motion::BigWordFwd
3637        | Motion::WordBack
3638        | Motion::BigWordBack
3639        | Motion::WordEnd
3640        | Motion::BigWordEnd
3641        | Motion::WordEndBack
3642        | Motion::BigWordEndBack
3643        | Motion::LineStart
3644        | Motion::FirstNonBlank
3645        | Motion::LineEnd
3646        | Motion::Find { .. }
3647        | Motion::FindRepeat { .. }
3648        | Motion::MatchBracket => {
3649            ed.vim.block_vcol = ed.cursor().1;
3650        }
3651        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
3652        _ => {}
3653    }
3654}
3655
3656/// Yank / delete / change / replace a rectangular selection. Yanked text
3657/// is stored as one string per row joined with `\n` so pasting reproduces
3658/// the block as sequential lines. (Vim's true block-paste reinserts as
3659/// columns; we render the content with our char-wise paste path.)
3660fn apply_block_operator(ed: &mut Editor<'_>, op: Operator) {
3661    let (top, bot, left, right) = block_bounds(ed);
3662    // Snapshot the block text for yank / clipboard.
3663    let yank = block_yank(ed, top, bot, left, right);
3664
3665    match op {
3666        Operator::Yank => {
3667            if !yank.is_empty() {
3668                ed.last_yank = Some(yank.clone());
3669                ed.record_yank(yank, false);
3670            }
3671            ed.vim.mode = Mode::Normal;
3672            ed.jump_cursor(top, left);
3673        }
3674        Operator::Delete => {
3675            ed.push_undo();
3676            delete_block_contents(ed, top, bot, left, right);
3677            if !yank.is_empty() {
3678                ed.last_yank = Some(yank.clone());
3679                ed.record_delete(yank, false);
3680            }
3681            ed.vim.mode = Mode::Normal;
3682            ed.jump_cursor(top, left);
3683        }
3684        Operator::Change => {
3685            ed.push_undo();
3686            delete_block_contents(ed, top, bot, left, right);
3687            if !yank.is_empty() {
3688                ed.last_yank = Some(yank.clone());
3689                ed.record_delete(yank, false);
3690            }
3691            ed.jump_cursor(top, left);
3692            begin_insert_noundo(
3693                ed,
3694                1,
3695                InsertReason::BlockEdge {
3696                    top,
3697                    bot,
3698                    col: left,
3699                },
3700            );
3701        }
3702        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3703            ed.push_undo();
3704            transform_block_case(ed, op, top, bot, left, right);
3705            ed.vim.mode = Mode::Normal;
3706            ed.jump_cursor(top, left);
3707        }
3708        Operator::Indent | Operator::Outdent => {
3709            // VisualBlock `>` / `<` falls back to linewise indent over
3710            // the block's row range — vim does the same (column-wise
3711            // indent/outdent doesn't make sense).
3712            ed.push_undo();
3713            if op == Operator::Indent {
3714                indent_rows(ed, top, bot, 1);
3715            } else {
3716                outdent_rows(ed, top, bot, 1);
3717            }
3718            ed.vim.mode = Mode::Normal;
3719        }
3720        Operator::Fold => unreachable!("Visual zf takes its own path"),
3721        Operator::Reflow => {
3722            // Reflow over the block falls back to linewise reflow over
3723            // the row range — column slicing for `gq` doesn't make
3724            // sense.
3725            ed.push_undo();
3726            reflow_rows(ed, top, bot);
3727            ed.vim.mode = Mode::Normal;
3728        }
3729    }
3730}
3731
3732/// In-place case transform over the rectangular block
3733/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
3734/// untouched — vim behaves the same way (ragged blocks).
3735fn transform_block_case(
3736    ed: &mut Editor<'_>,
3737    op: Operator,
3738    top: usize,
3739    bot: usize,
3740    left: usize,
3741    right: usize,
3742) {
3743    let mut lines: Vec<String> = ed.buffer().lines().to_vec();
3744    for r in top..=bot.min(lines.len().saturating_sub(1)) {
3745        let chars: Vec<char> = lines[r].chars().collect();
3746        if left >= chars.len() {
3747            continue;
3748        }
3749        let end = (right + 1).min(chars.len());
3750        let head: String = chars[..left].iter().collect();
3751        let mid: String = chars[left..end].iter().collect();
3752        let tail: String = chars[end..].iter().collect();
3753        let transformed = match op {
3754            Operator::Uppercase => mid.to_uppercase(),
3755            Operator::Lowercase => mid.to_lowercase(),
3756            Operator::ToggleCase => toggle_case_str(&mid),
3757            _ => mid,
3758        };
3759        lines[r] = format!("{head}{transformed}{tail}");
3760    }
3761    let saved_yank = ed.yank().to_string();
3762    let saved_linewise = ed.vim.yank_linewise;
3763    ed.restore(lines, (top, left));
3764    ed.set_yank(saved_yank);
3765    ed.vim.yank_linewise = saved_linewise;
3766}
3767
3768fn block_yank(ed: &Editor<'_>, top: usize, bot: usize, left: usize, right: usize) -> String {
3769    let lines = ed.buffer().lines();
3770    let mut rows: Vec<String> = Vec::new();
3771    for r in top..=bot {
3772        let line = match lines.get(r) {
3773            Some(l) => l,
3774            None => break,
3775        };
3776        let chars: Vec<char> = line.chars().collect();
3777        let end = (right + 1).min(chars.len());
3778        if left >= chars.len() {
3779            rows.push(String::new());
3780        } else {
3781            rows.push(chars[left..end].iter().collect());
3782        }
3783    }
3784    rows.join("\n")
3785}
3786
3787fn delete_block_contents(ed: &mut Editor<'_>, top: usize, bot: usize, left: usize, right: usize) {
3788    use hjkl_buffer::{Edit, MotionKind, Position};
3789    ed.sync_buffer_content_from_textarea();
3790    let last_row = bot.min(ed.buffer().row_count().saturating_sub(1));
3791    if last_row < top {
3792        return;
3793    }
3794    ed.mutate_edit(Edit::DeleteRange {
3795        start: Position::new(top, left),
3796        end: Position::new(last_row, right),
3797        kind: MotionKind::Block,
3798    });
3799    ed.push_buffer_cursor_to_textarea();
3800}
3801
3802/// Replace each character cell in the block with `ch`.
3803fn block_replace(ed: &mut Editor<'_>, ch: char) {
3804    let (top, bot, left, right) = block_bounds(ed);
3805    ed.push_undo();
3806    ed.sync_buffer_content_from_textarea();
3807    let mut lines: Vec<String> = ed.buffer().lines().to_vec();
3808    for r in top..=bot.min(lines.len().saturating_sub(1)) {
3809        let chars: Vec<char> = lines[r].chars().collect();
3810        if left >= chars.len() {
3811            continue;
3812        }
3813        let end = (right + 1).min(chars.len());
3814        let before: String = chars[..left].iter().collect();
3815        let middle: String = std::iter::repeat_n(ch, end - left).collect();
3816        let after: String = chars[end..].iter().collect();
3817        lines[r] = format!("{before}{middle}{after}");
3818    }
3819    reset_textarea_lines(ed, lines);
3820    ed.vim.mode = Mode::Normal;
3821    ed.jump_cursor(top, left);
3822}
3823
3824/// Replace buffer content with `lines` while preserving the cursor.
3825/// Used by indent / outdent / block_replace to wholesale rewrite
3826/// rows without going through the per-edit funnel.
3827fn reset_textarea_lines(ed: &mut Editor<'_>, lines: Vec<String>) {
3828    let cursor = ed.cursor();
3829    ed.buffer_mut().replace_all(&lines.join("\n"));
3830    ed.buffer_mut()
3831        .set_cursor(hjkl_buffer::Position::new(cursor.0, cursor.1));
3832    ed.mark_content_dirty();
3833}
3834
3835// ─── Visual-line helpers ───────────────────────────────────────────────────
3836
3837// ─── Text-object range computation ─────────────────────────────────────────
3838
3839/// Cursor position as `(row, col)`.
3840type Pos = (usize, usize);
3841
3842/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
3843/// last character to act on). `kind` is `Linewise` for line-oriented text
3844/// objects like paragraphs and `Exclusive` otherwise.
3845fn text_object_range(
3846    ed: &Editor<'_>,
3847    obj: TextObject,
3848    inner: bool,
3849) -> Option<(Pos, Pos, MotionKind)> {
3850    match obj {
3851        TextObject::Word { big } => {
3852            word_text_object(ed, inner, big).map(|(s, e)| (s, e, MotionKind::Exclusive))
3853        }
3854        TextObject::Quote(q) => {
3855            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
3856        }
3857        TextObject::Bracket(open) => {
3858            bracket_text_object(ed, open, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
3859        }
3860        TextObject::Paragraph => {
3861            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Linewise))
3862        }
3863        TextObject::XmlTag => {
3864            tag_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
3865        }
3866        TextObject::Sentence => {
3867            sentence_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
3868        }
3869    }
3870}
3871
3872/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
3873/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
3874/// `None` when already at the buffer's edge in that direction.
3875fn sentence_boundary(ed: &Editor<'_>, forward: bool) -> Option<(usize, usize)> {
3876    let lines = ed.buffer().lines();
3877    if lines.is_empty() {
3878        return None;
3879    }
3880    let pos_to_idx = |pos: (usize, usize)| -> usize {
3881        let mut idx = 0;
3882        for line in lines.iter().take(pos.0) {
3883            idx += line.chars().count() + 1;
3884        }
3885        idx + pos.1
3886    };
3887    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
3888        for (r, line) in lines.iter().enumerate() {
3889            let len = line.chars().count();
3890            if idx <= len {
3891                return (r, idx);
3892            }
3893            idx -= len + 1;
3894        }
3895        let last = lines.len().saturating_sub(1);
3896        (last, lines[last].chars().count())
3897    };
3898    let mut chars: Vec<char> = Vec::new();
3899    for (r, line) in lines.iter().enumerate() {
3900        chars.extend(line.chars());
3901        if r + 1 < lines.len() {
3902            chars.push('\n');
3903        }
3904    }
3905    if chars.is_empty() {
3906        return None;
3907    }
3908    let total = chars.len();
3909    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
3910    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
3911
3912    if forward {
3913        // Walk forward looking for a terminator run followed by
3914        // whitespace; land on the first non-whitespace cell after.
3915        let mut i = cursor_idx + 1;
3916        while i < total {
3917            if is_terminator(chars[i]) {
3918                while i + 1 < total && is_terminator(chars[i + 1]) {
3919                    i += 1;
3920                }
3921                if i + 1 >= total {
3922                    return None;
3923                }
3924                if chars[i + 1].is_whitespace() {
3925                    let mut j = i + 1;
3926                    while j < total && chars[j].is_whitespace() {
3927                        j += 1;
3928                    }
3929                    if j >= total {
3930                        return None;
3931                    }
3932                    return Some(idx_to_pos(j));
3933                }
3934            }
3935            i += 1;
3936        }
3937        None
3938    } else {
3939        // Walk backward to find the start of the current sentence (if
3940        // we're already at the start, jump to the previous sentence's
3941        // start instead).
3942        let find_start = |from: usize| -> Option<usize> {
3943            let mut start = from;
3944            while start > 0 {
3945                let prev = chars[start - 1];
3946                if prev.is_whitespace() {
3947                    let mut k = start - 1;
3948                    while k > 0 && chars[k - 1].is_whitespace() {
3949                        k -= 1;
3950                    }
3951                    if k > 0 && is_terminator(chars[k - 1]) {
3952                        break;
3953                    }
3954                }
3955                start -= 1;
3956            }
3957            while start < total && chars[start].is_whitespace() {
3958                start += 1;
3959            }
3960            (start < total).then_some(start)
3961        };
3962        let current_start = find_start(cursor_idx)?;
3963        if current_start < cursor_idx {
3964            return Some(idx_to_pos(current_start));
3965        }
3966        // Already at the sentence start — step over the boundary into
3967        // the previous sentence and find its start.
3968        let mut k = current_start;
3969        while k > 0 && chars[k - 1].is_whitespace() {
3970            k -= 1;
3971        }
3972        if k == 0 {
3973            return None;
3974        }
3975        let prev_start = find_start(k - 1)?;
3976        Some(idx_to_pos(prev_start))
3977    }
3978}
3979
3980/// `is` / `as` — sentence: text up to and including the next sentence
3981/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
3982/// whitespace (or end-of-line) as a boundary; runs of consecutive
3983/// terminators stay attached to the same sentence. `as` extends to
3984/// include trailing whitespace; `is` does not.
3985fn sentence_text_object(ed: &Editor<'_>, inner: bool) -> Option<((usize, usize), (usize, usize))> {
3986    let lines = ed.buffer().lines();
3987    if lines.is_empty() {
3988        return None;
3989    }
3990    // Flatten the buffer so a sentence can span lines (vim's behaviour).
3991    // Newlines count as whitespace for boundary detection.
3992    let pos_to_idx = |pos: (usize, usize)| -> usize {
3993        let mut idx = 0;
3994        for line in lines.iter().take(pos.0) {
3995            idx += line.chars().count() + 1;
3996        }
3997        idx + pos.1
3998    };
3999    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4000        for (r, line) in lines.iter().enumerate() {
4001            let len = line.chars().count();
4002            if idx <= len {
4003                return (r, idx);
4004            }
4005            idx -= len + 1;
4006        }
4007        let last = lines.len().saturating_sub(1);
4008        (last, lines[last].chars().count())
4009    };
4010    let mut chars: Vec<char> = Vec::new();
4011    for (r, line) in lines.iter().enumerate() {
4012        chars.extend(line.chars());
4013        if r + 1 < lines.len() {
4014            chars.push('\n');
4015        }
4016    }
4017    if chars.is_empty() {
4018        return None;
4019    }
4020
4021    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
4022    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4023
4024    // Walk backward from cursor to find the start of the current
4025    // sentence. A boundary is: whitespace immediately after a run of
4026    // terminators (or start-of-buffer).
4027    let mut start = cursor_idx;
4028    while start > 0 {
4029        let prev = chars[start - 1];
4030        if prev.is_whitespace() {
4031            // Check if the whitespace follows a terminator — if so,
4032            // we've crossed a sentence boundary; the sentence begins
4033            // at the first non-whitespace cell *after* this run.
4034            let mut k = start - 1;
4035            while k > 0 && chars[k - 1].is_whitespace() {
4036                k -= 1;
4037            }
4038            if k > 0 && is_terminator(chars[k - 1]) {
4039                break;
4040            }
4041        }
4042        start -= 1;
4043    }
4044    // Skip leading whitespace (vim doesn't include it in the
4045    // sentence body).
4046    while start < chars.len() && chars[start].is_whitespace() {
4047        start += 1;
4048    }
4049    if start >= chars.len() {
4050        return None;
4051    }
4052
4053    // Walk forward to the sentence end (last terminator before the
4054    // next whitespace boundary).
4055    let mut end = start;
4056    while end < chars.len() {
4057        if is_terminator(chars[end]) {
4058            // Consume any consecutive terminators (e.g. `?!`).
4059            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
4060                end += 1;
4061            }
4062            // If followed by whitespace or end-of-buffer, that's the
4063            // boundary.
4064            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
4065                break;
4066            }
4067        }
4068        end += 1;
4069    }
4070    // Inclusive end → exclusive end_idx.
4071    let end_idx = (end + 1).min(chars.len());
4072
4073    let final_end = if inner {
4074        end_idx
4075    } else {
4076        // `as`: include trailing whitespace (but stop before the next
4077        // newline so we don't gobble a paragraph break — vim keeps
4078        // sentences within a paragraph for the trailing-ws extension).
4079        let mut e = end_idx;
4080        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
4081            e += 1;
4082        }
4083        e
4084    };
4085
4086    Some((idx_to_pos(start), idx_to_pos(final_end)))
4087}
4088
4089/// `it` / `at` — XML tag pair text object. Builds a flat char index of
4090/// the buffer, walks `<...>` tokens to pair tags via a stack, and
4091/// returns the innermost pair containing the cursor.
4092fn tag_text_object(ed: &Editor<'_>, inner: bool) -> Option<((usize, usize), (usize, usize))> {
4093    let lines = ed.buffer().lines();
4094    if lines.is_empty() {
4095        return None;
4096    }
4097    // Flatten char positions so we can compare cursor against tag
4098    // ranges without per-row arithmetic. `\n` between lines counts as
4099    // a single char.
4100    let pos_to_idx = |pos: (usize, usize)| -> usize {
4101        let mut idx = 0;
4102        for line in lines.iter().take(pos.0) {
4103            idx += line.chars().count() + 1;
4104        }
4105        idx + pos.1
4106    };
4107    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4108        for (r, line) in lines.iter().enumerate() {
4109            let len = line.chars().count();
4110            if idx <= len {
4111                return (r, idx);
4112            }
4113            idx -= len + 1;
4114        }
4115        let last = lines.len().saturating_sub(1);
4116        (last, lines[last].chars().count())
4117    };
4118    let mut chars: Vec<char> = Vec::new();
4119    for (r, line) in lines.iter().enumerate() {
4120        chars.extend(line.chars());
4121        if r + 1 < lines.len() {
4122            chars.push('\n');
4123        }
4124    }
4125    let cursor_idx = pos_to_idx(ed.cursor());
4126
4127    // Walk `<...>` tokens. Track open tags on a stack; on a matching
4128    // close pop and consider the pair a candidate when the cursor lies
4129    // inside its content range. Innermost wins (replace whenever a
4130    // tighter range turns up).
4131    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
4132    let mut innermost: Option<(usize, usize, usize, usize)> = None;
4133    let mut i = 0;
4134    while i < chars.len() {
4135        if chars[i] != '<' {
4136            i += 1;
4137            continue;
4138        }
4139        let mut j = i + 1;
4140        while j < chars.len() && chars[j] != '>' {
4141            j += 1;
4142        }
4143        if j >= chars.len() {
4144            break;
4145        }
4146        let inside: String = chars[i + 1..j].iter().collect();
4147        let close_end = j + 1;
4148        let trimmed = inside.trim();
4149        if trimmed.starts_with('!') || trimmed.starts_with('?') {
4150            i = close_end;
4151            continue;
4152        }
4153        if let Some(rest) = trimmed.strip_prefix('/') {
4154            let name = rest.split_whitespace().next().unwrap_or("").to_string();
4155            if !name.is_empty()
4156                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
4157            {
4158                let (open_start, content_start, _) = stack[stack_idx].clone();
4159                stack.truncate(stack_idx);
4160                let content_end = i;
4161                if cursor_idx >= content_start && cursor_idx <= content_end {
4162                    let candidate = (open_start, content_start, content_end, close_end);
4163                    innermost = match innermost {
4164                        Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
4165                            Some(candidate)
4166                        }
4167                        None => Some(candidate),
4168                        existing => existing,
4169                    };
4170                }
4171            }
4172        } else if !trimmed.ends_with('/') {
4173            let name: String = trimmed
4174                .split(|c: char| c.is_whitespace() || c == '/')
4175                .next()
4176                .unwrap_or("")
4177                .to_string();
4178            if !name.is_empty() {
4179                stack.push((i, close_end, name));
4180            }
4181        }
4182        i = close_end;
4183    }
4184
4185    let (open_start, content_start, content_end, close_end) = innermost?;
4186    if inner {
4187        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
4188    } else {
4189        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
4190    }
4191}
4192
4193fn is_wordchar(c: char) -> bool {
4194    c.is_alphanumeric() || c == '_'
4195}
4196
4197fn word_text_object(
4198    ed: &Editor<'_>,
4199    inner: bool,
4200    big: bool,
4201) -> Option<((usize, usize), (usize, usize))> {
4202    let (row, col) = ed.cursor();
4203    let line = ed.buffer().lines().get(row)?;
4204    let chars: Vec<char> = line.chars().collect();
4205    if chars.is_empty() {
4206        return None;
4207    }
4208    let at = col.min(chars.len().saturating_sub(1));
4209    let classify = |c: char| -> u8 {
4210        if c.is_whitespace() {
4211            0
4212        } else if big || is_wordchar(c) {
4213            1
4214        } else {
4215            2
4216        }
4217    };
4218    let cls = classify(chars[at]);
4219    let mut start = at;
4220    while start > 0 && classify(chars[start - 1]) == cls {
4221        start -= 1;
4222    }
4223    let mut end = at;
4224    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
4225        end += 1;
4226    }
4227    // Byte-offset helpers.
4228    let char_byte = |i: usize| {
4229        if i >= chars.len() {
4230            line.len()
4231        } else {
4232            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
4233        }
4234    };
4235    let mut start_col = char_byte(start);
4236    // Exclusive end: byte index of char AFTER the last-included char.
4237    let mut end_col = char_byte(end + 1);
4238    if !inner {
4239        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
4240        let mut t = end + 1;
4241        let mut included_trailing = false;
4242        while t < chars.len() && chars[t].is_whitespace() {
4243            included_trailing = true;
4244            t += 1;
4245        }
4246        if included_trailing {
4247            end_col = char_byte(t);
4248        } else {
4249            let mut s = start;
4250            while s > 0 && chars[s - 1].is_whitespace() {
4251                s -= 1;
4252            }
4253            start_col = char_byte(s);
4254        }
4255    }
4256    Some(((row, start_col), (row, end_col)))
4257}
4258
4259fn quote_text_object(
4260    ed: &Editor<'_>,
4261    q: char,
4262    inner: bool,
4263) -> Option<((usize, usize), (usize, usize))> {
4264    let (row, col) = ed.cursor();
4265    let line = ed.buffer().lines().get(row)?;
4266    let bytes = line.as_bytes();
4267    let q_byte = q as u8;
4268    // Find opening and closing quote on the same line.
4269    let mut positions: Vec<usize> = Vec::new();
4270    for (i, &b) in bytes.iter().enumerate() {
4271        if b == q_byte {
4272            positions.push(i);
4273        }
4274    }
4275    if positions.len() < 2 {
4276        return None;
4277    }
4278    let mut open_idx: Option<usize> = None;
4279    let mut close_idx: Option<usize> = None;
4280    for pair in positions.chunks(2) {
4281        if pair.len() < 2 {
4282            break;
4283        }
4284        if col >= pair[0] && col <= pair[1] {
4285            open_idx = Some(pair[0]);
4286            close_idx = Some(pair[1]);
4287            break;
4288        }
4289        if col < pair[0] {
4290            open_idx = Some(pair[0]);
4291            close_idx = Some(pair[1]);
4292            break;
4293        }
4294    }
4295    let open = open_idx?;
4296    let close = close_idx?;
4297    // End columns are *exclusive* — one past the last character to act on.
4298    if inner {
4299        if close <= open + 1 {
4300            return None;
4301        }
4302        Some(((row, open + 1), (row, close)))
4303    } else {
4304        Some(((row, open), (row, close + 1)))
4305    }
4306}
4307
4308fn bracket_text_object(
4309    ed: &Editor<'_>,
4310    open: char,
4311    inner: bool,
4312) -> Option<((usize, usize), (usize, usize))> {
4313    let close = match open {
4314        '(' => ')',
4315        '[' => ']',
4316        '{' => '}',
4317        '<' => '>',
4318        _ => return None,
4319    };
4320    let (row, col) = ed.cursor();
4321    let lines = ed.buffer().lines();
4322    // Walk backward from cursor to find unbalanced opening.
4323    let open_pos = find_open_bracket(lines, row, col, open, close)?;
4324    let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
4325    // End positions are *exclusive*.
4326    if inner {
4327        let inner_start = advance_pos(lines, open_pos);
4328        if inner_start.0 > close_pos.0
4329            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
4330        {
4331            return None;
4332        }
4333        Some((inner_start, close_pos))
4334    } else {
4335        Some((open_pos, advance_pos(lines, close_pos)))
4336    }
4337}
4338
4339fn find_open_bracket(
4340    lines: &[String],
4341    row: usize,
4342    col: usize,
4343    open: char,
4344    close: char,
4345) -> Option<(usize, usize)> {
4346    let mut depth: i32 = 0;
4347    let mut r = row;
4348    let mut c = col as isize;
4349    loop {
4350        let cur = &lines[r];
4351        let chars: Vec<char> = cur.chars().collect();
4352        while c >= 0 {
4353            let ch = chars[c as usize];
4354            if ch == close {
4355                depth += 1;
4356            } else if ch == open {
4357                if depth == 0 {
4358                    return Some((r, c as usize));
4359                }
4360                depth -= 1;
4361            }
4362            c -= 1;
4363        }
4364        if r == 0 {
4365            return None;
4366        }
4367        r -= 1;
4368        c = lines[r].chars().count() as isize - 1;
4369    }
4370}
4371
4372fn find_close_bracket(
4373    lines: &[String],
4374    row: usize,
4375    start_col: usize,
4376    open: char,
4377    close: char,
4378) -> Option<(usize, usize)> {
4379    let mut depth: i32 = 0;
4380    let mut r = row;
4381    let mut c = start_col;
4382    loop {
4383        let cur = &lines[r];
4384        let chars: Vec<char> = cur.chars().collect();
4385        while c < chars.len() {
4386            let ch = chars[c];
4387            if ch == open {
4388                depth += 1;
4389            } else if ch == close {
4390                if depth == 0 {
4391                    return Some((r, c));
4392                }
4393                depth -= 1;
4394            }
4395            c += 1;
4396        }
4397        if r + 1 >= lines.len() {
4398            return None;
4399        }
4400        r += 1;
4401        c = 0;
4402    }
4403}
4404
4405fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
4406    let (r, c) = pos;
4407    let line_len = lines[r].chars().count();
4408    if c < line_len {
4409        (r, c + 1)
4410    } else if r + 1 < lines.len() {
4411        (r + 1, 0)
4412    } else {
4413        pos
4414    }
4415}
4416
4417fn paragraph_text_object(ed: &Editor<'_>, inner: bool) -> Option<((usize, usize), (usize, usize))> {
4418    let (row, _) = ed.cursor();
4419    let lines = ed.buffer().lines();
4420    if lines.is_empty() {
4421        return None;
4422    }
4423    // A paragraph is a run of non-blank lines.
4424    let is_blank = |r: usize| lines.get(r).map(|s| s.trim().is_empty()).unwrap_or(true);
4425    if is_blank(row) {
4426        return None;
4427    }
4428    let mut top = row;
4429    while top > 0 && !is_blank(top - 1) {
4430        top -= 1;
4431    }
4432    let mut bot = row;
4433    while bot + 1 < lines.len() && !is_blank(bot + 1) {
4434        bot += 1;
4435    }
4436    // For `ap`, include one trailing blank line if present.
4437    if !inner && bot + 1 < lines.len() && is_blank(bot + 1) {
4438        bot += 1;
4439    }
4440    let end_col = lines[bot].chars().count();
4441    Some(((top, 0), (bot, end_col)))
4442}
4443
4444// ─── Individual commands ───────────────────────────────────────────────────
4445
4446/// Read the text in a vim-shaped range without mutating. Used by
4447/// `Operator::Yank` so we can pipe the same range translation as
4448/// [`cut_vim_range`] but skip the delete + inverse extraction.
4449fn read_vim_range(
4450    ed: &mut Editor<'_>,
4451    start: (usize, usize),
4452    end: (usize, usize),
4453    kind: MotionKind,
4454) -> String {
4455    let (top, bot) = order(start, end);
4456    ed.sync_buffer_content_from_textarea();
4457    let lines = ed.buffer().lines();
4458    match kind {
4459        MotionKind::Linewise => {
4460            let lo = top.0;
4461            let hi = bot.0.min(lines.len().saturating_sub(1));
4462            let mut text = lines[lo..=hi].join("\n");
4463            text.push('\n');
4464            text
4465        }
4466        MotionKind::Inclusive | MotionKind::Exclusive => {
4467            let inclusive = matches!(kind, MotionKind::Inclusive);
4468            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
4469            let mut out = String::new();
4470            for row in top.0..=bot.0 {
4471                let line = lines.get(row).map(String::as_str).unwrap_or("");
4472                let lo = if row == top.0 { top.1 } else { 0 };
4473                let hi_unclamped = if row == bot.0 {
4474                    if inclusive { bot.1 + 1 } else { bot.1 }
4475                } else {
4476                    line.chars().count() + 1
4477                };
4478                let row_chars: Vec<char> = line.chars().collect();
4479                let hi = hi_unclamped.min(row_chars.len());
4480                if lo < hi {
4481                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
4482                }
4483                if row < bot.0 {
4484                    out.push('\n');
4485                }
4486            }
4487            out
4488        }
4489    }
4490}
4491
4492/// Cut a vim-shaped range through the Buffer edit funnel and return
4493/// the deleted text. Translates vim's `MotionKind`
4494/// (Linewise/Inclusive/Exclusive) into the buffer's
4495/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
4496/// position adjustment so inclusive motions actually include the bot
4497/// cell. Pushes the cut text into both `last_yank` and the textarea
4498/// yank buffer (still observed by `p`/`P` until the paste path is
4499/// ported), and updates `yank_linewise` for linewise cuts.
4500fn cut_vim_range(
4501    ed: &mut Editor<'_>,
4502    start: (usize, usize),
4503    end: (usize, usize),
4504    kind: MotionKind,
4505) -> String {
4506    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4507    let (top, bot) = order(start, end);
4508    ed.sync_buffer_content_from_textarea();
4509    let (buf_start, buf_end, buf_kind) = match kind {
4510        MotionKind::Linewise => (
4511            Position::new(top.0, 0),
4512            Position::new(bot.0, 0),
4513            BufKind::Line,
4514        ),
4515        MotionKind::Inclusive => {
4516            let line_chars = ed
4517                .buffer()
4518                .line(bot.0)
4519                .map(|l| l.chars().count())
4520                .unwrap_or(0);
4521            // Advance one cell past `bot` so the buffer's exclusive
4522            // `cut_chars` actually drops the inclusive endpoint. Wrap
4523            // to the next row when bot already sits on the last char.
4524            let next = if bot.1 < line_chars {
4525                Position::new(bot.0, bot.1 + 1)
4526            } else if bot.0 + 1 < ed.buffer().row_count() {
4527                Position::new(bot.0 + 1, 0)
4528            } else {
4529                Position::new(bot.0, line_chars)
4530            };
4531            (Position::new(top.0, top.1), next, BufKind::Char)
4532        }
4533        MotionKind::Exclusive => (
4534            Position::new(top.0, top.1),
4535            Position::new(bot.0, bot.1),
4536            BufKind::Char,
4537        ),
4538    };
4539    let inverse = ed.mutate_edit(Edit::DeleteRange {
4540        start: buf_start,
4541        end: buf_end,
4542        kind: buf_kind,
4543    });
4544    let text = match inverse {
4545        Edit::InsertStr { text, .. } => text,
4546        _ => String::new(),
4547    };
4548    if !text.is_empty() {
4549        ed.last_yank = Some(text.clone());
4550        ed.record_delete(text.clone(), matches!(kind, MotionKind::Linewise));
4551    }
4552    ed.push_buffer_cursor_to_textarea();
4553    text
4554}
4555
4556/// `D` / `C` — delete from cursor to end of line through the edit
4557/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
4558/// textarea's yank buffer (still observed by `p`/`P` until the paste
4559/// path is ported). Cursor lands at the deletion start so the caller
4560/// can decide whether to step it left (`D`) or open insert mode (`C`).
4561fn delete_to_eol(ed: &mut Editor<'_>) {
4562    use hjkl_buffer::{Edit, MotionKind, Position};
4563    ed.sync_buffer_content_from_textarea();
4564    let cursor = ed.buffer().cursor();
4565    let line_chars = ed
4566        .buffer()
4567        .line(cursor.row)
4568        .map(|l| l.chars().count())
4569        .unwrap_or(0);
4570    if cursor.col >= line_chars {
4571        return;
4572    }
4573    let inverse = ed.mutate_edit(Edit::DeleteRange {
4574        start: cursor,
4575        end: Position::new(cursor.row, line_chars),
4576        kind: MotionKind::Char,
4577    });
4578    if let Edit::InsertStr { text, .. } = inverse
4579        && !text.is_empty()
4580    {
4581        ed.last_yank = Some(text.clone());
4582        ed.vim.yank_linewise = false;
4583        ed.set_yank(text);
4584    }
4585    ed.buffer_mut().set_cursor(cursor);
4586    ed.push_buffer_cursor_to_textarea();
4587}
4588
4589fn do_char_delete(ed: &mut Editor<'_>, forward: bool, count: usize) {
4590    use hjkl_buffer::{Edit, MotionKind, Position};
4591    ed.push_undo();
4592    ed.sync_buffer_content_from_textarea();
4593    for _ in 0..count {
4594        let cursor = ed.buffer().cursor();
4595        let line_chars = ed
4596            .buffer()
4597            .line(cursor.row)
4598            .map(|l| l.chars().count())
4599            .unwrap_or(0);
4600        if forward {
4601            // `x` — delete the char under the cursor. Vim no-ops on
4602            // an empty line; the buffer would drop a row otherwise.
4603            if cursor.col >= line_chars {
4604                continue;
4605            }
4606            ed.mutate_edit(Edit::DeleteRange {
4607                start: cursor,
4608                end: Position::new(cursor.row, cursor.col + 1),
4609                kind: MotionKind::Char,
4610            });
4611        } else {
4612            // `X` — delete the char before the cursor.
4613            if cursor.col == 0 {
4614                continue;
4615            }
4616            ed.mutate_edit(Edit::DeleteRange {
4617                start: Position::new(cursor.row, cursor.col - 1),
4618                end: cursor,
4619                kind: MotionKind::Char,
4620            });
4621        }
4622    }
4623    ed.push_buffer_cursor_to_textarea();
4624}
4625
4626/// Vim `Ctrl-a` / `Ctrl-x` — find the next decimal number at or after the
4627/// cursor on the current line, add `delta`, leave the cursor on the last
4628/// digit of the result. No-op if the line has no digits to the right.
4629fn adjust_number(ed: &mut Editor<'_>, delta: i64) -> bool {
4630    use hjkl_buffer::{Edit, MotionKind, Position};
4631    ed.sync_buffer_content_from_textarea();
4632    let cursor = ed.buffer().cursor();
4633    let row = cursor.row;
4634    let chars: Vec<char> = match ed.buffer().line(row) {
4635        Some(l) => l.chars().collect(),
4636        None => return false,
4637    };
4638    let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
4639        return false;
4640    };
4641    let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
4642        digit_start - 1
4643    } else {
4644        digit_start
4645    };
4646    let mut span_end = digit_start;
4647    while span_end < chars.len() && chars[span_end].is_ascii_digit() {
4648        span_end += 1;
4649    }
4650    let s: String = chars[span_start..span_end].iter().collect();
4651    let Ok(n) = s.parse::<i64>() else {
4652        return false;
4653    };
4654    let new_s = n.saturating_add(delta).to_string();
4655
4656    ed.push_undo();
4657    let span_start_pos = Position::new(row, span_start);
4658    let span_end_pos = Position::new(row, span_end);
4659    ed.mutate_edit(Edit::DeleteRange {
4660        start: span_start_pos,
4661        end: span_end_pos,
4662        kind: MotionKind::Char,
4663    });
4664    ed.mutate_edit(Edit::InsertStr {
4665        at: span_start_pos,
4666        text: new_s.clone(),
4667    });
4668    let new_len = new_s.chars().count();
4669    ed.buffer_mut()
4670        .set_cursor(Position::new(row, span_start + new_len.saturating_sub(1)));
4671    ed.push_buffer_cursor_to_textarea();
4672    true
4673}
4674
4675fn replace_char(ed: &mut Editor<'_>, ch: char, count: usize) {
4676    use hjkl_buffer::{Edit, MotionKind, Position};
4677    ed.push_undo();
4678    ed.sync_buffer_content_from_textarea();
4679    for _ in 0..count {
4680        let cursor = ed.buffer().cursor();
4681        let line_chars = ed
4682            .buffer()
4683            .line(cursor.row)
4684            .map(|l| l.chars().count())
4685            .unwrap_or(0);
4686        if cursor.col >= line_chars {
4687            break;
4688        }
4689        ed.mutate_edit(Edit::DeleteRange {
4690            start: cursor,
4691            end: Position::new(cursor.row, cursor.col + 1),
4692            kind: MotionKind::Char,
4693        });
4694        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
4695    }
4696    // Vim leaves the cursor on the last replaced char.
4697    ed.buffer_mut().move_left(1);
4698    ed.push_buffer_cursor_to_textarea();
4699}
4700
4701fn toggle_case_at_cursor(ed: &mut Editor<'_>) {
4702    use hjkl_buffer::{Edit, MotionKind, Position};
4703    ed.sync_buffer_content_from_textarea();
4704    let cursor = ed.buffer().cursor();
4705    let Some(c) = ed
4706        .buffer()
4707        .line(cursor.row)
4708        .and_then(|l| l.chars().nth(cursor.col))
4709    else {
4710        return;
4711    };
4712    let toggled = if c.is_uppercase() {
4713        c.to_lowercase().next().unwrap_or(c)
4714    } else {
4715        c.to_uppercase().next().unwrap_or(c)
4716    };
4717    ed.mutate_edit(Edit::DeleteRange {
4718        start: cursor,
4719        end: Position::new(cursor.row, cursor.col + 1),
4720        kind: MotionKind::Char,
4721    });
4722    ed.mutate_edit(Edit::InsertChar {
4723        at: cursor,
4724        ch: toggled,
4725    });
4726}
4727
4728fn join_line(ed: &mut Editor<'_>) {
4729    use hjkl_buffer::{Edit, Position};
4730    ed.sync_buffer_content_from_textarea();
4731    let row = ed.buffer().cursor().row;
4732    if row + 1 >= ed.buffer().row_count() {
4733        return;
4734    }
4735    let cur_line = ed.buffer().line(row).unwrap_or("").to_string();
4736    let next_raw = ed.buffer().line(row + 1).unwrap_or("").to_string();
4737    let next_trimmed = next_raw.trim_start();
4738    let cur_chars = cur_line.chars().count();
4739    let next_chars = next_raw.chars().count();
4740    // `J` inserts a single space iff both sides are non-empty after
4741    // stripping the next line's leading whitespace.
4742    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
4743        " "
4744    } else {
4745        ""
4746    };
4747    let joined = format!("{cur_line}{separator}{next_trimmed}");
4748    ed.mutate_edit(Edit::Replace {
4749        start: Position::new(row, 0),
4750        end: Position::new(row + 1, next_chars),
4751        with: joined,
4752    });
4753    // Vim parks the cursor on the inserted space — or at the join
4754    // point when no space went in (which is the same column either
4755    // way, since the space sits exactly at `cur_chars`).
4756    ed.buffer_mut().set_cursor(Position::new(row, cur_chars));
4757    ed.push_buffer_cursor_to_textarea();
4758}
4759
4760/// `gJ` — join the next line onto the current one without inserting a
4761/// separating space or stripping leading whitespace.
4762fn join_line_raw(ed: &mut Editor<'_>) {
4763    use hjkl_buffer::{Edit, Position};
4764    ed.sync_buffer_content_from_textarea();
4765    let row = ed.buffer().cursor().row;
4766    if row + 1 >= ed.buffer().row_count() {
4767        return;
4768    }
4769    let join_col = ed
4770        .buffer()
4771        .line(row)
4772        .map(|l| l.chars().count())
4773        .unwrap_or(0);
4774    ed.mutate_edit(Edit::JoinLines {
4775        row,
4776        count: 1,
4777        with_space: false,
4778    });
4779    // Vim leaves the cursor at the join point (end of original line).
4780    ed.buffer_mut().set_cursor(Position::new(row, join_col));
4781    ed.push_buffer_cursor_to_textarea();
4782}
4783
4784fn do_paste(ed: &mut Editor<'_>, before: bool, count: usize) {
4785    use hjkl_buffer::{Edit, Position};
4786    ed.push_undo();
4787    // Resolve the source register: `"reg` prefix (consumed) or the
4788    // unnamed register otherwise. Read text + linewise from the
4789    // selected slot rather than the global `vim.yank_linewise` so
4790    // pasting from `"0` after a delete still uses the yank's layout.
4791    let selector = ed.vim.pending_register.take();
4792    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
4793        Some(slot) => (slot.text.clone(), slot.linewise),
4794        None => (ed.yank().to_string(), ed.vim.yank_linewise),
4795    };
4796    for _ in 0..count {
4797        ed.sync_buffer_content_from_textarea();
4798        let yank = yank.clone();
4799        if yank.is_empty() {
4800            continue;
4801        }
4802        if linewise {
4803            // Linewise paste: insert payload as fresh row(s) above
4804            // (`P`) or below (`p`) the cursor's row. Cursor lands on
4805            // the first non-blank of the first pasted line.
4806            let text = yank.trim_matches('\n').to_string();
4807            let row = ed.buffer().cursor().row;
4808            let target_row = if before {
4809                ed.mutate_edit(Edit::InsertStr {
4810                    at: Position::new(row, 0),
4811                    text: format!("{text}\n"),
4812                });
4813                row
4814            } else {
4815                let line_chars = ed
4816                    .buffer()
4817                    .line(row)
4818                    .map(|l| l.chars().count())
4819                    .unwrap_or(0);
4820                ed.mutate_edit(Edit::InsertStr {
4821                    at: Position::new(row, line_chars),
4822                    text: format!("\n{text}"),
4823                });
4824                row + 1
4825            };
4826            ed.buffer_mut().set_cursor(Position::new(target_row, 0));
4827            ed.buffer_mut().move_first_non_blank();
4828            ed.push_buffer_cursor_to_textarea();
4829        } else {
4830            // Charwise paste. `P` inserts at cursor (shifting cell
4831            // right); `p` inserts after cursor (advance one cell
4832            // first, clamped to the end of the line).
4833            let cursor = ed.buffer().cursor();
4834            let at = if before {
4835                cursor
4836            } else {
4837                let line_chars = ed
4838                    .buffer()
4839                    .line(cursor.row)
4840                    .map(|l| l.chars().count())
4841                    .unwrap_or(0);
4842                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
4843            };
4844            ed.mutate_edit(Edit::InsertStr {
4845                at,
4846                text: yank.clone(),
4847            });
4848            // Vim parks the cursor on the last char of the pasted
4849            // text (do_insert_str leaves it one past the end).
4850            ed.buffer_mut().move_left(1);
4851            ed.push_buffer_cursor_to_textarea();
4852        }
4853    }
4854    // Any paste re-anchors the sticky column to the new cursor position.
4855    ed.vim.sticky_col = Some(ed.buffer().cursor().col);
4856}
4857
4858pub(crate) fn do_undo(ed: &mut Editor<'_>) {
4859    if let Some((lines, cursor)) = ed.undo_stack.pop() {
4860        let current = ed.snapshot();
4861        ed.redo_stack.push(current);
4862        ed.restore(lines, cursor);
4863    }
4864    ed.vim.mode = Mode::Normal;
4865}
4866
4867pub(crate) fn do_redo(ed: &mut Editor<'_>) {
4868    if let Some((lines, cursor)) = ed.redo_stack.pop() {
4869        let current = ed.snapshot();
4870        ed.undo_stack.push(current);
4871        ed.restore(lines, cursor);
4872    }
4873    ed.vim.mode = Mode::Normal;
4874}
4875
4876// ─── Dot repeat ────────────────────────────────────────────────────────────
4877
4878/// Replay-side helper: insert `text` at the cursor through the
4879/// edit funnel, then leave insert mode (the original change ended
4880/// with Esc, so the dot-repeat must end the same way — including
4881/// the cursor step-back vim does on Esc-from-insert).
4882fn replay_insert_and_finish(ed: &mut Editor<'_>, text: &str) {
4883    use hjkl_buffer::{Edit, Position};
4884    let cursor = ed.cursor();
4885    ed.mutate_edit(Edit::InsertStr {
4886        at: Position::new(cursor.0, cursor.1),
4887        text: text.to_string(),
4888    });
4889    if ed.vim.insert_session.take().is_some() {
4890        if ed.cursor().1 > 0 {
4891            ed.buffer_mut().move_left(1);
4892            ed.push_buffer_cursor_to_textarea();
4893        }
4894        ed.vim.mode = Mode::Normal;
4895    }
4896}
4897
4898fn replay_last_change(ed: &mut Editor<'_>, outer_count: usize) {
4899    let Some(change) = ed.vim.last_change.clone() else {
4900        return;
4901    };
4902    ed.vim.replaying = true;
4903    let scale = if outer_count > 0 { outer_count } else { 1 };
4904    match change {
4905        LastChange::OpMotion {
4906            op,
4907            motion,
4908            count,
4909            inserted,
4910        } => {
4911            let total = count.max(1) * scale;
4912            apply_op_with_motion(ed, op, &motion, total);
4913            if let Some(text) = inserted {
4914                replay_insert_and_finish(ed, &text);
4915            }
4916        }
4917        LastChange::OpTextObj {
4918            op,
4919            obj,
4920            inner,
4921            inserted,
4922        } => {
4923            apply_op_with_text_object(ed, op, obj, inner);
4924            if let Some(text) = inserted {
4925                replay_insert_and_finish(ed, &text);
4926            }
4927        }
4928        LastChange::LineOp {
4929            op,
4930            count,
4931            inserted,
4932        } => {
4933            let total = count.max(1) * scale;
4934            execute_line_op(ed, op, total);
4935            if let Some(text) = inserted {
4936                replay_insert_and_finish(ed, &text);
4937            }
4938        }
4939        LastChange::CharDel { forward, count } => {
4940            do_char_delete(ed, forward, count * scale);
4941        }
4942        LastChange::ReplaceChar { ch, count } => {
4943            replace_char(ed, ch, count * scale);
4944        }
4945        LastChange::ToggleCase { count } => {
4946            for _ in 0..count * scale {
4947                ed.push_undo();
4948                toggle_case_at_cursor(ed);
4949            }
4950        }
4951        LastChange::JoinLine { count } => {
4952            for _ in 0..count * scale {
4953                ed.push_undo();
4954                join_line(ed);
4955            }
4956        }
4957        LastChange::Paste { before, count } => {
4958            do_paste(ed, before, count * scale);
4959        }
4960        LastChange::DeleteToEol { inserted } => {
4961            use hjkl_buffer::{Edit, Position};
4962            ed.push_undo();
4963            delete_to_eol(ed);
4964            if let Some(text) = inserted {
4965                let cursor = ed.cursor();
4966                ed.mutate_edit(Edit::InsertStr {
4967                    at: Position::new(cursor.0, cursor.1),
4968                    text,
4969                });
4970            }
4971        }
4972        LastChange::OpenLine { above, inserted } => {
4973            use hjkl_buffer::{Edit, Position};
4974            ed.push_undo();
4975            ed.sync_buffer_content_from_textarea();
4976            let row = ed.buffer().cursor().row;
4977            if above {
4978                ed.mutate_edit(Edit::InsertStr {
4979                    at: Position::new(row, 0),
4980                    text: "\n".to_string(),
4981                });
4982                ed.buffer_mut().move_up(1);
4983            } else {
4984                let line_chars = ed
4985                    .buffer()
4986                    .line(row)
4987                    .map(|l| l.chars().count())
4988                    .unwrap_or(0);
4989                ed.mutate_edit(Edit::InsertStr {
4990                    at: Position::new(row, line_chars),
4991                    text: "\n".to_string(),
4992                });
4993            }
4994            ed.push_buffer_cursor_to_textarea();
4995            let cursor = ed.cursor();
4996            ed.mutate_edit(Edit::InsertStr {
4997                at: Position::new(cursor.0, cursor.1),
4998                text: inserted,
4999            });
5000        }
5001        LastChange::InsertAt {
5002            entry,
5003            inserted,
5004            count,
5005        } => {
5006            use hjkl_buffer::{Edit, Position};
5007            ed.push_undo();
5008            match entry {
5009                InsertEntry::I => {}
5010                InsertEntry::ShiftI => move_first_non_whitespace(ed),
5011                InsertEntry::A => {
5012                    ed.buffer_mut().move_right_to_end(1);
5013                    ed.push_buffer_cursor_to_textarea();
5014                }
5015                InsertEntry::ShiftA => {
5016                    ed.buffer_mut().move_line_end();
5017                    ed.buffer_mut().move_right_to_end(1);
5018                    ed.push_buffer_cursor_to_textarea();
5019                }
5020            }
5021            for _ in 0..count.max(1) {
5022                let cursor = ed.cursor();
5023                ed.mutate_edit(Edit::InsertStr {
5024                    at: Position::new(cursor.0, cursor.1),
5025                    text: inserted.clone(),
5026                });
5027            }
5028        }
5029    }
5030    ed.vim.replaying = false;
5031}
5032
5033// ─── Extracting inserted text for replay ───────────────────────────────────
5034
5035fn extract_inserted(before: &str, after: &str) -> String {
5036    let before_chars: Vec<char> = before.chars().collect();
5037    let after_chars: Vec<char> = after.chars().collect();
5038    if after_chars.len() <= before_chars.len() {
5039        return String::new();
5040    }
5041    let prefix = before_chars
5042        .iter()
5043        .zip(after_chars.iter())
5044        .take_while(|(a, b)| a == b)
5045        .count();
5046    let max_suffix = before_chars.len() - prefix;
5047    let suffix = before_chars
5048        .iter()
5049        .rev()
5050        .zip(after_chars.iter().rev())
5051        .take(max_suffix)
5052        .take_while(|(a, b)| a == b)
5053        .count();
5054    after_chars[prefix..after_chars.len() - suffix]
5055        .iter()
5056        .collect()
5057}
5058
5059// ─── Tests ────────────────────────────────────────────────────────────────
5060
5061#[cfg(test)]
5062mod tests {
5063    use crate::editor::Editor;
5064    use crate::{KeybindingMode, VimMode};
5065    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5066
5067    fn run_keys(e: &mut Editor<'_>, keys: &str) {
5068        // Minimal notation:
5069        //   <Esc> <CR> <BS> <Left/Right/Up/Down> <C-x>
5070        //   anything else = single char
5071        let mut iter = keys.chars().peekable();
5072        while let Some(c) = iter.next() {
5073            if c == '<' {
5074                let mut tag = String::new();
5075                for ch in iter.by_ref() {
5076                    if ch == '>' {
5077                        break;
5078                    }
5079                    tag.push(ch);
5080                }
5081                let ev = match tag.as_str() {
5082                    "Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
5083                    "CR" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
5084                    "BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
5085                    "Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
5086                    "Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
5087                    "Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
5088                    "Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
5089                    "Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
5090                    // Vim-style literal `<` escape so tests can type
5091                    // the outdent operator without colliding with the
5092                    // `<tag>` notation this helper uses for special keys.
5093                    "lt" => KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
5094                    s if s.starts_with("C-") => {
5095                        let ch = s.chars().nth(2).unwrap();
5096                        KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
5097                    }
5098                    _ => continue,
5099                };
5100                e.handle_key(ev);
5101            } else {
5102                let mods = if c.is_uppercase() {
5103                    KeyModifiers::SHIFT
5104                } else {
5105                    KeyModifiers::NONE
5106                };
5107                e.handle_key(KeyEvent::new(KeyCode::Char(c), mods));
5108            }
5109        }
5110    }
5111
5112    fn editor_with(content: &str) -> Editor<'static> {
5113        let mut e = Editor::new(KeybindingMode::Vim);
5114        e.set_content(content);
5115        e
5116    }
5117
5118    #[test]
5119    fn f_char_jumps_on_line() {
5120        let mut e = editor_with("hello world");
5121        run_keys(&mut e, "fw");
5122        assert_eq!(e.cursor(), (0, 6));
5123    }
5124
5125    #[test]
5126    fn cap_f_jumps_backward() {
5127        let mut e = editor_with("hello world");
5128        e.jump_cursor(0, 10);
5129        run_keys(&mut e, "Fo");
5130        assert_eq!(e.cursor().1, 7);
5131    }
5132
5133    #[test]
5134    fn t_stops_before_char() {
5135        let mut e = editor_with("hello");
5136        run_keys(&mut e, "tl");
5137        assert_eq!(e.cursor(), (0, 1));
5138    }
5139
5140    #[test]
5141    fn semicolon_repeats_find() {
5142        let mut e = editor_with("aa.bb.cc");
5143        run_keys(&mut e, "f.");
5144        assert_eq!(e.cursor().1, 2);
5145        run_keys(&mut e, ";");
5146        assert_eq!(e.cursor().1, 5);
5147    }
5148
5149    #[test]
5150    fn comma_repeats_find_reverse() {
5151        let mut e = editor_with("aa.bb.cc");
5152        run_keys(&mut e, "f.");
5153        run_keys(&mut e, ";");
5154        run_keys(&mut e, ",");
5155        assert_eq!(e.cursor().1, 2);
5156    }
5157
5158    #[test]
5159    fn di_quote_deletes_content() {
5160        let mut e = editor_with("foo \"bar\" baz");
5161        e.jump_cursor(0, 6); // inside quotes
5162        run_keys(&mut e, "di\"");
5163        assert_eq!(e.buffer().lines()[0], "foo \"\" baz");
5164    }
5165
5166    #[test]
5167    fn da_quote_deletes_with_quotes() {
5168        let mut e = editor_with("foo \"bar\" baz");
5169        e.jump_cursor(0, 6);
5170        run_keys(&mut e, "da\"");
5171        assert_eq!(e.buffer().lines()[0], "foo  baz");
5172    }
5173
5174    #[test]
5175    fn ci_paren_deletes_and_inserts() {
5176        let mut e = editor_with("fn(a, b, c)");
5177        e.jump_cursor(0, 5);
5178        run_keys(&mut e, "ci(");
5179        assert_eq!(e.vim_mode(), VimMode::Insert);
5180        assert_eq!(e.buffer().lines()[0], "fn()");
5181    }
5182
5183    #[test]
5184    fn diw_deletes_inner_word() {
5185        let mut e = editor_with("hello world");
5186        e.jump_cursor(0, 2);
5187        run_keys(&mut e, "diw");
5188        assert_eq!(e.buffer().lines()[0], " world");
5189    }
5190
5191    #[test]
5192    fn daw_deletes_word_with_trailing_space() {
5193        let mut e = editor_with("hello world");
5194        run_keys(&mut e, "daw");
5195        assert_eq!(e.buffer().lines()[0], "world");
5196    }
5197
5198    #[test]
5199    fn percent_jumps_to_matching_bracket() {
5200        let mut e = editor_with("foo(bar)");
5201        e.jump_cursor(0, 3);
5202        run_keys(&mut e, "%");
5203        assert_eq!(e.cursor().1, 7);
5204        run_keys(&mut e, "%");
5205        assert_eq!(e.cursor().1, 3);
5206    }
5207
5208    #[test]
5209    fn dot_repeats_last_change() {
5210        let mut e = editor_with("aaa bbb ccc");
5211        run_keys(&mut e, "dw");
5212        assert_eq!(e.buffer().lines()[0], "bbb ccc");
5213        run_keys(&mut e, ".");
5214        assert_eq!(e.buffer().lines()[0], "ccc");
5215    }
5216
5217    #[test]
5218    fn dot_repeats_change_operator_with_text() {
5219        let mut e = editor_with("foo foo foo");
5220        run_keys(&mut e, "cwbar<Esc>");
5221        assert_eq!(e.buffer().lines()[0], "bar foo foo");
5222        // Move past the space.
5223        run_keys(&mut e, "w");
5224        run_keys(&mut e, ".");
5225        assert_eq!(e.buffer().lines()[0], "bar bar foo");
5226    }
5227
5228    #[test]
5229    fn dot_repeats_x() {
5230        let mut e = editor_with("abcdef");
5231        run_keys(&mut e, "x");
5232        run_keys(&mut e, "..");
5233        assert_eq!(e.buffer().lines()[0], "def");
5234    }
5235
5236    #[test]
5237    fn count_operator_motion_compose() {
5238        let mut e = editor_with("one two three four five");
5239        run_keys(&mut e, "d3w");
5240        assert_eq!(e.buffer().lines()[0], "four five");
5241    }
5242
5243    #[test]
5244    fn two_dd_deletes_two_lines() {
5245        let mut e = editor_with("a\nb\nc");
5246        run_keys(&mut e, "2dd");
5247        assert_eq!(e.buffer().lines().len(), 1);
5248        assert_eq!(e.buffer().lines()[0], "c");
5249    }
5250
5251    /// Vim's `dd` leaves the cursor on the first non-blank of the line
5252    /// that now sits at the deleted row — not at the end of the
5253    /// previous line, which is where tui-textarea's raw cut would
5254    /// park it.
5255    #[test]
5256    fn dd_in_middle_puts_cursor_on_first_non_blank_of_next() {
5257        let mut e = editor_with("one\ntwo\n    three\nfour");
5258        e.jump_cursor(1, 2);
5259        run_keys(&mut e, "dd");
5260        // Buffer: ["one", "    three", "four"]
5261        assert_eq!(e.buffer().lines()[1], "    three");
5262        assert_eq!(e.cursor(), (1, 4));
5263    }
5264
5265    #[test]
5266    fn dd_on_last_line_puts_cursor_on_first_non_blank_of_prev() {
5267        let mut e = editor_with("one\n  two\nthree");
5268        e.jump_cursor(2, 0);
5269        run_keys(&mut e, "dd");
5270        // Buffer: ["one", "  two"]
5271        assert_eq!(e.buffer().lines().len(), 2);
5272        assert_eq!(e.cursor(), (1, 2));
5273    }
5274
5275    #[test]
5276    fn dd_on_only_line_leaves_empty_buffer_and_cursor_at_zero() {
5277        let mut e = editor_with("lonely");
5278        run_keys(&mut e, "dd");
5279        assert_eq!(e.buffer().lines().len(), 1);
5280        assert_eq!(e.buffer().lines()[0], "");
5281        assert_eq!(e.cursor(), (0, 0));
5282    }
5283
5284    #[test]
5285    fn count_dd_puts_cursor_on_first_non_blank_of_remaining() {
5286        let mut e = editor_with("a\nb\nc\n   d\ne");
5287        // Cursor on row 1, "3dd" deletes b/c/   d → lines become [a, e].
5288        e.jump_cursor(1, 0);
5289        run_keys(&mut e, "3dd");
5290        assert_eq!(e.buffer().lines(), &["a".to_string(), "e".to_string()]);
5291        assert_eq!(e.cursor(), (1, 0));
5292    }
5293
5294    #[test]
5295    fn gu_lowercases_motion_range() {
5296        let mut e = editor_with("HELLO WORLD");
5297        run_keys(&mut e, "guw");
5298        assert_eq!(e.buffer().lines()[0], "hello WORLD");
5299        assert_eq!(e.cursor(), (0, 0));
5300    }
5301
5302    #[test]
5303    fn g_u_uppercases_text_object() {
5304        let mut e = editor_with("hello world");
5305        // gUiw uppercases the word at the cursor.
5306        run_keys(&mut e, "gUiw");
5307        assert_eq!(e.buffer().lines()[0], "HELLO world");
5308        assert_eq!(e.cursor(), (0, 0));
5309    }
5310
5311    #[test]
5312    fn g_tilde_toggles_case_of_range() {
5313        let mut e = editor_with("Hello World");
5314        run_keys(&mut e, "g~iw");
5315        assert_eq!(e.buffer().lines()[0], "hELLO World");
5316    }
5317
5318    #[test]
5319    fn g_uu_uppercases_current_line() {
5320        let mut e = editor_with("select 1\nselect 2");
5321        run_keys(&mut e, "gUU");
5322        assert_eq!(e.buffer().lines()[0], "SELECT 1");
5323        assert_eq!(e.buffer().lines()[1], "select 2");
5324    }
5325
5326    #[test]
5327    fn gugu_lowercases_current_line() {
5328        let mut e = editor_with("FOO BAR\nBAZ");
5329        run_keys(&mut e, "gugu");
5330        assert_eq!(e.buffer().lines()[0], "foo bar");
5331    }
5332
5333    #[test]
5334    fn visual_u_uppercases_selection() {
5335        let mut e = editor_with("hello world");
5336        // v + e selects "hello" (inclusive of last char), U uppercases.
5337        run_keys(&mut e, "veU");
5338        assert_eq!(e.buffer().lines()[0], "HELLO world");
5339    }
5340
5341    #[test]
5342    fn visual_line_u_lowercases_line() {
5343        let mut e = editor_with("HELLO WORLD\nOTHER");
5344        run_keys(&mut e, "Vu");
5345        assert_eq!(e.buffer().lines()[0], "hello world");
5346        assert_eq!(e.buffer().lines()[1], "OTHER");
5347    }
5348
5349    #[test]
5350    fn g_uu_with_count_uppercases_multiple_lines() {
5351        let mut e = editor_with("one\ntwo\nthree\nfour");
5352        // `3gUU` uppercases 3 lines starting from the cursor.
5353        run_keys(&mut e, "3gUU");
5354        assert_eq!(e.buffer().lines()[0], "ONE");
5355        assert_eq!(e.buffer().lines()[1], "TWO");
5356        assert_eq!(e.buffer().lines()[2], "THREE");
5357        assert_eq!(e.buffer().lines()[3], "four");
5358    }
5359
5360    #[test]
5361    fn double_gt_indents_current_line() {
5362        let mut e = editor_with("hello");
5363        run_keys(&mut e, ">>");
5364        assert_eq!(e.buffer().lines()[0], "  hello");
5365        // Cursor lands on first non-blank.
5366        assert_eq!(e.cursor(), (0, 2));
5367    }
5368
5369    #[test]
5370    fn double_lt_outdents_current_line() {
5371        let mut e = editor_with("    hello");
5372        run_keys(&mut e, "<lt><lt>");
5373        assert_eq!(e.buffer().lines()[0], "  hello");
5374        assert_eq!(e.cursor(), (0, 2));
5375    }
5376
5377    #[test]
5378    fn count_double_gt_indents_multiple_lines() {
5379        let mut e = editor_with("a\nb\nc\nd");
5380        // `3>>` indents 3 lines starting at cursor.
5381        run_keys(&mut e, "3>>");
5382        assert_eq!(e.buffer().lines()[0], "  a");
5383        assert_eq!(e.buffer().lines()[1], "  b");
5384        assert_eq!(e.buffer().lines()[2], "  c");
5385        assert_eq!(e.buffer().lines()[3], "d");
5386    }
5387
5388    #[test]
5389    fn outdent_clips_ragged_leading_whitespace() {
5390        // Only one space of indent — outdent should strip what's
5391        // there, not leave anything negative.
5392        let mut e = editor_with(" x");
5393        run_keys(&mut e, "<lt><lt>");
5394        assert_eq!(e.buffer().lines()[0], "x");
5395    }
5396
5397    #[test]
5398    fn indent_motion_is_always_linewise() {
5399        // `>w` indents the current line (linewise) — it doesn't
5400        // insert spaces into the middle of the word.
5401        let mut e = editor_with("foo bar");
5402        run_keys(&mut e, ">w");
5403        assert_eq!(e.buffer().lines()[0], "  foo bar");
5404    }
5405
5406    #[test]
5407    fn indent_text_object_extends_over_paragraph() {
5408        let mut e = editor_with("a\nb\n\nc\nd");
5409        // `>ap` indents the whole paragraph (rows 0..=1).
5410        run_keys(&mut e, ">ap");
5411        assert_eq!(e.buffer().lines()[0], "  a");
5412        assert_eq!(e.buffer().lines()[1], "  b");
5413        assert_eq!(e.buffer().lines()[2], "");
5414        assert_eq!(e.buffer().lines()[3], "c");
5415    }
5416
5417    #[test]
5418    fn visual_line_indent_shifts_selected_rows() {
5419        let mut e = editor_with("x\ny\nz");
5420        // Vj selects rows 0..=1 linewise; `>` indents.
5421        run_keys(&mut e, "Vj>");
5422        assert_eq!(e.buffer().lines()[0], "  x");
5423        assert_eq!(e.buffer().lines()[1], "  y");
5424        assert_eq!(e.buffer().lines()[2], "z");
5425    }
5426
5427    #[test]
5428    fn outdent_empty_line_is_noop() {
5429        let mut e = editor_with("\nfoo");
5430        run_keys(&mut e, "<lt><lt>");
5431        assert_eq!(e.buffer().lines()[0], "");
5432    }
5433
5434    #[test]
5435    fn indent_skips_empty_lines() {
5436        // Vim convention: `>>` on an empty line doesn't pad it with
5437        // trailing whitespace.
5438        let mut e = editor_with("");
5439        run_keys(&mut e, ">>");
5440        assert_eq!(e.buffer().lines()[0], "");
5441    }
5442
5443    #[test]
5444    fn insert_ctrl_t_indents_current_line() {
5445        let mut e = editor_with("x");
5446        // Enter insert, Ctrl-t indents the line; cursor advances too.
5447        run_keys(&mut e, "i<C-t>");
5448        assert_eq!(e.buffer().lines()[0], "  x");
5449        // After insert-mode start `i` cursor was at (0, 0); Ctrl-t
5450        // shifts it by SHIFTWIDTH=2.
5451        assert_eq!(e.cursor(), (0, 2));
5452    }
5453
5454    #[test]
5455    fn insert_ctrl_d_outdents_current_line() {
5456        let mut e = editor_with("    x");
5457        // Enter insert-at-end `A`, Ctrl-d outdents by shiftwidth.
5458        run_keys(&mut e, "A<C-d>");
5459        assert_eq!(e.buffer().lines()[0], "  x");
5460    }
5461
5462    #[test]
5463    fn h_at_col_zero_does_not_wrap_to_prev_line() {
5464        let mut e = editor_with("first\nsecond");
5465        e.jump_cursor(1, 0);
5466        run_keys(&mut e, "h");
5467        // Cursor must stay on row 1 col 0 — vim default doesn't wrap.
5468        assert_eq!(e.cursor(), (1, 0));
5469    }
5470
5471    #[test]
5472    fn l_at_last_char_does_not_wrap_to_next_line() {
5473        let mut e = editor_with("ab\ncd");
5474        // Move to last char of row 0 (col 1).
5475        e.jump_cursor(0, 1);
5476        run_keys(&mut e, "l");
5477        // Cursor stays on last char — no wrap.
5478        assert_eq!(e.cursor(), (0, 1));
5479    }
5480
5481    #[test]
5482    fn count_l_clamps_at_line_end() {
5483        let mut e = editor_with("abcde");
5484        // 20l starting at col 0 should land on last char (col 4),
5485        // not overflow / wrap.
5486        run_keys(&mut e, "20l");
5487        assert_eq!(e.cursor(), (0, 4));
5488    }
5489
5490    #[test]
5491    fn count_h_clamps_at_col_zero() {
5492        let mut e = editor_with("abcde");
5493        e.jump_cursor(0, 3);
5494        run_keys(&mut e, "20h");
5495        assert_eq!(e.cursor(), (0, 0));
5496    }
5497
5498    #[test]
5499    fn dl_on_last_char_still_deletes_it() {
5500        // `dl` / `x`-equivalent at EOL must delete the last char —
5501        // operator motion allows endpoint past-last even though bare
5502        // `l` stops before.
5503        let mut e = editor_with("ab");
5504        e.jump_cursor(0, 1);
5505        run_keys(&mut e, "dl");
5506        assert_eq!(e.buffer().lines()[0], "a");
5507    }
5508
5509    #[test]
5510    fn case_op_preserves_yank_register() {
5511        let mut e = editor_with("target");
5512        run_keys(&mut e, "yy");
5513        let yank_before = e.yank().to_string();
5514        // gUU changes the line but must not clobber the yank register.
5515        run_keys(&mut e, "gUU");
5516        assert_eq!(e.buffer().lines()[0], "TARGET");
5517        assert_eq!(
5518            e.yank(),
5519            yank_before,
5520            "case ops must preserve the yank buffer"
5521        );
5522    }
5523
5524    #[test]
5525    fn dap_deletes_paragraph() {
5526        let mut e = editor_with("a\nb\n\nc\nd");
5527        run_keys(&mut e, "dap");
5528        assert_eq!(e.buffer().lines().first().map(String::as_str), Some("c"));
5529    }
5530
5531    #[test]
5532    fn dit_deletes_inner_tag_content() {
5533        let mut e = editor_with("<b>hello</b>");
5534        // Cursor on `e`.
5535        e.jump_cursor(0, 4);
5536        run_keys(&mut e, "dit");
5537        assert_eq!(e.buffer().lines()[0], "<b></b>");
5538    }
5539
5540    #[test]
5541    fn dat_deletes_around_tag() {
5542        let mut e = editor_with("hi <b>foo</b> bye");
5543        e.jump_cursor(0, 6);
5544        run_keys(&mut e, "dat");
5545        assert_eq!(e.buffer().lines()[0], "hi  bye");
5546    }
5547
5548    #[test]
5549    fn dit_picks_innermost_tag() {
5550        let mut e = editor_with("<a><b>x</b></a>");
5551        // Cursor on `x`.
5552        e.jump_cursor(0, 6);
5553        run_keys(&mut e, "dit");
5554        // Inner of <b> is removed; <a> wrapping stays.
5555        assert_eq!(e.buffer().lines()[0], "<a><b></b></a>");
5556    }
5557
5558    #[test]
5559    fn dat_innermost_tag_pair() {
5560        let mut e = editor_with("<a><b>x</b></a>");
5561        e.jump_cursor(0, 6);
5562        run_keys(&mut e, "dat");
5563        assert_eq!(e.buffer().lines()[0], "<a></a>");
5564    }
5565
5566    #[test]
5567    fn dit_outside_any_tag_no_op() {
5568        let mut e = editor_with("plain text");
5569        e.jump_cursor(0, 3);
5570        run_keys(&mut e, "dit");
5571        // No tag pair surrounds the cursor — buffer unchanged.
5572        assert_eq!(e.buffer().lines()[0], "plain text");
5573    }
5574
5575    #[test]
5576    fn cit_changes_inner_tag_content() {
5577        let mut e = editor_with("<b>hello</b>");
5578        e.jump_cursor(0, 4);
5579        run_keys(&mut e, "citNEW<Esc>");
5580        assert_eq!(e.buffer().lines()[0], "<b>NEW</b>");
5581    }
5582
5583    #[test]
5584    fn cat_changes_around_tag() {
5585        let mut e = editor_with("hi <b>foo</b> bye");
5586        e.jump_cursor(0, 6);
5587        run_keys(&mut e, "catBAR<Esc>");
5588        assert_eq!(e.buffer().lines()[0], "hi BAR bye");
5589    }
5590
5591    #[test]
5592    fn yit_yanks_inner_tag_content() {
5593        let mut e = editor_with("<b>hello</b>");
5594        e.jump_cursor(0, 4);
5595        run_keys(&mut e, "yit");
5596        assert_eq!(e.registers().read('"').unwrap().text, "hello");
5597    }
5598
5599    #[test]
5600    fn yat_yanks_full_tag_pair() {
5601        let mut e = editor_with("hi <b>foo</b> bye");
5602        e.jump_cursor(0, 6);
5603        run_keys(&mut e, "yat");
5604        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
5605    }
5606
5607    #[test]
5608    fn vit_visually_selects_inner_tag() {
5609        let mut e = editor_with("<b>hello</b>");
5610        e.jump_cursor(0, 4);
5611        run_keys(&mut e, "vit");
5612        assert_eq!(e.vim_mode(), VimMode::Visual);
5613        run_keys(&mut e, "y");
5614        assert_eq!(e.registers().read('"').unwrap().text, "hello");
5615    }
5616
5617    #[test]
5618    fn vat_visually_selects_around_tag() {
5619        let mut e = editor_with("x<b>foo</b>y");
5620        e.jump_cursor(0, 5);
5621        run_keys(&mut e, "vat");
5622        assert_eq!(e.vim_mode(), VimMode::Visual);
5623        run_keys(&mut e, "y");
5624        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
5625    }
5626
5627    // ─── Text-object coverage (d operator, inner + around) ───────────
5628
5629    #[test]
5630    #[allow(non_snake_case)]
5631    fn diW_deletes_inner_big_word() {
5632        let mut e = editor_with("foo.bar baz");
5633        e.jump_cursor(0, 2);
5634        run_keys(&mut e, "diW");
5635        // Big word treats `foo.bar` as one token.
5636        assert_eq!(e.buffer().lines()[0], " baz");
5637    }
5638
5639    #[test]
5640    #[allow(non_snake_case)]
5641    fn daW_deletes_around_big_word() {
5642        let mut e = editor_with("foo.bar baz");
5643        e.jump_cursor(0, 2);
5644        run_keys(&mut e, "daW");
5645        assert_eq!(e.buffer().lines()[0], "baz");
5646    }
5647
5648    #[test]
5649    fn di_double_quote_deletes_inside() {
5650        let mut e = editor_with("a \"hello\" b");
5651        e.jump_cursor(0, 4);
5652        run_keys(&mut e, "di\"");
5653        assert_eq!(e.buffer().lines()[0], "a \"\" b");
5654    }
5655
5656    #[test]
5657    fn da_double_quote_deletes_around() {
5658        let mut e = editor_with("a \"hello\" b");
5659        e.jump_cursor(0, 4);
5660        run_keys(&mut e, "da\"");
5661        assert_eq!(e.buffer().lines()[0], "a  b");
5662    }
5663
5664    #[test]
5665    fn di_single_quote_deletes_inside() {
5666        let mut e = editor_with("x 'foo' y");
5667        e.jump_cursor(0, 4);
5668        run_keys(&mut e, "di'");
5669        assert_eq!(e.buffer().lines()[0], "x '' y");
5670    }
5671
5672    #[test]
5673    fn da_single_quote_deletes_around() {
5674        let mut e = editor_with("x 'foo' y");
5675        e.jump_cursor(0, 4);
5676        run_keys(&mut e, "da'");
5677        assert_eq!(e.buffer().lines()[0], "x  y");
5678    }
5679
5680    #[test]
5681    fn di_backtick_deletes_inside() {
5682        let mut e = editor_with("p `q` r");
5683        e.jump_cursor(0, 3);
5684        run_keys(&mut e, "di`");
5685        assert_eq!(e.buffer().lines()[0], "p `` r");
5686    }
5687
5688    #[test]
5689    fn da_backtick_deletes_around() {
5690        let mut e = editor_with("p `q` r");
5691        e.jump_cursor(0, 3);
5692        run_keys(&mut e, "da`");
5693        assert_eq!(e.buffer().lines()[0], "p  r");
5694    }
5695
5696    #[test]
5697    fn di_paren_deletes_inside() {
5698        let mut e = editor_with("f(arg)");
5699        e.jump_cursor(0, 3);
5700        run_keys(&mut e, "di(");
5701        assert_eq!(e.buffer().lines()[0], "f()");
5702    }
5703
5704    #[test]
5705    fn di_paren_alias_b_works() {
5706        let mut e = editor_with("f(arg)");
5707        e.jump_cursor(0, 3);
5708        run_keys(&mut e, "dib");
5709        assert_eq!(e.buffer().lines()[0], "f()");
5710    }
5711
5712    #[test]
5713    fn di_bracket_deletes_inside() {
5714        let mut e = editor_with("a[b,c]d");
5715        e.jump_cursor(0, 3);
5716        run_keys(&mut e, "di[");
5717        assert_eq!(e.buffer().lines()[0], "a[]d");
5718    }
5719
5720    #[test]
5721    fn da_bracket_deletes_around() {
5722        let mut e = editor_with("a[b,c]d");
5723        e.jump_cursor(0, 3);
5724        run_keys(&mut e, "da[");
5725        assert_eq!(e.buffer().lines()[0], "ad");
5726    }
5727
5728    #[test]
5729    fn di_brace_deletes_inside() {
5730        let mut e = editor_with("x{y}z");
5731        e.jump_cursor(0, 2);
5732        run_keys(&mut e, "di{");
5733        assert_eq!(e.buffer().lines()[0], "x{}z");
5734    }
5735
5736    #[test]
5737    fn da_brace_deletes_around() {
5738        let mut e = editor_with("x{y}z");
5739        e.jump_cursor(0, 2);
5740        run_keys(&mut e, "da{");
5741        assert_eq!(e.buffer().lines()[0], "xz");
5742    }
5743
5744    #[test]
5745    fn di_brace_alias_capital_b_works() {
5746        let mut e = editor_with("x{y}z");
5747        e.jump_cursor(0, 2);
5748        run_keys(&mut e, "diB");
5749        assert_eq!(e.buffer().lines()[0], "x{}z");
5750    }
5751
5752    #[test]
5753    fn di_angle_deletes_inside() {
5754        let mut e = editor_with("p<q>r");
5755        e.jump_cursor(0, 2);
5756        // `<lt>` so run_keys doesn't treat `<` as the start of a special-key tag.
5757        run_keys(&mut e, "di<lt>");
5758        assert_eq!(e.buffer().lines()[0], "p<>r");
5759    }
5760
5761    #[test]
5762    fn da_angle_deletes_around() {
5763        let mut e = editor_with("p<q>r");
5764        e.jump_cursor(0, 2);
5765        run_keys(&mut e, "da<lt>");
5766        assert_eq!(e.buffer().lines()[0], "pr");
5767    }
5768
5769    #[test]
5770    fn dip_deletes_inner_paragraph() {
5771        let mut e = editor_with("a\nb\nc\n\nd");
5772        e.jump_cursor(1, 0);
5773        run_keys(&mut e, "dip");
5774        // Inner paragraph (rows 0..=2) drops; the trailing blank
5775        // separator + remaining paragraph stay.
5776        assert_eq!(e.buffer().lines(), vec!["".to_string(), "d".into()]);
5777    }
5778
5779    // ─── Operator pipeline spot checks (non-tag text objects) ───────
5780
5781    #[test]
5782    fn sentence_motion_close_paren_jumps_forward() {
5783        let mut e = editor_with("Alpha. Beta. Gamma.");
5784        e.jump_cursor(0, 0);
5785        run_keys(&mut e, ")");
5786        // Lands on the start of "Beta".
5787        assert_eq!(e.cursor(), (0, 7));
5788        run_keys(&mut e, ")");
5789        assert_eq!(e.cursor(), (0, 13));
5790    }
5791
5792    #[test]
5793    fn sentence_motion_open_paren_jumps_backward() {
5794        let mut e = editor_with("Alpha. Beta. Gamma.");
5795        e.jump_cursor(0, 13);
5796        run_keys(&mut e, "(");
5797        // Cursor was at start of "Gamma" (col 13); first `(` walks
5798        // back to the previous sentence's start.
5799        assert_eq!(e.cursor(), (0, 7));
5800        run_keys(&mut e, "(");
5801        assert_eq!(e.cursor(), (0, 0));
5802    }
5803
5804    #[test]
5805    fn sentence_motion_count() {
5806        let mut e = editor_with("A. B. C. D.");
5807        e.jump_cursor(0, 0);
5808        run_keys(&mut e, "3)");
5809        // 3 forward jumps land on "D".
5810        assert_eq!(e.cursor(), (0, 9));
5811    }
5812
5813    #[test]
5814    fn dis_deletes_inner_sentence() {
5815        let mut e = editor_with("First one. Second one. Third one.");
5816        e.jump_cursor(0, 13);
5817        run_keys(&mut e, "dis");
5818        // Removed "Second one." inclusive of its terminator.
5819        assert_eq!(e.buffer().lines()[0], "First one.  Third one.");
5820    }
5821
5822    #[test]
5823    fn das_deletes_around_sentence_with_trailing_space() {
5824        let mut e = editor_with("Alpha. Beta. Gamma.");
5825        e.jump_cursor(0, 8);
5826        run_keys(&mut e, "das");
5827        // `as` swallows the trailing whitespace before the next
5828        // sentence — exactly one space here.
5829        assert_eq!(e.buffer().lines()[0], "Alpha. Gamma.");
5830    }
5831
5832    #[test]
5833    fn dis_handles_double_terminator() {
5834        let mut e = editor_with("Wow!? Next.");
5835        e.jump_cursor(0, 1);
5836        run_keys(&mut e, "dis");
5837        // Run of `!?` collapses into one boundary; sentence body
5838        // including both terminators is removed.
5839        assert_eq!(e.buffer().lines()[0], " Next.");
5840    }
5841
5842    #[test]
5843    fn dis_first_sentence_from_cursor_at_zero() {
5844        let mut e = editor_with("Alpha. Beta.");
5845        e.jump_cursor(0, 0);
5846        run_keys(&mut e, "dis");
5847        assert_eq!(e.buffer().lines()[0], " Beta.");
5848    }
5849
5850    #[test]
5851    fn yis_yanks_inner_sentence() {
5852        let mut e = editor_with("Hello world. Bye.");
5853        e.jump_cursor(0, 5);
5854        run_keys(&mut e, "yis");
5855        assert_eq!(e.registers().read('"').unwrap().text, "Hello world.");
5856    }
5857
5858    #[test]
5859    fn vis_visually_selects_inner_sentence() {
5860        let mut e = editor_with("First. Second.");
5861        e.jump_cursor(0, 1);
5862        run_keys(&mut e, "vis");
5863        assert_eq!(e.vim_mode(), VimMode::Visual);
5864        run_keys(&mut e, "y");
5865        assert_eq!(e.registers().read('"').unwrap().text, "First.");
5866    }
5867
5868    #[test]
5869    fn ciw_changes_inner_word() {
5870        let mut e = editor_with("hello world");
5871        e.jump_cursor(0, 1);
5872        run_keys(&mut e, "ciwHEY<Esc>");
5873        assert_eq!(e.buffer().lines()[0], "HEY world");
5874    }
5875
5876    #[test]
5877    fn yiw_yanks_inner_word() {
5878        let mut e = editor_with("hello world");
5879        e.jump_cursor(0, 1);
5880        run_keys(&mut e, "yiw");
5881        assert_eq!(e.registers().read('"').unwrap().text, "hello");
5882    }
5883
5884    #[test]
5885    fn viw_selects_inner_word() {
5886        let mut e = editor_with("hello world");
5887        e.jump_cursor(0, 2);
5888        run_keys(&mut e, "viw");
5889        assert_eq!(e.vim_mode(), VimMode::Visual);
5890        run_keys(&mut e, "y");
5891        assert_eq!(e.registers().read('"').unwrap().text, "hello");
5892    }
5893
5894    #[test]
5895    fn ci_paren_changes_inside() {
5896        let mut e = editor_with("f(old)");
5897        e.jump_cursor(0, 3);
5898        run_keys(&mut e, "ci(NEW<Esc>");
5899        assert_eq!(e.buffer().lines()[0], "f(NEW)");
5900    }
5901
5902    #[test]
5903    fn yi_double_quote_yanks_inside() {
5904        let mut e = editor_with("say \"hi there\" then");
5905        e.jump_cursor(0, 6);
5906        run_keys(&mut e, "yi\"");
5907        assert_eq!(e.registers().read('"').unwrap().text, "hi there");
5908    }
5909
5910    #[test]
5911    fn vap_visual_selects_around_paragraph() {
5912        let mut e = editor_with("a\nb\n\nc");
5913        e.jump_cursor(0, 0);
5914        run_keys(&mut e, "vap");
5915        assert_eq!(e.vim_mode(), VimMode::VisualLine);
5916        run_keys(&mut e, "y");
5917        // Linewise yank includes the paragraph rows + trailing blank.
5918        let text = e.registers().read('"').unwrap().text.clone();
5919        assert!(text.starts_with("a\nb"));
5920    }
5921
5922    #[test]
5923    fn star_finds_next_occurrence() {
5924        let mut e = editor_with("foo bar foo baz");
5925        run_keys(&mut e, "*");
5926        assert_eq!(e.cursor().1, 8);
5927    }
5928
5929    #[test]
5930    fn star_skips_substring_match() {
5931        // `*` uses `\bfoo\b` so `foobar` is *not* a hit; cursor wraps
5932        // back to the original `foo` at col 0.
5933        let mut e = editor_with("foo foobar baz");
5934        run_keys(&mut e, "*");
5935        assert_eq!(e.cursor().1, 0);
5936    }
5937
5938    #[test]
5939    fn g_star_matches_substring() {
5940        // `g*` drops the boundary; from `foo` at col 0 the next hit is
5941        // inside `foobar` (col 4).
5942        let mut e = editor_with("foo foobar baz");
5943        run_keys(&mut e, "g*");
5944        assert_eq!(e.cursor().1, 4);
5945    }
5946
5947    #[test]
5948    fn g_pound_matches_substring_backward() {
5949        // Start on the last `foo`; `g#` walks backward and lands inside
5950        // `foobar` (col 4).
5951        let mut e = editor_with("foo foobar baz foo");
5952        run_keys(&mut e, "$b");
5953        assert_eq!(e.cursor().1, 15);
5954        run_keys(&mut e, "g#");
5955        assert_eq!(e.cursor().1, 4);
5956    }
5957
5958    #[test]
5959    fn n_repeats_last_search_forward() {
5960        let mut e = editor_with("foo bar foo baz foo");
5961        // `/foo<CR>` jumps past the cursor's current cell, so from
5962        // col 0 the first hit is the second `foo` at col 8.
5963        run_keys(&mut e, "/foo<CR>");
5964        assert_eq!(e.cursor().1, 8);
5965        run_keys(&mut e, "n");
5966        assert_eq!(e.cursor().1, 16);
5967    }
5968
5969    #[test]
5970    fn shift_n_reverses_search() {
5971        let mut e = editor_with("foo bar foo baz foo");
5972        run_keys(&mut e, "/foo<CR>");
5973        run_keys(&mut e, "n");
5974        assert_eq!(e.cursor().1, 16);
5975        run_keys(&mut e, "N");
5976        assert_eq!(e.cursor().1, 8);
5977    }
5978
5979    #[test]
5980    fn n_noop_without_pattern() {
5981        let mut e = editor_with("foo bar");
5982        run_keys(&mut e, "n");
5983        assert_eq!(e.cursor(), (0, 0));
5984    }
5985
5986    #[test]
5987    fn visual_line_preserves_cursor_column() {
5988        // V should never drag the cursor off its natural column — the
5989        // highlight is painted as a post-render overlay instead.
5990        let mut e = editor_with("hello world\nanother one\nbye");
5991        run_keys(&mut e, "lllll"); // col 5
5992        run_keys(&mut e, "V");
5993        assert_eq!(e.vim_mode(), VimMode::VisualLine);
5994        assert_eq!(e.cursor(), (0, 5));
5995        run_keys(&mut e, "j");
5996        assert_eq!(e.cursor(), (1, 5));
5997    }
5998
5999    #[test]
6000    fn visual_line_yank_includes_trailing_newline() {
6001        let mut e = editor_with("aaa\nbbb\nccc");
6002        run_keys(&mut e, "Vjy");
6003        // Two lines yanked — must be `aaa\nbbb\n`, trailing newline preserved.
6004        assert_eq!(e.last_yank.as_deref(), Some("aaa\nbbb\n"));
6005    }
6006
6007    #[test]
6008    fn visual_line_yank_last_line_trailing_newline() {
6009        let mut e = editor_with("aaa\nbbb\nccc");
6010        // Move to the last line and yank with V (final buffer line).
6011        run_keys(&mut e, "jj");
6012        run_keys(&mut e, "Vy");
6013        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6014    }
6015
6016    #[test]
6017    fn yy_on_last_line_has_trailing_newline() {
6018        let mut e = editor_with("aaa\nbbb\nccc");
6019        run_keys(&mut e, "jj");
6020        run_keys(&mut e, "yy");
6021        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6022    }
6023
6024    #[test]
6025    fn yy_in_middle_has_trailing_newline() {
6026        let mut e = editor_with("aaa\nbbb\nccc");
6027        run_keys(&mut e, "j");
6028        run_keys(&mut e, "yy");
6029        assert_eq!(e.last_yank.as_deref(), Some("bbb\n"));
6030    }
6031
6032    #[test]
6033    fn di_single_quote() {
6034        let mut e = editor_with("say 'hello world' now");
6035        e.jump_cursor(0, 7);
6036        run_keys(&mut e, "di'");
6037        assert_eq!(e.buffer().lines()[0], "say '' now");
6038    }
6039
6040    #[test]
6041    fn da_single_quote() {
6042        let mut e = editor_with("say 'hello' now");
6043        e.jump_cursor(0, 7);
6044        run_keys(&mut e, "da'");
6045        assert_eq!(e.buffer().lines()[0], "say  now");
6046    }
6047
6048    #[test]
6049    fn di_backtick() {
6050        let mut e = editor_with("say `hi` now");
6051        e.jump_cursor(0, 5);
6052        run_keys(&mut e, "di`");
6053        assert_eq!(e.buffer().lines()[0], "say `` now");
6054    }
6055
6056    #[test]
6057    fn di_brace() {
6058        let mut e = editor_with("fn { a; b; c }");
6059        e.jump_cursor(0, 7);
6060        run_keys(&mut e, "di{");
6061        assert_eq!(e.buffer().lines()[0], "fn {}");
6062    }
6063
6064    #[test]
6065    fn di_bracket() {
6066        let mut e = editor_with("arr[1, 2, 3]");
6067        e.jump_cursor(0, 5);
6068        run_keys(&mut e, "di[");
6069        assert_eq!(e.buffer().lines()[0], "arr[]");
6070    }
6071
6072    #[test]
6073    fn dab_deletes_around_paren() {
6074        let mut e = editor_with("fn(a, b) + 1");
6075        e.jump_cursor(0, 4);
6076        run_keys(&mut e, "dab");
6077        assert_eq!(e.buffer().lines()[0], "fn + 1");
6078    }
6079
6080    #[test]
6081    fn da_big_b_deletes_around_brace() {
6082        let mut e = editor_with("x = {a: 1}");
6083        e.jump_cursor(0, 6);
6084        run_keys(&mut e, "daB");
6085        assert_eq!(e.buffer().lines()[0], "x = ");
6086    }
6087
6088    #[test]
6089    fn di_big_w_deletes_bigword() {
6090        let mut e = editor_with("foo-bar baz");
6091        e.jump_cursor(0, 2);
6092        run_keys(&mut e, "diW");
6093        assert_eq!(e.buffer().lines()[0], " baz");
6094    }
6095
6096    #[test]
6097    fn visual_select_inner_word() {
6098        let mut e = editor_with("hello world");
6099        e.jump_cursor(0, 2);
6100        run_keys(&mut e, "viw");
6101        assert_eq!(e.vim_mode(), VimMode::Visual);
6102        run_keys(&mut e, "y");
6103        assert_eq!(e.last_yank.as_deref(), Some("hello"));
6104    }
6105
6106    #[test]
6107    fn visual_select_inner_quote() {
6108        let mut e = editor_with("foo \"bar\" baz");
6109        e.jump_cursor(0, 6);
6110        run_keys(&mut e, "vi\"");
6111        run_keys(&mut e, "y");
6112        assert_eq!(e.last_yank.as_deref(), Some("bar"));
6113    }
6114
6115    #[test]
6116    fn visual_select_inner_paren() {
6117        let mut e = editor_with("fn(a, b)");
6118        e.jump_cursor(0, 4);
6119        run_keys(&mut e, "vi(");
6120        run_keys(&mut e, "y");
6121        assert_eq!(e.last_yank.as_deref(), Some("a, b"));
6122    }
6123
6124    #[test]
6125    fn visual_select_outer_brace() {
6126        let mut e = editor_with("{x}");
6127        e.jump_cursor(0, 1);
6128        run_keys(&mut e, "va{");
6129        run_keys(&mut e, "y");
6130        assert_eq!(e.last_yank.as_deref(), Some("{x}"));
6131    }
6132
6133    #[test]
6134    fn caw_changes_word_with_trailing_space() {
6135        let mut e = editor_with("hello world");
6136        run_keys(&mut e, "cawfoo<Esc>");
6137        assert_eq!(e.buffer().lines()[0], "fooworld");
6138    }
6139
6140    #[test]
6141    fn visual_char_yank_preserves_raw_text() {
6142        let mut e = editor_with("hello world");
6143        run_keys(&mut e, "vllly");
6144        assert_eq!(e.last_yank.as_deref(), Some("hell"));
6145    }
6146
6147    #[test]
6148    fn single_line_visual_line_selects_full_line_on_yank() {
6149        let mut e = editor_with("hello world\nbye");
6150        run_keys(&mut e, "V");
6151        // Yank the selection — should include the full line + trailing
6152        // newline (linewise yank convention).
6153        run_keys(&mut e, "y");
6154        assert_eq!(e.last_yank.as_deref(), Some("hello world\n"));
6155    }
6156
6157    #[test]
6158    fn visual_line_extends_both_directions() {
6159        let mut e = editor_with("aaa\nbbb\nccc\nddd");
6160        run_keys(&mut e, "jjj"); // row 3, col 0
6161        run_keys(&mut e, "V");
6162        assert_eq!(e.cursor(), (3, 0));
6163        run_keys(&mut e, "k");
6164        // Cursor is free to sit on its natural column — no forced Jump.
6165        assert_eq!(e.cursor(), (2, 0));
6166        run_keys(&mut e, "k");
6167        assert_eq!(e.cursor(), (1, 0));
6168    }
6169
6170    #[test]
6171    fn visual_char_preserves_cursor_column() {
6172        let mut e = editor_with("hello world");
6173        run_keys(&mut e, "lllll"); // col 5
6174        run_keys(&mut e, "v");
6175        assert_eq!(e.cursor(), (0, 5));
6176        run_keys(&mut e, "ll");
6177        assert_eq!(e.cursor(), (0, 7));
6178    }
6179
6180    #[test]
6181    fn visual_char_highlight_bounds_order() {
6182        let mut e = editor_with("abcdef");
6183        run_keys(&mut e, "lll"); // col 3
6184        run_keys(&mut e, "v");
6185        run_keys(&mut e, "hh"); // col 1
6186        // Anchor (0, 3), cursor (0, 1). Bounds ordered: start=(0,1) end=(0,3).
6187        assert_eq!(e.char_highlight(), Some(((0, 1), (0, 3))));
6188    }
6189
6190    #[test]
6191    fn visual_line_highlight_bounds() {
6192        let mut e = editor_with("a\nb\nc");
6193        run_keys(&mut e, "V");
6194        assert_eq!(e.line_highlight(), Some((0, 0)));
6195        run_keys(&mut e, "j");
6196        assert_eq!(e.line_highlight(), Some((0, 1)));
6197        run_keys(&mut e, "j");
6198        assert_eq!(e.line_highlight(), Some((0, 2)));
6199    }
6200
6201    // ─── Basic motions ─────────────────────────────────────────────────────
6202
6203    #[test]
6204    fn h_moves_left() {
6205        let mut e = editor_with("hello");
6206        e.jump_cursor(0, 3);
6207        run_keys(&mut e, "h");
6208        assert_eq!(e.cursor(), (0, 2));
6209    }
6210
6211    #[test]
6212    fn l_moves_right() {
6213        let mut e = editor_with("hello");
6214        run_keys(&mut e, "l");
6215        assert_eq!(e.cursor(), (0, 1));
6216    }
6217
6218    #[test]
6219    fn k_moves_up() {
6220        let mut e = editor_with("a\nb\nc");
6221        e.jump_cursor(2, 0);
6222        run_keys(&mut e, "k");
6223        assert_eq!(e.cursor(), (1, 0));
6224    }
6225
6226    #[test]
6227    fn zero_moves_to_line_start() {
6228        let mut e = editor_with("    hello");
6229        run_keys(&mut e, "$");
6230        run_keys(&mut e, "0");
6231        assert_eq!(e.cursor().1, 0);
6232    }
6233
6234    #[test]
6235    fn caret_moves_to_first_non_blank() {
6236        let mut e = editor_with("    hello");
6237        run_keys(&mut e, "0");
6238        run_keys(&mut e, "^");
6239        assert_eq!(e.cursor().1, 4);
6240    }
6241
6242    #[test]
6243    fn dollar_moves_to_last_char() {
6244        let mut e = editor_with("hello");
6245        run_keys(&mut e, "$");
6246        assert_eq!(e.cursor().1, 4);
6247    }
6248
6249    #[test]
6250    fn dollar_on_empty_line_stays_at_col_zero() {
6251        let mut e = editor_with("");
6252        run_keys(&mut e, "$");
6253        assert_eq!(e.cursor().1, 0);
6254    }
6255
6256    #[test]
6257    fn w_jumps_to_next_word() {
6258        let mut e = editor_with("foo bar baz");
6259        run_keys(&mut e, "w");
6260        assert_eq!(e.cursor().1, 4);
6261    }
6262
6263    #[test]
6264    fn b_jumps_back_a_word() {
6265        let mut e = editor_with("foo bar");
6266        e.jump_cursor(0, 6);
6267        run_keys(&mut e, "b");
6268        assert_eq!(e.cursor().1, 4);
6269    }
6270
6271    #[test]
6272    fn e_jumps_to_word_end() {
6273        let mut e = editor_with("foo bar");
6274        run_keys(&mut e, "e");
6275        assert_eq!(e.cursor().1, 2);
6276    }
6277
6278    // ─── Operators with line-edge and file-edge motions ───────────────────
6279
6280    #[test]
6281    fn d_dollar_deletes_to_eol() {
6282        let mut e = editor_with("hello world");
6283        e.jump_cursor(0, 5);
6284        run_keys(&mut e, "d$");
6285        assert_eq!(e.buffer().lines()[0], "hello");
6286    }
6287
6288    #[test]
6289    fn d_zero_deletes_to_line_start() {
6290        let mut e = editor_with("hello world");
6291        e.jump_cursor(0, 6);
6292        run_keys(&mut e, "d0");
6293        assert_eq!(e.buffer().lines()[0], "world");
6294    }
6295
6296    #[test]
6297    fn d_caret_deletes_to_first_non_blank() {
6298        let mut e = editor_with("    hello");
6299        e.jump_cursor(0, 6);
6300        run_keys(&mut e, "d^");
6301        assert_eq!(e.buffer().lines()[0], "    llo");
6302    }
6303
6304    #[test]
6305    fn d_capital_g_deletes_to_end_of_file() {
6306        let mut e = editor_with("a\nb\nc\nd");
6307        e.jump_cursor(1, 0);
6308        run_keys(&mut e, "dG");
6309        assert_eq!(e.buffer().lines(), &["a".to_string()]);
6310    }
6311
6312    #[test]
6313    fn d_gg_deletes_to_start_of_file() {
6314        let mut e = editor_with("a\nb\nc\nd");
6315        e.jump_cursor(2, 0);
6316        run_keys(&mut e, "dgg");
6317        assert_eq!(e.buffer().lines(), &["d".to_string()]);
6318    }
6319
6320    #[test]
6321    fn cw_is_ce_quirk() {
6322        // `cw` on a non-blank word must NOT eat the trailing whitespace;
6323        // it behaves like `ce` so the replacement lands before the space.
6324        let mut e = editor_with("foo bar");
6325        run_keys(&mut e, "cwxyz<Esc>");
6326        assert_eq!(e.buffer().lines()[0], "xyz bar");
6327    }
6328
6329    // ─── Single-char edits ────────────────────────────────────────────────
6330
6331    #[test]
6332    fn big_d_deletes_to_eol() {
6333        let mut e = editor_with("hello world");
6334        e.jump_cursor(0, 5);
6335        run_keys(&mut e, "D");
6336        assert_eq!(e.buffer().lines()[0], "hello");
6337    }
6338
6339    #[test]
6340    fn big_c_deletes_to_eol_and_inserts() {
6341        let mut e = editor_with("hello world");
6342        e.jump_cursor(0, 5);
6343        run_keys(&mut e, "C!<Esc>");
6344        assert_eq!(e.buffer().lines()[0], "hello!");
6345    }
6346
6347    #[test]
6348    fn j_joins_next_line_with_space() {
6349        let mut e = editor_with("hello\nworld");
6350        run_keys(&mut e, "J");
6351        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
6352    }
6353
6354    #[test]
6355    fn j_strips_leading_whitespace_on_join() {
6356        let mut e = editor_with("hello\n    world");
6357        run_keys(&mut e, "J");
6358        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
6359    }
6360
6361    #[test]
6362    fn big_x_deletes_char_before_cursor() {
6363        let mut e = editor_with("hello");
6364        e.jump_cursor(0, 3);
6365        run_keys(&mut e, "X");
6366        assert_eq!(e.buffer().lines()[0], "helo");
6367    }
6368
6369    #[test]
6370    fn s_substitutes_char_and_enters_insert() {
6371        let mut e = editor_with("hello");
6372        run_keys(&mut e, "sX<Esc>");
6373        assert_eq!(e.buffer().lines()[0], "Xello");
6374    }
6375
6376    #[test]
6377    fn count_x_deletes_many() {
6378        let mut e = editor_with("abcdef");
6379        run_keys(&mut e, "3x");
6380        assert_eq!(e.buffer().lines()[0], "def");
6381    }
6382
6383    // ─── Paste ────────────────────────────────────────────────────────────
6384
6385    #[test]
6386    fn p_pastes_charwise_after_cursor() {
6387        let mut e = editor_with("hello");
6388        run_keys(&mut e, "yw");
6389        run_keys(&mut e, "$p");
6390        assert_eq!(e.buffer().lines()[0], "hellohello");
6391    }
6392
6393    #[test]
6394    fn capital_p_pastes_charwise_before_cursor() {
6395        let mut e = editor_with("hello");
6396        // Yank "he" (2 chars) then paste it before the cursor.
6397        run_keys(&mut e, "v");
6398        run_keys(&mut e, "l");
6399        run_keys(&mut e, "y");
6400        run_keys(&mut e, "$P");
6401        // After yank cursor is at 0; $ goes to end (col 4), P pastes
6402        // before cursor — "hell" + "he" + "o" = "hellheo".
6403        assert_eq!(e.buffer().lines()[0], "hellheo");
6404    }
6405
6406    #[test]
6407    fn p_pastes_linewise_below() {
6408        let mut e = editor_with("one\ntwo\nthree");
6409        run_keys(&mut e, "yy");
6410        run_keys(&mut e, "p");
6411        assert_eq!(
6412            e.buffer().lines(),
6413            &[
6414                "one".to_string(),
6415                "one".to_string(),
6416                "two".to_string(),
6417                "three".to_string()
6418            ]
6419        );
6420    }
6421
6422    #[test]
6423    fn capital_p_pastes_linewise_above() {
6424        let mut e = editor_with("one\ntwo");
6425        e.jump_cursor(1, 0);
6426        run_keys(&mut e, "yy");
6427        run_keys(&mut e, "P");
6428        assert_eq!(
6429            e.buffer().lines(),
6430            &["one".to_string(), "two".to_string(), "two".to_string()]
6431        );
6432    }
6433
6434    // ─── Reverse word search ──────────────────────────────────────────────
6435
6436    #[test]
6437    fn hash_finds_previous_occurrence() {
6438        let mut e = editor_with("foo bar foo baz foo");
6439        // Move to the third 'foo' then #.
6440        e.jump_cursor(0, 16);
6441        run_keys(&mut e, "#");
6442        assert_eq!(e.cursor().1, 8);
6443    }
6444
6445    // ─── VisualLine delete / change ───────────────────────────────────────
6446
6447    #[test]
6448    fn visual_line_delete_removes_full_lines() {
6449        let mut e = editor_with("a\nb\nc\nd");
6450        run_keys(&mut e, "Vjd");
6451        assert_eq!(e.buffer().lines(), &["c".to_string(), "d".to_string()]);
6452    }
6453
6454    #[test]
6455    fn visual_line_change_leaves_blank_line() {
6456        let mut e = editor_with("a\nb\nc");
6457        run_keys(&mut e, "Vjc");
6458        assert_eq!(e.vim_mode(), VimMode::Insert);
6459        run_keys(&mut e, "X<Esc>");
6460        // `Vjc` wipes rows 0-1's contents and leaves a blank line in
6461        // their place (vim convention). Typing `X` lands on that blank
6462        // first line.
6463        assert_eq!(e.buffer().lines(), &["X".to_string(), "c".to_string()]);
6464    }
6465
6466    #[test]
6467    fn cc_leaves_blank_line() {
6468        let mut e = editor_with("a\nb\nc");
6469        e.jump_cursor(1, 0);
6470        run_keys(&mut e, "ccX<Esc>");
6471        assert_eq!(
6472            e.buffer().lines(),
6473            &["a".to_string(), "X".to_string(), "c".to_string()]
6474        );
6475    }
6476
6477    // ─── Scrolling ────────────────────────────────────────────────────────
6478
6479    // ─── WORD motions (W/B/E) ─────────────────────────────────────────────
6480
6481    #[test]
6482    fn big_w_skips_hyphens() {
6483        // `w` stops at `-`; `W` treats the whole `foo-bar` as one WORD.
6484        let mut e = editor_with("foo-bar baz");
6485        run_keys(&mut e, "W");
6486        assert_eq!(e.cursor().1, 8);
6487    }
6488
6489    #[test]
6490    fn big_w_crosses_lines() {
6491        let mut e = editor_with("foo-bar\nbaz-qux");
6492        run_keys(&mut e, "W");
6493        assert_eq!(e.cursor(), (1, 0));
6494    }
6495
6496    #[test]
6497    fn big_b_skips_hyphens() {
6498        let mut e = editor_with("foo-bar baz");
6499        e.jump_cursor(0, 9);
6500        run_keys(&mut e, "B");
6501        assert_eq!(e.cursor().1, 8);
6502        run_keys(&mut e, "B");
6503        assert_eq!(e.cursor().1, 0);
6504    }
6505
6506    #[test]
6507    fn big_e_jumps_to_big_word_end() {
6508        let mut e = editor_with("foo-bar baz");
6509        run_keys(&mut e, "E");
6510        assert_eq!(e.cursor().1, 6);
6511        run_keys(&mut e, "E");
6512        assert_eq!(e.cursor().1, 10);
6513    }
6514
6515    #[test]
6516    fn dw_with_big_word_variant() {
6517        // `dW` uses the WORD motion, so `foo-bar` deletes as a unit.
6518        let mut e = editor_with("foo-bar baz");
6519        run_keys(&mut e, "dW");
6520        assert_eq!(e.buffer().lines()[0], "baz");
6521    }
6522
6523    // ─── Insert-mode Ctrl shortcuts ──────────────────────────────────────
6524
6525    #[test]
6526    fn insert_ctrl_w_deletes_word_back() {
6527        let mut e = editor_with("");
6528        run_keys(&mut e, "i");
6529        for c in "hello world".chars() {
6530            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
6531        }
6532        run_keys(&mut e, "<C-w>");
6533        assert_eq!(e.buffer().lines()[0], "hello ");
6534    }
6535
6536    #[test]
6537    fn insert_ctrl_w_at_col0_joins_with_prev_word() {
6538        // Vim with default `backspace=indent,eol,start`: Ctrl-W at the
6539        // start of a row joins to the previous line and deletes the
6540        // word now before the cursor.
6541        let mut e = editor_with("hello\nworld");
6542        e.jump_cursor(1, 0);
6543        run_keys(&mut e, "i");
6544        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
6545        // "hello" was the only word on row 0; it gets deleted, leaving
6546        // "world" on a single line.
6547        assert_eq!(e.buffer().lines(), vec!["world".to_string()]);
6548        assert_eq!(e.cursor(), (0, 0));
6549    }
6550
6551    #[test]
6552    fn insert_ctrl_w_at_col0_keeps_prefix_words() {
6553        let mut e = editor_with("foo bar\nbaz");
6554        e.jump_cursor(1, 0);
6555        run_keys(&mut e, "i");
6556        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
6557        // Joins lines, then deletes the trailing "bar" of the prev line.
6558        assert_eq!(e.buffer().lines(), vec!["foo baz".to_string()]);
6559        assert_eq!(e.cursor(), (0, 4));
6560    }
6561
6562    #[test]
6563    fn insert_ctrl_u_deletes_to_line_start() {
6564        let mut e = editor_with("");
6565        run_keys(&mut e, "i");
6566        for c in "hello world".chars() {
6567            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
6568        }
6569        run_keys(&mut e, "<C-u>");
6570        assert_eq!(e.buffer().lines()[0], "");
6571    }
6572
6573    #[test]
6574    fn insert_ctrl_o_runs_one_normal_command() {
6575        let mut e = editor_with("hello world");
6576        // Enter insert, then Ctrl-o dw (delete a word while in insert).
6577        run_keys(&mut e, "A");
6578        assert_eq!(e.vim_mode(), VimMode::Insert);
6579        // Move cursor back to start of "hello" for the Ctrl-o dw.
6580        e.jump_cursor(0, 0);
6581        run_keys(&mut e, "<C-o>");
6582        assert_eq!(e.vim_mode(), VimMode::Normal);
6583        run_keys(&mut e, "dw");
6584        // After the command completes, back in insert.
6585        assert_eq!(e.vim_mode(), VimMode::Insert);
6586        assert_eq!(e.buffer().lines()[0], "world");
6587    }
6588
6589    // ─── Sticky column across vertical motion ────────────────────────────
6590
6591    #[test]
6592    fn j_through_empty_line_preserves_column() {
6593        let mut e = editor_with("hello world\n\nanother line");
6594        // Park cursor at col 6 on row 0.
6595        run_keys(&mut e, "llllll");
6596        assert_eq!(e.cursor(), (0, 6));
6597        // j into the empty line — cursor clamps to (1, 0) visually, but
6598        // sticky col stays at 6.
6599        run_keys(&mut e, "j");
6600        assert_eq!(e.cursor(), (1, 0));
6601        // j onto a longer row — sticky col restores us to col 6.
6602        run_keys(&mut e, "j");
6603        assert_eq!(e.cursor(), (2, 6));
6604    }
6605
6606    #[test]
6607    fn j_through_shorter_line_preserves_column() {
6608        let mut e = editor_with("hello world\nhi\nanother line");
6609        run_keys(&mut e, "lllllll"); // col 7
6610        run_keys(&mut e, "j"); // short line — clamps to col 1
6611        assert_eq!(e.cursor(), (1, 1));
6612        run_keys(&mut e, "j");
6613        assert_eq!(e.cursor(), (2, 7));
6614    }
6615
6616    #[test]
6617    fn esc_from_insert_sticky_matches_visible_cursor() {
6618        // Cursor at col 12, I (moves to col 4), type "X" (col 5), Esc
6619        // backs to col 4 — sticky must mirror that visible col so j
6620        // lands at col 4 of the next row, not col 5 or col 12.
6621        let mut e = editor_with("    this is a line\n    another one of a similar size");
6622        e.jump_cursor(0, 12);
6623        run_keys(&mut e, "I");
6624        assert_eq!(e.cursor(), (0, 4));
6625        run_keys(&mut e, "X<Esc>");
6626        assert_eq!(e.cursor(), (0, 4));
6627        run_keys(&mut e, "j");
6628        assert_eq!(e.cursor(), (1, 4));
6629    }
6630
6631    #[test]
6632    fn esc_from_insert_sticky_tracks_inserted_chars() {
6633        let mut e = editor_with("xxxxxxx\nyyyyyyy");
6634        run_keys(&mut e, "i");
6635        run_keys(&mut e, "abc<Esc>");
6636        assert_eq!(e.cursor(), (0, 2));
6637        run_keys(&mut e, "j");
6638        assert_eq!(e.cursor(), (1, 2));
6639    }
6640
6641    #[test]
6642    fn esc_from_insert_sticky_tracks_arrow_nav() {
6643        let mut e = editor_with("xxxxxx\nyyyyyy");
6644        run_keys(&mut e, "i");
6645        run_keys(&mut e, "abc");
6646        for _ in 0..2 {
6647            e.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
6648        }
6649        run_keys(&mut e, "<Esc>");
6650        assert_eq!(e.cursor(), (0, 0));
6651        run_keys(&mut e, "j");
6652        assert_eq!(e.cursor(), (1, 0));
6653    }
6654
6655    #[test]
6656    fn esc_from_insert_at_col_14_followed_by_j() {
6657        // User-reported regression: cursor at col 14, i, type "test "
6658        // (5 chars → col 19), Esc → col 18. j must land at col 18.
6659        let line = "x".repeat(30);
6660        let buf = format!("{line}\n{line}");
6661        let mut e = editor_with(&buf);
6662        e.jump_cursor(0, 14);
6663        run_keys(&mut e, "i");
6664        for c in "test ".chars() {
6665            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
6666        }
6667        run_keys(&mut e, "<Esc>");
6668        assert_eq!(e.cursor(), (0, 18));
6669        run_keys(&mut e, "j");
6670        assert_eq!(e.cursor(), (1, 18));
6671    }
6672
6673    #[test]
6674    fn linewise_paste_resets_sticky_column() {
6675        // yy then p lands the cursor on the first non-blank of the
6676        // pasted line; the next j must not drag back to the old
6677        // sticky column.
6678        let mut e = editor_with("    hello\naaaaaaaa\nbye");
6679        run_keys(&mut e, "llllll"); // col 6, sticky = 6
6680        run_keys(&mut e, "yy");
6681        run_keys(&mut e, "j"); // into row 1 col 6
6682        run_keys(&mut e, "p"); // paste below row 1 — cursor on "    hello"
6683        // Cursor should be at (2, 4) — first non-blank of the pasted line.
6684        assert_eq!(e.cursor(), (2, 4));
6685        // j should then preserve col 4, not jump back to 6.
6686        run_keys(&mut e, "j");
6687        assert_eq!(e.cursor(), (3, 2));
6688    }
6689
6690    #[test]
6691    fn horizontal_motion_resyncs_sticky_column() {
6692        // Starting col 6 on row 0, go back to col 3, then down through
6693        // an empty row. The sticky col should be 3 (from the last `h`
6694        // sequence), not 6.
6695        let mut e = editor_with("hello world\n\nanother line");
6696        run_keys(&mut e, "llllll"); // col 6
6697        run_keys(&mut e, "hhh"); // col 3
6698        run_keys(&mut e, "jj");
6699        assert_eq!(e.cursor(), (2, 3));
6700    }
6701
6702    // ─── Visual block ────────────────────────────────────────────────────
6703
6704    #[test]
6705    fn ctrl_v_enters_visual_block() {
6706        let mut e = editor_with("aaa\nbbb\nccc");
6707        run_keys(&mut e, "<C-v>");
6708        assert_eq!(e.vim_mode(), VimMode::VisualBlock);
6709    }
6710
6711    #[test]
6712    fn visual_block_esc_returns_to_normal() {
6713        let mut e = editor_with("aaa\nbbb\nccc");
6714        run_keys(&mut e, "<C-v>");
6715        run_keys(&mut e, "<Esc>");
6716        assert_eq!(e.vim_mode(), VimMode::Normal);
6717    }
6718
6719    #[test]
6720    fn visual_block_delete_removes_column_range() {
6721        let mut e = editor_with("hello\nworld\nhappy");
6722        // Move off col 0 first so the block starts mid-row.
6723        run_keys(&mut e, "l");
6724        run_keys(&mut e, "<C-v>");
6725        run_keys(&mut e, "jj");
6726        run_keys(&mut e, "ll");
6727        run_keys(&mut e, "d");
6728        // Deletes cols 1-3 on every row — "ell" / "orl" / "app".
6729        assert_eq!(
6730            e.buffer().lines(),
6731            &["ho".to_string(), "wd".to_string(), "hy".to_string()]
6732        );
6733    }
6734
6735    #[test]
6736    fn visual_block_yank_joins_with_newlines() {
6737        let mut e = editor_with("hello\nworld\nhappy");
6738        run_keys(&mut e, "<C-v>");
6739        run_keys(&mut e, "jj");
6740        run_keys(&mut e, "ll");
6741        run_keys(&mut e, "y");
6742        assert_eq!(e.last_yank.as_deref(), Some("hel\nwor\nhap"));
6743    }
6744
6745    #[test]
6746    fn visual_block_replace_fills_block() {
6747        let mut e = editor_with("hello\nworld\nhappy");
6748        run_keys(&mut e, "<C-v>");
6749        run_keys(&mut e, "jj");
6750        run_keys(&mut e, "ll");
6751        run_keys(&mut e, "rx");
6752        assert_eq!(
6753            e.buffer().lines(),
6754            &[
6755                "xxxlo".to_string(),
6756                "xxxld".to_string(),
6757                "xxxpy".to_string()
6758            ]
6759        );
6760    }
6761
6762    #[test]
6763    fn visual_block_insert_repeats_across_rows() {
6764        let mut e = editor_with("hello\nworld\nhappy");
6765        run_keys(&mut e, "<C-v>");
6766        run_keys(&mut e, "jj");
6767        run_keys(&mut e, "I");
6768        run_keys(&mut e, "# <Esc>");
6769        assert_eq!(
6770            e.buffer().lines(),
6771            &[
6772                "# hello".to_string(),
6773                "# world".to_string(),
6774                "# happy".to_string()
6775            ]
6776        );
6777    }
6778
6779    #[test]
6780    fn block_highlight_returns_none_outside_block_mode() {
6781        let mut e = editor_with("abc");
6782        assert!(e.block_highlight().is_none());
6783        run_keys(&mut e, "v");
6784        assert!(e.block_highlight().is_none());
6785        run_keys(&mut e, "<Esc>V");
6786        assert!(e.block_highlight().is_none());
6787    }
6788
6789    #[test]
6790    fn block_highlight_bounds_track_anchor_and_cursor() {
6791        let mut e = editor_with("aaaa\nbbbb\ncccc");
6792        run_keys(&mut e, "ll"); // cursor (0, 2)
6793        run_keys(&mut e, "<C-v>");
6794        run_keys(&mut e, "jh"); // cursor (1, 1)
6795        // anchor = (0, 2), cursor = (1, 1) → top=0 bot=1 left=1 right=2.
6796        assert_eq!(e.block_highlight(), Some((0, 1, 1, 2)));
6797    }
6798
6799    #[test]
6800    fn visual_block_delete_handles_short_lines() {
6801        // Middle row is shorter than the block's right column.
6802        let mut e = editor_with("hello\nhi\nworld");
6803        run_keys(&mut e, "l"); // col 1
6804        run_keys(&mut e, "<C-v>");
6805        run_keys(&mut e, "jjll"); // cursor (2, 3)
6806        run_keys(&mut e, "d");
6807        // Row 0: delete cols 1-3 ("ell") → "ho".
6808        // Row 1: only 2 chars ("hi"); block starts at col 1, so just "i"
6809        //        gets removed → "h".
6810        // Row 2: delete cols 1-3 ("orl") → "wd".
6811        assert_eq!(
6812            e.buffer().lines(),
6813            &["ho".to_string(), "h".to_string(), "wd".to_string()]
6814        );
6815    }
6816
6817    #[test]
6818    fn visual_block_yank_pads_short_lines_with_empties() {
6819        let mut e = editor_with("hello\nhi\nworld");
6820        run_keys(&mut e, "l");
6821        run_keys(&mut e, "<C-v>");
6822        run_keys(&mut e, "jjll");
6823        run_keys(&mut e, "y");
6824        // Row 0 chars 1-3 = "ell"; row 1 chars 1- (only "i"); row 2 "orl".
6825        assert_eq!(e.last_yank.as_deref(), Some("ell\ni\norl"));
6826    }
6827
6828    #[test]
6829    fn visual_block_replace_skips_past_eol() {
6830        // Block extends past the end of every row in column range;
6831        // replace should leave lines shorter than `left` untouched.
6832        let mut e = editor_with("ab\ncd\nef");
6833        // Put cursor at col 1 (last char), extend block 5 columns right.
6834        run_keys(&mut e, "l");
6835        run_keys(&mut e, "<C-v>");
6836        run_keys(&mut e, "jjllllll");
6837        run_keys(&mut e, "rX");
6838        // Every row had only col 0..=1; block covers col 1..=7 → only
6839        // col 1 is in range on each row, so just that cell changes.
6840        assert_eq!(
6841            e.buffer().lines(),
6842            &["aX".to_string(), "cX".to_string(), "eX".to_string()]
6843        );
6844    }
6845
6846    #[test]
6847    fn visual_block_with_empty_line_in_middle() {
6848        let mut e = editor_with("abcd\n\nefgh");
6849        run_keys(&mut e, "<C-v>");
6850        run_keys(&mut e, "jjll"); // cursor (2, 2)
6851        run_keys(&mut e, "d");
6852        // Row 0 cols 0-2 removed → "d". Row 1 empty → untouched.
6853        // Row 2 cols 0-2 removed → "h".
6854        assert_eq!(
6855            e.buffer().lines(),
6856            &["d".to_string(), "".to_string(), "h".to_string()]
6857        );
6858    }
6859
6860    #[test]
6861    fn block_insert_pads_empty_lines_to_block_column() {
6862        // Middle line is empty; block I at column 3 should pad the empty
6863        // line with spaces so the inserted text lines up.
6864        let mut e = editor_with("this is a line\n\nthis is a line");
6865        e.jump_cursor(0, 3);
6866        run_keys(&mut e, "<C-v>");
6867        run_keys(&mut e, "jj");
6868        run_keys(&mut e, "I");
6869        run_keys(&mut e, "XX<Esc>");
6870        assert_eq!(
6871            e.buffer().lines(),
6872            &[
6873                "thiXXs is a line".to_string(),
6874                "   XX".to_string(),
6875                "thiXXs is a line".to_string()
6876            ]
6877        );
6878    }
6879
6880    #[test]
6881    fn block_insert_pads_short_lines_to_block_column() {
6882        let mut e = editor_with("aaaaa\nbb\naaaaa");
6883        e.jump_cursor(0, 3);
6884        run_keys(&mut e, "<C-v>");
6885        run_keys(&mut e, "jj");
6886        run_keys(&mut e, "I");
6887        run_keys(&mut e, "Y<Esc>");
6888        // Row 1 "bb" is shorter than col 3 — pad with one space then Y.
6889        assert_eq!(
6890            e.buffer().lines(),
6891            &[
6892                "aaaYaa".to_string(),
6893                "bb Y".to_string(),
6894                "aaaYaa".to_string()
6895            ]
6896        );
6897    }
6898
6899    #[test]
6900    fn visual_block_append_repeats_across_rows() {
6901        let mut e = editor_with("foo\nbar\nbaz");
6902        run_keys(&mut e, "<C-v>");
6903        run_keys(&mut e, "jj");
6904        // Single-column block (anchor col = cursor col = 0); `A` appends
6905        // after column 0 on every row.
6906        run_keys(&mut e, "A");
6907        run_keys(&mut e, "!<Esc>");
6908        assert_eq!(
6909            e.buffer().lines(),
6910            &["f!oo".to_string(), "b!ar".to_string(), "b!az".to_string()]
6911        );
6912    }
6913
6914    // ─── `/` / `?` search prompt ─────────────────────────────────────────
6915
6916    #[test]
6917    fn slash_opens_forward_search_prompt() {
6918        let mut e = editor_with("hello world");
6919        run_keys(&mut e, "/");
6920        let p = e.search_prompt().expect("prompt should be active");
6921        assert!(p.text.is_empty());
6922        assert!(p.forward);
6923    }
6924
6925    #[test]
6926    fn question_opens_backward_search_prompt() {
6927        let mut e = editor_with("hello world");
6928        run_keys(&mut e, "?");
6929        let p = e.search_prompt().expect("prompt should be active");
6930        assert!(!p.forward);
6931    }
6932
6933    #[test]
6934    fn search_prompt_typing_updates_pattern_live() {
6935        let mut e = editor_with("foo bar\nbaz");
6936        run_keys(&mut e, "/bar");
6937        assert_eq!(e.search_prompt().unwrap().text, "bar");
6938        // Pattern set on the migration buffer for live highlight.
6939        assert!(e.buffer().search_pattern().is_some());
6940    }
6941
6942    #[test]
6943    fn search_prompt_backspace_and_enter() {
6944        let mut e = editor_with("hello world\nagain");
6945        run_keys(&mut e, "/worlx");
6946        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
6947        assert_eq!(e.search_prompt().unwrap().text, "worl");
6948        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6949        // Prompt closed, last_search set, cursor advanced to match.
6950        assert!(e.search_prompt().is_none());
6951        assert_eq!(e.last_search(), Some("worl"));
6952        assert_eq!(e.cursor(), (0, 6));
6953    }
6954
6955    #[test]
6956    fn empty_search_prompt_enter_repeats_last_search() {
6957        let mut e = editor_with("foo bar foo baz foo");
6958        run_keys(&mut e, "/foo");
6959        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6960        assert_eq!(e.cursor().1, 8);
6961        // Empty `/<CR>` should advance to the next match, not clear last_search.
6962        run_keys(&mut e, "/");
6963        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
6964        assert_eq!(e.cursor().1, 16);
6965        assert_eq!(e.last_search(), Some("foo"));
6966    }
6967
6968    #[test]
6969    fn search_history_records_committed_patterns() {
6970        let mut e = editor_with("alpha beta gamma");
6971        run_keys(&mut e, "/alpha<CR>");
6972        run_keys(&mut e, "/beta<CR>");
6973        // Newest entry at the back.
6974        let history = e.vim.search_history.clone();
6975        assert_eq!(history, vec!["alpha", "beta"]);
6976    }
6977
6978    #[test]
6979    fn search_history_dedupes_consecutive_repeats() {
6980        let mut e = editor_with("foo bar foo");
6981        run_keys(&mut e, "/foo<CR>");
6982        run_keys(&mut e, "/foo<CR>");
6983        run_keys(&mut e, "/bar<CR>");
6984        run_keys(&mut e, "/bar<CR>");
6985        // Two distinct entries; the duplicates collapsed.
6986        assert_eq!(e.vim.search_history.clone(), vec!["foo", "bar"]);
6987    }
6988
6989    #[test]
6990    fn ctrl_p_walks_history_backward() {
6991        let mut e = editor_with("alpha beta gamma");
6992        run_keys(&mut e, "/alpha<CR>");
6993        run_keys(&mut e, "/beta<CR>");
6994        // Open a fresh prompt; Ctrl-P pulls in the newest entry.
6995        run_keys(&mut e, "/");
6996        assert_eq!(e.search_prompt().unwrap().text, "");
6997        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
6998        assert_eq!(e.search_prompt().unwrap().text, "beta");
6999        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7000        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7001        // At the oldest entry; further Ctrl-P is a no-op.
7002        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7003        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7004    }
7005
7006    #[test]
7007    fn ctrl_n_walks_history_forward_after_ctrl_p() {
7008        let mut e = editor_with("a b c");
7009        run_keys(&mut e, "/a<CR>");
7010        run_keys(&mut e, "/b<CR>");
7011        run_keys(&mut e, "/c<CR>");
7012        run_keys(&mut e, "/");
7013        // Walk back to "a", then forward again.
7014        for _ in 0..3 {
7015            e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7016        }
7017        assert_eq!(e.search_prompt().unwrap().text, "a");
7018        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7019        assert_eq!(e.search_prompt().unwrap().text, "b");
7020        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7021        assert_eq!(e.search_prompt().unwrap().text, "c");
7022        // Past the newest — stays at "c".
7023        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7024        assert_eq!(e.search_prompt().unwrap().text, "c");
7025    }
7026
7027    #[test]
7028    fn typing_after_history_walk_resets_cursor() {
7029        let mut e = editor_with("foo");
7030        run_keys(&mut e, "/foo<CR>");
7031        run_keys(&mut e, "/");
7032        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7033        assert_eq!(e.search_prompt().unwrap().text, "foo");
7034        // User edits — append a char. Next Ctrl-P should restart from
7035        // the newest entry, not continue walking older.
7036        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
7037        assert_eq!(e.search_prompt().unwrap().text, "foox");
7038        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7039        assert_eq!(e.search_prompt().unwrap().text, "foo");
7040    }
7041
7042    #[test]
7043    fn empty_backward_search_prompt_enter_repeats_last_search() {
7044        let mut e = editor_with("foo bar foo baz foo");
7045        // Forward to col 8, then `?<CR>` should walk backward to col 0.
7046        run_keys(&mut e, "/foo");
7047        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7048        assert_eq!(e.cursor().1, 8);
7049        run_keys(&mut e, "?");
7050        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7051        assert_eq!(e.cursor().1, 0);
7052        assert_eq!(e.last_search(), Some("foo"));
7053    }
7054
7055    #[test]
7056    fn search_prompt_esc_cancels_but_keeps_last_search() {
7057        let mut e = editor_with("foo bar\nbaz");
7058        run_keys(&mut e, "/bar");
7059        e.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
7060        assert!(e.search_prompt().is_none());
7061        assert_eq!(e.last_search(), Some("bar"));
7062    }
7063
7064    #[test]
7065    fn search_then_n_and_shift_n_navigate() {
7066        let mut e = editor_with("foo bar foo baz foo");
7067        run_keys(&mut e, "/foo");
7068        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7069        // `/foo` + Enter jumps forward; we land on the next match after col 0.
7070        assert_eq!(e.cursor().1, 8);
7071        run_keys(&mut e, "n");
7072        assert_eq!(e.cursor().1, 16);
7073        run_keys(&mut e, "N");
7074        assert_eq!(e.cursor().1, 8);
7075    }
7076
7077    #[test]
7078    fn question_mark_searches_backward_on_enter() {
7079        let mut e = editor_with("foo bar foo baz");
7080        e.jump_cursor(0, 10);
7081        run_keys(&mut e, "?foo");
7082        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7083        // Cursor jumps backward to the closest match before col 10.
7084        assert_eq!(e.cursor(), (0, 8));
7085    }
7086
7087    // ─── P6 quick wins (Y, gJ, ge / gE) ──────────────────────────────────
7088
7089    #[test]
7090    fn big_y_yanks_to_end_of_line() {
7091        let mut e = editor_with("hello world");
7092        e.jump_cursor(0, 6);
7093        run_keys(&mut e, "Y");
7094        assert_eq!(e.last_yank.as_deref(), Some("world"));
7095    }
7096
7097    #[test]
7098    fn big_y_from_line_start_yanks_full_line() {
7099        let mut e = editor_with("hello world");
7100        run_keys(&mut e, "Y");
7101        assert_eq!(e.last_yank.as_deref(), Some("hello world"));
7102    }
7103
7104    #[test]
7105    fn gj_joins_without_inserting_space() {
7106        let mut e = editor_with("hello\n    world");
7107        run_keys(&mut e, "gJ");
7108        // No space inserted, leading whitespace preserved.
7109        assert_eq!(e.buffer().lines(), &["hello    world".to_string()]);
7110    }
7111
7112    #[test]
7113    fn gj_noop_on_last_line() {
7114        let mut e = editor_with("only");
7115        run_keys(&mut e, "gJ");
7116        assert_eq!(e.buffer().lines(), &["only".to_string()]);
7117    }
7118
7119    #[test]
7120    fn ge_jumps_to_previous_word_end() {
7121        let mut e = editor_with("foo bar baz");
7122        e.jump_cursor(0, 5);
7123        run_keys(&mut e, "ge");
7124        assert_eq!(e.cursor(), (0, 2));
7125    }
7126
7127    #[test]
7128    fn ge_respects_word_class() {
7129        // Small-word `ge` treats `-` as its own word, so from mid-"bar"
7130        // it lands on the `-` rather than end of "foo".
7131        let mut e = editor_with("foo-bar baz");
7132        e.jump_cursor(0, 5);
7133        run_keys(&mut e, "ge");
7134        assert_eq!(e.cursor(), (0, 3));
7135    }
7136
7137    #[test]
7138    fn big_ge_treats_hyphens_as_part_of_word() {
7139        // `gE` uses WORD (whitespace-delimited) semantics so it skips
7140        // over the `-` and lands on the end of "foo-bar".
7141        let mut e = editor_with("foo-bar baz");
7142        e.jump_cursor(0, 10);
7143        run_keys(&mut e, "gE");
7144        assert_eq!(e.cursor(), (0, 6));
7145    }
7146
7147    #[test]
7148    fn ge_crosses_line_boundary() {
7149        let mut e = editor_with("foo\nbar");
7150        e.jump_cursor(1, 0);
7151        run_keys(&mut e, "ge");
7152        assert_eq!(e.cursor(), (0, 2));
7153    }
7154
7155    #[test]
7156    fn dge_deletes_to_end_of_previous_word() {
7157        let mut e = editor_with("foo bar baz");
7158        e.jump_cursor(0, 8);
7159        // d + ge from 'b' of "baz": range is ge → col 6 ('r' of bar),
7160        // inclusive, so cols 6-8 ("r b") are cut.
7161        run_keys(&mut e, "dge");
7162        assert_eq!(e.buffer().lines()[0], "foo baaz");
7163    }
7164
7165    #[test]
7166    fn ctrl_scroll_keys_do_not_panic() {
7167        // Viewport-less test: just exercise the code paths so a regression
7168        // in the scroll dispatch surfaces as a panic or assertion failure.
7169        let mut e = editor_with(
7170            (0..50)
7171                .map(|i| format!("line{i}"))
7172                .collect::<Vec<_>>()
7173                .join("\n")
7174                .as_str(),
7175        );
7176        run_keys(&mut e, "<C-f>");
7177        run_keys(&mut e, "<C-b>");
7178        // No explicit assert beyond "didn't panic".
7179        assert!(!e.buffer().lines().is_empty());
7180    }
7181
7182    /// Regression: arrow-navigation during a count-insert session must
7183    /// not pull unrelated rows into the "inserted" replay string.
7184    /// Before the fix, `before_lines` only snapshotted the entry row,
7185    /// so the diff at Esc spuriously saw the navigated-over row as
7186    /// part of the insert — count-replay then duplicated cross-row
7187    /// content across the buffer.
7188    #[test]
7189    fn count_insert_with_arrow_nav_does_not_leak_rows() {
7190        let mut e = Editor::new(KeybindingMode::Vim);
7191        e.set_content("row0\nrow1\nrow2");
7192        // `3i`, type X, arrow down, Esc.
7193        run_keys(&mut e, "3iX<Down><Esc>");
7194        // Row 0 keeps the originally-typed X.
7195        assert!(e.buffer().lines()[0].contains('X'));
7196        // Row 1 must not contain a fragment of row 0 ("row0") — that
7197        // was the buggy leak from the before-diff window.
7198        assert!(
7199            !e.buffer().lines()[1].contains("row0"),
7200            "row1 leaked row0 contents: {:?}",
7201            e.buffer().lines()[1]
7202        );
7203        // Buffer stays the same number of rows — no extra lines
7204        // injected by a multi-line "inserted" replay.
7205        assert_eq!(e.buffer().lines().len(), 3);
7206    }
7207
7208    // ─── Viewport scroll / jump tests ─────────────────────────────────
7209
7210    fn editor_with_rows(n: usize, viewport: u16) -> Editor<'static> {
7211        let mut e = Editor::new(KeybindingMode::Vim);
7212        let body = (0..n)
7213            .map(|i| format!("  line{}", i))
7214            .collect::<Vec<_>>()
7215            .join("\n");
7216        e.set_content(&body);
7217        e.set_viewport_height(viewport);
7218        e
7219    }
7220
7221    #[test]
7222    fn ctrl_d_moves_cursor_half_page_down() {
7223        let mut e = editor_with_rows(100, 20);
7224        run_keys(&mut e, "<C-d>");
7225        assert_eq!(e.cursor().0, 10);
7226    }
7227
7228    fn editor_with_wrap_lines(lines: &[&str], viewport: u16, text_width: u16) -> Editor<'static> {
7229        let mut e = Editor::new(KeybindingMode::Vim);
7230        e.set_content(&lines.join("\n"));
7231        e.set_viewport_height(viewport);
7232        let v = e.buffer_mut().viewport_mut();
7233        v.height = viewport;
7234        v.width = text_width;
7235        v.text_width = text_width;
7236        v.wrap = hjkl_buffer::Wrap::Char;
7237        e.settings_mut().wrap = hjkl_buffer::Wrap::Char;
7238        e
7239    }
7240
7241    #[test]
7242    fn scrolloff_wrap_keeps_cursor_off_bottom_edge() {
7243        // 10 doc rows, each wraps to 3 segments → 30 screen rows.
7244        // Viewport height 12, margin = SCROLLOFF.min(11/2) = 5,
7245        // max bottom = 11 - 5 = 6. Plenty of headroom past row 4.
7246        let lines = ["aaaabbbbcccc"; 10];
7247        let mut e = editor_with_wrap_lines(&lines, 12, 4);
7248        e.jump_cursor(4, 0);
7249        e.ensure_cursor_in_scrolloff();
7250        let csr = e.buffer().cursor_screen_row().unwrap();
7251        assert!(csr <= 6, "csr={csr}");
7252    }
7253
7254    #[test]
7255    fn scrolloff_wrap_keeps_cursor_off_top_edge() {
7256        let lines = ["aaaabbbbcccc"; 10];
7257        let mut e = editor_with_wrap_lines(&lines, 12, 4);
7258        // Force top down then bring cursor up so the top-edge margin
7259        // path runs.
7260        e.jump_cursor(7, 0);
7261        e.ensure_cursor_in_scrolloff();
7262        e.jump_cursor(2, 0);
7263        e.ensure_cursor_in_scrolloff();
7264        let csr = e.buffer().cursor_screen_row().unwrap();
7265        // SCROLLOFF.min((height - 1) / 2) = 5.min(5) = 5.
7266        assert!(csr >= 5, "csr={csr}");
7267    }
7268
7269    #[test]
7270    fn scrolloff_wrap_clamps_top_at_buffer_end() {
7271        let lines = ["aaaabbbbcccc"; 5];
7272        let mut e = editor_with_wrap_lines(&lines, 12, 4);
7273        e.jump_cursor(4, 11);
7274        e.ensure_cursor_in_scrolloff();
7275        // max_top_for_height(12) on 15 screen rows: row 4 (3 segs) +
7276        // row 3 (3 segs) + row 2 (3 segs) + row 1 (3 segs) = 12 —
7277        // max_top = row 1. Margin can't be honoured at EOF (matches
7278        // vim's behaviour — scrolloff is a soft constraint).
7279        let top = e.buffer().viewport().top_row;
7280        assert_eq!(top, 1);
7281    }
7282
7283    #[test]
7284    fn ctrl_u_moves_cursor_half_page_up() {
7285        let mut e = editor_with_rows(100, 20);
7286        e.jump_cursor(50, 0);
7287        run_keys(&mut e, "<C-u>");
7288        assert_eq!(e.cursor().0, 40);
7289    }
7290
7291    #[test]
7292    fn ctrl_f_moves_cursor_full_page_down() {
7293        let mut e = editor_with_rows(100, 20);
7294        run_keys(&mut e, "<C-f>");
7295        // One full page ≈ h - 2 (overlap).
7296        assert_eq!(e.cursor().0, 18);
7297    }
7298
7299    #[test]
7300    fn ctrl_b_moves_cursor_full_page_up() {
7301        let mut e = editor_with_rows(100, 20);
7302        e.jump_cursor(50, 0);
7303        run_keys(&mut e, "<C-b>");
7304        assert_eq!(e.cursor().0, 32);
7305    }
7306
7307    #[test]
7308    fn ctrl_d_lands_on_first_non_blank() {
7309        let mut e = editor_with_rows(100, 20);
7310        run_keys(&mut e, "<C-d>");
7311        // "  line10" — first non-blank is col 2.
7312        assert_eq!(e.cursor().1, 2);
7313    }
7314
7315    #[test]
7316    fn ctrl_d_clamps_at_end_of_buffer() {
7317        let mut e = editor_with_rows(5, 20);
7318        run_keys(&mut e, "<C-d>");
7319        assert_eq!(e.cursor().0, 4);
7320    }
7321
7322    #[test]
7323    fn capital_h_jumps_to_viewport_top() {
7324        let mut e = editor_with_rows(100, 10);
7325        e.jump_cursor(50, 0);
7326        e.set_viewport_top(45);
7327        let top = e.buffer().viewport().top_row;
7328        run_keys(&mut e, "H");
7329        assert_eq!(e.cursor().0, top);
7330        assert_eq!(e.cursor().1, 2);
7331    }
7332
7333    #[test]
7334    fn capital_l_jumps_to_viewport_bottom() {
7335        let mut e = editor_with_rows(100, 10);
7336        e.jump_cursor(50, 0);
7337        e.set_viewport_top(45);
7338        let top = e.buffer().viewport().top_row;
7339        run_keys(&mut e, "L");
7340        assert_eq!(e.cursor().0, top + 9);
7341    }
7342
7343    #[test]
7344    fn capital_m_jumps_to_viewport_middle() {
7345        let mut e = editor_with_rows(100, 10);
7346        e.jump_cursor(50, 0);
7347        e.set_viewport_top(45);
7348        let top = e.buffer().viewport().top_row;
7349        run_keys(&mut e, "M");
7350        // 10-row viewport: middle is top + 4.
7351        assert_eq!(e.cursor().0, top + 4);
7352    }
7353
7354    #[test]
7355    fn g_capital_m_lands_at_line_midpoint() {
7356        let mut e = editor_with("hello world!"); // 12 chars
7357        run_keys(&mut e, "gM");
7358        // floor(12 / 2) = 6.
7359        assert_eq!(e.cursor(), (0, 6));
7360    }
7361
7362    #[test]
7363    fn g_capital_m_on_empty_line_stays_at_zero() {
7364        let mut e = editor_with("");
7365        run_keys(&mut e, "gM");
7366        assert_eq!(e.cursor(), (0, 0));
7367    }
7368
7369    #[test]
7370    fn g_capital_m_uses_current_line_only() {
7371        // Each line's midpoint is independent of others.
7372        let mut e = editor_with("a\nlonglongline"); // line 1: 12 chars
7373        e.jump_cursor(1, 0);
7374        run_keys(&mut e, "gM");
7375        assert_eq!(e.cursor(), (1, 6));
7376    }
7377
7378    #[test]
7379    fn capital_h_count_offsets_from_top() {
7380        let mut e = editor_with_rows(100, 10);
7381        e.jump_cursor(50, 0);
7382        e.set_viewport_top(45);
7383        let top = e.buffer().viewport().top_row;
7384        run_keys(&mut e, "3H");
7385        assert_eq!(e.cursor().0, top + 2);
7386    }
7387
7388    // ─── Jumplist tests ───────────────────────────────────────────────
7389
7390    #[test]
7391    fn ctrl_o_returns_to_pre_g_position() {
7392        let mut e = editor_with_rows(50, 20);
7393        e.jump_cursor(5, 2);
7394        run_keys(&mut e, "G");
7395        assert_eq!(e.cursor().0, 49);
7396        run_keys(&mut e, "<C-o>");
7397        assert_eq!(e.cursor(), (5, 2));
7398    }
7399
7400    #[test]
7401    fn ctrl_i_redoes_jump_after_ctrl_o() {
7402        let mut e = editor_with_rows(50, 20);
7403        e.jump_cursor(5, 2);
7404        run_keys(&mut e, "G");
7405        let post = e.cursor();
7406        run_keys(&mut e, "<C-o>");
7407        run_keys(&mut e, "<C-i>");
7408        assert_eq!(e.cursor(), post);
7409    }
7410
7411    #[test]
7412    fn new_jump_clears_forward_stack() {
7413        let mut e = editor_with_rows(50, 20);
7414        e.jump_cursor(5, 2);
7415        run_keys(&mut e, "G");
7416        run_keys(&mut e, "<C-o>");
7417        run_keys(&mut e, "gg");
7418        run_keys(&mut e, "<C-i>");
7419        assert_eq!(e.cursor().0, 0);
7420    }
7421
7422    #[test]
7423    fn ctrl_o_on_empty_stack_is_noop() {
7424        let mut e = editor_with_rows(10, 20);
7425        e.jump_cursor(3, 1);
7426        run_keys(&mut e, "<C-o>");
7427        assert_eq!(e.cursor(), (3, 1));
7428    }
7429
7430    #[test]
7431    fn asterisk_search_pushes_jump() {
7432        let mut e = editor_with("foo bar\nbaz foo end");
7433        e.jump_cursor(0, 0);
7434        run_keys(&mut e, "*");
7435        let after = e.cursor();
7436        assert_ne!(after, (0, 0));
7437        run_keys(&mut e, "<C-o>");
7438        assert_eq!(e.cursor(), (0, 0));
7439    }
7440
7441    #[test]
7442    fn h_viewport_jump_is_recorded() {
7443        let mut e = editor_with_rows(100, 10);
7444        e.jump_cursor(50, 0);
7445        e.set_viewport_top(45);
7446        let pre = e.cursor();
7447        run_keys(&mut e, "H");
7448        assert_ne!(e.cursor(), pre);
7449        run_keys(&mut e, "<C-o>");
7450        assert_eq!(e.cursor(), pre);
7451    }
7452
7453    #[test]
7454    fn j_k_motion_does_not_push_jump() {
7455        let mut e = editor_with_rows(50, 20);
7456        e.jump_cursor(5, 0);
7457        run_keys(&mut e, "jjj");
7458        run_keys(&mut e, "<C-o>");
7459        assert_eq!(e.cursor().0, 8);
7460    }
7461
7462    #[test]
7463    fn jumplist_caps_at_100() {
7464        let mut e = editor_with_rows(200, 20);
7465        for i in 0..101 {
7466            e.jump_cursor(i, 0);
7467            run_keys(&mut e, "G");
7468        }
7469        assert!(e.vim.jump_back.len() <= 100);
7470    }
7471
7472    #[test]
7473    fn tab_acts_as_ctrl_i() {
7474        let mut e = editor_with_rows(50, 20);
7475        e.jump_cursor(5, 2);
7476        run_keys(&mut e, "G");
7477        let post = e.cursor();
7478        run_keys(&mut e, "<C-o>");
7479        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
7480        assert_eq!(e.cursor(), post);
7481    }
7482
7483    // ─── Mark tests ───────────────────────────────────────────────────
7484
7485    #[test]
7486    fn ma_then_backtick_a_jumps_exact() {
7487        let mut e = editor_with_rows(50, 20);
7488        e.jump_cursor(5, 3);
7489        run_keys(&mut e, "ma");
7490        e.jump_cursor(20, 0);
7491        run_keys(&mut e, "`a");
7492        assert_eq!(e.cursor(), (5, 3));
7493    }
7494
7495    #[test]
7496    fn ma_then_apostrophe_a_lands_on_first_non_blank() {
7497        let mut e = editor_with_rows(50, 20);
7498        // "  line5" — first non-blank is col 2.
7499        e.jump_cursor(5, 6);
7500        run_keys(&mut e, "ma");
7501        e.jump_cursor(30, 4);
7502        run_keys(&mut e, "'a");
7503        assert_eq!(e.cursor(), (5, 2));
7504    }
7505
7506    #[test]
7507    fn goto_mark_pushes_jumplist() {
7508        let mut e = editor_with_rows(50, 20);
7509        e.jump_cursor(10, 2);
7510        run_keys(&mut e, "mz");
7511        e.jump_cursor(3, 0);
7512        run_keys(&mut e, "`z");
7513        assert_eq!(e.cursor(), (10, 2));
7514        run_keys(&mut e, "<C-o>");
7515        assert_eq!(e.cursor(), (3, 0));
7516    }
7517
7518    #[test]
7519    fn goto_missing_mark_is_noop() {
7520        let mut e = editor_with_rows(50, 20);
7521        e.jump_cursor(3, 1);
7522        run_keys(&mut e, "`q");
7523        assert_eq!(e.cursor(), (3, 1));
7524    }
7525
7526    #[test]
7527    fn uppercase_mark_letter_ignored() {
7528        let mut e = editor_with_rows(50, 20);
7529        e.jump_cursor(5, 3);
7530        run_keys(&mut e, "mA");
7531        // Uppercase marks aren't supported — entry bailed, nothing
7532        // stored under 'a' or 'A'.
7533        assert!(e.vim.marks.is_empty());
7534    }
7535
7536    #[test]
7537    fn mark_survives_document_shrink_via_clamp() {
7538        let mut e = editor_with_rows(50, 20);
7539        e.jump_cursor(40, 4);
7540        run_keys(&mut e, "mx");
7541        // Shrink the buffer to 10 rows.
7542        e.set_content("a\nb\nc\nd\ne");
7543        run_keys(&mut e, "`x");
7544        // Mark clamped to last row, col 0 (short line).
7545        let (r, _) = e.cursor();
7546        assert!(r <= 4);
7547    }
7548
7549    #[test]
7550    fn g_semicolon_walks_back_through_edits() {
7551        let mut e = editor_with("alpha\nbeta\ngamma");
7552        // Two distinct edits — cells (0, 0) → InsertChar lands cursor
7553        // at (0, 1), (2, 0) → (2, 1).
7554        e.jump_cursor(0, 0);
7555        run_keys(&mut e, "iX<Esc>");
7556        e.jump_cursor(2, 0);
7557        run_keys(&mut e, "iY<Esc>");
7558        // First g; lands on the most recent entry's exact cell.
7559        run_keys(&mut e, "g;");
7560        assert_eq!(e.cursor(), (2, 1));
7561        // Second g; walks to the older entry.
7562        run_keys(&mut e, "g;");
7563        assert_eq!(e.cursor(), (0, 1));
7564        // Past the oldest — no-op.
7565        run_keys(&mut e, "g;");
7566        assert_eq!(e.cursor(), (0, 1));
7567    }
7568
7569    #[test]
7570    fn g_comma_walks_forward_after_g_semicolon() {
7571        let mut e = editor_with("a\nb\nc");
7572        e.jump_cursor(0, 0);
7573        run_keys(&mut e, "iX<Esc>");
7574        e.jump_cursor(2, 0);
7575        run_keys(&mut e, "iY<Esc>");
7576        run_keys(&mut e, "g;");
7577        run_keys(&mut e, "g;");
7578        assert_eq!(e.cursor(), (0, 1));
7579        run_keys(&mut e, "g,");
7580        assert_eq!(e.cursor(), (2, 1));
7581    }
7582
7583    #[test]
7584    fn new_edit_during_walk_trims_forward_entries() {
7585        let mut e = editor_with("a\nb\nc\nd");
7586        e.jump_cursor(0, 0);
7587        run_keys(&mut e, "iX<Esc>"); // entry 0 → (0, 1)
7588        e.jump_cursor(2, 0);
7589        run_keys(&mut e, "iY<Esc>"); // entry 1 → (2, 1)
7590        // Walk back twice to land on entry 0.
7591        run_keys(&mut e, "g;");
7592        run_keys(&mut e, "g;");
7593        assert_eq!(e.cursor(), (0, 1));
7594        // New edit while walking discards entries forward of the cursor.
7595        run_keys(&mut e, "iZ<Esc>");
7596        // No newer entry left to walk to.
7597        run_keys(&mut e, "g,");
7598        // Cursor stays where the latest edit landed it.
7599        assert_ne!(e.cursor(), (2, 1));
7600    }
7601
7602    #[test]
7603    fn gqq_reflows_current_line_to_textwidth() {
7604        let mut e = editor_with("alpha beta gamma delta epsilon zeta eta theta iota");
7605        crate::ex::run(&mut e, "set tw=20");
7606        assert_eq!(e.settings().textwidth, 20);
7607        run_keys(&mut e, "gqq");
7608        // Each output line should fit within 20 chars.
7609        for line in e.buffer().lines() {
7610            assert!(line.chars().count() <= 20, "line too long: {line:?}");
7611        }
7612        // Output is split across multiple rows now.
7613        assert!(e.buffer().lines().len() > 1);
7614    }
7615
7616    #[test]
7617    fn gq_motion_reflows_paragraph() {
7618        let mut e = editor_with("one two three\nfour five six\nseven eight\n\ntail");
7619        crate::ex::run(&mut e, "set tw=15");
7620        e.jump_cursor(0, 0);
7621        // gq} reflows up to the next blank line.
7622        run_keys(&mut e, "gq}");
7623        // Last row past the blank stays untouched.
7624        assert_eq!(e.buffer().lines().last().unwrap(), "tail");
7625    }
7626
7627    #[test]
7628    fn gq_preserves_paragraph_breaks() {
7629        let mut e = editor_with("alpha beta gamma\n\ndelta epsilon zeta");
7630        crate::ex::run(&mut e, "set tw=10");
7631        run_keys(&mut e, "ggVGgq");
7632        // The blank line between the two paragraphs survives the
7633        // reflow.
7634        let blanks = e.buffer().lines().iter().filter(|l| l.is_empty()).count();
7635        assert_eq!(blanks, 1);
7636    }
7637
7638    #[test]
7639    fn gqq_undo_restores_original_line() {
7640        let mut e = editor_with("a b c d e f g h i j k l m n o p");
7641        crate::ex::run(&mut e, "set tw=10");
7642        let before: Vec<String> = e.buffer().lines().to_vec();
7643        run_keys(&mut e, "gqq");
7644        crate::vim::do_undo(&mut e);
7645        assert_eq!(e.buffer().lines(), before);
7646    }
7647
7648    #[test]
7649    fn capital_mark_set_and_jump() {
7650        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
7651        e.jump_cursor(2, 1);
7652        run_keys(&mut e, "mA");
7653        // Move away.
7654        e.jump_cursor(0, 0);
7655        // Jump back via `'A`.
7656        run_keys(&mut e, "'A");
7657        // Linewise jump → row preserved, col first non-blank (here 0).
7658        assert_eq!(e.cursor().0, 2);
7659    }
7660
7661    #[test]
7662    fn capital_mark_survives_set_content() {
7663        let mut e = editor_with("first buffer line\nsecond");
7664        e.jump_cursor(1, 3);
7665        run_keys(&mut e, "mA");
7666        // Swap buffer content (host loading a different tab).
7667        e.set_content("totally different content\non many\nrows of text");
7668        // `'A` should still jump to (1, 3) — it survived the swap.
7669        e.jump_cursor(0, 0);
7670        run_keys(&mut e, "'A");
7671        assert_eq!(e.cursor().0, 1);
7672    }
7673
7674    #[test]
7675    fn capital_mark_shows_in_marks_listing() {
7676        let mut e = editor_with("a\nb\nc");
7677        e.jump_cursor(2, 0);
7678        run_keys(&mut e, "mZ");
7679        e.jump_cursor(0, 0);
7680        run_keys(&mut e, "ma");
7681        let info = match crate::ex::run(&mut e, "marks") {
7682            crate::ex::ExEffect::Info(s) => s,
7683            other => panic!("expected Info, got {other:?}"),
7684        };
7685        assert!(info.contains(" a "));
7686        assert!(info.contains(" Z "));
7687    }
7688
7689    #[test]
7690    fn capital_mark_shifts_with_edit() {
7691        let mut e = editor_with("a\nb\nc\nd");
7692        e.jump_cursor(3, 0);
7693        run_keys(&mut e, "mA");
7694        // Delete the first row — `A` should shift up to row 2.
7695        e.jump_cursor(0, 0);
7696        run_keys(&mut e, "dd");
7697        e.jump_cursor(0, 0);
7698        run_keys(&mut e, "'A");
7699        assert_eq!(e.cursor().0, 2);
7700    }
7701
7702    #[test]
7703    fn mark_below_delete_shifts_up() {
7704        let mut e = editor_with("a\nb\nc\nd\ne");
7705        // Set mark `a` on row 3 (the `d`).
7706        e.jump_cursor(3, 0);
7707        run_keys(&mut e, "ma");
7708        // Go back to row 0 and `dd`.
7709        e.jump_cursor(0, 0);
7710        run_keys(&mut e, "dd");
7711        // Mark `a` should now point at row 2 — its content stayed `d`.
7712        e.jump_cursor(0, 0);
7713        run_keys(&mut e, "'a");
7714        assert_eq!(e.cursor().0, 2);
7715        assert_eq!(e.buffer().line(2).unwrap(), "d");
7716    }
7717
7718    #[test]
7719    fn mark_on_deleted_row_is_dropped() {
7720        let mut e = editor_with("a\nb\nc\nd");
7721        // Mark `a` on row 1 (`b`).
7722        e.jump_cursor(1, 0);
7723        run_keys(&mut e, "ma");
7724        // Delete row 1.
7725        run_keys(&mut e, "dd");
7726        // The row that held `a` is gone; `'a` should be a no-op now.
7727        e.jump_cursor(2, 0);
7728        run_keys(&mut e, "'a");
7729        // Cursor stays on row 2 — `'a` no-ops on missing marks.
7730        assert_eq!(e.cursor().0, 2);
7731    }
7732
7733    #[test]
7734    fn mark_above_edit_unchanged() {
7735        let mut e = editor_with("a\nb\nc\nd\ne");
7736        // Mark `a` on row 0.
7737        e.jump_cursor(0, 0);
7738        run_keys(&mut e, "ma");
7739        // Delete row 3.
7740        e.jump_cursor(3, 0);
7741        run_keys(&mut e, "dd");
7742        // Mark `a` should still point at row 0.
7743        e.jump_cursor(2, 0);
7744        run_keys(&mut e, "'a");
7745        assert_eq!(e.cursor().0, 0);
7746    }
7747
7748    #[test]
7749    fn mark_shifts_down_after_insert() {
7750        let mut e = editor_with("a\nb\nc");
7751        // Mark `a` on row 2 (`c`).
7752        e.jump_cursor(2, 0);
7753        run_keys(&mut e, "ma");
7754        // Open a new line above row 0 with `O\nfoo<Esc>`.
7755        e.jump_cursor(0, 0);
7756        run_keys(&mut e, "Onew<Esc>");
7757        // Buffer is now ["new", "a", "b", "c"]; mark `a` should track
7758        // the original content row → 3.
7759        e.jump_cursor(0, 0);
7760        run_keys(&mut e, "'a");
7761        assert_eq!(e.cursor().0, 3);
7762        assert_eq!(e.buffer().line(3).unwrap(), "c");
7763    }
7764
7765    // ─── Search / jumplist interaction ───────────────────────────────
7766
7767    #[test]
7768    fn forward_search_commit_pushes_jump() {
7769        let mut e = editor_with("alpha beta\nfoo target end\nmore");
7770        e.jump_cursor(0, 0);
7771        run_keys(&mut e, "/target<CR>");
7772        // Cursor moved to the match.
7773        assert_ne!(e.cursor(), (0, 0));
7774        // Ctrl-o returns to the pre-search position.
7775        run_keys(&mut e, "<C-o>");
7776        assert_eq!(e.cursor(), (0, 0));
7777    }
7778
7779    #[test]
7780    fn search_commit_no_match_does_not_push_jump() {
7781        let mut e = editor_with("alpha beta\nfoo end");
7782        e.jump_cursor(0, 3);
7783        let pre_len = e.vim.jump_back.len();
7784        run_keys(&mut e, "/zzznotfound<CR>");
7785        // No match → cursor stays, jumplist shouldn't grow.
7786        assert_eq!(e.vim.jump_back.len(), pre_len);
7787    }
7788
7789    // ─── Phase 7b: migration buffer cursor sync ──────────────────────
7790
7791    #[test]
7792    fn buffer_cursor_mirrors_textarea_after_horizontal_motion() {
7793        let mut e = editor_with("hello world");
7794        run_keys(&mut e, "lll");
7795        let (row, col) = e.cursor();
7796        assert_eq!(e.buffer.cursor().row, row);
7797        assert_eq!(e.buffer.cursor().col, col);
7798    }
7799
7800    #[test]
7801    fn buffer_cursor_mirrors_textarea_after_vertical_motion() {
7802        let mut e = editor_with("aaaa\nbbbb\ncccc");
7803        run_keys(&mut e, "jj");
7804        let (row, col) = e.cursor();
7805        assert_eq!(e.buffer.cursor().row, row);
7806        assert_eq!(e.buffer.cursor().col, col);
7807    }
7808
7809    #[test]
7810    fn buffer_cursor_mirrors_textarea_after_word_motion() {
7811        let mut e = editor_with("foo bar baz");
7812        run_keys(&mut e, "ww");
7813        let (row, col) = e.cursor();
7814        assert_eq!(e.buffer.cursor().row, row);
7815        assert_eq!(e.buffer.cursor().col, col);
7816    }
7817
7818    #[test]
7819    fn buffer_cursor_mirrors_textarea_after_jump_motion() {
7820        let mut e = editor_with("a\nb\nc\nd\ne");
7821        run_keys(&mut e, "G");
7822        let (row, col) = e.cursor();
7823        assert_eq!(e.buffer.cursor().row, row);
7824        assert_eq!(e.buffer.cursor().col, col);
7825    }
7826
7827    #[test]
7828    fn buffer_sticky_col_mirrors_vim_state() {
7829        let mut e = editor_with("longline\nhi\nlongline");
7830        run_keys(&mut e, "fl");
7831        run_keys(&mut e, "j");
7832        // Sticky col should be set; buffer carries the same value.
7833        assert_eq!(e.buffer.sticky_col(), e.vim.sticky_col);
7834    }
7835
7836    #[test]
7837    fn buffer_content_mirrors_textarea_after_insert() {
7838        let mut e = editor_with("hello");
7839        run_keys(&mut e, "iXYZ<Esc>");
7840        let text = e.buffer().lines().join("\n");
7841        assert_eq!(e.buffer.as_string(), text);
7842    }
7843
7844    #[test]
7845    fn buffer_content_mirrors_textarea_after_delete() {
7846        let mut e = editor_with("alpha bravo charlie");
7847        run_keys(&mut e, "dw");
7848        let text = e.buffer().lines().join("\n");
7849        assert_eq!(e.buffer.as_string(), text);
7850    }
7851
7852    #[test]
7853    fn buffer_content_mirrors_textarea_after_dd() {
7854        let mut e = editor_with("a\nb\nc\nd");
7855        run_keys(&mut e, "jdd");
7856        let text = e.buffer().lines().join("\n");
7857        assert_eq!(e.buffer.as_string(), text);
7858    }
7859
7860    #[test]
7861    fn buffer_content_mirrors_textarea_after_open_line() {
7862        let mut e = editor_with("foo\nbar");
7863        run_keys(&mut e, "oNEW<Esc>");
7864        let text = e.buffer().lines().join("\n");
7865        assert_eq!(e.buffer.as_string(), text);
7866    }
7867
7868    #[test]
7869    fn buffer_content_mirrors_textarea_after_paste() {
7870        let mut e = editor_with("hello");
7871        run_keys(&mut e, "yy");
7872        run_keys(&mut e, "p");
7873        let text = e.buffer().lines().join("\n");
7874        assert_eq!(e.buffer.as_string(), text);
7875    }
7876
7877    #[test]
7878    fn buffer_selection_none_in_normal_mode() {
7879        let e = editor_with("foo bar");
7880        assert!(e.buffer_selection().is_none());
7881    }
7882
7883    #[test]
7884    fn buffer_selection_char_in_visual_mode() {
7885        use hjkl_buffer::{Position, Selection};
7886        let mut e = editor_with("hello world");
7887        run_keys(&mut e, "vlll");
7888        assert_eq!(
7889            e.buffer_selection(),
7890            Some(Selection::Char {
7891                anchor: Position::new(0, 0),
7892                head: Position::new(0, 3),
7893            })
7894        );
7895    }
7896
7897    #[test]
7898    fn buffer_selection_line_in_visual_line_mode() {
7899        use hjkl_buffer::Selection;
7900        let mut e = editor_with("a\nb\nc\nd");
7901        run_keys(&mut e, "Vj");
7902        assert_eq!(
7903            e.buffer_selection(),
7904            Some(Selection::Line {
7905                anchor_row: 0,
7906                head_row: 1,
7907            })
7908        );
7909    }
7910
7911    #[test]
7912    fn intern_style_dedups_repeated_styles() {
7913        use ratatui::style::{Color, Style};
7914        let mut e = editor_with("");
7915        let red = Style::default().fg(Color::Red);
7916        let blue = Style::default().fg(Color::Blue);
7917        let id_r1 = e.intern_style(red);
7918        let id_r2 = e.intern_style(red);
7919        let id_b = e.intern_style(blue);
7920        assert_eq!(id_r1, id_r2);
7921        assert_ne!(id_r1, id_b);
7922        assert_eq!(e.style_table().len(), 2);
7923    }
7924
7925    #[test]
7926    fn install_syntax_spans_translates_styled_spans() {
7927        use ratatui::style::{Color, Style};
7928        let mut e = editor_with("SELECT foo");
7929        e.install_syntax_spans(vec![vec![(0, 6, Style::default().fg(Color::Red))]]);
7930        let by_row = e.buffer.spans();
7931        assert_eq!(by_row.len(), 1);
7932        assert_eq!(by_row[0].len(), 1);
7933        assert_eq!(by_row[0][0].start_byte, 0);
7934        assert_eq!(by_row[0][0].end_byte, 6);
7935        let id = by_row[0][0].style;
7936        assert_eq!(e.style_table()[id as usize].fg, Some(Color::Red));
7937    }
7938
7939    #[test]
7940    fn install_syntax_spans_clamps_sentinel_end() {
7941        use ratatui::style::{Color, Style};
7942        let mut e = editor_with("hello");
7943        e.install_syntax_spans(vec![vec![(
7944            0,
7945            usize::MAX,
7946            Style::default().fg(Color::Blue),
7947        )]]);
7948        let by_row = e.buffer.spans();
7949        assert_eq!(by_row[0][0].end_byte, 5);
7950    }
7951
7952    #[test]
7953    fn install_syntax_spans_drops_zero_width() {
7954        use ratatui::style::{Color, Style};
7955        let mut e = editor_with("abc");
7956        e.install_syntax_spans(vec![vec![(2, 2, Style::default().fg(Color::Red))]]);
7957        assert!(e.buffer.spans()[0].is_empty());
7958    }
7959
7960    #[test]
7961    fn named_register_yank_into_a_then_paste_from_a() {
7962        let mut e = editor_with("hello world\nsecond");
7963        run_keys(&mut e, "\"ayw");
7964        // `yw` over "hello world" yanks "hello " (word + trailing space).
7965        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
7966        // Move to second line then paste from "a.
7967        run_keys(&mut e, "j0\"aP");
7968        assert_eq!(e.buffer().lines()[1], "hello second");
7969    }
7970
7971    #[test]
7972    fn capital_r_overstrikes_chars() {
7973        let mut e = editor_with("hello");
7974        e.jump_cursor(0, 0);
7975        run_keys(&mut e, "RXY<Esc>");
7976        // 'h' and 'e' replaced; 'llo' kept.
7977        assert_eq!(e.buffer().lines()[0], "XYllo");
7978    }
7979
7980    #[test]
7981    fn capital_r_at_eol_appends() {
7982        let mut e = editor_with("hi");
7983        e.jump_cursor(0, 1);
7984        // Cursor on the final 'i'; replace it then keep typing past EOL.
7985        run_keys(&mut e, "RXYZ<Esc>");
7986        assert_eq!(e.buffer().lines()[0], "hXYZ");
7987    }
7988
7989    #[test]
7990    fn capital_r_count_does_not_repeat_overstrike_char_by_char() {
7991        // Vim's `2R` replays the *whole session* on Esc, not each char.
7992        // We don't model that fully, but the basic R should at least
7993        // not crash on empty session count handling.
7994        let mut e = editor_with("abc");
7995        e.jump_cursor(0, 0);
7996        run_keys(&mut e, "RX<Esc>");
7997        assert_eq!(e.buffer().lines()[0], "Xbc");
7998    }
7999
8000    #[test]
8001    fn ctrl_r_in_insert_pastes_named_register() {
8002        let mut e = editor_with("hello world");
8003        // Yank "hello " into "a".
8004        run_keys(&mut e, "\"ayw");
8005        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
8006        // Open a fresh line, enter insert, Ctrl-R a.
8007        run_keys(&mut e, "o");
8008        assert_eq!(e.vim_mode(), VimMode::Insert);
8009        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
8010        e.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
8011        assert_eq!(e.buffer().lines()[1], "hello ");
8012        // Cursor sits at end of inserted payload (col 6).
8013        assert_eq!(e.cursor(), (1, 6));
8014        // Stayed in insert mode; next char appends.
8015        assert_eq!(e.vim_mode(), VimMode::Insert);
8016        e.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
8017        assert_eq!(e.buffer().lines()[1], "hello X");
8018    }
8019
8020    #[test]
8021    fn ctrl_r_with_unnamed_register() {
8022        let mut e = editor_with("foo");
8023        run_keys(&mut e, "yiw");
8024        run_keys(&mut e, "A ");
8025        // Unnamed register paste via `"`.
8026        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
8027        e.handle_key(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
8028        assert_eq!(e.buffer().lines()[0], "foo foo");
8029    }
8030
8031    #[test]
8032    fn ctrl_r_unknown_selector_is_no_op() {
8033        let mut e = editor_with("abc");
8034        run_keys(&mut e, "A");
8035        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
8036        // `?` isn't a valid register selector — paste skipped, the
8037        // armed flag still clears so the next key types normally.
8038        e.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
8039        e.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
8040        assert_eq!(e.buffer().lines()[0], "abcZ");
8041    }
8042
8043    #[test]
8044    fn ctrl_r_multiline_register_pastes_with_newlines() {
8045        let mut e = editor_with("alpha\nbeta\ngamma");
8046        // Yank two whole lines into "b".
8047        run_keys(&mut e, "\"byy");
8048        run_keys(&mut e, "j\"byy");
8049        // Linewise yanks include trailing \n; second yank into uppercase
8050        // would append, but lowercase "b" overwrote — ensure we have a
8051        // multi-line payload by yanking 2 lines linewise via V.
8052        run_keys(&mut e, "ggVj\"by");
8053        let payload = e.registers().read('b').unwrap().text.clone();
8054        assert!(payload.contains('\n'));
8055        run_keys(&mut e, "Go");
8056        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
8057        e.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
8058        // The buffer should now contain the original 3 lines plus the
8059        // pasted 2-line payload (with its own newline) on its own line.
8060        let total_lines = e.buffer().lines().len();
8061        assert!(total_lines >= 5);
8062    }
8063
8064    #[test]
8065    fn yank_zero_holds_last_yank_after_delete() {
8066        let mut e = editor_with("hello world");
8067        run_keys(&mut e, "yw");
8068        let yanked = e.registers().read('0').unwrap().text.clone();
8069        assert!(!yanked.is_empty());
8070        // Delete a word; "0 should still hold the original yank.
8071        run_keys(&mut e, "dw");
8072        assert_eq!(e.registers().read('0').unwrap().text, yanked);
8073        // "1 holds the just-deleted text (non-empty, regardless of exact contents).
8074        assert!(!e.registers().read('1').unwrap().text.is_empty());
8075    }
8076
8077    #[test]
8078    fn delete_ring_rotates_through_one_through_nine() {
8079        let mut e = editor_with("a b c d e f g h i j");
8080        // Delete each word — each delete pushes onto "1, shifting older.
8081        for _ in 0..3 {
8082            run_keys(&mut e, "dw");
8083        }
8084        // Most recent delete is in "1.
8085        let r1 = e.registers().read('1').unwrap().text.clone();
8086        let r2 = e.registers().read('2').unwrap().text.clone();
8087        let r3 = e.registers().read('3').unwrap().text.clone();
8088        assert!(!r1.is_empty() && !r2.is_empty() && !r3.is_empty());
8089        assert_ne!(r1, r2);
8090        assert_ne!(r2, r3);
8091    }
8092
8093    #[test]
8094    fn capital_register_appends_to_lowercase() {
8095        let mut e = editor_with("foo bar");
8096        run_keys(&mut e, "\"ayw");
8097        let first = e.registers().read('a').unwrap().text.clone();
8098        assert!(first.contains("foo"));
8099        // Yank again into "A — appends to "a.
8100        run_keys(&mut e, "w\"Ayw");
8101        let combined = e.registers().read('a').unwrap().text.clone();
8102        assert!(combined.starts_with(&first));
8103        assert!(combined.contains("bar"));
8104    }
8105
8106    #[test]
8107    fn zf_in_visual_line_creates_closed_fold() {
8108        let mut e = editor_with("a\nb\nc\nd\ne");
8109        // VisualLine over rows 1..=3 then zf.
8110        e.jump_cursor(1, 0);
8111        run_keys(&mut e, "Vjjzf");
8112        assert_eq!(e.buffer().folds().len(), 1);
8113        let f = e.buffer().folds()[0];
8114        assert_eq!(f.start_row, 1);
8115        assert_eq!(f.end_row, 3);
8116        assert!(f.closed);
8117    }
8118
8119    #[test]
8120    fn zfj_in_normal_creates_two_row_fold() {
8121        let mut e = editor_with("a\nb\nc\nd\ne");
8122        e.jump_cursor(1, 0);
8123        run_keys(&mut e, "zfj");
8124        assert_eq!(e.buffer().folds().len(), 1);
8125        let f = e.buffer().folds()[0];
8126        assert_eq!(f.start_row, 1);
8127        assert_eq!(f.end_row, 2);
8128        assert!(f.closed);
8129        // Cursor stays where it started.
8130        assert_eq!(e.cursor().0, 1);
8131    }
8132
8133    #[test]
8134    fn zf_with_count_folds_count_rows() {
8135        let mut e = editor_with("a\nb\nc\nd\ne\nf");
8136        e.jump_cursor(0, 0);
8137        // `zf3j` — fold rows 0..=3.
8138        run_keys(&mut e, "zf3j");
8139        assert_eq!(e.buffer().folds().len(), 1);
8140        let f = e.buffer().folds()[0];
8141        assert_eq!(f.start_row, 0);
8142        assert_eq!(f.end_row, 3);
8143    }
8144
8145    #[test]
8146    fn zfk_folds_upward_range() {
8147        let mut e = editor_with("a\nb\nc\nd\ne");
8148        e.jump_cursor(3, 0);
8149        run_keys(&mut e, "zfk");
8150        let f = e.buffer().folds()[0];
8151        // start_row = min(3, 2) = 2, end_row = max(3, 2) = 3.
8152        assert_eq!(f.start_row, 2);
8153        assert_eq!(f.end_row, 3);
8154    }
8155
8156    #[test]
8157    fn zf_capital_g_folds_to_bottom() {
8158        let mut e = editor_with("a\nb\nc\nd\ne");
8159        e.jump_cursor(1, 0);
8160        // `G` is a single-char motion; folds rows 1..=4.
8161        run_keys(&mut e, "zfG");
8162        let f = e.buffer().folds()[0];
8163        assert_eq!(f.start_row, 1);
8164        assert_eq!(f.end_row, 4);
8165    }
8166
8167    #[test]
8168    fn zfgg_folds_to_top_via_operator_pipeline() {
8169        let mut e = editor_with("a\nb\nc\nd\ne");
8170        e.jump_cursor(3, 0);
8171        // `gg` is a 2-key chord (Pending::OpG path) — `zfgg` works
8172        // because `zf` arms `Pending::Op { Fold }` which already knows
8173        // how to wait for `g` then `g`.
8174        run_keys(&mut e, "zfgg");
8175        let f = e.buffer().folds()[0];
8176        assert_eq!(f.start_row, 0);
8177        assert_eq!(f.end_row, 3);
8178    }
8179
8180    #[test]
8181    fn zfip_folds_paragraph_via_text_object() {
8182        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta\nepsilon");
8183        e.jump_cursor(1, 0);
8184        // `ip` is a text object — same operator pipeline routes it.
8185        run_keys(&mut e, "zfip");
8186        assert_eq!(e.buffer().folds().len(), 1);
8187        let f = e.buffer().folds()[0];
8188        assert_eq!(f.start_row, 0);
8189        assert_eq!(f.end_row, 2);
8190    }
8191
8192    #[test]
8193    fn zfap_folds_paragraph_with_trailing_blank() {
8194        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta");
8195        e.jump_cursor(0, 0);
8196        // `ap` includes the trailing blank line.
8197        run_keys(&mut e, "zfap");
8198        let f = e.buffer().folds()[0];
8199        assert_eq!(f.start_row, 0);
8200        assert_eq!(f.end_row, 3);
8201    }
8202
8203    #[test]
8204    fn zf_paragraph_motion_folds_to_blank() {
8205        let mut e = editor_with("alpha\nbeta\n\ngamma");
8206        e.jump_cursor(0, 0);
8207        // `}` jumps to the blank-line boundary; fold spans rows 0..=2.
8208        run_keys(&mut e, "zf}");
8209        let f = e.buffer().folds()[0];
8210        assert_eq!(f.start_row, 0);
8211        assert_eq!(f.end_row, 2);
8212    }
8213
8214    #[test]
8215    fn za_toggles_fold_under_cursor() {
8216        let mut e = editor_with("a\nb\nc\nd");
8217        e.buffer_mut().add_fold(1, 2, true);
8218        e.jump_cursor(1, 0);
8219        run_keys(&mut e, "za");
8220        assert!(!e.buffer().folds()[0].closed);
8221        run_keys(&mut e, "za");
8222        assert!(e.buffer().folds()[0].closed);
8223    }
8224
8225    #[test]
8226    fn zr_opens_all_folds_zm_closes_all() {
8227        let mut e = editor_with("a\nb\nc\nd\ne\nf");
8228        e.buffer_mut().add_fold(0, 1, true);
8229        e.buffer_mut().add_fold(2, 3, true);
8230        e.buffer_mut().add_fold(4, 5, true);
8231        run_keys(&mut e, "zR");
8232        assert!(e.buffer().folds().iter().all(|f| !f.closed));
8233        run_keys(&mut e, "zM");
8234        assert!(e.buffer().folds().iter().all(|f| f.closed));
8235    }
8236
8237    #[test]
8238    fn ze_clears_all_folds() {
8239        let mut e = editor_with("a\nb\nc\nd");
8240        e.buffer_mut().add_fold(0, 1, true);
8241        e.buffer_mut().add_fold(2, 3, false);
8242        run_keys(&mut e, "zE");
8243        assert!(e.buffer().folds().is_empty());
8244    }
8245
8246    #[test]
8247    fn g_underscore_jumps_to_last_non_blank() {
8248        let mut e = editor_with("hello world   ");
8249        run_keys(&mut e, "g_");
8250        // Last non-blank is 'd' at col 10.
8251        assert_eq!(e.cursor().1, 10);
8252    }
8253
8254    #[test]
8255    fn gj_and_gk_alias_j_and_k() {
8256        let mut e = editor_with("a\nb\nc");
8257        run_keys(&mut e, "gj");
8258        assert_eq!(e.cursor().0, 1);
8259        run_keys(&mut e, "gk");
8260        assert_eq!(e.cursor().0, 0);
8261    }
8262
8263    #[test]
8264    fn paragraph_motions_walk_blank_lines() {
8265        let mut e = editor_with("first\nblock\n\nsecond\nblock\n\nthird");
8266        run_keys(&mut e, "}");
8267        assert_eq!(e.cursor().0, 2);
8268        run_keys(&mut e, "}");
8269        assert_eq!(e.cursor().0, 5);
8270        run_keys(&mut e, "{");
8271        assert_eq!(e.cursor().0, 2);
8272    }
8273
8274    #[test]
8275    fn gv_reenters_last_visual_selection() {
8276        let mut e = editor_with("alpha\nbeta\ngamma");
8277        run_keys(&mut e, "Vj");
8278        // Exit visual.
8279        run_keys(&mut e, "<Esc>");
8280        assert_eq!(e.vim_mode(), VimMode::Normal);
8281        // gv re-enters VisualLine.
8282        run_keys(&mut e, "gv");
8283        assert_eq!(e.vim_mode(), VimMode::VisualLine);
8284    }
8285
8286    #[test]
8287    fn o_in_visual_swaps_anchor_and_cursor() {
8288        let mut e = editor_with("hello world");
8289        // v then move right 4 — anchor at col 0, cursor at col 4.
8290        run_keys(&mut e, "vllll");
8291        assert_eq!(e.cursor().1, 4);
8292        // o swaps; cursor jumps to anchor (col 0).
8293        run_keys(&mut e, "o");
8294        assert_eq!(e.cursor().1, 0);
8295        // Anchor now at original cursor (col 4).
8296        assert_eq!(e.vim.visual_anchor, (0, 4));
8297    }
8298
8299    #[test]
8300    fn editing_inside_fold_invalidates_it() {
8301        let mut e = editor_with("a\nb\nc\nd");
8302        e.buffer_mut().add_fold(1, 2, true);
8303        e.jump_cursor(1, 0);
8304        // Insert a char on a row covered by the fold.
8305        run_keys(&mut e, "iX<Esc>");
8306        // Fold should be gone — vim opens (drops) folds on edit.
8307        assert!(e.buffer().folds().is_empty());
8308    }
8309
8310    #[test]
8311    fn zd_removes_fold_under_cursor() {
8312        let mut e = editor_with("a\nb\nc\nd");
8313        e.buffer_mut().add_fold(1, 2, true);
8314        e.jump_cursor(2, 0);
8315        run_keys(&mut e, "zd");
8316        assert!(e.buffer().folds().is_empty());
8317    }
8318
8319    #[test]
8320    fn dot_mark_jumps_to_last_edit_position() {
8321        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
8322        e.jump_cursor(2, 0);
8323        // Insert at line 2 — sets last_edit_pos.
8324        run_keys(&mut e, "iX<Esc>");
8325        let after_edit = e.cursor();
8326        // Move away.
8327        run_keys(&mut e, "gg");
8328        assert_eq!(e.cursor().0, 0);
8329        // `'.` jumps back to the edit's row (linewise variant).
8330        run_keys(&mut e, "'.");
8331        assert_eq!(e.cursor().0, after_edit.0);
8332    }
8333
8334    #[test]
8335    fn quote_quote_returns_to_pre_jump_position() {
8336        let mut e = editor_with_rows(50, 20);
8337        e.jump_cursor(10, 2);
8338        let before = e.cursor();
8339        // `G` is a big jump — pushes (10, 2) onto jump_back.
8340        run_keys(&mut e, "G");
8341        assert_ne!(e.cursor(), before);
8342        // `''` jumps back to the pre-jump position (linewise).
8343        run_keys(&mut e, "''");
8344        assert_eq!(e.cursor().0, before.0);
8345    }
8346
8347    #[test]
8348    fn backtick_backtick_restores_exact_pre_jump_pos() {
8349        let mut e = editor_with_rows(50, 20);
8350        e.jump_cursor(7, 3);
8351        let before = e.cursor();
8352        run_keys(&mut e, "G");
8353        run_keys(&mut e, "``");
8354        assert_eq!(e.cursor(), before);
8355    }
8356
8357    #[test]
8358    fn macro_record_and_replay_basic() {
8359        let mut e = editor_with("foo\nbar\nbaz");
8360        // Record into "a": insert "X" at line start, exit insert.
8361        run_keys(&mut e, "qaIX<Esc>jq");
8362        assert_eq!(e.buffer().lines()[0], "Xfoo");
8363        // Replay on the next two lines.
8364        run_keys(&mut e, "@a");
8365        assert_eq!(e.buffer().lines()[1], "Xbar");
8366        // @@ replays the last-played macro.
8367        run_keys(&mut e, "j@@");
8368        assert_eq!(e.buffer().lines()[2], "Xbaz");
8369    }
8370
8371    #[test]
8372    fn macro_count_replays_n_times() {
8373        let mut e = editor_with("a\nb\nc\nd\ne");
8374        // Record "j" — move down once.
8375        run_keys(&mut e, "qajq");
8376        assert_eq!(e.cursor().0, 1);
8377        // Replay 3 times via 3@a.
8378        run_keys(&mut e, "3@a");
8379        assert_eq!(e.cursor().0, 4);
8380    }
8381
8382    #[test]
8383    fn macro_capital_q_appends_to_lowercase_register() {
8384        let mut e = editor_with("hello");
8385        run_keys(&mut e, "qall<Esc>q");
8386        run_keys(&mut e, "qAhh<Esc>q");
8387        // Macros + named registers share storage now: register `a`
8388        // holds the encoded keystrokes from both recordings.
8389        let text = e.registers().read('a').unwrap().text.clone();
8390        assert!(text.contains("ll<Esc>"));
8391        assert!(text.contains("hh<Esc>"));
8392    }
8393
8394    #[test]
8395    fn buffer_selection_block_in_visual_block_mode() {
8396        use hjkl_buffer::{Position, Selection};
8397        let mut e = editor_with("aaaa\nbbbb\ncccc");
8398        run_keys(&mut e, "<C-v>jl");
8399        assert_eq!(
8400            e.buffer_selection(),
8401            Some(Selection::Block {
8402                anchor: Position::new(0, 0),
8403                head: Position::new(1, 1),
8404            })
8405        );
8406    }
8407
8408    // ─── Audit batch: lock in known-good behaviour ───────────────────────
8409
8410    #[test]
8411    fn n_after_question_mark_keeps_walking_backward() {
8412        // After committing a `?` search, `n` should continue in the
8413        // backward direction; `N` flips forward.
8414        let mut e = editor_with("foo bar foo baz foo end");
8415        e.jump_cursor(0, 22);
8416        run_keys(&mut e, "?foo<CR>");
8417        assert_eq!(e.cursor().1, 16);
8418        run_keys(&mut e, "n");
8419        assert_eq!(e.cursor().1, 8);
8420        run_keys(&mut e, "N");
8421        assert_eq!(e.cursor().1, 16);
8422    }
8423
8424    #[test]
8425    fn nested_macro_chord_records_literal_keys() {
8426        // `qa@bq` should capture `@` and `b` as literal keys in `a`,
8427        // not as a macro-replay invocation. Replay then re-runs them.
8428        let mut e = editor_with("alpha\nbeta\ngamma");
8429        // First record `b` as a noop-ish macro: just `l` (move right).
8430        run_keys(&mut e, "qblq");
8431        // Now record `a` as: enter insert, type X, exit, then trigger
8432        // `@b` which should run the macro inline during recording too.
8433        run_keys(&mut e, "qaIX<Esc>q");
8434        // `@a` re-runs the captured key sequence on a different line.
8435        e.jump_cursor(1, 0);
8436        run_keys(&mut e, "@a");
8437        assert_eq!(e.buffer().lines()[1], "Xbeta");
8438    }
8439
8440    #[test]
8441    fn shift_gt_motion_indents_one_line() {
8442        // `>w` over a single-line buffer should indent that line by
8443        // one shiftwidth — operator routes through the operator
8444        // pipeline like `dw` / `cw`.
8445        let mut e = editor_with("hello world");
8446        run_keys(&mut e, ">w");
8447        assert_eq!(e.buffer().lines()[0], "  hello world");
8448    }
8449
8450    #[test]
8451    fn shift_lt_motion_outdents_one_line() {
8452        let mut e = editor_with("    hello world");
8453        run_keys(&mut e, "<lt>w");
8454        // Outdent strips up to one shiftwidth (default 2).
8455        assert_eq!(e.buffer().lines()[0], "  hello world");
8456    }
8457
8458    #[test]
8459    fn shift_gt_text_object_indents_paragraph() {
8460        let mut e = editor_with("alpha\nbeta\ngamma\n\nrest");
8461        e.jump_cursor(0, 0);
8462        run_keys(&mut e, ">ip");
8463        assert_eq!(e.buffer().lines()[0], "  alpha");
8464        assert_eq!(e.buffer().lines()[1], "  beta");
8465        assert_eq!(e.buffer().lines()[2], "  gamma");
8466        // Blank separator + the next paragraph stay untouched.
8467        assert_eq!(e.buffer().lines()[4], "rest");
8468    }
8469
8470    #[test]
8471    fn ctrl_o_runs_exactly_one_normal_command() {
8472        // `Ctrl-O dw` returns to insert after the single `dw`. A
8473        // second `Ctrl-O` is needed for another normal command.
8474        let mut e = editor_with("alpha beta gamma");
8475        e.jump_cursor(0, 0);
8476        run_keys(&mut e, "i");
8477        e.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
8478        run_keys(&mut e, "dw");
8479        // First `dw` ran in normal; we're back in insert.
8480        assert_eq!(e.vim_mode(), VimMode::Insert);
8481        // Typing a char now inserts.
8482        run_keys(&mut e, "X");
8483        assert_eq!(e.buffer().lines()[0], "Xbeta gamma");
8484    }
8485
8486    #[test]
8487    fn macro_replay_respects_mode_switching() {
8488        // Recording `iX<Esc>0` should leave us in normal mode at col 0
8489        // after replay — the embedded Esc in the macro must drop the
8490        // replayed insert session.
8491        let mut e = editor_with("hi");
8492        run_keys(&mut e, "qaiX<Esc>0q");
8493        assert_eq!(e.vim_mode(), VimMode::Normal);
8494        // Replay on a fresh line.
8495        e.set_content("yo");
8496        run_keys(&mut e, "@a");
8497        assert_eq!(e.vim_mode(), VimMode::Normal);
8498        assert_eq!(e.cursor().1, 0);
8499        assert_eq!(e.buffer().lines()[0], "Xyo");
8500    }
8501
8502    #[test]
8503    fn macro_recorded_text_round_trips_through_register() {
8504        // After the macros-in-registers unification, recording into
8505        // `a` writes the encoded keystroke text into register `a`'s
8506        // slot. `@a` decodes back to inputs and replays.
8507        let mut e = editor_with("");
8508        run_keys(&mut e, "qaiX<Esc>q");
8509        let text = e.registers().read('a').unwrap().text.clone();
8510        assert!(text.starts_with("iX"));
8511        // Replay inserts another X at the cursor.
8512        run_keys(&mut e, "@a");
8513        assert_eq!(e.buffer().lines()[0], "XX");
8514    }
8515
8516    #[test]
8517    fn dot_after_macro_replays_macros_last_change() {
8518        // After `@a` runs a macro whose last mutation was an insert,
8519        // `.` should repeat that final change, not the whole macro.
8520        let mut e = editor_with("ab\ncd\nef");
8521        // Record: insert 'X' at line start, then move down. The last
8522        // mutation is the insert — `.` should re-apply just that.
8523        run_keys(&mut e, "qaIX<Esc>jq");
8524        assert_eq!(e.buffer().lines()[0], "Xab");
8525        run_keys(&mut e, "@a");
8526        assert_eq!(e.buffer().lines()[1], "Xcd");
8527        // `.` from the new cursor row repeats the last edit (the
8528        // insert `X`), not the whole macro (which would also `j`).
8529        let row_before_dot = e.cursor().0;
8530        run_keys(&mut e, ".");
8531        assert!(e.buffer().lines()[row_before_dot].starts_with('X'));
8532    }
8533}