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    /// Stashed start position for the `[` mark on a Change operation.
430    /// Set to `top` before the cut in `run_operator_over_range` (Change
431    /// arm); consumed by `finish_insert_session` on Esc-from-insert
432    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
433    /// rule that `[` = start of change, `]` = last typed char on exit.
434    pub(super) change_mark_start: Option<(usize, usize)>,
435    /// Bounded history of committed `/` / `?` search patterns. Newest
436    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
437    /// avoid unbounded growth on long sessions.
438    pub(super) search_history: Vec<String>,
439    /// Index into `search_history` while the user walks past patterns
440    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
441    /// — typing or backspacing in the prompt resets it so the next
442    /// `Ctrl-P` starts from the most recent entry again.
443    pub(super) search_history_cursor: Option<usize>,
444    /// Wall-clock instant of the last keystroke. Drives the
445    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
446    /// exceeds the configured budget, any pending prefix is cleared
447    /// before the new key dispatches. `None` before the first key.
448    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
449    /// [`crate::types::Host::now`] via `last_input_host_at`. This
450    /// `Instant`-flavoured field stays for snapshot tests that still
451    /// observe it directly.
452    pub(super) last_input_at: Option<std::time::Instant>,
453    /// `Host::now()` reading at the last keystroke. Drives
454    /// `:set timeoutlen` so macro replay / headless drivers stay
455    /// deterministic regardless of wall-clock skew.
456    pub(super) last_input_host_at: Option<core::time::Duration>,
457}
458
459const SEARCH_HISTORY_MAX: usize = 100;
460pub(crate) const CHANGE_LIST_MAX: usize = 100;
461
462/// Active `/` or `?` search prompt. Text mutations drive the textarea's
463/// live search pattern so matches highlight as the user types.
464#[derive(Debug, Clone)]
465pub struct SearchPrompt {
466    pub text: String,
467    pub cursor: usize,
468    pub forward: bool,
469}
470
471#[derive(Debug, Clone)]
472struct InsertSession {
473    count: usize,
474    /// Min/max row visited during this session. Widens on every key.
475    row_min: usize,
476    row_max: usize,
477    /// Snapshot of the full buffer at session entry. Used to diff the
478    /// affected row window at finish without being fooled by cursor
479    /// navigation through rows the user never edited.
480    before_lines: Vec<String>,
481    reason: InsertReason,
482}
483
484#[derive(Debug, Clone)]
485enum InsertReason {
486    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
487    Enter(InsertEntry),
488    /// Entry via `o`/`O` — records OpenLine on Esc.
489    Open { above: bool },
490    /// Entry via an operator's change side-effect. Retro-fills the
491    /// stored last-change's `inserted` field on Esc.
492    AfterChange,
493    /// Entry via `C` (delete to EOL + insert).
494    DeleteToEol,
495    /// Entry via an insert triggered during dot-replay — don't touch
496    /// last_change because the outer replay will restore it.
497    ReplayOnly,
498    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
499    /// every row in `top..=bot`. `col` is the start column for `I`, the
500    /// one-past-block-end column for `A`.
501    BlockEdge { top: usize, bot: usize, col: usize },
502    /// `R` — Replace mode. Each typed char overwrites the cell under
503    /// the cursor instead of inserting; at end-of-line the session
504    /// falls through to insert (same as vim).
505    Replace,
506}
507
508/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
509/// visual selection). `mode` carries which visual flavour to
510/// restore; `anchor` / `cursor` mean different things per flavour:
511///
512/// - `Visual`     — `anchor` is the char-wise visual anchor.
513/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
514///   `anchor.1` is unused.
515/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
516///   sticky vcol that survives j/k clamping.
517#[derive(Debug, Clone, Copy)]
518pub(super) struct LastVisual {
519    pub mode: Mode,
520    pub anchor: (usize, usize),
521    pub cursor: (usize, usize),
522    pub block_vcol: usize,
523}
524
525impl VimState {
526    pub fn public_mode(&self) -> VimMode {
527        match self.mode {
528            Mode::Normal => VimMode::Normal,
529            Mode::Insert => VimMode::Insert,
530            Mode::Visual => VimMode::Visual,
531            Mode::VisualLine => VimMode::VisualLine,
532            Mode::VisualBlock => VimMode::VisualBlock,
533        }
534    }
535
536    pub fn force_normal(&mut self) {
537        self.mode = Mode::Normal;
538        self.pending = Pending::None;
539        self.count = 0;
540        self.insert_session = None;
541    }
542
543    /// Reset every prefix-tracking field so the next keystroke starts
544    /// a fresh sequence. Drives `:set timeoutlen` — when the user
545    /// pauses past the configured budget, [`crate::vim::step`] calls
546    /// this before dispatching the new key.
547    ///
548    /// Resets: `pending`, `count`, `pending_register`,
549    /// `insert_pending_register`. Does NOT touch `mode`,
550    /// `insert_session`, marks, jump list, or visual anchors —
551    /// those aren't part of the in-flight chord.
552    pub(crate) fn clear_pending_prefix(&mut self) {
553        self.pending = Pending::None;
554        self.count = 0;
555        self.pending_register = None;
556        self.insert_pending_register = false;
557    }
558
559    pub fn is_visual(&self) -> bool {
560        matches!(
561            self.mode,
562            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
563        )
564    }
565
566    pub fn is_visual_char(&self) -> bool {
567        self.mode == Mode::Visual
568    }
569
570    pub fn enter_visual(&mut self, anchor: (usize, usize)) {
571        self.visual_anchor = anchor;
572        self.mode = Mode::Visual;
573    }
574
575    /// The pending repeat count (typed digits before a motion/operator),
576    /// or `None` when no digits are pending. Zero is treated as absent.
577    pub(crate) fn pending_count_val(&self) -> Option<u32> {
578        if self.count == 0 {
579            None
580        } else {
581            Some(self.count as u32)
582        }
583    }
584
585    /// `true` when an in-flight chord is awaiting more keys. Inverse of
586    /// `matches!(self.pending, Pending::None)`.
587    pub(crate) fn is_chord_pending(&self) -> bool {
588        !matches!(self.pending, Pending::None)
589    }
590
591    /// Return a single char representing the pending operator, if any.
592    /// Used by host apps (status line "showcmd" area) to display e.g.
593    /// `d`, `y`, `c` while waiting for a motion.
594    pub(crate) fn pending_op_char(&self) -> Option<char> {
595        let op = match &self.pending {
596            Pending::Op { op, .. }
597            | Pending::OpTextObj { op, .. }
598            | Pending::OpG { op, .. }
599            | Pending::OpFind { op, .. } => Some(*op),
600            _ => None,
601        };
602        op.map(|o| match o {
603            Operator::Delete => 'd',
604            Operator::Change => 'c',
605            Operator::Yank => 'y',
606            Operator::Uppercase => 'U',
607            Operator::Lowercase => 'u',
608            Operator::ToggleCase => '~',
609            Operator::Indent => '>',
610            Operator::Outdent => '<',
611            Operator::Fold => 'z',
612            Operator::Reflow => 'q',
613        })
614    }
615}
616
617// ─── Entry point ───────────────────────────────────────────────────────────
618
619/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
620/// live search highlight until the user commits a query. `last_search`
621/// is preserved so an empty `<CR>` can re-run the previous pattern.
622fn enter_search<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, forward: bool) {
623    ed.vim.search_prompt = Some(SearchPrompt {
624        text: String::new(),
625        cursor: 0,
626        forward,
627    });
628    ed.vim.search_history_cursor = None;
629    // 0.0.37: clear via the engine search state (the buffer-side
630    // bridge from 0.0.35 was removed in this patch — the `BufferView`
631    // renderer reads the pattern from `Editor::search_state()`).
632    ed.set_search_pattern(None);
633}
634
635/// Compile `pattern` into a regex and push it onto the migration
636/// buffer's search state. Invalid patterns clear the highlight (the
637/// user is mid-typing a regex like `[` and we don't want to flash an
638/// error).
639fn push_search_pattern<H: crate::types::Host>(
640    ed: &mut Editor<hjkl_buffer::Buffer, H>,
641    pattern: &str,
642) {
643    let compiled = if pattern.is_empty() {
644        None
645    } else {
646        // `:set ignorecase` flips every search pattern to case-insensitive
647        // unless the user already prefixed an explicit `(?i)` / `(?-i)`
648        // (regex crate honours those even when we layer another `(?i)`).
649        // `:set smartcase` re-enables case sensitivity for any pattern
650        // that contains an uppercase letter — matches vim's combined
651        // `ignorecase` + `smartcase` behaviour.
652        let case_insensitive = ed.settings().ignore_case
653            && !(ed.settings().smartcase && pattern.chars().any(|c| c.is_uppercase()));
654        let effective: std::borrow::Cow<'_, str> = if case_insensitive {
655            std::borrow::Cow::Owned(format!("(?i){pattern}"))
656        } else {
657            std::borrow::Cow::Borrowed(pattern)
658        };
659        regex::Regex::new(&effective).ok()
660    };
661    let wrap = ed.settings().wrapscan;
662    // 0.0.37: search FSM lives entirely on Editor — pattern + wrap
663    // policy + per-row match cache. The `Search` trait impl always
664    // wraps; engine code honours `wrap_around` before invoking it.
665    ed.set_search_pattern(compiled);
666    ed.search_state_mut().wrap_around = wrap;
667}
668
669fn step_search_prompt<H: crate::types::Host>(
670    ed: &mut Editor<hjkl_buffer::Buffer, H>,
671    input: Input,
672) -> bool {
673    // Ctrl-P / Ctrl-N (and Up / Down) walk the search history. Handled
674    // before the regular char/backspace branches so `Ctrl-P` doesn't
675    // type a literal `p`.
676    let history_dir = match (input.key, input.ctrl) {
677        (Key::Char('p'), true) | (Key::Up, _) => Some(-1),
678        (Key::Char('n'), true) | (Key::Down, _) => Some(1),
679        _ => None,
680    };
681    if let Some(dir) = history_dir {
682        walk_search_history(ed, dir);
683        return true;
684    }
685    match input.key {
686        Key::Esc => {
687            // Cancel. Drop the prompt but keep the highlighted matches
688            // so `n` / `N` can repeat whatever was typed.
689            let text = ed
690                .vim
691                .search_prompt
692                .take()
693                .map(|p| p.text)
694                .unwrap_or_default();
695            if !text.is_empty() {
696                ed.vim.last_search = Some(text);
697            }
698            ed.vim.search_history_cursor = None;
699        }
700        Key::Enter => {
701            let prompt = ed.vim.search_prompt.take();
702            if let Some(p) = prompt {
703                // Empty `/<CR>` (or `?<CR>`) re-runs the previous search
704                // pattern in the prompt's direction — vim parity.
705                let pattern = if p.text.is_empty() {
706                    ed.vim.last_search.clone()
707                } else {
708                    Some(p.text.clone())
709                };
710                if let Some(pattern) = pattern {
711                    push_search_pattern(ed, &pattern);
712                    let pre = ed.cursor();
713                    if p.forward {
714                        ed.search_advance_forward(true);
715                    } else {
716                        ed.search_advance_backward(true);
717                    }
718                    ed.push_buffer_cursor_to_textarea();
719                    if ed.cursor() != pre {
720                        push_jump(ed, pre);
721                    }
722                    record_search_history(ed, &pattern);
723                    ed.vim.last_search = Some(pattern);
724                    ed.vim.last_search_forward = p.forward;
725                }
726            }
727            ed.vim.search_history_cursor = None;
728        }
729        Key::Backspace => {
730            ed.vim.search_history_cursor = None;
731            let new_text = ed.vim.search_prompt.as_mut().and_then(|p| {
732                if p.text.pop().is_some() {
733                    p.cursor = p.text.chars().count();
734                    Some(p.text.clone())
735                } else {
736                    None
737                }
738            });
739            if let Some(text) = new_text {
740                push_search_pattern(ed, &text);
741            }
742        }
743        Key::Char(c) => {
744            ed.vim.search_history_cursor = None;
745            let new_text = ed.vim.search_prompt.as_mut().map(|p| {
746                p.text.push(c);
747                p.cursor = p.text.chars().count();
748                p.text.clone()
749            });
750            if let Some(text) = new_text {
751                push_search_pattern(ed, &text);
752            }
753        }
754        _ => {}
755    }
756    true
757}
758
759/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
760/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
761/// the ends of the ring; off-ring positions are silently ignored.
762fn walk_change_list<H: crate::types::Host>(
763    ed: &mut Editor<hjkl_buffer::Buffer, H>,
764    dir: isize,
765    count: usize,
766) {
767    if ed.vim.change_list.is_empty() {
768        return;
769    }
770    let len = ed.vim.change_list.len();
771    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
772        (None, -1) => len as isize - 1,
773        (None, 1) => return, // already past the newest entry
774        (Some(i), -1) => i as isize - 1,
775        (Some(i), 1) => i as isize + 1,
776        _ => return,
777    };
778    for _ in 1..count {
779        let next = idx + dir;
780        if next < 0 || next >= len as isize {
781            break;
782        }
783        idx = next;
784    }
785    if idx < 0 || idx >= len as isize {
786        return;
787    }
788    let idx = idx as usize;
789    ed.vim.change_list_cursor = Some(idx);
790    let (row, col) = ed.vim.change_list[idx];
791    ed.jump_cursor(row, col);
792}
793
794/// Push `pattern` onto the search history. Skips the push when the
795/// most recent entry already matches (consecutive dedupe) and trims
796/// the oldest entries beyond [`SEARCH_HISTORY_MAX`].
797fn record_search_history<H: crate::types::Host>(
798    ed: &mut Editor<hjkl_buffer::Buffer, H>,
799    pattern: &str,
800) {
801    if pattern.is_empty() {
802        return;
803    }
804    if ed.vim.search_history.last().map(String::as_str) == Some(pattern) {
805        return;
806    }
807    ed.vim.search_history.push(pattern.to_string());
808    let len = ed.vim.search_history.len();
809    if len > SEARCH_HISTORY_MAX {
810        ed.vim.search_history.drain(0..len - SEARCH_HISTORY_MAX);
811    }
812}
813
814/// Replace the prompt text with the next entry in the search history.
815/// `dir = -1` walks toward older entries (`Ctrl-P` / `Up`); `dir = 1`
816/// toward newer ones (`Ctrl-N` / `Down`). Stops at the ends of the
817/// history; the user can keep pressing the key without effect rather
818/// than wrapping around.
819fn walk_search_history<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, dir: isize) {
820    if ed.vim.search_history.is_empty() || ed.vim.search_prompt.is_none() {
821        return;
822    }
823    let len = ed.vim.search_history.len();
824    let next_idx = match (ed.vim.search_history_cursor, dir) {
825        (None, -1) => Some(len - 1),
826        (None, 1) => return, // already past the newest entry
827        (Some(i), -1) => i.checked_sub(1),
828        (Some(i), 1) if i + 1 < len => Some(i + 1),
829        _ => None,
830    };
831    let Some(idx) = next_idx else {
832        return;
833    };
834    ed.vim.search_history_cursor = Some(idx);
835    let text = ed.vim.search_history[idx].clone();
836    if let Some(prompt) = ed.vim.search_prompt.as_mut() {
837        prompt.cursor = text.chars().count();
838        prompt.text = text.clone();
839    }
840    push_search_pattern(ed, &text);
841}
842
843pub fn step<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, input: Input) -> bool {
844    // Phase 7f port: any cursor / content the host changed between
845    // steps (mouse jumps, paste, programmatic set_content, …) needs
846    // to land in the migration buffer before motion handlers that
847    // call into `Buffer::move_*` see a stale state.
848    ed.sync_buffer_content_from_textarea();
849    // `:set timeoutlen` — if the user paused longer than the budget
850    // since the last keystroke and a chord is in flight, drop the
851    // pending prefix so the new key starts fresh. 0.0.29 (Patch B):
852    // chord-timeout math now reads `Host::now()` so macro replay /
853    // headless drivers stay deterministic. The legacy
854    // `Instant::now()`-backed `last_input_at` field is retained for
855    // snapshot tests that still observe it.
856    let now = std::time::Instant::now();
857    let host_now = ed.host.now();
858    let timed_out = match ed.vim.last_input_host_at {
859        Some(prev) => host_now.saturating_sub(prev) > ed.settings.timeout_len,
860        None => false,
861    };
862    if timed_out {
863        let chord_in_flight = !matches!(ed.vim.pending, Pending::None)
864            || ed.vim.count != 0
865            || ed.vim.pending_register.is_some()
866            || ed.vim.insert_pending_register;
867        if chord_in_flight {
868            ed.vim.clear_pending_prefix();
869        }
870    }
871    ed.vim.last_input_at = Some(now);
872    ed.vim.last_input_host_at = Some(host_now);
873    // Macro stop: a bare `q` ends an active recording before any
874    // other handler sees the key (so `q` itself doesn't get
875    // recorded). Replays don't trigger this — they finish on their
876    // own when the captured key list runs out.
877    if ed.vim.recording_macro.is_some()
878        && !ed.vim.replaying_macro
879        && matches!(ed.vim.pending, Pending::None)
880        && ed.vim.mode != Mode::Insert
881        && input.key == Key::Char('q')
882        && !input.ctrl
883        && !input.alt
884    {
885        let reg = ed.vim.recording_macro.take().unwrap();
886        let keys = std::mem::take(&mut ed.vim.recording_keys);
887        let text = crate::input::encode_macro(&keys);
888        ed.set_named_register_text(reg.to_ascii_lowercase(), text);
889        return true;
890    }
891    // Search prompt eats all keys until Enter / Esc.
892    if ed.vim.search_prompt.is_some() {
893        return step_search_prompt(ed, input);
894    }
895    // Snapshot whether this step is consuming the register-name half
896    // of a macro chord. The recorder hook below uses this to skip
897    // the chord's bookkeeping keys (`q{reg}` open and `@{reg}` open).
898    let pending_was_macro_chord = matches!(
899        ed.vim.pending,
900        Pending::RecordMacroTarget | Pending::PlayMacroTarget { .. }
901    );
902    let was_insert = ed.vim.mode == Mode::Insert;
903    // Capture pre-step visual snapshot so a visual → normal transition
904    // can stash the selection for `gv` re-entry.
905    let pre_visual_snapshot = match ed.vim.mode {
906        Mode::Visual => Some(LastVisual {
907            mode: Mode::Visual,
908            anchor: ed.vim.visual_anchor,
909            cursor: ed.cursor(),
910            block_vcol: 0,
911        }),
912        Mode::VisualLine => Some(LastVisual {
913            mode: Mode::VisualLine,
914            anchor: (ed.vim.visual_line_anchor, 0),
915            cursor: ed.cursor(),
916            block_vcol: 0,
917        }),
918        Mode::VisualBlock => Some(LastVisual {
919            mode: Mode::VisualBlock,
920            anchor: ed.vim.block_anchor,
921            cursor: ed.cursor(),
922            block_vcol: ed.vim.block_vcol,
923        }),
924        _ => None,
925    };
926    let consumed = match ed.vim.mode {
927        Mode::Insert => step_insert(ed, input),
928        _ => step_normal(ed, input),
929    };
930    if let Some(snap) = pre_visual_snapshot
931        && !matches!(
932            ed.vim.mode,
933            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
934        )
935    {
936        // Set the `<` / `>` marks so ex commands like `:'<,'>sort` resolve
937        // their range. Per `:h v_:` the mark positions depend on the visual
938        // submode:
939        //
940        // * Visual (charwise): position-ordered. `<` = lower (row, col),
941        //   `>` = higher. Tuple comparison works because the selection is
942        //   contiguous text.
943        // * VisualLine: `<` snaps to (top_row, 0), `>` snaps to
944        //   (bot_row, last_col_of_that_line). Vim treats linewise
945        //   selections as full lines so the column components are
946        //   normalised to line edges.
947        // * VisualBlock: corners. `<` = (min_row, min_col),
948        //   `>` = (max_row, max_col) computed independently — the cursor
949        //   may sit on any corner so tuple ordering would mis-place the
950        //   columns when the selection grew leftward.
951        let (lo, hi) = match snap.mode {
952            Mode::Visual => {
953                if snap.anchor <= snap.cursor {
954                    (snap.anchor, snap.cursor)
955                } else {
956                    (snap.cursor, snap.anchor)
957                }
958            }
959            Mode::VisualLine => {
960                let r_lo = snap.anchor.0.min(snap.cursor.0);
961                let r_hi = snap.anchor.0.max(snap.cursor.0);
962                let last_col = ed
963                    .buffer()
964                    .lines()
965                    .get(r_hi)
966                    .map(|l| l.chars().count().saturating_sub(1))
967                    .unwrap_or(0);
968                ((r_lo, 0), (r_hi, last_col))
969            }
970            Mode::VisualBlock => {
971                let (r1, c1) = snap.anchor;
972                let (r2, c2) = snap.cursor;
973                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
974            }
975            _ => {
976                // Defensive: pre_visual_snapshot only stores visual modes,
977                // so this arm is unreachable in practice.
978                if snap.anchor <= snap.cursor {
979                    (snap.anchor, snap.cursor)
980                } else {
981                    (snap.cursor, snap.anchor)
982                }
983            }
984        };
985        ed.set_mark('<', lo);
986        ed.set_mark('>', hi);
987        ed.vim.last_visual = Some(snap);
988    }
989    // Ctrl-o in insert mode queues a single normal-mode command; once
990    // that command finishes (pending cleared, not in operator / visual),
991    // drop back to insert without replaying the insert session.
992    if !was_insert
993        && ed.vim.one_shot_normal
994        && ed.vim.mode == Mode::Normal
995        && matches!(ed.vim.pending, Pending::None)
996    {
997        ed.vim.one_shot_normal = false;
998        ed.vim.mode = Mode::Insert;
999    }
1000    // Phase 7c: every step ends with the migration buffer mirroring
1001    // the textarea's content + cursor + viewport. Edit-emitting paths
1002    // (insert_char, delete_char, …) inside `step_insert` /
1003    // `step_normal` thus all flow through here without each call
1004    // site needing to remember to sync.
1005    ed.sync_buffer_content_from_textarea();
1006    // Scroll viewport to keep cursor on-screen, honouring the same
1007    // `SCROLLOFF` margin the mouse-driven scroll uses. Skip when
1008    // the user just pinned the viewport with `zz` / `zt` / `zb`.
1009    if !ed.vim.viewport_pinned {
1010        ed.ensure_cursor_in_scrolloff();
1011    }
1012    ed.vim.viewport_pinned = false;
1013    // Recorder hook: append every consumed input to the active
1014    // recording (if any) so the replay reproduces the same sequence.
1015    // Skip the chord that started the recording (`q{reg}` open) and
1016    // skip during replay so a macro doesn't capture itself.
1017    if ed.vim.recording_macro.is_some()
1018        && !ed.vim.replaying_macro
1019        && input.key != Key::Char('q')
1020        && !pending_was_macro_chord
1021    {
1022        ed.vim.recording_keys.push(input);
1023    }
1024    consumed
1025}
1026
1027// ─── Insert mode ───────────────────────────────────────────────────────────
1028
1029fn step_insert<H: crate::types::Host>(
1030    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1031    input: Input,
1032) -> bool {
1033    // `Ctrl-R {reg}` paste — the previous keystroke armed the wait. Any
1034    // non-char key cancels (matches vim, which beeps on selectors like
1035    // Esc and re-emits the literal text otherwise).
1036    if ed.vim.insert_pending_register {
1037        ed.vim.insert_pending_register = false;
1038        if let Key::Char(c) = input.key
1039            && !input.ctrl
1040        {
1041            insert_register_text(ed, c);
1042        }
1043        return true;
1044    }
1045
1046    if input.key == Key::Esc {
1047        finish_insert_session(ed);
1048        ed.vim.mode = Mode::Normal;
1049        // Vim convention: pull the cursor back one cell on exit when
1050        // possible. Sticky column then mirrors the *visible* post-Back
1051        // column so the next vertical motion lands where the user
1052        // actually sees the cursor — not one cell to the right.
1053        let col = ed.cursor().1;
1054        if col > 0 {
1055            crate::motions::move_left(&mut ed.buffer, 1);
1056            ed.push_buffer_cursor_to_textarea();
1057        }
1058        ed.sticky_col = Some(ed.cursor().1);
1059        return true;
1060    }
1061
1062    // Ctrl-prefixed insert-mode shortcuts.
1063    if input.ctrl {
1064        match input.key {
1065            Key::Char('w') => {
1066                use hjkl_buffer::{Edit, MotionKind};
1067                ed.sync_buffer_content_from_textarea();
1068                let cursor = buf_cursor_pos(&ed.buffer);
1069                if cursor.row == 0 && cursor.col == 0 {
1070                    return true;
1071                }
1072                // Find the previous word start by stepping the buffer
1073                // cursor (vim `b` semantics) and snapshot it.
1074                crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
1075                let word_start = buf_cursor_pos(&ed.buffer);
1076                if word_start == cursor {
1077                    return true;
1078                }
1079                buf_set_cursor_pos(&mut ed.buffer, cursor);
1080                ed.mutate_edit(Edit::DeleteRange {
1081                    start: word_start,
1082                    end: cursor,
1083                    kind: MotionKind::Char,
1084                });
1085                ed.push_buffer_cursor_to_textarea();
1086                return true;
1087            }
1088            Key::Char('u') => {
1089                use hjkl_buffer::{Edit, MotionKind, Position};
1090                ed.sync_buffer_content_from_textarea();
1091                let cursor = buf_cursor_pos(&ed.buffer);
1092                if cursor.col > 0 {
1093                    ed.mutate_edit(Edit::DeleteRange {
1094                        start: Position::new(cursor.row, 0),
1095                        end: cursor,
1096                        kind: MotionKind::Char,
1097                    });
1098                    ed.push_buffer_cursor_to_textarea();
1099                }
1100                return true;
1101            }
1102            Key::Char('h') => {
1103                use hjkl_buffer::{Edit, MotionKind, Position};
1104                ed.sync_buffer_content_from_textarea();
1105                let cursor = buf_cursor_pos(&ed.buffer);
1106                if cursor.col > 0 {
1107                    ed.mutate_edit(Edit::DeleteRange {
1108                        start: Position::new(cursor.row, cursor.col - 1),
1109                        end: cursor,
1110                        kind: MotionKind::Char,
1111                    });
1112                } else if cursor.row > 0 {
1113                    let prev_row = cursor.row - 1;
1114                    let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1115                    ed.mutate_edit(Edit::JoinLines {
1116                        row: prev_row,
1117                        count: 1,
1118                        with_space: false,
1119                    });
1120                    buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1121                }
1122                ed.push_buffer_cursor_to_textarea();
1123                return true;
1124            }
1125            Key::Char('o') => {
1126                // One-shot normal: leave insert mode for the next full
1127                // normal-mode command, then come back.
1128                ed.vim.one_shot_normal = true;
1129                ed.vim.mode = Mode::Normal;
1130                return true;
1131            }
1132            Key::Char('r') => {
1133                // Arm the register selector — the next typed char picks
1134                // a slot and pastes its text inline.
1135                ed.vim.insert_pending_register = true;
1136                return true;
1137            }
1138            Key::Char('t') => {
1139                // Insert-mode indent: prepend one shiftwidth to the
1140                // current line's leading whitespace. Cursor shifts
1141                // right by the same amount so the user keeps typing
1142                // at their logical position.
1143                let (row, col) = ed.cursor();
1144                let sw = ed.settings().shiftwidth;
1145                indent_rows(ed, row, row, 1);
1146                ed.jump_cursor(row, col + sw);
1147                return true;
1148            }
1149            Key::Char('d') => {
1150                // Insert-mode outdent: drop up to one shiftwidth of
1151                // leading whitespace. Cursor shifts left by the amount
1152                // actually stripped.
1153                let (row, col) = ed.cursor();
1154                let before_len = buf_line_bytes(&ed.buffer, row);
1155                outdent_rows(ed, row, row, 1);
1156                let after_len = buf_line_bytes(&ed.buffer, row);
1157                let stripped = before_len.saturating_sub(after_len);
1158                let new_col = col.saturating_sub(stripped);
1159                ed.jump_cursor(row, new_col);
1160                return true;
1161            }
1162            _ => {}
1163        }
1164    }
1165
1166    // Widen the session's visited row window *before* handling the key
1167    // so navigation-only keystrokes (arrow keys) still extend the range.
1168    let (row, _) = ed.cursor();
1169    if let Some(ref mut session) = ed.vim.insert_session {
1170        session.row_min = session.row_min.min(row);
1171        session.row_max = session.row_max.max(row);
1172    }
1173    let mutated = handle_insert_key(ed, input);
1174    if mutated {
1175        ed.mark_content_dirty();
1176        let (row, _) = ed.cursor();
1177        if let Some(ref mut session) = ed.vim.insert_session {
1178            session.row_min = session.row_min.min(row);
1179            session.row_max = session.row_max.max(row);
1180        }
1181    }
1182    true
1183}
1184
1185/// `Ctrl-R {reg}` body — insert the named register's contents at the
1186/// cursor as charwise text. Embedded newlines split lines naturally via
1187/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
1188/// stray keystrokes don't mutate the buffer.
1189fn insert_register_text<H: crate::types::Host>(
1190    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1191    selector: char,
1192) {
1193    use hjkl_buffer::Edit;
1194    let text = match ed.registers().read(selector) {
1195        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
1196        _ => return,
1197    };
1198    ed.sync_buffer_content_from_textarea();
1199    let cursor = buf_cursor_pos(&ed.buffer);
1200    ed.mutate_edit(Edit::InsertStr {
1201        at: cursor,
1202        text: text.clone(),
1203    });
1204    // Advance cursor to the end of the inserted payload — multi-line
1205    // pastes land on the last inserted row at the post-text column.
1206    let mut row = cursor.row;
1207    let mut col = cursor.col;
1208    for ch in text.chars() {
1209        if ch == '\n' {
1210            row += 1;
1211            col = 0;
1212        } else {
1213            col += 1;
1214        }
1215    }
1216    buf_set_cursor_rc(&mut ed.buffer, row, col);
1217    ed.push_buffer_cursor_to_textarea();
1218    ed.mark_content_dirty();
1219    if let Some(ref mut session) = ed.vim.insert_session {
1220        session.row_min = session.row_min.min(row);
1221        session.row_max = session.row_max.max(row);
1222    }
1223}
1224
1225/// Compute the indent string to insert at the start of a new line
1226/// after Enter is pressed at `cursor`. Walks the smartindent rules:
1227///
1228/// - autoindent off → empty string
1229/// - autoindent on  → copy prev line's leading whitespace
1230/// - smartindent on → bump one `shiftwidth` if prev line's last
1231///   non-whitespace char is `{` / `(` / `[`
1232///
1233/// Indent unit (used for the smartindent bump):
1234///
1235/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
1236/// - `expandtab` → `shiftwidth` spaces
1237/// - `!expandtab` → one literal `\t`
1238///
1239/// This is the placeholder for a future tree-sitter indent provider:
1240/// when a language has an `indents.scm` query, the engine will route
1241/// the same call through that provider and only fall back to this
1242/// heuristic when no query matches.
1243pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
1244    if !settings.autoindent {
1245        return String::new();
1246    }
1247    // Copy the prev line's leading whitespace (autoindent base).
1248    let base: String = prev_line
1249        .chars()
1250        .take_while(|c| *c == ' ' || *c == '\t')
1251        .collect();
1252
1253    if settings.smartindent {
1254        // If the last non-whitespace character is an open bracket, bump
1255        // indent by one unit. This is the heuristic seam: a tree-sitter
1256        // `indents.scm` provider would replace this branch.
1257        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
1258        if matches!(last_non_ws, Some('{' | '(' | '[')) {
1259            let unit = if settings.expandtab {
1260                if settings.softtabstop > 0 {
1261                    " ".repeat(settings.softtabstop)
1262                } else {
1263                    " ".repeat(settings.shiftwidth)
1264                }
1265            } else {
1266                "\t".to_string()
1267            };
1268            return format!("{base}{unit}");
1269        }
1270    }
1271
1272    base
1273}
1274
1275/// Strip one indent unit from the beginning of `line` and insert `ch`
1276/// instead. Returns `true` when it consumed the keystroke (dedent +
1277/// insert), `false` when the caller should insert normally.
1278///
1279/// Dedent fires when:
1280///   - `smartindent` is on
1281///   - `ch` is `}` / `)` / `]`
1282///   - all bytes BEFORE the cursor on the current line are whitespace
1283///   - there is at least one full indent unit of leading whitespace
1284fn try_dedent_close_bracket<H: crate::types::Host>(
1285    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1286    cursor: hjkl_buffer::Position,
1287    ch: char,
1288) -> bool {
1289    use hjkl_buffer::{Edit, MotionKind, Position};
1290
1291    if !ed.settings.smartindent {
1292        return false;
1293    }
1294    if !matches!(ch, '}' | ')' | ']') {
1295        return false;
1296    }
1297
1298    let line = match buf_line(&ed.buffer, cursor.row) {
1299        Some(l) => l.to_string(),
1300        None => return false,
1301    };
1302
1303    // All chars before cursor must be whitespace.
1304    let before: String = line.chars().take(cursor.col).collect();
1305    if !before.chars().all(|c| c == ' ' || c == '\t') {
1306        return false;
1307    }
1308    if before.is_empty() {
1309        // Nothing to strip — just insert normally (cursor at col 0).
1310        return false;
1311    }
1312
1313    // Compute indent unit.
1314    let unit_len: usize = if ed.settings.expandtab {
1315        if ed.settings.softtabstop > 0 {
1316            ed.settings.softtabstop
1317        } else {
1318            ed.settings.shiftwidth
1319        }
1320    } else {
1321        // Tab: one literal tab character.
1322        1
1323    };
1324
1325    // Check there's at least one full unit to strip.
1326    let strip_len = if ed.settings.expandtab {
1327        // Count leading spaces; need at least `unit_len`.
1328        let spaces = before.chars().filter(|c| *c == ' ').count();
1329        if spaces < unit_len {
1330            return false;
1331        }
1332        unit_len
1333    } else {
1334        // noexpandtab: strip one leading tab.
1335        if !before.starts_with('\t') {
1336            return false;
1337        }
1338        1
1339    };
1340
1341    // Delete the leading `strip_len` chars of the current line.
1342    ed.mutate_edit(Edit::DeleteRange {
1343        start: Position::new(cursor.row, 0),
1344        end: Position::new(cursor.row, strip_len),
1345        kind: MotionKind::Char,
1346    });
1347    // Insert the close bracket at column 0 (after the delete the cursor
1348    // is still positioned at the end of the remaining whitespace; the
1349    // delete moved the text so the cursor is now at col = before.len() -
1350    // strip_len).
1351    let new_col = cursor.col.saturating_sub(strip_len);
1352    ed.mutate_edit(Edit::InsertChar {
1353        at: Position::new(cursor.row, new_col),
1354        ch,
1355    });
1356    true
1357}
1358
1359/// Insert-mode key dispatcher backed by the migration buffer. Replaces
1360/// the historical `textarea.input(input)` call so the textarea field
1361/// can be ripped at the end of Phase 7f. PageUp / PageDown still flow
1362/// through the textarea (they're scroll-only with no buffer side
1363/// effect); every other navigation + edit key lands on `Buffer`.
1364/// Returns true when the buffer mutated.
1365fn handle_insert_key<H: crate::types::Host>(
1366    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1367    input: Input,
1368) -> bool {
1369    use hjkl_buffer::{Edit, MotionKind, Position};
1370    ed.sync_buffer_content_from_textarea();
1371    let cursor = buf_cursor_pos(&ed.buffer);
1372    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1373    // Replace mode: overstrike the cell at the cursor instead of
1374    // inserting. At end-of-line, fall through to plain insert (vim
1375    // appends past the line).
1376    let in_replace = matches!(
1377        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1378        Some(InsertReason::Replace)
1379    );
1380    let mutated = match input.key {
1381        Key::Char(c) if in_replace && cursor.col < line_chars => {
1382            ed.mutate_edit(Edit::DeleteRange {
1383                start: cursor,
1384                end: Position::new(cursor.row, cursor.col + 1),
1385                kind: MotionKind::Char,
1386            });
1387            ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1388            true
1389        }
1390        Key::Char(c) => {
1391            if !try_dedent_close_bracket(ed, cursor, c) {
1392                ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1393            }
1394            true
1395        }
1396        Key::Enter => {
1397            let prev_line = buf_line(&ed.buffer, cursor.row)
1398                .unwrap_or_default()
1399                .to_string();
1400            let indent = compute_enter_indent(&ed.settings, &prev_line);
1401            let text = format!("\n{indent}");
1402            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1403            true
1404        }
1405        Key::Tab => {
1406            if ed.settings.expandtab {
1407                // With softtabstop > 0, fill to the next sts boundary.
1408                // Otherwise insert a full tabstop run.
1409                let sts = ed.settings.softtabstop;
1410                let n = if sts > 0 {
1411                    sts - (cursor.col % sts)
1412                } else {
1413                    ed.settings.tabstop.max(1)
1414                };
1415                ed.mutate_edit(Edit::InsertStr {
1416                    at: cursor,
1417                    text: " ".repeat(n),
1418                });
1419            } else {
1420                ed.mutate_edit(Edit::InsertChar {
1421                    at: cursor,
1422                    ch: '\t',
1423                });
1424            }
1425            true
1426        }
1427        Key::Backspace => {
1428            // Softtabstop: if the N chars before the cursor are all spaces
1429            // and the cursor sits on an sts-aligned column, delete the run
1430            // as a single unit (vim's "backspace deletes a soft tab" feel).
1431            let sts = ed.settings.softtabstop;
1432            if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
1433                let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1434                let chars: Vec<char> = line.chars().collect();
1435                let run_start = cursor.col - sts;
1436                if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
1437                    ed.mutate_edit(Edit::DeleteRange {
1438                        start: Position::new(cursor.row, run_start),
1439                        end: cursor,
1440                        kind: MotionKind::Char,
1441                    });
1442                    return true;
1443                }
1444            }
1445            if cursor.col > 0 {
1446                ed.mutate_edit(Edit::DeleteRange {
1447                    start: Position::new(cursor.row, cursor.col - 1),
1448                    end: cursor,
1449                    kind: MotionKind::Char,
1450                });
1451                true
1452            } else if cursor.row > 0 {
1453                let prev_row = cursor.row - 1;
1454                let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1455                ed.mutate_edit(Edit::JoinLines {
1456                    row: prev_row,
1457                    count: 1,
1458                    with_space: false,
1459                });
1460                buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1461                true
1462            } else {
1463                false
1464            }
1465        }
1466        Key::Delete => {
1467            if cursor.col < line_chars {
1468                ed.mutate_edit(Edit::DeleteRange {
1469                    start: cursor,
1470                    end: Position::new(cursor.row, cursor.col + 1),
1471                    kind: MotionKind::Char,
1472                });
1473                true
1474            } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
1475                ed.mutate_edit(Edit::JoinLines {
1476                    row: cursor.row,
1477                    count: 1,
1478                    with_space: false,
1479                });
1480                buf_set_cursor_pos(&mut ed.buffer, cursor);
1481                true
1482            } else {
1483                false
1484            }
1485        }
1486        Key::Left => {
1487            crate::motions::move_left(&mut ed.buffer, 1);
1488            break_undo_group_in_insert(ed);
1489            false
1490        }
1491        Key::Right => {
1492            // Insert mode allows the cursor one past the last char so the
1493            // next typed letter appends — use the operator-context move.
1494            crate::motions::move_right_to_end(&mut ed.buffer, 1);
1495            break_undo_group_in_insert(ed);
1496            false
1497        }
1498        Key::Up => {
1499            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1500            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1501            break_undo_group_in_insert(ed);
1502            false
1503        }
1504        Key::Down => {
1505            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1506            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1507            break_undo_group_in_insert(ed);
1508            false
1509        }
1510        Key::Home => {
1511            crate::motions::move_line_start(&mut ed.buffer);
1512            break_undo_group_in_insert(ed);
1513            false
1514        }
1515        Key::End => {
1516            crate::motions::move_line_end(&mut ed.buffer);
1517            break_undo_group_in_insert(ed);
1518            false
1519        }
1520        Key::PageUp => {
1521            // Vim default: PageUp scrolls a full window up, cursor
1522            // tracks. Reuse the Ctrl-b scroll helper so behavior
1523            // matches the normal-mode equivalent.
1524            let rows = viewport_full_rows(ed, 1) as isize;
1525            scroll_cursor_rows(ed, -rows);
1526            return false;
1527        }
1528        Key::PageDown => {
1529            let rows = viewport_full_rows(ed, 1) as isize;
1530            scroll_cursor_rows(ed, rows);
1531            return false;
1532        }
1533        // F-keys, mouse scroll, copy/cut/paste virtual keys, Null —
1534        // no insert-mode behaviour.
1535        _ => false,
1536    };
1537    ed.push_buffer_cursor_to_textarea();
1538    mutated
1539}
1540
1541fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1542    let Some(session) = ed.vim.insert_session.take() else {
1543        return;
1544    };
1545    let lines = buf_lines_to_vec(&ed.buffer);
1546    // Clamp both slices to their respective bounds — the buffer may have
1547    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1548    // the session, so row_max can overshoot either side.
1549    let after_end = session.row_max.min(lines.len().saturating_sub(1));
1550    let before_end = session
1551        .row_max
1552        .min(session.before_lines.len().saturating_sub(1));
1553    let before = if before_end >= session.row_min && session.row_min < session.before_lines.len() {
1554        session.before_lines[session.row_min..=before_end].join("\n")
1555    } else {
1556        String::new()
1557    };
1558    let after = if after_end >= session.row_min && session.row_min < lines.len() {
1559        lines[session.row_min..=after_end].join("\n")
1560    } else {
1561        String::new()
1562    };
1563    let inserted = extract_inserted(&before, &after);
1564    if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
1565        use hjkl_buffer::{Edit, Position};
1566        for _ in 0..session.count - 1 {
1567            let (row, col) = ed.cursor();
1568            ed.mutate_edit(Edit::InsertStr {
1569                at: Position::new(row, col),
1570                text: inserted.clone(),
1571            });
1572        }
1573    }
1574    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1575        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1576            use hjkl_buffer::{Edit, Position};
1577            for r in (top + 1)..=bot {
1578                let line_len = buf_line_chars(&ed.buffer, r);
1579                if col > line_len {
1580                    // Pad short rows with spaces up to the block edge
1581                    // column so the inserted text lands at `col`.
1582                    let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1583                    ed.mutate_edit(Edit::InsertStr {
1584                        at: Position::new(r, line_len),
1585                        text: pad,
1586                    });
1587                }
1588                ed.mutate_edit(Edit::InsertStr {
1589                    at: Position::new(r, col),
1590                    text: inserted.clone(),
1591                });
1592            }
1593            buf_set_cursor_rc(&mut ed.buffer, top, col);
1594            ed.push_buffer_cursor_to_textarea();
1595        }
1596        return;
1597    }
1598    if ed.vim.replaying {
1599        return;
1600    }
1601    match session.reason {
1602        InsertReason::Enter(entry) => {
1603            ed.vim.last_change = Some(LastChange::InsertAt {
1604                entry,
1605                inserted,
1606                count: session.count,
1607            });
1608        }
1609        InsertReason::Open { above } => {
1610            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1611        }
1612        InsertReason::AfterChange => {
1613            if let Some(
1614                LastChange::OpMotion { inserted: ins, .. }
1615                | LastChange::OpTextObj { inserted: ins, .. }
1616                | LastChange::LineOp { inserted: ins, .. },
1617            ) = ed.vim.last_change.as_mut()
1618            {
1619                *ins = Some(inserted);
1620            }
1621            // Vim `:h '[` / `:h ']`: on change, `[` = start of the
1622            // changed range (stashed before the cut), `]` = the cursor
1623            // at Esc time (last inserted char, before the step-back).
1624            // When nothing was typed cursor still sits at the change
1625            // start, satisfying vim's "both at start" parity for `c<m><Esc>`.
1626            if let Some(start) = ed.vim.change_mark_start.take() {
1627                let end = ed.cursor();
1628                ed.set_mark('[', start);
1629                ed.set_mark(']', end);
1630            }
1631        }
1632        InsertReason::DeleteToEol => {
1633            ed.vim.last_change = Some(LastChange::DeleteToEol {
1634                inserted: Some(inserted),
1635            });
1636        }
1637        InsertReason::ReplayOnly => {}
1638        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1639        InsertReason::Replace => {
1640            // Record overstrike sessions as DeleteToEol-style — replay
1641            // re-types each character but doesn't try to restore prior
1642            // content (vim's R has its own replay path; this is the
1643            // pragmatic approximation).
1644            ed.vim.last_change = Some(LastChange::DeleteToEol {
1645                inserted: Some(inserted),
1646            });
1647        }
1648    }
1649}
1650
1651fn begin_insert<H: crate::types::Host>(
1652    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1653    count: usize,
1654    reason: InsertReason,
1655) {
1656    let record = !matches!(reason, InsertReason::ReplayOnly);
1657    if record {
1658        ed.push_undo();
1659    }
1660    let reason = if ed.vim.replaying {
1661        InsertReason::ReplayOnly
1662    } else {
1663        reason
1664    };
1665    let (row, _) = ed.cursor();
1666    ed.vim.insert_session = Some(InsertSession {
1667        count,
1668        row_min: row,
1669        row_max: row,
1670        before_lines: buf_lines_to_vec(&ed.buffer),
1671        reason,
1672    });
1673    ed.vim.mode = Mode::Insert;
1674}
1675
1676/// `:set undobreak` semantics for insert-mode motions. When the
1677/// toggle is on, a non-character keystroke that moves the cursor
1678/// (arrow keys, Home/End, mouse click) ends the current undo group
1679/// and starts a new one mid-session. After this, a subsequent `u`
1680/// in normal mode reverts only the post-break run, leaving the
1681/// pre-break edits in place — matching vim's behaviour.
1682///
1683/// Implementation: snapshot the current buffer onto the undo stack
1684/// (the new break point) and reset the active `InsertSession`'s
1685/// `before_lines` so `finish_insert_session`'s diff window only
1686/// captures the post-break run for `last_change` / dot-repeat.
1687///
1688/// During replay we skip the break — replay shouldn't pollute the
1689/// undo stack with intra-replay snapshots.
1690pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1691    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1692) {
1693    if !ed.settings.undo_break_on_motion {
1694        return;
1695    }
1696    if ed.vim.replaying {
1697        return;
1698    }
1699    if ed.vim.insert_session.is_none() {
1700        return;
1701    }
1702    ed.push_undo();
1703    let n = crate::types::Query::line_count(&ed.buffer) as usize;
1704    let mut lines: Vec<String> = Vec::with_capacity(n);
1705    for r in 0..n {
1706        lines.push(crate::types::Query::line(&ed.buffer, r as u32).to_string());
1707    }
1708    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1709    if let Some(ref mut session) = ed.vim.insert_session {
1710        session.before_lines = lines;
1711        session.row_min = row;
1712        session.row_max = row;
1713    }
1714}
1715
1716// ─── Normal / Visual / Operator-pending dispatcher ─────────────────────────
1717
1718fn step_normal<H: crate::types::Host>(
1719    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1720    input: Input,
1721) -> bool {
1722    // Consume digits first — except '0' at start of count (that's LineStart).
1723    if let Key::Char(d @ '0'..='9') = input.key
1724        && !input.ctrl
1725        && !input.alt
1726        && !matches!(
1727            ed.vim.pending,
1728            Pending::Replace
1729                | Pending::Find { .. }
1730                | Pending::OpFind { .. }
1731                | Pending::VisualTextObj { .. }
1732        )
1733        && (d != '0' || ed.vim.count > 0)
1734    {
1735        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
1736        return true;
1737    }
1738
1739    // Handle pending two-key sequences first.
1740    match std::mem::take(&mut ed.vim.pending) {
1741        Pending::Replace => return handle_replace(ed, input),
1742        Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
1743        Pending::OpFind {
1744            op,
1745            count1,
1746            forward,
1747            till,
1748        } => return handle_op_find_target(ed, input, op, count1, forward, till),
1749        Pending::G => return handle_after_g(ed, input),
1750        Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
1751        Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
1752        Pending::OpTextObj { op, count1, inner } => {
1753            return handle_text_object(ed, input, op, count1, inner);
1754        }
1755        Pending::VisualTextObj { inner } => {
1756            return handle_visual_text_obj(ed, input, inner);
1757        }
1758        Pending::Z => return handle_after_z(ed, input),
1759        Pending::SetMark => return handle_set_mark(ed, input),
1760        Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
1761        Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
1762        Pending::SelectRegister => return handle_select_register(ed, input),
1763        Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
1764        Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
1765        Pending::None => {}
1766    }
1767
1768    let count = take_count(&mut ed.vim);
1769
1770    // Common normal / visual keys.
1771    match input.key {
1772        Key::Esc => {
1773            ed.vim.force_normal();
1774            return true;
1775        }
1776        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1777            ed.vim.visual_anchor = ed.cursor();
1778            ed.vim.mode = Mode::Visual;
1779            return true;
1780        }
1781        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1782            let (row, _) = ed.cursor();
1783            ed.vim.visual_line_anchor = row;
1784            ed.vim.mode = Mode::VisualLine;
1785            return true;
1786        }
1787        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::VisualLine => {
1788            ed.vim.visual_anchor = ed.cursor();
1789            ed.vim.mode = Mode::Visual;
1790            return true;
1791        }
1792        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Visual => {
1793            let (row, _) = ed.cursor();
1794            ed.vim.visual_line_anchor = row;
1795            ed.vim.mode = Mode::VisualLine;
1796            return true;
1797        }
1798        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::Normal => {
1799            let cur = ed.cursor();
1800            ed.vim.block_anchor = cur;
1801            ed.vim.block_vcol = cur.1;
1802            ed.vim.mode = Mode::VisualBlock;
1803            return true;
1804        }
1805        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::VisualBlock => {
1806            // Second Ctrl-v exits block mode back to Normal.
1807            ed.vim.mode = Mode::Normal;
1808            return true;
1809        }
1810        // `o` in visual modes — swap anchor and cursor so the user
1811        // can extend the other end of the selection.
1812        Key::Char('o') if !input.ctrl => match ed.vim.mode {
1813            Mode::Visual => {
1814                let cur = ed.cursor();
1815                let anchor = ed.vim.visual_anchor;
1816                ed.vim.visual_anchor = cur;
1817                ed.jump_cursor(anchor.0, anchor.1);
1818                return true;
1819            }
1820            Mode::VisualLine => {
1821                let cur_row = ed.cursor().0;
1822                let anchor_row = ed.vim.visual_line_anchor;
1823                ed.vim.visual_line_anchor = cur_row;
1824                ed.jump_cursor(anchor_row, 0);
1825                return true;
1826            }
1827            Mode::VisualBlock => {
1828                let cur = ed.cursor();
1829                let anchor = ed.vim.block_anchor;
1830                ed.vim.block_anchor = cur;
1831                ed.vim.block_vcol = anchor.1;
1832                ed.jump_cursor(anchor.0, anchor.1);
1833                return true;
1834            }
1835            _ => {}
1836        },
1837        _ => {}
1838    }
1839
1840    // Visual mode: operators act on the current selection.
1841    if ed.vim.is_visual()
1842        && let Some(op) = visual_operator(&input)
1843    {
1844        apply_visual_operator(ed, op);
1845        return true;
1846    }
1847
1848    // VisualBlock: extra commands beyond the standard y/d/c/x — `r`
1849    // replaces the block with a single char, `I` / `A` enter insert
1850    // mode at the block's left / right edge and repeat on every row.
1851    if ed.vim.mode == Mode::VisualBlock && !input.ctrl {
1852        match input.key {
1853            Key::Char('r') => {
1854                ed.vim.pending = Pending::Replace;
1855                return true;
1856            }
1857            Key::Char('I') => {
1858                let (top, bot, left, _right) = block_bounds(ed);
1859                ed.jump_cursor(top, left);
1860                ed.vim.mode = Mode::Normal;
1861                begin_insert(
1862                    ed,
1863                    1,
1864                    InsertReason::BlockEdge {
1865                        top,
1866                        bot,
1867                        col: left,
1868                    },
1869                );
1870                return true;
1871            }
1872            Key::Char('A') => {
1873                let (top, bot, _left, right) = block_bounds(ed);
1874                let line_len = buf_line_chars(&ed.buffer, top);
1875                let col = (right + 1).min(line_len);
1876                ed.jump_cursor(top, col);
1877                ed.vim.mode = Mode::Normal;
1878                begin_insert(ed, 1, InsertReason::BlockEdge { top, bot, col });
1879                return true;
1880            }
1881            _ => {}
1882        }
1883    }
1884
1885    // Visual mode: `i` / `a` start a text-object extension.
1886    if matches!(ed.vim.mode, Mode::Visual | Mode::VisualLine)
1887        && !input.ctrl
1888        && matches!(input.key, Key::Char('i') | Key::Char('a'))
1889    {
1890        let inner = matches!(input.key, Key::Char('i'));
1891        ed.vim.pending = Pending::VisualTextObj { inner };
1892        return true;
1893    }
1894
1895    // Ctrl-prefixed scrolling + misc. Vim semantics: Ctrl-d / Ctrl-u
1896    // move the cursor by half a window, Ctrl-f / Ctrl-b by a full
1897    // window. Viewport follows the cursor. Cursor lands on the first
1898    // non-blank of the target row (matches vim).
1899    if input.ctrl
1900        && let Key::Char(c) = input.key
1901    {
1902        match c {
1903            'd' => {
1904                scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
1905                return true;
1906            }
1907            'u' => {
1908                scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
1909                return true;
1910            }
1911            'f' => {
1912                scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
1913                return true;
1914            }
1915            'b' => {
1916                scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
1917                return true;
1918            }
1919            'r' => {
1920                do_redo(ed);
1921                return true;
1922            }
1923            'a' if ed.vim.mode == Mode::Normal => {
1924                adjust_number(ed, count.max(1) as i64);
1925                return true;
1926            }
1927            'x' if ed.vim.mode == Mode::Normal => {
1928                adjust_number(ed, -(count.max(1) as i64));
1929                return true;
1930            }
1931            'o' if ed.vim.mode == Mode::Normal => {
1932                for _ in 0..count.max(1) {
1933                    jump_back(ed);
1934                }
1935                return true;
1936            }
1937            'i' if ed.vim.mode == Mode::Normal => {
1938                for _ in 0..count.max(1) {
1939                    jump_forward(ed);
1940                }
1941                return true;
1942            }
1943            _ => {}
1944        }
1945    }
1946
1947    // `Tab` in normal mode is also `Ctrl-i` — vim aliases them.
1948    if !input.ctrl && input.key == Key::Tab && ed.vim.mode == Mode::Normal {
1949        for _ in 0..count.max(1) {
1950            jump_forward(ed);
1951        }
1952        return true;
1953    }
1954
1955    // Motion-only commands.
1956    if let Some(motion) = parse_motion(&input) {
1957        execute_motion(ed, motion.clone(), count);
1958        // Block mode: maintain the virtual column across j/k clamps.
1959        if ed.vim.mode == Mode::VisualBlock {
1960            update_block_vcol(ed, &motion);
1961        }
1962        if let Motion::Find { ch, forward, till } = motion {
1963            ed.vim.last_find = Some((ch, forward, till));
1964        }
1965        return true;
1966    }
1967
1968    // Mode transitions + pure normal-mode commands (not applicable in visual).
1969    if ed.vim.mode == Mode::Normal && handle_normal_only(ed, &input, count) {
1970        return true;
1971    }
1972
1973    // Operator triggers in normal mode.
1974    if ed.vim.mode == Mode::Normal
1975        && let Key::Char(op_ch) = input.key
1976        && !input.ctrl
1977        && let Some(op) = char_to_operator(op_ch)
1978    {
1979        ed.vim.pending = Pending::Op { op, count1: count };
1980        return true;
1981    }
1982
1983    // `f`/`F`/`t`/`T` entry.
1984    if ed.vim.mode == Mode::Normal
1985        && let Some((forward, till)) = find_entry(&input)
1986    {
1987        ed.vim.count = count;
1988        ed.vim.pending = Pending::Find { forward, till };
1989        return true;
1990    }
1991
1992    // `g` prefix.
1993    if !input.ctrl && input.key == Key::Char('g') && ed.vim.mode == Mode::Normal {
1994        ed.vim.count = count;
1995        ed.vim.pending = Pending::G;
1996        return true;
1997    }
1998
1999    // `z` prefix (zz / zt / zb — cursor-relative viewport scrolls).
2000    if !input.ctrl
2001        && input.key == Key::Char('z')
2002        && matches!(
2003            ed.vim.mode,
2004            Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock
2005        )
2006    {
2007        ed.vim.pending = Pending::Z;
2008        return true;
2009    }
2010
2011    // Mark set / jump entries. `m` arms the set-mark pending state;
2012    // `'` and `` ` `` arm the goto states (linewise vs charwise). The
2013    // mark letter is consumed on the next keystroke.
2014    // In visual modes, `` ` `` also arms GotoMarkChar so the cursor can
2015    // extend the selection to a mark position (e.g. `` `[v`] `` idiom).
2016    if !input.ctrl
2017        && matches!(
2018            ed.vim.mode,
2019            Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock
2020        )
2021        && input.key == Key::Char('`')
2022    {
2023        ed.vim.pending = Pending::GotoMarkChar;
2024        return true;
2025    }
2026    if !input.ctrl && ed.vim.mode == Mode::Normal {
2027        match input.key {
2028            Key::Char('m') => {
2029                ed.vim.pending = Pending::SetMark;
2030                return true;
2031            }
2032            Key::Char('\'') => {
2033                ed.vim.pending = Pending::GotoMarkLine;
2034                return true;
2035            }
2036            Key::Char('`') => {
2037                // Already handled above for all visual modes + normal.
2038                ed.vim.pending = Pending::GotoMarkChar;
2039                return true;
2040            }
2041            Key::Char('"') => {
2042                // Open the register-selector chord. The next char picks
2043                // a register that the next y/d/c/p uses.
2044                ed.vim.pending = Pending::SelectRegister;
2045                return true;
2046            }
2047            Key::Char('@') => {
2048                // Open the macro-play chord. Next char names the
2049                // register; `@@` re-plays the last-played macro.
2050                // Stash any count so the chord can multiply replays.
2051                ed.vim.pending = Pending::PlayMacroTarget { count };
2052                return true;
2053            }
2054            Key::Char('q') if ed.vim.recording_macro.is_none() => {
2055                // Open the macro-record chord. The bare-q stop is
2056                // handled at the top of `step` so it's not consumed
2057                // as another open. Recording-in-progress falls through
2058                // here and is treated as a no-op (matches vim).
2059                ed.vim.pending = Pending::RecordMacroTarget;
2060                return true;
2061            }
2062            _ => {}
2063        }
2064    }
2065
2066    // Unknown key — swallow so it doesn't bubble into the TUI layer.
2067    true
2068}
2069
2070fn handle_set_mark<H: crate::types::Host>(
2071    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2072    input: Input,
2073) -> bool {
2074    if let Key::Char(c) = input.key
2075        && (c.is_ascii_lowercase() || c.is_ascii_uppercase())
2076    {
2077        // 0.0.36: lowercase + uppercase marks share the unified
2078        // `Editor::marks` map. Uppercase entries survive
2079        // `set_content` so they persist across tab swaps within the
2080        // same Editor (the map lives on the Editor, not the buffer).
2081        let pos = ed.cursor();
2082        ed.set_mark(c, pos);
2083    }
2084    true
2085}
2086
2087/// `"reg` — store the register selector for the next y / d / c / p.
2088/// Accepts `a`–`z`, `A`–`Z`, `0`–`9`, `"`, and the system-clipboard
2089/// selectors `+` / `*`. Anything else cancels silently.
2090fn handle_select_register<H: crate::types::Host>(
2091    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2092    input: Input,
2093) -> bool {
2094    if let Key::Char(c) = input.key
2095        && (c.is_ascii_alphanumeric() || matches!(c, '"' | '+' | '*'))
2096    {
2097        ed.vim.pending_register = Some(c);
2098    }
2099    true
2100}
2101
2102/// `q{reg}` — start recording into `reg`. The recording session
2103/// captures every consumed `Input` until a bare `q` ends it (handled
2104/// inline at the top of `step`). Capital letters append to the
2105/// matching lowercase register, mirroring named-register semantics.
2106fn handle_record_macro_target<H: crate::types::Host>(
2107    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2108    input: Input,
2109) -> bool {
2110    if let Key::Char(c) = input.key
2111        && (c.is_ascii_alphabetic() || c.is_ascii_digit())
2112    {
2113        ed.vim.recording_macro = Some(c);
2114        // For `qA` (capital), seed the buffer with the existing
2115        // lowercase recording so the new keystrokes append.
2116        if c.is_ascii_uppercase() {
2117            let lower = c.to_ascii_lowercase();
2118            // Seed `recording_keys` with the existing register's text
2119            // decoded back to inputs, so capital-register append
2120            // continues from where the previous recording left off.
2121            let text = ed
2122                .registers()
2123                .read(lower)
2124                .map(|s| s.text.clone())
2125                .unwrap_or_default();
2126            ed.vim.recording_keys = crate::input::decode_macro(&text);
2127        } else {
2128            ed.vim.recording_keys.clear();
2129        }
2130    }
2131    true
2132}
2133
2134/// `@{reg}` — replay the macro recorded under `reg`. `@@` re-plays
2135/// the last-played macro. The replay re-feeds each captured `Input`
2136/// through `step`, with `replaying_macro` flagged so the recorder
2137/// (if active) doesn't double-capture. Honours the count prefix:
2138/// `3@a` plays the macro three times.
2139fn handle_play_macro_target<H: crate::types::Host>(
2140    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2141    input: Input,
2142    count: usize,
2143) -> bool {
2144    let reg = match input.key {
2145        Key::Char('@') => ed.vim.last_macro,
2146        Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
2147            Some(c.to_ascii_lowercase())
2148        }
2149        _ => None,
2150    };
2151    let Some(reg) = reg else {
2152        return true;
2153    };
2154    // Read the macro text from the named register and decode back to
2155    // an Input stream. Empty / unset registers replay nothing.
2156    let text = match ed.registers().read(reg) {
2157        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
2158        _ => return true,
2159    };
2160    let keys = crate::input::decode_macro(&text);
2161    ed.vim.last_macro = Some(reg);
2162    let times = count.max(1);
2163    let was_replaying = ed.vim.replaying_macro;
2164    ed.vim.replaying_macro = true;
2165    for _ in 0..times {
2166        for k in keys.iter().copied() {
2167            step(ed, k);
2168        }
2169    }
2170    ed.vim.replaying_macro = was_replaying;
2171    true
2172}
2173
2174fn handle_goto_mark<H: crate::types::Host>(
2175    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2176    input: Input,
2177    linewise: bool,
2178) -> bool {
2179    let Key::Char(c) = input.key else {
2180        return true;
2181    };
2182    // Resolve the mark target. Lowercase letters look up the user
2183    // marks set via `m{a..z}`; the special chars below come from
2184    // automatic state vim maintains:
2185    //   `'` / `` ` `` — position before the most recent big jump
2186    //                  (peeks `jump_back` without popping).
2187    //   `.`           — the last edit's position.
2188    let target = match c {
2189        'a'..='z' | 'A'..='Z' => ed.mark(c),
2190        '\'' | '`' => ed.vim.jump_back.last().copied(),
2191        '.' => ed.vim.last_edit_pos,
2192        // Special auto-marks: `[` / `]` — last yank / change / paste bounds
2193        // (vim `:h '[` / `:h ']`). Stored by the operator and paste paths.
2194        // `<` / `>` — last visual selection start / end (vim `:h '<` /
2195        // `:h '>`). Stored by the visual-exit hook (0.5.3).
2196        '[' | ']' | '<' | '>' => ed.mark(c),
2197        _ => None,
2198    };
2199    let Some((row, col)) = target else {
2200        return true;
2201    };
2202    let pre = ed.cursor();
2203    let (r, c_clamped) = clamp_pos(ed, (row, col));
2204    if linewise {
2205        buf_set_cursor_rc(&mut ed.buffer, r, 0);
2206        ed.push_buffer_cursor_to_textarea();
2207        move_first_non_whitespace(ed);
2208    } else {
2209        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
2210        ed.push_buffer_cursor_to_textarea();
2211    }
2212    if ed.cursor() != pre {
2213        push_jump(ed, pre);
2214    }
2215    ed.sticky_col = Some(ed.cursor().1);
2216    true
2217}
2218
2219fn take_count(vim: &mut VimState) -> usize {
2220    if vim.count > 0 {
2221        let n = vim.count;
2222        vim.count = 0;
2223        n
2224    } else {
2225        1
2226    }
2227}
2228
2229fn char_to_operator(c: char) -> Option<Operator> {
2230    match c {
2231        'd' => Some(Operator::Delete),
2232        'c' => Some(Operator::Change),
2233        'y' => Some(Operator::Yank),
2234        '>' => Some(Operator::Indent),
2235        '<' => Some(Operator::Outdent),
2236        _ => None,
2237    }
2238}
2239
2240fn visual_operator(input: &Input) -> Option<Operator> {
2241    if input.ctrl {
2242        return None;
2243    }
2244    match input.key {
2245        Key::Char('y') => Some(Operator::Yank),
2246        Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
2247        Key::Char('c') | Key::Char('s') => Some(Operator::Change),
2248        // Case operators — shift forms apply to the active selection.
2249        Key::Char('U') => Some(Operator::Uppercase),
2250        Key::Char('u') => Some(Operator::Lowercase),
2251        Key::Char('~') => Some(Operator::ToggleCase),
2252        // Indent operators on selection.
2253        Key::Char('>') => Some(Operator::Indent),
2254        Key::Char('<') => Some(Operator::Outdent),
2255        _ => None,
2256    }
2257}
2258
2259fn find_entry(input: &Input) -> Option<(bool, bool)> {
2260    if input.ctrl {
2261        return None;
2262    }
2263    match input.key {
2264        Key::Char('f') => Some((true, false)),
2265        Key::Char('F') => Some((false, false)),
2266        Key::Char('t') => Some((true, true)),
2267        Key::Char('T') => Some((false, true)),
2268        _ => None,
2269    }
2270}
2271
2272// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
2273
2274/// Max jumplist depth. Matches vim default.
2275const JUMPLIST_MAX: usize = 100;
2276
2277/// Record a pre-jump cursor position. Called *before* a big-jump
2278/// motion runs (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, `/`?
2279/// commit, `:{nr}`). Making a new jump while the forward stack had
2280/// entries trims them — branching off the history clears the "redo".
2281fn push_jump<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, from: (usize, usize)) {
2282    ed.vim.jump_back.push(from);
2283    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2284        ed.vim.jump_back.remove(0);
2285    }
2286    ed.vim.jump_fwd.clear();
2287}
2288
2289/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
2290/// the current cursor onto the forward stack so `Ctrl-i` can return.
2291fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2292    let Some(target) = ed.vim.jump_back.pop() else {
2293        return;
2294    };
2295    let cur = ed.cursor();
2296    ed.vim.jump_fwd.push(cur);
2297    let (r, c) = clamp_pos(ed, target);
2298    ed.jump_cursor(r, c);
2299    ed.sticky_col = Some(c);
2300}
2301
2302/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
2303/// onto the back stack.
2304fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2305    let Some(target) = ed.vim.jump_fwd.pop() else {
2306        return;
2307    };
2308    let cur = ed.cursor();
2309    ed.vim.jump_back.push(cur);
2310    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2311        ed.vim.jump_back.remove(0);
2312    }
2313    let (r, c) = clamp_pos(ed, target);
2314    ed.jump_cursor(r, c);
2315    ed.sticky_col = Some(c);
2316}
2317
2318/// Clamp a stored `(row, col)` to the live buffer in case edits
2319/// shrunk the document between push and pop.
2320fn clamp_pos<H: crate::types::Host>(
2321    ed: &Editor<hjkl_buffer::Buffer, H>,
2322    pos: (usize, usize),
2323) -> (usize, usize) {
2324    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2325    let r = pos.0.min(last_row);
2326    let line_len = buf_line_chars(&ed.buffer, r);
2327    let c = pos.1.min(line_len.saturating_sub(1));
2328    (r, c)
2329}
2330
2331/// True for motions that vim treats as jumps (pushed onto the jumplist).
2332fn is_big_jump(motion: &Motion) -> bool {
2333    matches!(
2334        motion,
2335        Motion::FileTop
2336            | Motion::FileBottom
2337            | Motion::MatchBracket
2338            | Motion::WordAtCursor { .. }
2339            | Motion::SearchNext { .. }
2340            | Motion::ViewportTop
2341            | Motion::ViewportMiddle
2342            | Motion::ViewportBottom
2343    )
2344}
2345
2346// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
2347
2348/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
2349/// viewports still step by a single row. `count` multiplies.
2350fn viewport_half_rows<H: crate::types::Host>(
2351    ed: &Editor<hjkl_buffer::Buffer, H>,
2352    count: usize,
2353) -> usize {
2354    let h = ed.viewport_height_value() as usize;
2355    (h / 2).max(1).saturating_mul(count.max(1))
2356}
2357
2358/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
2359/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
2360fn viewport_full_rows<H: crate::types::Host>(
2361    ed: &Editor<hjkl_buffer::Buffer, H>,
2362    count: usize,
2363) -> usize {
2364    let h = ed.viewport_height_value() as usize;
2365    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
2366}
2367
2368/// Move the cursor by `delta` rows (positive = down, negative = up),
2369/// clamp to the document, then land at the first non-blank on the new
2370/// row. The textarea viewport auto-scrolls to keep the cursor visible
2371/// when the cursor pushes off-screen.
2372fn scroll_cursor_rows<H: crate::types::Host>(
2373    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2374    delta: isize,
2375) {
2376    if delta == 0 {
2377        return;
2378    }
2379    ed.sync_buffer_content_from_textarea();
2380    let (row, _) = ed.cursor();
2381    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2382    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
2383    buf_set_cursor_rc(&mut ed.buffer, target, 0);
2384    crate::motions::move_first_non_blank(&mut ed.buffer);
2385    ed.push_buffer_cursor_to_textarea();
2386    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
2387}
2388
2389// ─── Motion parsing ────────────────────────────────────────────────────────
2390
2391fn parse_motion(input: &Input) -> Option<Motion> {
2392    if input.ctrl {
2393        return None;
2394    }
2395    match input.key {
2396        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
2397        Key::Char('l') | Key::Right => Some(Motion::Right),
2398        Key::Char('j') | Key::Down | Key::Enter => Some(Motion::Down),
2399        Key::Char('k') | Key::Up => Some(Motion::Up),
2400        Key::Char('w') => Some(Motion::WordFwd),
2401        Key::Char('W') => Some(Motion::BigWordFwd),
2402        Key::Char('b') => Some(Motion::WordBack),
2403        Key::Char('B') => Some(Motion::BigWordBack),
2404        Key::Char('e') => Some(Motion::WordEnd),
2405        Key::Char('E') => Some(Motion::BigWordEnd),
2406        Key::Char('0') | Key::Home => Some(Motion::LineStart),
2407        Key::Char('^') => Some(Motion::FirstNonBlank),
2408        Key::Char('$') | Key::End => Some(Motion::LineEnd),
2409        Key::Char('G') => Some(Motion::FileBottom),
2410        Key::Char('%') => Some(Motion::MatchBracket),
2411        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
2412        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
2413        Key::Char('*') => Some(Motion::WordAtCursor {
2414            forward: true,
2415            whole_word: true,
2416        }),
2417        Key::Char('#') => Some(Motion::WordAtCursor {
2418            forward: false,
2419            whole_word: true,
2420        }),
2421        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
2422        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
2423        Key::Char('H') => Some(Motion::ViewportTop),
2424        Key::Char('M') => Some(Motion::ViewportMiddle),
2425        Key::Char('L') => Some(Motion::ViewportBottom),
2426        Key::Char('{') => Some(Motion::ParagraphPrev),
2427        Key::Char('}') => Some(Motion::ParagraphNext),
2428        Key::Char('(') => Some(Motion::SentencePrev),
2429        Key::Char(')') => Some(Motion::SentenceNext),
2430        _ => None,
2431    }
2432}
2433
2434// ─── Motion execution ──────────────────────────────────────────────────────
2435
2436fn execute_motion<H: crate::types::Host>(
2437    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2438    motion: Motion,
2439    count: usize,
2440) {
2441    let count = count.max(1);
2442    // FindRepeat needs the stored direction.
2443    let motion = match motion {
2444        Motion::FindRepeat { reverse } => match ed.vim.last_find {
2445            Some((ch, forward, till)) => Motion::Find {
2446                ch,
2447                forward: if reverse { !forward } else { forward },
2448                till,
2449            },
2450            None => return,
2451        },
2452        other => other,
2453    };
2454    let pre_pos = ed.cursor();
2455    let pre_col = pre_pos.1;
2456    apply_motion_cursor(ed, &motion, count);
2457    let post_pos = ed.cursor();
2458    if is_big_jump(&motion) && pre_pos != post_pos {
2459        push_jump(ed, pre_pos);
2460    }
2461    apply_sticky_col(ed, &motion, pre_col);
2462    // Phase 7b: keep the migration buffer's cursor + viewport in
2463    // lockstep with the textarea after every motion. Once 7c lands
2464    // (motions ported onto the buffer's API), this flips: the
2465    // buffer becomes authoritative and the textarea mirrors it.
2466    ed.sync_buffer_from_textarea();
2467}
2468
2469/// Restore the cursor to the sticky column after vertical motions and
2470/// sync the sticky column to the current column after horizontal ones.
2471/// `pre_col` is the cursor column captured *before* the motion — used
2472/// to bootstrap the sticky value on the very first motion.
2473fn apply_sticky_col<H: crate::types::Host>(
2474    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2475    motion: &Motion,
2476    pre_col: usize,
2477) {
2478    if is_vertical_motion(motion) {
2479        let want = ed.sticky_col.unwrap_or(pre_col);
2480        // Record the desired column so the next vertical motion sees
2481        // it even if we currently clamped to a shorter row.
2482        ed.sticky_col = Some(want);
2483        let (row, _) = ed.cursor();
2484        let line_len = buf_line_chars(&ed.buffer, row);
2485        // Clamp to the last char on non-empty lines (vim normal-mode
2486        // never parks the cursor one past end of line). Empty lines
2487        // collapse to col 0.
2488        let max_col = line_len.saturating_sub(1);
2489        let target = want.min(max_col);
2490        ed.jump_cursor(row, target);
2491    } else {
2492        // Horizontal motion or non-motion: sticky column tracks the
2493        // new cursor column so the *next* vertical motion aims there.
2494        ed.sticky_col = Some(ed.cursor().1);
2495    }
2496}
2497
2498fn is_vertical_motion(motion: &Motion) -> bool {
2499    // Only j / k preserve the sticky column. Everything else (search,
2500    // gg / G, word jumps, etc.) lands at the match's own column so the
2501    // sticky value should sync to the new cursor column.
2502    matches!(
2503        motion,
2504        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
2505    )
2506}
2507
2508fn apply_motion_cursor<H: crate::types::Host>(
2509    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2510    motion: &Motion,
2511    count: usize,
2512) {
2513    apply_motion_cursor_ctx(ed, motion, count, false)
2514}
2515
2516fn apply_motion_cursor_ctx<H: crate::types::Host>(
2517    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2518    motion: &Motion,
2519    count: usize,
2520    as_operator: bool,
2521) {
2522    match motion {
2523        Motion::Left => {
2524            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
2525            crate::motions::move_left(&mut ed.buffer, count);
2526            ed.push_buffer_cursor_to_textarea();
2527        }
2528        Motion::Right => {
2529            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
2530            // one past the last char so the range includes it; cursor
2531            // context clamps at the last char.
2532            if as_operator {
2533                crate::motions::move_right_to_end(&mut ed.buffer, count);
2534            } else {
2535                crate::motions::move_right_in_line(&mut ed.buffer, count);
2536            }
2537            ed.push_buffer_cursor_to_textarea();
2538        }
2539        Motion::Up => {
2540            // Final col is set by `apply_sticky_col` below — push the
2541            // post-move row to the textarea and let sticky tracking
2542            // finish the work.
2543            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2544            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2545            ed.push_buffer_cursor_to_textarea();
2546        }
2547        Motion::Down => {
2548            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2549            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2550            ed.push_buffer_cursor_to_textarea();
2551        }
2552        Motion::ScreenUp => {
2553            let v = *ed.host.viewport();
2554            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2555            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2556            ed.push_buffer_cursor_to_textarea();
2557        }
2558        Motion::ScreenDown => {
2559            let v = *ed.host.viewport();
2560            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2561            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2562            ed.push_buffer_cursor_to_textarea();
2563        }
2564        Motion::WordFwd => {
2565            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2566            ed.push_buffer_cursor_to_textarea();
2567        }
2568        Motion::WordBack => {
2569            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2570            ed.push_buffer_cursor_to_textarea();
2571        }
2572        Motion::WordEnd => {
2573            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2574            ed.push_buffer_cursor_to_textarea();
2575        }
2576        Motion::BigWordFwd => {
2577            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2578            ed.push_buffer_cursor_to_textarea();
2579        }
2580        Motion::BigWordBack => {
2581            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2582            ed.push_buffer_cursor_to_textarea();
2583        }
2584        Motion::BigWordEnd => {
2585            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2586            ed.push_buffer_cursor_to_textarea();
2587        }
2588        Motion::WordEndBack => {
2589            crate::motions::move_word_end_back(
2590                &mut ed.buffer,
2591                false,
2592                count,
2593                &ed.settings.iskeyword,
2594            );
2595            ed.push_buffer_cursor_to_textarea();
2596        }
2597        Motion::BigWordEndBack => {
2598            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2599            ed.push_buffer_cursor_to_textarea();
2600        }
2601        Motion::LineStart => {
2602            crate::motions::move_line_start(&mut ed.buffer);
2603            ed.push_buffer_cursor_to_textarea();
2604        }
2605        Motion::FirstNonBlank => {
2606            crate::motions::move_first_non_blank(&mut ed.buffer);
2607            ed.push_buffer_cursor_to_textarea();
2608        }
2609        Motion::LineEnd => {
2610            // Vim normal-mode `$` lands on the last char, not one past it.
2611            crate::motions::move_line_end(&mut ed.buffer);
2612            ed.push_buffer_cursor_to_textarea();
2613        }
2614        Motion::FileTop => {
2615            // `count gg` jumps to line `count` (first non-blank);
2616            // bare `gg` lands at the top.
2617            if count > 1 {
2618                crate::motions::move_bottom(&mut ed.buffer, count);
2619            } else {
2620                crate::motions::move_top(&mut ed.buffer);
2621            }
2622            ed.push_buffer_cursor_to_textarea();
2623        }
2624        Motion::FileBottom => {
2625            // `count G` jumps to line `count`; bare `G` lands at
2626            // the buffer bottom (`Buffer::move_bottom(0)`).
2627            if count > 1 {
2628                crate::motions::move_bottom(&mut ed.buffer, count);
2629            } else {
2630                crate::motions::move_bottom(&mut ed.buffer, 0);
2631            }
2632            ed.push_buffer_cursor_to_textarea();
2633        }
2634        Motion::Find { ch, forward, till } => {
2635            for _ in 0..count {
2636                if !find_char_on_line(ed, *ch, *forward, *till) {
2637                    break;
2638                }
2639            }
2640        }
2641        Motion::FindRepeat { .. } => {} // already resolved upstream
2642        Motion::MatchBracket => {
2643            let _ = matching_bracket(ed);
2644        }
2645        Motion::WordAtCursor {
2646            forward,
2647            whole_word,
2648        } => {
2649            word_at_cursor_search(ed, *forward, *whole_word, count);
2650        }
2651        Motion::SearchNext { reverse } => {
2652            // Re-push the last query so the buffer's search state is
2653            // correct even if the host happened to clear it (e.g. while
2654            // a Visual mode draw was in progress).
2655            if let Some(pattern) = ed.vim.last_search.clone() {
2656                push_search_pattern(ed, &pattern);
2657            }
2658            if ed.search_state().pattern.is_none() {
2659                return;
2660            }
2661            // `n` repeats the last search in its committed direction;
2662            // `N` inverts. So a `?` search makes `n` walk backward and
2663            // `N` walk forward.
2664            let forward = ed.vim.last_search_forward != *reverse;
2665            for _ in 0..count.max(1) {
2666                if forward {
2667                    ed.search_advance_forward(true);
2668                } else {
2669                    ed.search_advance_backward(true);
2670                }
2671            }
2672            ed.push_buffer_cursor_to_textarea();
2673        }
2674        Motion::ViewportTop => {
2675            let v = *ed.host().viewport();
2676            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
2677            ed.push_buffer_cursor_to_textarea();
2678        }
2679        Motion::ViewportMiddle => {
2680            let v = *ed.host().viewport();
2681            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
2682            ed.push_buffer_cursor_to_textarea();
2683        }
2684        Motion::ViewportBottom => {
2685            let v = *ed.host().viewport();
2686            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
2687            ed.push_buffer_cursor_to_textarea();
2688        }
2689        Motion::LastNonBlank => {
2690            crate::motions::move_last_non_blank(&mut ed.buffer);
2691            ed.push_buffer_cursor_to_textarea();
2692        }
2693        Motion::LineMiddle => {
2694            let row = ed.cursor().0;
2695            let line_chars = buf_line_chars(&ed.buffer, row);
2696            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
2697            // lines stay at col 0.
2698            let target = line_chars / 2;
2699            ed.jump_cursor(row, target);
2700        }
2701        Motion::ParagraphPrev => {
2702            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
2703            ed.push_buffer_cursor_to_textarea();
2704        }
2705        Motion::ParagraphNext => {
2706            crate::motions::move_paragraph_next(&mut ed.buffer, count);
2707            ed.push_buffer_cursor_to_textarea();
2708        }
2709        Motion::SentencePrev => {
2710            for _ in 0..count.max(1) {
2711                if let Some((row, col)) = sentence_boundary(ed, false) {
2712                    ed.jump_cursor(row, col);
2713                }
2714            }
2715        }
2716        Motion::SentenceNext => {
2717            for _ in 0..count.max(1) {
2718                if let Some((row, col)) = sentence_boundary(ed, true) {
2719                    ed.jump_cursor(row, col);
2720                }
2721            }
2722        }
2723    }
2724}
2725
2726fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2727    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
2728    // mutates the textarea content, so the migration buffer hasn't
2729    // seen the new lines OR new cursor yet. Mirror the full content
2730    // across before delegating, then push the result back so the
2731    // textarea reflects the resolved column too.
2732    ed.sync_buffer_content_from_textarea();
2733    crate::motions::move_first_non_blank(&mut ed.buffer);
2734    ed.push_buffer_cursor_to_textarea();
2735}
2736
2737fn find_char_on_line<H: crate::types::Host>(
2738    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2739    ch: char,
2740    forward: bool,
2741    till: bool,
2742) -> bool {
2743    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
2744    if moved {
2745        ed.push_buffer_cursor_to_textarea();
2746    }
2747    moved
2748}
2749
2750fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
2751    let moved = crate::motions::match_bracket(&mut ed.buffer);
2752    if moved {
2753        ed.push_buffer_cursor_to_textarea();
2754    }
2755    moved
2756}
2757
2758fn word_at_cursor_search<H: crate::types::Host>(
2759    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2760    forward: bool,
2761    whole_word: bool,
2762    count: usize,
2763) {
2764    let (row, col) = ed.cursor();
2765    let line: String = buf_line(&ed.buffer, row).unwrap_or("").to_string();
2766    let chars: Vec<char> = line.chars().collect();
2767    if chars.is_empty() {
2768        return;
2769    }
2770    // Expand around cursor to a word boundary.
2771    let spec = ed.settings().iskeyword.clone();
2772    let is_word = |c: char| is_keyword_char(c, &spec);
2773    let mut start = col.min(chars.len().saturating_sub(1));
2774    while start > 0 && is_word(chars[start - 1]) {
2775        start -= 1;
2776    }
2777    let mut end = start;
2778    while end < chars.len() && is_word(chars[end]) {
2779        end += 1;
2780    }
2781    if end <= start {
2782        return;
2783    }
2784    let word: String = chars[start..end].iter().collect();
2785    let escaped = regex_escape(&word);
2786    let pattern = if whole_word {
2787        format!(r"\b{escaped}\b")
2788    } else {
2789        escaped
2790    };
2791    push_search_pattern(ed, &pattern);
2792    if ed.search_state().pattern.is_none() {
2793        return;
2794    }
2795    // Remember the query so `n` / `N` keep working after the jump.
2796    ed.vim.last_search = Some(pattern);
2797    ed.vim.last_search_forward = forward;
2798    for _ in 0..count.max(1) {
2799        if forward {
2800            ed.search_advance_forward(true);
2801        } else {
2802            ed.search_advance_backward(true);
2803        }
2804    }
2805    ed.push_buffer_cursor_to_textarea();
2806}
2807
2808fn regex_escape(s: &str) -> String {
2809    let mut out = String::with_capacity(s.len());
2810    for c in s.chars() {
2811        if matches!(
2812            c,
2813            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
2814        ) {
2815            out.push('\\');
2816        }
2817        out.push(c);
2818    }
2819    out
2820}
2821
2822// ─── Operator application ──────────────────────────────────────────────────
2823
2824fn handle_after_op<H: crate::types::Host>(
2825    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2826    input: Input,
2827    op: Operator,
2828    count1: usize,
2829) -> bool {
2830    // Inner count after operator (e.g. d3w): accumulate in state.count.
2831    if let Key::Char(d @ '0'..='9') = input.key
2832        && !input.ctrl
2833        && (d != '0' || ed.vim.count > 0)
2834    {
2835        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
2836        ed.vim.pending = Pending::Op { op, count1 };
2837        return true;
2838    }
2839
2840    // Esc cancels.
2841    if input.key == Key::Esc {
2842        ed.vim.count = 0;
2843        return true;
2844    }
2845
2846    // Same-letter: dd / cc / yy / gUU / guu / g~~ / >> / <<. Fold has
2847    // no doubled form in vim — `zfzf` is two `zf` chords, not a line
2848    // op — so skip the branch entirely.
2849    let double_ch = match op {
2850        Operator::Delete => Some('d'),
2851        Operator::Change => Some('c'),
2852        Operator::Yank => Some('y'),
2853        Operator::Indent => Some('>'),
2854        Operator::Outdent => Some('<'),
2855        Operator::Uppercase => Some('U'),
2856        Operator::Lowercase => Some('u'),
2857        Operator::ToggleCase => Some('~'),
2858        Operator::Fold => None,
2859        // `gqq` reflows the current line — vim's doubled form for the
2860        // reflow operator is the second `q` after `gq`.
2861        Operator::Reflow => Some('q'),
2862    };
2863    if let Key::Char(c) = input.key
2864        && !input.ctrl
2865        && Some(c) == double_ch
2866    {
2867        let count2 = take_count(&mut ed.vim);
2868        let total = count1.max(1) * count2.max(1);
2869        execute_line_op(ed, op, total);
2870        if !ed.vim.replaying {
2871            ed.vim.last_change = Some(LastChange::LineOp {
2872                op,
2873                count: total,
2874                inserted: None,
2875            });
2876        }
2877        return true;
2878    }
2879
2880    // Text object: `i` or `a`.
2881    if let Key::Char('i') | Key::Char('a') = input.key
2882        && !input.ctrl
2883    {
2884        let inner = matches!(input.key, Key::Char('i'));
2885        ed.vim.pending = Pending::OpTextObj { op, count1, inner };
2886        return true;
2887    }
2888
2889    // `g` — awaiting `g` for `gg`.
2890    if input.key == Key::Char('g') && !input.ctrl {
2891        ed.vim.pending = Pending::OpG { op, count1 };
2892        return true;
2893    }
2894
2895    // `f`/`F`/`t`/`T` with pending target.
2896    if let Some((forward, till)) = find_entry(&input) {
2897        ed.vim.pending = Pending::OpFind {
2898            op,
2899            count1,
2900            forward,
2901            till,
2902        };
2903        return true;
2904    }
2905
2906    // Motion.
2907    let count2 = take_count(&mut ed.vim);
2908    let total = count1.max(1) * count2.max(1);
2909    if let Some(motion) = parse_motion(&input) {
2910        let motion = match motion {
2911            Motion::FindRepeat { reverse } => match ed.vim.last_find {
2912                Some((ch, forward, till)) => Motion::Find {
2913                    ch,
2914                    forward: if reverse { !forward } else { forward },
2915                    till,
2916                },
2917                None => return true,
2918            },
2919            // Vim quirk: `cw` / `cW` are `ce` / `cE` — don't include
2920            // trailing whitespace so the user's replacement text lands
2921            // before the following word's leading space.
2922            Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
2923            Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
2924            m => m,
2925        };
2926        apply_op_with_motion(ed, op, &motion, total);
2927        if let Motion::Find { ch, forward, till } = &motion {
2928            ed.vim.last_find = Some((*ch, *forward, *till));
2929        }
2930        if !ed.vim.replaying && op_is_change(op) {
2931            ed.vim.last_change = Some(LastChange::OpMotion {
2932                op,
2933                motion,
2934                count: total,
2935                inserted: None,
2936            });
2937        }
2938        return true;
2939    }
2940
2941    // Unknown — cancel the operator.
2942    true
2943}
2944
2945fn handle_op_after_g<H: crate::types::Host>(
2946    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2947    input: Input,
2948    op: Operator,
2949    count1: usize,
2950) -> bool {
2951    if input.ctrl {
2952        return true;
2953    }
2954    let count2 = take_count(&mut ed.vim);
2955    let total = count1.max(1) * count2.max(1);
2956    // Case-op linewise form: `gUgU`, `gugu`, `g~g~` — same effect as
2957    // `gUU` / `guu` / `g~~`. The leading `g` was consumed into
2958    // `Pending::OpG`, so here we see the trailing U / u / ~.
2959    if matches!(
2960        op,
2961        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
2962    ) {
2963        let op_char = match op {
2964            Operator::Uppercase => 'U',
2965            Operator::Lowercase => 'u',
2966            Operator::ToggleCase => '~',
2967            _ => unreachable!(),
2968        };
2969        if input.key == Key::Char(op_char) {
2970            execute_line_op(ed, op, total);
2971            if !ed.vim.replaying {
2972                ed.vim.last_change = Some(LastChange::LineOp {
2973                    op,
2974                    count: total,
2975                    inserted: None,
2976                });
2977            }
2978            return true;
2979        }
2980    }
2981    let motion = match input.key {
2982        Key::Char('g') => Motion::FileTop,
2983        Key::Char('e') => Motion::WordEndBack,
2984        Key::Char('E') => Motion::BigWordEndBack,
2985        Key::Char('j') => Motion::ScreenDown,
2986        Key::Char('k') => Motion::ScreenUp,
2987        _ => return true,
2988    };
2989    apply_op_with_motion(ed, op, &motion, total);
2990    if !ed.vim.replaying && op_is_change(op) {
2991        ed.vim.last_change = Some(LastChange::OpMotion {
2992            op,
2993            motion,
2994            count: total,
2995            inserted: None,
2996        });
2997    }
2998    true
2999}
3000
3001fn handle_after_g<H: crate::types::Host>(
3002    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3003    input: Input,
3004) -> bool {
3005    let count = take_count(&mut ed.vim);
3006    match input.key {
3007        Key::Char('g') => {
3008            // gg — top / jump to line count.
3009            let pre = ed.cursor();
3010            if count > 1 {
3011                ed.jump_cursor(count - 1, 0);
3012            } else {
3013                ed.jump_cursor(0, 0);
3014            }
3015            move_first_non_whitespace(ed);
3016            if ed.cursor() != pre {
3017                push_jump(ed, pre);
3018            }
3019        }
3020        Key::Char('e') => execute_motion(ed, Motion::WordEndBack, count),
3021        Key::Char('E') => execute_motion(ed, Motion::BigWordEndBack, count),
3022        // `g_` — last non-blank on the line.
3023        Key::Char('_') => execute_motion(ed, Motion::LastNonBlank, count),
3024        // `gM` — middle char column of the current line.
3025        Key::Char('M') => execute_motion(ed, Motion::LineMiddle, count),
3026        // `gv` — re-enter the last visual selection.
3027        Key::Char('v') => {
3028            if let Some(snap) = ed.vim.last_visual {
3029                match snap.mode {
3030                    Mode::Visual => {
3031                        ed.vim.visual_anchor = snap.anchor;
3032                        ed.vim.mode = Mode::Visual;
3033                    }
3034                    Mode::VisualLine => {
3035                        ed.vim.visual_line_anchor = snap.anchor.0;
3036                        ed.vim.mode = Mode::VisualLine;
3037                    }
3038                    Mode::VisualBlock => {
3039                        ed.vim.block_anchor = snap.anchor;
3040                        ed.vim.block_vcol = snap.block_vcol;
3041                        ed.vim.mode = Mode::VisualBlock;
3042                    }
3043                    _ => {}
3044                }
3045                ed.jump_cursor(snap.cursor.0, snap.cursor.1);
3046            }
3047        }
3048        // `gj` / `gk` — display-line down / up. Walks one screen
3049        // segment at a time under `:set wrap`; falls back to `j`/`k`
3050        // when wrap is off (Buffer::move_screen_* handles the branch).
3051        Key::Char('j') => execute_motion(ed, Motion::ScreenDown, count),
3052        Key::Char('k') => execute_motion(ed, Motion::ScreenUp, count),
3053        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
3054        // so the next input is treated as the motion / text object /
3055        // shorthand double (`gUU`, `guu`, `g~~`).
3056        Key::Char('U') => {
3057            ed.vim.pending = Pending::Op {
3058                op: Operator::Uppercase,
3059                count1: count,
3060            };
3061        }
3062        Key::Char('u') => {
3063            ed.vim.pending = Pending::Op {
3064                op: Operator::Lowercase,
3065                count1: count,
3066            };
3067        }
3068        Key::Char('~') => {
3069            ed.vim.pending = Pending::Op {
3070                op: Operator::ToggleCase,
3071                count1: count,
3072            };
3073        }
3074        Key::Char('q') => {
3075            // `gq{motion}` — text reflow operator. Subsequent motion
3076            // / textobj rides the same operator pipeline.
3077            ed.vim.pending = Pending::Op {
3078                op: Operator::Reflow,
3079                count1: count,
3080            };
3081        }
3082        Key::Char('J') => {
3083            // `gJ` — join line below without inserting a space.
3084            for _ in 0..count.max(1) {
3085                ed.push_undo();
3086                join_line_raw(ed);
3087            }
3088            if !ed.vim.replaying {
3089                ed.vim.last_change = Some(LastChange::JoinLine {
3090                    count: count.max(1),
3091                });
3092            }
3093        }
3094        Key::Char('d') => {
3095            // `gd` — goto definition. hjkl-engine doesn't run an LSP
3096            // itself; raise an intent the host drains and routes to
3097            // `sqls`. The cursor stays put here — the host moves it
3098            // once it has the target location.
3099            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
3100        }
3101        // `g;` / `g,` — walk the change list. `g;` toward older
3102        // entries, `g,` toward newer.
3103        Key::Char(';') => walk_change_list(ed, -1, count.max(1)),
3104        Key::Char(',') => walk_change_list(ed, 1, count.max(1)),
3105        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
3106        // boundary anchors), so the cursor on `foo` finds it inside
3107        // `foobar` too.
3108        Key::Char('*') => execute_motion(
3109            ed,
3110            Motion::WordAtCursor {
3111                forward: true,
3112                whole_word: false,
3113            },
3114            count,
3115        ),
3116        Key::Char('#') => execute_motion(
3117            ed,
3118            Motion::WordAtCursor {
3119                forward: false,
3120                whole_word: false,
3121            },
3122            count,
3123        ),
3124        _ => {}
3125    }
3126    true
3127}
3128
3129fn handle_after_z<H: crate::types::Host>(
3130    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3131    input: Input,
3132) -> bool {
3133    use crate::editor::CursorScrollTarget;
3134    let row = ed.cursor().0;
3135    match input.key {
3136        Key::Char('z') => {
3137            ed.scroll_cursor_to(CursorScrollTarget::Center);
3138            ed.vim.viewport_pinned = true;
3139        }
3140        Key::Char('t') => {
3141            ed.scroll_cursor_to(CursorScrollTarget::Top);
3142            ed.vim.viewport_pinned = true;
3143        }
3144        Key::Char('b') => {
3145            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
3146            ed.vim.viewport_pinned = true;
3147        }
3148        // Folds — operate on the fold under the cursor (or the
3149        // whole buffer for `R` / `M`). Routed through
3150        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
3151        // can observe / veto each op via [`Editor::take_fold_ops`].
3152        Key::Char('o') => {
3153            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
3154        }
3155        Key::Char('c') => {
3156            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
3157        }
3158        Key::Char('a') => {
3159            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
3160        }
3161        Key::Char('R') => {
3162            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
3163        }
3164        Key::Char('M') => {
3165            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
3166        }
3167        Key::Char('E') => {
3168            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
3169        }
3170        Key::Char('d') => {
3171            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
3172        }
3173        Key::Char('f') => {
3174            if matches!(
3175                ed.vim.mode,
3176                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
3177            ) {
3178                // `zf` over a Visual selection creates a fold spanning
3179                // anchor → cursor.
3180                let anchor_row = match ed.vim.mode {
3181                    Mode::VisualLine => ed.vim.visual_line_anchor,
3182                    Mode::VisualBlock => ed.vim.block_anchor.0,
3183                    _ => ed.vim.visual_anchor.0,
3184                };
3185                let cur = ed.cursor().0;
3186                let top = anchor_row.min(cur);
3187                let bot = anchor_row.max(cur);
3188                ed.apply_fold_op(crate::types::FoldOp::Add {
3189                    start_row: top,
3190                    end_row: bot,
3191                    closed: true,
3192                });
3193                ed.vim.mode = Mode::Normal;
3194            } else {
3195                // `zf{motion}` / `zf{textobj}` — route through the
3196                // operator pipeline. `Operator::Fold` reuses every
3197                // motion / text-object / `g`-prefix branch the other
3198                // operators get.
3199                let count = take_count(&mut ed.vim);
3200                ed.vim.pending = Pending::Op {
3201                    op: Operator::Fold,
3202                    count1: count,
3203                };
3204            }
3205        }
3206        _ => {}
3207    }
3208    true
3209}
3210
3211fn handle_replace<H: crate::types::Host>(
3212    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3213    input: Input,
3214) -> bool {
3215    if let Key::Char(ch) = input.key {
3216        if ed.vim.mode == Mode::VisualBlock {
3217            block_replace(ed, ch);
3218            return true;
3219        }
3220        let count = take_count(&mut ed.vim);
3221        replace_char(ed, ch, count.max(1));
3222        if !ed.vim.replaying {
3223            ed.vim.last_change = Some(LastChange::ReplaceChar {
3224                ch,
3225                count: count.max(1),
3226            });
3227        }
3228    }
3229    true
3230}
3231
3232fn handle_find_target<H: crate::types::Host>(
3233    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3234    input: Input,
3235    forward: bool,
3236    till: bool,
3237) -> bool {
3238    let Key::Char(ch) = input.key else {
3239        return true;
3240    };
3241    let count = take_count(&mut ed.vim);
3242    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
3243    ed.vim.last_find = Some((ch, forward, till));
3244    true
3245}
3246
3247fn handle_op_find_target<H: crate::types::Host>(
3248    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3249    input: Input,
3250    op: Operator,
3251    count1: usize,
3252    forward: bool,
3253    till: bool,
3254) -> bool {
3255    let Key::Char(ch) = input.key else {
3256        return true;
3257    };
3258    let count2 = take_count(&mut ed.vim);
3259    let total = count1.max(1) * count2.max(1);
3260    let motion = Motion::Find { ch, forward, till };
3261    apply_op_with_motion(ed, op, &motion, total);
3262    ed.vim.last_find = Some((ch, forward, till));
3263    if !ed.vim.replaying && op_is_change(op) {
3264        ed.vim.last_change = Some(LastChange::OpMotion {
3265            op,
3266            motion,
3267            count: total,
3268            inserted: None,
3269        });
3270    }
3271    true
3272}
3273
3274fn handle_text_object<H: crate::types::Host>(
3275    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3276    input: Input,
3277    op: Operator,
3278    _count1: usize,
3279    inner: bool,
3280) -> bool {
3281    let Key::Char(ch) = input.key else {
3282        return true;
3283    };
3284    let obj = match ch {
3285        'w' => TextObject::Word { big: false },
3286        'W' => TextObject::Word { big: true },
3287        '"' | '\'' | '`' => TextObject::Quote(ch),
3288        '(' | ')' | 'b' => TextObject::Bracket('('),
3289        '[' | ']' => TextObject::Bracket('['),
3290        '{' | '}' | 'B' => TextObject::Bracket('{'),
3291        '<' | '>' => TextObject::Bracket('<'),
3292        'p' => TextObject::Paragraph,
3293        't' => TextObject::XmlTag,
3294        's' => TextObject::Sentence,
3295        _ => return true,
3296    };
3297    apply_op_with_text_object(ed, op, obj, inner);
3298    if !ed.vim.replaying && op_is_change(op) {
3299        ed.vim.last_change = Some(LastChange::OpTextObj {
3300            op,
3301            obj,
3302            inner,
3303            inserted: None,
3304        });
3305    }
3306    true
3307}
3308
3309fn handle_visual_text_obj<H: crate::types::Host>(
3310    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3311    input: Input,
3312    inner: bool,
3313) -> bool {
3314    let Key::Char(ch) = input.key else {
3315        return true;
3316    };
3317    let obj = match ch {
3318        'w' => TextObject::Word { big: false },
3319        'W' => TextObject::Word { big: true },
3320        '"' | '\'' | '`' => TextObject::Quote(ch),
3321        '(' | ')' | 'b' => TextObject::Bracket('('),
3322        '[' | ']' => TextObject::Bracket('['),
3323        '{' | '}' | 'B' => TextObject::Bracket('{'),
3324        '<' | '>' => TextObject::Bracket('<'),
3325        'p' => TextObject::Paragraph,
3326        't' => TextObject::XmlTag,
3327        's' => TextObject::Sentence,
3328        _ => return true,
3329    };
3330    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3331        return true;
3332    };
3333    // Anchor + cursor position the char-wise highlight / operator range;
3334    // for linewise text-objects we switch into VisualLine with the
3335    // appropriate row anchor.
3336    match kind {
3337        MotionKind::Linewise => {
3338            ed.vim.visual_line_anchor = start.0;
3339            ed.vim.mode = Mode::VisualLine;
3340            ed.jump_cursor(end.0, 0);
3341        }
3342        _ => {
3343            ed.vim.mode = Mode::Visual;
3344            ed.vim.visual_anchor = (start.0, start.1);
3345            let (er, ec) = retreat_one(ed, end);
3346            ed.jump_cursor(er, ec);
3347        }
3348    }
3349    true
3350}
3351
3352/// Move `pos` back by one character, clamped to (0, 0).
3353fn retreat_one<H: crate::types::Host>(
3354    ed: &Editor<hjkl_buffer::Buffer, H>,
3355    pos: (usize, usize),
3356) -> (usize, usize) {
3357    let (r, c) = pos;
3358    if c > 0 {
3359        (r, c - 1)
3360    } else if r > 0 {
3361        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
3362        (r - 1, prev_len)
3363    } else {
3364        (0, 0)
3365    }
3366}
3367
3368fn op_is_change(op: Operator) -> bool {
3369    matches!(op, Operator::Delete | Operator::Change)
3370}
3371
3372// ─── Normal-only commands (not motion, not operator) ───────────────────────
3373
3374fn handle_normal_only<H: crate::types::Host>(
3375    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3376    input: &Input,
3377    count: usize,
3378) -> bool {
3379    if input.ctrl {
3380        return false;
3381    }
3382    match input.key {
3383        Key::Char('i') => {
3384            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
3385            true
3386        }
3387        Key::Char('I') => {
3388            move_first_non_whitespace(ed);
3389            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
3390            true
3391        }
3392        Key::Char('a') => {
3393            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3394            ed.push_buffer_cursor_to_textarea();
3395            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
3396            true
3397        }
3398        Key::Char('A') => {
3399            crate::motions::move_line_end(&mut ed.buffer);
3400            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3401            ed.push_buffer_cursor_to_textarea();
3402            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
3403            true
3404        }
3405        Key::Char('R') => {
3406            // Replace mode — overstrike each typed cell. Reuses the
3407            // insert-mode key handler with a Replace-flavoured session.
3408            begin_insert(ed, count.max(1), InsertReason::Replace);
3409            true
3410        }
3411        Key::Char('o') => {
3412            use hjkl_buffer::{Edit, Position};
3413            ed.push_undo();
3414            // Snapshot BEFORE the newline so replay sees "\n<text>" as the
3415            // delta and produces one fresh line per iteration.
3416            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
3417            ed.sync_buffer_content_from_textarea();
3418            let row = buf_cursor_pos(&ed.buffer).row;
3419            let line_chars = buf_line_chars(&ed.buffer, row);
3420            // Smart/auto-indent based on the current line (becomes the
3421            // "previous" line for the freshly-opened line below).
3422            let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
3423            let indent = compute_enter_indent(&ed.settings, prev_line);
3424            ed.mutate_edit(Edit::InsertStr {
3425                at: Position::new(row, line_chars),
3426                text: format!("\n{indent}"),
3427            });
3428            ed.push_buffer_cursor_to_textarea();
3429            true
3430        }
3431        Key::Char('O') => {
3432            use hjkl_buffer::{Edit, Position};
3433            ed.push_undo();
3434            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
3435            ed.sync_buffer_content_from_textarea();
3436            let row = buf_cursor_pos(&ed.buffer).row;
3437            // The line opened above sits between row-1 and the current
3438            // row. Smart/auto-indent off the line above when there is
3439            // one; otherwise copy the current line's leading whitespace.
3440            let indent = if row > 0 {
3441                let above = buf_line(&ed.buffer, row - 1).unwrap_or_default();
3442                compute_enter_indent(&ed.settings, above)
3443            } else {
3444                let cur = buf_line(&ed.buffer, row).unwrap_or_default();
3445                cur.chars()
3446                    .take_while(|c| *c == ' ' || *c == '\t')
3447                    .collect::<String>()
3448            };
3449            ed.mutate_edit(Edit::InsertStr {
3450                at: Position::new(row, 0),
3451                text: format!("{indent}\n"),
3452            });
3453            // After insert, cursor sits on the surviving content one row
3454            // down — step back up onto the freshly-opened line, then to
3455            // the end of its indent so insert mode picks up where the
3456            // user expects to type.
3457            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3458            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
3459            let new_row = buf_cursor_pos(&ed.buffer).row;
3460            buf_set_cursor_rc(&mut ed.buffer, new_row, indent.chars().count());
3461            ed.push_buffer_cursor_to_textarea();
3462            true
3463        }
3464        Key::Char('x') => {
3465            do_char_delete(ed, true, count.max(1));
3466            if !ed.vim.replaying {
3467                ed.vim.last_change = Some(LastChange::CharDel {
3468                    forward: true,
3469                    count: count.max(1),
3470                });
3471            }
3472            true
3473        }
3474        Key::Char('X') => {
3475            do_char_delete(ed, false, count.max(1));
3476            if !ed.vim.replaying {
3477                ed.vim.last_change = Some(LastChange::CharDel {
3478                    forward: false,
3479                    count: count.max(1),
3480                });
3481            }
3482            true
3483        }
3484        Key::Char('~') => {
3485            for _ in 0..count.max(1) {
3486                ed.push_undo();
3487                toggle_case_at_cursor(ed);
3488            }
3489            if !ed.vim.replaying {
3490                ed.vim.last_change = Some(LastChange::ToggleCase {
3491                    count: count.max(1),
3492                });
3493            }
3494            true
3495        }
3496        Key::Char('J') => {
3497            for _ in 0..count.max(1) {
3498                ed.push_undo();
3499                join_line(ed);
3500            }
3501            if !ed.vim.replaying {
3502                ed.vim.last_change = Some(LastChange::JoinLine {
3503                    count: count.max(1),
3504                });
3505            }
3506            true
3507        }
3508        Key::Char('D') => {
3509            ed.push_undo();
3510            delete_to_eol(ed);
3511            // Vim parks the cursor on the new last char.
3512            crate::motions::move_left(&mut ed.buffer, 1);
3513            ed.push_buffer_cursor_to_textarea();
3514            if !ed.vim.replaying {
3515                ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
3516            }
3517            true
3518        }
3519        Key::Char('Y') => {
3520            // Vim 8 default: `Y` yanks to end of line (same as `y$`).
3521            apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
3522            true
3523        }
3524        Key::Char('C') => {
3525            ed.push_undo();
3526            delete_to_eol(ed);
3527            begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
3528            true
3529        }
3530        Key::Char('s') => {
3531            use hjkl_buffer::{Edit, MotionKind, Position};
3532            ed.push_undo();
3533            ed.sync_buffer_content_from_textarea();
3534            for _ in 0..count.max(1) {
3535                let cursor = buf_cursor_pos(&ed.buffer);
3536                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
3537                if cursor.col >= line_chars {
3538                    break;
3539                }
3540                ed.mutate_edit(Edit::DeleteRange {
3541                    start: cursor,
3542                    end: Position::new(cursor.row, cursor.col + 1),
3543                    kind: MotionKind::Char,
3544                });
3545            }
3546            ed.push_buffer_cursor_to_textarea();
3547            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3548            // `s` == `cl` — record as such.
3549            if !ed.vim.replaying {
3550                ed.vim.last_change = Some(LastChange::OpMotion {
3551                    op: Operator::Change,
3552                    motion: Motion::Right,
3553                    count: count.max(1),
3554                    inserted: None,
3555                });
3556            }
3557            true
3558        }
3559        Key::Char('p') => {
3560            do_paste(ed, false, count.max(1));
3561            if !ed.vim.replaying {
3562                ed.vim.last_change = Some(LastChange::Paste {
3563                    before: false,
3564                    count: count.max(1),
3565                });
3566            }
3567            true
3568        }
3569        Key::Char('P') => {
3570            do_paste(ed, true, count.max(1));
3571            if !ed.vim.replaying {
3572                ed.vim.last_change = Some(LastChange::Paste {
3573                    before: true,
3574                    count: count.max(1),
3575                });
3576            }
3577            true
3578        }
3579        Key::Char('u') => {
3580            do_undo(ed);
3581            true
3582        }
3583        Key::Char('r') => {
3584            ed.vim.count = count;
3585            ed.vim.pending = Pending::Replace;
3586            true
3587        }
3588        Key::Char('/') => {
3589            enter_search(ed, true);
3590            true
3591        }
3592        Key::Char('?') => {
3593            enter_search(ed, false);
3594            true
3595        }
3596        Key::Char('.') => {
3597            replay_last_change(ed, count);
3598            true
3599        }
3600        _ => false,
3601    }
3602}
3603
3604/// Variant of begin_insert that doesn't push_undo (caller already did).
3605fn begin_insert_noundo<H: crate::types::Host>(
3606    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3607    count: usize,
3608    reason: InsertReason,
3609) {
3610    let reason = if ed.vim.replaying {
3611        InsertReason::ReplayOnly
3612    } else {
3613        reason
3614    };
3615    let (row, _) = ed.cursor();
3616    ed.vim.insert_session = Some(InsertSession {
3617        count,
3618        row_min: row,
3619        row_max: row,
3620        before_lines: buf_lines_to_vec(&ed.buffer),
3621        reason,
3622    });
3623    ed.vim.mode = Mode::Insert;
3624}
3625
3626// ─── Operator × Motion application ─────────────────────────────────────────
3627
3628fn apply_op_with_motion<H: crate::types::Host>(
3629    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3630    op: Operator,
3631    motion: &Motion,
3632    count: usize,
3633) {
3634    let start = ed.cursor();
3635    // Tentatively apply motion to find the endpoint. Operator context
3636    // so `l` on the last char advances past-last (standard vim
3637    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
3638    // `yl` to cover the final char.
3639    apply_motion_cursor_ctx(ed, motion, count, true);
3640    let end = ed.cursor();
3641    let kind = motion_kind(motion);
3642    // Restore cursor before selecting (so Yank leaves cursor at start).
3643    ed.jump_cursor(start.0, start.1);
3644    run_operator_over_range(ed, op, start, end, kind);
3645}
3646
3647fn apply_op_with_text_object<H: crate::types::Host>(
3648    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3649    op: Operator,
3650    obj: TextObject,
3651    inner: bool,
3652) {
3653    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3654        return;
3655    };
3656    ed.jump_cursor(start.0, start.1);
3657    run_operator_over_range(ed, op, start, end, kind);
3658}
3659
3660fn motion_kind(motion: &Motion) -> MotionKind {
3661    match motion {
3662        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => MotionKind::Linewise,
3663        Motion::FileTop | Motion::FileBottom => MotionKind::Linewise,
3664        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
3665            MotionKind::Linewise
3666        }
3667        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
3668            MotionKind::Inclusive
3669        }
3670        Motion::Find { .. } => MotionKind::Inclusive,
3671        Motion::MatchBracket => MotionKind::Inclusive,
3672        // `$` now lands on the last char — operator ranges include it.
3673        Motion::LineEnd => MotionKind::Inclusive,
3674        _ => MotionKind::Exclusive,
3675    }
3676}
3677
3678fn run_operator_over_range<H: crate::types::Host>(
3679    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3680    op: Operator,
3681    start: (usize, usize),
3682    end: (usize, usize),
3683    kind: MotionKind,
3684) {
3685    let (top, bot) = order(start, end);
3686    if top == bot {
3687        return;
3688    }
3689
3690    match op {
3691        Operator::Yank => {
3692            let text = read_vim_range(ed, top, bot, kind);
3693            if !text.is_empty() {
3694                ed.record_yank_to_host(text.clone());
3695                ed.record_yank(text, matches!(kind, MotionKind::Linewise));
3696            }
3697            // Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
3698            // `]` = last yanked char. Mode-aware: linewise snaps to line
3699            // edges; charwise uses the actual inclusive endpoint.
3700            let rbr = match kind {
3701                MotionKind::Linewise => {
3702                    let last_col = buf_line_chars(&ed.buffer, bot.0).saturating_sub(1);
3703                    (bot.0, last_col)
3704                }
3705                MotionKind::Inclusive => (bot.0, bot.1),
3706                MotionKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
3707            };
3708            ed.set_mark('[', top);
3709            ed.set_mark(']', rbr);
3710            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3711            ed.push_buffer_cursor_to_textarea();
3712        }
3713        Operator::Delete => {
3714            ed.push_undo();
3715            cut_vim_range(ed, top, bot, kind);
3716            // After a charwise / inclusive delete the buffer cursor is
3717            // placed at `start` by the edit path. In Normal mode the
3718            // cursor max col is `line_len - 1`; clamp it here so e.g.
3719            // `d$` doesn't leave the cursor one past the new line end.
3720            if !matches!(kind, MotionKind::Linewise) {
3721                clamp_cursor_to_normal_mode(ed);
3722            }
3723            ed.vim.mode = Mode::Normal;
3724            // Vim `:h '[` / `:h ']`: after a delete both marks park at
3725            // the cursor position where the deletion collapsed (the join
3726            // point). Set after the cut and clamp so the position is final.
3727            let pos = ed.cursor();
3728            ed.set_mark('[', pos);
3729            ed.set_mark(']', pos);
3730        }
3731        Operator::Change => {
3732            // Vim `:h '[`: `[` is set to the start of the changed range
3733            // before the cut. `]` is deferred to insert-exit (AfterChange
3734            // path in finish_insert_session) where the cursor sits on the
3735            // last inserted char.
3736            ed.vim.change_mark_start = Some(top);
3737            ed.push_undo();
3738            cut_vim_range(ed, top, bot, kind);
3739            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3740        }
3741        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3742            apply_case_op_to_selection(ed, op, top, bot, kind);
3743        }
3744        Operator::Indent | Operator::Outdent => {
3745            // Indent / outdent are always linewise even when triggered
3746            // by a char-wise motion (e.g. `>w` indents the whole line).
3747            ed.push_undo();
3748            if op == Operator::Indent {
3749                indent_rows(ed, top.0, bot.0, 1);
3750            } else {
3751                outdent_rows(ed, top.0, bot.0, 1);
3752            }
3753            ed.vim.mode = Mode::Normal;
3754        }
3755        Operator::Fold => {
3756            // Always linewise — fold the spanned rows regardless of the
3757            // motion's natural kind. Cursor lands on `top.0` to mirror
3758            // the visual `zf` path.
3759            if bot.0 >= top.0 {
3760                ed.apply_fold_op(crate::types::FoldOp::Add {
3761                    start_row: top.0,
3762                    end_row: bot.0,
3763                    closed: true,
3764                });
3765            }
3766            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3767            ed.push_buffer_cursor_to_textarea();
3768            ed.vim.mode = Mode::Normal;
3769        }
3770        Operator::Reflow => {
3771            ed.push_undo();
3772            reflow_rows(ed, top.0, bot.0);
3773            ed.vim.mode = Mode::Normal;
3774        }
3775    }
3776}
3777
3778/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
3779/// Splits on blank-line boundaries so paragraph structure is
3780/// preserved. Each paragraph's words are joined with single spaces
3781/// before re-wrapping.
3782fn reflow_rows<H: crate::types::Host>(
3783    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3784    top: usize,
3785    bot: usize,
3786) {
3787    let width = ed.settings().textwidth.max(1);
3788    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3789    let bot = bot.min(lines.len().saturating_sub(1));
3790    if top > bot {
3791        return;
3792    }
3793    let original = lines[top..=bot].to_vec();
3794    let mut wrapped: Vec<String> = Vec::new();
3795    let mut paragraph: Vec<String> = Vec::new();
3796    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
3797        if para.is_empty() {
3798            return;
3799        }
3800        let words = para.join(" ");
3801        let mut current = String::new();
3802        for word in words.split_whitespace() {
3803            let extra = if current.is_empty() {
3804                word.chars().count()
3805            } else {
3806                current.chars().count() + 1 + word.chars().count()
3807            };
3808            if extra > width && !current.is_empty() {
3809                out.push(std::mem::take(&mut current));
3810                current.push_str(word);
3811            } else if current.is_empty() {
3812                current.push_str(word);
3813            } else {
3814                current.push(' ');
3815                current.push_str(word);
3816            }
3817        }
3818        if !current.is_empty() {
3819            out.push(current);
3820        }
3821        para.clear();
3822    };
3823    for line in &original {
3824        if line.trim().is_empty() {
3825            flush(&mut paragraph, &mut wrapped, width);
3826            wrapped.push(String::new());
3827        } else {
3828            paragraph.push(line.clone());
3829        }
3830    }
3831    flush(&mut paragraph, &mut wrapped, width);
3832
3833    // Splice back. push_undo above means `u` reverses.
3834    let after: Vec<String> = lines.split_off(bot + 1);
3835    lines.truncate(top);
3836    lines.extend(wrapped);
3837    lines.extend(after);
3838    ed.restore(lines, (top, 0));
3839    ed.mark_content_dirty();
3840}
3841
3842/// Transform the range `[top, bot]` (vim `MotionKind`) in place with
3843/// the given case operator. Cursor lands on `top` afterward — vim
3844/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
3845/// Preserves the textarea yank buffer (vim's case operators don't
3846/// touch registers).
3847fn apply_case_op_to_selection<H: crate::types::Host>(
3848    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3849    op: Operator,
3850    top: (usize, usize),
3851    bot: (usize, usize),
3852    kind: MotionKind,
3853) {
3854    use hjkl_buffer::Edit;
3855    ed.push_undo();
3856    let saved_yank = ed.yank().to_string();
3857    let saved_yank_linewise = ed.vim.yank_linewise;
3858    let selection = cut_vim_range(ed, top, bot, kind);
3859    let transformed = match op {
3860        Operator::Uppercase => selection.to_uppercase(),
3861        Operator::Lowercase => selection.to_lowercase(),
3862        Operator::ToggleCase => toggle_case_str(&selection),
3863        _ => unreachable!(),
3864    };
3865    if !transformed.is_empty() {
3866        let cursor = buf_cursor_pos(&ed.buffer);
3867        ed.mutate_edit(Edit::InsertStr {
3868            at: cursor,
3869            text: transformed,
3870        });
3871    }
3872    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3873    ed.push_buffer_cursor_to_textarea();
3874    ed.set_yank(saved_yank);
3875    ed.vim.yank_linewise = saved_yank_linewise;
3876    ed.vim.mode = Mode::Normal;
3877}
3878
3879/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
3880/// Rows that are empty are skipped (vim leaves blank lines alone when
3881/// indenting). `shiftwidth` is read from `editor.settings()` so
3882/// `:set shiftwidth=N` takes effect on the next operation.
3883fn indent_rows<H: crate::types::Host>(
3884    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3885    top: usize,
3886    bot: usize,
3887    count: usize,
3888) {
3889    ed.sync_buffer_content_from_textarea();
3890    let width = ed.settings().shiftwidth * count.max(1);
3891    let pad: String = " ".repeat(width);
3892    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3893    let bot = bot.min(lines.len().saturating_sub(1));
3894    for line in lines.iter_mut().take(bot + 1).skip(top) {
3895        if !line.is_empty() {
3896            line.insert_str(0, &pad);
3897        }
3898    }
3899    // Restore cursor to first non-blank of the top row so the next
3900    // vertical motion aims sensibly — matches vim's `>>` convention.
3901    ed.restore(lines, (top, 0));
3902    move_first_non_whitespace(ed);
3903}
3904
3905/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
3906/// each row in `[top, bot]`. Rows with less leading whitespace have
3907/// all their indent stripped, not clipped to zero length.
3908fn outdent_rows<H: crate::types::Host>(
3909    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3910    top: usize,
3911    bot: usize,
3912    count: usize,
3913) {
3914    ed.sync_buffer_content_from_textarea();
3915    let width = ed.settings().shiftwidth * count.max(1);
3916    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3917    let bot = bot.min(lines.len().saturating_sub(1));
3918    for line in lines.iter_mut().take(bot + 1).skip(top) {
3919        let strip: usize = line
3920            .chars()
3921            .take(width)
3922            .take_while(|c| *c == ' ' || *c == '\t')
3923            .count();
3924        if strip > 0 {
3925            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
3926            line.drain(..byte_len);
3927        }
3928    }
3929    ed.restore(lines, (top, 0));
3930    move_first_non_whitespace(ed);
3931}
3932
3933fn toggle_case_str(s: &str) -> String {
3934    s.chars()
3935        .map(|c| {
3936            if c.is_lowercase() {
3937                c.to_uppercase().next().unwrap_or(c)
3938            } else if c.is_uppercase() {
3939                c.to_lowercase().next().unwrap_or(c)
3940            } else {
3941                c
3942            }
3943        })
3944        .collect()
3945}
3946
3947fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
3948    if a <= b { (a, b) } else { (b, a) }
3949}
3950
3951/// Clamp the buffer cursor to normal-mode valid position: col may not
3952/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
3953/// line). Vim applies this clamp on every return to Normal mode after an
3954/// operator or Esc-from-insert.
3955fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3956    let (row, col) = ed.cursor();
3957    let line_chars = buf_line_chars(&ed.buffer, row);
3958    let max_col = line_chars.saturating_sub(1);
3959    if col > max_col {
3960        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
3961        ed.push_buffer_cursor_to_textarea();
3962    }
3963}
3964
3965// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
3966
3967fn execute_line_op<H: crate::types::Host>(
3968    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3969    op: Operator,
3970    count: usize,
3971) {
3972    let (row, col) = ed.cursor();
3973    let total = buf_row_count(&ed.buffer);
3974    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
3975
3976    match op {
3977        Operator::Yank => {
3978            // yy must not move the cursor.
3979            let text = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3980            if !text.is_empty() {
3981                ed.record_yank_to_host(text.clone());
3982                ed.record_yank(text, true);
3983            }
3984            // Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
3985            // (top_row, 0), `]` = (bot_row, last_col).
3986            let last_col = buf_line_chars(&ed.buffer, end_row).saturating_sub(1);
3987            ed.set_mark('[', (row, 0));
3988            ed.set_mark(']', (end_row, last_col));
3989            buf_set_cursor_rc(&mut ed.buffer, row, col);
3990            ed.push_buffer_cursor_to_textarea();
3991            ed.vim.mode = Mode::Normal;
3992        }
3993        Operator::Delete => {
3994            ed.push_undo();
3995            let deleted_through_last = end_row + 1 >= total;
3996            cut_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3997            // Vim's `dd` / `Ndd` leaves the cursor on the *first
3998            // non-blank* of the line that now occupies `row` — or, if
3999            // the deletion consumed the last line, the line above it.
4000            let total_after = buf_row_count(&ed.buffer);
4001            let raw_target = if deleted_through_last {
4002                row.saturating_sub(1).min(total_after.saturating_sub(1))
4003            } else {
4004                row.min(total_after.saturating_sub(1))
4005            };
4006            // Clamp off the trailing phantom empty row that arises from a
4007            // buffer with a trailing newline (stored as ["...", ""]). If
4008            // the target row is the trailing empty row and there is a real
4009            // content row above it, use that instead — matching vim's view
4010            // that the trailing `\n` is a terminator, not a separator.
4011            let target_row = if raw_target > 0
4012                && raw_target + 1 == total_after
4013                && buf_line(&ed.buffer, raw_target)
4014                    .map(str::is_empty)
4015                    .unwrap_or(false)
4016            {
4017                raw_target - 1
4018            } else {
4019                raw_target
4020            };
4021            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
4022            ed.push_buffer_cursor_to_textarea();
4023            move_first_non_whitespace(ed);
4024            ed.sticky_col = Some(ed.cursor().1);
4025            ed.vim.mode = Mode::Normal;
4026            // Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
4027            // post-delete cursor position (the join point).
4028            let pos = ed.cursor();
4029            ed.set_mark('[', pos);
4030            ed.set_mark(']', pos);
4031        }
4032        Operator::Change => {
4033            // `cc` / `3cc`: wipe contents of the covered lines but leave
4034            // a single blank line so insert-mode opens on it. Done as two
4035            // edits: drop rows past the first, then clear row `row`.
4036            use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4037            // Vim `:h '[`: stash change start for `]` deferral on insert-exit.
4038            ed.vim.change_mark_start = Some((row, 0));
4039            ed.push_undo();
4040            ed.sync_buffer_content_from_textarea();
4041            // Read the cut payload first so yank reflects every line.
4042            let payload = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
4043            if end_row > row {
4044                ed.mutate_edit(Edit::DeleteRange {
4045                    start: Position::new(row + 1, 0),
4046                    end: Position::new(end_row, 0),
4047                    kind: BufKind::Line,
4048                });
4049            }
4050            let line_chars = buf_line_chars(&ed.buffer, row);
4051            if line_chars > 0 {
4052                ed.mutate_edit(Edit::DeleteRange {
4053                    start: Position::new(row, 0),
4054                    end: Position::new(row, line_chars),
4055                    kind: BufKind::Char,
4056                });
4057            }
4058            if !payload.is_empty() {
4059                ed.record_yank_to_host(payload.clone());
4060                ed.record_delete(payload, true);
4061            }
4062            buf_set_cursor_rc(&mut ed.buffer, row, 0);
4063            ed.push_buffer_cursor_to_textarea();
4064            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4065        }
4066        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4067            // `gUU` / `guu` / `g~~` — linewise case transform over
4068            // [row, end_row]. Preserve cursor on `row` (first non-blank
4069            // lines up with vim's behaviour).
4070            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), MotionKind::Linewise);
4071            // After case-op on a linewise range vim puts the cursor on
4072            // the first non-blank of the starting line.
4073            move_first_non_whitespace(ed);
4074        }
4075        Operator::Indent | Operator::Outdent => {
4076            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
4077            ed.push_undo();
4078            if op == Operator::Indent {
4079                indent_rows(ed, row, end_row, 1);
4080            } else {
4081                outdent_rows(ed, row, end_row, 1);
4082            }
4083            ed.sticky_col = Some(ed.cursor().1);
4084            ed.vim.mode = Mode::Normal;
4085        }
4086        // No doubled form — `zfzf` is two consecutive `zf` chords.
4087        Operator::Fold => unreachable!("Fold has no line-op double"),
4088        Operator::Reflow => {
4089            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
4090            ed.push_undo();
4091            reflow_rows(ed, row, end_row);
4092            move_first_non_whitespace(ed);
4093            ed.sticky_col = Some(ed.cursor().1);
4094            ed.vim.mode = Mode::Normal;
4095        }
4096    }
4097}
4098
4099// ─── Visual mode operators ─────────────────────────────────────────────────
4100
4101fn apply_visual_operator<H: crate::types::Host>(
4102    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4103    op: Operator,
4104) {
4105    match ed.vim.mode {
4106        Mode::VisualLine => {
4107            let cursor_row = buf_cursor_pos(&ed.buffer).row;
4108            let top = cursor_row.min(ed.vim.visual_line_anchor);
4109            let bot = cursor_row.max(ed.vim.visual_line_anchor);
4110            ed.vim.yank_linewise = true;
4111            match op {
4112                Operator::Yank => {
4113                    let text = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4114                    if !text.is_empty() {
4115                        ed.record_yank_to_host(text.clone());
4116                        ed.record_yank(text, true);
4117                    }
4118                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4119                    ed.push_buffer_cursor_to_textarea();
4120                    ed.vim.mode = Mode::Normal;
4121                }
4122                Operator::Delete => {
4123                    ed.push_undo();
4124                    cut_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4125                    ed.vim.mode = Mode::Normal;
4126                }
4127                Operator::Change => {
4128                    // Vim `Vc`: wipe the line contents but leave a blank
4129                    // line in place so insert-mode starts on an empty row.
4130                    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4131                    ed.push_undo();
4132                    ed.sync_buffer_content_from_textarea();
4133                    let payload = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4134                    if bot > top {
4135                        ed.mutate_edit(Edit::DeleteRange {
4136                            start: Position::new(top + 1, 0),
4137                            end: Position::new(bot, 0),
4138                            kind: BufKind::Line,
4139                        });
4140                    }
4141                    let line_chars = buf_line_chars(&ed.buffer, top);
4142                    if line_chars > 0 {
4143                        ed.mutate_edit(Edit::DeleteRange {
4144                            start: Position::new(top, 0),
4145                            end: Position::new(top, line_chars),
4146                            kind: BufKind::Char,
4147                        });
4148                    }
4149                    if !payload.is_empty() {
4150                        ed.record_yank_to_host(payload.clone());
4151                        ed.record_delete(payload, true);
4152                    }
4153                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4154                    ed.push_buffer_cursor_to_textarea();
4155                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4156                }
4157                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4158                    let bot = buf_cursor_pos(&ed.buffer)
4159                        .row
4160                        .max(ed.vim.visual_line_anchor);
4161                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), MotionKind::Linewise);
4162                    move_first_non_whitespace(ed);
4163                }
4164                Operator::Indent | Operator::Outdent => {
4165                    ed.push_undo();
4166                    let (cursor_row, _) = ed.cursor();
4167                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4168                    if op == Operator::Indent {
4169                        indent_rows(ed, top, bot, 1);
4170                    } else {
4171                        outdent_rows(ed, top, bot, 1);
4172                    }
4173                    ed.vim.mode = Mode::Normal;
4174                }
4175                Operator::Reflow => {
4176                    ed.push_undo();
4177                    let (cursor_row, _) = ed.cursor();
4178                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4179                    reflow_rows(ed, top, bot);
4180                    ed.vim.mode = Mode::Normal;
4181                }
4182                // Visual `zf` is handled inline in `handle_after_z`,
4183                // never routed through this dispatcher.
4184                Operator::Fold => unreachable!("Visual zf takes its own path"),
4185            }
4186        }
4187        Mode::Visual => {
4188            ed.vim.yank_linewise = false;
4189            let anchor = ed.vim.visual_anchor;
4190            let cursor = ed.cursor();
4191            let (top, bot) = order(anchor, cursor);
4192            match op {
4193                Operator::Yank => {
4194                    let text = read_vim_range(ed, top, bot, MotionKind::Inclusive);
4195                    if !text.is_empty() {
4196                        ed.record_yank_to_host(text.clone());
4197                        ed.record_yank(text, false);
4198                    }
4199                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4200                    ed.push_buffer_cursor_to_textarea();
4201                    ed.vim.mode = Mode::Normal;
4202                }
4203                Operator::Delete => {
4204                    ed.push_undo();
4205                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4206                    ed.vim.mode = Mode::Normal;
4207                }
4208                Operator::Change => {
4209                    ed.push_undo();
4210                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4211                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4212                }
4213                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4214                    // Anchor stays where the visual selection started.
4215                    let anchor = ed.vim.visual_anchor;
4216                    let cursor = ed.cursor();
4217                    let (top, bot) = order(anchor, cursor);
4218                    apply_case_op_to_selection(ed, op, top, bot, MotionKind::Inclusive);
4219                }
4220                Operator::Indent | Operator::Outdent => {
4221                    ed.push_undo();
4222                    let anchor = ed.vim.visual_anchor;
4223                    let cursor = ed.cursor();
4224                    let (top, bot) = order(anchor, cursor);
4225                    if op == Operator::Indent {
4226                        indent_rows(ed, top.0, bot.0, 1);
4227                    } else {
4228                        outdent_rows(ed, top.0, bot.0, 1);
4229                    }
4230                    ed.vim.mode = Mode::Normal;
4231                }
4232                Operator::Reflow => {
4233                    ed.push_undo();
4234                    let anchor = ed.vim.visual_anchor;
4235                    let cursor = ed.cursor();
4236                    let (top, bot) = order(anchor, cursor);
4237                    reflow_rows(ed, top.0, bot.0);
4238                    ed.vim.mode = Mode::Normal;
4239                }
4240                Operator::Fold => unreachable!("Visual zf takes its own path"),
4241            }
4242        }
4243        Mode::VisualBlock => apply_block_operator(ed, op),
4244        _ => {}
4245    }
4246}
4247
4248/// Compute `(top_row, bot_row, left_col, right_col)` for the current
4249/// VisualBlock selection. Columns are inclusive on both ends. Uses the
4250/// tracked virtual column (updated by h/l, preserved across j/k) so
4251/// ragged / empty rows don't collapse the block's width.
4252fn block_bounds<H: crate::types::Host>(
4253    ed: &Editor<hjkl_buffer::Buffer, H>,
4254) -> (usize, usize, usize, usize) {
4255    let (ar, ac) = ed.vim.block_anchor;
4256    let (cr, _) = ed.cursor();
4257    let cc = ed.vim.block_vcol;
4258    let top = ar.min(cr);
4259    let bot = ar.max(cr);
4260    let left = ac.min(cc);
4261    let right = ac.max(cc);
4262    (top, bot, left, right)
4263}
4264
4265/// Update the virtual column after a motion in VisualBlock mode.
4266/// Horizontal motions sync `block_vcol` to the new cursor column;
4267/// vertical / non-h/l motions leave it alone so the intended column
4268/// survives clamping to shorter lines.
4269fn update_block_vcol<H: crate::types::Host>(
4270    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4271    motion: &Motion,
4272) {
4273    match motion {
4274        Motion::Left
4275        | Motion::Right
4276        | Motion::WordFwd
4277        | Motion::BigWordFwd
4278        | Motion::WordBack
4279        | Motion::BigWordBack
4280        | Motion::WordEnd
4281        | Motion::BigWordEnd
4282        | Motion::WordEndBack
4283        | Motion::BigWordEndBack
4284        | Motion::LineStart
4285        | Motion::FirstNonBlank
4286        | Motion::LineEnd
4287        | Motion::Find { .. }
4288        | Motion::FindRepeat { .. }
4289        | Motion::MatchBracket => {
4290            ed.vim.block_vcol = ed.cursor().1;
4291        }
4292        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
4293        _ => {}
4294    }
4295}
4296
4297/// Yank / delete / change / replace a rectangular selection. Yanked text
4298/// is stored as one string per row joined with `\n` so pasting reproduces
4299/// the block as sequential lines. (Vim's true block-paste reinserts as
4300/// columns; we render the content with our char-wise paste path.)
4301fn apply_block_operator<H: crate::types::Host>(
4302    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4303    op: Operator,
4304) {
4305    let (top, bot, left, right) = block_bounds(ed);
4306    // Snapshot the block text for yank / clipboard.
4307    let yank = block_yank(ed, top, bot, left, right);
4308
4309    match op {
4310        Operator::Yank => {
4311            if !yank.is_empty() {
4312                ed.record_yank_to_host(yank.clone());
4313                ed.record_yank(yank, false);
4314            }
4315            ed.vim.mode = Mode::Normal;
4316            ed.jump_cursor(top, left);
4317        }
4318        Operator::Delete => {
4319            ed.push_undo();
4320            delete_block_contents(ed, top, bot, left, right);
4321            if !yank.is_empty() {
4322                ed.record_yank_to_host(yank.clone());
4323                ed.record_delete(yank, false);
4324            }
4325            ed.vim.mode = Mode::Normal;
4326            ed.jump_cursor(top, left);
4327        }
4328        Operator::Change => {
4329            ed.push_undo();
4330            delete_block_contents(ed, top, bot, left, right);
4331            if !yank.is_empty() {
4332                ed.record_yank_to_host(yank.clone());
4333                ed.record_delete(yank, false);
4334            }
4335            ed.jump_cursor(top, left);
4336            begin_insert_noundo(
4337                ed,
4338                1,
4339                InsertReason::BlockEdge {
4340                    top,
4341                    bot,
4342                    col: left,
4343                },
4344            );
4345        }
4346        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4347            ed.push_undo();
4348            transform_block_case(ed, op, top, bot, left, right);
4349            ed.vim.mode = Mode::Normal;
4350            ed.jump_cursor(top, left);
4351        }
4352        Operator::Indent | Operator::Outdent => {
4353            // VisualBlock `>` / `<` falls back to linewise indent over
4354            // the block's row range — vim does the same (column-wise
4355            // indent/outdent doesn't make sense).
4356            ed.push_undo();
4357            if op == Operator::Indent {
4358                indent_rows(ed, top, bot, 1);
4359            } else {
4360                outdent_rows(ed, top, bot, 1);
4361            }
4362            ed.vim.mode = Mode::Normal;
4363        }
4364        Operator::Fold => unreachable!("Visual zf takes its own path"),
4365        Operator::Reflow => {
4366            // Reflow over the block falls back to linewise reflow over
4367            // the row range — column slicing for `gq` doesn't make
4368            // sense.
4369            ed.push_undo();
4370            reflow_rows(ed, top, bot);
4371            ed.vim.mode = Mode::Normal;
4372        }
4373    }
4374}
4375
4376/// In-place case transform over the rectangular block
4377/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
4378/// untouched — vim behaves the same way (ragged blocks).
4379fn transform_block_case<H: crate::types::Host>(
4380    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4381    op: Operator,
4382    top: usize,
4383    bot: usize,
4384    left: usize,
4385    right: usize,
4386) {
4387    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4388    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4389        let chars: Vec<char> = lines[r].chars().collect();
4390        if left >= chars.len() {
4391            continue;
4392        }
4393        let end = (right + 1).min(chars.len());
4394        let head: String = chars[..left].iter().collect();
4395        let mid: String = chars[left..end].iter().collect();
4396        let tail: String = chars[end..].iter().collect();
4397        let transformed = match op {
4398            Operator::Uppercase => mid.to_uppercase(),
4399            Operator::Lowercase => mid.to_lowercase(),
4400            Operator::ToggleCase => toggle_case_str(&mid),
4401            _ => mid,
4402        };
4403        lines[r] = format!("{head}{transformed}{tail}");
4404    }
4405    let saved_yank = ed.yank().to_string();
4406    let saved_linewise = ed.vim.yank_linewise;
4407    ed.restore(lines, (top, left));
4408    ed.set_yank(saved_yank);
4409    ed.vim.yank_linewise = saved_linewise;
4410}
4411
4412fn block_yank<H: crate::types::Host>(
4413    ed: &Editor<hjkl_buffer::Buffer, H>,
4414    top: usize,
4415    bot: usize,
4416    left: usize,
4417    right: usize,
4418) -> String {
4419    let lines = buf_lines_to_vec(&ed.buffer);
4420    let mut rows: Vec<String> = Vec::new();
4421    for r in top..=bot {
4422        let line = match lines.get(r) {
4423            Some(l) => l,
4424            None => break,
4425        };
4426        let chars: Vec<char> = line.chars().collect();
4427        let end = (right + 1).min(chars.len());
4428        if left >= chars.len() {
4429            rows.push(String::new());
4430        } else {
4431            rows.push(chars[left..end].iter().collect());
4432        }
4433    }
4434    rows.join("\n")
4435}
4436
4437fn delete_block_contents<H: crate::types::Host>(
4438    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4439    top: usize,
4440    bot: usize,
4441    left: usize,
4442    right: usize,
4443) {
4444    use hjkl_buffer::{Edit, MotionKind, Position};
4445    ed.sync_buffer_content_from_textarea();
4446    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
4447    if last_row < top {
4448        return;
4449    }
4450    ed.mutate_edit(Edit::DeleteRange {
4451        start: Position::new(top, left),
4452        end: Position::new(last_row, right),
4453        kind: MotionKind::Block,
4454    });
4455    ed.push_buffer_cursor_to_textarea();
4456}
4457
4458/// Replace each character cell in the block with `ch`.
4459fn block_replace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, ch: char) {
4460    let (top, bot, left, right) = block_bounds(ed);
4461    ed.push_undo();
4462    ed.sync_buffer_content_from_textarea();
4463    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4464    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4465        let chars: Vec<char> = lines[r].chars().collect();
4466        if left >= chars.len() {
4467            continue;
4468        }
4469        let end = (right + 1).min(chars.len());
4470        let before: String = chars[..left].iter().collect();
4471        let middle: String = std::iter::repeat_n(ch, end - left).collect();
4472        let after: String = chars[end..].iter().collect();
4473        lines[r] = format!("{before}{middle}{after}");
4474    }
4475    reset_textarea_lines(ed, lines);
4476    ed.vim.mode = Mode::Normal;
4477    ed.jump_cursor(top, left);
4478}
4479
4480/// Replace buffer content with `lines` while preserving the cursor.
4481/// Used by indent / outdent / block_replace to wholesale rewrite
4482/// rows without going through the per-edit funnel.
4483fn reset_textarea_lines<H: crate::types::Host>(
4484    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4485    lines: Vec<String>,
4486) {
4487    let cursor = ed.cursor();
4488    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
4489    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
4490    ed.mark_content_dirty();
4491}
4492
4493// ─── Visual-line helpers ───────────────────────────────────────────────────
4494
4495// ─── Text-object range computation ─────────────────────────────────────────
4496
4497/// Cursor position as `(row, col)`.
4498type Pos = (usize, usize);
4499
4500/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
4501/// last character to act on). `kind` is `Linewise` for line-oriented text
4502/// objects like paragraphs and `Exclusive` otherwise.
4503fn text_object_range<H: crate::types::Host>(
4504    ed: &Editor<hjkl_buffer::Buffer, H>,
4505    obj: TextObject,
4506    inner: bool,
4507) -> Option<(Pos, Pos, MotionKind)> {
4508    match obj {
4509        TextObject::Word { big } => {
4510            word_text_object(ed, inner, big).map(|(s, e)| (s, e, MotionKind::Exclusive))
4511        }
4512        TextObject::Quote(q) => {
4513            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4514        }
4515        TextObject::Bracket(open) => bracket_text_object(ed, open, inner),
4516        TextObject::Paragraph => {
4517            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Linewise))
4518        }
4519        TextObject::XmlTag => {
4520            tag_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4521        }
4522        TextObject::Sentence => {
4523            sentence_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4524        }
4525    }
4526}
4527
4528/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
4529/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
4530/// `None` when already at the buffer's edge in that direction.
4531fn sentence_boundary<H: crate::types::Host>(
4532    ed: &Editor<hjkl_buffer::Buffer, H>,
4533    forward: bool,
4534) -> Option<(usize, usize)> {
4535    let lines = buf_lines_to_vec(&ed.buffer);
4536    if lines.is_empty() {
4537        return None;
4538    }
4539    let pos_to_idx = |pos: (usize, usize)| -> usize {
4540        let mut idx = 0;
4541        for line in lines.iter().take(pos.0) {
4542            idx += line.chars().count() + 1;
4543        }
4544        idx + pos.1
4545    };
4546    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4547        for (r, line) in lines.iter().enumerate() {
4548            let len = line.chars().count();
4549            if idx <= len {
4550                return (r, idx);
4551            }
4552            idx -= len + 1;
4553        }
4554        let last = lines.len().saturating_sub(1);
4555        (last, lines[last].chars().count())
4556    };
4557    let mut chars: Vec<char> = Vec::new();
4558    for (r, line) in lines.iter().enumerate() {
4559        chars.extend(line.chars());
4560        if r + 1 < lines.len() {
4561            chars.push('\n');
4562        }
4563    }
4564    if chars.is_empty() {
4565        return None;
4566    }
4567    let total = chars.len();
4568    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
4569    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4570
4571    if forward {
4572        // Walk forward looking for a terminator run followed by
4573        // whitespace; land on the first non-whitespace cell after.
4574        let mut i = cursor_idx + 1;
4575        while i < total {
4576            if is_terminator(chars[i]) {
4577                while i + 1 < total && is_terminator(chars[i + 1]) {
4578                    i += 1;
4579                }
4580                if i + 1 >= total {
4581                    return None;
4582                }
4583                if chars[i + 1].is_whitespace() {
4584                    let mut j = i + 1;
4585                    while j < total && chars[j].is_whitespace() {
4586                        j += 1;
4587                    }
4588                    if j >= total {
4589                        return None;
4590                    }
4591                    return Some(idx_to_pos(j));
4592                }
4593            }
4594            i += 1;
4595        }
4596        None
4597    } else {
4598        // Walk backward to find the start of the current sentence (if
4599        // we're already at the start, jump to the previous sentence's
4600        // start instead).
4601        let find_start = |from: usize| -> Option<usize> {
4602            let mut start = from;
4603            while start > 0 {
4604                let prev = chars[start - 1];
4605                if prev.is_whitespace() {
4606                    let mut k = start - 1;
4607                    while k > 0 && chars[k - 1].is_whitespace() {
4608                        k -= 1;
4609                    }
4610                    if k > 0 && is_terminator(chars[k - 1]) {
4611                        break;
4612                    }
4613                }
4614                start -= 1;
4615            }
4616            while start < total && chars[start].is_whitespace() {
4617                start += 1;
4618            }
4619            (start < total).then_some(start)
4620        };
4621        let current_start = find_start(cursor_idx)?;
4622        if current_start < cursor_idx {
4623            return Some(idx_to_pos(current_start));
4624        }
4625        // Already at the sentence start — step over the boundary into
4626        // the previous sentence and find its start.
4627        let mut k = current_start;
4628        while k > 0 && chars[k - 1].is_whitespace() {
4629            k -= 1;
4630        }
4631        if k == 0 {
4632            return None;
4633        }
4634        let prev_start = find_start(k - 1)?;
4635        Some(idx_to_pos(prev_start))
4636    }
4637}
4638
4639/// `is` / `as` — sentence: text up to and including the next sentence
4640/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
4641/// whitespace (or end-of-line) as a boundary; runs of consecutive
4642/// terminators stay attached to the same sentence. `as` extends to
4643/// include trailing whitespace; `is` does not.
4644fn sentence_text_object<H: crate::types::Host>(
4645    ed: &Editor<hjkl_buffer::Buffer, H>,
4646    inner: bool,
4647) -> Option<((usize, usize), (usize, usize))> {
4648    let lines = buf_lines_to_vec(&ed.buffer);
4649    if lines.is_empty() {
4650        return None;
4651    }
4652    // Flatten the buffer so a sentence can span lines (vim's behaviour).
4653    // Newlines count as whitespace for boundary detection.
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    if chars.is_empty() {
4680        return None;
4681    }
4682
4683    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
4684    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4685
4686    // Walk backward from cursor to find the start of the current
4687    // sentence. A boundary is: whitespace immediately after a run of
4688    // terminators (or start-of-buffer).
4689    let mut start = cursor_idx;
4690    while start > 0 {
4691        let prev = chars[start - 1];
4692        if prev.is_whitespace() {
4693            // Check if the whitespace follows a terminator — if so,
4694            // we've crossed a sentence boundary; the sentence begins
4695            // at the first non-whitespace cell *after* this run.
4696            let mut k = start - 1;
4697            while k > 0 && chars[k - 1].is_whitespace() {
4698                k -= 1;
4699            }
4700            if k > 0 && is_terminator(chars[k - 1]) {
4701                break;
4702            }
4703        }
4704        start -= 1;
4705    }
4706    // Skip leading whitespace (vim doesn't include it in the
4707    // sentence body).
4708    while start < chars.len() && chars[start].is_whitespace() {
4709        start += 1;
4710    }
4711    if start >= chars.len() {
4712        return None;
4713    }
4714
4715    // Walk forward to the sentence end (last terminator before the
4716    // next whitespace boundary).
4717    let mut end = start;
4718    while end < chars.len() {
4719        if is_terminator(chars[end]) {
4720            // Consume any consecutive terminators (e.g. `?!`).
4721            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
4722                end += 1;
4723            }
4724            // If followed by whitespace or end-of-buffer, that's the
4725            // boundary.
4726            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
4727                break;
4728            }
4729        }
4730        end += 1;
4731    }
4732    // Inclusive end → exclusive end_idx.
4733    let end_idx = (end + 1).min(chars.len());
4734
4735    let final_end = if inner {
4736        end_idx
4737    } else {
4738        // `as`: include trailing whitespace (but stop before the next
4739        // newline so we don't gobble a paragraph break — vim keeps
4740        // sentences within a paragraph for the trailing-ws extension).
4741        let mut e = end_idx;
4742        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
4743            e += 1;
4744        }
4745        e
4746    };
4747
4748    Some((idx_to_pos(start), idx_to_pos(final_end)))
4749}
4750
4751/// `it` / `at` — XML tag pair text object. Builds a flat char index of
4752/// the buffer, walks `<...>` tokens to pair tags via a stack, and
4753/// returns the innermost pair containing the cursor.
4754fn tag_text_object<H: crate::types::Host>(
4755    ed: &Editor<hjkl_buffer::Buffer, H>,
4756    inner: bool,
4757) -> Option<((usize, usize), (usize, usize))> {
4758    let lines = buf_lines_to_vec(&ed.buffer);
4759    if lines.is_empty() {
4760        return None;
4761    }
4762    // Flatten char positions so we can compare cursor against tag
4763    // ranges without per-row arithmetic. `\n` between lines counts as
4764    // a single char.
4765    let pos_to_idx = |pos: (usize, usize)| -> usize {
4766        let mut idx = 0;
4767        for line in lines.iter().take(pos.0) {
4768            idx += line.chars().count() + 1;
4769        }
4770        idx + pos.1
4771    };
4772    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4773        for (r, line) in lines.iter().enumerate() {
4774            let len = line.chars().count();
4775            if idx <= len {
4776                return (r, idx);
4777            }
4778            idx -= len + 1;
4779        }
4780        let last = lines.len().saturating_sub(1);
4781        (last, lines[last].chars().count())
4782    };
4783    let mut chars: Vec<char> = Vec::new();
4784    for (r, line) in lines.iter().enumerate() {
4785        chars.extend(line.chars());
4786        if r + 1 < lines.len() {
4787            chars.push('\n');
4788        }
4789    }
4790    let cursor_idx = pos_to_idx(ed.cursor());
4791
4792    // Walk `<...>` tokens. Track open tags on a stack; on a matching
4793    // close pop and consider the pair a candidate when the cursor lies
4794    // inside its content range. Innermost wins (replace whenever a
4795    // tighter range turns up). Also track the first complete pair that
4796    // starts at or after the cursor so we can fall back to a forward
4797    // scan (targets.vim-style) when the cursor isn't inside any tag.
4798    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
4799    let mut innermost: Option<(usize, usize, usize, usize)> = None;
4800    let mut next_after: Option<(usize, usize, usize, usize)> = None;
4801    let mut i = 0;
4802    while i < chars.len() {
4803        if chars[i] != '<' {
4804            i += 1;
4805            continue;
4806        }
4807        let mut j = i + 1;
4808        while j < chars.len() && chars[j] != '>' {
4809            j += 1;
4810        }
4811        if j >= chars.len() {
4812            break;
4813        }
4814        let inside: String = chars[i + 1..j].iter().collect();
4815        let close_end = j + 1;
4816        let trimmed = inside.trim();
4817        if trimmed.starts_with('!') || trimmed.starts_with('?') {
4818            i = close_end;
4819            continue;
4820        }
4821        if let Some(rest) = trimmed.strip_prefix('/') {
4822            let name = rest.split_whitespace().next().unwrap_or("").to_string();
4823            if !name.is_empty()
4824                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
4825            {
4826                let (open_start, content_start, _) = stack[stack_idx].clone();
4827                stack.truncate(stack_idx);
4828                let content_end = i;
4829                let candidate = (open_start, content_start, content_end, close_end);
4830                if cursor_idx >= content_start && cursor_idx <= content_end {
4831                    innermost = match innermost {
4832                        Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
4833                            Some(candidate)
4834                        }
4835                        None => Some(candidate),
4836                        existing => existing,
4837                    };
4838                } else if open_start >= cursor_idx && next_after.is_none() {
4839                    next_after = Some(candidate);
4840                }
4841            }
4842        } else if !trimmed.ends_with('/') {
4843            let name: String = trimmed
4844                .split(|c: char| c.is_whitespace() || c == '/')
4845                .next()
4846                .unwrap_or("")
4847                .to_string();
4848            if !name.is_empty() {
4849                stack.push((i, close_end, name));
4850            }
4851        }
4852        i = close_end;
4853    }
4854
4855    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
4856    if inner {
4857        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
4858    } else {
4859        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
4860    }
4861}
4862
4863fn is_wordchar(c: char) -> bool {
4864    c.is_alphanumeric() || c == '_'
4865}
4866
4867// `is_keyword_char` lives in hjkl-buffer (used by word motions);
4868// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
4869// one parser, one default, one bug surface.
4870pub(crate) use hjkl_buffer::is_keyword_char;
4871
4872fn word_text_object<H: crate::types::Host>(
4873    ed: &Editor<hjkl_buffer::Buffer, H>,
4874    inner: bool,
4875    big: bool,
4876) -> Option<((usize, usize), (usize, usize))> {
4877    let (row, col) = ed.cursor();
4878    let line = buf_line(&ed.buffer, row)?;
4879    let chars: Vec<char> = line.chars().collect();
4880    if chars.is_empty() {
4881        return None;
4882    }
4883    let at = col.min(chars.len().saturating_sub(1));
4884    let classify = |c: char| -> u8 {
4885        if c.is_whitespace() {
4886            0
4887        } else if big || is_wordchar(c) {
4888            1
4889        } else {
4890            2
4891        }
4892    };
4893    let cls = classify(chars[at]);
4894    let mut start = at;
4895    while start > 0 && classify(chars[start - 1]) == cls {
4896        start -= 1;
4897    }
4898    let mut end = at;
4899    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
4900        end += 1;
4901    }
4902    // Byte-offset helpers.
4903    let char_byte = |i: usize| {
4904        if i >= chars.len() {
4905            line.len()
4906        } else {
4907            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
4908        }
4909    };
4910    let mut start_col = char_byte(start);
4911    // Exclusive end: byte index of char AFTER the last-included char.
4912    let mut end_col = char_byte(end + 1);
4913    if !inner {
4914        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
4915        let mut t = end + 1;
4916        let mut included_trailing = false;
4917        while t < chars.len() && chars[t].is_whitespace() {
4918            included_trailing = true;
4919            t += 1;
4920        }
4921        if included_trailing {
4922            end_col = char_byte(t);
4923        } else {
4924            let mut s = start;
4925            while s > 0 && chars[s - 1].is_whitespace() {
4926                s -= 1;
4927            }
4928            start_col = char_byte(s);
4929        }
4930    }
4931    Some(((row, start_col), (row, end_col)))
4932}
4933
4934fn quote_text_object<H: crate::types::Host>(
4935    ed: &Editor<hjkl_buffer::Buffer, H>,
4936    q: char,
4937    inner: bool,
4938) -> Option<((usize, usize), (usize, usize))> {
4939    let (row, col) = ed.cursor();
4940    let line = buf_line(&ed.buffer, row)?;
4941    let bytes = line.as_bytes();
4942    let q_byte = q as u8;
4943    // Find opening and closing quote on the same line.
4944    let mut positions: Vec<usize> = Vec::new();
4945    for (i, &b) in bytes.iter().enumerate() {
4946        if b == q_byte {
4947            positions.push(i);
4948        }
4949    }
4950    if positions.len() < 2 {
4951        return None;
4952    }
4953    let mut open_idx: Option<usize> = None;
4954    let mut close_idx: Option<usize> = None;
4955    for pair in positions.chunks(2) {
4956        if pair.len() < 2 {
4957            break;
4958        }
4959        if col >= pair[0] && col <= pair[1] {
4960            open_idx = Some(pair[0]);
4961            close_idx = Some(pair[1]);
4962            break;
4963        }
4964        if col < pair[0] {
4965            open_idx = Some(pair[0]);
4966            close_idx = Some(pair[1]);
4967            break;
4968        }
4969    }
4970    let open = open_idx?;
4971    let close = close_idx?;
4972    // End columns are *exclusive* — one past the last character to act on.
4973    if inner {
4974        if close <= open + 1 {
4975            return None;
4976        }
4977        Some(((row, open + 1), (row, close)))
4978    } else {
4979        // `da<q>` — "around" includes the surrounding whitespace on one
4980        // side: trailing whitespace if any exists after the closing quote;
4981        // otherwise leading whitespace before the opening quote. This
4982        // matches vim's `:help text-objects` behaviour and avoids leaving
4983        // a double-space when the quoted span sits mid-sentence.
4984        let after_close = close + 1; // byte index after closing quote
4985        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
4986            // Eat trailing whitespace run.
4987            let mut end = after_close;
4988            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
4989                end += 1;
4990            }
4991            Some(((row, open), (row, end)))
4992        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
4993            // Eat leading whitespace run.
4994            let mut start = open;
4995            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
4996                start -= 1;
4997            }
4998            Some(((row, start), (row, close + 1)))
4999        } else {
5000            Some(((row, open), (row, close + 1)))
5001        }
5002    }
5003}
5004
5005fn bracket_text_object<H: crate::types::Host>(
5006    ed: &Editor<hjkl_buffer::Buffer, H>,
5007    open: char,
5008    inner: bool,
5009) -> Option<(Pos, Pos, MotionKind)> {
5010    let close = match open {
5011        '(' => ')',
5012        '[' => ']',
5013        '{' => '}',
5014        '<' => '>',
5015        _ => return None,
5016    };
5017    let (row, col) = ed.cursor();
5018    let lines = buf_lines_to_vec(&ed.buffer);
5019    let lines = lines.as_slice();
5020    // Walk backward from cursor to find unbalanced opening. When the
5021    // cursor isn't inside any pair, fall back to scanning forward for
5022    // the next opening bracket (targets.vim-style: `ci(` works when
5023    // cursor is before the `(` on the same line or below).
5024    let open_pos = find_open_bracket(lines, row, col, open, close)
5025        .or_else(|| find_next_open(lines, row, col, open))?;
5026    let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
5027    // End positions are *exclusive*.
5028    if inner {
5029        // Multi-line `iB` / `i{` etc: vim deletes the full lines between
5030        // the braces (linewise), preserving the `{` and `}` lines
5031        // themselves and the newlines that directly abut them. E.g.:
5032        //   {\n    body\n}\n  →  {\n}\n    (cursor on `}` line)
5033        // Single-line `i{` falls back to charwise exclusive.
5034        if close_pos.0 > open_pos.0 + 1 {
5035            // There is at least one line strictly between open and close.
5036            let inner_row_start = open_pos.0 + 1;
5037            let inner_row_end = close_pos.0 - 1;
5038            let end_col = lines
5039                .get(inner_row_end)
5040                .map(|l| l.chars().count())
5041                .unwrap_or(0);
5042            return Some((
5043                (inner_row_start, 0),
5044                (inner_row_end, end_col),
5045                MotionKind::Linewise,
5046            ));
5047        }
5048        let inner_start = advance_pos(lines, open_pos);
5049        if inner_start.0 > close_pos.0
5050            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
5051        {
5052            return None;
5053        }
5054        Some((inner_start, close_pos, MotionKind::Exclusive))
5055    } else {
5056        Some((
5057            open_pos,
5058            advance_pos(lines, close_pos),
5059            MotionKind::Exclusive,
5060        ))
5061    }
5062}
5063
5064fn find_open_bracket(
5065    lines: &[String],
5066    row: usize,
5067    col: usize,
5068    open: char,
5069    close: char,
5070) -> Option<(usize, usize)> {
5071    let mut depth: i32 = 0;
5072    let mut r = row;
5073    let mut c = col as isize;
5074    loop {
5075        let cur = &lines[r];
5076        let chars: Vec<char> = cur.chars().collect();
5077        // Clamp `c` to the line length: callers may seed `col` past
5078        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
5079        // so direct indexing would panic on empty / short lines.
5080        if (c as usize) >= chars.len() {
5081            c = chars.len() as isize - 1;
5082        }
5083        while c >= 0 {
5084            let ch = chars[c as usize];
5085            if ch == close {
5086                depth += 1;
5087            } else if ch == open {
5088                if depth == 0 {
5089                    return Some((r, c as usize));
5090                }
5091                depth -= 1;
5092            }
5093            c -= 1;
5094        }
5095        if r == 0 {
5096            return None;
5097        }
5098        r -= 1;
5099        c = lines[r].chars().count() as isize - 1;
5100    }
5101}
5102
5103fn find_close_bracket(
5104    lines: &[String],
5105    row: usize,
5106    start_col: usize,
5107    open: char,
5108    close: char,
5109) -> Option<(usize, usize)> {
5110    let mut depth: i32 = 0;
5111    let mut r = row;
5112    let mut c = start_col;
5113    loop {
5114        let cur = &lines[r];
5115        let chars: Vec<char> = cur.chars().collect();
5116        while c < chars.len() {
5117            let ch = chars[c];
5118            if ch == open {
5119                depth += 1;
5120            } else if ch == close {
5121                if depth == 0 {
5122                    return Some((r, c));
5123                }
5124                depth -= 1;
5125            }
5126            c += 1;
5127        }
5128        if r + 1 >= lines.len() {
5129            return None;
5130        }
5131        r += 1;
5132        c = 0;
5133    }
5134}
5135
5136/// Forward scan from `(row, col)` for the next occurrence of `open`.
5137/// Multi-line. Used by bracket text objects to support targets.vim-style
5138/// "search forward when not currently inside a pair" behaviour.
5139fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
5140    let mut r = row;
5141    let mut c = col;
5142    while r < lines.len() {
5143        let chars: Vec<char> = lines[r].chars().collect();
5144        while c < chars.len() {
5145            if chars[c] == open {
5146                return Some((r, c));
5147            }
5148            c += 1;
5149        }
5150        r += 1;
5151        c = 0;
5152    }
5153    None
5154}
5155
5156fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
5157    let (r, c) = pos;
5158    let line_len = lines[r].chars().count();
5159    if c < line_len {
5160        (r, c + 1)
5161    } else if r + 1 < lines.len() {
5162        (r + 1, 0)
5163    } else {
5164        pos
5165    }
5166}
5167
5168fn paragraph_text_object<H: crate::types::Host>(
5169    ed: &Editor<hjkl_buffer::Buffer, H>,
5170    inner: bool,
5171) -> Option<((usize, usize), (usize, usize))> {
5172    let (row, _) = ed.cursor();
5173    let lines = buf_lines_to_vec(&ed.buffer);
5174    if lines.is_empty() {
5175        return None;
5176    }
5177    // A paragraph is a run of non-blank lines.
5178    let is_blank = |r: usize| lines.get(r).map(|s| s.trim().is_empty()).unwrap_or(true);
5179    if is_blank(row) {
5180        return None;
5181    }
5182    let mut top = row;
5183    while top > 0 && !is_blank(top - 1) {
5184        top -= 1;
5185    }
5186    let mut bot = row;
5187    while bot + 1 < lines.len() && !is_blank(bot + 1) {
5188        bot += 1;
5189    }
5190    // For `ap`, include one trailing blank line if present.
5191    if !inner && bot + 1 < lines.len() && is_blank(bot + 1) {
5192        bot += 1;
5193    }
5194    let end_col = lines[bot].chars().count();
5195    Some(((top, 0), (bot, end_col)))
5196}
5197
5198// ─── Individual commands ───────────────────────────────────────────────────
5199
5200/// Read the text in a vim-shaped range without mutating. Used by
5201/// `Operator::Yank` so we can pipe the same range translation as
5202/// [`cut_vim_range`] but skip the delete + inverse extraction.
5203fn read_vim_range<H: crate::types::Host>(
5204    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5205    start: (usize, usize),
5206    end: (usize, usize),
5207    kind: MotionKind,
5208) -> String {
5209    let (top, bot) = order(start, end);
5210    ed.sync_buffer_content_from_textarea();
5211    let lines = buf_lines_to_vec(&ed.buffer);
5212    match kind {
5213        MotionKind::Linewise => {
5214            let lo = top.0;
5215            let hi = bot.0.min(lines.len().saturating_sub(1));
5216            let mut text = lines[lo..=hi].join("\n");
5217            text.push('\n');
5218            text
5219        }
5220        MotionKind::Inclusive | MotionKind::Exclusive => {
5221            let inclusive = matches!(kind, MotionKind::Inclusive);
5222            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
5223            let mut out = String::new();
5224            for row in top.0..=bot.0 {
5225                let line = lines.get(row).map(String::as_str).unwrap_or("");
5226                let lo = if row == top.0 { top.1 } else { 0 };
5227                let hi_unclamped = if row == bot.0 {
5228                    if inclusive { bot.1 + 1 } else { bot.1 }
5229                } else {
5230                    line.chars().count() + 1
5231                };
5232                let row_chars: Vec<char> = line.chars().collect();
5233                let hi = hi_unclamped.min(row_chars.len());
5234                if lo < hi {
5235                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
5236                }
5237                if row < bot.0 {
5238                    out.push('\n');
5239                }
5240            }
5241            out
5242        }
5243    }
5244}
5245
5246/// Cut a vim-shaped range through the Buffer edit funnel and return
5247/// the deleted text. Translates vim's `MotionKind`
5248/// (Linewise/Inclusive/Exclusive) into the buffer's
5249/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
5250/// position adjustment so inclusive motions actually include the bot
5251/// cell. Pushes the cut text into both `last_yank` and the textarea
5252/// yank buffer (still observed by `p`/`P` until the paste path is
5253/// ported), and updates `yank_linewise` for linewise cuts.
5254fn cut_vim_range<H: crate::types::Host>(
5255    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5256    start: (usize, usize),
5257    end: (usize, usize),
5258    kind: MotionKind,
5259) -> String {
5260    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5261    let (top, bot) = order(start, end);
5262    ed.sync_buffer_content_from_textarea();
5263    let (buf_start, buf_end, buf_kind) = match kind {
5264        MotionKind::Linewise => (
5265            Position::new(top.0, 0),
5266            Position::new(bot.0, 0),
5267            BufKind::Line,
5268        ),
5269        MotionKind::Inclusive => {
5270            let line_chars = buf_line_chars(&ed.buffer, bot.0);
5271            // Advance one cell past `bot` so the buffer's exclusive
5272            // `cut_chars` actually drops the inclusive endpoint. Wrap
5273            // to the next row when bot already sits on the last char.
5274            let next = if bot.1 < line_chars {
5275                Position::new(bot.0, bot.1 + 1)
5276            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
5277                Position::new(bot.0 + 1, 0)
5278            } else {
5279                Position::new(bot.0, line_chars)
5280            };
5281            (Position::new(top.0, top.1), next, BufKind::Char)
5282        }
5283        MotionKind::Exclusive => (
5284            Position::new(top.0, top.1),
5285            Position::new(bot.0, bot.1),
5286            BufKind::Char,
5287        ),
5288    };
5289    let inverse = ed.mutate_edit(Edit::DeleteRange {
5290        start: buf_start,
5291        end: buf_end,
5292        kind: buf_kind,
5293    });
5294    let text = match inverse {
5295        Edit::InsertStr { text, .. } => text,
5296        _ => String::new(),
5297    };
5298    if !text.is_empty() {
5299        ed.record_yank_to_host(text.clone());
5300        ed.record_delete(text.clone(), matches!(kind, MotionKind::Linewise));
5301    }
5302    ed.push_buffer_cursor_to_textarea();
5303    text
5304}
5305
5306/// `D` / `C` — delete from cursor to end of line through the edit
5307/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
5308/// textarea's yank buffer (still observed by `p`/`P` until the paste
5309/// path is ported). Cursor lands at the deletion start so the caller
5310/// can decide whether to step it left (`D`) or open insert mode (`C`).
5311fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5312    use hjkl_buffer::{Edit, MotionKind, Position};
5313    ed.sync_buffer_content_from_textarea();
5314    let cursor = buf_cursor_pos(&ed.buffer);
5315    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5316    if cursor.col >= line_chars {
5317        return;
5318    }
5319    let inverse = ed.mutate_edit(Edit::DeleteRange {
5320        start: cursor,
5321        end: Position::new(cursor.row, line_chars),
5322        kind: MotionKind::Char,
5323    });
5324    if let Edit::InsertStr { text, .. } = inverse
5325        && !text.is_empty()
5326    {
5327        ed.record_yank_to_host(text.clone());
5328        ed.vim.yank_linewise = false;
5329        ed.set_yank(text);
5330    }
5331    buf_set_cursor_pos(&mut ed.buffer, cursor);
5332    ed.push_buffer_cursor_to_textarea();
5333}
5334
5335fn do_char_delete<H: crate::types::Host>(
5336    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5337    forward: bool,
5338    count: usize,
5339) {
5340    use hjkl_buffer::{Edit, MotionKind, Position};
5341    ed.push_undo();
5342    ed.sync_buffer_content_from_textarea();
5343    // Collect deleted chars so we can write them to the unnamed register
5344    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
5345    let mut deleted = String::new();
5346    for _ in 0..count {
5347        let cursor = buf_cursor_pos(&ed.buffer);
5348        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5349        if forward {
5350            // `x` — delete the char under the cursor. Vim no-ops on
5351            // an empty line; the buffer would drop a row otherwise.
5352            if cursor.col >= line_chars {
5353                continue;
5354            }
5355            let inverse = ed.mutate_edit(Edit::DeleteRange {
5356                start: cursor,
5357                end: Position::new(cursor.row, cursor.col + 1),
5358                kind: MotionKind::Char,
5359            });
5360            if let Edit::InsertStr { text, .. } = inverse {
5361                deleted.push_str(&text);
5362            }
5363        } else {
5364            // `X` — delete the char before the cursor.
5365            if cursor.col == 0 {
5366                continue;
5367            }
5368            let inverse = ed.mutate_edit(Edit::DeleteRange {
5369                start: Position::new(cursor.row, cursor.col - 1),
5370                end: cursor,
5371                kind: MotionKind::Char,
5372            });
5373            if let Edit::InsertStr { text, .. } = inverse {
5374                // X deletes backwards; prepend so the register text
5375                // matches reading order (first deleted char first).
5376                deleted = text + &deleted;
5377            }
5378        }
5379    }
5380    if !deleted.is_empty() {
5381        ed.record_yank_to_host(deleted.clone());
5382        ed.record_delete(deleted, false);
5383    }
5384    ed.push_buffer_cursor_to_textarea();
5385}
5386
5387/// Vim `Ctrl-a` / `Ctrl-x` — find the next decimal number at or after the
5388/// cursor on the current line, add `delta`, leave the cursor on the last
5389/// digit of the result. No-op if the line has no digits to the right.
5390fn adjust_number<H: crate::types::Host>(
5391    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5392    delta: i64,
5393) -> bool {
5394    use hjkl_buffer::{Edit, MotionKind, Position};
5395    ed.sync_buffer_content_from_textarea();
5396    let cursor = buf_cursor_pos(&ed.buffer);
5397    let row = cursor.row;
5398    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
5399        Some(l) => l.chars().collect(),
5400        None => return false,
5401    };
5402    let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
5403        return false;
5404    };
5405    let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
5406        digit_start - 1
5407    } else {
5408        digit_start
5409    };
5410    let mut span_end = digit_start;
5411    while span_end < chars.len() && chars[span_end].is_ascii_digit() {
5412        span_end += 1;
5413    }
5414    let s: String = chars[span_start..span_end].iter().collect();
5415    let Ok(n) = s.parse::<i64>() else {
5416        return false;
5417    };
5418    let new_s = n.saturating_add(delta).to_string();
5419
5420    ed.push_undo();
5421    let span_start_pos = Position::new(row, span_start);
5422    let span_end_pos = Position::new(row, span_end);
5423    ed.mutate_edit(Edit::DeleteRange {
5424        start: span_start_pos,
5425        end: span_end_pos,
5426        kind: MotionKind::Char,
5427    });
5428    ed.mutate_edit(Edit::InsertStr {
5429        at: span_start_pos,
5430        text: new_s.clone(),
5431    });
5432    let new_len = new_s.chars().count();
5433    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
5434    ed.push_buffer_cursor_to_textarea();
5435    true
5436}
5437
5438pub(crate) fn replace_char<H: crate::types::Host>(
5439    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5440    ch: char,
5441    count: usize,
5442) {
5443    use hjkl_buffer::{Edit, MotionKind, Position};
5444    ed.push_undo();
5445    ed.sync_buffer_content_from_textarea();
5446    for _ in 0..count {
5447        let cursor = buf_cursor_pos(&ed.buffer);
5448        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5449        if cursor.col >= line_chars {
5450            break;
5451        }
5452        ed.mutate_edit(Edit::DeleteRange {
5453            start: cursor,
5454            end: Position::new(cursor.row, cursor.col + 1),
5455            kind: MotionKind::Char,
5456        });
5457        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
5458    }
5459    // Vim leaves the cursor on the last replaced char.
5460    crate::motions::move_left(&mut ed.buffer, 1);
5461    ed.push_buffer_cursor_to_textarea();
5462}
5463
5464fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5465    use hjkl_buffer::{Edit, MotionKind, Position};
5466    ed.sync_buffer_content_from_textarea();
5467    let cursor = buf_cursor_pos(&ed.buffer);
5468    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
5469        return;
5470    };
5471    let toggled = if c.is_uppercase() {
5472        c.to_lowercase().next().unwrap_or(c)
5473    } else {
5474        c.to_uppercase().next().unwrap_or(c)
5475    };
5476    ed.mutate_edit(Edit::DeleteRange {
5477        start: cursor,
5478        end: Position::new(cursor.row, cursor.col + 1),
5479        kind: MotionKind::Char,
5480    });
5481    ed.mutate_edit(Edit::InsertChar {
5482        at: cursor,
5483        ch: toggled,
5484    });
5485}
5486
5487fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5488    use hjkl_buffer::{Edit, Position};
5489    ed.sync_buffer_content_from_textarea();
5490    let row = buf_cursor_pos(&ed.buffer).row;
5491    if row + 1 >= buf_row_count(&ed.buffer) {
5492        return;
5493    }
5494    let cur_line = buf_line(&ed.buffer, row).unwrap_or("").to_string();
5495    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or("").to_string();
5496    let next_trimmed = next_raw.trim_start();
5497    let cur_chars = cur_line.chars().count();
5498    let next_chars = next_raw.chars().count();
5499    // `J` inserts a single space iff both sides are non-empty after
5500    // stripping the next line's leading whitespace.
5501    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
5502        " "
5503    } else {
5504        ""
5505    };
5506    let joined = format!("{cur_line}{separator}{next_trimmed}");
5507    ed.mutate_edit(Edit::Replace {
5508        start: Position::new(row, 0),
5509        end: Position::new(row + 1, next_chars),
5510        with: joined,
5511    });
5512    // Vim parks the cursor on the inserted space — or at the join
5513    // point when no space went in (which is the same column either
5514    // way, since the space sits exactly at `cur_chars`).
5515    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
5516    ed.push_buffer_cursor_to_textarea();
5517}
5518
5519/// `gJ` — join the next line onto the current one without inserting a
5520/// separating space or stripping leading whitespace.
5521fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5522    use hjkl_buffer::Edit;
5523    ed.sync_buffer_content_from_textarea();
5524    let row = buf_cursor_pos(&ed.buffer).row;
5525    if row + 1 >= buf_row_count(&ed.buffer) {
5526        return;
5527    }
5528    let join_col = buf_line_chars(&ed.buffer, row);
5529    ed.mutate_edit(Edit::JoinLines {
5530        row,
5531        count: 1,
5532        with_space: false,
5533    });
5534    // Vim leaves the cursor at the join point (end of original line).
5535    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
5536    ed.push_buffer_cursor_to_textarea();
5537}
5538
5539fn do_paste<H: crate::types::Host>(
5540    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5541    before: bool,
5542    count: usize,
5543) {
5544    use hjkl_buffer::{Edit, Position};
5545    ed.push_undo();
5546    // Resolve the source register: `"reg` prefix (consumed) or the
5547    // unnamed register otherwise. Read text + linewise from the
5548    // selected slot rather than the global `vim.yank_linewise` so
5549    // pasting from `"0` after a delete still uses the yank's layout.
5550    let selector = ed.vim.pending_register.take();
5551    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
5552        Some(slot) => (slot.text.clone(), slot.linewise),
5553        // Read both fields from the unnamed slot rather than mixing the
5554        // slot's text with `vim.yank_linewise`. The cached vim flag is
5555        // per-editor, so a register imported from another editor (e.g.
5556        // cross-buffer yank/paste) carried the wrong linewise without
5557        // this — pasting a linewise yank inserted at the char cursor.
5558        None => {
5559            let s = &ed.registers().unnamed;
5560            (s.text.clone(), s.linewise)
5561        }
5562    };
5563    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
5564    // the final paste, `]` = last inserted char of the final paste.
5565    // We track (lo, hi) across iterations; the last value wins.
5566    let mut paste_mark: Option<((usize, usize), (usize, usize))> = None;
5567    for _ in 0..count {
5568        ed.sync_buffer_content_from_textarea();
5569        let yank = yank.clone();
5570        if yank.is_empty() {
5571            continue;
5572        }
5573        if linewise {
5574            // Linewise paste: insert payload as fresh row(s) above
5575            // (`P`) or below (`p`) the cursor's row. Cursor lands on
5576            // the first non-blank of the first pasted line.
5577            let text = yank.trim_matches('\n').to_string();
5578            let row = buf_cursor_pos(&ed.buffer).row;
5579            let target_row = if before {
5580                ed.mutate_edit(Edit::InsertStr {
5581                    at: Position::new(row, 0),
5582                    text: format!("{text}\n"),
5583                });
5584                row
5585            } else {
5586                let line_chars = buf_line_chars(&ed.buffer, row);
5587                ed.mutate_edit(Edit::InsertStr {
5588                    at: Position::new(row, line_chars),
5589                    text: format!("\n{text}"),
5590                });
5591                row + 1
5592            };
5593            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
5594            crate::motions::move_first_non_blank(&mut ed.buffer);
5595            ed.push_buffer_cursor_to_textarea();
5596            // Linewise: `[` = (target_row, 0), `]` = (bot_row, last_col).
5597            let payload_lines = text.lines().count().max(1);
5598            let bot_row = target_row + payload_lines - 1;
5599            let bot_last_col = buf_line_chars(&ed.buffer, bot_row).saturating_sub(1);
5600            paste_mark = Some(((target_row, 0), (bot_row, bot_last_col)));
5601        } else {
5602            // Charwise paste. `P` inserts at cursor (shifting cell
5603            // right); `p` inserts after cursor (advance one cell
5604            // first, clamped to the end of the line).
5605            let cursor = buf_cursor_pos(&ed.buffer);
5606            let at = if before {
5607                cursor
5608            } else {
5609                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5610                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
5611            };
5612            ed.mutate_edit(Edit::InsertStr {
5613                at,
5614                text: yank.clone(),
5615            });
5616            // Vim parks the cursor on the last char of the pasted
5617            // text (do_insert_str leaves it one past the end).
5618            crate::motions::move_left(&mut ed.buffer, 1);
5619            ed.push_buffer_cursor_to_textarea();
5620            // Charwise: `[` = insert start, `]` = cursor (last pasted char).
5621            let lo = (at.row, at.col);
5622            let hi = ed.cursor();
5623            paste_mark = Some((lo, hi));
5624        }
5625    }
5626    if let Some((lo, hi)) = paste_mark {
5627        ed.set_mark('[', lo);
5628        ed.set_mark(']', hi);
5629    }
5630    // Any paste re-anchors the sticky column to the new cursor position.
5631    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
5632}
5633
5634pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5635    if let Some((lines, cursor)) = ed.undo_stack.pop() {
5636        let current = ed.snapshot();
5637        ed.redo_stack.push(current);
5638        ed.restore(lines, cursor);
5639    }
5640    ed.vim.mode = Mode::Normal;
5641    // The restored cursor came from a snapshot taken in insert mode
5642    // (before the insert started) and may be past the last valid
5643    // normal-mode column. Clamp it now, same as Esc-from-insert does.
5644    clamp_cursor_to_normal_mode(ed);
5645}
5646
5647pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5648    if let Some((lines, cursor)) = ed.redo_stack.pop() {
5649        let current = ed.snapshot();
5650        ed.undo_stack.push(current);
5651        ed.cap_undo();
5652        ed.restore(lines, cursor);
5653    }
5654    ed.vim.mode = Mode::Normal;
5655}
5656
5657// ─── Dot repeat ────────────────────────────────────────────────────────────
5658
5659/// Replay-side helper: insert `text` at the cursor through the
5660/// edit funnel, then leave insert mode (the original change ended
5661/// with Esc, so the dot-repeat must end the same way — including
5662/// the cursor step-back vim does on Esc-from-insert).
5663fn replay_insert_and_finish<H: crate::types::Host>(
5664    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5665    text: &str,
5666) {
5667    use hjkl_buffer::{Edit, Position};
5668    let cursor = ed.cursor();
5669    ed.mutate_edit(Edit::InsertStr {
5670        at: Position::new(cursor.0, cursor.1),
5671        text: text.to_string(),
5672    });
5673    if ed.vim.insert_session.take().is_some() {
5674        if ed.cursor().1 > 0 {
5675            crate::motions::move_left(&mut ed.buffer, 1);
5676            ed.push_buffer_cursor_to_textarea();
5677        }
5678        ed.vim.mode = Mode::Normal;
5679    }
5680}
5681
5682fn replay_last_change<H: crate::types::Host>(
5683    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5684    outer_count: usize,
5685) {
5686    let Some(change) = ed.vim.last_change.clone() else {
5687        return;
5688    };
5689    ed.vim.replaying = true;
5690    let scale = if outer_count > 0 { outer_count } else { 1 };
5691    match change {
5692        LastChange::OpMotion {
5693            op,
5694            motion,
5695            count,
5696            inserted,
5697        } => {
5698            let total = count.max(1) * scale;
5699            apply_op_with_motion(ed, op, &motion, total);
5700            if let Some(text) = inserted {
5701                replay_insert_and_finish(ed, &text);
5702            }
5703        }
5704        LastChange::OpTextObj {
5705            op,
5706            obj,
5707            inner,
5708            inserted,
5709        } => {
5710            apply_op_with_text_object(ed, op, obj, inner);
5711            if let Some(text) = inserted {
5712                replay_insert_and_finish(ed, &text);
5713            }
5714        }
5715        LastChange::LineOp {
5716            op,
5717            count,
5718            inserted,
5719        } => {
5720            let total = count.max(1) * scale;
5721            execute_line_op(ed, op, total);
5722            if let Some(text) = inserted {
5723                replay_insert_and_finish(ed, &text);
5724            }
5725        }
5726        LastChange::CharDel { forward, count } => {
5727            do_char_delete(ed, forward, count * scale);
5728        }
5729        LastChange::ReplaceChar { ch, count } => {
5730            replace_char(ed, ch, count * scale);
5731        }
5732        LastChange::ToggleCase { count } => {
5733            for _ in 0..count * scale {
5734                ed.push_undo();
5735                toggle_case_at_cursor(ed);
5736            }
5737        }
5738        LastChange::JoinLine { count } => {
5739            for _ in 0..count * scale {
5740                ed.push_undo();
5741                join_line(ed);
5742            }
5743        }
5744        LastChange::Paste { before, count } => {
5745            do_paste(ed, before, count * scale);
5746        }
5747        LastChange::DeleteToEol { inserted } => {
5748            use hjkl_buffer::{Edit, Position};
5749            ed.push_undo();
5750            delete_to_eol(ed);
5751            if let Some(text) = inserted {
5752                let cursor = ed.cursor();
5753                ed.mutate_edit(Edit::InsertStr {
5754                    at: Position::new(cursor.0, cursor.1),
5755                    text,
5756                });
5757            }
5758        }
5759        LastChange::OpenLine { above, inserted } => {
5760            use hjkl_buffer::{Edit, Position};
5761            ed.push_undo();
5762            ed.sync_buffer_content_from_textarea();
5763            let row = buf_cursor_pos(&ed.buffer).row;
5764            if above {
5765                ed.mutate_edit(Edit::InsertStr {
5766                    at: Position::new(row, 0),
5767                    text: "\n".to_string(),
5768                });
5769                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
5770                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
5771            } else {
5772                let line_chars = buf_line_chars(&ed.buffer, row);
5773                ed.mutate_edit(Edit::InsertStr {
5774                    at: Position::new(row, line_chars),
5775                    text: "\n".to_string(),
5776                });
5777            }
5778            ed.push_buffer_cursor_to_textarea();
5779            let cursor = ed.cursor();
5780            ed.mutate_edit(Edit::InsertStr {
5781                at: Position::new(cursor.0, cursor.1),
5782                text: inserted,
5783            });
5784        }
5785        LastChange::InsertAt {
5786            entry,
5787            inserted,
5788            count,
5789        } => {
5790            use hjkl_buffer::{Edit, Position};
5791            ed.push_undo();
5792            match entry {
5793                InsertEntry::I => {}
5794                InsertEntry::ShiftI => move_first_non_whitespace(ed),
5795                InsertEntry::A => {
5796                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5797                    ed.push_buffer_cursor_to_textarea();
5798                }
5799                InsertEntry::ShiftA => {
5800                    crate::motions::move_line_end(&mut ed.buffer);
5801                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5802                    ed.push_buffer_cursor_to_textarea();
5803                }
5804            }
5805            for _ in 0..count.max(1) {
5806                let cursor = ed.cursor();
5807                ed.mutate_edit(Edit::InsertStr {
5808                    at: Position::new(cursor.0, cursor.1),
5809                    text: inserted.clone(),
5810                });
5811            }
5812        }
5813    }
5814    ed.vim.replaying = false;
5815}
5816
5817// ─── Extracting inserted text for replay ───────────────────────────────────
5818
5819fn extract_inserted(before: &str, after: &str) -> String {
5820    let before_chars: Vec<char> = before.chars().collect();
5821    let after_chars: Vec<char> = after.chars().collect();
5822    if after_chars.len() <= before_chars.len() {
5823        return String::new();
5824    }
5825    let prefix = before_chars
5826        .iter()
5827        .zip(after_chars.iter())
5828        .take_while(|(a, b)| a == b)
5829        .count();
5830    let max_suffix = before_chars.len() - prefix;
5831    let suffix = before_chars
5832        .iter()
5833        .rev()
5834        .zip(after_chars.iter().rev())
5835        .take(max_suffix)
5836        .take_while(|(a, b)| a == b)
5837        .count();
5838    after_chars[prefix..after_chars.len() - suffix]
5839        .iter()
5840        .collect()
5841}
5842
5843// ─── Tests ────────────────────────────────────────────────────────────────
5844
5845#[cfg(all(test, feature = "crossterm"))]
5846mod tests {
5847    use crate::VimMode;
5848    use crate::editor::Editor;
5849    use crate::types::Host;
5850    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5851
5852    fn run_keys<H: crate::types::Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, keys: &str) {
5853        // Minimal notation:
5854        //   <Esc> <CR> <BS> <Left/Right/Up/Down> <C-x>
5855        //   anything else = single char
5856        let mut iter = keys.chars().peekable();
5857        while let Some(c) = iter.next() {
5858            if c == '<' {
5859                let mut tag = String::new();
5860                for ch in iter.by_ref() {
5861                    if ch == '>' {
5862                        break;
5863                    }
5864                    tag.push(ch);
5865                }
5866                let ev = match tag.as_str() {
5867                    "Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
5868                    "CR" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
5869                    "BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
5870                    "Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
5871                    "Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
5872                    "Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
5873                    "Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
5874                    "Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
5875                    // Vim-style literal `<` escape so tests can type
5876                    // the outdent operator without colliding with the
5877                    // `<tag>` notation this helper uses for special keys.
5878                    "lt" => KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
5879                    s if s.starts_with("C-") => {
5880                        let ch = s.chars().nth(2).unwrap();
5881                        KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
5882                    }
5883                    _ => continue,
5884                };
5885                e.handle_key(ev);
5886            } else {
5887                let mods = if c.is_uppercase() {
5888                    KeyModifiers::SHIFT
5889                } else {
5890                    KeyModifiers::NONE
5891                };
5892                e.handle_key(KeyEvent::new(KeyCode::Char(c), mods));
5893            }
5894        }
5895    }
5896
5897    fn editor_with(content: &str) -> Editor {
5898        // Tests historically assume shiftwidth=2 (sqeel-derived). The 0.1.0
5899        // SPEC default is shiftwidth=8 (vim-faithful). Keep these tests on
5900        // the legacy 2-space rhythm so the indent/outdent assertions don't
5901        // churn.
5902        let opts = crate::types::Options {
5903            shiftwidth: 2,
5904            ..crate::types::Options::default()
5905        };
5906        let mut e = Editor::new(
5907            hjkl_buffer::Buffer::new(),
5908            crate::types::DefaultHost::new(),
5909            opts,
5910        );
5911        e.set_content(content);
5912        e
5913    }
5914
5915    #[test]
5916    fn f_char_jumps_on_line() {
5917        let mut e = editor_with("hello world");
5918        run_keys(&mut e, "fw");
5919        assert_eq!(e.cursor(), (0, 6));
5920    }
5921
5922    #[test]
5923    fn cap_f_jumps_backward() {
5924        let mut e = editor_with("hello world");
5925        e.jump_cursor(0, 10);
5926        run_keys(&mut e, "Fo");
5927        assert_eq!(e.cursor().1, 7);
5928    }
5929
5930    #[test]
5931    fn t_stops_before_char() {
5932        let mut e = editor_with("hello");
5933        run_keys(&mut e, "tl");
5934        assert_eq!(e.cursor(), (0, 1));
5935    }
5936
5937    #[test]
5938    fn semicolon_repeats_find() {
5939        let mut e = editor_with("aa.bb.cc");
5940        run_keys(&mut e, "f.");
5941        assert_eq!(e.cursor().1, 2);
5942        run_keys(&mut e, ";");
5943        assert_eq!(e.cursor().1, 5);
5944    }
5945
5946    #[test]
5947    fn comma_repeats_find_reverse() {
5948        let mut e = editor_with("aa.bb.cc");
5949        run_keys(&mut e, "f.");
5950        run_keys(&mut e, ";");
5951        run_keys(&mut e, ",");
5952        assert_eq!(e.cursor().1, 2);
5953    }
5954
5955    #[test]
5956    fn di_quote_deletes_content() {
5957        let mut e = editor_with("foo \"bar\" baz");
5958        e.jump_cursor(0, 6); // inside quotes
5959        run_keys(&mut e, "di\"");
5960        assert_eq!(e.buffer().lines()[0], "foo \"\" baz");
5961    }
5962
5963    #[test]
5964    fn da_quote_deletes_with_quotes() {
5965        // `da"` eats the trailing space after the closing quote so the
5966        // result matches vim's "around" text-object whitespace rule.
5967        let mut e = editor_with("foo \"bar\" baz");
5968        e.jump_cursor(0, 6);
5969        run_keys(&mut e, "da\"");
5970        assert_eq!(e.buffer().lines()[0], "foo baz");
5971    }
5972
5973    #[test]
5974    fn ci_paren_deletes_and_inserts() {
5975        let mut e = editor_with("fn(a, b, c)");
5976        e.jump_cursor(0, 5);
5977        run_keys(&mut e, "ci(");
5978        assert_eq!(e.vim_mode(), VimMode::Insert);
5979        assert_eq!(e.buffer().lines()[0], "fn()");
5980    }
5981
5982    #[test]
5983    fn diw_deletes_inner_word() {
5984        let mut e = editor_with("hello world");
5985        e.jump_cursor(0, 2);
5986        run_keys(&mut e, "diw");
5987        assert_eq!(e.buffer().lines()[0], " world");
5988    }
5989
5990    #[test]
5991    fn daw_deletes_word_with_trailing_space() {
5992        let mut e = editor_with("hello world");
5993        run_keys(&mut e, "daw");
5994        assert_eq!(e.buffer().lines()[0], "world");
5995    }
5996
5997    #[test]
5998    fn percent_jumps_to_matching_bracket() {
5999        let mut e = editor_with("foo(bar)");
6000        e.jump_cursor(0, 3);
6001        run_keys(&mut e, "%");
6002        assert_eq!(e.cursor().1, 7);
6003        run_keys(&mut e, "%");
6004        assert_eq!(e.cursor().1, 3);
6005    }
6006
6007    #[test]
6008    fn dot_repeats_last_change() {
6009        let mut e = editor_with("aaa bbb ccc");
6010        run_keys(&mut e, "dw");
6011        assert_eq!(e.buffer().lines()[0], "bbb ccc");
6012        run_keys(&mut e, ".");
6013        assert_eq!(e.buffer().lines()[0], "ccc");
6014    }
6015
6016    #[test]
6017    fn dot_repeats_change_operator_with_text() {
6018        let mut e = editor_with("foo foo foo");
6019        run_keys(&mut e, "cwbar<Esc>");
6020        assert_eq!(e.buffer().lines()[0], "bar foo foo");
6021        // Move past the space.
6022        run_keys(&mut e, "w");
6023        run_keys(&mut e, ".");
6024        assert_eq!(e.buffer().lines()[0], "bar bar foo");
6025    }
6026
6027    #[test]
6028    fn dot_repeats_x() {
6029        let mut e = editor_with("abcdef");
6030        run_keys(&mut e, "x");
6031        run_keys(&mut e, "..");
6032        assert_eq!(e.buffer().lines()[0], "def");
6033    }
6034
6035    #[test]
6036    fn count_operator_motion_compose() {
6037        let mut e = editor_with("one two three four five");
6038        run_keys(&mut e, "d3w");
6039        assert_eq!(e.buffer().lines()[0], "four five");
6040    }
6041
6042    #[test]
6043    fn two_dd_deletes_two_lines() {
6044        let mut e = editor_with("a\nb\nc");
6045        run_keys(&mut e, "2dd");
6046        assert_eq!(e.buffer().lines().len(), 1);
6047        assert_eq!(e.buffer().lines()[0], "c");
6048    }
6049
6050    /// Vim's `dd` leaves the cursor on the first non-blank of the line
6051    /// that now sits at the deleted row — not at the end of the
6052    /// previous line, which is where tui-textarea's raw cut would
6053    /// park it.
6054    #[test]
6055    fn dd_in_middle_puts_cursor_on_first_non_blank_of_next() {
6056        let mut e = editor_with("one\ntwo\n    three\nfour");
6057        e.jump_cursor(1, 2);
6058        run_keys(&mut e, "dd");
6059        // Buffer: ["one", "    three", "four"]
6060        assert_eq!(e.buffer().lines()[1], "    three");
6061        assert_eq!(e.cursor(), (1, 4));
6062    }
6063
6064    #[test]
6065    fn dd_on_last_line_puts_cursor_on_first_non_blank_of_prev() {
6066        let mut e = editor_with("one\n  two\nthree");
6067        e.jump_cursor(2, 0);
6068        run_keys(&mut e, "dd");
6069        // Buffer: ["one", "  two"]
6070        assert_eq!(e.buffer().lines().len(), 2);
6071        assert_eq!(e.cursor(), (1, 2));
6072    }
6073
6074    #[test]
6075    fn dd_on_only_line_leaves_empty_buffer_and_cursor_at_zero() {
6076        let mut e = editor_with("lonely");
6077        run_keys(&mut e, "dd");
6078        assert_eq!(e.buffer().lines().len(), 1);
6079        assert_eq!(e.buffer().lines()[0], "");
6080        assert_eq!(e.cursor(), (0, 0));
6081    }
6082
6083    #[test]
6084    fn count_dd_puts_cursor_on_first_non_blank_of_remaining() {
6085        let mut e = editor_with("a\nb\nc\n   d\ne");
6086        // Cursor on row 1, "3dd" deletes b/c/   d → lines become [a, e].
6087        e.jump_cursor(1, 0);
6088        run_keys(&mut e, "3dd");
6089        assert_eq!(e.buffer().lines(), &["a".to_string(), "e".to_string()]);
6090        assert_eq!(e.cursor(), (1, 0));
6091    }
6092
6093    #[test]
6094    fn dd_then_j_uses_first_non_blank_not_sticky_col() {
6095        // Buffer: 3 lines with predictable widths.
6096        // Line 0: "    line one"   (12 chars, first-non-blank at col 4)
6097        // Line 1: "    line two"   (12 chars, first-non-blank at col 4)
6098        // Line 2: "  xy"           (4 chars, indices 0-3; last char at col 3)
6099        //
6100        // Cursor starts at col 8 on line 0.  After `dd`:
6101        //   - line 0 is deleted; cursor lands on first-non-blank of new line 0
6102        //     ("    line two") → col 4.
6103        //   - sticky_col must be updated to 4.
6104        //
6105        // Then `j` moves to "  xy" (4 chars, max col = 3).
6106        //   - With the fix   : sticky_col=4 → clamps to col 3 (last char).
6107        //   - Without the fix: sticky_col=8 → clamps to col 3 (same clamp).
6108        //
6109        // To make the two cases distinguishable we choose line 2 with
6110        // exactly 6 chars ("  xyz!") so max col = 5:
6111        //   - fix   : sticky_col=4 → lands at col 4.
6112        //   - no fix: sticky_col=8 → clamps to col 5.
6113        let mut e = editor_with("    line one\n    line two\n  xyz!");
6114        // Move to col 8 on line 0.
6115        e.jump_cursor(0, 8);
6116        assert_eq!(e.cursor(), (0, 8));
6117        // `dd` deletes line 0; cursor should land on first-non-blank of
6118        // the new line 0 ("    line two" → col 4).
6119        run_keys(&mut e, "dd");
6120        assert_eq!(
6121            e.cursor(),
6122            (0, 4),
6123            "dd must place cursor on first-non-blank"
6124        );
6125        // `j` moves to "  xyz!" (6 chars, cols 0-5).
6126        // Bug: stale sticky_col=8 clamps to col 5 (last char).
6127        // Fixed: sticky_col=4 → lands at col 4.
6128        run_keys(&mut e, "j");
6129        let (row, col) = e.cursor();
6130        assert_eq!(row, 1);
6131        assert_eq!(
6132            col, 4,
6133            "after dd, j should use the column dd established (4), not pre-dd sticky_col (8)"
6134        );
6135    }
6136
6137    #[test]
6138    fn gu_lowercases_motion_range() {
6139        let mut e = editor_with("HELLO WORLD");
6140        run_keys(&mut e, "guw");
6141        assert_eq!(e.buffer().lines()[0], "hello WORLD");
6142        assert_eq!(e.cursor(), (0, 0));
6143    }
6144
6145    #[test]
6146    fn g_u_uppercases_text_object() {
6147        let mut e = editor_with("hello world");
6148        // gUiw uppercases the word at the cursor.
6149        run_keys(&mut e, "gUiw");
6150        assert_eq!(e.buffer().lines()[0], "HELLO world");
6151        assert_eq!(e.cursor(), (0, 0));
6152    }
6153
6154    #[test]
6155    fn g_tilde_toggles_case_of_range() {
6156        let mut e = editor_with("Hello World");
6157        run_keys(&mut e, "g~iw");
6158        assert_eq!(e.buffer().lines()[0], "hELLO World");
6159    }
6160
6161    #[test]
6162    fn g_uu_uppercases_current_line() {
6163        let mut e = editor_with("select 1\nselect 2");
6164        run_keys(&mut e, "gUU");
6165        assert_eq!(e.buffer().lines()[0], "SELECT 1");
6166        assert_eq!(e.buffer().lines()[1], "select 2");
6167    }
6168
6169    #[test]
6170    fn gugu_lowercases_current_line() {
6171        let mut e = editor_with("FOO BAR\nBAZ");
6172        run_keys(&mut e, "gugu");
6173        assert_eq!(e.buffer().lines()[0], "foo bar");
6174    }
6175
6176    #[test]
6177    fn visual_u_uppercases_selection() {
6178        let mut e = editor_with("hello world");
6179        // v + e selects "hello" (inclusive of last char), U uppercases.
6180        run_keys(&mut e, "veU");
6181        assert_eq!(e.buffer().lines()[0], "HELLO world");
6182    }
6183
6184    #[test]
6185    fn visual_line_u_lowercases_line() {
6186        let mut e = editor_with("HELLO WORLD\nOTHER");
6187        run_keys(&mut e, "Vu");
6188        assert_eq!(e.buffer().lines()[0], "hello world");
6189        assert_eq!(e.buffer().lines()[1], "OTHER");
6190    }
6191
6192    #[test]
6193    fn g_uu_with_count_uppercases_multiple_lines() {
6194        let mut e = editor_with("one\ntwo\nthree\nfour");
6195        // `3gUU` uppercases 3 lines starting from the cursor.
6196        run_keys(&mut e, "3gUU");
6197        assert_eq!(e.buffer().lines()[0], "ONE");
6198        assert_eq!(e.buffer().lines()[1], "TWO");
6199        assert_eq!(e.buffer().lines()[2], "THREE");
6200        assert_eq!(e.buffer().lines()[3], "four");
6201    }
6202
6203    #[test]
6204    fn double_gt_indents_current_line() {
6205        let mut e = editor_with("hello");
6206        run_keys(&mut e, ">>");
6207        assert_eq!(e.buffer().lines()[0], "  hello");
6208        // Cursor lands on first non-blank.
6209        assert_eq!(e.cursor(), (0, 2));
6210    }
6211
6212    #[test]
6213    fn double_lt_outdents_current_line() {
6214        let mut e = editor_with("    hello");
6215        run_keys(&mut e, "<lt><lt>");
6216        assert_eq!(e.buffer().lines()[0], "  hello");
6217        assert_eq!(e.cursor(), (0, 2));
6218    }
6219
6220    #[test]
6221    fn count_double_gt_indents_multiple_lines() {
6222        let mut e = editor_with("a\nb\nc\nd");
6223        // `3>>` indents 3 lines starting at cursor.
6224        run_keys(&mut e, "3>>");
6225        assert_eq!(e.buffer().lines()[0], "  a");
6226        assert_eq!(e.buffer().lines()[1], "  b");
6227        assert_eq!(e.buffer().lines()[2], "  c");
6228        assert_eq!(e.buffer().lines()[3], "d");
6229    }
6230
6231    #[test]
6232    fn outdent_clips_ragged_leading_whitespace() {
6233        // Only one space of indent — outdent should strip what's
6234        // there, not leave anything negative.
6235        let mut e = editor_with(" x");
6236        run_keys(&mut e, "<lt><lt>");
6237        assert_eq!(e.buffer().lines()[0], "x");
6238    }
6239
6240    #[test]
6241    fn indent_motion_is_always_linewise() {
6242        // `>w` indents the current line (linewise) — it doesn't
6243        // insert spaces into the middle of the word.
6244        let mut e = editor_with("foo bar");
6245        run_keys(&mut e, ">w");
6246        assert_eq!(e.buffer().lines()[0], "  foo bar");
6247    }
6248
6249    #[test]
6250    fn indent_text_object_extends_over_paragraph() {
6251        let mut e = editor_with("a\nb\n\nc\nd");
6252        // `>ap` indents the whole paragraph (rows 0..=1).
6253        run_keys(&mut e, ">ap");
6254        assert_eq!(e.buffer().lines()[0], "  a");
6255        assert_eq!(e.buffer().lines()[1], "  b");
6256        assert_eq!(e.buffer().lines()[2], "");
6257        assert_eq!(e.buffer().lines()[3], "c");
6258    }
6259
6260    #[test]
6261    fn visual_line_indent_shifts_selected_rows() {
6262        let mut e = editor_with("x\ny\nz");
6263        // Vj selects rows 0..=1 linewise; `>` indents.
6264        run_keys(&mut e, "Vj>");
6265        assert_eq!(e.buffer().lines()[0], "  x");
6266        assert_eq!(e.buffer().lines()[1], "  y");
6267        assert_eq!(e.buffer().lines()[2], "z");
6268    }
6269
6270    #[test]
6271    fn outdent_empty_line_is_noop() {
6272        let mut e = editor_with("\nfoo");
6273        run_keys(&mut e, "<lt><lt>");
6274        assert_eq!(e.buffer().lines()[0], "");
6275    }
6276
6277    #[test]
6278    fn indent_skips_empty_lines() {
6279        // Vim convention: `>>` on an empty line doesn't pad it with
6280        // trailing whitespace.
6281        let mut e = editor_with("");
6282        run_keys(&mut e, ">>");
6283        assert_eq!(e.buffer().lines()[0], "");
6284    }
6285
6286    #[test]
6287    fn insert_ctrl_t_indents_current_line() {
6288        let mut e = editor_with("x");
6289        // Enter insert, Ctrl-t indents the line; cursor advances too.
6290        run_keys(&mut e, "i<C-t>");
6291        assert_eq!(e.buffer().lines()[0], "  x");
6292        // After insert-mode start `i` cursor was at (0, 0); Ctrl-t
6293        // shifts it by SHIFTWIDTH=2.
6294        assert_eq!(e.cursor(), (0, 2));
6295    }
6296
6297    #[test]
6298    fn insert_ctrl_d_outdents_current_line() {
6299        let mut e = editor_with("    x");
6300        // Enter insert-at-end `A`, Ctrl-d outdents by shiftwidth.
6301        run_keys(&mut e, "A<C-d>");
6302        assert_eq!(e.buffer().lines()[0], "  x");
6303    }
6304
6305    #[test]
6306    fn h_at_col_zero_does_not_wrap_to_prev_line() {
6307        let mut e = editor_with("first\nsecond");
6308        e.jump_cursor(1, 0);
6309        run_keys(&mut e, "h");
6310        // Cursor must stay on row 1 col 0 — vim default doesn't wrap.
6311        assert_eq!(e.cursor(), (1, 0));
6312    }
6313
6314    #[test]
6315    fn l_at_last_char_does_not_wrap_to_next_line() {
6316        let mut e = editor_with("ab\ncd");
6317        // Move to last char of row 0 (col 1).
6318        e.jump_cursor(0, 1);
6319        run_keys(&mut e, "l");
6320        // Cursor stays on last char — no wrap.
6321        assert_eq!(e.cursor(), (0, 1));
6322    }
6323
6324    #[test]
6325    fn count_l_clamps_at_line_end() {
6326        let mut e = editor_with("abcde");
6327        // 20l starting at col 0 should land on last char (col 4),
6328        // not overflow / wrap.
6329        run_keys(&mut e, "20l");
6330        assert_eq!(e.cursor(), (0, 4));
6331    }
6332
6333    #[test]
6334    fn count_h_clamps_at_col_zero() {
6335        let mut e = editor_with("abcde");
6336        e.jump_cursor(0, 3);
6337        run_keys(&mut e, "20h");
6338        assert_eq!(e.cursor(), (0, 0));
6339    }
6340
6341    #[test]
6342    fn dl_on_last_char_still_deletes_it() {
6343        // `dl` / `x`-equivalent at EOL must delete the last char —
6344        // operator motion allows endpoint past-last even though bare
6345        // `l` stops before.
6346        let mut e = editor_with("ab");
6347        e.jump_cursor(0, 1);
6348        run_keys(&mut e, "dl");
6349        assert_eq!(e.buffer().lines()[0], "a");
6350    }
6351
6352    #[test]
6353    fn case_op_preserves_yank_register() {
6354        let mut e = editor_with("target");
6355        run_keys(&mut e, "yy");
6356        let yank_before = e.yank().to_string();
6357        // gUU changes the line but must not clobber the yank register.
6358        run_keys(&mut e, "gUU");
6359        assert_eq!(e.buffer().lines()[0], "TARGET");
6360        assert_eq!(
6361            e.yank(),
6362            yank_before,
6363            "case ops must preserve the yank buffer"
6364        );
6365    }
6366
6367    #[test]
6368    fn dap_deletes_paragraph() {
6369        let mut e = editor_with("a\nb\n\nc\nd");
6370        run_keys(&mut e, "dap");
6371        assert_eq!(e.buffer().lines().first().map(String::as_str), Some("c"));
6372    }
6373
6374    #[test]
6375    fn dit_deletes_inner_tag_content() {
6376        let mut e = editor_with("<b>hello</b>");
6377        // Cursor on `e`.
6378        e.jump_cursor(0, 4);
6379        run_keys(&mut e, "dit");
6380        assert_eq!(e.buffer().lines()[0], "<b></b>");
6381    }
6382
6383    #[test]
6384    fn dat_deletes_around_tag() {
6385        let mut e = editor_with("hi <b>foo</b> bye");
6386        e.jump_cursor(0, 6);
6387        run_keys(&mut e, "dat");
6388        assert_eq!(e.buffer().lines()[0], "hi  bye");
6389    }
6390
6391    #[test]
6392    fn dit_picks_innermost_tag() {
6393        let mut e = editor_with("<a><b>x</b></a>");
6394        // Cursor on `x`.
6395        e.jump_cursor(0, 6);
6396        run_keys(&mut e, "dit");
6397        // Inner of <b> is removed; <a> wrapping stays.
6398        assert_eq!(e.buffer().lines()[0], "<a><b></b></a>");
6399    }
6400
6401    #[test]
6402    fn dat_innermost_tag_pair() {
6403        let mut e = editor_with("<a><b>x</b></a>");
6404        e.jump_cursor(0, 6);
6405        run_keys(&mut e, "dat");
6406        assert_eq!(e.buffer().lines()[0], "<a></a>");
6407    }
6408
6409    #[test]
6410    fn dit_outside_any_tag_no_op() {
6411        let mut e = editor_with("plain text");
6412        e.jump_cursor(0, 3);
6413        run_keys(&mut e, "dit");
6414        // No tag pair surrounds the cursor — buffer unchanged.
6415        assert_eq!(e.buffer().lines()[0], "plain text");
6416    }
6417
6418    #[test]
6419    fn cit_changes_inner_tag_content() {
6420        let mut e = editor_with("<b>hello</b>");
6421        e.jump_cursor(0, 4);
6422        run_keys(&mut e, "citNEW<Esc>");
6423        assert_eq!(e.buffer().lines()[0], "<b>NEW</b>");
6424    }
6425
6426    #[test]
6427    fn cat_changes_around_tag() {
6428        let mut e = editor_with("hi <b>foo</b> bye");
6429        e.jump_cursor(0, 6);
6430        run_keys(&mut e, "catBAR<Esc>");
6431        assert_eq!(e.buffer().lines()[0], "hi BAR bye");
6432    }
6433
6434    #[test]
6435    fn yit_yanks_inner_tag_content() {
6436        let mut e = editor_with("<b>hello</b>");
6437        e.jump_cursor(0, 4);
6438        run_keys(&mut e, "yit");
6439        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6440    }
6441
6442    #[test]
6443    fn yat_yanks_full_tag_pair() {
6444        let mut e = editor_with("hi <b>foo</b> bye");
6445        e.jump_cursor(0, 6);
6446        run_keys(&mut e, "yat");
6447        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6448    }
6449
6450    #[test]
6451    fn vit_visually_selects_inner_tag() {
6452        let mut e = editor_with("<b>hello</b>");
6453        e.jump_cursor(0, 4);
6454        run_keys(&mut e, "vit");
6455        assert_eq!(e.vim_mode(), VimMode::Visual);
6456        run_keys(&mut e, "y");
6457        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6458    }
6459
6460    #[test]
6461    fn vat_visually_selects_around_tag() {
6462        let mut e = editor_with("x<b>foo</b>y");
6463        e.jump_cursor(0, 5);
6464        run_keys(&mut e, "vat");
6465        assert_eq!(e.vim_mode(), VimMode::Visual);
6466        run_keys(&mut e, "y");
6467        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6468    }
6469
6470    // ─── Text-object coverage (d operator, inner + around) ───────────
6471
6472    #[test]
6473    #[allow(non_snake_case)]
6474    fn diW_deletes_inner_big_word() {
6475        let mut e = editor_with("foo.bar baz");
6476        e.jump_cursor(0, 2);
6477        run_keys(&mut e, "diW");
6478        // Big word treats `foo.bar` as one token.
6479        assert_eq!(e.buffer().lines()[0], " baz");
6480    }
6481
6482    #[test]
6483    #[allow(non_snake_case)]
6484    fn daW_deletes_around_big_word() {
6485        let mut e = editor_with("foo.bar baz");
6486        e.jump_cursor(0, 2);
6487        run_keys(&mut e, "daW");
6488        assert_eq!(e.buffer().lines()[0], "baz");
6489    }
6490
6491    #[test]
6492    fn di_double_quote_deletes_inside() {
6493        let mut e = editor_with("a \"hello\" b");
6494        e.jump_cursor(0, 4);
6495        run_keys(&mut e, "di\"");
6496        assert_eq!(e.buffer().lines()[0], "a \"\" b");
6497    }
6498
6499    #[test]
6500    fn da_double_quote_deletes_around() {
6501        // `da"` eats the trailing space — matches vim's around-whitespace rule.
6502        let mut e = editor_with("a \"hello\" b");
6503        e.jump_cursor(0, 4);
6504        run_keys(&mut e, "da\"");
6505        assert_eq!(e.buffer().lines()[0], "a b");
6506    }
6507
6508    #[test]
6509    fn di_single_quote_deletes_inside() {
6510        let mut e = editor_with("x 'foo' y");
6511        e.jump_cursor(0, 4);
6512        run_keys(&mut e, "di'");
6513        assert_eq!(e.buffer().lines()[0], "x '' y");
6514    }
6515
6516    #[test]
6517    fn da_single_quote_deletes_around() {
6518        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6519        let mut e = editor_with("x 'foo' y");
6520        e.jump_cursor(0, 4);
6521        run_keys(&mut e, "da'");
6522        assert_eq!(e.buffer().lines()[0], "x y");
6523    }
6524
6525    #[test]
6526    fn di_backtick_deletes_inside() {
6527        let mut e = editor_with("p `q` r");
6528        e.jump_cursor(0, 3);
6529        run_keys(&mut e, "di`");
6530        assert_eq!(e.buffer().lines()[0], "p `` r");
6531    }
6532
6533    #[test]
6534    fn da_backtick_deletes_around() {
6535        // `da`` eats the trailing space — matches vim's around-whitespace rule.
6536        let mut e = editor_with("p `q` r");
6537        e.jump_cursor(0, 3);
6538        run_keys(&mut e, "da`");
6539        assert_eq!(e.buffer().lines()[0], "p r");
6540    }
6541
6542    #[test]
6543    fn di_paren_deletes_inside() {
6544        let mut e = editor_with("f(arg)");
6545        e.jump_cursor(0, 3);
6546        run_keys(&mut e, "di(");
6547        assert_eq!(e.buffer().lines()[0], "f()");
6548    }
6549
6550    #[test]
6551    fn di_paren_alias_b_works() {
6552        let mut e = editor_with("f(arg)");
6553        e.jump_cursor(0, 3);
6554        run_keys(&mut e, "dib");
6555        assert_eq!(e.buffer().lines()[0], "f()");
6556    }
6557
6558    #[test]
6559    fn di_bracket_deletes_inside() {
6560        let mut e = editor_with("a[b,c]d");
6561        e.jump_cursor(0, 3);
6562        run_keys(&mut e, "di[");
6563        assert_eq!(e.buffer().lines()[0], "a[]d");
6564    }
6565
6566    #[test]
6567    fn da_bracket_deletes_around() {
6568        let mut e = editor_with("a[b,c]d");
6569        e.jump_cursor(0, 3);
6570        run_keys(&mut e, "da[");
6571        assert_eq!(e.buffer().lines()[0], "ad");
6572    }
6573
6574    #[test]
6575    fn di_brace_deletes_inside() {
6576        let mut e = editor_with("x{y}z");
6577        e.jump_cursor(0, 2);
6578        run_keys(&mut e, "di{");
6579        assert_eq!(e.buffer().lines()[0], "x{}z");
6580    }
6581
6582    #[test]
6583    fn da_brace_deletes_around() {
6584        let mut e = editor_with("x{y}z");
6585        e.jump_cursor(0, 2);
6586        run_keys(&mut e, "da{");
6587        assert_eq!(e.buffer().lines()[0], "xz");
6588    }
6589
6590    #[test]
6591    fn di_brace_alias_capital_b_works() {
6592        let mut e = editor_with("x{y}z");
6593        e.jump_cursor(0, 2);
6594        run_keys(&mut e, "diB");
6595        assert_eq!(e.buffer().lines()[0], "x{}z");
6596    }
6597
6598    #[test]
6599    fn di_angle_deletes_inside() {
6600        let mut e = editor_with("p<q>r");
6601        e.jump_cursor(0, 2);
6602        // `<lt>` so run_keys doesn't treat `<` as the start of a special-key tag.
6603        run_keys(&mut e, "di<lt>");
6604        assert_eq!(e.buffer().lines()[0], "p<>r");
6605    }
6606
6607    #[test]
6608    fn da_angle_deletes_around() {
6609        let mut e = editor_with("p<q>r");
6610        e.jump_cursor(0, 2);
6611        run_keys(&mut e, "da<lt>");
6612        assert_eq!(e.buffer().lines()[0], "pr");
6613    }
6614
6615    #[test]
6616    fn dip_deletes_inner_paragraph() {
6617        let mut e = editor_with("a\nb\nc\n\nd");
6618        e.jump_cursor(1, 0);
6619        run_keys(&mut e, "dip");
6620        // Inner paragraph (rows 0..=2) drops; the trailing blank
6621        // separator + remaining paragraph stay.
6622        assert_eq!(e.buffer().lines(), vec!["".to_string(), "d".into()]);
6623    }
6624
6625    // ─── Operator pipeline spot checks (non-tag text objects) ───────
6626
6627    #[test]
6628    fn sentence_motion_close_paren_jumps_forward() {
6629        let mut e = editor_with("Alpha. Beta. Gamma.");
6630        e.jump_cursor(0, 0);
6631        run_keys(&mut e, ")");
6632        // Lands on the start of "Beta".
6633        assert_eq!(e.cursor(), (0, 7));
6634        run_keys(&mut e, ")");
6635        assert_eq!(e.cursor(), (0, 13));
6636    }
6637
6638    #[test]
6639    fn sentence_motion_open_paren_jumps_backward() {
6640        let mut e = editor_with("Alpha. Beta. Gamma.");
6641        e.jump_cursor(0, 13);
6642        run_keys(&mut e, "(");
6643        // Cursor was at start of "Gamma" (col 13); first `(` walks
6644        // back to the previous sentence's start.
6645        assert_eq!(e.cursor(), (0, 7));
6646        run_keys(&mut e, "(");
6647        assert_eq!(e.cursor(), (0, 0));
6648    }
6649
6650    #[test]
6651    fn sentence_motion_count() {
6652        let mut e = editor_with("A. B. C. D.");
6653        e.jump_cursor(0, 0);
6654        run_keys(&mut e, "3)");
6655        // 3 forward jumps land on "D".
6656        assert_eq!(e.cursor(), (0, 9));
6657    }
6658
6659    #[test]
6660    fn dis_deletes_inner_sentence() {
6661        let mut e = editor_with("First one. Second one. Third one.");
6662        e.jump_cursor(0, 13);
6663        run_keys(&mut e, "dis");
6664        // Removed "Second one." inclusive of its terminator.
6665        assert_eq!(e.buffer().lines()[0], "First one.  Third one.");
6666    }
6667
6668    #[test]
6669    fn das_deletes_around_sentence_with_trailing_space() {
6670        let mut e = editor_with("Alpha. Beta. Gamma.");
6671        e.jump_cursor(0, 8);
6672        run_keys(&mut e, "das");
6673        // `as` swallows the trailing whitespace before the next
6674        // sentence — exactly one space here.
6675        assert_eq!(e.buffer().lines()[0], "Alpha. Gamma.");
6676    }
6677
6678    #[test]
6679    fn dis_handles_double_terminator() {
6680        let mut e = editor_with("Wow!? Next.");
6681        e.jump_cursor(0, 1);
6682        run_keys(&mut e, "dis");
6683        // Run of `!?` collapses into one boundary; sentence body
6684        // including both terminators is removed.
6685        assert_eq!(e.buffer().lines()[0], " Next.");
6686    }
6687
6688    #[test]
6689    fn dis_first_sentence_from_cursor_at_zero() {
6690        let mut e = editor_with("Alpha. Beta.");
6691        e.jump_cursor(0, 0);
6692        run_keys(&mut e, "dis");
6693        assert_eq!(e.buffer().lines()[0], " Beta.");
6694    }
6695
6696    #[test]
6697    fn yis_yanks_inner_sentence() {
6698        let mut e = editor_with("Hello world. Bye.");
6699        e.jump_cursor(0, 5);
6700        run_keys(&mut e, "yis");
6701        assert_eq!(e.registers().read('"').unwrap().text, "Hello world.");
6702    }
6703
6704    #[test]
6705    fn vis_visually_selects_inner_sentence() {
6706        let mut e = editor_with("First. Second.");
6707        e.jump_cursor(0, 1);
6708        run_keys(&mut e, "vis");
6709        assert_eq!(e.vim_mode(), VimMode::Visual);
6710        run_keys(&mut e, "y");
6711        assert_eq!(e.registers().read('"').unwrap().text, "First.");
6712    }
6713
6714    #[test]
6715    fn ciw_changes_inner_word() {
6716        let mut e = editor_with("hello world");
6717        e.jump_cursor(0, 1);
6718        run_keys(&mut e, "ciwHEY<Esc>");
6719        assert_eq!(e.buffer().lines()[0], "HEY world");
6720    }
6721
6722    #[test]
6723    fn yiw_yanks_inner_word() {
6724        let mut e = editor_with("hello world");
6725        e.jump_cursor(0, 1);
6726        run_keys(&mut e, "yiw");
6727        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6728    }
6729
6730    #[test]
6731    fn viw_selects_inner_word() {
6732        let mut e = editor_with("hello world");
6733        e.jump_cursor(0, 2);
6734        run_keys(&mut e, "viw");
6735        assert_eq!(e.vim_mode(), VimMode::Visual);
6736        run_keys(&mut e, "y");
6737        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6738    }
6739
6740    #[test]
6741    fn ci_paren_changes_inside() {
6742        let mut e = editor_with("f(old)");
6743        e.jump_cursor(0, 3);
6744        run_keys(&mut e, "ci(NEW<Esc>");
6745        assert_eq!(e.buffer().lines()[0], "f(NEW)");
6746    }
6747
6748    #[test]
6749    fn yi_double_quote_yanks_inside() {
6750        let mut e = editor_with("say \"hi there\" then");
6751        e.jump_cursor(0, 6);
6752        run_keys(&mut e, "yi\"");
6753        assert_eq!(e.registers().read('"').unwrap().text, "hi there");
6754    }
6755
6756    #[test]
6757    fn vap_visual_selects_around_paragraph() {
6758        let mut e = editor_with("a\nb\n\nc");
6759        e.jump_cursor(0, 0);
6760        run_keys(&mut e, "vap");
6761        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6762        run_keys(&mut e, "y");
6763        // Linewise yank includes the paragraph rows + trailing blank.
6764        let text = e.registers().read('"').unwrap().text.clone();
6765        assert!(text.starts_with("a\nb"));
6766    }
6767
6768    #[test]
6769    fn star_finds_next_occurrence() {
6770        let mut e = editor_with("foo bar foo baz");
6771        run_keys(&mut e, "*");
6772        assert_eq!(e.cursor().1, 8);
6773    }
6774
6775    #[test]
6776    fn star_skips_substring_match() {
6777        // `*` uses `\bfoo\b` so `foobar` is *not* a hit; cursor wraps
6778        // back to the original `foo` at col 0.
6779        let mut e = editor_with("foo foobar baz");
6780        run_keys(&mut e, "*");
6781        assert_eq!(e.cursor().1, 0);
6782    }
6783
6784    #[test]
6785    fn g_star_matches_substring() {
6786        // `g*` drops the boundary; from `foo` at col 0 the next hit is
6787        // inside `foobar` (col 4).
6788        let mut e = editor_with("foo foobar baz");
6789        run_keys(&mut e, "g*");
6790        assert_eq!(e.cursor().1, 4);
6791    }
6792
6793    #[test]
6794    fn g_pound_matches_substring_backward() {
6795        // Start on the last `foo`; `g#` walks backward and lands inside
6796        // `foobar` (col 4).
6797        let mut e = editor_with("foo foobar baz foo");
6798        run_keys(&mut e, "$b");
6799        assert_eq!(e.cursor().1, 15);
6800        run_keys(&mut e, "g#");
6801        assert_eq!(e.cursor().1, 4);
6802    }
6803
6804    #[test]
6805    fn n_repeats_last_search_forward() {
6806        let mut e = editor_with("foo bar foo baz foo");
6807        // `/foo<CR>` jumps past the cursor's current cell, so from
6808        // col 0 the first hit is the second `foo` at col 8.
6809        run_keys(&mut e, "/foo<CR>");
6810        assert_eq!(e.cursor().1, 8);
6811        run_keys(&mut e, "n");
6812        assert_eq!(e.cursor().1, 16);
6813    }
6814
6815    #[test]
6816    fn shift_n_reverses_search() {
6817        let mut e = editor_with("foo bar foo baz foo");
6818        run_keys(&mut e, "/foo<CR>");
6819        run_keys(&mut e, "n");
6820        assert_eq!(e.cursor().1, 16);
6821        run_keys(&mut e, "N");
6822        assert_eq!(e.cursor().1, 8);
6823    }
6824
6825    #[test]
6826    fn n_noop_without_pattern() {
6827        let mut e = editor_with("foo bar");
6828        run_keys(&mut e, "n");
6829        assert_eq!(e.cursor(), (0, 0));
6830    }
6831
6832    #[test]
6833    fn visual_line_preserves_cursor_column() {
6834        // V should never drag the cursor off its natural column — the
6835        // highlight is painted as a post-render overlay instead.
6836        let mut e = editor_with("hello world\nanother one\nbye");
6837        run_keys(&mut e, "lllll"); // col 5
6838        run_keys(&mut e, "V");
6839        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6840        assert_eq!(e.cursor(), (0, 5));
6841        run_keys(&mut e, "j");
6842        assert_eq!(e.cursor(), (1, 5));
6843    }
6844
6845    #[test]
6846    fn visual_line_yank_includes_trailing_newline() {
6847        let mut e = editor_with("aaa\nbbb\nccc");
6848        run_keys(&mut e, "Vjy");
6849        // Two lines yanked — must be `aaa\nbbb\n`, trailing newline preserved.
6850        assert_eq!(e.last_yank.as_deref(), Some("aaa\nbbb\n"));
6851    }
6852
6853    #[test]
6854    fn visual_line_yank_last_line_trailing_newline() {
6855        let mut e = editor_with("aaa\nbbb\nccc");
6856        // Move to the last line and yank with V (final buffer line).
6857        run_keys(&mut e, "jj");
6858        run_keys(&mut e, "Vy");
6859        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6860    }
6861
6862    #[test]
6863    fn yy_on_last_line_has_trailing_newline() {
6864        let mut e = editor_with("aaa\nbbb\nccc");
6865        run_keys(&mut e, "jj");
6866        run_keys(&mut e, "yy");
6867        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6868    }
6869
6870    #[test]
6871    fn yy_in_middle_has_trailing_newline() {
6872        let mut e = editor_with("aaa\nbbb\nccc");
6873        run_keys(&mut e, "j");
6874        run_keys(&mut e, "yy");
6875        assert_eq!(e.last_yank.as_deref(), Some("bbb\n"));
6876    }
6877
6878    #[test]
6879    fn di_single_quote() {
6880        let mut e = editor_with("say 'hello world' now");
6881        e.jump_cursor(0, 7);
6882        run_keys(&mut e, "di'");
6883        assert_eq!(e.buffer().lines()[0], "say '' now");
6884    }
6885
6886    #[test]
6887    fn da_single_quote() {
6888        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6889        let mut e = editor_with("say 'hello' now");
6890        e.jump_cursor(0, 7);
6891        run_keys(&mut e, "da'");
6892        assert_eq!(e.buffer().lines()[0], "say now");
6893    }
6894
6895    #[test]
6896    fn di_backtick() {
6897        let mut e = editor_with("say `hi` now");
6898        e.jump_cursor(0, 5);
6899        run_keys(&mut e, "di`");
6900        assert_eq!(e.buffer().lines()[0], "say `` now");
6901    }
6902
6903    #[test]
6904    fn di_brace() {
6905        let mut e = editor_with("fn { a; b; c }");
6906        e.jump_cursor(0, 7);
6907        run_keys(&mut e, "di{");
6908        assert_eq!(e.buffer().lines()[0], "fn {}");
6909    }
6910
6911    #[test]
6912    fn di_bracket() {
6913        let mut e = editor_with("arr[1, 2, 3]");
6914        e.jump_cursor(0, 5);
6915        run_keys(&mut e, "di[");
6916        assert_eq!(e.buffer().lines()[0], "arr[]");
6917    }
6918
6919    #[test]
6920    fn dab_deletes_around_paren() {
6921        let mut e = editor_with("fn(a, b) + 1");
6922        e.jump_cursor(0, 4);
6923        run_keys(&mut e, "dab");
6924        assert_eq!(e.buffer().lines()[0], "fn + 1");
6925    }
6926
6927    #[test]
6928    fn da_big_b_deletes_around_brace() {
6929        let mut e = editor_with("x = {a: 1}");
6930        e.jump_cursor(0, 6);
6931        run_keys(&mut e, "daB");
6932        assert_eq!(e.buffer().lines()[0], "x = ");
6933    }
6934
6935    #[test]
6936    fn di_big_w_deletes_bigword() {
6937        let mut e = editor_with("foo-bar baz");
6938        e.jump_cursor(0, 2);
6939        run_keys(&mut e, "diW");
6940        assert_eq!(e.buffer().lines()[0], " baz");
6941    }
6942
6943    #[test]
6944    fn visual_select_inner_word() {
6945        let mut e = editor_with("hello world");
6946        e.jump_cursor(0, 2);
6947        run_keys(&mut e, "viw");
6948        assert_eq!(e.vim_mode(), VimMode::Visual);
6949        run_keys(&mut e, "y");
6950        assert_eq!(e.last_yank.as_deref(), Some("hello"));
6951    }
6952
6953    #[test]
6954    fn visual_select_inner_quote() {
6955        let mut e = editor_with("foo \"bar\" baz");
6956        e.jump_cursor(0, 6);
6957        run_keys(&mut e, "vi\"");
6958        run_keys(&mut e, "y");
6959        assert_eq!(e.last_yank.as_deref(), Some("bar"));
6960    }
6961
6962    #[test]
6963    fn visual_select_inner_paren() {
6964        let mut e = editor_with("fn(a, b)");
6965        e.jump_cursor(0, 4);
6966        run_keys(&mut e, "vi(");
6967        run_keys(&mut e, "y");
6968        assert_eq!(e.last_yank.as_deref(), Some("a, b"));
6969    }
6970
6971    #[test]
6972    fn visual_select_outer_brace() {
6973        let mut e = editor_with("{x}");
6974        e.jump_cursor(0, 1);
6975        run_keys(&mut e, "va{");
6976        run_keys(&mut e, "y");
6977        assert_eq!(e.last_yank.as_deref(), Some("{x}"));
6978    }
6979
6980    #[test]
6981    fn ci_paren_forward_scans_when_cursor_before_pair() {
6982        // targets.vim-style: cursor at start of `foo`, ci( jumps to next
6983        // `(...)` pair on the same line and replaces the contents.
6984        let mut e = editor_with("foo(bar)");
6985        e.jump_cursor(0, 0);
6986        run_keys(&mut e, "ci(NEW<Esc>");
6987        assert_eq!(e.buffer().lines()[0], "foo(NEW)");
6988    }
6989
6990    #[test]
6991    fn ci_paren_forward_scans_across_lines() {
6992        let mut e = editor_with("first\nfoo(bar)\nlast");
6993        e.jump_cursor(0, 0);
6994        run_keys(&mut e, "ci(NEW<Esc>");
6995        assert_eq!(e.buffer().lines()[1], "foo(NEW)");
6996    }
6997
6998    #[test]
6999    fn ci_brace_forward_scans_when_cursor_before_pair() {
7000        let mut e = editor_with("let x = {y};");
7001        e.jump_cursor(0, 0);
7002        run_keys(&mut e, "ci{NEW<Esc>");
7003        assert_eq!(e.buffer().lines()[0], "let x = {NEW};");
7004    }
7005
7006    #[test]
7007    fn cit_forward_scans_when_cursor_before_tag() {
7008        // Cursor at column 0 (before `<b>`), cit jumps into the next tag
7009        // pair and replaces its contents.
7010        let mut e = editor_with("text <b>hello</b> rest");
7011        e.jump_cursor(0, 0);
7012        run_keys(&mut e, "citNEW<Esc>");
7013        assert_eq!(e.buffer().lines()[0], "text <b>NEW</b> rest");
7014    }
7015
7016    #[test]
7017    fn dat_forward_scans_when_cursor_before_tag() {
7018        // dat = delete around tag — including the `<b>...</b>` markup.
7019        let mut e = editor_with("text <b>hello</b> rest");
7020        e.jump_cursor(0, 0);
7021        run_keys(&mut e, "dat");
7022        assert_eq!(e.buffer().lines()[0], "text  rest");
7023    }
7024
7025    #[test]
7026    fn ci_paren_still_works_when_cursor_inside() {
7027        // Regression: forward-scan fallback must not break the
7028        // canonical "cursor inside the pair" case.
7029        let mut e = editor_with("fn(a, b)");
7030        e.jump_cursor(0, 4);
7031        run_keys(&mut e, "ci(NEW<Esc>");
7032        assert_eq!(e.buffer().lines()[0], "fn(NEW)");
7033    }
7034
7035    #[test]
7036    fn caw_changes_word_with_trailing_space() {
7037        let mut e = editor_with("hello world");
7038        run_keys(&mut e, "cawfoo<Esc>");
7039        assert_eq!(e.buffer().lines()[0], "fooworld");
7040    }
7041
7042    #[test]
7043    fn visual_char_yank_preserves_raw_text() {
7044        let mut e = editor_with("hello world");
7045        run_keys(&mut e, "vllly");
7046        assert_eq!(e.last_yank.as_deref(), Some("hell"));
7047    }
7048
7049    #[test]
7050    fn single_line_visual_line_selects_full_line_on_yank() {
7051        let mut e = editor_with("hello world\nbye");
7052        run_keys(&mut e, "V");
7053        // Yank the selection — should include the full line + trailing
7054        // newline (linewise yank convention).
7055        run_keys(&mut e, "y");
7056        assert_eq!(e.last_yank.as_deref(), Some("hello world\n"));
7057    }
7058
7059    #[test]
7060    fn visual_line_extends_both_directions() {
7061        let mut e = editor_with("aaa\nbbb\nccc\nddd");
7062        run_keys(&mut e, "jjj"); // row 3, col 0
7063        run_keys(&mut e, "V");
7064        assert_eq!(e.cursor(), (3, 0));
7065        run_keys(&mut e, "k");
7066        // Cursor is free to sit on its natural column — no forced Jump.
7067        assert_eq!(e.cursor(), (2, 0));
7068        run_keys(&mut e, "k");
7069        assert_eq!(e.cursor(), (1, 0));
7070    }
7071
7072    #[test]
7073    fn visual_char_preserves_cursor_column() {
7074        let mut e = editor_with("hello world");
7075        run_keys(&mut e, "lllll"); // col 5
7076        run_keys(&mut e, "v");
7077        assert_eq!(e.cursor(), (0, 5));
7078        run_keys(&mut e, "ll");
7079        assert_eq!(e.cursor(), (0, 7));
7080    }
7081
7082    #[test]
7083    fn visual_char_highlight_bounds_order() {
7084        let mut e = editor_with("abcdef");
7085        run_keys(&mut e, "lll"); // col 3
7086        run_keys(&mut e, "v");
7087        run_keys(&mut e, "hh"); // col 1
7088        // Anchor (0, 3), cursor (0, 1). Bounds ordered: start=(0,1) end=(0,3).
7089        assert_eq!(e.char_highlight(), Some(((0, 1), (0, 3))));
7090    }
7091
7092    #[test]
7093    fn visual_line_highlight_bounds() {
7094        let mut e = editor_with("a\nb\nc");
7095        run_keys(&mut e, "V");
7096        assert_eq!(e.line_highlight(), Some((0, 0)));
7097        run_keys(&mut e, "j");
7098        assert_eq!(e.line_highlight(), Some((0, 1)));
7099        run_keys(&mut e, "j");
7100        assert_eq!(e.line_highlight(), Some((0, 2)));
7101    }
7102
7103    // ─── Basic motions ─────────────────────────────────────────────────────
7104
7105    #[test]
7106    fn h_moves_left() {
7107        let mut e = editor_with("hello");
7108        e.jump_cursor(0, 3);
7109        run_keys(&mut e, "h");
7110        assert_eq!(e.cursor(), (0, 2));
7111    }
7112
7113    #[test]
7114    fn l_moves_right() {
7115        let mut e = editor_with("hello");
7116        run_keys(&mut e, "l");
7117        assert_eq!(e.cursor(), (0, 1));
7118    }
7119
7120    #[test]
7121    fn k_moves_up() {
7122        let mut e = editor_with("a\nb\nc");
7123        e.jump_cursor(2, 0);
7124        run_keys(&mut e, "k");
7125        assert_eq!(e.cursor(), (1, 0));
7126    }
7127
7128    #[test]
7129    fn zero_moves_to_line_start() {
7130        let mut e = editor_with("    hello");
7131        run_keys(&mut e, "$");
7132        run_keys(&mut e, "0");
7133        assert_eq!(e.cursor().1, 0);
7134    }
7135
7136    #[test]
7137    fn caret_moves_to_first_non_blank() {
7138        let mut e = editor_with("    hello");
7139        run_keys(&mut e, "0");
7140        run_keys(&mut e, "^");
7141        assert_eq!(e.cursor().1, 4);
7142    }
7143
7144    #[test]
7145    fn dollar_moves_to_last_char() {
7146        let mut e = editor_with("hello");
7147        run_keys(&mut e, "$");
7148        assert_eq!(e.cursor().1, 4);
7149    }
7150
7151    #[test]
7152    fn dollar_on_empty_line_stays_at_col_zero() {
7153        let mut e = editor_with("");
7154        run_keys(&mut e, "$");
7155        assert_eq!(e.cursor().1, 0);
7156    }
7157
7158    #[test]
7159    fn w_jumps_to_next_word() {
7160        let mut e = editor_with("foo bar baz");
7161        run_keys(&mut e, "w");
7162        assert_eq!(e.cursor().1, 4);
7163    }
7164
7165    #[test]
7166    fn b_jumps_back_a_word() {
7167        let mut e = editor_with("foo bar");
7168        e.jump_cursor(0, 6);
7169        run_keys(&mut e, "b");
7170        assert_eq!(e.cursor().1, 4);
7171    }
7172
7173    #[test]
7174    fn e_jumps_to_word_end() {
7175        let mut e = editor_with("foo bar");
7176        run_keys(&mut e, "e");
7177        assert_eq!(e.cursor().1, 2);
7178    }
7179
7180    // ─── Operators with line-edge and file-edge motions ───────────────────
7181
7182    #[test]
7183    fn d_dollar_deletes_to_eol() {
7184        let mut e = editor_with("hello world");
7185        e.jump_cursor(0, 5);
7186        run_keys(&mut e, "d$");
7187        assert_eq!(e.buffer().lines()[0], "hello");
7188    }
7189
7190    #[test]
7191    fn d_zero_deletes_to_line_start() {
7192        let mut e = editor_with("hello world");
7193        e.jump_cursor(0, 6);
7194        run_keys(&mut e, "d0");
7195        assert_eq!(e.buffer().lines()[0], "world");
7196    }
7197
7198    #[test]
7199    fn d_caret_deletes_to_first_non_blank() {
7200        let mut e = editor_with("    hello");
7201        e.jump_cursor(0, 6);
7202        run_keys(&mut e, "d^");
7203        assert_eq!(e.buffer().lines()[0], "    llo");
7204    }
7205
7206    #[test]
7207    fn d_capital_g_deletes_to_end_of_file() {
7208        let mut e = editor_with("a\nb\nc\nd");
7209        e.jump_cursor(1, 0);
7210        run_keys(&mut e, "dG");
7211        assert_eq!(e.buffer().lines(), &["a".to_string()]);
7212    }
7213
7214    #[test]
7215    fn d_gg_deletes_to_start_of_file() {
7216        let mut e = editor_with("a\nb\nc\nd");
7217        e.jump_cursor(2, 0);
7218        run_keys(&mut e, "dgg");
7219        assert_eq!(e.buffer().lines(), &["d".to_string()]);
7220    }
7221
7222    #[test]
7223    fn cw_is_ce_quirk() {
7224        // `cw` on a non-blank word must NOT eat the trailing whitespace;
7225        // it behaves like `ce` so the replacement lands before the space.
7226        let mut e = editor_with("foo bar");
7227        run_keys(&mut e, "cwxyz<Esc>");
7228        assert_eq!(e.buffer().lines()[0], "xyz bar");
7229    }
7230
7231    // ─── Single-char edits ────────────────────────────────────────────────
7232
7233    #[test]
7234    fn big_d_deletes_to_eol() {
7235        let mut e = editor_with("hello world");
7236        e.jump_cursor(0, 5);
7237        run_keys(&mut e, "D");
7238        assert_eq!(e.buffer().lines()[0], "hello");
7239    }
7240
7241    #[test]
7242    fn big_c_deletes_to_eol_and_inserts() {
7243        let mut e = editor_with("hello world");
7244        e.jump_cursor(0, 5);
7245        run_keys(&mut e, "C!<Esc>");
7246        assert_eq!(e.buffer().lines()[0], "hello!");
7247    }
7248
7249    #[test]
7250    fn j_joins_next_line_with_space() {
7251        let mut e = editor_with("hello\nworld");
7252        run_keys(&mut e, "J");
7253        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7254    }
7255
7256    #[test]
7257    fn j_strips_leading_whitespace_on_join() {
7258        let mut e = editor_with("hello\n    world");
7259        run_keys(&mut e, "J");
7260        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7261    }
7262
7263    #[test]
7264    fn big_x_deletes_char_before_cursor() {
7265        let mut e = editor_with("hello");
7266        e.jump_cursor(0, 3);
7267        run_keys(&mut e, "X");
7268        assert_eq!(e.buffer().lines()[0], "helo");
7269    }
7270
7271    #[test]
7272    fn s_substitutes_char_and_enters_insert() {
7273        let mut e = editor_with("hello");
7274        run_keys(&mut e, "sX<Esc>");
7275        assert_eq!(e.buffer().lines()[0], "Xello");
7276    }
7277
7278    #[test]
7279    fn count_x_deletes_many() {
7280        let mut e = editor_with("abcdef");
7281        run_keys(&mut e, "3x");
7282        assert_eq!(e.buffer().lines()[0], "def");
7283    }
7284
7285    // ─── Paste ────────────────────────────────────────────────────────────
7286
7287    #[test]
7288    fn p_pastes_charwise_after_cursor() {
7289        let mut e = editor_with("hello");
7290        run_keys(&mut e, "yw");
7291        run_keys(&mut e, "$p");
7292        assert_eq!(e.buffer().lines()[0], "hellohello");
7293    }
7294
7295    #[test]
7296    fn capital_p_pastes_charwise_before_cursor() {
7297        let mut e = editor_with("hello");
7298        // Yank "he" (2 chars) then paste it before the cursor.
7299        run_keys(&mut e, "v");
7300        run_keys(&mut e, "l");
7301        run_keys(&mut e, "y");
7302        run_keys(&mut e, "$P");
7303        // After yank cursor is at 0; $ goes to end (col 4), P pastes
7304        // before cursor — "hell" + "he" + "o" = "hellheo".
7305        assert_eq!(e.buffer().lines()[0], "hellheo");
7306    }
7307
7308    #[test]
7309    fn p_pastes_linewise_below() {
7310        let mut e = editor_with("one\ntwo\nthree");
7311        run_keys(&mut e, "yy");
7312        run_keys(&mut e, "p");
7313        assert_eq!(
7314            e.buffer().lines(),
7315            &[
7316                "one".to_string(),
7317                "one".to_string(),
7318                "two".to_string(),
7319                "three".to_string()
7320            ]
7321        );
7322    }
7323
7324    #[test]
7325    fn capital_p_pastes_linewise_above() {
7326        let mut e = editor_with("one\ntwo");
7327        e.jump_cursor(1, 0);
7328        run_keys(&mut e, "yy");
7329        run_keys(&mut e, "P");
7330        assert_eq!(
7331            e.buffer().lines(),
7332            &["one".to_string(), "two".to_string(), "two".to_string()]
7333        );
7334    }
7335
7336    // ─── Reverse word search ──────────────────────────────────────────────
7337
7338    #[test]
7339    fn hash_finds_previous_occurrence() {
7340        let mut e = editor_with("foo bar foo baz foo");
7341        // Move to the third 'foo' then #.
7342        e.jump_cursor(0, 16);
7343        run_keys(&mut e, "#");
7344        assert_eq!(e.cursor().1, 8);
7345    }
7346
7347    // ─── VisualLine delete / change ───────────────────────────────────────
7348
7349    #[test]
7350    fn visual_line_delete_removes_full_lines() {
7351        let mut e = editor_with("a\nb\nc\nd");
7352        run_keys(&mut e, "Vjd");
7353        assert_eq!(e.buffer().lines(), &["c".to_string(), "d".to_string()]);
7354    }
7355
7356    #[test]
7357    fn visual_line_change_leaves_blank_line() {
7358        let mut e = editor_with("a\nb\nc");
7359        run_keys(&mut e, "Vjc");
7360        assert_eq!(e.vim_mode(), VimMode::Insert);
7361        run_keys(&mut e, "X<Esc>");
7362        // `Vjc` wipes rows 0-1's contents and leaves a blank line in
7363        // their place (vim convention). Typing `X` lands on that blank
7364        // first line.
7365        assert_eq!(e.buffer().lines(), &["X".to_string(), "c".to_string()]);
7366    }
7367
7368    #[test]
7369    fn cc_leaves_blank_line() {
7370        let mut e = editor_with("a\nb\nc");
7371        e.jump_cursor(1, 0);
7372        run_keys(&mut e, "ccX<Esc>");
7373        assert_eq!(
7374            e.buffer().lines(),
7375            &["a".to_string(), "X".to_string(), "c".to_string()]
7376        );
7377    }
7378
7379    // ─── Scrolling ────────────────────────────────────────────────────────
7380
7381    // ─── WORD motions (W/B/E) ─────────────────────────────────────────────
7382
7383    #[test]
7384    fn big_w_skips_hyphens() {
7385        // `w` stops at `-`; `W` treats the whole `foo-bar` as one WORD.
7386        let mut e = editor_with("foo-bar baz");
7387        run_keys(&mut e, "W");
7388        assert_eq!(e.cursor().1, 8);
7389    }
7390
7391    #[test]
7392    fn big_w_crosses_lines() {
7393        let mut e = editor_with("foo-bar\nbaz-qux");
7394        run_keys(&mut e, "W");
7395        assert_eq!(e.cursor(), (1, 0));
7396    }
7397
7398    #[test]
7399    fn big_b_skips_hyphens() {
7400        let mut e = editor_with("foo-bar baz");
7401        e.jump_cursor(0, 9);
7402        run_keys(&mut e, "B");
7403        assert_eq!(e.cursor().1, 8);
7404        run_keys(&mut e, "B");
7405        assert_eq!(e.cursor().1, 0);
7406    }
7407
7408    #[test]
7409    fn big_e_jumps_to_big_word_end() {
7410        let mut e = editor_with("foo-bar baz");
7411        run_keys(&mut e, "E");
7412        assert_eq!(e.cursor().1, 6);
7413        run_keys(&mut e, "E");
7414        assert_eq!(e.cursor().1, 10);
7415    }
7416
7417    #[test]
7418    fn dw_with_big_word_variant() {
7419        // `dW` uses the WORD motion, so `foo-bar` deletes as a unit.
7420        let mut e = editor_with("foo-bar baz");
7421        run_keys(&mut e, "dW");
7422        assert_eq!(e.buffer().lines()[0], "baz");
7423    }
7424
7425    // ─── Insert-mode Ctrl shortcuts ──────────────────────────────────────
7426
7427    #[test]
7428    fn insert_ctrl_w_deletes_word_back() {
7429        let mut e = editor_with("");
7430        run_keys(&mut e, "i");
7431        for c in "hello world".chars() {
7432            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7433        }
7434        run_keys(&mut e, "<C-w>");
7435        assert_eq!(e.buffer().lines()[0], "hello ");
7436    }
7437
7438    #[test]
7439    fn insert_ctrl_w_at_col0_joins_with_prev_word() {
7440        // Vim with default `backspace=indent,eol,start`: Ctrl-W at the
7441        // start of a row joins to the previous line and deletes the
7442        // word now before the cursor.
7443        let mut e = editor_with("hello\nworld");
7444        e.jump_cursor(1, 0);
7445        run_keys(&mut e, "i");
7446        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7447        // "hello" was the only word on row 0; it gets deleted, leaving
7448        // "world" on a single line.
7449        assert_eq!(e.buffer().lines(), vec!["world".to_string()]);
7450        assert_eq!(e.cursor(), (0, 0));
7451    }
7452
7453    #[test]
7454    fn insert_ctrl_w_at_col0_keeps_prefix_words() {
7455        let mut e = editor_with("foo bar\nbaz");
7456        e.jump_cursor(1, 0);
7457        run_keys(&mut e, "i");
7458        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7459        // Joins lines, then deletes the trailing "bar" of the prev line.
7460        assert_eq!(e.buffer().lines(), vec!["foo baz".to_string()]);
7461        assert_eq!(e.cursor(), (0, 4));
7462    }
7463
7464    #[test]
7465    fn insert_ctrl_u_deletes_to_line_start() {
7466        let mut e = editor_with("");
7467        run_keys(&mut e, "i");
7468        for c in "hello world".chars() {
7469            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7470        }
7471        run_keys(&mut e, "<C-u>");
7472        assert_eq!(e.buffer().lines()[0], "");
7473    }
7474
7475    #[test]
7476    fn insert_ctrl_o_runs_one_normal_command() {
7477        let mut e = editor_with("hello world");
7478        // Enter insert, then Ctrl-o dw (delete a word while in insert).
7479        run_keys(&mut e, "A");
7480        assert_eq!(e.vim_mode(), VimMode::Insert);
7481        // Move cursor back to start of "hello" for the Ctrl-o dw.
7482        e.jump_cursor(0, 0);
7483        run_keys(&mut e, "<C-o>");
7484        assert_eq!(e.vim_mode(), VimMode::Normal);
7485        run_keys(&mut e, "dw");
7486        // After the command completes, back in insert.
7487        assert_eq!(e.vim_mode(), VimMode::Insert);
7488        assert_eq!(e.buffer().lines()[0], "world");
7489    }
7490
7491    // ─── Sticky column across vertical motion ────────────────────────────
7492
7493    #[test]
7494    fn j_through_empty_line_preserves_column() {
7495        let mut e = editor_with("hello world\n\nanother line");
7496        // Park cursor at col 6 on row 0.
7497        run_keys(&mut e, "llllll");
7498        assert_eq!(e.cursor(), (0, 6));
7499        // j into the empty line — cursor clamps to (1, 0) visually, but
7500        // sticky col stays at 6.
7501        run_keys(&mut e, "j");
7502        assert_eq!(e.cursor(), (1, 0));
7503        // j onto a longer row — sticky col restores us to col 6.
7504        run_keys(&mut e, "j");
7505        assert_eq!(e.cursor(), (2, 6));
7506    }
7507
7508    #[test]
7509    fn j_through_shorter_line_preserves_column() {
7510        let mut e = editor_with("hello world\nhi\nanother line");
7511        run_keys(&mut e, "lllllll"); // col 7
7512        run_keys(&mut e, "j"); // short line — clamps to col 1
7513        assert_eq!(e.cursor(), (1, 1));
7514        run_keys(&mut e, "j");
7515        assert_eq!(e.cursor(), (2, 7));
7516    }
7517
7518    #[test]
7519    fn esc_from_insert_sticky_matches_visible_cursor() {
7520        // Cursor at col 12, I (moves to col 4), type "X" (col 5), Esc
7521        // backs to col 4 — sticky must mirror that visible col so j
7522        // lands at col 4 of the next row, not col 5 or col 12.
7523        let mut e = editor_with("    this is a line\n    another one of a similar size");
7524        e.jump_cursor(0, 12);
7525        run_keys(&mut e, "I");
7526        assert_eq!(e.cursor(), (0, 4));
7527        run_keys(&mut e, "X<Esc>");
7528        assert_eq!(e.cursor(), (0, 4));
7529        run_keys(&mut e, "j");
7530        assert_eq!(e.cursor(), (1, 4));
7531    }
7532
7533    #[test]
7534    fn esc_from_insert_sticky_tracks_inserted_chars() {
7535        let mut e = editor_with("xxxxxxx\nyyyyyyy");
7536        run_keys(&mut e, "i");
7537        run_keys(&mut e, "abc<Esc>");
7538        assert_eq!(e.cursor(), (0, 2));
7539        run_keys(&mut e, "j");
7540        assert_eq!(e.cursor(), (1, 2));
7541    }
7542
7543    #[test]
7544    fn esc_from_insert_sticky_tracks_arrow_nav() {
7545        let mut e = editor_with("xxxxxx\nyyyyyy");
7546        run_keys(&mut e, "i");
7547        run_keys(&mut e, "abc");
7548        for _ in 0..2 {
7549            e.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
7550        }
7551        run_keys(&mut e, "<Esc>");
7552        assert_eq!(e.cursor(), (0, 0));
7553        run_keys(&mut e, "j");
7554        assert_eq!(e.cursor(), (1, 0));
7555    }
7556
7557    #[test]
7558    fn esc_from_insert_at_col_14_followed_by_j() {
7559        // User-reported regression: cursor at col 14, i, type "test "
7560        // (5 chars → col 19), Esc → col 18. j must land at col 18.
7561        let line = "x".repeat(30);
7562        let buf = format!("{line}\n{line}");
7563        let mut e = editor_with(&buf);
7564        e.jump_cursor(0, 14);
7565        run_keys(&mut e, "i");
7566        for c in "test ".chars() {
7567            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7568        }
7569        run_keys(&mut e, "<Esc>");
7570        assert_eq!(e.cursor(), (0, 18));
7571        run_keys(&mut e, "j");
7572        assert_eq!(e.cursor(), (1, 18));
7573    }
7574
7575    #[test]
7576    fn linewise_paste_resets_sticky_column() {
7577        // yy then p lands the cursor on the first non-blank of the
7578        // pasted line; the next j must not drag back to the old
7579        // sticky column.
7580        let mut e = editor_with("    hello\naaaaaaaa\nbye");
7581        run_keys(&mut e, "llllll"); // col 6, sticky = 6
7582        run_keys(&mut e, "yy");
7583        run_keys(&mut e, "j"); // into row 1 col 6
7584        run_keys(&mut e, "p"); // paste below row 1 — cursor on "    hello"
7585        // Cursor should be at (2, 4) — first non-blank of the pasted line.
7586        assert_eq!(e.cursor(), (2, 4));
7587        // j should then preserve col 4, not jump back to 6.
7588        run_keys(&mut e, "j");
7589        assert_eq!(e.cursor(), (3, 2));
7590    }
7591
7592    #[test]
7593    fn horizontal_motion_resyncs_sticky_column() {
7594        // Starting col 6 on row 0, go back to col 3, then down through
7595        // an empty row. The sticky col should be 3 (from the last `h`
7596        // sequence), not 6.
7597        let mut e = editor_with("hello world\n\nanother line");
7598        run_keys(&mut e, "llllll"); // col 6
7599        run_keys(&mut e, "hhh"); // col 3
7600        run_keys(&mut e, "jj");
7601        assert_eq!(e.cursor(), (2, 3));
7602    }
7603
7604    // ─── Visual block ────────────────────────────────────────────────────
7605
7606    #[test]
7607    fn ctrl_v_enters_visual_block() {
7608        let mut e = editor_with("aaa\nbbb\nccc");
7609        run_keys(&mut e, "<C-v>");
7610        assert_eq!(e.vim_mode(), VimMode::VisualBlock);
7611    }
7612
7613    #[test]
7614    fn visual_block_esc_returns_to_normal() {
7615        let mut e = editor_with("aaa\nbbb\nccc");
7616        run_keys(&mut e, "<C-v>");
7617        run_keys(&mut e, "<Esc>");
7618        assert_eq!(e.vim_mode(), VimMode::Normal);
7619    }
7620
7621    #[test]
7622    fn backtick_lt_jumps_to_visual_start_mark() {
7623        // `` `< `` jumps the cursor to the start of the last visual selection.
7624        // Regression: pre-0.5.7, handle_goto_mark didn't recognise `<` / `>`
7625        // as targets even though set_mark stored them correctly.
7626        let mut e = editor_with("foo bar baz\n");
7627        run_keys(&mut e, "v");
7628        run_keys(&mut e, "w"); // cursor advances to col 4
7629        run_keys(&mut e, "<Esc>"); // sets `<` = (0,0), `>` = (0,4)
7630        assert_eq!(e.cursor(), (0, 4));
7631        // `<lt>` is the helper's literal-`<` escape (see run_keys docstring).
7632        run_keys(&mut e, "`<lt>");
7633        assert_eq!(e.cursor(), (0, 0));
7634    }
7635
7636    #[test]
7637    fn backtick_gt_jumps_to_visual_end_mark() {
7638        let mut e = editor_with("foo bar baz\n");
7639        run_keys(&mut e, "v");
7640        run_keys(&mut e, "w"); // cursor at col 4
7641        run_keys(&mut e, "<Esc>");
7642        run_keys(&mut e, "0"); // cursor at col 0
7643        run_keys(&mut e, "`>");
7644        assert_eq!(e.cursor(), (0, 4));
7645    }
7646
7647    #[test]
7648    fn visual_exit_sets_lt_gt_marks() {
7649        // Vim sets `<` to the start and `>` to the end of the last visual
7650        // selection on every visual exit. Required for :'<,'> ex ranges.
7651        let mut e = editor_with("aaa\nbbb\nccc\nddd");
7652        // V<j><Esc> → selects rows 0..=1 in line-wise visual.
7653        run_keys(&mut e, "V");
7654        run_keys(&mut e, "j");
7655        run_keys(&mut e, "<Esc>");
7656        let lt = e.mark('<').expect("'<' mark must be set on visual exit");
7657        let gt = e.mark('>').expect("'>' mark must be set on visual exit");
7658        assert_eq!(lt.0, 0, "'< row should be the lower bound");
7659        assert_eq!(gt.0, 1, "'> row should be the upper bound");
7660    }
7661
7662    #[test]
7663    fn visual_exit_marks_use_lower_higher_order() {
7664        // Selecting upward (cursor < anchor) must still produce `<` = lower,
7665        // `>` = higher — vim's marks are position-ordered, not selection-
7666        // ordered.
7667        let mut e = editor_with("aaa\nbbb\nccc\nddd");
7668        run_keys(&mut e, "jjj"); // cursor at row 3
7669        run_keys(&mut e, "V");
7670        run_keys(&mut e, "k"); // anchor row 3, cursor row 2
7671        run_keys(&mut e, "<Esc>");
7672        let lt = e.mark('<').unwrap();
7673        let gt = e.mark('>').unwrap();
7674        assert_eq!(lt.0, 2);
7675        assert_eq!(gt.0, 3);
7676    }
7677
7678    #[test]
7679    fn visualline_exit_marks_snap_to_line_edges() {
7680        // VisualLine: `<` snaps to col 0, `>` snaps to last col of bot row.
7681        let mut e = editor_with("aaaaa\nbbbbb\ncc");
7682        run_keys(&mut e, "lll"); // cursor at row 0, col 3
7683        run_keys(&mut e, "V");
7684        run_keys(&mut e, "j"); // VisualLine over rows 0..=1
7685        run_keys(&mut e, "<Esc>");
7686        let lt = e.mark('<').unwrap();
7687        let gt = e.mark('>').unwrap();
7688        assert_eq!(lt, (0, 0), "'< should snap to (top_row, 0)");
7689        // Row 1 is "bbbbb" — last col is 4.
7690        assert_eq!(gt, (1, 4), "'> should snap to (bot_row, last_col)");
7691    }
7692
7693    #[test]
7694    fn visualblock_exit_marks_use_block_corners() {
7695        // VisualBlock with cursor moving left + down. Corners are not
7696        // tuple-ordered: top-left is (anchor_row, cursor_col), bottom-right
7697        // is (cursor_row, anchor_col). `<` must be top-left, `>` bottom-right.
7698        let mut e = editor_with("aaaaa\nbbbbb\nccccc");
7699        run_keys(&mut e, "llll"); // row 0, col 4
7700        run_keys(&mut e, "<C-v>");
7701        run_keys(&mut e, "j"); // row 1, col 4
7702        run_keys(&mut e, "hh"); // row 1, col 2
7703        run_keys(&mut e, "<Esc>");
7704        let lt = e.mark('<').unwrap();
7705        let gt = e.mark('>').unwrap();
7706        // anchor=(0,4), cursor=(1,2) → corners are (0,2) and (1,4).
7707        assert_eq!(lt, (0, 2), "'< should be top-left corner");
7708        assert_eq!(gt, (1, 4), "'> should be bottom-right corner");
7709    }
7710
7711    #[test]
7712    fn visual_block_delete_removes_column_range() {
7713        let mut e = editor_with("hello\nworld\nhappy");
7714        // Move off col 0 first so the block starts mid-row.
7715        run_keys(&mut e, "l");
7716        run_keys(&mut e, "<C-v>");
7717        run_keys(&mut e, "jj");
7718        run_keys(&mut e, "ll");
7719        run_keys(&mut e, "d");
7720        // Deletes cols 1-3 on every row — "ell" / "orl" / "app".
7721        assert_eq!(
7722            e.buffer().lines(),
7723            &["ho".to_string(), "wd".to_string(), "hy".to_string()]
7724        );
7725    }
7726
7727    #[test]
7728    fn visual_block_yank_joins_with_newlines() {
7729        let mut e = editor_with("hello\nworld\nhappy");
7730        run_keys(&mut e, "<C-v>");
7731        run_keys(&mut e, "jj");
7732        run_keys(&mut e, "ll");
7733        run_keys(&mut e, "y");
7734        assert_eq!(e.last_yank.as_deref(), Some("hel\nwor\nhap"));
7735    }
7736
7737    #[test]
7738    fn visual_block_replace_fills_block() {
7739        let mut e = editor_with("hello\nworld\nhappy");
7740        run_keys(&mut e, "<C-v>");
7741        run_keys(&mut e, "jj");
7742        run_keys(&mut e, "ll");
7743        run_keys(&mut e, "rx");
7744        assert_eq!(
7745            e.buffer().lines(),
7746            &[
7747                "xxxlo".to_string(),
7748                "xxxld".to_string(),
7749                "xxxpy".to_string()
7750            ]
7751        );
7752    }
7753
7754    #[test]
7755    fn visual_block_insert_repeats_across_rows() {
7756        let mut e = editor_with("hello\nworld\nhappy");
7757        run_keys(&mut e, "<C-v>");
7758        run_keys(&mut e, "jj");
7759        run_keys(&mut e, "I");
7760        run_keys(&mut e, "# <Esc>");
7761        assert_eq!(
7762            e.buffer().lines(),
7763            &[
7764                "# hello".to_string(),
7765                "# world".to_string(),
7766                "# happy".to_string()
7767            ]
7768        );
7769    }
7770
7771    #[test]
7772    fn block_highlight_returns_none_outside_block_mode() {
7773        let mut e = editor_with("abc");
7774        assert!(e.block_highlight().is_none());
7775        run_keys(&mut e, "v");
7776        assert!(e.block_highlight().is_none());
7777        run_keys(&mut e, "<Esc>V");
7778        assert!(e.block_highlight().is_none());
7779    }
7780
7781    #[test]
7782    fn block_highlight_bounds_track_anchor_and_cursor() {
7783        let mut e = editor_with("aaaa\nbbbb\ncccc");
7784        run_keys(&mut e, "ll"); // cursor (0, 2)
7785        run_keys(&mut e, "<C-v>");
7786        run_keys(&mut e, "jh"); // cursor (1, 1)
7787        // anchor = (0, 2), cursor = (1, 1) → top=0 bot=1 left=1 right=2.
7788        assert_eq!(e.block_highlight(), Some((0, 1, 1, 2)));
7789    }
7790
7791    #[test]
7792    fn visual_block_delete_handles_short_lines() {
7793        // Middle row is shorter than the block's right column.
7794        let mut e = editor_with("hello\nhi\nworld");
7795        run_keys(&mut e, "l"); // col 1
7796        run_keys(&mut e, "<C-v>");
7797        run_keys(&mut e, "jjll"); // cursor (2, 3)
7798        run_keys(&mut e, "d");
7799        // Row 0: delete cols 1-3 ("ell") → "ho".
7800        // Row 1: only 2 chars ("hi"); block starts at col 1, so just "i"
7801        //        gets removed → "h".
7802        // Row 2: delete cols 1-3 ("orl") → "wd".
7803        assert_eq!(
7804            e.buffer().lines(),
7805            &["ho".to_string(), "h".to_string(), "wd".to_string()]
7806        );
7807    }
7808
7809    #[test]
7810    fn visual_block_yank_pads_short_lines_with_empties() {
7811        let mut e = editor_with("hello\nhi\nworld");
7812        run_keys(&mut e, "l");
7813        run_keys(&mut e, "<C-v>");
7814        run_keys(&mut e, "jjll");
7815        run_keys(&mut e, "y");
7816        // Row 0 chars 1-3 = "ell"; row 1 chars 1- (only "i"); row 2 "orl".
7817        assert_eq!(e.last_yank.as_deref(), Some("ell\ni\norl"));
7818    }
7819
7820    #[test]
7821    fn visual_block_replace_skips_past_eol() {
7822        // Block extends past the end of every row in column range;
7823        // replace should leave lines shorter than `left` untouched.
7824        let mut e = editor_with("ab\ncd\nef");
7825        // Put cursor at col 1 (last char), extend block 5 columns right.
7826        run_keys(&mut e, "l");
7827        run_keys(&mut e, "<C-v>");
7828        run_keys(&mut e, "jjllllll");
7829        run_keys(&mut e, "rX");
7830        // Every row had only col 0..=1; block covers col 1..=7 → only
7831        // col 1 is in range on each row, so just that cell changes.
7832        assert_eq!(
7833            e.buffer().lines(),
7834            &["aX".to_string(), "cX".to_string(), "eX".to_string()]
7835        );
7836    }
7837
7838    #[test]
7839    fn visual_block_with_empty_line_in_middle() {
7840        let mut e = editor_with("abcd\n\nefgh");
7841        run_keys(&mut e, "<C-v>");
7842        run_keys(&mut e, "jjll"); // cursor (2, 2)
7843        run_keys(&mut e, "d");
7844        // Row 0 cols 0-2 removed → "d". Row 1 empty → untouched.
7845        // Row 2 cols 0-2 removed → "h".
7846        assert_eq!(
7847            e.buffer().lines(),
7848            &["d".to_string(), "".to_string(), "h".to_string()]
7849        );
7850    }
7851
7852    #[test]
7853    fn block_insert_pads_empty_lines_to_block_column() {
7854        // Middle line is empty; block I at column 3 should pad the empty
7855        // line with spaces so the inserted text lines up.
7856        let mut e = editor_with("this is a line\n\nthis is a line");
7857        e.jump_cursor(0, 3);
7858        run_keys(&mut e, "<C-v>");
7859        run_keys(&mut e, "jj");
7860        run_keys(&mut e, "I");
7861        run_keys(&mut e, "XX<Esc>");
7862        assert_eq!(
7863            e.buffer().lines(),
7864            &[
7865                "thiXXs is a line".to_string(),
7866                "   XX".to_string(),
7867                "thiXXs is a line".to_string()
7868            ]
7869        );
7870    }
7871
7872    #[test]
7873    fn block_insert_pads_short_lines_to_block_column() {
7874        let mut e = editor_with("aaaaa\nbb\naaaaa");
7875        e.jump_cursor(0, 3);
7876        run_keys(&mut e, "<C-v>");
7877        run_keys(&mut e, "jj");
7878        run_keys(&mut e, "I");
7879        run_keys(&mut e, "Y<Esc>");
7880        // Row 1 "bb" is shorter than col 3 — pad with one space then Y.
7881        assert_eq!(
7882            e.buffer().lines(),
7883            &[
7884                "aaaYaa".to_string(),
7885                "bb Y".to_string(),
7886                "aaaYaa".to_string()
7887            ]
7888        );
7889    }
7890
7891    #[test]
7892    fn visual_block_append_repeats_across_rows() {
7893        let mut e = editor_with("foo\nbar\nbaz");
7894        run_keys(&mut e, "<C-v>");
7895        run_keys(&mut e, "jj");
7896        // Single-column block (anchor col = cursor col = 0); `A` appends
7897        // after column 0 on every row.
7898        run_keys(&mut e, "A");
7899        run_keys(&mut e, "!<Esc>");
7900        assert_eq!(
7901            e.buffer().lines(),
7902            &["f!oo".to_string(), "b!ar".to_string(), "b!az".to_string()]
7903        );
7904    }
7905
7906    // ─── `/` / `?` search prompt ─────────────────────────────────────────
7907
7908    #[test]
7909    fn slash_opens_forward_search_prompt() {
7910        let mut e = editor_with("hello world");
7911        run_keys(&mut e, "/");
7912        let p = e.search_prompt().expect("prompt should be active");
7913        assert!(p.text.is_empty());
7914        assert!(p.forward);
7915    }
7916
7917    #[test]
7918    fn question_opens_backward_search_prompt() {
7919        let mut e = editor_with("hello world");
7920        run_keys(&mut e, "?");
7921        let p = e.search_prompt().expect("prompt should be active");
7922        assert!(!p.forward);
7923    }
7924
7925    #[test]
7926    fn search_prompt_typing_updates_pattern_live() {
7927        let mut e = editor_with("foo bar\nbaz");
7928        run_keys(&mut e, "/bar");
7929        assert_eq!(e.search_prompt().unwrap().text, "bar");
7930        // Pattern set on the engine search state for live highlight.
7931        assert!(e.search_state().pattern.is_some());
7932    }
7933
7934    #[test]
7935    fn search_prompt_backspace_and_enter() {
7936        let mut e = editor_with("hello world\nagain");
7937        run_keys(&mut e, "/worlx");
7938        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
7939        assert_eq!(e.search_prompt().unwrap().text, "worl");
7940        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7941        // Prompt closed, last_search set, cursor advanced to match.
7942        assert!(e.search_prompt().is_none());
7943        assert_eq!(e.last_search(), Some("worl"));
7944        assert_eq!(e.cursor(), (0, 6));
7945    }
7946
7947    #[test]
7948    fn empty_search_prompt_enter_repeats_last_search() {
7949        let mut e = editor_with("foo bar foo baz foo");
7950        run_keys(&mut e, "/foo");
7951        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7952        assert_eq!(e.cursor().1, 8);
7953        // Empty `/<CR>` should advance to the next match, not clear last_search.
7954        run_keys(&mut e, "/");
7955        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7956        assert_eq!(e.cursor().1, 16);
7957        assert_eq!(e.last_search(), Some("foo"));
7958    }
7959
7960    #[test]
7961    fn search_history_records_committed_patterns() {
7962        let mut e = editor_with("alpha beta gamma");
7963        run_keys(&mut e, "/alpha<CR>");
7964        run_keys(&mut e, "/beta<CR>");
7965        // Newest entry at the back.
7966        let history = e.vim.search_history.clone();
7967        assert_eq!(history, vec!["alpha", "beta"]);
7968    }
7969
7970    #[test]
7971    fn search_history_dedupes_consecutive_repeats() {
7972        let mut e = editor_with("foo bar foo");
7973        run_keys(&mut e, "/foo<CR>");
7974        run_keys(&mut e, "/foo<CR>");
7975        run_keys(&mut e, "/bar<CR>");
7976        run_keys(&mut e, "/bar<CR>");
7977        // Two distinct entries; the duplicates collapsed.
7978        assert_eq!(e.vim.search_history.clone(), vec!["foo", "bar"]);
7979    }
7980
7981    #[test]
7982    fn ctrl_p_walks_history_backward() {
7983        let mut e = editor_with("alpha beta gamma");
7984        run_keys(&mut e, "/alpha<CR>");
7985        run_keys(&mut e, "/beta<CR>");
7986        // Open a fresh prompt; Ctrl-P pulls in the newest entry.
7987        run_keys(&mut e, "/");
7988        assert_eq!(e.search_prompt().unwrap().text, "");
7989        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7990        assert_eq!(e.search_prompt().unwrap().text, "beta");
7991        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7992        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7993        // At the oldest entry; further Ctrl-P is a no-op.
7994        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7995        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7996    }
7997
7998    #[test]
7999    fn ctrl_n_walks_history_forward_after_ctrl_p() {
8000        let mut e = editor_with("a b c");
8001        run_keys(&mut e, "/a<CR>");
8002        run_keys(&mut e, "/b<CR>");
8003        run_keys(&mut e, "/c<CR>");
8004        run_keys(&mut e, "/");
8005        // Walk back to "a", then forward again.
8006        for _ in 0..3 {
8007            e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
8008        }
8009        assert_eq!(e.search_prompt().unwrap().text, "a");
8010        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
8011        assert_eq!(e.search_prompt().unwrap().text, "b");
8012        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
8013        assert_eq!(e.search_prompt().unwrap().text, "c");
8014        // Past the newest — stays at "c".
8015        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
8016        assert_eq!(e.search_prompt().unwrap().text, "c");
8017    }
8018
8019    #[test]
8020    fn typing_after_history_walk_resets_cursor() {
8021        let mut e = editor_with("foo");
8022        run_keys(&mut e, "/foo<CR>");
8023        run_keys(&mut e, "/");
8024        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
8025        assert_eq!(e.search_prompt().unwrap().text, "foo");
8026        // User edits — append a char. Next Ctrl-P should restart from
8027        // the newest entry, not continue walking older.
8028        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
8029        assert_eq!(e.search_prompt().unwrap().text, "foox");
8030        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
8031        assert_eq!(e.search_prompt().unwrap().text, "foo");
8032    }
8033
8034    #[test]
8035    fn empty_backward_search_prompt_enter_repeats_last_search() {
8036        let mut e = editor_with("foo bar foo baz foo");
8037        // Forward to col 8, then `?<CR>` should walk backward to col 0.
8038        run_keys(&mut e, "/foo");
8039        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8040        assert_eq!(e.cursor().1, 8);
8041        run_keys(&mut e, "?");
8042        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8043        assert_eq!(e.cursor().1, 0);
8044        assert_eq!(e.last_search(), Some("foo"));
8045    }
8046
8047    #[test]
8048    fn search_prompt_esc_cancels_but_keeps_last_search() {
8049        let mut e = editor_with("foo bar\nbaz");
8050        run_keys(&mut e, "/bar");
8051        e.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
8052        assert!(e.search_prompt().is_none());
8053        assert_eq!(e.last_search(), Some("bar"));
8054    }
8055
8056    #[test]
8057    fn search_then_n_and_shift_n_navigate() {
8058        let mut e = editor_with("foo bar foo baz foo");
8059        run_keys(&mut e, "/foo");
8060        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8061        // `/foo` + Enter jumps forward; we land on the next match after col 0.
8062        assert_eq!(e.cursor().1, 8);
8063        run_keys(&mut e, "n");
8064        assert_eq!(e.cursor().1, 16);
8065        run_keys(&mut e, "N");
8066        assert_eq!(e.cursor().1, 8);
8067    }
8068
8069    #[test]
8070    fn question_mark_searches_backward_on_enter() {
8071        let mut e = editor_with("foo bar foo baz");
8072        e.jump_cursor(0, 10);
8073        run_keys(&mut e, "?foo");
8074        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8075        // Cursor jumps backward to the closest match before col 10.
8076        assert_eq!(e.cursor(), (0, 8));
8077    }
8078
8079    // ─── P6 quick wins (Y, gJ, ge / gE) ──────────────────────────────────
8080
8081    #[test]
8082    fn big_y_yanks_to_end_of_line() {
8083        let mut e = editor_with("hello world");
8084        e.jump_cursor(0, 6);
8085        run_keys(&mut e, "Y");
8086        assert_eq!(e.last_yank.as_deref(), Some("world"));
8087    }
8088
8089    #[test]
8090    fn big_y_from_line_start_yanks_full_line() {
8091        let mut e = editor_with("hello world");
8092        run_keys(&mut e, "Y");
8093        assert_eq!(e.last_yank.as_deref(), Some("hello world"));
8094    }
8095
8096    #[test]
8097    fn gj_joins_without_inserting_space() {
8098        let mut e = editor_with("hello\n    world");
8099        run_keys(&mut e, "gJ");
8100        // No space inserted, leading whitespace preserved.
8101        assert_eq!(e.buffer().lines(), &["hello    world".to_string()]);
8102    }
8103
8104    #[test]
8105    fn gj_noop_on_last_line() {
8106        let mut e = editor_with("only");
8107        run_keys(&mut e, "gJ");
8108        assert_eq!(e.buffer().lines(), &["only".to_string()]);
8109    }
8110
8111    #[test]
8112    fn ge_jumps_to_previous_word_end() {
8113        let mut e = editor_with("foo bar baz");
8114        e.jump_cursor(0, 5);
8115        run_keys(&mut e, "ge");
8116        assert_eq!(e.cursor(), (0, 2));
8117    }
8118
8119    #[test]
8120    fn ge_respects_word_class() {
8121        // Small-word `ge` treats `-` as its own word, so from mid-"bar"
8122        // it lands on the `-` rather than end of "foo".
8123        let mut e = editor_with("foo-bar baz");
8124        e.jump_cursor(0, 5);
8125        run_keys(&mut e, "ge");
8126        assert_eq!(e.cursor(), (0, 3));
8127    }
8128
8129    #[test]
8130    fn big_ge_treats_hyphens_as_part_of_word() {
8131        // `gE` uses WORD (whitespace-delimited) semantics so it skips
8132        // over the `-` and lands on the end of "foo-bar".
8133        let mut e = editor_with("foo-bar baz");
8134        e.jump_cursor(0, 10);
8135        run_keys(&mut e, "gE");
8136        assert_eq!(e.cursor(), (0, 6));
8137    }
8138
8139    #[test]
8140    fn ge_crosses_line_boundary() {
8141        let mut e = editor_with("foo\nbar");
8142        e.jump_cursor(1, 0);
8143        run_keys(&mut e, "ge");
8144        assert_eq!(e.cursor(), (0, 2));
8145    }
8146
8147    #[test]
8148    fn dge_deletes_to_end_of_previous_word() {
8149        let mut e = editor_with("foo bar baz");
8150        e.jump_cursor(0, 8);
8151        // d + ge from 'b' of "baz": range is ge → col 6 ('r' of bar),
8152        // inclusive, so cols 6-8 ("r b") are cut.
8153        run_keys(&mut e, "dge");
8154        assert_eq!(e.buffer().lines()[0], "foo baaz");
8155    }
8156
8157    #[test]
8158    fn ctrl_scroll_keys_do_not_panic() {
8159        // Viewport-less test: just exercise the code paths so a regression
8160        // in the scroll dispatch surfaces as a panic or assertion failure.
8161        let mut e = editor_with(
8162            (0..50)
8163                .map(|i| format!("line{i}"))
8164                .collect::<Vec<_>>()
8165                .join("\n")
8166                .as_str(),
8167        );
8168        run_keys(&mut e, "<C-f>");
8169        run_keys(&mut e, "<C-b>");
8170        // No explicit assert beyond "didn't panic".
8171        assert!(!e.buffer().lines().is_empty());
8172    }
8173
8174    /// Regression: arrow-navigation during a count-insert session must
8175    /// not pull unrelated rows into the "inserted" replay string.
8176    /// Before the fix, `before_lines` only snapshotted the entry row,
8177    /// so the diff at Esc spuriously saw the navigated-over row as
8178    /// part of the insert — count-replay then duplicated cross-row
8179    /// content across the buffer.
8180    #[test]
8181    fn count_insert_with_arrow_nav_does_not_leak_rows() {
8182        let mut e = Editor::new(
8183            hjkl_buffer::Buffer::new(),
8184            crate::types::DefaultHost::new(),
8185            crate::types::Options::default(),
8186        );
8187        e.set_content("row0\nrow1\nrow2");
8188        // `3i`, type X, arrow down, Esc.
8189        run_keys(&mut e, "3iX<Down><Esc>");
8190        // Row 0 keeps the originally-typed X.
8191        assert!(e.buffer().lines()[0].contains('X'));
8192        // Row 1 must not contain a fragment of row 0 ("row0") — that
8193        // was the buggy leak from the before-diff window.
8194        assert!(
8195            !e.buffer().lines()[1].contains("row0"),
8196            "row1 leaked row0 contents: {:?}",
8197            e.buffer().lines()[1]
8198        );
8199        // Buffer stays the same number of rows — no extra lines
8200        // injected by a multi-line "inserted" replay.
8201        assert_eq!(e.buffer().lines().len(), 3);
8202    }
8203
8204    // ─── Viewport scroll / jump tests ─────────────────────────────────
8205
8206    fn editor_with_rows(n: usize, viewport: u16) -> Editor {
8207        let mut e = Editor::new(
8208            hjkl_buffer::Buffer::new(),
8209            crate::types::DefaultHost::new(),
8210            crate::types::Options::default(),
8211        );
8212        let body = (0..n)
8213            .map(|i| format!("  line{}", i))
8214            .collect::<Vec<_>>()
8215            .join("\n");
8216        e.set_content(&body);
8217        e.set_viewport_height(viewport);
8218        e
8219    }
8220
8221    #[test]
8222    fn ctrl_d_moves_cursor_half_page_down() {
8223        let mut e = editor_with_rows(100, 20);
8224        run_keys(&mut e, "<C-d>");
8225        assert_eq!(e.cursor().0, 10);
8226    }
8227
8228    fn editor_with_wrap_lines(lines: &[&str], viewport: u16, text_width: u16) -> Editor {
8229        let mut e = Editor::new(
8230            hjkl_buffer::Buffer::new(),
8231            crate::types::DefaultHost::new(),
8232            crate::types::Options::default(),
8233        );
8234        e.set_content(&lines.join("\n"));
8235        e.set_viewport_height(viewport);
8236        let v = e.host_mut().viewport_mut();
8237        v.height = viewport;
8238        v.width = text_width;
8239        v.text_width = text_width;
8240        v.wrap = hjkl_buffer::Wrap::Char;
8241        e.settings_mut().wrap = hjkl_buffer::Wrap::Char;
8242        e
8243    }
8244
8245    #[test]
8246    fn scrolloff_wrap_keeps_cursor_off_bottom_edge() {
8247        // 10 doc rows, each wraps to 3 segments → 30 screen rows.
8248        // Viewport height 12, margin = SCROLLOFF.min(11/2) = 5,
8249        // max bottom = 11 - 5 = 6. Plenty of headroom past row 4.
8250        let lines = ["aaaabbbbcccc"; 10];
8251        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8252        e.jump_cursor(4, 0);
8253        e.ensure_cursor_in_scrolloff();
8254        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8255        assert!(csr <= 6, "csr={csr}");
8256    }
8257
8258    #[test]
8259    fn scrolloff_wrap_keeps_cursor_off_top_edge() {
8260        let lines = ["aaaabbbbcccc"; 10];
8261        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8262        // Force top down then bring cursor up so the top-edge margin
8263        // path runs.
8264        e.jump_cursor(7, 0);
8265        e.ensure_cursor_in_scrolloff();
8266        e.jump_cursor(2, 0);
8267        e.ensure_cursor_in_scrolloff();
8268        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8269        // SCROLLOFF.min((height - 1) / 2) = 5.min(5) = 5.
8270        assert!(csr >= 5, "csr={csr}");
8271    }
8272
8273    #[test]
8274    fn scrolloff_wrap_clamps_top_at_buffer_end() {
8275        let lines = ["aaaabbbbcccc"; 5];
8276        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8277        e.jump_cursor(4, 11);
8278        e.ensure_cursor_in_scrolloff();
8279        // max_top_for_height(12) on 15 screen rows: row 4 (3 segs) +
8280        // row 3 (3 segs) + row 2 (3 segs) + row 1 (3 segs) = 12 —
8281        // max_top = row 1. Margin can't be honoured at EOF (matches
8282        // vim's behaviour — scrolloff is a soft constraint).
8283        let top = e.host().viewport().top_row;
8284        assert_eq!(top, 1);
8285    }
8286
8287    #[test]
8288    fn ctrl_u_moves_cursor_half_page_up() {
8289        let mut e = editor_with_rows(100, 20);
8290        e.jump_cursor(50, 0);
8291        run_keys(&mut e, "<C-u>");
8292        assert_eq!(e.cursor().0, 40);
8293    }
8294
8295    #[test]
8296    fn ctrl_f_moves_cursor_full_page_down() {
8297        let mut e = editor_with_rows(100, 20);
8298        run_keys(&mut e, "<C-f>");
8299        // One full page ≈ h - 2 (overlap).
8300        assert_eq!(e.cursor().0, 18);
8301    }
8302
8303    #[test]
8304    fn ctrl_b_moves_cursor_full_page_up() {
8305        let mut e = editor_with_rows(100, 20);
8306        e.jump_cursor(50, 0);
8307        run_keys(&mut e, "<C-b>");
8308        assert_eq!(e.cursor().0, 32);
8309    }
8310
8311    #[test]
8312    fn ctrl_d_lands_on_first_non_blank() {
8313        let mut e = editor_with_rows(100, 20);
8314        run_keys(&mut e, "<C-d>");
8315        // "  line10" — first non-blank is col 2.
8316        assert_eq!(e.cursor().1, 2);
8317    }
8318
8319    #[test]
8320    fn ctrl_d_clamps_at_end_of_buffer() {
8321        let mut e = editor_with_rows(5, 20);
8322        run_keys(&mut e, "<C-d>");
8323        assert_eq!(e.cursor().0, 4);
8324    }
8325
8326    #[test]
8327    fn capital_h_jumps_to_viewport_top() {
8328        let mut e = editor_with_rows(100, 10);
8329        e.jump_cursor(50, 0);
8330        e.set_viewport_top(45);
8331        let top = e.host().viewport().top_row;
8332        run_keys(&mut e, "H");
8333        assert_eq!(e.cursor().0, top);
8334        assert_eq!(e.cursor().1, 2);
8335    }
8336
8337    #[test]
8338    fn capital_l_jumps_to_viewport_bottom() {
8339        let mut e = editor_with_rows(100, 10);
8340        e.jump_cursor(50, 0);
8341        e.set_viewport_top(45);
8342        let top = e.host().viewport().top_row;
8343        run_keys(&mut e, "L");
8344        assert_eq!(e.cursor().0, top + 9);
8345    }
8346
8347    #[test]
8348    fn capital_m_jumps_to_viewport_middle() {
8349        let mut e = editor_with_rows(100, 10);
8350        e.jump_cursor(50, 0);
8351        e.set_viewport_top(45);
8352        let top = e.host().viewport().top_row;
8353        run_keys(&mut e, "M");
8354        // 10-row viewport: middle is top + 4.
8355        assert_eq!(e.cursor().0, top + 4);
8356    }
8357
8358    #[test]
8359    fn g_capital_m_lands_at_line_midpoint() {
8360        let mut e = editor_with("hello world!"); // 12 chars
8361        run_keys(&mut e, "gM");
8362        // floor(12 / 2) = 6.
8363        assert_eq!(e.cursor(), (0, 6));
8364    }
8365
8366    #[test]
8367    fn g_capital_m_on_empty_line_stays_at_zero() {
8368        let mut e = editor_with("");
8369        run_keys(&mut e, "gM");
8370        assert_eq!(e.cursor(), (0, 0));
8371    }
8372
8373    #[test]
8374    fn g_capital_m_uses_current_line_only() {
8375        // Each line's midpoint is independent of others.
8376        let mut e = editor_with("a\nlonglongline"); // line 1: 12 chars
8377        e.jump_cursor(1, 0);
8378        run_keys(&mut e, "gM");
8379        assert_eq!(e.cursor(), (1, 6));
8380    }
8381
8382    #[test]
8383    fn capital_h_count_offsets_from_top() {
8384        let mut e = editor_with_rows(100, 10);
8385        e.jump_cursor(50, 0);
8386        e.set_viewport_top(45);
8387        let top = e.host().viewport().top_row;
8388        run_keys(&mut e, "3H");
8389        assert_eq!(e.cursor().0, top + 2);
8390    }
8391
8392    // ─── Jumplist tests ───────────────────────────────────────────────
8393
8394    #[test]
8395    fn ctrl_o_returns_to_pre_g_position() {
8396        let mut e = editor_with_rows(50, 20);
8397        e.jump_cursor(5, 2);
8398        run_keys(&mut e, "G");
8399        assert_eq!(e.cursor().0, 49);
8400        run_keys(&mut e, "<C-o>");
8401        assert_eq!(e.cursor(), (5, 2));
8402    }
8403
8404    #[test]
8405    fn ctrl_i_redoes_jump_after_ctrl_o() {
8406        let mut e = editor_with_rows(50, 20);
8407        e.jump_cursor(5, 2);
8408        run_keys(&mut e, "G");
8409        let post = e.cursor();
8410        run_keys(&mut e, "<C-o>");
8411        run_keys(&mut e, "<C-i>");
8412        assert_eq!(e.cursor(), post);
8413    }
8414
8415    #[test]
8416    fn new_jump_clears_forward_stack() {
8417        let mut e = editor_with_rows(50, 20);
8418        e.jump_cursor(5, 2);
8419        run_keys(&mut e, "G");
8420        run_keys(&mut e, "<C-o>");
8421        run_keys(&mut e, "gg");
8422        run_keys(&mut e, "<C-i>");
8423        assert_eq!(e.cursor().0, 0);
8424    }
8425
8426    #[test]
8427    fn ctrl_o_on_empty_stack_is_noop() {
8428        let mut e = editor_with_rows(10, 20);
8429        e.jump_cursor(3, 1);
8430        run_keys(&mut e, "<C-o>");
8431        assert_eq!(e.cursor(), (3, 1));
8432    }
8433
8434    #[test]
8435    fn asterisk_search_pushes_jump() {
8436        let mut e = editor_with("foo bar\nbaz foo end");
8437        e.jump_cursor(0, 0);
8438        run_keys(&mut e, "*");
8439        let after = e.cursor();
8440        assert_ne!(after, (0, 0));
8441        run_keys(&mut e, "<C-o>");
8442        assert_eq!(e.cursor(), (0, 0));
8443    }
8444
8445    #[test]
8446    fn h_viewport_jump_is_recorded() {
8447        let mut e = editor_with_rows(100, 10);
8448        e.jump_cursor(50, 0);
8449        e.set_viewport_top(45);
8450        let pre = e.cursor();
8451        run_keys(&mut e, "H");
8452        assert_ne!(e.cursor(), pre);
8453        run_keys(&mut e, "<C-o>");
8454        assert_eq!(e.cursor(), pre);
8455    }
8456
8457    #[test]
8458    fn j_k_motion_does_not_push_jump() {
8459        let mut e = editor_with_rows(50, 20);
8460        e.jump_cursor(5, 0);
8461        run_keys(&mut e, "jjj");
8462        run_keys(&mut e, "<C-o>");
8463        assert_eq!(e.cursor().0, 8);
8464    }
8465
8466    #[test]
8467    fn jumplist_caps_at_100() {
8468        let mut e = editor_with_rows(200, 20);
8469        for i in 0..101 {
8470            e.jump_cursor(i, 0);
8471            run_keys(&mut e, "G");
8472        }
8473        assert!(e.vim.jump_back.len() <= 100);
8474    }
8475
8476    #[test]
8477    fn tab_acts_as_ctrl_i() {
8478        let mut e = editor_with_rows(50, 20);
8479        e.jump_cursor(5, 2);
8480        run_keys(&mut e, "G");
8481        let post = e.cursor();
8482        run_keys(&mut e, "<C-o>");
8483        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8484        assert_eq!(e.cursor(), post);
8485    }
8486
8487    // ─── Mark tests ───────────────────────────────────────────────────
8488
8489    #[test]
8490    fn ma_then_backtick_a_jumps_exact() {
8491        let mut e = editor_with_rows(50, 20);
8492        e.jump_cursor(5, 3);
8493        run_keys(&mut e, "ma");
8494        e.jump_cursor(20, 0);
8495        run_keys(&mut e, "`a");
8496        assert_eq!(e.cursor(), (5, 3));
8497    }
8498
8499    #[test]
8500    fn ma_then_apostrophe_a_lands_on_first_non_blank() {
8501        let mut e = editor_with_rows(50, 20);
8502        // "  line5" — first non-blank is col 2.
8503        e.jump_cursor(5, 6);
8504        run_keys(&mut e, "ma");
8505        e.jump_cursor(30, 4);
8506        run_keys(&mut e, "'a");
8507        assert_eq!(e.cursor(), (5, 2));
8508    }
8509
8510    #[test]
8511    fn goto_mark_pushes_jumplist() {
8512        let mut e = editor_with_rows(50, 20);
8513        e.jump_cursor(10, 2);
8514        run_keys(&mut e, "mz");
8515        e.jump_cursor(3, 0);
8516        run_keys(&mut e, "`z");
8517        assert_eq!(e.cursor(), (10, 2));
8518        run_keys(&mut e, "<C-o>");
8519        assert_eq!(e.cursor(), (3, 0));
8520    }
8521
8522    #[test]
8523    fn goto_missing_mark_is_noop() {
8524        let mut e = editor_with_rows(50, 20);
8525        e.jump_cursor(3, 1);
8526        run_keys(&mut e, "`q");
8527        assert_eq!(e.cursor(), (3, 1));
8528    }
8529
8530    #[test]
8531    fn uppercase_mark_stored_under_uppercase_key() {
8532        let mut e = editor_with_rows(50, 20);
8533        e.jump_cursor(5, 3);
8534        run_keys(&mut e, "mA");
8535        // 0.0.36: uppercase marks land in the unified `Editor::marks`
8536        // map under the uppercase key — not under 'a'.
8537        assert_eq!(e.mark('A'), Some((5, 3)));
8538        assert!(e.mark('a').is_none());
8539    }
8540
8541    #[test]
8542    fn mark_survives_document_shrink_via_clamp() {
8543        let mut e = editor_with_rows(50, 20);
8544        e.jump_cursor(40, 4);
8545        run_keys(&mut e, "mx");
8546        // Shrink the buffer to 10 rows.
8547        e.set_content("a\nb\nc\nd\ne");
8548        run_keys(&mut e, "`x");
8549        // Mark clamped to last row, col 0 (short line).
8550        let (r, _) = e.cursor();
8551        assert!(r <= 4);
8552    }
8553
8554    #[test]
8555    fn g_semicolon_walks_back_through_edits() {
8556        let mut e = editor_with("alpha\nbeta\ngamma");
8557        // Two distinct edits — cells (0, 0) → InsertChar lands cursor
8558        // at (0, 1), (2, 0) → (2, 1).
8559        e.jump_cursor(0, 0);
8560        run_keys(&mut e, "iX<Esc>");
8561        e.jump_cursor(2, 0);
8562        run_keys(&mut e, "iY<Esc>");
8563        // First g; lands on the most recent entry's exact cell.
8564        run_keys(&mut e, "g;");
8565        assert_eq!(e.cursor(), (2, 1));
8566        // Second g; walks to the older entry.
8567        run_keys(&mut e, "g;");
8568        assert_eq!(e.cursor(), (0, 1));
8569        // Past the oldest — no-op.
8570        run_keys(&mut e, "g;");
8571        assert_eq!(e.cursor(), (0, 1));
8572    }
8573
8574    #[test]
8575    fn g_comma_walks_forward_after_g_semicolon() {
8576        let mut e = editor_with("a\nb\nc");
8577        e.jump_cursor(0, 0);
8578        run_keys(&mut e, "iX<Esc>");
8579        e.jump_cursor(2, 0);
8580        run_keys(&mut e, "iY<Esc>");
8581        run_keys(&mut e, "g;");
8582        run_keys(&mut e, "g;");
8583        assert_eq!(e.cursor(), (0, 1));
8584        run_keys(&mut e, "g,");
8585        assert_eq!(e.cursor(), (2, 1));
8586    }
8587
8588    #[test]
8589    fn new_edit_during_walk_trims_forward_entries() {
8590        let mut e = editor_with("a\nb\nc\nd");
8591        e.jump_cursor(0, 0);
8592        run_keys(&mut e, "iX<Esc>"); // entry 0 → (0, 1)
8593        e.jump_cursor(2, 0);
8594        run_keys(&mut e, "iY<Esc>"); // entry 1 → (2, 1)
8595        // Walk back twice to land on entry 0.
8596        run_keys(&mut e, "g;");
8597        run_keys(&mut e, "g;");
8598        assert_eq!(e.cursor(), (0, 1));
8599        // New edit while walking discards entries forward of the cursor.
8600        run_keys(&mut e, "iZ<Esc>");
8601        // No newer entry left to walk to.
8602        run_keys(&mut e, "g,");
8603        // Cursor stays where the latest edit landed it.
8604        assert_ne!(e.cursor(), (2, 1));
8605    }
8606
8607    // gq* tests moved to crates/hjkl-editor/tests/vim_ex_integration.rs
8608    // — they exercise the vim FSM through ex commands which now live in
8609    // a sibling crate. cargo dev-dep cycles produce duplicate type IDs
8610    // so the integration must run from the editor side.
8611
8612    #[test]
8613    fn capital_mark_set_and_jump() {
8614        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
8615        e.jump_cursor(2, 1);
8616        run_keys(&mut e, "mA");
8617        // Move away.
8618        e.jump_cursor(0, 0);
8619        // Jump back via `'A`.
8620        run_keys(&mut e, "'A");
8621        // Linewise jump → row preserved, col first non-blank (here 0).
8622        assert_eq!(e.cursor().0, 2);
8623    }
8624
8625    #[test]
8626    fn capital_mark_survives_set_content() {
8627        let mut e = editor_with("first buffer line\nsecond");
8628        e.jump_cursor(1, 3);
8629        run_keys(&mut e, "mA");
8630        // Swap buffer content (host loading a different tab).
8631        e.set_content("totally different content\non many\nrows of text");
8632        // `'A` should still jump to (1, 3) — it survived the swap.
8633        e.jump_cursor(0, 0);
8634        run_keys(&mut e, "'A");
8635        assert_eq!(e.cursor().0, 1);
8636    }
8637
8638    // capital_mark_shows_in_marks_listing moved to
8639    // crates/hjkl-editor/tests/vim_ex_integration.rs (depends on the
8640    // ex `marks` command).
8641
8642    #[test]
8643    fn capital_mark_shifts_with_edit() {
8644        let mut e = editor_with("a\nb\nc\nd");
8645        e.jump_cursor(3, 0);
8646        run_keys(&mut e, "mA");
8647        // Delete the first row — `A` should shift up to row 2.
8648        e.jump_cursor(0, 0);
8649        run_keys(&mut e, "dd");
8650        e.jump_cursor(0, 0);
8651        run_keys(&mut e, "'A");
8652        assert_eq!(e.cursor().0, 2);
8653    }
8654
8655    #[test]
8656    fn mark_below_delete_shifts_up() {
8657        let mut e = editor_with("a\nb\nc\nd\ne");
8658        // Set mark `a` on row 3 (the `d`).
8659        e.jump_cursor(3, 0);
8660        run_keys(&mut e, "ma");
8661        // Go back to row 0 and `dd`.
8662        e.jump_cursor(0, 0);
8663        run_keys(&mut e, "dd");
8664        // Mark `a` should now point at row 2 — its content stayed `d`.
8665        e.jump_cursor(0, 0);
8666        run_keys(&mut e, "'a");
8667        assert_eq!(e.cursor().0, 2);
8668        assert_eq!(e.buffer().line(2).unwrap(), "d");
8669    }
8670
8671    #[test]
8672    fn mark_on_deleted_row_is_dropped() {
8673        let mut e = editor_with("a\nb\nc\nd");
8674        // Mark `a` on row 1 (`b`).
8675        e.jump_cursor(1, 0);
8676        run_keys(&mut e, "ma");
8677        // Delete row 1.
8678        run_keys(&mut e, "dd");
8679        // The row that held `a` is gone; `'a` should be a no-op now.
8680        e.jump_cursor(2, 0);
8681        run_keys(&mut e, "'a");
8682        // Cursor stays on row 2 — `'a` no-ops on missing marks.
8683        assert_eq!(e.cursor().0, 2);
8684    }
8685
8686    #[test]
8687    fn mark_above_edit_unchanged() {
8688        let mut e = editor_with("a\nb\nc\nd\ne");
8689        // Mark `a` on row 0.
8690        e.jump_cursor(0, 0);
8691        run_keys(&mut e, "ma");
8692        // Delete row 3.
8693        e.jump_cursor(3, 0);
8694        run_keys(&mut e, "dd");
8695        // Mark `a` should still point at row 0.
8696        e.jump_cursor(2, 0);
8697        run_keys(&mut e, "'a");
8698        assert_eq!(e.cursor().0, 0);
8699    }
8700
8701    #[test]
8702    fn mark_shifts_down_after_insert() {
8703        let mut e = editor_with("a\nb\nc");
8704        // Mark `a` on row 2 (`c`).
8705        e.jump_cursor(2, 0);
8706        run_keys(&mut e, "ma");
8707        // Open a new line above row 0 with `O\nfoo<Esc>`.
8708        e.jump_cursor(0, 0);
8709        run_keys(&mut e, "Onew<Esc>");
8710        // Buffer is now ["new", "a", "b", "c"]; mark `a` should track
8711        // the original content row → 3.
8712        e.jump_cursor(0, 0);
8713        run_keys(&mut e, "'a");
8714        assert_eq!(e.cursor().0, 3);
8715        assert_eq!(e.buffer().line(3).unwrap(), "c");
8716    }
8717
8718    // ─── Search / jumplist interaction ───────────────────────────────
8719
8720    #[test]
8721    fn forward_search_commit_pushes_jump() {
8722        let mut e = editor_with("alpha beta\nfoo target end\nmore");
8723        e.jump_cursor(0, 0);
8724        run_keys(&mut e, "/target<CR>");
8725        // Cursor moved to the match.
8726        assert_ne!(e.cursor(), (0, 0));
8727        // Ctrl-o returns to the pre-search position.
8728        run_keys(&mut e, "<C-o>");
8729        assert_eq!(e.cursor(), (0, 0));
8730    }
8731
8732    #[test]
8733    fn search_commit_no_match_does_not_push_jump() {
8734        let mut e = editor_with("alpha beta\nfoo end");
8735        e.jump_cursor(0, 3);
8736        let pre_len = e.vim.jump_back.len();
8737        run_keys(&mut e, "/zzznotfound<CR>");
8738        // No match → cursor stays, jumplist shouldn't grow.
8739        assert_eq!(e.vim.jump_back.len(), pre_len);
8740    }
8741
8742    // ─── Phase 7b: migration buffer cursor sync ──────────────────────
8743
8744    #[test]
8745    fn buffer_cursor_mirrors_textarea_after_horizontal_motion() {
8746        let mut e = editor_with("hello world");
8747        run_keys(&mut e, "lll");
8748        let (row, col) = e.cursor();
8749        assert_eq!(e.buffer.cursor().row, row);
8750        assert_eq!(e.buffer.cursor().col, col);
8751    }
8752
8753    #[test]
8754    fn buffer_cursor_mirrors_textarea_after_vertical_motion() {
8755        let mut e = editor_with("aaaa\nbbbb\ncccc");
8756        run_keys(&mut e, "jj");
8757        let (row, col) = e.cursor();
8758        assert_eq!(e.buffer.cursor().row, row);
8759        assert_eq!(e.buffer.cursor().col, col);
8760    }
8761
8762    #[test]
8763    fn buffer_cursor_mirrors_textarea_after_word_motion() {
8764        let mut e = editor_with("foo bar baz");
8765        run_keys(&mut e, "ww");
8766        let (row, col) = e.cursor();
8767        assert_eq!(e.buffer.cursor().row, row);
8768        assert_eq!(e.buffer.cursor().col, col);
8769    }
8770
8771    #[test]
8772    fn buffer_cursor_mirrors_textarea_after_jump_motion() {
8773        let mut e = editor_with("a\nb\nc\nd\ne");
8774        run_keys(&mut e, "G");
8775        let (row, col) = e.cursor();
8776        assert_eq!(e.buffer.cursor().row, row);
8777        assert_eq!(e.buffer.cursor().col, col);
8778    }
8779
8780    #[test]
8781    fn editor_sticky_col_tracks_horizontal_motion() {
8782        let mut e = editor_with("longline\nhi\nlongline");
8783        // `fl` from col 0 lands on the next `l` past the cursor —
8784        // "longline" → second `l` is at col 4. Horizontal motion
8785        // should refresh sticky to that column so the next `j`
8786        // picks it up across the short row.
8787        run_keys(&mut e, "fl");
8788        let landed = e.cursor().1;
8789        assert!(landed > 0, "fl should have moved");
8790        run_keys(&mut e, "j");
8791        // Editor is the single owner of sticky_col (0.0.28). The
8792        // sticky value was set from the post-`fl` column.
8793        assert_eq!(e.sticky_col(), Some(landed));
8794    }
8795
8796    #[test]
8797    fn buffer_content_mirrors_textarea_after_insert() {
8798        let mut e = editor_with("hello");
8799        run_keys(&mut e, "iXYZ<Esc>");
8800        let text = e.buffer().lines().join("\n");
8801        assert_eq!(e.buffer.as_string(), text);
8802    }
8803
8804    #[test]
8805    fn buffer_content_mirrors_textarea_after_delete() {
8806        let mut e = editor_with("alpha bravo charlie");
8807        run_keys(&mut e, "dw");
8808        let text = e.buffer().lines().join("\n");
8809        assert_eq!(e.buffer.as_string(), text);
8810    }
8811
8812    #[test]
8813    fn buffer_content_mirrors_textarea_after_dd() {
8814        let mut e = editor_with("a\nb\nc\nd");
8815        run_keys(&mut e, "jdd");
8816        let text = e.buffer().lines().join("\n");
8817        assert_eq!(e.buffer.as_string(), text);
8818    }
8819
8820    #[test]
8821    fn buffer_content_mirrors_textarea_after_open_line() {
8822        let mut e = editor_with("foo\nbar");
8823        run_keys(&mut e, "oNEW<Esc>");
8824        let text = e.buffer().lines().join("\n");
8825        assert_eq!(e.buffer.as_string(), text);
8826    }
8827
8828    #[test]
8829    fn buffer_content_mirrors_textarea_after_paste() {
8830        let mut e = editor_with("hello");
8831        run_keys(&mut e, "yy");
8832        run_keys(&mut e, "p");
8833        let text = e.buffer().lines().join("\n");
8834        assert_eq!(e.buffer.as_string(), text);
8835    }
8836
8837    #[test]
8838    fn buffer_selection_none_in_normal_mode() {
8839        let e = editor_with("foo bar");
8840        assert!(e.buffer_selection().is_none());
8841    }
8842
8843    #[test]
8844    fn buffer_selection_char_in_visual_mode() {
8845        use hjkl_buffer::{Position, Selection};
8846        let mut e = editor_with("hello world");
8847        run_keys(&mut e, "vlll");
8848        assert_eq!(
8849            e.buffer_selection(),
8850            Some(Selection::Char {
8851                anchor: Position::new(0, 0),
8852                head: Position::new(0, 3),
8853            })
8854        );
8855    }
8856
8857    #[test]
8858    fn buffer_selection_line_in_visual_line_mode() {
8859        use hjkl_buffer::Selection;
8860        let mut e = editor_with("a\nb\nc\nd");
8861        run_keys(&mut e, "Vj");
8862        assert_eq!(
8863            e.buffer_selection(),
8864            Some(Selection::Line {
8865                anchor_row: 0,
8866                head_row: 1,
8867            })
8868        );
8869    }
8870
8871    #[test]
8872    fn wrapscan_off_blocks_wrap_around() {
8873        let mut e = editor_with("first\nsecond\nthird\n");
8874        e.settings_mut().wrapscan = false;
8875        // Place cursor on row 2 ("third") and search for "first".
8876        e.jump_cursor(2, 0);
8877        run_keys(&mut e, "/first<CR>");
8878        // No wrap → cursor stays on row 2.
8879        assert_eq!(e.cursor().0, 2, "wrapscan off should block wrap");
8880        // Re-enable wrapscan and try again.
8881        e.settings_mut().wrapscan = true;
8882        run_keys(&mut e, "/first<CR>");
8883        assert_eq!(e.cursor().0, 0, "wrapscan on should wrap to row 0");
8884    }
8885
8886    #[test]
8887    fn smartcase_uppercase_pattern_stays_sensitive() {
8888        let mut e = editor_with("foo\nFoo\nBAR\n");
8889        e.settings_mut().ignore_case = true;
8890        e.settings_mut().smartcase = true;
8891        // All-lowercase pattern → ignorecase wins → compiled regex
8892        // is case-insensitive.
8893        run_keys(&mut e, "/foo<CR>");
8894        let r1 = e
8895            .search_state()
8896            .pattern
8897            .as_ref()
8898            .unwrap()
8899            .as_str()
8900            .to_string();
8901        assert!(r1.starts_with("(?i)"), "lowercase under smartcase: {r1}");
8902        // Uppercase letter → smartcase flips back to case-sensitive.
8903        run_keys(&mut e, "/Foo<CR>");
8904        let r2 = e
8905            .search_state()
8906            .pattern
8907            .as_ref()
8908            .unwrap()
8909            .as_str()
8910            .to_string();
8911        assert!(!r2.starts_with("(?i)"), "mixed-case under smartcase: {r2}");
8912    }
8913
8914    #[test]
8915    fn enter_with_autoindent_copies_leading_whitespace() {
8916        let mut e = editor_with("    foo");
8917        e.jump_cursor(0, 7);
8918        run_keys(&mut e, "i<CR>");
8919        assert_eq!(e.buffer.line(1).unwrap(), "    ");
8920    }
8921
8922    #[test]
8923    fn enter_without_autoindent_inserts_bare_newline() {
8924        let mut e = editor_with("    foo");
8925        e.settings_mut().autoindent = false;
8926        e.jump_cursor(0, 7);
8927        run_keys(&mut e, "i<CR>");
8928        assert_eq!(e.buffer.line(1).unwrap(), "");
8929    }
8930
8931    #[test]
8932    fn iskeyword_default_treats_alnum_underscore_as_word() {
8933        let mut e = editor_with("foo_bar baz");
8934        // `*` searches for the word at the cursor — picks up everything
8935        // matching iskeyword. With default spec, `foo_bar` is one word,
8936        // so the search pattern should bound that whole token.
8937        e.jump_cursor(0, 0);
8938        run_keys(&mut e, "*");
8939        let p = e
8940            .search_state()
8941            .pattern
8942            .as_ref()
8943            .unwrap()
8944            .as_str()
8945            .to_string();
8946        assert!(p.contains("foo_bar"), "default iskeyword: {p}");
8947    }
8948
8949    #[test]
8950    fn w_motion_respects_custom_iskeyword() {
8951        // `foo-bar baz`. With the default spec, `-` is NOT a word char,
8952        // so `foo` / `-` / `bar` / ` ` / `baz` are 5 transitions and a
8953        // single `w` from col 0 lands on `-` (col 3).
8954        let mut e = editor_with("foo-bar baz");
8955        run_keys(&mut e, "w");
8956        assert_eq!(e.cursor().1, 3, "default iskeyword: {:?}", e.cursor());
8957        // Re-set with `-` (45) treated as a word char. Now `foo-bar` is
8958        // one token; `w` from col 0 should jump to `baz` (col 8).
8959        let mut e2 = editor_with("foo-bar baz");
8960        e2.set_iskeyword("@,_,45");
8961        run_keys(&mut e2, "w");
8962        assert_eq!(e2.cursor().1, 8, "dash-as-word: {:?}", e2.cursor());
8963    }
8964
8965    #[test]
8966    fn iskeyword_with_dash_treats_dash_as_word_char() {
8967        let mut e = editor_with("foo-bar baz");
8968        e.settings_mut().iskeyword = "@,_,45".to_string();
8969        e.jump_cursor(0, 0);
8970        run_keys(&mut e, "*");
8971        let p = e
8972            .search_state()
8973            .pattern
8974            .as_ref()
8975            .unwrap()
8976            .as_str()
8977            .to_string();
8978        assert!(p.contains("foo-bar"), "dash-as-word: {p}");
8979    }
8980
8981    #[test]
8982    fn timeoutlen_drops_pending_g_prefix() {
8983        use std::time::{Duration, Instant};
8984        let mut e = editor_with("a\nb\nc");
8985        e.jump_cursor(2, 0);
8986        // First `g` lands us in g-pending state.
8987        run_keys(&mut e, "g");
8988        assert!(matches!(e.vim.pending, super::Pending::G));
8989        // Push last_input timestamps into the past beyond the default
8990        // timeout. 0.0.29 (Patch B) drives `:set timeoutlen` off
8991        // `Host::now()` (monotonic Duration), so shrink the timeout
8992        // window to a nanosecond and zero out the host slot — any
8993        // wall-clock progress between this line and the next step
8994        // exceeds it. The Instant-flavoured field is rewound for
8995        // snapshot tests that still observe it directly.
8996        e.settings.timeout_len = Duration::from_nanos(0);
8997        e.vim.last_input_at = Some(Instant::now() - Duration::from_secs(60));
8998        e.vim.last_input_host_at = Some(Duration::ZERO);
8999        // Second `g` arrives "late" — timeout fires, prefix is cleared,
9000        // and the bare `g` is re-dispatched: nothing happens at the
9001        // engine level because `g` alone isn't a complete command.
9002        run_keys(&mut e, "g");
9003        // Cursor must still be at row 2 — `gg` was NOT completed.
9004        assert_eq!(e.cursor().0, 2, "timeout must abandon g-prefix");
9005    }
9006
9007    #[test]
9008    fn undobreak_on_breaks_group_at_arrow_motion() {
9009        let mut e = editor_with("");
9010        // i a a a <Left> b b b <Esc> u
9011        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
9012        // Default settings.undo_break_on_motion = true, so `u` only
9013        // reverses the `bbb` run; `aaa` remains.
9014        let line = e.buffer.line(0).unwrap_or("").to_string();
9015        assert!(line.contains("aaa"), "after undobreak: {line:?}");
9016        assert!(!line.contains("bbb"), "bbb should be undone: {line:?}");
9017    }
9018
9019    #[test]
9020    fn undobreak_off_keeps_full_run_in_one_group() {
9021        let mut e = editor_with("");
9022        e.settings_mut().undo_break_on_motion = false;
9023        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
9024        // With undobreak off, the whole insert (aaa<Left>bbb) is one
9025        // group — `u` reverts back to empty.
9026        assert_eq!(e.buffer.line(0).unwrap_or(""), "");
9027    }
9028
9029    #[test]
9030    fn undobreak_round_trips_through_options() {
9031        let e = editor_with("");
9032        let opts = e.current_options();
9033        assert!(opts.undo_break_on_motion);
9034        let mut e2 = editor_with("");
9035        let mut new_opts = opts.clone();
9036        new_opts.undo_break_on_motion = false;
9037        e2.apply_options(&new_opts);
9038        assert!(!e2.current_options().undo_break_on_motion);
9039    }
9040
9041    #[test]
9042    fn undo_levels_cap_drops_oldest() {
9043        let mut e = editor_with("abcde");
9044        e.settings_mut().undo_levels = 3;
9045        run_keys(&mut e, "ra");
9046        run_keys(&mut e, "lrb");
9047        run_keys(&mut e, "lrc");
9048        run_keys(&mut e, "lrd");
9049        run_keys(&mut e, "lre");
9050        assert_eq!(e.undo_stack_len(), 3);
9051    }
9052
9053    #[test]
9054    fn tab_inserts_literal_tab_when_noexpandtab() {
9055        let mut e = editor_with("");
9056        // 0.2.0: expandtab now defaults on (modern). Opt out for the
9057        // literal-tab test.
9058        e.settings_mut().expandtab = false;
9059        e.settings_mut().softtabstop = 0;
9060        run_keys(&mut e, "i");
9061        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
9062        assert_eq!(e.buffer.line(0).unwrap(), "\t");
9063    }
9064
9065    #[test]
9066    fn tab_inserts_spaces_when_expandtab() {
9067        let mut e = editor_with("");
9068        e.settings_mut().expandtab = true;
9069        e.settings_mut().tabstop = 4;
9070        run_keys(&mut e, "i");
9071        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
9072        assert_eq!(e.buffer.line(0).unwrap(), "    ");
9073    }
9074
9075    #[test]
9076    fn tab_with_softtabstop_fills_to_next_boundary() {
9077        // sts=4, cursor at col 2 → Tab inserts 2 spaces (to col 4).
9078        let mut e = editor_with("ab");
9079        e.settings_mut().expandtab = true;
9080        e.settings_mut().tabstop = 8;
9081        e.settings_mut().softtabstop = 4;
9082        run_keys(&mut e, "A"); // append at end (col 2)
9083        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
9084        assert_eq!(e.buffer.line(0).unwrap(), "ab  ");
9085    }
9086
9087    #[test]
9088    fn backspace_deletes_softtab_run() {
9089        // sts=4, line "    x" with cursor at col 4 → Backspace deletes
9090        // the whole 4-space run instead of one char.
9091        let mut e = editor_with("    x");
9092        e.settings_mut().softtabstop = 4;
9093        // Move to col 4 (start of 'x'), then enter insert.
9094        run_keys(&mut e, "fxi");
9095        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
9096        assert_eq!(e.buffer.line(0).unwrap(), "x");
9097    }
9098
9099    #[test]
9100    fn backspace_falls_back_to_single_char_when_run_not_aligned() {
9101        // sts=4, but cursor at col 5 (one space past the boundary) →
9102        // Backspace deletes only the one trailing space.
9103        let mut e = editor_with("     x");
9104        e.settings_mut().softtabstop = 4;
9105        run_keys(&mut e, "fxi");
9106        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
9107        assert_eq!(e.buffer.line(0).unwrap(), "    x");
9108    }
9109
9110    #[test]
9111    fn readonly_blocks_insert_mutation() {
9112        let mut e = editor_with("hello");
9113        e.settings_mut().readonly = true;
9114        run_keys(&mut e, "iX<Esc>");
9115        assert_eq!(e.buffer.line(0).unwrap(), "hello");
9116    }
9117
9118    #[cfg(feature = "ratatui")]
9119    #[test]
9120    fn intern_ratatui_style_dedups_repeated_styles() {
9121        use ratatui::style::{Color, Style};
9122        let mut e = editor_with("");
9123        let red = Style::default().fg(Color::Red);
9124        let blue = Style::default().fg(Color::Blue);
9125        let id_r1 = e.intern_ratatui_style(red);
9126        let id_r2 = e.intern_ratatui_style(red);
9127        let id_b = e.intern_ratatui_style(blue);
9128        assert_eq!(id_r1, id_r2);
9129        assert_ne!(id_r1, id_b);
9130        assert_eq!(e.style_table().len(), 2);
9131    }
9132
9133    #[cfg(feature = "ratatui")]
9134    #[test]
9135    fn install_ratatui_syntax_spans_translates_styled_spans() {
9136        use ratatui::style::{Color, Style};
9137        let mut e = editor_with("SELECT foo");
9138        e.install_ratatui_syntax_spans(vec![vec![(0, 6, Style::default().fg(Color::Red))]]);
9139        let by_row = e.buffer_spans();
9140        assert_eq!(by_row.len(), 1);
9141        assert_eq!(by_row[0].len(), 1);
9142        assert_eq!(by_row[0][0].start_byte, 0);
9143        assert_eq!(by_row[0][0].end_byte, 6);
9144        let id = by_row[0][0].style;
9145        assert_eq!(e.style_table()[id as usize].fg, Some(Color::Red));
9146    }
9147
9148    #[cfg(feature = "ratatui")]
9149    #[test]
9150    fn install_ratatui_syntax_spans_clamps_sentinel_end() {
9151        use ratatui::style::{Color, Style};
9152        let mut e = editor_with("hello");
9153        e.install_ratatui_syntax_spans(vec![vec![(
9154            0,
9155            usize::MAX,
9156            Style::default().fg(Color::Blue),
9157        )]]);
9158        let by_row = e.buffer_spans();
9159        assert_eq!(by_row[0][0].end_byte, 5);
9160    }
9161
9162    #[cfg(feature = "ratatui")]
9163    #[test]
9164    fn install_ratatui_syntax_spans_drops_zero_width() {
9165        use ratatui::style::{Color, Style};
9166        let mut e = editor_with("abc");
9167        e.install_ratatui_syntax_spans(vec![vec![(2, 2, Style::default().fg(Color::Red))]]);
9168        assert!(e.buffer_spans()[0].is_empty());
9169    }
9170
9171    #[test]
9172    fn named_register_yank_into_a_then_paste_from_a() {
9173        let mut e = editor_with("hello world\nsecond");
9174        run_keys(&mut e, "\"ayw");
9175        // `yw` over "hello world" yanks "hello " (word + trailing space).
9176        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
9177        // Move to second line then paste from "a.
9178        run_keys(&mut e, "j0\"aP");
9179        assert_eq!(e.buffer().lines()[1], "hello second");
9180    }
9181
9182    #[test]
9183    fn capital_r_overstrikes_chars() {
9184        let mut e = editor_with("hello");
9185        e.jump_cursor(0, 0);
9186        run_keys(&mut e, "RXY<Esc>");
9187        // 'h' and 'e' replaced; 'llo' kept.
9188        assert_eq!(e.buffer().lines()[0], "XYllo");
9189    }
9190
9191    #[test]
9192    fn capital_r_at_eol_appends() {
9193        let mut e = editor_with("hi");
9194        e.jump_cursor(0, 1);
9195        // Cursor on the final 'i'; replace it then keep typing past EOL.
9196        run_keys(&mut e, "RXYZ<Esc>");
9197        assert_eq!(e.buffer().lines()[0], "hXYZ");
9198    }
9199
9200    #[test]
9201    fn capital_r_count_does_not_repeat_overstrike_char_by_char() {
9202        // Vim's `2R` replays the *whole session* on Esc, not each char.
9203        // We don't model that fully, but the basic R should at least
9204        // not crash on empty session count handling.
9205        let mut e = editor_with("abc");
9206        e.jump_cursor(0, 0);
9207        run_keys(&mut e, "RX<Esc>");
9208        assert_eq!(e.buffer().lines()[0], "Xbc");
9209    }
9210
9211    #[test]
9212    fn ctrl_r_in_insert_pastes_named_register() {
9213        let mut e = editor_with("hello world");
9214        // Yank "hello " into "a".
9215        run_keys(&mut e, "\"ayw");
9216        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
9217        // Open a fresh line, enter insert, Ctrl-R a.
9218        run_keys(&mut e, "o");
9219        assert_eq!(e.vim_mode(), VimMode::Insert);
9220        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9221        e.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
9222        assert_eq!(e.buffer().lines()[1], "hello ");
9223        // Cursor sits at end of inserted payload (col 6).
9224        assert_eq!(e.cursor(), (1, 6));
9225        // Stayed in insert mode; next char appends.
9226        assert_eq!(e.vim_mode(), VimMode::Insert);
9227        e.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
9228        assert_eq!(e.buffer().lines()[1], "hello X");
9229    }
9230
9231    #[test]
9232    fn ctrl_r_with_unnamed_register() {
9233        let mut e = editor_with("foo");
9234        run_keys(&mut e, "yiw");
9235        run_keys(&mut e, "A ");
9236        // Unnamed register paste via `"`.
9237        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9238        e.handle_key(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
9239        assert_eq!(e.buffer().lines()[0], "foo foo");
9240    }
9241
9242    #[test]
9243    fn ctrl_r_unknown_selector_is_no_op() {
9244        let mut e = editor_with("abc");
9245        run_keys(&mut e, "A");
9246        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9247        // `?` isn't a valid register selector — paste skipped, the
9248        // armed flag still clears so the next key types normally.
9249        e.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
9250        e.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
9251        assert_eq!(e.buffer().lines()[0], "abcZ");
9252    }
9253
9254    #[test]
9255    fn ctrl_r_multiline_register_pastes_with_newlines() {
9256        let mut e = editor_with("alpha\nbeta\ngamma");
9257        // Yank two whole lines into "b".
9258        run_keys(&mut e, "\"byy");
9259        run_keys(&mut e, "j\"byy");
9260        // Linewise yanks include trailing \n; second yank into uppercase
9261        // would append, but lowercase "b" overwrote — ensure we have a
9262        // multi-line payload by yanking 2 lines linewise via V.
9263        run_keys(&mut e, "ggVj\"by");
9264        let payload = e.registers().read('b').unwrap().text.clone();
9265        assert!(payload.contains('\n'));
9266        run_keys(&mut e, "Go");
9267        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9268        e.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
9269        // The buffer should now contain the original 3 lines plus the
9270        // pasted 2-line payload (with its own newline) on its own line.
9271        let total_lines = e.buffer().lines().len();
9272        assert!(total_lines >= 5);
9273    }
9274
9275    #[test]
9276    fn yank_zero_holds_last_yank_after_delete() {
9277        let mut e = editor_with("hello world");
9278        run_keys(&mut e, "yw");
9279        let yanked = e.registers().read('0').unwrap().text.clone();
9280        assert!(!yanked.is_empty());
9281        // Delete a word; "0 should still hold the original yank.
9282        run_keys(&mut e, "dw");
9283        assert_eq!(e.registers().read('0').unwrap().text, yanked);
9284        // "1 holds the just-deleted text (non-empty, regardless of exact contents).
9285        assert!(!e.registers().read('1').unwrap().text.is_empty());
9286    }
9287
9288    #[test]
9289    fn delete_ring_rotates_through_one_through_nine() {
9290        let mut e = editor_with("a b c d e f g h i j");
9291        // Delete each word — each delete pushes onto "1, shifting older.
9292        for _ in 0..3 {
9293            run_keys(&mut e, "dw");
9294        }
9295        // Most recent delete is in "1.
9296        let r1 = e.registers().read('1').unwrap().text.clone();
9297        let r2 = e.registers().read('2').unwrap().text.clone();
9298        let r3 = e.registers().read('3').unwrap().text.clone();
9299        assert!(!r1.is_empty() && !r2.is_empty() && !r3.is_empty());
9300        assert_ne!(r1, r2);
9301        assert_ne!(r2, r3);
9302    }
9303
9304    #[test]
9305    fn capital_register_appends_to_lowercase() {
9306        let mut e = editor_with("foo bar");
9307        run_keys(&mut e, "\"ayw");
9308        let first = e.registers().read('a').unwrap().text.clone();
9309        assert!(first.contains("foo"));
9310        // Yank again into "A — appends to "a.
9311        run_keys(&mut e, "w\"Ayw");
9312        let combined = e.registers().read('a').unwrap().text.clone();
9313        assert!(combined.starts_with(&first));
9314        assert!(combined.contains("bar"));
9315    }
9316
9317    #[test]
9318    fn zf_in_visual_line_creates_closed_fold() {
9319        let mut e = editor_with("a\nb\nc\nd\ne");
9320        // VisualLine over rows 1..=3 then zf.
9321        e.jump_cursor(1, 0);
9322        run_keys(&mut e, "Vjjzf");
9323        assert_eq!(e.buffer().folds().len(), 1);
9324        let f = e.buffer().folds()[0];
9325        assert_eq!(f.start_row, 1);
9326        assert_eq!(f.end_row, 3);
9327        assert!(f.closed);
9328    }
9329
9330    #[test]
9331    fn zfj_in_normal_creates_two_row_fold() {
9332        let mut e = editor_with("a\nb\nc\nd\ne");
9333        e.jump_cursor(1, 0);
9334        run_keys(&mut e, "zfj");
9335        assert_eq!(e.buffer().folds().len(), 1);
9336        let f = e.buffer().folds()[0];
9337        assert_eq!(f.start_row, 1);
9338        assert_eq!(f.end_row, 2);
9339        assert!(f.closed);
9340        // Cursor stays where it started.
9341        assert_eq!(e.cursor().0, 1);
9342    }
9343
9344    #[test]
9345    fn zf_with_count_folds_count_rows() {
9346        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9347        e.jump_cursor(0, 0);
9348        // `zf3j` — fold rows 0..=3.
9349        run_keys(&mut e, "zf3j");
9350        assert_eq!(e.buffer().folds().len(), 1);
9351        let f = e.buffer().folds()[0];
9352        assert_eq!(f.start_row, 0);
9353        assert_eq!(f.end_row, 3);
9354    }
9355
9356    #[test]
9357    fn zfk_folds_upward_range() {
9358        let mut e = editor_with("a\nb\nc\nd\ne");
9359        e.jump_cursor(3, 0);
9360        run_keys(&mut e, "zfk");
9361        let f = e.buffer().folds()[0];
9362        // start_row = min(3, 2) = 2, end_row = max(3, 2) = 3.
9363        assert_eq!(f.start_row, 2);
9364        assert_eq!(f.end_row, 3);
9365    }
9366
9367    #[test]
9368    fn zf_capital_g_folds_to_bottom() {
9369        let mut e = editor_with("a\nb\nc\nd\ne");
9370        e.jump_cursor(1, 0);
9371        // `G` is a single-char motion; folds rows 1..=4.
9372        run_keys(&mut e, "zfG");
9373        let f = e.buffer().folds()[0];
9374        assert_eq!(f.start_row, 1);
9375        assert_eq!(f.end_row, 4);
9376    }
9377
9378    #[test]
9379    fn zfgg_folds_to_top_via_operator_pipeline() {
9380        let mut e = editor_with("a\nb\nc\nd\ne");
9381        e.jump_cursor(3, 0);
9382        // `gg` is a 2-key chord (Pending::OpG path) — `zfgg` works
9383        // because `zf` arms `Pending::Op { Fold }` which already knows
9384        // how to wait for `g` then `g`.
9385        run_keys(&mut e, "zfgg");
9386        let f = e.buffer().folds()[0];
9387        assert_eq!(f.start_row, 0);
9388        assert_eq!(f.end_row, 3);
9389    }
9390
9391    #[test]
9392    fn zfip_folds_paragraph_via_text_object() {
9393        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta\nepsilon");
9394        e.jump_cursor(1, 0);
9395        // `ip` is a text object — same operator pipeline routes it.
9396        run_keys(&mut e, "zfip");
9397        assert_eq!(e.buffer().folds().len(), 1);
9398        let f = e.buffer().folds()[0];
9399        assert_eq!(f.start_row, 0);
9400        assert_eq!(f.end_row, 2);
9401    }
9402
9403    #[test]
9404    fn zfap_folds_paragraph_with_trailing_blank() {
9405        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta");
9406        e.jump_cursor(0, 0);
9407        // `ap` includes the trailing blank line.
9408        run_keys(&mut e, "zfap");
9409        let f = e.buffer().folds()[0];
9410        assert_eq!(f.start_row, 0);
9411        assert_eq!(f.end_row, 3);
9412    }
9413
9414    #[test]
9415    fn zf_paragraph_motion_folds_to_blank() {
9416        let mut e = editor_with("alpha\nbeta\n\ngamma");
9417        e.jump_cursor(0, 0);
9418        // `}` jumps to the blank-line boundary; fold spans rows 0..=2.
9419        run_keys(&mut e, "zf}");
9420        let f = e.buffer().folds()[0];
9421        assert_eq!(f.start_row, 0);
9422        assert_eq!(f.end_row, 2);
9423    }
9424
9425    #[test]
9426    fn za_toggles_fold_under_cursor() {
9427        let mut e = editor_with("a\nb\nc\nd");
9428        e.buffer_mut().add_fold(1, 2, true);
9429        e.jump_cursor(1, 0);
9430        run_keys(&mut e, "za");
9431        assert!(!e.buffer().folds()[0].closed);
9432        run_keys(&mut e, "za");
9433        assert!(e.buffer().folds()[0].closed);
9434    }
9435
9436    #[test]
9437    fn zr_opens_all_folds_zm_closes_all() {
9438        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9439        e.buffer_mut().add_fold(0, 1, true);
9440        e.buffer_mut().add_fold(2, 3, true);
9441        e.buffer_mut().add_fold(4, 5, true);
9442        run_keys(&mut e, "zR");
9443        assert!(e.buffer().folds().iter().all(|f| !f.closed));
9444        run_keys(&mut e, "zM");
9445        assert!(e.buffer().folds().iter().all(|f| f.closed));
9446    }
9447
9448    #[test]
9449    fn ze_clears_all_folds() {
9450        let mut e = editor_with("a\nb\nc\nd");
9451        e.buffer_mut().add_fold(0, 1, true);
9452        e.buffer_mut().add_fold(2, 3, false);
9453        run_keys(&mut e, "zE");
9454        assert!(e.buffer().folds().is_empty());
9455    }
9456
9457    #[test]
9458    fn g_underscore_jumps_to_last_non_blank() {
9459        let mut e = editor_with("hello world   ");
9460        run_keys(&mut e, "g_");
9461        // Last non-blank is 'd' at col 10.
9462        assert_eq!(e.cursor().1, 10);
9463    }
9464
9465    #[test]
9466    fn gj_and_gk_alias_j_and_k() {
9467        let mut e = editor_with("a\nb\nc");
9468        run_keys(&mut e, "gj");
9469        assert_eq!(e.cursor().0, 1);
9470        run_keys(&mut e, "gk");
9471        assert_eq!(e.cursor().0, 0);
9472    }
9473
9474    #[test]
9475    fn paragraph_motions_walk_blank_lines() {
9476        let mut e = editor_with("first\nblock\n\nsecond\nblock\n\nthird");
9477        run_keys(&mut e, "}");
9478        assert_eq!(e.cursor().0, 2);
9479        run_keys(&mut e, "}");
9480        assert_eq!(e.cursor().0, 5);
9481        run_keys(&mut e, "{");
9482        assert_eq!(e.cursor().0, 2);
9483    }
9484
9485    #[test]
9486    fn gv_reenters_last_visual_selection() {
9487        let mut e = editor_with("alpha\nbeta\ngamma");
9488        run_keys(&mut e, "Vj");
9489        // Exit visual.
9490        run_keys(&mut e, "<Esc>");
9491        assert_eq!(e.vim_mode(), VimMode::Normal);
9492        // gv re-enters VisualLine.
9493        run_keys(&mut e, "gv");
9494        assert_eq!(e.vim_mode(), VimMode::VisualLine);
9495    }
9496
9497    #[test]
9498    fn o_in_visual_swaps_anchor_and_cursor() {
9499        let mut e = editor_with("hello world");
9500        // v then move right 4 — anchor at col 0, cursor at col 4.
9501        run_keys(&mut e, "vllll");
9502        assert_eq!(e.cursor().1, 4);
9503        // o swaps; cursor jumps to anchor (col 0).
9504        run_keys(&mut e, "o");
9505        assert_eq!(e.cursor().1, 0);
9506        // Anchor now at original cursor (col 4).
9507        assert_eq!(e.vim.visual_anchor, (0, 4));
9508    }
9509
9510    #[test]
9511    fn editing_inside_fold_invalidates_it() {
9512        let mut e = editor_with("a\nb\nc\nd");
9513        e.buffer_mut().add_fold(1, 2, true);
9514        e.jump_cursor(1, 0);
9515        // Insert a char on a row covered by the fold.
9516        run_keys(&mut e, "iX<Esc>");
9517        // Fold should be gone — vim opens (drops) folds on edit.
9518        assert!(e.buffer().folds().is_empty());
9519    }
9520
9521    #[test]
9522    fn zd_removes_fold_under_cursor() {
9523        let mut e = editor_with("a\nb\nc\nd");
9524        e.buffer_mut().add_fold(1, 2, true);
9525        e.jump_cursor(2, 0);
9526        run_keys(&mut e, "zd");
9527        assert!(e.buffer().folds().is_empty());
9528    }
9529
9530    #[test]
9531    fn take_fold_ops_observes_z_keystroke_dispatch() {
9532        // 0.0.38 (Patch C-δ.4): every `z…` keystroke routes through
9533        // `Editor::apply_fold_op`, which queues a `FoldOp` for hosts to
9534        // observe via `take_fold_ops` AND applies the op locally so
9535        // buffer fold storage stays in sync.
9536        use crate::types::FoldOp;
9537        let mut e = editor_with("a\nb\nc\nd");
9538        e.buffer_mut().add_fold(1, 2, true);
9539        e.jump_cursor(1, 0);
9540        // Drain any queue from the buffer setup above (none expected,
9541        // but be defensive).
9542        let _ = e.take_fold_ops();
9543        run_keys(&mut e, "zo");
9544        run_keys(&mut e, "zM");
9545        let ops = e.take_fold_ops();
9546        assert_eq!(ops.len(), 2);
9547        assert!(matches!(ops[0], FoldOp::OpenAt(1)));
9548        assert!(matches!(ops[1], FoldOp::CloseAll));
9549        // Second drain returns empty.
9550        assert!(e.take_fold_ops().is_empty());
9551    }
9552
9553    #[test]
9554    fn edit_pipeline_emits_invalidate_fold_op() {
9555        // The edit pipeline routes its fold invalidation through
9556        // `apply_fold_op` so hosts can observe + dedupe.
9557        use crate::types::FoldOp;
9558        let mut e = editor_with("a\nb\nc\nd");
9559        e.buffer_mut().add_fold(1, 2, true);
9560        e.jump_cursor(1, 0);
9561        let _ = e.take_fold_ops();
9562        run_keys(&mut e, "iX<Esc>");
9563        let ops = e.take_fold_ops();
9564        assert!(
9565            ops.iter().any(|op| matches!(op, FoldOp::Invalidate { .. })),
9566            "expected at least one Invalidate op, got {ops:?}"
9567        );
9568    }
9569
9570    #[test]
9571    fn dot_mark_jumps_to_last_edit_position() {
9572        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
9573        e.jump_cursor(2, 0);
9574        // Insert at line 2 — sets last_edit_pos.
9575        run_keys(&mut e, "iX<Esc>");
9576        let after_edit = e.cursor();
9577        // Move away.
9578        run_keys(&mut e, "gg");
9579        assert_eq!(e.cursor().0, 0);
9580        // `'.` jumps back to the edit's row (linewise variant).
9581        run_keys(&mut e, "'.");
9582        assert_eq!(e.cursor().0, after_edit.0);
9583    }
9584
9585    #[test]
9586    fn quote_quote_returns_to_pre_jump_position() {
9587        let mut e = editor_with_rows(50, 20);
9588        e.jump_cursor(10, 2);
9589        let before = e.cursor();
9590        // `G` is a big jump — pushes (10, 2) onto jump_back.
9591        run_keys(&mut e, "G");
9592        assert_ne!(e.cursor(), before);
9593        // `''` jumps back to the pre-jump position (linewise).
9594        run_keys(&mut e, "''");
9595        assert_eq!(e.cursor().0, before.0);
9596    }
9597
9598    #[test]
9599    fn backtick_backtick_restores_exact_pre_jump_pos() {
9600        let mut e = editor_with_rows(50, 20);
9601        e.jump_cursor(7, 3);
9602        let before = e.cursor();
9603        run_keys(&mut e, "G");
9604        run_keys(&mut e, "``");
9605        assert_eq!(e.cursor(), before);
9606    }
9607
9608    #[test]
9609    fn macro_record_and_replay_basic() {
9610        let mut e = editor_with("foo\nbar\nbaz");
9611        // Record into "a": insert "X" at line start, exit insert.
9612        run_keys(&mut e, "qaIX<Esc>jq");
9613        assert_eq!(e.buffer().lines()[0], "Xfoo");
9614        // Replay on the next two lines.
9615        run_keys(&mut e, "@a");
9616        assert_eq!(e.buffer().lines()[1], "Xbar");
9617        // @@ replays the last-played macro.
9618        run_keys(&mut e, "j@@");
9619        assert_eq!(e.buffer().lines()[2], "Xbaz");
9620    }
9621
9622    #[test]
9623    fn macro_count_replays_n_times() {
9624        let mut e = editor_with("a\nb\nc\nd\ne");
9625        // Record "j" — move down once.
9626        run_keys(&mut e, "qajq");
9627        assert_eq!(e.cursor().0, 1);
9628        // Replay 3 times via 3@a.
9629        run_keys(&mut e, "3@a");
9630        assert_eq!(e.cursor().0, 4);
9631    }
9632
9633    #[test]
9634    fn macro_capital_q_appends_to_lowercase_register() {
9635        let mut e = editor_with("hello");
9636        run_keys(&mut e, "qall<Esc>q");
9637        run_keys(&mut e, "qAhh<Esc>q");
9638        // Macros + named registers share storage now: register `a`
9639        // holds the encoded keystrokes from both recordings.
9640        let text = e.registers().read('a').unwrap().text.clone();
9641        assert!(text.contains("ll<Esc>"));
9642        assert!(text.contains("hh<Esc>"));
9643    }
9644
9645    #[test]
9646    fn buffer_selection_block_in_visual_block_mode() {
9647        use hjkl_buffer::{Position, Selection};
9648        let mut e = editor_with("aaaa\nbbbb\ncccc");
9649        run_keys(&mut e, "<C-v>jl");
9650        assert_eq!(
9651            e.buffer_selection(),
9652            Some(Selection::Block {
9653                anchor: Position::new(0, 0),
9654                head: Position::new(1, 1),
9655            })
9656        );
9657    }
9658
9659    // ─── Audit batch: lock in known-good behaviour ───────────────────────
9660
9661    #[test]
9662    fn n_after_question_mark_keeps_walking_backward() {
9663        // After committing a `?` search, `n` should continue in the
9664        // backward direction; `N` flips forward.
9665        let mut e = editor_with("foo bar foo baz foo end");
9666        e.jump_cursor(0, 22);
9667        run_keys(&mut e, "?foo<CR>");
9668        assert_eq!(e.cursor().1, 16);
9669        run_keys(&mut e, "n");
9670        assert_eq!(e.cursor().1, 8);
9671        run_keys(&mut e, "N");
9672        assert_eq!(e.cursor().1, 16);
9673    }
9674
9675    #[test]
9676    fn nested_macro_chord_records_literal_keys() {
9677        // `qa@bq` should capture `@` and `b` as literal keys in `a`,
9678        // not as a macro-replay invocation. Replay then re-runs them.
9679        let mut e = editor_with("alpha\nbeta\ngamma");
9680        // First record `b` as a noop-ish macro: just `l` (move right).
9681        run_keys(&mut e, "qblq");
9682        // Now record `a` as: enter insert, type X, exit, then trigger
9683        // `@b` which should run the macro inline during recording too.
9684        run_keys(&mut e, "qaIX<Esc>q");
9685        // `@a` re-runs the captured key sequence on a different line.
9686        e.jump_cursor(1, 0);
9687        run_keys(&mut e, "@a");
9688        assert_eq!(e.buffer().lines()[1], "Xbeta");
9689    }
9690
9691    #[test]
9692    fn shift_gt_motion_indents_one_line() {
9693        // `>w` over a single-line buffer should indent that line by
9694        // one shiftwidth — operator routes through the operator
9695        // pipeline like `dw` / `cw`.
9696        let mut e = editor_with("hello world");
9697        run_keys(&mut e, ">w");
9698        assert_eq!(e.buffer().lines()[0], "  hello world");
9699    }
9700
9701    #[test]
9702    fn shift_lt_motion_outdents_one_line() {
9703        let mut e = editor_with("    hello world");
9704        run_keys(&mut e, "<lt>w");
9705        // Outdent strips up to one shiftwidth (default 2).
9706        assert_eq!(e.buffer().lines()[0], "  hello world");
9707    }
9708
9709    #[test]
9710    fn shift_gt_text_object_indents_paragraph() {
9711        let mut e = editor_with("alpha\nbeta\ngamma\n\nrest");
9712        e.jump_cursor(0, 0);
9713        run_keys(&mut e, ">ip");
9714        assert_eq!(e.buffer().lines()[0], "  alpha");
9715        assert_eq!(e.buffer().lines()[1], "  beta");
9716        assert_eq!(e.buffer().lines()[2], "  gamma");
9717        // Blank separator + the next paragraph stay untouched.
9718        assert_eq!(e.buffer().lines()[4], "rest");
9719    }
9720
9721    #[test]
9722    fn ctrl_o_runs_exactly_one_normal_command() {
9723        // `Ctrl-O dw` returns to insert after the single `dw`. A
9724        // second `Ctrl-O` is needed for another normal command.
9725        let mut e = editor_with("alpha beta gamma");
9726        e.jump_cursor(0, 0);
9727        run_keys(&mut e, "i");
9728        e.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
9729        run_keys(&mut e, "dw");
9730        // First `dw` ran in normal; we're back in insert.
9731        assert_eq!(e.vim_mode(), VimMode::Insert);
9732        // Typing a char now inserts.
9733        run_keys(&mut e, "X");
9734        assert_eq!(e.buffer().lines()[0], "Xbeta gamma");
9735    }
9736
9737    #[test]
9738    fn macro_replay_respects_mode_switching() {
9739        // Recording `iX<Esc>0` should leave us in normal mode at col 0
9740        // after replay — the embedded Esc in the macro must drop the
9741        // replayed insert session.
9742        let mut e = editor_with("hi");
9743        run_keys(&mut e, "qaiX<Esc>0q");
9744        assert_eq!(e.vim_mode(), VimMode::Normal);
9745        // Replay on a fresh line.
9746        e.set_content("yo");
9747        run_keys(&mut e, "@a");
9748        assert_eq!(e.vim_mode(), VimMode::Normal);
9749        assert_eq!(e.cursor().1, 0);
9750        assert_eq!(e.buffer().lines()[0], "Xyo");
9751    }
9752
9753    #[test]
9754    fn macro_recorded_text_round_trips_through_register() {
9755        // After the macros-in-registers unification, recording into
9756        // `a` writes the encoded keystroke text into register `a`'s
9757        // slot. `@a` decodes back to inputs and replays.
9758        let mut e = editor_with("");
9759        run_keys(&mut e, "qaiX<Esc>q");
9760        let text = e.registers().read('a').unwrap().text.clone();
9761        assert!(text.starts_with("iX"));
9762        // Replay inserts another X at the cursor.
9763        run_keys(&mut e, "@a");
9764        assert_eq!(e.buffer().lines()[0], "XX");
9765    }
9766
9767    #[test]
9768    fn dot_after_macro_replays_macros_last_change() {
9769        // After `@a` runs a macro whose last mutation was an insert,
9770        // `.` should repeat that final change, not the whole macro.
9771        let mut e = editor_with("ab\ncd\nef");
9772        // Record: insert 'X' at line start, then move down. The last
9773        // mutation is the insert — `.` should re-apply just that.
9774        run_keys(&mut e, "qaIX<Esc>jq");
9775        assert_eq!(e.buffer().lines()[0], "Xab");
9776        run_keys(&mut e, "@a");
9777        assert_eq!(e.buffer().lines()[1], "Xcd");
9778        // `.` from the new cursor row repeats the last edit (the
9779        // insert `X`), not the whole macro (which would also `j`).
9780        let row_before_dot = e.cursor().0;
9781        run_keys(&mut e, ".");
9782        assert!(e.buffer().lines()[row_before_dot].starts_with('X'));
9783    }
9784
9785    // ── smartindent tests ────────────────────────────────────────────────
9786
9787    /// Build an editor with 4-space settings (expandtab, shiftwidth=4,
9788    /// softtabstop=4) for smartindent tests. Does NOT inherit the
9789    /// shiftwidth=2 override from `editor_with`.
9790    fn si_editor(content: &str) -> Editor {
9791        let opts = crate::types::Options {
9792            shiftwidth: 4,
9793            softtabstop: 4,
9794            expandtab: true,
9795            smartindent: true,
9796            autoindent: true,
9797            ..crate::types::Options::default()
9798        };
9799        let mut e = Editor::new(
9800            hjkl_buffer::Buffer::new(),
9801            crate::types::DefaultHost::new(),
9802            opts,
9803        );
9804        e.set_content(content);
9805        e
9806    }
9807
9808    #[test]
9809    fn smartindent_bumps_indent_after_open_brace() {
9810        // "fn foo() {" + Enter → new line has 4 spaces of indent
9811        let mut e = si_editor("fn foo() {");
9812        e.jump_cursor(0, 10); // after the `{`
9813        run_keys(&mut e, "i<CR>");
9814        assert_eq!(
9815            e.buffer().lines()[1],
9816            "    ",
9817            "smartindent should bump one shiftwidth after {{"
9818        );
9819    }
9820
9821    #[test]
9822    fn smartindent_no_bump_when_off() {
9823        // Same input but smartindent=false → just copies prev leading ws
9824        // (which is empty on "fn foo() {"), so new line is empty.
9825        let mut e = si_editor("fn foo() {");
9826        e.settings_mut().smartindent = false;
9827        e.jump_cursor(0, 10);
9828        run_keys(&mut e, "i<CR>");
9829        assert_eq!(
9830            e.buffer().lines()[1],
9831            "",
9832            "without smartindent, no bump: new line copies empty leading ws"
9833        );
9834    }
9835
9836    #[test]
9837    fn smartindent_uses_tab_when_noexpandtab() {
9838        // noexpandtab + prev line ends in `{` → new line starts with `\t`
9839        let opts = crate::types::Options {
9840            shiftwidth: 4,
9841            softtabstop: 0,
9842            expandtab: false,
9843            smartindent: true,
9844            autoindent: true,
9845            ..crate::types::Options::default()
9846        };
9847        let mut e = Editor::new(
9848            hjkl_buffer::Buffer::new(),
9849            crate::types::DefaultHost::new(),
9850            opts,
9851        );
9852        e.set_content("fn foo() {");
9853        e.jump_cursor(0, 10);
9854        run_keys(&mut e, "i<CR>");
9855        assert_eq!(
9856            e.buffer().lines()[1],
9857            "\t",
9858            "noexpandtab: smartindent bump inserts a literal tab"
9859        );
9860    }
9861
9862    #[test]
9863    fn smartindent_dedent_on_close_brace() {
9864        // Line is "    " (4 spaces), cursor at col 4, type `}` →
9865        // leading spaces stripped, `}` at col 0.
9866        let mut e = si_editor("fn foo() {");
9867        // Add a second line with only indentation.
9868        e.set_content("fn foo() {\n    ");
9869        e.jump_cursor(1, 4); // end of "    "
9870        run_keys(&mut e, "i}");
9871        assert_eq!(
9872            e.buffer().lines()[1],
9873            "}",
9874            "close brace on whitespace-only line should dedent"
9875        );
9876        assert_eq!(e.cursor(), (1, 1), "cursor should be after the `}}`");
9877    }
9878
9879    #[test]
9880    fn smartindent_no_dedent_when_off() {
9881        // Same setup but smartindent=false → `}` appended normally.
9882        let mut e = si_editor("fn foo() {\n    ");
9883        e.settings_mut().smartindent = false;
9884        e.jump_cursor(1, 4);
9885        run_keys(&mut e, "i}");
9886        assert_eq!(
9887            e.buffer().lines()[1],
9888            "    }",
9889            "without smartindent, `}}` just appends at cursor"
9890        );
9891    }
9892
9893    #[test]
9894    fn smartindent_no_dedent_mid_line() {
9895        // Line has "    let x = 1", cursor after `1`; type `}` → no
9896        // dedent because chars before cursor aren't all whitespace.
9897        let mut e = si_editor("    let x = 1");
9898        e.jump_cursor(0, 13); // after `1`
9899        run_keys(&mut e, "i}");
9900        assert_eq!(
9901            e.buffer().lines()[0],
9902            "    let x = 1}",
9903            "mid-line `}}` should not dedent"
9904        );
9905    }
9906
9907    // ─── Vim-compat divergence fixes (issue #24) ─────────────────────
9908
9909    // Fix #1: x/X populate the unnamed register.
9910    #[test]
9911    fn count_5x_fills_unnamed_register() {
9912        let mut e = editor_with("hello world\n");
9913        e.jump_cursor(0, 0);
9914        run_keys(&mut e, "5x");
9915        assert_eq!(e.buffer().lines()[0], " world");
9916        assert_eq!(e.cursor(), (0, 0));
9917        assert_eq!(e.yank(), "hello");
9918    }
9919
9920    #[test]
9921    fn x_fills_unnamed_register_single_char() {
9922        let mut e = editor_with("abc\n");
9923        e.jump_cursor(0, 0);
9924        run_keys(&mut e, "x");
9925        assert_eq!(e.buffer().lines()[0], "bc");
9926        assert_eq!(e.yank(), "a");
9927    }
9928
9929    #[test]
9930    fn big_x_fills_unnamed_register() {
9931        let mut e = editor_with("hello\n");
9932        e.jump_cursor(0, 3);
9933        run_keys(&mut e, "X");
9934        assert_eq!(e.buffer().lines()[0], "helo");
9935        assert_eq!(e.yank(), "l");
9936    }
9937
9938    // Fix #2: G lands on last content row, not phantom trailing-empty row.
9939    #[test]
9940    fn g_motion_trailing_newline_lands_on_last_content_row() {
9941        let mut e = editor_with("foo\nbar\nbaz\n");
9942        e.jump_cursor(0, 0);
9943        run_keys(&mut e, "G");
9944        // buffer is stored as ["foo","bar","baz",""] — G must land on row 2 ("baz").
9945        assert_eq!(
9946            e.cursor().0,
9947            2,
9948            "G should land on row 2 (baz), not row 3 (phantom empty)"
9949        );
9950    }
9951
9952    // Fix #3: dd on last line clamps cursor to new last content row.
9953    #[test]
9954    fn dd_last_line_clamps_cursor_to_new_last_row() {
9955        let mut e = editor_with("foo\nbar\n");
9956        e.jump_cursor(1, 0);
9957        run_keys(&mut e, "dd");
9958        assert_eq!(e.buffer().lines()[0], "foo");
9959        assert_eq!(
9960            e.cursor(),
9961            (0, 0),
9962            "cursor should clamp to row 0 after dd on last content line"
9963        );
9964    }
9965
9966    // Fix #4: d$ cursor lands on last char, not one past.
9967    #[test]
9968    fn d_dollar_cursor_on_last_char() {
9969        let mut e = editor_with("hello world\n");
9970        e.jump_cursor(0, 5);
9971        run_keys(&mut e, "d$");
9972        assert_eq!(e.buffer().lines()[0], "hello");
9973        assert_eq!(
9974            e.cursor(),
9975            (0, 4),
9976            "d$ should leave cursor on col 4, not col 5"
9977        );
9978    }
9979
9980    // Fix #5: undo clamps cursor to last valid normal-mode col.
9981    #[test]
9982    fn undo_insert_clamps_cursor_to_last_valid_col() {
9983        let mut e = editor_with("hello\n");
9984        e.jump_cursor(0, 5); // one-past-last, as in oracle initial_cursor
9985        run_keys(&mut e, "a world<Esc>u");
9986        assert_eq!(e.buffer().lines()[0], "hello");
9987        assert_eq!(
9988            e.cursor(),
9989            (0, 4),
9990            "undo should clamp cursor to col 4 on 'hello'"
9991        );
9992    }
9993
9994    // Fix #6: da" eats trailing whitespace when present.
9995    #[test]
9996    fn da_doublequote_eats_trailing_whitespace() {
9997        let mut e = editor_with("say \"hello\" there\n");
9998        e.jump_cursor(0, 6);
9999        run_keys(&mut e, "da\"");
10000        assert_eq!(e.buffer().lines()[0], "say there");
10001        assert_eq!(e.cursor().1, 4, "cursor should be at col 4 after da\"");
10002    }
10003
10004    // Fix #7: daB cursor off-by-one — clamp to new last col.
10005    #[test]
10006    fn dab_cursor_col_clamped_after_delete() {
10007        let mut e = editor_with("fn x() {\n    body\n}\n");
10008        e.jump_cursor(1, 4);
10009        run_keys(&mut e, "daB");
10010        assert_eq!(e.buffer().lines()[0], "fn x() ");
10011        assert_eq!(
10012            e.cursor(),
10013            (0, 6),
10014            "daB should leave cursor at col 6, not 7"
10015        );
10016    }
10017
10018    // Fix #8: diB preserves surrounding newlines on multi-line block.
10019    #[test]
10020    fn dib_preserves_surrounding_newlines() {
10021        let mut e = editor_with("{\n    body\n}\n");
10022        e.jump_cursor(1, 4);
10023        run_keys(&mut e, "diB");
10024        assert_eq!(e.buffer().lines()[0], "{");
10025        assert_eq!(e.buffer().lines()[1], "}");
10026        assert_eq!(e.cursor().0, 1, "cursor should be on the '}}' line");
10027    }
10028
10029    #[test]
10030    fn is_chord_pending_tracks_replace_state() {
10031        let mut e = editor_with("abc\n");
10032        assert!(!e.is_chord_pending());
10033        // Press `r` — engine enters Pending::Replace.
10034        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
10035        assert!(e.is_chord_pending(), "engine should be pending after r");
10036        // Press a char to complete — pending clears.
10037        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
10038        assert!(
10039            !e.is_chord_pending(),
10040            "engine pending should clear after replace"
10041        );
10042    }
10043
10044    // ─── Special marks `[` / `]` (vim `:h '[` / `:h ']`) ────────────────────
10045
10046    #[test]
10047    fn yiw_sets_lbr_rbr_marks_around_word() {
10048        // `yiw` on "hello" — charwise exclusive range. `[` = col 0,
10049        // `]` = col 4 (last char of "hello").
10050        let mut e = editor_with("hello world");
10051        run_keys(&mut e, "yiw");
10052        let lo = e.mark('[').expect("'[' must be set after yiw");
10053        let hi = e.mark(']').expect("']' must be set after yiw");
10054        assert_eq!(lo, (0, 0), "'[ should be first char of yanked word");
10055        assert_eq!(hi, (0, 4), "'] should be last char of yanked word");
10056    }
10057
10058    #[test]
10059    fn yj_linewise_sets_marks_at_line_edges() {
10060        // `yj` yanks 2 lines linewise. `[` = (0, 0), `]` = (1, last_col).
10061        // "bbbbb" is 5 chars — last_col = 4.
10062        let mut e = editor_with("aaaaa\nbbbbb\nccc");
10063        run_keys(&mut e, "yj");
10064        let lo = e.mark('[').expect("'[' must be set after yj");
10065        let hi = e.mark(']').expect("']' must be set after yj");
10066        assert_eq!(lo, (0, 0), "'[ snaps to (top_row, 0) for linewise yank");
10067        assert_eq!(
10068            hi,
10069            (1, 4),
10070            "'] snaps to (bot_row, last_col) for linewise yank"
10071        );
10072    }
10073
10074    #[test]
10075    fn dd_sets_lbr_rbr_marks_to_cursor() {
10076        // `dd` on the first of two lines — post-delete cursor is row 0.
10077        // Both marks must park there (vim `:h '[` delete rule).
10078        let mut e = editor_with("aaa\nbbb");
10079        run_keys(&mut e, "dd");
10080        let lo = e.mark('[').expect("'[' must be set after dd");
10081        let hi = e.mark(']').expect("']' must be set after dd");
10082        assert_eq!(lo, hi, "after delete both marks are at the same position");
10083        assert_eq!(lo.0, 0, "post-delete cursor row should be 0");
10084    }
10085
10086    #[test]
10087    fn dw_sets_lbr_rbr_marks_to_cursor() {
10088        // `dw` on "hello world" — deletes "hello ". Post-delete cursor
10089        // stays at col 0. Both marks land there.
10090        let mut e = editor_with("hello world");
10091        run_keys(&mut e, "dw");
10092        let lo = e.mark('[').expect("'[' must be set after dw");
10093        let hi = e.mark(']').expect("']' must be set after dw");
10094        assert_eq!(lo, hi, "after delete both marks are at the same position");
10095        assert_eq!(lo, (0, 0), "post-dw cursor is at col 0");
10096    }
10097
10098    #[test]
10099    fn cw_then_esc_sets_lbr_at_start_rbr_at_inserted_text_end() {
10100        // `cw` on "hello world" → deletes "hello", enters insert, types
10101        // "foo", then Esc. `[` = start of change = (0,0). `]` = last
10102        // typed char = (0,2) ("foo" spans cols 0-2; cursor is at col 2
10103        // during finish_insert_session, before the Esc step-back).
10104        let mut e = editor_with("hello world");
10105        run_keys(&mut e, "cwfoo<Esc>");
10106        let lo = e.mark('[').expect("'[' must be set after cw");
10107        let hi = e.mark(']').expect("']' must be set after cw");
10108        assert_eq!(lo, (0, 0), "'[ should be start of change");
10109        // "foo" is 3 chars; cursor was at col 3 (past end) at finish_insert_session
10110        // before step-back. `]` = col 3 (the position during finish).
10111        assert_eq!(hi.0, 0, "'] should be on row 0");
10112        assert!(hi.1 >= 2, "'] should be at or past last char of 'foo'");
10113    }
10114
10115    #[test]
10116    fn cw_with_no_insertion_sets_marks_at_change_start() {
10117        // `cw<Esc>` with no chars typed. Both marks land at the change
10118        // start (cursor parks at col 0 after cut).
10119        let mut e = editor_with("hello world");
10120        run_keys(&mut e, "cw<Esc>");
10121        let lo = e.mark('[').expect("'[' must be set after cw<Esc>");
10122        let hi = e.mark(']').expect("']' must be set after cw<Esc>");
10123        assert_eq!(lo.0, 0, "'[ should be on row 0");
10124        assert_eq!(hi.0, 0, "'] should be on row 0");
10125        // Both marks at the same position when nothing was typed.
10126        assert_eq!(lo, hi, "marks coincide when insert is empty");
10127    }
10128
10129    #[test]
10130    fn p_charwise_sets_marks_around_pasted_text() {
10131        // `yiw` yanks "abc", then `p` pastes after the cursor.
10132        // `[` = first pasted char position, `]` = last pasted char.
10133        let mut e = editor_with("abc xyz");
10134        run_keys(&mut e, "yiw"); // yank "abc" (exclusive, last yanked = col 2)
10135        run_keys(&mut e, "p"); // paste after cursor (at col 1, the 'b')
10136        let lo = e.mark('[').expect("'[' set after charwise paste");
10137        let hi = e.mark(']').expect("']' set after charwise paste");
10138        assert!(lo <= hi, "'[ must not exceed ']'");
10139        // The pasted text is "abc" (3 chars). Marks bracket exactly 3 cols.
10140        assert_eq!(
10141            hi.1.wrapping_sub(lo.1),
10142            2,
10143            "'] - '[ should span 2 cols for a 3-char paste"
10144        );
10145    }
10146
10147    #[test]
10148    fn p_linewise_sets_marks_at_line_edges() {
10149        // Yank 2 lines linewise (`yj`), paste below (`p`).
10150        // `[` = (target_row, 0), `]` = (target_row+1, last_col_of_second_line).
10151        let mut e = editor_with("aaa\nbbb\nccc");
10152        run_keys(&mut e, "yj"); // yank rows 0-1 linewise
10153        run_keys(&mut e, "j"); // cursor to row 1
10154        run_keys(&mut e, "p"); // paste below row 1
10155        let lo = e.mark('[').expect("'[' set after linewise paste");
10156        let hi = e.mark(']').expect("']' set after linewise paste");
10157        assert_eq!(lo.1, 0, "'[ col must be 0 for linewise paste");
10158        assert!(hi.0 > lo.0, "'] row must be below '[ row for 2-line paste");
10159        assert_eq!(hi.0 - lo.0, 1, "exactly 1 row gap for a 2-line payload");
10160    }
10161
10162    #[test]
10163    fn backtick_lbr_v_backtick_rbr_reselects_yanked_text() {
10164        // Vim idiom: after `yiw`, `` `[v`] `` re-selects exactly the
10165        // yanked word in charwise visual. The marks must bracket the
10166        // yanked text end-to-end for this idiom to work.
10167        let mut e = editor_with("hello world");
10168        run_keys(&mut e, "yiw"); // yank "hello"
10169        // Jump to `[`, enter visual, jump to `]`.
10170        // run_keys uses backtick as a plain char in goto-mark-char path.
10171        run_keys(&mut e, "`[v`]");
10172        // Cursor should now be on col 4 (last char of "hello").
10173        assert_eq!(
10174            e.cursor(),
10175            (0, 4),
10176            "visual `[v`] should land on last yanked char"
10177        );
10178        // The mode should be Visual (selection active).
10179        assert_eq!(
10180            e.vim_mode(),
10181            crate::VimMode::Visual,
10182            "should be in Visual mode"
10183        );
10184    }
10185}