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::buf_helpers::{
78    buf_cursor_pos, buf_line, buf_line_bytes, buf_line_chars, buf_lines_to_vec, buf_row_count,
79    buf_set_cursor_pos, buf_set_cursor_rc,
80};
81use crate::editor::Editor;
82
83// ─── Modes & parser state ───────────────────────────────────────────────────
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum Mode {
87    #[default]
88    Normal,
89    Insert,
90    Visual,
91    VisualLine,
92    /// Column-oriented selection (`Ctrl-V`). Unlike the other visual
93    /// modes this one doesn't use tui-textarea's single-range selection
94    /// — the block corners live in [`VimState::block_anchor`] and the
95    /// live cursor. Operators read the rectangle off those two points.
96    VisualBlock,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100enum Pending {
101    #[default]
102    None,
103    /// Operator seen; still waiting for a motion / text-object / double-op.
104    /// `count1` is any count pressed before the operator.
105    Op { op: Operator, count1: usize },
106    /// Operator + 'i' or 'a' seen; waiting for the text-object character.
107    OpTextObj {
108        op: Operator,
109        count1: usize,
110        inner: bool,
111    },
112    /// Operator + 'g' seen (for `dgg`).
113    OpG { op: Operator, count1: usize },
114    /// Bare `g` seen in normal/visual — looking for `g`, `e`, `E`, …
115    G,
116    /// Bare `f`/`F`/`t`/`T` — looking for the target char.
117    Find { forward: bool, till: bool },
118    /// Operator + `f`/`F`/`t`/`T` — looking for target char.
119    OpFind {
120        op: Operator,
121        count1: usize,
122        forward: bool,
123        till: bool,
124    },
125    /// `r` pressed — waiting for the replacement char.
126    Replace,
127    /// Visual mode + `i` or `a` pressed — waiting for the text-object
128    /// character to extend the selection over.
129    VisualTextObj { inner: bool },
130    /// Bare `z` seen — looking for `z` (center), `t` (top), `b` (bottom).
131    Z,
132    /// `m` pressed — waiting for the mark letter to set.
133    SetMark,
134    /// `'` pressed — waiting for the mark letter to jump to its line
135    /// (lands on first non-blank, linewise for operators).
136    GotoMarkLine,
137    /// `` ` `` pressed — waiting for the mark letter to jump to the
138    /// exact `(row, col)` stored at set time (charwise for operators).
139    GotoMarkChar,
140    /// `"` pressed — waiting for the register selector. The next char
141    /// (`a`–`z`, `A`–`Z`, `0`–`9`, or `"`) sets `pending_register`.
142    SelectRegister,
143    /// `q` pressed (not currently recording) — waiting for the macro
144    /// register name. The macro records every key after the chord
145    /// resolves, until a bare `q` ends the recording.
146    RecordMacroTarget,
147    /// `@` pressed — waiting for the macro register name to play.
148    /// `count` is the prefix multiplier (`3@a` plays the macro 3
149    /// times); 0 means "no prefix" and is treated as 1.
150    PlayMacroTarget { count: usize },
151}
152
153// ─── Operator / Motion / TextObject ────────────────────────────────────────
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum Operator {
157    Delete,
158    Change,
159    Yank,
160    /// `gU{motion}` — uppercase the range. Entered via the `g` prefix
161    /// in normal mode or `U` in visual mode.
162    Uppercase,
163    /// `gu{motion}` — lowercase the range. `u` in visual mode.
164    Lowercase,
165    /// `g~{motion}` — toggle case of the range. `~` in visual mode
166    /// (character at the cursor for the single-char `~` command stays
167    /// its own code path in normal mode).
168    ToggleCase,
169    /// `>{motion}` — indent the line range by `shiftwidth` spaces.
170    /// Always linewise, even when the motion is char-wise — mirrors
171    /// vim's behaviour where `>w` indents the current line, not the
172    /// word on it.
173    Indent,
174    /// `<{motion}` — outdent the line range (remove up to
175    /// `shiftwidth` leading spaces per line).
176    Outdent,
177    /// `zf{motion}` / `zf{textobj}` / Visual `zf` — create a closed
178    /// fold spanning the row range. Doesn't mutate the buffer text;
179    /// cursor restores to the operator's start position.
180    Fold,
181    /// `gq{motion}` — reflow the row range to `settings.textwidth`.
182    /// Greedy word-wrap: collapses each paragraph (blank-line-bounded
183    /// run) into space-separated words, then re-emits lines whose
184    /// width stays under `textwidth`. Always linewise, like indent.
185    Reflow,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum Motion {
190    Left,
191    Right,
192    Up,
193    Down,
194    WordFwd,
195    BigWordFwd,
196    WordBack,
197    BigWordBack,
198    WordEnd,
199    BigWordEnd,
200    /// `ge` — backward word end.
201    WordEndBack,
202    /// `gE` — backward WORD end.
203    BigWordEndBack,
204    LineStart,
205    FirstNonBlank,
206    LineEnd,
207    FileTop,
208    FileBottom,
209    Find {
210        ch: char,
211        forward: bool,
212        till: bool,
213    },
214    FindRepeat {
215        reverse: bool,
216    },
217    MatchBracket,
218    WordAtCursor {
219        forward: bool,
220        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
221        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
222        whole_word: bool,
223    },
224    /// `n` / `N` — repeat the last `/` or `?` search.
225    SearchNext {
226        reverse: bool,
227    },
228    /// `H` — cursor to viewport top (plus `count - 1` rows down).
229    ViewportTop,
230    /// `M` — cursor to viewport middle.
231    ViewportMiddle,
232    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
233    ViewportBottom,
234    /// `g_` — last non-blank char on the line.
235    LastNonBlank,
236    /// `gM` — cursor to the middle char column of the current line
237    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
238    LineMiddle,
239    /// `{` — previous paragraph (preceding blank line, or top).
240    ParagraphPrev,
241    /// `}` — next paragraph (following blank line, or bottom).
242    ParagraphNext,
243    /// `(` — previous sentence boundary.
244    SentencePrev,
245    /// `)` — next sentence boundary.
246    SentenceNext,
247    /// `gj` — `count` visual rows down (one screen segment per step
248    /// under `:set wrap`; falls back to `Down` otherwise).
249    ScreenDown,
250    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
251    ScreenUp,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum TextObject {
256    Word {
257        big: bool,
258    },
259    Quote(char),
260    Bracket(char),
261    Paragraph,
262    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
263    /// content between `>` and `</`; `inner = false` covers the open
264    /// tag through the close tag inclusive.
265    XmlTag,
266    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
267    /// followed by whitespace or end-of-line. `inner = true` covers
268    /// the sentence text only; `inner = false` includes trailing
269    /// whitespace.
270    Sentence,
271}
272
273/// Classification determines how operators treat the range end.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum MotionKind {
276    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
277    Exclusive,
278    /// Range end is inclusive. Typical: e, f, t, %.
279    Inclusive,
280    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
281    Linewise,
282}
283
284// ─── Dot-repeat storage ────────────────────────────────────────────────────
285
286/// Information needed to replay a mutating change via `.`.
287#[derive(Debug, Clone)]
288enum LastChange {
289    /// Operator over a motion.
290    OpMotion {
291        op: Operator,
292        motion: Motion,
293        count: usize,
294        inserted: Option<String>,
295    },
296    /// Operator over a text-object.
297    OpTextObj {
298        op: Operator,
299        obj: TextObject,
300        inner: bool,
301        inserted: Option<String>,
302    },
303    /// `dd`, `cc`, `yy` with a count.
304    LineOp {
305        op: Operator,
306        count: usize,
307        inserted: Option<String>,
308    },
309    /// `x`, `X` with a count.
310    CharDel { forward: bool, count: usize },
311    /// `r<ch>` with a count.
312    ReplaceChar { ch: char, count: usize },
313    /// `~` with a count.
314    ToggleCase { count: usize },
315    /// `J` with a count.
316    JoinLine { count: usize },
317    /// `p` / `P` with a count.
318    Paste { before: bool, count: usize },
319    /// `D` (delete to EOL).
320    DeleteToEol { inserted: Option<String> },
321    /// `o` / `O` + the inserted text.
322    OpenLine { above: bool, inserted: String },
323    /// `i`/`I`/`a`/`A` + inserted text.
324    InsertAt {
325        entry: InsertEntry,
326        inserted: String,
327        count: usize,
328    },
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332enum InsertEntry {
333    I,
334    A,
335    ShiftI,
336    ShiftA,
337}
338
339// ─── VimState ──────────────────────────────────────────────────────────────
340
341#[derive(Default)]
342pub struct VimState {
343    mode: Mode,
344    pending: Pending,
345    count: usize,
346    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
347    last_find: Option<(char, bool, bool)>,
348    last_change: Option<LastChange>,
349    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
350    insert_session: Option<InsertSession>,
351    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
352    /// to compute the highlight range and the operator range without
353    /// relying on tui-textarea's live selection.
354    pub(super) visual_anchor: (usize, usize),
355    /// Row anchor for VisualLine mode.
356    pub(super) visual_line_anchor: usize,
357    /// (row, col) anchor for VisualBlock mode. The live cursor is the
358    /// opposite corner.
359    pub(super) block_anchor: (usize, usize),
360    /// Intended "virtual" column for the block's active corner. j/k
361    /// clamp cursor.col to shorter rows, which would collapse the
362    /// block across ragged content — so we remember the desired column
363    /// separately and use it for block bounds / insert-column
364    /// computations. Updated by h/l only.
365    pub(super) block_vcol: usize,
366    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
367    pub(super) yank_linewise: bool,
368    /// Active register selector — set by `"reg` prefix, consumed by
369    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
370    pub(super) pending_register: Option<char>,
371    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
372    /// While `Some`, every consumed `Input` is appended to
373    /// `recording_keys`.
374    pub(super) recording_macro: Option<char>,
375    /// Keys recorded into the in-progress macro. On `q` finish, these
376    /// are encoded via [`crate::input::encode_macro`] and written to
377    /// the matching named register slot, so macros and yanks share a
378    /// single store.
379    pub(super) recording_keys: Vec<crate::input::Input>,
380    /// Set during `@reg` replay so the recorder doesn't capture the
381    /// replayed keystrokes a second time.
382    pub(super) replaying_macro: bool,
383    /// Last register played via `@reg`. `@@` re-plays this one.
384    pub(super) last_macro: Option<char>,
385    /// Position of the most recent buffer mutation. Surfaced via
386    /// the `'.` / `` `. `` marks for quick "back to last edit".
387    pub(super) last_edit_pos: Option<(usize, usize)>,
388    /// Bounded ring of recent edit positions (newest at the back).
389    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
390    /// at [`CHANGE_LIST_MAX`].
391    pub(super) change_list: Vec<(usize, usize)>,
392    /// Index into `change_list` while walking. `None` outside a walk —
393    /// any new edit clears it (and trims forward entries past it).
394    pub(super) change_list_cursor: Option<usize>,
395    /// Snapshot of the last visual selection for `gv` re-entry.
396    /// Stored on every Visual / VisualLine / VisualBlock exit.
397    pub(super) last_visual: Option<LastVisual>,
398    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
399    /// pass doesn't override the user's explicit viewport pinning.
400    /// Cleared every step.
401    pub(super) viewport_pinned: bool,
402    /// Set while replaying `.` / last-change so we don't re-record it.
403    replaying: bool,
404    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
405    /// normal-mode command we return to Insert.
406    one_shot_normal: bool,
407    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
408    pub(super) search_prompt: Option<SearchPrompt>,
409    /// Most recent committed search pattern. Surfaced to host apps via
410    /// [`Editor::last_search`] so their status line can render a hint
411    /// and so `n` / `N` have something to repeat.
412    pub(super) last_search: Option<String>,
413    /// Direction of the last committed search. `n` repeats this; `N`
414    /// inverts it. Defaults to forward so a never-searched buffer's
415    /// `n` still walks downward.
416    pub(super) last_search_forward: bool,
417    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
418    /// with the pre-motion cursor when a "big jump" motion fires
419    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
420    /// `?`). Capped at 100 entries.
421    pub(super) jump_back: Vec<(usize, usize)>,
422    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
423    /// jump, matching vim's "branch off trims forward history" rule.
424    pub(super) jump_fwd: Vec<(usize, usize)>,
425    /// Set by `Ctrl-R` in insert mode while waiting for the register
426    /// selector. The next typed char names the register; its contents
427    /// are inserted inline at the cursor and the flag clears.
428    pub(super) insert_pending_register: bool,
429    /// Bounded history of committed `/` / `?` search patterns. Newest
430    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
431    /// avoid unbounded growth on long sessions.
432    pub(super) search_history: Vec<String>,
433    /// Index into `search_history` while the user walks past patterns
434    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
435    /// — typing or backspacing in the prompt resets it so the next
436    /// `Ctrl-P` starts from the most recent entry again.
437    pub(super) search_history_cursor: Option<usize>,
438    /// Wall-clock instant of the last keystroke. Drives the
439    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
440    /// exceeds the configured budget, any pending prefix is cleared
441    /// before the new key dispatches. `None` before the first key.
442    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
443    /// [`crate::types::Host::now`] via `last_input_host_at`. This
444    /// `Instant`-flavoured field stays for snapshot tests that still
445    /// observe it directly.
446    pub(super) last_input_at: Option<std::time::Instant>,
447    /// `Host::now()` reading at the last keystroke. Drives
448    /// `:set timeoutlen` so macro replay / headless drivers stay
449    /// deterministic regardless of wall-clock skew.
450    pub(super) last_input_host_at: Option<core::time::Duration>,
451}
452
453const SEARCH_HISTORY_MAX: usize = 100;
454pub(crate) const CHANGE_LIST_MAX: usize = 100;
455
456/// Active `/` or `?` search prompt. Text mutations drive the textarea's
457/// live search pattern so matches highlight as the user types.
458#[derive(Debug, Clone)]
459pub struct SearchPrompt {
460    pub text: String,
461    pub cursor: usize,
462    pub forward: bool,
463}
464
465#[derive(Debug, Clone)]
466struct InsertSession {
467    count: usize,
468    /// Min/max row visited during this session. Widens on every key.
469    row_min: usize,
470    row_max: usize,
471    /// Snapshot of the full buffer at session entry. Used to diff the
472    /// affected row window at finish without being fooled by cursor
473    /// navigation through rows the user never edited.
474    before_lines: Vec<String>,
475    reason: InsertReason,
476}
477
478#[derive(Debug, Clone)]
479enum InsertReason {
480    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
481    Enter(InsertEntry),
482    /// Entry via `o`/`O` — records OpenLine on Esc.
483    Open { above: bool },
484    /// Entry via an operator's change side-effect. Retro-fills the
485    /// stored last-change's `inserted` field on Esc.
486    AfterChange,
487    /// Entry via `C` (delete to EOL + insert).
488    DeleteToEol,
489    /// Entry via an insert triggered during dot-replay — don't touch
490    /// last_change because the outer replay will restore it.
491    ReplayOnly,
492    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
493    /// every row in `top..=bot`. `col` is the start column for `I`, the
494    /// one-past-block-end column for `A`.
495    BlockEdge { top: usize, bot: usize, col: usize },
496    /// `R` — Replace mode. Each typed char overwrites the cell under
497    /// the cursor instead of inserting; at end-of-line the session
498    /// falls through to insert (same as vim).
499    Replace,
500}
501
502/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
503/// visual selection). `mode` carries which visual flavour to
504/// restore; `anchor` / `cursor` mean different things per flavour:
505///
506/// - `Visual`     — `anchor` is the char-wise visual anchor.
507/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
508///   `anchor.1` is unused.
509/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
510///   sticky vcol that survives j/k clamping.
511#[derive(Debug, Clone, Copy)]
512pub(super) struct LastVisual {
513    pub mode: Mode,
514    pub anchor: (usize, usize),
515    pub cursor: (usize, usize),
516    pub block_vcol: usize,
517}
518
519impl VimState {
520    pub fn public_mode(&self) -> VimMode {
521        match self.mode {
522            Mode::Normal => VimMode::Normal,
523            Mode::Insert => VimMode::Insert,
524            Mode::Visual => VimMode::Visual,
525            Mode::VisualLine => VimMode::VisualLine,
526            Mode::VisualBlock => VimMode::VisualBlock,
527        }
528    }
529
530    pub fn force_normal(&mut self) {
531        self.mode = Mode::Normal;
532        self.pending = Pending::None;
533        self.count = 0;
534        self.insert_session = None;
535    }
536
537    /// Reset every prefix-tracking field so the next keystroke starts
538    /// a fresh sequence. Drives `:set timeoutlen` — when the user
539    /// pauses past the configured budget, [`crate::vim::step`] calls
540    /// this before dispatching the new key.
541    ///
542    /// Resets: `pending`, `count`, `pending_register`,
543    /// `insert_pending_register`. Does NOT touch `mode`,
544    /// `insert_session`, marks, jump list, or visual anchors —
545    /// those aren't part of the in-flight chord.
546    pub(crate) fn clear_pending_prefix(&mut self) {
547        self.pending = Pending::None;
548        self.count = 0;
549        self.pending_register = None;
550        self.insert_pending_register = false;
551    }
552
553    pub fn is_visual(&self) -> bool {
554        matches!(
555            self.mode,
556            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
557        )
558    }
559
560    pub fn is_visual_char(&self) -> bool {
561        self.mode == Mode::Visual
562    }
563
564    pub fn enter_visual(&mut self, anchor: (usize, usize)) {
565        self.visual_anchor = anchor;
566        self.mode = Mode::Visual;
567    }
568
569    /// The pending repeat count (typed digits before a motion/operator),
570    /// or `None` when no digits are pending. Zero is treated as absent.
571    pub(crate) fn pending_count_val(&self) -> Option<u32> {
572        if self.count == 0 {
573            None
574        } else {
575            Some(self.count as u32)
576        }
577    }
578
579    /// `true` when an in-flight chord is awaiting more keys. Inverse of
580    /// `matches!(self.pending, Pending::None)`.
581    pub(crate) fn is_chord_pending(&self) -> bool {
582        !matches!(self.pending, Pending::None)
583    }
584
585    /// Return a single char representing the pending operator, if any.
586    /// Used by host apps (status line "showcmd" area) to display e.g.
587    /// `d`, `y`, `c` while waiting for a motion.
588    pub(crate) fn pending_op_char(&self) -> Option<char> {
589        let op = match &self.pending {
590            Pending::Op { op, .. }
591            | Pending::OpTextObj { op, .. }
592            | Pending::OpG { op, .. }
593            | Pending::OpFind { op, .. } => Some(*op),
594            _ => None,
595        };
596        op.map(|o| match o {
597            Operator::Delete => 'd',
598            Operator::Change => 'c',
599            Operator::Yank => 'y',
600            Operator::Uppercase => 'U',
601            Operator::Lowercase => 'u',
602            Operator::ToggleCase => '~',
603            Operator::Indent => '>',
604            Operator::Outdent => '<',
605            Operator::Fold => 'z',
606            Operator::Reflow => 'q',
607        })
608    }
609}
610
611// ─── Entry point ───────────────────────────────────────────────────────────
612
613/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
614/// live search highlight until the user commits a query. `last_search`
615/// is preserved so an empty `<CR>` can re-run the previous pattern.
616fn enter_search<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, forward: bool) {
617    ed.vim.search_prompt = Some(SearchPrompt {
618        text: String::new(),
619        cursor: 0,
620        forward,
621    });
622    ed.vim.search_history_cursor = None;
623    // 0.0.37: clear via the engine search state (the buffer-side
624    // bridge from 0.0.35 was removed in this patch — the `BufferView`
625    // renderer reads the pattern from `Editor::search_state()`).
626    ed.set_search_pattern(None);
627}
628
629/// Compile `pattern` into a regex and push it onto the migration
630/// buffer's search state. Invalid patterns clear the highlight (the
631/// user is mid-typing a regex like `[` and we don't want to flash an
632/// error).
633fn push_search_pattern<H: crate::types::Host>(
634    ed: &mut Editor<hjkl_buffer::Buffer, H>,
635    pattern: &str,
636) {
637    let compiled = if pattern.is_empty() {
638        None
639    } else {
640        // `:set ignorecase` flips every search pattern to case-insensitive
641        // unless the user already prefixed an explicit `(?i)` / `(?-i)`
642        // (regex crate honours those even when we layer another `(?i)`).
643        // `:set smartcase` re-enables case sensitivity for any pattern
644        // that contains an uppercase letter — matches vim's combined
645        // `ignorecase` + `smartcase` behaviour.
646        let case_insensitive = ed.settings().ignore_case
647            && !(ed.settings().smartcase && pattern.chars().any(|c| c.is_uppercase()));
648        let effective: std::borrow::Cow<'_, str> = if case_insensitive {
649            std::borrow::Cow::Owned(format!("(?i){pattern}"))
650        } else {
651            std::borrow::Cow::Borrowed(pattern)
652        };
653        regex::Regex::new(&effective).ok()
654    };
655    let wrap = ed.settings().wrapscan;
656    // 0.0.37: search FSM lives entirely on Editor — pattern + wrap
657    // policy + per-row match cache. The `Search` trait impl always
658    // wraps; engine code honours `wrap_around` before invoking it.
659    ed.set_search_pattern(compiled);
660    ed.search_state_mut().wrap_around = wrap;
661}
662
663fn step_search_prompt<H: crate::types::Host>(
664    ed: &mut Editor<hjkl_buffer::Buffer, H>,
665    input: Input,
666) -> bool {
667    // Ctrl-P / Ctrl-N (and Up / Down) walk the search history. Handled
668    // before the regular char/backspace branches so `Ctrl-P` doesn't
669    // type a literal `p`.
670    let history_dir = match (input.key, input.ctrl) {
671        (Key::Char('p'), true) | (Key::Up, _) => Some(-1),
672        (Key::Char('n'), true) | (Key::Down, _) => Some(1),
673        _ => None,
674    };
675    if let Some(dir) = history_dir {
676        walk_search_history(ed, dir);
677        return true;
678    }
679    match input.key {
680        Key::Esc => {
681            // Cancel. Drop the prompt but keep the highlighted matches
682            // so `n` / `N` can repeat whatever was typed.
683            let text = ed
684                .vim
685                .search_prompt
686                .take()
687                .map(|p| p.text)
688                .unwrap_or_default();
689            if !text.is_empty() {
690                ed.vim.last_search = Some(text);
691            }
692            ed.vim.search_history_cursor = None;
693        }
694        Key::Enter => {
695            let prompt = ed.vim.search_prompt.take();
696            if let Some(p) = prompt {
697                // Empty `/<CR>` (or `?<CR>`) re-runs the previous search
698                // pattern in the prompt's direction — vim parity.
699                let pattern = if p.text.is_empty() {
700                    ed.vim.last_search.clone()
701                } else {
702                    Some(p.text.clone())
703                };
704                if let Some(pattern) = pattern {
705                    push_search_pattern(ed, &pattern);
706                    let pre = ed.cursor();
707                    if p.forward {
708                        ed.search_advance_forward(true);
709                    } else {
710                        ed.search_advance_backward(true);
711                    }
712                    ed.push_buffer_cursor_to_textarea();
713                    if ed.cursor() != pre {
714                        push_jump(ed, pre);
715                    }
716                    record_search_history(ed, &pattern);
717                    ed.vim.last_search = Some(pattern);
718                    ed.vim.last_search_forward = p.forward;
719                }
720            }
721            ed.vim.search_history_cursor = None;
722        }
723        Key::Backspace => {
724            ed.vim.search_history_cursor = None;
725            let new_text = ed.vim.search_prompt.as_mut().and_then(|p| {
726                if p.text.pop().is_some() {
727                    p.cursor = p.text.chars().count();
728                    Some(p.text.clone())
729                } else {
730                    None
731                }
732            });
733            if let Some(text) = new_text {
734                push_search_pattern(ed, &text);
735            }
736        }
737        Key::Char(c) => {
738            ed.vim.search_history_cursor = None;
739            let new_text = ed.vim.search_prompt.as_mut().map(|p| {
740                p.text.push(c);
741                p.cursor = p.text.chars().count();
742                p.text.clone()
743            });
744            if let Some(text) = new_text {
745                push_search_pattern(ed, &text);
746            }
747        }
748        _ => {}
749    }
750    true
751}
752
753/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
754/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
755/// the ends of the ring; off-ring positions are silently ignored.
756fn walk_change_list<H: crate::types::Host>(
757    ed: &mut Editor<hjkl_buffer::Buffer, H>,
758    dir: isize,
759    count: usize,
760) {
761    if ed.vim.change_list.is_empty() {
762        return;
763    }
764    let len = ed.vim.change_list.len();
765    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
766        (None, -1) => len as isize - 1,
767        (None, 1) => return, // already past the newest entry
768        (Some(i), -1) => i as isize - 1,
769        (Some(i), 1) => i as isize + 1,
770        _ => return,
771    };
772    for _ in 1..count {
773        let next = idx + dir;
774        if next < 0 || next >= len as isize {
775            break;
776        }
777        idx = next;
778    }
779    if idx < 0 || idx >= len as isize {
780        return;
781    }
782    let idx = idx as usize;
783    ed.vim.change_list_cursor = Some(idx);
784    let (row, col) = ed.vim.change_list[idx];
785    ed.jump_cursor(row, col);
786}
787
788/// Push `pattern` onto the search history. Skips the push when the
789/// most recent entry already matches (consecutive dedupe) and trims
790/// the oldest entries beyond [`SEARCH_HISTORY_MAX`].
791fn record_search_history<H: crate::types::Host>(
792    ed: &mut Editor<hjkl_buffer::Buffer, H>,
793    pattern: &str,
794) {
795    if pattern.is_empty() {
796        return;
797    }
798    if ed.vim.search_history.last().map(String::as_str) == Some(pattern) {
799        return;
800    }
801    ed.vim.search_history.push(pattern.to_string());
802    let len = ed.vim.search_history.len();
803    if len > SEARCH_HISTORY_MAX {
804        ed.vim.search_history.drain(0..len - SEARCH_HISTORY_MAX);
805    }
806}
807
808/// Replace the prompt text with the next entry in the search history.
809/// `dir = -1` walks toward older entries (`Ctrl-P` / `Up`); `dir = 1`
810/// toward newer ones (`Ctrl-N` / `Down`). Stops at the ends of the
811/// history; the user can keep pressing the key without effect rather
812/// than wrapping around.
813fn walk_search_history<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, dir: isize) {
814    if ed.vim.search_history.is_empty() || ed.vim.search_prompt.is_none() {
815        return;
816    }
817    let len = ed.vim.search_history.len();
818    let next_idx = match (ed.vim.search_history_cursor, dir) {
819        (None, -1) => Some(len - 1),
820        (None, 1) => return, // already past the newest entry
821        (Some(i), -1) => i.checked_sub(1),
822        (Some(i), 1) if i + 1 < len => Some(i + 1),
823        _ => None,
824    };
825    let Some(idx) = next_idx else {
826        return;
827    };
828    ed.vim.search_history_cursor = Some(idx);
829    let text = ed.vim.search_history[idx].clone();
830    if let Some(prompt) = ed.vim.search_prompt.as_mut() {
831        prompt.cursor = text.chars().count();
832        prompt.text = text.clone();
833    }
834    push_search_pattern(ed, &text);
835}
836
837pub fn step<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, input: Input) -> bool {
838    // Phase 7f port: any cursor / content the host changed between
839    // steps (mouse jumps, paste, programmatic set_content, …) needs
840    // to land in the migration buffer before motion handlers that
841    // call into `Buffer::move_*` see a stale state.
842    ed.sync_buffer_content_from_textarea();
843    // `:set timeoutlen` — if the user paused longer than the budget
844    // since the last keystroke and a chord is in flight, drop the
845    // pending prefix so the new key starts fresh. 0.0.29 (Patch B):
846    // chord-timeout math now reads `Host::now()` so macro replay /
847    // headless drivers stay deterministic. The legacy
848    // `Instant::now()`-backed `last_input_at` field is retained for
849    // snapshot tests that still observe it.
850    let now = std::time::Instant::now();
851    let host_now = ed.host.now();
852    let timed_out = match ed.vim.last_input_host_at {
853        Some(prev) => host_now.saturating_sub(prev) > ed.settings.timeout_len,
854        None => false,
855    };
856    if timed_out {
857        let chord_in_flight = !matches!(ed.vim.pending, Pending::None)
858            || ed.vim.count != 0
859            || ed.vim.pending_register.is_some()
860            || ed.vim.insert_pending_register;
861        if chord_in_flight {
862            ed.vim.clear_pending_prefix();
863        }
864    }
865    ed.vim.last_input_at = Some(now);
866    ed.vim.last_input_host_at = Some(host_now);
867    // Macro stop: a bare `q` ends an active recording before any
868    // other handler sees the key (so `q` itself doesn't get
869    // recorded). Replays don't trigger this — they finish on their
870    // own when the captured key list runs out.
871    if ed.vim.recording_macro.is_some()
872        && !ed.vim.replaying_macro
873        && matches!(ed.vim.pending, Pending::None)
874        && ed.vim.mode != Mode::Insert
875        && input.key == Key::Char('q')
876        && !input.ctrl
877        && !input.alt
878    {
879        let reg = ed.vim.recording_macro.take().unwrap();
880        let keys = std::mem::take(&mut ed.vim.recording_keys);
881        let text = crate::input::encode_macro(&keys);
882        ed.set_named_register_text(reg.to_ascii_lowercase(), text);
883        return true;
884    }
885    // Search prompt eats all keys until Enter / Esc.
886    if ed.vim.search_prompt.is_some() {
887        return step_search_prompt(ed, input);
888    }
889    // Snapshot whether this step is consuming the register-name half
890    // of a macro chord. The recorder hook below uses this to skip
891    // the chord's bookkeeping keys (`q{reg}` open and `@{reg}` open).
892    let pending_was_macro_chord = matches!(
893        ed.vim.pending,
894        Pending::RecordMacroTarget | Pending::PlayMacroTarget { .. }
895    );
896    let was_insert = ed.vim.mode == Mode::Insert;
897    // Capture pre-step visual snapshot so a visual → normal transition
898    // can stash the selection for `gv` re-entry.
899    let pre_visual_snapshot = match ed.vim.mode {
900        Mode::Visual => Some(LastVisual {
901            mode: Mode::Visual,
902            anchor: ed.vim.visual_anchor,
903            cursor: ed.cursor(),
904            block_vcol: 0,
905        }),
906        Mode::VisualLine => Some(LastVisual {
907            mode: Mode::VisualLine,
908            anchor: (ed.vim.visual_line_anchor, 0),
909            cursor: ed.cursor(),
910            block_vcol: 0,
911        }),
912        Mode::VisualBlock => Some(LastVisual {
913            mode: Mode::VisualBlock,
914            anchor: ed.vim.block_anchor,
915            cursor: ed.cursor(),
916            block_vcol: ed.vim.block_vcol,
917        }),
918        _ => None,
919    };
920    let consumed = match ed.vim.mode {
921        Mode::Insert => step_insert(ed, input),
922        _ => step_normal(ed, input),
923    };
924    if let Some(snap) = pre_visual_snapshot
925        && !matches!(
926            ed.vim.mode,
927            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
928        )
929    {
930        // Set the `<` / `>` marks so ex commands like `:'<,'>sort` resolve
931        // their range. Per `:h v_:` the mark positions depend on the visual
932        // submode:
933        //
934        // * Visual (charwise): position-ordered. `<` = lower (row, col),
935        //   `>` = higher. Tuple comparison works because the selection is
936        //   contiguous text.
937        // * VisualLine: `<` snaps to (top_row, 0), `>` snaps to
938        //   (bot_row, last_col_of_that_line). Vim treats linewise
939        //   selections as full lines so the column components are
940        //   normalised to line edges.
941        // * VisualBlock: corners. `<` = (min_row, min_col),
942        //   `>` = (max_row, max_col) computed independently — the cursor
943        //   may sit on any corner so tuple ordering would mis-place the
944        //   columns when the selection grew leftward.
945        let (lo, hi) = match snap.mode {
946            Mode::Visual => {
947                if snap.anchor <= snap.cursor {
948                    (snap.anchor, snap.cursor)
949                } else {
950                    (snap.cursor, snap.anchor)
951                }
952            }
953            Mode::VisualLine => {
954                let r_lo = snap.anchor.0.min(snap.cursor.0);
955                let r_hi = snap.anchor.0.max(snap.cursor.0);
956                let last_col = ed
957                    .buffer()
958                    .lines()
959                    .get(r_hi)
960                    .map(|l| l.chars().count().saturating_sub(1))
961                    .unwrap_or(0);
962                ((r_lo, 0), (r_hi, last_col))
963            }
964            Mode::VisualBlock => {
965                let (r1, c1) = snap.anchor;
966                let (r2, c2) = snap.cursor;
967                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
968            }
969            _ => {
970                // Defensive: pre_visual_snapshot only stores visual modes,
971                // so this arm is unreachable in practice.
972                if snap.anchor <= snap.cursor {
973                    (snap.anchor, snap.cursor)
974                } else {
975                    (snap.cursor, snap.anchor)
976                }
977            }
978        };
979        ed.set_mark('<', lo);
980        ed.set_mark('>', hi);
981        ed.vim.last_visual = Some(snap);
982    }
983    // Ctrl-o in insert mode queues a single normal-mode command; once
984    // that command finishes (pending cleared, not in operator / visual),
985    // drop back to insert without replaying the insert session.
986    if !was_insert
987        && ed.vim.one_shot_normal
988        && ed.vim.mode == Mode::Normal
989        && matches!(ed.vim.pending, Pending::None)
990    {
991        ed.vim.one_shot_normal = false;
992        ed.vim.mode = Mode::Insert;
993    }
994    // Phase 7c: every step ends with the migration buffer mirroring
995    // the textarea's content + cursor + viewport. Edit-emitting paths
996    // (insert_char, delete_char, …) inside `step_insert` /
997    // `step_normal` thus all flow through here without each call
998    // site needing to remember to sync.
999    ed.sync_buffer_content_from_textarea();
1000    // Scroll viewport to keep cursor on-screen, honouring the same
1001    // `SCROLLOFF` margin the mouse-driven scroll uses. Skip when
1002    // the user just pinned the viewport with `zz` / `zt` / `zb`.
1003    if !ed.vim.viewport_pinned {
1004        ed.ensure_cursor_in_scrolloff();
1005    }
1006    ed.vim.viewport_pinned = false;
1007    // Recorder hook: append every consumed input to the active
1008    // recording (if any) so the replay reproduces the same sequence.
1009    // Skip the chord that started the recording (`q{reg}` open) and
1010    // skip during replay so a macro doesn't capture itself.
1011    if ed.vim.recording_macro.is_some()
1012        && !ed.vim.replaying_macro
1013        && input.key != Key::Char('q')
1014        && !pending_was_macro_chord
1015    {
1016        ed.vim.recording_keys.push(input);
1017    }
1018    consumed
1019}
1020
1021// ─── Insert mode ───────────────────────────────────────────────────────────
1022
1023fn step_insert<H: crate::types::Host>(
1024    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1025    input: Input,
1026) -> bool {
1027    // `Ctrl-R {reg}` paste — the previous keystroke armed the wait. Any
1028    // non-char key cancels (matches vim, which beeps on selectors like
1029    // Esc and re-emits the literal text otherwise).
1030    if ed.vim.insert_pending_register {
1031        ed.vim.insert_pending_register = false;
1032        if let Key::Char(c) = input.key
1033            && !input.ctrl
1034        {
1035            insert_register_text(ed, c);
1036        }
1037        return true;
1038    }
1039
1040    if input.key == Key::Esc {
1041        finish_insert_session(ed);
1042        ed.vim.mode = Mode::Normal;
1043        // Vim convention: pull the cursor back one cell on exit when
1044        // possible. Sticky column then mirrors the *visible* post-Back
1045        // column so the next vertical motion lands where the user
1046        // actually sees the cursor — not one cell to the right.
1047        let col = ed.cursor().1;
1048        if col > 0 {
1049            crate::motions::move_left(&mut ed.buffer, 1);
1050            ed.push_buffer_cursor_to_textarea();
1051        }
1052        ed.sticky_col = Some(ed.cursor().1);
1053        return true;
1054    }
1055
1056    // Ctrl-prefixed insert-mode shortcuts.
1057    if input.ctrl {
1058        match input.key {
1059            Key::Char('w') => {
1060                use hjkl_buffer::{Edit, MotionKind};
1061                ed.sync_buffer_content_from_textarea();
1062                let cursor = buf_cursor_pos(&ed.buffer);
1063                if cursor.row == 0 && cursor.col == 0 {
1064                    return true;
1065                }
1066                // Find the previous word start by stepping the buffer
1067                // cursor (vim `b` semantics) and snapshot it.
1068                crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
1069                let word_start = buf_cursor_pos(&ed.buffer);
1070                if word_start == cursor {
1071                    return true;
1072                }
1073                buf_set_cursor_pos(&mut ed.buffer, cursor);
1074                ed.mutate_edit(Edit::DeleteRange {
1075                    start: word_start,
1076                    end: cursor,
1077                    kind: MotionKind::Char,
1078                });
1079                ed.push_buffer_cursor_to_textarea();
1080                return true;
1081            }
1082            Key::Char('u') => {
1083                use hjkl_buffer::{Edit, MotionKind, Position};
1084                ed.sync_buffer_content_from_textarea();
1085                let cursor = buf_cursor_pos(&ed.buffer);
1086                if cursor.col > 0 {
1087                    ed.mutate_edit(Edit::DeleteRange {
1088                        start: Position::new(cursor.row, 0),
1089                        end: cursor,
1090                        kind: MotionKind::Char,
1091                    });
1092                    ed.push_buffer_cursor_to_textarea();
1093                }
1094                return true;
1095            }
1096            Key::Char('h') => {
1097                use hjkl_buffer::{Edit, MotionKind, Position};
1098                ed.sync_buffer_content_from_textarea();
1099                let cursor = buf_cursor_pos(&ed.buffer);
1100                if cursor.col > 0 {
1101                    ed.mutate_edit(Edit::DeleteRange {
1102                        start: Position::new(cursor.row, cursor.col - 1),
1103                        end: cursor,
1104                        kind: MotionKind::Char,
1105                    });
1106                } else if cursor.row > 0 {
1107                    let prev_row = cursor.row - 1;
1108                    let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1109                    ed.mutate_edit(Edit::JoinLines {
1110                        row: prev_row,
1111                        count: 1,
1112                        with_space: false,
1113                    });
1114                    buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1115                }
1116                ed.push_buffer_cursor_to_textarea();
1117                return true;
1118            }
1119            Key::Char('o') => {
1120                // One-shot normal: leave insert mode for the next full
1121                // normal-mode command, then come back.
1122                ed.vim.one_shot_normal = true;
1123                ed.vim.mode = Mode::Normal;
1124                return true;
1125            }
1126            Key::Char('r') => {
1127                // Arm the register selector — the next typed char picks
1128                // a slot and pastes its text inline.
1129                ed.vim.insert_pending_register = true;
1130                return true;
1131            }
1132            Key::Char('t') => {
1133                // Insert-mode indent: prepend one shiftwidth to the
1134                // current line's leading whitespace. Cursor shifts
1135                // right by the same amount so the user keeps typing
1136                // at their logical position.
1137                let (row, col) = ed.cursor();
1138                let sw = ed.settings().shiftwidth;
1139                indent_rows(ed, row, row, 1);
1140                ed.jump_cursor(row, col + sw);
1141                return true;
1142            }
1143            Key::Char('d') => {
1144                // Insert-mode outdent: drop up to one shiftwidth of
1145                // leading whitespace. Cursor shifts left by the amount
1146                // actually stripped.
1147                let (row, col) = ed.cursor();
1148                let before_len = buf_line_bytes(&ed.buffer, row);
1149                outdent_rows(ed, row, row, 1);
1150                let after_len = buf_line_bytes(&ed.buffer, row);
1151                let stripped = before_len.saturating_sub(after_len);
1152                let new_col = col.saturating_sub(stripped);
1153                ed.jump_cursor(row, new_col);
1154                return true;
1155            }
1156            _ => {}
1157        }
1158    }
1159
1160    // Widen the session's visited row window *before* handling the key
1161    // so navigation-only keystrokes (arrow keys) still extend the range.
1162    let (row, _) = ed.cursor();
1163    if let Some(ref mut session) = ed.vim.insert_session {
1164        session.row_min = session.row_min.min(row);
1165        session.row_max = session.row_max.max(row);
1166    }
1167    let mutated = handle_insert_key(ed, input);
1168    if mutated {
1169        ed.mark_content_dirty();
1170        let (row, _) = ed.cursor();
1171        if let Some(ref mut session) = ed.vim.insert_session {
1172            session.row_min = session.row_min.min(row);
1173            session.row_max = session.row_max.max(row);
1174        }
1175    }
1176    true
1177}
1178
1179/// `Ctrl-R {reg}` body — insert the named register's contents at the
1180/// cursor as charwise text. Embedded newlines split lines naturally via
1181/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
1182/// stray keystrokes don't mutate the buffer.
1183fn insert_register_text<H: crate::types::Host>(
1184    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1185    selector: char,
1186) {
1187    use hjkl_buffer::Edit;
1188    let text = match ed.registers().read(selector) {
1189        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
1190        _ => return,
1191    };
1192    ed.sync_buffer_content_from_textarea();
1193    let cursor = buf_cursor_pos(&ed.buffer);
1194    ed.mutate_edit(Edit::InsertStr {
1195        at: cursor,
1196        text: text.clone(),
1197    });
1198    // Advance cursor to the end of the inserted payload — multi-line
1199    // pastes land on the last inserted row at the post-text column.
1200    let mut row = cursor.row;
1201    let mut col = cursor.col;
1202    for ch in text.chars() {
1203        if ch == '\n' {
1204            row += 1;
1205            col = 0;
1206        } else {
1207            col += 1;
1208        }
1209    }
1210    buf_set_cursor_rc(&mut ed.buffer, row, col);
1211    ed.push_buffer_cursor_to_textarea();
1212    ed.mark_content_dirty();
1213    if let Some(ref mut session) = ed.vim.insert_session {
1214        session.row_min = session.row_min.min(row);
1215        session.row_max = session.row_max.max(row);
1216    }
1217}
1218
1219/// Compute the indent string to insert at the start of a new line
1220/// after Enter is pressed at `cursor`. Walks the smartindent rules:
1221///
1222/// - autoindent off → empty string
1223/// - autoindent on  → copy prev line's leading whitespace
1224/// - smartindent on → bump one `shiftwidth` if prev line's last
1225///   non-whitespace char is `{` / `(` / `[`
1226///
1227/// Indent unit (used for the smartindent bump):
1228///
1229/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
1230/// - `expandtab` → `shiftwidth` spaces
1231/// - `!expandtab` → one literal `\t`
1232///
1233/// This is the placeholder for a future tree-sitter indent provider:
1234/// when a language has an `indents.scm` query, the engine will route
1235/// the same call through that provider and only fall back to this
1236/// heuristic when no query matches.
1237pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
1238    if !settings.autoindent {
1239        return String::new();
1240    }
1241    // Copy the prev line's leading whitespace (autoindent base).
1242    let base: String = prev_line
1243        .chars()
1244        .take_while(|c| *c == ' ' || *c == '\t')
1245        .collect();
1246
1247    if settings.smartindent {
1248        // If the last non-whitespace character is an open bracket, bump
1249        // indent by one unit. This is the heuristic seam: a tree-sitter
1250        // `indents.scm` provider would replace this branch.
1251        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
1252        if matches!(last_non_ws, Some('{' | '(' | '[')) {
1253            let unit = if settings.expandtab {
1254                if settings.softtabstop > 0 {
1255                    " ".repeat(settings.softtabstop)
1256                } else {
1257                    " ".repeat(settings.shiftwidth)
1258                }
1259            } else {
1260                "\t".to_string()
1261            };
1262            return format!("{base}{unit}");
1263        }
1264    }
1265
1266    base
1267}
1268
1269/// Strip one indent unit from the beginning of `line` and insert `ch`
1270/// instead. Returns `true` when it consumed the keystroke (dedent +
1271/// insert), `false` when the caller should insert normally.
1272///
1273/// Dedent fires when:
1274///   - `smartindent` is on
1275///   - `ch` is `}` / `)` / `]`
1276///   - all bytes BEFORE the cursor on the current line are whitespace
1277///   - there is at least one full indent unit of leading whitespace
1278fn try_dedent_close_bracket<H: crate::types::Host>(
1279    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1280    cursor: hjkl_buffer::Position,
1281    ch: char,
1282) -> bool {
1283    use hjkl_buffer::{Edit, MotionKind, Position};
1284
1285    if !ed.settings.smartindent {
1286        return false;
1287    }
1288    if !matches!(ch, '}' | ')' | ']') {
1289        return false;
1290    }
1291
1292    let line = match buf_line(&ed.buffer, cursor.row) {
1293        Some(l) => l.to_string(),
1294        None => return false,
1295    };
1296
1297    // All chars before cursor must be whitespace.
1298    let before: String = line.chars().take(cursor.col).collect();
1299    if !before.chars().all(|c| c == ' ' || c == '\t') {
1300        return false;
1301    }
1302    if before.is_empty() {
1303        // Nothing to strip — just insert normally (cursor at col 0).
1304        return false;
1305    }
1306
1307    // Compute indent unit.
1308    let unit_len: usize = if ed.settings.expandtab {
1309        if ed.settings.softtabstop > 0 {
1310            ed.settings.softtabstop
1311        } else {
1312            ed.settings.shiftwidth
1313        }
1314    } else {
1315        // Tab: one literal tab character.
1316        1
1317    };
1318
1319    // Check there's at least one full unit to strip.
1320    let strip_len = if ed.settings.expandtab {
1321        // Count leading spaces; need at least `unit_len`.
1322        let spaces = before.chars().filter(|c| *c == ' ').count();
1323        if spaces < unit_len {
1324            return false;
1325        }
1326        unit_len
1327    } else {
1328        // noexpandtab: strip one leading tab.
1329        if !before.starts_with('\t') {
1330            return false;
1331        }
1332        1
1333    };
1334
1335    // Delete the leading `strip_len` chars of the current line.
1336    ed.mutate_edit(Edit::DeleteRange {
1337        start: Position::new(cursor.row, 0),
1338        end: Position::new(cursor.row, strip_len),
1339        kind: MotionKind::Char,
1340    });
1341    // Insert the close bracket at column 0 (after the delete the cursor
1342    // is still positioned at the end of the remaining whitespace; the
1343    // delete moved the text so the cursor is now at col = before.len() -
1344    // strip_len).
1345    let new_col = cursor.col.saturating_sub(strip_len);
1346    ed.mutate_edit(Edit::InsertChar {
1347        at: Position::new(cursor.row, new_col),
1348        ch,
1349    });
1350    true
1351}
1352
1353/// Insert-mode key dispatcher backed by the migration buffer. Replaces
1354/// the historical `textarea.input(input)` call so the textarea field
1355/// can be ripped at the end of Phase 7f. PageUp / PageDown still flow
1356/// through the textarea (they're scroll-only with no buffer side
1357/// effect); every other navigation + edit key lands on `Buffer`.
1358/// Returns true when the buffer mutated.
1359fn handle_insert_key<H: crate::types::Host>(
1360    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1361    input: Input,
1362) -> bool {
1363    use hjkl_buffer::{Edit, MotionKind, Position};
1364    ed.sync_buffer_content_from_textarea();
1365    let cursor = buf_cursor_pos(&ed.buffer);
1366    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1367    // Replace mode: overstrike the cell at the cursor instead of
1368    // inserting. At end-of-line, fall through to plain insert (vim
1369    // appends past the line).
1370    let in_replace = matches!(
1371        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1372        Some(InsertReason::Replace)
1373    );
1374    let mutated = match input.key {
1375        Key::Char(c) if in_replace && cursor.col < line_chars => {
1376            ed.mutate_edit(Edit::DeleteRange {
1377                start: cursor,
1378                end: Position::new(cursor.row, cursor.col + 1),
1379                kind: MotionKind::Char,
1380            });
1381            ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1382            true
1383        }
1384        Key::Char(c) => {
1385            if !try_dedent_close_bracket(ed, cursor, c) {
1386                ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1387            }
1388            true
1389        }
1390        Key::Enter => {
1391            let prev_line = buf_line(&ed.buffer, cursor.row)
1392                .unwrap_or_default()
1393                .to_string();
1394            let indent = compute_enter_indent(&ed.settings, &prev_line);
1395            let text = format!("\n{indent}");
1396            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1397            true
1398        }
1399        Key::Tab => {
1400            if ed.settings.expandtab {
1401                // With softtabstop > 0, fill to the next sts boundary.
1402                // Otherwise insert a full tabstop run.
1403                let sts = ed.settings.softtabstop;
1404                let n = if sts > 0 {
1405                    sts - (cursor.col % sts)
1406                } else {
1407                    ed.settings.tabstop.max(1)
1408                };
1409                ed.mutate_edit(Edit::InsertStr {
1410                    at: cursor,
1411                    text: " ".repeat(n),
1412                });
1413            } else {
1414                ed.mutate_edit(Edit::InsertChar {
1415                    at: cursor,
1416                    ch: '\t',
1417                });
1418            }
1419            true
1420        }
1421        Key::Backspace => {
1422            // Softtabstop: if the N chars before the cursor are all spaces
1423            // and the cursor sits on an sts-aligned column, delete the run
1424            // as a single unit (vim's "backspace deletes a soft tab" feel).
1425            let sts = ed.settings.softtabstop;
1426            if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
1427                let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1428                let chars: Vec<char> = line.chars().collect();
1429                let run_start = cursor.col - sts;
1430                if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
1431                    ed.mutate_edit(Edit::DeleteRange {
1432                        start: Position::new(cursor.row, run_start),
1433                        end: cursor,
1434                        kind: MotionKind::Char,
1435                    });
1436                    return true;
1437                }
1438            }
1439            if cursor.col > 0 {
1440                ed.mutate_edit(Edit::DeleteRange {
1441                    start: Position::new(cursor.row, cursor.col - 1),
1442                    end: cursor,
1443                    kind: MotionKind::Char,
1444                });
1445                true
1446            } else if cursor.row > 0 {
1447                let prev_row = cursor.row - 1;
1448                let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1449                ed.mutate_edit(Edit::JoinLines {
1450                    row: prev_row,
1451                    count: 1,
1452                    with_space: false,
1453                });
1454                buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1455                true
1456            } else {
1457                false
1458            }
1459        }
1460        Key::Delete => {
1461            if cursor.col < line_chars {
1462                ed.mutate_edit(Edit::DeleteRange {
1463                    start: cursor,
1464                    end: Position::new(cursor.row, cursor.col + 1),
1465                    kind: MotionKind::Char,
1466                });
1467                true
1468            } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
1469                ed.mutate_edit(Edit::JoinLines {
1470                    row: cursor.row,
1471                    count: 1,
1472                    with_space: false,
1473                });
1474                buf_set_cursor_pos(&mut ed.buffer, cursor);
1475                true
1476            } else {
1477                false
1478            }
1479        }
1480        Key::Left => {
1481            crate::motions::move_left(&mut ed.buffer, 1);
1482            break_undo_group_in_insert(ed);
1483            false
1484        }
1485        Key::Right => {
1486            // Insert mode allows the cursor one past the last char so the
1487            // next typed letter appends — use the operator-context move.
1488            crate::motions::move_right_to_end(&mut ed.buffer, 1);
1489            break_undo_group_in_insert(ed);
1490            false
1491        }
1492        Key::Up => {
1493            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1494            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1495            break_undo_group_in_insert(ed);
1496            false
1497        }
1498        Key::Down => {
1499            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1500            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1501            break_undo_group_in_insert(ed);
1502            false
1503        }
1504        Key::Home => {
1505            crate::motions::move_line_start(&mut ed.buffer);
1506            break_undo_group_in_insert(ed);
1507            false
1508        }
1509        Key::End => {
1510            crate::motions::move_line_end(&mut ed.buffer);
1511            break_undo_group_in_insert(ed);
1512            false
1513        }
1514        Key::PageUp => {
1515            // Vim default: PageUp scrolls a full window up, cursor
1516            // tracks. Reuse the Ctrl-b scroll helper so behavior
1517            // matches the normal-mode equivalent.
1518            let rows = viewport_full_rows(ed, 1) as isize;
1519            scroll_cursor_rows(ed, -rows);
1520            return false;
1521        }
1522        Key::PageDown => {
1523            let rows = viewport_full_rows(ed, 1) as isize;
1524            scroll_cursor_rows(ed, rows);
1525            return false;
1526        }
1527        // F-keys, mouse scroll, copy/cut/paste virtual keys, Null —
1528        // no insert-mode behaviour.
1529        _ => false,
1530    };
1531    ed.push_buffer_cursor_to_textarea();
1532    mutated
1533}
1534
1535fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1536    let Some(session) = ed.vim.insert_session.take() else {
1537        return;
1538    };
1539    let lines = buf_lines_to_vec(&ed.buffer);
1540    // Clamp both slices to their respective bounds — the buffer may have
1541    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1542    // the session, so row_max can overshoot either side.
1543    let after_end = session.row_max.min(lines.len().saturating_sub(1));
1544    let before_end = session
1545        .row_max
1546        .min(session.before_lines.len().saturating_sub(1));
1547    let before = if before_end >= session.row_min && session.row_min < session.before_lines.len() {
1548        session.before_lines[session.row_min..=before_end].join("\n")
1549    } else {
1550        String::new()
1551    };
1552    let after = if after_end >= session.row_min && session.row_min < lines.len() {
1553        lines[session.row_min..=after_end].join("\n")
1554    } else {
1555        String::new()
1556    };
1557    let inserted = extract_inserted(&before, &after);
1558    if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
1559        use hjkl_buffer::{Edit, Position};
1560        for _ in 0..session.count - 1 {
1561            let (row, col) = ed.cursor();
1562            ed.mutate_edit(Edit::InsertStr {
1563                at: Position::new(row, col),
1564                text: inserted.clone(),
1565            });
1566        }
1567    }
1568    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1569        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1570            use hjkl_buffer::{Edit, Position};
1571            for r in (top + 1)..=bot {
1572                let line_len = buf_line_chars(&ed.buffer, r);
1573                if col > line_len {
1574                    // Pad short rows with spaces up to the block edge
1575                    // column so the inserted text lands at `col`.
1576                    let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1577                    ed.mutate_edit(Edit::InsertStr {
1578                        at: Position::new(r, line_len),
1579                        text: pad,
1580                    });
1581                }
1582                ed.mutate_edit(Edit::InsertStr {
1583                    at: Position::new(r, col),
1584                    text: inserted.clone(),
1585                });
1586            }
1587            buf_set_cursor_rc(&mut ed.buffer, top, col);
1588            ed.push_buffer_cursor_to_textarea();
1589        }
1590        return;
1591    }
1592    if ed.vim.replaying {
1593        return;
1594    }
1595    match session.reason {
1596        InsertReason::Enter(entry) => {
1597            ed.vim.last_change = Some(LastChange::InsertAt {
1598                entry,
1599                inserted,
1600                count: session.count,
1601            });
1602        }
1603        InsertReason::Open { above } => {
1604            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1605        }
1606        InsertReason::AfterChange => {
1607            if let Some(
1608                LastChange::OpMotion { inserted: ins, .. }
1609                | LastChange::OpTextObj { inserted: ins, .. }
1610                | LastChange::LineOp { inserted: ins, .. },
1611            ) = ed.vim.last_change.as_mut()
1612            {
1613                *ins = Some(inserted);
1614            }
1615        }
1616        InsertReason::DeleteToEol => {
1617            ed.vim.last_change = Some(LastChange::DeleteToEol {
1618                inserted: Some(inserted),
1619            });
1620        }
1621        InsertReason::ReplayOnly => {}
1622        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1623        InsertReason::Replace => {
1624            // Record overstrike sessions as DeleteToEol-style — replay
1625            // re-types each character but doesn't try to restore prior
1626            // content (vim's R has its own replay path; this is the
1627            // pragmatic approximation).
1628            ed.vim.last_change = Some(LastChange::DeleteToEol {
1629                inserted: Some(inserted),
1630            });
1631        }
1632    }
1633}
1634
1635fn begin_insert<H: crate::types::Host>(
1636    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1637    count: usize,
1638    reason: InsertReason,
1639) {
1640    let record = !matches!(reason, InsertReason::ReplayOnly);
1641    if record {
1642        ed.push_undo();
1643    }
1644    let reason = if ed.vim.replaying {
1645        InsertReason::ReplayOnly
1646    } else {
1647        reason
1648    };
1649    let (row, _) = ed.cursor();
1650    ed.vim.insert_session = Some(InsertSession {
1651        count,
1652        row_min: row,
1653        row_max: row,
1654        before_lines: buf_lines_to_vec(&ed.buffer),
1655        reason,
1656    });
1657    ed.vim.mode = Mode::Insert;
1658}
1659
1660/// `:set undobreak` semantics for insert-mode motions. When the
1661/// toggle is on, a non-character keystroke that moves the cursor
1662/// (arrow keys, Home/End, mouse click) ends the current undo group
1663/// and starts a new one mid-session. After this, a subsequent `u`
1664/// in normal mode reverts only the post-break run, leaving the
1665/// pre-break edits in place — matching vim's behaviour.
1666///
1667/// Implementation: snapshot the current buffer onto the undo stack
1668/// (the new break point) and reset the active `InsertSession`'s
1669/// `before_lines` so `finish_insert_session`'s diff window only
1670/// captures the post-break run for `last_change` / dot-repeat.
1671///
1672/// During replay we skip the break — replay shouldn't pollute the
1673/// undo stack with intra-replay snapshots.
1674pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1675    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1676) {
1677    if !ed.settings.undo_break_on_motion {
1678        return;
1679    }
1680    if ed.vim.replaying {
1681        return;
1682    }
1683    if ed.vim.insert_session.is_none() {
1684        return;
1685    }
1686    ed.push_undo();
1687    let n = crate::types::Query::line_count(&ed.buffer) as usize;
1688    let mut lines: Vec<String> = Vec::with_capacity(n);
1689    for r in 0..n {
1690        lines.push(crate::types::Query::line(&ed.buffer, r as u32).to_string());
1691    }
1692    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1693    if let Some(ref mut session) = ed.vim.insert_session {
1694        session.before_lines = lines;
1695        session.row_min = row;
1696        session.row_max = row;
1697    }
1698}
1699
1700// ─── Normal / Visual / Operator-pending dispatcher ─────────────────────────
1701
1702fn step_normal<H: crate::types::Host>(
1703    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1704    input: Input,
1705) -> bool {
1706    // Consume digits first — except '0' at start of count (that's LineStart).
1707    if let Key::Char(d @ '0'..='9') = input.key
1708        && !input.ctrl
1709        && !input.alt
1710        && !matches!(
1711            ed.vim.pending,
1712            Pending::Replace
1713                | Pending::Find { .. }
1714                | Pending::OpFind { .. }
1715                | Pending::VisualTextObj { .. }
1716        )
1717        && (d != '0' || ed.vim.count > 0)
1718    {
1719        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
1720        return true;
1721    }
1722
1723    // Handle pending two-key sequences first.
1724    match std::mem::take(&mut ed.vim.pending) {
1725        Pending::Replace => return handle_replace(ed, input),
1726        Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
1727        Pending::OpFind {
1728            op,
1729            count1,
1730            forward,
1731            till,
1732        } => return handle_op_find_target(ed, input, op, count1, forward, till),
1733        Pending::G => return handle_after_g(ed, input),
1734        Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
1735        Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
1736        Pending::OpTextObj { op, count1, inner } => {
1737            return handle_text_object(ed, input, op, count1, inner);
1738        }
1739        Pending::VisualTextObj { inner } => {
1740            return handle_visual_text_obj(ed, input, inner);
1741        }
1742        Pending::Z => return handle_after_z(ed, input),
1743        Pending::SetMark => return handle_set_mark(ed, input),
1744        Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
1745        Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
1746        Pending::SelectRegister => return handle_select_register(ed, input),
1747        Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
1748        Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
1749        Pending::None => {}
1750    }
1751
1752    let count = take_count(&mut ed.vim);
1753
1754    // Common normal / visual keys.
1755    match input.key {
1756        Key::Esc => {
1757            ed.vim.force_normal();
1758            return true;
1759        }
1760        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1761            ed.vim.visual_anchor = ed.cursor();
1762            ed.vim.mode = Mode::Visual;
1763            return true;
1764        }
1765        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1766            let (row, _) = ed.cursor();
1767            ed.vim.visual_line_anchor = row;
1768            ed.vim.mode = Mode::VisualLine;
1769            return true;
1770        }
1771        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::VisualLine => {
1772            ed.vim.visual_anchor = ed.cursor();
1773            ed.vim.mode = Mode::Visual;
1774            return true;
1775        }
1776        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Visual => {
1777            let (row, _) = ed.cursor();
1778            ed.vim.visual_line_anchor = row;
1779            ed.vim.mode = Mode::VisualLine;
1780            return true;
1781        }
1782        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::Normal => {
1783            let cur = ed.cursor();
1784            ed.vim.block_anchor = cur;
1785            ed.vim.block_vcol = cur.1;
1786            ed.vim.mode = Mode::VisualBlock;
1787            return true;
1788        }
1789        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::VisualBlock => {
1790            // Second Ctrl-v exits block mode back to Normal.
1791            ed.vim.mode = Mode::Normal;
1792            return true;
1793        }
1794        // `o` in visual modes — swap anchor and cursor so the user
1795        // can extend the other end of the selection.
1796        Key::Char('o') if !input.ctrl => match ed.vim.mode {
1797            Mode::Visual => {
1798                let cur = ed.cursor();
1799                let anchor = ed.vim.visual_anchor;
1800                ed.vim.visual_anchor = cur;
1801                ed.jump_cursor(anchor.0, anchor.1);
1802                return true;
1803            }
1804            Mode::VisualLine => {
1805                let cur_row = ed.cursor().0;
1806                let anchor_row = ed.vim.visual_line_anchor;
1807                ed.vim.visual_line_anchor = cur_row;
1808                ed.jump_cursor(anchor_row, 0);
1809                return true;
1810            }
1811            Mode::VisualBlock => {
1812                let cur = ed.cursor();
1813                let anchor = ed.vim.block_anchor;
1814                ed.vim.block_anchor = cur;
1815                ed.vim.block_vcol = anchor.1;
1816                ed.jump_cursor(anchor.0, anchor.1);
1817                return true;
1818            }
1819            _ => {}
1820        },
1821        _ => {}
1822    }
1823
1824    // Visual mode: operators act on the current selection.
1825    if ed.vim.is_visual()
1826        && let Some(op) = visual_operator(&input)
1827    {
1828        apply_visual_operator(ed, op);
1829        return true;
1830    }
1831
1832    // VisualBlock: extra commands beyond the standard y/d/c/x — `r`
1833    // replaces the block with a single char, `I` / `A` enter insert
1834    // mode at the block's left / right edge and repeat on every row.
1835    if ed.vim.mode == Mode::VisualBlock && !input.ctrl {
1836        match input.key {
1837            Key::Char('r') => {
1838                ed.vim.pending = Pending::Replace;
1839                return true;
1840            }
1841            Key::Char('I') => {
1842                let (top, bot, left, _right) = block_bounds(ed);
1843                ed.jump_cursor(top, left);
1844                ed.vim.mode = Mode::Normal;
1845                begin_insert(
1846                    ed,
1847                    1,
1848                    InsertReason::BlockEdge {
1849                        top,
1850                        bot,
1851                        col: left,
1852                    },
1853                );
1854                return true;
1855            }
1856            Key::Char('A') => {
1857                let (top, bot, _left, right) = block_bounds(ed);
1858                let line_len = buf_line_chars(&ed.buffer, top);
1859                let col = (right + 1).min(line_len);
1860                ed.jump_cursor(top, col);
1861                ed.vim.mode = Mode::Normal;
1862                begin_insert(ed, 1, InsertReason::BlockEdge { top, bot, col });
1863                return true;
1864            }
1865            _ => {}
1866        }
1867    }
1868
1869    // Visual mode: `i` / `a` start a text-object extension.
1870    if matches!(ed.vim.mode, Mode::Visual | Mode::VisualLine)
1871        && !input.ctrl
1872        && matches!(input.key, Key::Char('i') | Key::Char('a'))
1873    {
1874        let inner = matches!(input.key, Key::Char('i'));
1875        ed.vim.pending = Pending::VisualTextObj { inner };
1876        return true;
1877    }
1878
1879    // Ctrl-prefixed scrolling + misc. Vim semantics: Ctrl-d / Ctrl-u
1880    // move the cursor by half a window, Ctrl-f / Ctrl-b by a full
1881    // window. Viewport follows the cursor. Cursor lands on the first
1882    // non-blank of the target row (matches vim).
1883    if input.ctrl
1884        && let Key::Char(c) = input.key
1885    {
1886        match c {
1887            'd' => {
1888                scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
1889                return true;
1890            }
1891            'u' => {
1892                scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
1893                return true;
1894            }
1895            'f' => {
1896                scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
1897                return true;
1898            }
1899            'b' => {
1900                scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
1901                return true;
1902            }
1903            'r' => {
1904                do_redo(ed);
1905                return true;
1906            }
1907            'a' if ed.vim.mode == Mode::Normal => {
1908                adjust_number(ed, count.max(1) as i64);
1909                return true;
1910            }
1911            'x' if ed.vim.mode == Mode::Normal => {
1912                adjust_number(ed, -(count.max(1) as i64));
1913                return true;
1914            }
1915            'o' if ed.vim.mode == Mode::Normal => {
1916                for _ in 0..count.max(1) {
1917                    jump_back(ed);
1918                }
1919                return true;
1920            }
1921            'i' if ed.vim.mode == Mode::Normal => {
1922                for _ in 0..count.max(1) {
1923                    jump_forward(ed);
1924                }
1925                return true;
1926            }
1927            _ => {}
1928        }
1929    }
1930
1931    // `Tab` in normal mode is also `Ctrl-i` — vim aliases them.
1932    if !input.ctrl && input.key == Key::Tab && ed.vim.mode == Mode::Normal {
1933        for _ in 0..count.max(1) {
1934            jump_forward(ed);
1935        }
1936        return true;
1937    }
1938
1939    // Motion-only commands.
1940    if let Some(motion) = parse_motion(&input) {
1941        execute_motion(ed, motion.clone(), count);
1942        // Block mode: maintain the virtual column across j/k clamps.
1943        if ed.vim.mode == Mode::VisualBlock {
1944            update_block_vcol(ed, &motion);
1945        }
1946        if let Motion::Find { ch, forward, till } = motion {
1947            ed.vim.last_find = Some((ch, forward, till));
1948        }
1949        return true;
1950    }
1951
1952    // Mode transitions + pure normal-mode commands (not applicable in visual).
1953    if ed.vim.mode == Mode::Normal && handle_normal_only(ed, &input, count) {
1954        return true;
1955    }
1956
1957    // Operator triggers in normal mode.
1958    if ed.vim.mode == Mode::Normal
1959        && let Key::Char(op_ch) = input.key
1960        && !input.ctrl
1961        && let Some(op) = char_to_operator(op_ch)
1962    {
1963        ed.vim.pending = Pending::Op { op, count1: count };
1964        return true;
1965    }
1966
1967    // `f`/`F`/`t`/`T` entry.
1968    if ed.vim.mode == Mode::Normal
1969        && let Some((forward, till)) = find_entry(&input)
1970    {
1971        ed.vim.count = count;
1972        ed.vim.pending = Pending::Find { forward, till };
1973        return true;
1974    }
1975
1976    // `g` prefix.
1977    if !input.ctrl && input.key == Key::Char('g') && ed.vim.mode == Mode::Normal {
1978        ed.vim.count = count;
1979        ed.vim.pending = Pending::G;
1980        return true;
1981    }
1982
1983    // `z` prefix (zz / zt / zb — cursor-relative viewport scrolls).
1984    if !input.ctrl
1985        && input.key == Key::Char('z')
1986        && matches!(
1987            ed.vim.mode,
1988            Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock
1989        )
1990    {
1991        ed.vim.pending = Pending::Z;
1992        return true;
1993    }
1994
1995    // Mark set / jump entries. `m` arms the set-mark pending state;
1996    // `'` and `` ` `` arm the goto states (linewise vs charwise). The
1997    // mark letter is consumed on the next keystroke.
1998    if !input.ctrl && ed.vim.mode == Mode::Normal {
1999        match input.key {
2000            Key::Char('m') => {
2001                ed.vim.pending = Pending::SetMark;
2002                return true;
2003            }
2004            Key::Char('\'') => {
2005                ed.vim.pending = Pending::GotoMarkLine;
2006                return true;
2007            }
2008            Key::Char('`') => {
2009                ed.vim.pending = Pending::GotoMarkChar;
2010                return true;
2011            }
2012            Key::Char('"') => {
2013                // Open the register-selector chord. The next char picks
2014                // a register that the next y/d/c/p uses.
2015                ed.vim.pending = Pending::SelectRegister;
2016                return true;
2017            }
2018            Key::Char('@') => {
2019                // Open the macro-play chord. Next char names the
2020                // register; `@@` re-plays the last-played macro.
2021                // Stash any count so the chord can multiply replays.
2022                ed.vim.pending = Pending::PlayMacroTarget { count };
2023                return true;
2024            }
2025            Key::Char('q') if ed.vim.recording_macro.is_none() => {
2026                // Open the macro-record chord. The bare-q stop is
2027                // handled at the top of `step` so it's not consumed
2028                // as another open. Recording-in-progress falls through
2029                // here and is treated as a no-op (matches vim).
2030                ed.vim.pending = Pending::RecordMacroTarget;
2031                return true;
2032            }
2033            _ => {}
2034        }
2035    }
2036
2037    // Unknown key — swallow so it doesn't bubble into the TUI layer.
2038    true
2039}
2040
2041fn handle_set_mark<H: crate::types::Host>(
2042    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2043    input: Input,
2044) -> bool {
2045    if let Key::Char(c) = input.key
2046        && (c.is_ascii_lowercase() || c.is_ascii_uppercase())
2047    {
2048        // 0.0.36: lowercase + uppercase marks share the unified
2049        // `Editor::marks` map. Uppercase entries survive
2050        // `set_content` so they persist across tab swaps within the
2051        // same Editor (the map lives on the Editor, not the buffer).
2052        let pos = ed.cursor();
2053        ed.set_mark(c, pos);
2054    }
2055    true
2056}
2057
2058/// `"reg` — store the register selector for the next y / d / c / p.
2059/// Accepts `a`–`z`, `A`–`Z`, `0`–`9`, `"`, and the system-clipboard
2060/// selectors `+` / `*`. Anything else cancels silently.
2061fn handle_select_register<H: crate::types::Host>(
2062    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2063    input: Input,
2064) -> bool {
2065    if let Key::Char(c) = input.key
2066        && (c.is_ascii_alphanumeric() || matches!(c, '"' | '+' | '*'))
2067    {
2068        ed.vim.pending_register = Some(c);
2069    }
2070    true
2071}
2072
2073/// `q{reg}` — start recording into `reg`. The recording session
2074/// captures every consumed `Input` until a bare `q` ends it (handled
2075/// inline at the top of `step`). Capital letters append to the
2076/// matching lowercase register, mirroring named-register semantics.
2077fn handle_record_macro_target<H: crate::types::Host>(
2078    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2079    input: Input,
2080) -> bool {
2081    if let Key::Char(c) = input.key
2082        && (c.is_ascii_alphabetic() || c.is_ascii_digit())
2083    {
2084        ed.vim.recording_macro = Some(c);
2085        // For `qA` (capital), seed the buffer with the existing
2086        // lowercase recording so the new keystrokes append.
2087        if c.is_ascii_uppercase() {
2088            let lower = c.to_ascii_lowercase();
2089            // Seed `recording_keys` with the existing register's text
2090            // decoded back to inputs, so capital-register append
2091            // continues from where the previous recording left off.
2092            let text = ed
2093                .registers()
2094                .read(lower)
2095                .map(|s| s.text.clone())
2096                .unwrap_or_default();
2097            ed.vim.recording_keys = crate::input::decode_macro(&text);
2098        } else {
2099            ed.vim.recording_keys.clear();
2100        }
2101    }
2102    true
2103}
2104
2105/// `@{reg}` — replay the macro recorded under `reg`. `@@` re-plays
2106/// the last-played macro. The replay re-feeds each captured `Input`
2107/// through `step`, with `replaying_macro` flagged so the recorder
2108/// (if active) doesn't double-capture. Honours the count prefix:
2109/// `3@a` plays the macro three times.
2110fn handle_play_macro_target<H: crate::types::Host>(
2111    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2112    input: Input,
2113    count: usize,
2114) -> bool {
2115    let reg = match input.key {
2116        Key::Char('@') => ed.vim.last_macro,
2117        Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
2118            Some(c.to_ascii_lowercase())
2119        }
2120        _ => None,
2121    };
2122    let Some(reg) = reg else {
2123        return true;
2124    };
2125    // Read the macro text from the named register and decode back to
2126    // an Input stream. Empty / unset registers replay nothing.
2127    let text = match ed.registers().read(reg) {
2128        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
2129        _ => return true,
2130    };
2131    let keys = crate::input::decode_macro(&text);
2132    ed.vim.last_macro = Some(reg);
2133    let times = count.max(1);
2134    let was_replaying = ed.vim.replaying_macro;
2135    ed.vim.replaying_macro = true;
2136    for _ in 0..times {
2137        for k in keys.iter().copied() {
2138            step(ed, k);
2139        }
2140    }
2141    ed.vim.replaying_macro = was_replaying;
2142    true
2143}
2144
2145fn handle_goto_mark<H: crate::types::Host>(
2146    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2147    input: Input,
2148    linewise: bool,
2149) -> bool {
2150    let Key::Char(c) = input.key else {
2151        return true;
2152    };
2153    // Resolve the mark target. Lowercase letters look up the user
2154    // marks set via `m{a..z}`; the special chars below come from
2155    // automatic state vim maintains:
2156    //   `'` / `` ` `` — position before the most recent big jump
2157    //                  (peeks `jump_back` without popping).
2158    //   `.`           — the last edit's position.
2159    let target = match c {
2160        'a'..='z' | 'A'..='Z' => ed.mark(c),
2161        '\'' | '`' => ed.vim.jump_back.last().copied(),
2162        '.' => ed.vim.last_edit_pos,
2163        _ => None,
2164    };
2165    let Some((row, col)) = target else {
2166        return true;
2167    };
2168    let pre = ed.cursor();
2169    let (r, c_clamped) = clamp_pos(ed, (row, col));
2170    if linewise {
2171        buf_set_cursor_rc(&mut ed.buffer, r, 0);
2172        ed.push_buffer_cursor_to_textarea();
2173        move_first_non_whitespace(ed);
2174    } else {
2175        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
2176        ed.push_buffer_cursor_to_textarea();
2177    }
2178    if ed.cursor() != pre {
2179        push_jump(ed, pre);
2180    }
2181    ed.sticky_col = Some(ed.cursor().1);
2182    true
2183}
2184
2185fn take_count(vim: &mut VimState) -> usize {
2186    if vim.count > 0 {
2187        let n = vim.count;
2188        vim.count = 0;
2189        n
2190    } else {
2191        1
2192    }
2193}
2194
2195fn char_to_operator(c: char) -> Option<Operator> {
2196    match c {
2197        'd' => Some(Operator::Delete),
2198        'c' => Some(Operator::Change),
2199        'y' => Some(Operator::Yank),
2200        '>' => Some(Operator::Indent),
2201        '<' => Some(Operator::Outdent),
2202        _ => None,
2203    }
2204}
2205
2206fn visual_operator(input: &Input) -> Option<Operator> {
2207    if input.ctrl {
2208        return None;
2209    }
2210    match input.key {
2211        Key::Char('y') => Some(Operator::Yank),
2212        Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
2213        Key::Char('c') | Key::Char('s') => Some(Operator::Change),
2214        // Case operators — shift forms apply to the active selection.
2215        Key::Char('U') => Some(Operator::Uppercase),
2216        Key::Char('u') => Some(Operator::Lowercase),
2217        Key::Char('~') => Some(Operator::ToggleCase),
2218        // Indent operators on selection.
2219        Key::Char('>') => Some(Operator::Indent),
2220        Key::Char('<') => Some(Operator::Outdent),
2221        _ => None,
2222    }
2223}
2224
2225fn find_entry(input: &Input) -> Option<(bool, bool)> {
2226    if input.ctrl {
2227        return None;
2228    }
2229    match input.key {
2230        Key::Char('f') => Some((true, false)),
2231        Key::Char('F') => Some((false, false)),
2232        Key::Char('t') => Some((true, true)),
2233        Key::Char('T') => Some((false, true)),
2234        _ => None,
2235    }
2236}
2237
2238// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
2239
2240/// Max jumplist depth. Matches vim default.
2241const JUMPLIST_MAX: usize = 100;
2242
2243/// Record a pre-jump cursor position. Called *before* a big-jump
2244/// motion runs (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, `/`?
2245/// commit, `:{nr}`). Making a new jump while the forward stack had
2246/// entries trims them — branching off the history clears the "redo".
2247fn push_jump<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, from: (usize, usize)) {
2248    ed.vim.jump_back.push(from);
2249    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2250        ed.vim.jump_back.remove(0);
2251    }
2252    ed.vim.jump_fwd.clear();
2253}
2254
2255/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
2256/// the current cursor onto the forward stack so `Ctrl-i` can return.
2257fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2258    let Some(target) = ed.vim.jump_back.pop() else {
2259        return;
2260    };
2261    let cur = ed.cursor();
2262    ed.vim.jump_fwd.push(cur);
2263    let (r, c) = clamp_pos(ed, target);
2264    ed.jump_cursor(r, c);
2265    ed.sticky_col = Some(c);
2266}
2267
2268/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
2269/// onto the back stack.
2270fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2271    let Some(target) = ed.vim.jump_fwd.pop() else {
2272        return;
2273    };
2274    let cur = ed.cursor();
2275    ed.vim.jump_back.push(cur);
2276    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2277        ed.vim.jump_back.remove(0);
2278    }
2279    let (r, c) = clamp_pos(ed, target);
2280    ed.jump_cursor(r, c);
2281    ed.sticky_col = Some(c);
2282}
2283
2284/// Clamp a stored `(row, col)` to the live buffer in case edits
2285/// shrunk the document between push and pop.
2286fn clamp_pos<H: crate::types::Host>(
2287    ed: &Editor<hjkl_buffer::Buffer, H>,
2288    pos: (usize, usize),
2289) -> (usize, usize) {
2290    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2291    let r = pos.0.min(last_row);
2292    let line_len = buf_line_chars(&ed.buffer, r);
2293    let c = pos.1.min(line_len.saturating_sub(1));
2294    (r, c)
2295}
2296
2297/// True for motions that vim treats as jumps (pushed onto the jumplist).
2298fn is_big_jump(motion: &Motion) -> bool {
2299    matches!(
2300        motion,
2301        Motion::FileTop
2302            | Motion::FileBottom
2303            | Motion::MatchBracket
2304            | Motion::WordAtCursor { .. }
2305            | Motion::SearchNext { .. }
2306            | Motion::ViewportTop
2307            | Motion::ViewportMiddle
2308            | Motion::ViewportBottom
2309    )
2310}
2311
2312// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
2313
2314/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
2315/// viewports still step by a single row. `count` multiplies.
2316fn viewport_half_rows<H: crate::types::Host>(
2317    ed: &Editor<hjkl_buffer::Buffer, H>,
2318    count: usize,
2319) -> usize {
2320    let h = ed.viewport_height_value() as usize;
2321    (h / 2).max(1).saturating_mul(count.max(1))
2322}
2323
2324/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
2325/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
2326fn viewport_full_rows<H: crate::types::Host>(
2327    ed: &Editor<hjkl_buffer::Buffer, H>,
2328    count: usize,
2329) -> usize {
2330    let h = ed.viewport_height_value() as usize;
2331    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
2332}
2333
2334/// Move the cursor by `delta` rows (positive = down, negative = up),
2335/// clamp to the document, then land at the first non-blank on the new
2336/// row. The textarea viewport auto-scrolls to keep the cursor visible
2337/// when the cursor pushes off-screen.
2338fn scroll_cursor_rows<H: crate::types::Host>(
2339    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2340    delta: isize,
2341) {
2342    if delta == 0 {
2343        return;
2344    }
2345    ed.sync_buffer_content_from_textarea();
2346    let (row, _) = ed.cursor();
2347    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2348    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
2349    buf_set_cursor_rc(&mut ed.buffer, target, 0);
2350    crate::motions::move_first_non_blank(&mut ed.buffer);
2351    ed.push_buffer_cursor_to_textarea();
2352    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
2353}
2354
2355// ─── Motion parsing ────────────────────────────────────────────────────────
2356
2357fn parse_motion(input: &Input) -> Option<Motion> {
2358    if input.ctrl {
2359        return None;
2360    }
2361    match input.key {
2362        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
2363        Key::Char('l') | Key::Right => Some(Motion::Right),
2364        Key::Char('j') | Key::Down | Key::Enter => Some(Motion::Down),
2365        Key::Char('k') | Key::Up => Some(Motion::Up),
2366        Key::Char('w') => Some(Motion::WordFwd),
2367        Key::Char('W') => Some(Motion::BigWordFwd),
2368        Key::Char('b') => Some(Motion::WordBack),
2369        Key::Char('B') => Some(Motion::BigWordBack),
2370        Key::Char('e') => Some(Motion::WordEnd),
2371        Key::Char('E') => Some(Motion::BigWordEnd),
2372        Key::Char('0') | Key::Home => Some(Motion::LineStart),
2373        Key::Char('^') => Some(Motion::FirstNonBlank),
2374        Key::Char('$') | Key::End => Some(Motion::LineEnd),
2375        Key::Char('G') => Some(Motion::FileBottom),
2376        Key::Char('%') => Some(Motion::MatchBracket),
2377        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
2378        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
2379        Key::Char('*') => Some(Motion::WordAtCursor {
2380            forward: true,
2381            whole_word: true,
2382        }),
2383        Key::Char('#') => Some(Motion::WordAtCursor {
2384            forward: false,
2385            whole_word: true,
2386        }),
2387        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
2388        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
2389        Key::Char('H') => Some(Motion::ViewportTop),
2390        Key::Char('M') => Some(Motion::ViewportMiddle),
2391        Key::Char('L') => Some(Motion::ViewportBottom),
2392        Key::Char('{') => Some(Motion::ParagraphPrev),
2393        Key::Char('}') => Some(Motion::ParagraphNext),
2394        Key::Char('(') => Some(Motion::SentencePrev),
2395        Key::Char(')') => Some(Motion::SentenceNext),
2396        _ => None,
2397    }
2398}
2399
2400// ─── Motion execution ──────────────────────────────────────────────────────
2401
2402fn execute_motion<H: crate::types::Host>(
2403    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2404    motion: Motion,
2405    count: usize,
2406) {
2407    let count = count.max(1);
2408    // FindRepeat needs the stored direction.
2409    let motion = match motion {
2410        Motion::FindRepeat { reverse } => match ed.vim.last_find {
2411            Some((ch, forward, till)) => Motion::Find {
2412                ch,
2413                forward: if reverse { !forward } else { forward },
2414                till,
2415            },
2416            None => return,
2417        },
2418        other => other,
2419    };
2420    let pre_pos = ed.cursor();
2421    let pre_col = pre_pos.1;
2422    apply_motion_cursor(ed, &motion, count);
2423    let post_pos = ed.cursor();
2424    if is_big_jump(&motion) && pre_pos != post_pos {
2425        push_jump(ed, pre_pos);
2426    }
2427    apply_sticky_col(ed, &motion, pre_col);
2428    // Phase 7b: keep the migration buffer's cursor + viewport in
2429    // lockstep with the textarea after every motion. Once 7c lands
2430    // (motions ported onto the buffer's API), this flips: the
2431    // buffer becomes authoritative and the textarea mirrors it.
2432    ed.sync_buffer_from_textarea();
2433}
2434
2435/// Restore the cursor to the sticky column after vertical motions and
2436/// sync the sticky column to the current column after horizontal ones.
2437/// `pre_col` is the cursor column captured *before* the motion — used
2438/// to bootstrap the sticky value on the very first motion.
2439fn apply_sticky_col<H: crate::types::Host>(
2440    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2441    motion: &Motion,
2442    pre_col: usize,
2443) {
2444    if is_vertical_motion(motion) {
2445        let want = ed.sticky_col.unwrap_or(pre_col);
2446        // Record the desired column so the next vertical motion sees
2447        // it even if we currently clamped to a shorter row.
2448        ed.sticky_col = Some(want);
2449        let (row, _) = ed.cursor();
2450        let line_len = buf_line_chars(&ed.buffer, row);
2451        // Clamp to the last char on non-empty lines (vim normal-mode
2452        // never parks the cursor one past end of line). Empty lines
2453        // collapse to col 0.
2454        let max_col = line_len.saturating_sub(1);
2455        let target = want.min(max_col);
2456        ed.jump_cursor(row, target);
2457    } else {
2458        // Horizontal motion or non-motion: sticky column tracks the
2459        // new cursor column so the *next* vertical motion aims there.
2460        ed.sticky_col = Some(ed.cursor().1);
2461    }
2462}
2463
2464fn is_vertical_motion(motion: &Motion) -> bool {
2465    // Only j / k preserve the sticky column. Everything else (search,
2466    // gg / G, word jumps, etc.) lands at the match's own column so the
2467    // sticky value should sync to the new cursor column.
2468    matches!(
2469        motion,
2470        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
2471    )
2472}
2473
2474fn apply_motion_cursor<H: crate::types::Host>(
2475    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2476    motion: &Motion,
2477    count: usize,
2478) {
2479    apply_motion_cursor_ctx(ed, motion, count, false)
2480}
2481
2482fn apply_motion_cursor_ctx<H: crate::types::Host>(
2483    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2484    motion: &Motion,
2485    count: usize,
2486    as_operator: bool,
2487) {
2488    match motion {
2489        Motion::Left => {
2490            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
2491            crate::motions::move_left(&mut ed.buffer, count);
2492            ed.push_buffer_cursor_to_textarea();
2493        }
2494        Motion::Right => {
2495            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
2496            // one past the last char so the range includes it; cursor
2497            // context clamps at the last char.
2498            if as_operator {
2499                crate::motions::move_right_to_end(&mut ed.buffer, count);
2500            } else {
2501                crate::motions::move_right_in_line(&mut ed.buffer, count);
2502            }
2503            ed.push_buffer_cursor_to_textarea();
2504        }
2505        Motion::Up => {
2506            // Final col is set by `apply_sticky_col` below — push the
2507            // post-move row to the textarea and let sticky tracking
2508            // finish the work.
2509            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2510            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2511            ed.push_buffer_cursor_to_textarea();
2512        }
2513        Motion::Down => {
2514            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2515            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2516            ed.push_buffer_cursor_to_textarea();
2517        }
2518        Motion::ScreenUp => {
2519            let v = *ed.host.viewport();
2520            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2521            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2522            ed.push_buffer_cursor_to_textarea();
2523        }
2524        Motion::ScreenDown => {
2525            let v = *ed.host.viewport();
2526            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2527            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2528            ed.push_buffer_cursor_to_textarea();
2529        }
2530        Motion::WordFwd => {
2531            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2532            ed.push_buffer_cursor_to_textarea();
2533        }
2534        Motion::WordBack => {
2535            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2536            ed.push_buffer_cursor_to_textarea();
2537        }
2538        Motion::WordEnd => {
2539            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2540            ed.push_buffer_cursor_to_textarea();
2541        }
2542        Motion::BigWordFwd => {
2543            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2544            ed.push_buffer_cursor_to_textarea();
2545        }
2546        Motion::BigWordBack => {
2547            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2548            ed.push_buffer_cursor_to_textarea();
2549        }
2550        Motion::BigWordEnd => {
2551            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2552            ed.push_buffer_cursor_to_textarea();
2553        }
2554        Motion::WordEndBack => {
2555            crate::motions::move_word_end_back(
2556                &mut ed.buffer,
2557                false,
2558                count,
2559                &ed.settings.iskeyword,
2560            );
2561            ed.push_buffer_cursor_to_textarea();
2562        }
2563        Motion::BigWordEndBack => {
2564            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2565            ed.push_buffer_cursor_to_textarea();
2566        }
2567        Motion::LineStart => {
2568            crate::motions::move_line_start(&mut ed.buffer);
2569            ed.push_buffer_cursor_to_textarea();
2570        }
2571        Motion::FirstNonBlank => {
2572            crate::motions::move_first_non_blank(&mut ed.buffer);
2573            ed.push_buffer_cursor_to_textarea();
2574        }
2575        Motion::LineEnd => {
2576            // Vim normal-mode `$` lands on the last char, not one past it.
2577            crate::motions::move_line_end(&mut ed.buffer);
2578            ed.push_buffer_cursor_to_textarea();
2579        }
2580        Motion::FileTop => {
2581            // `count gg` jumps to line `count` (first non-blank);
2582            // bare `gg` lands at the top.
2583            if count > 1 {
2584                crate::motions::move_bottom(&mut ed.buffer, count);
2585            } else {
2586                crate::motions::move_top(&mut ed.buffer);
2587            }
2588            ed.push_buffer_cursor_to_textarea();
2589        }
2590        Motion::FileBottom => {
2591            // `count G` jumps to line `count`; bare `G` lands at
2592            // the buffer bottom (`Buffer::move_bottom(0)`).
2593            if count > 1 {
2594                crate::motions::move_bottom(&mut ed.buffer, count);
2595            } else {
2596                crate::motions::move_bottom(&mut ed.buffer, 0);
2597            }
2598            ed.push_buffer_cursor_to_textarea();
2599        }
2600        Motion::Find { ch, forward, till } => {
2601            for _ in 0..count {
2602                if !find_char_on_line(ed, *ch, *forward, *till) {
2603                    break;
2604                }
2605            }
2606        }
2607        Motion::FindRepeat { .. } => {} // already resolved upstream
2608        Motion::MatchBracket => {
2609            let _ = matching_bracket(ed);
2610        }
2611        Motion::WordAtCursor {
2612            forward,
2613            whole_word,
2614        } => {
2615            word_at_cursor_search(ed, *forward, *whole_word, count);
2616        }
2617        Motion::SearchNext { reverse } => {
2618            // Re-push the last query so the buffer's search state is
2619            // correct even if the host happened to clear it (e.g. while
2620            // a Visual mode draw was in progress).
2621            if let Some(pattern) = ed.vim.last_search.clone() {
2622                push_search_pattern(ed, &pattern);
2623            }
2624            if ed.search_state().pattern.is_none() {
2625                return;
2626            }
2627            // `n` repeats the last search in its committed direction;
2628            // `N` inverts. So a `?` search makes `n` walk backward and
2629            // `N` walk forward.
2630            let forward = ed.vim.last_search_forward != *reverse;
2631            for _ in 0..count.max(1) {
2632                if forward {
2633                    ed.search_advance_forward(true);
2634                } else {
2635                    ed.search_advance_backward(true);
2636                }
2637            }
2638            ed.push_buffer_cursor_to_textarea();
2639        }
2640        Motion::ViewportTop => {
2641            let v = *ed.host().viewport();
2642            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
2643            ed.push_buffer_cursor_to_textarea();
2644        }
2645        Motion::ViewportMiddle => {
2646            let v = *ed.host().viewport();
2647            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
2648            ed.push_buffer_cursor_to_textarea();
2649        }
2650        Motion::ViewportBottom => {
2651            let v = *ed.host().viewport();
2652            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
2653            ed.push_buffer_cursor_to_textarea();
2654        }
2655        Motion::LastNonBlank => {
2656            crate::motions::move_last_non_blank(&mut ed.buffer);
2657            ed.push_buffer_cursor_to_textarea();
2658        }
2659        Motion::LineMiddle => {
2660            let row = ed.cursor().0;
2661            let line_chars = buf_line_chars(&ed.buffer, row);
2662            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
2663            // lines stay at col 0.
2664            let target = line_chars / 2;
2665            ed.jump_cursor(row, target);
2666        }
2667        Motion::ParagraphPrev => {
2668            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
2669            ed.push_buffer_cursor_to_textarea();
2670        }
2671        Motion::ParagraphNext => {
2672            crate::motions::move_paragraph_next(&mut ed.buffer, count);
2673            ed.push_buffer_cursor_to_textarea();
2674        }
2675        Motion::SentencePrev => {
2676            for _ in 0..count.max(1) {
2677                if let Some((row, col)) = sentence_boundary(ed, false) {
2678                    ed.jump_cursor(row, col);
2679                }
2680            }
2681        }
2682        Motion::SentenceNext => {
2683            for _ in 0..count.max(1) {
2684                if let Some((row, col)) = sentence_boundary(ed, true) {
2685                    ed.jump_cursor(row, col);
2686                }
2687            }
2688        }
2689    }
2690}
2691
2692fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2693    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
2694    // mutates the textarea content, so the migration buffer hasn't
2695    // seen the new lines OR new cursor yet. Mirror the full content
2696    // across before delegating, then push the result back so the
2697    // textarea reflects the resolved column too.
2698    ed.sync_buffer_content_from_textarea();
2699    crate::motions::move_first_non_blank(&mut ed.buffer);
2700    ed.push_buffer_cursor_to_textarea();
2701}
2702
2703fn find_char_on_line<H: crate::types::Host>(
2704    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2705    ch: char,
2706    forward: bool,
2707    till: bool,
2708) -> bool {
2709    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
2710    if moved {
2711        ed.push_buffer_cursor_to_textarea();
2712    }
2713    moved
2714}
2715
2716fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
2717    let moved = crate::motions::match_bracket(&mut ed.buffer);
2718    if moved {
2719        ed.push_buffer_cursor_to_textarea();
2720    }
2721    moved
2722}
2723
2724fn word_at_cursor_search<H: crate::types::Host>(
2725    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2726    forward: bool,
2727    whole_word: bool,
2728    count: usize,
2729) {
2730    let (row, col) = ed.cursor();
2731    let line: String = buf_line(&ed.buffer, row).unwrap_or("").to_string();
2732    let chars: Vec<char> = line.chars().collect();
2733    if chars.is_empty() {
2734        return;
2735    }
2736    // Expand around cursor to a word boundary.
2737    let spec = ed.settings().iskeyword.clone();
2738    let is_word = |c: char| is_keyword_char(c, &spec);
2739    let mut start = col.min(chars.len().saturating_sub(1));
2740    while start > 0 && is_word(chars[start - 1]) {
2741        start -= 1;
2742    }
2743    let mut end = start;
2744    while end < chars.len() && is_word(chars[end]) {
2745        end += 1;
2746    }
2747    if end <= start {
2748        return;
2749    }
2750    let word: String = chars[start..end].iter().collect();
2751    let escaped = regex_escape(&word);
2752    let pattern = if whole_word {
2753        format!(r"\b{escaped}\b")
2754    } else {
2755        escaped
2756    };
2757    push_search_pattern(ed, &pattern);
2758    if ed.search_state().pattern.is_none() {
2759        return;
2760    }
2761    // Remember the query so `n` / `N` keep working after the jump.
2762    ed.vim.last_search = Some(pattern);
2763    ed.vim.last_search_forward = forward;
2764    for _ in 0..count.max(1) {
2765        if forward {
2766            ed.search_advance_forward(true);
2767        } else {
2768            ed.search_advance_backward(true);
2769        }
2770    }
2771    ed.push_buffer_cursor_to_textarea();
2772}
2773
2774fn regex_escape(s: &str) -> String {
2775    let mut out = String::with_capacity(s.len());
2776    for c in s.chars() {
2777        if matches!(
2778            c,
2779            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
2780        ) {
2781            out.push('\\');
2782        }
2783        out.push(c);
2784    }
2785    out
2786}
2787
2788// ─── Operator application ──────────────────────────────────────────────────
2789
2790fn handle_after_op<H: crate::types::Host>(
2791    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2792    input: Input,
2793    op: Operator,
2794    count1: usize,
2795) -> bool {
2796    // Inner count after operator (e.g. d3w): accumulate in state.count.
2797    if let Key::Char(d @ '0'..='9') = input.key
2798        && !input.ctrl
2799        && (d != '0' || ed.vim.count > 0)
2800    {
2801        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
2802        ed.vim.pending = Pending::Op { op, count1 };
2803        return true;
2804    }
2805
2806    // Esc cancels.
2807    if input.key == Key::Esc {
2808        ed.vim.count = 0;
2809        return true;
2810    }
2811
2812    // Same-letter: dd / cc / yy / gUU / guu / g~~ / >> / <<. Fold has
2813    // no doubled form in vim — `zfzf` is two `zf` chords, not a line
2814    // op — so skip the branch entirely.
2815    let double_ch = match op {
2816        Operator::Delete => Some('d'),
2817        Operator::Change => Some('c'),
2818        Operator::Yank => Some('y'),
2819        Operator::Indent => Some('>'),
2820        Operator::Outdent => Some('<'),
2821        Operator::Uppercase => Some('U'),
2822        Operator::Lowercase => Some('u'),
2823        Operator::ToggleCase => Some('~'),
2824        Operator::Fold => None,
2825        // `gqq` reflows the current line — vim's doubled form for the
2826        // reflow operator is the second `q` after `gq`.
2827        Operator::Reflow => Some('q'),
2828    };
2829    if let Key::Char(c) = input.key
2830        && !input.ctrl
2831        && Some(c) == double_ch
2832    {
2833        let count2 = take_count(&mut ed.vim);
2834        let total = count1.max(1) * count2.max(1);
2835        execute_line_op(ed, op, total);
2836        if !ed.vim.replaying {
2837            ed.vim.last_change = Some(LastChange::LineOp {
2838                op,
2839                count: total,
2840                inserted: None,
2841            });
2842        }
2843        return true;
2844    }
2845
2846    // Text object: `i` or `a`.
2847    if let Key::Char('i') | Key::Char('a') = input.key
2848        && !input.ctrl
2849    {
2850        let inner = matches!(input.key, Key::Char('i'));
2851        ed.vim.pending = Pending::OpTextObj { op, count1, inner };
2852        return true;
2853    }
2854
2855    // `g` — awaiting `g` for `gg`.
2856    if input.key == Key::Char('g') && !input.ctrl {
2857        ed.vim.pending = Pending::OpG { op, count1 };
2858        return true;
2859    }
2860
2861    // `f`/`F`/`t`/`T` with pending target.
2862    if let Some((forward, till)) = find_entry(&input) {
2863        ed.vim.pending = Pending::OpFind {
2864            op,
2865            count1,
2866            forward,
2867            till,
2868        };
2869        return true;
2870    }
2871
2872    // Motion.
2873    let count2 = take_count(&mut ed.vim);
2874    let total = count1.max(1) * count2.max(1);
2875    if let Some(motion) = parse_motion(&input) {
2876        let motion = match motion {
2877            Motion::FindRepeat { reverse } => match ed.vim.last_find {
2878                Some((ch, forward, till)) => Motion::Find {
2879                    ch,
2880                    forward: if reverse { !forward } else { forward },
2881                    till,
2882                },
2883                None => return true,
2884            },
2885            // Vim quirk: `cw` / `cW` are `ce` / `cE` — don't include
2886            // trailing whitespace so the user's replacement text lands
2887            // before the following word's leading space.
2888            Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
2889            Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
2890            m => m,
2891        };
2892        apply_op_with_motion(ed, op, &motion, total);
2893        if let Motion::Find { ch, forward, till } = &motion {
2894            ed.vim.last_find = Some((*ch, *forward, *till));
2895        }
2896        if !ed.vim.replaying && op_is_change(op) {
2897            ed.vim.last_change = Some(LastChange::OpMotion {
2898                op,
2899                motion,
2900                count: total,
2901                inserted: None,
2902            });
2903        }
2904        return true;
2905    }
2906
2907    // Unknown — cancel the operator.
2908    true
2909}
2910
2911fn handle_op_after_g<H: crate::types::Host>(
2912    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2913    input: Input,
2914    op: Operator,
2915    count1: usize,
2916) -> bool {
2917    if input.ctrl {
2918        return true;
2919    }
2920    let count2 = take_count(&mut ed.vim);
2921    let total = count1.max(1) * count2.max(1);
2922    // Case-op linewise form: `gUgU`, `gugu`, `g~g~` — same effect as
2923    // `gUU` / `guu` / `g~~`. The leading `g` was consumed into
2924    // `Pending::OpG`, so here we see the trailing U / u / ~.
2925    if matches!(
2926        op,
2927        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
2928    ) {
2929        let op_char = match op {
2930            Operator::Uppercase => 'U',
2931            Operator::Lowercase => 'u',
2932            Operator::ToggleCase => '~',
2933            _ => unreachable!(),
2934        };
2935        if input.key == Key::Char(op_char) {
2936            execute_line_op(ed, op, total);
2937            if !ed.vim.replaying {
2938                ed.vim.last_change = Some(LastChange::LineOp {
2939                    op,
2940                    count: total,
2941                    inserted: None,
2942                });
2943            }
2944            return true;
2945        }
2946    }
2947    let motion = match input.key {
2948        Key::Char('g') => Motion::FileTop,
2949        Key::Char('e') => Motion::WordEndBack,
2950        Key::Char('E') => Motion::BigWordEndBack,
2951        Key::Char('j') => Motion::ScreenDown,
2952        Key::Char('k') => Motion::ScreenUp,
2953        _ => return true,
2954    };
2955    apply_op_with_motion(ed, op, &motion, total);
2956    if !ed.vim.replaying && op_is_change(op) {
2957        ed.vim.last_change = Some(LastChange::OpMotion {
2958            op,
2959            motion,
2960            count: total,
2961            inserted: None,
2962        });
2963    }
2964    true
2965}
2966
2967fn handle_after_g<H: crate::types::Host>(
2968    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2969    input: Input,
2970) -> bool {
2971    let count = take_count(&mut ed.vim);
2972    match input.key {
2973        Key::Char('g') => {
2974            // gg — top / jump to line count.
2975            let pre = ed.cursor();
2976            if count > 1 {
2977                ed.jump_cursor(count - 1, 0);
2978            } else {
2979                ed.jump_cursor(0, 0);
2980            }
2981            move_first_non_whitespace(ed);
2982            if ed.cursor() != pre {
2983                push_jump(ed, pre);
2984            }
2985        }
2986        Key::Char('e') => execute_motion(ed, Motion::WordEndBack, count),
2987        Key::Char('E') => execute_motion(ed, Motion::BigWordEndBack, count),
2988        // `g_` — last non-blank on the line.
2989        Key::Char('_') => execute_motion(ed, Motion::LastNonBlank, count),
2990        // `gM` — middle char column of the current line.
2991        Key::Char('M') => execute_motion(ed, Motion::LineMiddle, count),
2992        // `gv` — re-enter the last visual selection.
2993        Key::Char('v') => {
2994            if let Some(snap) = ed.vim.last_visual {
2995                match snap.mode {
2996                    Mode::Visual => {
2997                        ed.vim.visual_anchor = snap.anchor;
2998                        ed.vim.mode = Mode::Visual;
2999                    }
3000                    Mode::VisualLine => {
3001                        ed.vim.visual_line_anchor = snap.anchor.0;
3002                        ed.vim.mode = Mode::VisualLine;
3003                    }
3004                    Mode::VisualBlock => {
3005                        ed.vim.block_anchor = snap.anchor;
3006                        ed.vim.block_vcol = snap.block_vcol;
3007                        ed.vim.mode = Mode::VisualBlock;
3008                    }
3009                    _ => {}
3010                }
3011                ed.jump_cursor(snap.cursor.0, snap.cursor.1);
3012            }
3013        }
3014        // `gj` / `gk` — display-line down / up. Walks one screen
3015        // segment at a time under `:set wrap`; falls back to `j`/`k`
3016        // when wrap is off (Buffer::move_screen_* handles the branch).
3017        Key::Char('j') => execute_motion(ed, Motion::ScreenDown, count),
3018        Key::Char('k') => execute_motion(ed, Motion::ScreenUp, count),
3019        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
3020        // so the next input is treated as the motion / text object /
3021        // shorthand double (`gUU`, `guu`, `g~~`).
3022        Key::Char('U') => {
3023            ed.vim.pending = Pending::Op {
3024                op: Operator::Uppercase,
3025                count1: count,
3026            };
3027        }
3028        Key::Char('u') => {
3029            ed.vim.pending = Pending::Op {
3030                op: Operator::Lowercase,
3031                count1: count,
3032            };
3033        }
3034        Key::Char('~') => {
3035            ed.vim.pending = Pending::Op {
3036                op: Operator::ToggleCase,
3037                count1: count,
3038            };
3039        }
3040        Key::Char('q') => {
3041            // `gq{motion}` — text reflow operator. Subsequent motion
3042            // / textobj rides the same operator pipeline.
3043            ed.vim.pending = Pending::Op {
3044                op: Operator::Reflow,
3045                count1: count,
3046            };
3047        }
3048        Key::Char('J') => {
3049            // `gJ` — join line below without inserting a space.
3050            for _ in 0..count.max(1) {
3051                ed.push_undo();
3052                join_line_raw(ed);
3053            }
3054            if !ed.vim.replaying {
3055                ed.vim.last_change = Some(LastChange::JoinLine {
3056                    count: count.max(1),
3057                });
3058            }
3059        }
3060        Key::Char('d') => {
3061            // `gd` — goto definition. hjkl-engine doesn't run an LSP
3062            // itself; raise an intent the host drains and routes to
3063            // `sqls`. The cursor stays put here — the host moves it
3064            // once it has the target location.
3065            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
3066        }
3067        // `g;` / `g,` — walk the change list. `g;` toward older
3068        // entries, `g,` toward newer.
3069        Key::Char(';') => walk_change_list(ed, -1, count.max(1)),
3070        Key::Char(',') => walk_change_list(ed, 1, count.max(1)),
3071        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
3072        // boundary anchors), so the cursor on `foo` finds it inside
3073        // `foobar` too.
3074        Key::Char('*') => execute_motion(
3075            ed,
3076            Motion::WordAtCursor {
3077                forward: true,
3078                whole_word: false,
3079            },
3080            count,
3081        ),
3082        Key::Char('#') => execute_motion(
3083            ed,
3084            Motion::WordAtCursor {
3085                forward: false,
3086                whole_word: false,
3087            },
3088            count,
3089        ),
3090        _ => {}
3091    }
3092    true
3093}
3094
3095fn handle_after_z<H: crate::types::Host>(
3096    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3097    input: Input,
3098) -> bool {
3099    use crate::editor::CursorScrollTarget;
3100    let row = ed.cursor().0;
3101    match input.key {
3102        Key::Char('z') => {
3103            ed.scroll_cursor_to(CursorScrollTarget::Center);
3104            ed.vim.viewport_pinned = true;
3105        }
3106        Key::Char('t') => {
3107            ed.scroll_cursor_to(CursorScrollTarget::Top);
3108            ed.vim.viewport_pinned = true;
3109        }
3110        Key::Char('b') => {
3111            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
3112            ed.vim.viewport_pinned = true;
3113        }
3114        // Folds — operate on the fold under the cursor (or the
3115        // whole buffer for `R` / `M`). Routed through
3116        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
3117        // can observe / veto each op via [`Editor::take_fold_ops`].
3118        Key::Char('o') => {
3119            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
3120        }
3121        Key::Char('c') => {
3122            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
3123        }
3124        Key::Char('a') => {
3125            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
3126        }
3127        Key::Char('R') => {
3128            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
3129        }
3130        Key::Char('M') => {
3131            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
3132        }
3133        Key::Char('E') => {
3134            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
3135        }
3136        Key::Char('d') => {
3137            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
3138        }
3139        Key::Char('f') => {
3140            if matches!(
3141                ed.vim.mode,
3142                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
3143            ) {
3144                // `zf` over a Visual selection creates a fold spanning
3145                // anchor → cursor.
3146                let anchor_row = match ed.vim.mode {
3147                    Mode::VisualLine => ed.vim.visual_line_anchor,
3148                    Mode::VisualBlock => ed.vim.block_anchor.0,
3149                    _ => ed.vim.visual_anchor.0,
3150                };
3151                let cur = ed.cursor().0;
3152                let top = anchor_row.min(cur);
3153                let bot = anchor_row.max(cur);
3154                ed.apply_fold_op(crate::types::FoldOp::Add {
3155                    start_row: top,
3156                    end_row: bot,
3157                    closed: true,
3158                });
3159                ed.vim.mode = Mode::Normal;
3160            } else {
3161                // `zf{motion}` / `zf{textobj}` — route through the
3162                // operator pipeline. `Operator::Fold` reuses every
3163                // motion / text-object / `g`-prefix branch the other
3164                // operators get.
3165                let count = take_count(&mut ed.vim);
3166                ed.vim.pending = Pending::Op {
3167                    op: Operator::Fold,
3168                    count1: count,
3169                };
3170            }
3171        }
3172        _ => {}
3173    }
3174    true
3175}
3176
3177fn handle_replace<H: crate::types::Host>(
3178    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3179    input: Input,
3180) -> bool {
3181    if let Key::Char(ch) = input.key {
3182        if ed.vim.mode == Mode::VisualBlock {
3183            block_replace(ed, ch);
3184            return true;
3185        }
3186        let count = take_count(&mut ed.vim);
3187        replace_char(ed, ch, count.max(1));
3188        if !ed.vim.replaying {
3189            ed.vim.last_change = Some(LastChange::ReplaceChar {
3190                ch,
3191                count: count.max(1),
3192            });
3193        }
3194    }
3195    true
3196}
3197
3198fn handle_find_target<H: crate::types::Host>(
3199    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3200    input: Input,
3201    forward: bool,
3202    till: bool,
3203) -> bool {
3204    let Key::Char(ch) = input.key else {
3205        return true;
3206    };
3207    let count = take_count(&mut ed.vim);
3208    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
3209    ed.vim.last_find = Some((ch, forward, till));
3210    true
3211}
3212
3213fn handle_op_find_target<H: crate::types::Host>(
3214    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3215    input: Input,
3216    op: Operator,
3217    count1: usize,
3218    forward: bool,
3219    till: bool,
3220) -> bool {
3221    let Key::Char(ch) = input.key else {
3222        return true;
3223    };
3224    let count2 = take_count(&mut ed.vim);
3225    let total = count1.max(1) * count2.max(1);
3226    let motion = Motion::Find { ch, forward, till };
3227    apply_op_with_motion(ed, op, &motion, total);
3228    ed.vim.last_find = Some((ch, forward, till));
3229    if !ed.vim.replaying && op_is_change(op) {
3230        ed.vim.last_change = Some(LastChange::OpMotion {
3231            op,
3232            motion,
3233            count: total,
3234            inserted: None,
3235        });
3236    }
3237    true
3238}
3239
3240fn handle_text_object<H: crate::types::Host>(
3241    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3242    input: Input,
3243    op: Operator,
3244    _count1: usize,
3245    inner: bool,
3246) -> bool {
3247    let Key::Char(ch) = input.key else {
3248        return true;
3249    };
3250    let obj = match ch {
3251        'w' => TextObject::Word { big: false },
3252        'W' => TextObject::Word { big: true },
3253        '"' | '\'' | '`' => TextObject::Quote(ch),
3254        '(' | ')' | 'b' => TextObject::Bracket('('),
3255        '[' | ']' => TextObject::Bracket('['),
3256        '{' | '}' | 'B' => TextObject::Bracket('{'),
3257        '<' | '>' => TextObject::Bracket('<'),
3258        'p' => TextObject::Paragraph,
3259        't' => TextObject::XmlTag,
3260        's' => TextObject::Sentence,
3261        _ => return true,
3262    };
3263    apply_op_with_text_object(ed, op, obj, inner);
3264    if !ed.vim.replaying && op_is_change(op) {
3265        ed.vim.last_change = Some(LastChange::OpTextObj {
3266            op,
3267            obj,
3268            inner,
3269            inserted: None,
3270        });
3271    }
3272    true
3273}
3274
3275fn handle_visual_text_obj<H: crate::types::Host>(
3276    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3277    input: Input,
3278    inner: bool,
3279) -> bool {
3280    let Key::Char(ch) = input.key else {
3281        return true;
3282    };
3283    let obj = match ch {
3284        'w' => TextObject::Word { big: false },
3285        'W' => TextObject::Word { big: true },
3286        '"' | '\'' | '`' => TextObject::Quote(ch),
3287        '(' | ')' | 'b' => TextObject::Bracket('('),
3288        '[' | ']' => TextObject::Bracket('['),
3289        '{' | '}' | 'B' => TextObject::Bracket('{'),
3290        '<' | '>' => TextObject::Bracket('<'),
3291        'p' => TextObject::Paragraph,
3292        't' => TextObject::XmlTag,
3293        's' => TextObject::Sentence,
3294        _ => return true,
3295    };
3296    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3297        return true;
3298    };
3299    // Anchor + cursor position the char-wise highlight / operator range;
3300    // for linewise text-objects we switch into VisualLine with the
3301    // appropriate row anchor.
3302    match kind {
3303        MotionKind::Linewise => {
3304            ed.vim.visual_line_anchor = start.0;
3305            ed.vim.mode = Mode::VisualLine;
3306            ed.jump_cursor(end.0, 0);
3307        }
3308        _ => {
3309            ed.vim.mode = Mode::Visual;
3310            ed.vim.visual_anchor = (start.0, start.1);
3311            let (er, ec) = retreat_one(ed, end);
3312            ed.jump_cursor(er, ec);
3313        }
3314    }
3315    true
3316}
3317
3318/// Move `pos` back by one character, clamped to (0, 0).
3319fn retreat_one<H: crate::types::Host>(
3320    ed: &Editor<hjkl_buffer::Buffer, H>,
3321    pos: (usize, usize),
3322) -> (usize, usize) {
3323    let (r, c) = pos;
3324    if c > 0 {
3325        (r, c - 1)
3326    } else if r > 0 {
3327        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
3328        (r - 1, prev_len)
3329    } else {
3330        (0, 0)
3331    }
3332}
3333
3334fn op_is_change(op: Operator) -> bool {
3335    matches!(op, Operator::Delete | Operator::Change)
3336}
3337
3338// ─── Normal-only commands (not motion, not operator) ───────────────────────
3339
3340fn handle_normal_only<H: crate::types::Host>(
3341    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3342    input: &Input,
3343    count: usize,
3344) -> bool {
3345    if input.ctrl {
3346        return false;
3347    }
3348    match input.key {
3349        Key::Char('i') => {
3350            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
3351            true
3352        }
3353        Key::Char('I') => {
3354            move_first_non_whitespace(ed);
3355            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
3356            true
3357        }
3358        Key::Char('a') => {
3359            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3360            ed.push_buffer_cursor_to_textarea();
3361            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
3362            true
3363        }
3364        Key::Char('A') => {
3365            crate::motions::move_line_end(&mut ed.buffer);
3366            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3367            ed.push_buffer_cursor_to_textarea();
3368            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
3369            true
3370        }
3371        Key::Char('R') => {
3372            // Replace mode — overstrike each typed cell. Reuses the
3373            // insert-mode key handler with a Replace-flavoured session.
3374            begin_insert(ed, count.max(1), InsertReason::Replace);
3375            true
3376        }
3377        Key::Char('o') => {
3378            use hjkl_buffer::{Edit, Position};
3379            ed.push_undo();
3380            // Snapshot BEFORE the newline so replay sees "\n<text>" as the
3381            // delta and produces one fresh line per iteration.
3382            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
3383            ed.sync_buffer_content_from_textarea();
3384            let row = buf_cursor_pos(&ed.buffer).row;
3385            let line_chars = buf_line_chars(&ed.buffer, row);
3386            // Smart/auto-indent based on the current line (becomes the
3387            // "previous" line for the freshly-opened line below).
3388            let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
3389            let indent = compute_enter_indent(&ed.settings, prev_line);
3390            ed.mutate_edit(Edit::InsertStr {
3391                at: Position::new(row, line_chars),
3392                text: format!("\n{indent}"),
3393            });
3394            ed.push_buffer_cursor_to_textarea();
3395            true
3396        }
3397        Key::Char('O') => {
3398            use hjkl_buffer::{Edit, Position};
3399            ed.push_undo();
3400            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
3401            ed.sync_buffer_content_from_textarea();
3402            let row = buf_cursor_pos(&ed.buffer).row;
3403            // The line opened above sits between row-1 and the current
3404            // row. Smart/auto-indent off the line above when there is
3405            // one; otherwise copy the current line's leading whitespace.
3406            let indent = if row > 0 {
3407                let above = buf_line(&ed.buffer, row - 1).unwrap_or_default();
3408                compute_enter_indent(&ed.settings, above)
3409            } else {
3410                let cur = buf_line(&ed.buffer, row).unwrap_or_default();
3411                cur.chars()
3412                    .take_while(|c| *c == ' ' || *c == '\t')
3413                    .collect::<String>()
3414            };
3415            ed.mutate_edit(Edit::InsertStr {
3416                at: Position::new(row, 0),
3417                text: format!("{indent}\n"),
3418            });
3419            // After insert, cursor sits on the surviving content one row
3420            // down — step back up onto the freshly-opened line, then to
3421            // the end of its indent so insert mode picks up where the
3422            // user expects to type.
3423            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3424            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
3425            let new_row = buf_cursor_pos(&ed.buffer).row;
3426            buf_set_cursor_rc(&mut ed.buffer, new_row, indent.chars().count());
3427            ed.push_buffer_cursor_to_textarea();
3428            true
3429        }
3430        Key::Char('x') => {
3431            do_char_delete(ed, true, count.max(1));
3432            if !ed.vim.replaying {
3433                ed.vim.last_change = Some(LastChange::CharDel {
3434                    forward: true,
3435                    count: count.max(1),
3436                });
3437            }
3438            true
3439        }
3440        Key::Char('X') => {
3441            do_char_delete(ed, false, count.max(1));
3442            if !ed.vim.replaying {
3443                ed.vim.last_change = Some(LastChange::CharDel {
3444                    forward: false,
3445                    count: count.max(1),
3446                });
3447            }
3448            true
3449        }
3450        Key::Char('~') => {
3451            for _ in 0..count.max(1) {
3452                ed.push_undo();
3453                toggle_case_at_cursor(ed);
3454            }
3455            if !ed.vim.replaying {
3456                ed.vim.last_change = Some(LastChange::ToggleCase {
3457                    count: count.max(1),
3458                });
3459            }
3460            true
3461        }
3462        Key::Char('J') => {
3463            for _ in 0..count.max(1) {
3464                ed.push_undo();
3465                join_line(ed);
3466            }
3467            if !ed.vim.replaying {
3468                ed.vim.last_change = Some(LastChange::JoinLine {
3469                    count: count.max(1),
3470                });
3471            }
3472            true
3473        }
3474        Key::Char('D') => {
3475            ed.push_undo();
3476            delete_to_eol(ed);
3477            // Vim parks the cursor on the new last char.
3478            crate::motions::move_left(&mut ed.buffer, 1);
3479            ed.push_buffer_cursor_to_textarea();
3480            if !ed.vim.replaying {
3481                ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
3482            }
3483            true
3484        }
3485        Key::Char('Y') => {
3486            // Vim 8 default: `Y` yanks to end of line (same as `y$`).
3487            apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
3488            true
3489        }
3490        Key::Char('C') => {
3491            ed.push_undo();
3492            delete_to_eol(ed);
3493            begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
3494            true
3495        }
3496        Key::Char('s') => {
3497            use hjkl_buffer::{Edit, MotionKind, Position};
3498            ed.push_undo();
3499            ed.sync_buffer_content_from_textarea();
3500            for _ in 0..count.max(1) {
3501                let cursor = buf_cursor_pos(&ed.buffer);
3502                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
3503                if cursor.col >= line_chars {
3504                    break;
3505                }
3506                ed.mutate_edit(Edit::DeleteRange {
3507                    start: cursor,
3508                    end: Position::new(cursor.row, cursor.col + 1),
3509                    kind: MotionKind::Char,
3510                });
3511            }
3512            ed.push_buffer_cursor_to_textarea();
3513            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3514            // `s` == `cl` — record as such.
3515            if !ed.vim.replaying {
3516                ed.vim.last_change = Some(LastChange::OpMotion {
3517                    op: Operator::Change,
3518                    motion: Motion::Right,
3519                    count: count.max(1),
3520                    inserted: None,
3521                });
3522            }
3523            true
3524        }
3525        Key::Char('p') => {
3526            do_paste(ed, false, count.max(1));
3527            if !ed.vim.replaying {
3528                ed.vim.last_change = Some(LastChange::Paste {
3529                    before: false,
3530                    count: count.max(1),
3531                });
3532            }
3533            true
3534        }
3535        Key::Char('P') => {
3536            do_paste(ed, true, count.max(1));
3537            if !ed.vim.replaying {
3538                ed.vim.last_change = Some(LastChange::Paste {
3539                    before: true,
3540                    count: count.max(1),
3541                });
3542            }
3543            true
3544        }
3545        Key::Char('u') => {
3546            do_undo(ed);
3547            true
3548        }
3549        Key::Char('r') => {
3550            ed.vim.count = count;
3551            ed.vim.pending = Pending::Replace;
3552            true
3553        }
3554        Key::Char('/') => {
3555            enter_search(ed, true);
3556            true
3557        }
3558        Key::Char('?') => {
3559            enter_search(ed, false);
3560            true
3561        }
3562        Key::Char('.') => {
3563            replay_last_change(ed, count);
3564            true
3565        }
3566        _ => false,
3567    }
3568}
3569
3570/// Variant of begin_insert that doesn't push_undo (caller already did).
3571fn begin_insert_noundo<H: crate::types::Host>(
3572    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3573    count: usize,
3574    reason: InsertReason,
3575) {
3576    let reason = if ed.vim.replaying {
3577        InsertReason::ReplayOnly
3578    } else {
3579        reason
3580    };
3581    let (row, _) = ed.cursor();
3582    ed.vim.insert_session = Some(InsertSession {
3583        count,
3584        row_min: row,
3585        row_max: row,
3586        before_lines: buf_lines_to_vec(&ed.buffer),
3587        reason,
3588    });
3589    ed.vim.mode = Mode::Insert;
3590}
3591
3592// ─── Operator × Motion application ─────────────────────────────────────────
3593
3594fn apply_op_with_motion<H: crate::types::Host>(
3595    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3596    op: Operator,
3597    motion: &Motion,
3598    count: usize,
3599) {
3600    let start = ed.cursor();
3601    // Tentatively apply motion to find the endpoint. Operator context
3602    // so `l` on the last char advances past-last (standard vim
3603    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
3604    // `yl` to cover the final char.
3605    apply_motion_cursor_ctx(ed, motion, count, true);
3606    let end = ed.cursor();
3607    let kind = motion_kind(motion);
3608    // Restore cursor before selecting (so Yank leaves cursor at start).
3609    ed.jump_cursor(start.0, start.1);
3610    run_operator_over_range(ed, op, start, end, kind);
3611}
3612
3613fn apply_op_with_text_object<H: crate::types::Host>(
3614    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3615    op: Operator,
3616    obj: TextObject,
3617    inner: bool,
3618) {
3619    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3620        return;
3621    };
3622    ed.jump_cursor(start.0, start.1);
3623    run_operator_over_range(ed, op, start, end, kind);
3624}
3625
3626fn motion_kind(motion: &Motion) -> MotionKind {
3627    match motion {
3628        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => MotionKind::Linewise,
3629        Motion::FileTop | Motion::FileBottom => MotionKind::Linewise,
3630        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
3631            MotionKind::Linewise
3632        }
3633        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
3634            MotionKind::Inclusive
3635        }
3636        Motion::Find { .. } => MotionKind::Inclusive,
3637        Motion::MatchBracket => MotionKind::Inclusive,
3638        // `$` now lands on the last char — operator ranges include it.
3639        Motion::LineEnd => MotionKind::Inclusive,
3640        _ => MotionKind::Exclusive,
3641    }
3642}
3643
3644fn run_operator_over_range<H: crate::types::Host>(
3645    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3646    op: Operator,
3647    start: (usize, usize),
3648    end: (usize, usize),
3649    kind: MotionKind,
3650) {
3651    let (top, bot) = order(start, end);
3652    if top == bot {
3653        return;
3654    }
3655
3656    match op {
3657        Operator::Yank => {
3658            let text = read_vim_range(ed, top, bot, kind);
3659            if !text.is_empty() {
3660                ed.record_yank_to_host(text.clone());
3661                ed.record_yank(text, matches!(kind, MotionKind::Linewise));
3662            }
3663            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3664            ed.push_buffer_cursor_to_textarea();
3665        }
3666        Operator::Delete => {
3667            ed.push_undo();
3668            cut_vim_range(ed, top, bot, kind);
3669            // After a charwise / inclusive delete the buffer cursor is
3670            // placed at `start` by the edit path. In Normal mode the
3671            // cursor max col is `line_len - 1`; clamp it here so e.g.
3672            // `d$` doesn't leave the cursor one past the new line end.
3673            if !matches!(kind, MotionKind::Linewise) {
3674                clamp_cursor_to_normal_mode(ed);
3675            }
3676            ed.vim.mode = Mode::Normal;
3677        }
3678        Operator::Change => {
3679            ed.push_undo();
3680            cut_vim_range(ed, top, bot, kind);
3681            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3682        }
3683        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3684            apply_case_op_to_selection(ed, op, top, bot, kind);
3685        }
3686        Operator::Indent | Operator::Outdent => {
3687            // Indent / outdent are always linewise even when triggered
3688            // by a char-wise motion (e.g. `>w` indents the whole line).
3689            ed.push_undo();
3690            if op == Operator::Indent {
3691                indent_rows(ed, top.0, bot.0, 1);
3692            } else {
3693                outdent_rows(ed, top.0, bot.0, 1);
3694            }
3695            ed.vim.mode = Mode::Normal;
3696        }
3697        Operator::Fold => {
3698            // Always linewise — fold the spanned rows regardless of the
3699            // motion's natural kind. Cursor lands on `top.0` to mirror
3700            // the visual `zf` path.
3701            if bot.0 >= top.0 {
3702                ed.apply_fold_op(crate::types::FoldOp::Add {
3703                    start_row: top.0,
3704                    end_row: bot.0,
3705                    closed: true,
3706                });
3707            }
3708            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3709            ed.push_buffer_cursor_to_textarea();
3710            ed.vim.mode = Mode::Normal;
3711        }
3712        Operator::Reflow => {
3713            ed.push_undo();
3714            reflow_rows(ed, top.0, bot.0);
3715            ed.vim.mode = Mode::Normal;
3716        }
3717    }
3718}
3719
3720/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
3721/// Splits on blank-line boundaries so paragraph structure is
3722/// preserved. Each paragraph's words are joined with single spaces
3723/// before re-wrapping.
3724fn reflow_rows<H: crate::types::Host>(
3725    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3726    top: usize,
3727    bot: usize,
3728) {
3729    let width = ed.settings().textwidth.max(1);
3730    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3731    let bot = bot.min(lines.len().saturating_sub(1));
3732    if top > bot {
3733        return;
3734    }
3735    let original = lines[top..=bot].to_vec();
3736    let mut wrapped: Vec<String> = Vec::new();
3737    let mut paragraph: Vec<String> = Vec::new();
3738    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
3739        if para.is_empty() {
3740            return;
3741        }
3742        let words = para.join(" ");
3743        let mut current = String::new();
3744        for word in words.split_whitespace() {
3745            let extra = if current.is_empty() {
3746                word.chars().count()
3747            } else {
3748                current.chars().count() + 1 + word.chars().count()
3749            };
3750            if extra > width && !current.is_empty() {
3751                out.push(std::mem::take(&mut current));
3752                current.push_str(word);
3753            } else if current.is_empty() {
3754                current.push_str(word);
3755            } else {
3756                current.push(' ');
3757                current.push_str(word);
3758            }
3759        }
3760        if !current.is_empty() {
3761            out.push(current);
3762        }
3763        para.clear();
3764    };
3765    for line in &original {
3766        if line.trim().is_empty() {
3767            flush(&mut paragraph, &mut wrapped, width);
3768            wrapped.push(String::new());
3769        } else {
3770            paragraph.push(line.clone());
3771        }
3772    }
3773    flush(&mut paragraph, &mut wrapped, width);
3774
3775    // Splice back. push_undo above means `u` reverses.
3776    let after: Vec<String> = lines.split_off(bot + 1);
3777    lines.truncate(top);
3778    lines.extend(wrapped);
3779    lines.extend(after);
3780    ed.restore(lines, (top, 0));
3781    ed.mark_content_dirty();
3782}
3783
3784/// Transform the range `[top, bot]` (vim `MotionKind`) in place with
3785/// the given case operator. Cursor lands on `top` afterward — vim
3786/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
3787/// Preserves the textarea yank buffer (vim's case operators don't
3788/// touch registers).
3789fn apply_case_op_to_selection<H: crate::types::Host>(
3790    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3791    op: Operator,
3792    top: (usize, usize),
3793    bot: (usize, usize),
3794    kind: MotionKind,
3795) {
3796    use hjkl_buffer::Edit;
3797    ed.push_undo();
3798    let saved_yank = ed.yank().to_string();
3799    let saved_yank_linewise = ed.vim.yank_linewise;
3800    let selection = cut_vim_range(ed, top, bot, kind);
3801    let transformed = match op {
3802        Operator::Uppercase => selection.to_uppercase(),
3803        Operator::Lowercase => selection.to_lowercase(),
3804        Operator::ToggleCase => toggle_case_str(&selection),
3805        _ => unreachable!(),
3806    };
3807    if !transformed.is_empty() {
3808        let cursor = buf_cursor_pos(&ed.buffer);
3809        ed.mutate_edit(Edit::InsertStr {
3810            at: cursor,
3811            text: transformed,
3812        });
3813    }
3814    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3815    ed.push_buffer_cursor_to_textarea();
3816    ed.set_yank(saved_yank);
3817    ed.vim.yank_linewise = saved_yank_linewise;
3818    ed.vim.mode = Mode::Normal;
3819}
3820
3821/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
3822/// Rows that are empty are skipped (vim leaves blank lines alone when
3823/// indenting). `shiftwidth` is read from `editor.settings()` so
3824/// `:set shiftwidth=N` takes effect on the next operation.
3825fn indent_rows<H: crate::types::Host>(
3826    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3827    top: usize,
3828    bot: usize,
3829    count: usize,
3830) {
3831    ed.sync_buffer_content_from_textarea();
3832    let width = ed.settings().shiftwidth * count.max(1);
3833    let pad: String = " ".repeat(width);
3834    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3835    let bot = bot.min(lines.len().saturating_sub(1));
3836    for line in lines.iter_mut().take(bot + 1).skip(top) {
3837        if !line.is_empty() {
3838            line.insert_str(0, &pad);
3839        }
3840    }
3841    // Restore cursor to first non-blank of the top row so the next
3842    // vertical motion aims sensibly — matches vim's `>>` convention.
3843    ed.restore(lines, (top, 0));
3844    move_first_non_whitespace(ed);
3845}
3846
3847/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
3848/// each row in `[top, bot]`. Rows with less leading whitespace have
3849/// all their indent stripped, not clipped to zero length.
3850fn outdent_rows<H: crate::types::Host>(
3851    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3852    top: usize,
3853    bot: usize,
3854    count: usize,
3855) {
3856    ed.sync_buffer_content_from_textarea();
3857    let width = ed.settings().shiftwidth * count.max(1);
3858    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3859    let bot = bot.min(lines.len().saturating_sub(1));
3860    for line in lines.iter_mut().take(bot + 1).skip(top) {
3861        let strip: usize = line
3862            .chars()
3863            .take(width)
3864            .take_while(|c| *c == ' ' || *c == '\t')
3865            .count();
3866        if strip > 0 {
3867            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
3868            line.drain(..byte_len);
3869        }
3870    }
3871    ed.restore(lines, (top, 0));
3872    move_first_non_whitespace(ed);
3873}
3874
3875fn toggle_case_str(s: &str) -> String {
3876    s.chars()
3877        .map(|c| {
3878            if c.is_lowercase() {
3879                c.to_uppercase().next().unwrap_or(c)
3880            } else if c.is_uppercase() {
3881                c.to_lowercase().next().unwrap_or(c)
3882            } else {
3883                c
3884            }
3885        })
3886        .collect()
3887}
3888
3889fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
3890    if a <= b { (a, b) } else { (b, a) }
3891}
3892
3893/// Clamp the buffer cursor to normal-mode valid position: col may not
3894/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
3895/// line). Vim applies this clamp on every return to Normal mode after an
3896/// operator or Esc-from-insert.
3897fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3898    let (row, col) = ed.cursor();
3899    let line_chars = buf_line_chars(&ed.buffer, row);
3900    let max_col = line_chars.saturating_sub(1);
3901    if col > max_col {
3902        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
3903        ed.push_buffer_cursor_to_textarea();
3904    }
3905}
3906
3907// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
3908
3909fn execute_line_op<H: crate::types::Host>(
3910    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3911    op: Operator,
3912    count: usize,
3913) {
3914    let (row, col) = ed.cursor();
3915    let total = buf_row_count(&ed.buffer);
3916    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
3917
3918    match op {
3919        Operator::Yank => {
3920            // yy must not move the cursor.
3921            let text = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3922            if !text.is_empty() {
3923                ed.record_yank_to_host(text.clone());
3924                ed.record_yank(text, true);
3925            }
3926            buf_set_cursor_rc(&mut ed.buffer, row, col);
3927            ed.push_buffer_cursor_to_textarea();
3928            ed.vim.mode = Mode::Normal;
3929        }
3930        Operator::Delete => {
3931            ed.push_undo();
3932            let deleted_through_last = end_row + 1 >= total;
3933            cut_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3934            // Vim's `dd` / `Ndd` leaves the cursor on the *first
3935            // non-blank* of the line that now occupies `row` — or, if
3936            // the deletion consumed the last line, the line above it.
3937            let total_after = buf_row_count(&ed.buffer);
3938            let raw_target = if deleted_through_last {
3939                row.saturating_sub(1).min(total_after.saturating_sub(1))
3940            } else {
3941                row.min(total_after.saturating_sub(1))
3942            };
3943            // Clamp off the trailing phantom empty row that arises from a
3944            // buffer with a trailing newline (stored as ["...", ""]). If
3945            // the target row is the trailing empty row and there is a real
3946            // content row above it, use that instead — matching vim's view
3947            // that the trailing `\n` is a terminator, not a separator.
3948            let target_row = if raw_target > 0
3949                && raw_target + 1 == total_after
3950                && buf_line(&ed.buffer, raw_target)
3951                    .map(str::is_empty)
3952                    .unwrap_or(false)
3953            {
3954                raw_target - 1
3955            } else {
3956                raw_target
3957            };
3958            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
3959            ed.push_buffer_cursor_to_textarea();
3960            move_first_non_whitespace(ed);
3961            ed.sticky_col = Some(ed.cursor().1);
3962            ed.vim.mode = Mode::Normal;
3963        }
3964        Operator::Change => {
3965            // `cc` / `3cc`: wipe contents of the covered lines but leave
3966            // a single blank line so insert-mode opens on it. Done as two
3967            // edits: drop rows past the first, then clear row `row`.
3968            use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
3969            ed.push_undo();
3970            ed.sync_buffer_content_from_textarea();
3971            // Read the cut payload first so yank reflects every line.
3972            let payload = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3973            if end_row > row {
3974                ed.mutate_edit(Edit::DeleteRange {
3975                    start: Position::new(row + 1, 0),
3976                    end: Position::new(end_row, 0),
3977                    kind: BufKind::Line,
3978                });
3979            }
3980            let line_chars = buf_line_chars(&ed.buffer, row);
3981            if line_chars > 0 {
3982                ed.mutate_edit(Edit::DeleteRange {
3983                    start: Position::new(row, 0),
3984                    end: Position::new(row, line_chars),
3985                    kind: BufKind::Char,
3986                });
3987            }
3988            if !payload.is_empty() {
3989                ed.record_yank_to_host(payload.clone());
3990                ed.record_delete(payload, true);
3991            }
3992            buf_set_cursor_rc(&mut ed.buffer, row, 0);
3993            ed.push_buffer_cursor_to_textarea();
3994            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3995        }
3996        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3997            // `gUU` / `guu` / `g~~` — linewise case transform over
3998            // [row, end_row]. Preserve cursor on `row` (first non-blank
3999            // lines up with vim's behaviour).
4000            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), MotionKind::Linewise);
4001            // After case-op on a linewise range vim puts the cursor on
4002            // the first non-blank of the starting line.
4003            move_first_non_whitespace(ed);
4004        }
4005        Operator::Indent | Operator::Outdent => {
4006            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
4007            ed.push_undo();
4008            if op == Operator::Indent {
4009                indent_rows(ed, row, end_row, 1);
4010            } else {
4011                outdent_rows(ed, row, end_row, 1);
4012            }
4013            ed.sticky_col = Some(ed.cursor().1);
4014            ed.vim.mode = Mode::Normal;
4015        }
4016        // No doubled form — `zfzf` is two consecutive `zf` chords.
4017        Operator::Fold => unreachable!("Fold has no line-op double"),
4018        Operator::Reflow => {
4019            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
4020            ed.push_undo();
4021            reflow_rows(ed, row, end_row);
4022            move_first_non_whitespace(ed);
4023            ed.sticky_col = Some(ed.cursor().1);
4024            ed.vim.mode = Mode::Normal;
4025        }
4026    }
4027}
4028
4029// ─── Visual mode operators ─────────────────────────────────────────────────
4030
4031fn apply_visual_operator<H: crate::types::Host>(
4032    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4033    op: Operator,
4034) {
4035    match ed.vim.mode {
4036        Mode::VisualLine => {
4037            let cursor_row = buf_cursor_pos(&ed.buffer).row;
4038            let top = cursor_row.min(ed.vim.visual_line_anchor);
4039            let bot = cursor_row.max(ed.vim.visual_line_anchor);
4040            ed.vim.yank_linewise = true;
4041            match op {
4042                Operator::Yank => {
4043                    let text = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4044                    if !text.is_empty() {
4045                        ed.record_yank_to_host(text.clone());
4046                        ed.record_yank(text, true);
4047                    }
4048                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4049                    ed.push_buffer_cursor_to_textarea();
4050                    ed.vim.mode = Mode::Normal;
4051                }
4052                Operator::Delete => {
4053                    ed.push_undo();
4054                    cut_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4055                    ed.vim.mode = Mode::Normal;
4056                }
4057                Operator::Change => {
4058                    // Vim `Vc`: wipe the line contents but leave a blank
4059                    // line in place so insert-mode starts on an empty row.
4060                    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4061                    ed.push_undo();
4062                    ed.sync_buffer_content_from_textarea();
4063                    let payload = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4064                    if bot > top {
4065                        ed.mutate_edit(Edit::DeleteRange {
4066                            start: Position::new(top + 1, 0),
4067                            end: Position::new(bot, 0),
4068                            kind: BufKind::Line,
4069                        });
4070                    }
4071                    let line_chars = buf_line_chars(&ed.buffer, top);
4072                    if line_chars > 0 {
4073                        ed.mutate_edit(Edit::DeleteRange {
4074                            start: Position::new(top, 0),
4075                            end: Position::new(top, line_chars),
4076                            kind: BufKind::Char,
4077                        });
4078                    }
4079                    if !payload.is_empty() {
4080                        ed.record_yank_to_host(payload.clone());
4081                        ed.record_delete(payload, true);
4082                    }
4083                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4084                    ed.push_buffer_cursor_to_textarea();
4085                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4086                }
4087                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4088                    let bot = buf_cursor_pos(&ed.buffer)
4089                        .row
4090                        .max(ed.vim.visual_line_anchor);
4091                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), MotionKind::Linewise);
4092                    move_first_non_whitespace(ed);
4093                }
4094                Operator::Indent | Operator::Outdent => {
4095                    ed.push_undo();
4096                    let (cursor_row, _) = ed.cursor();
4097                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4098                    if op == Operator::Indent {
4099                        indent_rows(ed, top, bot, 1);
4100                    } else {
4101                        outdent_rows(ed, top, bot, 1);
4102                    }
4103                    ed.vim.mode = Mode::Normal;
4104                }
4105                Operator::Reflow => {
4106                    ed.push_undo();
4107                    let (cursor_row, _) = ed.cursor();
4108                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4109                    reflow_rows(ed, top, bot);
4110                    ed.vim.mode = Mode::Normal;
4111                }
4112                // Visual `zf` is handled inline in `handle_after_z`,
4113                // never routed through this dispatcher.
4114                Operator::Fold => unreachable!("Visual zf takes its own path"),
4115            }
4116        }
4117        Mode::Visual => {
4118            ed.vim.yank_linewise = false;
4119            let anchor = ed.vim.visual_anchor;
4120            let cursor = ed.cursor();
4121            let (top, bot) = order(anchor, cursor);
4122            match op {
4123                Operator::Yank => {
4124                    let text = read_vim_range(ed, top, bot, MotionKind::Inclusive);
4125                    if !text.is_empty() {
4126                        ed.record_yank_to_host(text.clone());
4127                        ed.record_yank(text, false);
4128                    }
4129                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4130                    ed.push_buffer_cursor_to_textarea();
4131                    ed.vim.mode = Mode::Normal;
4132                }
4133                Operator::Delete => {
4134                    ed.push_undo();
4135                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4136                    ed.vim.mode = Mode::Normal;
4137                }
4138                Operator::Change => {
4139                    ed.push_undo();
4140                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4141                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4142                }
4143                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4144                    // Anchor stays where the visual selection started.
4145                    let anchor = ed.vim.visual_anchor;
4146                    let cursor = ed.cursor();
4147                    let (top, bot) = order(anchor, cursor);
4148                    apply_case_op_to_selection(ed, op, top, bot, MotionKind::Inclusive);
4149                }
4150                Operator::Indent | Operator::Outdent => {
4151                    ed.push_undo();
4152                    let anchor = ed.vim.visual_anchor;
4153                    let cursor = ed.cursor();
4154                    let (top, bot) = order(anchor, cursor);
4155                    if op == Operator::Indent {
4156                        indent_rows(ed, top.0, bot.0, 1);
4157                    } else {
4158                        outdent_rows(ed, top.0, bot.0, 1);
4159                    }
4160                    ed.vim.mode = Mode::Normal;
4161                }
4162                Operator::Reflow => {
4163                    ed.push_undo();
4164                    let anchor = ed.vim.visual_anchor;
4165                    let cursor = ed.cursor();
4166                    let (top, bot) = order(anchor, cursor);
4167                    reflow_rows(ed, top.0, bot.0);
4168                    ed.vim.mode = Mode::Normal;
4169                }
4170                Operator::Fold => unreachable!("Visual zf takes its own path"),
4171            }
4172        }
4173        Mode::VisualBlock => apply_block_operator(ed, op),
4174        _ => {}
4175    }
4176}
4177
4178/// Compute `(top_row, bot_row, left_col, right_col)` for the current
4179/// VisualBlock selection. Columns are inclusive on both ends. Uses the
4180/// tracked virtual column (updated by h/l, preserved across j/k) so
4181/// ragged / empty rows don't collapse the block's width.
4182fn block_bounds<H: crate::types::Host>(
4183    ed: &Editor<hjkl_buffer::Buffer, H>,
4184) -> (usize, usize, usize, usize) {
4185    let (ar, ac) = ed.vim.block_anchor;
4186    let (cr, _) = ed.cursor();
4187    let cc = ed.vim.block_vcol;
4188    let top = ar.min(cr);
4189    let bot = ar.max(cr);
4190    let left = ac.min(cc);
4191    let right = ac.max(cc);
4192    (top, bot, left, right)
4193}
4194
4195/// Update the virtual column after a motion in VisualBlock mode.
4196/// Horizontal motions sync `block_vcol` to the new cursor column;
4197/// vertical / non-h/l motions leave it alone so the intended column
4198/// survives clamping to shorter lines.
4199fn update_block_vcol<H: crate::types::Host>(
4200    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4201    motion: &Motion,
4202) {
4203    match motion {
4204        Motion::Left
4205        | Motion::Right
4206        | Motion::WordFwd
4207        | Motion::BigWordFwd
4208        | Motion::WordBack
4209        | Motion::BigWordBack
4210        | Motion::WordEnd
4211        | Motion::BigWordEnd
4212        | Motion::WordEndBack
4213        | Motion::BigWordEndBack
4214        | Motion::LineStart
4215        | Motion::FirstNonBlank
4216        | Motion::LineEnd
4217        | Motion::Find { .. }
4218        | Motion::FindRepeat { .. }
4219        | Motion::MatchBracket => {
4220            ed.vim.block_vcol = ed.cursor().1;
4221        }
4222        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
4223        _ => {}
4224    }
4225}
4226
4227/// Yank / delete / change / replace a rectangular selection. Yanked text
4228/// is stored as one string per row joined with `\n` so pasting reproduces
4229/// the block as sequential lines. (Vim's true block-paste reinserts as
4230/// columns; we render the content with our char-wise paste path.)
4231fn apply_block_operator<H: crate::types::Host>(
4232    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4233    op: Operator,
4234) {
4235    let (top, bot, left, right) = block_bounds(ed);
4236    // Snapshot the block text for yank / clipboard.
4237    let yank = block_yank(ed, top, bot, left, right);
4238
4239    match op {
4240        Operator::Yank => {
4241            if !yank.is_empty() {
4242                ed.record_yank_to_host(yank.clone());
4243                ed.record_yank(yank, false);
4244            }
4245            ed.vim.mode = Mode::Normal;
4246            ed.jump_cursor(top, left);
4247        }
4248        Operator::Delete => {
4249            ed.push_undo();
4250            delete_block_contents(ed, top, bot, left, right);
4251            if !yank.is_empty() {
4252                ed.record_yank_to_host(yank.clone());
4253                ed.record_delete(yank, false);
4254            }
4255            ed.vim.mode = Mode::Normal;
4256            ed.jump_cursor(top, left);
4257        }
4258        Operator::Change => {
4259            ed.push_undo();
4260            delete_block_contents(ed, top, bot, left, right);
4261            if !yank.is_empty() {
4262                ed.record_yank_to_host(yank.clone());
4263                ed.record_delete(yank, false);
4264            }
4265            ed.jump_cursor(top, left);
4266            begin_insert_noundo(
4267                ed,
4268                1,
4269                InsertReason::BlockEdge {
4270                    top,
4271                    bot,
4272                    col: left,
4273                },
4274            );
4275        }
4276        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4277            ed.push_undo();
4278            transform_block_case(ed, op, top, bot, left, right);
4279            ed.vim.mode = Mode::Normal;
4280            ed.jump_cursor(top, left);
4281        }
4282        Operator::Indent | Operator::Outdent => {
4283            // VisualBlock `>` / `<` falls back to linewise indent over
4284            // the block's row range — vim does the same (column-wise
4285            // indent/outdent doesn't make sense).
4286            ed.push_undo();
4287            if op == Operator::Indent {
4288                indent_rows(ed, top, bot, 1);
4289            } else {
4290                outdent_rows(ed, top, bot, 1);
4291            }
4292            ed.vim.mode = Mode::Normal;
4293        }
4294        Operator::Fold => unreachable!("Visual zf takes its own path"),
4295        Operator::Reflow => {
4296            // Reflow over the block falls back to linewise reflow over
4297            // the row range — column slicing for `gq` doesn't make
4298            // sense.
4299            ed.push_undo();
4300            reflow_rows(ed, top, bot);
4301            ed.vim.mode = Mode::Normal;
4302        }
4303    }
4304}
4305
4306/// In-place case transform over the rectangular block
4307/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
4308/// untouched — vim behaves the same way (ragged blocks).
4309fn transform_block_case<H: crate::types::Host>(
4310    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4311    op: Operator,
4312    top: usize,
4313    bot: usize,
4314    left: usize,
4315    right: usize,
4316) {
4317    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4318    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4319        let chars: Vec<char> = lines[r].chars().collect();
4320        if left >= chars.len() {
4321            continue;
4322        }
4323        let end = (right + 1).min(chars.len());
4324        let head: String = chars[..left].iter().collect();
4325        let mid: String = chars[left..end].iter().collect();
4326        let tail: String = chars[end..].iter().collect();
4327        let transformed = match op {
4328            Operator::Uppercase => mid.to_uppercase(),
4329            Operator::Lowercase => mid.to_lowercase(),
4330            Operator::ToggleCase => toggle_case_str(&mid),
4331            _ => mid,
4332        };
4333        lines[r] = format!("{head}{transformed}{tail}");
4334    }
4335    let saved_yank = ed.yank().to_string();
4336    let saved_linewise = ed.vim.yank_linewise;
4337    ed.restore(lines, (top, left));
4338    ed.set_yank(saved_yank);
4339    ed.vim.yank_linewise = saved_linewise;
4340}
4341
4342fn block_yank<H: crate::types::Host>(
4343    ed: &Editor<hjkl_buffer::Buffer, H>,
4344    top: usize,
4345    bot: usize,
4346    left: usize,
4347    right: usize,
4348) -> String {
4349    let lines = buf_lines_to_vec(&ed.buffer);
4350    let mut rows: Vec<String> = Vec::new();
4351    for r in top..=bot {
4352        let line = match lines.get(r) {
4353            Some(l) => l,
4354            None => break,
4355        };
4356        let chars: Vec<char> = line.chars().collect();
4357        let end = (right + 1).min(chars.len());
4358        if left >= chars.len() {
4359            rows.push(String::new());
4360        } else {
4361            rows.push(chars[left..end].iter().collect());
4362        }
4363    }
4364    rows.join("\n")
4365}
4366
4367fn delete_block_contents<H: crate::types::Host>(
4368    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4369    top: usize,
4370    bot: usize,
4371    left: usize,
4372    right: usize,
4373) {
4374    use hjkl_buffer::{Edit, MotionKind, Position};
4375    ed.sync_buffer_content_from_textarea();
4376    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
4377    if last_row < top {
4378        return;
4379    }
4380    ed.mutate_edit(Edit::DeleteRange {
4381        start: Position::new(top, left),
4382        end: Position::new(last_row, right),
4383        kind: MotionKind::Block,
4384    });
4385    ed.push_buffer_cursor_to_textarea();
4386}
4387
4388/// Replace each character cell in the block with `ch`.
4389fn block_replace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, ch: char) {
4390    let (top, bot, left, right) = block_bounds(ed);
4391    ed.push_undo();
4392    ed.sync_buffer_content_from_textarea();
4393    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4394    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4395        let chars: Vec<char> = lines[r].chars().collect();
4396        if left >= chars.len() {
4397            continue;
4398        }
4399        let end = (right + 1).min(chars.len());
4400        let before: String = chars[..left].iter().collect();
4401        let middle: String = std::iter::repeat_n(ch, end - left).collect();
4402        let after: String = chars[end..].iter().collect();
4403        lines[r] = format!("{before}{middle}{after}");
4404    }
4405    reset_textarea_lines(ed, lines);
4406    ed.vim.mode = Mode::Normal;
4407    ed.jump_cursor(top, left);
4408}
4409
4410/// Replace buffer content with `lines` while preserving the cursor.
4411/// Used by indent / outdent / block_replace to wholesale rewrite
4412/// rows without going through the per-edit funnel.
4413fn reset_textarea_lines<H: crate::types::Host>(
4414    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4415    lines: Vec<String>,
4416) {
4417    let cursor = ed.cursor();
4418    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
4419    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
4420    ed.mark_content_dirty();
4421}
4422
4423// ─── Visual-line helpers ───────────────────────────────────────────────────
4424
4425// ─── Text-object range computation ─────────────────────────────────────────
4426
4427/// Cursor position as `(row, col)`.
4428type Pos = (usize, usize);
4429
4430/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
4431/// last character to act on). `kind` is `Linewise` for line-oriented text
4432/// objects like paragraphs and `Exclusive` otherwise.
4433fn text_object_range<H: crate::types::Host>(
4434    ed: &Editor<hjkl_buffer::Buffer, H>,
4435    obj: TextObject,
4436    inner: bool,
4437) -> Option<(Pos, Pos, MotionKind)> {
4438    match obj {
4439        TextObject::Word { big } => {
4440            word_text_object(ed, inner, big).map(|(s, e)| (s, e, MotionKind::Exclusive))
4441        }
4442        TextObject::Quote(q) => {
4443            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4444        }
4445        TextObject::Bracket(open) => bracket_text_object(ed, open, inner),
4446        TextObject::Paragraph => {
4447            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Linewise))
4448        }
4449        TextObject::XmlTag => {
4450            tag_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4451        }
4452        TextObject::Sentence => {
4453            sentence_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4454        }
4455    }
4456}
4457
4458/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
4459/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
4460/// `None` when already at the buffer's edge in that direction.
4461fn sentence_boundary<H: crate::types::Host>(
4462    ed: &Editor<hjkl_buffer::Buffer, H>,
4463    forward: bool,
4464) -> Option<(usize, usize)> {
4465    let lines = buf_lines_to_vec(&ed.buffer);
4466    if lines.is_empty() {
4467        return None;
4468    }
4469    let pos_to_idx = |pos: (usize, usize)| -> usize {
4470        let mut idx = 0;
4471        for line in lines.iter().take(pos.0) {
4472            idx += line.chars().count() + 1;
4473        }
4474        idx + pos.1
4475    };
4476    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4477        for (r, line) in lines.iter().enumerate() {
4478            let len = line.chars().count();
4479            if idx <= len {
4480                return (r, idx);
4481            }
4482            idx -= len + 1;
4483        }
4484        let last = lines.len().saturating_sub(1);
4485        (last, lines[last].chars().count())
4486    };
4487    let mut chars: Vec<char> = Vec::new();
4488    for (r, line) in lines.iter().enumerate() {
4489        chars.extend(line.chars());
4490        if r + 1 < lines.len() {
4491            chars.push('\n');
4492        }
4493    }
4494    if chars.is_empty() {
4495        return None;
4496    }
4497    let total = chars.len();
4498    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
4499    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4500
4501    if forward {
4502        // Walk forward looking for a terminator run followed by
4503        // whitespace; land on the first non-whitespace cell after.
4504        let mut i = cursor_idx + 1;
4505        while i < total {
4506            if is_terminator(chars[i]) {
4507                while i + 1 < total && is_terminator(chars[i + 1]) {
4508                    i += 1;
4509                }
4510                if i + 1 >= total {
4511                    return None;
4512                }
4513                if chars[i + 1].is_whitespace() {
4514                    let mut j = i + 1;
4515                    while j < total && chars[j].is_whitespace() {
4516                        j += 1;
4517                    }
4518                    if j >= total {
4519                        return None;
4520                    }
4521                    return Some(idx_to_pos(j));
4522                }
4523            }
4524            i += 1;
4525        }
4526        None
4527    } else {
4528        // Walk backward to find the start of the current sentence (if
4529        // we're already at the start, jump to the previous sentence's
4530        // start instead).
4531        let find_start = |from: usize| -> Option<usize> {
4532            let mut start = from;
4533            while start > 0 {
4534                let prev = chars[start - 1];
4535                if prev.is_whitespace() {
4536                    let mut k = start - 1;
4537                    while k > 0 && chars[k - 1].is_whitespace() {
4538                        k -= 1;
4539                    }
4540                    if k > 0 && is_terminator(chars[k - 1]) {
4541                        break;
4542                    }
4543                }
4544                start -= 1;
4545            }
4546            while start < total && chars[start].is_whitespace() {
4547                start += 1;
4548            }
4549            (start < total).then_some(start)
4550        };
4551        let current_start = find_start(cursor_idx)?;
4552        if current_start < cursor_idx {
4553            return Some(idx_to_pos(current_start));
4554        }
4555        // Already at the sentence start — step over the boundary into
4556        // the previous sentence and find its start.
4557        let mut k = current_start;
4558        while k > 0 && chars[k - 1].is_whitespace() {
4559            k -= 1;
4560        }
4561        if k == 0 {
4562            return None;
4563        }
4564        let prev_start = find_start(k - 1)?;
4565        Some(idx_to_pos(prev_start))
4566    }
4567}
4568
4569/// `is` / `as` — sentence: text up to and including the next sentence
4570/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
4571/// whitespace (or end-of-line) as a boundary; runs of consecutive
4572/// terminators stay attached to the same sentence. `as` extends to
4573/// include trailing whitespace; `is` does not.
4574fn sentence_text_object<H: crate::types::Host>(
4575    ed: &Editor<hjkl_buffer::Buffer, H>,
4576    inner: bool,
4577) -> Option<((usize, usize), (usize, usize))> {
4578    let lines = buf_lines_to_vec(&ed.buffer);
4579    if lines.is_empty() {
4580        return None;
4581    }
4582    // Flatten the buffer so a sentence can span lines (vim's behaviour).
4583    // Newlines count as whitespace for boundary detection.
4584    let pos_to_idx = |pos: (usize, usize)| -> usize {
4585        let mut idx = 0;
4586        for line in lines.iter().take(pos.0) {
4587            idx += line.chars().count() + 1;
4588        }
4589        idx + pos.1
4590    };
4591    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4592        for (r, line) in lines.iter().enumerate() {
4593            let len = line.chars().count();
4594            if idx <= len {
4595                return (r, idx);
4596            }
4597            idx -= len + 1;
4598        }
4599        let last = lines.len().saturating_sub(1);
4600        (last, lines[last].chars().count())
4601    };
4602    let mut chars: Vec<char> = Vec::new();
4603    for (r, line) in lines.iter().enumerate() {
4604        chars.extend(line.chars());
4605        if r + 1 < lines.len() {
4606            chars.push('\n');
4607        }
4608    }
4609    if chars.is_empty() {
4610        return None;
4611    }
4612
4613    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
4614    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4615
4616    // Walk backward from cursor to find the start of the current
4617    // sentence. A boundary is: whitespace immediately after a run of
4618    // terminators (or start-of-buffer).
4619    let mut start = cursor_idx;
4620    while start > 0 {
4621        let prev = chars[start - 1];
4622        if prev.is_whitespace() {
4623            // Check if the whitespace follows a terminator — if so,
4624            // we've crossed a sentence boundary; the sentence begins
4625            // at the first non-whitespace cell *after* this run.
4626            let mut k = start - 1;
4627            while k > 0 && chars[k - 1].is_whitespace() {
4628                k -= 1;
4629            }
4630            if k > 0 && is_terminator(chars[k - 1]) {
4631                break;
4632            }
4633        }
4634        start -= 1;
4635    }
4636    // Skip leading whitespace (vim doesn't include it in the
4637    // sentence body).
4638    while start < chars.len() && chars[start].is_whitespace() {
4639        start += 1;
4640    }
4641    if start >= chars.len() {
4642        return None;
4643    }
4644
4645    // Walk forward to the sentence end (last terminator before the
4646    // next whitespace boundary).
4647    let mut end = start;
4648    while end < chars.len() {
4649        if is_terminator(chars[end]) {
4650            // Consume any consecutive terminators (e.g. `?!`).
4651            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
4652                end += 1;
4653            }
4654            // If followed by whitespace or end-of-buffer, that's the
4655            // boundary.
4656            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
4657                break;
4658            }
4659        }
4660        end += 1;
4661    }
4662    // Inclusive end → exclusive end_idx.
4663    let end_idx = (end + 1).min(chars.len());
4664
4665    let final_end = if inner {
4666        end_idx
4667    } else {
4668        // `as`: include trailing whitespace (but stop before the next
4669        // newline so we don't gobble a paragraph break — vim keeps
4670        // sentences within a paragraph for the trailing-ws extension).
4671        let mut e = end_idx;
4672        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
4673            e += 1;
4674        }
4675        e
4676    };
4677
4678    Some((idx_to_pos(start), idx_to_pos(final_end)))
4679}
4680
4681/// `it` / `at` — XML tag pair text object. Builds a flat char index of
4682/// the buffer, walks `<...>` tokens to pair tags via a stack, and
4683/// returns the innermost pair containing the cursor.
4684fn tag_text_object<H: crate::types::Host>(
4685    ed: &Editor<hjkl_buffer::Buffer, H>,
4686    inner: bool,
4687) -> Option<((usize, usize), (usize, usize))> {
4688    let lines = buf_lines_to_vec(&ed.buffer);
4689    if lines.is_empty() {
4690        return None;
4691    }
4692    // Flatten char positions so we can compare cursor against tag
4693    // ranges without per-row arithmetic. `\n` between lines counts as
4694    // a single char.
4695    let pos_to_idx = |pos: (usize, usize)| -> usize {
4696        let mut idx = 0;
4697        for line in lines.iter().take(pos.0) {
4698            idx += line.chars().count() + 1;
4699        }
4700        idx + pos.1
4701    };
4702    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4703        for (r, line) in lines.iter().enumerate() {
4704            let len = line.chars().count();
4705            if idx <= len {
4706                return (r, idx);
4707            }
4708            idx -= len + 1;
4709        }
4710        let last = lines.len().saturating_sub(1);
4711        (last, lines[last].chars().count())
4712    };
4713    let mut chars: Vec<char> = Vec::new();
4714    for (r, line) in lines.iter().enumerate() {
4715        chars.extend(line.chars());
4716        if r + 1 < lines.len() {
4717            chars.push('\n');
4718        }
4719    }
4720    let cursor_idx = pos_to_idx(ed.cursor());
4721
4722    // Walk `<...>` tokens. Track open tags on a stack; on a matching
4723    // close pop and consider the pair a candidate when the cursor lies
4724    // inside its content range. Innermost wins (replace whenever a
4725    // tighter range turns up). Also track the first complete pair that
4726    // starts at or after the cursor so we can fall back to a forward
4727    // scan (targets.vim-style) when the cursor isn't inside any tag.
4728    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
4729    let mut innermost: Option<(usize, usize, usize, usize)> = None;
4730    let mut next_after: Option<(usize, usize, usize, usize)> = None;
4731    let mut i = 0;
4732    while i < chars.len() {
4733        if chars[i] != '<' {
4734            i += 1;
4735            continue;
4736        }
4737        let mut j = i + 1;
4738        while j < chars.len() && chars[j] != '>' {
4739            j += 1;
4740        }
4741        if j >= chars.len() {
4742            break;
4743        }
4744        let inside: String = chars[i + 1..j].iter().collect();
4745        let close_end = j + 1;
4746        let trimmed = inside.trim();
4747        if trimmed.starts_with('!') || trimmed.starts_with('?') {
4748            i = close_end;
4749            continue;
4750        }
4751        if let Some(rest) = trimmed.strip_prefix('/') {
4752            let name = rest.split_whitespace().next().unwrap_or("").to_string();
4753            if !name.is_empty()
4754                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
4755            {
4756                let (open_start, content_start, _) = stack[stack_idx].clone();
4757                stack.truncate(stack_idx);
4758                let content_end = i;
4759                let candidate = (open_start, content_start, content_end, close_end);
4760                if cursor_idx >= content_start && cursor_idx <= content_end {
4761                    innermost = match innermost {
4762                        Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
4763                            Some(candidate)
4764                        }
4765                        None => Some(candidate),
4766                        existing => existing,
4767                    };
4768                } else if open_start >= cursor_idx && next_after.is_none() {
4769                    next_after = Some(candidate);
4770                }
4771            }
4772        } else if !trimmed.ends_with('/') {
4773            let name: String = trimmed
4774                .split(|c: char| c.is_whitespace() || c == '/')
4775                .next()
4776                .unwrap_or("")
4777                .to_string();
4778            if !name.is_empty() {
4779                stack.push((i, close_end, name));
4780            }
4781        }
4782        i = close_end;
4783    }
4784
4785    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
4786    if inner {
4787        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
4788    } else {
4789        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
4790    }
4791}
4792
4793fn is_wordchar(c: char) -> bool {
4794    c.is_alphanumeric() || c == '_'
4795}
4796
4797// `is_keyword_char` lives in hjkl-buffer (used by word motions);
4798// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
4799// one parser, one default, one bug surface.
4800pub(crate) use hjkl_buffer::is_keyword_char;
4801
4802fn word_text_object<H: crate::types::Host>(
4803    ed: &Editor<hjkl_buffer::Buffer, H>,
4804    inner: bool,
4805    big: bool,
4806) -> Option<((usize, usize), (usize, usize))> {
4807    let (row, col) = ed.cursor();
4808    let line = buf_line(&ed.buffer, row)?;
4809    let chars: Vec<char> = line.chars().collect();
4810    if chars.is_empty() {
4811        return None;
4812    }
4813    let at = col.min(chars.len().saturating_sub(1));
4814    let classify = |c: char| -> u8 {
4815        if c.is_whitespace() {
4816            0
4817        } else if big || is_wordchar(c) {
4818            1
4819        } else {
4820            2
4821        }
4822    };
4823    let cls = classify(chars[at]);
4824    let mut start = at;
4825    while start > 0 && classify(chars[start - 1]) == cls {
4826        start -= 1;
4827    }
4828    let mut end = at;
4829    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
4830        end += 1;
4831    }
4832    // Byte-offset helpers.
4833    let char_byte = |i: usize| {
4834        if i >= chars.len() {
4835            line.len()
4836        } else {
4837            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
4838        }
4839    };
4840    let mut start_col = char_byte(start);
4841    // Exclusive end: byte index of char AFTER the last-included char.
4842    let mut end_col = char_byte(end + 1);
4843    if !inner {
4844        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
4845        let mut t = end + 1;
4846        let mut included_trailing = false;
4847        while t < chars.len() && chars[t].is_whitespace() {
4848            included_trailing = true;
4849            t += 1;
4850        }
4851        if included_trailing {
4852            end_col = char_byte(t);
4853        } else {
4854            let mut s = start;
4855            while s > 0 && chars[s - 1].is_whitespace() {
4856                s -= 1;
4857            }
4858            start_col = char_byte(s);
4859        }
4860    }
4861    Some(((row, start_col), (row, end_col)))
4862}
4863
4864fn quote_text_object<H: crate::types::Host>(
4865    ed: &Editor<hjkl_buffer::Buffer, H>,
4866    q: char,
4867    inner: bool,
4868) -> Option<((usize, usize), (usize, usize))> {
4869    let (row, col) = ed.cursor();
4870    let line = buf_line(&ed.buffer, row)?;
4871    let bytes = line.as_bytes();
4872    let q_byte = q as u8;
4873    // Find opening and closing quote on the same line.
4874    let mut positions: Vec<usize> = Vec::new();
4875    for (i, &b) in bytes.iter().enumerate() {
4876        if b == q_byte {
4877            positions.push(i);
4878        }
4879    }
4880    if positions.len() < 2 {
4881        return None;
4882    }
4883    let mut open_idx: Option<usize> = None;
4884    let mut close_idx: Option<usize> = None;
4885    for pair in positions.chunks(2) {
4886        if pair.len() < 2 {
4887            break;
4888        }
4889        if col >= pair[0] && col <= pair[1] {
4890            open_idx = Some(pair[0]);
4891            close_idx = Some(pair[1]);
4892            break;
4893        }
4894        if col < pair[0] {
4895            open_idx = Some(pair[0]);
4896            close_idx = Some(pair[1]);
4897            break;
4898        }
4899    }
4900    let open = open_idx?;
4901    let close = close_idx?;
4902    // End columns are *exclusive* — one past the last character to act on.
4903    if inner {
4904        if close <= open + 1 {
4905            return None;
4906        }
4907        Some(((row, open + 1), (row, close)))
4908    } else {
4909        // `da<q>` — "around" includes the surrounding whitespace on one
4910        // side: trailing whitespace if any exists after the closing quote;
4911        // otherwise leading whitespace before the opening quote. This
4912        // matches vim's `:help text-objects` behaviour and avoids leaving
4913        // a double-space when the quoted span sits mid-sentence.
4914        let after_close = close + 1; // byte index after closing quote
4915        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
4916            // Eat trailing whitespace run.
4917            let mut end = after_close;
4918            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
4919                end += 1;
4920            }
4921            Some(((row, open), (row, end)))
4922        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
4923            // Eat leading whitespace run.
4924            let mut start = open;
4925            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
4926                start -= 1;
4927            }
4928            Some(((row, start), (row, close + 1)))
4929        } else {
4930            Some(((row, open), (row, close + 1)))
4931        }
4932    }
4933}
4934
4935fn bracket_text_object<H: crate::types::Host>(
4936    ed: &Editor<hjkl_buffer::Buffer, H>,
4937    open: char,
4938    inner: bool,
4939) -> Option<(Pos, Pos, MotionKind)> {
4940    let close = match open {
4941        '(' => ')',
4942        '[' => ']',
4943        '{' => '}',
4944        '<' => '>',
4945        _ => return None,
4946    };
4947    let (row, col) = ed.cursor();
4948    let lines = buf_lines_to_vec(&ed.buffer);
4949    let lines = lines.as_slice();
4950    // Walk backward from cursor to find unbalanced opening. When the
4951    // cursor isn't inside any pair, fall back to scanning forward for
4952    // the next opening bracket (targets.vim-style: `ci(` works when
4953    // cursor is before the `(` on the same line or below).
4954    let open_pos = find_open_bracket(lines, row, col, open, close)
4955        .or_else(|| find_next_open(lines, row, col, open))?;
4956    let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
4957    // End positions are *exclusive*.
4958    if inner {
4959        // Multi-line `iB` / `i{` etc: vim deletes the full lines between
4960        // the braces (linewise), preserving the `{` and `}` lines
4961        // themselves and the newlines that directly abut them. E.g.:
4962        //   {\n    body\n}\n  →  {\n}\n    (cursor on `}` line)
4963        // Single-line `i{` falls back to charwise exclusive.
4964        if close_pos.0 > open_pos.0 + 1 {
4965            // There is at least one line strictly between open and close.
4966            let inner_row_start = open_pos.0 + 1;
4967            let inner_row_end = close_pos.0 - 1;
4968            let end_col = lines
4969                .get(inner_row_end)
4970                .map(|l| l.chars().count())
4971                .unwrap_or(0);
4972            return Some((
4973                (inner_row_start, 0),
4974                (inner_row_end, end_col),
4975                MotionKind::Linewise,
4976            ));
4977        }
4978        let inner_start = advance_pos(lines, open_pos);
4979        if inner_start.0 > close_pos.0
4980            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
4981        {
4982            return None;
4983        }
4984        Some((inner_start, close_pos, MotionKind::Exclusive))
4985    } else {
4986        Some((
4987            open_pos,
4988            advance_pos(lines, close_pos),
4989            MotionKind::Exclusive,
4990        ))
4991    }
4992}
4993
4994fn find_open_bracket(
4995    lines: &[String],
4996    row: usize,
4997    col: usize,
4998    open: char,
4999    close: char,
5000) -> Option<(usize, usize)> {
5001    let mut depth: i32 = 0;
5002    let mut r = row;
5003    let mut c = col as isize;
5004    loop {
5005        let cur = &lines[r];
5006        let chars: Vec<char> = cur.chars().collect();
5007        // Clamp `c` to the line length: callers may seed `col` past
5008        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
5009        // so direct indexing would panic on empty / short lines.
5010        if (c as usize) >= chars.len() {
5011            c = chars.len() as isize - 1;
5012        }
5013        while c >= 0 {
5014            let ch = chars[c as usize];
5015            if ch == close {
5016                depth += 1;
5017            } else if ch == open {
5018                if depth == 0 {
5019                    return Some((r, c as usize));
5020                }
5021                depth -= 1;
5022            }
5023            c -= 1;
5024        }
5025        if r == 0 {
5026            return None;
5027        }
5028        r -= 1;
5029        c = lines[r].chars().count() as isize - 1;
5030    }
5031}
5032
5033fn find_close_bracket(
5034    lines: &[String],
5035    row: usize,
5036    start_col: usize,
5037    open: char,
5038    close: char,
5039) -> Option<(usize, usize)> {
5040    let mut depth: i32 = 0;
5041    let mut r = row;
5042    let mut c = start_col;
5043    loop {
5044        let cur = &lines[r];
5045        let chars: Vec<char> = cur.chars().collect();
5046        while c < chars.len() {
5047            let ch = chars[c];
5048            if ch == open {
5049                depth += 1;
5050            } else if ch == close {
5051                if depth == 0 {
5052                    return Some((r, c));
5053                }
5054                depth -= 1;
5055            }
5056            c += 1;
5057        }
5058        if r + 1 >= lines.len() {
5059            return None;
5060        }
5061        r += 1;
5062        c = 0;
5063    }
5064}
5065
5066/// Forward scan from `(row, col)` for the next occurrence of `open`.
5067/// Multi-line. Used by bracket text objects to support targets.vim-style
5068/// "search forward when not currently inside a pair" behaviour.
5069fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
5070    let mut r = row;
5071    let mut c = col;
5072    while r < lines.len() {
5073        let chars: Vec<char> = lines[r].chars().collect();
5074        while c < chars.len() {
5075            if chars[c] == open {
5076                return Some((r, c));
5077            }
5078            c += 1;
5079        }
5080        r += 1;
5081        c = 0;
5082    }
5083    None
5084}
5085
5086fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
5087    let (r, c) = pos;
5088    let line_len = lines[r].chars().count();
5089    if c < line_len {
5090        (r, c + 1)
5091    } else if r + 1 < lines.len() {
5092        (r + 1, 0)
5093    } else {
5094        pos
5095    }
5096}
5097
5098fn paragraph_text_object<H: crate::types::Host>(
5099    ed: &Editor<hjkl_buffer::Buffer, H>,
5100    inner: bool,
5101) -> Option<((usize, usize), (usize, usize))> {
5102    let (row, _) = ed.cursor();
5103    let lines = buf_lines_to_vec(&ed.buffer);
5104    if lines.is_empty() {
5105        return None;
5106    }
5107    // A paragraph is a run of non-blank lines.
5108    let is_blank = |r: usize| lines.get(r).map(|s| s.trim().is_empty()).unwrap_or(true);
5109    if is_blank(row) {
5110        return None;
5111    }
5112    let mut top = row;
5113    while top > 0 && !is_blank(top - 1) {
5114        top -= 1;
5115    }
5116    let mut bot = row;
5117    while bot + 1 < lines.len() && !is_blank(bot + 1) {
5118        bot += 1;
5119    }
5120    // For `ap`, include one trailing blank line if present.
5121    if !inner && bot + 1 < lines.len() && is_blank(bot + 1) {
5122        bot += 1;
5123    }
5124    let end_col = lines[bot].chars().count();
5125    Some(((top, 0), (bot, end_col)))
5126}
5127
5128// ─── Individual commands ───────────────────────────────────────────────────
5129
5130/// Read the text in a vim-shaped range without mutating. Used by
5131/// `Operator::Yank` so we can pipe the same range translation as
5132/// [`cut_vim_range`] but skip the delete + inverse extraction.
5133fn read_vim_range<H: crate::types::Host>(
5134    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5135    start: (usize, usize),
5136    end: (usize, usize),
5137    kind: MotionKind,
5138) -> String {
5139    let (top, bot) = order(start, end);
5140    ed.sync_buffer_content_from_textarea();
5141    let lines = buf_lines_to_vec(&ed.buffer);
5142    match kind {
5143        MotionKind::Linewise => {
5144            let lo = top.0;
5145            let hi = bot.0.min(lines.len().saturating_sub(1));
5146            let mut text = lines[lo..=hi].join("\n");
5147            text.push('\n');
5148            text
5149        }
5150        MotionKind::Inclusive | MotionKind::Exclusive => {
5151            let inclusive = matches!(kind, MotionKind::Inclusive);
5152            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
5153            let mut out = String::new();
5154            for row in top.0..=bot.0 {
5155                let line = lines.get(row).map(String::as_str).unwrap_or("");
5156                let lo = if row == top.0 { top.1 } else { 0 };
5157                let hi_unclamped = if row == bot.0 {
5158                    if inclusive { bot.1 + 1 } else { bot.1 }
5159                } else {
5160                    line.chars().count() + 1
5161                };
5162                let row_chars: Vec<char> = line.chars().collect();
5163                let hi = hi_unclamped.min(row_chars.len());
5164                if lo < hi {
5165                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
5166                }
5167                if row < bot.0 {
5168                    out.push('\n');
5169                }
5170            }
5171            out
5172        }
5173    }
5174}
5175
5176/// Cut a vim-shaped range through the Buffer edit funnel and return
5177/// the deleted text. Translates vim's `MotionKind`
5178/// (Linewise/Inclusive/Exclusive) into the buffer's
5179/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
5180/// position adjustment so inclusive motions actually include the bot
5181/// cell. Pushes the cut text into both `last_yank` and the textarea
5182/// yank buffer (still observed by `p`/`P` until the paste path is
5183/// ported), and updates `yank_linewise` for linewise cuts.
5184fn cut_vim_range<H: crate::types::Host>(
5185    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5186    start: (usize, usize),
5187    end: (usize, usize),
5188    kind: MotionKind,
5189) -> String {
5190    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5191    let (top, bot) = order(start, end);
5192    ed.sync_buffer_content_from_textarea();
5193    let (buf_start, buf_end, buf_kind) = match kind {
5194        MotionKind::Linewise => (
5195            Position::new(top.0, 0),
5196            Position::new(bot.0, 0),
5197            BufKind::Line,
5198        ),
5199        MotionKind::Inclusive => {
5200            let line_chars = buf_line_chars(&ed.buffer, bot.0);
5201            // Advance one cell past `bot` so the buffer's exclusive
5202            // `cut_chars` actually drops the inclusive endpoint. Wrap
5203            // to the next row when bot already sits on the last char.
5204            let next = if bot.1 < line_chars {
5205                Position::new(bot.0, bot.1 + 1)
5206            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
5207                Position::new(bot.0 + 1, 0)
5208            } else {
5209                Position::new(bot.0, line_chars)
5210            };
5211            (Position::new(top.0, top.1), next, BufKind::Char)
5212        }
5213        MotionKind::Exclusive => (
5214            Position::new(top.0, top.1),
5215            Position::new(bot.0, bot.1),
5216            BufKind::Char,
5217        ),
5218    };
5219    let inverse = ed.mutate_edit(Edit::DeleteRange {
5220        start: buf_start,
5221        end: buf_end,
5222        kind: buf_kind,
5223    });
5224    let text = match inverse {
5225        Edit::InsertStr { text, .. } => text,
5226        _ => String::new(),
5227    };
5228    if !text.is_empty() {
5229        ed.record_yank_to_host(text.clone());
5230        ed.record_delete(text.clone(), matches!(kind, MotionKind::Linewise));
5231    }
5232    ed.push_buffer_cursor_to_textarea();
5233    text
5234}
5235
5236/// `D` / `C` — delete from cursor to end of line through the edit
5237/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
5238/// textarea's yank buffer (still observed by `p`/`P` until the paste
5239/// path is ported). Cursor lands at the deletion start so the caller
5240/// can decide whether to step it left (`D`) or open insert mode (`C`).
5241fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5242    use hjkl_buffer::{Edit, MotionKind, Position};
5243    ed.sync_buffer_content_from_textarea();
5244    let cursor = buf_cursor_pos(&ed.buffer);
5245    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5246    if cursor.col >= line_chars {
5247        return;
5248    }
5249    let inverse = ed.mutate_edit(Edit::DeleteRange {
5250        start: cursor,
5251        end: Position::new(cursor.row, line_chars),
5252        kind: MotionKind::Char,
5253    });
5254    if let Edit::InsertStr { text, .. } = inverse
5255        && !text.is_empty()
5256    {
5257        ed.record_yank_to_host(text.clone());
5258        ed.vim.yank_linewise = false;
5259        ed.set_yank(text);
5260    }
5261    buf_set_cursor_pos(&mut ed.buffer, cursor);
5262    ed.push_buffer_cursor_to_textarea();
5263}
5264
5265fn do_char_delete<H: crate::types::Host>(
5266    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5267    forward: bool,
5268    count: usize,
5269) {
5270    use hjkl_buffer::{Edit, MotionKind, Position};
5271    ed.push_undo();
5272    ed.sync_buffer_content_from_textarea();
5273    // Collect deleted chars so we can write them to the unnamed register
5274    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
5275    let mut deleted = String::new();
5276    for _ in 0..count {
5277        let cursor = buf_cursor_pos(&ed.buffer);
5278        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5279        if forward {
5280            // `x` — delete the char under the cursor. Vim no-ops on
5281            // an empty line; the buffer would drop a row otherwise.
5282            if cursor.col >= line_chars {
5283                continue;
5284            }
5285            let inverse = ed.mutate_edit(Edit::DeleteRange {
5286                start: cursor,
5287                end: Position::new(cursor.row, cursor.col + 1),
5288                kind: MotionKind::Char,
5289            });
5290            if let Edit::InsertStr { text, .. } = inverse {
5291                deleted.push_str(&text);
5292            }
5293        } else {
5294            // `X` — delete the char before the cursor.
5295            if cursor.col == 0 {
5296                continue;
5297            }
5298            let inverse = ed.mutate_edit(Edit::DeleteRange {
5299                start: Position::new(cursor.row, cursor.col - 1),
5300                end: cursor,
5301                kind: MotionKind::Char,
5302            });
5303            if let Edit::InsertStr { text, .. } = inverse {
5304                // X deletes backwards; prepend so the register text
5305                // matches reading order (first deleted char first).
5306                deleted = text + &deleted;
5307            }
5308        }
5309    }
5310    if !deleted.is_empty() {
5311        ed.record_yank_to_host(deleted.clone());
5312        ed.record_delete(deleted, false);
5313    }
5314    ed.push_buffer_cursor_to_textarea();
5315}
5316
5317/// Vim `Ctrl-a` / `Ctrl-x` — find the next decimal number at or after the
5318/// cursor on the current line, add `delta`, leave the cursor on the last
5319/// digit of the result. No-op if the line has no digits to the right.
5320fn adjust_number<H: crate::types::Host>(
5321    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5322    delta: i64,
5323) -> bool {
5324    use hjkl_buffer::{Edit, MotionKind, Position};
5325    ed.sync_buffer_content_from_textarea();
5326    let cursor = buf_cursor_pos(&ed.buffer);
5327    let row = cursor.row;
5328    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
5329        Some(l) => l.chars().collect(),
5330        None => return false,
5331    };
5332    let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
5333        return false;
5334    };
5335    let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
5336        digit_start - 1
5337    } else {
5338        digit_start
5339    };
5340    let mut span_end = digit_start;
5341    while span_end < chars.len() && chars[span_end].is_ascii_digit() {
5342        span_end += 1;
5343    }
5344    let s: String = chars[span_start..span_end].iter().collect();
5345    let Ok(n) = s.parse::<i64>() else {
5346        return false;
5347    };
5348    let new_s = n.saturating_add(delta).to_string();
5349
5350    ed.push_undo();
5351    let span_start_pos = Position::new(row, span_start);
5352    let span_end_pos = Position::new(row, span_end);
5353    ed.mutate_edit(Edit::DeleteRange {
5354        start: span_start_pos,
5355        end: span_end_pos,
5356        kind: MotionKind::Char,
5357    });
5358    ed.mutate_edit(Edit::InsertStr {
5359        at: span_start_pos,
5360        text: new_s.clone(),
5361    });
5362    let new_len = new_s.chars().count();
5363    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
5364    ed.push_buffer_cursor_to_textarea();
5365    true
5366}
5367
5368pub(crate) fn replace_char<H: crate::types::Host>(
5369    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5370    ch: char,
5371    count: usize,
5372) {
5373    use hjkl_buffer::{Edit, MotionKind, Position};
5374    ed.push_undo();
5375    ed.sync_buffer_content_from_textarea();
5376    for _ in 0..count {
5377        let cursor = buf_cursor_pos(&ed.buffer);
5378        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5379        if cursor.col >= line_chars {
5380            break;
5381        }
5382        ed.mutate_edit(Edit::DeleteRange {
5383            start: cursor,
5384            end: Position::new(cursor.row, cursor.col + 1),
5385            kind: MotionKind::Char,
5386        });
5387        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
5388    }
5389    // Vim leaves the cursor on the last replaced char.
5390    crate::motions::move_left(&mut ed.buffer, 1);
5391    ed.push_buffer_cursor_to_textarea();
5392}
5393
5394fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5395    use hjkl_buffer::{Edit, MotionKind, Position};
5396    ed.sync_buffer_content_from_textarea();
5397    let cursor = buf_cursor_pos(&ed.buffer);
5398    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
5399        return;
5400    };
5401    let toggled = if c.is_uppercase() {
5402        c.to_lowercase().next().unwrap_or(c)
5403    } else {
5404        c.to_uppercase().next().unwrap_or(c)
5405    };
5406    ed.mutate_edit(Edit::DeleteRange {
5407        start: cursor,
5408        end: Position::new(cursor.row, cursor.col + 1),
5409        kind: MotionKind::Char,
5410    });
5411    ed.mutate_edit(Edit::InsertChar {
5412        at: cursor,
5413        ch: toggled,
5414    });
5415}
5416
5417fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5418    use hjkl_buffer::{Edit, Position};
5419    ed.sync_buffer_content_from_textarea();
5420    let row = buf_cursor_pos(&ed.buffer).row;
5421    if row + 1 >= buf_row_count(&ed.buffer) {
5422        return;
5423    }
5424    let cur_line = buf_line(&ed.buffer, row).unwrap_or("").to_string();
5425    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or("").to_string();
5426    let next_trimmed = next_raw.trim_start();
5427    let cur_chars = cur_line.chars().count();
5428    let next_chars = next_raw.chars().count();
5429    // `J` inserts a single space iff both sides are non-empty after
5430    // stripping the next line's leading whitespace.
5431    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
5432        " "
5433    } else {
5434        ""
5435    };
5436    let joined = format!("{cur_line}{separator}{next_trimmed}");
5437    ed.mutate_edit(Edit::Replace {
5438        start: Position::new(row, 0),
5439        end: Position::new(row + 1, next_chars),
5440        with: joined,
5441    });
5442    // Vim parks the cursor on the inserted space — or at the join
5443    // point when no space went in (which is the same column either
5444    // way, since the space sits exactly at `cur_chars`).
5445    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
5446    ed.push_buffer_cursor_to_textarea();
5447}
5448
5449/// `gJ` — join the next line onto the current one without inserting a
5450/// separating space or stripping leading whitespace.
5451fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5452    use hjkl_buffer::Edit;
5453    ed.sync_buffer_content_from_textarea();
5454    let row = buf_cursor_pos(&ed.buffer).row;
5455    if row + 1 >= buf_row_count(&ed.buffer) {
5456        return;
5457    }
5458    let join_col = buf_line_chars(&ed.buffer, row);
5459    ed.mutate_edit(Edit::JoinLines {
5460        row,
5461        count: 1,
5462        with_space: false,
5463    });
5464    // Vim leaves the cursor at the join point (end of original line).
5465    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
5466    ed.push_buffer_cursor_to_textarea();
5467}
5468
5469fn do_paste<H: crate::types::Host>(
5470    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5471    before: bool,
5472    count: usize,
5473) {
5474    use hjkl_buffer::{Edit, Position};
5475    ed.push_undo();
5476    // Resolve the source register: `"reg` prefix (consumed) or the
5477    // unnamed register otherwise. Read text + linewise from the
5478    // selected slot rather than the global `vim.yank_linewise` so
5479    // pasting from `"0` after a delete still uses the yank's layout.
5480    let selector = ed.vim.pending_register.take();
5481    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
5482        Some(slot) => (slot.text.clone(), slot.linewise),
5483        // Read both fields from the unnamed slot rather than mixing the
5484        // slot's text with `vim.yank_linewise`. The cached vim flag is
5485        // per-editor, so a register imported from another editor (e.g.
5486        // cross-buffer yank/paste) carried the wrong linewise without
5487        // this — pasting a linewise yank inserted at the char cursor.
5488        None => {
5489            let s = &ed.registers().unnamed;
5490            (s.text.clone(), s.linewise)
5491        }
5492    };
5493    for _ in 0..count {
5494        ed.sync_buffer_content_from_textarea();
5495        let yank = yank.clone();
5496        if yank.is_empty() {
5497            continue;
5498        }
5499        if linewise {
5500            // Linewise paste: insert payload as fresh row(s) above
5501            // (`P`) or below (`p`) the cursor's row. Cursor lands on
5502            // the first non-blank of the first pasted line.
5503            let text = yank.trim_matches('\n').to_string();
5504            let row = buf_cursor_pos(&ed.buffer).row;
5505            let target_row = if before {
5506                ed.mutate_edit(Edit::InsertStr {
5507                    at: Position::new(row, 0),
5508                    text: format!("{text}\n"),
5509                });
5510                row
5511            } else {
5512                let line_chars = buf_line_chars(&ed.buffer, row);
5513                ed.mutate_edit(Edit::InsertStr {
5514                    at: Position::new(row, line_chars),
5515                    text: format!("\n{text}"),
5516                });
5517                row + 1
5518            };
5519            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
5520            crate::motions::move_first_non_blank(&mut ed.buffer);
5521            ed.push_buffer_cursor_to_textarea();
5522        } else {
5523            // Charwise paste. `P` inserts at cursor (shifting cell
5524            // right); `p` inserts after cursor (advance one cell
5525            // first, clamped to the end of the line).
5526            let cursor = buf_cursor_pos(&ed.buffer);
5527            let at = if before {
5528                cursor
5529            } else {
5530                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5531                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
5532            };
5533            ed.mutate_edit(Edit::InsertStr {
5534                at,
5535                text: yank.clone(),
5536            });
5537            // Vim parks the cursor on the last char of the pasted
5538            // text (do_insert_str leaves it one past the end).
5539            crate::motions::move_left(&mut ed.buffer, 1);
5540            ed.push_buffer_cursor_to_textarea();
5541        }
5542    }
5543    // Any paste re-anchors the sticky column to the new cursor position.
5544    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
5545}
5546
5547pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5548    if let Some((lines, cursor)) = ed.undo_stack.pop() {
5549        let current = ed.snapshot();
5550        ed.redo_stack.push(current);
5551        ed.restore(lines, cursor);
5552    }
5553    ed.vim.mode = Mode::Normal;
5554    // The restored cursor came from a snapshot taken in insert mode
5555    // (before the insert started) and may be past the last valid
5556    // normal-mode column. Clamp it now, same as Esc-from-insert does.
5557    clamp_cursor_to_normal_mode(ed);
5558}
5559
5560pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5561    if let Some((lines, cursor)) = ed.redo_stack.pop() {
5562        let current = ed.snapshot();
5563        ed.undo_stack.push(current);
5564        ed.cap_undo();
5565        ed.restore(lines, cursor);
5566    }
5567    ed.vim.mode = Mode::Normal;
5568}
5569
5570// ─── Dot repeat ────────────────────────────────────────────────────────────
5571
5572/// Replay-side helper: insert `text` at the cursor through the
5573/// edit funnel, then leave insert mode (the original change ended
5574/// with Esc, so the dot-repeat must end the same way — including
5575/// the cursor step-back vim does on Esc-from-insert).
5576fn replay_insert_and_finish<H: crate::types::Host>(
5577    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5578    text: &str,
5579) {
5580    use hjkl_buffer::{Edit, Position};
5581    let cursor = ed.cursor();
5582    ed.mutate_edit(Edit::InsertStr {
5583        at: Position::new(cursor.0, cursor.1),
5584        text: text.to_string(),
5585    });
5586    if ed.vim.insert_session.take().is_some() {
5587        if ed.cursor().1 > 0 {
5588            crate::motions::move_left(&mut ed.buffer, 1);
5589            ed.push_buffer_cursor_to_textarea();
5590        }
5591        ed.vim.mode = Mode::Normal;
5592    }
5593}
5594
5595fn replay_last_change<H: crate::types::Host>(
5596    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5597    outer_count: usize,
5598) {
5599    let Some(change) = ed.vim.last_change.clone() else {
5600        return;
5601    };
5602    ed.vim.replaying = true;
5603    let scale = if outer_count > 0 { outer_count } else { 1 };
5604    match change {
5605        LastChange::OpMotion {
5606            op,
5607            motion,
5608            count,
5609            inserted,
5610        } => {
5611            let total = count.max(1) * scale;
5612            apply_op_with_motion(ed, op, &motion, total);
5613            if let Some(text) = inserted {
5614                replay_insert_and_finish(ed, &text);
5615            }
5616        }
5617        LastChange::OpTextObj {
5618            op,
5619            obj,
5620            inner,
5621            inserted,
5622        } => {
5623            apply_op_with_text_object(ed, op, obj, inner);
5624            if let Some(text) = inserted {
5625                replay_insert_and_finish(ed, &text);
5626            }
5627        }
5628        LastChange::LineOp {
5629            op,
5630            count,
5631            inserted,
5632        } => {
5633            let total = count.max(1) * scale;
5634            execute_line_op(ed, op, total);
5635            if let Some(text) = inserted {
5636                replay_insert_and_finish(ed, &text);
5637            }
5638        }
5639        LastChange::CharDel { forward, count } => {
5640            do_char_delete(ed, forward, count * scale);
5641        }
5642        LastChange::ReplaceChar { ch, count } => {
5643            replace_char(ed, ch, count * scale);
5644        }
5645        LastChange::ToggleCase { count } => {
5646            for _ in 0..count * scale {
5647                ed.push_undo();
5648                toggle_case_at_cursor(ed);
5649            }
5650        }
5651        LastChange::JoinLine { count } => {
5652            for _ in 0..count * scale {
5653                ed.push_undo();
5654                join_line(ed);
5655            }
5656        }
5657        LastChange::Paste { before, count } => {
5658            do_paste(ed, before, count * scale);
5659        }
5660        LastChange::DeleteToEol { inserted } => {
5661            use hjkl_buffer::{Edit, Position};
5662            ed.push_undo();
5663            delete_to_eol(ed);
5664            if let Some(text) = inserted {
5665                let cursor = ed.cursor();
5666                ed.mutate_edit(Edit::InsertStr {
5667                    at: Position::new(cursor.0, cursor.1),
5668                    text,
5669                });
5670            }
5671        }
5672        LastChange::OpenLine { above, inserted } => {
5673            use hjkl_buffer::{Edit, Position};
5674            ed.push_undo();
5675            ed.sync_buffer_content_from_textarea();
5676            let row = buf_cursor_pos(&ed.buffer).row;
5677            if above {
5678                ed.mutate_edit(Edit::InsertStr {
5679                    at: Position::new(row, 0),
5680                    text: "\n".to_string(),
5681                });
5682                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
5683                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
5684            } else {
5685                let line_chars = buf_line_chars(&ed.buffer, row);
5686                ed.mutate_edit(Edit::InsertStr {
5687                    at: Position::new(row, line_chars),
5688                    text: "\n".to_string(),
5689                });
5690            }
5691            ed.push_buffer_cursor_to_textarea();
5692            let cursor = ed.cursor();
5693            ed.mutate_edit(Edit::InsertStr {
5694                at: Position::new(cursor.0, cursor.1),
5695                text: inserted,
5696            });
5697        }
5698        LastChange::InsertAt {
5699            entry,
5700            inserted,
5701            count,
5702        } => {
5703            use hjkl_buffer::{Edit, Position};
5704            ed.push_undo();
5705            match entry {
5706                InsertEntry::I => {}
5707                InsertEntry::ShiftI => move_first_non_whitespace(ed),
5708                InsertEntry::A => {
5709                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5710                    ed.push_buffer_cursor_to_textarea();
5711                }
5712                InsertEntry::ShiftA => {
5713                    crate::motions::move_line_end(&mut ed.buffer);
5714                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5715                    ed.push_buffer_cursor_to_textarea();
5716                }
5717            }
5718            for _ in 0..count.max(1) {
5719                let cursor = ed.cursor();
5720                ed.mutate_edit(Edit::InsertStr {
5721                    at: Position::new(cursor.0, cursor.1),
5722                    text: inserted.clone(),
5723                });
5724            }
5725        }
5726    }
5727    ed.vim.replaying = false;
5728}
5729
5730// ─── Extracting inserted text for replay ───────────────────────────────────
5731
5732fn extract_inserted(before: &str, after: &str) -> String {
5733    let before_chars: Vec<char> = before.chars().collect();
5734    let after_chars: Vec<char> = after.chars().collect();
5735    if after_chars.len() <= before_chars.len() {
5736        return String::new();
5737    }
5738    let prefix = before_chars
5739        .iter()
5740        .zip(after_chars.iter())
5741        .take_while(|(a, b)| a == b)
5742        .count();
5743    let max_suffix = before_chars.len() - prefix;
5744    let suffix = before_chars
5745        .iter()
5746        .rev()
5747        .zip(after_chars.iter().rev())
5748        .take(max_suffix)
5749        .take_while(|(a, b)| a == b)
5750        .count();
5751    after_chars[prefix..after_chars.len() - suffix]
5752        .iter()
5753        .collect()
5754}
5755
5756// ─── Tests ────────────────────────────────────────────────────────────────
5757
5758#[cfg(all(test, feature = "crossterm"))]
5759mod tests {
5760    use crate::VimMode;
5761    use crate::editor::Editor;
5762    use crate::types::Host;
5763    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5764
5765    fn run_keys<H: crate::types::Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, keys: &str) {
5766        // Minimal notation:
5767        //   <Esc> <CR> <BS> <Left/Right/Up/Down> <C-x>
5768        //   anything else = single char
5769        let mut iter = keys.chars().peekable();
5770        while let Some(c) = iter.next() {
5771            if c == '<' {
5772                let mut tag = String::new();
5773                for ch in iter.by_ref() {
5774                    if ch == '>' {
5775                        break;
5776                    }
5777                    tag.push(ch);
5778                }
5779                let ev = match tag.as_str() {
5780                    "Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
5781                    "CR" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
5782                    "BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
5783                    "Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
5784                    "Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
5785                    "Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
5786                    "Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
5787                    "Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
5788                    // Vim-style literal `<` escape so tests can type
5789                    // the outdent operator without colliding with the
5790                    // `<tag>` notation this helper uses for special keys.
5791                    "lt" => KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
5792                    s if s.starts_with("C-") => {
5793                        let ch = s.chars().nth(2).unwrap();
5794                        KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
5795                    }
5796                    _ => continue,
5797                };
5798                e.handle_key(ev);
5799            } else {
5800                let mods = if c.is_uppercase() {
5801                    KeyModifiers::SHIFT
5802                } else {
5803                    KeyModifiers::NONE
5804                };
5805                e.handle_key(KeyEvent::new(KeyCode::Char(c), mods));
5806            }
5807        }
5808    }
5809
5810    fn editor_with(content: &str) -> Editor {
5811        // Tests historically assume shiftwidth=2 (sqeel-derived). The 0.1.0
5812        // SPEC default is shiftwidth=8 (vim-faithful). Keep these tests on
5813        // the legacy 2-space rhythm so the indent/outdent assertions don't
5814        // churn.
5815        let opts = crate::types::Options {
5816            shiftwidth: 2,
5817            ..crate::types::Options::default()
5818        };
5819        let mut e = Editor::new(
5820            hjkl_buffer::Buffer::new(),
5821            crate::types::DefaultHost::new(),
5822            opts,
5823        );
5824        e.set_content(content);
5825        e
5826    }
5827
5828    #[test]
5829    fn f_char_jumps_on_line() {
5830        let mut e = editor_with("hello world");
5831        run_keys(&mut e, "fw");
5832        assert_eq!(e.cursor(), (0, 6));
5833    }
5834
5835    #[test]
5836    fn cap_f_jumps_backward() {
5837        let mut e = editor_with("hello world");
5838        e.jump_cursor(0, 10);
5839        run_keys(&mut e, "Fo");
5840        assert_eq!(e.cursor().1, 7);
5841    }
5842
5843    #[test]
5844    fn t_stops_before_char() {
5845        let mut e = editor_with("hello");
5846        run_keys(&mut e, "tl");
5847        assert_eq!(e.cursor(), (0, 1));
5848    }
5849
5850    #[test]
5851    fn semicolon_repeats_find() {
5852        let mut e = editor_with("aa.bb.cc");
5853        run_keys(&mut e, "f.");
5854        assert_eq!(e.cursor().1, 2);
5855        run_keys(&mut e, ";");
5856        assert_eq!(e.cursor().1, 5);
5857    }
5858
5859    #[test]
5860    fn comma_repeats_find_reverse() {
5861        let mut e = editor_with("aa.bb.cc");
5862        run_keys(&mut e, "f.");
5863        run_keys(&mut e, ";");
5864        run_keys(&mut e, ",");
5865        assert_eq!(e.cursor().1, 2);
5866    }
5867
5868    #[test]
5869    fn di_quote_deletes_content() {
5870        let mut e = editor_with("foo \"bar\" baz");
5871        e.jump_cursor(0, 6); // inside quotes
5872        run_keys(&mut e, "di\"");
5873        assert_eq!(e.buffer().lines()[0], "foo \"\" baz");
5874    }
5875
5876    #[test]
5877    fn da_quote_deletes_with_quotes() {
5878        // `da"` eats the trailing space after the closing quote so the
5879        // result matches vim's "around" text-object whitespace rule.
5880        let mut e = editor_with("foo \"bar\" baz");
5881        e.jump_cursor(0, 6);
5882        run_keys(&mut e, "da\"");
5883        assert_eq!(e.buffer().lines()[0], "foo baz");
5884    }
5885
5886    #[test]
5887    fn ci_paren_deletes_and_inserts() {
5888        let mut e = editor_with("fn(a, b, c)");
5889        e.jump_cursor(0, 5);
5890        run_keys(&mut e, "ci(");
5891        assert_eq!(e.vim_mode(), VimMode::Insert);
5892        assert_eq!(e.buffer().lines()[0], "fn()");
5893    }
5894
5895    #[test]
5896    fn diw_deletes_inner_word() {
5897        let mut e = editor_with("hello world");
5898        e.jump_cursor(0, 2);
5899        run_keys(&mut e, "diw");
5900        assert_eq!(e.buffer().lines()[0], " world");
5901    }
5902
5903    #[test]
5904    fn daw_deletes_word_with_trailing_space() {
5905        let mut e = editor_with("hello world");
5906        run_keys(&mut e, "daw");
5907        assert_eq!(e.buffer().lines()[0], "world");
5908    }
5909
5910    #[test]
5911    fn percent_jumps_to_matching_bracket() {
5912        let mut e = editor_with("foo(bar)");
5913        e.jump_cursor(0, 3);
5914        run_keys(&mut e, "%");
5915        assert_eq!(e.cursor().1, 7);
5916        run_keys(&mut e, "%");
5917        assert_eq!(e.cursor().1, 3);
5918    }
5919
5920    #[test]
5921    fn dot_repeats_last_change() {
5922        let mut e = editor_with("aaa bbb ccc");
5923        run_keys(&mut e, "dw");
5924        assert_eq!(e.buffer().lines()[0], "bbb ccc");
5925        run_keys(&mut e, ".");
5926        assert_eq!(e.buffer().lines()[0], "ccc");
5927    }
5928
5929    #[test]
5930    fn dot_repeats_change_operator_with_text() {
5931        let mut e = editor_with("foo foo foo");
5932        run_keys(&mut e, "cwbar<Esc>");
5933        assert_eq!(e.buffer().lines()[0], "bar foo foo");
5934        // Move past the space.
5935        run_keys(&mut e, "w");
5936        run_keys(&mut e, ".");
5937        assert_eq!(e.buffer().lines()[0], "bar bar foo");
5938    }
5939
5940    #[test]
5941    fn dot_repeats_x() {
5942        let mut e = editor_with("abcdef");
5943        run_keys(&mut e, "x");
5944        run_keys(&mut e, "..");
5945        assert_eq!(e.buffer().lines()[0], "def");
5946    }
5947
5948    #[test]
5949    fn count_operator_motion_compose() {
5950        let mut e = editor_with("one two three four five");
5951        run_keys(&mut e, "d3w");
5952        assert_eq!(e.buffer().lines()[0], "four five");
5953    }
5954
5955    #[test]
5956    fn two_dd_deletes_two_lines() {
5957        let mut e = editor_with("a\nb\nc");
5958        run_keys(&mut e, "2dd");
5959        assert_eq!(e.buffer().lines().len(), 1);
5960        assert_eq!(e.buffer().lines()[0], "c");
5961    }
5962
5963    /// Vim's `dd` leaves the cursor on the first non-blank of the line
5964    /// that now sits at the deleted row — not at the end of the
5965    /// previous line, which is where tui-textarea's raw cut would
5966    /// park it.
5967    #[test]
5968    fn dd_in_middle_puts_cursor_on_first_non_blank_of_next() {
5969        let mut e = editor_with("one\ntwo\n    three\nfour");
5970        e.jump_cursor(1, 2);
5971        run_keys(&mut e, "dd");
5972        // Buffer: ["one", "    three", "four"]
5973        assert_eq!(e.buffer().lines()[1], "    three");
5974        assert_eq!(e.cursor(), (1, 4));
5975    }
5976
5977    #[test]
5978    fn dd_on_last_line_puts_cursor_on_first_non_blank_of_prev() {
5979        let mut e = editor_with("one\n  two\nthree");
5980        e.jump_cursor(2, 0);
5981        run_keys(&mut e, "dd");
5982        // Buffer: ["one", "  two"]
5983        assert_eq!(e.buffer().lines().len(), 2);
5984        assert_eq!(e.cursor(), (1, 2));
5985    }
5986
5987    #[test]
5988    fn dd_on_only_line_leaves_empty_buffer_and_cursor_at_zero() {
5989        let mut e = editor_with("lonely");
5990        run_keys(&mut e, "dd");
5991        assert_eq!(e.buffer().lines().len(), 1);
5992        assert_eq!(e.buffer().lines()[0], "");
5993        assert_eq!(e.cursor(), (0, 0));
5994    }
5995
5996    #[test]
5997    fn count_dd_puts_cursor_on_first_non_blank_of_remaining() {
5998        let mut e = editor_with("a\nb\nc\n   d\ne");
5999        // Cursor on row 1, "3dd" deletes b/c/   d → lines become [a, e].
6000        e.jump_cursor(1, 0);
6001        run_keys(&mut e, "3dd");
6002        assert_eq!(e.buffer().lines(), &["a".to_string(), "e".to_string()]);
6003        assert_eq!(e.cursor(), (1, 0));
6004    }
6005
6006    #[test]
6007    fn dd_then_j_uses_first_non_blank_not_sticky_col() {
6008        // Buffer: 3 lines with predictable widths.
6009        // Line 0: "    line one"   (12 chars, first-non-blank at col 4)
6010        // Line 1: "    line two"   (12 chars, first-non-blank at col 4)
6011        // Line 2: "  xy"           (4 chars, indices 0-3; last char at col 3)
6012        //
6013        // Cursor starts at col 8 on line 0.  After `dd`:
6014        //   - line 0 is deleted; cursor lands on first-non-blank of new line 0
6015        //     ("    line two") → col 4.
6016        //   - sticky_col must be updated to 4.
6017        //
6018        // Then `j` moves to "  xy" (4 chars, max col = 3).
6019        //   - With the fix   : sticky_col=4 → clamps to col 3 (last char).
6020        //   - Without the fix: sticky_col=8 → clamps to col 3 (same clamp).
6021        //
6022        // To make the two cases distinguishable we choose line 2 with
6023        // exactly 6 chars ("  xyz!") so max col = 5:
6024        //   - fix   : sticky_col=4 → lands at col 4.
6025        //   - no fix: sticky_col=8 → clamps to col 5.
6026        let mut e = editor_with("    line one\n    line two\n  xyz!");
6027        // Move to col 8 on line 0.
6028        e.jump_cursor(0, 8);
6029        assert_eq!(e.cursor(), (0, 8));
6030        // `dd` deletes line 0; cursor should land on first-non-blank of
6031        // the new line 0 ("    line two" → col 4).
6032        run_keys(&mut e, "dd");
6033        assert_eq!(
6034            e.cursor(),
6035            (0, 4),
6036            "dd must place cursor on first-non-blank"
6037        );
6038        // `j` moves to "  xyz!" (6 chars, cols 0-5).
6039        // Bug: stale sticky_col=8 clamps to col 5 (last char).
6040        // Fixed: sticky_col=4 → lands at col 4.
6041        run_keys(&mut e, "j");
6042        let (row, col) = e.cursor();
6043        assert_eq!(row, 1);
6044        assert_eq!(
6045            col, 4,
6046            "after dd, j should use the column dd established (4), not pre-dd sticky_col (8)"
6047        );
6048    }
6049
6050    #[test]
6051    fn gu_lowercases_motion_range() {
6052        let mut e = editor_with("HELLO WORLD");
6053        run_keys(&mut e, "guw");
6054        assert_eq!(e.buffer().lines()[0], "hello WORLD");
6055        assert_eq!(e.cursor(), (0, 0));
6056    }
6057
6058    #[test]
6059    fn g_u_uppercases_text_object() {
6060        let mut e = editor_with("hello world");
6061        // gUiw uppercases the word at the cursor.
6062        run_keys(&mut e, "gUiw");
6063        assert_eq!(e.buffer().lines()[0], "HELLO world");
6064        assert_eq!(e.cursor(), (0, 0));
6065    }
6066
6067    #[test]
6068    fn g_tilde_toggles_case_of_range() {
6069        let mut e = editor_with("Hello World");
6070        run_keys(&mut e, "g~iw");
6071        assert_eq!(e.buffer().lines()[0], "hELLO World");
6072    }
6073
6074    #[test]
6075    fn g_uu_uppercases_current_line() {
6076        let mut e = editor_with("select 1\nselect 2");
6077        run_keys(&mut e, "gUU");
6078        assert_eq!(e.buffer().lines()[0], "SELECT 1");
6079        assert_eq!(e.buffer().lines()[1], "select 2");
6080    }
6081
6082    #[test]
6083    fn gugu_lowercases_current_line() {
6084        let mut e = editor_with("FOO BAR\nBAZ");
6085        run_keys(&mut e, "gugu");
6086        assert_eq!(e.buffer().lines()[0], "foo bar");
6087    }
6088
6089    #[test]
6090    fn visual_u_uppercases_selection() {
6091        let mut e = editor_with("hello world");
6092        // v + e selects "hello" (inclusive of last char), U uppercases.
6093        run_keys(&mut e, "veU");
6094        assert_eq!(e.buffer().lines()[0], "HELLO world");
6095    }
6096
6097    #[test]
6098    fn visual_line_u_lowercases_line() {
6099        let mut e = editor_with("HELLO WORLD\nOTHER");
6100        run_keys(&mut e, "Vu");
6101        assert_eq!(e.buffer().lines()[0], "hello world");
6102        assert_eq!(e.buffer().lines()[1], "OTHER");
6103    }
6104
6105    #[test]
6106    fn g_uu_with_count_uppercases_multiple_lines() {
6107        let mut e = editor_with("one\ntwo\nthree\nfour");
6108        // `3gUU` uppercases 3 lines starting from the cursor.
6109        run_keys(&mut e, "3gUU");
6110        assert_eq!(e.buffer().lines()[0], "ONE");
6111        assert_eq!(e.buffer().lines()[1], "TWO");
6112        assert_eq!(e.buffer().lines()[2], "THREE");
6113        assert_eq!(e.buffer().lines()[3], "four");
6114    }
6115
6116    #[test]
6117    fn double_gt_indents_current_line() {
6118        let mut e = editor_with("hello");
6119        run_keys(&mut e, ">>");
6120        assert_eq!(e.buffer().lines()[0], "  hello");
6121        // Cursor lands on first non-blank.
6122        assert_eq!(e.cursor(), (0, 2));
6123    }
6124
6125    #[test]
6126    fn double_lt_outdents_current_line() {
6127        let mut e = editor_with("    hello");
6128        run_keys(&mut e, "<lt><lt>");
6129        assert_eq!(e.buffer().lines()[0], "  hello");
6130        assert_eq!(e.cursor(), (0, 2));
6131    }
6132
6133    #[test]
6134    fn count_double_gt_indents_multiple_lines() {
6135        let mut e = editor_with("a\nb\nc\nd");
6136        // `3>>` indents 3 lines starting at cursor.
6137        run_keys(&mut e, "3>>");
6138        assert_eq!(e.buffer().lines()[0], "  a");
6139        assert_eq!(e.buffer().lines()[1], "  b");
6140        assert_eq!(e.buffer().lines()[2], "  c");
6141        assert_eq!(e.buffer().lines()[3], "d");
6142    }
6143
6144    #[test]
6145    fn outdent_clips_ragged_leading_whitespace() {
6146        // Only one space of indent — outdent should strip what's
6147        // there, not leave anything negative.
6148        let mut e = editor_with(" x");
6149        run_keys(&mut e, "<lt><lt>");
6150        assert_eq!(e.buffer().lines()[0], "x");
6151    }
6152
6153    #[test]
6154    fn indent_motion_is_always_linewise() {
6155        // `>w` indents the current line (linewise) — it doesn't
6156        // insert spaces into the middle of the word.
6157        let mut e = editor_with("foo bar");
6158        run_keys(&mut e, ">w");
6159        assert_eq!(e.buffer().lines()[0], "  foo bar");
6160    }
6161
6162    #[test]
6163    fn indent_text_object_extends_over_paragraph() {
6164        let mut e = editor_with("a\nb\n\nc\nd");
6165        // `>ap` indents the whole paragraph (rows 0..=1).
6166        run_keys(&mut e, ">ap");
6167        assert_eq!(e.buffer().lines()[0], "  a");
6168        assert_eq!(e.buffer().lines()[1], "  b");
6169        assert_eq!(e.buffer().lines()[2], "");
6170        assert_eq!(e.buffer().lines()[3], "c");
6171    }
6172
6173    #[test]
6174    fn visual_line_indent_shifts_selected_rows() {
6175        let mut e = editor_with("x\ny\nz");
6176        // Vj selects rows 0..=1 linewise; `>` indents.
6177        run_keys(&mut e, "Vj>");
6178        assert_eq!(e.buffer().lines()[0], "  x");
6179        assert_eq!(e.buffer().lines()[1], "  y");
6180        assert_eq!(e.buffer().lines()[2], "z");
6181    }
6182
6183    #[test]
6184    fn outdent_empty_line_is_noop() {
6185        let mut e = editor_with("\nfoo");
6186        run_keys(&mut e, "<lt><lt>");
6187        assert_eq!(e.buffer().lines()[0], "");
6188    }
6189
6190    #[test]
6191    fn indent_skips_empty_lines() {
6192        // Vim convention: `>>` on an empty line doesn't pad it with
6193        // trailing whitespace.
6194        let mut e = editor_with("");
6195        run_keys(&mut e, ">>");
6196        assert_eq!(e.buffer().lines()[0], "");
6197    }
6198
6199    #[test]
6200    fn insert_ctrl_t_indents_current_line() {
6201        let mut e = editor_with("x");
6202        // Enter insert, Ctrl-t indents the line; cursor advances too.
6203        run_keys(&mut e, "i<C-t>");
6204        assert_eq!(e.buffer().lines()[0], "  x");
6205        // After insert-mode start `i` cursor was at (0, 0); Ctrl-t
6206        // shifts it by SHIFTWIDTH=2.
6207        assert_eq!(e.cursor(), (0, 2));
6208    }
6209
6210    #[test]
6211    fn insert_ctrl_d_outdents_current_line() {
6212        let mut e = editor_with("    x");
6213        // Enter insert-at-end `A`, Ctrl-d outdents by shiftwidth.
6214        run_keys(&mut e, "A<C-d>");
6215        assert_eq!(e.buffer().lines()[0], "  x");
6216    }
6217
6218    #[test]
6219    fn h_at_col_zero_does_not_wrap_to_prev_line() {
6220        let mut e = editor_with("first\nsecond");
6221        e.jump_cursor(1, 0);
6222        run_keys(&mut e, "h");
6223        // Cursor must stay on row 1 col 0 — vim default doesn't wrap.
6224        assert_eq!(e.cursor(), (1, 0));
6225    }
6226
6227    #[test]
6228    fn l_at_last_char_does_not_wrap_to_next_line() {
6229        let mut e = editor_with("ab\ncd");
6230        // Move to last char of row 0 (col 1).
6231        e.jump_cursor(0, 1);
6232        run_keys(&mut e, "l");
6233        // Cursor stays on last char — no wrap.
6234        assert_eq!(e.cursor(), (0, 1));
6235    }
6236
6237    #[test]
6238    fn count_l_clamps_at_line_end() {
6239        let mut e = editor_with("abcde");
6240        // 20l starting at col 0 should land on last char (col 4),
6241        // not overflow / wrap.
6242        run_keys(&mut e, "20l");
6243        assert_eq!(e.cursor(), (0, 4));
6244    }
6245
6246    #[test]
6247    fn count_h_clamps_at_col_zero() {
6248        let mut e = editor_with("abcde");
6249        e.jump_cursor(0, 3);
6250        run_keys(&mut e, "20h");
6251        assert_eq!(e.cursor(), (0, 0));
6252    }
6253
6254    #[test]
6255    fn dl_on_last_char_still_deletes_it() {
6256        // `dl` / `x`-equivalent at EOL must delete the last char —
6257        // operator motion allows endpoint past-last even though bare
6258        // `l` stops before.
6259        let mut e = editor_with("ab");
6260        e.jump_cursor(0, 1);
6261        run_keys(&mut e, "dl");
6262        assert_eq!(e.buffer().lines()[0], "a");
6263    }
6264
6265    #[test]
6266    fn case_op_preserves_yank_register() {
6267        let mut e = editor_with("target");
6268        run_keys(&mut e, "yy");
6269        let yank_before = e.yank().to_string();
6270        // gUU changes the line but must not clobber the yank register.
6271        run_keys(&mut e, "gUU");
6272        assert_eq!(e.buffer().lines()[0], "TARGET");
6273        assert_eq!(
6274            e.yank(),
6275            yank_before,
6276            "case ops must preserve the yank buffer"
6277        );
6278    }
6279
6280    #[test]
6281    fn dap_deletes_paragraph() {
6282        let mut e = editor_with("a\nb\n\nc\nd");
6283        run_keys(&mut e, "dap");
6284        assert_eq!(e.buffer().lines().first().map(String::as_str), Some("c"));
6285    }
6286
6287    #[test]
6288    fn dit_deletes_inner_tag_content() {
6289        let mut e = editor_with("<b>hello</b>");
6290        // Cursor on `e`.
6291        e.jump_cursor(0, 4);
6292        run_keys(&mut e, "dit");
6293        assert_eq!(e.buffer().lines()[0], "<b></b>");
6294    }
6295
6296    #[test]
6297    fn dat_deletes_around_tag() {
6298        let mut e = editor_with("hi <b>foo</b> bye");
6299        e.jump_cursor(0, 6);
6300        run_keys(&mut e, "dat");
6301        assert_eq!(e.buffer().lines()[0], "hi  bye");
6302    }
6303
6304    #[test]
6305    fn dit_picks_innermost_tag() {
6306        let mut e = editor_with("<a><b>x</b></a>");
6307        // Cursor on `x`.
6308        e.jump_cursor(0, 6);
6309        run_keys(&mut e, "dit");
6310        // Inner of <b> is removed; <a> wrapping stays.
6311        assert_eq!(e.buffer().lines()[0], "<a><b></b></a>");
6312    }
6313
6314    #[test]
6315    fn dat_innermost_tag_pair() {
6316        let mut e = editor_with("<a><b>x</b></a>");
6317        e.jump_cursor(0, 6);
6318        run_keys(&mut e, "dat");
6319        assert_eq!(e.buffer().lines()[0], "<a></a>");
6320    }
6321
6322    #[test]
6323    fn dit_outside_any_tag_no_op() {
6324        let mut e = editor_with("plain text");
6325        e.jump_cursor(0, 3);
6326        run_keys(&mut e, "dit");
6327        // No tag pair surrounds the cursor — buffer unchanged.
6328        assert_eq!(e.buffer().lines()[0], "plain text");
6329    }
6330
6331    #[test]
6332    fn cit_changes_inner_tag_content() {
6333        let mut e = editor_with("<b>hello</b>");
6334        e.jump_cursor(0, 4);
6335        run_keys(&mut e, "citNEW<Esc>");
6336        assert_eq!(e.buffer().lines()[0], "<b>NEW</b>");
6337    }
6338
6339    #[test]
6340    fn cat_changes_around_tag() {
6341        let mut e = editor_with("hi <b>foo</b> bye");
6342        e.jump_cursor(0, 6);
6343        run_keys(&mut e, "catBAR<Esc>");
6344        assert_eq!(e.buffer().lines()[0], "hi BAR bye");
6345    }
6346
6347    #[test]
6348    fn yit_yanks_inner_tag_content() {
6349        let mut e = editor_with("<b>hello</b>");
6350        e.jump_cursor(0, 4);
6351        run_keys(&mut e, "yit");
6352        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6353    }
6354
6355    #[test]
6356    fn yat_yanks_full_tag_pair() {
6357        let mut e = editor_with("hi <b>foo</b> bye");
6358        e.jump_cursor(0, 6);
6359        run_keys(&mut e, "yat");
6360        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6361    }
6362
6363    #[test]
6364    fn vit_visually_selects_inner_tag() {
6365        let mut e = editor_with("<b>hello</b>");
6366        e.jump_cursor(0, 4);
6367        run_keys(&mut e, "vit");
6368        assert_eq!(e.vim_mode(), VimMode::Visual);
6369        run_keys(&mut e, "y");
6370        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6371    }
6372
6373    #[test]
6374    fn vat_visually_selects_around_tag() {
6375        let mut e = editor_with("x<b>foo</b>y");
6376        e.jump_cursor(0, 5);
6377        run_keys(&mut e, "vat");
6378        assert_eq!(e.vim_mode(), VimMode::Visual);
6379        run_keys(&mut e, "y");
6380        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6381    }
6382
6383    // ─── Text-object coverage (d operator, inner + around) ───────────
6384
6385    #[test]
6386    #[allow(non_snake_case)]
6387    fn diW_deletes_inner_big_word() {
6388        let mut e = editor_with("foo.bar baz");
6389        e.jump_cursor(0, 2);
6390        run_keys(&mut e, "diW");
6391        // Big word treats `foo.bar` as one token.
6392        assert_eq!(e.buffer().lines()[0], " baz");
6393    }
6394
6395    #[test]
6396    #[allow(non_snake_case)]
6397    fn daW_deletes_around_big_word() {
6398        let mut e = editor_with("foo.bar baz");
6399        e.jump_cursor(0, 2);
6400        run_keys(&mut e, "daW");
6401        assert_eq!(e.buffer().lines()[0], "baz");
6402    }
6403
6404    #[test]
6405    fn di_double_quote_deletes_inside() {
6406        let mut e = editor_with("a \"hello\" b");
6407        e.jump_cursor(0, 4);
6408        run_keys(&mut e, "di\"");
6409        assert_eq!(e.buffer().lines()[0], "a \"\" b");
6410    }
6411
6412    #[test]
6413    fn da_double_quote_deletes_around() {
6414        // `da"` eats the trailing space — matches vim's around-whitespace rule.
6415        let mut e = editor_with("a \"hello\" b");
6416        e.jump_cursor(0, 4);
6417        run_keys(&mut e, "da\"");
6418        assert_eq!(e.buffer().lines()[0], "a b");
6419    }
6420
6421    #[test]
6422    fn di_single_quote_deletes_inside() {
6423        let mut e = editor_with("x 'foo' y");
6424        e.jump_cursor(0, 4);
6425        run_keys(&mut e, "di'");
6426        assert_eq!(e.buffer().lines()[0], "x '' y");
6427    }
6428
6429    #[test]
6430    fn da_single_quote_deletes_around() {
6431        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6432        let mut e = editor_with("x 'foo' y");
6433        e.jump_cursor(0, 4);
6434        run_keys(&mut e, "da'");
6435        assert_eq!(e.buffer().lines()[0], "x y");
6436    }
6437
6438    #[test]
6439    fn di_backtick_deletes_inside() {
6440        let mut e = editor_with("p `q` r");
6441        e.jump_cursor(0, 3);
6442        run_keys(&mut e, "di`");
6443        assert_eq!(e.buffer().lines()[0], "p `` r");
6444    }
6445
6446    #[test]
6447    fn da_backtick_deletes_around() {
6448        // `da`` eats the trailing space — matches vim's around-whitespace rule.
6449        let mut e = editor_with("p `q` r");
6450        e.jump_cursor(0, 3);
6451        run_keys(&mut e, "da`");
6452        assert_eq!(e.buffer().lines()[0], "p r");
6453    }
6454
6455    #[test]
6456    fn di_paren_deletes_inside() {
6457        let mut e = editor_with("f(arg)");
6458        e.jump_cursor(0, 3);
6459        run_keys(&mut e, "di(");
6460        assert_eq!(e.buffer().lines()[0], "f()");
6461    }
6462
6463    #[test]
6464    fn di_paren_alias_b_works() {
6465        let mut e = editor_with("f(arg)");
6466        e.jump_cursor(0, 3);
6467        run_keys(&mut e, "dib");
6468        assert_eq!(e.buffer().lines()[0], "f()");
6469    }
6470
6471    #[test]
6472    fn di_bracket_deletes_inside() {
6473        let mut e = editor_with("a[b,c]d");
6474        e.jump_cursor(0, 3);
6475        run_keys(&mut e, "di[");
6476        assert_eq!(e.buffer().lines()[0], "a[]d");
6477    }
6478
6479    #[test]
6480    fn da_bracket_deletes_around() {
6481        let mut e = editor_with("a[b,c]d");
6482        e.jump_cursor(0, 3);
6483        run_keys(&mut e, "da[");
6484        assert_eq!(e.buffer().lines()[0], "ad");
6485    }
6486
6487    #[test]
6488    fn di_brace_deletes_inside() {
6489        let mut e = editor_with("x{y}z");
6490        e.jump_cursor(0, 2);
6491        run_keys(&mut e, "di{");
6492        assert_eq!(e.buffer().lines()[0], "x{}z");
6493    }
6494
6495    #[test]
6496    fn da_brace_deletes_around() {
6497        let mut e = editor_with("x{y}z");
6498        e.jump_cursor(0, 2);
6499        run_keys(&mut e, "da{");
6500        assert_eq!(e.buffer().lines()[0], "xz");
6501    }
6502
6503    #[test]
6504    fn di_brace_alias_capital_b_works() {
6505        let mut e = editor_with("x{y}z");
6506        e.jump_cursor(0, 2);
6507        run_keys(&mut e, "diB");
6508        assert_eq!(e.buffer().lines()[0], "x{}z");
6509    }
6510
6511    #[test]
6512    fn di_angle_deletes_inside() {
6513        let mut e = editor_with("p<q>r");
6514        e.jump_cursor(0, 2);
6515        // `<lt>` so run_keys doesn't treat `<` as the start of a special-key tag.
6516        run_keys(&mut e, "di<lt>");
6517        assert_eq!(e.buffer().lines()[0], "p<>r");
6518    }
6519
6520    #[test]
6521    fn da_angle_deletes_around() {
6522        let mut e = editor_with("p<q>r");
6523        e.jump_cursor(0, 2);
6524        run_keys(&mut e, "da<lt>");
6525        assert_eq!(e.buffer().lines()[0], "pr");
6526    }
6527
6528    #[test]
6529    fn dip_deletes_inner_paragraph() {
6530        let mut e = editor_with("a\nb\nc\n\nd");
6531        e.jump_cursor(1, 0);
6532        run_keys(&mut e, "dip");
6533        // Inner paragraph (rows 0..=2) drops; the trailing blank
6534        // separator + remaining paragraph stay.
6535        assert_eq!(e.buffer().lines(), vec!["".to_string(), "d".into()]);
6536    }
6537
6538    // ─── Operator pipeline spot checks (non-tag text objects) ───────
6539
6540    #[test]
6541    fn sentence_motion_close_paren_jumps_forward() {
6542        let mut e = editor_with("Alpha. Beta. Gamma.");
6543        e.jump_cursor(0, 0);
6544        run_keys(&mut e, ")");
6545        // Lands on the start of "Beta".
6546        assert_eq!(e.cursor(), (0, 7));
6547        run_keys(&mut e, ")");
6548        assert_eq!(e.cursor(), (0, 13));
6549    }
6550
6551    #[test]
6552    fn sentence_motion_open_paren_jumps_backward() {
6553        let mut e = editor_with("Alpha. Beta. Gamma.");
6554        e.jump_cursor(0, 13);
6555        run_keys(&mut e, "(");
6556        // Cursor was at start of "Gamma" (col 13); first `(` walks
6557        // back to the previous sentence's start.
6558        assert_eq!(e.cursor(), (0, 7));
6559        run_keys(&mut e, "(");
6560        assert_eq!(e.cursor(), (0, 0));
6561    }
6562
6563    #[test]
6564    fn sentence_motion_count() {
6565        let mut e = editor_with("A. B. C. D.");
6566        e.jump_cursor(0, 0);
6567        run_keys(&mut e, "3)");
6568        // 3 forward jumps land on "D".
6569        assert_eq!(e.cursor(), (0, 9));
6570    }
6571
6572    #[test]
6573    fn dis_deletes_inner_sentence() {
6574        let mut e = editor_with("First one. Second one. Third one.");
6575        e.jump_cursor(0, 13);
6576        run_keys(&mut e, "dis");
6577        // Removed "Second one." inclusive of its terminator.
6578        assert_eq!(e.buffer().lines()[0], "First one.  Third one.");
6579    }
6580
6581    #[test]
6582    fn das_deletes_around_sentence_with_trailing_space() {
6583        let mut e = editor_with("Alpha. Beta. Gamma.");
6584        e.jump_cursor(0, 8);
6585        run_keys(&mut e, "das");
6586        // `as` swallows the trailing whitespace before the next
6587        // sentence — exactly one space here.
6588        assert_eq!(e.buffer().lines()[0], "Alpha. Gamma.");
6589    }
6590
6591    #[test]
6592    fn dis_handles_double_terminator() {
6593        let mut e = editor_with("Wow!? Next.");
6594        e.jump_cursor(0, 1);
6595        run_keys(&mut e, "dis");
6596        // Run of `!?` collapses into one boundary; sentence body
6597        // including both terminators is removed.
6598        assert_eq!(e.buffer().lines()[0], " Next.");
6599    }
6600
6601    #[test]
6602    fn dis_first_sentence_from_cursor_at_zero() {
6603        let mut e = editor_with("Alpha. Beta.");
6604        e.jump_cursor(0, 0);
6605        run_keys(&mut e, "dis");
6606        assert_eq!(e.buffer().lines()[0], " Beta.");
6607    }
6608
6609    #[test]
6610    fn yis_yanks_inner_sentence() {
6611        let mut e = editor_with("Hello world. Bye.");
6612        e.jump_cursor(0, 5);
6613        run_keys(&mut e, "yis");
6614        assert_eq!(e.registers().read('"').unwrap().text, "Hello world.");
6615    }
6616
6617    #[test]
6618    fn vis_visually_selects_inner_sentence() {
6619        let mut e = editor_with("First. Second.");
6620        e.jump_cursor(0, 1);
6621        run_keys(&mut e, "vis");
6622        assert_eq!(e.vim_mode(), VimMode::Visual);
6623        run_keys(&mut e, "y");
6624        assert_eq!(e.registers().read('"').unwrap().text, "First.");
6625    }
6626
6627    #[test]
6628    fn ciw_changes_inner_word() {
6629        let mut e = editor_with("hello world");
6630        e.jump_cursor(0, 1);
6631        run_keys(&mut e, "ciwHEY<Esc>");
6632        assert_eq!(e.buffer().lines()[0], "HEY world");
6633    }
6634
6635    #[test]
6636    fn yiw_yanks_inner_word() {
6637        let mut e = editor_with("hello world");
6638        e.jump_cursor(0, 1);
6639        run_keys(&mut e, "yiw");
6640        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6641    }
6642
6643    #[test]
6644    fn viw_selects_inner_word() {
6645        let mut e = editor_with("hello world");
6646        e.jump_cursor(0, 2);
6647        run_keys(&mut e, "viw");
6648        assert_eq!(e.vim_mode(), VimMode::Visual);
6649        run_keys(&mut e, "y");
6650        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6651    }
6652
6653    #[test]
6654    fn ci_paren_changes_inside() {
6655        let mut e = editor_with("f(old)");
6656        e.jump_cursor(0, 3);
6657        run_keys(&mut e, "ci(NEW<Esc>");
6658        assert_eq!(e.buffer().lines()[0], "f(NEW)");
6659    }
6660
6661    #[test]
6662    fn yi_double_quote_yanks_inside() {
6663        let mut e = editor_with("say \"hi there\" then");
6664        e.jump_cursor(0, 6);
6665        run_keys(&mut e, "yi\"");
6666        assert_eq!(e.registers().read('"').unwrap().text, "hi there");
6667    }
6668
6669    #[test]
6670    fn vap_visual_selects_around_paragraph() {
6671        let mut e = editor_with("a\nb\n\nc");
6672        e.jump_cursor(0, 0);
6673        run_keys(&mut e, "vap");
6674        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6675        run_keys(&mut e, "y");
6676        // Linewise yank includes the paragraph rows + trailing blank.
6677        let text = e.registers().read('"').unwrap().text.clone();
6678        assert!(text.starts_with("a\nb"));
6679    }
6680
6681    #[test]
6682    fn star_finds_next_occurrence() {
6683        let mut e = editor_with("foo bar foo baz");
6684        run_keys(&mut e, "*");
6685        assert_eq!(e.cursor().1, 8);
6686    }
6687
6688    #[test]
6689    fn star_skips_substring_match() {
6690        // `*` uses `\bfoo\b` so `foobar` is *not* a hit; cursor wraps
6691        // back to the original `foo` at col 0.
6692        let mut e = editor_with("foo foobar baz");
6693        run_keys(&mut e, "*");
6694        assert_eq!(e.cursor().1, 0);
6695    }
6696
6697    #[test]
6698    fn g_star_matches_substring() {
6699        // `g*` drops the boundary; from `foo` at col 0 the next hit is
6700        // inside `foobar` (col 4).
6701        let mut e = editor_with("foo foobar baz");
6702        run_keys(&mut e, "g*");
6703        assert_eq!(e.cursor().1, 4);
6704    }
6705
6706    #[test]
6707    fn g_pound_matches_substring_backward() {
6708        // Start on the last `foo`; `g#` walks backward and lands inside
6709        // `foobar` (col 4).
6710        let mut e = editor_with("foo foobar baz foo");
6711        run_keys(&mut e, "$b");
6712        assert_eq!(e.cursor().1, 15);
6713        run_keys(&mut e, "g#");
6714        assert_eq!(e.cursor().1, 4);
6715    }
6716
6717    #[test]
6718    fn n_repeats_last_search_forward() {
6719        let mut e = editor_with("foo bar foo baz foo");
6720        // `/foo<CR>` jumps past the cursor's current cell, so from
6721        // col 0 the first hit is the second `foo` at col 8.
6722        run_keys(&mut e, "/foo<CR>");
6723        assert_eq!(e.cursor().1, 8);
6724        run_keys(&mut e, "n");
6725        assert_eq!(e.cursor().1, 16);
6726    }
6727
6728    #[test]
6729    fn shift_n_reverses_search() {
6730        let mut e = editor_with("foo bar foo baz foo");
6731        run_keys(&mut e, "/foo<CR>");
6732        run_keys(&mut e, "n");
6733        assert_eq!(e.cursor().1, 16);
6734        run_keys(&mut e, "N");
6735        assert_eq!(e.cursor().1, 8);
6736    }
6737
6738    #[test]
6739    fn n_noop_without_pattern() {
6740        let mut e = editor_with("foo bar");
6741        run_keys(&mut e, "n");
6742        assert_eq!(e.cursor(), (0, 0));
6743    }
6744
6745    #[test]
6746    fn visual_line_preserves_cursor_column() {
6747        // V should never drag the cursor off its natural column — the
6748        // highlight is painted as a post-render overlay instead.
6749        let mut e = editor_with("hello world\nanother one\nbye");
6750        run_keys(&mut e, "lllll"); // col 5
6751        run_keys(&mut e, "V");
6752        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6753        assert_eq!(e.cursor(), (0, 5));
6754        run_keys(&mut e, "j");
6755        assert_eq!(e.cursor(), (1, 5));
6756    }
6757
6758    #[test]
6759    fn visual_line_yank_includes_trailing_newline() {
6760        let mut e = editor_with("aaa\nbbb\nccc");
6761        run_keys(&mut e, "Vjy");
6762        // Two lines yanked — must be `aaa\nbbb\n`, trailing newline preserved.
6763        assert_eq!(e.last_yank.as_deref(), Some("aaa\nbbb\n"));
6764    }
6765
6766    #[test]
6767    fn visual_line_yank_last_line_trailing_newline() {
6768        let mut e = editor_with("aaa\nbbb\nccc");
6769        // Move to the last line and yank with V (final buffer line).
6770        run_keys(&mut e, "jj");
6771        run_keys(&mut e, "Vy");
6772        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6773    }
6774
6775    #[test]
6776    fn yy_on_last_line_has_trailing_newline() {
6777        let mut e = editor_with("aaa\nbbb\nccc");
6778        run_keys(&mut e, "jj");
6779        run_keys(&mut e, "yy");
6780        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6781    }
6782
6783    #[test]
6784    fn yy_in_middle_has_trailing_newline() {
6785        let mut e = editor_with("aaa\nbbb\nccc");
6786        run_keys(&mut e, "j");
6787        run_keys(&mut e, "yy");
6788        assert_eq!(e.last_yank.as_deref(), Some("bbb\n"));
6789    }
6790
6791    #[test]
6792    fn di_single_quote() {
6793        let mut e = editor_with("say 'hello world' now");
6794        e.jump_cursor(0, 7);
6795        run_keys(&mut e, "di'");
6796        assert_eq!(e.buffer().lines()[0], "say '' now");
6797    }
6798
6799    #[test]
6800    fn da_single_quote() {
6801        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6802        let mut e = editor_with("say 'hello' now");
6803        e.jump_cursor(0, 7);
6804        run_keys(&mut e, "da'");
6805        assert_eq!(e.buffer().lines()[0], "say now");
6806    }
6807
6808    #[test]
6809    fn di_backtick() {
6810        let mut e = editor_with("say `hi` now");
6811        e.jump_cursor(0, 5);
6812        run_keys(&mut e, "di`");
6813        assert_eq!(e.buffer().lines()[0], "say `` now");
6814    }
6815
6816    #[test]
6817    fn di_brace() {
6818        let mut e = editor_with("fn { a; b; c }");
6819        e.jump_cursor(0, 7);
6820        run_keys(&mut e, "di{");
6821        assert_eq!(e.buffer().lines()[0], "fn {}");
6822    }
6823
6824    #[test]
6825    fn di_bracket() {
6826        let mut e = editor_with("arr[1, 2, 3]");
6827        e.jump_cursor(0, 5);
6828        run_keys(&mut e, "di[");
6829        assert_eq!(e.buffer().lines()[0], "arr[]");
6830    }
6831
6832    #[test]
6833    fn dab_deletes_around_paren() {
6834        let mut e = editor_with("fn(a, b) + 1");
6835        e.jump_cursor(0, 4);
6836        run_keys(&mut e, "dab");
6837        assert_eq!(e.buffer().lines()[0], "fn + 1");
6838    }
6839
6840    #[test]
6841    fn da_big_b_deletes_around_brace() {
6842        let mut e = editor_with("x = {a: 1}");
6843        e.jump_cursor(0, 6);
6844        run_keys(&mut e, "daB");
6845        assert_eq!(e.buffer().lines()[0], "x = ");
6846    }
6847
6848    #[test]
6849    fn di_big_w_deletes_bigword() {
6850        let mut e = editor_with("foo-bar baz");
6851        e.jump_cursor(0, 2);
6852        run_keys(&mut e, "diW");
6853        assert_eq!(e.buffer().lines()[0], " baz");
6854    }
6855
6856    #[test]
6857    fn visual_select_inner_word() {
6858        let mut e = editor_with("hello world");
6859        e.jump_cursor(0, 2);
6860        run_keys(&mut e, "viw");
6861        assert_eq!(e.vim_mode(), VimMode::Visual);
6862        run_keys(&mut e, "y");
6863        assert_eq!(e.last_yank.as_deref(), Some("hello"));
6864    }
6865
6866    #[test]
6867    fn visual_select_inner_quote() {
6868        let mut e = editor_with("foo \"bar\" baz");
6869        e.jump_cursor(0, 6);
6870        run_keys(&mut e, "vi\"");
6871        run_keys(&mut e, "y");
6872        assert_eq!(e.last_yank.as_deref(), Some("bar"));
6873    }
6874
6875    #[test]
6876    fn visual_select_inner_paren() {
6877        let mut e = editor_with("fn(a, b)");
6878        e.jump_cursor(0, 4);
6879        run_keys(&mut e, "vi(");
6880        run_keys(&mut e, "y");
6881        assert_eq!(e.last_yank.as_deref(), Some("a, b"));
6882    }
6883
6884    #[test]
6885    fn visual_select_outer_brace() {
6886        let mut e = editor_with("{x}");
6887        e.jump_cursor(0, 1);
6888        run_keys(&mut e, "va{");
6889        run_keys(&mut e, "y");
6890        assert_eq!(e.last_yank.as_deref(), Some("{x}"));
6891    }
6892
6893    #[test]
6894    fn ci_paren_forward_scans_when_cursor_before_pair() {
6895        // targets.vim-style: cursor at start of `foo`, ci( jumps to next
6896        // `(...)` pair on the same line and replaces the contents.
6897        let mut e = editor_with("foo(bar)");
6898        e.jump_cursor(0, 0);
6899        run_keys(&mut e, "ci(NEW<Esc>");
6900        assert_eq!(e.buffer().lines()[0], "foo(NEW)");
6901    }
6902
6903    #[test]
6904    fn ci_paren_forward_scans_across_lines() {
6905        let mut e = editor_with("first\nfoo(bar)\nlast");
6906        e.jump_cursor(0, 0);
6907        run_keys(&mut e, "ci(NEW<Esc>");
6908        assert_eq!(e.buffer().lines()[1], "foo(NEW)");
6909    }
6910
6911    #[test]
6912    fn ci_brace_forward_scans_when_cursor_before_pair() {
6913        let mut e = editor_with("let x = {y};");
6914        e.jump_cursor(0, 0);
6915        run_keys(&mut e, "ci{NEW<Esc>");
6916        assert_eq!(e.buffer().lines()[0], "let x = {NEW};");
6917    }
6918
6919    #[test]
6920    fn cit_forward_scans_when_cursor_before_tag() {
6921        // Cursor at column 0 (before `<b>`), cit jumps into the next tag
6922        // pair and replaces its contents.
6923        let mut e = editor_with("text <b>hello</b> rest");
6924        e.jump_cursor(0, 0);
6925        run_keys(&mut e, "citNEW<Esc>");
6926        assert_eq!(e.buffer().lines()[0], "text <b>NEW</b> rest");
6927    }
6928
6929    #[test]
6930    fn dat_forward_scans_when_cursor_before_tag() {
6931        // dat = delete around tag — including the `<b>...</b>` markup.
6932        let mut e = editor_with("text <b>hello</b> rest");
6933        e.jump_cursor(0, 0);
6934        run_keys(&mut e, "dat");
6935        assert_eq!(e.buffer().lines()[0], "text  rest");
6936    }
6937
6938    #[test]
6939    fn ci_paren_still_works_when_cursor_inside() {
6940        // Regression: forward-scan fallback must not break the
6941        // canonical "cursor inside the pair" case.
6942        let mut e = editor_with("fn(a, b)");
6943        e.jump_cursor(0, 4);
6944        run_keys(&mut e, "ci(NEW<Esc>");
6945        assert_eq!(e.buffer().lines()[0], "fn(NEW)");
6946    }
6947
6948    #[test]
6949    fn caw_changes_word_with_trailing_space() {
6950        let mut e = editor_with("hello world");
6951        run_keys(&mut e, "cawfoo<Esc>");
6952        assert_eq!(e.buffer().lines()[0], "fooworld");
6953    }
6954
6955    #[test]
6956    fn visual_char_yank_preserves_raw_text() {
6957        let mut e = editor_with("hello world");
6958        run_keys(&mut e, "vllly");
6959        assert_eq!(e.last_yank.as_deref(), Some("hell"));
6960    }
6961
6962    #[test]
6963    fn single_line_visual_line_selects_full_line_on_yank() {
6964        let mut e = editor_with("hello world\nbye");
6965        run_keys(&mut e, "V");
6966        // Yank the selection — should include the full line + trailing
6967        // newline (linewise yank convention).
6968        run_keys(&mut e, "y");
6969        assert_eq!(e.last_yank.as_deref(), Some("hello world\n"));
6970    }
6971
6972    #[test]
6973    fn visual_line_extends_both_directions() {
6974        let mut e = editor_with("aaa\nbbb\nccc\nddd");
6975        run_keys(&mut e, "jjj"); // row 3, col 0
6976        run_keys(&mut e, "V");
6977        assert_eq!(e.cursor(), (3, 0));
6978        run_keys(&mut e, "k");
6979        // Cursor is free to sit on its natural column — no forced Jump.
6980        assert_eq!(e.cursor(), (2, 0));
6981        run_keys(&mut e, "k");
6982        assert_eq!(e.cursor(), (1, 0));
6983    }
6984
6985    #[test]
6986    fn visual_char_preserves_cursor_column() {
6987        let mut e = editor_with("hello world");
6988        run_keys(&mut e, "lllll"); // col 5
6989        run_keys(&mut e, "v");
6990        assert_eq!(e.cursor(), (0, 5));
6991        run_keys(&mut e, "ll");
6992        assert_eq!(e.cursor(), (0, 7));
6993    }
6994
6995    #[test]
6996    fn visual_char_highlight_bounds_order() {
6997        let mut e = editor_with("abcdef");
6998        run_keys(&mut e, "lll"); // col 3
6999        run_keys(&mut e, "v");
7000        run_keys(&mut e, "hh"); // col 1
7001        // Anchor (0, 3), cursor (0, 1). Bounds ordered: start=(0,1) end=(0,3).
7002        assert_eq!(e.char_highlight(), Some(((0, 1), (0, 3))));
7003    }
7004
7005    #[test]
7006    fn visual_line_highlight_bounds() {
7007        let mut e = editor_with("a\nb\nc");
7008        run_keys(&mut e, "V");
7009        assert_eq!(e.line_highlight(), Some((0, 0)));
7010        run_keys(&mut e, "j");
7011        assert_eq!(e.line_highlight(), Some((0, 1)));
7012        run_keys(&mut e, "j");
7013        assert_eq!(e.line_highlight(), Some((0, 2)));
7014    }
7015
7016    // ─── Basic motions ─────────────────────────────────────────────────────
7017
7018    #[test]
7019    fn h_moves_left() {
7020        let mut e = editor_with("hello");
7021        e.jump_cursor(0, 3);
7022        run_keys(&mut e, "h");
7023        assert_eq!(e.cursor(), (0, 2));
7024    }
7025
7026    #[test]
7027    fn l_moves_right() {
7028        let mut e = editor_with("hello");
7029        run_keys(&mut e, "l");
7030        assert_eq!(e.cursor(), (0, 1));
7031    }
7032
7033    #[test]
7034    fn k_moves_up() {
7035        let mut e = editor_with("a\nb\nc");
7036        e.jump_cursor(2, 0);
7037        run_keys(&mut e, "k");
7038        assert_eq!(e.cursor(), (1, 0));
7039    }
7040
7041    #[test]
7042    fn zero_moves_to_line_start() {
7043        let mut e = editor_with("    hello");
7044        run_keys(&mut e, "$");
7045        run_keys(&mut e, "0");
7046        assert_eq!(e.cursor().1, 0);
7047    }
7048
7049    #[test]
7050    fn caret_moves_to_first_non_blank() {
7051        let mut e = editor_with("    hello");
7052        run_keys(&mut e, "0");
7053        run_keys(&mut e, "^");
7054        assert_eq!(e.cursor().1, 4);
7055    }
7056
7057    #[test]
7058    fn dollar_moves_to_last_char() {
7059        let mut e = editor_with("hello");
7060        run_keys(&mut e, "$");
7061        assert_eq!(e.cursor().1, 4);
7062    }
7063
7064    #[test]
7065    fn dollar_on_empty_line_stays_at_col_zero() {
7066        let mut e = editor_with("");
7067        run_keys(&mut e, "$");
7068        assert_eq!(e.cursor().1, 0);
7069    }
7070
7071    #[test]
7072    fn w_jumps_to_next_word() {
7073        let mut e = editor_with("foo bar baz");
7074        run_keys(&mut e, "w");
7075        assert_eq!(e.cursor().1, 4);
7076    }
7077
7078    #[test]
7079    fn b_jumps_back_a_word() {
7080        let mut e = editor_with("foo bar");
7081        e.jump_cursor(0, 6);
7082        run_keys(&mut e, "b");
7083        assert_eq!(e.cursor().1, 4);
7084    }
7085
7086    #[test]
7087    fn e_jumps_to_word_end() {
7088        let mut e = editor_with("foo bar");
7089        run_keys(&mut e, "e");
7090        assert_eq!(e.cursor().1, 2);
7091    }
7092
7093    // ─── Operators with line-edge and file-edge motions ───────────────────
7094
7095    #[test]
7096    fn d_dollar_deletes_to_eol() {
7097        let mut e = editor_with("hello world");
7098        e.jump_cursor(0, 5);
7099        run_keys(&mut e, "d$");
7100        assert_eq!(e.buffer().lines()[0], "hello");
7101    }
7102
7103    #[test]
7104    fn d_zero_deletes_to_line_start() {
7105        let mut e = editor_with("hello world");
7106        e.jump_cursor(0, 6);
7107        run_keys(&mut e, "d0");
7108        assert_eq!(e.buffer().lines()[0], "world");
7109    }
7110
7111    #[test]
7112    fn d_caret_deletes_to_first_non_blank() {
7113        let mut e = editor_with("    hello");
7114        e.jump_cursor(0, 6);
7115        run_keys(&mut e, "d^");
7116        assert_eq!(e.buffer().lines()[0], "    llo");
7117    }
7118
7119    #[test]
7120    fn d_capital_g_deletes_to_end_of_file() {
7121        let mut e = editor_with("a\nb\nc\nd");
7122        e.jump_cursor(1, 0);
7123        run_keys(&mut e, "dG");
7124        assert_eq!(e.buffer().lines(), &["a".to_string()]);
7125    }
7126
7127    #[test]
7128    fn d_gg_deletes_to_start_of_file() {
7129        let mut e = editor_with("a\nb\nc\nd");
7130        e.jump_cursor(2, 0);
7131        run_keys(&mut e, "dgg");
7132        assert_eq!(e.buffer().lines(), &["d".to_string()]);
7133    }
7134
7135    #[test]
7136    fn cw_is_ce_quirk() {
7137        // `cw` on a non-blank word must NOT eat the trailing whitespace;
7138        // it behaves like `ce` so the replacement lands before the space.
7139        let mut e = editor_with("foo bar");
7140        run_keys(&mut e, "cwxyz<Esc>");
7141        assert_eq!(e.buffer().lines()[0], "xyz bar");
7142    }
7143
7144    // ─── Single-char edits ────────────────────────────────────────────────
7145
7146    #[test]
7147    fn big_d_deletes_to_eol() {
7148        let mut e = editor_with("hello world");
7149        e.jump_cursor(0, 5);
7150        run_keys(&mut e, "D");
7151        assert_eq!(e.buffer().lines()[0], "hello");
7152    }
7153
7154    #[test]
7155    fn big_c_deletes_to_eol_and_inserts() {
7156        let mut e = editor_with("hello world");
7157        e.jump_cursor(0, 5);
7158        run_keys(&mut e, "C!<Esc>");
7159        assert_eq!(e.buffer().lines()[0], "hello!");
7160    }
7161
7162    #[test]
7163    fn j_joins_next_line_with_space() {
7164        let mut e = editor_with("hello\nworld");
7165        run_keys(&mut e, "J");
7166        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7167    }
7168
7169    #[test]
7170    fn j_strips_leading_whitespace_on_join() {
7171        let mut e = editor_with("hello\n    world");
7172        run_keys(&mut e, "J");
7173        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7174    }
7175
7176    #[test]
7177    fn big_x_deletes_char_before_cursor() {
7178        let mut e = editor_with("hello");
7179        e.jump_cursor(0, 3);
7180        run_keys(&mut e, "X");
7181        assert_eq!(e.buffer().lines()[0], "helo");
7182    }
7183
7184    #[test]
7185    fn s_substitutes_char_and_enters_insert() {
7186        let mut e = editor_with("hello");
7187        run_keys(&mut e, "sX<Esc>");
7188        assert_eq!(e.buffer().lines()[0], "Xello");
7189    }
7190
7191    #[test]
7192    fn count_x_deletes_many() {
7193        let mut e = editor_with("abcdef");
7194        run_keys(&mut e, "3x");
7195        assert_eq!(e.buffer().lines()[0], "def");
7196    }
7197
7198    // ─── Paste ────────────────────────────────────────────────────────────
7199
7200    #[test]
7201    fn p_pastes_charwise_after_cursor() {
7202        let mut e = editor_with("hello");
7203        run_keys(&mut e, "yw");
7204        run_keys(&mut e, "$p");
7205        assert_eq!(e.buffer().lines()[0], "hellohello");
7206    }
7207
7208    #[test]
7209    fn capital_p_pastes_charwise_before_cursor() {
7210        let mut e = editor_with("hello");
7211        // Yank "he" (2 chars) then paste it before the cursor.
7212        run_keys(&mut e, "v");
7213        run_keys(&mut e, "l");
7214        run_keys(&mut e, "y");
7215        run_keys(&mut e, "$P");
7216        // After yank cursor is at 0; $ goes to end (col 4), P pastes
7217        // before cursor — "hell" + "he" + "o" = "hellheo".
7218        assert_eq!(e.buffer().lines()[0], "hellheo");
7219    }
7220
7221    #[test]
7222    fn p_pastes_linewise_below() {
7223        let mut e = editor_with("one\ntwo\nthree");
7224        run_keys(&mut e, "yy");
7225        run_keys(&mut e, "p");
7226        assert_eq!(
7227            e.buffer().lines(),
7228            &[
7229                "one".to_string(),
7230                "one".to_string(),
7231                "two".to_string(),
7232                "three".to_string()
7233            ]
7234        );
7235    }
7236
7237    #[test]
7238    fn capital_p_pastes_linewise_above() {
7239        let mut e = editor_with("one\ntwo");
7240        e.jump_cursor(1, 0);
7241        run_keys(&mut e, "yy");
7242        run_keys(&mut e, "P");
7243        assert_eq!(
7244            e.buffer().lines(),
7245            &["one".to_string(), "two".to_string(), "two".to_string()]
7246        );
7247    }
7248
7249    // ─── Reverse word search ──────────────────────────────────────────────
7250
7251    #[test]
7252    fn hash_finds_previous_occurrence() {
7253        let mut e = editor_with("foo bar foo baz foo");
7254        // Move to the third 'foo' then #.
7255        e.jump_cursor(0, 16);
7256        run_keys(&mut e, "#");
7257        assert_eq!(e.cursor().1, 8);
7258    }
7259
7260    // ─── VisualLine delete / change ───────────────────────────────────────
7261
7262    #[test]
7263    fn visual_line_delete_removes_full_lines() {
7264        let mut e = editor_with("a\nb\nc\nd");
7265        run_keys(&mut e, "Vjd");
7266        assert_eq!(e.buffer().lines(), &["c".to_string(), "d".to_string()]);
7267    }
7268
7269    #[test]
7270    fn visual_line_change_leaves_blank_line() {
7271        let mut e = editor_with("a\nb\nc");
7272        run_keys(&mut e, "Vjc");
7273        assert_eq!(e.vim_mode(), VimMode::Insert);
7274        run_keys(&mut e, "X<Esc>");
7275        // `Vjc` wipes rows 0-1's contents and leaves a blank line in
7276        // their place (vim convention). Typing `X` lands on that blank
7277        // first line.
7278        assert_eq!(e.buffer().lines(), &["X".to_string(), "c".to_string()]);
7279    }
7280
7281    #[test]
7282    fn cc_leaves_blank_line() {
7283        let mut e = editor_with("a\nb\nc");
7284        e.jump_cursor(1, 0);
7285        run_keys(&mut e, "ccX<Esc>");
7286        assert_eq!(
7287            e.buffer().lines(),
7288            &["a".to_string(), "X".to_string(), "c".to_string()]
7289        );
7290    }
7291
7292    // ─── Scrolling ────────────────────────────────────────────────────────
7293
7294    // ─── WORD motions (W/B/E) ─────────────────────────────────────────────
7295
7296    #[test]
7297    fn big_w_skips_hyphens() {
7298        // `w` stops at `-`; `W` treats the whole `foo-bar` as one WORD.
7299        let mut e = editor_with("foo-bar baz");
7300        run_keys(&mut e, "W");
7301        assert_eq!(e.cursor().1, 8);
7302    }
7303
7304    #[test]
7305    fn big_w_crosses_lines() {
7306        let mut e = editor_with("foo-bar\nbaz-qux");
7307        run_keys(&mut e, "W");
7308        assert_eq!(e.cursor(), (1, 0));
7309    }
7310
7311    #[test]
7312    fn big_b_skips_hyphens() {
7313        let mut e = editor_with("foo-bar baz");
7314        e.jump_cursor(0, 9);
7315        run_keys(&mut e, "B");
7316        assert_eq!(e.cursor().1, 8);
7317        run_keys(&mut e, "B");
7318        assert_eq!(e.cursor().1, 0);
7319    }
7320
7321    #[test]
7322    fn big_e_jumps_to_big_word_end() {
7323        let mut e = editor_with("foo-bar baz");
7324        run_keys(&mut e, "E");
7325        assert_eq!(e.cursor().1, 6);
7326        run_keys(&mut e, "E");
7327        assert_eq!(e.cursor().1, 10);
7328    }
7329
7330    #[test]
7331    fn dw_with_big_word_variant() {
7332        // `dW` uses the WORD motion, so `foo-bar` deletes as a unit.
7333        let mut e = editor_with("foo-bar baz");
7334        run_keys(&mut e, "dW");
7335        assert_eq!(e.buffer().lines()[0], "baz");
7336    }
7337
7338    // ─── Insert-mode Ctrl shortcuts ──────────────────────────────────────
7339
7340    #[test]
7341    fn insert_ctrl_w_deletes_word_back() {
7342        let mut e = editor_with("");
7343        run_keys(&mut e, "i");
7344        for c in "hello world".chars() {
7345            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7346        }
7347        run_keys(&mut e, "<C-w>");
7348        assert_eq!(e.buffer().lines()[0], "hello ");
7349    }
7350
7351    #[test]
7352    fn insert_ctrl_w_at_col0_joins_with_prev_word() {
7353        // Vim with default `backspace=indent,eol,start`: Ctrl-W at the
7354        // start of a row joins to the previous line and deletes the
7355        // word now before the cursor.
7356        let mut e = editor_with("hello\nworld");
7357        e.jump_cursor(1, 0);
7358        run_keys(&mut e, "i");
7359        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7360        // "hello" was the only word on row 0; it gets deleted, leaving
7361        // "world" on a single line.
7362        assert_eq!(e.buffer().lines(), vec!["world".to_string()]);
7363        assert_eq!(e.cursor(), (0, 0));
7364    }
7365
7366    #[test]
7367    fn insert_ctrl_w_at_col0_keeps_prefix_words() {
7368        let mut e = editor_with("foo bar\nbaz");
7369        e.jump_cursor(1, 0);
7370        run_keys(&mut e, "i");
7371        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7372        // Joins lines, then deletes the trailing "bar" of the prev line.
7373        assert_eq!(e.buffer().lines(), vec!["foo baz".to_string()]);
7374        assert_eq!(e.cursor(), (0, 4));
7375    }
7376
7377    #[test]
7378    fn insert_ctrl_u_deletes_to_line_start() {
7379        let mut e = editor_with("");
7380        run_keys(&mut e, "i");
7381        for c in "hello world".chars() {
7382            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7383        }
7384        run_keys(&mut e, "<C-u>");
7385        assert_eq!(e.buffer().lines()[0], "");
7386    }
7387
7388    #[test]
7389    fn insert_ctrl_o_runs_one_normal_command() {
7390        let mut e = editor_with("hello world");
7391        // Enter insert, then Ctrl-o dw (delete a word while in insert).
7392        run_keys(&mut e, "A");
7393        assert_eq!(e.vim_mode(), VimMode::Insert);
7394        // Move cursor back to start of "hello" for the Ctrl-o dw.
7395        e.jump_cursor(0, 0);
7396        run_keys(&mut e, "<C-o>");
7397        assert_eq!(e.vim_mode(), VimMode::Normal);
7398        run_keys(&mut e, "dw");
7399        // After the command completes, back in insert.
7400        assert_eq!(e.vim_mode(), VimMode::Insert);
7401        assert_eq!(e.buffer().lines()[0], "world");
7402    }
7403
7404    // ─── Sticky column across vertical motion ────────────────────────────
7405
7406    #[test]
7407    fn j_through_empty_line_preserves_column() {
7408        let mut e = editor_with("hello world\n\nanother line");
7409        // Park cursor at col 6 on row 0.
7410        run_keys(&mut e, "llllll");
7411        assert_eq!(e.cursor(), (0, 6));
7412        // j into the empty line — cursor clamps to (1, 0) visually, but
7413        // sticky col stays at 6.
7414        run_keys(&mut e, "j");
7415        assert_eq!(e.cursor(), (1, 0));
7416        // j onto a longer row — sticky col restores us to col 6.
7417        run_keys(&mut e, "j");
7418        assert_eq!(e.cursor(), (2, 6));
7419    }
7420
7421    #[test]
7422    fn j_through_shorter_line_preserves_column() {
7423        let mut e = editor_with("hello world\nhi\nanother line");
7424        run_keys(&mut e, "lllllll"); // col 7
7425        run_keys(&mut e, "j"); // short line — clamps to col 1
7426        assert_eq!(e.cursor(), (1, 1));
7427        run_keys(&mut e, "j");
7428        assert_eq!(e.cursor(), (2, 7));
7429    }
7430
7431    #[test]
7432    fn esc_from_insert_sticky_matches_visible_cursor() {
7433        // Cursor at col 12, I (moves to col 4), type "X" (col 5), Esc
7434        // backs to col 4 — sticky must mirror that visible col so j
7435        // lands at col 4 of the next row, not col 5 or col 12.
7436        let mut e = editor_with("    this is a line\n    another one of a similar size");
7437        e.jump_cursor(0, 12);
7438        run_keys(&mut e, "I");
7439        assert_eq!(e.cursor(), (0, 4));
7440        run_keys(&mut e, "X<Esc>");
7441        assert_eq!(e.cursor(), (0, 4));
7442        run_keys(&mut e, "j");
7443        assert_eq!(e.cursor(), (1, 4));
7444    }
7445
7446    #[test]
7447    fn esc_from_insert_sticky_tracks_inserted_chars() {
7448        let mut e = editor_with("xxxxxxx\nyyyyyyy");
7449        run_keys(&mut e, "i");
7450        run_keys(&mut e, "abc<Esc>");
7451        assert_eq!(e.cursor(), (0, 2));
7452        run_keys(&mut e, "j");
7453        assert_eq!(e.cursor(), (1, 2));
7454    }
7455
7456    #[test]
7457    fn esc_from_insert_sticky_tracks_arrow_nav() {
7458        let mut e = editor_with("xxxxxx\nyyyyyy");
7459        run_keys(&mut e, "i");
7460        run_keys(&mut e, "abc");
7461        for _ in 0..2 {
7462            e.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
7463        }
7464        run_keys(&mut e, "<Esc>");
7465        assert_eq!(e.cursor(), (0, 0));
7466        run_keys(&mut e, "j");
7467        assert_eq!(e.cursor(), (1, 0));
7468    }
7469
7470    #[test]
7471    fn esc_from_insert_at_col_14_followed_by_j() {
7472        // User-reported regression: cursor at col 14, i, type "test "
7473        // (5 chars → col 19), Esc → col 18. j must land at col 18.
7474        let line = "x".repeat(30);
7475        let buf = format!("{line}\n{line}");
7476        let mut e = editor_with(&buf);
7477        e.jump_cursor(0, 14);
7478        run_keys(&mut e, "i");
7479        for c in "test ".chars() {
7480            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7481        }
7482        run_keys(&mut e, "<Esc>");
7483        assert_eq!(e.cursor(), (0, 18));
7484        run_keys(&mut e, "j");
7485        assert_eq!(e.cursor(), (1, 18));
7486    }
7487
7488    #[test]
7489    fn linewise_paste_resets_sticky_column() {
7490        // yy then p lands the cursor on the first non-blank of the
7491        // pasted line; the next j must not drag back to the old
7492        // sticky column.
7493        let mut e = editor_with("    hello\naaaaaaaa\nbye");
7494        run_keys(&mut e, "llllll"); // col 6, sticky = 6
7495        run_keys(&mut e, "yy");
7496        run_keys(&mut e, "j"); // into row 1 col 6
7497        run_keys(&mut e, "p"); // paste below row 1 — cursor on "    hello"
7498        // Cursor should be at (2, 4) — first non-blank of the pasted line.
7499        assert_eq!(e.cursor(), (2, 4));
7500        // j should then preserve col 4, not jump back to 6.
7501        run_keys(&mut e, "j");
7502        assert_eq!(e.cursor(), (3, 2));
7503    }
7504
7505    #[test]
7506    fn horizontal_motion_resyncs_sticky_column() {
7507        // Starting col 6 on row 0, go back to col 3, then down through
7508        // an empty row. The sticky col should be 3 (from the last `h`
7509        // sequence), not 6.
7510        let mut e = editor_with("hello world\n\nanother line");
7511        run_keys(&mut e, "llllll"); // col 6
7512        run_keys(&mut e, "hhh"); // col 3
7513        run_keys(&mut e, "jj");
7514        assert_eq!(e.cursor(), (2, 3));
7515    }
7516
7517    // ─── Visual block ────────────────────────────────────────────────────
7518
7519    #[test]
7520    fn ctrl_v_enters_visual_block() {
7521        let mut e = editor_with("aaa\nbbb\nccc");
7522        run_keys(&mut e, "<C-v>");
7523        assert_eq!(e.vim_mode(), VimMode::VisualBlock);
7524    }
7525
7526    #[test]
7527    fn visual_block_esc_returns_to_normal() {
7528        let mut e = editor_with("aaa\nbbb\nccc");
7529        run_keys(&mut e, "<C-v>");
7530        run_keys(&mut e, "<Esc>");
7531        assert_eq!(e.vim_mode(), VimMode::Normal);
7532    }
7533
7534    #[test]
7535    fn visual_exit_sets_lt_gt_marks() {
7536        // Vim sets `<` to the start and `>` to the end of the last visual
7537        // selection on every visual exit. Required for :'<,'> ex ranges.
7538        let mut e = editor_with("aaa\nbbb\nccc\nddd");
7539        // V<j><Esc> → selects rows 0..=1 in line-wise visual.
7540        run_keys(&mut e, "V");
7541        run_keys(&mut e, "j");
7542        run_keys(&mut e, "<Esc>");
7543        let lt = e.mark('<').expect("'<' mark must be set on visual exit");
7544        let gt = e.mark('>').expect("'>' mark must be set on visual exit");
7545        assert_eq!(lt.0, 0, "'< row should be the lower bound");
7546        assert_eq!(gt.0, 1, "'> row should be the upper bound");
7547    }
7548
7549    #[test]
7550    fn visual_exit_marks_use_lower_higher_order() {
7551        // Selecting upward (cursor < anchor) must still produce `<` = lower,
7552        // `>` = higher — vim's marks are position-ordered, not selection-
7553        // ordered.
7554        let mut e = editor_with("aaa\nbbb\nccc\nddd");
7555        run_keys(&mut e, "jjj"); // cursor at row 3
7556        run_keys(&mut e, "V");
7557        run_keys(&mut e, "k"); // anchor row 3, cursor row 2
7558        run_keys(&mut e, "<Esc>");
7559        let lt = e.mark('<').unwrap();
7560        let gt = e.mark('>').unwrap();
7561        assert_eq!(lt.0, 2);
7562        assert_eq!(gt.0, 3);
7563    }
7564
7565    #[test]
7566    fn visualline_exit_marks_snap_to_line_edges() {
7567        // VisualLine: `<` snaps to col 0, `>` snaps to last col of bot row.
7568        let mut e = editor_with("aaaaa\nbbbbb\ncc");
7569        run_keys(&mut e, "lll"); // cursor at row 0, col 3
7570        run_keys(&mut e, "V");
7571        run_keys(&mut e, "j"); // VisualLine over rows 0..=1
7572        run_keys(&mut e, "<Esc>");
7573        let lt = e.mark('<').unwrap();
7574        let gt = e.mark('>').unwrap();
7575        assert_eq!(lt, (0, 0), "'< should snap to (top_row, 0)");
7576        // Row 1 is "bbbbb" — last col is 4.
7577        assert_eq!(gt, (1, 4), "'> should snap to (bot_row, last_col)");
7578    }
7579
7580    #[test]
7581    fn visualblock_exit_marks_use_block_corners() {
7582        // VisualBlock with cursor moving left + down. Corners are not
7583        // tuple-ordered: top-left is (anchor_row, cursor_col), bottom-right
7584        // is (cursor_row, anchor_col). `<` must be top-left, `>` bottom-right.
7585        let mut e = editor_with("aaaaa\nbbbbb\nccccc");
7586        run_keys(&mut e, "llll"); // row 0, col 4
7587        run_keys(&mut e, "<C-v>");
7588        run_keys(&mut e, "j"); // row 1, col 4
7589        run_keys(&mut e, "hh"); // row 1, col 2
7590        run_keys(&mut e, "<Esc>");
7591        let lt = e.mark('<').unwrap();
7592        let gt = e.mark('>').unwrap();
7593        // anchor=(0,4), cursor=(1,2) → corners are (0,2) and (1,4).
7594        assert_eq!(lt, (0, 2), "'< should be top-left corner");
7595        assert_eq!(gt, (1, 4), "'> should be bottom-right corner");
7596    }
7597
7598    #[test]
7599    fn visual_block_delete_removes_column_range() {
7600        let mut e = editor_with("hello\nworld\nhappy");
7601        // Move off col 0 first so the block starts mid-row.
7602        run_keys(&mut e, "l");
7603        run_keys(&mut e, "<C-v>");
7604        run_keys(&mut e, "jj");
7605        run_keys(&mut e, "ll");
7606        run_keys(&mut e, "d");
7607        // Deletes cols 1-3 on every row — "ell" / "orl" / "app".
7608        assert_eq!(
7609            e.buffer().lines(),
7610            &["ho".to_string(), "wd".to_string(), "hy".to_string()]
7611        );
7612    }
7613
7614    #[test]
7615    fn visual_block_yank_joins_with_newlines() {
7616        let mut e = editor_with("hello\nworld\nhappy");
7617        run_keys(&mut e, "<C-v>");
7618        run_keys(&mut e, "jj");
7619        run_keys(&mut e, "ll");
7620        run_keys(&mut e, "y");
7621        assert_eq!(e.last_yank.as_deref(), Some("hel\nwor\nhap"));
7622    }
7623
7624    #[test]
7625    fn visual_block_replace_fills_block() {
7626        let mut e = editor_with("hello\nworld\nhappy");
7627        run_keys(&mut e, "<C-v>");
7628        run_keys(&mut e, "jj");
7629        run_keys(&mut e, "ll");
7630        run_keys(&mut e, "rx");
7631        assert_eq!(
7632            e.buffer().lines(),
7633            &[
7634                "xxxlo".to_string(),
7635                "xxxld".to_string(),
7636                "xxxpy".to_string()
7637            ]
7638        );
7639    }
7640
7641    #[test]
7642    fn visual_block_insert_repeats_across_rows() {
7643        let mut e = editor_with("hello\nworld\nhappy");
7644        run_keys(&mut e, "<C-v>");
7645        run_keys(&mut e, "jj");
7646        run_keys(&mut e, "I");
7647        run_keys(&mut e, "# <Esc>");
7648        assert_eq!(
7649            e.buffer().lines(),
7650            &[
7651                "# hello".to_string(),
7652                "# world".to_string(),
7653                "# happy".to_string()
7654            ]
7655        );
7656    }
7657
7658    #[test]
7659    fn block_highlight_returns_none_outside_block_mode() {
7660        let mut e = editor_with("abc");
7661        assert!(e.block_highlight().is_none());
7662        run_keys(&mut e, "v");
7663        assert!(e.block_highlight().is_none());
7664        run_keys(&mut e, "<Esc>V");
7665        assert!(e.block_highlight().is_none());
7666    }
7667
7668    #[test]
7669    fn block_highlight_bounds_track_anchor_and_cursor() {
7670        let mut e = editor_with("aaaa\nbbbb\ncccc");
7671        run_keys(&mut e, "ll"); // cursor (0, 2)
7672        run_keys(&mut e, "<C-v>");
7673        run_keys(&mut e, "jh"); // cursor (1, 1)
7674        // anchor = (0, 2), cursor = (1, 1) → top=0 bot=1 left=1 right=2.
7675        assert_eq!(e.block_highlight(), Some((0, 1, 1, 2)));
7676    }
7677
7678    #[test]
7679    fn visual_block_delete_handles_short_lines() {
7680        // Middle row is shorter than the block's right column.
7681        let mut e = editor_with("hello\nhi\nworld");
7682        run_keys(&mut e, "l"); // col 1
7683        run_keys(&mut e, "<C-v>");
7684        run_keys(&mut e, "jjll"); // cursor (2, 3)
7685        run_keys(&mut e, "d");
7686        // Row 0: delete cols 1-3 ("ell") → "ho".
7687        // Row 1: only 2 chars ("hi"); block starts at col 1, so just "i"
7688        //        gets removed → "h".
7689        // Row 2: delete cols 1-3 ("orl") → "wd".
7690        assert_eq!(
7691            e.buffer().lines(),
7692            &["ho".to_string(), "h".to_string(), "wd".to_string()]
7693        );
7694    }
7695
7696    #[test]
7697    fn visual_block_yank_pads_short_lines_with_empties() {
7698        let mut e = editor_with("hello\nhi\nworld");
7699        run_keys(&mut e, "l");
7700        run_keys(&mut e, "<C-v>");
7701        run_keys(&mut e, "jjll");
7702        run_keys(&mut e, "y");
7703        // Row 0 chars 1-3 = "ell"; row 1 chars 1- (only "i"); row 2 "orl".
7704        assert_eq!(e.last_yank.as_deref(), Some("ell\ni\norl"));
7705    }
7706
7707    #[test]
7708    fn visual_block_replace_skips_past_eol() {
7709        // Block extends past the end of every row in column range;
7710        // replace should leave lines shorter than `left` untouched.
7711        let mut e = editor_with("ab\ncd\nef");
7712        // Put cursor at col 1 (last char), extend block 5 columns right.
7713        run_keys(&mut e, "l");
7714        run_keys(&mut e, "<C-v>");
7715        run_keys(&mut e, "jjllllll");
7716        run_keys(&mut e, "rX");
7717        // Every row had only col 0..=1; block covers col 1..=7 → only
7718        // col 1 is in range on each row, so just that cell changes.
7719        assert_eq!(
7720            e.buffer().lines(),
7721            &["aX".to_string(), "cX".to_string(), "eX".to_string()]
7722        );
7723    }
7724
7725    #[test]
7726    fn visual_block_with_empty_line_in_middle() {
7727        let mut e = editor_with("abcd\n\nefgh");
7728        run_keys(&mut e, "<C-v>");
7729        run_keys(&mut e, "jjll"); // cursor (2, 2)
7730        run_keys(&mut e, "d");
7731        // Row 0 cols 0-2 removed → "d". Row 1 empty → untouched.
7732        // Row 2 cols 0-2 removed → "h".
7733        assert_eq!(
7734            e.buffer().lines(),
7735            &["d".to_string(), "".to_string(), "h".to_string()]
7736        );
7737    }
7738
7739    #[test]
7740    fn block_insert_pads_empty_lines_to_block_column() {
7741        // Middle line is empty; block I at column 3 should pad the empty
7742        // line with spaces so the inserted text lines up.
7743        let mut e = editor_with("this is a line\n\nthis is a line");
7744        e.jump_cursor(0, 3);
7745        run_keys(&mut e, "<C-v>");
7746        run_keys(&mut e, "jj");
7747        run_keys(&mut e, "I");
7748        run_keys(&mut e, "XX<Esc>");
7749        assert_eq!(
7750            e.buffer().lines(),
7751            &[
7752                "thiXXs is a line".to_string(),
7753                "   XX".to_string(),
7754                "thiXXs is a line".to_string()
7755            ]
7756        );
7757    }
7758
7759    #[test]
7760    fn block_insert_pads_short_lines_to_block_column() {
7761        let mut e = editor_with("aaaaa\nbb\naaaaa");
7762        e.jump_cursor(0, 3);
7763        run_keys(&mut e, "<C-v>");
7764        run_keys(&mut e, "jj");
7765        run_keys(&mut e, "I");
7766        run_keys(&mut e, "Y<Esc>");
7767        // Row 1 "bb" is shorter than col 3 — pad with one space then Y.
7768        assert_eq!(
7769            e.buffer().lines(),
7770            &[
7771                "aaaYaa".to_string(),
7772                "bb Y".to_string(),
7773                "aaaYaa".to_string()
7774            ]
7775        );
7776    }
7777
7778    #[test]
7779    fn visual_block_append_repeats_across_rows() {
7780        let mut e = editor_with("foo\nbar\nbaz");
7781        run_keys(&mut e, "<C-v>");
7782        run_keys(&mut e, "jj");
7783        // Single-column block (anchor col = cursor col = 0); `A` appends
7784        // after column 0 on every row.
7785        run_keys(&mut e, "A");
7786        run_keys(&mut e, "!<Esc>");
7787        assert_eq!(
7788            e.buffer().lines(),
7789            &["f!oo".to_string(), "b!ar".to_string(), "b!az".to_string()]
7790        );
7791    }
7792
7793    // ─── `/` / `?` search prompt ─────────────────────────────────────────
7794
7795    #[test]
7796    fn slash_opens_forward_search_prompt() {
7797        let mut e = editor_with("hello world");
7798        run_keys(&mut e, "/");
7799        let p = e.search_prompt().expect("prompt should be active");
7800        assert!(p.text.is_empty());
7801        assert!(p.forward);
7802    }
7803
7804    #[test]
7805    fn question_opens_backward_search_prompt() {
7806        let mut e = editor_with("hello world");
7807        run_keys(&mut e, "?");
7808        let p = e.search_prompt().expect("prompt should be active");
7809        assert!(!p.forward);
7810    }
7811
7812    #[test]
7813    fn search_prompt_typing_updates_pattern_live() {
7814        let mut e = editor_with("foo bar\nbaz");
7815        run_keys(&mut e, "/bar");
7816        assert_eq!(e.search_prompt().unwrap().text, "bar");
7817        // Pattern set on the engine search state for live highlight.
7818        assert!(e.search_state().pattern.is_some());
7819    }
7820
7821    #[test]
7822    fn search_prompt_backspace_and_enter() {
7823        let mut e = editor_with("hello world\nagain");
7824        run_keys(&mut e, "/worlx");
7825        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
7826        assert_eq!(e.search_prompt().unwrap().text, "worl");
7827        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7828        // Prompt closed, last_search set, cursor advanced to match.
7829        assert!(e.search_prompt().is_none());
7830        assert_eq!(e.last_search(), Some("worl"));
7831        assert_eq!(e.cursor(), (0, 6));
7832    }
7833
7834    #[test]
7835    fn empty_search_prompt_enter_repeats_last_search() {
7836        let mut e = editor_with("foo bar foo baz foo");
7837        run_keys(&mut e, "/foo");
7838        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7839        assert_eq!(e.cursor().1, 8);
7840        // Empty `/<CR>` should advance to the next match, not clear last_search.
7841        run_keys(&mut e, "/");
7842        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7843        assert_eq!(e.cursor().1, 16);
7844        assert_eq!(e.last_search(), Some("foo"));
7845    }
7846
7847    #[test]
7848    fn search_history_records_committed_patterns() {
7849        let mut e = editor_with("alpha beta gamma");
7850        run_keys(&mut e, "/alpha<CR>");
7851        run_keys(&mut e, "/beta<CR>");
7852        // Newest entry at the back.
7853        let history = e.vim.search_history.clone();
7854        assert_eq!(history, vec!["alpha", "beta"]);
7855    }
7856
7857    #[test]
7858    fn search_history_dedupes_consecutive_repeats() {
7859        let mut e = editor_with("foo bar foo");
7860        run_keys(&mut e, "/foo<CR>");
7861        run_keys(&mut e, "/foo<CR>");
7862        run_keys(&mut e, "/bar<CR>");
7863        run_keys(&mut e, "/bar<CR>");
7864        // Two distinct entries; the duplicates collapsed.
7865        assert_eq!(e.vim.search_history.clone(), vec!["foo", "bar"]);
7866    }
7867
7868    #[test]
7869    fn ctrl_p_walks_history_backward() {
7870        let mut e = editor_with("alpha beta gamma");
7871        run_keys(&mut e, "/alpha<CR>");
7872        run_keys(&mut e, "/beta<CR>");
7873        // Open a fresh prompt; Ctrl-P pulls in the newest entry.
7874        run_keys(&mut e, "/");
7875        assert_eq!(e.search_prompt().unwrap().text, "");
7876        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7877        assert_eq!(e.search_prompt().unwrap().text, "beta");
7878        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7879        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7880        // At the oldest entry; further Ctrl-P is a no-op.
7881        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7882        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7883    }
7884
7885    #[test]
7886    fn ctrl_n_walks_history_forward_after_ctrl_p() {
7887        let mut e = editor_with("a b c");
7888        run_keys(&mut e, "/a<CR>");
7889        run_keys(&mut e, "/b<CR>");
7890        run_keys(&mut e, "/c<CR>");
7891        run_keys(&mut e, "/");
7892        // Walk back to "a", then forward again.
7893        for _ in 0..3 {
7894            e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7895        }
7896        assert_eq!(e.search_prompt().unwrap().text, "a");
7897        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7898        assert_eq!(e.search_prompt().unwrap().text, "b");
7899        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7900        assert_eq!(e.search_prompt().unwrap().text, "c");
7901        // Past the newest — stays at "c".
7902        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7903        assert_eq!(e.search_prompt().unwrap().text, "c");
7904    }
7905
7906    #[test]
7907    fn typing_after_history_walk_resets_cursor() {
7908        let mut e = editor_with("foo");
7909        run_keys(&mut e, "/foo<CR>");
7910        run_keys(&mut e, "/");
7911        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7912        assert_eq!(e.search_prompt().unwrap().text, "foo");
7913        // User edits — append a char. Next Ctrl-P should restart from
7914        // the newest entry, not continue walking older.
7915        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
7916        assert_eq!(e.search_prompt().unwrap().text, "foox");
7917        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7918        assert_eq!(e.search_prompt().unwrap().text, "foo");
7919    }
7920
7921    #[test]
7922    fn empty_backward_search_prompt_enter_repeats_last_search() {
7923        let mut e = editor_with("foo bar foo baz foo");
7924        // Forward to col 8, then `?<CR>` should walk backward to col 0.
7925        run_keys(&mut e, "/foo");
7926        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7927        assert_eq!(e.cursor().1, 8);
7928        run_keys(&mut e, "?");
7929        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7930        assert_eq!(e.cursor().1, 0);
7931        assert_eq!(e.last_search(), Some("foo"));
7932    }
7933
7934    #[test]
7935    fn search_prompt_esc_cancels_but_keeps_last_search() {
7936        let mut e = editor_with("foo bar\nbaz");
7937        run_keys(&mut e, "/bar");
7938        e.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
7939        assert!(e.search_prompt().is_none());
7940        assert_eq!(e.last_search(), Some("bar"));
7941    }
7942
7943    #[test]
7944    fn search_then_n_and_shift_n_navigate() {
7945        let mut e = editor_with("foo bar foo baz foo");
7946        run_keys(&mut e, "/foo");
7947        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7948        // `/foo` + Enter jumps forward; we land on the next match after col 0.
7949        assert_eq!(e.cursor().1, 8);
7950        run_keys(&mut e, "n");
7951        assert_eq!(e.cursor().1, 16);
7952        run_keys(&mut e, "N");
7953        assert_eq!(e.cursor().1, 8);
7954    }
7955
7956    #[test]
7957    fn question_mark_searches_backward_on_enter() {
7958        let mut e = editor_with("foo bar foo baz");
7959        e.jump_cursor(0, 10);
7960        run_keys(&mut e, "?foo");
7961        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7962        // Cursor jumps backward to the closest match before col 10.
7963        assert_eq!(e.cursor(), (0, 8));
7964    }
7965
7966    // ─── P6 quick wins (Y, gJ, ge / gE) ──────────────────────────────────
7967
7968    #[test]
7969    fn big_y_yanks_to_end_of_line() {
7970        let mut e = editor_with("hello world");
7971        e.jump_cursor(0, 6);
7972        run_keys(&mut e, "Y");
7973        assert_eq!(e.last_yank.as_deref(), Some("world"));
7974    }
7975
7976    #[test]
7977    fn big_y_from_line_start_yanks_full_line() {
7978        let mut e = editor_with("hello world");
7979        run_keys(&mut e, "Y");
7980        assert_eq!(e.last_yank.as_deref(), Some("hello world"));
7981    }
7982
7983    #[test]
7984    fn gj_joins_without_inserting_space() {
7985        let mut e = editor_with("hello\n    world");
7986        run_keys(&mut e, "gJ");
7987        // No space inserted, leading whitespace preserved.
7988        assert_eq!(e.buffer().lines(), &["hello    world".to_string()]);
7989    }
7990
7991    #[test]
7992    fn gj_noop_on_last_line() {
7993        let mut e = editor_with("only");
7994        run_keys(&mut e, "gJ");
7995        assert_eq!(e.buffer().lines(), &["only".to_string()]);
7996    }
7997
7998    #[test]
7999    fn ge_jumps_to_previous_word_end() {
8000        let mut e = editor_with("foo bar baz");
8001        e.jump_cursor(0, 5);
8002        run_keys(&mut e, "ge");
8003        assert_eq!(e.cursor(), (0, 2));
8004    }
8005
8006    #[test]
8007    fn ge_respects_word_class() {
8008        // Small-word `ge` treats `-` as its own word, so from mid-"bar"
8009        // it lands on the `-` rather than end of "foo".
8010        let mut e = editor_with("foo-bar baz");
8011        e.jump_cursor(0, 5);
8012        run_keys(&mut e, "ge");
8013        assert_eq!(e.cursor(), (0, 3));
8014    }
8015
8016    #[test]
8017    fn big_ge_treats_hyphens_as_part_of_word() {
8018        // `gE` uses WORD (whitespace-delimited) semantics so it skips
8019        // over the `-` and lands on the end of "foo-bar".
8020        let mut e = editor_with("foo-bar baz");
8021        e.jump_cursor(0, 10);
8022        run_keys(&mut e, "gE");
8023        assert_eq!(e.cursor(), (0, 6));
8024    }
8025
8026    #[test]
8027    fn ge_crosses_line_boundary() {
8028        let mut e = editor_with("foo\nbar");
8029        e.jump_cursor(1, 0);
8030        run_keys(&mut e, "ge");
8031        assert_eq!(e.cursor(), (0, 2));
8032    }
8033
8034    #[test]
8035    fn dge_deletes_to_end_of_previous_word() {
8036        let mut e = editor_with("foo bar baz");
8037        e.jump_cursor(0, 8);
8038        // d + ge from 'b' of "baz": range is ge → col 6 ('r' of bar),
8039        // inclusive, so cols 6-8 ("r b") are cut.
8040        run_keys(&mut e, "dge");
8041        assert_eq!(e.buffer().lines()[0], "foo baaz");
8042    }
8043
8044    #[test]
8045    fn ctrl_scroll_keys_do_not_panic() {
8046        // Viewport-less test: just exercise the code paths so a regression
8047        // in the scroll dispatch surfaces as a panic or assertion failure.
8048        let mut e = editor_with(
8049            (0..50)
8050                .map(|i| format!("line{i}"))
8051                .collect::<Vec<_>>()
8052                .join("\n")
8053                .as_str(),
8054        );
8055        run_keys(&mut e, "<C-f>");
8056        run_keys(&mut e, "<C-b>");
8057        // No explicit assert beyond "didn't panic".
8058        assert!(!e.buffer().lines().is_empty());
8059    }
8060
8061    /// Regression: arrow-navigation during a count-insert session must
8062    /// not pull unrelated rows into the "inserted" replay string.
8063    /// Before the fix, `before_lines` only snapshotted the entry row,
8064    /// so the diff at Esc spuriously saw the navigated-over row as
8065    /// part of the insert — count-replay then duplicated cross-row
8066    /// content across the buffer.
8067    #[test]
8068    fn count_insert_with_arrow_nav_does_not_leak_rows() {
8069        let mut e = Editor::new(
8070            hjkl_buffer::Buffer::new(),
8071            crate::types::DefaultHost::new(),
8072            crate::types::Options::default(),
8073        );
8074        e.set_content("row0\nrow1\nrow2");
8075        // `3i`, type X, arrow down, Esc.
8076        run_keys(&mut e, "3iX<Down><Esc>");
8077        // Row 0 keeps the originally-typed X.
8078        assert!(e.buffer().lines()[0].contains('X'));
8079        // Row 1 must not contain a fragment of row 0 ("row0") — that
8080        // was the buggy leak from the before-diff window.
8081        assert!(
8082            !e.buffer().lines()[1].contains("row0"),
8083            "row1 leaked row0 contents: {:?}",
8084            e.buffer().lines()[1]
8085        );
8086        // Buffer stays the same number of rows — no extra lines
8087        // injected by a multi-line "inserted" replay.
8088        assert_eq!(e.buffer().lines().len(), 3);
8089    }
8090
8091    // ─── Viewport scroll / jump tests ─────────────────────────────────
8092
8093    fn editor_with_rows(n: usize, viewport: u16) -> Editor {
8094        let mut e = Editor::new(
8095            hjkl_buffer::Buffer::new(),
8096            crate::types::DefaultHost::new(),
8097            crate::types::Options::default(),
8098        );
8099        let body = (0..n)
8100            .map(|i| format!("  line{}", i))
8101            .collect::<Vec<_>>()
8102            .join("\n");
8103        e.set_content(&body);
8104        e.set_viewport_height(viewport);
8105        e
8106    }
8107
8108    #[test]
8109    fn ctrl_d_moves_cursor_half_page_down() {
8110        let mut e = editor_with_rows(100, 20);
8111        run_keys(&mut e, "<C-d>");
8112        assert_eq!(e.cursor().0, 10);
8113    }
8114
8115    fn editor_with_wrap_lines(lines: &[&str], viewport: u16, text_width: u16) -> Editor {
8116        let mut e = Editor::new(
8117            hjkl_buffer::Buffer::new(),
8118            crate::types::DefaultHost::new(),
8119            crate::types::Options::default(),
8120        );
8121        e.set_content(&lines.join("\n"));
8122        e.set_viewport_height(viewport);
8123        let v = e.host_mut().viewport_mut();
8124        v.height = viewport;
8125        v.width = text_width;
8126        v.text_width = text_width;
8127        v.wrap = hjkl_buffer::Wrap::Char;
8128        e.settings_mut().wrap = hjkl_buffer::Wrap::Char;
8129        e
8130    }
8131
8132    #[test]
8133    fn scrolloff_wrap_keeps_cursor_off_bottom_edge() {
8134        // 10 doc rows, each wraps to 3 segments → 30 screen rows.
8135        // Viewport height 12, margin = SCROLLOFF.min(11/2) = 5,
8136        // max bottom = 11 - 5 = 6. Plenty of headroom past row 4.
8137        let lines = ["aaaabbbbcccc"; 10];
8138        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8139        e.jump_cursor(4, 0);
8140        e.ensure_cursor_in_scrolloff();
8141        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8142        assert!(csr <= 6, "csr={csr}");
8143    }
8144
8145    #[test]
8146    fn scrolloff_wrap_keeps_cursor_off_top_edge() {
8147        let lines = ["aaaabbbbcccc"; 10];
8148        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8149        // Force top down then bring cursor up so the top-edge margin
8150        // path runs.
8151        e.jump_cursor(7, 0);
8152        e.ensure_cursor_in_scrolloff();
8153        e.jump_cursor(2, 0);
8154        e.ensure_cursor_in_scrolloff();
8155        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8156        // SCROLLOFF.min((height - 1) / 2) = 5.min(5) = 5.
8157        assert!(csr >= 5, "csr={csr}");
8158    }
8159
8160    #[test]
8161    fn scrolloff_wrap_clamps_top_at_buffer_end() {
8162        let lines = ["aaaabbbbcccc"; 5];
8163        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8164        e.jump_cursor(4, 11);
8165        e.ensure_cursor_in_scrolloff();
8166        // max_top_for_height(12) on 15 screen rows: row 4 (3 segs) +
8167        // row 3 (3 segs) + row 2 (3 segs) + row 1 (3 segs) = 12 —
8168        // max_top = row 1. Margin can't be honoured at EOF (matches
8169        // vim's behaviour — scrolloff is a soft constraint).
8170        let top = e.host().viewport().top_row;
8171        assert_eq!(top, 1);
8172    }
8173
8174    #[test]
8175    fn ctrl_u_moves_cursor_half_page_up() {
8176        let mut e = editor_with_rows(100, 20);
8177        e.jump_cursor(50, 0);
8178        run_keys(&mut e, "<C-u>");
8179        assert_eq!(e.cursor().0, 40);
8180    }
8181
8182    #[test]
8183    fn ctrl_f_moves_cursor_full_page_down() {
8184        let mut e = editor_with_rows(100, 20);
8185        run_keys(&mut e, "<C-f>");
8186        // One full page ≈ h - 2 (overlap).
8187        assert_eq!(e.cursor().0, 18);
8188    }
8189
8190    #[test]
8191    fn ctrl_b_moves_cursor_full_page_up() {
8192        let mut e = editor_with_rows(100, 20);
8193        e.jump_cursor(50, 0);
8194        run_keys(&mut e, "<C-b>");
8195        assert_eq!(e.cursor().0, 32);
8196    }
8197
8198    #[test]
8199    fn ctrl_d_lands_on_first_non_blank() {
8200        let mut e = editor_with_rows(100, 20);
8201        run_keys(&mut e, "<C-d>");
8202        // "  line10" — first non-blank is col 2.
8203        assert_eq!(e.cursor().1, 2);
8204    }
8205
8206    #[test]
8207    fn ctrl_d_clamps_at_end_of_buffer() {
8208        let mut e = editor_with_rows(5, 20);
8209        run_keys(&mut e, "<C-d>");
8210        assert_eq!(e.cursor().0, 4);
8211    }
8212
8213    #[test]
8214    fn capital_h_jumps_to_viewport_top() {
8215        let mut e = editor_with_rows(100, 10);
8216        e.jump_cursor(50, 0);
8217        e.set_viewport_top(45);
8218        let top = e.host().viewport().top_row;
8219        run_keys(&mut e, "H");
8220        assert_eq!(e.cursor().0, top);
8221        assert_eq!(e.cursor().1, 2);
8222    }
8223
8224    #[test]
8225    fn capital_l_jumps_to_viewport_bottom() {
8226        let mut e = editor_with_rows(100, 10);
8227        e.jump_cursor(50, 0);
8228        e.set_viewport_top(45);
8229        let top = e.host().viewport().top_row;
8230        run_keys(&mut e, "L");
8231        assert_eq!(e.cursor().0, top + 9);
8232    }
8233
8234    #[test]
8235    fn capital_m_jumps_to_viewport_middle() {
8236        let mut e = editor_with_rows(100, 10);
8237        e.jump_cursor(50, 0);
8238        e.set_viewport_top(45);
8239        let top = e.host().viewport().top_row;
8240        run_keys(&mut e, "M");
8241        // 10-row viewport: middle is top + 4.
8242        assert_eq!(e.cursor().0, top + 4);
8243    }
8244
8245    #[test]
8246    fn g_capital_m_lands_at_line_midpoint() {
8247        let mut e = editor_with("hello world!"); // 12 chars
8248        run_keys(&mut e, "gM");
8249        // floor(12 / 2) = 6.
8250        assert_eq!(e.cursor(), (0, 6));
8251    }
8252
8253    #[test]
8254    fn g_capital_m_on_empty_line_stays_at_zero() {
8255        let mut e = editor_with("");
8256        run_keys(&mut e, "gM");
8257        assert_eq!(e.cursor(), (0, 0));
8258    }
8259
8260    #[test]
8261    fn g_capital_m_uses_current_line_only() {
8262        // Each line's midpoint is independent of others.
8263        let mut e = editor_with("a\nlonglongline"); // line 1: 12 chars
8264        e.jump_cursor(1, 0);
8265        run_keys(&mut e, "gM");
8266        assert_eq!(e.cursor(), (1, 6));
8267    }
8268
8269    #[test]
8270    fn capital_h_count_offsets_from_top() {
8271        let mut e = editor_with_rows(100, 10);
8272        e.jump_cursor(50, 0);
8273        e.set_viewport_top(45);
8274        let top = e.host().viewport().top_row;
8275        run_keys(&mut e, "3H");
8276        assert_eq!(e.cursor().0, top + 2);
8277    }
8278
8279    // ─── Jumplist tests ───────────────────────────────────────────────
8280
8281    #[test]
8282    fn ctrl_o_returns_to_pre_g_position() {
8283        let mut e = editor_with_rows(50, 20);
8284        e.jump_cursor(5, 2);
8285        run_keys(&mut e, "G");
8286        assert_eq!(e.cursor().0, 49);
8287        run_keys(&mut e, "<C-o>");
8288        assert_eq!(e.cursor(), (5, 2));
8289    }
8290
8291    #[test]
8292    fn ctrl_i_redoes_jump_after_ctrl_o() {
8293        let mut e = editor_with_rows(50, 20);
8294        e.jump_cursor(5, 2);
8295        run_keys(&mut e, "G");
8296        let post = e.cursor();
8297        run_keys(&mut e, "<C-o>");
8298        run_keys(&mut e, "<C-i>");
8299        assert_eq!(e.cursor(), post);
8300    }
8301
8302    #[test]
8303    fn new_jump_clears_forward_stack() {
8304        let mut e = editor_with_rows(50, 20);
8305        e.jump_cursor(5, 2);
8306        run_keys(&mut e, "G");
8307        run_keys(&mut e, "<C-o>");
8308        run_keys(&mut e, "gg");
8309        run_keys(&mut e, "<C-i>");
8310        assert_eq!(e.cursor().0, 0);
8311    }
8312
8313    #[test]
8314    fn ctrl_o_on_empty_stack_is_noop() {
8315        let mut e = editor_with_rows(10, 20);
8316        e.jump_cursor(3, 1);
8317        run_keys(&mut e, "<C-o>");
8318        assert_eq!(e.cursor(), (3, 1));
8319    }
8320
8321    #[test]
8322    fn asterisk_search_pushes_jump() {
8323        let mut e = editor_with("foo bar\nbaz foo end");
8324        e.jump_cursor(0, 0);
8325        run_keys(&mut e, "*");
8326        let after = e.cursor();
8327        assert_ne!(after, (0, 0));
8328        run_keys(&mut e, "<C-o>");
8329        assert_eq!(e.cursor(), (0, 0));
8330    }
8331
8332    #[test]
8333    fn h_viewport_jump_is_recorded() {
8334        let mut e = editor_with_rows(100, 10);
8335        e.jump_cursor(50, 0);
8336        e.set_viewport_top(45);
8337        let pre = e.cursor();
8338        run_keys(&mut e, "H");
8339        assert_ne!(e.cursor(), pre);
8340        run_keys(&mut e, "<C-o>");
8341        assert_eq!(e.cursor(), pre);
8342    }
8343
8344    #[test]
8345    fn j_k_motion_does_not_push_jump() {
8346        let mut e = editor_with_rows(50, 20);
8347        e.jump_cursor(5, 0);
8348        run_keys(&mut e, "jjj");
8349        run_keys(&mut e, "<C-o>");
8350        assert_eq!(e.cursor().0, 8);
8351    }
8352
8353    #[test]
8354    fn jumplist_caps_at_100() {
8355        let mut e = editor_with_rows(200, 20);
8356        for i in 0..101 {
8357            e.jump_cursor(i, 0);
8358            run_keys(&mut e, "G");
8359        }
8360        assert!(e.vim.jump_back.len() <= 100);
8361    }
8362
8363    #[test]
8364    fn tab_acts_as_ctrl_i() {
8365        let mut e = editor_with_rows(50, 20);
8366        e.jump_cursor(5, 2);
8367        run_keys(&mut e, "G");
8368        let post = e.cursor();
8369        run_keys(&mut e, "<C-o>");
8370        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8371        assert_eq!(e.cursor(), post);
8372    }
8373
8374    // ─── Mark tests ───────────────────────────────────────────────────
8375
8376    #[test]
8377    fn ma_then_backtick_a_jumps_exact() {
8378        let mut e = editor_with_rows(50, 20);
8379        e.jump_cursor(5, 3);
8380        run_keys(&mut e, "ma");
8381        e.jump_cursor(20, 0);
8382        run_keys(&mut e, "`a");
8383        assert_eq!(e.cursor(), (5, 3));
8384    }
8385
8386    #[test]
8387    fn ma_then_apostrophe_a_lands_on_first_non_blank() {
8388        let mut e = editor_with_rows(50, 20);
8389        // "  line5" — first non-blank is col 2.
8390        e.jump_cursor(5, 6);
8391        run_keys(&mut e, "ma");
8392        e.jump_cursor(30, 4);
8393        run_keys(&mut e, "'a");
8394        assert_eq!(e.cursor(), (5, 2));
8395    }
8396
8397    #[test]
8398    fn goto_mark_pushes_jumplist() {
8399        let mut e = editor_with_rows(50, 20);
8400        e.jump_cursor(10, 2);
8401        run_keys(&mut e, "mz");
8402        e.jump_cursor(3, 0);
8403        run_keys(&mut e, "`z");
8404        assert_eq!(e.cursor(), (10, 2));
8405        run_keys(&mut e, "<C-o>");
8406        assert_eq!(e.cursor(), (3, 0));
8407    }
8408
8409    #[test]
8410    fn goto_missing_mark_is_noop() {
8411        let mut e = editor_with_rows(50, 20);
8412        e.jump_cursor(3, 1);
8413        run_keys(&mut e, "`q");
8414        assert_eq!(e.cursor(), (3, 1));
8415    }
8416
8417    #[test]
8418    fn uppercase_mark_stored_under_uppercase_key() {
8419        let mut e = editor_with_rows(50, 20);
8420        e.jump_cursor(5, 3);
8421        run_keys(&mut e, "mA");
8422        // 0.0.36: uppercase marks land in the unified `Editor::marks`
8423        // map under the uppercase key — not under 'a'.
8424        assert_eq!(e.mark('A'), Some((5, 3)));
8425        assert!(e.mark('a').is_none());
8426    }
8427
8428    #[test]
8429    fn mark_survives_document_shrink_via_clamp() {
8430        let mut e = editor_with_rows(50, 20);
8431        e.jump_cursor(40, 4);
8432        run_keys(&mut e, "mx");
8433        // Shrink the buffer to 10 rows.
8434        e.set_content("a\nb\nc\nd\ne");
8435        run_keys(&mut e, "`x");
8436        // Mark clamped to last row, col 0 (short line).
8437        let (r, _) = e.cursor();
8438        assert!(r <= 4);
8439    }
8440
8441    #[test]
8442    fn g_semicolon_walks_back_through_edits() {
8443        let mut e = editor_with("alpha\nbeta\ngamma");
8444        // Two distinct edits — cells (0, 0) → InsertChar lands cursor
8445        // at (0, 1), (2, 0) → (2, 1).
8446        e.jump_cursor(0, 0);
8447        run_keys(&mut e, "iX<Esc>");
8448        e.jump_cursor(2, 0);
8449        run_keys(&mut e, "iY<Esc>");
8450        // First g; lands on the most recent entry's exact cell.
8451        run_keys(&mut e, "g;");
8452        assert_eq!(e.cursor(), (2, 1));
8453        // Second g; walks to the older entry.
8454        run_keys(&mut e, "g;");
8455        assert_eq!(e.cursor(), (0, 1));
8456        // Past the oldest — no-op.
8457        run_keys(&mut e, "g;");
8458        assert_eq!(e.cursor(), (0, 1));
8459    }
8460
8461    #[test]
8462    fn g_comma_walks_forward_after_g_semicolon() {
8463        let mut e = editor_with("a\nb\nc");
8464        e.jump_cursor(0, 0);
8465        run_keys(&mut e, "iX<Esc>");
8466        e.jump_cursor(2, 0);
8467        run_keys(&mut e, "iY<Esc>");
8468        run_keys(&mut e, "g;");
8469        run_keys(&mut e, "g;");
8470        assert_eq!(e.cursor(), (0, 1));
8471        run_keys(&mut e, "g,");
8472        assert_eq!(e.cursor(), (2, 1));
8473    }
8474
8475    #[test]
8476    fn new_edit_during_walk_trims_forward_entries() {
8477        let mut e = editor_with("a\nb\nc\nd");
8478        e.jump_cursor(0, 0);
8479        run_keys(&mut e, "iX<Esc>"); // entry 0 → (0, 1)
8480        e.jump_cursor(2, 0);
8481        run_keys(&mut e, "iY<Esc>"); // entry 1 → (2, 1)
8482        // Walk back twice to land on entry 0.
8483        run_keys(&mut e, "g;");
8484        run_keys(&mut e, "g;");
8485        assert_eq!(e.cursor(), (0, 1));
8486        // New edit while walking discards entries forward of the cursor.
8487        run_keys(&mut e, "iZ<Esc>");
8488        // No newer entry left to walk to.
8489        run_keys(&mut e, "g,");
8490        // Cursor stays where the latest edit landed it.
8491        assert_ne!(e.cursor(), (2, 1));
8492    }
8493
8494    // gq* tests moved to crates/hjkl-editor/tests/vim_ex_integration.rs
8495    // — they exercise the vim FSM through ex commands which now live in
8496    // a sibling crate. cargo dev-dep cycles produce duplicate type IDs
8497    // so the integration must run from the editor side.
8498
8499    #[test]
8500    fn capital_mark_set_and_jump() {
8501        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
8502        e.jump_cursor(2, 1);
8503        run_keys(&mut e, "mA");
8504        // Move away.
8505        e.jump_cursor(0, 0);
8506        // Jump back via `'A`.
8507        run_keys(&mut e, "'A");
8508        // Linewise jump → row preserved, col first non-blank (here 0).
8509        assert_eq!(e.cursor().0, 2);
8510    }
8511
8512    #[test]
8513    fn capital_mark_survives_set_content() {
8514        let mut e = editor_with("first buffer line\nsecond");
8515        e.jump_cursor(1, 3);
8516        run_keys(&mut e, "mA");
8517        // Swap buffer content (host loading a different tab).
8518        e.set_content("totally different content\non many\nrows of text");
8519        // `'A` should still jump to (1, 3) — it survived the swap.
8520        e.jump_cursor(0, 0);
8521        run_keys(&mut e, "'A");
8522        assert_eq!(e.cursor().0, 1);
8523    }
8524
8525    // capital_mark_shows_in_marks_listing moved to
8526    // crates/hjkl-editor/tests/vim_ex_integration.rs (depends on the
8527    // ex `marks` command).
8528
8529    #[test]
8530    fn capital_mark_shifts_with_edit() {
8531        let mut e = editor_with("a\nb\nc\nd");
8532        e.jump_cursor(3, 0);
8533        run_keys(&mut e, "mA");
8534        // Delete the first row — `A` should shift up to row 2.
8535        e.jump_cursor(0, 0);
8536        run_keys(&mut e, "dd");
8537        e.jump_cursor(0, 0);
8538        run_keys(&mut e, "'A");
8539        assert_eq!(e.cursor().0, 2);
8540    }
8541
8542    #[test]
8543    fn mark_below_delete_shifts_up() {
8544        let mut e = editor_with("a\nb\nc\nd\ne");
8545        // Set mark `a` on row 3 (the `d`).
8546        e.jump_cursor(3, 0);
8547        run_keys(&mut e, "ma");
8548        // Go back to row 0 and `dd`.
8549        e.jump_cursor(0, 0);
8550        run_keys(&mut e, "dd");
8551        // Mark `a` should now point at row 2 — its content stayed `d`.
8552        e.jump_cursor(0, 0);
8553        run_keys(&mut e, "'a");
8554        assert_eq!(e.cursor().0, 2);
8555        assert_eq!(e.buffer().line(2).unwrap(), "d");
8556    }
8557
8558    #[test]
8559    fn mark_on_deleted_row_is_dropped() {
8560        let mut e = editor_with("a\nb\nc\nd");
8561        // Mark `a` on row 1 (`b`).
8562        e.jump_cursor(1, 0);
8563        run_keys(&mut e, "ma");
8564        // Delete row 1.
8565        run_keys(&mut e, "dd");
8566        // The row that held `a` is gone; `'a` should be a no-op now.
8567        e.jump_cursor(2, 0);
8568        run_keys(&mut e, "'a");
8569        // Cursor stays on row 2 — `'a` no-ops on missing marks.
8570        assert_eq!(e.cursor().0, 2);
8571    }
8572
8573    #[test]
8574    fn mark_above_edit_unchanged() {
8575        let mut e = editor_with("a\nb\nc\nd\ne");
8576        // Mark `a` on row 0.
8577        e.jump_cursor(0, 0);
8578        run_keys(&mut e, "ma");
8579        // Delete row 3.
8580        e.jump_cursor(3, 0);
8581        run_keys(&mut e, "dd");
8582        // Mark `a` should still point at row 0.
8583        e.jump_cursor(2, 0);
8584        run_keys(&mut e, "'a");
8585        assert_eq!(e.cursor().0, 0);
8586    }
8587
8588    #[test]
8589    fn mark_shifts_down_after_insert() {
8590        let mut e = editor_with("a\nb\nc");
8591        // Mark `a` on row 2 (`c`).
8592        e.jump_cursor(2, 0);
8593        run_keys(&mut e, "ma");
8594        // Open a new line above row 0 with `O\nfoo<Esc>`.
8595        e.jump_cursor(0, 0);
8596        run_keys(&mut e, "Onew<Esc>");
8597        // Buffer is now ["new", "a", "b", "c"]; mark `a` should track
8598        // the original content row → 3.
8599        e.jump_cursor(0, 0);
8600        run_keys(&mut e, "'a");
8601        assert_eq!(e.cursor().0, 3);
8602        assert_eq!(e.buffer().line(3).unwrap(), "c");
8603    }
8604
8605    // ─── Search / jumplist interaction ───────────────────────────────
8606
8607    #[test]
8608    fn forward_search_commit_pushes_jump() {
8609        let mut e = editor_with("alpha beta\nfoo target end\nmore");
8610        e.jump_cursor(0, 0);
8611        run_keys(&mut e, "/target<CR>");
8612        // Cursor moved to the match.
8613        assert_ne!(e.cursor(), (0, 0));
8614        // Ctrl-o returns to the pre-search position.
8615        run_keys(&mut e, "<C-o>");
8616        assert_eq!(e.cursor(), (0, 0));
8617    }
8618
8619    #[test]
8620    fn search_commit_no_match_does_not_push_jump() {
8621        let mut e = editor_with("alpha beta\nfoo end");
8622        e.jump_cursor(0, 3);
8623        let pre_len = e.vim.jump_back.len();
8624        run_keys(&mut e, "/zzznotfound<CR>");
8625        // No match → cursor stays, jumplist shouldn't grow.
8626        assert_eq!(e.vim.jump_back.len(), pre_len);
8627    }
8628
8629    // ─── Phase 7b: migration buffer cursor sync ──────────────────────
8630
8631    #[test]
8632    fn buffer_cursor_mirrors_textarea_after_horizontal_motion() {
8633        let mut e = editor_with("hello world");
8634        run_keys(&mut e, "lll");
8635        let (row, col) = e.cursor();
8636        assert_eq!(e.buffer.cursor().row, row);
8637        assert_eq!(e.buffer.cursor().col, col);
8638    }
8639
8640    #[test]
8641    fn buffer_cursor_mirrors_textarea_after_vertical_motion() {
8642        let mut e = editor_with("aaaa\nbbbb\ncccc");
8643        run_keys(&mut e, "jj");
8644        let (row, col) = e.cursor();
8645        assert_eq!(e.buffer.cursor().row, row);
8646        assert_eq!(e.buffer.cursor().col, col);
8647    }
8648
8649    #[test]
8650    fn buffer_cursor_mirrors_textarea_after_word_motion() {
8651        let mut e = editor_with("foo bar baz");
8652        run_keys(&mut e, "ww");
8653        let (row, col) = e.cursor();
8654        assert_eq!(e.buffer.cursor().row, row);
8655        assert_eq!(e.buffer.cursor().col, col);
8656    }
8657
8658    #[test]
8659    fn buffer_cursor_mirrors_textarea_after_jump_motion() {
8660        let mut e = editor_with("a\nb\nc\nd\ne");
8661        run_keys(&mut e, "G");
8662        let (row, col) = e.cursor();
8663        assert_eq!(e.buffer.cursor().row, row);
8664        assert_eq!(e.buffer.cursor().col, col);
8665    }
8666
8667    #[test]
8668    fn editor_sticky_col_tracks_horizontal_motion() {
8669        let mut e = editor_with("longline\nhi\nlongline");
8670        // `fl` from col 0 lands on the next `l` past the cursor —
8671        // "longline" → second `l` is at col 4. Horizontal motion
8672        // should refresh sticky to that column so the next `j`
8673        // picks it up across the short row.
8674        run_keys(&mut e, "fl");
8675        let landed = e.cursor().1;
8676        assert!(landed > 0, "fl should have moved");
8677        run_keys(&mut e, "j");
8678        // Editor is the single owner of sticky_col (0.0.28). The
8679        // sticky value was set from the post-`fl` column.
8680        assert_eq!(e.sticky_col(), Some(landed));
8681    }
8682
8683    #[test]
8684    fn buffer_content_mirrors_textarea_after_insert() {
8685        let mut e = editor_with("hello");
8686        run_keys(&mut e, "iXYZ<Esc>");
8687        let text = e.buffer().lines().join("\n");
8688        assert_eq!(e.buffer.as_string(), text);
8689    }
8690
8691    #[test]
8692    fn buffer_content_mirrors_textarea_after_delete() {
8693        let mut e = editor_with("alpha bravo charlie");
8694        run_keys(&mut e, "dw");
8695        let text = e.buffer().lines().join("\n");
8696        assert_eq!(e.buffer.as_string(), text);
8697    }
8698
8699    #[test]
8700    fn buffer_content_mirrors_textarea_after_dd() {
8701        let mut e = editor_with("a\nb\nc\nd");
8702        run_keys(&mut e, "jdd");
8703        let text = e.buffer().lines().join("\n");
8704        assert_eq!(e.buffer.as_string(), text);
8705    }
8706
8707    #[test]
8708    fn buffer_content_mirrors_textarea_after_open_line() {
8709        let mut e = editor_with("foo\nbar");
8710        run_keys(&mut e, "oNEW<Esc>");
8711        let text = e.buffer().lines().join("\n");
8712        assert_eq!(e.buffer.as_string(), text);
8713    }
8714
8715    #[test]
8716    fn buffer_content_mirrors_textarea_after_paste() {
8717        let mut e = editor_with("hello");
8718        run_keys(&mut e, "yy");
8719        run_keys(&mut e, "p");
8720        let text = e.buffer().lines().join("\n");
8721        assert_eq!(e.buffer.as_string(), text);
8722    }
8723
8724    #[test]
8725    fn buffer_selection_none_in_normal_mode() {
8726        let e = editor_with("foo bar");
8727        assert!(e.buffer_selection().is_none());
8728    }
8729
8730    #[test]
8731    fn buffer_selection_char_in_visual_mode() {
8732        use hjkl_buffer::{Position, Selection};
8733        let mut e = editor_with("hello world");
8734        run_keys(&mut e, "vlll");
8735        assert_eq!(
8736            e.buffer_selection(),
8737            Some(Selection::Char {
8738                anchor: Position::new(0, 0),
8739                head: Position::new(0, 3),
8740            })
8741        );
8742    }
8743
8744    #[test]
8745    fn buffer_selection_line_in_visual_line_mode() {
8746        use hjkl_buffer::Selection;
8747        let mut e = editor_with("a\nb\nc\nd");
8748        run_keys(&mut e, "Vj");
8749        assert_eq!(
8750            e.buffer_selection(),
8751            Some(Selection::Line {
8752                anchor_row: 0,
8753                head_row: 1,
8754            })
8755        );
8756    }
8757
8758    #[test]
8759    fn wrapscan_off_blocks_wrap_around() {
8760        let mut e = editor_with("first\nsecond\nthird\n");
8761        e.settings_mut().wrapscan = false;
8762        // Place cursor on row 2 ("third") and search for "first".
8763        e.jump_cursor(2, 0);
8764        run_keys(&mut e, "/first<CR>");
8765        // No wrap → cursor stays on row 2.
8766        assert_eq!(e.cursor().0, 2, "wrapscan off should block wrap");
8767        // Re-enable wrapscan and try again.
8768        e.settings_mut().wrapscan = true;
8769        run_keys(&mut e, "/first<CR>");
8770        assert_eq!(e.cursor().0, 0, "wrapscan on should wrap to row 0");
8771    }
8772
8773    #[test]
8774    fn smartcase_uppercase_pattern_stays_sensitive() {
8775        let mut e = editor_with("foo\nFoo\nBAR\n");
8776        e.settings_mut().ignore_case = true;
8777        e.settings_mut().smartcase = true;
8778        // All-lowercase pattern → ignorecase wins → compiled regex
8779        // is case-insensitive.
8780        run_keys(&mut e, "/foo<CR>");
8781        let r1 = e
8782            .search_state()
8783            .pattern
8784            .as_ref()
8785            .unwrap()
8786            .as_str()
8787            .to_string();
8788        assert!(r1.starts_with("(?i)"), "lowercase under smartcase: {r1}");
8789        // Uppercase letter → smartcase flips back to case-sensitive.
8790        run_keys(&mut e, "/Foo<CR>");
8791        let r2 = e
8792            .search_state()
8793            .pattern
8794            .as_ref()
8795            .unwrap()
8796            .as_str()
8797            .to_string();
8798        assert!(!r2.starts_with("(?i)"), "mixed-case under smartcase: {r2}");
8799    }
8800
8801    #[test]
8802    fn enter_with_autoindent_copies_leading_whitespace() {
8803        let mut e = editor_with("    foo");
8804        e.jump_cursor(0, 7);
8805        run_keys(&mut e, "i<CR>");
8806        assert_eq!(e.buffer.line(1).unwrap(), "    ");
8807    }
8808
8809    #[test]
8810    fn enter_without_autoindent_inserts_bare_newline() {
8811        let mut e = editor_with("    foo");
8812        e.settings_mut().autoindent = false;
8813        e.jump_cursor(0, 7);
8814        run_keys(&mut e, "i<CR>");
8815        assert_eq!(e.buffer.line(1).unwrap(), "");
8816    }
8817
8818    #[test]
8819    fn iskeyword_default_treats_alnum_underscore_as_word() {
8820        let mut e = editor_with("foo_bar baz");
8821        // `*` searches for the word at the cursor — picks up everything
8822        // matching iskeyword. With default spec, `foo_bar` is one word,
8823        // so the search pattern should bound that whole token.
8824        e.jump_cursor(0, 0);
8825        run_keys(&mut e, "*");
8826        let p = e
8827            .search_state()
8828            .pattern
8829            .as_ref()
8830            .unwrap()
8831            .as_str()
8832            .to_string();
8833        assert!(p.contains("foo_bar"), "default iskeyword: {p}");
8834    }
8835
8836    #[test]
8837    fn w_motion_respects_custom_iskeyword() {
8838        // `foo-bar baz`. With the default spec, `-` is NOT a word char,
8839        // so `foo` / `-` / `bar` / ` ` / `baz` are 5 transitions and a
8840        // single `w` from col 0 lands on `-` (col 3).
8841        let mut e = editor_with("foo-bar baz");
8842        run_keys(&mut e, "w");
8843        assert_eq!(e.cursor().1, 3, "default iskeyword: {:?}", e.cursor());
8844        // Re-set with `-` (45) treated as a word char. Now `foo-bar` is
8845        // one token; `w` from col 0 should jump to `baz` (col 8).
8846        let mut e2 = editor_with("foo-bar baz");
8847        e2.set_iskeyword("@,_,45");
8848        run_keys(&mut e2, "w");
8849        assert_eq!(e2.cursor().1, 8, "dash-as-word: {:?}", e2.cursor());
8850    }
8851
8852    #[test]
8853    fn iskeyword_with_dash_treats_dash_as_word_char() {
8854        let mut e = editor_with("foo-bar baz");
8855        e.settings_mut().iskeyword = "@,_,45".to_string();
8856        e.jump_cursor(0, 0);
8857        run_keys(&mut e, "*");
8858        let p = e
8859            .search_state()
8860            .pattern
8861            .as_ref()
8862            .unwrap()
8863            .as_str()
8864            .to_string();
8865        assert!(p.contains("foo-bar"), "dash-as-word: {p}");
8866    }
8867
8868    #[test]
8869    fn timeoutlen_drops_pending_g_prefix() {
8870        use std::time::{Duration, Instant};
8871        let mut e = editor_with("a\nb\nc");
8872        e.jump_cursor(2, 0);
8873        // First `g` lands us in g-pending state.
8874        run_keys(&mut e, "g");
8875        assert!(matches!(e.vim.pending, super::Pending::G));
8876        // Push last_input timestamps into the past beyond the default
8877        // timeout. 0.0.29 (Patch B) drives `:set timeoutlen` off
8878        // `Host::now()` (monotonic Duration), so shrink the timeout
8879        // window to a nanosecond and zero out the host slot — any
8880        // wall-clock progress between this line and the next step
8881        // exceeds it. The Instant-flavoured field is rewound for
8882        // snapshot tests that still observe it directly.
8883        e.settings.timeout_len = Duration::from_nanos(0);
8884        e.vim.last_input_at = Some(Instant::now() - Duration::from_secs(60));
8885        e.vim.last_input_host_at = Some(Duration::ZERO);
8886        // Second `g` arrives "late" — timeout fires, prefix is cleared,
8887        // and the bare `g` is re-dispatched: nothing happens at the
8888        // engine level because `g` alone isn't a complete command.
8889        run_keys(&mut e, "g");
8890        // Cursor must still be at row 2 — `gg` was NOT completed.
8891        assert_eq!(e.cursor().0, 2, "timeout must abandon g-prefix");
8892    }
8893
8894    #[test]
8895    fn undobreak_on_breaks_group_at_arrow_motion() {
8896        let mut e = editor_with("");
8897        // i a a a <Left> b b b <Esc> u
8898        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
8899        // Default settings.undo_break_on_motion = true, so `u` only
8900        // reverses the `bbb` run; `aaa` remains.
8901        let line = e.buffer.line(0).unwrap_or("").to_string();
8902        assert!(line.contains("aaa"), "after undobreak: {line:?}");
8903        assert!(!line.contains("bbb"), "bbb should be undone: {line:?}");
8904    }
8905
8906    #[test]
8907    fn undobreak_off_keeps_full_run_in_one_group() {
8908        let mut e = editor_with("");
8909        e.settings_mut().undo_break_on_motion = false;
8910        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
8911        // With undobreak off, the whole insert (aaa<Left>bbb) is one
8912        // group — `u` reverts back to empty.
8913        assert_eq!(e.buffer.line(0).unwrap_or(""), "");
8914    }
8915
8916    #[test]
8917    fn undobreak_round_trips_through_options() {
8918        let e = editor_with("");
8919        let opts = e.current_options();
8920        assert!(opts.undo_break_on_motion);
8921        let mut e2 = editor_with("");
8922        let mut new_opts = opts.clone();
8923        new_opts.undo_break_on_motion = false;
8924        e2.apply_options(&new_opts);
8925        assert!(!e2.current_options().undo_break_on_motion);
8926    }
8927
8928    #[test]
8929    fn undo_levels_cap_drops_oldest() {
8930        let mut e = editor_with("abcde");
8931        e.settings_mut().undo_levels = 3;
8932        run_keys(&mut e, "ra");
8933        run_keys(&mut e, "lrb");
8934        run_keys(&mut e, "lrc");
8935        run_keys(&mut e, "lrd");
8936        run_keys(&mut e, "lre");
8937        assert_eq!(e.undo_stack_len(), 3);
8938    }
8939
8940    #[test]
8941    fn tab_inserts_literal_tab_when_noexpandtab() {
8942        let mut e = editor_with("");
8943        // 0.2.0: expandtab now defaults on (modern). Opt out for the
8944        // literal-tab test.
8945        e.settings_mut().expandtab = false;
8946        e.settings_mut().softtabstop = 0;
8947        run_keys(&mut e, "i");
8948        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8949        assert_eq!(e.buffer.line(0).unwrap(), "\t");
8950    }
8951
8952    #[test]
8953    fn tab_inserts_spaces_when_expandtab() {
8954        let mut e = editor_with("");
8955        e.settings_mut().expandtab = true;
8956        e.settings_mut().tabstop = 4;
8957        run_keys(&mut e, "i");
8958        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8959        assert_eq!(e.buffer.line(0).unwrap(), "    ");
8960    }
8961
8962    #[test]
8963    fn tab_with_softtabstop_fills_to_next_boundary() {
8964        // sts=4, cursor at col 2 → Tab inserts 2 spaces (to col 4).
8965        let mut e = editor_with("ab");
8966        e.settings_mut().expandtab = true;
8967        e.settings_mut().tabstop = 8;
8968        e.settings_mut().softtabstop = 4;
8969        run_keys(&mut e, "A"); // append at end (col 2)
8970        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8971        assert_eq!(e.buffer.line(0).unwrap(), "ab  ");
8972    }
8973
8974    #[test]
8975    fn backspace_deletes_softtab_run() {
8976        // sts=4, line "    x" with cursor at col 4 → Backspace deletes
8977        // the whole 4-space run instead of one char.
8978        let mut e = editor_with("    x");
8979        e.settings_mut().softtabstop = 4;
8980        // Move to col 4 (start of 'x'), then enter insert.
8981        run_keys(&mut e, "fxi");
8982        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
8983        assert_eq!(e.buffer.line(0).unwrap(), "x");
8984    }
8985
8986    #[test]
8987    fn backspace_falls_back_to_single_char_when_run_not_aligned() {
8988        // sts=4, but cursor at col 5 (one space past the boundary) →
8989        // Backspace deletes only the one trailing space.
8990        let mut e = editor_with("     x");
8991        e.settings_mut().softtabstop = 4;
8992        run_keys(&mut e, "fxi");
8993        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
8994        assert_eq!(e.buffer.line(0).unwrap(), "    x");
8995    }
8996
8997    #[test]
8998    fn readonly_blocks_insert_mutation() {
8999        let mut e = editor_with("hello");
9000        e.settings_mut().readonly = true;
9001        run_keys(&mut e, "iX<Esc>");
9002        assert_eq!(e.buffer.line(0).unwrap(), "hello");
9003    }
9004
9005    #[cfg(feature = "ratatui")]
9006    #[test]
9007    fn intern_ratatui_style_dedups_repeated_styles() {
9008        use ratatui::style::{Color, Style};
9009        let mut e = editor_with("");
9010        let red = Style::default().fg(Color::Red);
9011        let blue = Style::default().fg(Color::Blue);
9012        let id_r1 = e.intern_ratatui_style(red);
9013        let id_r2 = e.intern_ratatui_style(red);
9014        let id_b = e.intern_ratatui_style(blue);
9015        assert_eq!(id_r1, id_r2);
9016        assert_ne!(id_r1, id_b);
9017        assert_eq!(e.style_table().len(), 2);
9018    }
9019
9020    #[cfg(feature = "ratatui")]
9021    #[test]
9022    fn install_ratatui_syntax_spans_translates_styled_spans() {
9023        use ratatui::style::{Color, Style};
9024        let mut e = editor_with("SELECT foo");
9025        e.install_ratatui_syntax_spans(vec![vec![(0, 6, Style::default().fg(Color::Red))]]);
9026        let by_row = e.buffer_spans();
9027        assert_eq!(by_row.len(), 1);
9028        assert_eq!(by_row[0].len(), 1);
9029        assert_eq!(by_row[0][0].start_byte, 0);
9030        assert_eq!(by_row[0][0].end_byte, 6);
9031        let id = by_row[0][0].style;
9032        assert_eq!(e.style_table()[id as usize].fg, Some(Color::Red));
9033    }
9034
9035    #[cfg(feature = "ratatui")]
9036    #[test]
9037    fn install_ratatui_syntax_spans_clamps_sentinel_end() {
9038        use ratatui::style::{Color, Style};
9039        let mut e = editor_with("hello");
9040        e.install_ratatui_syntax_spans(vec![vec![(
9041            0,
9042            usize::MAX,
9043            Style::default().fg(Color::Blue),
9044        )]]);
9045        let by_row = e.buffer_spans();
9046        assert_eq!(by_row[0][0].end_byte, 5);
9047    }
9048
9049    #[cfg(feature = "ratatui")]
9050    #[test]
9051    fn install_ratatui_syntax_spans_drops_zero_width() {
9052        use ratatui::style::{Color, Style};
9053        let mut e = editor_with("abc");
9054        e.install_ratatui_syntax_spans(vec![vec![(2, 2, Style::default().fg(Color::Red))]]);
9055        assert!(e.buffer_spans()[0].is_empty());
9056    }
9057
9058    #[test]
9059    fn named_register_yank_into_a_then_paste_from_a() {
9060        let mut e = editor_with("hello world\nsecond");
9061        run_keys(&mut e, "\"ayw");
9062        // `yw` over "hello world" yanks "hello " (word + trailing space).
9063        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
9064        // Move to second line then paste from "a.
9065        run_keys(&mut e, "j0\"aP");
9066        assert_eq!(e.buffer().lines()[1], "hello second");
9067    }
9068
9069    #[test]
9070    fn capital_r_overstrikes_chars() {
9071        let mut e = editor_with("hello");
9072        e.jump_cursor(0, 0);
9073        run_keys(&mut e, "RXY<Esc>");
9074        // 'h' and 'e' replaced; 'llo' kept.
9075        assert_eq!(e.buffer().lines()[0], "XYllo");
9076    }
9077
9078    #[test]
9079    fn capital_r_at_eol_appends() {
9080        let mut e = editor_with("hi");
9081        e.jump_cursor(0, 1);
9082        // Cursor on the final 'i'; replace it then keep typing past EOL.
9083        run_keys(&mut e, "RXYZ<Esc>");
9084        assert_eq!(e.buffer().lines()[0], "hXYZ");
9085    }
9086
9087    #[test]
9088    fn capital_r_count_does_not_repeat_overstrike_char_by_char() {
9089        // Vim's `2R` replays the *whole session* on Esc, not each char.
9090        // We don't model that fully, but the basic R should at least
9091        // not crash on empty session count handling.
9092        let mut e = editor_with("abc");
9093        e.jump_cursor(0, 0);
9094        run_keys(&mut e, "RX<Esc>");
9095        assert_eq!(e.buffer().lines()[0], "Xbc");
9096    }
9097
9098    #[test]
9099    fn ctrl_r_in_insert_pastes_named_register() {
9100        let mut e = editor_with("hello world");
9101        // Yank "hello " into "a".
9102        run_keys(&mut e, "\"ayw");
9103        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
9104        // Open a fresh line, enter insert, Ctrl-R a.
9105        run_keys(&mut e, "o");
9106        assert_eq!(e.vim_mode(), VimMode::Insert);
9107        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9108        e.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
9109        assert_eq!(e.buffer().lines()[1], "hello ");
9110        // Cursor sits at end of inserted payload (col 6).
9111        assert_eq!(e.cursor(), (1, 6));
9112        // Stayed in insert mode; next char appends.
9113        assert_eq!(e.vim_mode(), VimMode::Insert);
9114        e.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
9115        assert_eq!(e.buffer().lines()[1], "hello X");
9116    }
9117
9118    #[test]
9119    fn ctrl_r_with_unnamed_register() {
9120        let mut e = editor_with("foo");
9121        run_keys(&mut e, "yiw");
9122        run_keys(&mut e, "A ");
9123        // Unnamed register paste via `"`.
9124        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9125        e.handle_key(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
9126        assert_eq!(e.buffer().lines()[0], "foo foo");
9127    }
9128
9129    #[test]
9130    fn ctrl_r_unknown_selector_is_no_op() {
9131        let mut e = editor_with("abc");
9132        run_keys(&mut e, "A");
9133        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9134        // `?` isn't a valid register selector — paste skipped, the
9135        // armed flag still clears so the next key types normally.
9136        e.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
9137        e.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
9138        assert_eq!(e.buffer().lines()[0], "abcZ");
9139    }
9140
9141    #[test]
9142    fn ctrl_r_multiline_register_pastes_with_newlines() {
9143        let mut e = editor_with("alpha\nbeta\ngamma");
9144        // Yank two whole lines into "b".
9145        run_keys(&mut e, "\"byy");
9146        run_keys(&mut e, "j\"byy");
9147        // Linewise yanks include trailing \n; second yank into uppercase
9148        // would append, but lowercase "b" overwrote — ensure we have a
9149        // multi-line payload by yanking 2 lines linewise via V.
9150        run_keys(&mut e, "ggVj\"by");
9151        let payload = e.registers().read('b').unwrap().text.clone();
9152        assert!(payload.contains('\n'));
9153        run_keys(&mut e, "Go");
9154        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9155        e.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
9156        // The buffer should now contain the original 3 lines plus the
9157        // pasted 2-line payload (with its own newline) on its own line.
9158        let total_lines = e.buffer().lines().len();
9159        assert!(total_lines >= 5);
9160    }
9161
9162    #[test]
9163    fn yank_zero_holds_last_yank_after_delete() {
9164        let mut e = editor_with("hello world");
9165        run_keys(&mut e, "yw");
9166        let yanked = e.registers().read('0').unwrap().text.clone();
9167        assert!(!yanked.is_empty());
9168        // Delete a word; "0 should still hold the original yank.
9169        run_keys(&mut e, "dw");
9170        assert_eq!(e.registers().read('0').unwrap().text, yanked);
9171        // "1 holds the just-deleted text (non-empty, regardless of exact contents).
9172        assert!(!e.registers().read('1').unwrap().text.is_empty());
9173    }
9174
9175    #[test]
9176    fn delete_ring_rotates_through_one_through_nine() {
9177        let mut e = editor_with("a b c d e f g h i j");
9178        // Delete each word — each delete pushes onto "1, shifting older.
9179        for _ in 0..3 {
9180            run_keys(&mut e, "dw");
9181        }
9182        // Most recent delete is in "1.
9183        let r1 = e.registers().read('1').unwrap().text.clone();
9184        let r2 = e.registers().read('2').unwrap().text.clone();
9185        let r3 = e.registers().read('3').unwrap().text.clone();
9186        assert!(!r1.is_empty() && !r2.is_empty() && !r3.is_empty());
9187        assert_ne!(r1, r2);
9188        assert_ne!(r2, r3);
9189    }
9190
9191    #[test]
9192    fn capital_register_appends_to_lowercase() {
9193        let mut e = editor_with("foo bar");
9194        run_keys(&mut e, "\"ayw");
9195        let first = e.registers().read('a').unwrap().text.clone();
9196        assert!(first.contains("foo"));
9197        // Yank again into "A — appends to "a.
9198        run_keys(&mut e, "w\"Ayw");
9199        let combined = e.registers().read('a').unwrap().text.clone();
9200        assert!(combined.starts_with(&first));
9201        assert!(combined.contains("bar"));
9202    }
9203
9204    #[test]
9205    fn zf_in_visual_line_creates_closed_fold() {
9206        let mut e = editor_with("a\nb\nc\nd\ne");
9207        // VisualLine over rows 1..=3 then zf.
9208        e.jump_cursor(1, 0);
9209        run_keys(&mut e, "Vjjzf");
9210        assert_eq!(e.buffer().folds().len(), 1);
9211        let f = e.buffer().folds()[0];
9212        assert_eq!(f.start_row, 1);
9213        assert_eq!(f.end_row, 3);
9214        assert!(f.closed);
9215    }
9216
9217    #[test]
9218    fn zfj_in_normal_creates_two_row_fold() {
9219        let mut e = editor_with("a\nb\nc\nd\ne");
9220        e.jump_cursor(1, 0);
9221        run_keys(&mut e, "zfj");
9222        assert_eq!(e.buffer().folds().len(), 1);
9223        let f = e.buffer().folds()[0];
9224        assert_eq!(f.start_row, 1);
9225        assert_eq!(f.end_row, 2);
9226        assert!(f.closed);
9227        // Cursor stays where it started.
9228        assert_eq!(e.cursor().0, 1);
9229    }
9230
9231    #[test]
9232    fn zf_with_count_folds_count_rows() {
9233        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9234        e.jump_cursor(0, 0);
9235        // `zf3j` — fold rows 0..=3.
9236        run_keys(&mut e, "zf3j");
9237        assert_eq!(e.buffer().folds().len(), 1);
9238        let f = e.buffer().folds()[0];
9239        assert_eq!(f.start_row, 0);
9240        assert_eq!(f.end_row, 3);
9241    }
9242
9243    #[test]
9244    fn zfk_folds_upward_range() {
9245        let mut e = editor_with("a\nb\nc\nd\ne");
9246        e.jump_cursor(3, 0);
9247        run_keys(&mut e, "zfk");
9248        let f = e.buffer().folds()[0];
9249        // start_row = min(3, 2) = 2, end_row = max(3, 2) = 3.
9250        assert_eq!(f.start_row, 2);
9251        assert_eq!(f.end_row, 3);
9252    }
9253
9254    #[test]
9255    fn zf_capital_g_folds_to_bottom() {
9256        let mut e = editor_with("a\nb\nc\nd\ne");
9257        e.jump_cursor(1, 0);
9258        // `G` is a single-char motion; folds rows 1..=4.
9259        run_keys(&mut e, "zfG");
9260        let f = e.buffer().folds()[0];
9261        assert_eq!(f.start_row, 1);
9262        assert_eq!(f.end_row, 4);
9263    }
9264
9265    #[test]
9266    fn zfgg_folds_to_top_via_operator_pipeline() {
9267        let mut e = editor_with("a\nb\nc\nd\ne");
9268        e.jump_cursor(3, 0);
9269        // `gg` is a 2-key chord (Pending::OpG path) — `zfgg` works
9270        // because `zf` arms `Pending::Op { Fold }` which already knows
9271        // how to wait for `g` then `g`.
9272        run_keys(&mut e, "zfgg");
9273        let f = e.buffer().folds()[0];
9274        assert_eq!(f.start_row, 0);
9275        assert_eq!(f.end_row, 3);
9276    }
9277
9278    #[test]
9279    fn zfip_folds_paragraph_via_text_object() {
9280        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta\nepsilon");
9281        e.jump_cursor(1, 0);
9282        // `ip` is a text object — same operator pipeline routes it.
9283        run_keys(&mut e, "zfip");
9284        assert_eq!(e.buffer().folds().len(), 1);
9285        let f = e.buffer().folds()[0];
9286        assert_eq!(f.start_row, 0);
9287        assert_eq!(f.end_row, 2);
9288    }
9289
9290    #[test]
9291    fn zfap_folds_paragraph_with_trailing_blank() {
9292        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta");
9293        e.jump_cursor(0, 0);
9294        // `ap` includes the trailing blank line.
9295        run_keys(&mut e, "zfap");
9296        let f = e.buffer().folds()[0];
9297        assert_eq!(f.start_row, 0);
9298        assert_eq!(f.end_row, 3);
9299    }
9300
9301    #[test]
9302    fn zf_paragraph_motion_folds_to_blank() {
9303        let mut e = editor_with("alpha\nbeta\n\ngamma");
9304        e.jump_cursor(0, 0);
9305        // `}` jumps to the blank-line boundary; fold spans rows 0..=2.
9306        run_keys(&mut e, "zf}");
9307        let f = e.buffer().folds()[0];
9308        assert_eq!(f.start_row, 0);
9309        assert_eq!(f.end_row, 2);
9310    }
9311
9312    #[test]
9313    fn za_toggles_fold_under_cursor() {
9314        let mut e = editor_with("a\nb\nc\nd");
9315        e.buffer_mut().add_fold(1, 2, true);
9316        e.jump_cursor(1, 0);
9317        run_keys(&mut e, "za");
9318        assert!(!e.buffer().folds()[0].closed);
9319        run_keys(&mut e, "za");
9320        assert!(e.buffer().folds()[0].closed);
9321    }
9322
9323    #[test]
9324    fn zr_opens_all_folds_zm_closes_all() {
9325        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9326        e.buffer_mut().add_fold(0, 1, true);
9327        e.buffer_mut().add_fold(2, 3, true);
9328        e.buffer_mut().add_fold(4, 5, true);
9329        run_keys(&mut e, "zR");
9330        assert!(e.buffer().folds().iter().all(|f| !f.closed));
9331        run_keys(&mut e, "zM");
9332        assert!(e.buffer().folds().iter().all(|f| f.closed));
9333    }
9334
9335    #[test]
9336    fn ze_clears_all_folds() {
9337        let mut e = editor_with("a\nb\nc\nd");
9338        e.buffer_mut().add_fold(0, 1, true);
9339        e.buffer_mut().add_fold(2, 3, false);
9340        run_keys(&mut e, "zE");
9341        assert!(e.buffer().folds().is_empty());
9342    }
9343
9344    #[test]
9345    fn g_underscore_jumps_to_last_non_blank() {
9346        let mut e = editor_with("hello world   ");
9347        run_keys(&mut e, "g_");
9348        // Last non-blank is 'd' at col 10.
9349        assert_eq!(e.cursor().1, 10);
9350    }
9351
9352    #[test]
9353    fn gj_and_gk_alias_j_and_k() {
9354        let mut e = editor_with("a\nb\nc");
9355        run_keys(&mut e, "gj");
9356        assert_eq!(e.cursor().0, 1);
9357        run_keys(&mut e, "gk");
9358        assert_eq!(e.cursor().0, 0);
9359    }
9360
9361    #[test]
9362    fn paragraph_motions_walk_blank_lines() {
9363        let mut e = editor_with("first\nblock\n\nsecond\nblock\n\nthird");
9364        run_keys(&mut e, "}");
9365        assert_eq!(e.cursor().0, 2);
9366        run_keys(&mut e, "}");
9367        assert_eq!(e.cursor().0, 5);
9368        run_keys(&mut e, "{");
9369        assert_eq!(e.cursor().0, 2);
9370    }
9371
9372    #[test]
9373    fn gv_reenters_last_visual_selection() {
9374        let mut e = editor_with("alpha\nbeta\ngamma");
9375        run_keys(&mut e, "Vj");
9376        // Exit visual.
9377        run_keys(&mut e, "<Esc>");
9378        assert_eq!(e.vim_mode(), VimMode::Normal);
9379        // gv re-enters VisualLine.
9380        run_keys(&mut e, "gv");
9381        assert_eq!(e.vim_mode(), VimMode::VisualLine);
9382    }
9383
9384    #[test]
9385    fn o_in_visual_swaps_anchor_and_cursor() {
9386        let mut e = editor_with("hello world");
9387        // v then move right 4 — anchor at col 0, cursor at col 4.
9388        run_keys(&mut e, "vllll");
9389        assert_eq!(e.cursor().1, 4);
9390        // o swaps; cursor jumps to anchor (col 0).
9391        run_keys(&mut e, "o");
9392        assert_eq!(e.cursor().1, 0);
9393        // Anchor now at original cursor (col 4).
9394        assert_eq!(e.vim.visual_anchor, (0, 4));
9395    }
9396
9397    #[test]
9398    fn editing_inside_fold_invalidates_it() {
9399        let mut e = editor_with("a\nb\nc\nd");
9400        e.buffer_mut().add_fold(1, 2, true);
9401        e.jump_cursor(1, 0);
9402        // Insert a char on a row covered by the fold.
9403        run_keys(&mut e, "iX<Esc>");
9404        // Fold should be gone — vim opens (drops) folds on edit.
9405        assert!(e.buffer().folds().is_empty());
9406    }
9407
9408    #[test]
9409    fn zd_removes_fold_under_cursor() {
9410        let mut e = editor_with("a\nb\nc\nd");
9411        e.buffer_mut().add_fold(1, 2, true);
9412        e.jump_cursor(2, 0);
9413        run_keys(&mut e, "zd");
9414        assert!(e.buffer().folds().is_empty());
9415    }
9416
9417    #[test]
9418    fn take_fold_ops_observes_z_keystroke_dispatch() {
9419        // 0.0.38 (Patch C-δ.4): every `z…` keystroke routes through
9420        // `Editor::apply_fold_op`, which queues a `FoldOp` for hosts to
9421        // observe via `take_fold_ops` AND applies the op locally so
9422        // buffer fold storage stays in sync.
9423        use crate::types::FoldOp;
9424        let mut e = editor_with("a\nb\nc\nd");
9425        e.buffer_mut().add_fold(1, 2, true);
9426        e.jump_cursor(1, 0);
9427        // Drain any queue from the buffer setup above (none expected,
9428        // but be defensive).
9429        let _ = e.take_fold_ops();
9430        run_keys(&mut e, "zo");
9431        run_keys(&mut e, "zM");
9432        let ops = e.take_fold_ops();
9433        assert_eq!(ops.len(), 2);
9434        assert!(matches!(ops[0], FoldOp::OpenAt(1)));
9435        assert!(matches!(ops[1], FoldOp::CloseAll));
9436        // Second drain returns empty.
9437        assert!(e.take_fold_ops().is_empty());
9438    }
9439
9440    #[test]
9441    fn edit_pipeline_emits_invalidate_fold_op() {
9442        // The edit pipeline routes its fold invalidation through
9443        // `apply_fold_op` so hosts can observe + dedupe.
9444        use crate::types::FoldOp;
9445        let mut e = editor_with("a\nb\nc\nd");
9446        e.buffer_mut().add_fold(1, 2, true);
9447        e.jump_cursor(1, 0);
9448        let _ = e.take_fold_ops();
9449        run_keys(&mut e, "iX<Esc>");
9450        let ops = e.take_fold_ops();
9451        assert!(
9452            ops.iter().any(|op| matches!(op, FoldOp::Invalidate { .. })),
9453            "expected at least one Invalidate op, got {ops:?}"
9454        );
9455    }
9456
9457    #[test]
9458    fn dot_mark_jumps_to_last_edit_position() {
9459        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
9460        e.jump_cursor(2, 0);
9461        // Insert at line 2 — sets last_edit_pos.
9462        run_keys(&mut e, "iX<Esc>");
9463        let after_edit = e.cursor();
9464        // Move away.
9465        run_keys(&mut e, "gg");
9466        assert_eq!(e.cursor().0, 0);
9467        // `'.` jumps back to the edit's row (linewise variant).
9468        run_keys(&mut e, "'.");
9469        assert_eq!(e.cursor().0, after_edit.0);
9470    }
9471
9472    #[test]
9473    fn quote_quote_returns_to_pre_jump_position() {
9474        let mut e = editor_with_rows(50, 20);
9475        e.jump_cursor(10, 2);
9476        let before = e.cursor();
9477        // `G` is a big jump — pushes (10, 2) onto jump_back.
9478        run_keys(&mut e, "G");
9479        assert_ne!(e.cursor(), before);
9480        // `''` jumps back to the pre-jump position (linewise).
9481        run_keys(&mut e, "''");
9482        assert_eq!(e.cursor().0, before.0);
9483    }
9484
9485    #[test]
9486    fn backtick_backtick_restores_exact_pre_jump_pos() {
9487        let mut e = editor_with_rows(50, 20);
9488        e.jump_cursor(7, 3);
9489        let before = e.cursor();
9490        run_keys(&mut e, "G");
9491        run_keys(&mut e, "``");
9492        assert_eq!(e.cursor(), before);
9493    }
9494
9495    #[test]
9496    fn macro_record_and_replay_basic() {
9497        let mut e = editor_with("foo\nbar\nbaz");
9498        // Record into "a": insert "X" at line start, exit insert.
9499        run_keys(&mut e, "qaIX<Esc>jq");
9500        assert_eq!(e.buffer().lines()[0], "Xfoo");
9501        // Replay on the next two lines.
9502        run_keys(&mut e, "@a");
9503        assert_eq!(e.buffer().lines()[1], "Xbar");
9504        // @@ replays the last-played macro.
9505        run_keys(&mut e, "j@@");
9506        assert_eq!(e.buffer().lines()[2], "Xbaz");
9507    }
9508
9509    #[test]
9510    fn macro_count_replays_n_times() {
9511        let mut e = editor_with("a\nb\nc\nd\ne");
9512        // Record "j" — move down once.
9513        run_keys(&mut e, "qajq");
9514        assert_eq!(e.cursor().0, 1);
9515        // Replay 3 times via 3@a.
9516        run_keys(&mut e, "3@a");
9517        assert_eq!(e.cursor().0, 4);
9518    }
9519
9520    #[test]
9521    fn macro_capital_q_appends_to_lowercase_register() {
9522        let mut e = editor_with("hello");
9523        run_keys(&mut e, "qall<Esc>q");
9524        run_keys(&mut e, "qAhh<Esc>q");
9525        // Macros + named registers share storage now: register `a`
9526        // holds the encoded keystrokes from both recordings.
9527        let text = e.registers().read('a').unwrap().text.clone();
9528        assert!(text.contains("ll<Esc>"));
9529        assert!(text.contains("hh<Esc>"));
9530    }
9531
9532    #[test]
9533    fn buffer_selection_block_in_visual_block_mode() {
9534        use hjkl_buffer::{Position, Selection};
9535        let mut e = editor_with("aaaa\nbbbb\ncccc");
9536        run_keys(&mut e, "<C-v>jl");
9537        assert_eq!(
9538            e.buffer_selection(),
9539            Some(Selection::Block {
9540                anchor: Position::new(0, 0),
9541                head: Position::new(1, 1),
9542            })
9543        );
9544    }
9545
9546    // ─── Audit batch: lock in known-good behaviour ───────────────────────
9547
9548    #[test]
9549    fn n_after_question_mark_keeps_walking_backward() {
9550        // After committing a `?` search, `n` should continue in the
9551        // backward direction; `N` flips forward.
9552        let mut e = editor_with("foo bar foo baz foo end");
9553        e.jump_cursor(0, 22);
9554        run_keys(&mut e, "?foo<CR>");
9555        assert_eq!(e.cursor().1, 16);
9556        run_keys(&mut e, "n");
9557        assert_eq!(e.cursor().1, 8);
9558        run_keys(&mut e, "N");
9559        assert_eq!(e.cursor().1, 16);
9560    }
9561
9562    #[test]
9563    fn nested_macro_chord_records_literal_keys() {
9564        // `qa@bq` should capture `@` and `b` as literal keys in `a`,
9565        // not as a macro-replay invocation. Replay then re-runs them.
9566        let mut e = editor_with("alpha\nbeta\ngamma");
9567        // First record `b` as a noop-ish macro: just `l` (move right).
9568        run_keys(&mut e, "qblq");
9569        // Now record `a` as: enter insert, type X, exit, then trigger
9570        // `@b` which should run the macro inline during recording too.
9571        run_keys(&mut e, "qaIX<Esc>q");
9572        // `@a` re-runs the captured key sequence on a different line.
9573        e.jump_cursor(1, 0);
9574        run_keys(&mut e, "@a");
9575        assert_eq!(e.buffer().lines()[1], "Xbeta");
9576    }
9577
9578    #[test]
9579    fn shift_gt_motion_indents_one_line() {
9580        // `>w` over a single-line buffer should indent that line by
9581        // one shiftwidth — operator routes through the operator
9582        // pipeline like `dw` / `cw`.
9583        let mut e = editor_with("hello world");
9584        run_keys(&mut e, ">w");
9585        assert_eq!(e.buffer().lines()[0], "  hello world");
9586    }
9587
9588    #[test]
9589    fn shift_lt_motion_outdents_one_line() {
9590        let mut e = editor_with("    hello world");
9591        run_keys(&mut e, "<lt>w");
9592        // Outdent strips up to one shiftwidth (default 2).
9593        assert_eq!(e.buffer().lines()[0], "  hello world");
9594    }
9595
9596    #[test]
9597    fn shift_gt_text_object_indents_paragraph() {
9598        let mut e = editor_with("alpha\nbeta\ngamma\n\nrest");
9599        e.jump_cursor(0, 0);
9600        run_keys(&mut e, ">ip");
9601        assert_eq!(e.buffer().lines()[0], "  alpha");
9602        assert_eq!(e.buffer().lines()[1], "  beta");
9603        assert_eq!(e.buffer().lines()[2], "  gamma");
9604        // Blank separator + the next paragraph stay untouched.
9605        assert_eq!(e.buffer().lines()[4], "rest");
9606    }
9607
9608    #[test]
9609    fn ctrl_o_runs_exactly_one_normal_command() {
9610        // `Ctrl-O dw` returns to insert after the single `dw`. A
9611        // second `Ctrl-O` is needed for another normal command.
9612        let mut e = editor_with("alpha beta gamma");
9613        e.jump_cursor(0, 0);
9614        run_keys(&mut e, "i");
9615        e.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
9616        run_keys(&mut e, "dw");
9617        // First `dw` ran in normal; we're back in insert.
9618        assert_eq!(e.vim_mode(), VimMode::Insert);
9619        // Typing a char now inserts.
9620        run_keys(&mut e, "X");
9621        assert_eq!(e.buffer().lines()[0], "Xbeta gamma");
9622    }
9623
9624    #[test]
9625    fn macro_replay_respects_mode_switching() {
9626        // Recording `iX<Esc>0` should leave us in normal mode at col 0
9627        // after replay — the embedded Esc in the macro must drop the
9628        // replayed insert session.
9629        let mut e = editor_with("hi");
9630        run_keys(&mut e, "qaiX<Esc>0q");
9631        assert_eq!(e.vim_mode(), VimMode::Normal);
9632        // Replay on a fresh line.
9633        e.set_content("yo");
9634        run_keys(&mut e, "@a");
9635        assert_eq!(e.vim_mode(), VimMode::Normal);
9636        assert_eq!(e.cursor().1, 0);
9637        assert_eq!(e.buffer().lines()[0], "Xyo");
9638    }
9639
9640    #[test]
9641    fn macro_recorded_text_round_trips_through_register() {
9642        // After the macros-in-registers unification, recording into
9643        // `a` writes the encoded keystroke text into register `a`'s
9644        // slot. `@a` decodes back to inputs and replays.
9645        let mut e = editor_with("");
9646        run_keys(&mut e, "qaiX<Esc>q");
9647        let text = e.registers().read('a').unwrap().text.clone();
9648        assert!(text.starts_with("iX"));
9649        // Replay inserts another X at the cursor.
9650        run_keys(&mut e, "@a");
9651        assert_eq!(e.buffer().lines()[0], "XX");
9652    }
9653
9654    #[test]
9655    fn dot_after_macro_replays_macros_last_change() {
9656        // After `@a` runs a macro whose last mutation was an insert,
9657        // `.` should repeat that final change, not the whole macro.
9658        let mut e = editor_with("ab\ncd\nef");
9659        // Record: insert 'X' at line start, then move down. The last
9660        // mutation is the insert — `.` should re-apply just that.
9661        run_keys(&mut e, "qaIX<Esc>jq");
9662        assert_eq!(e.buffer().lines()[0], "Xab");
9663        run_keys(&mut e, "@a");
9664        assert_eq!(e.buffer().lines()[1], "Xcd");
9665        // `.` from the new cursor row repeats the last edit (the
9666        // insert `X`), not the whole macro (which would also `j`).
9667        let row_before_dot = e.cursor().0;
9668        run_keys(&mut e, ".");
9669        assert!(e.buffer().lines()[row_before_dot].starts_with('X'));
9670    }
9671
9672    // ── smartindent tests ────────────────────────────────────────────────
9673
9674    /// Build an editor with 4-space settings (expandtab, shiftwidth=4,
9675    /// softtabstop=4) for smartindent tests. Does NOT inherit the
9676    /// shiftwidth=2 override from `editor_with`.
9677    fn si_editor(content: &str) -> Editor {
9678        let opts = crate::types::Options {
9679            shiftwidth: 4,
9680            softtabstop: 4,
9681            expandtab: true,
9682            smartindent: true,
9683            autoindent: true,
9684            ..crate::types::Options::default()
9685        };
9686        let mut e = Editor::new(
9687            hjkl_buffer::Buffer::new(),
9688            crate::types::DefaultHost::new(),
9689            opts,
9690        );
9691        e.set_content(content);
9692        e
9693    }
9694
9695    #[test]
9696    fn smartindent_bumps_indent_after_open_brace() {
9697        // "fn foo() {" + Enter → new line has 4 spaces of indent
9698        let mut e = si_editor("fn foo() {");
9699        e.jump_cursor(0, 10); // after the `{`
9700        run_keys(&mut e, "i<CR>");
9701        assert_eq!(
9702            e.buffer().lines()[1],
9703            "    ",
9704            "smartindent should bump one shiftwidth after {{"
9705        );
9706    }
9707
9708    #[test]
9709    fn smartindent_no_bump_when_off() {
9710        // Same input but smartindent=false → just copies prev leading ws
9711        // (which is empty on "fn foo() {"), so new line is empty.
9712        let mut e = si_editor("fn foo() {");
9713        e.settings_mut().smartindent = false;
9714        e.jump_cursor(0, 10);
9715        run_keys(&mut e, "i<CR>");
9716        assert_eq!(
9717            e.buffer().lines()[1],
9718            "",
9719            "without smartindent, no bump: new line copies empty leading ws"
9720        );
9721    }
9722
9723    #[test]
9724    fn smartindent_uses_tab_when_noexpandtab() {
9725        // noexpandtab + prev line ends in `{` → new line starts with `\t`
9726        let opts = crate::types::Options {
9727            shiftwidth: 4,
9728            softtabstop: 0,
9729            expandtab: false,
9730            smartindent: true,
9731            autoindent: true,
9732            ..crate::types::Options::default()
9733        };
9734        let mut e = Editor::new(
9735            hjkl_buffer::Buffer::new(),
9736            crate::types::DefaultHost::new(),
9737            opts,
9738        );
9739        e.set_content("fn foo() {");
9740        e.jump_cursor(0, 10);
9741        run_keys(&mut e, "i<CR>");
9742        assert_eq!(
9743            e.buffer().lines()[1],
9744            "\t",
9745            "noexpandtab: smartindent bump inserts a literal tab"
9746        );
9747    }
9748
9749    #[test]
9750    fn smartindent_dedent_on_close_brace() {
9751        // Line is "    " (4 spaces), cursor at col 4, type `}` →
9752        // leading spaces stripped, `}` at col 0.
9753        let mut e = si_editor("fn foo() {");
9754        // Add a second line with only indentation.
9755        e.set_content("fn foo() {\n    ");
9756        e.jump_cursor(1, 4); // end of "    "
9757        run_keys(&mut e, "i}");
9758        assert_eq!(
9759            e.buffer().lines()[1],
9760            "}",
9761            "close brace on whitespace-only line should dedent"
9762        );
9763        assert_eq!(e.cursor(), (1, 1), "cursor should be after the `}}`");
9764    }
9765
9766    #[test]
9767    fn smartindent_no_dedent_when_off() {
9768        // Same setup but smartindent=false → `}` appended normally.
9769        let mut e = si_editor("fn foo() {\n    ");
9770        e.settings_mut().smartindent = false;
9771        e.jump_cursor(1, 4);
9772        run_keys(&mut e, "i}");
9773        assert_eq!(
9774            e.buffer().lines()[1],
9775            "    }",
9776            "without smartindent, `}}` just appends at cursor"
9777        );
9778    }
9779
9780    #[test]
9781    fn smartindent_no_dedent_mid_line() {
9782        // Line has "    let x = 1", cursor after `1`; type `}` → no
9783        // dedent because chars before cursor aren't all whitespace.
9784        let mut e = si_editor("    let x = 1");
9785        e.jump_cursor(0, 13); // after `1`
9786        run_keys(&mut e, "i}");
9787        assert_eq!(
9788            e.buffer().lines()[0],
9789            "    let x = 1}",
9790            "mid-line `}}` should not dedent"
9791        );
9792    }
9793
9794    // ─── Vim-compat divergence fixes (issue #24) ─────────────────────
9795
9796    // Fix #1: x/X populate the unnamed register.
9797    #[test]
9798    fn count_5x_fills_unnamed_register() {
9799        let mut e = editor_with("hello world\n");
9800        e.jump_cursor(0, 0);
9801        run_keys(&mut e, "5x");
9802        assert_eq!(e.buffer().lines()[0], " world");
9803        assert_eq!(e.cursor(), (0, 0));
9804        assert_eq!(e.yank(), "hello");
9805    }
9806
9807    #[test]
9808    fn x_fills_unnamed_register_single_char() {
9809        let mut e = editor_with("abc\n");
9810        e.jump_cursor(0, 0);
9811        run_keys(&mut e, "x");
9812        assert_eq!(e.buffer().lines()[0], "bc");
9813        assert_eq!(e.yank(), "a");
9814    }
9815
9816    #[test]
9817    fn big_x_fills_unnamed_register() {
9818        let mut e = editor_with("hello\n");
9819        e.jump_cursor(0, 3);
9820        run_keys(&mut e, "X");
9821        assert_eq!(e.buffer().lines()[0], "helo");
9822        assert_eq!(e.yank(), "l");
9823    }
9824
9825    // Fix #2: G lands on last content row, not phantom trailing-empty row.
9826    #[test]
9827    fn g_motion_trailing_newline_lands_on_last_content_row() {
9828        let mut e = editor_with("foo\nbar\nbaz\n");
9829        e.jump_cursor(0, 0);
9830        run_keys(&mut e, "G");
9831        // buffer is stored as ["foo","bar","baz",""] — G must land on row 2 ("baz").
9832        assert_eq!(
9833            e.cursor().0,
9834            2,
9835            "G should land on row 2 (baz), not row 3 (phantom empty)"
9836        );
9837    }
9838
9839    // Fix #3: dd on last line clamps cursor to new last content row.
9840    #[test]
9841    fn dd_last_line_clamps_cursor_to_new_last_row() {
9842        let mut e = editor_with("foo\nbar\n");
9843        e.jump_cursor(1, 0);
9844        run_keys(&mut e, "dd");
9845        assert_eq!(e.buffer().lines()[0], "foo");
9846        assert_eq!(
9847            e.cursor(),
9848            (0, 0),
9849            "cursor should clamp to row 0 after dd on last content line"
9850        );
9851    }
9852
9853    // Fix #4: d$ cursor lands on last char, not one past.
9854    #[test]
9855    fn d_dollar_cursor_on_last_char() {
9856        let mut e = editor_with("hello world\n");
9857        e.jump_cursor(0, 5);
9858        run_keys(&mut e, "d$");
9859        assert_eq!(e.buffer().lines()[0], "hello");
9860        assert_eq!(
9861            e.cursor(),
9862            (0, 4),
9863            "d$ should leave cursor on col 4, not col 5"
9864        );
9865    }
9866
9867    // Fix #5: undo clamps cursor to last valid normal-mode col.
9868    #[test]
9869    fn undo_insert_clamps_cursor_to_last_valid_col() {
9870        let mut e = editor_with("hello\n");
9871        e.jump_cursor(0, 5); // one-past-last, as in oracle initial_cursor
9872        run_keys(&mut e, "a world<Esc>u");
9873        assert_eq!(e.buffer().lines()[0], "hello");
9874        assert_eq!(
9875            e.cursor(),
9876            (0, 4),
9877            "undo should clamp cursor to col 4 on 'hello'"
9878        );
9879    }
9880
9881    // Fix #6: da" eats trailing whitespace when present.
9882    #[test]
9883    fn da_doublequote_eats_trailing_whitespace() {
9884        let mut e = editor_with("say \"hello\" there\n");
9885        e.jump_cursor(0, 6);
9886        run_keys(&mut e, "da\"");
9887        assert_eq!(e.buffer().lines()[0], "say there");
9888        assert_eq!(e.cursor().1, 4, "cursor should be at col 4 after da\"");
9889    }
9890
9891    // Fix #7: daB cursor off-by-one — clamp to new last col.
9892    #[test]
9893    fn dab_cursor_col_clamped_after_delete() {
9894        let mut e = editor_with("fn x() {\n    body\n}\n");
9895        e.jump_cursor(1, 4);
9896        run_keys(&mut e, "daB");
9897        assert_eq!(e.buffer().lines()[0], "fn x() ");
9898        assert_eq!(
9899            e.cursor(),
9900            (0, 6),
9901            "daB should leave cursor at col 6, not 7"
9902        );
9903    }
9904
9905    // Fix #8: diB preserves surrounding newlines on multi-line block.
9906    #[test]
9907    fn dib_preserves_surrounding_newlines() {
9908        let mut e = editor_with("{\n    body\n}\n");
9909        e.jump_cursor(1, 4);
9910        run_keys(&mut e, "diB");
9911        assert_eq!(e.buffer().lines()[0], "{");
9912        assert_eq!(e.buffer().lines()[1], "}");
9913        assert_eq!(e.cursor().0, 1, "cursor should be on the '}}' line");
9914    }
9915
9916    #[test]
9917    fn is_chord_pending_tracks_replace_state() {
9918        let mut e = editor_with("abc\n");
9919        assert!(!e.is_chord_pending());
9920        // Press `r` — engine enters Pending::Replace.
9921        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
9922        assert!(e.is_chord_pending(), "engine should be pending after r");
9923        // Press a char to complete — pending clears.
9924        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
9925        assert!(
9926            !e.is_chord_pending(),
9927            "engine pending should clear after replace"
9928        );
9929    }
9930}