Skip to main content

hjkl_engine/
vim.rs

1//! Vim-mode engine.
2//!
3//! Implements a command grammar of the form
4//!
5//! ```text
6//! Command := count? (operator count? (motion | text-object)
7//!                   | motion
8//!                   | insert-entry
9//!                   | misc)
10//! ```
11//!
12//! The parser is a small state machine driven by one `Input` at a time.
13//! Motions and text objects produce a [`Range`] (with inclusive/exclusive
14//! / linewise classification). A single [`Operator`] implementation
15//! applies a range — so `dw`, `d$`, `daw`, and visual `d` all go through
16//! the same code path.
17//!
18//! The most recent mutating command is stored in
19//! [`VimState::last_change`] so `.` can replay it.
20//!
21//! # Roadmap
22//!
23//! Tracked in the original plan at
24//! `~/.claude/plans/look-at-the-vim-curried-fern.md`. Phases still
25//! outstanding — each one can land as an isolated PR.
26//!
27//! ## P3 — Registers & marks
28//!
29//! - TODO: `RegisterBank` indexed by char:
30//!     - unnamed `""`, last-yank `"0`, small-delete `"-`
31//!     - named `"a-"z` (uppercase `"A-"Z` appends instead of overwriting)
32//!     - blackhole `"_`
33//!     - system clipboard `"+` / `"*` (wire to `crate::clipboard::Clipboard`)
34//!     - read-only `":`, `".`, `"%` — surface in `:reg` output
35//! - TODO: route every yank / cut / paste through the bank. Parser needs
36//!   a `"{reg}` prefix state that captures the target register before a
37//!   count / operator.
38//! - TODO: `m{a-z}` sets a mark in a `HashMap<char, (buffer_id, row, col)>`;
39//!   `'x` jumps to the line (FirstNonBlank), `` `x `` to the exact cell.
40//!   Uppercase marks are global across tabs; lowercase are per-buffer.
41//! - TODO: `''` and `` `` `` jump to the last-jump position; `'[` `']`
42//!   `'<` `'>` bound the last change / visual region.
43//! - TODO: `:reg` and `:marks` ex commands.
44//!
45//! ## P4 — Macros
46//!
47//! - TODO: `q{a-z}` starts recording raw `Input`s into the register;
48//!   next `q` stops.
49//! - TODO: `@{a-z}` replays the register by re-feeding inputs through
50//!   `step`. `@@` repeats the last macro. Nested macros need a sane
51//!   depth cap (e.g. 100) to avoid runaway loops.
52//! - TODO: ensure recording doesn't capture the initial `q{a-z}` itself.
53//!
54//! ## P6 — Polish (still outstanding)
55//!
56//! - TODO: indent operators `>` / `<` (with line + text-object targets).
57//! - TODO: format operator `=` — map to whatever SQL formatter we wire
58//!   up; for now stub that returns the range unchanged with a toast.
59//! - TODO: case operators `gU` / `gu` / `g~` on a range (already have
60//!   single-char `~`).
61//! - TODO: screen motions `H` / `M` / `L` once we track the render
62//!   viewport height inside Editor.
63//! - TODO: scroll-to-cursor motions `zz` / `zt` / `zb`.
64//!
65//! ## Known substrate / divergence notes
66//!
67//! - TODO: insert-mode indent helpers — `Ctrl-t` / `Ctrl-d` (increase /
68//!   decrease indent on current line) and `Ctrl-r <reg>` (paste from a
69//!   register). `Ctrl-r` needs the `RegisterBank` from P3 to be useful.
70//! - TODO: `/` and `?` search prompts still live in `the host/src/lib.rs`.
71//!   The plan calls for moving them into the editor (so the editor owns
72//!   `last_search_pattern` rather than the TUI loop). Safe to defer.
73
74use crate::VimMode;
75use crate::input::{Input, Key};
76
77use crate::buf_helpers::{
78    buf_cursor_pos, buf_line, buf_line_bytes, buf_line_chars, buf_lines_to_vec, buf_row_count,
79    buf_set_cursor_pos, buf_set_cursor_rc,
80};
81use crate::editor::Editor;
82
83// ─── Modes & parser state ───────────────────────────────────────────────────
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum Mode {
87    #[default]
88    Normal,
89    Insert,
90    Visual,
91    VisualLine,
92    /// Column-oriented selection (`Ctrl-V`). Unlike the other visual
93    /// modes this one doesn't use tui-textarea's single-range selection
94    /// — the block corners live in [`VimState::block_anchor`] and the
95    /// live cursor. Operators read the rectangle off those two points.
96    VisualBlock,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100enum Pending {
101    #[default]
102    None,
103    /// Operator seen; still waiting for a motion / text-object / double-op.
104    /// `count1` is any count pressed before the operator.
105    Op { op: Operator, count1: usize },
106    /// Operator + 'i' or 'a' seen; waiting for the text-object character.
107    OpTextObj {
108        op: Operator,
109        count1: usize,
110        inner: bool,
111    },
112    /// Operator + 'g' seen (for `dgg`).
113    OpG { op: Operator, count1: usize },
114    /// Bare `g` seen in normal/visual — looking for `g`, `e`, `E`, …
115    G,
116    /// Bare `f`/`F`/`t`/`T` — looking for the target char.
117    Find { forward: bool, till: bool },
118    /// Operator + `f`/`F`/`t`/`T` — looking for target char.
119    OpFind {
120        op: Operator,
121        count1: usize,
122        forward: bool,
123        till: bool,
124    },
125    /// `r` pressed — waiting for the replacement char.
126    Replace,
127    /// Visual mode + `i` or `a` pressed — waiting for the text-object
128    /// character to extend the selection over.
129    VisualTextObj { inner: bool },
130    /// Bare `z` seen — looking for `z` (center), `t` (top), `b` (bottom).
131    Z,
132    /// `m` pressed — waiting for the mark letter to set.
133    SetMark,
134    /// `'` pressed — waiting for the mark letter to jump to its line
135    /// (lands on first non-blank, linewise for operators).
136    GotoMarkLine,
137    /// `` ` `` pressed — waiting for the mark letter to jump to the
138    /// exact `(row, col)` stored at set time (charwise for operators).
139    GotoMarkChar,
140    /// `"` pressed — waiting for the register selector. The next char
141    /// (`a`–`z`, `A`–`Z`, `0`–`9`, or `"`) sets `pending_register`.
142    SelectRegister,
143    /// `q` pressed (not currently recording) — waiting for the macro
144    /// register name. The macro records every key after the chord
145    /// resolves, until a bare `q` ends the recording.
146    RecordMacroTarget,
147    /// `@` pressed — waiting for the macro register name to play.
148    /// `count` is the prefix multiplier (`3@a` plays the macro 3
149    /// times); 0 means "no prefix" and is treated as 1.
150    PlayMacroTarget { count: usize },
151}
152
153// ─── Operator / Motion / TextObject ────────────────────────────────────────
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum Operator {
157    Delete,
158    Change,
159    Yank,
160    /// `gU{motion}` — uppercase the range. Entered via the `g` prefix
161    /// in normal mode or `U` in visual mode.
162    Uppercase,
163    /// `gu{motion}` — lowercase the range. `u` in visual mode.
164    Lowercase,
165    /// `g~{motion}` — toggle case of the range. `~` in visual mode
166    /// (character at the cursor for the single-char `~` command stays
167    /// its own code path in normal mode).
168    ToggleCase,
169    /// `>{motion}` — indent the line range by `shiftwidth` spaces.
170    /// Always linewise, even when the motion is char-wise — mirrors
171    /// vim's behaviour where `>w` indents the current line, not the
172    /// word on it.
173    Indent,
174    /// `<{motion}` — outdent the line range (remove up to
175    /// `shiftwidth` leading spaces per line).
176    Outdent,
177    /// `zf{motion}` / `zf{textobj}` / Visual `zf` — create a closed
178    /// fold spanning the row range. Doesn't mutate the buffer text;
179    /// cursor restores to the operator's start position.
180    Fold,
181    /// `gq{motion}` — reflow the row range to `settings.textwidth`.
182    /// Greedy word-wrap: collapses each paragraph (blank-line-bounded
183    /// run) into space-separated words, then re-emits lines whose
184    /// width stays under `textwidth`. Always linewise, like indent.
185    Reflow,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum Motion {
190    Left,
191    Right,
192    Up,
193    Down,
194    WordFwd,
195    BigWordFwd,
196    WordBack,
197    BigWordBack,
198    WordEnd,
199    BigWordEnd,
200    /// `ge` — backward word end.
201    WordEndBack,
202    /// `gE` — backward WORD end.
203    BigWordEndBack,
204    LineStart,
205    FirstNonBlank,
206    LineEnd,
207    FileTop,
208    FileBottom,
209    Find {
210        ch: char,
211        forward: bool,
212        till: bool,
213    },
214    FindRepeat {
215        reverse: bool,
216    },
217    MatchBracket,
218    WordAtCursor {
219        forward: bool,
220        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
221        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
222        whole_word: bool,
223    },
224    /// `n` / `N` — repeat the last `/` or `?` search.
225    SearchNext {
226        reverse: bool,
227    },
228    /// `H` — cursor to viewport top (plus `count - 1` rows down).
229    ViewportTop,
230    /// `M` — cursor to viewport middle.
231    ViewportMiddle,
232    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
233    ViewportBottom,
234    /// `g_` — last non-blank char on the line.
235    LastNonBlank,
236    /// `gM` — cursor to the middle char column of the current line
237    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
238    LineMiddle,
239    /// `{` — previous paragraph (preceding blank line, or top).
240    ParagraphPrev,
241    /// `}` — next paragraph (following blank line, or bottom).
242    ParagraphNext,
243    /// `(` — previous sentence boundary.
244    SentencePrev,
245    /// `)` — next sentence boundary.
246    SentenceNext,
247    /// `gj` — `count` visual rows down (one screen segment per step
248    /// under `:set wrap`; falls back to `Down` otherwise).
249    ScreenDown,
250    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
251    ScreenUp,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum TextObject {
256    Word {
257        big: bool,
258    },
259    Quote(char),
260    Bracket(char),
261    Paragraph,
262    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
263    /// content between `>` and `</`; `inner = false` covers the open
264    /// tag through the close tag inclusive.
265    XmlTag,
266    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
267    /// followed by whitespace or end-of-line. `inner = true` covers
268    /// the sentence text only; `inner = false` includes trailing
269    /// whitespace.
270    Sentence,
271}
272
273/// Classification determines how operators treat the range end.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum MotionKind {
276    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
277    Exclusive,
278    /// Range end is inclusive. Typical: e, f, t, %.
279    Inclusive,
280    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
281    Linewise,
282}
283
284// ─── Dot-repeat storage ────────────────────────────────────────────────────
285
286/// Information needed to replay a mutating change via `.`.
287#[derive(Debug, Clone)]
288enum LastChange {
289    /// Operator over a motion.
290    OpMotion {
291        op: Operator,
292        motion: Motion,
293        count: usize,
294        inserted: Option<String>,
295    },
296    /// Operator over a text-object.
297    OpTextObj {
298        op: Operator,
299        obj: TextObject,
300        inner: bool,
301        inserted: Option<String>,
302    },
303    /// `dd`, `cc`, `yy` with a count.
304    LineOp {
305        op: Operator,
306        count: usize,
307        inserted: Option<String>,
308    },
309    /// `x`, `X` with a count.
310    CharDel { forward: bool, count: usize },
311    /// `r<ch>` with a count.
312    ReplaceChar { ch: char, count: usize },
313    /// `~` with a count.
314    ToggleCase { count: usize },
315    /// `J` with a count.
316    JoinLine { count: usize },
317    /// `p` / `P` with a count.
318    Paste { before: bool, count: usize },
319    /// `D` (delete to EOL).
320    DeleteToEol { inserted: Option<String> },
321    /// `o` / `O` + the inserted text.
322    OpenLine { above: bool, inserted: String },
323    /// `i`/`I`/`a`/`A` + inserted text.
324    InsertAt {
325        entry: InsertEntry,
326        inserted: String,
327        count: usize,
328    },
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332enum InsertEntry {
333    I,
334    A,
335    ShiftI,
336    ShiftA,
337}
338
339// ─── VimState ──────────────────────────────────────────────────────────────
340
341#[derive(Default)]
342pub struct VimState {
343    mode: Mode,
344    pending: Pending,
345    count: usize,
346    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
347    last_find: Option<(char, bool, bool)>,
348    last_change: Option<LastChange>,
349    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
350    insert_session: Option<InsertSession>,
351    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
352    /// to compute the highlight range and the operator range without
353    /// relying on tui-textarea's live selection.
354    pub(super) visual_anchor: (usize, usize),
355    /// Row anchor for VisualLine mode.
356    pub(super) visual_line_anchor: usize,
357    /// (row, col) anchor for VisualBlock mode. The live cursor is the
358    /// opposite corner.
359    pub(super) block_anchor: (usize, usize),
360    /// Intended "virtual" column for the block's active corner. j/k
361    /// clamp cursor.col to shorter rows, which would collapse the
362    /// block across ragged content — so we remember the desired column
363    /// separately and use it for block bounds / insert-column
364    /// computations. Updated by h/l only.
365    pub(super) block_vcol: usize,
366    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
367    pub(super) yank_linewise: bool,
368    /// Active register selector — set by `"reg` prefix, consumed by
369    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
370    pub(super) pending_register: Option<char>,
371    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
372    /// While `Some`, every consumed `Input` is appended to
373    /// `recording_keys`.
374    pub(super) recording_macro: Option<char>,
375    /// Keys recorded into the in-progress macro. On `q` finish, these
376    /// are encoded via [`crate::input::encode_macro`] and written to
377    /// the matching named register slot, so macros and yanks share a
378    /// single store.
379    pub(super) recording_keys: Vec<crate::input::Input>,
380    /// Set during `@reg` replay so the recorder doesn't capture the
381    /// replayed keystrokes a second time.
382    pub(super) replaying_macro: bool,
383    /// Last register played via `@reg`. `@@` re-plays this one.
384    pub(super) last_macro: Option<char>,
385    /// Position of the most recent buffer mutation. Surfaced via
386    /// the `'.` / `` `. `` marks for quick "back to last edit".
387    pub(super) last_edit_pos: Option<(usize, usize)>,
388    /// Bounded ring of recent edit positions (newest at the back).
389    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
390    /// at [`CHANGE_LIST_MAX`].
391    pub(super) change_list: Vec<(usize, usize)>,
392    /// Index into `change_list` while walking. `None` outside a walk —
393    /// any new edit clears it (and trims forward entries past it).
394    pub(super) change_list_cursor: Option<usize>,
395    /// Snapshot of the last visual selection for `gv` re-entry.
396    /// Stored on every Visual / VisualLine / VisualBlock exit.
397    pub(super) last_visual: Option<LastVisual>,
398    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
399    /// pass doesn't override the user's explicit viewport pinning.
400    /// Cleared every step.
401    pub(super) viewport_pinned: bool,
402    /// Set while replaying `.` / last-change so we don't re-record it.
403    replaying: bool,
404    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
405    /// normal-mode command we return to Insert.
406    one_shot_normal: bool,
407    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
408    pub(super) search_prompt: Option<SearchPrompt>,
409    /// Most recent committed search pattern. Surfaced to host apps via
410    /// [`Editor::last_search`] so their status line can render a hint
411    /// and so `n` / `N` have something to repeat.
412    pub(super) last_search: Option<String>,
413    /// Direction of the last committed search. `n` repeats this; `N`
414    /// inverts it. Defaults to forward so a never-searched buffer's
415    /// `n` still walks downward.
416    pub(super) last_search_forward: bool,
417    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
418    /// with the pre-motion cursor when a "big jump" motion fires
419    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
420    /// `?`). Capped at 100 entries.
421    pub(super) jump_back: Vec<(usize, usize)>,
422    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
423    /// jump, matching vim's "branch off trims forward history" rule.
424    pub(super) jump_fwd: Vec<(usize, usize)>,
425    /// Set by `Ctrl-R` in insert mode while waiting for the register
426    /// selector. The next typed char names the register; its contents
427    /// are inserted inline at the cursor and the flag clears.
428    pub(super) insert_pending_register: bool,
429    /// Bounded history of committed `/` / `?` search patterns. Newest
430    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
431    /// avoid unbounded growth on long sessions.
432    pub(super) search_history: Vec<String>,
433    /// Index into `search_history` while the user walks past patterns
434    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
435    /// — typing or backspacing in the prompt resets it so the next
436    /// `Ctrl-P` starts from the most recent entry again.
437    pub(super) search_history_cursor: Option<usize>,
438    /// Wall-clock instant of the last keystroke. Drives the
439    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
440    /// exceeds the configured budget, any pending prefix is cleared
441    /// before the new key dispatches. `None` before the first key.
442    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
443    /// [`crate::types::Host::now`] via `last_input_host_at`. This
444    /// `Instant`-flavoured field stays for snapshot tests that still
445    /// observe it directly.
446    pub(super) last_input_at: Option<std::time::Instant>,
447    /// `Host::now()` reading at the last keystroke. Drives
448    /// `:set timeoutlen` so macro replay / headless drivers stay
449    /// deterministic regardless of wall-clock skew.
450    pub(super) last_input_host_at: Option<core::time::Duration>,
451}
452
453const SEARCH_HISTORY_MAX: usize = 100;
454pub(crate) const CHANGE_LIST_MAX: usize = 100;
455
456/// Active `/` or `?` search prompt. Text mutations drive the textarea's
457/// live search pattern so matches highlight as the user types.
458#[derive(Debug, Clone)]
459pub struct SearchPrompt {
460    pub text: String,
461    pub cursor: usize,
462    pub forward: bool,
463}
464
465#[derive(Debug, Clone)]
466struct InsertSession {
467    count: usize,
468    /// Min/max row visited during this session. Widens on every key.
469    row_min: usize,
470    row_max: usize,
471    /// Snapshot of the full buffer at session entry. Used to diff the
472    /// affected row window at finish without being fooled by cursor
473    /// navigation through rows the user never edited.
474    before_lines: Vec<String>,
475    reason: InsertReason,
476}
477
478#[derive(Debug, Clone)]
479enum InsertReason {
480    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
481    Enter(InsertEntry),
482    /// Entry via `o`/`O` — records OpenLine on Esc.
483    Open { above: bool },
484    /// Entry via an operator's change side-effect. Retro-fills the
485    /// stored last-change's `inserted` field on Esc.
486    AfterChange,
487    /// Entry via `C` (delete to EOL + insert).
488    DeleteToEol,
489    /// Entry via an insert triggered during dot-replay — don't touch
490    /// last_change because the outer replay will restore it.
491    ReplayOnly,
492    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
493    /// every row in `top..=bot`. `col` is the start column for `I`, the
494    /// one-past-block-end column for `A`.
495    BlockEdge { top: usize, bot: usize, col: usize },
496    /// `R` — Replace mode. Each typed char overwrites the cell under
497    /// the cursor instead of inserting; at end-of-line the session
498    /// falls through to insert (same as vim).
499    Replace,
500}
501
502/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
503/// visual selection). `mode` carries which visual flavour to
504/// restore; `anchor` / `cursor` mean different things per flavour:
505///
506/// - `Visual`     — `anchor` is the char-wise visual anchor.
507/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
508///   `anchor.1` is unused.
509/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
510///   sticky vcol that survives j/k clamping.
511#[derive(Debug, Clone, Copy)]
512pub(super) struct LastVisual {
513    pub mode: Mode,
514    pub anchor: (usize, usize),
515    pub cursor: (usize, usize),
516    pub block_vcol: usize,
517}
518
519impl VimState {
520    pub fn public_mode(&self) -> VimMode {
521        match self.mode {
522            Mode::Normal => VimMode::Normal,
523            Mode::Insert => VimMode::Insert,
524            Mode::Visual => VimMode::Visual,
525            Mode::VisualLine => VimMode::VisualLine,
526            Mode::VisualBlock => VimMode::VisualBlock,
527        }
528    }
529
530    pub fn force_normal(&mut self) {
531        self.mode = Mode::Normal;
532        self.pending = Pending::None;
533        self.count = 0;
534        self.insert_session = None;
535    }
536
537    /// Reset every prefix-tracking field so the next keystroke starts
538    /// a fresh sequence. Drives `:set timeoutlen` — when the user
539    /// pauses past the configured budget, [`crate::vim::step`] calls
540    /// this before dispatching the new key.
541    ///
542    /// Resets: `pending`, `count`, `pending_register`,
543    /// `insert_pending_register`. Does NOT touch `mode`,
544    /// `insert_session`, marks, jump list, or visual anchors —
545    /// those aren't part of the in-flight chord.
546    pub(crate) fn clear_pending_prefix(&mut self) {
547        self.pending = Pending::None;
548        self.count = 0;
549        self.pending_register = None;
550        self.insert_pending_register = false;
551    }
552
553    pub fn is_visual(&self) -> bool {
554        matches!(
555            self.mode,
556            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
557        )
558    }
559
560    pub fn is_visual_char(&self) -> bool {
561        self.mode == Mode::Visual
562    }
563
564    pub fn enter_visual(&mut self, anchor: (usize, usize)) {
565        self.visual_anchor = anchor;
566        self.mode = Mode::Visual;
567    }
568
569    /// The pending repeat count (typed digits before a motion/operator),
570    /// or `None` when no digits are pending. Zero is treated as absent.
571    pub(crate) fn pending_count_val(&self) -> Option<u32> {
572        if self.count == 0 {
573            None
574        } else {
575            Some(self.count as u32)
576        }
577    }
578
579    /// `true` when an in-flight chord is awaiting more keys. Inverse of
580    /// `matches!(self.pending, Pending::None)`.
581    pub(crate) fn is_chord_pending(&self) -> bool {
582        !matches!(self.pending, Pending::None)
583    }
584
585    /// Return a single char representing the pending operator, if any.
586    /// Used by host apps (status line "showcmd" area) to display e.g.
587    /// `d`, `y`, `c` while waiting for a motion.
588    pub(crate) fn pending_op_char(&self) -> Option<char> {
589        let op = match &self.pending {
590            Pending::Op { op, .. }
591            | Pending::OpTextObj { op, .. }
592            | Pending::OpG { op, .. }
593            | Pending::OpFind { op, .. } => Some(*op),
594            _ => None,
595        };
596        op.map(|o| match o {
597            Operator::Delete => 'd',
598            Operator::Change => 'c',
599            Operator::Yank => 'y',
600            Operator::Uppercase => 'U',
601            Operator::Lowercase => 'u',
602            Operator::ToggleCase => '~',
603            Operator::Indent => '>',
604            Operator::Outdent => '<',
605            Operator::Fold => 'z',
606            Operator::Reflow => 'q',
607        })
608    }
609}
610
611// ─── Entry point ───────────────────────────────────────────────────────────
612
613/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
614/// live search highlight until the user commits a query. `last_search`
615/// is preserved so an empty `<CR>` can re-run the previous pattern.
616fn enter_search<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, forward: bool) {
617    ed.vim.search_prompt = Some(SearchPrompt {
618        text: String::new(),
619        cursor: 0,
620        forward,
621    });
622    ed.vim.search_history_cursor = None;
623    // 0.0.37: clear via the engine search state (the buffer-side
624    // bridge from 0.0.35 was removed in this patch — the `BufferView`
625    // renderer reads the pattern from `Editor::search_state()`).
626    ed.set_search_pattern(None);
627}
628
629/// Compile `pattern` into a regex and push it onto the migration
630/// buffer's search state. Invalid patterns clear the highlight (the
631/// user is mid-typing a regex like `[` and we don't want to flash an
632/// error).
633fn push_search_pattern<H: crate::types::Host>(
634    ed: &mut Editor<hjkl_buffer::Buffer, H>,
635    pattern: &str,
636) {
637    let compiled = if pattern.is_empty() {
638        None
639    } else {
640        // `:set ignorecase` flips every search pattern to case-insensitive
641        // unless the user already prefixed an explicit `(?i)` / `(?-i)`
642        // (regex crate honours those even when we layer another `(?i)`).
643        // `:set smartcase` re-enables case sensitivity for any pattern
644        // that contains an uppercase letter — matches vim's combined
645        // `ignorecase` + `smartcase` behaviour.
646        let case_insensitive = ed.settings().ignore_case
647            && !(ed.settings().smartcase && pattern.chars().any(|c| c.is_uppercase()));
648        let effective: std::borrow::Cow<'_, str> = if case_insensitive {
649            std::borrow::Cow::Owned(format!("(?i){pattern}"))
650        } else {
651            std::borrow::Cow::Borrowed(pattern)
652        };
653        regex::Regex::new(&effective).ok()
654    };
655    let wrap = ed.settings().wrapscan;
656    // 0.0.37: search FSM lives entirely on Editor — pattern + wrap
657    // policy + per-row match cache. The `Search` trait impl always
658    // wraps; engine code honours `wrap_around` before invoking it.
659    ed.set_search_pattern(compiled);
660    ed.search_state_mut().wrap_around = wrap;
661}
662
663fn step_search_prompt<H: crate::types::Host>(
664    ed: &mut Editor<hjkl_buffer::Buffer, H>,
665    input: Input,
666) -> bool {
667    // Ctrl-P / Ctrl-N (and Up / Down) walk the search history. Handled
668    // before the regular char/backspace branches so `Ctrl-P` doesn't
669    // type a literal `p`.
670    let history_dir = match (input.key, input.ctrl) {
671        (Key::Char('p'), true) | (Key::Up, _) => Some(-1),
672        (Key::Char('n'), true) | (Key::Down, _) => Some(1),
673        _ => None,
674    };
675    if let Some(dir) = history_dir {
676        walk_search_history(ed, dir);
677        return true;
678    }
679    match input.key {
680        Key::Esc => {
681            // Cancel. Drop the prompt but keep the highlighted matches
682            // so `n` / `N` can repeat whatever was typed.
683            let text = ed
684                .vim
685                .search_prompt
686                .take()
687                .map(|p| p.text)
688                .unwrap_or_default();
689            if !text.is_empty() {
690                ed.vim.last_search = Some(text);
691            }
692            ed.vim.search_history_cursor = None;
693        }
694        Key::Enter => {
695            let prompt = ed.vim.search_prompt.take();
696            if let Some(p) = prompt {
697                // Empty `/<CR>` (or `?<CR>`) re-runs the previous search
698                // pattern in the prompt's direction — vim parity.
699                let pattern = if p.text.is_empty() {
700                    ed.vim.last_search.clone()
701                } else {
702                    Some(p.text.clone())
703                };
704                if let Some(pattern) = pattern {
705                    push_search_pattern(ed, &pattern);
706                    let pre = ed.cursor();
707                    if p.forward {
708                        ed.search_advance_forward(true);
709                    } else {
710                        ed.search_advance_backward(true);
711                    }
712                    ed.push_buffer_cursor_to_textarea();
713                    if ed.cursor() != pre {
714                        push_jump(ed, pre);
715                    }
716                    record_search_history(ed, &pattern);
717                    ed.vim.last_search = Some(pattern);
718                    ed.vim.last_search_forward = p.forward;
719                }
720            }
721            ed.vim.search_history_cursor = None;
722        }
723        Key::Backspace => {
724            ed.vim.search_history_cursor = None;
725            let new_text = ed.vim.search_prompt.as_mut().and_then(|p| {
726                if p.text.pop().is_some() {
727                    p.cursor = p.text.chars().count();
728                    Some(p.text.clone())
729                } else {
730                    None
731                }
732            });
733            if let Some(text) = new_text {
734                push_search_pattern(ed, &text);
735            }
736        }
737        Key::Char(c) => {
738            ed.vim.search_history_cursor = None;
739            let new_text = ed.vim.search_prompt.as_mut().map(|p| {
740                p.text.push(c);
741                p.cursor = p.text.chars().count();
742                p.text.clone()
743            });
744            if let Some(text) = new_text {
745                push_search_pattern(ed, &text);
746            }
747        }
748        _ => {}
749    }
750    true
751}
752
753/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
754/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
755/// the ends of the ring; off-ring positions are silently ignored.
756fn walk_change_list<H: crate::types::Host>(
757    ed: &mut Editor<hjkl_buffer::Buffer, H>,
758    dir: isize,
759    count: usize,
760) {
761    if ed.vim.change_list.is_empty() {
762        return;
763    }
764    let len = ed.vim.change_list.len();
765    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
766        (None, -1) => len as isize - 1,
767        (None, 1) => return, // already past the newest entry
768        (Some(i), -1) => i as isize - 1,
769        (Some(i), 1) => i as isize + 1,
770        _ => return,
771    };
772    for _ in 1..count {
773        let next = idx + dir;
774        if next < 0 || next >= len as isize {
775            break;
776        }
777        idx = next;
778    }
779    if idx < 0 || idx >= len as isize {
780        return;
781    }
782    let idx = idx as usize;
783    ed.vim.change_list_cursor = Some(idx);
784    let (row, col) = ed.vim.change_list[idx];
785    ed.jump_cursor(row, col);
786}
787
788/// Push `pattern` onto the search history. Skips the push when the
789/// most recent entry already matches (consecutive dedupe) and trims
790/// the oldest entries beyond [`SEARCH_HISTORY_MAX`].
791fn record_search_history<H: crate::types::Host>(
792    ed: &mut Editor<hjkl_buffer::Buffer, H>,
793    pattern: &str,
794) {
795    if pattern.is_empty() {
796        return;
797    }
798    if ed.vim.search_history.last().map(String::as_str) == Some(pattern) {
799        return;
800    }
801    ed.vim.search_history.push(pattern.to_string());
802    let len = ed.vim.search_history.len();
803    if len > SEARCH_HISTORY_MAX {
804        ed.vim.search_history.drain(0..len - SEARCH_HISTORY_MAX);
805    }
806}
807
808/// Replace the prompt text with the next entry in the search history.
809/// `dir = -1` walks toward older entries (`Ctrl-P` / `Up`); `dir = 1`
810/// toward newer ones (`Ctrl-N` / `Down`). Stops at the ends of the
811/// history; the user can keep pressing the key without effect rather
812/// than wrapping around.
813fn walk_search_history<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, dir: isize) {
814    if ed.vim.search_history.is_empty() || ed.vim.search_prompt.is_none() {
815        return;
816    }
817    let len = ed.vim.search_history.len();
818    let next_idx = match (ed.vim.search_history_cursor, dir) {
819        (None, -1) => Some(len - 1),
820        (None, 1) => return, // already past the newest entry
821        (Some(i), -1) => i.checked_sub(1),
822        (Some(i), 1) if i + 1 < len => Some(i + 1),
823        _ => None,
824    };
825    let Some(idx) = next_idx else {
826        return;
827    };
828    ed.vim.search_history_cursor = Some(idx);
829    let text = ed.vim.search_history[idx].clone();
830    if let Some(prompt) = ed.vim.search_prompt.as_mut() {
831        prompt.cursor = text.chars().count();
832        prompt.text = text.clone();
833    }
834    push_search_pattern(ed, &text);
835}
836
837pub fn step<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, input: Input) -> bool {
838    // Phase 7f port: any cursor / content the host changed between
839    // steps (mouse jumps, paste, programmatic set_content, …) needs
840    // to land in the migration buffer before motion handlers that
841    // call into `Buffer::move_*` see a stale state.
842    ed.sync_buffer_content_from_textarea();
843    // `:set timeoutlen` — if the user paused longer than the budget
844    // since the last keystroke and a chord is in flight, drop the
845    // pending prefix so the new key starts fresh. 0.0.29 (Patch B):
846    // chord-timeout math now reads `Host::now()` so macro replay /
847    // headless drivers stay deterministic. The legacy
848    // `Instant::now()`-backed `last_input_at` field is retained for
849    // snapshot tests that still observe it.
850    let now = std::time::Instant::now();
851    let host_now = ed.host.now();
852    let timed_out = match ed.vim.last_input_host_at {
853        Some(prev) => host_now.saturating_sub(prev) > ed.settings.timeout_len,
854        None => false,
855    };
856    if timed_out {
857        let chord_in_flight = !matches!(ed.vim.pending, Pending::None)
858            || ed.vim.count != 0
859            || ed.vim.pending_register.is_some()
860            || ed.vim.insert_pending_register;
861        if chord_in_flight {
862            ed.vim.clear_pending_prefix();
863        }
864    }
865    ed.vim.last_input_at = Some(now);
866    ed.vim.last_input_host_at = Some(host_now);
867    // Macro stop: a bare `q` ends an active recording before any
868    // other handler sees the key (so `q` itself doesn't get
869    // recorded). Replays don't trigger this — they finish on their
870    // own when the captured key list runs out.
871    if ed.vim.recording_macro.is_some()
872        && !ed.vim.replaying_macro
873        && matches!(ed.vim.pending, Pending::None)
874        && ed.vim.mode != Mode::Insert
875        && input.key == Key::Char('q')
876        && !input.ctrl
877        && !input.alt
878    {
879        let reg = ed.vim.recording_macro.take().unwrap();
880        let keys = std::mem::take(&mut ed.vim.recording_keys);
881        let text = crate::input::encode_macro(&keys);
882        ed.set_named_register_text(reg.to_ascii_lowercase(), text);
883        return true;
884    }
885    // Search prompt eats all keys until Enter / Esc.
886    if ed.vim.search_prompt.is_some() {
887        return step_search_prompt(ed, input);
888    }
889    // Snapshot whether this step is consuming the register-name half
890    // of a macro chord. The recorder hook below uses this to skip
891    // the chord's bookkeeping keys (`q{reg}` open and `@{reg}` open).
892    let pending_was_macro_chord = matches!(
893        ed.vim.pending,
894        Pending::RecordMacroTarget | Pending::PlayMacroTarget { .. }
895    );
896    let was_insert = ed.vim.mode == Mode::Insert;
897    // Capture pre-step visual snapshot so a visual → normal transition
898    // can stash the selection for `gv` re-entry.
899    let pre_visual_snapshot = match ed.vim.mode {
900        Mode::Visual => Some(LastVisual {
901            mode: Mode::Visual,
902            anchor: ed.vim.visual_anchor,
903            cursor: ed.cursor(),
904            block_vcol: 0,
905        }),
906        Mode::VisualLine => Some(LastVisual {
907            mode: Mode::VisualLine,
908            anchor: (ed.vim.visual_line_anchor, 0),
909            cursor: ed.cursor(),
910            block_vcol: 0,
911        }),
912        Mode::VisualBlock => Some(LastVisual {
913            mode: Mode::VisualBlock,
914            anchor: ed.vim.block_anchor,
915            cursor: ed.cursor(),
916            block_vcol: ed.vim.block_vcol,
917        }),
918        _ => None,
919    };
920    let consumed = match ed.vim.mode {
921        Mode::Insert => step_insert(ed, input),
922        _ => step_normal(ed, input),
923    };
924    if let Some(snap) = pre_visual_snapshot
925        && !matches!(
926            ed.vim.mode,
927            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
928        )
929    {
930        // Set the `<` / `>` marks to the start / end of the last selection so
931        // ex commands like `:'<,'>sort` resolve their range. `<` is the lower
932        // (row, col), `>` is the higher — matches vim semantics.
933        let (lo, hi) = if snap.anchor <= snap.cursor {
934            (snap.anchor, snap.cursor)
935        } else {
936            (snap.cursor, snap.anchor)
937        };
938        ed.set_mark('<', lo);
939        ed.set_mark('>', hi);
940        ed.vim.last_visual = Some(snap);
941    }
942    // Ctrl-o in insert mode queues a single normal-mode command; once
943    // that command finishes (pending cleared, not in operator / visual),
944    // drop back to insert without replaying the insert session.
945    if !was_insert
946        && ed.vim.one_shot_normal
947        && ed.vim.mode == Mode::Normal
948        && matches!(ed.vim.pending, Pending::None)
949    {
950        ed.vim.one_shot_normal = false;
951        ed.vim.mode = Mode::Insert;
952    }
953    // Phase 7c: every step ends with the migration buffer mirroring
954    // the textarea's content + cursor + viewport. Edit-emitting paths
955    // (insert_char, delete_char, …) inside `step_insert` /
956    // `step_normal` thus all flow through here without each call
957    // site needing to remember to sync.
958    ed.sync_buffer_content_from_textarea();
959    // Scroll viewport to keep cursor on-screen, honouring the same
960    // `SCROLLOFF` margin the mouse-driven scroll uses. Skip when
961    // the user just pinned the viewport with `zz` / `zt` / `zb`.
962    if !ed.vim.viewport_pinned {
963        ed.ensure_cursor_in_scrolloff();
964    }
965    ed.vim.viewport_pinned = false;
966    // Recorder hook: append every consumed input to the active
967    // recording (if any) so the replay reproduces the same sequence.
968    // Skip the chord that started the recording (`q{reg}` open) and
969    // skip during replay so a macro doesn't capture itself.
970    if ed.vim.recording_macro.is_some()
971        && !ed.vim.replaying_macro
972        && input.key != Key::Char('q')
973        && !pending_was_macro_chord
974    {
975        ed.vim.recording_keys.push(input);
976    }
977    consumed
978}
979
980// ─── Insert mode ───────────────────────────────────────────────────────────
981
982fn step_insert<H: crate::types::Host>(
983    ed: &mut Editor<hjkl_buffer::Buffer, H>,
984    input: Input,
985) -> bool {
986    // `Ctrl-R {reg}` paste — the previous keystroke armed the wait. Any
987    // non-char key cancels (matches vim, which beeps on selectors like
988    // Esc and re-emits the literal text otherwise).
989    if ed.vim.insert_pending_register {
990        ed.vim.insert_pending_register = false;
991        if let Key::Char(c) = input.key
992            && !input.ctrl
993        {
994            insert_register_text(ed, c);
995        }
996        return true;
997    }
998
999    if input.key == Key::Esc {
1000        finish_insert_session(ed);
1001        ed.vim.mode = Mode::Normal;
1002        // Vim convention: pull the cursor back one cell on exit when
1003        // possible. Sticky column then mirrors the *visible* post-Back
1004        // column so the next vertical motion lands where the user
1005        // actually sees the cursor — not one cell to the right.
1006        let col = ed.cursor().1;
1007        if col > 0 {
1008            crate::motions::move_left(&mut ed.buffer, 1);
1009            ed.push_buffer_cursor_to_textarea();
1010        }
1011        ed.sticky_col = Some(ed.cursor().1);
1012        return true;
1013    }
1014
1015    // Ctrl-prefixed insert-mode shortcuts.
1016    if input.ctrl {
1017        match input.key {
1018            Key::Char('w') => {
1019                use hjkl_buffer::{Edit, MotionKind};
1020                ed.sync_buffer_content_from_textarea();
1021                let cursor = buf_cursor_pos(&ed.buffer);
1022                if cursor.row == 0 && cursor.col == 0 {
1023                    return true;
1024                }
1025                // Find the previous word start by stepping the buffer
1026                // cursor (vim `b` semantics) and snapshot it.
1027                crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
1028                let word_start = buf_cursor_pos(&ed.buffer);
1029                if word_start == cursor {
1030                    return true;
1031                }
1032                buf_set_cursor_pos(&mut ed.buffer, cursor);
1033                ed.mutate_edit(Edit::DeleteRange {
1034                    start: word_start,
1035                    end: cursor,
1036                    kind: MotionKind::Char,
1037                });
1038                ed.push_buffer_cursor_to_textarea();
1039                return true;
1040            }
1041            Key::Char('u') => {
1042                use hjkl_buffer::{Edit, MotionKind, Position};
1043                ed.sync_buffer_content_from_textarea();
1044                let cursor = buf_cursor_pos(&ed.buffer);
1045                if cursor.col > 0 {
1046                    ed.mutate_edit(Edit::DeleteRange {
1047                        start: Position::new(cursor.row, 0),
1048                        end: cursor,
1049                        kind: MotionKind::Char,
1050                    });
1051                    ed.push_buffer_cursor_to_textarea();
1052                }
1053                return true;
1054            }
1055            Key::Char('h') => {
1056                use hjkl_buffer::{Edit, MotionKind, Position};
1057                ed.sync_buffer_content_from_textarea();
1058                let cursor = buf_cursor_pos(&ed.buffer);
1059                if cursor.col > 0 {
1060                    ed.mutate_edit(Edit::DeleteRange {
1061                        start: Position::new(cursor.row, cursor.col - 1),
1062                        end: cursor,
1063                        kind: MotionKind::Char,
1064                    });
1065                } else if cursor.row > 0 {
1066                    let prev_row = cursor.row - 1;
1067                    let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1068                    ed.mutate_edit(Edit::JoinLines {
1069                        row: prev_row,
1070                        count: 1,
1071                        with_space: false,
1072                    });
1073                    buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1074                }
1075                ed.push_buffer_cursor_to_textarea();
1076                return true;
1077            }
1078            Key::Char('o') => {
1079                // One-shot normal: leave insert mode for the next full
1080                // normal-mode command, then come back.
1081                ed.vim.one_shot_normal = true;
1082                ed.vim.mode = Mode::Normal;
1083                return true;
1084            }
1085            Key::Char('r') => {
1086                // Arm the register selector — the next typed char picks
1087                // a slot and pastes its text inline.
1088                ed.vim.insert_pending_register = true;
1089                return true;
1090            }
1091            Key::Char('t') => {
1092                // Insert-mode indent: prepend one shiftwidth to the
1093                // current line's leading whitespace. Cursor shifts
1094                // right by the same amount so the user keeps typing
1095                // at their logical position.
1096                let (row, col) = ed.cursor();
1097                let sw = ed.settings().shiftwidth;
1098                indent_rows(ed, row, row, 1);
1099                ed.jump_cursor(row, col + sw);
1100                return true;
1101            }
1102            Key::Char('d') => {
1103                // Insert-mode outdent: drop up to one shiftwidth of
1104                // leading whitespace. Cursor shifts left by the amount
1105                // actually stripped.
1106                let (row, col) = ed.cursor();
1107                let before_len = buf_line_bytes(&ed.buffer, row);
1108                outdent_rows(ed, row, row, 1);
1109                let after_len = buf_line_bytes(&ed.buffer, row);
1110                let stripped = before_len.saturating_sub(after_len);
1111                let new_col = col.saturating_sub(stripped);
1112                ed.jump_cursor(row, new_col);
1113                return true;
1114            }
1115            _ => {}
1116        }
1117    }
1118
1119    // Widen the session's visited row window *before* handling the key
1120    // so navigation-only keystrokes (arrow keys) still extend the range.
1121    let (row, _) = ed.cursor();
1122    if let Some(ref mut session) = ed.vim.insert_session {
1123        session.row_min = session.row_min.min(row);
1124        session.row_max = session.row_max.max(row);
1125    }
1126    let mutated = handle_insert_key(ed, input);
1127    if mutated {
1128        ed.mark_content_dirty();
1129        let (row, _) = ed.cursor();
1130        if let Some(ref mut session) = ed.vim.insert_session {
1131            session.row_min = session.row_min.min(row);
1132            session.row_max = session.row_max.max(row);
1133        }
1134    }
1135    true
1136}
1137
1138/// `Ctrl-R {reg}` body — insert the named register's contents at the
1139/// cursor as charwise text. Embedded newlines split lines naturally via
1140/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
1141/// stray keystrokes don't mutate the buffer.
1142fn insert_register_text<H: crate::types::Host>(
1143    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1144    selector: char,
1145) {
1146    use hjkl_buffer::Edit;
1147    let text = match ed.registers().read(selector) {
1148        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
1149        _ => return,
1150    };
1151    ed.sync_buffer_content_from_textarea();
1152    let cursor = buf_cursor_pos(&ed.buffer);
1153    ed.mutate_edit(Edit::InsertStr {
1154        at: cursor,
1155        text: text.clone(),
1156    });
1157    // Advance cursor to the end of the inserted payload — multi-line
1158    // pastes land on the last inserted row at the post-text column.
1159    let mut row = cursor.row;
1160    let mut col = cursor.col;
1161    for ch in text.chars() {
1162        if ch == '\n' {
1163            row += 1;
1164            col = 0;
1165        } else {
1166            col += 1;
1167        }
1168    }
1169    buf_set_cursor_rc(&mut ed.buffer, row, col);
1170    ed.push_buffer_cursor_to_textarea();
1171    ed.mark_content_dirty();
1172    if let Some(ref mut session) = ed.vim.insert_session {
1173        session.row_min = session.row_min.min(row);
1174        session.row_max = session.row_max.max(row);
1175    }
1176}
1177
1178/// Compute the indent string to insert at the start of a new line
1179/// after Enter is pressed at `cursor`. Walks the smartindent rules:
1180///
1181/// - autoindent off → empty string
1182/// - autoindent on  → copy prev line's leading whitespace
1183/// - smartindent on → bump one `shiftwidth` if prev line's last
1184///   non-whitespace char is `{` / `(` / `[`
1185///
1186/// Indent unit (used for the smartindent bump):
1187///
1188/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
1189/// - `expandtab` → `shiftwidth` spaces
1190/// - `!expandtab` → one literal `\t`
1191///
1192/// This is the placeholder for a future tree-sitter indent provider:
1193/// when a language has an `indents.scm` query, the engine will route
1194/// the same call through that provider and only fall back to this
1195/// heuristic when no query matches.
1196pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
1197    if !settings.autoindent {
1198        return String::new();
1199    }
1200    // Copy the prev line's leading whitespace (autoindent base).
1201    let base: String = prev_line
1202        .chars()
1203        .take_while(|c| *c == ' ' || *c == '\t')
1204        .collect();
1205
1206    if settings.smartindent {
1207        // If the last non-whitespace character is an open bracket, bump
1208        // indent by one unit. This is the heuristic seam: a tree-sitter
1209        // `indents.scm` provider would replace this branch.
1210        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
1211        if matches!(last_non_ws, Some('{' | '(' | '[')) {
1212            let unit = if settings.expandtab {
1213                if settings.softtabstop > 0 {
1214                    " ".repeat(settings.softtabstop)
1215                } else {
1216                    " ".repeat(settings.shiftwidth)
1217                }
1218            } else {
1219                "\t".to_string()
1220            };
1221            return format!("{base}{unit}");
1222        }
1223    }
1224
1225    base
1226}
1227
1228/// Strip one indent unit from the beginning of `line` and insert `ch`
1229/// instead. Returns `true` when it consumed the keystroke (dedent +
1230/// insert), `false` when the caller should insert normally.
1231///
1232/// Dedent fires when:
1233///   - `smartindent` is on
1234///   - `ch` is `}` / `)` / `]`
1235///   - all bytes BEFORE the cursor on the current line are whitespace
1236///   - there is at least one full indent unit of leading whitespace
1237fn try_dedent_close_bracket<H: crate::types::Host>(
1238    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1239    cursor: hjkl_buffer::Position,
1240    ch: char,
1241) -> bool {
1242    use hjkl_buffer::{Edit, MotionKind, Position};
1243
1244    if !ed.settings.smartindent {
1245        return false;
1246    }
1247    if !matches!(ch, '}' | ')' | ']') {
1248        return false;
1249    }
1250
1251    let line = match buf_line(&ed.buffer, cursor.row) {
1252        Some(l) => l.to_string(),
1253        None => return false,
1254    };
1255
1256    // All chars before cursor must be whitespace.
1257    let before: String = line.chars().take(cursor.col).collect();
1258    if !before.chars().all(|c| c == ' ' || c == '\t') {
1259        return false;
1260    }
1261    if before.is_empty() {
1262        // Nothing to strip — just insert normally (cursor at col 0).
1263        return false;
1264    }
1265
1266    // Compute indent unit.
1267    let unit_len: usize = if ed.settings.expandtab {
1268        if ed.settings.softtabstop > 0 {
1269            ed.settings.softtabstop
1270        } else {
1271            ed.settings.shiftwidth
1272        }
1273    } else {
1274        // Tab: one literal tab character.
1275        1
1276    };
1277
1278    // Check there's at least one full unit to strip.
1279    let strip_len = if ed.settings.expandtab {
1280        // Count leading spaces; need at least `unit_len`.
1281        let spaces = before.chars().filter(|c| *c == ' ').count();
1282        if spaces < unit_len {
1283            return false;
1284        }
1285        unit_len
1286    } else {
1287        // noexpandtab: strip one leading tab.
1288        if !before.starts_with('\t') {
1289            return false;
1290        }
1291        1
1292    };
1293
1294    // Delete the leading `strip_len` chars of the current line.
1295    ed.mutate_edit(Edit::DeleteRange {
1296        start: Position::new(cursor.row, 0),
1297        end: Position::new(cursor.row, strip_len),
1298        kind: MotionKind::Char,
1299    });
1300    // Insert the close bracket at column 0 (after the delete the cursor
1301    // is still positioned at the end of the remaining whitespace; the
1302    // delete moved the text so the cursor is now at col = before.len() -
1303    // strip_len).
1304    let new_col = cursor.col.saturating_sub(strip_len);
1305    ed.mutate_edit(Edit::InsertChar {
1306        at: Position::new(cursor.row, new_col),
1307        ch,
1308    });
1309    true
1310}
1311
1312/// Insert-mode key dispatcher backed by the migration buffer. Replaces
1313/// the historical `textarea.input(input)` call so the textarea field
1314/// can be ripped at the end of Phase 7f. PageUp / PageDown still flow
1315/// through the textarea (they're scroll-only with no buffer side
1316/// effect); every other navigation + edit key lands on `Buffer`.
1317/// Returns true when the buffer mutated.
1318fn handle_insert_key<H: crate::types::Host>(
1319    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1320    input: Input,
1321) -> bool {
1322    use hjkl_buffer::{Edit, MotionKind, Position};
1323    ed.sync_buffer_content_from_textarea();
1324    let cursor = buf_cursor_pos(&ed.buffer);
1325    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1326    // Replace mode: overstrike the cell at the cursor instead of
1327    // inserting. At end-of-line, fall through to plain insert (vim
1328    // appends past the line).
1329    let in_replace = matches!(
1330        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1331        Some(InsertReason::Replace)
1332    );
1333    let mutated = match input.key {
1334        Key::Char(c) if in_replace && cursor.col < line_chars => {
1335            ed.mutate_edit(Edit::DeleteRange {
1336                start: cursor,
1337                end: Position::new(cursor.row, cursor.col + 1),
1338                kind: MotionKind::Char,
1339            });
1340            ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1341            true
1342        }
1343        Key::Char(c) => {
1344            if !try_dedent_close_bracket(ed, cursor, c) {
1345                ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1346            }
1347            true
1348        }
1349        Key::Enter => {
1350            let prev_line = buf_line(&ed.buffer, cursor.row)
1351                .unwrap_or_default()
1352                .to_string();
1353            let indent = compute_enter_indent(&ed.settings, &prev_line);
1354            let text = format!("\n{indent}");
1355            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1356            true
1357        }
1358        Key::Tab => {
1359            if ed.settings.expandtab {
1360                // With softtabstop > 0, fill to the next sts boundary.
1361                // Otherwise insert a full tabstop run.
1362                let sts = ed.settings.softtabstop;
1363                let n = if sts > 0 {
1364                    sts - (cursor.col % sts)
1365                } else {
1366                    ed.settings.tabstop.max(1)
1367                };
1368                ed.mutate_edit(Edit::InsertStr {
1369                    at: cursor,
1370                    text: " ".repeat(n),
1371                });
1372            } else {
1373                ed.mutate_edit(Edit::InsertChar {
1374                    at: cursor,
1375                    ch: '\t',
1376                });
1377            }
1378            true
1379        }
1380        Key::Backspace => {
1381            // Softtabstop: if the N chars before the cursor are all spaces
1382            // and the cursor sits on an sts-aligned column, delete the run
1383            // as a single unit (vim's "backspace deletes a soft tab" feel).
1384            let sts = ed.settings.softtabstop;
1385            if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
1386                let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1387                let chars: Vec<char> = line.chars().collect();
1388                let run_start = cursor.col - sts;
1389                if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
1390                    ed.mutate_edit(Edit::DeleteRange {
1391                        start: Position::new(cursor.row, run_start),
1392                        end: cursor,
1393                        kind: MotionKind::Char,
1394                    });
1395                    return true;
1396                }
1397            }
1398            if cursor.col > 0 {
1399                ed.mutate_edit(Edit::DeleteRange {
1400                    start: Position::new(cursor.row, cursor.col - 1),
1401                    end: cursor,
1402                    kind: MotionKind::Char,
1403                });
1404                true
1405            } else if cursor.row > 0 {
1406                let prev_row = cursor.row - 1;
1407                let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1408                ed.mutate_edit(Edit::JoinLines {
1409                    row: prev_row,
1410                    count: 1,
1411                    with_space: false,
1412                });
1413                buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1414                true
1415            } else {
1416                false
1417            }
1418        }
1419        Key::Delete => {
1420            if cursor.col < line_chars {
1421                ed.mutate_edit(Edit::DeleteRange {
1422                    start: cursor,
1423                    end: Position::new(cursor.row, cursor.col + 1),
1424                    kind: MotionKind::Char,
1425                });
1426                true
1427            } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
1428                ed.mutate_edit(Edit::JoinLines {
1429                    row: cursor.row,
1430                    count: 1,
1431                    with_space: false,
1432                });
1433                buf_set_cursor_pos(&mut ed.buffer, cursor);
1434                true
1435            } else {
1436                false
1437            }
1438        }
1439        Key::Left => {
1440            crate::motions::move_left(&mut ed.buffer, 1);
1441            break_undo_group_in_insert(ed);
1442            false
1443        }
1444        Key::Right => {
1445            // Insert mode allows the cursor one past the last char so the
1446            // next typed letter appends — use the operator-context move.
1447            crate::motions::move_right_to_end(&mut ed.buffer, 1);
1448            break_undo_group_in_insert(ed);
1449            false
1450        }
1451        Key::Up => {
1452            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1453            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1454            break_undo_group_in_insert(ed);
1455            false
1456        }
1457        Key::Down => {
1458            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1459            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1460            break_undo_group_in_insert(ed);
1461            false
1462        }
1463        Key::Home => {
1464            crate::motions::move_line_start(&mut ed.buffer);
1465            break_undo_group_in_insert(ed);
1466            false
1467        }
1468        Key::End => {
1469            crate::motions::move_line_end(&mut ed.buffer);
1470            break_undo_group_in_insert(ed);
1471            false
1472        }
1473        Key::PageUp => {
1474            // Vim default: PageUp scrolls a full window up, cursor
1475            // tracks. Reuse the Ctrl-b scroll helper so behavior
1476            // matches the normal-mode equivalent.
1477            let rows = viewport_full_rows(ed, 1) as isize;
1478            scroll_cursor_rows(ed, -rows);
1479            return false;
1480        }
1481        Key::PageDown => {
1482            let rows = viewport_full_rows(ed, 1) as isize;
1483            scroll_cursor_rows(ed, rows);
1484            return false;
1485        }
1486        // F-keys, mouse scroll, copy/cut/paste virtual keys, Null —
1487        // no insert-mode behaviour.
1488        _ => false,
1489    };
1490    ed.push_buffer_cursor_to_textarea();
1491    mutated
1492}
1493
1494fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1495    let Some(session) = ed.vim.insert_session.take() else {
1496        return;
1497    };
1498    let lines = buf_lines_to_vec(&ed.buffer);
1499    // Clamp both slices to their respective bounds — the buffer may have
1500    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1501    // the session, so row_max can overshoot either side.
1502    let after_end = session.row_max.min(lines.len().saturating_sub(1));
1503    let before_end = session
1504        .row_max
1505        .min(session.before_lines.len().saturating_sub(1));
1506    let before = if before_end >= session.row_min && session.row_min < session.before_lines.len() {
1507        session.before_lines[session.row_min..=before_end].join("\n")
1508    } else {
1509        String::new()
1510    };
1511    let after = if after_end >= session.row_min && session.row_min < lines.len() {
1512        lines[session.row_min..=after_end].join("\n")
1513    } else {
1514        String::new()
1515    };
1516    let inserted = extract_inserted(&before, &after);
1517    if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
1518        use hjkl_buffer::{Edit, Position};
1519        for _ in 0..session.count - 1 {
1520            let (row, col) = ed.cursor();
1521            ed.mutate_edit(Edit::InsertStr {
1522                at: Position::new(row, col),
1523                text: inserted.clone(),
1524            });
1525        }
1526    }
1527    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1528        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1529            use hjkl_buffer::{Edit, Position};
1530            for r in (top + 1)..=bot {
1531                let line_len = buf_line_chars(&ed.buffer, r);
1532                if col > line_len {
1533                    // Pad short rows with spaces up to the block edge
1534                    // column so the inserted text lands at `col`.
1535                    let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1536                    ed.mutate_edit(Edit::InsertStr {
1537                        at: Position::new(r, line_len),
1538                        text: pad,
1539                    });
1540                }
1541                ed.mutate_edit(Edit::InsertStr {
1542                    at: Position::new(r, col),
1543                    text: inserted.clone(),
1544                });
1545            }
1546            buf_set_cursor_rc(&mut ed.buffer, top, col);
1547            ed.push_buffer_cursor_to_textarea();
1548        }
1549        return;
1550    }
1551    if ed.vim.replaying {
1552        return;
1553    }
1554    match session.reason {
1555        InsertReason::Enter(entry) => {
1556            ed.vim.last_change = Some(LastChange::InsertAt {
1557                entry,
1558                inserted,
1559                count: session.count,
1560            });
1561        }
1562        InsertReason::Open { above } => {
1563            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1564        }
1565        InsertReason::AfterChange => {
1566            if let Some(
1567                LastChange::OpMotion { inserted: ins, .. }
1568                | LastChange::OpTextObj { inserted: ins, .. }
1569                | LastChange::LineOp { inserted: ins, .. },
1570            ) = ed.vim.last_change.as_mut()
1571            {
1572                *ins = Some(inserted);
1573            }
1574        }
1575        InsertReason::DeleteToEol => {
1576            ed.vim.last_change = Some(LastChange::DeleteToEol {
1577                inserted: Some(inserted),
1578            });
1579        }
1580        InsertReason::ReplayOnly => {}
1581        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1582        InsertReason::Replace => {
1583            // Record overstrike sessions as DeleteToEol-style — replay
1584            // re-types each character but doesn't try to restore prior
1585            // content (vim's R has its own replay path; this is the
1586            // pragmatic approximation).
1587            ed.vim.last_change = Some(LastChange::DeleteToEol {
1588                inserted: Some(inserted),
1589            });
1590        }
1591    }
1592}
1593
1594fn begin_insert<H: crate::types::Host>(
1595    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1596    count: usize,
1597    reason: InsertReason,
1598) {
1599    let record = !matches!(reason, InsertReason::ReplayOnly);
1600    if record {
1601        ed.push_undo();
1602    }
1603    let reason = if ed.vim.replaying {
1604        InsertReason::ReplayOnly
1605    } else {
1606        reason
1607    };
1608    let (row, _) = ed.cursor();
1609    ed.vim.insert_session = Some(InsertSession {
1610        count,
1611        row_min: row,
1612        row_max: row,
1613        before_lines: buf_lines_to_vec(&ed.buffer),
1614        reason,
1615    });
1616    ed.vim.mode = Mode::Insert;
1617}
1618
1619/// `:set undobreak` semantics for insert-mode motions. When the
1620/// toggle is on, a non-character keystroke that moves the cursor
1621/// (arrow keys, Home/End, mouse click) ends the current undo group
1622/// and starts a new one mid-session. After this, a subsequent `u`
1623/// in normal mode reverts only the post-break run, leaving the
1624/// pre-break edits in place — matching vim's behaviour.
1625///
1626/// Implementation: snapshot the current buffer onto the undo stack
1627/// (the new break point) and reset the active `InsertSession`'s
1628/// `before_lines` so `finish_insert_session`'s diff window only
1629/// captures the post-break run for `last_change` / dot-repeat.
1630///
1631/// During replay we skip the break — replay shouldn't pollute the
1632/// undo stack with intra-replay snapshots.
1633pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1634    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1635) {
1636    if !ed.settings.undo_break_on_motion {
1637        return;
1638    }
1639    if ed.vim.replaying {
1640        return;
1641    }
1642    if ed.vim.insert_session.is_none() {
1643        return;
1644    }
1645    ed.push_undo();
1646    let n = crate::types::Query::line_count(&ed.buffer) as usize;
1647    let mut lines: Vec<String> = Vec::with_capacity(n);
1648    for r in 0..n {
1649        lines.push(crate::types::Query::line(&ed.buffer, r as u32).to_string());
1650    }
1651    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1652    if let Some(ref mut session) = ed.vim.insert_session {
1653        session.before_lines = lines;
1654        session.row_min = row;
1655        session.row_max = row;
1656    }
1657}
1658
1659// ─── Normal / Visual / Operator-pending dispatcher ─────────────────────────
1660
1661fn step_normal<H: crate::types::Host>(
1662    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1663    input: Input,
1664) -> bool {
1665    // Consume digits first — except '0' at start of count (that's LineStart).
1666    if let Key::Char(d @ '0'..='9') = input.key
1667        && !input.ctrl
1668        && !input.alt
1669        && !matches!(
1670            ed.vim.pending,
1671            Pending::Replace
1672                | Pending::Find { .. }
1673                | Pending::OpFind { .. }
1674                | Pending::VisualTextObj { .. }
1675        )
1676        && (d != '0' || ed.vim.count > 0)
1677    {
1678        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
1679        return true;
1680    }
1681
1682    // Handle pending two-key sequences first.
1683    match std::mem::take(&mut ed.vim.pending) {
1684        Pending::Replace => return handle_replace(ed, input),
1685        Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
1686        Pending::OpFind {
1687            op,
1688            count1,
1689            forward,
1690            till,
1691        } => return handle_op_find_target(ed, input, op, count1, forward, till),
1692        Pending::G => return handle_after_g(ed, input),
1693        Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
1694        Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
1695        Pending::OpTextObj { op, count1, inner } => {
1696            return handle_text_object(ed, input, op, count1, inner);
1697        }
1698        Pending::VisualTextObj { inner } => {
1699            return handle_visual_text_obj(ed, input, inner);
1700        }
1701        Pending::Z => return handle_after_z(ed, input),
1702        Pending::SetMark => return handle_set_mark(ed, input),
1703        Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
1704        Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
1705        Pending::SelectRegister => return handle_select_register(ed, input),
1706        Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
1707        Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
1708        Pending::None => {}
1709    }
1710
1711    let count = take_count(&mut ed.vim);
1712
1713    // Common normal / visual keys.
1714    match input.key {
1715        Key::Esc => {
1716            ed.vim.force_normal();
1717            return true;
1718        }
1719        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1720            ed.vim.visual_anchor = ed.cursor();
1721            ed.vim.mode = Mode::Visual;
1722            return true;
1723        }
1724        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1725            let (row, _) = ed.cursor();
1726            ed.vim.visual_line_anchor = row;
1727            ed.vim.mode = Mode::VisualLine;
1728            return true;
1729        }
1730        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::VisualLine => {
1731            ed.vim.visual_anchor = ed.cursor();
1732            ed.vim.mode = Mode::Visual;
1733            return true;
1734        }
1735        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Visual => {
1736            let (row, _) = ed.cursor();
1737            ed.vim.visual_line_anchor = row;
1738            ed.vim.mode = Mode::VisualLine;
1739            return true;
1740        }
1741        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::Normal => {
1742            let cur = ed.cursor();
1743            ed.vim.block_anchor = cur;
1744            ed.vim.block_vcol = cur.1;
1745            ed.vim.mode = Mode::VisualBlock;
1746            return true;
1747        }
1748        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::VisualBlock => {
1749            // Second Ctrl-v exits block mode back to Normal.
1750            ed.vim.mode = Mode::Normal;
1751            return true;
1752        }
1753        // `o` in visual modes — swap anchor and cursor so the user
1754        // can extend the other end of the selection.
1755        Key::Char('o') if !input.ctrl => match ed.vim.mode {
1756            Mode::Visual => {
1757                let cur = ed.cursor();
1758                let anchor = ed.vim.visual_anchor;
1759                ed.vim.visual_anchor = cur;
1760                ed.jump_cursor(anchor.0, anchor.1);
1761                return true;
1762            }
1763            Mode::VisualLine => {
1764                let cur_row = ed.cursor().0;
1765                let anchor_row = ed.vim.visual_line_anchor;
1766                ed.vim.visual_line_anchor = cur_row;
1767                ed.jump_cursor(anchor_row, 0);
1768                return true;
1769            }
1770            Mode::VisualBlock => {
1771                let cur = ed.cursor();
1772                let anchor = ed.vim.block_anchor;
1773                ed.vim.block_anchor = cur;
1774                ed.vim.block_vcol = anchor.1;
1775                ed.jump_cursor(anchor.0, anchor.1);
1776                return true;
1777            }
1778            _ => {}
1779        },
1780        _ => {}
1781    }
1782
1783    // Visual mode: operators act on the current selection.
1784    if ed.vim.is_visual()
1785        && let Some(op) = visual_operator(&input)
1786    {
1787        apply_visual_operator(ed, op);
1788        return true;
1789    }
1790
1791    // VisualBlock: extra commands beyond the standard y/d/c/x — `r`
1792    // replaces the block with a single char, `I` / `A` enter insert
1793    // mode at the block's left / right edge and repeat on every row.
1794    if ed.vim.mode == Mode::VisualBlock && !input.ctrl {
1795        match input.key {
1796            Key::Char('r') => {
1797                ed.vim.pending = Pending::Replace;
1798                return true;
1799            }
1800            Key::Char('I') => {
1801                let (top, bot, left, _right) = block_bounds(ed);
1802                ed.jump_cursor(top, left);
1803                ed.vim.mode = Mode::Normal;
1804                begin_insert(
1805                    ed,
1806                    1,
1807                    InsertReason::BlockEdge {
1808                        top,
1809                        bot,
1810                        col: left,
1811                    },
1812                );
1813                return true;
1814            }
1815            Key::Char('A') => {
1816                let (top, bot, _left, right) = block_bounds(ed);
1817                let line_len = buf_line_chars(&ed.buffer, top);
1818                let col = (right + 1).min(line_len);
1819                ed.jump_cursor(top, col);
1820                ed.vim.mode = Mode::Normal;
1821                begin_insert(ed, 1, InsertReason::BlockEdge { top, bot, col });
1822                return true;
1823            }
1824            _ => {}
1825        }
1826    }
1827
1828    // Visual mode: `i` / `a` start a text-object extension.
1829    if matches!(ed.vim.mode, Mode::Visual | Mode::VisualLine)
1830        && !input.ctrl
1831        && matches!(input.key, Key::Char('i') | Key::Char('a'))
1832    {
1833        let inner = matches!(input.key, Key::Char('i'));
1834        ed.vim.pending = Pending::VisualTextObj { inner };
1835        return true;
1836    }
1837
1838    // Ctrl-prefixed scrolling + misc. Vim semantics: Ctrl-d / Ctrl-u
1839    // move the cursor by half a window, Ctrl-f / Ctrl-b by a full
1840    // window. Viewport follows the cursor. Cursor lands on the first
1841    // non-blank of the target row (matches vim).
1842    if input.ctrl
1843        && let Key::Char(c) = input.key
1844    {
1845        match c {
1846            'd' => {
1847                scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
1848                return true;
1849            }
1850            'u' => {
1851                scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
1852                return true;
1853            }
1854            'f' => {
1855                scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
1856                return true;
1857            }
1858            'b' => {
1859                scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
1860                return true;
1861            }
1862            'r' => {
1863                do_redo(ed);
1864                return true;
1865            }
1866            'a' if ed.vim.mode == Mode::Normal => {
1867                adjust_number(ed, count.max(1) as i64);
1868                return true;
1869            }
1870            'x' if ed.vim.mode == Mode::Normal => {
1871                adjust_number(ed, -(count.max(1) as i64));
1872                return true;
1873            }
1874            'o' if ed.vim.mode == Mode::Normal => {
1875                for _ in 0..count.max(1) {
1876                    jump_back(ed);
1877                }
1878                return true;
1879            }
1880            'i' if ed.vim.mode == Mode::Normal => {
1881                for _ in 0..count.max(1) {
1882                    jump_forward(ed);
1883                }
1884                return true;
1885            }
1886            _ => {}
1887        }
1888    }
1889
1890    // `Tab` in normal mode is also `Ctrl-i` — vim aliases them.
1891    if !input.ctrl && input.key == Key::Tab && ed.vim.mode == Mode::Normal {
1892        for _ in 0..count.max(1) {
1893            jump_forward(ed);
1894        }
1895        return true;
1896    }
1897
1898    // Motion-only commands.
1899    if let Some(motion) = parse_motion(&input) {
1900        execute_motion(ed, motion.clone(), count);
1901        // Block mode: maintain the virtual column across j/k clamps.
1902        if ed.vim.mode == Mode::VisualBlock {
1903            update_block_vcol(ed, &motion);
1904        }
1905        if let Motion::Find { ch, forward, till } = motion {
1906            ed.vim.last_find = Some((ch, forward, till));
1907        }
1908        return true;
1909    }
1910
1911    // Mode transitions + pure normal-mode commands (not applicable in visual).
1912    if ed.vim.mode == Mode::Normal && handle_normal_only(ed, &input, count) {
1913        return true;
1914    }
1915
1916    // Operator triggers in normal mode.
1917    if ed.vim.mode == Mode::Normal
1918        && let Key::Char(op_ch) = input.key
1919        && !input.ctrl
1920        && let Some(op) = char_to_operator(op_ch)
1921    {
1922        ed.vim.pending = Pending::Op { op, count1: count };
1923        return true;
1924    }
1925
1926    // `f`/`F`/`t`/`T` entry.
1927    if ed.vim.mode == Mode::Normal
1928        && let Some((forward, till)) = find_entry(&input)
1929    {
1930        ed.vim.count = count;
1931        ed.vim.pending = Pending::Find { forward, till };
1932        return true;
1933    }
1934
1935    // `g` prefix.
1936    if !input.ctrl && input.key == Key::Char('g') && ed.vim.mode == Mode::Normal {
1937        ed.vim.count = count;
1938        ed.vim.pending = Pending::G;
1939        return true;
1940    }
1941
1942    // `z` prefix (zz / zt / zb — cursor-relative viewport scrolls).
1943    if !input.ctrl
1944        && input.key == Key::Char('z')
1945        && matches!(
1946            ed.vim.mode,
1947            Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock
1948        )
1949    {
1950        ed.vim.pending = Pending::Z;
1951        return true;
1952    }
1953
1954    // Mark set / jump entries. `m` arms the set-mark pending state;
1955    // `'` and `` ` `` arm the goto states (linewise vs charwise). The
1956    // mark letter is consumed on the next keystroke.
1957    if !input.ctrl && ed.vim.mode == Mode::Normal {
1958        match input.key {
1959            Key::Char('m') => {
1960                ed.vim.pending = Pending::SetMark;
1961                return true;
1962            }
1963            Key::Char('\'') => {
1964                ed.vim.pending = Pending::GotoMarkLine;
1965                return true;
1966            }
1967            Key::Char('`') => {
1968                ed.vim.pending = Pending::GotoMarkChar;
1969                return true;
1970            }
1971            Key::Char('"') => {
1972                // Open the register-selector chord. The next char picks
1973                // a register that the next y/d/c/p uses.
1974                ed.vim.pending = Pending::SelectRegister;
1975                return true;
1976            }
1977            Key::Char('@') => {
1978                // Open the macro-play chord. Next char names the
1979                // register; `@@` re-plays the last-played macro.
1980                // Stash any count so the chord can multiply replays.
1981                ed.vim.pending = Pending::PlayMacroTarget { count };
1982                return true;
1983            }
1984            Key::Char('q') if ed.vim.recording_macro.is_none() => {
1985                // Open the macro-record chord. The bare-q stop is
1986                // handled at the top of `step` so it's not consumed
1987                // as another open. Recording-in-progress falls through
1988                // here and is treated as a no-op (matches vim).
1989                ed.vim.pending = Pending::RecordMacroTarget;
1990                return true;
1991            }
1992            _ => {}
1993        }
1994    }
1995
1996    // Unknown key — swallow so it doesn't bubble into the TUI layer.
1997    true
1998}
1999
2000fn handle_set_mark<H: crate::types::Host>(
2001    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2002    input: Input,
2003) -> bool {
2004    if let Key::Char(c) = input.key
2005        && (c.is_ascii_lowercase() || c.is_ascii_uppercase())
2006    {
2007        // 0.0.36: lowercase + uppercase marks share the unified
2008        // `Editor::marks` map. Uppercase entries survive
2009        // `set_content` so they persist across tab swaps within the
2010        // same Editor (the map lives on the Editor, not the buffer).
2011        let pos = ed.cursor();
2012        ed.set_mark(c, pos);
2013    }
2014    true
2015}
2016
2017/// `"reg` — store the register selector for the next y / d / c / p.
2018/// Accepts `a`–`z`, `A`–`Z`, `0`–`9`, `"`, and the system-clipboard
2019/// selectors `+` / `*`. Anything else cancels silently.
2020fn handle_select_register<H: crate::types::Host>(
2021    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2022    input: Input,
2023) -> bool {
2024    if let Key::Char(c) = input.key
2025        && (c.is_ascii_alphanumeric() || matches!(c, '"' | '+' | '*'))
2026    {
2027        ed.vim.pending_register = Some(c);
2028    }
2029    true
2030}
2031
2032/// `q{reg}` — start recording into `reg`. The recording session
2033/// captures every consumed `Input` until a bare `q` ends it (handled
2034/// inline at the top of `step`). Capital letters append to the
2035/// matching lowercase register, mirroring named-register semantics.
2036fn handle_record_macro_target<H: crate::types::Host>(
2037    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2038    input: Input,
2039) -> bool {
2040    if let Key::Char(c) = input.key
2041        && (c.is_ascii_alphabetic() || c.is_ascii_digit())
2042    {
2043        ed.vim.recording_macro = Some(c);
2044        // For `qA` (capital), seed the buffer with the existing
2045        // lowercase recording so the new keystrokes append.
2046        if c.is_ascii_uppercase() {
2047            let lower = c.to_ascii_lowercase();
2048            // Seed `recording_keys` with the existing register's text
2049            // decoded back to inputs, so capital-register append
2050            // continues from where the previous recording left off.
2051            let text = ed
2052                .registers()
2053                .read(lower)
2054                .map(|s| s.text.clone())
2055                .unwrap_or_default();
2056            ed.vim.recording_keys = crate::input::decode_macro(&text);
2057        } else {
2058            ed.vim.recording_keys.clear();
2059        }
2060    }
2061    true
2062}
2063
2064/// `@{reg}` — replay the macro recorded under `reg`. `@@` re-plays
2065/// the last-played macro. The replay re-feeds each captured `Input`
2066/// through `step`, with `replaying_macro` flagged so the recorder
2067/// (if active) doesn't double-capture. Honours the count prefix:
2068/// `3@a` plays the macro three times.
2069fn handle_play_macro_target<H: crate::types::Host>(
2070    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2071    input: Input,
2072    count: usize,
2073) -> bool {
2074    let reg = match input.key {
2075        Key::Char('@') => ed.vim.last_macro,
2076        Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
2077            Some(c.to_ascii_lowercase())
2078        }
2079        _ => None,
2080    };
2081    let Some(reg) = reg else {
2082        return true;
2083    };
2084    // Read the macro text from the named register and decode back to
2085    // an Input stream. Empty / unset registers replay nothing.
2086    let text = match ed.registers().read(reg) {
2087        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
2088        _ => return true,
2089    };
2090    let keys = crate::input::decode_macro(&text);
2091    ed.vim.last_macro = Some(reg);
2092    let times = count.max(1);
2093    let was_replaying = ed.vim.replaying_macro;
2094    ed.vim.replaying_macro = true;
2095    for _ in 0..times {
2096        for k in keys.iter().copied() {
2097            step(ed, k);
2098        }
2099    }
2100    ed.vim.replaying_macro = was_replaying;
2101    true
2102}
2103
2104fn handle_goto_mark<H: crate::types::Host>(
2105    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2106    input: Input,
2107    linewise: bool,
2108) -> bool {
2109    let Key::Char(c) = input.key else {
2110        return true;
2111    };
2112    // Resolve the mark target. Lowercase letters look up the user
2113    // marks set via `m{a..z}`; the special chars below come from
2114    // automatic state vim maintains:
2115    //   `'` / `` ` `` — position before the most recent big jump
2116    //                  (peeks `jump_back` without popping).
2117    //   `.`           — the last edit's position.
2118    let target = match c {
2119        'a'..='z' | 'A'..='Z' => ed.mark(c),
2120        '\'' | '`' => ed.vim.jump_back.last().copied(),
2121        '.' => ed.vim.last_edit_pos,
2122        _ => None,
2123    };
2124    let Some((row, col)) = target else {
2125        return true;
2126    };
2127    let pre = ed.cursor();
2128    let (r, c_clamped) = clamp_pos(ed, (row, col));
2129    if linewise {
2130        buf_set_cursor_rc(&mut ed.buffer, r, 0);
2131        ed.push_buffer_cursor_to_textarea();
2132        move_first_non_whitespace(ed);
2133    } else {
2134        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
2135        ed.push_buffer_cursor_to_textarea();
2136    }
2137    if ed.cursor() != pre {
2138        push_jump(ed, pre);
2139    }
2140    ed.sticky_col = Some(ed.cursor().1);
2141    true
2142}
2143
2144fn take_count(vim: &mut VimState) -> usize {
2145    if vim.count > 0 {
2146        let n = vim.count;
2147        vim.count = 0;
2148        n
2149    } else {
2150        1
2151    }
2152}
2153
2154fn char_to_operator(c: char) -> Option<Operator> {
2155    match c {
2156        'd' => Some(Operator::Delete),
2157        'c' => Some(Operator::Change),
2158        'y' => Some(Operator::Yank),
2159        '>' => Some(Operator::Indent),
2160        '<' => Some(Operator::Outdent),
2161        _ => None,
2162    }
2163}
2164
2165fn visual_operator(input: &Input) -> Option<Operator> {
2166    if input.ctrl {
2167        return None;
2168    }
2169    match input.key {
2170        Key::Char('y') => Some(Operator::Yank),
2171        Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
2172        Key::Char('c') | Key::Char('s') => Some(Operator::Change),
2173        // Case operators — shift forms apply to the active selection.
2174        Key::Char('U') => Some(Operator::Uppercase),
2175        Key::Char('u') => Some(Operator::Lowercase),
2176        Key::Char('~') => Some(Operator::ToggleCase),
2177        // Indent operators on selection.
2178        Key::Char('>') => Some(Operator::Indent),
2179        Key::Char('<') => Some(Operator::Outdent),
2180        _ => None,
2181    }
2182}
2183
2184fn find_entry(input: &Input) -> Option<(bool, bool)> {
2185    if input.ctrl {
2186        return None;
2187    }
2188    match input.key {
2189        Key::Char('f') => Some((true, false)),
2190        Key::Char('F') => Some((false, false)),
2191        Key::Char('t') => Some((true, true)),
2192        Key::Char('T') => Some((false, true)),
2193        _ => None,
2194    }
2195}
2196
2197// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
2198
2199/// Max jumplist depth. Matches vim default.
2200const JUMPLIST_MAX: usize = 100;
2201
2202/// Record a pre-jump cursor position. Called *before* a big-jump
2203/// motion runs (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, `/`?
2204/// commit, `:{nr}`). Making a new jump while the forward stack had
2205/// entries trims them — branching off the history clears the "redo".
2206fn push_jump<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, from: (usize, usize)) {
2207    ed.vim.jump_back.push(from);
2208    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2209        ed.vim.jump_back.remove(0);
2210    }
2211    ed.vim.jump_fwd.clear();
2212}
2213
2214/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
2215/// the current cursor onto the forward stack so `Ctrl-i` can return.
2216fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2217    let Some(target) = ed.vim.jump_back.pop() else {
2218        return;
2219    };
2220    let cur = ed.cursor();
2221    ed.vim.jump_fwd.push(cur);
2222    let (r, c) = clamp_pos(ed, target);
2223    ed.jump_cursor(r, c);
2224    ed.sticky_col = Some(c);
2225}
2226
2227/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
2228/// onto the back stack.
2229fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2230    let Some(target) = ed.vim.jump_fwd.pop() else {
2231        return;
2232    };
2233    let cur = ed.cursor();
2234    ed.vim.jump_back.push(cur);
2235    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2236        ed.vim.jump_back.remove(0);
2237    }
2238    let (r, c) = clamp_pos(ed, target);
2239    ed.jump_cursor(r, c);
2240    ed.sticky_col = Some(c);
2241}
2242
2243/// Clamp a stored `(row, col)` to the live buffer in case edits
2244/// shrunk the document between push and pop.
2245fn clamp_pos<H: crate::types::Host>(
2246    ed: &Editor<hjkl_buffer::Buffer, H>,
2247    pos: (usize, usize),
2248) -> (usize, usize) {
2249    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2250    let r = pos.0.min(last_row);
2251    let line_len = buf_line_chars(&ed.buffer, r);
2252    let c = pos.1.min(line_len.saturating_sub(1));
2253    (r, c)
2254}
2255
2256/// True for motions that vim treats as jumps (pushed onto the jumplist).
2257fn is_big_jump(motion: &Motion) -> bool {
2258    matches!(
2259        motion,
2260        Motion::FileTop
2261            | Motion::FileBottom
2262            | Motion::MatchBracket
2263            | Motion::WordAtCursor { .. }
2264            | Motion::SearchNext { .. }
2265            | Motion::ViewportTop
2266            | Motion::ViewportMiddle
2267            | Motion::ViewportBottom
2268    )
2269}
2270
2271// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
2272
2273/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
2274/// viewports still step by a single row. `count` multiplies.
2275fn viewport_half_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 / 2).max(1).saturating_mul(count.max(1))
2281}
2282
2283/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
2284/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
2285fn viewport_full_rows<H: crate::types::Host>(
2286    ed: &Editor<hjkl_buffer::Buffer, H>,
2287    count: usize,
2288) -> usize {
2289    let h = ed.viewport_height_value() as usize;
2290    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
2291}
2292
2293/// Move the cursor by `delta` rows (positive = down, negative = up),
2294/// clamp to the document, then land at the first non-blank on the new
2295/// row. The textarea viewport auto-scrolls to keep the cursor visible
2296/// when the cursor pushes off-screen.
2297fn scroll_cursor_rows<H: crate::types::Host>(
2298    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2299    delta: isize,
2300) {
2301    if delta == 0 {
2302        return;
2303    }
2304    ed.sync_buffer_content_from_textarea();
2305    let (row, _) = ed.cursor();
2306    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2307    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
2308    buf_set_cursor_rc(&mut ed.buffer, target, 0);
2309    crate::motions::move_first_non_blank(&mut ed.buffer);
2310    ed.push_buffer_cursor_to_textarea();
2311    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
2312}
2313
2314// ─── Motion parsing ────────────────────────────────────────────────────────
2315
2316fn parse_motion(input: &Input) -> Option<Motion> {
2317    if input.ctrl {
2318        return None;
2319    }
2320    match input.key {
2321        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
2322        Key::Char('l') | Key::Right => Some(Motion::Right),
2323        Key::Char('j') | Key::Down | Key::Enter => Some(Motion::Down),
2324        Key::Char('k') | Key::Up => Some(Motion::Up),
2325        Key::Char('w') => Some(Motion::WordFwd),
2326        Key::Char('W') => Some(Motion::BigWordFwd),
2327        Key::Char('b') => Some(Motion::WordBack),
2328        Key::Char('B') => Some(Motion::BigWordBack),
2329        Key::Char('e') => Some(Motion::WordEnd),
2330        Key::Char('E') => Some(Motion::BigWordEnd),
2331        Key::Char('0') | Key::Home => Some(Motion::LineStart),
2332        Key::Char('^') => Some(Motion::FirstNonBlank),
2333        Key::Char('$') | Key::End => Some(Motion::LineEnd),
2334        Key::Char('G') => Some(Motion::FileBottom),
2335        Key::Char('%') => Some(Motion::MatchBracket),
2336        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
2337        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
2338        Key::Char('*') => Some(Motion::WordAtCursor {
2339            forward: true,
2340            whole_word: true,
2341        }),
2342        Key::Char('#') => Some(Motion::WordAtCursor {
2343            forward: false,
2344            whole_word: true,
2345        }),
2346        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
2347        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
2348        Key::Char('H') => Some(Motion::ViewportTop),
2349        Key::Char('M') => Some(Motion::ViewportMiddle),
2350        Key::Char('L') => Some(Motion::ViewportBottom),
2351        Key::Char('{') => Some(Motion::ParagraphPrev),
2352        Key::Char('}') => Some(Motion::ParagraphNext),
2353        Key::Char('(') => Some(Motion::SentencePrev),
2354        Key::Char(')') => Some(Motion::SentenceNext),
2355        _ => None,
2356    }
2357}
2358
2359// ─── Motion execution ──────────────────────────────────────────────────────
2360
2361fn execute_motion<H: crate::types::Host>(
2362    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2363    motion: Motion,
2364    count: usize,
2365) {
2366    let count = count.max(1);
2367    // FindRepeat needs the stored direction.
2368    let motion = match motion {
2369        Motion::FindRepeat { reverse } => match ed.vim.last_find {
2370            Some((ch, forward, till)) => Motion::Find {
2371                ch,
2372                forward: if reverse { !forward } else { forward },
2373                till,
2374            },
2375            None => return,
2376        },
2377        other => other,
2378    };
2379    let pre_pos = ed.cursor();
2380    let pre_col = pre_pos.1;
2381    apply_motion_cursor(ed, &motion, count);
2382    let post_pos = ed.cursor();
2383    if is_big_jump(&motion) && pre_pos != post_pos {
2384        push_jump(ed, pre_pos);
2385    }
2386    apply_sticky_col(ed, &motion, pre_col);
2387    // Phase 7b: keep the migration buffer's cursor + viewport in
2388    // lockstep with the textarea after every motion. Once 7c lands
2389    // (motions ported onto the buffer's API), this flips: the
2390    // buffer becomes authoritative and the textarea mirrors it.
2391    ed.sync_buffer_from_textarea();
2392}
2393
2394/// Restore the cursor to the sticky column after vertical motions and
2395/// sync the sticky column to the current column after horizontal ones.
2396/// `pre_col` is the cursor column captured *before* the motion — used
2397/// to bootstrap the sticky value on the very first motion.
2398fn apply_sticky_col<H: crate::types::Host>(
2399    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2400    motion: &Motion,
2401    pre_col: usize,
2402) {
2403    if is_vertical_motion(motion) {
2404        let want = ed.sticky_col.unwrap_or(pre_col);
2405        // Record the desired column so the next vertical motion sees
2406        // it even if we currently clamped to a shorter row.
2407        ed.sticky_col = Some(want);
2408        let (row, _) = ed.cursor();
2409        let line_len = buf_line_chars(&ed.buffer, row);
2410        // Clamp to the last char on non-empty lines (vim normal-mode
2411        // never parks the cursor one past end of line). Empty lines
2412        // collapse to col 0.
2413        let max_col = line_len.saturating_sub(1);
2414        let target = want.min(max_col);
2415        ed.jump_cursor(row, target);
2416    } else {
2417        // Horizontal motion or non-motion: sticky column tracks the
2418        // new cursor column so the *next* vertical motion aims there.
2419        ed.sticky_col = Some(ed.cursor().1);
2420    }
2421}
2422
2423fn is_vertical_motion(motion: &Motion) -> bool {
2424    // Only j / k preserve the sticky column. Everything else (search,
2425    // gg / G, word jumps, etc.) lands at the match's own column so the
2426    // sticky value should sync to the new cursor column.
2427    matches!(
2428        motion,
2429        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
2430    )
2431}
2432
2433fn apply_motion_cursor<H: crate::types::Host>(
2434    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2435    motion: &Motion,
2436    count: usize,
2437) {
2438    apply_motion_cursor_ctx(ed, motion, count, false)
2439}
2440
2441fn apply_motion_cursor_ctx<H: crate::types::Host>(
2442    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2443    motion: &Motion,
2444    count: usize,
2445    as_operator: bool,
2446) {
2447    match motion {
2448        Motion::Left => {
2449            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
2450            crate::motions::move_left(&mut ed.buffer, count);
2451            ed.push_buffer_cursor_to_textarea();
2452        }
2453        Motion::Right => {
2454            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
2455            // one past the last char so the range includes it; cursor
2456            // context clamps at the last char.
2457            if as_operator {
2458                crate::motions::move_right_to_end(&mut ed.buffer, count);
2459            } else {
2460                crate::motions::move_right_in_line(&mut ed.buffer, count);
2461            }
2462            ed.push_buffer_cursor_to_textarea();
2463        }
2464        Motion::Up => {
2465            // Final col is set by `apply_sticky_col` below — push the
2466            // post-move row to the textarea and let sticky tracking
2467            // finish the work.
2468            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2469            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2470            ed.push_buffer_cursor_to_textarea();
2471        }
2472        Motion::Down => {
2473            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2474            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2475            ed.push_buffer_cursor_to_textarea();
2476        }
2477        Motion::ScreenUp => {
2478            let v = *ed.host.viewport();
2479            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2480            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2481            ed.push_buffer_cursor_to_textarea();
2482        }
2483        Motion::ScreenDown => {
2484            let v = *ed.host.viewport();
2485            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2486            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2487            ed.push_buffer_cursor_to_textarea();
2488        }
2489        Motion::WordFwd => {
2490            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2491            ed.push_buffer_cursor_to_textarea();
2492        }
2493        Motion::WordBack => {
2494            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2495            ed.push_buffer_cursor_to_textarea();
2496        }
2497        Motion::WordEnd => {
2498            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2499            ed.push_buffer_cursor_to_textarea();
2500        }
2501        Motion::BigWordFwd => {
2502            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2503            ed.push_buffer_cursor_to_textarea();
2504        }
2505        Motion::BigWordBack => {
2506            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2507            ed.push_buffer_cursor_to_textarea();
2508        }
2509        Motion::BigWordEnd => {
2510            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2511            ed.push_buffer_cursor_to_textarea();
2512        }
2513        Motion::WordEndBack => {
2514            crate::motions::move_word_end_back(
2515                &mut ed.buffer,
2516                false,
2517                count,
2518                &ed.settings.iskeyword,
2519            );
2520            ed.push_buffer_cursor_to_textarea();
2521        }
2522        Motion::BigWordEndBack => {
2523            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2524            ed.push_buffer_cursor_to_textarea();
2525        }
2526        Motion::LineStart => {
2527            crate::motions::move_line_start(&mut ed.buffer);
2528            ed.push_buffer_cursor_to_textarea();
2529        }
2530        Motion::FirstNonBlank => {
2531            crate::motions::move_first_non_blank(&mut ed.buffer);
2532            ed.push_buffer_cursor_to_textarea();
2533        }
2534        Motion::LineEnd => {
2535            // Vim normal-mode `$` lands on the last char, not one past it.
2536            crate::motions::move_line_end(&mut ed.buffer);
2537            ed.push_buffer_cursor_to_textarea();
2538        }
2539        Motion::FileTop => {
2540            // `count gg` jumps to line `count` (first non-blank);
2541            // bare `gg` lands at the top.
2542            if count > 1 {
2543                crate::motions::move_bottom(&mut ed.buffer, count);
2544            } else {
2545                crate::motions::move_top(&mut ed.buffer);
2546            }
2547            ed.push_buffer_cursor_to_textarea();
2548        }
2549        Motion::FileBottom => {
2550            // `count G` jumps to line `count`; bare `G` lands at
2551            // the buffer bottom (`Buffer::move_bottom(0)`).
2552            if count > 1 {
2553                crate::motions::move_bottom(&mut ed.buffer, count);
2554            } else {
2555                crate::motions::move_bottom(&mut ed.buffer, 0);
2556            }
2557            ed.push_buffer_cursor_to_textarea();
2558        }
2559        Motion::Find { ch, forward, till } => {
2560            for _ in 0..count {
2561                if !find_char_on_line(ed, *ch, *forward, *till) {
2562                    break;
2563                }
2564            }
2565        }
2566        Motion::FindRepeat { .. } => {} // already resolved upstream
2567        Motion::MatchBracket => {
2568            let _ = matching_bracket(ed);
2569        }
2570        Motion::WordAtCursor {
2571            forward,
2572            whole_word,
2573        } => {
2574            word_at_cursor_search(ed, *forward, *whole_word, count);
2575        }
2576        Motion::SearchNext { reverse } => {
2577            // Re-push the last query so the buffer's search state is
2578            // correct even if the host happened to clear it (e.g. while
2579            // a Visual mode draw was in progress).
2580            if let Some(pattern) = ed.vim.last_search.clone() {
2581                push_search_pattern(ed, &pattern);
2582            }
2583            if ed.search_state().pattern.is_none() {
2584                return;
2585            }
2586            // `n` repeats the last search in its committed direction;
2587            // `N` inverts. So a `?` search makes `n` walk backward and
2588            // `N` walk forward.
2589            let forward = ed.vim.last_search_forward != *reverse;
2590            for _ in 0..count.max(1) {
2591                if forward {
2592                    ed.search_advance_forward(true);
2593                } else {
2594                    ed.search_advance_backward(true);
2595                }
2596            }
2597            ed.push_buffer_cursor_to_textarea();
2598        }
2599        Motion::ViewportTop => {
2600            let v = *ed.host().viewport();
2601            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
2602            ed.push_buffer_cursor_to_textarea();
2603        }
2604        Motion::ViewportMiddle => {
2605            let v = *ed.host().viewport();
2606            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
2607            ed.push_buffer_cursor_to_textarea();
2608        }
2609        Motion::ViewportBottom => {
2610            let v = *ed.host().viewport();
2611            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
2612            ed.push_buffer_cursor_to_textarea();
2613        }
2614        Motion::LastNonBlank => {
2615            crate::motions::move_last_non_blank(&mut ed.buffer);
2616            ed.push_buffer_cursor_to_textarea();
2617        }
2618        Motion::LineMiddle => {
2619            let row = ed.cursor().0;
2620            let line_chars = buf_line_chars(&ed.buffer, row);
2621            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
2622            // lines stay at col 0.
2623            let target = line_chars / 2;
2624            ed.jump_cursor(row, target);
2625        }
2626        Motion::ParagraphPrev => {
2627            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
2628            ed.push_buffer_cursor_to_textarea();
2629        }
2630        Motion::ParagraphNext => {
2631            crate::motions::move_paragraph_next(&mut ed.buffer, count);
2632            ed.push_buffer_cursor_to_textarea();
2633        }
2634        Motion::SentencePrev => {
2635            for _ in 0..count.max(1) {
2636                if let Some((row, col)) = sentence_boundary(ed, false) {
2637                    ed.jump_cursor(row, col);
2638                }
2639            }
2640        }
2641        Motion::SentenceNext => {
2642            for _ in 0..count.max(1) {
2643                if let Some((row, col)) = sentence_boundary(ed, true) {
2644                    ed.jump_cursor(row, col);
2645                }
2646            }
2647        }
2648    }
2649}
2650
2651fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2652    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
2653    // mutates the textarea content, so the migration buffer hasn't
2654    // seen the new lines OR new cursor yet. Mirror the full content
2655    // across before delegating, then push the result back so the
2656    // textarea reflects the resolved column too.
2657    ed.sync_buffer_content_from_textarea();
2658    crate::motions::move_first_non_blank(&mut ed.buffer);
2659    ed.push_buffer_cursor_to_textarea();
2660}
2661
2662fn find_char_on_line<H: crate::types::Host>(
2663    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2664    ch: char,
2665    forward: bool,
2666    till: bool,
2667) -> bool {
2668    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
2669    if moved {
2670        ed.push_buffer_cursor_to_textarea();
2671    }
2672    moved
2673}
2674
2675fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
2676    let moved = crate::motions::match_bracket(&mut ed.buffer);
2677    if moved {
2678        ed.push_buffer_cursor_to_textarea();
2679    }
2680    moved
2681}
2682
2683fn word_at_cursor_search<H: crate::types::Host>(
2684    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2685    forward: bool,
2686    whole_word: bool,
2687    count: usize,
2688) {
2689    let (row, col) = ed.cursor();
2690    let line: String = buf_line(&ed.buffer, row).unwrap_or("").to_string();
2691    let chars: Vec<char> = line.chars().collect();
2692    if chars.is_empty() {
2693        return;
2694    }
2695    // Expand around cursor to a word boundary.
2696    let spec = ed.settings().iskeyword.clone();
2697    let is_word = |c: char| is_keyword_char(c, &spec);
2698    let mut start = col.min(chars.len().saturating_sub(1));
2699    while start > 0 && is_word(chars[start - 1]) {
2700        start -= 1;
2701    }
2702    let mut end = start;
2703    while end < chars.len() && is_word(chars[end]) {
2704        end += 1;
2705    }
2706    if end <= start {
2707        return;
2708    }
2709    let word: String = chars[start..end].iter().collect();
2710    let escaped = regex_escape(&word);
2711    let pattern = if whole_word {
2712        format!(r"\b{escaped}\b")
2713    } else {
2714        escaped
2715    };
2716    push_search_pattern(ed, &pattern);
2717    if ed.search_state().pattern.is_none() {
2718        return;
2719    }
2720    // Remember the query so `n` / `N` keep working after the jump.
2721    ed.vim.last_search = Some(pattern);
2722    ed.vim.last_search_forward = forward;
2723    for _ in 0..count.max(1) {
2724        if forward {
2725            ed.search_advance_forward(true);
2726        } else {
2727            ed.search_advance_backward(true);
2728        }
2729    }
2730    ed.push_buffer_cursor_to_textarea();
2731}
2732
2733fn regex_escape(s: &str) -> String {
2734    let mut out = String::with_capacity(s.len());
2735    for c in s.chars() {
2736        if matches!(
2737            c,
2738            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
2739        ) {
2740            out.push('\\');
2741        }
2742        out.push(c);
2743    }
2744    out
2745}
2746
2747// ─── Operator application ──────────────────────────────────────────────────
2748
2749fn handle_after_op<H: crate::types::Host>(
2750    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2751    input: Input,
2752    op: Operator,
2753    count1: usize,
2754) -> bool {
2755    // Inner count after operator (e.g. d3w): accumulate in state.count.
2756    if let Key::Char(d @ '0'..='9') = input.key
2757        && !input.ctrl
2758        && (d != '0' || ed.vim.count > 0)
2759    {
2760        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
2761        ed.vim.pending = Pending::Op { op, count1 };
2762        return true;
2763    }
2764
2765    // Esc cancels.
2766    if input.key == Key::Esc {
2767        ed.vim.count = 0;
2768        return true;
2769    }
2770
2771    // Same-letter: dd / cc / yy / gUU / guu / g~~ / >> / <<. Fold has
2772    // no doubled form in vim — `zfzf` is two `zf` chords, not a line
2773    // op — so skip the branch entirely.
2774    let double_ch = match op {
2775        Operator::Delete => Some('d'),
2776        Operator::Change => Some('c'),
2777        Operator::Yank => Some('y'),
2778        Operator::Indent => Some('>'),
2779        Operator::Outdent => Some('<'),
2780        Operator::Uppercase => Some('U'),
2781        Operator::Lowercase => Some('u'),
2782        Operator::ToggleCase => Some('~'),
2783        Operator::Fold => None,
2784        // `gqq` reflows the current line — vim's doubled form for the
2785        // reflow operator is the second `q` after `gq`.
2786        Operator::Reflow => Some('q'),
2787    };
2788    if let Key::Char(c) = input.key
2789        && !input.ctrl
2790        && Some(c) == double_ch
2791    {
2792        let count2 = take_count(&mut ed.vim);
2793        let total = count1.max(1) * count2.max(1);
2794        execute_line_op(ed, op, total);
2795        if !ed.vim.replaying {
2796            ed.vim.last_change = Some(LastChange::LineOp {
2797                op,
2798                count: total,
2799                inserted: None,
2800            });
2801        }
2802        return true;
2803    }
2804
2805    // Text object: `i` or `a`.
2806    if let Key::Char('i') | Key::Char('a') = input.key
2807        && !input.ctrl
2808    {
2809        let inner = matches!(input.key, Key::Char('i'));
2810        ed.vim.pending = Pending::OpTextObj { op, count1, inner };
2811        return true;
2812    }
2813
2814    // `g` — awaiting `g` for `gg`.
2815    if input.key == Key::Char('g') && !input.ctrl {
2816        ed.vim.pending = Pending::OpG { op, count1 };
2817        return true;
2818    }
2819
2820    // `f`/`F`/`t`/`T` with pending target.
2821    if let Some((forward, till)) = find_entry(&input) {
2822        ed.vim.pending = Pending::OpFind {
2823            op,
2824            count1,
2825            forward,
2826            till,
2827        };
2828        return true;
2829    }
2830
2831    // Motion.
2832    let count2 = take_count(&mut ed.vim);
2833    let total = count1.max(1) * count2.max(1);
2834    if let Some(motion) = parse_motion(&input) {
2835        let motion = match motion {
2836            Motion::FindRepeat { reverse } => match ed.vim.last_find {
2837                Some((ch, forward, till)) => Motion::Find {
2838                    ch,
2839                    forward: if reverse { !forward } else { forward },
2840                    till,
2841                },
2842                None => return true,
2843            },
2844            // Vim quirk: `cw` / `cW` are `ce` / `cE` — don't include
2845            // trailing whitespace so the user's replacement text lands
2846            // before the following word's leading space.
2847            Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
2848            Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
2849            m => m,
2850        };
2851        apply_op_with_motion(ed, op, &motion, total);
2852        if let Motion::Find { ch, forward, till } = &motion {
2853            ed.vim.last_find = Some((*ch, *forward, *till));
2854        }
2855        if !ed.vim.replaying && op_is_change(op) {
2856            ed.vim.last_change = Some(LastChange::OpMotion {
2857                op,
2858                motion,
2859                count: total,
2860                inserted: None,
2861            });
2862        }
2863        return true;
2864    }
2865
2866    // Unknown — cancel the operator.
2867    true
2868}
2869
2870fn handle_op_after_g<H: crate::types::Host>(
2871    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2872    input: Input,
2873    op: Operator,
2874    count1: usize,
2875) -> bool {
2876    if input.ctrl {
2877        return true;
2878    }
2879    let count2 = take_count(&mut ed.vim);
2880    let total = count1.max(1) * count2.max(1);
2881    // Case-op linewise form: `gUgU`, `gugu`, `g~g~` — same effect as
2882    // `gUU` / `guu` / `g~~`. The leading `g` was consumed into
2883    // `Pending::OpG`, so here we see the trailing U / u / ~.
2884    if matches!(
2885        op,
2886        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
2887    ) {
2888        let op_char = match op {
2889            Operator::Uppercase => 'U',
2890            Operator::Lowercase => 'u',
2891            Operator::ToggleCase => '~',
2892            _ => unreachable!(),
2893        };
2894        if input.key == Key::Char(op_char) {
2895            execute_line_op(ed, op, total);
2896            if !ed.vim.replaying {
2897                ed.vim.last_change = Some(LastChange::LineOp {
2898                    op,
2899                    count: total,
2900                    inserted: None,
2901                });
2902            }
2903            return true;
2904        }
2905    }
2906    let motion = match input.key {
2907        Key::Char('g') => Motion::FileTop,
2908        Key::Char('e') => Motion::WordEndBack,
2909        Key::Char('E') => Motion::BigWordEndBack,
2910        Key::Char('j') => Motion::ScreenDown,
2911        Key::Char('k') => Motion::ScreenUp,
2912        _ => return true,
2913    };
2914    apply_op_with_motion(ed, op, &motion, total);
2915    if !ed.vim.replaying && op_is_change(op) {
2916        ed.vim.last_change = Some(LastChange::OpMotion {
2917            op,
2918            motion,
2919            count: total,
2920            inserted: None,
2921        });
2922    }
2923    true
2924}
2925
2926fn handle_after_g<H: crate::types::Host>(
2927    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2928    input: Input,
2929) -> bool {
2930    let count = take_count(&mut ed.vim);
2931    match input.key {
2932        Key::Char('g') => {
2933            // gg — top / jump to line count.
2934            let pre = ed.cursor();
2935            if count > 1 {
2936                ed.jump_cursor(count - 1, 0);
2937            } else {
2938                ed.jump_cursor(0, 0);
2939            }
2940            move_first_non_whitespace(ed);
2941            if ed.cursor() != pre {
2942                push_jump(ed, pre);
2943            }
2944        }
2945        Key::Char('e') => execute_motion(ed, Motion::WordEndBack, count),
2946        Key::Char('E') => execute_motion(ed, Motion::BigWordEndBack, count),
2947        // `g_` — last non-blank on the line.
2948        Key::Char('_') => execute_motion(ed, Motion::LastNonBlank, count),
2949        // `gM` — middle char column of the current line.
2950        Key::Char('M') => execute_motion(ed, Motion::LineMiddle, count),
2951        // `gv` — re-enter the last visual selection.
2952        Key::Char('v') => {
2953            if let Some(snap) = ed.vim.last_visual {
2954                match snap.mode {
2955                    Mode::Visual => {
2956                        ed.vim.visual_anchor = snap.anchor;
2957                        ed.vim.mode = Mode::Visual;
2958                    }
2959                    Mode::VisualLine => {
2960                        ed.vim.visual_line_anchor = snap.anchor.0;
2961                        ed.vim.mode = Mode::VisualLine;
2962                    }
2963                    Mode::VisualBlock => {
2964                        ed.vim.block_anchor = snap.anchor;
2965                        ed.vim.block_vcol = snap.block_vcol;
2966                        ed.vim.mode = Mode::VisualBlock;
2967                    }
2968                    _ => {}
2969                }
2970                ed.jump_cursor(snap.cursor.0, snap.cursor.1);
2971            }
2972        }
2973        // `gj` / `gk` — display-line down / up. Walks one screen
2974        // segment at a time under `:set wrap`; falls back to `j`/`k`
2975        // when wrap is off (Buffer::move_screen_* handles the branch).
2976        Key::Char('j') => execute_motion(ed, Motion::ScreenDown, count),
2977        Key::Char('k') => execute_motion(ed, Motion::ScreenUp, count),
2978        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
2979        // so the next input is treated as the motion / text object /
2980        // shorthand double (`gUU`, `guu`, `g~~`).
2981        Key::Char('U') => {
2982            ed.vim.pending = Pending::Op {
2983                op: Operator::Uppercase,
2984                count1: count,
2985            };
2986        }
2987        Key::Char('u') => {
2988            ed.vim.pending = Pending::Op {
2989                op: Operator::Lowercase,
2990                count1: count,
2991            };
2992        }
2993        Key::Char('~') => {
2994            ed.vim.pending = Pending::Op {
2995                op: Operator::ToggleCase,
2996                count1: count,
2997            };
2998        }
2999        Key::Char('q') => {
3000            // `gq{motion}` — text reflow operator. Subsequent motion
3001            // / textobj rides the same operator pipeline.
3002            ed.vim.pending = Pending::Op {
3003                op: Operator::Reflow,
3004                count1: count,
3005            };
3006        }
3007        Key::Char('J') => {
3008            // `gJ` — join line below without inserting a space.
3009            for _ in 0..count.max(1) {
3010                ed.push_undo();
3011                join_line_raw(ed);
3012            }
3013            if !ed.vim.replaying {
3014                ed.vim.last_change = Some(LastChange::JoinLine {
3015                    count: count.max(1),
3016                });
3017            }
3018        }
3019        Key::Char('d') => {
3020            // `gd` — goto definition. hjkl-engine doesn't run an LSP
3021            // itself; raise an intent the host drains and routes to
3022            // `sqls`. The cursor stays put here — the host moves it
3023            // once it has the target location.
3024            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
3025        }
3026        // `g;` / `g,` — walk the change list. `g;` toward older
3027        // entries, `g,` toward newer.
3028        Key::Char(';') => walk_change_list(ed, -1, count.max(1)),
3029        Key::Char(',') => walk_change_list(ed, 1, count.max(1)),
3030        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
3031        // boundary anchors), so the cursor on `foo` finds it inside
3032        // `foobar` too.
3033        Key::Char('*') => execute_motion(
3034            ed,
3035            Motion::WordAtCursor {
3036                forward: true,
3037                whole_word: false,
3038            },
3039            count,
3040        ),
3041        Key::Char('#') => execute_motion(
3042            ed,
3043            Motion::WordAtCursor {
3044                forward: false,
3045                whole_word: false,
3046            },
3047            count,
3048        ),
3049        _ => {}
3050    }
3051    true
3052}
3053
3054fn handle_after_z<H: crate::types::Host>(
3055    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3056    input: Input,
3057) -> bool {
3058    use crate::editor::CursorScrollTarget;
3059    let row = ed.cursor().0;
3060    match input.key {
3061        Key::Char('z') => {
3062            ed.scroll_cursor_to(CursorScrollTarget::Center);
3063            ed.vim.viewport_pinned = true;
3064        }
3065        Key::Char('t') => {
3066            ed.scroll_cursor_to(CursorScrollTarget::Top);
3067            ed.vim.viewport_pinned = true;
3068        }
3069        Key::Char('b') => {
3070            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
3071            ed.vim.viewport_pinned = true;
3072        }
3073        // Folds — operate on the fold under the cursor (or the
3074        // whole buffer for `R` / `M`). Routed through
3075        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
3076        // can observe / veto each op via [`Editor::take_fold_ops`].
3077        Key::Char('o') => {
3078            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
3079        }
3080        Key::Char('c') => {
3081            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
3082        }
3083        Key::Char('a') => {
3084            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
3085        }
3086        Key::Char('R') => {
3087            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
3088        }
3089        Key::Char('M') => {
3090            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
3091        }
3092        Key::Char('E') => {
3093            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
3094        }
3095        Key::Char('d') => {
3096            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
3097        }
3098        Key::Char('f') => {
3099            if matches!(
3100                ed.vim.mode,
3101                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
3102            ) {
3103                // `zf` over a Visual selection creates a fold spanning
3104                // anchor → cursor.
3105                let anchor_row = match ed.vim.mode {
3106                    Mode::VisualLine => ed.vim.visual_line_anchor,
3107                    Mode::VisualBlock => ed.vim.block_anchor.0,
3108                    _ => ed.vim.visual_anchor.0,
3109                };
3110                let cur = ed.cursor().0;
3111                let top = anchor_row.min(cur);
3112                let bot = anchor_row.max(cur);
3113                ed.apply_fold_op(crate::types::FoldOp::Add {
3114                    start_row: top,
3115                    end_row: bot,
3116                    closed: true,
3117                });
3118                ed.vim.mode = Mode::Normal;
3119            } else {
3120                // `zf{motion}` / `zf{textobj}` — route through the
3121                // operator pipeline. `Operator::Fold` reuses every
3122                // motion / text-object / `g`-prefix branch the other
3123                // operators get.
3124                let count = take_count(&mut ed.vim);
3125                ed.vim.pending = Pending::Op {
3126                    op: Operator::Fold,
3127                    count1: count,
3128                };
3129            }
3130        }
3131        _ => {}
3132    }
3133    true
3134}
3135
3136fn handle_replace<H: crate::types::Host>(
3137    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3138    input: Input,
3139) -> bool {
3140    if let Key::Char(ch) = input.key {
3141        if ed.vim.mode == Mode::VisualBlock {
3142            block_replace(ed, ch);
3143            return true;
3144        }
3145        let count = take_count(&mut ed.vim);
3146        replace_char(ed, ch, count.max(1));
3147        if !ed.vim.replaying {
3148            ed.vim.last_change = Some(LastChange::ReplaceChar {
3149                ch,
3150                count: count.max(1),
3151            });
3152        }
3153    }
3154    true
3155}
3156
3157fn handle_find_target<H: crate::types::Host>(
3158    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3159    input: Input,
3160    forward: bool,
3161    till: bool,
3162) -> bool {
3163    let Key::Char(ch) = input.key else {
3164        return true;
3165    };
3166    let count = take_count(&mut ed.vim);
3167    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
3168    ed.vim.last_find = Some((ch, forward, till));
3169    true
3170}
3171
3172fn handle_op_find_target<H: crate::types::Host>(
3173    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3174    input: Input,
3175    op: Operator,
3176    count1: usize,
3177    forward: bool,
3178    till: bool,
3179) -> bool {
3180    let Key::Char(ch) = input.key else {
3181        return true;
3182    };
3183    let count2 = take_count(&mut ed.vim);
3184    let total = count1.max(1) * count2.max(1);
3185    let motion = Motion::Find { ch, forward, till };
3186    apply_op_with_motion(ed, op, &motion, total);
3187    ed.vim.last_find = Some((ch, forward, till));
3188    if !ed.vim.replaying && op_is_change(op) {
3189        ed.vim.last_change = Some(LastChange::OpMotion {
3190            op,
3191            motion,
3192            count: total,
3193            inserted: None,
3194        });
3195    }
3196    true
3197}
3198
3199fn handle_text_object<H: crate::types::Host>(
3200    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3201    input: Input,
3202    op: Operator,
3203    _count1: usize,
3204    inner: bool,
3205) -> bool {
3206    let Key::Char(ch) = input.key else {
3207        return true;
3208    };
3209    let obj = match ch {
3210        'w' => TextObject::Word { big: false },
3211        'W' => TextObject::Word { big: true },
3212        '"' | '\'' | '`' => TextObject::Quote(ch),
3213        '(' | ')' | 'b' => TextObject::Bracket('('),
3214        '[' | ']' => TextObject::Bracket('['),
3215        '{' | '}' | 'B' => TextObject::Bracket('{'),
3216        '<' | '>' => TextObject::Bracket('<'),
3217        'p' => TextObject::Paragraph,
3218        't' => TextObject::XmlTag,
3219        's' => TextObject::Sentence,
3220        _ => return true,
3221    };
3222    apply_op_with_text_object(ed, op, obj, inner);
3223    if !ed.vim.replaying && op_is_change(op) {
3224        ed.vim.last_change = Some(LastChange::OpTextObj {
3225            op,
3226            obj,
3227            inner,
3228            inserted: None,
3229        });
3230    }
3231    true
3232}
3233
3234fn handle_visual_text_obj<H: crate::types::Host>(
3235    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3236    input: Input,
3237    inner: bool,
3238) -> bool {
3239    let Key::Char(ch) = input.key else {
3240        return true;
3241    };
3242    let obj = match ch {
3243        'w' => TextObject::Word { big: false },
3244        'W' => TextObject::Word { big: true },
3245        '"' | '\'' | '`' => TextObject::Quote(ch),
3246        '(' | ')' | 'b' => TextObject::Bracket('('),
3247        '[' | ']' => TextObject::Bracket('['),
3248        '{' | '}' | 'B' => TextObject::Bracket('{'),
3249        '<' | '>' => TextObject::Bracket('<'),
3250        'p' => TextObject::Paragraph,
3251        't' => TextObject::XmlTag,
3252        's' => TextObject::Sentence,
3253        _ => return true,
3254    };
3255    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3256        return true;
3257    };
3258    // Anchor + cursor position the char-wise highlight / operator range;
3259    // for linewise text-objects we switch into VisualLine with the
3260    // appropriate row anchor.
3261    match kind {
3262        MotionKind::Linewise => {
3263            ed.vim.visual_line_anchor = start.0;
3264            ed.vim.mode = Mode::VisualLine;
3265            ed.jump_cursor(end.0, 0);
3266        }
3267        _ => {
3268            ed.vim.mode = Mode::Visual;
3269            ed.vim.visual_anchor = (start.0, start.1);
3270            let (er, ec) = retreat_one(ed, end);
3271            ed.jump_cursor(er, ec);
3272        }
3273    }
3274    true
3275}
3276
3277/// Move `pos` back by one character, clamped to (0, 0).
3278fn retreat_one<H: crate::types::Host>(
3279    ed: &Editor<hjkl_buffer::Buffer, H>,
3280    pos: (usize, usize),
3281) -> (usize, usize) {
3282    let (r, c) = pos;
3283    if c > 0 {
3284        (r, c - 1)
3285    } else if r > 0 {
3286        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
3287        (r - 1, prev_len)
3288    } else {
3289        (0, 0)
3290    }
3291}
3292
3293fn op_is_change(op: Operator) -> bool {
3294    matches!(op, Operator::Delete | Operator::Change)
3295}
3296
3297// ─── Normal-only commands (not motion, not operator) ───────────────────────
3298
3299fn handle_normal_only<H: crate::types::Host>(
3300    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3301    input: &Input,
3302    count: usize,
3303) -> bool {
3304    if input.ctrl {
3305        return false;
3306    }
3307    match input.key {
3308        Key::Char('i') => {
3309            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
3310            true
3311        }
3312        Key::Char('I') => {
3313            move_first_non_whitespace(ed);
3314            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
3315            true
3316        }
3317        Key::Char('a') => {
3318            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3319            ed.push_buffer_cursor_to_textarea();
3320            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
3321            true
3322        }
3323        Key::Char('A') => {
3324            crate::motions::move_line_end(&mut ed.buffer);
3325            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3326            ed.push_buffer_cursor_to_textarea();
3327            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
3328            true
3329        }
3330        Key::Char('R') => {
3331            // Replace mode — overstrike each typed cell. Reuses the
3332            // insert-mode key handler with a Replace-flavoured session.
3333            begin_insert(ed, count.max(1), InsertReason::Replace);
3334            true
3335        }
3336        Key::Char('o') => {
3337            use hjkl_buffer::{Edit, Position};
3338            ed.push_undo();
3339            // Snapshot BEFORE the newline so replay sees "\n<text>" as the
3340            // delta and produces one fresh line per iteration.
3341            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
3342            ed.sync_buffer_content_from_textarea();
3343            let row = buf_cursor_pos(&ed.buffer).row;
3344            let line_chars = buf_line_chars(&ed.buffer, row);
3345            // Smart/auto-indent based on the current line (becomes the
3346            // "previous" line for the freshly-opened line below).
3347            let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
3348            let indent = compute_enter_indent(&ed.settings, prev_line);
3349            ed.mutate_edit(Edit::InsertStr {
3350                at: Position::new(row, line_chars),
3351                text: format!("\n{indent}"),
3352            });
3353            ed.push_buffer_cursor_to_textarea();
3354            true
3355        }
3356        Key::Char('O') => {
3357            use hjkl_buffer::{Edit, Position};
3358            ed.push_undo();
3359            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
3360            ed.sync_buffer_content_from_textarea();
3361            let row = buf_cursor_pos(&ed.buffer).row;
3362            // The line opened above sits between row-1 and the current
3363            // row. Smart/auto-indent off the line above when there is
3364            // one; otherwise copy the current line's leading whitespace.
3365            let indent = if row > 0 {
3366                let above = buf_line(&ed.buffer, row - 1).unwrap_or_default();
3367                compute_enter_indent(&ed.settings, above)
3368            } else {
3369                let cur = buf_line(&ed.buffer, row).unwrap_or_default();
3370                cur.chars()
3371                    .take_while(|c| *c == ' ' || *c == '\t')
3372                    .collect::<String>()
3373            };
3374            ed.mutate_edit(Edit::InsertStr {
3375                at: Position::new(row, 0),
3376                text: format!("{indent}\n"),
3377            });
3378            // After insert, cursor sits on the surviving content one row
3379            // down — step back up onto the freshly-opened line, then to
3380            // the end of its indent so insert mode picks up where the
3381            // user expects to type.
3382            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3383            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
3384            let new_row = buf_cursor_pos(&ed.buffer).row;
3385            buf_set_cursor_rc(&mut ed.buffer, new_row, indent.chars().count());
3386            ed.push_buffer_cursor_to_textarea();
3387            true
3388        }
3389        Key::Char('x') => {
3390            do_char_delete(ed, true, count.max(1));
3391            if !ed.vim.replaying {
3392                ed.vim.last_change = Some(LastChange::CharDel {
3393                    forward: true,
3394                    count: count.max(1),
3395                });
3396            }
3397            true
3398        }
3399        Key::Char('X') => {
3400            do_char_delete(ed, false, count.max(1));
3401            if !ed.vim.replaying {
3402                ed.vim.last_change = Some(LastChange::CharDel {
3403                    forward: false,
3404                    count: count.max(1),
3405                });
3406            }
3407            true
3408        }
3409        Key::Char('~') => {
3410            for _ in 0..count.max(1) {
3411                ed.push_undo();
3412                toggle_case_at_cursor(ed);
3413            }
3414            if !ed.vim.replaying {
3415                ed.vim.last_change = Some(LastChange::ToggleCase {
3416                    count: count.max(1),
3417                });
3418            }
3419            true
3420        }
3421        Key::Char('J') => {
3422            for _ in 0..count.max(1) {
3423                ed.push_undo();
3424                join_line(ed);
3425            }
3426            if !ed.vim.replaying {
3427                ed.vim.last_change = Some(LastChange::JoinLine {
3428                    count: count.max(1),
3429                });
3430            }
3431            true
3432        }
3433        Key::Char('D') => {
3434            ed.push_undo();
3435            delete_to_eol(ed);
3436            // Vim parks the cursor on the new last char.
3437            crate::motions::move_left(&mut ed.buffer, 1);
3438            ed.push_buffer_cursor_to_textarea();
3439            if !ed.vim.replaying {
3440                ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
3441            }
3442            true
3443        }
3444        Key::Char('Y') => {
3445            // Vim 8 default: `Y` yanks to end of line (same as `y$`).
3446            apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
3447            true
3448        }
3449        Key::Char('C') => {
3450            ed.push_undo();
3451            delete_to_eol(ed);
3452            begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
3453            true
3454        }
3455        Key::Char('s') => {
3456            use hjkl_buffer::{Edit, MotionKind, Position};
3457            ed.push_undo();
3458            ed.sync_buffer_content_from_textarea();
3459            for _ in 0..count.max(1) {
3460                let cursor = buf_cursor_pos(&ed.buffer);
3461                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
3462                if cursor.col >= line_chars {
3463                    break;
3464                }
3465                ed.mutate_edit(Edit::DeleteRange {
3466                    start: cursor,
3467                    end: Position::new(cursor.row, cursor.col + 1),
3468                    kind: MotionKind::Char,
3469                });
3470            }
3471            ed.push_buffer_cursor_to_textarea();
3472            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3473            // `s` == `cl` — record as such.
3474            if !ed.vim.replaying {
3475                ed.vim.last_change = Some(LastChange::OpMotion {
3476                    op: Operator::Change,
3477                    motion: Motion::Right,
3478                    count: count.max(1),
3479                    inserted: None,
3480                });
3481            }
3482            true
3483        }
3484        Key::Char('p') => {
3485            do_paste(ed, false, count.max(1));
3486            if !ed.vim.replaying {
3487                ed.vim.last_change = Some(LastChange::Paste {
3488                    before: false,
3489                    count: count.max(1),
3490                });
3491            }
3492            true
3493        }
3494        Key::Char('P') => {
3495            do_paste(ed, true, count.max(1));
3496            if !ed.vim.replaying {
3497                ed.vim.last_change = Some(LastChange::Paste {
3498                    before: true,
3499                    count: count.max(1),
3500                });
3501            }
3502            true
3503        }
3504        Key::Char('u') => {
3505            do_undo(ed);
3506            true
3507        }
3508        Key::Char('r') => {
3509            ed.vim.count = count;
3510            ed.vim.pending = Pending::Replace;
3511            true
3512        }
3513        Key::Char('/') => {
3514            enter_search(ed, true);
3515            true
3516        }
3517        Key::Char('?') => {
3518            enter_search(ed, false);
3519            true
3520        }
3521        Key::Char('.') => {
3522            replay_last_change(ed, count);
3523            true
3524        }
3525        _ => false,
3526    }
3527}
3528
3529/// Variant of begin_insert that doesn't push_undo (caller already did).
3530fn begin_insert_noundo<H: crate::types::Host>(
3531    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3532    count: usize,
3533    reason: InsertReason,
3534) {
3535    let reason = if ed.vim.replaying {
3536        InsertReason::ReplayOnly
3537    } else {
3538        reason
3539    };
3540    let (row, _) = ed.cursor();
3541    ed.vim.insert_session = Some(InsertSession {
3542        count,
3543        row_min: row,
3544        row_max: row,
3545        before_lines: buf_lines_to_vec(&ed.buffer),
3546        reason,
3547    });
3548    ed.vim.mode = Mode::Insert;
3549}
3550
3551// ─── Operator × Motion application ─────────────────────────────────────────
3552
3553fn apply_op_with_motion<H: crate::types::Host>(
3554    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3555    op: Operator,
3556    motion: &Motion,
3557    count: usize,
3558) {
3559    let start = ed.cursor();
3560    // Tentatively apply motion to find the endpoint. Operator context
3561    // so `l` on the last char advances past-last (standard vim
3562    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
3563    // `yl` to cover the final char.
3564    apply_motion_cursor_ctx(ed, motion, count, true);
3565    let end = ed.cursor();
3566    let kind = motion_kind(motion);
3567    // Restore cursor before selecting (so Yank leaves cursor at start).
3568    ed.jump_cursor(start.0, start.1);
3569    run_operator_over_range(ed, op, start, end, kind);
3570}
3571
3572fn apply_op_with_text_object<H: crate::types::Host>(
3573    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3574    op: Operator,
3575    obj: TextObject,
3576    inner: bool,
3577) {
3578    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3579        return;
3580    };
3581    ed.jump_cursor(start.0, start.1);
3582    run_operator_over_range(ed, op, start, end, kind);
3583}
3584
3585fn motion_kind(motion: &Motion) -> MotionKind {
3586    match motion {
3587        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => MotionKind::Linewise,
3588        Motion::FileTop | Motion::FileBottom => MotionKind::Linewise,
3589        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
3590            MotionKind::Linewise
3591        }
3592        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
3593            MotionKind::Inclusive
3594        }
3595        Motion::Find { .. } => MotionKind::Inclusive,
3596        Motion::MatchBracket => MotionKind::Inclusive,
3597        // `$` now lands on the last char — operator ranges include it.
3598        Motion::LineEnd => MotionKind::Inclusive,
3599        _ => MotionKind::Exclusive,
3600    }
3601}
3602
3603fn run_operator_over_range<H: crate::types::Host>(
3604    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3605    op: Operator,
3606    start: (usize, usize),
3607    end: (usize, usize),
3608    kind: MotionKind,
3609) {
3610    let (top, bot) = order(start, end);
3611    if top == bot {
3612        return;
3613    }
3614
3615    match op {
3616        Operator::Yank => {
3617            let text = read_vim_range(ed, top, bot, kind);
3618            if !text.is_empty() {
3619                ed.record_yank_to_host(text.clone());
3620                ed.record_yank(text, matches!(kind, MotionKind::Linewise));
3621            }
3622            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3623            ed.push_buffer_cursor_to_textarea();
3624        }
3625        Operator::Delete => {
3626            ed.push_undo();
3627            cut_vim_range(ed, top, bot, kind);
3628            // After a charwise / inclusive delete the buffer cursor is
3629            // placed at `start` by the edit path. In Normal mode the
3630            // cursor max col is `line_len - 1`; clamp it here so e.g.
3631            // `d$` doesn't leave the cursor one past the new line end.
3632            if !matches!(kind, MotionKind::Linewise) {
3633                clamp_cursor_to_normal_mode(ed);
3634            }
3635            ed.vim.mode = Mode::Normal;
3636        }
3637        Operator::Change => {
3638            ed.push_undo();
3639            cut_vim_range(ed, top, bot, kind);
3640            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3641        }
3642        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3643            apply_case_op_to_selection(ed, op, top, bot, kind);
3644        }
3645        Operator::Indent | Operator::Outdent => {
3646            // Indent / outdent are always linewise even when triggered
3647            // by a char-wise motion (e.g. `>w` indents the whole line).
3648            ed.push_undo();
3649            if op == Operator::Indent {
3650                indent_rows(ed, top.0, bot.0, 1);
3651            } else {
3652                outdent_rows(ed, top.0, bot.0, 1);
3653            }
3654            ed.vim.mode = Mode::Normal;
3655        }
3656        Operator::Fold => {
3657            // Always linewise — fold the spanned rows regardless of the
3658            // motion's natural kind. Cursor lands on `top.0` to mirror
3659            // the visual `zf` path.
3660            if bot.0 >= top.0 {
3661                ed.apply_fold_op(crate::types::FoldOp::Add {
3662                    start_row: top.0,
3663                    end_row: bot.0,
3664                    closed: true,
3665                });
3666            }
3667            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3668            ed.push_buffer_cursor_to_textarea();
3669            ed.vim.mode = Mode::Normal;
3670        }
3671        Operator::Reflow => {
3672            ed.push_undo();
3673            reflow_rows(ed, top.0, bot.0);
3674            ed.vim.mode = Mode::Normal;
3675        }
3676    }
3677}
3678
3679/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
3680/// Splits on blank-line boundaries so paragraph structure is
3681/// preserved. Each paragraph's words are joined with single spaces
3682/// before re-wrapping.
3683fn reflow_rows<H: crate::types::Host>(
3684    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3685    top: usize,
3686    bot: usize,
3687) {
3688    let width = ed.settings().textwidth.max(1);
3689    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3690    let bot = bot.min(lines.len().saturating_sub(1));
3691    if top > bot {
3692        return;
3693    }
3694    let original = lines[top..=bot].to_vec();
3695    let mut wrapped: Vec<String> = Vec::new();
3696    let mut paragraph: Vec<String> = Vec::new();
3697    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
3698        if para.is_empty() {
3699            return;
3700        }
3701        let words = para.join(" ");
3702        let mut current = String::new();
3703        for word in words.split_whitespace() {
3704            let extra = if current.is_empty() {
3705                word.chars().count()
3706            } else {
3707                current.chars().count() + 1 + word.chars().count()
3708            };
3709            if extra > width && !current.is_empty() {
3710                out.push(std::mem::take(&mut current));
3711                current.push_str(word);
3712            } else if current.is_empty() {
3713                current.push_str(word);
3714            } else {
3715                current.push(' ');
3716                current.push_str(word);
3717            }
3718        }
3719        if !current.is_empty() {
3720            out.push(current);
3721        }
3722        para.clear();
3723    };
3724    for line in &original {
3725        if line.trim().is_empty() {
3726            flush(&mut paragraph, &mut wrapped, width);
3727            wrapped.push(String::new());
3728        } else {
3729            paragraph.push(line.clone());
3730        }
3731    }
3732    flush(&mut paragraph, &mut wrapped, width);
3733
3734    // Splice back. push_undo above means `u` reverses.
3735    let after: Vec<String> = lines.split_off(bot + 1);
3736    lines.truncate(top);
3737    lines.extend(wrapped);
3738    lines.extend(after);
3739    ed.restore(lines, (top, 0));
3740    ed.mark_content_dirty();
3741}
3742
3743/// Transform the range `[top, bot]` (vim `MotionKind`) in place with
3744/// the given case operator. Cursor lands on `top` afterward — vim
3745/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
3746/// Preserves the textarea yank buffer (vim's case operators don't
3747/// touch registers).
3748fn apply_case_op_to_selection<H: crate::types::Host>(
3749    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3750    op: Operator,
3751    top: (usize, usize),
3752    bot: (usize, usize),
3753    kind: MotionKind,
3754) {
3755    use hjkl_buffer::Edit;
3756    ed.push_undo();
3757    let saved_yank = ed.yank().to_string();
3758    let saved_yank_linewise = ed.vim.yank_linewise;
3759    let selection = cut_vim_range(ed, top, bot, kind);
3760    let transformed = match op {
3761        Operator::Uppercase => selection.to_uppercase(),
3762        Operator::Lowercase => selection.to_lowercase(),
3763        Operator::ToggleCase => toggle_case_str(&selection),
3764        _ => unreachable!(),
3765    };
3766    if !transformed.is_empty() {
3767        let cursor = buf_cursor_pos(&ed.buffer);
3768        ed.mutate_edit(Edit::InsertStr {
3769            at: cursor,
3770            text: transformed,
3771        });
3772    }
3773    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3774    ed.push_buffer_cursor_to_textarea();
3775    ed.set_yank(saved_yank);
3776    ed.vim.yank_linewise = saved_yank_linewise;
3777    ed.vim.mode = Mode::Normal;
3778}
3779
3780/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
3781/// Rows that are empty are skipped (vim leaves blank lines alone when
3782/// indenting). `shiftwidth` is read from `editor.settings()` so
3783/// `:set shiftwidth=N` takes effect on the next operation.
3784fn indent_rows<H: crate::types::Host>(
3785    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3786    top: usize,
3787    bot: usize,
3788    count: usize,
3789) {
3790    ed.sync_buffer_content_from_textarea();
3791    let width = ed.settings().shiftwidth * count.max(1);
3792    let pad: String = " ".repeat(width);
3793    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3794    let bot = bot.min(lines.len().saturating_sub(1));
3795    for line in lines.iter_mut().take(bot + 1).skip(top) {
3796        if !line.is_empty() {
3797            line.insert_str(0, &pad);
3798        }
3799    }
3800    // Restore cursor to first non-blank of the top row so the next
3801    // vertical motion aims sensibly — matches vim's `>>` convention.
3802    ed.restore(lines, (top, 0));
3803    move_first_non_whitespace(ed);
3804}
3805
3806/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
3807/// each row in `[top, bot]`. Rows with less leading whitespace have
3808/// all their indent stripped, not clipped to zero length.
3809fn outdent_rows<H: crate::types::Host>(
3810    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3811    top: usize,
3812    bot: usize,
3813    count: usize,
3814) {
3815    ed.sync_buffer_content_from_textarea();
3816    let width = ed.settings().shiftwidth * count.max(1);
3817    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3818    let bot = bot.min(lines.len().saturating_sub(1));
3819    for line in lines.iter_mut().take(bot + 1).skip(top) {
3820        let strip: usize = line
3821            .chars()
3822            .take(width)
3823            .take_while(|c| *c == ' ' || *c == '\t')
3824            .count();
3825        if strip > 0 {
3826            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
3827            line.drain(..byte_len);
3828        }
3829    }
3830    ed.restore(lines, (top, 0));
3831    move_first_non_whitespace(ed);
3832}
3833
3834fn toggle_case_str(s: &str) -> String {
3835    s.chars()
3836        .map(|c| {
3837            if c.is_lowercase() {
3838                c.to_uppercase().next().unwrap_or(c)
3839            } else if c.is_uppercase() {
3840                c.to_lowercase().next().unwrap_or(c)
3841            } else {
3842                c
3843            }
3844        })
3845        .collect()
3846}
3847
3848fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
3849    if a <= b { (a, b) } else { (b, a) }
3850}
3851
3852/// Clamp the buffer cursor to normal-mode valid position: col may not
3853/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
3854/// line). Vim applies this clamp on every return to Normal mode after an
3855/// operator or Esc-from-insert.
3856fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3857    let (row, col) = ed.cursor();
3858    let line_chars = buf_line_chars(&ed.buffer, row);
3859    let max_col = line_chars.saturating_sub(1);
3860    if col > max_col {
3861        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
3862        ed.push_buffer_cursor_to_textarea();
3863    }
3864}
3865
3866// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
3867
3868fn execute_line_op<H: crate::types::Host>(
3869    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3870    op: Operator,
3871    count: usize,
3872) {
3873    let (row, col) = ed.cursor();
3874    let total = buf_row_count(&ed.buffer);
3875    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
3876
3877    match op {
3878        Operator::Yank => {
3879            // yy must not move the cursor.
3880            let text = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3881            if !text.is_empty() {
3882                ed.record_yank_to_host(text.clone());
3883                ed.record_yank(text, true);
3884            }
3885            buf_set_cursor_rc(&mut ed.buffer, row, col);
3886            ed.push_buffer_cursor_to_textarea();
3887            ed.vim.mode = Mode::Normal;
3888        }
3889        Operator::Delete => {
3890            ed.push_undo();
3891            let deleted_through_last = end_row + 1 >= total;
3892            cut_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3893            // Vim's `dd` / `Ndd` leaves the cursor on the *first
3894            // non-blank* of the line that now occupies `row` — or, if
3895            // the deletion consumed the last line, the line above it.
3896            let total_after = buf_row_count(&ed.buffer);
3897            let raw_target = if deleted_through_last {
3898                row.saturating_sub(1).min(total_after.saturating_sub(1))
3899            } else {
3900                row.min(total_after.saturating_sub(1))
3901            };
3902            // Clamp off the trailing phantom empty row that arises from a
3903            // buffer with a trailing newline (stored as ["...", ""]). If
3904            // the target row is the trailing empty row and there is a real
3905            // content row above it, use that instead — matching vim's view
3906            // that the trailing `\n` is a terminator, not a separator.
3907            let target_row = if raw_target > 0
3908                && raw_target + 1 == total_after
3909                && buf_line(&ed.buffer, raw_target)
3910                    .map(str::is_empty)
3911                    .unwrap_or(false)
3912            {
3913                raw_target - 1
3914            } else {
3915                raw_target
3916            };
3917            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
3918            ed.push_buffer_cursor_to_textarea();
3919            move_first_non_whitespace(ed);
3920            ed.sticky_col = Some(ed.cursor().1);
3921            ed.vim.mode = Mode::Normal;
3922        }
3923        Operator::Change => {
3924            // `cc` / `3cc`: wipe contents of the covered lines but leave
3925            // a single blank line so insert-mode opens on it. Done as two
3926            // edits: drop rows past the first, then clear row `row`.
3927            use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
3928            ed.push_undo();
3929            ed.sync_buffer_content_from_textarea();
3930            // Read the cut payload first so yank reflects every line.
3931            let payload = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3932            if end_row > row {
3933                ed.mutate_edit(Edit::DeleteRange {
3934                    start: Position::new(row + 1, 0),
3935                    end: Position::new(end_row, 0),
3936                    kind: BufKind::Line,
3937                });
3938            }
3939            let line_chars = buf_line_chars(&ed.buffer, row);
3940            if line_chars > 0 {
3941                ed.mutate_edit(Edit::DeleteRange {
3942                    start: Position::new(row, 0),
3943                    end: Position::new(row, line_chars),
3944                    kind: BufKind::Char,
3945                });
3946            }
3947            if !payload.is_empty() {
3948                ed.record_yank_to_host(payload.clone());
3949                ed.record_delete(payload, true);
3950            }
3951            buf_set_cursor_rc(&mut ed.buffer, row, 0);
3952            ed.push_buffer_cursor_to_textarea();
3953            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3954        }
3955        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3956            // `gUU` / `guu` / `g~~` — linewise case transform over
3957            // [row, end_row]. Preserve cursor on `row` (first non-blank
3958            // lines up with vim's behaviour).
3959            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), MotionKind::Linewise);
3960            // After case-op on a linewise range vim puts the cursor on
3961            // the first non-blank of the starting line.
3962            move_first_non_whitespace(ed);
3963        }
3964        Operator::Indent | Operator::Outdent => {
3965            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
3966            ed.push_undo();
3967            if op == Operator::Indent {
3968                indent_rows(ed, row, end_row, 1);
3969            } else {
3970                outdent_rows(ed, row, end_row, 1);
3971            }
3972            ed.sticky_col = Some(ed.cursor().1);
3973            ed.vim.mode = Mode::Normal;
3974        }
3975        // No doubled form — `zfzf` is two consecutive `zf` chords.
3976        Operator::Fold => unreachable!("Fold has no line-op double"),
3977        Operator::Reflow => {
3978            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
3979            ed.push_undo();
3980            reflow_rows(ed, row, end_row);
3981            move_first_non_whitespace(ed);
3982            ed.sticky_col = Some(ed.cursor().1);
3983            ed.vim.mode = Mode::Normal;
3984        }
3985    }
3986}
3987
3988// ─── Visual mode operators ─────────────────────────────────────────────────
3989
3990fn apply_visual_operator<H: crate::types::Host>(
3991    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3992    op: Operator,
3993) {
3994    match ed.vim.mode {
3995        Mode::VisualLine => {
3996            let cursor_row = buf_cursor_pos(&ed.buffer).row;
3997            let top = cursor_row.min(ed.vim.visual_line_anchor);
3998            let bot = cursor_row.max(ed.vim.visual_line_anchor);
3999            ed.vim.yank_linewise = true;
4000            match op {
4001                Operator::Yank => {
4002                    let text = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4003                    if !text.is_empty() {
4004                        ed.record_yank_to_host(text.clone());
4005                        ed.record_yank(text, true);
4006                    }
4007                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4008                    ed.push_buffer_cursor_to_textarea();
4009                    ed.vim.mode = Mode::Normal;
4010                }
4011                Operator::Delete => {
4012                    ed.push_undo();
4013                    cut_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4014                    ed.vim.mode = Mode::Normal;
4015                }
4016                Operator::Change => {
4017                    // Vim `Vc`: wipe the line contents but leave a blank
4018                    // line in place so insert-mode starts on an empty row.
4019                    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4020                    ed.push_undo();
4021                    ed.sync_buffer_content_from_textarea();
4022                    let payload = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4023                    if bot > top {
4024                        ed.mutate_edit(Edit::DeleteRange {
4025                            start: Position::new(top + 1, 0),
4026                            end: Position::new(bot, 0),
4027                            kind: BufKind::Line,
4028                        });
4029                    }
4030                    let line_chars = buf_line_chars(&ed.buffer, top);
4031                    if line_chars > 0 {
4032                        ed.mutate_edit(Edit::DeleteRange {
4033                            start: Position::new(top, 0),
4034                            end: Position::new(top, line_chars),
4035                            kind: BufKind::Char,
4036                        });
4037                    }
4038                    if !payload.is_empty() {
4039                        ed.record_yank_to_host(payload.clone());
4040                        ed.record_delete(payload, true);
4041                    }
4042                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4043                    ed.push_buffer_cursor_to_textarea();
4044                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4045                }
4046                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4047                    let bot = buf_cursor_pos(&ed.buffer)
4048                        .row
4049                        .max(ed.vim.visual_line_anchor);
4050                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), MotionKind::Linewise);
4051                    move_first_non_whitespace(ed);
4052                }
4053                Operator::Indent | Operator::Outdent => {
4054                    ed.push_undo();
4055                    let (cursor_row, _) = ed.cursor();
4056                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4057                    if op == Operator::Indent {
4058                        indent_rows(ed, top, bot, 1);
4059                    } else {
4060                        outdent_rows(ed, top, bot, 1);
4061                    }
4062                    ed.vim.mode = Mode::Normal;
4063                }
4064                Operator::Reflow => {
4065                    ed.push_undo();
4066                    let (cursor_row, _) = ed.cursor();
4067                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4068                    reflow_rows(ed, top, bot);
4069                    ed.vim.mode = Mode::Normal;
4070                }
4071                // Visual `zf` is handled inline in `handle_after_z`,
4072                // never routed through this dispatcher.
4073                Operator::Fold => unreachable!("Visual zf takes its own path"),
4074            }
4075        }
4076        Mode::Visual => {
4077            ed.vim.yank_linewise = false;
4078            let anchor = ed.vim.visual_anchor;
4079            let cursor = ed.cursor();
4080            let (top, bot) = order(anchor, cursor);
4081            match op {
4082                Operator::Yank => {
4083                    let text = read_vim_range(ed, top, bot, MotionKind::Inclusive);
4084                    if !text.is_empty() {
4085                        ed.record_yank_to_host(text.clone());
4086                        ed.record_yank(text, false);
4087                    }
4088                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4089                    ed.push_buffer_cursor_to_textarea();
4090                    ed.vim.mode = Mode::Normal;
4091                }
4092                Operator::Delete => {
4093                    ed.push_undo();
4094                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4095                    ed.vim.mode = Mode::Normal;
4096                }
4097                Operator::Change => {
4098                    ed.push_undo();
4099                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4100                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4101                }
4102                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4103                    // Anchor stays where the visual selection started.
4104                    let anchor = ed.vim.visual_anchor;
4105                    let cursor = ed.cursor();
4106                    let (top, bot) = order(anchor, cursor);
4107                    apply_case_op_to_selection(ed, op, top, bot, MotionKind::Inclusive);
4108                }
4109                Operator::Indent | Operator::Outdent => {
4110                    ed.push_undo();
4111                    let anchor = ed.vim.visual_anchor;
4112                    let cursor = ed.cursor();
4113                    let (top, bot) = order(anchor, cursor);
4114                    if op == Operator::Indent {
4115                        indent_rows(ed, top.0, bot.0, 1);
4116                    } else {
4117                        outdent_rows(ed, top.0, bot.0, 1);
4118                    }
4119                    ed.vim.mode = Mode::Normal;
4120                }
4121                Operator::Reflow => {
4122                    ed.push_undo();
4123                    let anchor = ed.vim.visual_anchor;
4124                    let cursor = ed.cursor();
4125                    let (top, bot) = order(anchor, cursor);
4126                    reflow_rows(ed, top.0, bot.0);
4127                    ed.vim.mode = Mode::Normal;
4128                }
4129                Operator::Fold => unreachable!("Visual zf takes its own path"),
4130            }
4131        }
4132        Mode::VisualBlock => apply_block_operator(ed, op),
4133        _ => {}
4134    }
4135}
4136
4137/// Compute `(top_row, bot_row, left_col, right_col)` for the current
4138/// VisualBlock selection. Columns are inclusive on both ends. Uses the
4139/// tracked virtual column (updated by h/l, preserved across j/k) so
4140/// ragged / empty rows don't collapse the block's width.
4141fn block_bounds<H: crate::types::Host>(
4142    ed: &Editor<hjkl_buffer::Buffer, H>,
4143) -> (usize, usize, usize, usize) {
4144    let (ar, ac) = ed.vim.block_anchor;
4145    let (cr, _) = ed.cursor();
4146    let cc = ed.vim.block_vcol;
4147    let top = ar.min(cr);
4148    let bot = ar.max(cr);
4149    let left = ac.min(cc);
4150    let right = ac.max(cc);
4151    (top, bot, left, right)
4152}
4153
4154/// Update the virtual column after a motion in VisualBlock mode.
4155/// Horizontal motions sync `block_vcol` to the new cursor column;
4156/// vertical / non-h/l motions leave it alone so the intended column
4157/// survives clamping to shorter lines.
4158fn update_block_vcol<H: crate::types::Host>(
4159    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4160    motion: &Motion,
4161) {
4162    match motion {
4163        Motion::Left
4164        | Motion::Right
4165        | Motion::WordFwd
4166        | Motion::BigWordFwd
4167        | Motion::WordBack
4168        | Motion::BigWordBack
4169        | Motion::WordEnd
4170        | Motion::BigWordEnd
4171        | Motion::WordEndBack
4172        | Motion::BigWordEndBack
4173        | Motion::LineStart
4174        | Motion::FirstNonBlank
4175        | Motion::LineEnd
4176        | Motion::Find { .. }
4177        | Motion::FindRepeat { .. }
4178        | Motion::MatchBracket => {
4179            ed.vim.block_vcol = ed.cursor().1;
4180        }
4181        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
4182        _ => {}
4183    }
4184}
4185
4186/// Yank / delete / change / replace a rectangular selection. Yanked text
4187/// is stored as one string per row joined with `\n` so pasting reproduces
4188/// the block as sequential lines. (Vim's true block-paste reinserts as
4189/// columns; we render the content with our char-wise paste path.)
4190fn apply_block_operator<H: crate::types::Host>(
4191    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4192    op: Operator,
4193) {
4194    let (top, bot, left, right) = block_bounds(ed);
4195    // Snapshot the block text for yank / clipboard.
4196    let yank = block_yank(ed, top, bot, left, right);
4197
4198    match op {
4199        Operator::Yank => {
4200            if !yank.is_empty() {
4201                ed.record_yank_to_host(yank.clone());
4202                ed.record_yank(yank, false);
4203            }
4204            ed.vim.mode = Mode::Normal;
4205            ed.jump_cursor(top, left);
4206        }
4207        Operator::Delete => {
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.vim.mode = Mode::Normal;
4215            ed.jump_cursor(top, left);
4216        }
4217        Operator::Change => {
4218            ed.push_undo();
4219            delete_block_contents(ed, top, bot, left, right);
4220            if !yank.is_empty() {
4221                ed.record_yank_to_host(yank.clone());
4222                ed.record_delete(yank, false);
4223            }
4224            ed.jump_cursor(top, left);
4225            begin_insert_noundo(
4226                ed,
4227                1,
4228                InsertReason::BlockEdge {
4229                    top,
4230                    bot,
4231                    col: left,
4232                },
4233            );
4234        }
4235        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4236            ed.push_undo();
4237            transform_block_case(ed, op, top, bot, left, right);
4238            ed.vim.mode = Mode::Normal;
4239            ed.jump_cursor(top, left);
4240        }
4241        Operator::Indent | Operator::Outdent => {
4242            // VisualBlock `>` / `<` falls back to linewise indent over
4243            // the block's row range — vim does the same (column-wise
4244            // indent/outdent doesn't make sense).
4245            ed.push_undo();
4246            if op == Operator::Indent {
4247                indent_rows(ed, top, bot, 1);
4248            } else {
4249                outdent_rows(ed, top, bot, 1);
4250            }
4251            ed.vim.mode = Mode::Normal;
4252        }
4253        Operator::Fold => unreachable!("Visual zf takes its own path"),
4254        Operator::Reflow => {
4255            // Reflow over the block falls back to linewise reflow over
4256            // the row range — column slicing for `gq` doesn't make
4257            // sense.
4258            ed.push_undo();
4259            reflow_rows(ed, top, bot);
4260            ed.vim.mode = Mode::Normal;
4261        }
4262    }
4263}
4264
4265/// In-place case transform over the rectangular block
4266/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
4267/// untouched — vim behaves the same way (ragged blocks).
4268fn transform_block_case<H: crate::types::Host>(
4269    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4270    op: Operator,
4271    top: usize,
4272    bot: usize,
4273    left: usize,
4274    right: usize,
4275) {
4276    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4277    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4278        let chars: Vec<char> = lines[r].chars().collect();
4279        if left >= chars.len() {
4280            continue;
4281        }
4282        let end = (right + 1).min(chars.len());
4283        let head: String = chars[..left].iter().collect();
4284        let mid: String = chars[left..end].iter().collect();
4285        let tail: String = chars[end..].iter().collect();
4286        let transformed = match op {
4287            Operator::Uppercase => mid.to_uppercase(),
4288            Operator::Lowercase => mid.to_lowercase(),
4289            Operator::ToggleCase => toggle_case_str(&mid),
4290            _ => mid,
4291        };
4292        lines[r] = format!("{head}{transformed}{tail}");
4293    }
4294    let saved_yank = ed.yank().to_string();
4295    let saved_linewise = ed.vim.yank_linewise;
4296    ed.restore(lines, (top, left));
4297    ed.set_yank(saved_yank);
4298    ed.vim.yank_linewise = saved_linewise;
4299}
4300
4301fn block_yank<H: crate::types::Host>(
4302    ed: &Editor<hjkl_buffer::Buffer, H>,
4303    top: usize,
4304    bot: usize,
4305    left: usize,
4306    right: usize,
4307) -> String {
4308    let lines = buf_lines_to_vec(&ed.buffer);
4309    let mut rows: Vec<String> = Vec::new();
4310    for r in top..=bot {
4311        let line = match lines.get(r) {
4312            Some(l) => l,
4313            None => break,
4314        };
4315        let chars: Vec<char> = line.chars().collect();
4316        let end = (right + 1).min(chars.len());
4317        if left >= chars.len() {
4318            rows.push(String::new());
4319        } else {
4320            rows.push(chars[left..end].iter().collect());
4321        }
4322    }
4323    rows.join("\n")
4324}
4325
4326fn delete_block_contents<H: crate::types::Host>(
4327    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4328    top: usize,
4329    bot: usize,
4330    left: usize,
4331    right: usize,
4332) {
4333    use hjkl_buffer::{Edit, MotionKind, Position};
4334    ed.sync_buffer_content_from_textarea();
4335    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
4336    if last_row < top {
4337        return;
4338    }
4339    ed.mutate_edit(Edit::DeleteRange {
4340        start: Position::new(top, left),
4341        end: Position::new(last_row, right),
4342        kind: MotionKind::Block,
4343    });
4344    ed.push_buffer_cursor_to_textarea();
4345}
4346
4347/// Replace each character cell in the block with `ch`.
4348fn block_replace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, ch: char) {
4349    let (top, bot, left, right) = block_bounds(ed);
4350    ed.push_undo();
4351    ed.sync_buffer_content_from_textarea();
4352    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4353    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4354        let chars: Vec<char> = lines[r].chars().collect();
4355        if left >= chars.len() {
4356            continue;
4357        }
4358        let end = (right + 1).min(chars.len());
4359        let before: String = chars[..left].iter().collect();
4360        let middle: String = std::iter::repeat_n(ch, end - left).collect();
4361        let after: String = chars[end..].iter().collect();
4362        lines[r] = format!("{before}{middle}{after}");
4363    }
4364    reset_textarea_lines(ed, lines);
4365    ed.vim.mode = Mode::Normal;
4366    ed.jump_cursor(top, left);
4367}
4368
4369/// Replace buffer content with `lines` while preserving the cursor.
4370/// Used by indent / outdent / block_replace to wholesale rewrite
4371/// rows without going through the per-edit funnel.
4372fn reset_textarea_lines<H: crate::types::Host>(
4373    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4374    lines: Vec<String>,
4375) {
4376    let cursor = ed.cursor();
4377    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
4378    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
4379    ed.mark_content_dirty();
4380}
4381
4382// ─── Visual-line helpers ───────────────────────────────────────────────────
4383
4384// ─── Text-object range computation ─────────────────────────────────────────
4385
4386/// Cursor position as `(row, col)`.
4387type Pos = (usize, usize);
4388
4389/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
4390/// last character to act on). `kind` is `Linewise` for line-oriented text
4391/// objects like paragraphs and `Exclusive` otherwise.
4392fn text_object_range<H: crate::types::Host>(
4393    ed: &Editor<hjkl_buffer::Buffer, H>,
4394    obj: TextObject,
4395    inner: bool,
4396) -> Option<(Pos, Pos, MotionKind)> {
4397    match obj {
4398        TextObject::Word { big } => {
4399            word_text_object(ed, inner, big).map(|(s, e)| (s, e, MotionKind::Exclusive))
4400        }
4401        TextObject::Quote(q) => {
4402            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4403        }
4404        TextObject::Bracket(open) => bracket_text_object(ed, open, inner),
4405        TextObject::Paragraph => {
4406            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Linewise))
4407        }
4408        TextObject::XmlTag => {
4409            tag_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4410        }
4411        TextObject::Sentence => {
4412            sentence_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4413        }
4414    }
4415}
4416
4417/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
4418/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
4419/// `None` when already at the buffer's edge in that direction.
4420fn sentence_boundary<H: crate::types::Host>(
4421    ed: &Editor<hjkl_buffer::Buffer, H>,
4422    forward: bool,
4423) -> Option<(usize, usize)> {
4424    let lines = buf_lines_to_vec(&ed.buffer);
4425    if lines.is_empty() {
4426        return None;
4427    }
4428    let pos_to_idx = |pos: (usize, usize)| -> usize {
4429        let mut idx = 0;
4430        for line in lines.iter().take(pos.0) {
4431            idx += line.chars().count() + 1;
4432        }
4433        idx + pos.1
4434    };
4435    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4436        for (r, line) in lines.iter().enumerate() {
4437            let len = line.chars().count();
4438            if idx <= len {
4439                return (r, idx);
4440            }
4441            idx -= len + 1;
4442        }
4443        let last = lines.len().saturating_sub(1);
4444        (last, lines[last].chars().count())
4445    };
4446    let mut chars: Vec<char> = Vec::new();
4447    for (r, line) in lines.iter().enumerate() {
4448        chars.extend(line.chars());
4449        if r + 1 < lines.len() {
4450            chars.push('\n');
4451        }
4452    }
4453    if chars.is_empty() {
4454        return None;
4455    }
4456    let total = chars.len();
4457    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
4458    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4459
4460    if forward {
4461        // Walk forward looking for a terminator run followed by
4462        // whitespace; land on the first non-whitespace cell after.
4463        let mut i = cursor_idx + 1;
4464        while i < total {
4465            if is_terminator(chars[i]) {
4466                while i + 1 < total && is_terminator(chars[i + 1]) {
4467                    i += 1;
4468                }
4469                if i + 1 >= total {
4470                    return None;
4471                }
4472                if chars[i + 1].is_whitespace() {
4473                    let mut j = i + 1;
4474                    while j < total && chars[j].is_whitespace() {
4475                        j += 1;
4476                    }
4477                    if j >= total {
4478                        return None;
4479                    }
4480                    return Some(idx_to_pos(j));
4481                }
4482            }
4483            i += 1;
4484        }
4485        None
4486    } else {
4487        // Walk backward to find the start of the current sentence (if
4488        // we're already at the start, jump to the previous sentence's
4489        // start instead).
4490        let find_start = |from: usize| -> Option<usize> {
4491            let mut start = from;
4492            while start > 0 {
4493                let prev = chars[start - 1];
4494                if prev.is_whitespace() {
4495                    let mut k = start - 1;
4496                    while k > 0 && chars[k - 1].is_whitespace() {
4497                        k -= 1;
4498                    }
4499                    if k > 0 && is_terminator(chars[k - 1]) {
4500                        break;
4501                    }
4502                }
4503                start -= 1;
4504            }
4505            while start < total && chars[start].is_whitespace() {
4506                start += 1;
4507            }
4508            (start < total).then_some(start)
4509        };
4510        let current_start = find_start(cursor_idx)?;
4511        if current_start < cursor_idx {
4512            return Some(idx_to_pos(current_start));
4513        }
4514        // Already at the sentence start — step over the boundary into
4515        // the previous sentence and find its start.
4516        let mut k = current_start;
4517        while k > 0 && chars[k - 1].is_whitespace() {
4518            k -= 1;
4519        }
4520        if k == 0 {
4521            return None;
4522        }
4523        let prev_start = find_start(k - 1)?;
4524        Some(idx_to_pos(prev_start))
4525    }
4526}
4527
4528/// `is` / `as` — sentence: text up to and including the next sentence
4529/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
4530/// whitespace (or end-of-line) as a boundary; runs of consecutive
4531/// terminators stay attached to the same sentence. `as` extends to
4532/// include trailing whitespace; `is` does not.
4533fn sentence_text_object<H: crate::types::Host>(
4534    ed: &Editor<hjkl_buffer::Buffer, H>,
4535    inner: bool,
4536) -> Option<((usize, usize), (usize, usize))> {
4537    let lines = buf_lines_to_vec(&ed.buffer);
4538    if lines.is_empty() {
4539        return None;
4540    }
4541    // Flatten the buffer so a sentence can span lines (vim's behaviour).
4542    // Newlines count as whitespace for boundary detection.
4543    let pos_to_idx = |pos: (usize, usize)| -> usize {
4544        let mut idx = 0;
4545        for line in lines.iter().take(pos.0) {
4546            idx += line.chars().count() + 1;
4547        }
4548        idx + pos.1
4549    };
4550    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4551        for (r, line) in lines.iter().enumerate() {
4552            let len = line.chars().count();
4553            if idx <= len {
4554                return (r, idx);
4555            }
4556            idx -= len + 1;
4557        }
4558        let last = lines.len().saturating_sub(1);
4559        (last, lines[last].chars().count())
4560    };
4561    let mut chars: Vec<char> = Vec::new();
4562    for (r, line) in lines.iter().enumerate() {
4563        chars.extend(line.chars());
4564        if r + 1 < lines.len() {
4565            chars.push('\n');
4566        }
4567    }
4568    if chars.is_empty() {
4569        return None;
4570    }
4571
4572    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
4573    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4574
4575    // Walk backward from cursor to find the start of the current
4576    // sentence. A boundary is: whitespace immediately after a run of
4577    // terminators (or start-of-buffer).
4578    let mut start = cursor_idx;
4579    while start > 0 {
4580        let prev = chars[start - 1];
4581        if prev.is_whitespace() {
4582            // Check if the whitespace follows a terminator — if so,
4583            // we've crossed a sentence boundary; the sentence begins
4584            // at the first non-whitespace cell *after* this run.
4585            let mut k = start - 1;
4586            while k > 0 && chars[k - 1].is_whitespace() {
4587                k -= 1;
4588            }
4589            if k > 0 && is_terminator(chars[k - 1]) {
4590                break;
4591            }
4592        }
4593        start -= 1;
4594    }
4595    // Skip leading whitespace (vim doesn't include it in the
4596    // sentence body).
4597    while start < chars.len() && chars[start].is_whitespace() {
4598        start += 1;
4599    }
4600    if start >= chars.len() {
4601        return None;
4602    }
4603
4604    // Walk forward to the sentence end (last terminator before the
4605    // next whitespace boundary).
4606    let mut end = start;
4607    while end < chars.len() {
4608        if is_terminator(chars[end]) {
4609            // Consume any consecutive terminators (e.g. `?!`).
4610            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
4611                end += 1;
4612            }
4613            // If followed by whitespace or end-of-buffer, that's the
4614            // boundary.
4615            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
4616                break;
4617            }
4618        }
4619        end += 1;
4620    }
4621    // Inclusive end → exclusive end_idx.
4622    let end_idx = (end + 1).min(chars.len());
4623
4624    let final_end = if inner {
4625        end_idx
4626    } else {
4627        // `as`: include trailing whitespace (but stop before the next
4628        // newline so we don't gobble a paragraph break — vim keeps
4629        // sentences within a paragraph for the trailing-ws extension).
4630        let mut e = end_idx;
4631        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
4632            e += 1;
4633        }
4634        e
4635    };
4636
4637    Some((idx_to_pos(start), idx_to_pos(final_end)))
4638}
4639
4640/// `it` / `at` — XML tag pair text object. Builds a flat char index of
4641/// the buffer, walks `<...>` tokens to pair tags via a stack, and
4642/// returns the innermost pair containing the cursor.
4643fn tag_text_object<H: crate::types::Host>(
4644    ed: &Editor<hjkl_buffer::Buffer, H>,
4645    inner: bool,
4646) -> Option<((usize, usize), (usize, usize))> {
4647    let lines = buf_lines_to_vec(&ed.buffer);
4648    if lines.is_empty() {
4649        return None;
4650    }
4651    // Flatten char positions so we can compare cursor against tag
4652    // ranges without per-row arithmetic. `\n` between lines counts as
4653    // a single char.
4654    let pos_to_idx = |pos: (usize, usize)| -> usize {
4655        let mut idx = 0;
4656        for line in lines.iter().take(pos.0) {
4657            idx += line.chars().count() + 1;
4658        }
4659        idx + pos.1
4660    };
4661    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4662        for (r, line) in lines.iter().enumerate() {
4663            let len = line.chars().count();
4664            if idx <= len {
4665                return (r, idx);
4666            }
4667            idx -= len + 1;
4668        }
4669        let last = lines.len().saturating_sub(1);
4670        (last, lines[last].chars().count())
4671    };
4672    let mut chars: Vec<char> = Vec::new();
4673    for (r, line) in lines.iter().enumerate() {
4674        chars.extend(line.chars());
4675        if r + 1 < lines.len() {
4676            chars.push('\n');
4677        }
4678    }
4679    let cursor_idx = pos_to_idx(ed.cursor());
4680
4681    // Walk `<...>` tokens. Track open tags on a stack; on a matching
4682    // close pop and consider the pair a candidate when the cursor lies
4683    // inside its content range. Innermost wins (replace whenever a
4684    // tighter range turns up). Also track the first complete pair that
4685    // starts at or after the cursor so we can fall back to a forward
4686    // scan (targets.vim-style) when the cursor isn't inside any tag.
4687    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
4688    let mut innermost: Option<(usize, usize, usize, usize)> = None;
4689    let mut next_after: Option<(usize, usize, usize, usize)> = None;
4690    let mut i = 0;
4691    while i < chars.len() {
4692        if chars[i] != '<' {
4693            i += 1;
4694            continue;
4695        }
4696        let mut j = i + 1;
4697        while j < chars.len() && chars[j] != '>' {
4698            j += 1;
4699        }
4700        if j >= chars.len() {
4701            break;
4702        }
4703        let inside: String = chars[i + 1..j].iter().collect();
4704        let close_end = j + 1;
4705        let trimmed = inside.trim();
4706        if trimmed.starts_with('!') || trimmed.starts_with('?') {
4707            i = close_end;
4708            continue;
4709        }
4710        if let Some(rest) = trimmed.strip_prefix('/') {
4711            let name = rest.split_whitespace().next().unwrap_or("").to_string();
4712            if !name.is_empty()
4713                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
4714            {
4715                let (open_start, content_start, _) = stack[stack_idx].clone();
4716                stack.truncate(stack_idx);
4717                let content_end = i;
4718                let candidate = (open_start, content_start, content_end, close_end);
4719                if cursor_idx >= content_start && cursor_idx <= content_end {
4720                    innermost = match innermost {
4721                        Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
4722                            Some(candidate)
4723                        }
4724                        None => Some(candidate),
4725                        existing => existing,
4726                    };
4727                } else if open_start >= cursor_idx && next_after.is_none() {
4728                    next_after = Some(candidate);
4729                }
4730            }
4731        } else if !trimmed.ends_with('/') {
4732            let name: String = trimmed
4733                .split(|c: char| c.is_whitespace() || c == '/')
4734                .next()
4735                .unwrap_or("")
4736                .to_string();
4737            if !name.is_empty() {
4738                stack.push((i, close_end, name));
4739            }
4740        }
4741        i = close_end;
4742    }
4743
4744    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
4745    if inner {
4746        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
4747    } else {
4748        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
4749    }
4750}
4751
4752fn is_wordchar(c: char) -> bool {
4753    c.is_alphanumeric() || c == '_'
4754}
4755
4756// `is_keyword_char` lives in hjkl-buffer (used by word motions);
4757// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
4758// one parser, one default, one bug surface.
4759pub(crate) use hjkl_buffer::is_keyword_char;
4760
4761fn word_text_object<H: crate::types::Host>(
4762    ed: &Editor<hjkl_buffer::Buffer, H>,
4763    inner: bool,
4764    big: bool,
4765) -> Option<((usize, usize), (usize, usize))> {
4766    let (row, col) = ed.cursor();
4767    let line = buf_line(&ed.buffer, row)?;
4768    let chars: Vec<char> = line.chars().collect();
4769    if chars.is_empty() {
4770        return None;
4771    }
4772    let at = col.min(chars.len().saturating_sub(1));
4773    let classify = |c: char| -> u8 {
4774        if c.is_whitespace() {
4775            0
4776        } else if big || is_wordchar(c) {
4777            1
4778        } else {
4779            2
4780        }
4781    };
4782    let cls = classify(chars[at]);
4783    let mut start = at;
4784    while start > 0 && classify(chars[start - 1]) == cls {
4785        start -= 1;
4786    }
4787    let mut end = at;
4788    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
4789        end += 1;
4790    }
4791    // Byte-offset helpers.
4792    let char_byte = |i: usize| {
4793        if i >= chars.len() {
4794            line.len()
4795        } else {
4796            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
4797        }
4798    };
4799    let mut start_col = char_byte(start);
4800    // Exclusive end: byte index of char AFTER the last-included char.
4801    let mut end_col = char_byte(end + 1);
4802    if !inner {
4803        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
4804        let mut t = end + 1;
4805        let mut included_trailing = false;
4806        while t < chars.len() && chars[t].is_whitespace() {
4807            included_trailing = true;
4808            t += 1;
4809        }
4810        if included_trailing {
4811            end_col = char_byte(t);
4812        } else {
4813            let mut s = start;
4814            while s > 0 && chars[s - 1].is_whitespace() {
4815                s -= 1;
4816            }
4817            start_col = char_byte(s);
4818        }
4819    }
4820    Some(((row, start_col), (row, end_col)))
4821}
4822
4823fn quote_text_object<H: crate::types::Host>(
4824    ed: &Editor<hjkl_buffer::Buffer, H>,
4825    q: char,
4826    inner: bool,
4827) -> Option<((usize, usize), (usize, usize))> {
4828    let (row, col) = ed.cursor();
4829    let line = buf_line(&ed.buffer, row)?;
4830    let bytes = line.as_bytes();
4831    let q_byte = q as u8;
4832    // Find opening and closing quote on the same line.
4833    let mut positions: Vec<usize> = Vec::new();
4834    for (i, &b) in bytes.iter().enumerate() {
4835        if b == q_byte {
4836            positions.push(i);
4837        }
4838    }
4839    if positions.len() < 2 {
4840        return None;
4841    }
4842    let mut open_idx: Option<usize> = None;
4843    let mut close_idx: Option<usize> = None;
4844    for pair in positions.chunks(2) {
4845        if pair.len() < 2 {
4846            break;
4847        }
4848        if col >= pair[0] && col <= pair[1] {
4849            open_idx = Some(pair[0]);
4850            close_idx = Some(pair[1]);
4851            break;
4852        }
4853        if col < pair[0] {
4854            open_idx = Some(pair[0]);
4855            close_idx = Some(pair[1]);
4856            break;
4857        }
4858    }
4859    let open = open_idx?;
4860    let close = close_idx?;
4861    // End columns are *exclusive* — one past the last character to act on.
4862    if inner {
4863        if close <= open + 1 {
4864            return None;
4865        }
4866        Some(((row, open + 1), (row, close)))
4867    } else {
4868        // `da<q>` — "around" includes the surrounding whitespace on one
4869        // side: trailing whitespace if any exists after the closing quote;
4870        // otherwise leading whitespace before the opening quote. This
4871        // matches vim's `:help text-objects` behaviour and avoids leaving
4872        // a double-space when the quoted span sits mid-sentence.
4873        let after_close = close + 1; // byte index after closing quote
4874        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
4875            // Eat trailing whitespace run.
4876            let mut end = after_close;
4877            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
4878                end += 1;
4879            }
4880            Some(((row, open), (row, end)))
4881        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
4882            // Eat leading whitespace run.
4883            let mut start = open;
4884            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
4885                start -= 1;
4886            }
4887            Some(((row, start), (row, close + 1)))
4888        } else {
4889            Some(((row, open), (row, close + 1)))
4890        }
4891    }
4892}
4893
4894fn bracket_text_object<H: crate::types::Host>(
4895    ed: &Editor<hjkl_buffer::Buffer, H>,
4896    open: char,
4897    inner: bool,
4898) -> Option<(Pos, Pos, MotionKind)> {
4899    let close = match open {
4900        '(' => ')',
4901        '[' => ']',
4902        '{' => '}',
4903        '<' => '>',
4904        _ => return None,
4905    };
4906    let (row, col) = ed.cursor();
4907    let lines = buf_lines_to_vec(&ed.buffer);
4908    let lines = lines.as_slice();
4909    // Walk backward from cursor to find unbalanced opening. When the
4910    // cursor isn't inside any pair, fall back to scanning forward for
4911    // the next opening bracket (targets.vim-style: `ci(` works when
4912    // cursor is before the `(` on the same line or below).
4913    let open_pos = find_open_bracket(lines, row, col, open, close)
4914        .or_else(|| find_next_open(lines, row, col, open))?;
4915    let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
4916    // End positions are *exclusive*.
4917    if inner {
4918        // Multi-line `iB` / `i{` etc: vim deletes the full lines between
4919        // the braces (linewise), preserving the `{` and `}` lines
4920        // themselves and the newlines that directly abut them. E.g.:
4921        //   {\n    body\n}\n  →  {\n}\n    (cursor on `}` line)
4922        // Single-line `i{` falls back to charwise exclusive.
4923        if close_pos.0 > open_pos.0 + 1 {
4924            // There is at least one line strictly between open and close.
4925            let inner_row_start = open_pos.0 + 1;
4926            let inner_row_end = close_pos.0 - 1;
4927            let end_col = lines
4928                .get(inner_row_end)
4929                .map(|l| l.chars().count())
4930                .unwrap_or(0);
4931            return Some((
4932                (inner_row_start, 0),
4933                (inner_row_end, end_col),
4934                MotionKind::Linewise,
4935            ));
4936        }
4937        let inner_start = advance_pos(lines, open_pos);
4938        if inner_start.0 > close_pos.0
4939            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
4940        {
4941            return None;
4942        }
4943        Some((inner_start, close_pos, MotionKind::Exclusive))
4944    } else {
4945        Some((
4946            open_pos,
4947            advance_pos(lines, close_pos),
4948            MotionKind::Exclusive,
4949        ))
4950    }
4951}
4952
4953fn find_open_bracket(
4954    lines: &[String],
4955    row: usize,
4956    col: usize,
4957    open: char,
4958    close: char,
4959) -> Option<(usize, usize)> {
4960    let mut depth: i32 = 0;
4961    let mut r = row;
4962    let mut c = col as isize;
4963    loop {
4964        let cur = &lines[r];
4965        let chars: Vec<char> = cur.chars().collect();
4966        // Clamp `c` to the line length: callers may seed `col` past
4967        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
4968        // so direct indexing would panic on empty / short lines.
4969        if (c as usize) >= chars.len() {
4970            c = chars.len() as isize - 1;
4971        }
4972        while c >= 0 {
4973            let ch = chars[c as usize];
4974            if ch == close {
4975                depth += 1;
4976            } else if ch == open {
4977                if depth == 0 {
4978                    return Some((r, c as usize));
4979                }
4980                depth -= 1;
4981            }
4982            c -= 1;
4983        }
4984        if r == 0 {
4985            return None;
4986        }
4987        r -= 1;
4988        c = lines[r].chars().count() as isize - 1;
4989    }
4990}
4991
4992fn find_close_bracket(
4993    lines: &[String],
4994    row: usize,
4995    start_col: usize,
4996    open: char,
4997    close: char,
4998) -> Option<(usize, usize)> {
4999    let mut depth: i32 = 0;
5000    let mut r = row;
5001    let mut c = start_col;
5002    loop {
5003        let cur = &lines[r];
5004        let chars: Vec<char> = cur.chars().collect();
5005        while c < chars.len() {
5006            let ch = chars[c];
5007            if ch == open {
5008                depth += 1;
5009            } else if ch == close {
5010                if depth == 0 {
5011                    return Some((r, c));
5012                }
5013                depth -= 1;
5014            }
5015            c += 1;
5016        }
5017        if r + 1 >= lines.len() {
5018            return None;
5019        }
5020        r += 1;
5021        c = 0;
5022    }
5023}
5024
5025/// Forward scan from `(row, col)` for the next occurrence of `open`.
5026/// Multi-line. Used by bracket text objects to support targets.vim-style
5027/// "search forward when not currently inside a pair" behaviour.
5028fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
5029    let mut r = row;
5030    let mut c = col;
5031    while r < lines.len() {
5032        let chars: Vec<char> = lines[r].chars().collect();
5033        while c < chars.len() {
5034            if chars[c] == open {
5035                return Some((r, c));
5036            }
5037            c += 1;
5038        }
5039        r += 1;
5040        c = 0;
5041    }
5042    None
5043}
5044
5045fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
5046    let (r, c) = pos;
5047    let line_len = lines[r].chars().count();
5048    if c < line_len {
5049        (r, c + 1)
5050    } else if r + 1 < lines.len() {
5051        (r + 1, 0)
5052    } else {
5053        pos
5054    }
5055}
5056
5057fn paragraph_text_object<H: crate::types::Host>(
5058    ed: &Editor<hjkl_buffer::Buffer, H>,
5059    inner: bool,
5060) -> Option<((usize, usize), (usize, usize))> {
5061    let (row, _) = ed.cursor();
5062    let lines = buf_lines_to_vec(&ed.buffer);
5063    if lines.is_empty() {
5064        return None;
5065    }
5066    // A paragraph is a run of non-blank lines.
5067    let is_blank = |r: usize| lines.get(r).map(|s| s.trim().is_empty()).unwrap_or(true);
5068    if is_blank(row) {
5069        return None;
5070    }
5071    let mut top = row;
5072    while top > 0 && !is_blank(top - 1) {
5073        top -= 1;
5074    }
5075    let mut bot = row;
5076    while bot + 1 < lines.len() && !is_blank(bot + 1) {
5077        bot += 1;
5078    }
5079    // For `ap`, include one trailing blank line if present.
5080    if !inner && bot + 1 < lines.len() && is_blank(bot + 1) {
5081        bot += 1;
5082    }
5083    let end_col = lines[bot].chars().count();
5084    Some(((top, 0), (bot, end_col)))
5085}
5086
5087// ─── Individual commands ───────────────────────────────────────────────────
5088
5089/// Read the text in a vim-shaped range without mutating. Used by
5090/// `Operator::Yank` so we can pipe the same range translation as
5091/// [`cut_vim_range`] but skip the delete + inverse extraction.
5092fn read_vim_range<H: crate::types::Host>(
5093    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5094    start: (usize, usize),
5095    end: (usize, usize),
5096    kind: MotionKind,
5097) -> String {
5098    let (top, bot) = order(start, end);
5099    ed.sync_buffer_content_from_textarea();
5100    let lines = buf_lines_to_vec(&ed.buffer);
5101    match kind {
5102        MotionKind::Linewise => {
5103            let lo = top.0;
5104            let hi = bot.0.min(lines.len().saturating_sub(1));
5105            let mut text = lines[lo..=hi].join("\n");
5106            text.push('\n');
5107            text
5108        }
5109        MotionKind::Inclusive | MotionKind::Exclusive => {
5110            let inclusive = matches!(kind, MotionKind::Inclusive);
5111            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
5112            let mut out = String::new();
5113            for row in top.0..=bot.0 {
5114                let line = lines.get(row).map(String::as_str).unwrap_or("");
5115                let lo = if row == top.0 { top.1 } else { 0 };
5116                let hi_unclamped = if row == bot.0 {
5117                    if inclusive { bot.1 + 1 } else { bot.1 }
5118                } else {
5119                    line.chars().count() + 1
5120                };
5121                let row_chars: Vec<char> = line.chars().collect();
5122                let hi = hi_unclamped.min(row_chars.len());
5123                if lo < hi {
5124                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
5125                }
5126                if row < bot.0 {
5127                    out.push('\n');
5128                }
5129            }
5130            out
5131        }
5132    }
5133}
5134
5135/// Cut a vim-shaped range through the Buffer edit funnel and return
5136/// the deleted text. Translates vim's `MotionKind`
5137/// (Linewise/Inclusive/Exclusive) into the buffer's
5138/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
5139/// position adjustment so inclusive motions actually include the bot
5140/// cell. Pushes the cut text into both `last_yank` and the textarea
5141/// yank buffer (still observed by `p`/`P` until the paste path is
5142/// ported), and updates `yank_linewise` for linewise cuts.
5143fn cut_vim_range<H: crate::types::Host>(
5144    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5145    start: (usize, usize),
5146    end: (usize, usize),
5147    kind: MotionKind,
5148) -> String {
5149    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5150    let (top, bot) = order(start, end);
5151    ed.sync_buffer_content_from_textarea();
5152    let (buf_start, buf_end, buf_kind) = match kind {
5153        MotionKind::Linewise => (
5154            Position::new(top.0, 0),
5155            Position::new(bot.0, 0),
5156            BufKind::Line,
5157        ),
5158        MotionKind::Inclusive => {
5159            let line_chars = buf_line_chars(&ed.buffer, bot.0);
5160            // Advance one cell past `bot` so the buffer's exclusive
5161            // `cut_chars` actually drops the inclusive endpoint. Wrap
5162            // to the next row when bot already sits on the last char.
5163            let next = if bot.1 < line_chars {
5164                Position::new(bot.0, bot.1 + 1)
5165            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
5166                Position::new(bot.0 + 1, 0)
5167            } else {
5168                Position::new(bot.0, line_chars)
5169            };
5170            (Position::new(top.0, top.1), next, BufKind::Char)
5171        }
5172        MotionKind::Exclusive => (
5173            Position::new(top.0, top.1),
5174            Position::new(bot.0, bot.1),
5175            BufKind::Char,
5176        ),
5177    };
5178    let inverse = ed.mutate_edit(Edit::DeleteRange {
5179        start: buf_start,
5180        end: buf_end,
5181        kind: buf_kind,
5182    });
5183    let text = match inverse {
5184        Edit::InsertStr { text, .. } => text,
5185        _ => String::new(),
5186    };
5187    if !text.is_empty() {
5188        ed.record_yank_to_host(text.clone());
5189        ed.record_delete(text.clone(), matches!(kind, MotionKind::Linewise));
5190    }
5191    ed.push_buffer_cursor_to_textarea();
5192    text
5193}
5194
5195/// `D` / `C` — delete from cursor to end of line through the edit
5196/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
5197/// textarea's yank buffer (still observed by `p`/`P` until the paste
5198/// path is ported). Cursor lands at the deletion start so the caller
5199/// can decide whether to step it left (`D`) or open insert mode (`C`).
5200fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5201    use hjkl_buffer::{Edit, MotionKind, Position};
5202    ed.sync_buffer_content_from_textarea();
5203    let cursor = buf_cursor_pos(&ed.buffer);
5204    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5205    if cursor.col >= line_chars {
5206        return;
5207    }
5208    let inverse = ed.mutate_edit(Edit::DeleteRange {
5209        start: cursor,
5210        end: Position::new(cursor.row, line_chars),
5211        kind: MotionKind::Char,
5212    });
5213    if let Edit::InsertStr { text, .. } = inverse
5214        && !text.is_empty()
5215    {
5216        ed.record_yank_to_host(text.clone());
5217        ed.vim.yank_linewise = false;
5218        ed.set_yank(text);
5219    }
5220    buf_set_cursor_pos(&mut ed.buffer, cursor);
5221    ed.push_buffer_cursor_to_textarea();
5222}
5223
5224fn do_char_delete<H: crate::types::Host>(
5225    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5226    forward: bool,
5227    count: usize,
5228) {
5229    use hjkl_buffer::{Edit, MotionKind, Position};
5230    ed.push_undo();
5231    ed.sync_buffer_content_from_textarea();
5232    // Collect deleted chars so we can write them to the unnamed register
5233    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
5234    let mut deleted = String::new();
5235    for _ in 0..count {
5236        let cursor = buf_cursor_pos(&ed.buffer);
5237        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5238        if forward {
5239            // `x` — delete the char under the cursor. Vim no-ops on
5240            // an empty line; the buffer would drop a row otherwise.
5241            if cursor.col >= line_chars {
5242                continue;
5243            }
5244            let inverse = ed.mutate_edit(Edit::DeleteRange {
5245                start: cursor,
5246                end: Position::new(cursor.row, cursor.col + 1),
5247                kind: MotionKind::Char,
5248            });
5249            if let Edit::InsertStr { text, .. } = inverse {
5250                deleted.push_str(&text);
5251            }
5252        } else {
5253            // `X` — delete the char before the cursor.
5254            if cursor.col == 0 {
5255                continue;
5256            }
5257            let inverse = ed.mutate_edit(Edit::DeleteRange {
5258                start: Position::new(cursor.row, cursor.col - 1),
5259                end: cursor,
5260                kind: MotionKind::Char,
5261            });
5262            if let Edit::InsertStr { text, .. } = inverse {
5263                // X deletes backwards; prepend so the register text
5264                // matches reading order (first deleted char first).
5265                deleted = text + &deleted;
5266            }
5267        }
5268    }
5269    if !deleted.is_empty() {
5270        ed.record_yank_to_host(deleted.clone());
5271        ed.record_delete(deleted, false);
5272    }
5273    ed.push_buffer_cursor_to_textarea();
5274}
5275
5276/// Vim `Ctrl-a` / `Ctrl-x` — find the next decimal number at or after the
5277/// cursor on the current line, add `delta`, leave the cursor on the last
5278/// digit of the result. No-op if the line has no digits to the right.
5279fn adjust_number<H: crate::types::Host>(
5280    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5281    delta: i64,
5282) -> bool {
5283    use hjkl_buffer::{Edit, MotionKind, Position};
5284    ed.sync_buffer_content_from_textarea();
5285    let cursor = buf_cursor_pos(&ed.buffer);
5286    let row = cursor.row;
5287    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
5288        Some(l) => l.chars().collect(),
5289        None => return false,
5290    };
5291    let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
5292        return false;
5293    };
5294    let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
5295        digit_start - 1
5296    } else {
5297        digit_start
5298    };
5299    let mut span_end = digit_start;
5300    while span_end < chars.len() && chars[span_end].is_ascii_digit() {
5301        span_end += 1;
5302    }
5303    let s: String = chars[span_start..span_end].iter().collect();
5304    let Ok(n) = s.parse::<i64>() else {
5305        return false;
5306    };
5307    let new_s = n.saturating_add(delta).to_string();
5308
5309    ed.push_undo();
5310    let span_start_pos = Position::new(row, span_start);
5311    let span_end_pos = Position::new(row, span_end);
5312    ed.mutate_edit(Edit::DeleteRange {
5313        start: span_start_pos,
5314        end: span_end_pos,
5315        kind: MotionKind::Char,
5316    });
5317    ed.mutate_edit(Edit::InsertStr {
5318        at: span_start_pos,
5319        text: new_s.clone(),
5320    });
5321    let new_len = new_s.chars().count();
5322    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
5323    ed.push_buffer_cursor_to_textarea();
5324    true
5325}
5326
5327fn replace_char<H: crate::types::Host>(
5328    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5329    ch: char,
5330    count: usize,
5331) {
5332    use hjkl_buffer::{Edit, MotionKind, Position};
5333    ed.push_undo();
5334    ed.sync_buffer_content_from_textarea();
5335    for _ in 0..count {
5336        let cursor = buf_cursor_pos(&ed.buffer);
5337        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5338        if cursor.col >= line_chars {
5339            break;
5340        }
5341        ed.mutate_edit(Edit::DeleteRange {
5342            start: cursor,
5343            end: Position::new(cursor.row, cursor.col + 1),
5344            kind: MotionKind::Char,
5345        });
5346        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
5347    }
5348    // Vim leaves the cursor on the last replaced char.
5349    crate::motions::move_left(&mut ed.buffer, 1);
5350    ed.push_buffer_cursor_to_textarea();
5351}
5352
5353fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5354    use hjkl_buffer::{Edit, MotionKind, Position};
5355    ed.sync_buffer_content_from_textarea();
5356    let cursor = buf_cursor_pos(&ed.buffer);
5357    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
5358        return;
5359    };
5360    let toggled = if c.is_uppercase() {
5361        c.to_lowercase().next().unwrap_or(c)
5362    } else {
5363        c.to_uppercase().next().unwrap_or(c)
5364    };
5365    ed.mutate_edit(Edit::DeleteRange {
5366        start: cursor,
5367        end: Position::new(cursor.row, cursor.col + 1),
5368        kind: MotionKind::Char,
5369    });
5370    ed.mutate_edit(Edit::InsertChar {
5371        at: cursor,
5372        ch: toggled,
5373    });
5374}
5375
5376fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5377    use hjkl_buffer::{Edit, Position};
5378    ed.sync_buffer_content_from_textarea();
5379    let row = buf_cursor_pos(&ed.buffer).row;
5380    if row + 1 >= buf_row_count(&ed.buffer) {
5381        return;
5382    }
5383    let cur_line = buf_line(&ed.buffer, row).unwrap_or("").to_string();
5384    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or("").to_string();
5385    let next_trimmed = next_raw.trim_start();
5386    let cur_chars = cur_line.chars().count();
5387    let next_chars = next_raw.chars().count();
5388    // `J` inserts a single space iff both sides are non-empty after
5389    // stripping the next line's leading whitespace.
5390    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
5391        " "
5392    } else {
5393        ""
5394    };
5395    let joined = format!("{cur_line}{separator}{next_trimmed}");
5396    ed.mutate_edit(Edit::Replace {
5397        start: Position::new(row, 0),
5398        end: Position::new(row + 1, next_chars),
5399        with: joined,
5400    });
5401    // Vim parks the cursor on the inserted space — or at the join
5402    // point when no space went in (which is the same column either
5403    // way, since the space sits exactly at `cur_chars`).
5404    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
5405    ed.push_buffer_cursor_to_textarea();
5406}
5407
5408/// `gJ` — join the next line onto the current one without inserting a
5409/// separating space or stripping leading whitespace.
5410fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5411    use hjkl_buffer::Edit;
5412    ed.sync_buffer_content_from_textarea();
5413    let row = buf_cursor_pos(&ed.buffer).row;
5414    if row + 1 >= buf_row_count(&ed.buffer) {
5415        return;
5416    }
5417    let join_col = buf_line_chars(&ed.buffer, row);
5418    ed.mutate_edit(Edit::JoinLines {
5419        row,
5420        count: 1,
5421        with_space: false,
5422    });
5423    // Vim leaves the cursor at the join point (end of original line).
5424    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
5425    ed.push_buffer_cursor_to_textarea();
5426}
5427
5428fn do_paste<H: crate::types::Host>(
5429    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5430    before: bool,
5431    count: usize,
5432) {
5433    use hjkl_buffer::{Edit, Position};
5434    ed.push_undo();
5435    // Resolve the source register: `"reg` prefix (consumed) or the
5436    // unnamed register otherwise. Read text + linewise from the
5437    // selected slot rather than the global `vim.yank_linewise` so
5438    // pasting from `"0` after a delete still uses the yank's layout.
5439    let selector = ed.vim.pending_register.take();
5440    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
5441        Some(slot) => (slot.text.clone(), slot.linewise),
5442        // Read both fields from the unnamed slot rather than mixing the
5443        // slot's text with `vim.yank_linewise`. The cached vim flag is
5444        // per-editor, so a register imported from another editor (e.g.
5445        // cross-buffer yank/paste) carried the wrong linewise without
5446        // this — pasting a linewise yank inserted at the char cursor.
5447        None => {
5448            let s = &ed.registers().unnamed;
5449            (s.text.clone(), s.linewise)
5450        }
5451    };
5452    for _ in 0..count {
5453        ed.sync_buffer_content_from_textarea();
5454        let yank = yank.clone();
5455        if yank.is_empty() {
5456            continue;
5457        }
5458        if linewise {
5459            // Linewise paste: insert payload as fresh row(s) above
5460            // (`P`) or below (`p`) the cursor's row. Cursor lands on
5461            // the first non-blank of the first pasted line.
5462            let text = yank.trim_matches('\n').to_string();
5463            let row = buf_cursor_pos(&ed.buffer).row;
5464            let target_row = if before {
5465                ed.mutate_edit(Edit::InsertStr {
5466                    at: Position::new(row, 0),
5467                    text: format!("{text}\n"),
5468                });
5469                row
5470            } else {
5471                let line_chars = buf_line_chars(&ed.buffer, row);
5472                ed.mutate_edit(Edit::InsertStr {
5473                    at: Position::new(row, line_chars),
5474                    text: format!("\n{text}"),
5475                });
5476                row + 1
5477            };
5478            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
5479            crate::motions::move_first_non_blank(&mut ed.buffer);
5480            ed.push_buffer_cursor_to_textarea();
5481        } else {
5482            // Charwise paste. `P` inserts at cursor (shifting cell
5483            // right); `p` inserts after cursor (advance one cell
5484            // first, clamped to the end of the line).
5485            let cursor = buf_cursor_pos(&ed.buffer);
5486            let at = if before {
5487                cursor
5488            } else {
5489                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5490                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
5491            };
5492            ed.mutate_edit(Edit::InsertStr {
5493                at,
5494                text: yank.clone(),
5495            });
5496            // Vim parks the cursor on the last char of the pasted
5497            // text (do_insert_str leaves it one past the end).
5498            crate::motions::move_left(&mut ed.buffer, 1);
5499            ed.push_buffer_cursor_to_textarea();
5500        }
5501    }
5502    // Any paste re-anchors the sticky column to the new cursor position.
5503    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
5504}
5505
5506pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5507    if let Some((lines, cursor)) = ed.undo_stack.pop() {
5508        let current = ed.snapshot();
5509        ed.redo_stack.push(current);
5510        ed.restore(lines, cursor);
5511    }
5512    ed.vim.mode = Mode::Normal;
5513    // The restored cursor came from a snapshot taken in insert mode
5514    // (before the insert started) and may be past the last valid
5515    // normal-mode column. Clamp it now, same as Esc-from-insert does.
5516    clamp_cursor_to_normal_mode(ed);
5517}
5518
5519pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5520    if let Some((lines, cursor)) = ed.redo_stack.pop() {
5521        let current = ed.snapshot();
5522        ed.undo_stack.push(current);
5523        ed.cap_undo();
5524        ed.restore(lines, cursor);
5525    }
5526    ed.vim.mode = Mode::Normal;
5527}
5528
5529// ─── Dot repeat ────────────────────────────────────────────────────────────
5530
5531/// Replay-side helper: insert `text` at the cursor through the
5532/// edit funnel, then leave insert mode (the original change ended
5533/// with Esc, so the dot-repeat must end the same way — including
5534/// the cursor step-back vim does on Esc-from-insert).
5535fn replay_insert_and_finish<H: crate::types::Host>(
5536    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5537    text: &str,
5538) {
5539    use hjkl_buffer::{Edit, Position};
5540    let cursor = ed.cursor();
5541    ed.mutate_edit(Edit::InsertStr {
5542        at: Position::new(cursor.0, cursor.1),
5543        text: text.to_string(),
5544    });
5545    if ed.vim.insert_session.take().is_some() {
5546        if ed.cursor().1 > 0 {
5547            crate::motions::move_left(&mut ed.buffer, 1);
5548            ed.push_buffer_cursor_to_textarea();
5549        }
5550        ed.vim.mode = Mode::Normal;
5551    }
5552}
5553
5554fn replay_last_change<H: crate::types::Host>(
5555    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5556    outer_count: usize,
5557) {
5558    let Some(change) = ed.vim.last_change.clone() else {
5559        return;
5560    };
5561    ed.vim.replaying = true;
5562    let scale = if outer_count > 0 { outer_count } else { 1 };
5563    match change {
5564        LastChange::OpMotion {
5565            op,
5566            motion,
5567            count,
5568            inserted,
5569        } => {
5570            let total = count.max(1) * scale;
5571            apply_op_with_motion(ed, op, &motion, total);
5572            if let Some(text) = inserted {
5573                replay_insert_and_finish(ed, &text);
5574            }
5575        }
5576        LastChange::OpTextObj {
5577            op,
5578            obj,
5579            inner,
5580            inserted,
5581        } => {
5582            apply_op_with_text_object(ed, op, obj, inner);
5583            if let Some(text) = inserted {
5584                replay_insert_and_finish(ed, &text);
5585            }
5586        }
5587        LastChange::LineOp {
5588            op,
5589            count,
5590            inserted,
5591        } => {
5592            let total = count.max(1) * scale;
5593            execute_line_op(ed, op, total);
5594            if let Some(text) = inserted {
5595                replay_insert_and_finish(ed, &text);
5596            }
5597        }
5598        LastChange::CharDel { forward, count } => {
5599            do_char_delete(ed, forward, count * scale);
5600        }
5601        LastChange::ReplaceChar { ch, count } => {
5602            replace_char(ed, ch, count * scale);
5603        }
5604        LastChange::ToggleCase { count } => {
5605            for _ in 0..count * scale {
5606                ed.push_undo();
5607                toggle_case_at_cursor(ed);
5608            }
5609        }
5610        LastChange::JoinLine { count } => {
5611            for _ in 0..count * scale {
5612                ed.push_undo();
5613                join_line(ed);
5614            }
5615        }
5616        LastChange::Paste { before, count } => {
5617            do_paste(ed, before, count * scale);
5618        }
5619        LastChange::DeleteToEol { inserted } => {
5620            use hjkl_buffer::{Edit, Position};
5621            ed.push_undo();
5622            delete_to_eol(ed);
5623            if let Some(text) = inserted {
5624                let cursor = ed.cursor();
5625                ed.mutate_edit(Edit::InsertStr {
5626                    at: Position::new(cursor.0, cursor.1),
5627                    text,
5628                });
5629            }
5630        }
5631        LastChange::OpenLine { above, inserted } => {
5632            use hjkl_buffer::{Edit, Position};
5633            ed.push_undo();
5634            ed.sync_buffer_content_from_textarea();
5635            let row = buf_cursor_pos(&ed.buffer).row;
5636            if above {
5637                ed.mutate_edit(Edit::InsertStr {
5638                    at: Position::new(row, 0),
5639                    text: "\n".to_string(),
5640                });
5641                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
5642                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
5643            } else {
5644                let line_chars = buf_line_chars(&ed.buffer, row);
5645                ed.mutate_edit(Edit::InsertStr {
5646                    at: Position::new(row, line_chars),
5647                    text: "\n".to_string(),
5648                });
5649            }
5650            ed.push_buffer_cursor_to_textarea();
5651            let cursor = ed.cursor();
5652            ed.mutate_edit(Edit::InsertStr {
5653                at: Position::new(cursor.0, cursor.1),
5654                text: inserted,
5655            });
5656        }
5657        LastChange::InsertAt {
5658            entry,
5659            inserted,
5660            count,
5661        } => {
5662            use hjkl_buffer::{Edit, Position};
5663            ed.push_undo();
5664            match entry {
5665                InsertEntry::I => {}
5666                InsertEntry::ShiftI => move_first_non_whitespace(ed),
5667                InsertEntry::A => {
5668                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5669                    ed.push_buffer_cursor_to_textarea();
5670                }
5671                InsertEntry::ShiftA => {
5672                    crate::motions::move_line_end(&mut ed.buffer);
5673                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5674                    ed.push_buffer_cursor_to_textarea();
5675                }
5676            }
5677            for _ in 0..count.max(1) {
5678                let cursor = ed.cursor();
5679                ed.mutate_edit(Edit::InsertStr {
5680                    at: Position::new(cursor.0, cursor.1),
5681                    text: inserted.clone(),
5682                });
5683            }
5684        }
5685    }
5686    ed.vim.replaying = false;
5687}
5688
5689// ─── Extracting inserted text for replay ───────────────────────────────────
5690
5691fn extract_inserted(before: &str, after: &str) -> String {
5692    let before_chars: Vec<char> = before.chars().collect();
5693    let after_chars: Vec<char> = after.chars().collect();
5694    if after_chars.len() <= before_chars.len() {
5695        return String::new();
5696    }
5697    let prefix = before_chars
5698        .iter()
5699        .zip(after_chars.iter())
5700        .take_while(|(a, b)| a == b)
5701        .count();
5702    let max_suffix = before_chars.len() - prefix;
5703    let suffix = before_chars
5704        .iter()
5705        .rev()
5706        .zip(after_chars.iter().rev())
5707        .take(max_suffix)
5708        .take_while(|(a, b)| a == b)
5709        .count();
5710    after_chars[prefix..after_chars.len() - suffix]
5711        .iter()
5712        .collect()
5713}
5714
5715// ─── Tests ────────────────────────────────────────────────────────────────
5716
5717#[cfg(all(test, feature = "crossterm"))]
5718mod tests {
5719    use crate::VimMode;
5720    use crate::editor::Editor;
5721    use crate::types::Host;
5722    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5723
5724    fn run_keys<H: crate::types::Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, keys: &str) {
5725        // Minimal notation:
5726        //   <Esc> <CR> <BS> <Left/Right/Up/Down> <C-x>
5727        //   anything else = single char
5728        let mut iter = keys.chars().peekable();
5729        while let Some(c) = iter.next() {
5730            if c == '<' {
5731                let mut tag = String::new();
5732                for ch in iter.by_ref() {
5733                    if ch == '>' {
5734                        break;
5735                    }
5736                    tag.push(ch);
5737                }
5738                let ev = match tag.as_str() {
5739                    "Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
5740                    "CR" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
5741                    "BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
5742                    "Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
5743                    "Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
5744                    "Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
5745                    "Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
5746                    "Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
5747                    // Vim-style literal `<` escape so tests can type
5748                    // the outdent operator without colliding with the
5749                    // `<tag>` notation this helper uses for special keys.
5750                    "lt" => KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
5751                    s if s.starts_with("C-") => {
5752                        let ch = s.chars().nth(2).unwrap();
5753                        KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
5754                    }
5755                    _ => continue,
5756                };
5757                e.handle_key(ev);
5758            } else {
5759                let mods = if c.is_uppercase() {
5760                    KeyModifiers::SHIFT
5761                } else {
5762                    KeyModifiers::NONE
5763                };
5764                e.handle_key(KeyEvent::new(KeyCode::Char(c), mods));
5765            }
5766        }
5767    }
5768
5769    fn editor_with(content: &str) -> Editor {
5770        // Tests historically assume shiftwidth=2 (sqeel-derived). The 0.1.0
5771        // SPEC default is shiftwidth=8 (vim-faithful). Keep these tests on
5772        // the legacy 2-space rhythm so the indent/outdent assertions don't
5773        // churn.
5774        let opts = crate::types::Options {
5775            shiftwidth: 2,
5776            ..crate::types::Options::default()
5777        };
5778        let mut e = Editor::new(
5779            hjkl_buffer::Buffer::new(),
5780            crate::types::DefaultHost::new(),
5781            opts,
5782        );
5783        e.set_content(content);
5784        e
5785    }
5786
5787    #[test]
5788    fn f_char_jumps_on_line() {
5789        let mut e = editor_with("hello world");
5790        run_keys(&mut e, "fw");
5791        assert_eq!(e.cursor(), (0, 6));
5792    }
5793
5794    #[test]
5795    fn cap_f_jumps_backward() {
5796        let mut e = editor_with("hello world");
5797        e.jump_cursor(0, 10);
5798        run_keys(&mut e, "Fo");
5799        assert_eq!(e.cursor().1, 7);
5800    }
5801
5802    #[test]
5803    fn t_stops_before_char() {
5804        let mut e = editor_with("hello");
5805        run_keys(&mut e, "tl");
5806        assert_eq!(e.cursor(), (0, 1));
5807    }
5808
5809    #[test]
5810    fn semicolon_repeats_find() {
5811        let mut e = editor_with("aa.bb.cc");
5812        run_keys(&mut e, "f.");
5813        assert_eq!(e.cursor().1, 2);
5814        run_keys(&mut e, ";");
5815        assert_eq!(e.cursor().1, 5);
5816    }
5817
5818    #[test]
5819    fn comma_repeats_find_reverse() {
5820        let mut e = editor_with("aa.bb.cc");
5821        run_keys(&mut e, "f.");
5822        run_keys(&mut e, ";");
5823        run_keys(&mut e, ",");
5824        assert_eq!(e.cursor().1, 2);
5825    }
5826
5827    #[test]
5828    fn di_quote_deletes_content() {
5829        let mut e = editor_with("foo \"bar\" baz");
5830        e.jump_cursor(0, 6); // inside quotes
5831        run_keys(&mut e, "di\"");
5832        assert_eq!(e.buffer().lines()[0], "foo \"\" baz");
5833    }
5834
5835    #[test]
5836    fn da_quote_deletes_with_quotes() {
5837        // `da"` eats the trailing space after the closing quote so the
5838        // result matches vim's "around" text-object whitespace rule.
5839        let mut e = editor_with("foo \"bar\" baz");
5840        e.jump_cursor(0, 6);
5841        run_keys(&mut e, "da\"");
5842        assert_eq!(e.buffer().lines()[0], "foo baz");
5843    }
5844
5845    #[test]
5846    fn ci_paren_deletes_and_inserts() {
5847        let mut e = editor_with("fn(a, b, c)");
5848        e.jump_cursor(0, 5);
5849        run_keys(&mut e, "ci(");
5850        assert_eq!(e.vim_mode(), VimMode::Insert);
5851        assert_eq!(e.buffer().lines()[0], "fn()");
5852    }
5853
5854    #[test]
5855    fn diw_deletes_inner_word() {
5856        let mut e = editor_with("hello world");
5857        e.jump_cursor(0, 2);
5858        run_keys(&mut e, "diw");
5859        assert_eq!(e.buffer().lines()[0], " world");
5860    }
5861
5862    #[test]
5863    fn daw_deletes_word_with_trailing_space() {
5864        let mut e = editor_with("hello world");
5865        run_keys(&mut e, "daw");
5866        assert_eq!(e.buffer().lines()[0], "world");
5867    }
5868
5869    #[test]
5870    fn percent_jumps_to_matching_bracket() {
5871        let mut e = editor_with("foo(bar)");
5872        e.jump_cursor(0, 3);
5873        run_keys(&mut e, "%");
5874        assert_eq!(e.cursor().1, 7);
5875        run_keys(&mut e, "%");
5876        assert_eq!(e.cursor().1, 3);
5877    }
5878
5879    #[test]
5880    fn dot_repeats_last_change() {
5881        let mut e = editor_with("aaa bbb ccc");
5882        run_keys(&mut e, "dw");
5883        assert_eq!(e.buffer().lines()[0], "bbb ccc");
5884        run_keys(&mut e, ".");
5885        assert_eq!(e.buffer().lines()[0], "ccc");
5886    }
5887
5888    #[test]
5889    fn dot_repeats_change_operator_with_text() {
5890        let mut e = editor_with("foo foo foo");
5891        run_keys(&mut e, "cwbar<Esc>");
5892        assert_eq!(e.buffer().lines()[0], "bar foo foo");
5893        // Move past the space.
5894        run_keys(&mut e, "w");
5895        run_keys(&mut e, ".");
5896        assert_eq!(e.buffer().lines()[0], "bar bar foo");
5897    }
5898
5899    #[test]
5900    fn dot_repeats_x() {
5901        let mut e = editor_with("abcdef");
5902        run_keys(&mut e, "x");
5903        run_keys(&mut e, "..");
5904        assert_eq!(e.buffer().lines()[0], "def");
5905    }
5906
5907    #[test]
5908    fn count_operator_motion_compose() {
5909        let mut e = editor_with("one two three four five");
5910        run_keys(&mut e, "d3w");
5911        assert_eq!(e.buffer().lines()[0], "four five");
5912    }
5913
5914    #[test]
5915    fn two_dd_deletes_two_lines() {
5916        let mut e = editor_with("a\nb\nc");
5917        run_keys(&mut e, "2dd");
5918        assert_eq!(e.buffer().lines().len(), 1);
5919        assert_eq!(e.buffer().lines()[0], "c");
5920    }
5921
5922    /// Vim's `dd` leaves the cursor on the first non-blank of the line
5923    /// that now sits at the deleted row — not at the end of the
5924    /// previous line, which is where tui-textarea's raw cut would
5925    /// park it.
5926    #[test]
5927    fn dd_in_middle_puts_cursor_on_first_non_blank_of_next() {
5928        let mut e = editor_with("one\ntwo\n    three\nfour");
5929        e.jump_cursor(1, 2);
5930        run_keys(&mut e, "dd");
5931        // Buffer: ["one", "    three", "four"]
5932        assert_eq!(e.buffer().lines()[1], "    three");
5933        assert_eq!(e.cursor(), (1, 4));
5934    }
5935
5936    #[test]
5937    fn dd_on_last_line_puts_cursor_on_first_non_blank_of_prev() {
5938        let mut e = editor_with("one\n  two\nthree");
5939        e.jump_cursor(2, 0);
5940        run_keys(&mut e, "dd");
5941        // Buffer: ["one", "  two"]
5942        assert_eq!(e.buffer().lines().len(), 2);
5943        assert_eq!(e.cursor(), (1, 2));
5944    }
5945
5946    #[test]
5947    fn dd_on_only_line_leaves_empty_buffer_and_cursor_at_zero() {
5948        let mut e = editor_with("lonely");
5949        run_keys(&mut e, "dd");
5950        assert_eq!(e.buffer().lines().len(), 1);
5951        assert_eq!(e.buffer().lines()[0], "");
5952        assert_eq!(e.cursor(), (0, 0));
5953    }
5954
5955    #[test]
5956    fn count_dd_puts_cursor_on_first_non_blank_of_remaining() {
5957        let mut e = editor_with("a\nb\nc\n   d\ne");
5958        // Cursor on row 1, "3dd" deletes b/c/   d → lines become [a, e].
5959        e.jump_cursor(1, 0);
5960        run_keys(&mut e, "3dd");
5961        assert_eq!(e.buffer().lines(), &["a".to_string(), "e".to_string()]);
5962        assert_eq!(e.cursor(), (1, 0));
5963    }
5964
5965    #[test]
5966    fn dd_then_j_uses_first_non_blank_not_sticky_col() {
5967        // Buffer: 3 lines with predictable widths.
5968        // Line 0: "    line one"   (12 chars, first-non-blank at col 4)
5969        // Line 1: "    line two"   (12 chars, first-non-blank at col 4)
5970        // Line 2: "  xy"           (4 chars, indices 0-3; last char at col 3)
5971        //
5972        // Cursor starts at col 8 on line 0.  After `dd`:
5973        //   - line 0 is deleted; cursor lands on first-non-blank of new line 0
5974        //     ("    line two") → col 4.
5975        //   - sticky_col must be updated to 4.
5976        //
5977        // Then `j` moves to "  xy" (4 chars, max col = 3).
5978        //   - With the fix   : sticky_col=4 → clamps to col 3 (last char).
5979        //   - Without the fix: sticky_col=8 → clamps to col 3 (same clamp).
5980        //
5981        // To make the two cases distinguishable we choose line 2 with
5982        // exactly 6 chars ("  xyz!") so max col = 5:
5983        //   - fix   : sticky_col=4 → lands at col 4.
5984        //   - no fix: sticky_col=8 → clamps to col 5.
5985        let mut e = editor_with("    line one\n    line two\n  xyz!");
5986        // Move to col 8 on line 0.
5987        e.jump_cursor(0, 8);
5988        assert_eq!(e.cursor(), (0, 8));
5989        // `dd` deletes line 0; cursor should land on first-non-blank of
5990        // the new line 0 ("    line two" → col 4).
5991        run_keys(&mut e, "dd");
5992        assert_eq!(
5993            e.cursor(),
5994            (0, 4),
5995            "dd must place cursor on first-non-blank"
5996        );
5997        // `j` moves to "  xyz!" (6 chars, cols 0-5).
5998        // Bug: stale sticky_col=8 clamps to col 5 (last char).
5999        // Fixed: sticky_col=4 → lands at col 4.
6000        run_keys(&mut e, "j");
6001        let (row, col) = e.cursor();
6002        assert_eq!(row, 1);
6003        assert_eq!(
6004            col, 4,
6005            "after dd, j should use the column dd established (4), not pre-dd sticky_col (8)"
6006        );
6007    }
6008
6009    #[test]
6010    fn gu_lowercases_motion_range() {
6011        let mut e = editor_with("HELLO WORLD");
6012        run_keys(&mut e, "guw");
6013        assert_eq!(e.buffer().lines()[0], "hello WORLD");
6014        assert_eq!(e.cursor(), (0, 0));
6015    }
6016
6017    #[test]
6018    fn g_u_uppercases_text_object() {
6019        let mut e = editor_with("hello world");
6020        // gUiw uppercases the word at the cursor.
6021        run_keys(&mut e, "gUiw");
6022        assert_eq!(e.buffer().lines()[0], "HELLO world");
6023        assert_eq!(e.cursor(), (0, 0));
6024    }
6025
6026    #[test]
6027    fn g_tilde_toggles_case_of_range() {
6028        let mut e = editor_with("Hello World");
6029        run_keys(&mut e, "g~iw");
6030        assert_eq!(e.buffer().lines()[0], "hELLO World");
6031    }
6032
6033    #[test]
6034    fn g_uu_uppercases_current_line() {
6035        let mut e = editor_with("select 1\nselect 2");
6036        run_keys(&mut e, "gUU");
6037        assert_eq!(e.buffer().lines()[0], "SELECT 1");
6038        assert_eq!(e.buffer().lines()[1], "select 2");
6039    }
6040
6041    #[test]
6042    fn gugu_lowercases_current_line() {
6043        let mut e = editor_with("FOO BAR\nBAZ");
6044        run_keys(&mut e, "gugu");
6045        assert_eq!(e.buffer().lines()[0], "foo bar");
6046    }
6047
6048    #[test]
6049    fn visual_u_uppercases_selection() {
6050        let mut e = editor_with("hello world");
6051        // v + e selects "hello" (inclusive of last char), U uppercases.
6052        run_keys(&mut e, "veU");
6053        assert_eq!(e.buffer().lines()[0], "HELLO world");
6054    }
6055
6056    #[test]
6057    fn visual_line_u_lowercases_line() {
6058        let mut e = editor_with("HELLO WORLD\nOTHER");
6059        run_keys(&mut e, "Vu");
6060        assert_eq!(e.buffer().lines()[0], "hello world");
6061        assert_eq!(e.buffer().lines()[1], "OTHER");
6062    }
6063
6064    #[test]
6065    fn g_uu_with_count_uppercases_multiple_lines() {
6066        let mut e = editor_with("one\ntwo\nthree\nfour");
6067        // `3gUU` uppercases 3 lines starting from the cursor.
6068        run_keys(&mut e, "3gUU");
6069        assert_eq!(e.buffer().lines()[0], "ONE");
6070        assert_eq!(e.buffer().lines()[1], "TWO");
6071        assert_eq!(e.buffer().lines()[2], "THREE");
6072        assert_eq!(e.buffer().lines()[3], "four");
6073    }
6074
6075    #[test]
6076    fn double_gt_indents_current_line() {
6077        let mut e = editor_with("hello");
6078        run_keys(&mut e, ">>");
6079        assert_eq!(e.buffer().lines()[0], "  hello");
6080        // Cursor lands on first non-blank.
6081        assert_eq!(e.cursor(), (0, 2));
6082    }
6083
6084    #[test]
6085    fn double_lt_outdents_current_line() {
6086        let mut e = editor_with("    hello");
6087        run_keys(&mut e, "<lt><lt>");
6088        assert_eq!(e.buffer().lines()[0], "  hello");
6089        assert_eq!(e.cursor(), (0, 2));
6090    }
6091
6092    #[test]
6093    fn count_double_gt_indents_multiple_lines() {
6094        let mut e = editor_with("a\nb\nc\nd");
6095        // `3>>` indents 3 lines starting at cursor.
6096        run_keys(&mut e, "3>>");
6097        assert_eq!(e.buffer().lines()[0], "  a");
6098        assert_eq!(e.buffer().lines()[1], "  b");
6099        assert_eq!(e.buffer().lines()[2], "  c");
6100        assert_eq!(e.buffer().lines()[3], "d");
6101    }
6102
6103    #[test]
6104    fn outdent_clips_ragged_leading_whitespace() {
6105        // Only one space of indent — outdent should strip what's
6106        // there, not leave anything negative.
6107        let mut e = editor_with(" x");
6108        run_keys(&mut e, "<lt><lt>");
6109        assert_eq!(e.buffer().lines()[0], "x");
6110    }
6111
6112    #[test]
6113    fn indent_motion_is_always_linewise() {
6114        // `>w` indents the current line (linewise) — it doesn't
6115        // insert spaces into the middle of the word.
6116        let mut e = editor_with("foo bar");
6117        run_keys(&mut e, ">w");
6118        assert_eq!(e.buffer().lines()[0], "  foo bar");
6119    }
6120
6121    #[test]
6122    fn indent_text_object_extends_over_paragraph() {
6123        let mut e = editor_with("a\nb\n\nc\nd");
6124        // `>ap` indents the whole paragraph (rows 0..=1).
6125        run_keys(&mut e, ">ap");
6126        assert_eq!(e.buffer().lines()[0], "  a");
6127        assert_eq!(e.buffer().lines()[1], "  b");
6128        assert_eq!(e.buffer().lines()[2], "");
6129        assert_eq!(e.buffer().lines()[3], "c");
6130    }
6131
6132    #[test]
6133    fn visual_line_indent_shifts_selected_rows() {
6134        let mut e = editor_with("x\ny\nz");
6135        // Vj selects rows 0..=1 linewise; `>` indents.
6136        run_keys(&mut e, "Vj>");
6137        assert_eq!(e.buffer().lines()[0], "  x");
6138        assert_eq!(e.buffer().lines()[1], "  y");
6139        assert_eq!(e.buffer().lines()[2], "z");
6140    }
6141
6142    #[test]
6143    fn outdent_empty_line_is_noop() {
6144        let mut e = editor_with("\nfoo");
6145        run_keys(&mut e, "<lt><lt>");
6146        assert_eq!(e.buffer().lines()[0], "");
6147    }
6148
6149    #[test]
6150    fn indent_skips_empty_lines() {
6151        // Vim convention: `>>` on an empty line doesn't pad it with
6152        // trailing whitespace.
6153        let mut e = editor_with("");
6154        run_keys(&mut e, ">>");
6155        assert_eq!(e.buffer().lines()[0], "");
6156    }
6157
6158    #[test]
6159    fn insert_ctrl_t_indents_current_line() {
6160        let mut e = editor_with("x");
6161        // Enter insert, Ctrl-t indents the line; cursor advances too.
6162        run_keys(&mut e, "i<C-t>");
6163        assert_eq!(e.buffer().lines()[0], "  x");
6164        // After insert-mode start `i` cursor was at (0, 0); Ctrl-t
6165        // shifts it by SHIFTWIDTH=2.
6166        assert_eq!(e.cursor(), (0, 2));
6167    }
6168
6169    #[test]
6170    fn insert_ctrl_d_outdents_current_line() {
6171        let mut e = editor_with("    x");
6172        // Enter insert-at-end `A`, Ctrl-d outdents by shiftwidth.
6173        run_keys(&mut e, "A<C-d>");
6174        assert_eq!(e.buffer().lines()[0], "  x");
6175    }
6176
6177    #[test]
6178    fn h_at_col_zero_does_not_wrap_to_prev_line() {
6179        let mut e = editor_with("first\nsecond");
6180        e.jump_cursor(1, 0);
6181        run_keys(&mut e, "h");
6182        // Cursor must stay on row 1 col 0 — vim default doesn't wrap.
6183        assert_eq!(e.cursor(), (1, 0));
6184    }
6185
6186    #[test]
6187    fn l_at_last_char_does_not_wrap_to_next_line() {
6188        let mut e = editor_with("ab\ncd");
6189        // Move to last char of row 0 (col 1).
6190        e.jump_cursor(0, 1);
6191        run_keys(&mut e, "l");
6192        // Cursor stays on last char — no wrap.
6193        assert_eq!(e.cursor(), (0, 1));
6194    }
6195
6196    #[test]
6197    fn count_l_clamps_at_line_end() {
6198        let mut e = editor_with("abcde");
6199        // 20l starting at col 0 should land on last char (col 4),
6200        // not overflow / wrap.
6201        run_keys(&mut e, "20l");
6202        assert_eq!(e.cursor(), (0, 4));
6203    }
6204
6205    #[test]
6206    fn count_h_clamps_at_col_zero() {
6207        let mut e = editor_with("abcde");
6208        e.jump_cursor(0, 3);
6209        run_keys(&mut e, "20h");
6210        assert_eq!(e.cursor(), (0, 0));
6211    }
6212
6213    #[test]
6214    fn dl_on_last_char_still_deletes_it() {
6215        // `dl` / `x`-equivalent at EOL must delete the last char —
6216        // operator motion allows endpoint past-last even though bare
6217        // `l` stops before.
6218        let mut e = editor_with("ab");
6219        e.jump_cursor(0, 1);
6220        run_keys(&mut e, "dl");
6221        assert_eq!(e.buffer().lines()[0], "a");
6222    }
6223
6224    #[test]
6225    fn case_op_preserves_yank_register() {
6226        let mut e = editor_with("target");
6227        run_keys(&mut e, "yy");
6228        let yank_before = e.yank().to_string();
6229        // gUU changes the line but must not clobber the yank register.
6230        run_keys(&mut e, "gUU");
6231        assert_eq!(e.buffer().lines()[0], "TARGET");
6232        assert_eq!(
6233            e.yank(),
6234            yank_before,
6235            "case ops must preserve the yank buffer"
6236        );
6237    }
6238
6239    #[test]
6240    fn dap_deletes_paragraph() {
6241        let mut e = editor_with("a\nb\n\nc\nd");
6242        run_keys(&mut e, "dap");
6243        assert_eq!(e.buffer().lines().first().map(String::as_str), Some("c"));
6244    }
6245
6246    #[test]
6247    fn dit_deletes_inner_tag_content() {
6248        let mut e = editor_with("<b>hello</b>");
6249        // Cursor on `e`.
6250        e.jump_cursor(0, 4);
6251        run_keys(&mut e, "dit");
6252        assert_eq!(e.buffer().lines()[0], "<b></b>");
6253    }
6254
6255    #[test]
6256    fn dat_deletes_around_tag() {
6257        let mut e = editor_with("hi <b>foo</b> bye");
6258        e.jump_cursor(0, 6);
6259        run_keys(&mut e, "dat");
6260        assert_eq!(e.buffer().lines()[0], "hi  bye");
6261    }
6262
6263    #[test]
6264    fn dit_picks_innermost_tag() {
6265        let mut e = editor_with("<a><b>x</b></a>");
6266        // Cursor on `x`.
6267        e.jump_cursor(0, 6);
6268        run_keys(&mut e, "dit");
6269        // Inner of <b> is removed; <a> wrapping stays.
6270        assert_eq!(e.buffer().lines()[0], "<a><b></b></a>");
6271    }
6272
6273    #[test]
6274    fn dat_innermost_tag_pair() {
6275        let mut e = editor_with("<a><b>x</b></a>");
6276        e.jump_cursor(0, 6);
6277        run_keys(&mut e, "dat");
6278        assert_eq!(e.buffer().lines()[0], "<a></a>");
6279    }
6280
6281    #[test]
6282    fn dit_outside_any_tag_no_op() {
6283        let mut e = editor_with("plain text");
6284        e.jump_cursor(0, 3);
6285        run_keys(&mut e, "dit");
6286        // No tag pair surrounds the cursor — buffer unchanged.
6287        assert_eq!(e.buffer().lines()[0], "plain text");
6288    }
6289
6290    #[test]
6291    fn cit_changes_inner_tag_content() {
6292        let mut e = editor_with("<b>hello</b>");
6293        e.jump_cursor(0, 4);
6294        run_keys(&mut e, "citNEW<Esc>");
6295        assert_eq!(e.buffer().lines()[0], "<b>NEW</b>");
6296    }
6297
6298    #[test]
6299    fn cat_changes_around_tag() {
6300        let mut e = editor_with("hi <b>foo</b> bye");
6301        e.jump_cursor(0, 6);
6302        run_keys(&mut e, "catBAR<Esc>");
6303        assert_eq!(e.buffer().lines()[0], "hi BAR bye");
6304    }
6305
6306    #[test]
6307    fn yit_yanks_inner_tag_content() {
6308        let mut e = editor_with("<b>hello</b>");
6309        e.jump_cursor(0, 4);
6310        run_keys(&mut e, "yit");
6311        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6312    }
6313
6314    #[test]
6315    fn yat_yanks_full_tag_pair() {
6316        let mut e = editor_with("hi <b>foo</b> bye");
6317        e.jump_cursor(0, 6);
6318        run_keys(&mut e, "yat");
6319        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6320    }
6321
6322    #[test]
6323    fn vit_visually_selects_inner_tag() {
6324        let mut e = editor_with("<b>hello</b>");
6325        e.jump_cursor(0, 4);
6326        run_keys(&mut e, "vit");
6327        assert_eq!(e.vim_mode(), VimMode::Visual);
6328        run_keys(&mut e, "y");
6329        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6330    }
6331
6332    #[test]
6333    fn vat_visually_selects_around_tag() {
6334        let mut e = editor_with("x<b>foo</b>y");
6335        e.jump_cursor(0, 5);
6336        run_keys(&mut e, "vat");
6337        assert_eq!(e.vim_mode(), VimMode::Visual);
6338        run_keys(&mut e, "y");
6339        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6340    }
6341
6342    // ─── Text-object coverage (d operator, inner + around) ───────────
6343
6344    #[test]
6345    #[allow(non_snake_case)]
6346    fn diW_deletes_inner_big_word() {
6347        let mut e = editor_with("foo.bar baz");
6348        e.jump_cursor(0, 2);
6349        run_keys(&mut e, "diW");
6350        // Big word treats `foo.bar` as one token.
6351        assert_eq!(e.buffer().lines()[0], " baz");
6352    }
6353
6354    #[test]
6355    #[allow(non_snake_case)]
6356    fn daW_deletes_around_big_word() {
6357        let mut e = editor_with("foo.bar baz");
6358        e.jump_cursor(0, 2);
6359        run_keys(&mut e, "daW");
6360        assert_eq!(e.buffer().lines()[0], "baz");
6361    }
6362
6363    #[test]
6364    fn di_double_quote_deletes_inside() {
6365        let mut e = editor_with("a \"hello\" b");
6366        e.jump_cursor(0, 4);
6367        run_keys(&mut e, "di\"");
6368        assert_eq!(e.buffer().lines()[0], "a \"\" b");
6369    }
6370
6371    #[test]
6372    fn da_double_quote_deletes_around() {
6373        // `da"` eats the trailing space — matches vim's around-whitespace rule.
6374        let mut e = editor_with("a \"hello\" b");
6375        e.jump_cursor(0, 4);
6376        run_keys(&mut e, "da\"");
6377        assert_eq!(e.buffer().lines()[0], "a b");
6378    }
6379
6380    #[test]
6381    fn di_single_quote_deletes_inside() {
6382        let mut e = editor_with("x 'foo' y");
6383        e.jump_cursor(0, 4);
6384        run_keys(&mut e, "di'");
6385        assert_eq!(e.buffer().lines()[0], "x '' y");
6386    }
6387
6388    #[test]
6389    fn da_single_quote_deletes_around() {
6390        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6391        let mut e = editor_with("x 'foo' y");
6392        e.jump_cursor(0, 4);
6393        run_keys(&mut e, "da'");
6394        assert_eq!(e.buffer().lines()[0], "x y");
6395    }
6396
6397    #[test]
6398    fn di_backtick_deletes_inside() {
6399        let mut e = editor_with("p `q` r");
6400        e.jump_cursor(0, 3);
6401        run_keys(&mut e, "di`");
6402        assert_eq!(e.buffer().lines()[0], "p `` r");
6403    }
6404
6405    #[test]
6406    fn da_backtick_deletes_around() {
6407        // `da`` eats the trailing space — matches vim's around-whitespace rule.
6408        let mut e = editor_with("p `q` r");
6409        e.jump_cursor(0, 3);
6410        run_keys(&mut e, "da`");
6411        assert_eq!(e.buffer().lines()[0], "p r");
6412    }
6413
6414    #[test]
6415    fn di_paren_deletes_inside() {
6416        let mut e = editor_with("f(arg)");
6417        e.jump_cursor(0, 3);
6418        run_keys(&mut e, "di(");
6419        assert_eq!(e.buffer().lines()[0], "f()");
6420    }
6421
6422    #[test]
6423    fn di_paren_alias_b_works() {
6424        let mut e = editor_with("f(arg)");
6425        e.jump_cursor(0, 3);
6426        run_keys(&mut e, "dib");
6427        assert_eq!(e.buffer().lines()[0], "f()");
6428    }
6429
6430    #[test]
6431    fn di_bracket_deletes_inside() {
6432        let mut e = editor_with("a[b,c]d");
6433        e.jump_cursor(0, 3);
6434        run_keys(&mut e, "di[");
6435        assert_eq!(e.buffer().lines()[0], "a[]d");
6436    }
6437
6438    #[test]
6439    fn da_bracket_deletes_around() {
6440        let mut e = editor_with("a[b,c]d");
6441        e.jump_cursor(0, 3);
6442        run_keys(&mut e, "da[");
6443        assert_eq!(e.buffer().lines()[0], "ad");
6444    }
6445
6446    #[test]
6447    fn di_brace_deletes_inside() {
6448        let mut e = editor_with("x{y}z");
6449        e.jump_cursor(0, 2);
6450        run_keys(&mut e, "di{");
6451        assert_eq!(e.buffer().lines()[0], "x{}z");
6452    }
6453
6454    #[test]
6455    fn da_brace_deletes_around() {
6456        let mut e = editor_with("x{y}z");
6457        e.jump_cursor(0, 2);
6458        run_keys(&mut e, "da{");
6459        assert_eq!(e.buffer().lines()[0], "xz");
6460    }
6461
6462    #[test]
6463    fn di_brace_alias_capital_b_works() {
6464        let mut e = editor_with("x{y}z");
6465        e.jump_cursor(0, 2);
6466        run_keys(&mut e, "diB");
6467        assert_eq!(e.buffer().lines()[0], "x{}z");
6468    }
6469
6470    #[test]
6471    fn di_angle_deletes_inside() {
6472        let mut e = editor_with("p<q>r");
6473        e.jump_cursor(0, 2);
6474        // `<lt>` so run_keys doesn't treat `<` as the start of a special-key tag.
6475        run_keys(&mut e, "di<lt>");
6476        assert_eq!(e.buffer().lines()[0], "p<>r");
6477    }
6478
6479    #[test]
6480    fn da_angle_deletes_around() {
6481        let mut e = editor_with("p<q>r");
6482        e.jump_cursor(0, 2);
6483        run_keys(&mut e, "da<lt>");
6484        assert_eq!(e.buffer().lines()[0], "pr");
6485    }
6486
6487    #[test]
6488    fn dip_deletes_inner_paragraph() {
6489        let mut e = editor_with("a\nb\nc\n\nd");
6490        e.jump_cursor(1, 0);
6491        run_keys(&mut e, "dip");
6492        // Inner paragraph (rows 0..=2) drops; the trailing blank
6493        // separator + remaining paragraph stay.
6494        assert_eq!(e.buffer().lines(), vec!["".to_string(), "d".into()]);
6495    }
6496
6497    // ─── Operator pipeline spot checks (non-tag text objects) ───────
6498
6499    #[test]
6500    fn sentence_motion_close_paren_jumps_forward() {
6501        let mut e = editor_with("Alpha. Beta. Gamma.");
6502        e.jump_cursor(0, 0);
6503        run_keys(&mut e, ")");
6504        // Lands on the start of "Beta".
6505        assert_eq!(e.cursor(), (0, 7));
6506        run_keys(&mut e, ")");
6507        assert_eq!(e.cursor(), (0, 13));
6508    }
6509
6510    #[test]
6511    fn sentence_motion_open_paren_jumps_backward() {
6512        let mut e = editor_with("Alpha. Beta. Gamma.");
6513        e.jump_cursor(0, 13);
6514        run_keys(&mut e, "(");
6515        // Cursor was at start of "Gamma" (col 13); first `(` walks
6516        // back to the previous sentence's start.
6517        assert_eq!(e.cursor(), (0, 7));
6518        run_keys(&mut e, "(");
6519        assert_eq!(e.cursor(), (0, 0));
6520    }
6521
6522    #[test]
6523    fn sentence_motion_count() {
6524        let mut e = editor_with("A. B. C. D.");
6525        e.jump_cursor(0, 0);
6526        run_keys(&mut e, "3)");
6527        // 3 forward jumps land on "D".
6528        assert_eq!(e.cursor(), (0, 9));
6529    }
6530
6531    #[test]
6532    fn dis_deletes_inner_sentence() {
6533        let mut e = editor_with("First one. Second one. Third one.");
6534        e.jump_cursor(0, 13);
6535        run_keys(&mut e, "dis");
6536        // Removed "Second one." inclusive of its terminator.
6537        assert_eq!(e.buffer().lines()[0], "First one.  Third one.");
6538    }
6539
6540    #[test]
6541    fn das_deletes_around_sentence_with_trailing_space() {
6542        let mut e = editor_with("Alpha. Beta. Gamma.");
6543        e.jump_cursor(0, 8);
6544        run_keys(&mut e, "das");
6545        // `as` swallows the trailing whitespace before the next
6546        // sentence — exactly one space here.
6547        assert_eq!(e.buffer().lines()[0], "Alpha. Gamma.");
6548    }
6549
6550    #[test]
6551    fn dis_handles_double_terminator() {
6552        let mut e = editor_with("Wow!? Next.");
6553        e.jump_cursor(0, 1);
6554        run_keys(&mut e, "dis");
6555        // Run of `!?` collapses into one boundary; sentence body
6556        // including both terminators is removed.
6557        assert_eq!(e.buffer().lines()[0], " Next.");
6558    }
6559
6560    #[test]
6561    fn dis_first_sentence_from_cursor_at_zero() {
6562        let mut e = editor_with("Alpha. Beta.");
6563        e.jump_cursor(0, 0);
6564        run_keys(&mut e, "dis");
6565        assert_eq!(e.buffer().lines()[0], " Beta.");
6566    }
6567
6568    #[test]
6569    fn yis_yanks_inner_sentence() {
6570        let mut e = editor_with("Hello world. Bye.");
6571        e.jump_cursor(0, 5);
6572        run_keys(&mut e, "yis");
6573        assert_eq!(e.registers().read('"').unwrap().text, "Hello world.");
6574    }
6575
6576    #[test]
6577    fn vis_visually_selects_inner_sentence() {
6578        let mut e = editor_with("First. Second.");
6579        e.jump_cursor(0, 1);
6580        run_keys(&mut e, "vis");
6581        assert_eq!(e.vim_mode(), VimMode::Visual);
6582        run_keys(&mut e, "y");
6583        assert_eq!(e.registers().read('"').unwrap().text, "First.");
6584    }
6585
6586    #[test]
6587    fn ciw_changes_inner_word() {
6588        let mut e = editor_with("hello world");
6589        e.jump_cursor(0, 1);
6590        run_keys(&mut e, "ciwHEY<Esc>");
6591        assert_eq!(e.buffer().lines()[0], "HEY world");
6592    }
6593
6594    #[test]
6595    fn yiw_yanks_inner_word() {
6596        let mut e = editor_with("hello world");
6597        e.jump_cursor(0, 1);
6598        run_keys(&mut e, "yiw");
6599        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6600    }
6601
6602    #[test]
6603    fn viw_selects_inner_word() {
6604        let mut e = editor_with("hello world");
6605        e.jump_cursor(0, 2);
6606        run_keys(&mut e, "viw");
6607        assert_eq!(e.vim_mode(), VimMode::Visual);
6608        run_keys(&mut e, "y");
6609        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6610    }
6611
6612    #[test]
6613    fn ci_paren_changes_inside() {
6614        let mut e = editor_with("f(old)");
6615        e.jump_cursor(0, 3);
6616        run_keys(&mut e, "ci(NEW<Esc>");
6617        assert_eq!(e.buffer().lines()[0], "f(NEW)");
6618    }
6619
6620    #[test]
6621    fn yi_double_quote_yanks_inside() {
6622        let mut e = editor_with("say \"hi there\" then");
6623        e.jump_cursor(0, 6);
6624        run_keys(&mut e, "yi\"");
6625        assert_eq!(e.registers().read('"').unwrap().text, "hi there");
6626    }
6627
6628    #[test]
6629    fn vap_visual_selects_around_paragraph() {
6630        let mut e = editor_with("a\nb\n\nc");
6631        e.jump_cursor(0, 0);
6632        run_keys(&mut e, "vap");
6633        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6634        run_keys(&mut e, "y");
6635        // Linewise yank includes the paragraph rows + trailing blank.
6636        let text = e.registers().read('"').unwrap().text.clone();
6637        assert!(text.starts_with("a\nb"));
6638    }
6639
6640    #[test]
6641    fn star_finds_next_occurrence() {
6642        let mut e = editor_with("foo bar foo baz");
6643        run_keys(&mut e, "*");
6644        assert_eq!(e.cursor().1, 8);
6645    }
6646
6647    #[test]
6648    fn star_skips_substring_match() {
6649        // `*` uses `\bfoo\b` so `foobar` is *not* a hit; cursor wraps
6650        // back to the original `foo` at col 0.
6651        let mut e = editor_with("foo foobar baz");
6652        run_keys(&mut e, "*");
6653        assert_eq!(e.cursor().1, 0);
6654    }
6655
6656    #[test]
6657    fn g_star_matches_substring() {
6658        // `g*` drops the boundary; from `foo` at col 0 the next hit is
6659        // inside `foobar` (col 4).
6660        let mut e = editor_with("foo foobar baz");
6661        run_keys(&mut e, "g*");
6662        assert_eq!(e.cursor().1, 4);
6663    }
6664
6665    #[test]
6666    fn g_pound_matches_substring_backward() {
6667        // Start on the last `foo`; `g#` walks backward and lands inside
6668        // `foobar` (col 4).
6669        let mut e = editor_with("foo foobar baz foo");
6670        run_keys(&mut e, "$b");
6671        assert_eq!(e.cursor().1, 15);
6672        run_keys(&mut e, "g#");
6673        assert_eq!(e.cursor().1, 4);
6674    }
6675
6676    #[test]
6677    fn n_repeats_last_search_forward() {
6678        let mut e = editor_with("foo bar foo baz foo");
6679        // `/foo<CR>` jumps past the cursor's current cell, so from
6680        // col 0 the first hit is the second `foo` at col 8.
6681        run_keys(&mut e, "/foo<CR>");
6682        assert_eq!(e.cursor().1, 8);
6683        run_keys(&mut e, "n");
6684        assert_eq!(e.cursor().1, 16);
6685    }
6686
6687    #[test]
6688    fn shift_n_reverses_search() {
6689        let mut e = editor_with("foo bar foo baz foo");
6690        run_keys(&mut e, "/foo<CR>");
6691        run_keys(&mut e, "n");
6692        assert_eq!(e.cursor().1, 16);
6693        run_keys(&mut e, "N");
6694        assert_eq!(e.cursor().1, 8);
6695    }
6696
6697    #[test]
6698    fn n_noop_without_pattern() {
6699        let mut e = editor_with("foo bar");
6700        run_keys(&mut e, "n");
6701        assert_eq!(e.cursor(), (0, 0));
6702    }
6703
6704    #[test]
6705    fn visual_line_preserves_cursor_column() {
6706        // V should never drag the cursor off its natural column — the
6707        // highlight is painted as a post-render overlay instead.
6708        let mut e = editor_with("hello world\nanother one\nbye");
6709        run_keys(&mut e, "lllll"); // col 5
6710        run_keys(&mut e, "V");
6711        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6712        assert_eq!(e.cursor(), (0, 5));
6713        run_keys(&mut e, "j");
6714        assert_eq!(e.cursor(), (1, 5));
6715    }
6716
6717    #[test]
6718    fn visual_line_yank_includes_trailing_newline() {
6719        let mut e = editor_with("aaa\nbbb\nccc");
6720        run_keys(&mut e, "Vjy");
6721        // Two lines yanked — must be `aaa\nbbb\n`, trailing newline preserved.
6722        assert_eq!(e.last_yank.as_deref(), Some("aaa\nbbb\n"));
6723    }
6724
6725    #[test]
6726    fn visual_line_yank_last_line_trailing_newline() {
6727        let mut e = editor_with("aaa\nbbb\nccc");
6728        // Move to the last line and yank with V (final buffer line).
6729        run_keys(&mut e, "jj");
6730        run_keys(&mut e, "Vy");
6731        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6732    }
6733
6734    #[test]
6735    fn yy_on_last_line_has_trailing_newline() {
6736        let mut e = editor_with("aaa\nbbb\nccc");
6737        run_keys(&mut e, "jj");
6738        run_keys(&mut e, "yy");
6739        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6740    }
6741
6742    #[test]
6743    fn yy_in_middle_has_trailing_newline() {
6744        let mut e = editor_with("aaa\nbbb\nccc");
6745        run_keys(&mut e, "j");
6746        run_keys(&mut e, "yy");
6747        assert_eq!(e.last_yank.as_deref(), Some("bbb\n"));
6748    }
6749
6750    #[test]
6751    fn di_single_quote() {
6752        let mut e = editor_with("say 'hello world' now");
6753        e.jump_cursor(0, 7);
6754        run_keys(&mut e, "di'");
6755        assert_eq!(e.buffer().lines()[0], "say '' now");
6756    }
6757
6758    #[test]
6759    fn da_single_quote() {
6760        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6761        let mut e = editor_with("say 'hello' now");
6762        e.jump_cursor(0, 7);
6763        run_keys(&mut e, "da'");
6764        assert_eq!(e.buffer().lines()[0], "say now");
6765    }
6766
6767    #[test]
6768    fn di_backtick() {
6769        let mut e = editor_with("say `hi` now");
6770        e.jump_cursor(0, 5);
6771        run_keys(&mut e, "di`");
6772        assert_eq!(e.buffer().lines()[0], "say `` now");
6773    }
6774
6775    #[test]
6776    fn di_brace() {
6777        let mut e = editor_with("fn { a; b; c }");
6778        e.jump_cursor(0, 7);
6779        run_keys(&mut e, "di{");
6780        assert_eq!(e.buffer().lines()[0], "fn {}");
6781    }
6782
6783    #[test]
6784    fn di_bracket() {
6785        let mut e = editor_with("arr[1, 2, 3]");
6786        e.jump_cursor(0, 5);
6787        run_keys(&mut e, "di[");
6788        assert_eq!(e.buffer().lines()[0], "arr[]");
6789    }
6790
6791    #[test]
6792    fn dab_deletes_around_paren() {
6793        let mut e = editor_with("fn(a, b) + 1");
6794        e.jump_cursor(0, 4);
6795        run_keys(&mut e, "dab");
6796        assert_eq!(e.buffer().lines()[0], "fn + 1");
6797    }
6798
6799    #[test]
6800    fn da_big_b_deletes_around_brace() {
6801        let mut e = editor_with("x = {a: 1}");
6802        e.jump_cursor(0, 6);
6803        run_keys(&mut e, "daB");
6804        assert_eq!(e.buffer().lines()[0], "x = ");
6805    }
6806
6807    #[test]
6808    fn di_big_w_deletes_bigword() {
6809        let mut e = editor_with("foo-bar baz");
6810        e.jump_cursor(0, 2);
6811        run_keys(&mut e, "diW");
6812        assert_eq!(e.buffer().lines()[0], " baz");
6813    }
6814
6815    #[test]
6816    fn visual_select_inner_word() {
6817        let mut e = editor_with("hello world");
6818        e.jump_cursor(0, 2);
6819        run_keys(&mut e, "viw");
6820        assert_eq!(e.vim_mode(), VimMode::Visual);
6821        run_keys(&mut e, "y");
6822        assert_eq!(e.last_yank.as_deref(), Some("hello"));
6823    }
6824
6825    #[test]
6826    fn visual_select_inner_quote() {
6827        let mut e = editor_with("foo \"bar\" baz");
6828        e.jump_cursor(0, 6);
6829        run_keys(&mut e, "vi\"");
6830        run_keys(&mut e, "y");
6831        assert_eq!(e.last_yank.as_deref(), Some("bar"));
6832    }
6833
6834    #[test]
6835    fn visual_select_inner_paren() {
6836        let mut e = editor_with("fn(a, b)");
6837        e.jump_cursor(0, 4);
6838        run_keys(&mut e, "vi(");
6839        run_keys(&mut e, "y");
6840        assert_eq!(e.last_yank.as_deref(), Some("a, b"));
6841    }
6842
6843    #[test]
6844    fn visual_select_outer_brace() {
6845        let mut e = editor_with("{x}");
6846        e.jump_cursor(0, 1);
6847        run_keys(&mut e, "va{");
6848        run_keys(&mut e, "y");
6849        assert_eq!(e.last_yank.as_deref(), Some("{x}"));
6850    }
6851
6852    #[test]
6853    fn ci_paren_forward_scans_when_cursor_before_pair() {
6854        // targets.vim-style: cursor at start of `foo`, ci( jumps to next
6855        // `(...)` pair on the same line and replaces the contents.
6856        let mut e = editor_with("foo(bar)");
6857        e.jump_cursor(0, 0);
6858        run_keys(&mut e, "ci(NEW<Esc>");
6859        assert_eq!(e.buffer().lines()[0], "foo(NEW)");
6860    }
6861
6862    #[test]
6863    fn ci_paren_forward_scans_across_lines() {
6864        let mut e = editor_with("first\nfoo(bar)\nlast");
6865        e.jump_cursor(0, 0);
6866        run_keys(&mut e, "ci(NEW<Esc>");
6867        assert_eq!(e.buffer().lines()[1], "foo(NEW)");
6868    }
6869
6870    #[test]
6871    fn ci_brace_forward_scans_when_cursor_before_pair() {
6872        let mut e = editor_with("let x = {y};");
6873        e.jump_cursor(0, 0);
6874        run_keys(&mut e, "ci{NEW<Esc>");
6875        assert_eq!(e.buffer().lines()[0], "let x = {NEW};");
6876    }
6877
6878    #[test]
6879    fn cit_forward_scans_when_cursor_before_tag() {
6880        // Cursor at column 0 (before `<b>`), cit jumps into the next tag
6881        // pair and replaces its contents.
6882        let mut e = editor_with("text <b>hello</b> rest");
6883        e.jump_cursor(0, 0);
6884        run_keys(&mut e, "citNEW<Esc>");
6885        assert_eq!(e.buffer().lines()[0], "text <b>NEW</b> rest");
6886    }
6887
6888    #[test]
6889    fn dat_forward_scans_when_cursor_before_tag() {
6890        // dat = delete around tag — including the `<b>...</b>` markup.
6891        let mut e = editor_with("text <b>hello</b> rest");
6892        e.jump_cursor(0, 0);
6893        run_keys(&mut e, "dat");
6894        assert_eq!(e.buffer().lines()[0], "text  rest");
6895    }
6896
6897    #[test]
6898    fn ci_paren_still_works_when_cursor_inside() {
6899        // Regression: forward-scan fallback must not break the
6900        // canonical "cursor inside the pair" case.
6901        let mut e = editor_with("fn(a, b)");
6902        e.jump_cursor(0, 4);
6903        run_keys(&mut e, "ci(NEW<Esc>");
6904        assert_eq!(e.buffer().lines()[0], "fn(NEW)");
6905    }
6906
6907    #[test]
6908    fn caw_changes_word_with_trailing_space() {
6909        let mut e = editor_with("hello world");
6910        run_keys(&mut e, "cawfoo<Esc>");
6911        assert_eq!(e.buffer().lines()[0], "fooworld");
6912    }
6913
6914    #[test]
6915    fn visual_char_yank_preserves_raw_text() {
6916        let mut e = editor_with("hello world");
6917        run_keys(&mut e, "vllly");
6918        assert_eq!(e.last_yank.as_deref(), Some("hell"));
6919    }
6920
6921    #[test]
6922    fn single_line_visual_line_selects_full_line_on_yank() {
6923        let mut e = editor_with("hello world\nbye");
6924        run_keys(&mut e, "V");
6925        // Yank the selection — should include the full line + trailing
6926        // newline (linewise yank convention).
6927        run_keys(&mut e, "y");
6928        assert_eq!(e.last_yank.as_deref(), Some("hello world\n"));
6929    }
6930
6931    #[test]
6932    fn visual_line_extends_both_directions() {
6933        let mut e = editor_with("aaa\nbbb\nccc\nddd");
6934        run_keys(&mut e, "jjj"); // row 3, col 0
6935        run_keys(&mut e, "V");
6936        assert_eq!(e.cursor(), (3, 0));
6937        run_keys(&mut e, "k");
6938        // Cursor is free to sit on its natural column — no forced Jump.
6939        assert_eq!(e.cursor(), (2, 0));
6940        run_keys(&mut e, "k");
6941        assert_eq!(e.cursor(), (1, 0));
6942    }
6943
6944    #[test]
6945    fn visual_char_preserves_cursor_column() {
6946        let mut e = editor_with("hello world");
6947        run_keys(&mut e, "lllll"); // col 5
6948        run_keys(&mut e, "v");
6949        assert_eq!(e.cursor(), (0, 5));
6950        run_keys(&mut e, "ll");
6951        assert_eq!(e.cursor(), (0, 7));
6952    }
6953
6954    #[test]
6955    fn visual_char_highlight_bounds_order() {
6956        let mut e = editor_with("abcdef");
6957        run_keys(&mut e, "lll"); // col 3
6958        run_keys(&mut e, "v");
6959        run_keys(&mut e, "hh"); // col 1
6960        // Anchor (0, 3), cursor (0, 1). Bounds ordered: start=(0,1) end=(0,3).
6961        assert_eq!(e.char_highlight(), Some(((0, 1), (0, 3))));
6962    }
6963
6964    #[test]
6965    fn visual_line_highlight_bounds() {
6966        let mut e = editor_with("a\nb\nc");
6967        run_keys(&mut e, "V");
6968        assert_eq!(e.line_highlight(), Some((0, 0)));
6969        run_keys(&mut e, "j");
6970        assert_eq!(e.line_highlight(), Some((0, 1)));
6971        run_keys(&mut e, "j");
6972        assert_eq!(e.line_highlight(), Some((0, 2)));
6973    }
6974
6975    // ─── Basic motions ─────────────────────────────────────────────────────
6976
6977    #[test]
6978    fn h_moves_left() {
6979        let mut e = editor_with("hello");
6980        e.jump_cursor(0, 3);
6981        run_keys(&mut e, "h");
6982        assert_eq!(e.cursor(), (0, 2));
6983    }
6984
6985    #[test]
6986    fn l_moves_right() {
6987        let mut e = editor_with("hello");
6988        run_keys(&mut e, "l");
6989        assert_eq!(e.cursor(), (0, 1));
6990    }
6991
6992    #[test]
6993    fn k_moves_up() {
6994        let mut e = editor_with("a\nb\nc");
6995        e.jump_cursor(2, 0);
6996        run_keys(&mut e, "k");
6997        assert_eq!(e.cursor(), (1, 0));
6998    }
6999
7000    #[test]
7001    fn zero_moves_to_line_start() {
7002        let mut e = editor_with("    hello");
7003        run_keys(&mut e, "$");
7004        run_keys(&mut e, "0");
7005        assert_eq!(e.cursor().1, 0);
7006    }
7007
7008    #[test]
7009    fn caret_moves_to_first_non_blank() {
7010        let mut e = editor_with("    hello");
7011        run_keys(&mut e, "0");
7012        run_keys(&mut e, "^");
7013        assert_eq!(e.cursor().1, 4);
7014    }
7015
7016    #[test]
7017    fn dollar_moves_to_last_char() {
7018        let mut e = editor_with("hello");
7019        run_keys(&mut e, "$");
7020        assert_eq!(e.cursor().1, 4);
7021    }
7022
7023    #[test]
7024    fn dollar_on_empty_line_stays_at_col_zero() {
7025        let mut e = editor_with("");
7026        run_keys(&mut e, "$");
7027        assert_eq!(e.cursor().1, 0);
7028    }
7029
7030    #[test]
7031    fn w_jumps_to_next_word() {
7032        let mut e = editor_with("foo bar baz");
7033        run_keys(&mut e, "w");
7034        assert_eq!(e.cursor().1, 4);
7035    }
7036
7037    #[test]
7038    fn b_jumps_back_a_word() {
7039        let mut e = editor_with("foo bar");
7040        e.jump_cursor(0, 6);
7041        run_keys(&mut e, "b");
7042        assert_eq!(e.cursor().1, 4);
7043    }
7044
7045    #[test]
7046    fn e_jumps_to_word_end() {
7047        let mut e = editor_with("foo bar");
7048        run_keys(&mut e, "e");
7049        assert_eq!(e.cursor().1, 2);
7050    }
7051
7052    // ─── Operators with line-edge and file-edge motions ───────────────────
7053
7054    #[test]
7055    fn d_dollar_deletes_to_eol() {
7056        let mut e = editor_with("hello world");
7057        e.jump_cursor(0, 5);
7058        run_keys(&mut e, "d$");
7059        assert_eq!(e.buffer().lines()[0], "hello");
7060    }
7061
7062    #[test]
7063    fn d_zero_deletes_to_line_start() {
7064        let mut e = editor_with("hello world");
7065        e.jump_cursor(0, 6);
7066        run_keys(&mut e, "d0");
7067        assert_eq!(e.buffer().lines()[0], "world");
7068    }
7069
7070    #[test]
7071    fn d_caret_deletes_to_first_non_blank() {
7072        let mut e = editor_with("    hello");
7073        e.jump_cursor(0, 6);
7074        run_keys(&mut e, "d^");
7075        assert_eq!(e.buffer().lines()[0], "    llo");
7076    }
7077
7078    #[test]
7079    fn d_capital_g_deletes_to_end_of_file() {
7080        let mut e = editor_with("a\nb\nc\nd");
7081        e.jump_cursor(1, 0);
7082        run_keys(&mut e, "dG");
7083        assert_eq!(e.buffer().lines(), &["a".to_string()]);
7084    }
7085
7086    #[test]
7087    fn d_gg_deletes_to_start_of_file() {
7088        let mut e = editor_with("a\nb\nc\nd");
7089        e.jump_cursor(2, 0);
7090        run_keys(&mut e, "dgg");
7091        assert_eq!(e.buffer().lines(), &["d".to_string()]);
7092    }
7093
7094    #[test]
7095    fn cw_is_ce_quirk() {
7096        // `cw` on a non-blank word must NOT eat the trailing whitespace;
7097        // it behaves like `ce` so the replacement lands before the space.
7098        let mut e = editor_with("foo bar");
7099        run_keys(&mut e, "cwxyz<Esc>");
7100        assert_eq!(e.buffer().lines()[0], "xyz bar");
7101    }
7102
7103    // ─── Single-char edits ────────────────────────────────────────────────
7104
7105    #[test]
7106    fn big_d_deletes_to_eol() {
7107        let mut e = editor_with("hello world");
7108        e.jump_cursor(0, 5);
7109        run_keys(&mut e, "D");
7110        assert_eq!(e.buffer().lines()[0], "hello");
7111    }
7112
7113    #[test]
7114    fn big_c_deletes_to_eol_and_inserts() {
7115        let mut e = editor_with("hello world");
7116        e.jump_cursor(0, 5);
7117        run_keys(&mut e, "C!<Esc>");
7118        assert_eq!(e.buffer().lines()[0], "hello!");
7119    }
7120
7121    #[test]
7122    fn j_joins_next_line_with_space() {
7123        let mut e = editor_with("hello\nworld");
7124        run_keys(&mut e, "J");
7125        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7126    }
7127
7128    #[test]
7129    fn j_strips_leading_whitespace_on_join() {
7130        let mut e = editor_with("hello\n    world");
7131        run_keys(&mut e, "J");
7132        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7133    }
7134
7135    #[test]
7136    fn big_x_deletes_char_before_cursor() {
7137        let mut e = editor_with("hello");
7138        e.jump_cursor(0, 3);
7139        run_keys(&mut e, "X");
7140        assert_eq!(e.buffer().lines()[0], "helo");
7141    }
7142
7143    #[test]
7144    fn s_substitutes_char_and_enters_insert() {
7145        let mut e = editor_with("hello");
7146        run_keys(&mut e, "sX<Esc>");
7147        assert_eq!(e.buffer().lines()[0], "Xello");
7148    }
7149
7150    #[test]
7151    fn count_x_deletes_many() {
7152        let mut e = editor_with("abcdef");
7153        run_keys(&mut e, "3x");
7154        assert_eq!(e.buffer().lines()[0], "def");
7155    }
7156
7157    // ─── Paste ────────────────────────────────────────────────────────────
7158
7159    #[test]
7160    fn p_pastes_charwise_after_cursor() {
7161        let mut e = editor_with("hello");
7162        run_keys(&mut e, "yw");
7163        run_keys(&mut e, "$p");
7164        assert_eq!(e.buffer().lines()[0], "hellohello");
7165    }
7166
7167    #[test]
7168    fn capital_p_pastes_charwise_before_cursor() {
7169        let mut e = editor_with("hello");
7170        // Yank "he" (2 chars) then paste it before the cursor.
7171        run_keys(&mut e, "v");
7172        run_keys(&mut e, "l");
7173        run_keys(&mut e, "y");
7174        run_keys(&mut e, "$P");
7175        // After yank cursor is at 0; $ goes to end (col 4), P pastes
7176        // before cursor — "hell" + "he" + "o" = "hellheo".
7177        assert_eq!(e.buffer().lines()[0], "hellheo");
7178    }
7179
7180    #[test]
7181    fn p_pastes_linewise_below() {
7182        let mut e = editor_with("one\ntwo\nthree");
7183        run_keys(&mut e, "yy");
7184        run_keys(&mut e, "p");
7185        assert_eq!(
7186            e.buffer().lines(),
7187            &[
7188                "one".to_string(),
7189                "one".to_string(),
7190                "two".to_string(),
7191                "three".to_string()
7192            ]
7193        );
7194    }
7195
7196    #[test]
7197    fn capital_p_pastes_linewise_above() {
7198        let mut e = editor_with("one\ntwo");
7199        e.jump_cursor(1, 0);
7200        run_keys(&mut e, "yy");
7201        run_keys(&mut e, "P");
7202        assert_eq!(
7203            e.buffer().lines(),
7204            &["one".to_string(), "two".to_string(), "two".to_string()]
7205        );
7206    }
7207
7208    // ─── Reverse word search ──────────────────────────────────────────────
7209
7210    #[test]
7211    fn hash_finds_previous_occurrence() {
7212        let mut e = editor_with("foo bar foo baz foo");
7213        // Move to the third 'foo' then #.
7214        e.jump_cursor(0, 16);
7215        run_keys(&mut e, "#");
7216        assert_eq!(e.cursor().1, 8);
7217    }
7218
7219    // ─── VisualLine delete / change ───────────────────────────────────────
7220
7221    #[test]
7222    fn visual_line_delete_removes_full_lines() {
7223        let mut e = editor_with("a\nb\nc\nd");
7224        run_keys(&mut e, "Vjd");
7225        assert_eq!(e.buffer().lines(), &["c".to_string(), "d".to_string()]);
7226    }
7227
7228    #[test]
7229    fn visual_line_change_leaves_blank_line() {
7230        let mut e = editor_with("a\nb\nc");
7231        run_keys(&mut e, "Vjc");
7232        assert_eq!(e.vim_mode(), VimMode::Insert);
7233        run_keys(&mut e, "X<Esc>");
7234        // `Vjc` wipes rows 0-1's contents and leaves a blank line in
7235        // their place (vim convention). Typing `X` lands on that blank
7236        // first line.
7237        assert_eq!(e.buffer().lines(), &["X".to_string(), "c".to_string()]);
7238    }
7239
7240    #[test]
7241    fn cc_leaves_blank_line() {
7242        let mut e = editor_with("a\nb\nc");
7243        e.jump_cursor(1, 0);
7244        run_keys(&mut e, "ccX<Esc>");
7245        assert_eq!(
7246            e.buffer().lines(),
7247            &["a".to_string(), "X".to_string(), "c".to_string()]
7248        );
7249    }
7250
7251    // ─── Scrolling ────────────────────────────────────────────────────────
7252
7253    // ─── WORD motions (W/B/E) ─────────────────────────────────────────────
7254
7255    #[test]
7256    fn big_w_skips_hyphens() {
7257        // `w` stops at `-`; `W` treats the whole `foo-bar` as one WORD.
7258        let mut e = editor_with("foo-bar baz");
7259        run_keys(&mut e, "W");
7260        assert_eq!(e.cursor().1, 8);
7261    }
7262
7263    #[test]
7264    fn big_w_crosses_lines() {
7265        let mut e = editor_with("foo-bar\nbaz-qux");
7266        run_keys(&mut e, "W");
7267        assert_eq!(e.cursor(), (1, 0));
7268    }
7269
7270    #[test]
7271    fn big_b_skips_hyphens() {
7272        let mut e = editor_with("foo-bar baz");
7273        e.jump_cursor(0, 9);
7274        run_keys(&mut e, "B");
7275        assert_eq!(e.cursor().1, 8);
7276        run_keys(&mut e, "B");
7277        assert_eq!(e.cursor().1, 0);
7278    }
7279
7280    #[test]
7281    fn big_e_jumps_to_big_word_end() {
7282        let mut e = editor_with("foo-bar baz");
7283        run_keys(&mut e, "E");
7284        assert_eq!(e.cursor().1, 6);
7285        run_keys(&mut e, "E");
7286        assert_eq!(e.cursor().1, 10);
7287    }
7288
7289    #[test]
7290    fn dw_with_big_word_variant() {
7291        // `dW` uses the WORD motion, so `foo-bar` deletes as a unit.
7292        let mut e = editor_with("foo-bar baz");
7293        run_keys(&mut e, "dW");
7294        assert_eq!(e.buffer().lines()[0], "baz");
7295    }
7296
7297    // ─── Insert-mode Ctrl shortcuts ──────────────────────────────────────
7298
7299    #[test]
7300    fn insert_ctrl_w_deletes_word_back() {
7301        let mut e = editor_with("");
7302        run_keys(&mut e, "i");
7303        for c in "hello world".chars() {
7304            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7305        }
7306        run_keys(&mut e, "<C-w>");
7307        assert_eq!(e.buffer().lines()[0], "hello ");
7308    }
7309
7310    #[test]
7311    fn insert_ctrl_w_at_col0_joins_with_prev_word() {
7312        // Vim with default `backspace=indent,eol,start`: Ctrl-W at the
7313        // start of a row joins to the previous line and deletes the
7314        // word now before the cursor.
7315        let mut e = editor_with("hello\nworld");
7316        e.jump_cursor(1, 0);
7317        run_keys(&mut e, "i");
7318        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7319        // "hello" was the only word on row 0; it gets deleted, leaving
7320        // "world" on a single line.
7321        assert_eq!(e.buffer().lines(), vec!["world".to_string()]);
7322        assert_eq!(e.cursor(), (0, 0));
7323    }
7324
7325    #[test]
7326    fn insert_ctrl_w_at_col0_keeps_prefix_words() {
7327        let mut e = editor_with("foo bar\nbaz");
7328        e.jump_cursor(1, 0);
7329        run_keys(&mut e, "i");
7330        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7331        // Joins lines, then deletes the trailing "bar" of the prev line.
7332        assert_eq!(e.buffer().lines(), vec!["foo baz".to_string()]);
7333        assert_eq!(e.cursor(), (0, 4));
7334    }
7335
7336    #[test]
7337    fn insert_ctrl_u_deletes_to_line_start() {
7338        let mut e = editor_with("");
7339        run_keys(&mut e, "i");
7340        for c in "hello world".chars() {
7341            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7342        }
7343        run_keys(&mut e, "<C-u>");
7344        assert_eq!(e.buffer().lines()[0], "");
7345    }
7346
7347    #[test]
7348    fn insert_ctrl_o_runs_one_normal_command() {
7349        let mut e = editor_with("hello world");
7350        // Enter insert, then Ctrl-o dw (delete a word while in insert).
7351        run_keys(&mut e, "A");
7352        assert_eq!(e.vim_mode(), VimMode::Insert);
7353        // Move cursor back to start of "hello" for the Ctrl-o dw.
7354        e.jump_cursor(0, 0);
7355        run_keys(&mut e, "<C-o>");
7356        assert_eq!(e.vim_mode(), VimMode::Normal);
7357        run_keys(&mut e, "dw");
7358        // After the command completes, back in insert.
7359        assert_eq!(e.vim_mode(), VimMode::Insert);
7360        assert_eq!(e.buffer().lines()[0], "world");
7361    }
7362
7363    // ─── Sticky column across vertical motion ────────────────────────────
7364
7365    #[test]
7366    fn j_through_empty_line_preserves_column() {
7367        let mut e = editor_with("hello world\n\nanother line");
7368        // Park cursor at col 6 on row 0.
7369        run_keys(&mut e, "llllll");
7370        assert_eq!(e.cursor(), (0, 6));
7371        // j into the empty line — cursor clamps to (1, 0) visually, but
7372        // sticky col stays at 6.
7373        run_keys(&mut e, "j");
7374        assert_eq!(e.cursor(), (1, 0));
7375        // j onto a longer row — sticky col restores us to col 6.
7376        run_keys(&mut e, "j");
7377        assert_eq!(e.cursor(), (2, 6));
7378    }
7379
7380    #[test]
7381    fn j_through_shorter_line_preserves_column() {
7382        let mut e = editor_with("hello world\nhi\nanother line");
7383        run_keys(&mut e, "lllllll"); // col 7
7384        run_keys(&mut e, "j"); // short line — clamps to col 1
7385        assert_eq!(e.cursor(), (1, 1));
7386        run_keys(&mut e, "j");
7387        assert_eq!(e.cursor(), (2, 7));
7388    }
7389
7390    #[test]
7391    fn esc_from_insert_sticky_matches_visible_cursor() {
7392        // Cursor at col 12, I (moves to col 4), type "X" (col 5), Esc
7393        // backs to col 4 — sticky must mirror that visible col so j
7394        // lands at col 4 of the next row, not col 5 or col 12.
7395        let mut e = editor_with("    this is a line\n    another one of a similar size");
7396        e.jump_cursor(0, 12);
7397        run_keys(&mut e, "I");
7398        assert_eq!(e.cursor(), (0, 4));
7399        run_keys(&mut e, "X<Esc>");
7400        assert_eq!(e.cursor(), (0, 4));
7401        run_keys(&mut e, "j");
7402        assert_eq!(e.cursor(), (1, 4));
7403    }
7404
7405    #[test]
7406    fn esc_from_insert_sticky_tracks_inserted_chars() {
7407        let mut e = editor_with("xxxxxxx\nyyyyyyy");
7408        run_keys(&mut e, "i");
7409        run_keys(&mut e, "abc<Esc>");
7410        assert_eq!(e.cursor(), (0, 2));
7411        run_keys(&mut e, "j");
7412        assert_eq!(e.cursor(), (1, 2));
7413    }
7414
7415    #[test]
7416    fn esc_from_insert_sticky_tracks_arrow_nav() {
7417        let mut e = editor_with("xxxxxx\nyyyyyy");
7418        run_keys(&mut e, "i");
7419        run_keys(&mut e, "abc");
7420        for _ in 0..2 {
7421            e.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
7422        }
7423        run_keys(&mut e, "<Esc>");
7424        assert_eq!(e.cursor(), (0, 0));
7425        run_keys(&mut e, "j");
7426        assert_eq!(e.cursor(), (1, 0));
7427    }
7428
7429    #[test]
7430    fn esc_from_insert_at_col_14_followed_by_j() {
7431        // User-reported regression: cursor at col 14, i, type "test "
7432        // (5 chars → col 19), Esc → col 18. j must land at col 18.
7433        let line = "x".repeat(30);
7434        let buf = format!("{line}\n{line}");
7435        let mut e = editor_with(&buf);
7436        e.jump_cursor(0, 14);
7437        run_keys(&mut e, "i");
7438        for c in "test ".chars() {
7439            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7440        }
7441        run_keys(&mut e, "<Esc>");
7442        assert_eq!(e.cursor(), (0, 18));
7443        run_keys(&mut e, "j");
7444        assert_eq!(e.cursor(), (1, 18));
7445    }
7446
7447    #[test]
7448    fn linewise_paste_resets_sticky_column() {
7449        // yy then p lands the cursor on the first non-blank of the
7450        // pasted line; the next j must not drag back to the old
7451        // sticky column.
7452        let mut e = editor_with("    hello\naaaaaaaa\nbye");
7453        run_keys(&mut e, "llllll"); // col 6, sticky = 6
7454        run_keys(&mut e, "yy");
7455        run_keys(&mut e, "j"); // into row 1 col 6
7456        run_keys(&mut e, "p"); // paste below row 1 — cursor on "    hello"
7457        // Cursor should be at (2, 4) — first non-blank of the pasted line.
7458        assert_eq!(e.cursor(), (2, 4));
7459        // j should then preserve col 4, not jump back to 6.
7460        run_keys(&mut e, "j");
7461        assert_eq!(e.cursor(), (3, 2));
7462    }
7463
7464    #[test]
7465    fn horizontal_motion_resyncs_sticky_column() {
7466        // Starting col 6 on row 0, go back to col 3, then down through
7467        // an empty row. The sticky col should be 3 (from the last `h`
7468        // sequence), not 6.
7469        let mut e = editor_with("hello world\n\nanother line");
7470        run_keys(&mut e, "llllll"); // col 6
7471        run_keys(&mut e, "hhh"); // col 3
7472        run_keys(&mut e, "jj");
7473        assert_eq!(e.cursor(), (2, 3));
7474    }
7475
7476    // ─── Visual block ────────────────────────────────────────────────────
7477
7478    #[test]
7479    fn ctrl_v_enters_visual_block() {
7480        let mut e = editor_with("aaa\nbbb\nccc");
7481        run_keys(&mut e, "<C-v>");
7482        assert_eq!(e.vim_mode(), VimMode::VisualBlock);
7483    }
7484
7485    #[test]
7486    fn visual_block_esc_returns_to_normal() {
7487        let mut e = editor_with("aaa\nbbb\nccc");
7488        run_keys(&mut e, "<C-v>");
7489        run_keys(&mut e, "<Esc>");
7490        assert_eq!(e.vim_mode(), VimMode::Normal);
7491    }
7492
7493    #[test]
7494    fn visual_exit_sets_lt_gt_marks() {
7495        // Vim sets `<` to the start and `>` to the end of the last visual
7496        // selection on every visual exit. Required for :'<,'> ex ranges.
7497        let mut e = editor_with("aaa\nbbb\nccc\nddd");
7498        // V<j><Esc> → selects rows 0..=1 in line-wise visual.
7499        run_keys(&mut e, "V");
7500        run_keys(&mut e, "j");
7501        run_keys(&mut e, "<Esc>");
7502        let lt = e.mark('<').expect("'<' mark must be set on visual exit");
7503        let gt = e.mark('>').expect("'>' mark must be set on visual exit");
7504        assert_eq!(lt.0, 0, "'< row should be the lower bound");
7505        assert_eq!(gt.0, 1, "'> row should be the upper bound");
7506    }
7507
7508    #[test]
7509    fn visual_exit_marks_use_lower_higher_order() {
7510        // Selecting upward (cursor < anchor) must still produce `<` = lower,
7511        // `>` = higher — vim's marks are position-ordered, not selection-
7512        // ordered.
7513        let mut e = editor_with("aaa\nbbb\nccc\nddd");
7514        run_keys(&mut e, "jjj"); // cursor at row 3
7515        run_keys(&mut e, "V");
7516        run_keys(&mut e, "k"); // anchor row 3, cursor row 2
7517        run_keys(&mut e, "<Esc>");
7518        let lt = e.mark('<').unwrap();
7519        let gt = e.mark('>').unwrap();
7520        assert_eq!(lt.0, 2);
7521        assert_eq!(gt.0, 3);
7522    }
7523
7524    #[test]
7525    fn visual_block_delete_removes_column_range() {
7526        let mut e = editor_with("hello\nworld\nhappy");
7527        // Move off col 0 first so the block starts mid-row.
7528        run_keys(&mut e, "l");
7529        run_keys(&mut e, "<C-v>");
7530        run_keys(&mut e, "jj");
7531        run_keys(&mut e, "ll");
7532        run_keys(&mut e, "d");
7533        // Deletes cols 1-3 on every row — "ell" / "orl" / "app".
7534        assert_eq!(
7535            e.buffer().lines(),
7536            &["ho".to_string(), "wd".to_string(), "hy".to_string()]
7537        );
7538    }
7539
7540    #[test]
7541    fn visual_block_yank_joins_with_newlines() {
7542        let mut e = editor_with("hello\nworld\nhappy");
7543        run_keys(&mut e, "<C-v>");
7544        run_keys(&mut e, "jj");
7545        run_keys(&mut e, "ll");
7546        run_keys(&mut e, "y");
7547        assert_eq!(e.last_yank.as_deref(), Some("hel\nwor\nhap"));
7548    }
7549
7550    #[test]
7551    fn visual_block_replace_fills_block() {
7552        let mut e = editor_with("hello\nworld\nhappy");
7553        run_keys(&mut e, "<C-v>");
7554        run_keys(&mut e, "jj");
7555        run_keys(&mut e, "ll");
7556        run_keys(&mut e, "rx");
7557        assert_eq!(
7558            e.buffer().lines(),
7559            &[
7560                "xxxlo".to_string(),
7561                "xxxld".to_string(),
7562                "xxxpy".to_string()
7563            ]
7564        );
7565    }
7566
7567    #[test]
7568    fn visual_block_insert_repeats_across_rows() {
7569        let mut e = editor_with("hello\nworld\nhappy");
7570        run_keys(&mut e, "<C-v>");
7571        run_keys(&mut e, "jj");
7572        run_keys(&mut e, "I");
7573        run_keys(&mut e, "# <Esc>");
7574        assert_eq!(
7575            e.buffer().lines(),
7576            &[
7577                "# hello".to_string(),
7578                "# world".to_string(),
7579                "# happy".to_string()
7580            ]
7581        );
7582    }
7583
7584    #[test]
7585    fn block_highlight_returns_none_outside_block_mode() {
7586        let mut e = editor_with("abc");
7587        assert!(e.block_highlight().is_none());
7588        run_keys(&mut e, "v");
7589        assert!(e.block_highlight().is_none());
7590        run_keys(&mut e, "<Esc>V");
7591        assert!(e.block_highlight().is_none());
7592    }
7593
7594    #[test]
7595    fn block_highlight_bounds_track_anchor_and_cursor() {
7596        let mut e = editor_with("aaaa\nbbbb\ncccc");
7597        run_keys(&mut e, "ll"); // cursor (0, 2)
7598        run_keys(&mut e, "<C-v>");
7599        run_keys(&mut e, "jh"); // cursor (1, 1)
7600        // anchor = (0, 2), cursor = (1, 1) → top=0 bot=1 left=1 right=2.
7601        assert_eq!(e.block_highlight(), Some((0, 1, 1, 2)));
7602    }
7603
7604    #[test]
7605    fn visual_block_delete_handles_short_lines() {
7606        // Middle row is shorter than the block's right column.
7607        let mut e = editor_with("hello\nhi\nworld");
7608        run_keys(&mut e, "l"); // col 1
7609        run_keys(&mut e, "<C-v>");
7610        run_keys(&mut e, "jjll"); // cursor (2, 3)
7611        run_keys(&mut e, "d");
7612        // Row 0: delete cols 1-3 ("ell") → "ho".
7613        // Row 1: only 2 chars ("hi"); block starts at col 1, so just "i"
7614        //        gets removed → "h".
7615        // Row 2: delete cols 1-3 ("orl") → "wd".
7616        assert_eq!(
7617            e.buffer().lines(),
7618            &["ho".to_string(), "h".to_string(), "wd".to_string()]
7619        );
7620    }
7621
7622    #[test]
7623    fn visual_block_yank_pads_short_lines_with_empties() {
7624        let mut e = editor_with("hello\nhi\nworld");
7625        run_keys(&mut e, "l");
7626        run_keys(&mut e, "<C-v>");
7627        run_keys(&mut e, "jjll");
7628        run_keys(&mut e, "y");
7629        // Row 0 chars 1-3 = "ell"; row 1 chars 1- (only "i"); row 2 "orl".
7630        assert_eq!(e.last_yank.as_deref(), Some("ell\ni\norl"));
7631    }
7632
7633    #[test]
7634    fn visual_block_replace_skips_past_eol() {
7635        // Block extends past the end of every row in column range;
7636        // replace should leave lines shorter than `left` untouched.
7637        let mut e = editor_with("ab\ncd\nef");
7638        // Put cursor at col 1 (last char), extend block 5 columns right.
7639        run_keys(&mut e, "l");
7640        run_keys(&mut e, "<C-v>");
7641        run_keys(&mut e, "jjllllll");
7642        run_keys(&mut e, "rX");
7643        // Every row had only col 0..=1; block covers col 1..=7 → only
7644        // col 1 is in range on each row, so just that cell changes.
7645        assert_eq!(
7646            e.buffer().lines(),
7647            &["aX".to_string(), "cX".to_string(), "eX".to_string()]
7648        );
7649    }
7650
7651    #[test]
7652    fn visual_block_with_empty_line_in_middle() {
7653        let mut e = editor_with("abcd\n\nefgh");
7654        run_keys(&mut e, "<C-v>");
7655        run_keys(&mut e, "jjll"); // cursor (2, 2)
7656        run_keys(&mut e, "d");
7657        // Row 0 cols 0-2 removed → "d". Row 1 empty → untouched.
7658        // Row 2 cols 0-2 removed → "h".
7659        assert_eq!(
7660            e.buffer().lines(),
7661            &["d".to_string(), "".to_string(), "h".to_string()]
7662        );
7663    }
7664
7665    #[test]
7666    fn block_insert_pads_empty_lines_to_block_column() {
7667        // Middle line is empty; block I at column 3 should pad the empty
7668        // line with spaces so the inserted text lines up.
7669        let mut e = editor_with("this is a line\n\nthis is a line");
7670        e.jump_cursor(0, 3);
7671        run_keys(&mut e, "<C-v>");
7672        run_keys(&mut e, "jj");
7673        run_keys(&mut e, "I");
7674        run_keys(&mut e, "XX<Esc>");
7675        assert_eq!(
7676            e.buffer().lines(),
7677            &[
7678                "thiXXs is a line".to_string(),
7679                "   XX".to_string(),
7680                "thiXXs is a line".to_string()
7681            ]
7682        );
7683    }
7684
7685    #[test]
7686    fn block_insert_pads_short_lines_to_block_column() {
7687        let mut e = editor_with("aaaaa\nbb\naaaaa");
7688        e.jump_cursor(0, 3);
7689        run_keys(&mut e, "<C-v>");
7690        run_keys(&mut e, "jj");
7691        run_keys(&mut e, "I");
7692        run_keys(&mut e, "Y<Esc>");
7693        // Row 1 "bb" is shorter than col 3 — pad with one space then Y.
7694        assert_eq!(
7695            e.buffer().lines(),
7696            &[
7697                "aaaYaa".to_string(),
7698                "bb Y".to_string(),
7699                "aaaYaa".to_string()
7700            ]
7701        );
7702    }
7703
7704    #[test]
7705    fn visual_block_append_repeats_across_rows() {
7706        let mut e = editor_with("foo\nbar\nbaz");
7707        run_keys(&mut e, "<C-v>");
7708        run_keys(&mut e, "jj");
7709        // Single-column block (anchor col = cursor col = 0); `A` appends
7710        // after column 0 on every row.
7711        run_keys(&mut e, "A");
7712        run_keys(&mut e, "!<Esc>");
7713        assert_eq!(
7714            e.buffer().lines(),
7715            &["f!oo".to_string(), "b!ar".to_string(), "b!az".to_string()]
7716        );
7717    }
7718
7719    // ─── `/` / `?` search prompt ─────────────────────────────────────────
7720
7721    #[test]
7722    fn slash_opens_forward_search_prompt() {
7723        let mut e = editor_with("hello world");
7724        run_keys(&mut e, "/");
7725        let p = e.search_prompt().expect("prompt should be active");
7726        assert!(p.text.is_empty());
7727        assert!(p.forward);
7728    }
7729
7730    #[test]
7731    fn question_opens_backward_search_prompt() {
7732        let mut e = editor_with("hello world");
7733        run_keys(&mut e, "?");
7734        let p = e.search_prompt().expect("prompt should be active");
7735        assert!(!p.forward);
7736    }
7737
7738    #[test]
7739    fn search_prompt_typing_updates_pattern_live() {
7740        let mut e = editor_with("foo bar\nbaz");
7741        run_keys(&mut e, "/bar");
7742        assert_eq!(e.search_prompt().unwrap().text, "bar");
7743        // Pattern set on the engine search state for live highlight.
7744        assert!(e.search_state().pattern.is_some());
7745    }
7746
7747    #[test]
7748    fn search_prompt_backspace_and_enter() {
7749        let mut e = editor_with("hello world\nagain");
7750        run_keys(&mut e, "/worlx");
7751        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
7752        assert_eq!(e.search_prompt().unwrap().text, "worl");
7753        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7754        // Prompt closed, last_search set, cursor advanced to match.
7755        assert!(e.search_prompt().is_none());
7756        assert_eq!(e.last_search(), Some("worl"));
7757        assert_eq!(e.cursor(), (0, 6));
7758    }
7759
7760    #[test]
7761    fn empty_search_prompt_enter_repeats_last_search() {
7762        let mut e = editor_with("foo bar foo baz foo");
7763        run_keys(&mut e, "/foo");
7764        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7765        assert_eq!(e.cursor().1, 8);
7766        // Empty `/<CR>` should advance to the next match, not clear last_search.
7767        run_keys(&mut e, "/");
7768        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7769        assert_eq!(e.cursor().1, 16);
7770        assert_eq!(e.last_search(), Some("foo"));
7771    }
7772
7773    #[test]
7774    fn search_history_records_committed_patterns() {
7775        let mut e = editor_with("alpha beta gamma");
7776        run_keys(&mut e, "/alpha<CR>");
7777        run_keys(&mut e, "/beta<CR>");
7778        // Newest entry at the back.
7779        let history = e.vim.search_history.clone();
7780        assert_eq!(history, vec!["alpha", "beta"]);
7781    }
7782
7783    #[test]
7784    fn search_history_dedupes_consecutive_repeats() {
7785        let mut e = editor_with("foo bar foo");
7786        run_keys(&mut e, "/foo<CR>");
7787        run_keys(&mut e, "/foo<CR>");
7788        run_keys(&mut e, "/bar<CR>");
7789        run_keys(&mut e, "/bar<CR>");
7790        // Two distinct entries; the duplicates collapsed.
7791        assert_eq!(e.vim.search_history.clone(), vec!["foo", "bar"]);
7792    }
7793
7794    #[test]
7795    fn ctrl_p_walks_history_backward() {
7796        let mut e = editor_with("alpha beta gamma");
7797        run_keys(&mut e, "/alpha<CR>");
7798        run_keys(&mut e, "/beta<CR>");
7799        // Open a fresh prompt; Ctrl-P pulls in the newest entry.
7800        run_keys(&mut e, "/");
7801        assert_eq!(e.search_prompt().unwrap().text, "");
7802        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7803        assert_eq!(e.search_prompt().unwrap().text, "beta");
7804        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7805        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7806        // At the oldest entry; further Ctrl-P is a no-op.
7807        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7808        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7809    }
7810
7811    #[test]
7812    fn ctrl_n_walks_history_forward_after_ctrl_p() {
7813        let mut e = editor_with("a b c");
7814        run_keys(&mut e, "/a<CR>");
7815        run_keys(&mut e, "/b<CR>");
7816        run_keys(&mut e, "/c<CR>");
7817        run_keys(&mut e, "/");
7818        // Walk back to "a", then forward again.
7819        for _ in 0..3 {
7820            e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7821        }
7822        assert_eq!(e.search_prompt().unwrap().text, "a");
7823        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7824        assert_eq!(e.search_prompt().unwrap().text, "b");
7825        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7826        assert_eq!(e.search_prompt().unwrap().text, "c");
7827        // Past the newest — stays at "c".
7828        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7829        assert_eq!(e.search_prompt().unwrap().text, "c");
7830    }
7831
7832    #[test]
7833    fn typing_after_history_walk_resets_cursor() {
7834        let mut e = editor_with("foo");
7835        run_keys(&mut e, "/foo<CR>");
7836        run_keys(&mut e, "/");
7837        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7838        assert_eq!(e.search_prompt().unwrap().text, "foo");
7839        // User edits — append a char. Next Ctrl-P should restart from
7840        // the newest entry, not continue walking older.
7841        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
7842        assert_eq!(e.search_prompt().unwrap().text, "foox");
7843        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7844        assert_eq!(e.search_prompt().unwrap().text, "foo");
7845    }
7846
7847    #[test]
7848    fn empty_backward_search_prompt_enter_repeats_last_search() {
7849        let mut e = editor_with("foo bar foo baz foo");
7850        // Forward to col 8, then `?<CR>` should walk backward to col 0.
7851        run_keys(&mut e, "/foo");
7852        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7853        assert_eq!(e.cursor().1, 8);
7854        run_keys(&mut e, "?");
7855        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7856        assert_eq!(e.cursor().1, 0);
7857        assert_eq!(e.last_search(), Some("foo"));
7858    }
7859
7860    #[test]
7861    fn search_prompt_esc_cancels_but_keeps_last_search() {
7862        let mut e = editor_with("foo bar\nbaz");
7863        run_keys(&mut e, "/bar");
7864        e.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
7865        assert!(e.search_prompt().is_none());
7866        assert_eq!(e.last_search(), Some("bar"));
7867    }
7868
7869    #[test]
7870    fn search_then_n_and_shift_n_navigate() {
7871        let mut e = editor_with("foo bar foo baz foo");
7872        run_keys(&mut e, "/foo");
7873        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7874        // `/foo` + Enter jumps forward; we land on the next match after col 0.
7875        assert_eq!(e.cursor().1, 8);
7876        run_keys(&mut e, "n");
7877        assert_eq!(e.cursor().1, 16);
7878        run_keys(&mut e, "N");
7879        assert_eq!(e.cursor().1, 8);
7880    }
7881
7882    #[test]
7883    fn question_mark_searches_backward_on_enter() {
7884        let mut e = editor_with("foo bar foo baz");
7885        e.jump_cursor(0, 10);
7886        run_keys(&mut e, "?foo");
7887        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7888        // Cursor jumps backward to the closest match before col 10.
7889        assert_eq!(e.cursor(), (0, 8));
7890    }
7891
7892    // ─── P6 quick wins (Y, gJ, ge / gE) ──────────────────────────────────
7893
7894    #[test]
7895    fn big_y_yanks_to_end_of_line() {
7896        let mut e = editor_with("hello world");
7897        e.jump_cursor(0, 6);
7898        run_keys(&mut e, "Y");
7899        assert_eq!(e.last_yank.as_deref(), Some("world"));
7900    }
7901
7902    #[test]
7903    fn big_y_from_line_start_yanks_full_line() {
7904        let mut e = editor_with("hello world");
7905        run_keys(&mut e, "Y");
7906        assert_eq!(e.last_yank.as_deref(), Some("hello world"));
7907    }
7908
7909    #[test]
7910    fn gj_joins_without_inserting_space() {
7911        let mut e = editor_with("hello\n    world");
7912        run_keys(&mut e, "gJ");
7913        // No space inserted, leading whitespace preserved.
7914        assert_eq!(e.buffer().lines(), &["hello    world".to_string()]);
7915    }
7916
7917    #[test]
7918    fn gj_noop_on_last_line() {
7919        let mut e = editor_with("only");
7920        run_keys(&mut e, "gJ");
7921        assert_eq!(e.buffer().lines(), &["only".to_string()]);
7922    }
7923
7924    #[test]
7925    fn ge_jumps_to_previous_word_end() {
7926        let mut e = editor_with("foo bar baz");
7927        e.jump_cursor(0, 5);
7928        run_keys(&mut e, "ge");
7929        assert_eq!(e.cursor(), (0, 2));
7930    }
7931
7932    #[test]
7933    fn ge_respects_word_class() {
7934        // Small-word `ge` treats `-` as its own word, so from mid-"bar"
7935        // it lands on the `-` rather than end of "foo".
7936        let mut e = editor_with("foo-bar baz");
7937        e.jump_cursor(0, 5);
7938        run_keys(&mut e, "ge");
7939        assert_eq!(e.cursor(), (0, 3));
7940    }
7941
7942    #[test]
7943    fn big_ge_treats_hyphens_as_part_of_word() {
7944        // `gE` uses WORD (whitespace-delimited) semantics so it skips
7945        // over the `-` and lands on the end of "foo-bar".
7946        let mut e = editor_with("foo-bar baz");
7947        e.jump_cursor(0, 10);
7948        run_keys(&mut e, "gE");
7949        assert_eq!(e.cursor(), (0, 6));
7950    }
7951
7952    #[test]
7953    fn ge_crosses_line_boundary() {
7954        let mut e = editor_with("foo\nbar");
7955        e.jump_cursor(1, 0);
7956        run_keys(&mut e, "ge");
7957        assert_eq!(e.cursor(), (0, 2));
7958    }
7959
7960    #[test]
7961    fn dge_deletes_to_end_of_previous_word() {
7962        let mut e = editor_with("foo bar baz");
7963        e.jump_cursor(0, 8);
7964        // d + ge from 'b' of "baz": range is ge → col 6 ('r' of bar),
7965        // inclusive, so cols 6-8 ("r b") are cut.
7966        run_keys(&mut e, "dge");
7967        assert_eq!(e.buffer().lines()[0], "foo baaz");
7968    }
7969
7970    #[test]
7971    fn ctrl_scroll_keys_do_not_panic() {
7972        // Viewport-less test: just exercise the code paths so a regression
7973        // in the scroll dispatch surfaces as a panic or assertion failure.
7974        let mut e = editor_with(
7975            (0..50)
7976                .map(|i| format!("line{i}"))
7977                .collect::<Vec<_>>()
7978                .join("\n")
7979                .as_str(),
7980        );
7981        run_keys(&mut e, "<C-f>");
7982        run_keys(&mut e, "<C-b>");
7983        // No explicit assert beyond "didn't panic".
7984        assert!(!e.buffer().lines().is_empty());
7985    }
7986
7987    /// Regression: arrow-navigation during a count-insert session must
7988    /// not pull unrelated rows into the "inserted" replay string.
7989    /// Before the fix, `before_lines` only snapshotted the entry row,
7990    /// so the diff at Esc spuriously saw the navigated-over row as
7991    /// part of the insert — count-replay then duplicated cross-row
7992    /// content across the buffer.
7993    #[test]
7994    fn count_insert_with_arrow_nav_does_not_leak_rows() {
7995        let mut e = Editor::new(
7996            hjkl_buffer::Buffer::new(),
7997            crate::types::DefaultHost::new(),
7998            crate::types::Options::default(),
7999        );
8000        e.set_content("row0\nrow1\nrow2");
8001        // `3i`, type X, arrow down, Esc.
8002        run_keys(&mut e, "3iX<Down><Esc>");
8003        // Row 0 keeps the originally-typed X.
8004        assert!(e.buffer().lines()[0].contains('X'));
8005        // Row 1 must not contain a fragment of row 0 ("row0") — that
8006        // was the buggy leak from the before-diff window.
8007        assert!(
8008            !e.buffer().lines()[1].contains("row0"),
8009            "row1 leaked row0 contents: {:?}",
8010            e.buffer().lines()[1]
8011        );
8012        // Buffer stays the same number of rows — no extra lines
8013        // injected by a multi-line "inserted" replay.
8014        assert_eq!(e.buffer().lines().len(), 3);
8015    }
8016
8017    // ─── Viewport scroll / jump tests ─────────────────────────────────
8018
8019    fn editor_with_rows(n: usize, viewport: u16) -> Editor {
8020        let mut e = Editor::new(
8021            hjkl_buffer::Buffer::new(),
8022            crate::types::DefaultHost::new(),
8023            crate::types::Options::default(),
8024        );
8025        let body = (0..n)
8026            .map(|i| format!("  line{}", i))
8027            .collect::<Vec<_>>()
8028            .join("\n");
8029        e.set_content(&body);
8030        e.set_viewport_height(viewport);
8031        e
8032    }
8033
8034    #[test]
8035    fn ctrl_d_moves_cursor_half_page_down() {
8036        let mut e = editor_with_rows(100, 20);
8037        run_keys(&mut e, "<C-d>");
8038        assert_eq!(e.cursor().0, 10);
8039    }
8040
8041    fn editor_with_wrap_lines(lines: &[&str], viewport: u16, text_width: u16) -> Editor {
8042        let mut e = Editor::new(
8043            hjkl_buffer::Buffer::new(),
8044            crate::types::DefaultHost::new(),
8045            crate::types::Options::default(),
8046        );
8047        e.set_content(&lines.join("\n"));
8048        e.set_viewport_height(viewport);
8049        let v = e.host_mut().viewport_mut();
8050        v.height = viewport;
8051        v.width = text_width;
8052        v.text_width = text_width;
8053        v.wrap = hjkl_buffer::Wrap::Char;
8054        e.settings_mut().wrap = hjkl_buffer::Wrap::Char;
8055        e
8056    }
8057
8058    #[test]
8059    fn scrolloff_wrap_keeps_cursor_off_bottom_edge() {
8060        // 10 doc rows, each wraps to 3 segments → 30 screen rows.
8061        // Viewport height 12, margin = SCROLLOFF.min(11/2) = 5,
8062        // max bottom = 11 - 5 = 6. Plenty of headroom past row 4.
8063        let lines = ["aaaabbbbcccc"; 10];
8064        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8065        e.jump_cursor(4, 0);
8066        e.ensure_cursor_in_scrolloff();
8067        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8068        assert!(csr <= 6, "csr={csr}");
8069    }
8070
8071    #[test]
8072    fn scrolloff_wrap_keeps_cursor_off_top_edge() {
8073        let lines = ["aaaabbbbcccc"; 10];
8074        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8075        // Force top down then bring cursor up so the top-edge margin
8076        // path runs.
8077        e.jump_cursor(7, 0);
8078        e.ensure_cursor_in_scrolloff();
8079        e.jump_cursor(2, 0);
8080        e.ensure_cursor_in_scrolloff();
8081        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8082        // SCROLLOFF.min((height - 1) / 2) = 5.min(5) = 5.
8083        assert!(csr >= 5, "csr={csr}");
8084    }
8085
8086    #[test]
8087    fn scrolloff_wrap_clamps_top_at_buffer_end() {
8088        let lines = ["aaaabbbbcccc"; 5];
8089        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8090        e.jump_cursor(4, 11);
8091        e.ensure_cursor_in_scrolloff();
8092        // max_top_for_height(12) on 15 screen rows: row 4 (3 segs) +
8093        // row 3 (3 segs) + row 2 (3 segs) + row 1 (3 segs) = 12 —
8094        // max_top = row 1. Margin can't be honoured at EOF (matches
8095        // vim's behaviour — scrolloff is a soft constraint).
8096        let top = e.host().viewport().top_row;
8097        assert_eq!(top, 1);
8098    }
8099
8100    #[test]
8101    fn ctrl_u_moves_cursor_half_page_up() {
8102        let mut e = editor_with_rows(100, 20);
8103        e.jump_cursor(50, 0);
8104        run_keys(&mut e, "<C-u>");
8105        assert_eq!(e.cursor().0, 40);
8106    }
8107
8108    #[test]
8109    fn ctrl_f_moves_cursor_full_page_down() {
8110        let mut e = editor_with_rows(100, 20);
8111        run_keys(&mut e, "<C-f>");
8112        // One full page ≈ h - 2 (overlap).
8113        assert_eq!(e.cursor().0, 18);
8114    }
8115
8116    #[test]
8117    fn ctrl_b_moves_cursor_full_page_up() {
8118        let mut e = editor_with_rows(100, 20);
8119        e.jump_cursor(50, 0);
8120        run_keys(&mut e, "<C-b>");
8121        assert_eq!(e.cursor().0, 32);
8122    }
8123
8124    #[test]
8125    fn ctrl_d_lands_on_first_non_blank() {
8126        let mut e = editor_with_rows(100, 20);
8127        run_keys(&mut e, "<C-d>");
8128        // "  line10" — first non-blank is col 2.
8129        assert_eq!(e.cursor().1, 2);
8130    }
8131
8132    #[test]
8133    fn ctrl_d_clamps_at_end_of_buffer() {
8134        let mut e = editor_with_rows(5, 20);
8135        run_keys(&mut e, "<C-d>");
8136        assert_eq!(e.cursor().0, 4);
8137    }
8138
8139    #[test]
8140    fn capital_h_jumps_to_viewport_top() {
8141        let mut e = editor_with_rows(100, 10);
8142        e.jump_cursor(50, 0);
8143        e.set_viewport_top(45);
8144        let top = e.host().viewport().top_row;
8145        run_keys(&mut e, "H");
8146        assert_eq!(e.cursor().0, top);
8147        assert_eq!(e.cursor().1, 2);
8148    }
8149
8150    #[test]
8151    fn capital_l_jumps_to_viewport_bottom() {
8152        let mut e = editor_with_rows(100, 10);
8153        e.jump_cursor(50, 0);
8154        e.set_viewport_top(45);
8155        let top = e.host().viewport().top_row;
8156        run_keys(&mut e, "L");
8157        assert_eq!(e.cursor().0, top + 9);
8158    }
8159
8160    #[test]
8161    fn capital_m_jumps_to_viewport_middle() {
8162        let mut e = editor_with_rows(100, 10);
8163        e.jump_cursor(50, 0);
8164        e.set_viewport_top(45);
8165        let top = e.host().viewport().top_row;
8166        run_keys(&mut e, "M");
8167        // 10-row viewport: middle is top + 4.
8168        assert_eq!(e.cursor().0, top + 4);
8169    }
8170
8171    #[test]
8172    fn g_capital_m_lands_at_line_midpoint() {
8173        let mut e = editor_with("hello world!"); // 12 chars
8174        run_keys(&mut e, "gM");
8175        // floor(12 / 2) = 6.
8176        assert_eq!(e.cursor(), (0, 6));
8177    }
8178
8179    #[test]
8180    fn g_capital_m_on_empty_line_stays_at_zero() {
8181        let mut e = editor_with("");
8182        run_keys(&mut e, "gM");
8183        assert_eq!(e.cursor(), (0, 0));
8184    }
8185
8186    #[test]
8187    fn g_capital_m_uses_current_line_only() {
8188        // Each line's midpoint is independent of others.
8189        let mut e = editor_with("a\nlonglongline"); // line 1: 12 chars
8190        e.jump_cursor(1, 0);
8191        run_keys(&mut e, "gM");
8192        assert_eq!(e.cursor(), (1, 6));
8193    }
8194
8195    #[test]
8196    fn capital_h_count_offsets_from_top() {
8197        let mut e = editor_with_rows(100, 10);
8198        e.jump_cursor(50, 0);
8199        e.set_viewport_top(45);
8200        let top = e.host().viewport().top_row;
8201        run_keys(&mut e, "3H");
8202        assert_eq!(e.cursor().0, top + 2);
8203    }
8204
8205    // ─── Jumplist tests ───────────────────────────────────────────────
8206
8207    #[test]
8208    fn ctrl_o_returns_to_pre_g_position() {
8209        let mut e = editor_with_rows(50, 20);
8210        e.jump_cursor(5, 2);
8211        run_keys(&mut e, "G");
8212        assert_eq!(e.cursor().0, 49);
8213        run_keys(&mut e, "<C-o>");
8214        assert_eq!(e.cursor(), (5, 2));
8215    }
8216
8217    #[test]
8218    fn ctrl_i_redoes_jump_after_ctrl_o() {
8219        let mut e = editor_with_rows(50, 20);
8220        e.jump_cursor(5, 2);
8221        run_keys(&mut e, "G");
8222        let post = e.cursor();
8223        run_keys(&mut e, "<C-o>");
8224        run_keys(&mut e, "<C-i>");
8225        assert_eq!(e.cursor(), post);
8226    }
8227
8228    #[test]
8229    fn new_jump_clears_forward_stack() {
8230        let mut e = editor_with_rows(50, 20);
8231        e.jump_cursor(5, 2);
8232        run_keys(&mut e, "G");
8233        run_keys(&mut e, "<C-o>");
8234        run_keys(&mut e, "gg");
8235        run_keys(&mut e, "<C-i>");
8236        assert_eq!(e.cursor().0, 0);
8237    }
8238
8239    #[test]
8240    fn ctrl_o_on_empty_stack_is_noop() {
8241        let mut e = editor_with_rows(10, 20);
8242        e.jump_cursor(3, 1);
8243        run_keys(&mut e, "<C-o>");
8244        assert_eq!(e.cursor(), (3, 1));
8245    }
8246
8247    #[test]
8248    fn asterisk_search_pushes_jump() {
8249        let mut e = editor_with("foo bar\nbaz foo end");
8250        e.jump_cursor(0, 0);
8251        run_keys(&mut e, "*");
8252        let after = e.cursor();
8253        assert_ne!(after, (0, 0));
8254        run_keys(&mut e, "<C-o>");
8255        assert_eq!(e.cursor(), (0, 0));
8256    }
8257
8258    #[test]
8259    fn h_viewport_jump_is_recorded() {
8260        let mut e = editor_with_rows(100, 10);
8261        e.jump_cursor(50, 0);
8262        e.set_viewport_top(45);
8263        let pre = e.cursor();
8264        run_keys(&mut e, "H");
8265        assert_ne!(e.cursor(), pre);
8266        run_keys(&mut e, "<C-o>");
8267        assert_eq!(e.cursor(), pre);
8268    }
8269
8270    #[test]
8271    fn j_k_motion_does_not_push_jump() {
8272        let mut e = editor_with_rows(50, 20);
8273        e.jump_cursor(5, 0);
8274        run_keys(&mut e, "jjj");
8275        run_keys(&mut e, "<C-o>");
8276        assert_eq!(e.cursor().0, 8);
8277    }
8278
8279    #[test]
8280    fn jumplist_caps_at_100() {
8281        let mut e = editor_with_rows(200, 20);
8282        for i in 0..101 {
8283            e.jump_cursor(i, 0);
8284            run_keys(&mut e, "G");
8285        }
8286        assert!(e.vim.jump_back.len() <= 100);
8287    }
8288
8289    #[test]
8290    fn tab_acts_as_ctrl_i() {
8291        let mut e = editor_with_rows(50, 20);
8292        e.jump_cursor(5, 2);
8293        run_keys(&mut e, "G");
8294        let post = e.cursor();
8295        run_keys(&mut e, "<C-o>");
8296        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8297        assert_eq!(e.cursor(), post);
8298    }
8299
8300    // ─── Mark tests ───────────────────────────────────────────────────
8301
8302    #[test]
8303    fn ma_then_backtick_a_jumps_exact() {
8304        let mut e = editor_with_rows(50, 20);
8305        e.jump_cursor(5, 3);
8306        run_keys(&mut e, "ma");
8307        e.jump_cursor(20, 0);
8308        run_keys(&mut e, "`a");
8309        assert_eq!(e.cursor(), (5, 3));
8310    }
8311
8312    #[test]
8313    fn ma_then_apostrophe_a_lands_on_first_non_blank() {
8314        let mut e = editor_with_rows(50, 20);
8315        // "  line5" — first non-blank is col 2.
8316        e.jump_cursor(5, 6);
8317        run_keys(&mut e, "ma");
8318        e.jump_cursor(30, 4);
8319        run_keys(&mut e, "'a");
8320        assert_eq!(e.cursor(), (5, 2));
8321    }
8322
8323    #[test]
8324    fn goto_mark_pushes_jumplist() {
8325        let mut e = editor_with_rows(50, 20);
8326        e.jump_cursor(10, 2);
8327        run_keys(&mut e, "mz");
8328        e.jump_cursor(3, 0);
8329        run_keys(&mut e, "`z");
8330        assert_eq!(e.cursor(), (10, 2));
8331        run_keys(&mut e, "<C-o>");
8332        assert_eq!(e.cursor(), (3, 0));
8333    }
8334
8335    #[test]
8336    fn goto_missing_mark_is_noop() {
8337        let mut e = editor_with_rows(50, 20);
8338        e.jump_cursor(3, 1);
8339        run_keys(&mut e, "`q");
8340        assert_eq!(e.cursor(), (3, 1));
8341    }
8342
8343    #[test]
8344    fn uppercase_mark_stored_under_uppercase_key() {
8345        let mut e = editor_with_rows(50, 20);
8346        e.jump_cursor(5, 3);
8347        run_keys(&mut e, "mA");
8348        // 0.0.36: uppercase marks land in the unified `Editor::marks`
8349        // map under the uppercase key — not under 'a'.
8350        assert_eq!(e.mark('A'), Some((5, 3)));
8351        assert!(e.mark('a').is_none());
8352    }
8353
8354    #[test]
8355    fn mark_survives_document_shrink_via_clamp() {
8356        let mut e = editor_with_rows(50, 20);
8357        e.jump_cursor(40, 4);
8358        run_keys(&mut e, "mx");
8359        // Shrink the buffer to 10 rows.
8360        e.set_content("a\nb\nc\nd\ne");
8361        run_keys(&mut e, "`x");
8362        // Mark clamped to last row, col 0 (short line).
8363        let (r, _) = e.cursor();
8364        assert!(r <= 4);
8365    }
8366
8367    #[test]
8368    fn g_semicolon_walks_back_through_edits() {
8369        let mut e = editor_with("alpha\nbeta\ngamma");
8370        // Two distinct edits — cells (0, 0) → InsertChar lands cursor
8371        // at (0, 1), (2, 0) → (2, 1).
8372        e.jump_cursor(0, 0);
8373        run_keys(&mut e, "iX<Esc>");
8374        e.jump_cursor(2, 0);
8375        run_keys(&mut e, "iY<Esc>");
8376        // First g; lands on the most recent entry's exact cell.
8377        run_keys(&mut e, "g;");
8378        assert_eq!(e.cursor(), (2, 1));
8379        // Second g; walks to the older entry.
8380        run_keys(&mut e, "g;");
8381        assert_eq!(e.cursor(), (0, 1));
8382        // Past the oldest — no-op.
8383        run_keys(&mut e, "g;");
8384        assert_eq!(e.cursor(), (0, 1));
8385    }
8386
8387    #[test]
8388    fn g_comma_walks_forward_after_g_semicolon() {
8389        let mut e = editor_with("a\nb\nc");
8390        e.jump_cursor(0, 0);
8391        run_keys(&mut e, "iX<Esc>");
8392        e.jump_cursor(2, 0);
8393        run_keys(&mut e, "iY<Esc>");
8394        run_keys(&mut e, "g;");
8395        run_keys(&mut e, "g;");
8396        assert_eq!(e.cursor(), (0, 1));
8397        run_keys(&mut e, "g,");
8398        assert_eq!(e.cursor(), (2, 1));
8399    }
8400
8401    #[test]
8402    fn new_edit_during_walk_trims_forward_entries() {
8403        let mut e = editor_with("a\nb\nc\nd");
8404        e.jump_cursor(0, 0);
8405        run_keys(&mut e, "iX<Esc>"); // entry 0 → (0, 1)
8406        e.jump_cursor(2, 0);
8407        run_keys(&mut e, "iY<Esc>"); // entry 1 → (2, 1)
8408        // Walk back twice to land on entry 0.
8409        run_keys(&mut e, "g;");
8410        run_keys(&mut e, "g;");
8411        assert_eq!(e.cursor(), (0, 1));
8412        // New edit while walking discards entries forward of the cursor.
8413        run_keys(&mut e, "iZ<Esc>");
8414        // No newer entry left to walk to.
8415        run_keys(&mut e, "g,");
8416        // Cursor stays where the latest edit landed it.
8417        assert_ne!(e.cursor(), (2, 1));
8418    }
8419
8420    // gq* tests moved to crates/hjkl-editor/tests/vim_ex_integration.rs
8421    // — they exercise the vim FSM through ex commands which now live in
8422    // a sibling crate. cargo dev-dep cycles produce duplicate type IDs
8423    // so the integration must run from the editor side.
8424
8425    #[test]
8426    fn capital_mark_set_and_jump() {
8427        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
8428        e.jump_cursor(2, 1);
8429        run_keys(&mut e, "mA");
8430        // Move away.
8431        e.jump_cursor(0, 0);
8432        // Jump back via `'A`.
8433        run_keys(&mut e, "'A");
8434        // Linewise jump → row preserved, col first non-blank (here 0).
8435        assert_eq!(e.cursor().0, 2);
8436    }
8437
8438    #[test]
8439    fn capital_mark_survives_set_content() {
8440        let mut e = editor_with("first buffer line\nsecond");
8441        e.jump_cursor(1, 3);
8442        run_keys(&mut e, "mA");
8443        // Swap buffer content (host loading a different tab).
8444        e.set_content("totally different content\non many\nrows of text");
8445        // `'A` should still jump to (1, 3) — it survived the swap.
8446        e.jump_cursor(0, 0);
8447        run_keys(&mut e, "'A");
8448        assert_eq!(e.cursor().0, 1);
8449    }
8450
8451    // capital_mark_shows_in_marks_listing moved to
8452    // crates/hjkl-editor/tests/vim_ex_integration.rs (depends on the
8453    // ex `marks` command).
8454
8455    #[test]
8456    fn capital_mark_shifts_with_edit() {
8457        let mut e = editor_with("a\nb\nc\nd");
8458        e.jump_cursor(3, 0);
8459        run_keys(&mut e, "mA");
8460        // Delete the first row — `A` should shift up to row 2.
8461        e.jump_cursor(0, 0);
8462        run_keys(&mut e, "dd");
8463        e.jump_cursor(0, 0);
8464        run_keys(&mut e, "'A");
8465        assert_eq!(e.cursor().0, 2);
8466    }
8467
8468    #[test]
8469    fn mark_below_delete_shifts_up() {
8470        let mut e = editor_with("a\nb\nc\nd\ne");
8471        // Set mark `a` on row 3 (the `d`).
8472        e.jump_cursor(3, 0);
8473        run_keys(&mut e, "ma");
8474        // Go back to row 0 and `dd`.
8475        e.jump_cursor(0, 0);
8476        run_keys(&mut e, "dd");
8477        // Mark `a` should now point at row 2 — its content stayed `d`.
8478        e.jump_cursor(0, 0);
8479        run_keys(&mut e, "'a");
8480        assert_eq!(e.cursor().0, 2);
8481        assert_eq!(e.buffer().line(2).unwrap(), "d");
8482    }
8483
8484    #[test]
8485    fn mark_on_deleted_row_is_dropped() {
8486        let mut e = editor_with("a\nb\nc\nd");
8487        // Mark `a` on row 1 (`b`).
8488        e.jump_cursor(1, 0);
8489        run_keys(&mut e, "ma");
8490        // Delete row 1.
8491        run_keys(&mut e, "dd");
8492        // The row that held `a` is gone; `'a` should be a no-op now.
8493        e.jump_cursor(2, 0);
8494        run_keys(&mut e, "'a");
8495        // Cursor stays on row 2 — `'a` no-ops on missing marks.
8496        assert_eq!(e.cursor().0, 2);
8497    }
8498
8499    #[test]
8500    fn mark_above_edit_unchanged() {
8501        let mut e = editor_with("a\nb\nc\nd\ne");
8502        // Mark `a` on row 0.
8503        e.jump_cursor(0, 0);
8504        run_keys(&mut e, "ma");
8505        // Delete row 3.
8506        e.jump_cursor(3, 0);
8507        run_keys(&mut e, "dd");
8508        // Mark `a` should still point at row 0.
8509        e.jump_cursor(2, 0);
8510        run_keys(&mut e, "'a");
8511        assert_eq!(e.cursor().0, 0);
8512    }
8513
8514    #[test]
8515    fn mark_shifts_down_after_insert() {
8516        let mut e = editor_with("a\nb\nc");
8517        // Mark `a` on row 2 (`c`).
8518        e.jump_cursor(2, 0);
8519        run_keys(&mut e, "ma");
8520        // Open a new line above row 0 with `O\nfoo<Esc>`.
8521        e.jump_cursor(0, 0);
8522        run_keys(&mut e, "Onew<Esc>");
8523        // Buffer is now ["new", "a", "b", "c"]; mark `a` should track
8524        // the original content row → 3.
8525        e.jump_cursor(0, 0);
8526        run_keys(&mut e, "'a");
8527        assert_eq!(e.cursor().0, 3);
8528        assert_eq!(e.buffer().line(3).unwrap(), "c");
8529    }
8530
8531    // ─── Search / jumplist interaction ───────────────────────────────
8532
8533    #[test]
8534    fn forward_search_commit_pushes_jump() {
8535        let mut e = editor_with("alpha beta\nfoo target end\nmore");
8536        e.jump_cursor(0, 0);
8537        run_keys(&mut e, "/target<CR>");
8538        // Cursor moved to the match.
8539        assert_ne!(e.cursor(), (0, 0));
8540        // Ctrl-o returns to the pre-search position.
8541        run_keys(&mut e, "<C-o>");
8542        assert_eq!(e.cursor(), (0, 0));
8543    }
8544
8545    #[test]
8546    fn search_commit_no_match_does_not_push_jump() {
8547        let mut e = editor_with("alpha beta\nfoo end");
8548        e.jump_cursor(0, 3);
8549        let pre_len = e.vim.jump_back.len();
8550        run_keys(&mut e, "/zzznotfound<CR>");
8551        // No match → cursor stays, jumplist shouldn't grow.
8552        assert_eq!(e.vim.jump_back.len(), pre_len);
8553    }
8554
8555    // ─── Phase 7b: migration buffer cursor sync ──────────────────────
8556
8557    #[test]
8558    fn buffer_cursor_mirrors_textarea_after_horizontal_motion() {
8559        let mut e = editor_with("hello world");
8560        run_keys(&mut e, "lll");
8561        let (row, col) = e.cursor();
8562        assert_eq!(e.buffer.cursor().row, row);
8563        assert_eq!(e.buffer.cursor().col, col);
8564    }
8565
8566    #[test]
8567    fn buffer_cursor_mirrors_textarea_after_vertical_motion() {
8568        let mut e = editor_with("aaaa\nbbbb\ncccc");
8569        run_keys(&mut e, "jj");
8570        let (row, col) = e.cursor();
8571        assert_eq!(e.buffer.cursor().row, row);
8572        assert_eq!(e.buffer.cursor().col, col);
8573    }
8574
8575    #[test]
8576    fn buffer_cursor_mirrors_textarea_after_word_motion() {
8577        let mut e = editor_with("foo bar baz");
8578        run_keys(&mut e, "ww");
8579        let (row, col) = e.cursor();
8580        assert_eq!(e.buffer.cursor().row, row);
8581        assert_eq!(e.buffer.cursor().col, col);
8582    }
8583
8584    #[test]
8585    fn buffer_cursor_mirrors_textarea_after_jump_motion() {
8586        let mut e = editor_with("a\nb\nc\nd\ne");
8587        run_keys(&mut e, "G");
8588        let (row, col) = e.cursor();
8589        assert_eq!(e.buffer.cursor().row, row);
8590        assert_eq!(e.buffer.cursor().col, col);
8591    }
8592
8593    #[test]
8594    fn editor_sticky_col_tracks_horizontal_motion() {
8595        let mut e = editor_with("longline\nhi\nlongline");
8596        // `fl` from col 0 lands on the next `l` past the cursor —
8597        // "longline" → second `l` is at col 4. Horizontal motion
8598        // should refresh sticky to that column so the next `j`
8599        // picks it up across the short row.
8600        run_keys(&mut e, "fl");
8601        let landed = e.cursor().1;
8602        assert!(landed > 0, "fl should have moved");
8603        run_keys(&mut e, "j");
8604        // Editor is the single owner of sticky_col (0.0.28). The
8605        // sticky value was set from the post-`fl` column.
8606        assert_eq!(e.sticky_col(), Some(landed));
8607    }
8608
8609    #[test]
8610    fn buffer_content_mirrors_textarea_after_insert() {
8611        let mut e = editor_with("hello");
8612        run_keys(&mut e, "iXYZ<Esc>");
8613        let text = e.buffer().lines().join("\n");
8614        assert_eq!(e.buffer.as_string(), text);
8615    }
8616
8617    #[test]
8618    fn buffer_content_mirrors_textarea_after_delete() {
8619        let mut e = editor_with("alpha bravo charlie");
8620        run_keys(&mut e, "dw");
8621        let text = e.buffer().lines().join("\n");
8622        assert_eq!(e.buffer.as_string(), text);
8623    }
8624
8625    #[test]
8626    fn buffer_content_mirrors_textarea_after_dd() {
8627        let mut e = editor_with("a\nb\nc\nd");
8628        run_keys(&mut e, "jdd");
8629        let text = e.buffer().lines().join("\n");
8630        assert_eq!(e.buffer.as_string(), text);
8631    }
8632
8633    #[test]
8634    fn buffer_content_mirrors_textarea_after_open_line() {
8635        let mut e = editor_with("foo\nbar");
8636        run_keys(&mut e, "oNEW<Esc>");
8637        let text = e.buffer().lines().join("\n");
8638        assert_eq!(e.buffer.as_string(), text);
8639    }
8640
8641    #[test]
8642    fn buffer_content_mirrors_textarea_after_paste() {
8643        let mut e = editor_with("hello");
8644        run_keys(&mut e, "yy");
8645        run_keys(&mut e, "p");
8646        let text = e.buffer().lines().join("\n");
8647        assert_eq!(e.buffer.as_string(), text);
8648    }
8649
8650    #[test]
8651    fn buffer_selection_none_in_normal_mode() {
8652        let e = editor_with("foo bar");
8653        assert!(e.buffer_selection().is_none());
8654    }
8655
8656    #[test]
8657    fn buffer_selection_char_in_visual_mode() {
8658        use hjkl_buffer::{Position, Selection};
8659        let mut e = editor_with("hello world");
8660        run_keys(&mut e, "vlll");
8661        assert_eq!(
8662            e.buffer_selection(),
8663            Some(Selection::Char {
8664                anchor: Position::new(0, 0),
8665                head: Position::new(0, 3),
8666            })
8667        );
8668    }
8669
8670    #[test]
8671    fn buffer_selection_line_in_visual_line_mode() {
8672        use hjkl_buffer::Selection;
8673        let mut e = editor_with("a\nb\nc\nd");
8674        run_keys(&mut e, "Vj");
8675        assert_eq!(
8676            e.buffer_selection(),
8677            Some(Selection::Line {
8678                anchor_row: 0,
8679                head_row: 1,
8680            })
8681        );
8682    }
8683
8684    #[test]
8685    fn wrapscan_off_blocks_wrap_around() {
8686        let mut e = editor_with("first\nsecond\nthird\n");
8687        e.settings_mut().wrapscan = false;
8688        // Place cursor on row 2 ("third") and search for "first".
8689        e.jump_cursor(2, 0);
8690        run_keys(&mut e, "/first<CR>");
8691        // No wrap → cursor stays on row 2.
8692        assert_eq!(e.cursor().0, 2, "wrapscan off should block wrap");
8693        // Re-enable wrapscan and try again.
8694        e.settings_mut().wrapscan = true;
8695        run_keys(&mut e, "/first<CR>");
8696        assert_eq!(e.cursor().0, 0, "wrapscan on should wrap to row 0");
8697    }
8698
8699    #[test]
8700    fn smartcase_uppercase_pattern_stays_sensitive() {
8701        let mut e = editor_with("foo\nFoo\nBAR\n");
8702        e.settings_mut().ignore_case = true;
8703        e.settings_mut().smartcase = true;
8704        // All-lowercase pattern → ignorecase wins → compiled regex
8705        // is case-insensitive.
8706        run_keys(&mut e, "/foo<CR>");
8707        let r1 = e
8708            .search_state()
8709            .pattern
8710            .as_ref()
8711            .unwrap()
8712            .as_str()
8713            .to_string();
8714        assert!(r1.starts_with("(?i)"), "lowercase under smartcase: {r1}");
8715        // Uppercase letter → smartcase flips back to case-sensitive.
8716        run_keys(&mut e, "/Foo<CR>");
8717        let r2 = e
8718            .search_state()
8719            .pattern
8720            .as_ref()
8721            .unwrap()
8722            .as_str()
8723            .to_string();
8724        assert!(!r2.starts_with("(?i)"), "mixed-case under smartcase: {r2}");
8725    }
8726
8727    #[test]
8728    fn enter_with_autoindent_copies_leading_whitespace() {
8729        let mut e = editor_with("    foo");
8730        e.jump_cursor(0, 7);
8731        run_keys(&mut e, "i<CR>");
8732        assert_eq!(e.buffer.line(1).unwrap(), "    ");
8733    }
8734
8735    #[test]
8736    fn enter_without_autoindent_inserts_bare_newline() {
8737        let mut e = editor_with("    foo");
8738        e.settings_mut().autoindent = false;
8739        e.jump_cursor(0, 7);
8740        run_keys(&mut e, "i<CR>");
8741        assert_eq!(e.buffer.line(1).unwrap(), "");
8742    }
8743
8744    #[test]
8745    fn iskeyword_default_treats_alnum_underscore_as_word() {
8746        let mut e = editor_with("foo_bar baz");
8747        // `*` searches for the word at the cursor — picks up everything
8748        // matching iskeyword. With default spec, `foo_bar` is one word,
8749        // so the search pattern should bound that whole token.
8750        e.jump_cursor(0, 0);
8751        run_keys(&mut e, "*");
8752        let p = e
8753            .search_state()
8754            .pattern
8755            .as_ref()
8756            .unwrap()
8757            .as_str()
8758            .to_string();
8759        assert!(p.contains("foo_bar"), "default iskeyword: {p}");
8760    }
8761
8762    #[test]
8763    fn w_motion_respects_custom_iskeyword() {
8764        // `foo-bar baz`. With the default spec, `-` is NOT a word char,
8765        // so `foo` / `-` / `bar` / ` ` / `baz` are 5 transitions and a
8766        // single `w` from col 0 lands on `-` (col 3).
8767        let mut e = editor_with("foo-bar baz");
8768        run_keys(&mut e, "w");
8769        assert_eq!(e.cursor().1, 3, "default iskeyword: {:?}", e.cursor());
8770        // Re-set with `-` (45) treated as a word char. Now `foo-bar` is
8771        // one token; `w` from col 0 should jump to `baz` (col 8).
8772        let mut e2 = editor_with("foo-bar baz");
8773        e2.set_iskeyword("@,_,45");
8774        run_keys(&mut e2, "w");
8775        assert_eq!(e2.cursor().1, 8, "dash-as-word: {:?}", e2.cursor());
8776    }
8777
8778    #[test]
8779    fn iskeyword_with_dash_treats_dash_as_word_char() {
8780        let mut e = editor_with("foo-bar baz");
8781        e.settings_mut().iskeyword = "@,_,45".to_string();
8782        e.jump_cursor(0, 0);
8783        run_keys(&mut e, "*");
8784        let p = e
8785            .search_state()
8786            .pattern
8787            .as_ref()
8788            .unwrap()
8789            .as_str()
8790            .to_string();
8791        assert!(p.contains("foo-bar"), "dash-as-word: {p}");
8792    }
8793
8794    #[test]
8795    fn timeoutlen_drops_pending_g_prefix() {
8796        use std::time::{Duration, Instant};
8797        let mut e = editor_with("a\nb\nc");
8798        e.jump_cursor(2, 0);
8799        // First `g` lands us in g-pending state.
8800        run_keys(&mut e, "g");
8801        assert!(matches!(e.vim.pending, super::Pending::G));
8802        // Push last_input timestamps into the past beyond the default
8803        // timeout. 0.0.29 (Patch B) drives `:set timeoutlen` off
8804        // `Host::now()` (monotonic Duration), so shrink the timeout
8805        // window to a nanosecond and zero out the host slot — any
8806        // wall-clock progress between this line and the next step
8807        // exceeds it. The Instant-flavoured field is rewound for
8808        // snapshot tests that still observe it directly.
8809        e.settings.timeout_len = Duration::from_nanos(0);
8810        e.vim.last_input_at = Some(Instant::now() - Duration::from_secs(60));
8811        e.vim.last_input_host_at = Some(Duration::ZERO);
8812        // Second `g` arrives "late" — timeout fires, prefix is cleared,
8813        // and the bare `g` is re-dispatched: nothing happens at the
8814        // engine level because `g` alone isn't a complete command.
8815        run_keys(&mut e, "g");
8816        // Cursor must still be at row 2 — `gg` was NOT completed.
8817        assert_eq!(e.cursor().0, 2, "timeout must abandon g-prefix");
8818    }
8819
8820    #[test]
8821    fn undobreak_on_breaks_group_at_arrow_motion() {
8822        let mut e = editor_with("");
8823        // i a a a <Left> b b b <Esc> u
8824        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
8825        // Default settings.undo_break_on_motion = true, so `u` only
8826        // reverses the `bbb` run; `aaa` remains.
8827        let line = e.buffer.line(0).unwrap_or("").to_string();
8828        assert!(line.contains("aaa"), "after undobreak: {line:?}");
8829        assert!(!line.contains("bbb"), "bbb should be undone: {line:?}");
8830    }
8831
8832    #[test]
8833    fn undobreak_off_keeps_full_run_in_one_group() {
8834        let mut e = editor_with("");
8835        e.settings_mut().undo_break_on_motion = false;
8836        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
8837        // With undobreak off, the whole insert (aaa<Left>bbb) is one
8838        // group — `u` reverts back to empty.
8839        assert_eq!(e.buffer.line(0).unwrap_or(""), "");
8840    }
8841
8842    #[test]
8843    fn undobreak_round_trips_through_options() {
8844        let e = editor_with("");
8845        let opts = e.current_options();
8846        assert!(opts.undo_break_on_motion);
8847        let mut e2 = editor_with("");
8848        let mut new_opts = opts.clone();
8849        new_opts.undo_break_on_motion = false;
8850        e2.apply_options(&new_opts);
8851        assert!(!e2.current_options().undo_break_on_motion);
8852    }
8853
8854    #[test]
8855    fn undo_levels_cap_drops_oldest() {
8856        let mut e = editor_with("abcde");
8857        e.settings_mut().undo_levels = 3;
8858        run_keys(&mut e, "ra");
8859        run_keys(&mut e, "lrb");
8860        run_keys(&mut e, "lrc");
8861        run_keys(&mut e, "lrd");
8862        run_keys(&mut e, "lre");
8863        assert_eq!(e.undo_stack_len(), 3);
8864    }
8865
8866    #[test]
8867    fn tab_inserts_literal_tab_when_noexpandtab() {
8868        let mut e = editor_with("");
8869        // 0.2.0: expandtab now defaults on (modern). Opt out for the
8870        // literal-tab test.
8871        e.settings_mut().expandtab = false;
8872        e.settings_mut().softtabstop = 0;
8873        run_keys(&mut e, "i");
8874        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8875        assert_eq!(e.buffer.line(0).unwrap(), "\t");
8876    }
8877
8878    #[test]
8879    fn tab_inserts_spaces_when_expandtab() {
8880        let mut e = editor_with("");
8881        e.settings_mut().expandtab = true;
8882        e.settings_mut().tabstop = 4;
8883        run_keys(&mut e, "i");
8884        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8885        assert_eq!(e.buffer.line(0).unwrap(), "    ");
8886    }
8887
8888    #[test]
8889    fn tab_with_softtabstop_fills_to_next_boundary() {
8890        // sts=4, cursor at col 2 → Tab inserts 2 spaces (to col 4).
8891        let mut e = editor_with("ab");
8892        e.settings_mut().expandtab = true;
8893        e.settings_mut().tabstop = 8;
8894        e.settings_mut().softtabstop = 4;
8895        run_keys(&mut e, "A"); // append at end (col 2)
8896        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8897        assert_eq!(e.buffer.line(0).unwrap(), "ab  ");
8898    }
8899
8900    #[test]
8901    fn backspace_deletes_softtab_run() {
8902        // sts=4, line "    x" with cursor at col 4 → Backspace deletes
8903        // the whole 4-space run instead of one char.
8904        let mut e = editor_with("    x");
8905        e.settings_mut().softtabstop = 4;
8906        // Move to col 4 (start of 'x'), then enter insert.
8907        run_keys(&mut e, "fxi");
8908        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
8909        assert_eq!(e.buffer.line(0).unwrap(), "x");
8910    }
8911
8912    #[test]
8913    fn backspace_falls_back_to_single_char_when_run_not_aligned() {
8914        // sts=4, but cursor at col 5 (one space past the boundary) →
8915        // Backspace deletes only the one trailing space.
8916        let mut e = editor_with("     x");
8917        e.settings_mut().softtabstop = 4;
8918        run_keys(&mut e, "fxi");
8919        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
8920        assert_eq!(e.buffer.line(0).unwrap(), "    x");
8921    }
8922
8923    #[test]
8924    fn readonly_blocks_insert_mutation() {
8925        let mut e = editor_with("hello");
8926        e.settings_mut().readonly = true;
8927        run_keys(&mut e, "iX<Esc>");
8928        assert_eq!(e.buffer.line(0).unwrap(), "hello");
8929    }
8930
8931    #[cfg(feature = "ratatui")]
8932    #[test]
8933    fn intern_ratatui_style_dedups_repeated_styles() {
8934        use ratatui::style::{Color, Style};
8935        let mut e = editor_with("");
8936        let red = Style::default().fg(Color::Red);
8937        let blue = Style::default().fg(Color::Blue);
8938        let id_r1 = e.intern_ratatui_style(red);
8939        let id_r2 = e.intern_ratatui_style(red);
8940        let id_b = e.intern_ratatui_style(blue);
8941        assert_eq!(id_r1, id_r2);
8942        assert_ne!(id_r1, id_b);
8943        assert_eq!(e.style_table().len(), 2);
8944    }
8945
8946    #[cfg(feature = "ratatui")]
8947    #[test]
8948    fn install_ratatui_syntax_spans_translates_styled_spans() {
8949        use ratatui::style::{Color, Style};
8950        let mut e = editor_with("SELECT foo");
8951        e.install_ratatui_syntax_spans(vec![vec![(0, 6, Style::default().fg(Color::Red))]]);
8952        let by_row = e.buffer_spans();
8953        assert_eq!(by_row.len(), 1);
8954        assert_eq!(by_row[0].len(), 1);
8955        assert_eq!(by_row[0][0].start_byte, 0);
8956        assert_eq!(by_row[0][0].end_byte, 6);
8957        let id = by_row[0][0].style;
8958        assert_eq!(e.style_table()[id as usize].fg, Some(Color::Red));
8959    }
8960
8961    #[cfg(feature = "ratatui")]
8962    #[test]
8963    fn install_ratatui_syntax_spans_clamps_sentinel_end() {
8964        use ratatui::style::{Color, Style};
8965        let mut e = editor_with("hello");
8966        e.install_ratatui_syntax_spans(vec![vec![(
8967            0,
8968            usize::MAX,
8969            Style::default().fg(Color::Blue),
8970        )]]);
8971        let by_row = e.buffer_spans();
8972        assert_eq!(by_row[0][0].end_byte, 5);
8973    }
8974
8975    #[cfg(feature = "ratatui")]
8976    #[test]
8977    fn install_ratatui_syntax_spans_drops_zero_width() {
8978        use ratatui::style::{Color, Style};
8979        let mut e = editor_with("abc");
8980        e.install_ratatui_syntax_spans(vec![vec![(2, 2, Style::default().fg(Color::Red))]]);
8981        assert!(e.buffer_spans()[0].is_empty());
8982    }
8983
8984    #[test]
8985    fn named_register_yank_into_a_then_paste_from_a() {
8986        let mut e = editor_with("hello world\nsecond");
8987        run_keys(&mut e, "\"ayw");
8988        // `yw` over "hello world" yanks "hello " (word + trailing space).
8989        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
8990        // Move to second line then paste from "a.
8991        run_keys(&mut e, "j0\"aP");
8992        assert_eq!(e.buffer().lines()[1], "hello second");
8993    }
8994
8995    #[test]
8996    fn capital_r_overstrikes_chars() {
8997        let mut e = editor_with("hello");
8998        e.jump_cursor(0, 0);
8999        run_keys(&mut e, "RXY<Esc>");
9000        // 'h' and 'e' replaced; 'llo' kept.
9001        assert_eq!(e.buffer().lines()[0], "XYllo");
9002    }
9003
9004    #[test]
9005    fn capital_r_at_eol_appends() {
9006        let mut e = editor_with("hi");
9007        e.jump_cursor(0, 1);
9008        // Cursor on the final 'i'; replace it then keep typing past EOL.
9009        run_keys(&mut e, "RXYZ<Esc>");
9010        assert_eq!(e.buffer().lines()[0], "hXYZ");
9011    }
9012
9013    #[test]
9014    fn capital_r_count_does_not_repeat_overstrike_char_by_char() {
9015        // Vim's `2R` replays the *whole session* on Esc, not each char.
9016        // We don't model that fully, but the basic R should at least
9017        // not crash on empty session count handling.
9018        let mut e = editor_with("abc");
9019        e.jump_cursor(0, 0);
9020        run_keys(&mut e, "RX<Esc>");
9021        assert_eq!(e.buffer().lines()[0], "Xbc");
9022    }
9023
9024    #[test]
9025    fn ctrl_r_in_insert_pastes_named_register() {
9026        let mut e = editor_with("hello world");
9027        // Yank "hello " into "a".
9028        run_keys(&mut e, "\"ayw");
9029        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
9030        // Open a fresh line, enter insert, Ctrl-R a.
9031        run_keys(&mut e, "o");
9032        assert_eq!(e.vim_mode(), VimMode::Insert);
9033        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9034        e.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
9035        assert_eq!(e.buffer().lines()[1], "hello ");
9036        // Cursor sits at end of inserted payload (col 6).
9037        assert_eq!(e.cursor(), (1, 6));
9038        // Stayed in insert mode; next char appends.
9039        assert_eq!(e.vim_mode(), VimMode::Insert);
9040        e.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
9041        assert_eq!(e.buffer().lines()[1], "hello X");
9042    }
9043
9044    #[test]
9045    fn ctrl_r_with_unnamed_register() {
9046        let mut e = editor_with("foo");
9047        run_keys(&mut e, "yiw");
9048        run_keys(&mut e, "A ");
9049        // Unnamed register paste via `"`.
9050        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9051        e.handle_key(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
9052        assert_eq!(e.buffer().lines()[0], "foo foo");
9053    }
9054
9055    #[test]
9056    fn ctrl_r_unknown_selector_is_no_op() {
9057        let mut e = editor_with("abc");
9058        run_keys(&mut e, "A");
9059        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9060        // `?` isn't a valid register selector — paste skipped, the
9061        // armed flag still clears so the next key types normally.
9062        e.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
9063        e.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
9064        assert_eq!(e.buffer().lines()[0], "abcZ");
9065    }
9066
9067    #[test]
9068    fn ctrl_r_multiline_register_pastes_with_newlines() {
9069        let mut e = editor_with("alpha\nbeta\ngamma");
9070        // Yank two whole lines into "b".
9071        run_keys(&mut e, "\"byy");
9072        run_keys(&mut e, "j\"byy");
9073        // Linewise yanks include trailing \n; second yank into uppercase
9074        // would append, but lowercase "b" overwrote — ensure we have a
9075        // multi-line payload by yanking 2 lines linewise via V.
9076        run_keys(&mut e, "ggVj\"by");
9077        let payload = e.registers().read('b').unwrap().text.clone();
9078        assert!(payload.contains('\n'));
9079        run_keys(&mut e, "Go");
9080        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9081        e.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
9082        // The buffer should now contain the original 3 lines plus the
9083        // pasted 2-line payload (with its own newline) on its own line.
9084        let total_lines = e.buffer().lines().len();
9085        assert!(total_lines >= 5);
9086    }
9087
9088    #[test]
9089    fn yank_zero_holds_last_yank_after_delete() {
9090        let mut e = editor_with("hello world");
9091        run_keys(&mut e, "yw");
9092        let yanked = e.registers().read('0').unwrap().text.clone();
9093        assert!(!yanked.is_empty());
9094        // Delete a word; "0 should still hold the original yank.
9095        run_keys(&mut e, "dw");
9096        assert_eq!(e.registers().read('0').unwrap().text, yanked);
9097        // "1 holds the just-deleted text (non-empty, regardless of exact contents).
9098        assert!(!e.registers().read('1').unwrap().text.is_empty());
9099    }
9100
9101    #[test]
9102    fn delete_ring_rotates_through_one_through_nine() {
9103        let mut e = editor_with("a b c d e f g h i j");
9104        // Delete each word — each delete pushes onto "1, shifting older.
9105        for _ in 0..3 {
9106            run_keys(&mut e, "dw");
9107        }
9108        // Most recent delete is in "1.
9109        let r1 = e.registers().read('1').unwrap().text.clone();
9110        let r2 = e.registers().read('2').unwrap().text.clone();
9111        let r3 = e.registers().read('3').unwrap().text.clone();
9112        assert!(!r1.is_empty() && !r2.is_empty() && !r3.is_empty());
9113        assert_ne!(r1, r2);
9114        assert_ne!(r2, r3);
9115    }
9116
9117    #[test]
9118    fn capital_register_appends_to_lowercase() {
9119        let mut e = editor_with("foo bar");
9120        run_keys(&mut e, "\"ayw");
9121        let first = e.registers().read('a').unwrap().text.clone();
9122        assert!(first.contains("foo"));
9123        // Yank again into "A — appends to "a.
9124        run_keys(&mut e, "w\"Ayw");
9125        let combined = e.registers().read('a').unwrap().text.clone();
9126        assert!(combined.starts_with(&first));
9127        assert!(combined.contains("bar"));
9128    }
9129
9130    #[test]
9131    fn zf_in_visual_line_creates_closed_fold() {
9132        let mut e = editor_with("a\nb\nc\nd\ne");
9133        // VisualLine over rows 1..=3 then zf.
9134        e.jump_cursor(1, 0);
9135        run_keys(&mut e, "Vjjzf");
9136        assert_eq!(e.buffer().folds().len(), 1);
9137        let f = e.buffer().folds()[0];
9138        assert_eq!(f.start_row, 1);
9139        assert_eq!(f.end_row, 3);
9140        assert!(f.closed);
9141    }
9142
9143    #[test]
9144    fn zfj_in_normal_creates_two_row_fold() {
9145        let mut e = editor_with("a\nb\nc\nd\ne");
9146        e.jump_cursor(1, 0);
9147        run_keys(&mut e, "zfj");
9148        assert_eq!(e.buffer().folds().len(), 1);
9149        let f = e.buffer().folds()[0];
9150        assert_eq!(f.start_row, 1);
9151        assert_eq!(f.end_row, 2);
9152        assert!(f.closed);
9153        // Cursor stays where it started.
9154        assert_eq!(e.cursor().0, 1);
9155    }
9156
9157    #[test]
9158    fn zf_with_count_folds_count_rows() {
9159        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9160        e.jump_cursor(0, 0);
9161        // `zf3j` — fold rows 0..=3.
9162        run_keys(&mut e, "zf3j");
9163        assert_eq!(e.buffer().folds().len(), 1);
9164        let f = e.buffer().folds()[0];
9165        assert_eq!(f.start_row, 0);
9166        assert_eq!(f.end_row, 3);
9167    }
9168
9169    #[test]
9170    fn zfk_folds_upward_range() {
9171        let mut e = editor_with("a\nb\nc\nd\ne");
9172        e.jump_cursor(3, 0);
9173        run_keys(&mut e, "zfk");
9174        let f = e.buffer().folds()[0];
9175        // start_row = min(3, 2) = 2, end_row = max(3, 2) = 3.
9176        assert_eq!(f.start_row, 2);
9177        assert_eq!(f.end_row, 3);
9178    }
9179
9180    #[test]
9181    fn zf_capital_g_folds_to_bottom() {
9182        let mut e = editor_with("a\nb\nc\nd\ne");
9183        e.jump_cursor(1, 0);
9184        // `G` is a single-char motion; folds rows 1..=4.
9185        run_keys(&mut e, "zfG");
9186        let f = e.buffer().folds()[0];
9187        assert_eq!(f.start_row, 1);
9188        assert_eq!(f.end_row, 4);
9189    }
9190
9191    #[test]
9192    fn zfgg_folds_to_top_via_operator_pipeline() {
9193        let mut e = editor_with("a\nb\nc\nd\ne");
9194        e.jump_cursor(3, 0);
9195        // `gg` is a 2-key chord (Pending::OpG path) — `zfgg` works
9196        // because `zf` arms `Pending::Op { Fold }` which already knows
9197        // how to wait for `g` then `g`.
9198        run_keys(&mut e, "zfgg");
9199        let f = e.buffer().folds()[0];
9200        assert_eq!(f.start_row, 0);
9201        assert_eq!(f.end_row, 3);
9202    }
9203
9204    #[test]
9205    fn zfip_folds_paragraph_via_text_object() {
9206        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta\nepsilon");
9207        e.jump_cursor(1, 0);
9208        // `ip` is a text object — same operator pipeline routes it.
9209        run_keys(&mut e, "zfip");
9210        assert_eq!(e.buffer().folds().len(), 1);
9211        let f = e.buffer().folds()[0];
9212        assert_eq!(f.start_row, 0);
9213        assert_eq!(f.end_row, 2);
9214    }
9215
9216    #[test]
9217    fn zfap_folds_paragraph_with_trailing_blank() {
9218        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta");
9219        e.jump_cursor(0, 0);
9220        // `ap` includes the trailing blank line.
9221        run_keys(&mut e, "zfap");
9222        let f = e.buffer().folds()[0];
9223        assert_eq!(f.start_row, 0);
9224        assert_eq!(f.end_row, 3);
9225    }
9226
9227    #[test]
9228    fn zf_paragraph_motion_folds_to_blank() {
9229        let mut e = editor_with("alpha\nbeta\n\ngamma");
9230        e.jump_cursor(0, 0);
9231        // `}` jumps to the blank-line boundary; fold spans rows 0..=2.
9232        run_keys(&mut e, "zf}");
9233        let f = e.buffer().folds()[0];
9234        assert_eq!(f.start_row, 0);
9235        assert_eq!(f.end_row, 2);
9236    }
9237
9238    #[test]
9239    fn za_toggles_fold_under_cursor() {
9240        let mut e = editor_with("a\nb\nc\nd");
9241        e.buffer_mut().add_fold(1, 2, true);
9242        e.jump_cursor(1, 0);
9243        run_keys(&mut e, "za");
9244        assert!(!e.buffer().folds()[0].closed);
9245        run_keys(&mut e, "za");
9246        assert!(e.buffer().folds()[0].closed);
9247    }
9248
9249    #[test]
9250    fn zr_opens_all_folds_zm_closes_all() {
9251        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9252        e.buffer_mut().add_fold(0, 1, true);
9253        e.buffer_mut().add_fold(2, 3, true);
9254        e.buffer_mut().add_fold(4, 5, true);
9255        run_keys(&mut e, "zR");
9256        assert!(e.buffer().folds().iter().all(|f| !f.closed));
9257        run_keys(&mut e, "zM");
9258        assert!(e.buffer().folds().iter().all(|f| f.closed));
9259    }
9260
9261    #[test]
9262    fn ze_clears_all_folds() {
9263        let mut e = editor_with("a\nb\nc\nd");
9264        e.buffer_mut().add_fold(0, 1, true);
9265        e.buffer_mut().add_fold(2, 3, false);
9266        run_keys(&mut e, "zE");
9267        assert!(e.buffer().folds().is_empty());
9268    }
9269
9270    #[test]
9271    fn g_underscore_jumps_to_last_non_blank() {
9272        let mut e = editor_with("hello world   ");
9273        run_keys(&mut e, "g_");
9274        // Last non-blank is 'd' at col 10.
9275        assert_eq!(e.cursor().1, 10);
9276    }
9277
9278    #[test]
9279    fn gj_and_gk_alias_j_and_k() {
9280        let mut e = editor_with("a\nb\nc");
9281        run_keys(&mut e, "gj");
9282        assert_eq!(e.cursor().0, 1);
9283        run_keys(&mut e, "gk");
9284        assert_eq!(e.cursor().0, 0);
9285    }
9286
9287    #[test]
9288    fn paragraph_motions_walk_blank_lines() {
9289        let mut e = editor_with("first\nblock\n\nsecond\nblock\n\nthird");
9290        run_keys(&mut e, "}");
9291        assert_eq!(e.cursor().0, 2);
9292        run_keys(&mut e, "}");
9293        assert_eq!(e.cursor().0, 5);
9294        run_keys(&mut e, "{");
9295        assert_eq!(e.cursor().0, 2);
9296    }
9297
9298    #[test]
9299    fn gv_reenters_last_visual_selection() {
9300        let mut e = editor_with("alpha\nbeta\ngamma");
9301        run_keys(&mut e, "Vj");
9302        // Exit visual.
9303        run_keys(&mut e, "<Esc>");
9304        assert_eq!(e.vim_mode(), VimMode::Normal);
9305        // gv re-enters VisualLine.
9306        run_keys(&mut e, "gv");
9307        assert_eq!(e.vim_mode(), VimMode::VisualLine);
9308    }
9309
9310    #[test]
9311    fn o_in_visual_swaps_anchor_and_cursor() {
9312        let mut e = editor_with("hello world");
9313        // v then move right 4 — anchor at col 0, cursor at col 4.
9314        run_keys(&mut e, "vllll");
9315        assert_eq!(e.cursor().1, 4);
9316        // o swaps; cursor jumps to anchor (col 0).
9317        run_keys(&mut e, "o");
9318        assert_eq!(e.cursor().1, 0);
9319        // Anchor now at original cursor (col 4).
9320        assert_eq!(e.vim.visual_anchor, (0, 4));
9321    }
9322
9323    #[test]
9324    fn editing_inside_fold_invalidates_it() {
9325        let mut e = editor_with("a\nb\nc\nd");
9326        e.buffer_mut().add_fold(1, 2, true);
9327        e.jump_cursor(1, 0);
9328        // Insert a char on a row covered by the fold.
9329        run_keys(&mut e, "iX<Esc>");
9330        // Fold should be gone — vim opens (drops) folds on edit.
9331        assert!(e.buffer().folds().is_empty());
9332    }
9333
9334    #[test]
9335    fn zd_removes_fold_under_cursor() {
9336        let mut e = editor_with("a\nb\nc\nd");
9337        e.buffer_mut().add_fold(1, 2, true);
9338        e.jump_cursor(2, 0);
9339        run_keys(&mut e, "zd");
9340        assert!(e.buffer().folds().is_empty());
9341    }
9342
9343    #[test]
9344    fn take_fold_ops_observes_z_keystroke_dispatch() {
9345        // 0.0.38 (Patch C-δ.4): every `z…` keystroke routes through
9346        // `Editor::apply_fold_op`, which queues a `FoldOp` for hosts to
9347        // observe via `take_fold_ops` AND applies the op locally so
9348        // buffer fold storage stays in sync.
9349        use crate::types::FoldOp;
9350        let mut e = editor_with("a\nb\nc\nd");
9351        e.buffer_mut().add_fold(1, 2, true);
9352        e.jump_cursor(1, 0);
9353        // Drain any queue from the buffer setup above (none expected,
9354        // but be defensive).
9355        let _ = e.take_fold_ops();
9356        run_keys(&mut e, "zo");
9357        run_keys(&mut e, "zM");
9358        let ops = e.take_fold_ops();
9359        assert_eq!(ops.len(), 2);
9360        assert!(matches!(ops[0], FoldOp::OpenAt(1)));
9361        assert!(matches!(ops[1], FoldOp::CloseAll));
9362        // Second drain returns empty.
9363        assert!(e.take_fold_ops().is_empty());
9364    }
9365
9366    #[test]
9367    fn edit_pipeline_emits_invalidate_fold_op() {
9368        // The edit pipeline routes its fold invalidation through
9369        // `apply_fold_op` so hosts can observe + dedupe.
9370        use crate::types::FoldOp;
9371        let mut e = editor_with("a\nb\nc\nd");
9372        e.buffer_mut().add_fold(1, 2, true);
9373        e.jump_cursor(1, 0);
9374        let _ = e.take_fold_ops();
9375        run_keys(&mut e, "iX<Esc>");
9376        let ops = e.take_fold_ops();
9377        assert!(
9378            ops.iter().any(|op| matches!(op, FoldOp::Invalidate { .. })),
9379            "expected at least one Invalidate op, got {ops:?}"
9380        );
9381    }
9382
9383    #[test]
9384    fn dot_mark_jumps_to_last_edit_position() {
9385        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
9386        e.jump_cursor(2, 0);
9387        // Insert at line 2 — sets last_edit_pos.
9388        run_keys(&mut e, "iX<Esc>");
9389        let after_edit = e.cursor();
9390        // Move away.
9391        run_keys(&mut e, "gg");
9392        assert_eq!(e.cursor().0, 0);
9393        // `'.` jumps back to the edit's row (linewise variant).
9394        run_keys(&mut e, "'.");
9395        assert_eq!(e.cursor().0, after_edit.0);
9396    }
9397
9398    #[test]
9399    fn quote_quote_returns_to_pre_jump_position() {
9400        let mut e = editor_with_rows(50, 20);
9401        e.jump_cursor(10, 2);
9402        let before = e.cursor();
9403        // `G` is a big jump — pushes (10, 2) onto jump_back.
9404        run_keys(&mut e, "G");
9405        assert_ne!(e.cursor(), before);
9406        // `''` jumps back to the pre-jump position (linewise).
9407        run_keys(&mut e, "''");
9408        assert_eq!(e.cursor().0, before.0);
9409    }
9410
9411    #[test]
9412    fn backtick_backtick_restores_exact_pre_jump_pos() {
9413        let mut e = editor_with_rows(50, 20);
9414        e.jump_cursor(7, 3);
9415        let before = e.cursor();
9416        run_keys(&mut e, "G");
9417        run_keys(&mut e, "``");
9418        assert_eq!(e.cursor(), before);
9419    }
9420
9421    #[test]
9422    fn macro_record_and_replay_basic() {
9423        let mut e = editor_with("foo\nbar\nbaz");
9424        // Record into "a": insert "X" at line start, exit insert.
9425        run_keys(&mut e, "qaIX<Esc>jq");
9426        assert_eq!(e.buffer().lines()[0], "Xfoo");
9427        // Replay on the next two lines.
9428        run_keys(&mut e, "@a");
9429        assert_eq!(e.buffer().lines()[1], "Xbar");
9430        // @@ replays the last-played macro.
9431        run_keys(&mut e, "j@@");
9432        assert_eq!(e.buffer().lines()[2], "Xbaz");
9433    }
9434
9435    #[test]
9436    fn macro_count_replays_n_times() {
9437        let mut e = editor_with("a\nb\nc\nd\ne");
9438        // Record "j" — move down once.
9439        run_keys(&mut e, "qajq");
9440        assert_eq!(e.cursor().0, 1);
9441        // Replay 3 times via 3@a.
9442        run_keys(&mut e, "3@a");
9443        assert_eq!(e.cursor().0, 4);
9444    }
9445
9446    #[test]
9447    fn macro_capital_q_appends_to_lowercase_register() {
9448        let mut e = editor_with("hello");
9449        run_keys(&mut e, "qall<Esc>q");
9450        run_keys(&mut e, "qAhh<Esc>q");
9451        // Macros + named registers share storage now: register `a`
9452        // holds the encoded keystrokes from both recordings.
9453        let text = e.registers().read('a').unwrap().text.clone();
9454        assert!(text.contains("ll<Esc>"));
9455        assert!(text.contains("hh<Esc>"));
9456    }
9457
9458    #[test]
9459    fn buffer_selection_block_in_visual_block_mode() {
9460        use hjkl_buffer::{Position, Selection};
9461        let mut e = editor_with("aaaa\nbbbb\ncccc");
9462        run_keys(&mut e, "<C-v>jl");
9463        assert_eq!(
9464            e.buffer_selection(),
9465            Some(Selection::Block {
9466                anchor: Position::new(0, 0),
9467                head: Position::new(1, 1),
9468            })
9469        );
9470    }
9471
9472    // ─── Audit batch: lock in known-good behaviour ───────────────────────
9473
9474    #[test]
9475    fn n_after_question_mark_keeps_walking_backward() {
9476        // After committing a `?` search, `n` should continue in the
9477        // backward direction; `N` flips forward.
9478        let mut e = editor_with("foo bar foo baz foo end");
9479        e.jump_cursor(0, 22);
9480        run_keys(&mut e, "?foo<CR>");
9481        assert_eq!(e.cursor().1, 16);
9482        run_keys(&mut e, "n");
9483        assert_eq!(e.cursor().1, 8);
9484        run_keys(&mut e, "N");
9485        assert_eq!(e.cursor().1, 16);
9486    }
9487
9488    #[test]
9489    fn nested_macro_chord_records_literal_keys() {
9490        // `qa@bq` should capture `@` and `b` as literal keys in `a`,
9491        // not as a macro-replay invocation. Replay then re-runs them.
9492        let mut e = editor_with("alpha\nbeta\ngamma");
9493        // First record `b` as a noop-ish macro: just `l` (move right).
9494        run_keys(&mut e, "qblq");
9495        // Now record `a` as: enter insert, type X, exit, then trigger
9496        // `@b` which should run the macro inline during recording too.
9497        run_keys(&mut e, "qaIX<Esc>q");
9498        // `@a` re-runs the captured key sequence on a different line.
9499        e.jump_cursor(1, 0);
9500        run_keys(&mut e, "@a");
9501        assert_eq!(e.buffer().lines()[1], "Xbeta");
9502    }
9503
9504    #[test]
9505    fn shift_gt_motion_indents_one_line() {
9506        // `>w` over a single-line buffer should indent that line by
9507        // one shiftwidth — operator routes through the operator
9508        // pipeline like `dw` / `cw`.
9509        let mut e = editor_with("hello world");
9510        run_keys(&mut e, ">w");
9511        assert_eq!(e.buffer().lines()[0], "  hello world");
9512    }
9513
9514    #[test]
9515    fn shift_lt_motion_outdents_one_line() {
9516        let mut e = editor_with("    hello world");
9517        run_keys(&mut e, "<lt>w");
9518        // Outdent strips up to one shiftwidth (default 2).
9519        assert_eq!(e.buffer().lines()[0], "  hello world");
9520    }
9521
9522    #[test]
9523    fn shift_gt_text_object_indents_paragraph() {
9524        let mut e = editor_with("alpha\nbeta\ngamma\n\nrest");
9525        e.jump_cursor(0, 0);
9526        run_keys(&mut e, ">ip");
9527        assert_eq!(e.buffer().lines()[0], "  alpha");
9528        assert_eq!(e.buffer().lines()[1], "  beta");
9529        assert_eq!(e.buffer().lines()[2], "  gamma");
9530        // Blank separator + the next paragraph stay untouched.
9531        assert_eq!(e.buffer().lines()[4], "rest");
9532    }
9533
9534    #[test]
9535    fn ctrl_o_runs_exactly_one_normal_command() {
9536        // `Ctrl-O dw` returns to insert after the single `dw`. A
9537        // second `Ctrl-O` is needed for another normal command.
9538        let mut e = editor_with("alpha beta gamma");
9539        e.jump_cursor(0, 0);
9540        run_keys(&mut e, "i");
9541        e.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
9542        run_keys(&mut e, "dw");
9543        // First `dw` ran in normal; we're back in insert.
9544        assert_eq!(e.vim_mode(), VimMode::Insert);
9545        // Typing a char now inserts.
9546        run_keys(&mut e, "X");
9547        assert_eq!(e.buffer().lines()[0], "Xbeta gamma");
9548    }
9549
9550    #[test]
9551    fn macro_replay_respects_mode_switching() {
9552        // Recording `iX<Esc>0` should leave us in normal mode at col 0
9553        // after replay — the embedded Esc in the macro must drop the
9554        // replayed insert session.
9555        let mut e = editor_with("hi");
9556        run_keys(&mut e, "qaiX<Esc>0q");
9557        assert_eq!(e.vim_mode(), VimMode::Normal);
9558        // Replay on a fresh line.
9559        e.set_content("yo");
9560        run_keys(&mut e, "@a");
9561        assert_eq!(e.vim_mode(), VimMode::Normal);
9562        assert_eq!(e.cursor().1, 0);
9563        assert_eq!(e.buffer().lines()[0], "Xyo");
9564    }
9565
9566    #[test]
9567    fn macro_recorded_text_round_trips_through_register() {
9568        // After the macros-in-registers unification, recording into
9569        // `a` writes the encoded keystroke text into register `a`'s
9570        // slot. `@a` decodes back to inputs and replays.
9571        let mut e = editor_with("");
9572        run_keys(&mut e, "qaiX<Esc>q");
9573        let text = e.registers().read('a').unwrap().text.clone();
9574        assert!(text.starts_with("iX"));
9575        // Replay inserts another X at the cursor.
9576        run_keys(&mut e, "@a");
9577        assert_eq!(e.buffer().lines()[0], "XX");
9578    }
9579
9580    #[test]
9581    fn dot_after_macro_replays_macros_last_change() {
9582        // After `@a` runs a macro whose last mutation was an insert,
9583        // `.` should repeat that final change, not the whole macro.
9584        let mut e = editor_with("ab\ncd\nef");
9585        // Record: insert 'X' at line start, then move down. The last
9586        // mutation is the insert — `.` should re-apply just that.
9587        run_keys(&mut e, "qaIX<Esc>jq");
9588        assert_eq!(e.buffer().lines()[0], "Xab");
9589        run_keys(&mut e, "@a");
9590        assert_eq!(e.buffer().lines()[1], "Xcd");
9591        // `.` from the new cursor row repeats the last edit (the
9592        // insert `X`), not the whole macro (which would also `j`).
9593        let row_before_dot = e.cursor().0;
9594        run_keys(&mut e, ".");
9595        assert!(e.buffer().lines()[row_before_dot].starts_with('X'));
9596    }
9597
9598    // ── smartindent tests ────────────────────────────────────────────────
9599
9600    /// Build an editor with 4-space settings (expandtab, shiftwidth=4,
9601    /// softtabstop=4) for smartindent tests. Does NOT inherit the
9602    /// shiftwidth=2 override from `editor_with`.
9603    fn si_editor(content: &str) -> Editor {
9604        let opts = crate::types::Options {
9605            shiftwidth: 4,
9606            softtabstop: 4,
9607            expandtab: true,
9608            smartindent: true,
9609            autoindent: true,
9610            ..crate::types::Options::default()
9611        };
9612        let mut e = Editor::new(
9613            hjkl_buffer::Buffer::new(),
9614            crate::types::DefaultHost::new(),
9615            opts,
9616        );
9617        e.set_content(content);
9618        e
9619    }
9620
9621    #[test]
9622    fn smartindent_bumps_indent_after_open_brace() {
9623        // "fn foo() {" + Enter → new line has 4 spaces of indent
9624        let mut e = si_editor("fn foo() {");
9625        e.jump_cursor(0, 10); // after the `{`
9626        run_keys(&mut e, "i<CR>");
9627        assert_eq!(
9628            e.buffer().lines()[1],
9629            "    ",
9630            "smartindent should bump one shiftwidth after {{"
9631        );
9632    }
9633
9634    #[test]
9635    fn smartindent_no_bump_when_off() {
9636        // Same input but smartindent=false → just copies prev leading ws
9637        // (which is empty on "fn foo() {"), so new line is empty.
9638        let mut e = si_editor("fn foo() {");
9639        e.settings_mut().smartindent = false;
9640        e.jump_cursor(0, 10);
9641        run_keys(&mut e, "i<CR>");
9642        assert_eq!(
9643            e.buffer().lines()[1],
9644            "",
9645            "without smartindent, no bump: new line copies empty leading ws"
9646        );
9647    }
9648
9649    #[test]
9650    fn smartindent_uses_tab_when_noexpandtab() {
9651        // noexpandtab + prev line ends in `{` → new line starts with `\t`
9652        let opts = crate::types::Options {
9653            shiftwidth: 4,
9654            softtabstop: 0,
9655            expandtab: false,
9656            smartindent: true,
9657            autoindent: true,
9658            ..crate::types::Options::default()
9659        };
9660        let mut e = Editor::new(
9661            hjkl_buffer::Buffer::new(),
9662            crate::types::DefaultHost::new(),
9663            opts,
9664        );
9665        e.set_content("fn foo() {");
9666        e.jump_cursor(0, 10);
9667        run_keys(&mut e, "i<CR>");
9668        assert_eq!(
9669            e.buffer().lines()[1],
9670            "\t",
9671            "noexpandtab: smartindent bump inserts a literal tab"
9672        );
9673    }
9674
9675    #[test]
9676    fn smartindent_dedent_on_close_brace() {
9677        // Line is "    " (4 spaces), cursor at col 4, type `}` →
9678        // leading spaces stripped, `}` at col 0.
9679        let mut e = si_editor("fn foo() {");
9680        // Add a second line with only indentation.
9681        e.set_content("fn foo() {\n    ");
9682        e.jump_cursor(1, 4); // end of "    "
9683        run_keys(&mut e, "i}");
9684        assert_eq!(
9685            e.buffer().lines()[1],
9686            "}",
9687            "close brace on whitespace-only line should dedent"
9688        );
9689        assert_eq!(e.cursor(), (1, 1), "cursor should be after the `}}`");
9690    }
9691
9692    #[test]
9693    fn smartindent_no_dedent_when_off() {
9694        // Same setup but smartindent=false → `}` appended normally.
9695        let mut e = si_editor("fn foo() {\n    ");
9696        e.settings_mut().smartindent = false;
9697        e.jump_cursor(1, 4);
9698        run_keys(&mut e, "i}");
9699        assert_eq!(
9700            e.buffer().lines()[1],
9701            "    }",
9702            "without smartindent, `}}` just appends at cursor"
9703        );
9704    }
9705
9706    #[test]
9707    fn smartindent_no_dedent_mid_line() {
9708        // Line has "    let x = 1", cursor after `1`; type `}` → no
9709        // dedent because chars before cursor aren't all whitespace.
9710        let mut e = si_editor("    let x = 1");
9711        e.jump_cursor(0, 13); // after `1`
9712        run_keys(&mut e, "i}");
9713        assert_eq!(
9714            e.buffer().lines()[0],
9715            "    let x = 1}",
9716            "mid-line `}}` should not dedent"
9717        );
9718    }
9719
9720    // ─── Vim-compat divergence fixes (issue #24) ─────────────────────
9721
9722    // Fix #1: x/X populate the unnamed register.
9723    #[test]
9724    fn count_5x_fills_unnamed_register() {
9725        let mut e = editor_with("hello world\n");
9726        e.jump_cursor(0, 0);
9727        run_keys(&mut e, "5x");
9728        assert_eq!(e.buffer().lines()[0], " world");
9729        assert_eq!(e.cursor(), (0, 0));
9730        assert_eq!(e.yank(), "hello");
9731    }
9732
9733    #[test]
9734    fn x_fills_unnamed_register_single_char() {
9735        let mut e = editor_with("abc\n");
9736        e.jump_cursor(0, 0);
9737        run_keys(&mut e, "x");
9738        assert_eq!(e.buffer().lines()[0], "bc");
9739        assert_eq!(e.yank(), "a");
9740    }
9741
9742    #[test]
9743    fn big_x_fills_unnamed_register() {
9744        let mut e = editor_with("hello\n");
9745        e.jump_cursor(0, 3);
9746        run_keys(&mut e, "X");
9747        assert_eq!(e.buffer().lines()[0], "helo");
9748        assert_eq!(e.yank(), "l");
9749    }
9750
9751    // Fix #2: G lands on last content row, not phantom trailing-empty row.
9752    #[test]
9753    fn g_motion_trailing_newline_lands_on_last_content_row() {
9754        let mut e = editor_with("foo\nbar\nbaz\n");
9755        e.jump_cursor(0, 0);
9756        run_keys(&mut e, "G");
9757        // buffer is stored as ["foo","bar","baz",""] — G must land on row 2 ("baz").
9758        assert_eq!(
9759            e.cursor().0,
9760            2,
9761            "G should land on row 2 (baz), not row 3 (phantom empty)"
9762        );
9763    }
9764
9765    // Fix #3: dd on last line clamps cursor to new last content row.
9766    #[test]
9767    fn dd_last_line_clamps_cursor_to_new_last_row() {
9768        let mut e = editor_with("foo\nbar\n");
9769        e.jump_cursor(1, 0);
9770        run_keys(&mut e, "dd");
9771        assert_eq!(e.buffer().lines()[0], "foo");
9772        assert_eq!(
9773            e.cursor(),
9774            (0, 0),
9775            "cursor should clamp to row 0 after dd on last content line"
9776        );
9777    }
9778
9779    // Fix #4: d$ cursor lands on last char, not one past.
9780    #[test]
9781    fn d_dollar_cursor_on_last_char() {
9782        let mut e = editor_with("hello world\n");
9783        e.jump_cursor(0, 5);
9784        run_keys(&mut e, "d$");
9785        assert_eq!(e.buffer().lines()[0], "hello");
9786        assert_eq!(
9787            e.cursor(),
9788            (0, 4),
9789            "d$ should leave cursor on col 4, not col 5"
9790        );
9791    }
9792
9793    // Fix #5: undo clamps cursor to last valid normal-mode col.
9794    #[test]
9795    fn undo_insert_clamps_cursor_to_last_valid_col() {
9796        let mut e = editor_with("hello\n");
9797        e.jump_cursor(0, 5); // one-past-last, as in oracle initial_cursor
9798        run_keys(&mut e, "a world<Esc>u");
9799        assert_eq!(e.buffer().lines()[0], "hello");
9800        assert_eq!(
9801            e.cursor(),
9802            (0, 4),
9803            "undo should clamp cursor to col 4 on 'hello'"
9804        );
9805    }
9806
9807    // Fix #6: da" eats trailing whitespace when present.
9808    #[test]
9809    fn da_doublequote_eats_trailing_whitespace() {
9810        let mut e = editor_with("say \"hello\" there\n");
9811        e.jump_cursor(0, 6);
9812        run_keys(&mut e, "da\"");
9813        assert_eq!(e.buffer().lines()[0], "say there");
9814        assert_eq!(e.cursor().1, 4, "cursor should be at col 4 after da\"");
9815    }
9816
9817    // Fix #7: daB cursor off-by-one — clamp to new last col.
9818    #[test]
9819    fn dab_cursor_col_clamped_after_delete() {
9820        let mut e = editor_with("fn x() {\n    body\n}\n");
9821        e.jump_cursor(1, 4);
9822        run_keys(&mut e, "daB");
9823        assert_eq!(e.buffer().lines()[0], "fn x() ");
9824        assert_eq!(
9825            e.cursor(),
9826            (0, 6),
9827            "daB should leave cursor at col 6, not 7"
9828        );
9829    }
9830
9831    // Fix #8: diB preserves surrounding newlines on multi-line block.
9832    #[test]
9833    fn dib_preserves_surrounding_newlines() {
9834        let mut e = editor_with("{\n    body\n}\n");
9835        e.jump_cursor(1, 4);
9836        run_keys(&mut e, "diB");
9837        assert_eq!(e.buffer().lines()[0], "{");
9838        assert_eq!(e.buffer().lines()[1], "}");
9839        assert_eq!(e.cursor().0, 1, "cursor should be on the '}}' line");
9840    }
9841
9842    #[test]
9843    fn is_chord_pending_tracks_replace_state() {
9844        let mut e = editor_with("abc\n");
9845        assert!(!e.is_chord_pending());
9846        // Press `r` — engine enters Pending::Replace.
9847        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
9848        assert!(e.is_chord_pending(), "engine should be pending after r");
9849        // Press a char to complete — pending clears.
9850        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
9851        assert!(
9852            !e.is_chord_pending(),
9853            "engine pending should clear after replace"
9854        );
9855    }
9856}