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