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