Skip to main content

hjkl_engine/
vim.rs

1//! Vim-mode engine.
2//!
3//! Implements a command grammar of the form
4//!
5//! ```text
6//! Command := count? (operator count? (motion | text-object)
7//!                   | motion
8//!                   | insert-entry
9//!                   | misc)
10//! ```
11//!
12//! The parser is a small state machine driven by one `Input` at a time.
13//! Motions and text objects produce a [`Range`] (with inclusive/exclusive
14//! / linewise classification). A single [`Operator`] implementation
15//! applies a range — so `dw`, `d$`, `daw`, and visual `d` all go through
16//! the same code path.
17//!
18//! The most recent mutating command is stored in
19//! [`VimState::last_change`] so `.` can replay it.
20//!
21//! # Roadmap
22//!
23//! Tracked in the original plan at
24//! `~/.claude/plans/look-at-the-vim-curried-fern.md`. Phases still
25//! outstanding — each one can land as an isolated PR.
26//!
27//! ## P3 — Registers & marks
28//!
29//! - TODO: `RegisterBank` indexed by char:
30//!     - unnamed `""`, last-yank `"0`, small-delete `"-`
31//!     - named `"a-"z` (uppercase `"A-"Z` appends instead of overwriting)
32//!     - blackhole `"_`
33//!     - system clipboard `"+` / `"*` (wire to `crate::clipboard::Clipboard`)
34//!     - read-only `":`, `".`, `"%` — surface in `:reg` output
35//! - TODO: route every yank / cut / paste through the bank. Parser needs
36//!   a `"{reg}` prefix state that captures the target register before a
37//!   count / operator.
38//! - TODO: `m{a-z}` sets a mark in a `HashMap<char, (buffer_id, row, col)>`;
39//!   `'x` jumps to the line (FirstNonBlank), `` `x `` to the exact cell.
40//!   Uppercase marks are global across tabs; lowercase are per-buffer.
41//! - TODO: `''` and `` `` `` jump to the last-jump position; `'[` `']`
42//!   `'<` `'>` bound the last change / visual region.
43//! - TODO: `:reg` and `:marks` ex commands.
44//!
45//! ## P4 — Macros
46//!
47//! - TODO: `q{a-z}` starts recording raw `Input`s into the register;
48//!   next `q` stops.
49//! - TODO: `@{a-z}` replays the register by re-feeding inputs through
50//!   `step`. `@@` repeats the last macro. Nested macros need a sane
51//!   depth cap (e.g. 100) to avoid runaway loops.
52//! - TODO: ensure recording doesn't capture the initial `q{a-z}` itself.
53//!
54//! ## P6 — Polish (still outstanding)
55//!
56//! - TODO: indent operators `>` / `<` (with line + text-object targets).
57//! - TODO: format operator `=` — map to whatever SQL formatter we wire
58//!   up; for now stub that returns the range unchanged with a toast.
59//! - TODO: case operators `gU` / `gu` / `g~` on a range (already have
60//!   single-char `~`).
61//! - TODO: screen motions `H` / `M` / `L` once we track the render
62//!   viewport height inside Editor.
63//! - TODO: scroll-to-cursor motions `zz` / `zt` / `zb`.
64//!
65//! ## Known substrate / divergence notes
66//!
67//! - TODO: insert-mode indent helpers — `Ctrl-t` / `Ctrl-d` (increase /
68//!   decrease indent on current line) and `Ctrl-r <reg>` (paste from a
69//!   register). `Ctrl-r` needs the `RegisterBank` from P3 to be useful.
70//! - TODO: `/` and `?` search prompts still live in `the host/src/lib.rs`.
71//!   The plan calls for moving them into the editor (so the editor owns
72//!   `last_search_pattern` rather than the TUI loop). Safe to defer.
73
74use crate::VimMode;
75use crate::input::{Input, Key};
76
77use crate::buf_helpers::{
78    buf_cursor_pos, buf_line, buf_line_bytes, buf_line_chars, buf_lines_to_vec, buf_row_count,
79    buf_set_cursor_pos, buf_set_cursor_rc,
80};
81use crate::editor::Editor;
82
83// ─── Modes & parser state ───────────────────────────────────────────────────
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum Mode {
87    #[default]
88    Normal,
89    Insert,
90    Visual,
91    VisualLine,
92    /// Column-oriented selection (`Ctrl-V`). Unlike the other visual
93    /// modes this one doesn't use tui-textarea's single-range selection
94    /// — the block corners live in [`VimState::block_anchor`] and the
95    /// live cursor. Operators read the rectangle off those two points.
96    VisualBlock,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100enum Pending {
101    #[default]
102    None,
103    /// Operator seen; still waiting for a motion / text-object / double-op.
104    /// `count1` is any count pressed before the operator.
105    Op { op: Operator, count1: usize },
106    /// Operator + 'i' or 'a' seen; waiting for the text-object character.
107    OpTextObj {
108        op: Operator,
109        count1: usize,
110        inner: bool,
111    },
112    /// Operator + 'g' seen (for `dgg`).
113    OpG { op: Operator, count1: usize },
114    /// Bare `g` seen in normal/visual — looking for `g`, `e`, `E`, …
115    G,
116    /// Bare `f`/`F`/`t`/`T` — looking for the target char.
117    Find { forward: bool, till: bool },
118    /// Operator + `f`/`F`/`t`/`T` — looking for target char.
119    OpFind {
120        op: Operator,
121        count1: usize,
122        forward: bool,
123        till: bool,
124    },
125    /// `r` pressed — waiting for the replacement char.
126    Replace,
127    /// Visual mode + `i` or `a` pressed — waiting for the text-object
128    /// character to extend the selection over.
129    VisualTextObj { inner: bool },
130    /// Bare `z` seen — looking for `z` (center), `t` (top), `b` (bottom).
131    Z,
132    /// `m` pressed — waiting for the mark letter to set.
133    SetMark,
134    /// `'` pressed — waiting for the mark letter to jump to its line
135    /// (lands on first non-blank, linewise for operators).
136    GotoMarkLine,
137    /// `` ` `` pressed — waiting for the mark letter to jump to the
138    /// exact `(row, col)` stored at set time (charwise for operators).
139    GotoMarkChar,
140    /// `"` pressed — waiting for the register selector. The next char
141    /// (`a`–`z`, `A`–`Z`, `0`–`9`, or `"`) sets `pending_register`.
142    SelectRegister,
143    /// `q` pressed (not currently recording) — waiting for the macro
144    /// register name. The macro records every key after the chord
145    /// resolves, until a bare `q` ends the recording.
146    RecordMacroTarget,
147    /// `@` pressed — waiting for the macro register name to play.
148    /// `count` is the prefix multiplier (`3@a` plays the macro 3
149    /// times); 0 means "no prefix" and is treated as 1.
150    PlayMacroTarget { count: usize },
151}
152
153// ─── Operator / Motion / TextObject ────────────────────────────────────────
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum Operator {
157    Delete,
158    Change,
159    Yank,
160    /// `gU{motion}` — uppercase the range. Entered via the `g` prefix
161    /// in normal mode or `U` in visual mode.
162    Uppercase,
163    /// `gu{motion}` — lowercase the range. `u` in visual mode.
164    Lowercase,
165    /// `g~{motion}` — toggle case of the range. `~` in visual mode
166    /// (character at the cursor for the single-char `~` command stays
167    /// its own code path in normal mode).
168    ToggleCase,
169    /// `>{motion}` — indent the line range by `shiftwidth` spaces.
170    /// Always linewise, even when the motion is char-wise — mirrors
171    /// vim's behaviour where `>w` indents the current line, not the
172    /// word on it.
173    Indent,
174    /// `<{motion}` — outdent the line range (remove up to
175    /// `shiftwidth` leading spaces per line).
176    Outdent,
177    /// `zf{motion}` / `zf{textobj}` / Visual `zf` — create a closed
178    /// fold spanning the row range. Doesn't mutate the buffer text;
179    /// cursor restores to the operator's start position.
180    Fold,
181    /// `gq{motion}` — reflow the row range to `settings.textwidth`.
182    /// Greedy word-wrap: collapses each paragraph (blank-line-bounded
183    /// run) into space-separated words, then re-emits lines whose
184    /// width stays under `textwidth`. Always linewise, like indent.
185    Reflow,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum Motion {
190    Left,
191    Right,
192    Up,
193    Down,
194    WordFwd,
195    BigWordFwd,
196    WordBack,
197    BigWordBack,
198    WordEnd,
199    BigWordEnd,
200    /// `ge` — backward word end.
201    WordEndBack,
202    /// `gE` — backward WORD end.
203    BigWordEndBack,
204    LineStart,
205    FirstNonBlank,
206    LineEnd,
207    FileTop,
208    FileBottom,
209    Find {
210        ch: char,
211        forward: bool,
212        till: bool,
213    },
214    FindRepeat {
215        reverse: bool,
216    },
217    MatchBracket,
218    WordAtCursor {
219        forward: bool,
220        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
221        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
222        whole_word: bool,
223    },
224    /// `n` / `N` — repeat the last `/` or `?` search.
225    SearchNext {
226        reverse: bool,
227    },
228    /// `H` — cursor to viewport top (plus `count - 1` rows down).
229    ViewportTop,
230    /// `M` — cursor to viewport middle.
231    ViewportMiddle,
232    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
233    ViewportBottom,
234    /// `g_` — last non-blank char on the line.
235    LastNonBlank,
236    /// `gM` — cursor to the middle char column of the current line
237    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
238    LineMiddle,
239    /// `{` — previous paragraph (preceding blank line, or top).
240    ParagraphPrev,
241    /// `}` — next paragraph (following blank line, or bottom).
242    ParagraphNext,
243    /// `(` — previous sentence boundary.
244    SentencePrev,
245    /// `)` — next sentence boundary.
246    SentenceNext,
247    /// `gj` — `count` visual rows down (one screen segment per step
248    /// under `:set wrap`; falls back to `Down` otherwise).
249    ScreenDown,
250    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
251    ScreenUp,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum TextObject {
256    Word {
257        big: bool,
258    },
259    Quote(char),
260    Bracket(char),
261    Paragraph,
262    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
263    /// content between `>` and `</`; `inner = false` covers the open
264    /// tag through the close tag inclusive.
265    XmlTag,
266    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
267    /// followed by whitespace or end-of-line. `inner = true` covers
268    /// the sentence text only; `inner = false` includes trailing
269    /// whitespace.
270    Sentence,
271}
272
273/// Classification determines how operators treat the range end.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum MotionKind {
276    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
277    Exclusive,
278    /// Range end is inclusive. Typical: e, f, t, %.
279    Inclusive,
280    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
281    Linewise,
282}
283
284// ─── Dot-repeat storage ────────────────────────────────────────────────────
285
286/// Information needed to replay a mutating change via `.`.
287#[derive(Debug, Clone)]
288enum LastChange {
289    /// Operator over a motion.
290    OpMotion {
291        op: Operator,
292        motion: Motion,
293        count: usize,
294        inserted: Option<String>,
295    },
296    /// Operator over a text-object.
297    OpTextObj {
298        op: Operator,
299        obj: TextObject,
300        inner: bool,
301        inserted: Option<String>,
302    },
303    /// `dd`, `cc`, `yy` with a count.
304    LineOp {
305        op: Operator,
306        count: usize,
307        inserted: Option<String>,
308    },
309    /// `x`, `X` with a count.
310    CharDel { forward: bool, count: usize },
311    /// `r<ch>` with a count.
312    ReplaceChar { ch: char, count: usize },
313    /// `~` with a count.
314    ToggleCase { count: usize },
315    /// `J` with a count.
316    JoinLine { count: usize },
317    /// `p` / `P` with a count.
318    Paste { before: bool, count: usize },
319    /// `D` (delete to EOL).
320    DeleteToEol { inserted: Option<String> },
321    /// `o` / `O` + the inserted text.
322    OpenLine { above: bool, inserted: String },
323    /// `i`/`I`/`a`/`A` + inserted text.
324    InsertAt {
325        entry: InsertEntry,
326        inserted: String,
327        count: usize,
328    },
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332enum InsertEntry {
333    I,
334    A,
335    ShiftI,
336    ShiftA,
337}
338
339// ─── VimState ──────────────────────────────────────────────────────────────
340
341#[derive(Default)]
342pub struct VimState {
343    mode: Mode,
344    pending: Pending,
345    count: usize,
346    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
347    last_find: Option<(char, bool, bool)>,
348    last_change: Option<LastChange>,
349    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
350    insert_session: Option<InsertSession>,
351    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
352    /// to compute the highlight range and the operator range without
353    /// relying on tui-textarea's live selection.
354    pub(super) visual_anchor: (usize, usize),
355    /// Row anchor for VisualLine mode.
356    pub(super) visual_line_anchor: usize,
357    /// (row, col) anchor for VisualBlock mode. The live cursor is the
358    /// opposite corner.
359    pub(super) block_anchor: (usize, usize),
360    /// Intended "virtual" column for the block's active corner. j/k
361    /// clamp cursor.col to shorter rows, which would collapse the
362    /// block across ragged content — so we remember the desired column
363    /// separately and use it for block bounds / insert-column
364    /// computations. Updated by h/l only.
365    pub(super) block_vcol: usize,
366    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
367    pub(super) yank_linewise: bool,
368    /// Active register selector — set by `"reg` prefix, consumed by
369    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
370    pub(super) pending_register: Option<char>,
371    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
372    /// While `Some`, every consumed `Input` is appended to
373    /// `recording_keys`.
374    pub(super) recording_macro: Option<char>,
375    /// Keys recorded into the in-progress macro. On `q` finish, these
376    /// are encoded via [`crate::input::encode_macro`] and written to
377    /// the matching named register slot, so macros and yanks share a
378    /// single store.
379    pub(super) recording_keys: Vec<crate::input::Input>,
380    /// Set during `@reg` replay so the recorder doesn't capture the
381    /// replayed keystrokes a second time.
382    pub(super) replaying_macro: bool,
383    /// Last register played via `@reg`. `@@` re-plays this one.
384    pub(super) last_macro: Option<char>,
385    /// Position of the most recent buffer mutation. Surfaced via
386    /// the `'.` / `` `. `` marks for quick "back to last edit".
387    pub(super) last_edit_pos: Option<(usize, usize)>,
388    /// Bounded ring of recent edit positions (newest at the back).
389    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
390    /// at [`CHANGE_LIST_MAX`].
391    pub(super) change_list: Vec<(usize, usize)>,
392    /// Index into `change_list` while walking. `None` outside a walk —
393    /// any new edit clears it (and trims forward entries past it).
394    pub(super) change_list_cursor: Option<usize>,
395    /// Snapshot of the last visual selection for `gv` re-entry.
396    /// Stored on every Visual / VisualLine / VisualBlock exit.
397    pub(super) last_visual: Option<LastVisual>,
398    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
399    /// pass doesn't override the user's explicit viewport pinning.
400    /// Cleared every step.
401    pub(super) viewport_pinned: bool,
402    /// Set while replaying `.` / last-change so we don't re-record it.
403    replaying: bool,
404    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
405    /// normal-mode command we return to Insert.
406    one_shot_normal: bool,
407    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
408    pub(super) search_prompt: Option<SearchPrompt>,
409    /// Most recent committed search pattern. Surfaced to host apps via
410    /// [`Editor::last_search`] so their status line can render a hint
411    /// and so `n` / `N` have something to repeat.
412    pub(super) last_search: Option<String>,
413    /// Direction of the last committed search. `n` repeats this; `N`
414    /// inverts it. Defaults to forward so a never-searched buffer's
415    /// `n` still walks downward.
416    pub(super) last_search_forward: bool,
417    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
418    /// with the pre-motion cursor when a "big jump" motion fires
419    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
420    /// `?`). Capped at 100 entries.
421    pub(super) jump_back: Vec<(usize, usize)>,
422    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
423    /// jump, matching vim's "branch off trims forward history" rule.
424    pub(super) jump_fwd: Vec<(usize, usize)>,
425    /// Set by `Ctrl-R` in insert mode while waiting for the register
426    /// selector. The next typed char names the register; its contents
427    /// are inserted inline at the cursor and the flag clears.
428    pub(super) insert_pending_register: bool,
429    /// Bounded history of committed `/` / `?` search patterns. Newest
430    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
431    /// avoid unbounded growth on long sessions.
432    pub(super) search_history: Vec<String>,
433    /// Index into `search_history` while the user walks past patterns
434    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
435    /// — typing or backspacing in the prompt resets it so the next
436    /// `Ctrl-P` starts from the most recent entry again.
437    pub(super) search_history_cursor: Option<usize>,
438    /// Wall-clock instant of the last keystroke. Drives the
439    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
440    /// exceeds the configured budget, any pending prefix is cleared
441    /// before the new key dispatches. `None` before the first key.
442    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
443    /// [`crate::types::Host::now`] via `last_input_host_at`. This
444    /// `Instant`-flavoured field stays for snapshot tests that still
445    /// observe it directly.
446    pub(super) last_input_at: Option<std::time::Instant>,
447    /// `Host::now()` reading at the last keystroke. Drives
448    /// `:set timeoutlen` so macro replay / headless drivers stay
449    /// deterministic regardless of wall-clock skew.
450    pub(super) last_input_host_at: Option<core::time::Duration>,
451}
452
453const SEARCH_HISTORY_MAX: usize = 100;
454pub(crate) const CHANGE_LIST_MAX: usize = 100;
455
456/// Active `/` or `?` search prompt. Text mutations drive the textarea's
457/// live search pattern so matches highlight as the user types.
458#[derive(Debug, Clone)]
459pub struct SearchPrompt {
460    pub text: String,
461    pub cursor: usize,
462    pub forward: bool,
463}
464
465#[derive(Debug, Clone)]
466struct InsertSession {
467    count: usize,
468    /// Min/max row visited during this session. Widens on every key.
469    row_min: usize,
470    row_max: usize,
471    /// Snapshot of the full buffer at session entry. Used to diff the
472    /// affected row window at finish without being fooled by cursor
473    /// navigation through rows the user never edited.
474    before_lines: Vec<String>,
475    reason: InsertReason,
476}
477
478#[derive(Debug, Clone)]
479enum InsertReason {
480    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
481    Enter(InsertEntry),
482    /// Entry via `o`/`O` — records OpenLine on Esc.
483    Open { above: bool },
484    /// Entry via an operator's change side-effect. Retro-fills the
485    /// stored last-change's `inserted` field on Esc.
486    AfterChange,
487    /// Entry via `C` (delete to EOL + insert).
488    DeleteToEol,
489    /// Entry via an insert triggered during dot-replay — don't touch
490    /// last_change because the outer replay will restore it.
491    ReplayOnly,
492    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
493    /// every row in `top..=bot`. `col` is the start column for `I`, the
494    /// one-past-block-end column for `A`.
495    BlockEdge { top: usize, bot: usize, col: usize },
496    /// `R` — Replace mode. Each typed char overwrites the cell under
497    /// the cursor instead of inserting; at end-of-line the session
498    /// falls through to insert (same as vim).
499    Replace,
500}
501
502/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
503/// visual selection). `mode` carries which visual flavour to
504/// restore; `anchor` / `cursor` mean different things per flavour:
505///
506/// - `Visual`     — `anchor` is the char-wise visual anchor.
507/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
508///   `anchor.1` is unused.
509/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
510///   sticky vcol that survives j/k clamping.
511#[derive(Debug, Clone, Copy)]
512pub(super) struct LastVisual {
513    pub mode: Mode,
514    pub anchor: (usize, usize),
515    pub cursor: (usize, usize),
516    pub block_vcol: usize,
517}
518
519impl VimState {
520    pub fn public_mode(&self) -> VimMode {
521        match self.mode {
522            Mode::Normal => VimMode::Normal,
523            Mode::Insert => VimMode::Insert,
524            Mode::Visual => VimMode::Visual,
525            Mode::VisualLine => VimMode::VisualLine,
526            Mode::VisualBlock => VimMode::VisualBlock,
527        }
528    }
529
530    pub fn force_normal(&mut self) {
531        self.mode = Mode::Normal;
532        self.pending = Pending::None;
533        self.count = 0;
534        self.insert_session = None;
535    }
536
537    /// Reset every prefix-tracking field so the next keystroke starts
538    /// a fresh sequence. Drives `:set timeoutlen` — when the user
539    /// pauses past the configured budget, [`crate::vim::step`] calls
540    /// this before dispatching the new key.
541    ///
542    /// Resets: `pending`, `count`, `pending_register`,
543    /// `insert_pending_register`. Does NOT touch `mode`,
544    /// `insert_session`, marks, jump list, or visual anchors —
545    /// those aren't part of the in-flight chord.
546    pub(crate) fn clear_pending_prefix(&mut self) {
547        self.pending = Pending::None;
548        self.count = 0;
549        self.pending_register = None;
550        self.insert_pending_register = false;
551    }
552
553    pub fn is_visual(&self) -> bool {
554        matches!(
555            self.mode,
556            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
557        )
558    }
559
560    pub fn is_visual_char(&self) -> bool {
561        self.mode == Mode::Visual
562    }
563
564    pub fn enter_visual(&mut self, anchor: (usize, usize)) {
565        self.visual_anchor = anchor;
566        self.mode = Mode::Visual;
567    }
568
569    /// The pending repeat count (typed digits before a motion/operator),
570    /// or `None` when no digits are pending. Zero is treated as absent.
571    pub(crate) fn pending_count_val(&self) -> Option<u32> {
572        if self.count == 0 {
573            None
574        } else {
575            Some(self.count as u32)
576        }
577    }
578
579    /// Return a single char representing the pending operator, if any.
580    /// Used by host apps (status line "showcmd" area) to display e.g.
581    /// `d`, `y`, `c` while waiting for a motion.
582    pub(crate) fn pending_op_char(&self) -> Option<char> {
583        let op = match &self.pending {
584            Pending::Op { op, .. }
585            | Pending::OpTextObj { op, .. }
586            | Pending::OpG { op, .. }
587            | Pending::OpFind { op, .. } => Some(*op),
588            _ => None,
589        };
590        op.map(|o| match o {
591            Operator::Delete => 'd',
592            Operator::Change => 'c',
593            Operator::Yank => 'y',
594            Operator::Uppercase => 'U',
595            Operator::Lowercase => 'u',
596            Operator::ToggleCase => '~',
597            Operator::Indent => '>',
598            Operator::Outdent => '<',
599            Operator::Fold => 'z',
600            Operator::Reflow => 'q',
601        })
602    }
603}
604
605// ─── Entry point ───────────────────────────────────────────────────────────
606
607/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
608/// live search highlight until the user commits a query. `last_search`
609/// is preserved so an empty `<CR>` can re-run the previous pattern.
610fn enter_search<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, forward: bool) {
611    ed.vim.search_prompt = Some(SearchPrompt {
612        text: String::new(),
613        cursor: 0,
614        forward,
615    });
616    ed.vim.search_history_cursor = None;
617    // 0.0.37: clear via the engine search state (the buffer-side
618    // bridge from 0.0.35 was removed in this patch — the `BufferView`
619    // renderer reads the pattern from `Editor::search_state()`).
620    ed.set_search_pattern(None);
621}
622
623/// Compile `pattern` into a regex and push it onto the migration
624/// buffer's search state. Invalid patterns clear the highlight (the
625/// user is mid-typing a regex like `[` and we don't want to flash an
626/// error).
627fn push_search_pattern<H: crate::types::Host>(
628    ed: &mut Editor<hjkl_buffer::Buffer, H>,
629    pattern: &str,
630) {
631    let compiled = if pattern.is_empty() {
632        None
633    } else {
634        // `:set ignorecase` flips every search pattern to case-insensitive
635        // unless the user already prefixed an explicit `(?i)` / `(?-i)`
636        // (regex crate honours those even when we layer another `(?i)`).
637        // `:set smartcase` re-enables case sensitivity for any pattern
638        // that contains an uppercase letter — matches vim's combined
639        // `ignorecase` + `smartcase` behaviour.
640        let case_insensitive = ed.settings().ignore_case
641            && !(ed.settings().smartcase && pattern.chars().any(|c| c.is_uppercase()));
642        let effective: std::borrow::Cow<'_, str> = if case_insensitive {
643            std::borrow::Cow::Owned(format!("(?i){pattern}"))
644        } else {
645            std::borrow::Cow::Borrowed(pattern)
646        };
647        regex::Regex::new(&effective).ok()
648    };
649    let wrap = ed.settings().wrapscan;
650    // 0.0.37: search FSM lives entirely on Editor — pattern + wrap
651    // policy + per-row match cache. The `Search` trait impl always
652    // wraps; engine code honours `wrap_around` before invoking it.
653    ed.set_search_pattern(compiled);
654    ed.search_state_mut().wrap_around = wrap;
655}
656
657fn step_search_prompt<H: crate::types::Host>(
658    ed: &mut Editor<hjkl_buffer::Buffer, H>,
659    input: Input,
660) -> bool {
661    // Ctrl-P / Ctrl-N (and Up / Down) walk the search history. Handled
662    // before the regular char/backspace branches so `Ctrl-P` doesn't
663    // type a literal `p`.
664    let history_dir = match (input.key, input.ctrl) {
665        (Key::Char('p'), true) | (Key::Up, _) => Some(-1),
666        (Key::Char('n'), true) | (Key::Down, _) => Some(1),
667        _ => None,
668    };
669    if let Some(dir) = history_dir {
670        walk_search_history(ed, dir);
671        return true;
672    }
673    match input.key {
674        Key::Esc => {
675            // Cancel. Drop the prompt but keep the highlighted matches
676            // so `n` / `N` can repeat whatever was typed.
677            let text = ed
678                .vim
679                .search_prompt
680                .take()
681                .map(|p| p.text)
682                .unwrap_or_default();
683            if !text.is_empty() {
684                ed.vim.last_search = Some(text);
685            }
686            ed.vim.search_history_cursor = None;
687        }
688        Key::Enter => {
689            let prompt = ed.vim.search_prompt.take();
690            if let Some(p) = prompt {
691                // Empty `/<CR>` (or `?<CR>`) re-runs the previous search
692                // pattern in the prompt's direction — vim parity.
693                let pattern = if p.text.is_empty() {
694                    ed.vim.last_search.clone()
695                } else {
696                    Some(p.text.clone())
697                };
698                if let Some(pattern) = pattern {
699                    push_search_pattern(ed, &pattern);
700                    let pre = ed.cursor();
701                    if p.forward {
702                        ed.search_advance_forward(true);
703                    } else {
704                        ed.search_advance_backward(true);
705                    }
706                    ed.push_buffer_cursor_to_textarea();
707                    if ed.cursor() != pre {
708                        push_jump(ed, pre);
709                    }
710                    record_search_history(ed, &pattern);
711                    ed.vim.last_search = Some(pattern);
712                    ed.vim.last_search_forward = p.forward;
713                }
714            }
715            ed.vim.search_history_cursor = None;
716        }
717        Key::Backspace => {
718            ed.vim.search_history_cursor = None;
719            let new_text = ed.vim.search_prompt.as_mut().and_then(|p| {
720                if p.text.pop().is_some() {
721                    p.cursor = p.text.chars().count();
722                    Some(p.text.clone())
723                } else {
724                    None
725                }
726            });
727            if let Some(text) = new_text {
728                push_search_pattern(ed, &text);
729            }
730        }
731        Key::Char(c) => {
732            ed.vim.search_history_cursor = None;
733            let new_text = ed.vim.search_prompt.as_mut().map(|p| {
734                p.text.push(c);
735                p.cursor = p.text.chars().count();
736                p.text.clone()
737            });
738            if let Some(text) = new_text {
739                push_search_pattern(ed, &text);
740            }
741        }
742        _ => {}
743    }
744    true
745}
746
747/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
748/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
749/// the ends of the ring; off-ring positions are silently ignored.
750fn walk_change_list<H: crate::types::Host>(
751    ed: &mut Editor<hjkl_buffer::Buffer, H>,
752    dir: isize,
753    count: usize,
754) {
755    if ed.vim.change_list.is_empty() {
756        return;
757    }
758    let len = ed.vim.change_list.len();
759    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
760        (None, -1) => len as isize - 1,
761        (None, 1) => return, // already past the newest entry
762        (Some(i), -1) => i as isize - 1,
763        (Some(i), 1) => i as isize + 1,
764        _ => return,
765    };
766    for _ in 1..count {
767        let next = idx + dir;
768        if next < 0 || next >= len as isize {
769            break;
770        }
771        idx = next;
772    }
773    if idx < 0 || idx >= len as isize {
774        return;
775    }
776    let idx = idx as usize;
777    ed.vim.change_list_cursor = Some(idx);
778    let (row, col) = ed.vim.change_list[idx];
779    ed.jump_cursor(row, col);
780}
781
782/// Push `pattern` onto the search history. Skips the push when the
783/// most recent entry already matches (consecutive dedupe) and trims
784/// the oldest entries beyond [`SEARCH_HISTORY_MAX`].
785fn record_search_history<H: crate::types::Host>(
786    ed: &mut Editor<hjkl_buffer::Buffer, H>,
787    pattern: &str,
788) {
789    if pattern.is_empty() {
790        return;
791    }
792    if ed.vim.search_history.last().map(String::as_str) == Some(pattern) {
793        return;
794    }
795    ed.vim.search_history.push(pattern.to_string());
796    let len = ed.vim.search_history.len();
797    if len > SEARCH_HISTORY_MAX {
798        ed.vim.search_history.drain(0..len - SEARCH_HISTORY_MAX);
799    }
800}
801
802/// Replace the prompt text with the next entry in the search history.
803/// `dir = -1` walks toward older entries (`Ctrl-P` / `Up`); `dir = 1`
804/// toward newer ones (`Ctrl-N` / `Down`). Stops at the ends of the
805/// history; the user can keep pressing the key without effect rather
806/// than wrapping around.
807fn walk_search_history<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, dir: isize) {
808    if ed.vim.search_history.is_empty() || ed.vim.search_prompt.is_none() {
809        return;
810    }
811    let len = ed.vim.search_history.len();
812    let next_idx = match (ed.vim.search_history_cursor, dir) {
813        (None, -1) => Some(len - 1),
814        (None, 1) => return, // already past the newest entry
815        (Some(i), -1) => i.checked_sub(1),
816        (Some(i), 1) if i + 1 < len => Some(i + 1),
817        _ => None,
818    };
819    let Some(idx) = next_idx else {
820        return;
821    };
822    ed.vim.search_history_cursor = Some(idx);
823    let text = ed.vim.search_history[idx].clone();
824    if let Some(prompt) = ed.vim.search_prompt.as_mut() {
825        prompt.cursor = text.chars().count();
826        prompt.text = text.clone();
827    }
828    push_search_pattern(ed, &text);
829}
830
831pub fn step<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, input: Input) -> bool {
832    // Phase 7f port: any cursor / content the host changed between
833    // steps (mouse jumps, paste, programmatic set_content, …) needs
834    // to land in the migration buffer before motion handlers that
835    // call into `Buffer::move_*` see a stale state.
836    ed.sync_buffer_content_from_textarea();
837    // `:set timeoutlen` — if the user paused longer than the budget
838    // since the last keystroke and a chord is in flight, drop the
839    // pending prefix so the new key starts fresh. 0.0.29 (Patch B):
840    // chord-timeout math now reads `Host::now()` so macro replay /
841    // headless drivers stay deterministic. The legacy
842    // `Instant::now()`-backed `last_input_at` field is retained for
843    // snapshot tests that still observe it.
844    let now = std::time::Instant::now();
845    let host_now = ed.host.now();
846    let timed_out = match ed.vim.last_input_host_at {
847        Some(prev) => host_now.saturating_sub(prev) > ed.settings.timeout_len,
848        None => false,
849    };
850    if timed_out {
851        let chord_in_flight = !matches!(ed.vim.pending, Pending::None)
852            || ed.vim.count != 0
853            || ed.vim.pending_register.is_some()
854            || ed.vim.insert_pending_register;
855        if chord_in_flight {
856            ed.vim.clear_pending_prefix();
857        }
858    }
859    ed.vim.last_input_at = Some(now);
860    ed.vim.last_input_host_at = Some(host_now);
861    // Macro stop: a bare `q` ends an active recording before any
862    // other handler sees the key (so `q` itself doesn't get
863    // recorded). Replays don't trigger this — they finish on their
864    // own when the captured key list runs out.
865    if ed.vim.recording_macro.is_some()
866        && !ed.vim.replaying_macro
867        && matches!(ed.vim.pending, Pending::None)
868        && ed.vim.mode != Mode::Insert
869        && input.key == Key::Char('q')
870        && !input.ctrl
871        && !input.alt
872    {
873        let reg = ed.vim.recording_macro.take().unwrap();
874        let keys = std::mem::take(&mut ed.vim.recording_keys);
875        let text = crate::input::encode_macro(&keys);
876        ed.set_named_register_text(reg.to_ascii_lowercase(), text);
877        return true;
878    }
879    // Search prompt eats all keys until Enter / Esc.
880    if ed.vim.search_prompt.is_some() {
881        return step_search_prompt(ed, input);
882    }
883    // Snapshot whether this step is consuming the register-name half
884    // of a macro chord. The recorder hook below uses this to skip
885    // the chord's bookkeeping keys (`q{reg}` open and `@{reg}` open).
886    let pending_was_macro_chord = matches!(
887        ed.vim.pending,
888        Pending::RecordMacroTarget | Pending::PlayMacroTarget { .. }
889    );
890    let was_insert = ed.vim.mode == Mode::Insert;
891    // Capture pre-step visual snapshot so a visual → normal transition
892    // can stash the selection for `gv` re-entry.
893    let pre_visual_snapshot = match ed.vim.mode {
894        Mode::Visual => Some(LastVisual {
895            mode: Mode::Visual,
896            anchor: ed.vim.visual_anchor,
897            cursor: ed.cursor(),
898            block_vcol: 0,
899        }),
900        Mode::VisualLine => Some(LastVisual {
901            mode: Mode::VisualLine,
902            anchor: (ed.vim.visual_line_anchor, 0),
903            cursor: ed.cursor(),
904            block_vcol: 0,
905        }),
906        Mode::VisualBlock => Some(LastVisual {
907            mode: Mode::VisualBlock,
908            anchor: ed.vim.block_anchor,
909            cursor: ed.cursor(),
910            block_vcol: ed.vim.block_vcol,
911        }),
912        _ => None,
913    };
914    let consumed = match ed.vim.mode {
915        Mode::Insert => step_insert(ed, input),
916        _ => step_normal(ed, input),
917    };
918    if let Some(snap) = pre_visual_snapshot
919        && !matches!(
920            ed.vim.mode,
921            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
922        )
923    {
924        ed.vim.last_visual = Some(snap);
925    }
926    // Ctrl-o in insert mode queues a single normal-mode command; once
927    // that command finishes (pending cleared, not in operator / visual),
928    // drop back to insert without replaying the insert session.
929    if !was_insert
930        && ed.vim.one_shot_normal
931        && ed.vim.mode == Mode::Normal
932        && matches!(ed.vim.pending, Pending::None)
933    {
934        ed.vim.one_shot_normal = false;
935        ed.vim.mode = Mode::Insert;
936    }
937    // Phase 7c: every step ends with the migration buffer mirroring
938    // the textarea's content + cursor + viewport. Edit-emitting paths
939    // (insert_char, delete_char, …) inside `step_insert` /
940    // `step_normal` thus all flow through here without each call
941    // site needing to remember to sync.
942    ed.sync_buffer_content_from_textarea();
943    // Scroll viewport to keep cursor on-screen, honouring the same
944    // `SCROLLOFF` margin the mouse-driven scroll uses. Skip when
945    // the user just pinned the viewport with `zz` / `zt` / `zb`.
946    if !ed.vim.viewport_pinned {
947        ed.ensure_cursor_in_scrolloff();
948    }
949    ed.vim.viewport_pinned = false;
950    // Recorder hook: append every consumed input to the active
951    // recording (if any) so the replay reproduces the same sequence.
952    // Skip the chord that started the recording (`q{reg}` open) and
953    // skip during replay so a macro doesn't capture itself.
954    if ed.vim.recording_macro.is_some()
955        && !ed.vim.replaying_macro
956        && input.key != Key::Char('q')
957        && !pending_was_macro_chord
958    {
959        ed.vim.recording_keys.push(input);
960    }
961    consumed
962}
963
964// ─── Insert mode ───────────────────────────────────────────────────────────
965
966fn step_insert<H: crate::types::Host>(
967    ed: &mut Editor<hjkl_buffer::Buffer, H>,
968    input: Input,
969) -> bool {
970    // `Ctrl-R {reg}` paste — the previous keystroke armed the wait. Any
971    // non-char key cancels (matches vim, which beeps on selectors like
972    // Esc and re-emits the literal text otherwise).
973    if ed.vim.insert_pending_register {
974        ed.vim.insert_pending_register = false;
975        if let Key::Char(c) = input.key
976            && !input.ctrl
977        {
978            insert_register_text(ed, c);
979        }
980        return true;
981    }
982
983    if input.key == Key::Esc {
984        finish_insert_session(ed);
985        ed.vim.mode = Mode::Normal;
986        // Vim convention: pull the cursor back one cell on exit when
987        // possible. Sticky column then mirrors the *visible* post-Back
988        // column so the next vertical motion lands where the user
989        // actually sees the cursor — not one cell to the right.
990        let col = ed.cursor().1;
991        if col > 0 {
992            crate::motions::move_left(&mut ed.buffer, 1);
993            ed.push_buffer_cursor_to_textarea();
994        }
995        ed.sticky_col = Some(ed.cursor().1);
996        return true;
997    }
998
999    // Ctrl-prefixed insert-mode shortcuts.
1000    if input.ctrl {
1001        match input.key {
1002            Key::Char('w') => {
1003                use hjkl_buffer::{Edit, MotionKind};
1004                ed.sync_buffer_content_from_textarea();
1005                let cursor = buf_cursor_pos(&ed.buffer);
1006                if cursor.row == 0 && cursor.col == 0 {
1007                    return true;
1008                }
1009                // Find the previous word start by stepping the buffer
1010                // cursor (vim `b` semantics) and snapshot it.
1011                crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
1012                let word_start = buf_cursor_pos(&ed.buffer);
1013                if word_start == cursor {
1014                    return true;
1015                }
1016                buf_set_cursor_pos(&mut ed.buffer, cursor);
1017                ed.mutate_edit(Edit::DeleteRange {
1018                    start: word_start,
1019                    end: cursor,
1020                    kind: MotionKind::Char,
1021                });
1022                ed.push_buffer_cursor_to_textarea();
1023                return true;
1024            }
1025            Key::Char('u') => {
1026                use hjkl_buffer::{Edit, MotionKind, Position};
1027                ed.sync_buffer_content_from_textarea();
1028                let cursor = buf_cursor_pos(&ed.buffer);
1029                if cursor.col > 0 {
1030                    ed.mutate_edit(Edit::DeleteRange {
1031                        start: Position::new(cursor.row, 0),
1032                        end: cursor,
1033                        kind: MotionKind::Char,
1034                    });
1035                    ed.push_buffer_cursor_to_textarea();
1036                }
1037                return true;
1038            }
1039            Key::Char('h') => {
1040                use hjkl_buffer::{Edit, MotionKind, Position};
1041                ed.sync_buffer_content_from_textarea();
1042                let cursor = buf_cursor_pos(&ed.buffer);
1043                if cursor.col > 0 {
1044                    ed.mutate_edit(Edit::DeleteRange {
1045                        start: Position::new(cursor.row, cursor.col - 1),
1046                        end: cursor,
1047                        kind: MotionKind::Char,
1048                    });
1049                } else if cursor.row > 0 {
1050                    let prev_row = cursor.row - 1;
1051                    let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1052                    ed.mutate_edit(Edit::JoinLines {
1053                        row: prev_row,
1054                        count: 1,
1055                        with_space: false,
1056                    });
1057                    buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1058                }
1059                ed.push_buffer_cursor_to_textarea();
1060                return true;
1061            }
1062            Key::Char('o') => {
1063                // One-shot normal: leave insert mode for the next full
1064                // normal-mode command, then come back.
1065                ed.vim.one_shot_normal = true;
1066                ed.vim.mode = Mode::Normal;
1067                return true;
1068            }
1069            Key::Char('r') => {
1070                // Arm the register selector — the next typed char picks
1071                // a slot and pastes its text inline.
1072                ed.vim.insert_pending_register = true;
1073                return true;
1074            }
1075            Key::Char('t') => {
1076                // Insert-mode indent: prepend one shiftwidth to the
1077                // current line's leading whitespace. Cursor shifts
1078                // right by the same amount so the user keeps typing
1079                // at their logical position.
1080                let (row, col) = ed.cursor();
1081                let sw = ed.settings().shiftwidth;
1082                indent_rows(ed, row, row, 1);
1083                ed.jump_cursor(row, col + sw);
1084                return true;
1085            }
1086            Key::Char('d') => {
1087                // Insert-mode outdent: drop up to one shiftwidth of
1088                // leading whitespace. Cursor shifts left by the amount
1089                // actually stripped.
1090                let (row, col) = ed.cursor();
1091                let before_len = buf_line_bytes(&ed.buffer, row);
1092                outdent_rows(ed, row, row, 1);
1093                let after_len = buf_line_bytes(&ed.buffer, row);
1094                let stripped = before_len.saturating_sub(after_len);
1095                let new_col = col.saturating_sub(stripped);
1096                ed.jump_cursor(row, new_col);
1097                return true;
1098            }
1099            _ => {}
1100        }
1101    }
1102
1103    // Widen the session's visited row window *before* handling the key
1104    // so navigation-only keystrokes (arrow keys) still extend the range.
1105    let (row, _) = ed.cursor();
1106    if let Some(ref mut session) = ed.vim.insert_session {
1107        session.row_min = session.row_min.min(row);
1108        session.row_max = session.row_max.max(row);
1109    }
1110    let mutated = handle_insert_key(ed, input);
1111    if mutated {
1112        ed.mark_content_dirty();
1113        let (row, _) = ed.cursor();
1114        if let Some(ref mut session) = ed.vim.insert_session {
1115            session.row_min = session.row_min.min(row);
1116            session.row_max = session.row_max.max(row);
1117        }
1118    }
1119    true
1120}
1121
1122/// `Ctrl-R {reg}` body — insert the named register's contents at the
1123/// cursor as charwise text. Embedded newlines split lines naturally via
1124/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
1125/// stray keystrokes don't mutate the buffer.
1126fn insert_register_text<H: crate::types::Host>(
1127    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1128    selector: char,
1129) {
1130    use hjkl_buffer::Edit;
1131    let text = match ed.registers().read(selector) {
1132        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
1133        _ => return,
1134    };
1135    ed.sync_buffer_content_from_textarea();
1136    let cursor = buf_cursor_pos(&ed.buffer);
1137    ed.mutate_edit(Edit::InsertStr {
1138        at: cursor,
1139        text: text.clone(),
1140    });
1141    // Advance cursor to the end of the inserted payload — multi-line
1142    // pastes land on the last inserted row at the post-text column.
1143    let mut row = cursor.row;
1144    let mut col = cursor.col;
1145    for ch in text.chars() {
1146        if ch == '\n' {
1147            row += 1;
1148            col = 0;
1149        } else {
1150            col += 1;
1151        }
1152    }
1153    buf_set_cursor_rc(&mut ed.buffer, row, col);
1154    ed.push_buffer_cursor_to_textarea();
1155    ed.mark_content_dirty();
1156    if let Some(ref mut session) = ed.vim.insert_session {
1157        session.row_min = session.row_min.min(row);
1158        session.row_max = session.row_max.max(row);
1159    }
1160}
1161
1162/// Compute the indent string to insert at the start of a new line
1163/// after Enter is pressed at `cursor`. Walks the smartindent rules:
1164///
1165/// - autoindent off → empty string
1166/// - autoindent on  → copy prev line's leading whitespace
1167/// - smartindent on → bump one `shiftwidth` if prev line's last
1168///   non-whitespace char is `{` / `(` / `[`
1169///
1170/// Indent unit (used for the smartindent bump):
1171///
1172/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
1173/// - `expandtab` → `shiftwidth` spaces
1174/// - `!expandtab` → one literal `\t`
1175///
1176/// This is the placeholder for a future tree-sitter indent provider:
1177/// when a language has an `indents.scm` query, the engine will route
1178/// the same call through that provider and only fall back to this
1179/// heuristic when no query matches.
1180pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
1181    if !settings.autoindent {
1182        return String::new();
1183    }
1184    // Copy the prev line's leading whitespace (autoindent base).
1185    let base: String = prev_line
1186        .chars()
1187        .take_while(|c| *c == ' ' || *c == '\t')
1188        .collect();
1189
1190    if settings.smartindent {
1191        // If the last non-whitespace character is an open bracket, bump
1192        // indent by one unit. This is the heuristic seam: a tree-sitter
1193        // `indents.scm` provider would replace this branch.
1194        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
1195        if matches!(last_non_ws, Some('{' | '(' | '[')) {
1196            let unit = if settings.expandtab {
1197                if settings.softtabstop > 0 {
1198                    " ".repeat(settings.softtabstop)
1199                } else {
1200                    " ".repeat(settings.shiftwidth)
1201                }
1202            } else {
1203                "\t".to_string()
1204            };
1205            return format!("{base}{unit}");
1206        }
1207    }
1208
1209    base
1210}
1211
1212/// Strip one indent unit from the beginning of `line` and insert `ch`
1213/// instead. Returns `true` when it consumed the keystroke (dedent +
1214/// insert), `false` when the caller should insert normally.
1215///
1216/// Dedent fires when:
1217///   - `smartindent` is on
1218///   - `ch` is `}` / `)` / `]`
1219///   - all bytes BEFORE the cursor on the current line are whitespace
1220///   - there is at least one full indent unit of leading whitespace
1221fn try_dedent_close_bracket<H: crate::types::Host>(
1222    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1223    cursor: hjkl_buffer::Position,
1224    ch: char,
1225) -> bool {
1226    use hjkl_buffer::{Edit, MotionKind, Position};
1227
1228    if !ed.settings.smartindent {
1229        return false;
1230    }
1231    if !matches!(ch, '}' | ')' | ']') {
1232        return false;
1233    }
1234
1235    let line = match buf_line(&ed.buffer, cursor.row) {
1236        Some(l) => l.to_string(),
1237        None => return false,
1238    };
1239
1240    // All chars before cursor must be whitespace.
1241    let before: String = line.chars().take(cursor.col).collect();
1242    if !before.chars().all(|c| c == ' ' || c == '\t') {
1243        return false;
1244    }
1245    if before.is_empty() {
1246        // Nothing to strip — just insert normally (cursor at col 0).
1247        return false;
1248    }
1249
1250    // Compute indent unit.
1251    let unit_len: usize = if ed.settings.expandtab {
1252        if ed.settings.softtabstop > 0 {
1253            ed.settings.softtabstop
1254        } else {
1255            ed.settings.shiftwidth
1256        }
1257    } else {
1258        // Tab: one literal tab character.
1259        1
1260    };
1261
1262    // Check there's at least one full unit to strip.
1263    let strip_len = if ed.settings.expandtab {
1264        // Count leading spaces; need at least `unit_len`.
1265        let spaces = before.chars().filter(|c| *c == ' ').count();
1266        if spaces < unit_len {
1267            return false;
1268        }
1269        unit_len
1270    } else {
1271        // noexpandtab: strip one leading tab.
1272        if !before.starts_with('\t') {
1273            return false;
1274        }
1275        1
1276    };
1277
1278    // Delete the leading `strip_len` chars of the current line.
1279    ed.mutate_edit(Edit::DeleteRange {
1280        start: Position::new(cursor.row, 0),
1281        end: Position::new(cursor.row, strip_len),
1282        kind: MotionKind::Char,
1283    });
1284    // Insert the close bracket at column 0 (after the delete the cursor
1285    // is still positioned at the end of the remaining whitespace; the
1286    // delete moved the text so the cursor is now at col = before.len() -
1287    // strip_len).
1288    let new_col = cursor.col.saturating_sub(strip_len);
1289    ed.mutate_edit(Edit::InsertChar {
1290        at: Position::new(cursor.row, new_col),
1291        ch,
1292    });
1293    true
1294}
1295
1296/// Insert-mode key dispatcher backed by the migration buffer. Replaces
1297/// the historical `textarea.input(input)` call so the textarea field
1298/// can be ripped at the end of Phase 7f. PageUp / PageDown still flow
1299/// through the textarea (they're scroll-only with no buffer side
1300/// effect); every other navigation + edit key lands on `Buffer`.
1301/// Returns true when the buffer mutated.
1302fn handle_insert_key<H: crate::types::Host>(
1303    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1304    input: Input,
1305) -> bool {
1306    use hjkl_buffer::{Edit, MotionKind, Position};
1307    ed.sync_buffer_content_from_textarea();
1308    let cursor = buf_cursor_pos(&ed.buffer);
1309    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1310    // Replace mode: overstrike the cell at the cursor instead of
1311    // inserting. At end-of-line, fall through to plain insert (vim
1312    // appends past the line).
1313    let in_replace = matches!(
1314        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1315        Some(InsertReason::Replace)
1316    );
1317    let mutated = match input.key {
1318        Key::Char(c) if in_replace && cursor.col < line_chars => {
1319            ed.mutate_edit(Edit::DeleteRange {
1320                start: cursor,
1321                end: Position::new(cursor.row, cursor.col + 1),
1322                kind: MotionKind::Char,
1323            });
1324            ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1325            true
1326        }
1327        Key::Char(c) => {
1328            if !try_dedent_close_bracket(ed, cursor, c) {
1329                ed.mutate_edit(Edit::InsertChar { at: cursor, ch: c });
1330            }
1331            true
1332        }
1333        Key::Enter => {
1334            let prev_line = buf_line(&ed.buffer, cursor.row)
1335                .unwrap_or_default()
1336                .to_string();
1337            let indent = compute_enter_indent(&ed.settings, &prev_line);
1338            let text = format!("\n{indent}");
1339            ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1340            true
1341        }
1342        Key::Tab => {
1343            if ed.settings.expandtab {
1344                // With softtabstop > 0, fill to the next sts boundary.
1345                // Otherwise insert a full tabstop run.
1346                let sts = ed.settings.softtabstop;
1347                let n = if sts > 0 {
1348                    sts - (cursor.col % sts)
1349                } else {
1350                    ed.settings.tabstop.max(1)
1351                };
1352                ed.mutate_edit(Edit::InsertStr {
1353                    at: cursor,
1354                    text: " ".repeat(n),
1355                });
1356            } else {
1357                ed.mutate_edit(Edit::InsertChar {
1358                    at: cursor,
1359                    ch: '\t',
1360                });
1361            }
1362            true
1363        }
1364        Key::Backspace => {
1365            // Softtabstop: if the N chars before the cursor are all spaces
1366            // and the cursor sits on an sts-aligned column, delete the run
1367            // as a single unit (vim's "backspace deletes a soft tab" feel).
1368            let sts = ed.settings.softtabstop;
1369            if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
1370                let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1371                let chars: Vec<char> = line.chars().collect();
1372                let run_start = cursor.col - sts;
1373                if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
1374                    ed.mutate_edit(Edit::DeleteRange {
1375                        start: Position::new(cursor.row, run_start),
1376                        end: cursor,
1377                        kind: MotionKind::Char,
1378                    });
1379                    return true;
1380                }
1381            }
1382            if cursor.col > 0 {
1383                ed.mutate_edit(Edit::DeleteRange {
1384                    start: Position::new(cursor.row, cursor.col - 1),
1385                    end: cursor,
1386                    kind: MotionKind::Char,
1387                });
1388                true
1389            } else if cursor.row > 0 {
1390                let prev_row = cursor.row - 1;
1391                let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1392                ed.mutate_edit(Edit::JoinLines {
1393                    row: prev_row,
1394                    count: 1,
1395                    with_space: false,
1396                });
1397                buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1398                true
1399            } else {
1400                false
1401            }
1402        }
1403        Key::Delete => {
1404            if cursor.col < line_chars {
1405                ed.mutate_edit(Edit::DeleteRange {
1406                    start: cursor,
1407                    end: Position::new(cursor.row, cursor.col + 1),
1408                    kind: MotionKind::Char,
1409                });
1410                true
1411            } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
1412                ed.mutate_edit(Edit::JoinLines {
1413                    row: cursor.row,
1414                    count: 1,
1415                    with_space: false,
1416                });
1417                buf_set_cursor_pos(&mut ed.buffer, cursor);
1418                true
1419            } else {
1420                false
1421            }
1422        }
1423        Key::Left => {
1424            crate::motions::move_left(&mut ed.buffer, 1);
1425            break_undo_group_in_insert(ed);
1426            false
1427        }
1428        Key::Right => {
1429            // Insert mode allows the cursor one past the last char so the
1430            // next typed letter appends — use the operator-context move.
1431            crate::motions::move_right_to_end(&mut ed.buffer, 1);
1432            break_undo_group_in_insert(ed);
1433            false
1434        }
1435        Key::Up => {
1436            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1437            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1438            break_undo_group_in_insert(ed);
1439            false
1440        }
1441        Key::Down => {
1442            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1443            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1444            break_undo_group_in_insert(ed);
1445            false
1446        }
1447        Key::Home => {
1448            crate::motions::move_line_start(&mut ed.buffer);
1449            break_undo_group_in_insert(ed);
1450            false
1451        }
1452        Key::End => {
1453            crate::motions::move_line_end(&mut ed.buffer);
1454            break_undo_group_in_insert(ed);
1455            false
1456        }
1457        Key::PageUp => {
1458            // Vim default: PageUp scrolls a full window up, cursor
1459            // tracks. Reuse the Ctrl-b scroll helper so behavior
1460            // matches the normal-mode equivalent.
1461            let rows = viewport_full_rows(ed, 1) as isize;
1462            scroll_cursor_rows(ed, -rows);
1463            return false;
1464        }
1465        Key::PageDown => {
1466            let rows = viewport_full_rows(ed, 1) as isize;
1467            scroll_cursor_rows(ed, rows);
1468            return false;
1469        }
1470        // F-keys, mouse scroll, copy/cut/paste virtual keys, Null —
1471        // no insert-mode behaviour.
1472        _ => false,
1473    };
1474    ed.push_buffer_cursor_to_textarea();
1475    mutated
1476}
1477
1478fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1479    let Some(session) = ed.vim.insert_session.take() else {
1480        return;
1481    };
1482    let lines = buf_lines_to_vec(&ed.buffer);
1483    // Clamp both slices to their respective bounds — the buffer may have
1484    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
1485    // the session, so row_max can overshoot either side.
1486    let after_end = session.row_max.min(lines.len().saturating_sub(1));
1487    let before_end = session
1488        .row_max
1489        .min(session.before_lines.len().saturating_sub(1));
1490    let before = if before_end >= session.row_min && session.row_min < session.before_lines.len() {
1491        session.before_lines[session.row_min..=before_end].join("\n")
1492    } else {
1493        String::new()
1494    };
1495    let after = if after_end >= session.row_min && session.row_min < lines.len() {
1496        lines[session.row_min..=after_end].join("\n")
1497    } else {
1498        String::new()
1499    };
1500    let inserted = extract_inserted(&before, &after);
1501    if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
1502        use hjkl_buffer::{Edit, Position};
1503        for _ in 0..session.count - 1 {
1504            let (row, col) = ed.cursor();
1505            ed.mutate_edit(Edit::InsertStr {
1506                at: Position::new(row, col),
1507                text: inserted.clone(),
1508            });
1509        }
1510    }
1511    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
1512        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
1513            use hjkl_buffer::{Edit, Position};
1514            for r in (top + 1)..=bot {
1515                let line_len = buf_line_chars(&ed.buffer, r);
1516                if col > line_len {
1517                    // Pad short rows with spaces up to the block edge
1518                    // column so the inserted text lands at `col`.
1519                    let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1520                    ed.mutate_edit(Edit::InsertStr {
1521                        at: Position::new(r, line_len),
1522                        text: pad,
1523                    });
1524                }
1525                ed.mutate_edit(Edit::InsertStr {
1526                    at: Position::new(r, col),
1527                    text: inserted.clone(),
1528                });
1529            }
1530            buf_set_cursor_rc(&mut ed.buffer, top, col);
1531            ed.push_buffer_cursor_to_textarea();
1532        }
1533        return;
1534    }
1535    if ed.vim.replaying {
1536        return;
1537    }
1538    match session.reason {
1539        InsertReason::Enter(entry) => {
1540            ed.vim.last_change = Some(LastChange::InsertAt {
1541                entry,
1542                inserted,
1543                count: session.count,
1544            });
1545        }
1546        InsertReason::Open { above } => {
1547            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
1548        }
1549        InsertReason::AfterChange => {
1550            if let Some(
1551                LastChange::OpMotion { inserted: ins, .. }
1552                | LastChange::OpTextObj { inserted: ins, .. }
1553                | LastChange::LineOp { inserted: ins, .. },
1554            ) = ed.vim.last_change.as_mut()
1555            {
1556                *ins = Some(inserted);
1557            }
1558        }
1559        InsertReason::DeleteToEol => {
1560            ed.vim.last_change = Some(LastChange::DeleteToEol {
1561                inserted: Some(inserted),
1562            });
1563        }
1564        InsertReason::ReplayOnly => {}
1565        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1566        InsertReason::Replace => {
1567            // Record overstrike sessions as DeleteToEol-style — replay
1568            // re-types each character but doesn't try to restore prior
1569            // content (vim's R has its own replay path; this is the
1570            // pragmatic approximation).
1571            ed.vim.last_change = Some(LastChange::DeleteToEol {
1572                inserted: Some(inserted),
1573            });
1574        }
1575    }
1576}
1577
1578fn begin_insert<H: crate::types::Host>(
1579    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1580    count: usize,
1581    reason: InsertReason,
1582) {
1583    let record = !matches!(reason, InsertReason::ReplayOnly);
1584    if record {
1585        ed.push_undo();
1586    }
1587    let reason = if ed.vim.replaying {
1588        InsertReason::ReplayOnly
1589    } else {
1590        reason
1591    };
1592    let (row, _) = ed.cursor();
1593    ed.vim.insert_session = Some(InsertSession {
1594        count,
1595        row_min: row,
1596        row_max: row,
1597        before_lines: buf_lines_to_vec(&ed.buffer),
1598        reason,
1599    });
1600    ed.vim.mode = Mode::Insert;
1601}
1602
1603/// `:set undobreak` semantics for insert-mode motions. When the
1604/// toggle is on, a non-character keystroke that moves the cursor
1605/// (arrow keys, Home/End, mouse click) ends the current undo group
1606/// and starts a new one mid-session. After this, a subsequent `u`
1607/// in normal mode reverts only the post-break run, leaving the
1608/// pre-break edits in place — matching vim's behaviour.
1609///
1610/// Implementation: snapshot the current buffer onto the undo stack
1611/// (the new break point) and reset the active `InsertSession`'s
1612/// `before_lines` so `finish_insert_session`'s diff window only
1613/// captures the post-break run for `last_change` / dot-repeat.
1614///
1615/// During replay we skip the break — replay shouldn't pollute the
1616/// undo stack with intra-replay snapshots.
1617pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1618    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1619) {
1620    if !ed.settings.undo_break_on_motion {
1621        return;
1622    }
1623    if ed.vim.replaying {
1624        return;
1625    }
1626    if ed.vim.insert_session.is_none() {
1627        return;
1628    }
1629    ed.push_undo();
1630    let n = crate::types::Query::line_count(&ed.buffer) as usize;
1631    let mut lines: Vec<String> = Vec::with_capacity(n);
1632    for r in 0..n {
1633        lines.push(crate::types::Query::line(&ed.buffer, r as u32).to_string());
1634    }
1635    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1636    if let Some(ref mut session) = ed.vim.insert_session {
1637        session.before_lines = lines;
1638        session.row_min = row;
1639        session.row_max = row;
1640    }
1641}
1642
1643// ─── Normal / Visual / Operator-pending dispatcher ─────────────────────────
1644
1645fn step_normal<H: crate::types::Host>(
1646    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1647    input: Input,
1648) -> bool {
1649    // Consume digits first — except '0' at start of count (that's LineStart).
1650    if let Key::Char(d @ '0'..='9') = input.key
1651        && !input.ctrl
1652        && !input.alt
1653        && !matches!(
1654            ed.vim.pending,
1655            Pending::Replace
1656                | Pending::Find { .. }
1657                | Pending::OpFind { .. }
1658                | Pending::VisualTextObj { .. }
1659        )
1660        && (d != '0' || ed.vim.count > 0)
1661    {
1662        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
1663        return true;
1664    }
1665
1666    // Handle pending two-key sequences first.
1667    match std::mem::take(&mut ed.vim.pending) {
1668        Pending::Replace => return handle_replace(ed, input),
1669        Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
1670        Pending::OpFind {
1671            op,
1672            count1,
1673            forward,
1674            till,
1675        } => return handle_op_find_target(ed, input, op, count1, forward, till),
1676        Pending::G => return handle_after_g(ed, input),
1677        Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
1678        Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
1679        Pending::OpTextObj { op, count1, inner } => {
1680            return handle_text_object(ed, input, op, count1, inner);
1681        }
1682        Pending::VisualTextObj { inner } => {
1683            return handle_visual_text_obj(ed, input, inner);
1684        }
1685        Pending::Z => return handle_after_z(ed, input),
1686        Pending::SetMark => return handle_set_mark(ed, input),
1687        Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
1688        Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
1689        Pending::SelectRegister => return handle_select_register(ed, input),
1690        Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
1691        Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
1692        Pending::None => {}
1693    }
1694
1695    let count = take_count(&mut ed.vim);
1696
1697    // Common normal / visual keys.
1698    match input.key {
1699        Key::Esc => {
1700            ed.vim.force_normal();
1701            return true;
1702        }
1703        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1704            ed.vim.visual_anchor = ed.cursor();
1705            ed.vim.mode = Mode::Visual;
1706            return true;
1707        }
1708        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Normal => {
1709            let (row, _) = ed.cursor();
1710            ed.vim.visual_line_anchor = row;
1711            ed.vim.mode = Mode::VisualLine;
1712            return true;
1713        }
1714        Key::Char('v') if !input.ctrl && ed.vim.mode == Mode::VisualLine => {
1715            ed.vim.visual_anchor = ed.cursor();
1716            ed.vim.mode = Mode::Visual;
1717            return true;
1718        }
1719        Key::Char('V') if !input.ctrl && ed.vim.mode == Mode::Visual => {
1720            let (row, _) = ed.cursor();
1721            ed.vim.visual_line_anchor = row;
1722            ed.vim.mode = Mode::VisualLine;
1723            return true;
1724        }
1725        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::Normal => {
1726            let cur = ed.cursor();
1727            ed.vim.block_anchor = cur;
1728            ed.vim.block_vcol = cur.1;
1729            ed.vim.mode = Mode::VisualBlock;
1730            return true;
1731        }
1732        Key::Char('v') if input.ctrl && ed.vim.mode == Mode::VisualBlock => {
1733            // Second Ctrl-v exits block mode back to Normal.
1734            ed.vim.mode = Mode::Normal;
1735            return true;
1736        }
1737        // `o` in visual modes — swap anchor and cursor so the user
1738        // can extend the other end of the selection.
1739        Key::Char('o') if !input.ctrl => match ed.vim.mode {
1740            Mode::Visual => {
1741                let cur = ed.cursor();
1742                let anchor = ed.vim.visual_anchor;
1743                ed.vim.visual_anchor = cur;
1744                ed.jump_cursor(anchor.0, anchor.1);
1745                return true;
1746            }
1747            Mode::VisualLine => {
1748                let cur_row = ed.cursor().0;
1749                let anchor_row = ed.vim.visual_line_anchor;
1750                ed.vim.visual_line_anchor = cur_row;
1751                ed.jump_cursor(anchor_row, 0);
1752                return true;
1753            }
1754            Mode::VisualBlock => {
1755                let cur = ed.cursor();
1756                let anchor = ed.vim.block_anchor;
1757                ed.vim.block_anchor = cur;
1758                ed.vim.block_vcol = anchor.1;
1759                ed.jump_cursor(anchor.0, anchor.1);
1760                return true;
1761            }
1762            _ => {}
1763        },
1764        _ => {}
1765    }
1766
1767    // Visual mode: operators act on the current selection.
1768    if ed.vim.is_visual()
1769        && let Some(op) = visual_operator(&input)
1770    {
1771        apply_visual_operator(ed, op);
1772        return true;
1773    }
1774
1775    // VisualBlock: extra commands beyond the standard y/d/c/x — `r`
1776    // replaces the block with a single char, `I` / `A` enter insert
1777    // mode at the block's left / right edge and repeat on every row.
1778    if ed.vim.mode == Mode::VisualBlock && !input.ctrl {
1779        match input.key {
1780            Key::Char('r') => {
1781                ed.vim.pending = Pending::Replace;
1782                return true;
1783            }
1784            Key::Char('I') => {
1785                let (top, bot, left, _right) = block_bounds(ed);
1786                ed.jump_cursor(top, left);
1787                ed.vim.mode = Mode::Normal;
1788                begin_insert(
1789                    ed,
1790                    1,
1791                    InsertReason::BlockEdge {
1792                        top,
1793                        bot,
1794                        col: left,
1795                    },
1796                );
1797                return true;
1798            }
1799            Key::Char('A') => {
1800                let (top, bot, _left, right) = block_bounds(ed);
1801                let line_len = buf_line_chars(&ed.buffer, top);
1802                let col = (right + 1).min(line_len);
1803                ed.jump_cursor(top, col);
1804                ed.vim.mode = Mode::Normal;
1805                begin_insert(ed, 1, InsertReason::BlockEdge { top, bot, col });
1806                return true;
1807            }
1808            _ => {}
1809        }
1810    }
1811
1812    // Visual mode: `i` / `a` start a text-object extension.
1813    if matches!(ed.vim.mode, Mode::Visual | Mode::VisualLine)
1814        && !input.ctrl
1815        && matches!(input.key, Key::Char('i') | Key::Char('a'))
1816    {
1817        let inner = matches!(input.key, Key::Char('i'));
1818        ed.vim.pending = Pending::VisualTextObj { inner };
1819        return true;
1820    }
1821
1822    // Ctrl-prefixed scrolling + misc. Vim semantics: Ctrl-d / Ctrl-u
1823    // move the cursor by half a window, Ctrl-f / Ctrl-b by a full
1824    // window. Viewport follows the cursor. Cursor lands on the first
1825    // non-blank of the target row (matches vim).
1826    if input.ctrl
1827        && let Key::Char(c) = input.key
1828    {
1829        match c {
1830            'd' => {
1831                scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
1832                return true;
1833            }
1834            'u' => {
1835                scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
1836                return true;
1837            }
1838            'f' => {
1839                scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
1840                return true;
1841            }
1842            'b' => {
1843                scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
1844                return true;
1845            }
1846            'r' => {
1847                do_redo(ed);
1848                return true;
1849            }
1850            'a' if ed.vim.mode == Mode::Normal => {
1851                adjust_number(ed, count.max(1) as i64);
1852                return true;
1853            }
1854            'x' if ed.vim.mode == Mode::Normal => {
1855                adjust_number(ed, -(count.max(1) as i64));
1856                return true;
1857            }
1858            'o' if ed.vim.mode == Mode::Normal => {
1859                for _ in 0..count.max(1) {
1860                    jump_back(ed);
1861                }
1862                return true;
1863            }
1864            'i' if ed.vim.mode == Mode::Normal => {
1865                for _ in 0..count.max(1) {
1866                    jump_forward(ed);
1867                }
1868                return true;
1869            }
1870            _ => {}
1871        }
1872    }
1873
1874    // `Tab` in normal mode is also `Ctrl-i` — vim aliases them.
1875    if !input.ctrl && input.key == Key::Tab && ed.vim.mode == Mode::Normal {
1876        for _ in 0..count.max(1) {
1877            jump_forward(ed);
1878        }
1879        return true;
1880    }
1881
1882    // Motion-only commands.
1883    if let Some(motion) = parse_motion(&input) {
1884        execute_motion(ed, motion.clone(), count);
1885        // Block mode: maintain the virtual column across j/k clamps.
1886        if ed.vim.mode == Mode::VisualBlock {
1887            update_block_vcol(ed, &motion);
1888        }
1889        if let Motion::Find { ch, forward, till } = motion {
1890            ed.vim.last_find = Some((ch, forward, till));
1891        }
1892        return true;
1893    }
1894
1895    // Mode transitions + pure normal-mode commands (not applicable in visual).
1896    if ed.vim.mode == Mode::Normal && handle_normal_only(ed, &input, count) {
1897        return true;
1898    }
1899
1900    // Operator triggers in normal mode.
1901    if ed.vim.mode == Mode::Normal
1902        && let Key::Char(op_ch) = input.key
1903        && !input.ctrl
1904        && let Some(op) = char_to_operator(op_ch)
1905    {
1906        ed.vim.pending = Pending::Op { op, count1: count };
1907        return true;
1908    }
1909
1910    // `f`/`F`/`t`/`T` entry.
1911    if ed.vim.mode == Mode::Normal
1912        && let Some((forward, till)) = find_entry(&input)
1913    {
1914        ed.vim.count = count;
1915        ed.vim.pending = Pending::Find { forward, till };
1916        return true;
1917    }
1918
1919    // `g` prefix.
1920    if !input.ctrl && input.key == Key::Char('g') && ed.vim.mode == Mode::Normal {
1921        ed.vim.count = count;
1922        ed.vim.pending = Pending::G;
1923        return true;
1924    }
1925
1926    // `z` prefix (zz / zt / zb — cursor-relative viewport scrolls).
1927    if !input.ctrl
1928        && input.key == Key::Char('z')
1929        && matches!(
1930            ed.vim.mode,
1931            Mode::Normal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock
1932        )
1933    {
1934        ed.vim.pending = Pending::Z;
1935        return true;
1936    }
1937
1938    // Mark set / jump entries. `m` arms the set-mark pending state;
1939    // `'` and `` ` `` arm the goto states (linewise vs charwise). The
1940    // mark letter is consumed on the next keystroke.
1941    if !input.ctrl && ed.vim.mode == Mode::Normal {
1942        match input.key {
1943            Key::Char('m') => {
1944                ed.vim.pending = Pending::SetMark;
1945                return true;
1946            }
1947            Key::Char('\'') => {
1948                ed.vim.pending = Pending::GotoMarkLine;
1949                return true;
1950            }
1951            Key::Char('`') => {
1952                ed.vim.pending = Pending::GotoMarkChar;
1953                return true;
1954            }
1955            Key::Char('"') => {
1956                // Open the register-selector chord. The next char picks
1957                // a register that the next y/d/c/p uses.
1958                ed.vim.pending = Pending::SelectRegister;
1959                return true;
1960            }
1961            Key::Char('@') => {
1962                // Open the macro-play chord. Next char names the
1963                // register; `@@` re-plays the last-played macro.
1964                // Stash any count so the chord can multiply replays.
1965                ed.vim.pending = Pending::PlayMacroTarget { count };
1966                return true;
1967            }
1968            Key::Char('q') if ed.vim.recording_macro.is_none() => {
1969                // Open the macro-record chord. The bare-q stop is
1970                // handled at the top of `step` so it's not consumed
1971                // as another open. Recording-in-progress falls through
1972                // here and is treated as a no-op (matches vim).
1973                ed.vim.pending = Pending::RecordMacroTarget;
1974                return true;
1975            }
1976            _ => {}
1977        }
1978    }
1979
1980    // Unknown key — swallow so it doesn't bubble into the TUI layer.
1981    true
1982}
1983
1984fn handle_set_mark<H: crate::types::Host>(
1985    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1986    input: Input,
1987) -> bool {
1988    if let Key::Char(c) = input.key
1989        && (c.is_ascii_lowercase() || c.is_ascii_uppercase())
1990    {
1991        // 0.0.36: lowercase + uppercase marks share the unified
1992        // `Editor::marks` map. Uppercase entries survive
1993        // `set_content` so they persist across tab swaps within the
1994        // same Editor (the map lives on the Editor, not the buffer).
1995        let pos = ed.cursor();
1996        ed.set_mark(c, pos);
1997    }
1998    true
1999}
2000
2001/// `"reg` — store the register selector for the next y / d / c / p.
2002/// Accepts `a`–`z`, `A`–`Z`, `0`–`9`, `"`, and the system-clipboard
2003/// selectors `+` / `*`. Anything else cancels silently.
2004fn handle_select_register<H: crate::types::Host>(
2005    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2006    input: Input,
2007) -> bool {
2008    if let Key::Char(c) = input.key
2009        && (c.is_ascii_alphanumeric() || matches!(c, '"' | '+' | '*'))
2010    {
2011        ed.vim.pending_register = Some(c);
2012    }
2013    true
2014}
2015
2016/// `q{reg}` — start recording into `reg`. The recording session
2017/// captures every consumed `Input` until a bare `q` ends it (handled
2018/// inline at the top of `step`). Capital letters append to the
2019/// matching lowercase register, mirroring named-register semantics.
2020fn handle_record_macro_target<H: crate::types::Host>(
2021    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2022    input: Input,
2023) -> bool {
2024    if let Key::Char(c) = input.key
2025        && (c.is_ascii_alphabetic() || c.is_ascii_digit())
2026    {
2027        ed.vim.recording_macro = Some(c);
2028        // For `qA` (capital), seed the buffer with the existing
2029        // lowercase recording so the new keystrokes append.
2030        if c.is_ascii_uppercase() {
2031            let lower = c.to_ascii_lowercase();
2032            // Seed `recording_keys` with the existing register's text
2033            // decoded back to inputs, so capital-register append
2034            // continues from where the previous recording left off.
2035            let text = ed
2036                .registers()
2037                .read(lower)
2038                .map(|s| s.text.clone())
2039                .unwrap_or_default();
2040            ed.vim.recording_keys = crate::input::decode_macro(&text);
2041        } else {
2042            ed.vim.recording_keys.clear();
2043        }
2044    }
2045    true
2046}
2047
2048/// `@{reg}` — replay the macro recorded under `reg`. `@@` re-plays
2049/// the last-played macro. The replay re-feeds each captured `Input`
2050/// through `step`, with `replaying_macro` flagged so the recorder
2051/// (if active) doesn't double-capture. Honours the count prefix:
2052/// `3@a` plays the macro three times.
2053fn handle_play_macro_target<H: crate::types::Host>(
2054    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2055    input: Input,
2056    count: usize,
2057) -> bool {
2058    let reg = match input.key {
2059        Key::Char('@') => ed.vim.last_macro,
2060        Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
2061            Some(c.to_ascii_lowercase())
2062        }
2063        _ => None,
2064    };
2065    let Some(reg) = reg else {
2066        return true;
2067    };
2068    // Read the macro text from the named register and decode back to
2069    // an Input stream. Empty / unset registers replay nothing.
2070    let text = match ed.registers().read(reg) {
2071        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
2072        _ => return true,
2073    };
2074    let keys = crate::input::decode_macro(&text);
2075    ed.vim.last_macro = Some(reg);
2076    let times = count.max(1);
2077    let was_replaying = ed.vim.replaying_macro;
2078    ed.vim.replaying_macro = true;
2079    for _ in 0..times {
2080        for k in keys.iter().copied() {
2081            step(ed, k);
2082        }
2083    }
2084    ed.vim.replaying_macro = was_replaying;
2085    true
2086}
2087
2088fn handle_goto_mark<H: crate::types::Host>(
2089    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2090    input: Input,
2091    linewise: bool,
2092) -> bool {
2093    let Key::Char(c) = input.key else {
2094        return true;
2095    };
2096    // Resolve the mark target. Lowercase letters look up the user
2097    // marks set via `m{a..z}`; the special chars below come from
2098    // automatic state vim maintains:
2099    //   `'` / `` ` `` — position before the most recent big jump
2100    //                  (peeks `jump_back` without popping).
2101    //   `.`           — the last edit's position.
2102    let target = match c {
2103        'a'..='z' | 'A'..='Z' => ed.mark(c),
2104        '\'' | '`' => ed.vim.jump_back.last().copied(),
2105        '.' => ed.vim.last_edit_pos,
2106        _ => None,
2107    };
2108    let Some((row, col)) = target else {
2109        return true;
2110    };
2111    let pre = ed.cursor();
2112    let (r, c_clamped) = clamp_pos(ed, (row, col));
2113    if linewise {
2114        buf_set_cursor_rc(&mut ed.buffer, r, 0);
2115        ed.push_buffer_cursor_to_textarea();
2116        move_first_non_whitespace(ed);
2117    } else {
2118        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
2119        ed.push_buffer_cursor_to_textarea();
2120    }
2121    if ed.cursor() != pre {
2122        push_jump(ed, pre);
2123    }
2124    ed.sticky_col = Some(ed.cursor().1);
2125    true
2126}
2127
2128fn take_count(vim: &mut VimState) -> usize {
2129    if vim.count > 0 {
2130        let n = vim.count;
2131        vim.count = 0;
2132        n
2133    } else {
2134        1
2135    }
2136}
2137
2138fn char_to_operator(c: char) -> Option<Operator> {
2139    match c {
2140        'd' => Some(Operator::Delete),
2141        'c' => Some(Operator::Change),
2142        'y' => Some(Operator::Yank),
2143        '>' => Some(Operator::Indent),
2144        '<' => Some(Operator::Outdent),
2145        _ => None,
2146    }
2147}
2148
2149fn visual_operator(input: &Input) -> Option<Operator> {
2150    if input.ctrl {
2151        return None;
2152    }
2153    match input.key {
2154        Key::Char('y') => Some(Operator::Yank),
2155        Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
2156        Key::Char('c') | Key::Char('s') => Some(Operator::Change),
2157        // Case operators — shift forms apply to the active selection.
2158        Key::Char('U') => Some(Operator::Uppercase),
2159        Key::Char('u') => Some(Operator::Lowercase),
2160        Key::Char('~') => Some(Operator::ToggleCase),
2161        // Indent operators on selection.
2162        Key::Char('>') => Some(Operator::Indent),
2163        Key::Char('<') => Some(Operator::Outdent),
2164        _ => None,
2165    }
2166}
2167
2168fn find_entry(input: &Input) -> Option<(bool, bool)> {
2169    if input.ctrl {
2170        return None;
2171    }
2172    match input.key {
2173        Key::Char('f') => Some((true, false)),
2174        Key::Char('F') => Some((false, false)),
2175        Key::Char('t') => Some((true, true)),
2176        Key::Char('T') => Some((false, true)),
2177        _ => None,
2178    }
2179}
2180
2181// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
2182
2183/// Max jumplist depth. Matches vim default.
2184const JUMPLIST_MAX: usize = 100;
2185
2186/// Record a pre-jump cursor position. Called *before* a big-jump
2187/// motion runs (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, `/`?
2188/// commit, `:{nr}`). Making a new jump while the forward stack had
2189/// entries trims them — branching off the history clears the "redo".
2190fn push_jump<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, from: (usize, usize)) {
2191    ed.vim.jump_back.push(from);
2192    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2193        ed.vim.jump_back.remove(0);
2194    }
2195    ed.vim.jump_fwd.clear();
2196}
2197
2198/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
2199/// the current cursor onto the forward stack so `Ctrl-i` can return.
2200fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2201    let Some(target) = ed.vim.jump_back.pop() else {
2202        return;
2203    };
2204    let cur = ed.cursor();
2205    ed.vim.jump_fwd.push(cur);
2206    let (r, c) = clamp_pos(ed, target);
2207    ed.jump_cursor(r, c);
2208    ed.sticky_col = Some(c);
2209}
2210
2211/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
2212/// onto the back stack.
2213fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2214    let Some(target) = ed.vim.jump_fwd.pop() else {
2215        return;
2216    };
2217    let cur = ed.cursor();
2218    ed.vim.jump_back.push(cur);
2219    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2220        ed.vim.jump_back.remove(0);
2221    }
2222    let (r, c) = clamp_pos(ed, target);
2223    ed.jump_cursor(r, c);
2224    ed.sticky_col = Some(c);
2225}
2226
2227/// Clamp a stored `(row, col)` to the live buffer in case edits
2228/// shrunk the document between push and pop.
2229fn clamp_pos<H: crate::types::Host>(
2230    ed: &Editor<hjkl_buffer::Buffer, H>,
2231    pos: (usize, usize),
2232) -> (usize, usize) {
2233    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2234    let r = pos.0.min(last_row);
2235    let line_len = buf_line_chars(&ed.buffer, r);
2236    let c = pos.1.min(line_len.saturating_sub(1));
2237    (r, c)
2238}
2239
2240/// True for motions that vim treats as jumps (pushed onto the jumplist).
2241fn is_big_jump(motion: &Motion) -> bool {
2242    matches!(
2243        motion,
2244        Motion::FileTop
2245            | Motion::FileBottom
2246            | Motion::MatchBracket
2247            | Motion::WordAtCursor { .. }
2248            | Motion::SearchNext { .. }
2249            | Motion::ViewportTop
2250            | Motion::ViewportMiddle
2251            | Motion::ViewportBottom
2252    )
2253}
2254
2255// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
2256
2257/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
2258/// viewports still step by a single row. `count` multiplies.
2259fn viewport_half_rows<H: crate::types::Host>(
2260    ed: &Editor<hjkl_buffer::Buffer, H>,
2261    count: usize,
2262) -> usize {
2263    let h = ed.viewport_height_value() as usize;
2264    (h / 2).max(1).saturating_mul(count.max(1))
2265}
2266
2267/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
2268/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
2269fn viewport_full_rows<H: crate::types::Host>(
2270    ed: &Editor<hjkl_buffer::Buffer, H>,
2271    count: usize,
2272) -> usize {
2273    let h = ed.viewport_height_value() as usize;
2274    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
2275}
2276
2277/// Move the cursor by `delta` rows (positive = down, negative = up),
2278/// clamp to the document, then land at the first non-blank on the new
2279/// row. The textarea viewport auto-scrolls to keep the cursor visible
2280/// when the cursor pushes off-screen.
2281fn scroll_cursor_rows<H: crate::types::Host>(
2282    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2283    delta: isize,
2284) {
2285    if delta == 0 {
2286        return;
2287    }
2288    ed.sync_buffer_content_from_textarea();
2289    let (row, _) = ed.cursor();
2290    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2291    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
2292    buf_set_cursor_rc(&mut ed.buffer, target, 0);
2293    crate::motions::move_first_non_blank(&mut ed.buffer);
2294    ed.push_buffer_cursor_to_textarea();
2295    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
2296}
2297
2298// ─── Motion parsing ────────────────────────────────────────────────────────
2299
2300fn parse_motion(input: &Input) -> Option<Motion> {
2301    if input.ctrl {
2302        return None;
2303    }
2304    match input.key {
2305        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
2306        Key::Char('l') | Key::Right => Some(Motion::Right),
2307        Key::Char('j') | Key::Down | Key::Enter => Some(Motion::Down),
2308        Key::Char('k') | Key::Up => Some(Motion::Up),
2309        Key::Char('w') => Some(Motion::WordFwd),
2310        Key::Char('W') => Some(Motion::BigWordFwd),
2311        Key::Char('b') => Some(Motion::WordBack),
2312        Key::Char('B') => Some(Motion::BigWordBack),
2313        Key::Char('e') => Some(Motion::WordEnd),
2314        Key::Char('E') => Some(Motion::BigWordEnd),
2315        Key::Char('0') | Key::Home => Some(Motion::LineStart),
2316        Key::Char('^') => Some(Motion::FirstNonBlank),
2317        Key::Char('$') | Key::End => Some(Motion::LineEnd),
2318        Key::Char('G') => Some(Motion::FileBottom),
2319        Key::Char('%') => Some(Motion::MatchBracket),
2320        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
2321        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
2322        Key::Char('*') => Some(Motion::WordAtCursor {
2323            forward: true,
2324            whole_word: true,
2325        }),
2326        Key::Char('#') => Some(Motion::WordAtCursor {
2327            forward: false,
2328            whole_word: true,
2329        }),
2330        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
2331        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
2332        Key::Char('H') => Some(Motion::ViewportTop),
2333        Key::Char('M') => Some(Motion::ViewportMiddle),
2334        Key::Char('L') => Some(Motion::ViewportBottom),
2335        Key::Char('{') => Some(Motion::ParagraphPrev),
2336        Key::Char('}') => Some(Motion::ParagraphNext),
2337        Key::Char('(') => Some(Motion::SentencePrev),
2338        Key::Char(')') => Some(Motion::SentenceNext),
2339        _ => None,
2340    }
2341}
2342
2343// ─── Motion execution ──────────────────────────────────────────────────────
2344
2345fn execute_motion<H: crate::types::Host>(
2346    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2347    motion: Motion,
2348    count: usize,
2349) {
2350    let count = count.max(1);
2351    // FindRepeat needs the stored direction.
2352    let motion = match motion {
2353        Motion::FindRepeat { reverse } => match ed.vim.last_find {
2354            Some((ch, forward, till)) => Motion::Find {
2355                ch,
2356                forward: if reverse { !forward } else { forward },
2357                till,
2358            },
2359            None => return,
2360        },
2361        other => other,
2362    };
2363    let pre_pos = ed.cursor();
2364    let pre_col = pre_pos.1;
2365    apply_motion_cursor(ed, &motion, count);
2366    let post_pos = ed.cursor();
2367    if is_big_jump(&motion) && pre_pos != post_pos {
2368        push_jump(ed, pre_pos);
2369    }
2370    apply_sticky_col(ed, &motion, pre_col);
2371    // Phase 7b: keep the migration buffer's cursor + viewport in
2372    // lockstep with the textarea after every motion. Once 7c lands
2373    // (motions ported onto the buffer's API), this flips: the
2374    // buffer becomes authoritative and the textarea mirrors it.
2375    ed.sync_buffer_from_textarea();
2376}
2377
2378/// Restore the cursor to the sticky column after vertical motions and
2379/// sync the sticky column to the current column after horizontal ones.
2380/// `pre_col` is the cursor column captured *before* the motion — used
2381/// to bootstrap the sticky value on the very first motion.
2382fn apply_sticky_col<H: crate::types::Host>(
2383    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2384    motion: &Motion,
2385    pre_col: usize,
2386) {
2387    if is_vertical_motion(motion) {
2388        let want = ed.sticky_col.unwrap_or(pre_col);
2389        // Record the desired column so the next vertical motion sees
2390        // it even if we currently clamped to a shorter row.
2391        ed.sticky_col = Some(want);
2392        let (row, _) = ed.cursor();
2393        let line_len = buf_line_chars(&ed.buffer, row);
2394        // Clamp to the last char on non-empty lines (vim normal-mode
2395        // never parks the cursor one past end of line). Empty lines
2396        // collapse to col 0.
2397        let max_col = line_len.saturating_sub(1);
2398        let target = want.min(max_col);
2399        ed.jump_cursor(row, target);
2400    } else {
2401        // Horizontal motion or non-motion: sticky column tracks the
2402        // new cursor column so the *next* vertical motion aims there.
2403        ed.sticky_col = Some(ed.cursor().1);
2404    }
2405}
2406
2407fn is_vertical_motion(motion: &Motion) -> bool {
2408    // Only j / k preserve the sticky column. Everything else (search,
2409    // gg / G, word jumps, etc.) lands at the match's own column so the
2410    // sticky value should sync to the new cursor column.
2411    matches!(
2412        motion,
2413        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
2414    )
2415}
2416
2417fn apply_motion_cursor<H: crate::types::Host>(
2418    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2419    motion: &Motion,
2420    count: usize,
2421) {
2422    apply_motion_cursor_ctx(ed, motion, count, false)
2423}
2424
2425fn apply_motion_cursor_ctx<H: crate::types::Host>(
2426    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2427    motion: &Motion,
2428    count: usize,
2429    as_operator: bool,
2430) {
2431    match motion {
2432        Motion::Left => {
2433            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
2434            crate::motions::move_left(&mut ed.buffer, count);
2435            ed.push_buffer_cursor_to_textarea();
2436        }
2437        Motion::Right => {
2438            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
2439            // one past the last char so the range includes it; cursor
2440            // context clamps at the last char.
2441            if as_operator {
2442                crate::motions::move_right_to_end(&mut ed.buffer, count);
2443            } else {
2444                crate::motions::move_right_in_line(&mut ed.buffer, count);
2445            }
2446            ed.push_buffer_cursor_to_textarea();
2447        }
2448        Motion::Up => {
2449            // Final col is set by `apply_sticky_col` below — push the
2450            // post-move row to the textarea and let sticky tracking
2451            // finish the work.
2452            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2453            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2454            ed.push_buffer_cursor_to_textarea();
2455        }
2456        Motion::Down => {
2457            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2458            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2459            ed.push_buffer_cursor_to_textarea();
2460        }
2461        Motion::ScreenUp => {
2462            let v = *ed.host.viewport();
2463            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2464            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2465            ed.push_buffer_cursor_to_textarea();
2466        }
2467        Motion::ScreenDown => {
2468            let v = *ed.host.viewport();
2469            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2470            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2471            ed.push_buffer_cursor_to_textarea();
2472        }
2473        Motion::WordFwd => {
2474            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2475            ed.push_buffer_cursor_to_textarea();
2476        }
2477        Motion::WordBack => {
2478            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2479            ed.push_buffer_cursor_to_textarea();
2480        }
2481        Motion::WordEnd => {
2482            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2483            ed.push_buffer_cursor_to_textarea();
2484        }
2485        Motion::BigWordFwd => {
2486            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2487            ed.push_buffer_cursor_to_textarea();
2488        }
2489        Motion::BigWordBack => {
2490            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2491            ed.push_buffer_cursor_to_textarea();
2492        }
2493        Motion::BigWordEnd => {
2494            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2495            ed.push_buffer_cursor_to_textarea();
2496        }
2497        Motion::WordEndBack => {
2498            crate::motions::move_word_end_back(
2499                &mut ed.buffer,
2500                false,
2501                count,
2502                &ed.settings.iskeyword,
2503            );
2504            ed.push_buffer_cursor_to_textarea();
2505        }
2506        Motion::BigWordEndBack => {
2507            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2508            ed.push_buffer_cursor_to_textarea();
2509        }
2510        Motion::LineStart => {
2511            crate::motions::move_line_start(&mut ed.buffer);
2512            ed.push_buffer_cursor_to_textarea();
2513        }
2514        Motion::FirstNonBlank => {
2515            crate::motions::move_first_non_blank(&mut ed.buffer);
2516            ed.push_buffer_cursor_to_textarea();
2517        }
2518        Motion::LineEnd => {
2519            // Vim normal-mode `$` lands on the last char, not one past it.
2520            crate::motions::move_line_end(&mut ed.buffer);
2521            ed.push_buffer_cursor_to_textarea();
2522        }
2523        Motion::FileTop => {
2524            // `count gg` jumps to line `count` (first non-blank);
2525            // bare `gg` lands at the top.
2526            if count > 1 {
2527                crate::motions::move_bottom(&mut ed.buffer, count);
2528            } else {
2529                crate::motions::move_top(&mut ed.buffer);
2530            }
2531            ed.push_buffer_cursor_to_textarea();
2532        }
2533        Motion::FileBottom => {
2534            // `count G` jumps to line `count`; bare `G` lands at
2535            // the buffer bottom (`Buffer::move_bottom(0)`).
2536            if count > 1 {
2537                crate::motions::move_bottom(&mut ed.buffer, count);
2538            } else {
2539                crate::motions::move_bottom(&mut ed.buffer, 0);
2540            }
2541            ed.push_buffer_cursor_to_textarea();
2542        }
2543        Motion::Find { ch, forward, till } => {
2544            for _ in 0..count {
2545                if !find_char_on_line(ed, *ch, *forward, *till) {
2546                    break;
2547                }
2548            }
2549        }
2550        Motion::FindRepeat { .. } => {} // already resolved upstream
2551        Motion::MatchBracket => {
2552            let _ = matching_bracket(ed);
2553        }
2554        Motion::WordAtCursor {
2555            forward,
2556            whole_word,
2557        } => {
2558            word_at_cursor_search(ed, *forward, *whole_word, count);
2559        }
2560        Motion::SearchNext { reverse } => {
2561            // Re-push the last query so the buffer's search state is
2562            // correct even if the host happened to clear it (e.g. while
2563            // a Visual mode draw was in progress).
2564            if let Some(pattern) = ed.vim.last_search.clone() {
2565                push_search_pattern(ed, &pattern);
2566            }
2567            if ed.search_state().pattern.is_none() {
2568                return;
2569            }
2570            // `n` repeats the last search in its committed direction;
2571            // `N` inverts. So a `?` search makes `n` walk backward and
2572            // `N` walk forward.
2573            let forward = ed.vim.last_search_forward != *reverse;
2574            for _ in 0..count.max(1) {
2575                if forward {
2576                    ed.search_advance_forward(true);
2577                } else {
2578                    ed.search_advance_backward(true);
2579                }
2580            }
2581            ed.push_buffer_cursor_to_textarea();
2582        }
2583        Motion::ViewportTop => {
2584            let v = *ed.host().viewport();
2585            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
2586            ed.push_buffer_cursor_to_textarea();
2587        }
2588        Motion::ViewportMiddle => {
2589            let v = *ed.host().viewport();
2590            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
2591            ed.push_buffer_cursor_to_textarea();
2592        }
2593        Motion::ViewportBottom => {
2594            let v = *ed.host().viewport();
2595            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
2596            ed.push_buffer_cursor_to_textarea();
2597        }
2598        Motion::LastNonBlank => {
2599            crate::motions::move_last_non_blank(&mut ed.buffer);
2600            ed.push_buffer_cursor_to_textarea();
2601        }
2602        Motion::LineMiddle => {
2603            let row = ed.cursor().0;
2604            let line_chars = buf_line_chars(&ed.buffer, row);
2605            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
2606            // lines stay at col 0.
2607            let target = line_chars / 2;
2608            ed.jump_cursor(row, target);
2609        }
2610        Motion::ParagraphPrev => {
2611            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
2612            ed.push_buffer_cursor_to_textarea();
2613        }
2614        Motion::ParagraphNext => {
2615            crate::motions::move_paragraph_next(&mut ed.buffer, count);
2616            ed.push_buffer_cursor_to_textarea();
2617        }
2618        Motion::SentencePrev => {
2619            for _ in 0..count.max(1) {
2620                if let Some((row, col)) = sentence_boundary(ed, false) {
2621                    ed.jump_cursor(row, col);
2622                }
2623            }
2624        }
2625        Motion::SentenceNext => {
2626            for _ in 0..count.max(1) {
2627                if let Some((row, col)) = sentence_boundary(ed, true) {
2628                    ed.jump_cursor(row, col);
2629                }
2630            }
2631        }
2632    }
2633}
2634
2635fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2636    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
2637    // mutates the textarea content, so the migration buffer hasn't
2638    // seen the new lines OR new cursor yet. Mirror the full content
2639    // across before delegating, then push the result back so the
2640    // textarea reflects the resolved column too.
2641    ed.sync_buffer_content_from_textarea();
2642    crate::motions::move_first_non_blank(&mut ed.buffer);
2643    ed.push_buffer_cursor_to_textarea();
2644}
2645
2646fn find_char_on_line<H: crate::types::Host>(
2647    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2648    ch: char,
2649    forward: bool,
2650    till: bool,
2651) -> bool {
2652    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
2653    if moved {
2654        ed.push_buffer_cursor_to_textarea();
2655    }
2656    moved
2657}
2658
2659fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
2660    let moved = crate::motions::match_bracket(&mut ed.buffer);
2661    if moved {
2662        ed.push_buffer_cursor_to_textarea();
2663    }
2664    moved
2665}
2666
2667fn word_at_cursor_search<H: crate::types::Host>(
2668    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2669    forward: bool,
2670    whole_word: bool,
2671    count: usize,
2672) {
2673    let (row, col) = ed.cursor();
2674    let line: String = buf_line(&ed.buffer, row).unwrap_or("").to_string();
2675    let chars: Vec<char> = line.chars().collect();
2676    if chars.is_empty() {
2677        return;
2678    }
2679    // Expand around cursor to a word boundary.
2680    let spec = ed.settings().iskeyword.clone();
2681    let is_word = |c: char| is_keyword_char(c, &spec);
2682    let mut start = col.min(chars.len().saturating_sub(1));
2683    while start > 0 && is_word(chars[start - 1]) {
2684        start -= 1;
2685    }
2686    let mut end = start;
2687    while end < chars.len() && is_word(chars[end]) {
2688        end += 1;
2689    }
2690    if end <= start {
2691        return;
2692    }
2693    let word: String = chars[start..end].iter().collect();
2694    let escaped = regex_escape(&word);
2695    let pattern = if whole_word {
2696        format!(r"\b{escaped}\b")
2697    } else {
2698        escaped
2699    };
2700    push_search_pattern(ed, &pattern);
2701    if ed.search_state().pattern.is_none() {
2702        return;
2703    }
2704    // Remember the query so `n` / `N` keep working after the jump.
2705    ed.vim.last_search = Some(pattern);
2706    ed.vim.last_search_forward = forward;
2707    for _ in 0..count.max(1) {
2708        if forward {
2709            ed.search_advance_forward(true);
2710        } else {
2711            ed.search_advance_backward(true);
2712        }
2713    }
2714    ed.push_buffer_cursor_to_textarea();
2715}
2716
2717fn regex_escape(s: &str) -> String {
2718    let mut out = String::with_capacity(s.len());
2719    for c in s.chars() {
2720        if matches!(
2721            c,
2722            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
2723        ) {
2724            out.push('\\');
2725        }
2726        out.push(c);
2727    }
2728    out
2729}
2730
2731// ─── Operator application ──────────────────────────────────────────────────
2732
2733fn handle_after_op<H: crate::types::Host>(
2734    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2735    input: Input,
2736    op: Operator,
2737    count1: usize,
2738) -> bool {
2739    // Inner count after operator (e.g. d3w): accumulate in state.count.
2740    if let Key::Char(d @ '0'..='9') = input.key
2741        && !input.ctrl
2742        && (d != '0' || ed.vim.count > 0)
2743    {
2744        ed.vim.count = ed.vim.count.saturating_mul(10) + (d as usize - '0' as usize);
2745        ed.vim.pending = Pending::Op { op, count1 };
2746        return true;
2747    }
2748
2749    // Esc cancels.
2750    if input.key == Key::Esc {
2751        ed.vim.count = 0;
2752        return true;
2753    }
2754
2755    // Same-letter: dd / cc / yy / gUU / guu / g~~ / >> / <<. Fold has
2756    // no doubled form in vim — `zfzf` is two `zf` chords, not a line
2757    // op — so skip the branch entirely.
2758    let double_ch = match op {
2759        Operator::Delete => Some('d'),
2760        Operator::Change => Some('c'),
2761        Operator::Yank => Some('y'),
2762        Operator::Indent => Some('>'),
2763        Operator::Outdent => Some('<'),
2764        Operator::Uppercase => Some('U'),
2765        Operator::Lowercase => Some('u'),
2766        Operator::ToggleCase => Some('~'),
2767        Operator::Fold => None,
2768        // `gqq` reflows the current line — vim's doubled form for the
2769        // reflow operator is the second `q` after `gq`.
2770        Operator::Reflow => Some('q'),
2771    };
2772    if let Key::Char(c) = input.key
2773        && !input.ctrl
2774        && Some(c) == double_ch
2775    {
2776        let count2 = take_count(&mut ed.vim);
2777        let total = count1.max(1) * count2.max(1);
2778        execute_line_op(ed, op, total);
2779        if !ed.vim.replaying {
2780            ed.vim.last_change = Some(LastChange::LineOp {
2781                op,
2782                count: total,
2783                inserted: None,
2784            });
2785        }
2786        return true;
2787    }
2788
2789    // Text object: `i` or `a`.
2790    if let Key::Char('i') | Key::Char('a') = input.key
2791        && !input.ctrl
2792    {
2793        let inner = matches!(input.key, Key::Char('i'));
2794        ed.vim.pending = Pending::OpTextObj { op, count1, inner };
2795        return true;
2796    }
2797
2798    // `g` — awaiting `g` for `gg`.
2799    if input.key == Key::Char('g') && !input.ctrl {
2800        ed.vim.pending = Pending::OpG { op, count1 };
2801        return true;
2802    }
2803
2804    // `f`/`F`/`t`/`T` with pending target.
2805    if let Some((forward, till)) = find_entry(&input) {
2806        ed.vim.pending = Pending::OpFind {
2807            op,
2808            count1,
2809            forward,
2810            till,
2811        };
2812        return true;
2813    }
2814
2815    // Motion.
2816    let count2 = take_count(&mut ed.vim);
2817    let total = count1.max(1) * count2.max(1);
2818    if let Some(motion) = parse_motion(&input) {
2819        let motion = match motion {
2820            Motion::FindRepeat { reverse } => match ed.vim.last_find {
2821                Some((ch, forward, till)) => Motion::Find {
2822                    ch,
2823                    forward: if reverse { !forward } else { forward },
2824                    till,
2825                },
2826                None => return true,
2827            },
2828            // Vim quirk: `cw` / `cW` are `ce` / `cE` — don't include
2829            // trailing whitespace so the user's replacement text lands
2830            // before the following word's leading space.
2831            Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
2832            Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
2833            m => m,
2834        };
2835        apply_op_with_motion(ed, op, &motion, total);
2836        if let Motion::Find { ch, forward, till } = &motion {
2837            ed.vim.last_find = Some((*ch, *forward, *till));
2838        }
2839        if !ed.vim.replaying && op_is_change(op) {
2840            ed.vim.last_change = Some(LastChange::OpMotion {
2841                op,
2842                motion,
2843                count: total,
2844                inserted: None,
2845            });
2846        }
2847        return true;
2848    }
2849
2850    // Unknown — cancel the operator.
2851    true
2852}
2853
2854fn handle_op_after_g<H: crate::types::Host>(
2855    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2856    input: Input,
2857    op: Operator,
2858    count1: usize,
2859) -> bool {
2860    if input.ctrl {
2861        return true;
2862    }
2863    let count2 = take_count(&mut ed.vim);
2864    let total = count1.max(1) * count2.max(1);
2865    // Case-op linewise form: `gUgU`, `gugu`, `g~g~` — same effect as
2866    // `gUU` / `guu` / `g~~`. The leading `g` was consumed into
2867    // `Pending::OpG`, so here we see the trailing U / u / ~.
2868    if matches!(
2869        op,
2870        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
2871    ) {
2872        let op_char = match op {
2873            Operator::Uppercase => 'U',
2874            Operator::Lowercase => 'u',
2875            Operator::ToggleCase => '~',
2876            _ => unreachable!(),
2877        };
2878        if input.key == Key::Char(op_char) {
2879            execute_line_op(ed, op, total);
2880            if !ed.vim.replaying {
2881                ed.vim.last_change = Some(LastChange::LineOp {
2882                    op,
2883                    count: total,
2884                    inserted: None,
2885                });
2886            }
2887            return true;
2888        }
2889    }
2890    let motion = match input.key {
2891        Key::Char('g') => Motion::FileTop,
2892        Key::Char('e') => Motion::WordEndBack,
2893        Key::Char('E') => Motion::BigWordEndBack,
2894        Key::Char('j') => Motion::ScreenDown,
2895        Key::Char('k') => Motion::ScreenUp,
2896        _ => return true,
2897    };
2898    apply_op_with_motion(ed, op, &motion, total);
2899    if !ed.vim.replaying && op_is_change(op) {
2900        ed.vim.last_change = Some(LastChange::OpMotion {
2901            op,
2902            motion,
2903            count: total,
2904            inserted: None,
2905        });
2906    }
2907    true
2908}
2909
2910fn handle_after_g<H: crate::types::Host>(
2911    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2912    input: Input,
2913) -> bool {
2914    let count = take_count(&mut ed.vim);
2915    match input.key {
2916        Key::Char('g') => {
2917            // gg — top / jump to line count.
2918            let pre = ed.cursor();
2919            if count > 1 {
2920                ed.jump_cursor(count - 1, 0);
2921            } else {
2922                ed.jump_cursor(0, 0);
2923            }
2924            move_first_non_whitespace(ed);
2925            if ed.cursor() != pre {
2926                push_jump(ed, pre);
2927            }
2928        }
2929        Key::Char('e') => execute_motion(ed, Motion::WordEndBack, count),
2930        Key::Char('E') => execute_motion(ed, Motion::BigWordEndBack, count),
2931        // `g_` — last non-blank on the line.
2932        Key::Char('_') => execute_motion(ed, Motion::LastNonBlank, count),
2933        // `gM` — middle char column of the current line.
2934        Key::Char('M') => execute_motion(ed, Motion::LineMiddle, count),
2935        // `gv` — re-enter the last visual selection.
2936        Key::Char('v') => {
2937            if let Some(snap) = ed.vim.last_visual {
2938                match snap.mode {
2939                    Mode::Visual => {
2940                        ed.vim.visual_anchor = snap.anchor;
2941                        ed.vim.mode = Mode::Visual;
2942                    }
2943                    Mode::VisualLine => {
2944                        ed.vim.visual_line_anchor = snap.anchor.0;
2945                        ed.vim.mode = Mode::VisualLine;
2946                    }
2947                    Mode::VisualBlock => {
2948                        ed.vim.block_anchor = snap.anchor;
2949                        ed.vim.block_vcol = snap.block_vcol;
2950                        ed.vim.mode = Mode::VisualBlock;
2951                    }
2952                    _ => {}
2953                }
2954                ed.jump_cursor(snap.cursor.0, snap.cursor.1);
2955            }
2956        }
2957        // `gj` / `gk` — display-line down / up. Walks one screen
2958        // segment at a time under `:set wrap`; falls back to `j`/`k`
2959        // when wrap is off (Buffer::move_screen_* handles the branch).
2960        Key::Char('j') => execute_motion(ed, Motion::ScreenDown, count),
2961        Key::Char('k') => execute_motion(ed, Motion::ScreenUp, count),
2962        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
2963        // so the next input is treated as the motion / text object /
2964        // shorthand double (`gUU`, `guu`, `g~~`).
2965        Key::Char('U') => {
2966            ed.vim.pending = Pending::Op {
2967                op: Operator::Uppercase,
2968                count1: count,
2969            };
2970        }
2971        Key::Char('u') => {
2972            ed.vim.pending = Pending::Op {
2973                op: Operator::Lowercase,
2974                count1: count,
2975            };
2976        }
2977        Key::Char('~') => {
2978            ed.vim.pending = Pending::Op {
2979                op: Operator::ToggleCase,
2980                count1: count,
2981            };
2982        }
2983        Key::Char('q') => {
2984            // `gq{motion}` — text reflow operator. Subsequent motion
2985            // / textobj rides the same operator pipeline.
2986            ed.vim.pending = Pending::Op {
2987                op: Operator::Reflow,
2988                count1: count,
2989            };
2990        }
2991        Key::Char('J') => {
2992            // `gJ` — join line below without inserting a space.
2993            for _ in 0..count.max(1) {
2994                ed.push_undo();
2995                join_line_raw(ed);
2996            }
2997            if !ed.vim.replaying {
2998                ed.vim.last_change = Some(LastChange::JoinLine {
2999                    count: count.max(1),
3000                });
3001            }
3002        }
3003        Key::Char('d') => {
3004            // `gd` — goto definition. hjkl-engine doesn't run an LSP
3005            // itself; raise an intent the host drains and routes to
3006            // `sqls`. The cursor stays put here — the host moves it
3007            // once it has the target location.
3008            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
3009        }
3010        // `g;` / `g,` — walk the change list. `g;` toward older
3011        // entries, `g,` toward newer.
3012        Key::Char(';') => walk_change_list(ed, -1, count.max(1)),
3013        Key::Char(',') => walk_change_list(ed, 1, count.max(1)),
3014        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
3015        // boundary anchors), so the cursor on `foo` finds it inside
3016        // `foobar` too.
3017        Key::Char('*') => execute_motion(
3018            ed,
3019            Motion::WordAtCursor {
3020                forward: true,
3021                whole_word: false,
3022            },
3023            count,
3024        ),
3025        Key::Char('#') => execute_motion(
3026            ed,
3027            Motion::WordAtCursor {
3028                forward: false,
3029                whole_word: false,
3030            },
3031            count,
3032        ),
3033        _ => {}
3034    }
3035    true
3036}
3037
3038fn handle_after_z<H: crate::types::Host>(
3039    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3040    input: Input,
3041) -> bool {
3042    use crate::editor::CursorScrollTarget;
3043    let row = ed.cursor().0;
3044    match input.key {
3045        Key::Char('z') => {
3046            ed.scroll_cursor_to(CursorScrollTarget::Center);
3047            ed.vim.viewport_pinned = true;
3048        }
3049        Key::Char('t') => {
3050            ed.scroll_cursor_to(CursorScrollTarget::Top);
3051            ed.vim.viewport_pinned = true;
3052        }
3053        Key::Char('b') => {
3054            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
3055            ed.vim.viewport_pinned = true;
3056        }
3057        // Folds — operate on the fold under the cursor (or the
3058        // whole buffer for `R` / `M`). Routed through
3059        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
3060        // can observe / veto each op via [`Editor::take_fold_ops`].
3061        Key::Char('o') => {
3062            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
3063        }
3064        Key::Char('c') => {
3065            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
3066        }
3067        Key::Char('a') => {
3068            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
3069        }
3070        Key::Char('R') => {
3071            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
3072        }
3073        Key::Char('M') => {
3074            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
3075        }
3076        Key::Char('E') => {
3077            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
3078        }
3079        Key::Char('d') => {
3080            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
3081        }
3082        Key::Char('f') => {
3083            if matches!(
3084                ed.vim.mode,
3085                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
3086            ) {
3087                // `zf` over a Visual selection creates a fold spanning
3088                // anchor → cursor.
3089                let anchor_row = match ed.vim.mode {
3090                    Mode::VisualLine => ed.vim.visual_line_anchor,
3091                    Mode::VisualBlock => ed.vim.block_anchor.0,
3092                    _ => ed.vim.visual_anchor.0,
3093                };
3094                let cur = ed.cursor().0;
3095                let top = anchor_row.min(cur);
3096                let bot = anchor_row.max(cur);
3097                ed.apply_fold_op(crate::types::FoldOp::Add {
3098                    start_row: top,
3099                    end_row: bot,
3100                    closed: true,
3101                });
3102                ed.vim.mode = Mode::Normal;
3103            } else {
3104                // `zf{motion}` / `zf{textobj}` — route through the
3105                // operator pipeline. `Operator::Fold` reuses every
3106                // motion / text-object / `g`-prefix branch the other
3107                // operators get.
3108                let count = take_count(&mut ed.vim);
3109                ed.vim.pending = Pending::Op {
3110                    op: Operator::Fold,
3111                    count1: count,
3112                };
3113            }
3114        }
3115        _ => {}
3116    }
3117    true
3118}
3119
3120fn handle_replace<H: crate::types::Host>(
3121    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3122    input: Input,
3123) -> bool {
3124    if let Key::Char(ch) = input.key {
3125        if ed.vim.mode == Mode::VisualBlock {
3126            block_replace(ed, ch);
3127            return true;
3128        }
3129        let count = take_count(&mut ed.vim);
3130        replace_char(ed, ch, count.max(1));
3131        if !ed.vim.replaying {
3132            ed.vim.last_change = Some(LastChange::ReplaceChar {
3133                ch,
3134                count: count.max(1),
3135            });
3136        }
3137    }
3138    true
3139}
3140
3141fn handle_find_target<H: crate::types::Host>(
3142    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3143    input: Input,
3144    forward: bool,
3145    till: bool,
3146) -> bool {
3147    let Key::Char(ch) = input.key else {
3148        return true;
3149    };
3150    let count = take_count(&mut ed.vim);
3151    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
3152    ed.vim.last_find = Some((ch, forward, till));
3153    true
3154}
3155
3156fn handle_op_find_target<H: crate::types::Host>(
3157    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3158    input: Input,
3159    op: Operator,
3160    count1: usize,
3161    forward: bool,
3162    till: bool,
3163) -> bool {
3164    let Key::Char(ch) = input.key else {
3165        return true;
3166    };
3167    let count2 = take_count(&mut ed.vim);
3168    let total = count1.max(1) * count2.max(1);
3169    let motion = Motion::Find { ch, forward, till };
3170    apply_op_with_motion(ed, op, &motion, total);
3171    ed.vim.last_find = Some((ch, forward, till));
3172    if !ed.vim.replaying && op_is_change(op) {
3173        ed.vim.last_change = Some(LastChange::OpMotion {
3174            op,
3175            motion,
3176            count: total,
3177            inserted: None,
3178        });
3179    }
3180    true
3181}
3182
3183fn handle_text_object<H: crate::types::Host>(
3184    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3185    input: Input,
3186    op: Operator,
3187    _count1: usize,
3188    inner: bool,
3189) -> bool {
3190    let Key::Char(ch) = input.key else {
3191        return true;
3192    };
3193    let obj = match ch {
3194        'w' => TextObject::Word { big: false },
3195        'W' => TextObject::Word { big: true },
3196        '"' | '\'' | '`' => TextObject::Quote(ch),
3197        '(' | ')' | 'b' => TextObject::Bracket('('),
3198        '[' | ']' => TextObject::Bracket('['),
3199        '{' | '}' | 'B' => TextObject::Bracket('{'),
3200        '<' | '>' => TextObject::Bracket('<'),
3201        'p' => TextObject::Paragraph,
3202        't' => TextObject::XmlTag,
3203        's' => TextObject::Sentence,
3204        _ => return true,
3205    };
3206    apply_op_with_text_object(ed, op, obj, inner);
3207    if !ed.vim.replaying && op_is_change(op) {
3208        ed.vim.last_change = Some(LastChange::OpTextObj {
3209            op,
3210            obj,
3211            inner,
3212            inserted: None,
3213        });
3214    }
3215    true
3216}
3217
3218fn handle_visual_text_obj<H: crate::types::Host>(
3219    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3220    input: Input,
3221    inner: bool,
3222) -> bool {
3223    let Key::Char(ch) = input.key else {
3224        return true;
3225    };
3226    let obj = match ch {
3227        'w' => TextObject::Word { big: false },
3228        'W' => TextObject::Word { big: true },
3229        '"' | '\'' | '`' => TextObject::Quote(ch),
3230        '(' | ')' | 'b' => TextObject::Bracket('('),
3231        '[' | ']' => TextObject::Bracket('['),
3232        '{' | '}' | 'B' => TextObject::Bracket('{'),
3233        '<' | '>' => TextObject::Bracket('<'),
3234        'p' => TextObject::Paragraph,
3235        't' => TextObject::XmlTag,
3236        's' => TextObject::Sentence,
3237        _ => return true,
3238    };
3239    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3240        return true;
3241    };
3242    // Anchor + cursor position the char-wise highlight / operator range;
3243    // for linewise text-objects we switch into VisualLine with the
3244    // appropriate row anchor.
3245    match kind {
3246        MotionKind::Linewise => {
3247            ed.vim.visual_line_anchor = start.0;
3248            ed.vim.mode = Mode::VisualLine;
3249            ed.jump_cursor(end.0, 0);
3250        }
3251        _ => {
3252            ed.vim.mode = Mode::Visual;
3253            ed.vim.visual_anchor = (start.0, start.1);
3254            let (er, ec) = retreat_one(ed, end);
3255            ed.jump_cursor(er, ec);
3256        }
3257    }
3258    true
3259}
3260
3261/// Move `pos` back by one character, clamped to (0, 0).
3262fn retreat_one<H: crate::types::Host>(
3263    ed: &Editor<hjkl_buffer::Buffer, H>,
3264    pos: (usize, usize),
3265) -> (usize, usize) {
3266    let (r, c) = pos;
3267    if c > 0 {
3268        (r, c - 1)
3269    } else if r > 0 {
3270        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
3271        (r - 1, prev_len)
3272    } else {
3273        (0, 0)
3274    }
3275}
3276
3277fn op_is_change(op: Operator) -> bool {
3278    matches!(op, Operator::Delete | Operator::Change)
3279}
3280
3281// ─── Normal-only commands (not motion, not operator) ───────────────────────
3282
3283fn handle_normal_only<H: crate::types::Host>(
3284    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3285    input: &Input,
3286    count: usize,
3287) -> bool {
3288    if input.ctrl {
3289        return false;
3290    }
3291    match input.key {
3292        Key::Char('i') => {
3293            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
3294            true
3295        }
3296        Key::Char('I') => {
3297            move_first_non_whitespace(ed);
3298            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
3299            true
3300        }
3301        Key::Char('a') => {
3302            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3303            ed.push_buffer_cursor_to_textarea();
3304            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
3305            true
3306        }
3307        Key::Char('A') => {
3308            crate::motions::move_line_end(&mut ed.buffer);
3309            crate::motions::move_right_to_end(&mut ed.buffer, 1);
3310            ed.push_buffer_cursor_to_textarea();
3311            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
3312            true
3313        }
3314        Key::Char('R') => {
3315            // Replace mode — overstrike each typed cell. Reuses the
3316            // insert-mode key handler with a Replace-flavoured session.
3317            begin_insert(ed, count.max(1), InsertReason::Replace);
3318            true
3319        }
3320        Key::Char('o') => {
3321            use hjkl_buffer::{Edit, Position};
3322            ed.push_undo();
3323            // Snapshot BEFORE the newline so replay sees "\n<text>" as the
3324            // delta and produces one fresh line per iteration.
3325            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
3326            ed.sync_buffer_content_from_textarea();
3327            let row = buf_cursor_pos(&ed.buffer).row;
3328            let line_chars = buf_line_chars(&ed.buffer, row);
3329            // Smart/auto-indent based on the current line (becomes the
3330            // "previous" line for the freshly-opened line below).
3331            let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
3332            let indent = compute_enter_indent(&ed.settings, prev_line);
3333            ed.mutate_edit(Edit::InsertStr {
3334                at: Position::new(row, line_chars),
3335                text: format!("\n{indent}"),
3336            });
3337            ed.push_buffer_cursor_to_textarea();
3338            true
3339        }
3340        Key::Char('O') => {
3341            use hjkl_buffer::{Edit, Position};
3342            ed.push_undo();
3343            begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
3344            ed.sync_buffer_content_from_textarea();
3345            let row = buf_cursor_pos(&ed.buffer).row;
3346            // The line opened above sits between row-1 and the current
3347            // row. Smart/auto-indent off the line above when there is
3348            // one; otherwise copy the current line's leading whitespace.
3349            let indent = if row > 0 {
3350                let above = buf_line(&ed.buffer, row - 1).unwrap_or_default();
3351                compute_enter_indent(&ed.settings, above)
3352            } else {
3353                let cur = buf_line(&ed.buffer, row).unwrap_or_default();
3354                cur.chars()
3355                    .take_while(|c| *c == ' ' || *c == '\t')
3356                    .collect::<String>()
3357            };
3358            ed.mutate_edit(Edit::InsertStr {
3359                at: Position::new(row, 0),
3360                text: format!("{indent}\n"),
3361            });
3362            // After insert, cursor sits on the surviving content one row
3363            // down — step back up onto the freshly-opened line, then to
3364            // the end of its indent so insert mode picks up where the
3365            // user expects to type.
3366            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
3367            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
3368            let new_row = buf_cursor_pos(&ed.buffer).row;
3369            buf_set_cursor_rc(&mut ed.buffer, new_row, indent.chars().count());
3370            ed.push_buffer_cursor_to_textarea();
3371            true
3372        }
3373        Key::Char('x') => {
3374            do_char_delete(ed, true, count.max(1));
3375            if !ed.vim.replaying {
3376                ed.vim.last_change = Some(LastChange::CharDel {
3377                    forward: true,
3378                    count: count.max(1),
3379                });
3380            }
3381            true
3382        }
3383        Key::Char('X') => {
3384            do_char_delete(ed, false, count.max(1));
3385            if !ed.vim.replaying {
3386                ed.vim.last_change = Some(LastChange::CharDel {
3387                    forward: false,
3388                    count: count.max(1),
3389                });
3390            }
3391            true
3392        }
3393        Key::Char('~') => {
3394            for _ in 0..count.max(1) {
3395                ed.push_undo();
3396                toggle_case_at_cursor(ed);
3397            }
3398            if !ed.vim.replaying {
3399                ed.vim.last_change = Some(LastChange::ToggleCase {
3400                    count: count.max(1),
3401                });
3402            }
3403            true
3404        }
3405        Key::Char('J') => {
3406            for _ in 0..count.max(1) {
3407                ed.push_undo();
3408                join_line(ed);
3409            }
3410            if !ed.vim.replaying {
3411                ed.vim.last_change = Some(LastChange::JoinLine {
3412                    count: count.max(1),
3413                });
3414            }
3415            true
3416        }
3417        Key::Char('D') => {
3418            ed.push_undo();
3419            delete_to_eol(ed);
3420            // Vim parks the cursor on the new last char.
3421            crate::motions::move_left(&mut ed.buffer, 1);
3422            ed.push_buffer_cursor_to_textarea();
3423            if !ed.vim.replaying {
3424                ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
3425            }
3426            true
3427        }
3428        Key::Char('Y') => {
3429            // Vim 8 default: `Y` yanks to end of line (same as `y$`).
3430            apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
3431            true
3432        }
3433        Key::Char('C') => {
3434            ed.push_undo();
3435            delete_to_eol(ed);
3436            begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
3437            true
3438        }
3439        Key::Char('s') => {
3440            use hjkl_buffer::{Edit, MotionKind, Position};
3441            ed.push_undo();
3442            ed.sync_buffer_content_from_textarea();
3443            for _ in 0..count.max(1) {
3444                let cursor = buf_cursor_pos(&ed.buffer);
3445                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
3446                if cursor.col >= line_chars {
3447                    break;
3448                }
3449                ed.mutate_edit(Edit::DeleteRange {
3450                    start: cursor,
3451                    end: Position::new(cursor.row, cursor.col + 1),
3452                    kind: MotionKind::Char,
3453                });
3454            }
3455            ed.push_buffer_cursor_to_textarea();
3456            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3457            // `s` == `cl` — record as such.
3458            if !ed.vim.replaying {
3459                ed.vim.last_change = Some(LastChange::OpMotion {
3460                    op: Operator::Change,
3461                    motion: Motion::Right,
3462                    count: count.max(1),
3463                    inserted: None,
3464                });
3465            }
3466            true
3467        }
3468        Key::Char('p') => {
3469            do_paste(ed, false, count.max(1));
3470            if !ed.vim.replaying {
3471                ed.vim.last_change = Some(LastChange::Paste {
3472                    before: false,
3473                    count: count.max(1),
3474                });
3475            }
3476            true
3477        }
3478        Key::Char('P') => {
3479            do_paste(ed, true, count.max(1));
3480            if !ed.vim.replaying {
3481                ed.vim.last_change = Some(LastChange::Paste {
3482                    before: true,
3483                    count: count.max(1),
3484                });
3485            }
3486            true
3487        }
3488        Key::Char('u') => {
3489            do_undo(ed);
3490            true
3491        }
3492        Key::Char('r') => {
3493            ed.vim.count = count;
3494            ed.vim.pending = Pending::Replace;
3495            true
3496        }
3497        Key::Char('/') => {
3498            enter_search(ed, true);
3499            true
3500        }
3501        Key::Char('?') => {
3502            enter_search(ed, false);
3503            true
3504        }
3505        Key::Char('.') => {
3506            replay_last_change(ed, count);
3507            true
3508        }
3509        _ => false,
3510    }
3511}
3512
3513/// Variant of begin_insert that doesn't push_undo (caller already did).
3514fn begin_insert_noundo<H: crate::types::Host>(
3515    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3516    count: usize,
3517    reason: InsertReason,
3518) {
3519    let reason = if ed.vim.replaying {
3520        InsertReason::ReplayOnly
3521    } else {
3522        reason
3523    };
3524    let (row, _) = ed.cursor();
3525    ed.vim.insert_session = Some(InsertSession {
3526        count,
3527        row_min: row,
3528        row_max: row,
3529        before_lines: buf_lines_to_vec(&ed.buffer),
3530        reason,
3531    });
3532    ed.vim.mode = Mode::Insert;
3533}
3534
3535// ─── Operator × Motion application ─────────────────────────────────────────
3536
3537fn apply_op_with_motion<H: crate::types::Host>(
3538    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3539    op: Operator,
3540    motion: &Motion,
3541    count: usize,
3542) {
3543    let start = ed.cursor();
3544    // Tentatively apply motion to find the endpoint. Operator context
3545    // so `l` on the last char advances past-last (standard vim
3546    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
3547    // `yl` to cover the final char.
3548    apply_motion_cursor_ctx(ed, motion, count, true);
3549    let end = ed.cursor();
3550    let kind = motion_kind(motion);
3551    // Restore cursor before selecting (so Yank leaves cursor at start).
3552    ed.jump_cursor(start.0, start.1);
3553    run_operator_over_range(ed, op, start, end, kind);
3554}
3555
3556fn apply_op_with_text_object<H: crate::types::Host>(
3557    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3558    op: Operator,
3559    obj: TextObject,
3560    inner: bool,
3561) {
3562    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3563        return;
3564    };
3565    ed.jump_cursor(start.0, start.1);
3566    run_operator_over_range(ed, op, start, end, kind);
3567}
3568
3569fn motion_kind(motion: &Motion) -> MotionKind {
3570    match motion {
3571        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => MotionKind::Linewise,
3572        Motion::FileTop | Motion::FileBottom => MotionKind::Linewise,
3573        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
3574            MotionKind::Linewise
3575        }
3576        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
3577            MotionKind::Inclusive
3578        }
3579        Motion::Find { .. } => MotionKind::Inclusive,
3580        Motion::MatchBracket => MotionKind::Inclusive,
3581        // `$` now lands on the last char — operator ranges include it.
3582        Motion::LineEnd => MotionKind::Inclusive,
3583        _ => MotionKind::Exclusive,
3584    }
3585}
3586
3587fn run_operator_over_range<H: crate::types::Host>(
3588    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3589    op: Operator,
3590    start: (usize, usize),
3591    end: (usize, usize),
3592    kind: MotionKind,
3593) {
3594    let (top, bot) = order(start, end);
3595    if top == bot {
3596        return;
3597    }
3598
3599    match op {
3600        Operator::Yank => {
3601            let text = read_vim_range(ed, top, bot, kind);
3602            if !text.is_empty() {
3603                ed.record_yank_to_host(text.clone());
3604                ed.record_yank(text, matches!(kind, MotionKind::Linewise));
3605            }
3606            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3607            ed.push_buffer_cursor_to_textarea();
3608        }
3609        Operator::Delete => {
3610            ed.push_undo();
3611            cut_vim_range(ed, top, bot, kind);
3612            // After a charwise / inclusive delete the buffer cursor is
3613            // placed at `start` by the edit path. In Normal mode the
3614            // cursor max col is `line_len - 1`; clamp it here so e.g.
3615            // `d$` doesn't leave the cursor one past the new line end.
3616            if !matches!(kind, MotionKind::Linewise) {
3617                clamp_cursor_to_normal_mode(ed);
3618            }
3619            ed.vim.mode = Mode::Normal;
3620        }
3621        Operator::Change => {
3622            ed.push_undo();
3623            cut_vim_range(ed, top, bot, kind);
3624            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3625        }
3626        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3627            apply_case_op_to_selection(ed, op, top, bot, kind);
3628        }
3629        Operator::Indent | Operator::Outdent => {
3630            // Indent / outdent are always linewise even when triggered
3631            // by a char-wise motion (e.g. `>w` indents the whole line).
3632            ed.push_undo();
3633            if op == Operator::Indent {
3634                indent_rows(ed, top.0, bot.0, 1);
3635            } else {
3636                outdent_rows(ed, top.0, bot.0, 1);
3637            }
3638            ed.vim.mode = Mode::Normal;
3639        }
3640        Operator::Fold => {
3641            // Always linewise — fold the spanned rows regardless of the
3642            // motion's natural kind. Cursor lands on `top.0` to mirror
3643            // the visual `zf` path.
3644            if bot.0 >= top.0 {
3645                ed.apply_fold_op(crate::types::FoldOp::Add {
3646                    start_row: top.0,
3647                    end_row: bot.0,
3648                    closed: true,
3649                });
3650            }
3651            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3652            ed.push_buffer_cursor_to_textarea();
3653            ed.vim.mode = Mode::Normal;
3654        }
3655        Operator::Reflow => {
3656            ed.push_undo();
3657            reflow_rows(ed, top.0, bot.0);
3658            ed.vim.mode = Mode::Normal;
3659        }
3660    }
3661}
3662
3663/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
3664/// Splits on blank-line boundaries so paragraph structure is
3665/// preserved. Each paragraph's words are joined with single spaces
3666/// before re-wrapping.
3667fn reflow_rows<H: crate::types::Host>(
3668    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3669    top: usize,
3670    bot: usize,
3671) {
3672    let width = ed.settings().textwidth.max(1);
3673    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3674    let bot = bot.min(lines.len().saturating_sub(1));
3675    if top > bot {
3676        return;
3677    }
3678    let original = lines[top..=bot].to_vec();
3679    let mut wrapped: Vec<String> = Vec::new();
3680    let mut paragraph: Vec<String> = Vec::new();
3681    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
3682        if para.is_empty() {
3683            return;
3684        }
3685        let words = para.join(" ");
3686        let mut current = String::new();
3687        for word in words.split_whitespace() {
3688            let extra = if current.is_empty() {
3689                word.chars().count()
3690            } else {
3691                current.chars().count() + 1 + word.chars().count()
3692            };
3693            if extra > width && !current.is_empty() {
3694                out.push(std::mem::take(&mut current));
3695                current.push_str(word);
3696            } else if current.is_empty() {
3697                current.push_str(word);
3698            } else {
3699                current.push(' ');
3700                current.push_str(word);
3701            }
3702        }
3703        if !current.is_empty() {
3704            out.push(current);
3705        }
3706        para.clear();
3707    };
3708    for line in &original {
3709        if line.trim().is_empty() {
3710            flush(&mut paragraph, &mut wrapped, width);
3711            wrapped.push(String::new());
3712        } else {
3713            paragraph.push(line.clone());
3714        }
3715    }
3716    flush(&mut paragraph, &mut wrapped, width);
3717
3718    // Splice back. push_undo above means `u` reverses.
3719    let after: Vec<String> = lines.split_off(bot + 1);
3720    lines.truncate(top);
3721    lines.extend(wrapped);
3722    lines.extend(after);
3723    ed.restore(lines, (top, 0));
3724    ed.mark_content_dirty();
3725}
3726
3727/// Transform the range `[top, bot]` (vim `MotionKind`) in place with
3728/// the given case operator. Cursor lands on `top` afterward — vim
3729/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
3730/// Preserves the textarea yank buffer (vim's case operators don't
3731/// touch registers).
3732fn apply_case_op_to_selection<H: crate::types::Host>(
3733    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3734    op: Operator,
3735    top: (usize, usize),
3736    bot: (usize, usize),
3737    kind: MotionKind,
3738) {
3739    use hjkl_buffer::Edit;
3740    ed.push_undo();
3741    let saved_yank = ed.yank().to_string();
3742    let saved_yank_linewise = ed.vim.yank_linewise;
3743    let selection = cut_vim_range(ed, top, bot, kind);
3744    let transformed = match op {
3745        Operator::Uppercase => selection.to_uppercase(),
3746        Operator::Lowercase => selection.to_lowercase(),
3747        Operator::ToggleCase => toggle_case_str(&selection),
3748        _ => unreachable!(),
3749    };
3750    if !transformed.is_empty() {
3751        let cursor = buf_cursor_pos(&ed.buffer);
3752        ed.mutate_edit(Edit::InsertStr {
3753            at: cursor,
3754            text: transformed,
3755        });
3756    }
3757    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3758    ed.push_buffer_cursor_to_textarea();
3759    ed.set_yank(saved_yank);
3760    ed.vim.yank_linewise = saved_yank_linewise;
3761    ed.vim.mode = Mode::Normal;
3762}
3763
3764/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
3765/// Rows that are empty are skipped (vim leaves blank lines alone when
3766/// indenting). `shiftwidth` is read from `editor.settings()` so
3767/// `:set shiftwidth=N` takes effect on the next operation.
3768fn indent_rows<H: crate::types::Host>(
3769    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3770    top: usize,
3771    bot: usize,
3772    count: usize,
3773) {
3774    ed.sync_buffer_content_from_textarea();
3775    let width = ed.settings().shiftwidth * count.max(1);
3776    let pad: String = " ".repeat(width);
3777    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3778    let bot = bot.min(lines.len().saturating_sub(1));
3779    for line in lines.iter_mut().take(bot + 1).skip(top) {
3780        if !line.is_empty() {
3781            line.insert_str(0, &pad);
3782        }
3783    }
3784    // Restore cursor to first non-blank of the top row so the next
3785    // vertical motion aims sensibly — matches vim's `>>` convention.
3786    ed.restore(lines, (top, 0));
3787    move_first_non_whitespace(ed);
3788}
3789
3790/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
3791/// each row in `[top, bot]`. Rows with less leading whitespace have
3792/// all their indent stripped, not clipped to zero length.
3793fn outdent_rows<H: crate::types::Host>(
3794    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3795    top: usize,
3796    bot: usize,
3797    count: usize,
3798) {
3799    ed.sync_buffer_content_from_textarea();
3800    let width = ed.settings().shiftwidth * count.max(1);
3801    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3802    let bot = bot.min(lines.len().saturating_sub(1));
3803    for line in lines.iter_mut().take(bot + 1).skip(top) {
3804        let strip: usize = line
3805            .chars()
3806            .take(width)
3807            .take_while(|c| *c == ' ' || *c == '\t')
3808            .count();
3809        if strip > 0 {
3810            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
3811            line.drain(..byte_len);
3812        }
3813    }
3814    ed.restore(lines, (top, 0));
3815    move_first_non_whitespace(ed);
3816}
3817
3818fn toggle_case_str(s: &str) -> String {
3819    s.chars()
3820        .map(|c| {
3821            if c.is_lowercase() {
3822                c.to_uppercase().next().unwrap_or(c)
3823            } else if c.is_uppercase() {
3824                c.to_lowercase().next().unwrap_or(c)
3825            } else {
3826                c
3827            }
3828        })
3829        .collect()
3830}
3831
3832fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
3833    if a <= b { (a, b) } else { (b, a) }
3834}
3835
3836/// Clamp the buffer cursor to normal-mode valid position: col may not
3837/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
3838/// line). Vim applies this clamp on every return to Normal mode after an
3839/// operator or Esc-from-insert.
3840fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
3841    let (row, col) = ed.cursor();
3842    let line_chars = buf_line_chars(&ed.buffer, row);
3843    let max_col = line_chars.saturating_sub(1);
3844    if col > max_col {
3845        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
3846        ed.push_buffer_cursor_to_textarea();
3847    }
3848}
3849
3850// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
3851
3852fn execute_line_op<H: crate::types::Host>(
3853    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3854    op: Operator,
3855    count: usize,
3856) {
3857    let (row, col) = ed.cursor();
3858    let total = buf_row_count(&ed.buffer);
3859    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
3860
3861    match op {
3862        Operator::Yank => {
3863            // yy must not move the cursor.
3864            let text = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3865            if !text.is_empty() {
3866                ed.record_yank_to_host(text.clone());
3867                ed.record_yank(text, true);
3868            }
3869            buf_set_cursor_rc(&mut ed.buffer, row, col);
3870            ed.push_buffer_cursor_to_textarea();
3871            ed.vim.mode = Mode::Normal;
3872        }
3873        Operator::Delete => {
3874            ed.push_undo();
3875            let deleted_through_last = end_row + 1 >= total;
3876            cut_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3877            // Vim's `dd` / `Ndd` leaves the cursor on the *first
3878            // non-blank* of the line that now occupies `row` — or, if
3879            // the deletion consumed the last line, the line above it.
3880            let total_after = buf_row_count(&ed.buffer);
3881            let raw_target = if deleted_through_last {
3882                row.saturating_sub(1).min(total_after.saturating_sub(1))
3883            } else {
3884                row.min(total_after.saturating_sub(1))
3885            };
3886            // Clamp off the trailing phantom empty row that arises from a
3887            // buffer with a trailing newline (stored as ["...", ""]). If
3888            // the target row is the trailing empty row and there is a real
3889            // content row above it, use that instead — matching vim's view
3890            // that the trailing `\n` is a terminator, not a separator.
3891            let target_row = if raw_target > 0
3892                && raw_target + 1 == total_after
3893                && buf_line(&ed.buffer, raw_target)
3894                    .map(str::is_empty)
3895                    .unwrap_or(false)
3896            {
3897                raw_target - 1
3898            } else {
3899                raw_target
3900            };
3901            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
3902            ed.push_buffer_cursor_to_textarea();
3903            move_first_non_whitespace(ed);
3904            ed.sticky_col = Some(ed.cursor().1);
3905            ed.vim.mode = Mode::Normal;
3906        }
3907        Operator::Change => {
3908            // `cc` / `3cc`: wipe contents of the covered lines but leave
3909            // a single blank line so insert-mode opens on it. Done as two
3910            // edits: drop rows past the first, then clear row `row`.
3911            use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
3912            ed.push_undo();
3913            ed.sync_buffer_content_from_textarea();
3914            // Read the cut payload first so yank reflects every line.
3915            let payload = read_vim_range(ed, (row, col), (end_row, 0), MotionKind::Linewise);
3916            if end_row > row {
3917                ed.mutate_edit(Edit::DeleteRange {
3918                    start: Position::new(row + 1, 0),
3919                    end: Position::new(end_row, 0),
3920                    kind: BufKind::Line,
3921                });
3922            }
3923            let line_chars = buf_line_chars(&ed.buffer, row);
3924            if line_chars > 0 {
3925                ed.mutate_edit(Edit::DeleteRange {
3926                    start: Position::new(row, 0),
3927                    end: Position::new(row, line_chars),
3928                    kind: BufKind::Char,
3929                });
3930            }
3931            if !payload.is_empty() {
3932                ed.record_yank_to_host(payload.clone());
3933                ed.record_delete(payload, true);
3934            }
3935            buf_set_cursor_rc(&mut ed.buffer, row, 0);
3936            ed.push_buffer_cursor_to_textarea();
3937            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3938        }
3939        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3940            // `gUU` / `guu` / `g~~` — linewise case transform over
3941            // [row, end_row]. Preserve cursor on `row` (first non-blank
3942            // lines up with vim's behaviour).
3943            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), MotionKind::Linewise);
3944            // After case-op on a linewise range vim puts the cursor on
3945            // the first non-blank of the starting line.
3946            move_first_non_whitespace(ed);
3947        }
3948        Operator::Indent | Operator::Outdent => {
3949            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
3950            ed.push_undo();
3951            if op == Operator::Indent {
3952                indent_rows(ed, row, end_row, 1);
3953            } else {
3954                outdent_rows(ed, row, end_row, 1);
3955            }
3956            ed.sticky_col = Some(ed.cursor().1);
3957            ed.vim.mode = Mode::Normal;
3958        }
3959        // No doubled form — `zfzf` is two consecutive `zf` chords.
3960        Operator::Fold => unreachable!("Fold has no line-op double"),
3961        Operator::Reflow => {
3962            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
3963            ed.push_undo();
3964            reflow_rows(ed, row, end_row);
3965            move_first_non_whitespace(ed);
3966            ed.sticky_col = Some(ed.cursor().1);
3967            ed.vim.mode = Mode::Normal;
3968        }
3969    }
3970}
3971
3972// ─── Visual mode operators ─────────────────────────────────────────────────
3973
3974fn apply_visual_operator<H: crate::types::Host>(
3975    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3976    op: Operator,
3977) {
3978    match ed.vim.mode {
3979        Mode::VisualLine => {
3980            let cursor_row = buf_cursor_pos(&ed.buffer).row;
3981            let top = cursor_row.min(ed.vim.visual_line_anchor);
3982            let bot = cursor_row.max(ed.vim.visual_line_anchor);
3983            ed.vim.yank_linewise = true;
3984            match op {
3985                Operator::Yank => {
3986                    let text = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
3987                    if !text.is_empty() {
3988                        ed.record_yank_to_host(text.clone());
3989                        ed.record_yank(text, true);
3990                    }
3991                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
3992                    ed.push_buffer_cursor_to_textarea();
3993                    ed.vim.mode = Mode::Normal;
3994                }
3995                Operator::Delete => {
3996                    ed.push_undo();
3997                    cut_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
3998                    ed.vim.mode = Mode::Normal;
3999                }
4000                Operator::Change => {
4001                    // Vim `Vc`: wipe the line contents but leave a blank
4002                    // line in place so insert-mode starts on an empty row.
4003                    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4004                    ed.push_undo();
4005                    ed.sync_buffer_content_from_textarea();
4006                    let payload = read_vim_range(ed, (top, 0), (bot, 0), MotionKind::Linewise);
4007                    if bot > top {
4008                        ed.mutate_edit(Edit::DeleteRange {
4009                            start: Position::new(top + 1, 0),
4010                            end: Position::new(bot, 0),
4011                            kind: BufKind::Line,
4012                        });
4013                    }
4014                    let line_chars = buf_line_chars(&ed.buffer, top);
4015                    if line_chars > 0 {
4016                        ed.mutate_edit(Edit::DeleteRange {
4017                            start: Position::new(top, 0),
4018                            end: Position::new(top, line_chars),
4019                            kind: BufKind::Char,
4020                        });
4021                    }
4022                    if !payload.is_empty() {
4023                        ed.record_yank_to_host(payload.clone());
4024                        ed.record_delete(payload, true);
4025                    }
4026                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4027                    ed.push_buffer_cursor_to_textarea();
4028                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4029                }
4030                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4031                    let bot = buf_cursor_pos(&ed.buffer)
4032                        .row
4033                        .max(ed.vim.visual_line_anchor);
4034                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), MotionKind::Linewise);
4035                    move_first_non_whitespace(ed);
4036                }
4037                Operator::Indent | Operator::Outdent => {
4038                    ed.push_undo();
4039                    let (cursor_row, _) = ed.cursor();
4040                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4041                    if op == Operator::Indent {
4042                        indent_rows(ed, top, bot, 1);
4043                    } else {
4044                        outdent_rows(ed, top, bot, 1);
4045                    }
4046                    ed.vim.mode = Mode::Normal;
4047                }
4048                Operator::Reflow => {
4049                    ed.push_undo();
4050                    let (cursor_row, _) = ed.cursor();
4051                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4052                    reflow_rows(ed, top, bot);
4053                    ed.vim.mode = Mode::Normal;
4054                }
4055                // Visual `zf` is handled inline in `handle_after_z`,
4056                // never routed through this dispatcher.
4057                Operator::Fold => unreachable!("Visual zf takes its own path"),
4058            }
4059        }
4060        Mode::Visual => {
4061            ed.vim.yank_linewise = false;
4062            let anchor = ed.vim.visual_anchor;
4063            let cursor = ed.cursor();
4064            let (top, bot) = order(anchor, cursor);
4065            match op {
4066                Operator::Yank => {
4067                    let text = read_vim_range(ed, top, bot, MotionKind::Inclusive);
4068                    if !text.is_empty() {
4069                        ed.record_yank_to_host(text.clone());
4070                        ed.record_yank(text, false);
4071                    }
4072                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4073                    ed.push_buffer_cursor_to_textarea();
4074                    ed.vim.mode = Mode::Normal;
4075                }
4076                Operator::Delete => {
4077                    ed.push_undo();
4078                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4079                    ed.vim.mode = Mode::Normal;
4080                }
4081                Operator::Change => {
4082                    ed.push_undo();
4083                    cut_vim_range(ed, top, bot, MotionKind::Inclusive);
4084                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4085                }
4086                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4087                    // Anchor stays where the visual selection started.
4088                    let anchor = ed.vim.visual_anchor;
4089                    let cursor = ed.cursor();
4090                    let (top, bot) = order(anchor, cursor);
4091                    apply_case_op_to_selection(ed, op, top, bot, MotionKind::Inclusive);
4092                }
4093                Operator::Indent | Operator::Outdent => {
4094                    ed.push_undo();
4095                    let anchor = ed.vim.visual_anchor;
4096                    let cursor = ed.cursor();
4097                    let (top, bot) = order(anchor, cursor);
4098                    if op == Operator::Indent {
4099                        indent_rows(ed, top.0, bot.0, 1);
4100                    } else {
4101                        outdent_rows(ed, top.0, bot.0, 1);
4102                    }
4103                    ed.vim.mode = Mode::Normal;
4104                }
4105                Operator::Reflow => {
4106                    ed.push_undo();
4107                    let anchor = ed.vim.visual_anchor;
4108                    let cursor = ed.cursor();
4109                    let (top, bot) = order(anchor, cursor);
4110                    reflow_rows(ed, top.0, bot.0);
4111                    ed.vim.mode = Mode::Normal;
4112                }
4113                Operator::Fold => unreachable!("Visual zf takes its own path"),
4114            }
4115        }
4116        Mode::VisualBlock => apply_block_operator(ed, op),
4117        _ => {}
4118    }
4119}
4120
4121/// Compute `(top_row, bot_row, left_col, right_col)` for the current
4122/// VisualBlock selection. Columns are inclusive on both ends. Uses the
4123/// tracked virtual column (updated by h/l, preserved across j/k) so
4124/// ragged / empty rows don't collapse the block's width.
4125fn block_bounds<H: crate::types::Host>(
4126    ed: &Editor<hjkl_buffer::Buffer, H>,
4127) -> (usize, usize, usize, usize) {
4128    let (ar, ac) = ed.vim.block_anchor;
4129    let (cr, _) = ed.cursor();
4130    let cc = ed.vim.block_vcol;
4131    let top = ar.min(cr);
4132    let bot = ar.max(cr);
4133    let left = ac.min(cc);
4134    let right = ac.max(cc);
4135    (top, bot, left, right)
4136}
4137
4138/// Update the virtual column after a motion in VisualBlock mode.
4139/// Horizontal motions sync `block_vcol` to the new cursor column;
4140/// vertical / non-h/l motions leave it alone so the intended column
4141/// survives clamping to shorter lines.
4142fn update_block_vcol<H: crate::types::Host>(
4143    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4144    motion: &Motion,
4145) {
4146    match motion {
4147        Motion::Left
4148        | Motion::Right
4149        | Motion::WordFwd
4150        | Motion::BigWordFwd
4151        | Motion::WordBack
4152        | Motion::BigWordBack
4153        | Motion::WordEnd
4154        | Motion::BigWordEnd
4155        | Motion::WordEndBack
4156        | Motion::BigWordEndBack
4157        | Motion::LineStart
4158        | Motion::FirstNonBlank
4159        | Motion::LineEnd
4160        | Motion::Find { .. }
4161        | Motion::FindRepeat { .. }
4162        | Motion::MatchBracket => {
4163            ed.vim.block_vcol = ed.cursor().1;
4164        }
4165        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
4166        _ => {}
4167    }
4168}
4169
4170/// Yank / delete / change / replace a rectangular selection. Yanked text
4171/// is stored as one string per row joined with `\n` so pasting reproduces
4172/// the block as sequential lines. (Vim's true block-paste reinserts as
4173/// columns; we render the content with our char-wise paste path.)
4174fn apply_block_operator<H: crate::types::Host>(
4175    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4176    op: Operator,
4177) {
4178    let (top, bot, left, right) = block_bounds(ed);
4179    // Snapshot the block text for yank / clipboard.
4180    let yank = block_yank(ed, top, bot, left, right);
4181
4182    match op {
4183        Operator::Yank => {
4184            if !yank.is_empty() {
4185                ed.record_yank_to_host(yank.clone());
4186                ed.record_yank(yank, false);
4187            }
4188            ed.vim.mode = Mode::Normal;
4189            ed.jump_cursor(top, left);
4190        }
4191        Operator::Delete => {
4192            ed.push_undo();
4193            delete_block_contents(ed, top, bot, left, right);
4194            if !yank.is_empty() {
4195                ed.record_yank_to_host(yank.clone());
4196                ed.record_delete(yank, false);
4197            }
4198            ed.vim.mode = Mode::Normal;
4199            ed.jump_cursor(top, left);
4200        }
4201        Operator::Change => {
4202            ed.push_undo();
4203            delete_block_contents(ed, top, bot, left, right);
4204            if !yank.is_empty() {
4205                ed.record_yank_to_host(yank.clone());
4206                ed.record_delete(yank, false);
4207            }
4208            ed.jump_cursor(top, left);
4209            begin_insert_noundo(
4210                ed,
4211                1,
4212                InsertReason::BlockEdge {
4213                    top,
4214                    bot,
4215                    col: left,
4216                },
4217            );
4218        }
4219        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4220            ed.push_undo();
4221            transform_block_case(ed, op, top, bot, left, right);
4222            ed.vim.mode = Mode::Normal;
4223            ed.jump_cursor(top, left);
4224        }
4225        Operator::Indent | Operator::Outdent => {
4226            // VisualBlock `>` / `<` falls back to linewise indent over
4227            // the block's row range — vim does the same (column-wise
4228            // indent/outdent doesn't make sense).
4229            ed.push_undo();
4230            if op == Operator::Indent {
4231                indent_rows(ed, top, bot, 1);
4232            } else {
4233                outdent_rows(ed, top, bot, 1);
4234            }
4235            ed.vim.mode = Mode::Normal;
4236        }
4237        Operator::Fold => unreachable!("Visual zf takes its own path"),
4238        Operator::Reflow => {
4239            // Reflow over the block falls back to linewise reflow over
4240            // the row range — column slicing for `gq` doesn't make
4241            // sense.
4242            ed.push_undo();
4243            reflow_rows(ed, top, bot);
4244            ed.vim.mode = Mode::Normal;
4245        }
4246    }
4247}
4248
4249/// In-place case transform over the rectangular block
4250/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
4251/// untouched — vim behaves the same way (ragged blocks).
4252fn transform_block_case<H: crate::types::Host>(
4253    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4254    op: Operator,
4255    top: usize,
4256    bot: usize,
4257    left: usize,
4258    right: usize,
4259) {
4260    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4261    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4262        let chars: Vec<char> = lines[r].chars().collect();
4263        if left >= chars.len() {
4264            continue;
4265        }
4266        let end = (right + 1).min(chars.len());
4267        let head: String = chars[..left].iter().collect();
4268        let mid: String = chars[left..end].iter().collect();
4269        let tail: String = chars[end..].iter().collect();
4270        let transformed = match op {
4271            Operator::Uppercase => mid.to_uppercase(),
4272            Operator::Lowercase => mid.to_lowercase(),
4273            Operator::ToggleCase => toggle_case_str(&mid),
4274            _ => mid,
4275        };
4276        lines[r] = format!("{head}{transformed}{tail}");
4277    }
4278    let saved_yank = ed.yank().to_string();
4279    let saved_linewise = ed.vim.yank_linewise;
4280    ed.restore(lines, (top, left));
4281    ed.set_yank(saved_yank);
4282    ed.vim.yank_linewise = saved_linewise;
4283}
4284
4285fn block_yank<H: crate::types::Host>(
4286    ed: &Editor<hjkl_buffer::Buffer, H>,
4287    top: usize,
4288    bot: usize,
4289    left: usize,
4290    right: usize,
4291) -> String {
4292    let lines = buf_lines_to_vec(&ed.buffer);
4293    let mut rows: Vec<String> = Vec::new();
4294    for r in top..=bot {
4295        let line = match lines.get(r) {
4296            Some(l) => l,
4297            None => break,
4298        };
4299        let chars: Vec<char> = line.chars().collect();
4300        let end = (right + 1).min(chars.len());
4301        if left >= chars.len() {
4302            rows.push(String::new());
4303        } else {
4304            rows.push(chars[left..end].iter().collect());
4305        }
4306    }
4307    rows.join("\n")
4308}
4309
4310fn delete_block_contents<H: crate::types::Host>(
4311    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4312    top: usize,
4313    bot: usize,
4314    left: usize,
4315    right: usize,
4316) {
4317    use hjkl_buffer::{Edit, MotionKind, Position};
4318    ed.sync_buffer_content_from_textarea();
4319    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
4320    if last_row < top {
4321        return;
4322    }
4323    ed.mutate_edit(Edit::DeleteRange {
4324        start: Position::new(top, left),
4325        end: Position::new(last_row, right),
4326        kind: MotionKind::Block,
4327    });
4328    ed.push_buffer_cursor_to_textarea();
4329}
4330
4331/// Replace each character cell in the block with `ch`.
4332fn block_replace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>, ch: char) {
4333    let (top, bot, left, right) = block_bounds(ed);
4334    ed.push_undo();
4335    ed.sync_buffer_content_from_textarea();
4336    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4337    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4338        let chars: Vec<char> = lines[r].chars().collect();
4339        if left >= chars.len() {
4340            continue;
4341        }
4342        let end = (right + 1).min(chars.len());
4343        let before: String = chars[..left].iter().collect();
4344        let middle: String = std::iter::repeat_n(ch, end - left).collect();
4345        let after: String = chars[end..].iter().collect();
4346        lines[r] = format!("{before}{middle}{after}");
4347    }
4348    reset_textarea_lines(ed, lines);
4349    ed.vim.mode = Mode::Normal;
4350    ed.jump_cursor(top, left);
4351}
4352
4353/// Replace buffer content with `lines` while preserving the cursor.
4354/// Used by indent / outdent / block_replace to wholesale rewrite
4355/// rows without going through the per-edit funnel.
4356fn reset_textarea_lines<H: crate::types::Host>(
4357    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4358    lines: Vec<String>,
4359) {
4360    let cursor = ed.cursor();
4361    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
4362    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
4363    ed.mark_content_dirty();
4364}
4365
4366// ─── Visual-line helpers ───────────────────────────────────────────────────
4367
4368// ─── Text-object range computation ─────────────────────────────────────────
4369
4370/// Cursor position as `(row, col)`.
4371type Pos = (usize, usize);
4372
4373/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
4374/// last character to act on). `kind` is `Linewise` for line-oriented text
4375/// objects like paragraphs and `Exclusive` otherwise.
4376fn text_object_range<H: crate::types::Host>(
4377    ed: &Editor<hjkl_buffer::Buffer, H>,
4378    obj: TextObject,
4379    inner: bool,
4380) -> Option<(Pos, Pos, MotionKind)> {
4381    match obj {
4382        TextObject::Word { big } => {
4383            word_text_object(ed, inner, big).map(|(s, e)| (s, e, MotionKind::Exclusive))
4384        }
4385        TextObject::Quote(q) => {
4386            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4387        }
4388        TextObject::Bracket(open) => bracket_text_object(ed, open, inner),
4389        TextObject::Paragraph => {
4390            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Linewise))
4391        }
4392        TextObject::XmlTag => {
4393            tag_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4394        }
4395        TextObject::Sentence => {
4396            sentence_text_object(ed, inner).map(|(s, e)| (s, e, MotionKind::Exclusive))
4397        }
4398    }
4399}
4400
4401/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
4402/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
4403/// `None` when already at the buffer's edge in that direction.
4404fn sentence_boundary<H: crate::types::Host>(
4405    ed: &Editor<hjkl_buffer::Buffer, H>,
4406    forward: bool,
4407) -> Option<(usize, usize)> {
4408    let lines = buf_lines_to_vec(&ed.buffer);
4409    if lines.is_empty() {
4410        return None;
4411    }
4412    let pos_to_idx = |pos: (usize, usize)| -> usize {
4413        let mut idx = 0;
4414        for line in lines.iter().take(pos.0) {
4415            idx += line.chars().count() + 1;
4416        }
4417        idx + pos.1
4418    };
4419    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4420        for (r, line) in lines.iter().enumerate() {
4421            let len = line.chars().count();
4422            if idx <= len {
4423                return (r, idx);
4424            }
4425            idx -= len + 1;
4426        }
4427        let last = lines.len().saturating_sub(1);
4428        (last, lines[last].chars().count())
4429    };
4430    let mut chars: Vec<char> = Vec::new();
4431    for (r, line) in lines.iter().enumerate() {
4432        chars.extend(line.chars());
4433        if r + 1 < lines.len() {
4434            chars.push('\n');
4435        }
4436    }
4437    if chars.is_empty() {
4438        return None;
4439    }
4440    let total = chars.len();
4441    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
4442    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4443
4444    if forward {
4445        // Walk forward looking for a terminator run followed by
4446        // whitespace; land on the first non-whitespace cell after.
4447        let mut i = cursor_idx + 1;
4448        while i < total {
4449            if is_terminator(chars[i]) {
4450                while i + 1 < total && is_terminator(chars[i + 1]) {
4451                    i += 1;
4452                }
4453                if i + 1 >= total {
4454                    return None;
4455                }
4456                if chars[i + 1].is_whitespace() {
4457                    let mut j = i + 1;
4458                    while j < total && chars[j].is_whitespace() {
4459                        j += 1;
4460                    }
4461                    if j >= total {
4462                        return None;
4463                    }
4464                    return Some(idx_to_pos(j));
4465                }
4466            }
4467            i += 1;
4468        }
4469        None
4470    } else {
4471        // Walk backward to find the start of the current sentence (if
4472        // we're already at the start, jump to the previous sentence's
4473        // start instead).
4474        let find_start = |from: usize| -> Option<usize> {
4475            let mut start = from;
4476            while start > 0 {
4477                let prev = chars[start - 1];
4478                if prev.is_whitespace() {
4479                    let mut k = start - 1;
4480                    while k > 0 && chars[k - 1].is_whitespace() {
4481                        k -= 1;
4482                    }
4483                    if k > 0 && is_terminator(chars[k - 1]) {
4484                        break;
4485                    }
4486                }
4487                start -= 1;
4488            }
4489            while start < total && chars[start].is_whitespace() {
4490                start += 1;
4491            }
4492            (start < total).then_some(start)
4493        };
4494        let current_start = find_start(cursor_idx)?;
4495        if current_start < cursor_idx {
4496            return Some(idx_to_pos(current_start));
4497        }
4498        // Already at the sentence start — step over the boundary into
4499        // the previous sentence and find its start.
4500        let mut k = current_start;
4501        while k > 0 && chars[k - 1].is_whitespace() {
4502            k -= 1;
4503        }
4504        if k == 0 {
4505            return None;
4506        }
4507        let prev_start = find_start(k - 1)?;
4508        Some(idx_to_pos(prev_start))
4509    }
4510}
4511
4512/// `is` / `as` — sentence: text up to and including the next sentence
4513/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
4514/// whitespace (or end-of-line) as a boundary; runs of consecutive
4515/// terminators stay attached to the same sentence. `as` extends to
4516/// include trailing whitespace; `is` does not.
4517fn sentence_text_object<H: crate::types::Host>(
4518    ed: &Editor<hjkl_buffer::Buffer, H>,
4519    inner: bool,
4520) -> Option<((usize, usize), (usize, usize))> {
4521    let lines = buf_lines_to_vec(&ed.buffer);
4522    if lines.is_empty() {
4523        return None;
4524    }
4525    // Flatten the buffer so a sentence can span lines (vim's behaviour).
4526    // Newlines count as whitespace for boundary detection.
4527    let pos_to_idx = |pos: (usize, usize)| -> usize {
4528        let mut idx = 0;
4529        for line in lines.iter().take(pos.0) {
4530            idx += line.chars().count() + 1;
4531        }
4532        idx + pos.1
4533    };
4534    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4535        for (r, line) in lines.iter().enumerate() {
4536            let len = line.chars().count();
4537            if idx <= len {
4538                return (r, idx);
4539            }
4540            idx -= len + 1;
4541        }
4542        let last = lines.len().saturating_sub(1);
4543        (last, lines[last].chars().count())
4544    };
4545    let mut chars: Vec<char> = Vec::new();
4546    for (r, line) in lines.iter().enumerate() {
4547        chars.extend(line.chars());
4548        if r + 1 < lines.len() {
4549            chars.push('\n');
4550        }
4551    }
4552    if chars.is_empty() {
4553        return None;
4554    }
4555
4556    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
4557    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4558
4559    // Walk backward from cursor to find the start of the current
4560    // sentence. A boundary is: whitespace immediately after a run of
4561    // terminators (or start-of-buffer).
4562    let mut start = cursor_idx;
4563    while start > 0 {
4564        let prev = chars[start - 1];
4565        if prev.is_whitespace() {
4566            // Check if the whitespace follows a terminator — if so,
4567            // we've crossed a sentence boundary; the sentence begins
4568            // at the first non-whitespace cell *after* this run.
4569            let mut k = start - 1;
4570            while k > 0 && chars[k - 1].is_whitespace() {
4571                k -= 1;
4572            }
4573            if k > 0 && is_terminator(chars[k - 1]) {
4574                break;
4575            }
4576        }
4577        start -= 1;
4578    }
4579    // Skip leading whitespace (vim doesn't include it in the
4580    // sentence body).
4581    while start < chars.len() && chars[start].is_whitespace() {
4582        start += 1;
4583    }
4584    if start >= chars.len() {
4585        return None;
4586    }
4587
4588    // Walk forward to the sentence end (last terminator before the
4589    // next whitespace boundary).
4590    let mut end = start;
4591    while end < chars.len() {
4592        if is_terminator(chars[end]) {
4593            // Consume any consecutive terminators (e.g. `?!`).
4594            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
4595                end += 1;
4596            }
4597            // If followed by whitespace or end-of-buffer, that's the
4598            // boundary.
4599            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
4600                break;
4601            }
4602        }
4603        end += 1;
4604    }
4605    // Inclusive end → exclusive end_idx.
4606    let end_idx = (end + 1).min(chars.len());
4607
4608    let final_end = if inner {
4609        end_idx
4610    } else {
4611        // `as`: include trailing whitespace (but stop before the next
4612        // newline so we don't gobble a paragraph break — vim keeps
4613        // sentences within a paragraph for the trailing-ws extension).
4614        let mut e = end_idx;
4615        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
4616            e += 1;
4617        }
4618        e
4619    };
4620
4621    Some((idx_to_pos(start), idx_to_pos(final_end)))
4622}
4623
4624/// `it` / `at` — XML tag pair text object. Builds a flat char index of
4625/// the buffer, walks `<...>` tokens to pair tags via a stack, and
4626/// returns the innermost pair containing the cursor.
4627fn tag_text_object<H: crate::types::Host>(
4628    ed: &Editor<hjkl_buffer::Buffer, H>,
4629    inner: bool,
4630) -> Option<((usize, usize), (usize, usize))> {
4631    let lines = buf_lines_to_vec(&ed.buffer);
4632    if lines.is_empty() {
4633        return None;
4634    }
4635    // Flatten char positions so we can compare cursor against tag
4636    // ranges without per-row arithmetic. `\n` between lines counts as
4637    // a single char.
4638    let pos_to_idx = |pos: (usize, usize)| -> usize {
4639        let mut idx = 0;
4640        for line in lines.iter().take(pos.0) {
4641            idx += line.chars().count() + 1;
4642        }
4643        idx + pos.1
4644    };
4645    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4646        for (r, line) in lines.iter().enumerate() {
4647            let len = line.chars().count();
4648            if idx <= len {
4649                return (r, idx);
4650            }
4651            idx -= len + 1;
4652        }
4653        let last = lines.len().saturating_sub(1);
4654        (last, lines[last].chars().count())
4655    };
4656    let mut chars: Vec<char> = Vec::new();
4657    for (r, line) in lines.iter().enumerate() {
4658        chars.extend(line.chars());
4659        if r + 1 < lines.len() {
4660            chars.push('\n');
4661        }
4662    }
4663    let cursor_idx = pos_to_idx(ed.cursor());
4664
4665    // Walk `<...>` tokens. Track open tags on a stack; on a matching
4666    // close pop and consider the pair a candidate when the cursor lies
4667    // inside its content range. Innermost wins (replace whenever a
4668    // tighter range turns up). Also track the first complete pair that
4669    // starts at or after the cursor so we can fall back to a forward
4670    // scan (targets.vim-style) when the cursor isn't inside any tag.
4671    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
4672    let mut innermost: Option<(usize, usize, usize, usize)> = None;
4673    let mut next_after: Option<(usize, usize, usize, usize)> = None;
4674    let mut i = 0;
4675    while i < chars.len() {
4676        if chars[i] != '<' {
4677            i += 1;
4678            continue;
4679        }
4680        let mut j = i + 1;
4681        while j < chars.len() && chars[j] != '>' {
4682            j += 1;
4683        }
4684        if j >= chars.len() {
4685            break;
4686        }
4687        let inside: String = chars[i + 1..j].iter().collect();
4688        let close_end = j + 1;
4689        let trimmed = inside.trim();
4690        if trimmed.starts_with('!') || trimmed.starts_with('?') {
4691            i = close_end;
4692            continue;
4693        }
4694        if let Some(rest) = trimmed.strip_prefix('/') {
4695            let name = rest.split_whitespace().next().unwrap_or("").to_string();
4696            if !name.is_empty()
4697                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
4698            {
4699                let (open_start, content_start, _) = stack[stack_idx].clone();
4700                stack.truncate(stack_idx);
4701                let content_end = i;
4702                let candidate = (open_start, content_start, content_end, close_end);
4703                if cursor_idx >= content_start && cursor_idx <= content_end {
4704                    innermost = match innermost {
4705                        Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
4706                            Some(candidate)
4707                        }
4708                        None => Some(candidate),
4709                        existing => existing,
4710                    };
4711                } else if open_start >= cursor_idx && next_after.is_none() {
4712                    next_after = Some(candidate);
4713                }
4714            }
4715        } else if !trimmed.ends_with('/') {
4716            let name: String = trimmed
4717                .split(|c: char| c.is_whitespace() || c == '/')
4718                .next()
4719                .unwrap_or("")
4720                .to_string();
4721            if !name.is_empty() {
4722                stack.push((i, close_end, name));
4723            }
4724        }
4725        i = close_end;
4726    }
4727
4728    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
4729    if inner {
4730        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
4731    } else {
4732        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
4733    }
4734}
4735
4736fn is_wordchar(c: char) -> bool {
4737    c.is_alphanumeric() || c == '_'
4738}
4739
4740// `is_keyword_char` lives in hjkl-buffer (used by word motions);
4741// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
4742// one parser, one default, one bug surface.
4743pub(crate) use hjkl_buffer::is_keyword_char;
4744
4745fn word_text_object<H: crate::types::Host>(
4746    ed: &Editor<hjkl_buffer::Buffer, H>,
4747    inner: bool,
4748    big: bool,
4749) -> Option<((usize, usize), (usize, usize))> {
4750    let (row, col) = ed.cursor();
4751    let line = buf_line(&ed.buffer, row)?;
4752    let chars: Vec<char> = line.chars().collect();
4753    if chars.is_empty() {
4754        return None;
4755    }
4756    let at = col.min(chars.len().saturating_sub(1));
4757    let classify = |c: char| -> u8 {
4758        if c.is_whitespace() {
4759            0
4760        } else if big || is_wordchar(c) {
4761            1
4762        } else {
4763            2
4764        }
4765    };
4766    let cls = classify(chars[at]);
4767    let mut start = at;
4768    while start > 0 && classify(chars[start - 1]) == cls {
4769        start -= 1;
4770    }
4771    let mut end = at;
4772    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
4773        end += 1;
4774    }
4775    // Byte-offset helpers.
4776    let char_byte = |i: usize| {
4777        if i >= chars.len() {
4778            line.len()
4779        } else {
4780            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
4781        }
4782    };
4783    let mut start_col = char_byte(start);
4784    // Exclusive end: byte index of char AFTER the last-included char.
4785    let mut end_col = char_byte(end + 1);
4786    if !inner {
4787        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
4788        let mut t = end + 1;
4789        let mut included_trailing = false;
4790        while t < chars.len() && chars[t].is_whitespace() {
4791            included_trailing = true;
4792            t += 1;
4793        }
4794        if included_trailing {
4795            end_col = char_byte(t);
4796        } else {
4797            let mut s = start;
4798            while s > 0 && chars[s - 1].is_whitespace() {
4799                s -= 1;
4800            }
4801            start_col = char_byte(s);
4802        }
4803    }
4804    Some(((row, start_col), (row, end_col)))
4805}
4806
4807fn quote_text_object<H: crate::types::Host>(
4808    ed: &Editor<hjkl_buffer::Buffer, H>,
4809    q: char,
4810    inner: bool,
4811) -> Option<((usize, usize), (usize, usize))> {
4812    let (row, col) = ed.cursor();
4813    let line = buf_line(&ed.buffer, row)?;
4814    let bytes = line.as_bytes();
4815    let q_byte = q as u8;
4816    // Find opening and closing quote on the same line.
4817    let mut positions: Vec<usize> = Vec::new();
4818    for (i, &b) in bytes.iter().enumerate() {
4819        if b == q_byte {
4820            positions.push(i);
4821        }
4822    }
4823    if positions.len() < 2 {
4824        return None;
4825    }
4826    let mut open_idx: Option<usize> = None;
4827    let mut close_idx: Option<usize> = None;
4828    for pair in positions.chunks(2) {
4829        if pair.len() < 2 {
4830            break;
4831        }
4832        if col >= pair[0] && col <= pair[1] {
4833            open_idx = Some(pair[0]);
4834            close_idx = Some(pair[1]);
4835            break;
4836        }
4837        if col < pair[0] {
4838            open_idx = Some(pair[0]);
4839            close_idx = Some(pair[1]);
4840            break;
4841        }
4842    }
4843    let open = open_idx?;
4844    let close = close_idx?;
4845    // End columns are *exclusive* — one past the last character to act on.
4846    if inner {
4847        if close <= open + 1 {
4848            return None;
4849        }
4850        Some(((row, open + 1), (row, close)))
4851    } else {
4852        // `da<q>` — "around" includes the surrounding whitespace on one
4853        // side: trailing whitespace if any exists after the closing quote;
4854        // otherwise leading whitespace before the opening quote. This
4855        // matches vim's `:help text-objects` behaviour and avoids leaving
4856        // a double-space when the quoted span sits mid-sentence.
4857        let after_close = close + 1; // byte index after closing quote
4858        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
4859            // Eat trailing whitespace run.
4860            let mut end = after_close;
4861            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
4862                end += 1;
4863            }
4864            Some(((row, open), (row, end)))
4865        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
4866            // Eat leading whitespace run.
4867            let mut start = open;
4868            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
4869                start -= 1;
4870            }
4871            Some(((row, start), (row, close + 1)))
4872        } else {
4873            Some(((row, open), (row, close + 1)))
4874        }
4875    }
4876}
4877
4878fn bracket_text_object<H: crate::types::Host>(
4879    ed: &Editor<hjkl_buffer::Buffer, H>,
4880    open: char,
4881    inner: bool,
4882) -> Option<(Pos, Pos, MotionKind)> {
4883    let close = match open {
4884        '(' => ')',
4885        '[' => ']',
4886        '{' => '}',
4887        '<' => '>',
4888        _ => return None,
4889    };
4890    let (row, col) = ed.cursor();
4891    let lines = buf_lines_to_vec(&ed.buffer);
4892    let lines = lines.as_slice();
4893    // Walk backward from cursor to find unbalanced opening. When the
4894    // cursor isn't inside any pair, fall back to scanning forward for
4895    // the next opening bracket (targets.vim-style: `ci(` works when
4896    // cursor is before the `(` on the same line or below).
4897    let open_pos = find_open_bracket(lines, row, col, open, close)
4898        .or_else(|| find_next_open(lines, row, col, open))?;
4899    let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
4900    // End positions are *exclusive*.
4901    if inner {
4902        // Multi-line `iB` / `i{` etc: vim deletes the full lines between
4903        // the braces (linewise), preserving the `{` and `}` lines
4904        // themselves and the newlines that directly abut them. E.g.:
4905        //   {\n    body\n}\n  →  {\n}\n    (cursor on `}` line)
4906        // Single-line `i{` falls back to charwise exclusive.
4907        if close_pos.0 > open_pos.0 + 1 {
4908            // There is at least one line strictly between open and close.
4909            let inner_row_start = open_pos.0 + 1;
4910            let inner_row_end = close_pos.0 - 1;
4911            let end_col = lines
4912                .get(inner_row_end)
4913                .map(|l| l.chars().count())
4914                .unwrap_or(0);
4915            return Some((
4916                (inner_row_start, 0),
4917                (inner_row_end, end_col),
4918                MotionKind::Linewise,
4919            ));
4920        }
4921        let inner_start = advance_pos(lines, open_pos);
4922        if inner_start.0 > close_pos.0
4923            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
4924        {
4925            return None;
4926        }
4927        Some((inner_start, close_pos, MotionKind::Exclusive))
4928    } else {
4929        Some((
4930            open_pos,
4931            advance_pos(lines, close_pos),
4932            MotionKind::Exclusive,
4933        ))
4934    }
4935}
4936
4937fn find_open_bracket(
4938    lines: &[String],
4939    row: usize,
4940    col: usize,
4941    open: char,
4942    close: char,
4943) -> Option<(usize, usize)> {
4944    let mut depth: i32 = 0;
4945    let mut r = row;
4946    let mut c = col as isize;
4947    loop {
4948        let cur = &lines[r];
4949        let chars: Vec<char> = cur.chars().collect();
4950        // Clamp `c` to the line length: callers may seed `col` past
4951        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
4952        // so direct indexing would panic on empty / short lines.
4953        if (c as usize) >= chars.len() {
4954            c = chars.len() as isize - 1;
4955        }
4956        while c >= 0 {
4957            let ch = chars[c as usize];
4958            if ch == close {
4959                depth += 1;
4960            } else if ch == open {
4961                if depth == 0 {
4962                    return Some((r, c as usize));
4963                }
4964                depth -= 1;
4965            }
4966            c -= 1;
4967        }
4968        if r == 0 {
4969            return None;
4970        }
4971        r -= 1;
4972        c = lines[r].chars().count() as isize - 1;
4973    }
4974}
4975
4976fn find_close_bracket(
4977    lines: &[String],
4978    row: usize,
4979    start_col: usize,
4980    open: char,
4981    close: char,
4982) -> Option<(usize, usize)> {
4983    let mut depth: i32 = 0;
4984    let mut r = row;
4985    let mut c = start_col;
4986    loop {
4987        let cur = &lines[r];
4988        let chars: Vec<char> = cur.chars().collect();
4989        while c < chars.len() {
4990            let ch = chars[c];
4991            if ch == open {
4992                depth += 1;
4993            } else if ch == close {
4994                if depth == 0 {
4995                    return Some((r, c));
4996                }
4997                depth -= 1;
4998            }
4999            c += 1;
5000        }
5001        if r + 1 >= lines.len() {
5002            return None;
5003        }
5004        r += 1;
5005        c = 0;
5006    }
5007}
5008
5009/// Forward scan from `(row, col)` for the next occurrence of `open`.
5010/// Multi-line. Used by bracket text objects to support targets.vim-style
5011/// "search forward when not currently inside a pair" behaviour.
5012fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
5013    let mut r = row;
5014    let mut c = col;
5015    while r < lines.len() {
5016        let chars: Vec<char> = lines[r].chars().collect();
5017        while c < chars.len() {
5018            if chars[c] == open {
5019                return Some((r, c));
5020            }
5021            c += 1;
5022        }
5023        r += 1;
5024        c = 0;
5025    }
5026    None
5027}
5028
5029fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
5030    let (r, c) = pos;
5031    let line_len = lines[r].chars().count();
5032    if c < line_len {
5033        (r, c + 1)
5034    } else if r + 1 < lines.len() {
5035        (r + 1, 0)
5036    } else {
5037        pos
5038    }
5039}
5040
5041fn paragraph_text_object<H: crate::types::Host>(
5042    ed: &Editor<hjkl_buffer::Buffer, H>,
5043    inner: bool,
5044) -> Option<((usize, usize), (usize, usize))> {
5045    let (row, _) = ed.cursor();
5046    let lines = buf_lines_to_vec(&ed.buffer);
5047    if lines.is_empty() {
5048        return None;
5049    }
5050    // A paragraph is a run of non-blank lines.
5051    let is_blank = |r: usize| lines.get(r).map(|s| s.trim().is_empty()).unwrap_or(true);
5052    if is_blank(row) {
5053        return None;
5054    }
5055    let mut top = row;
5056    while top > 0 && !is_blank(top - 1) {
5057        top -= 1;
5058    }
5059    let mut bot = row;
5060    while bot + 1 < lines.len() && !is_blank(bot + 1) {
5061        bot += 1;
5062    }
5063    // For `ap`, include one trailing blank line if present.
5064    if !inner && bot + 1 < lines.len() && is_blank(bot + 1) {
5065        bot += 1;
5066    }
5067    let end_col = lines[bot].chars().count();
5068    Some(((top, 0), (bot, end_col)))
5069}
5070
5071// ─── Individual commands ───────────────────────────────────────────────────
5072
5073/// Read the text in a vim-shaped range without mutating. Used by
5074/// `Operator::Yank` so we can pipe the same range translation as
5075/// [`cut_vim_range`] but skip the delete + inverse extraction.
5076fn read_vim_range<H: crate::types::Host>(
5077    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5078    start: (usize, usize),
5079    end: (usize, usize),
5080    kind: MotionKind,
5081) -> String {
5082    let (top, bot) = order(start, end);
5083    ed.sync_buffer_content_from_textarea();
5084    let lines = buf_lines_to_vec(&ed.buffer);
5085    match kind {
5086        MotionKind::Linewise => {
5087            let lo = top.0;
5088            let hi = bot.0.min(lines.len().saturating_sub(1));
5089            let mut text = lines[lo..=hi].join("\n");
5090            text.push('\n');
5091            text
5092        }
5093        MotionKind::Inclusive | MotionKind::Exclusive => {
5094            let inclusive = matches!(kind, MotionKind::Inclusive);
5095            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
5096            let mut out = String::new();
5097            for row in top.0..=bot.0 {
5098                let line = lines.get(row).map(String::as_str).unwrap_or("");
5099                let lo = if row == top.0 { top.1 } else { 0 };
5100                let hi_unclamped = if row == bot.0 {
5101                    if inclusive { bot.1 + 1 } else { bot.1 }
5102                } else {
5103                    line.chars().count() + 1
5104                };
5105                let row_chars: Vec<char> = line.chars().collect();
5106                let hi = hi_unclamped.min(row_chars.len());
5107                if lo < hi {
5108                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
5109                }
5110                if row < bot.0 {
5111                    out.push('\n');
5112                }
5113            }
5114            out
5115        }
5116    }
5117}
5118
5119/// Cut a vim-shaped range through the Buffer edit funnel and return
5120/// the deleted text. Translates vim's `MotionKind`
5121/// (Linewise/Inclusive/Exclusive) into the buffer's
5122/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
5123/// position adjustment so inclusive motions actually include the bot
5124/// cell. Pushes the cut text into both `last_yank` and the textarea
5125/// yank buffer (still observed by `p`/`P` until the paste path is
5126/// ported), and updates `yank_linewise` for linewise cuts.
5127fn cut_vim_range<H: crate::types::Host>(
5128    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5129    start: (usize, usize),
5130    end: (usize, usize),
5131    kind: MotionKind,
5132) -> String {
5133    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5134    let (top, bot) = order(start, end);
5135    ed.sync_buffer_content_from_textarea();
5136    let (buf_start, buf_end, buf_kind) = match kind {
5137        MotionKind::Linewise => (
5138            Position::new(top.0, 0),
5139            Position::new(bot.0, 0),
5140            BufKind::Line,
5141        ),
5142        MotionKind::Inclusive => {
5143            let line_chars = buf_line_chars(&ed.buffer, bot.0);
5144            // Advance one cell past `bot` so the buffer's exclusive
5145            // `cut_chars` actually drops the inclusive endpoint. Wrap
5146            // to the next row when bot already sits on the last char.
5147            let next = if bot.1 < line_chars {
5148                Position::new(bot.0, bot.1 + 1)
5149            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
5150                Position::new(bot.0 + 1, 0)
5151            } else {
5152                Position::new(bot.0, line_chars)
5153            };
5154            (Position::new(top.0, top.1), next, BufKind::Char)
5155        }
5156        MotionKind::Exclusive => (
5157            Position::new(top.0, top.1),
5158            Position::new(bot.0, bot.1),
5159            BufKind::Char,
5160        ),
5161    };
5162    let inverse = ed.mutate_edit(Edit::DeleteRange {
5163        start: buf_start,
5164        end: buf_end,
5165        kind: buf_kind,
5166    });
5167    let text = match inverse {
5168        Edit::InsertStr { text, .. } => text,
5169        _ => String::new(),
5170    };
5171    if !text.is_empty() {
5172        ed.record_yank_to_host(text.clone());
5173        ed.record_delete(text.clone(), matches!(kind, MotionKind::Linewise));
5174    }
5175    ed.push_buffer_cursor_to_textarea();
5176    text
5177}
5178
5179/// `D` / `C` — delete from cursor to end of line through the edit
5180/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
5181/// textarea's yank buffer (still observed by `p`/`P` until the paste
5182/// path is ported). Cursor lands at the deletion start so the caller
5183/// can decide whether to step it left (`D`) or open insert mode (`C`).
5184fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5185    use hjkl_buffer::{Edit, MotionKind, Position};
5186    ed.sync_buffer_content_from_textarea();
5187    let cursor = buf_cursor_pos(&ed.buffer);
5188    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5189    if cursor.col >= line_chars {
5190        return;
5191    }
5192    let inverse = ed.mutate_edit(Edit::DeleteRange {
5193        start: cursor,
5194        end: Position::new(cursor.row, line_chars),
5195        kind: MotionKind::Char,
5196    });
5197    if let Edit::InsertStr { text, .. } = inverse
5198        && !text.is_empty()
5199    {
5200        ed.record_yank_to_host(text.clone());
5201        ed.vim.yank_linewise = false;
5202        ed.set_yank(text);
5203    }
5204    buf_set_cursor_pos(&mut ed.buffer, cursor);
5205    ed.push_buffer_cursor_to_textarea();
5206}
5207
5208fn do_char_delete<H: crate::types::Host>(
5209    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5210    forward: bool,
5211    count: usize,
5212) {
5213    use hjkl_buffer::{Edit, MotionKind, Position};
5214    ed.push_undo();
5215    ed.sync_buffer_content_from_textarea();
5216    // Collect deleted chars so we can write them to the unnamed register
5217    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
5218    let mut deleted = String::new();
5219    for _ in 0..count {
5220        let cursor = buf_cursor_pos(&ed.buffer);
5221        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5222        if forward {
5223            // `x` — delete the char under the cursor. Vim no-ops on
5224            // an empty line; the buffer would drop a row otherwise.
5225            if cursor.col >= line_chars {
5226                continue;
5227            }
5228            let inverse = ed.mutate_edit(Edit::DeleteRange {
5229                start: cursor,
5230                end: Position::new(cursor.row, cursor.col + 1),
5231                kind: MotionKind::Char,
5232            });
5233            if let Edit::InsertStr { text, .. } = inverse {
5234                deleted.push_str(&text);
5235            }
5236        } else {
5237            // `X` — delete the char before the cursor.
5238            if cursor.col == 0 {
5239                continue;
5240            }
5241            let inverse = ed.mutate_edit(Edit::DeleteRange {
5242                start: Position::new(cursor.row, cursor.col - 1),
5243                end: cursor,
5244                kind: MotionKind::Char,
5245            });
5246            if let Edit::InsertStr { text, .. } = inverse {
5247                // X deletes backwards; prepend so the register text
5248                // matches reading order (first deleted char first).
5249                deleted = text + &deleted;
5250            }
5251        }
5252    }
5253    if !deleted.is_empty() {
5254        ed.record_yank_to_host(deleted.clone());
5255        ed.record_delete(deleted, false);
5256    }
5257    ed.push_buffer_cursor_to_textarea();
5258}
5259
5260/// Vim `Ctrl-a` / `Ctrl-x` — find the next decimal number at or after the
5261/// cursor on the current line, add `delta`, leave the cursor on the last
5262/// digit of the result. No-op if the line has no digits to the right.
5263fn adjust_number<H: crate::types::Host>(
5264    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5265    delta: i64,
5266) -> bool {
5267    use hjkl_buffer::{Edit, MotionKind, Position};
5268    ed.sync_buffer_content_from_textarea();
5269    let cursor = buf_cursor_pos(&ed.buffer);
5270    let row = cursor.row;
5271    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
5272        Some(l) => l.chars().collect(),
5273        None => return false,
5274    };
5275    let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
5276        return false;
5277    };
5278    let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
5279        digit_start - 1
5280    } else {
5281        digit_start
5282    };
5283    let mut span_end = digit_start;
5284    while span_end < chars.len() && chars[span_end].is_ascii_digit() {
5285        span_end += 1;
5286    }
5287    let s: String = chars[span_start..span_end].iter().collect();
5288    let Ok(n) = s.parse::<i64>() else {
5289        return false;
5290    };
5291    let new_s = n.saturating_add(delta).to_string();
5292
5293    ed.push_undo();
5294    let span_start_pos = Position::new(row, span_start);
5295    let span_end_pos = Position::new(row, span_end);
5296    ed.mutate_edit(Edit::DeleteRange {
5297        start: span_start_pos,
5298        end: span_end_pos,
5299        kind: MotionKind::Char,
5300    });
5301    ed.mutate_edit(Edit::InsertStr {
5302        at: span_start_pos,
5303        text: new_s.clone(),
5304    });
5305    let new_len = new_s.chars().count();
5306    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
5307    ed.push_buffer_cursor_to_textarea();
5308    true
5309}
5310
5311fn replace_char<H: crate::types::Host>(
5312    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5313    ch: char,
5314    count: usize,
5315) {
5316    use hjkl_buffer::{Edit, MotionKind, Position};
5317    ed.push_undo();
5318    ed.sync_buffer_content_from_textarea();
5319    for _ in 0..count {
5320        let cursor = buf_cursor_pos(&ed.buffer);
5321        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5322        if cursor.col >= line_chars {
5323            break;
5324        }
5325        ed.mutate_edit(Edit::DeleteRange {
5326            start: cursor,
5327            end: Position::new(cursor.row, cursor.col + 1),
5328            kind: MotionKind::Char,
5329        });
5330        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
5331    }
5332    // Vim leaves the cursor on the last replaced char.
5333    crate::motions::move_left(&mut ed.buffer, 1);
5334    ed.push_buffer_cursor_to_textarea();
5335}
5336
5337fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5338    use hjkl_buffer::{Edit, MotionKind, Position};
5339    ed.sync_buffer_content_from_textarea();
5340    let cursor = buf_cursor_pos(&ed.buffer);
5341    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
5342        return;
5343    };
5344    let toggled = if c.is_uppercase() {
5345        c.to_lowercase().next().unwrap_or(c)
5346    } else {
5347        c.to_uppercase().next().unwrap_or(c)
5348    };
5349    ed.mutate_edit(Edit::DeleteRange {
5350        start: cursor,
5351        end: Position::new(cursor.row, cursor.col + 1),
5352        kind: MotionKind::Char,
5353    });
5354    ed.mutate_edit(Edit::InsertChar {
5355        at: cursor,
5356        ch: toggled,
5357    });
5358}
5359
5360fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5361    use hjkl_buffer::{Edit, Position};
5362    ed.sync_buffer_content_from_textarea();
5363    let row = buf_cursor_pos(&ed.buffer).row;
5364    if row + 1 >= buf_row_count(&ed.buffer) {
5365        return;
5366    }
5367    let cur_line = buf_line(&ed.buffer, row).unwrap_or("").to_string();
5368    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or("").to_string();
5369    let next_trimmed = next_raw.trim_start();
5370    let cur_chars = cur_line.chars().count();
5371    let next_chars = next_raw.chars().count();
5372    // `J` inserts a single space iff both sides are non-empty after
5373    // stripping the next line's leading whitespace.
5374    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
5375        " "
5376    } else {
5377        ""
5378    };
5379    let joined = format!("{cur_line}{separator}{next_trimmed}");
5380    ed.mutate_edit(Edit::Replace {
5381        start: Position::new(row, 0),
5382        end: Position::new(row + 1, next_chars),
5383        with: joined,
5384    });
5385    // Vim parks the cursor on the inserted space — or at the join
5386    // point when no space went in (which is the same column either
5387    // way, since the space sits exactly at `cur_chars`).
5388    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
5389    ed.push_buffer_cursor_to_textarea();
5390}
5391
5392/// `gJ` — join the next line onto the current one without inserting a
5393/// separating space or stripping leading whitespace.
5394fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5395    use hjkl_buffer::Edit;
5396    ed.sync_buffer_content_from_textarea();
5397    let row = buf_cursor_pos(&ed.buffer).row;
5398    if row + 1 >= buf_row_count(&ed.buffer) {
5399        return;
5400    }
5401    let join_col = buf_line_chars(&ed.buffer, row);
5402    ed.mutate_edit(Edit::JoinLines {
5403        row,
5404        count: 1,
5405        with_space: false,
5406    });
5407    // Vim leaves the cursor at the join point (end of original line).
5408    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
5409    ed.push_buffer_cursor_to_textarea();
5410}
5411
5412fn do_paste<H: crate::types::Host>(
5413    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5414    before: bool,
5415    count: usize,
5416) {
5417    use hjkl_buffer::{Edit, Position};
5418    ed.push_undo();
5419    // Resolve the source register: `"reg` prefix (consumed) or the
5420    // unnamed register otherwise. Read text + linewise from the
5421    // selected slot rather than the global `vim.yank_linewise` so
5422    // pasting from `"0` after a delete still uses the yank's layout.
5423    let selector = ed.vim.pending_register.take();
5424    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
5425        Some(slot) => (slot.text.clone(), slot.linewise),
5426        // Read both fields from the unnamed slot rather than mixing the
5427        // slot's text with `vim.yank_linewise`. The cached vim flag is
5428        // per-editor, so a register imported from another editor (e.g.
5429        // cross-buffer yank/paste) carried the wrong linewise without
5430        // this — pasting a linewise yank inserted at the char cursor.
5431        None => {
5432            let s = &ed.registers().unnamed;
5433            (s.text.clone(), s.linewise)
5434        }
5435    };
5436    for _ in 0..count {
5437        ed.sync_buffer_content_from_textarea();
5438        let yank = yank.clone();
5439        if yank.is_empty() {
5440            continue;
5441        }
5442        if linewise {
5443            // Linewise paste: insert payload as fresh row(s) above
5444            // (`P`) or below (`p`) the cursor's row. Cursor lands on
5445            // the first non-blank of the first pasted line.
5446            let text = yank.trim_matches('\n').to_string();
5447            let row = buf_cursor_pos(&ed.buffer).row;
5448            let target_row = if before {
5449                ed.mutate_edit(Edit::InsertStr {
5450                    at: Position::new(row, 0),
5451                    text: format!("{text}\n"),
5452                });
5453                row
5454            } else {
5455                let line_chars = buf_line_chars(&ed.buffer, row);
5456                ed.mutate_edit(Edit::InsertStr {
5457                    at: Position::new(row, line_chars),
5458                    text: format!("\n{text}"),
5459                });
5460                row + 1
5461            };
5462            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
5463            crate::motions::move_first_non_blank(&mut ed.buffer);
5464            ed.push_buffer_cursor_to_textarea();
5465        } else {
5466            // Charwise paste. `P` inserts at cursor (shifting cell
5467            // right); `p` inserts after cursor (advance one cell
5468            // first, clamped to the end of the line).
5469            let cursor = buf_cursor_pos(&ed.buffer);
5470            let at = if before {
5471                cursor
5472            } else {
5473                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5474                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
5475            };
5476            ed.mutate_edit(Edit::InsertStr {
5477                at,
5478                text: yank.clone(),
5479            });
5480            // Vim parks the cursor on the last char of the pasted
5481            // text (do_insert_str leaves it one past the end).
5482            crate::motions::move_left(&mut ed.buffer, 1);
5483            ed.push_buffer_cursor_to_textarea();
5484        }
5485    }
5486    // Any paste re-anchors the sticky column to the new cursor position.
5487    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
5488}
5489
5490pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5491    if let Some((lines, cursor)) = ed.undo_stack.pop() {
5492        let current = ed.snapshot();
5493        ed.redo_stack.push(current);
5494        ed.restore(lines, cursor);
5495    }
5496    ed.vim.mode = Mode::Normal;
5497    // The restored cursor came from a snapshot taken in insert mode
5498    // (before the insert started) and may be past the last valid
5499    // normal-mode column. Clamp it now, same as Esc-from-insert does.
5500    clamp_cursor_to_normal_mode(ed);
5501}
5502
5503pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5504    if let Some((lines, cursor)) = ed.redo_stack.pop() {
5505        let current = ed.snapshot();
5506        ed.undo_stack.push(current);
5507        ed.cap_undo();
5508        ed.restore(lines, cursor);
5509    }
5510    ed.vim.mode = Mode::Normal;
5511}
5512
5513// ─── Dot repeat ────────────────────────────────────────────────────────────
5514
5515/// Replay-side helper: insert `text` at the cursor through the
5516/// edit funnel, then leave insert mode (the original change ended
5517/// with Esc, so the dot-repeat must end the same way — including
5518/// the cursor step-back vim does on Esc-from-insert).
5519fn replay_insert_and_finish<H: crate::types::Host>(
5520    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5521    text: &str,
5522) {
5523    use hjkl_buffer::{Edit, Position};
5524    let cursor = ed.cursor();
5525    ed.mutate_edit(Edit::InsertStr {
5526        at: Position::new(cursor.0, cursor.1),
5527        text: text.to_string(),
5528    });
5529    if ed.vim.insert_session.take().is_some() {
5530        if ed.cursor().1 > 0 {
5531            crate::motions::move_left(&mut ed.buffer, 1);
5532            ed.push_buffer_cursor_to_textarea();
5533        }
5534        ed.vim.mode = Mode::Normal;
5535    }
5536}
5537
5538fn replay_last_change<H: crate::types::Host>(
5539    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5540    outer_count: usize,
5541) {
5542    let Some(change) = ed.vim.last_change.clone() else {
5543        return;
5544    };
5545    ed.vim.replaying = true;
5546    let scale = if outer_count > 0 { outer_count } else { 1 };
5547    match change {
5548        LastChange::OpMotion {
5549            op,
5550            motion,
5551            count,
5552            inserted,
5553        } => {
5554            let total = count.max(1) * scale;
5555            apply_op_with_motion(ed, op, &motion, total);
5556            if let Some(text) = inserted {
5557                replay_insert_and_finish(ed, &text);
5558            }
5559        }
5560        LastChange::OpTextObj {
5561            op,
5562            obj,
5563            inner,
5564            inserted,
5565        } => {
5566            apply_op_with_text_object(ed, op, obj, inner);
5567            if let Some(text) = inserted {
5568                replay_insert_and_finish(ed, &text);
5569            }
5570        }
5571        LastChange::LineOp {
5572            op,
5573            count,
5574            inserted,
5575        } => {
5576            let total = count.max(1) * scale;
5577            execute_line_op(ed, op, total);
5578            if let Some(text) = inserted {
5579                replay_insert_and_finish(ed, &text);
5580            }
5581        }
5582        LastChange::CharDel { forward, count } => {
5583            do_char_delete(ed, forward, count * scale);
5584        }
5585        LastChange::ReplaceChar { ch, count } => {
5586            replace_char(ed, ch, count * scale);
5587        }
5588        LastChange::ToggleCase { count } => {
5589            for _ in 0..count * scale {
5590                ed.push_undo();
5591                toggle_case_at_cursor(ed);
5592            }
5593        }
5594        LastChange::JoinLine { count } => {
5595            for _ in 0..count * scale {
5596                ed.push_undo();
5597                join_line(ed);
5598            }
5599        }
5600        LastChange::Paste { before, count } => {
5601            do_paste(ed, before, count * scale);
5602        }
5603        LastChange::DeleteToEol { inserted } => {
5604            use hjkl_buffer::{Edit, Position};
5605            ed.push_undo();
5606            delete_to_eol(ed);
5607            if let Some(text) = inserted {
5608                let cursor = ed.cursor();
5609                ed.mutate_edit(Edit::InsertStr {
5610                    at: Position::new(cursor.0, cursor.1),
5611                    text,
5612                });
5613            }
5614        }
5615        LastChange::OpenLine { above, inserted } => {
5616            use hjkl_buffer::{Edit, Position};
5617            ed.push_undo();
5618            ed.sync_buffer_content_from_textarea();
5619            let row = buf_cursor_pos(&ed.buffer).row;
5620            if above {
5621                ed.mutate_edit(Edit::InsertStr {
5622                    at: Position::new(row, 0),
5623                    text: "\n".to_string(),
5624                });
5625                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
5626                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
5627            } else {
5628                let line_chars = buf_line_chars(&ed.buffer, row);
5629                ed.mutate_edit(Edit::InsertStr {
5630                    at: Position::new(row, line_chars),
5631                    text: "\n".to_string(),
5632                });
5633            }
5634            ed.push_buffer_cursor_to_textarea();
5635            let cursor = ed.cursor();
5636            ed.mutate_edit(Edit::InsertStr {
5637                at: Position::new(cursor.0, cursor.1),
5638                text: inserted,
5639            });
5640        }
5641        LastChange::InsertAt {
5642            entry,
5643            inserted,
5644            count,
5645        } => {
5646            use hjkl_buffer::{Edit, Position};
5647            ed.push_undo();
5648            match entry {
5649                InsertEntry::I => {}
5650                InsertEntry::ShiftI => move_first_non_whitespace(ed),
5651                InsertEntry::A => {
5652                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5653                    ed.push_buffer_cursor_to_textarea();
5654                }
5655                InsertEntry::ShiftA => {
5656                    crate::motions::move_line_end(&mut ed.buffer);
5657                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
5658                    ed.push_buffer_cursor_to_textarea();
5659                }
5660            }
5661            for _ in 0..count.max(1) {
5662                let cursor = ed.cursor();
5663                ed.mutate_edit(Edit::InsertStr {
5664                    at: Position::new(cursor.0, cursor.1),
5665                    text: inserted.clone(),
5666                });
5667            }
5668        }
5669    }
5670    ed.vim.replaying = false;
5671}
5672
5673// ─── Extracting inserted text for replay ───────────────────────────────────
5674
5675fn extract_inserted(before: &str, after: &str) -> String {
5676    let before_chars: Vec<char> = before.chars().collect();
5677    let after_chars: Vec<char> = after.chars().collect();
5678    if after_chars.len() <= before_chars.len() {
5679        return String::new();
5680    }
5681    let prefix = before_chars
5682        .iter()
5683        .zip(after_chars.iter())
5684        .take_while(|(a, b)| a == b)
5685        .count();
5686    let max_suffix = before_chars.len() - prefix;
5687    let suffix = before_chars
5688        .iter()
5689        .rev()
5690        .zip(after_chars.iter().rev())
5691        .take(max_suffix)
5692        .take_while(|(a, b)| a == b)
5693        .count();
5694    after_chars[prefix..after_chars.len() - suffix]
5695        .iter()
5696        .collect()
5697}
5698
5699// ─── Tests ────────────────────────────────────────────────────────────────
5700
5701#[cfg(all(test, feature = "crossterm"))]
5702mod tests {
5703    use crate::VimMode;
5704    use crate::editor::Editor;
5705    use crate::types::Host;
5706    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5707
5708    fn run_keys<H: crate::types::Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, keys: &str) {
5709        // Minimal notation:
5710        //   <Esc> <CR> <BS> <Left/Right/Up/Down> <C-x>
5711        //   anything else = single char
5712        let mut iter = keys.chars().peekable();
5713        while let Some(c) = iter.next() {
5714            if c == '<' {
5715                let mut tag = String::new();
5716                for ch in iter.by_ref() {
5717                    if ch == '>' {
5718                        break;
5719                    }
5720                    tag.push(ch);
5721                }
5722                let ev = match tag.as_str() {
5723                    "Esc" => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
5724                    "CR" => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
5725                    "BS" => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
5726                    "Space" => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
5727                    "Up" => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
5728                    "Down" => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
5729                    "Left" => KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
5730                    "Right" => KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
5731                    // Vim-style literal `<` escape so tests can type
5732                    // the outdent operator without colliding with the
5733                    // `<tag>` notation this helper uses for special keys.
5734                    "lt" => KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
5735                    s if s.starts_with("C-") => {
5736                        let ch = s.chars().nth(2).unwrap();
5737                        KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
5738                    }
5739                    _ => continue,
5740                };
5741                e.handle_key(ev);
5742            } else {
5743                let mods = if c.is_uppercase() {
5744                    KeyModifiers::SHIFT
5745                } else {
5746                    KeyModifiers::NONE
5747                };
5748                e.handle_key(KeyEvent::new(KeyCode::Char(c), mods));
5749            }
5750        }
5751    }
5752
5753    fn editor_with(content: &str) -> Editor {
5754        // Tests historically assume shiftwidth=2 (sqeel-derived). The 0.1.0
5755        // SPEC default is shiftwidth=8 (vim-faithful). Keep these tests on
5756        // the legacy 2-space rhythm so the indent/outdent assertions don't
5757        // churn.
5758        let opts = crate::types::Options {
5759            shiftwidth: 2,
5760            ..crate::types::Options::default()
5761        };
5762        let mut e = Editor::new(
5763            hjkl_buffer::Buffer::new(),
5764            crate::types::DefaultHost::new(),
5765            opts,
5766        );
5767        e.set_content(content);
5768        e
5769    }
5770
5771    #[test]
5772    fn f_char_jumps_on_line() {
5773        let mut e = editor_with("hello world");
5774        run_keys(&mut e, "fw");
5775        assert_eq!(e.cursor(), (0, 6));
5776    }
5777
5778    #[test]
5779    fn cap_f_jumps_backward() {
5780        let mut e = editor_with("hello world");
5781        e.jump_cursor(0, 10);
5782        run_keys(&mut e, "Fo");
5783        assert_eq!(e.cursor().1, 7);
5784    }
5785
5786    #[test]
5787    fn t_stops_before_char() {
5788        let mut e = editor_with("hello");
5789        run_keys(&mut e, "tl");
5790        assert_eq!(e.cursor(), (0, 1));
5791    }
5792
5793    #[test]
5794    fn semicolon_repeats_find() {
5795        let mut e = editor_with("aa.bb.cc");
5796        run_keys(&mut e, "f.");
5797        assert_eq!(e.cursor().1, 2);
5798        run_keys(&mut e, ";");
5799        assert_eq!(e.cursor().1, 5);
5800    }
5801
5802    #[test]
5803    fn comma_repeats_find_reverse() {
5804        let mut e = editor_with("aa.bb.cc");
5805        run_keys(&mut e, "f.");
5806        run_keys(&mut e, ";");
5807        run_keys(&mut e, ",");
5808        assert_eq!(e.cursor().1, 2);
5809    }
5810
5811    #[test]
5812    fn di_quote_deletes_content() {
5813        let mut e = editor_with("foo \"bar\" baz");
5814        e.jump_cursor(0, 6); // inside quotes
5815        run_keys(&mut e, "di\"");
5816        assert_eq!(e.buffer().lines()[0], "foo \"\" baz");
5817    }
5818
5819    #[test]
5820    fn da_quote_deletes_with_quotes() {
5821        // `da"` eats the trailing space after the closing quote so the
5822        // result matches vim's "around" text-object whitespace rule.
5823        let mut e = editor_with("foo \"bar\" baz");
5824        e.jump_cursor(0, 6);
5825        run_keys(&mut e, "da\"");
5826        assert_eq!(e.buffer().lines()[0], "foo baz");
5827    }
5828
5829    #[test]
5830    fn ci_paren_deletes_and_inserts() {
5831        let mut e = editor_with("fn(a, b, c)");
5832        e.jump_cursor(0, 5);
5833        run_keys(&mut e, "ci(");
5834        assert_eq!(e.vim_mode(), VimMode::Insert);
5835        assert_eq!(e.buffer().lines()[0], "fn()");
5836    }
5837
5838    #[test]
5839    fn diw_deletes_inner_word() {
5840        let mut e = editor_with("hello world");
5841        e.jump_cursor(0, 2);
5842        run_keys(&mut e, "diw");
5843        assert_eq!(e.buffer().lines()[0], " world");
5844    }
5845
5846    #[test]
5847    fn daw_deletes_word_with_trailing_space() {
5848        let mut e = editor_with("hello world");
5849        run_keys(&mut e, "daw");
5850        assert_eq!(e.buffer().lines()[0], "world");
5851    }
5852
5853    #[test]
5854    fn percent_jumps_to_matching_bracket() {
5855        let mut e = editor_with("foo(bar)");
5856        e.jump_cursor(0, 3);
5857        run_keys(&mut e, "%");
5858        assert_eq!(e.cursor().1, 7);
5859        run_keys(&mut e, "%");
5860        assert_eq!(e.cursor().1, 3);
5861    }
5862
5863    #[test]
5864    fn dot_repeats_last_change() {
5865        let mut e = editor_with("aaa bbb ccc");
5866        run_keys(&mut e, "dw");
5867        assert_eq!(e.buffer().lines()[0], "bbb ccc");
5868        run_keys(&mut e, ".");
5869        assert_eq!(e.buffer().lines()[0], "ccc");
5870    }
5871
5872    #[test]
5873    fn dot_repeats_change_operator_with_text() {
5874        let mut e = editor_with("foo foo foo");
5875        run_keys(&mut e, "cwbar<Esc>");
5876        assert_eq!(e.buffer().lines()[0], "bar foo foo");
5877        // Move past the space.
5878        run_keys(&mut e, "w");
5879        run_keys(&mut e, ".");
5880        assert_eq!(e.buffer().lines()[0], "bar bar foo");
5881    }
5882
5883    #[test]
5884    fn dot_repeats_x() {
5885        let mut e = editor_with("abcdef");
5886        run_keys(&mut e, "x");
5887        run_keys(&mut e, "..");
5888        assert_eq!(e.buffer().lines()[0], "def");
5889    }
5890
5891    #[test]
5892    fn count_operator_motion_compose() {
5893        let mut e = editor_with("one two three four five");
5894        run_keys(&mut e, "d3w");
5895        assert_eq!(e.buffer().lines()[0], "four five");
5896    }
5897
5898    #[test]
5899    fn two_dd_deletes_two_lines() {
5900        let mut e = editor_with("a\nb\nc");
5901        run_keys(&mut e, "2dd");
5902        assert_eq!(e.buffer().lines().len(), 1);
5903        assert_eq!(e.buffer().lines()[0], "c");
5904    }
5905
5906    /// Vim's `dd` leaves the cursor on the first non-blank of the line
5907    /// that now sits at the deleted row — not at the end of the
5908    /// previous line, which is where tui-textarea's raw cut would
5909    /// park it.
5910    #[test]
5911    fn dd_in_middle_puts_cursor_on_first_non_blank_of_next() {
5912        let mut e = editor_with("one\ntwo\n    three\nfour");
5913        e.jump_cursor(1, 2);
5914        run_keys(&mut e, "dd");
5915        // Buffer: ["one", "    three", "four"]
5916        assert_eq!(e.buffer().lines()[1], "    three");
5917        assert_eq!(e.cursor(), (1, 4));
5918    }
5919
5920    #[test]
5921    fn dd_on_last_line_puts_cursor_on_first_non_blank_of_prev() {
5922        let mut e = editor_with("one\n  two\nthree");
5923        e.jump_cursor(2, 0);
5924        run_keys(&mut e, "dd");
5925        // Buffer: ["one", "  two"]
5926        assert_eq!(e.buffer().lines().len(), 2);
5927        assert_eq!(e.cursor(), (1, 2));
5928    }
5929
5930    #[test]
5931    fn dd_on_only_line_leaves_empty_buffer_and_cursor_at_zero() {
5932        let mut e = editor_with("lonely");
5933        run_keys(&mut e, "dd");
5934        assert_eq!(e.buffer().lines().len(), 1);
5935        assert_eq!(e.buffer().lines()[0], "");
5936        assert_eq!(e.cursor(), (0, 0));
5937    }
5938
5939    #[test]
5940    fn count_dd_puts_cursor_on_first_non_blank_of_remaining() {
5941        let mut e = editor_with("a\nb\nc\n   d\ne");
5942        // Cursor on row 1, "3dd" deletes b/c/   d → lines become [a, e].
5943        e.jump_cursor(1, 0);
5944        run_keys(&mut e, "3dd");
5945        assert_eq!(e.buffer().lines(), &["a".to_string(), "e".to_string()]);
5946        assert_eq!(e.cursor(), (1, 0));
5947    }
5948
5949    #[test]
5950    fn dd_then_j_uses_first_non_blank_not_sticky_col() {
5951        // Buffer: 3 lines with predictable widths.
5952        // Line 0: "    line one"   (12 chars, first-non-blank at col 4)
5953        // Line 1: "    line two"   (12 chars, first-non-blank at col 4)
5954        // Line 2: "  xy"           (4 chars, indices 0-3; last char at col 3)
5955        //
5956        // Cursor starts at col 8 on line 0.  After `dd`:
5957        //   - line 0 is deleted; cursor lands on first-non-blank of new line 0
5958        //     ("    line two") → col 4.
5959        //   - sticky_col must be updated to 4.
5960        //
5961        // Then `j` moves to "  xy" (4 chars, max col = 3).
5962        //   - With the fix   : sticky_col=4 → clamps to col 3 (last char).
5963        //   - Without the fix: sticky_col=8 → clamps to col 3 (same clamp).
5964        //
5965        // To make the two cases distinguishable we choose line 2 with
5966        // exactly 6 chars ("  xyz!") so max col = 5:
5967        //   - fix   : sticky_col=4 → lands at col 4.
5968        //   - no fix: sticky_col=8 → clamps to col 5.
5969        let mut e = editor_with("    line one\n    line two\n  xyz!");
5970        // Move to col 8 on line 0.
5971        e.jump_cursor(0, 8);
5972        assert_eq!(e.cursor(), (0, 8));
5973        // `dd` deletes line 0; cursor should land on first-non-blank of
5974        // the new line 0 ("    line two" → col 4).
5975        run_keys(&mut e, "dd");
5976        assert_eq!(
5977            e.cursor(),
5978            (0, 4),
5979            "dd must place cursor on first-non-blank"
5980        );
5981        // `j` moves to "  xyz!" (6 chars, cols 0-5).
5982        // Bug: stale sticky_col=8 clamps to col 5 (last char).
5983        // Fixed: sticky_col=4 → lands at col 4.
5984        run_keys(&mut e, "j");
5985        let (row, col) = e.cursor();
5986        assert_eq!(row, 1);
5987        assert_eq!(
5988            col, 4,
5989            "after dd, j should use the column dd established (4), not pre-dd sticky_col (8)"
5990        );
5991    }
5992
5993    #[test]
5994    fn gu_lowercases_motion_range() {
5995        let mut e = editor_with("HELLO WORLD");
5996        run_keys(&mut e, "guw");
5997        assert_eq!(e.buffer().lines()[0], "hello WORLD");
5998        assert_eq!(e.cursor(), (0, 0));
5999    }
6000
6001    #[test]
6002    fn g_u_uppercases_text_object() {
6003        let mut e = editor_with("hello world");
6004        // gUiw uppercases the word at the cursor.
6005        run_keys(&mut e, "gUiw");
6006        assert_eq!(e.buffer().lines()[0], "HELLO world");
6007        assert_eq!(e.cursor(), (0, 0));
6008    }
6009
6010    #[test]
6011    fn g_tilde_toggles_case_of_range() {
6012        let mut e = editor_with("Hello World");
6013        run_keys(&mut e, "g~iw");
6014        assert_eq!(e.buffer().lines()[0], "hELLO World");
6015    }
6016
6017    #[test]
6018    fn g_uu_uppercases_current_line() {
6019        let mut e = editor_with("select 1\nselect 2");
6020        run_keys(&mut e, "gUU");
6021        assert_eq!(e.buffer().lines()[0], "SELECT 1");
6022        assert_eq!(e.buffer().lines()[1], "select 2");
6023    }
6024
6025    #[test]
6026    fn gugu_lowercases_current_line() {
6027        let mut e = editor_with("FOO BAR\nBAZ");
6028        run_keys(&mut e, "gugu");
6029        assert_eq!(e.buffer().lines()[0], "foo bar");
6030    }
6031
6032    #[test]
6033    fn visual_u_uppercases_selection() {
6034        let mut e = editor_with("hello world");
6035        // v + e selects "hello" (inclusive of last char), U uppercases.
6036        run_keys(&mut e, "veU");
6037        assert_eq!(e.buffer().lines()[0], "HELLO world");
6038    }
6039
6040    #[test]
6041    fn visual_line_u_lowercases_line() {
6042        let mut e = editor_with("HELLO WORLD\nOTHER");
6043        run_keys(&mut e, "Vu");
6044        assert_eq!(e.buffer().lines()[0], "hello world");
6045        assert_eq!(e.buffer().lines()[1], "OTHER");
6046    }
6047
6048    #[test]
6049    fn g_uu_with_count_uppercases_multiple_lines() {
6050        let mut e = editor_with("one\ntwo\nthree\nfour");
6051        // `3gUU` uppercases 3 lines starting from the cursor.
6052        run_keys(&mut e, "3gUU");
6053        assert_eq!(e.buffer().lines()[0], "ONE");
6054        assert_eq!(e.buffer().lines()[1], "TWO");
6055        assert_eq!(e.buffer().lines()[2], "THREE");
6056        assert_eq!(e.buffer().lines()[3], "four");
6057    }
6058
6059    #[test]
6060    fn double_gt_indents_current_line() {
6061        let mut e = editor_with("hello");
6062        run_keys(&mut e, ">>");
6063        assert_eq!(e.buffer().lines()[0], "  hello");
6064        // Cursor lands on first non-blank.
6065        assert_eq!(e.cursor(), (0, 2));
6066    }
6067
6068    #[test]
6069    fn double_lt_outdents_current_line() {
6070        let mut e = editor_with("    hello");
6071        run_keys(&mut e, "<lt><lt>");
6072        assert_eq!(e.buffer().lines()[0], "  hello");
6073        assert_eq!(e.cursor(), (0, 2));
6074    }
6075
6076    #[test]
6077    fn count_double_gt_indents_multiple_lines() {
6078        let mut e = editor_with("a\nb\nc\nd");
6079        // `3>>` indents 3 lines starting at cursor.
6080        run_keys(&mut e, "3>>");
6081        assert_eq!(e.buffer().lines()[0], "  a");
6082        assert_eq!(e.buffer().lines()[1], "  b");
6083        assert_eq!(e.buffer().lines()[2], "  c");
6084        assert_eq!(e.buffer().lines()[3], "d");
6085    }
6086
6087    #[test]
6088    fn outdent_clips_ragged_leading_whitespace() {
6089        // Only one space of indent — outdent should strip what's
6090        // there, not leave anything negative.
6091        let mut e = editor_with(" x");
6092        run_keys(&mut e, "<lt><lt>");
6093        assert_eq!(e.buffer().lines()[0], "x");
6094    }
6095
6096    #[test]
6097    fn indent_motion_is_always_linewise() {
6098        // `>w` indents the current line (linewise) — it doesn't
6099        // insert spaces into the middle of the word.
6100        let mut e = editor_with("foo bar");
6101        run_keys(&mut e, ">w");
6102        assert_eq!(e.buffer().lines()[0], "  foo bar");
6103    }
6104
6105    #[test]
6106    fn indent_text_object_extends_over_paragraph() {
6107        let mut e = editor_with("a\nb\n\nc\nd");
6108        // `>ap` indents the whole paragraph (rows 0..=1).
6109        run_keys(&mut e, ">ap");
6110        assert_eq!(e.buffer().lines()[0], "  a");
6111        assert_eq!(e.buffer().lines()[1], "  b");
6112        assert_eq!(e.buffer().lines()[2], "");
6113        assert_eq!(e.buffer().lines()[3], "c");
6114    }
6115
6116    #[test]
6117    fn visual_line_indent_shifts_selected_rows() {
6118        let mut e = editor_with("x\ny\nz");
6119        // Vj selects rows 0..=1 linewise; `>` indents.
6120        run_keys(&mut e, "Vj>");
6121        assert_eq!(e.buffer().lines()[0], "  x");
6122        assert_eq!(e.buffer().lines()[1], "  y");
6123        assert_eq!(e.buffer().lines()[2], "z");
6124    }
6125
6126    #[test]
6127    fn outdent_empty_line_is_noop() {
6128        let mut e = editor_with("\nfoo");
6129        run_keys(&mut e, "<lt><lt>");
6130        assert_eq!(e.buffer().lines()[0], "");
6131    }
6132
6133    #[test]
6134    fn indent_skips_empty_lines() {
6135        // Vim convention: `>>` on an empty line doesn't pad it with
6136        // trailing whitespace.
6137        let mut e = editor_with("");
6138        run_keys(&mut e, ">>");
6139        assert_eq!(e.buffer().lines()[0], "");
6140    }
6141
6142    #[test]
6143    fn insert_ctrl_t_indents_current_line() {
6144        let mut e = editor_with("x");
6145        // Enter insert, Ctrl-t indents the line; cursor advances too.
6146        run_keys(&mut e, "i<C-t>");
6147        assert_eq!(e.buffer().lines()[0], "  x");
6148        // After insert-mode start `i` cursor was at (0, 0); Ctrl-t
6149        // shifts it by SHIFTWIDTH=2.
6150        assert_eq!(e.cursor(), (0, 2));
6151    }
6152
6153    #[test]
6154    fn insert_ctrl_d_outdents_current_line() {
6155        let mut e = editor_with("    x");
6156        // Enter insert-at-end `A`, Ctrl-d outdents by shiftwidth.
6157        run_keys(&mut e, "A<C-d>");
6158        assert_eq!(e.buffer().lines()[0], "  x");
6159    }
6160
6161    #[test]
6162    fn h_at_col_zero_does_not_wrap_to_prev_line() {
6163        let mut e = editor_with("first\nsecond");
6164        e.jump_cursor(1, 0);
6165        run_keys(&mut e, "h");
6166        // Cursor must stay on row 1 col 0 — vim default doesn't wrap.
6167        assert_eq!(e.cursor(), (1, 0));
6168    }
6169
6170    #[test]
6171    fn l_at_last_char_does_not_wrap_to_next_line() {
6172        let mut e = editor_with("ab\ncd");
6173        // Move to last char of row 0 (col 1).
6174        e.jump_cursor(0, 1);
6175        run_keys(&mut e, "l");
6176        // Cursor stays on last char — no wrap.
6177        assert_eq!(e.cursor(), (0, 1));
6178    }
6179
6180    #[test]
6181    fn count_l_clamps_at_line_end() {
6182        let mut e = editor_with("abcde");
6183        // 20l starting at col 0 should land on last char (col 4),
6184        // not overflow / wrap.
6185        run_keys(&mut e, "20l");
6186        assert_eq!(e.cursor(), (0, 4));
6187    }
6188
6189    #[test]
6190    fn count_h_clamps_at_col_zero() {
6191        let mut e = editor_with("abcde");
6192        e.jump_cursor(0, 3);
6193        run_keys(&mut e, "20h");
6194        assert_eq!(e.cursor(), (0, 0));
6195    }
6196
6197    #[test]
6198    fn dl_on_last_char_still_deletes_it() {
6199        // `dl` / `x`-equivalent at EOL must delete the last char —
6200        // operator motion allows endpoint past-last even though bare
6201        // `l` stops before.
6202        let mut e = editor_with("ab");
6203        e.jump_cursor(0, 1);
6204        run_keys(&mut e, "dl");
6205        assert_eq!(e.buffer().lines()[0], "a");
6206    }
6207
6208    #[test]
6209    fn case_op_preserves_yank_register() {
6210        let mut e = editor_with("target");
6211        run_keys(&mut e, "yy");
6212        let yank_before = e.yank().to_string();
6213        // gUU changes the line but must not clobber the yank register.
6214        run_keys(&mut e, "gUU");
6215        assert_eq!(e.buffer().lines()[0], "TARGET");
6216        assert_eq!(
6217            e.yank(),
6218            yank_before,
6219            "case ops must preserve the yank buffer"
6220        );
6221    }
6222
6223    #[test]
6224    fn dap_deletes_paragraph() {
6225        let mut e = editor_with("a\nb\n\nc\nd");
6226        run_keys(&mut e, "dap");
6227        assert_eq!(e.buffer().lines().first().map(String::as_str), Some("c"));
6228    }
6229
6230    #[test]
6231    fn dit_deletes_inner_tag_content() {
6232        let mut e = editor_with("<b>hello</b>");
6233        // Cursor on `e`.
6234        e.jump_cursor(0, 4);
6235        run_keys(&mut e, "dit");
6236        assert_eq!(e.buffer().lines()[0], "<b></b>");
6237    }
6238
6239    #[test]
6240    fn dat_deletes_around_tag() {
6241        let mut e = editor_with("hi <b>foo</b> bye");
6242        e.jump_cursor(0, 6);
6243        run_keys(&mut e, "dat");
6244        assert_eq!(e.buffer().lines()[0], "hi  bye");
6245    }
6246
6247    #[test]
6248    fn dit_picks_innermost_tag() {
6249        let mut e = editor_with("<a><b>x</b></a>");
6250        // Cursor on `x`.
6251        e.jump_cursor(0, 6);
6252        run_keys(&mut e, "dit");
6253        // Inner of <b> is removed; <a> wrapping stays.
6254        assert_eq!(e.buffer().lines()[0], "<a><b></b></a>");
6255    }
6256
6257    #[test]
6258    fn dat_innermost_tag_pair() {
6259        let mut e = editor_with("<a><b>x</b></a>");
6260        e.jump_cursor(0, 6);
6261        run_keys(&mut e, "dat");
6262        assert_eq!(e.buffer().lines()[0], "<a></a>");
6263    }
6264
6265    #[test]
6266    fn dit_outside_any_tag_no_op() {
6267        let mut e = editor_with("plain text");
6268        e.jump_cursor(0, 3);
6269        run_keys(&mut e, "dit");
6270        // No tag pair surrounds the cursor — buffer unchanged.
6271        assert_eq!(e.buffer().lines()[0], "plain text");
6272    }
6273
6274    #[test]
6275    fn cit_changes_inner_tag_content() {
6276        let mut e = editor_with("<b>hello</b>");
6277        e.jump_cursor(0, 4);
6278        run_keys(&mut e, "citNEW<Esc>");
6279        assert_eq!(e.buffer().lines()[0], "<b>NEW</b>");
6280    }
6281
6282    #[test]
6283    fn cat_changes_around_tag() {
6284        let mut e = editor_with("hi <b>foo</b> bye");
6285        e.jump_cursor(0, 6);
6286        run_keys(&mut e, "catBAR<Esc>");
6287        assert_eq!(e.buffer().lines()[0], "hi BAR bye");
6288    }
6289
6290    #[test]
6291    fn yit_yanks_inner_tag_content() {
6292        let mut e = editor_with("<b>hello</b>");
6293        e.jump_cursor(0, 4);
6294        run_keys(&mut e, "yit");
6295        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6296    }
6297
6298    #[test]
6299    fn yat_yanks_full_tag_pair() {
6300        let mut e = editor_with("hi <b>foo</b> bye");
6301        e.jump_cursor(0, 6);
6302        run_keys(&mut e, "yat");
6303        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6304    }
6305
6306    #[test]
6307    fn vit_visually_selects_inner_tag() {
6308        let mut e = editor_with("<b>hello</b>");
6309        e.jump_cursor(0, 4);
6310        run_keys(&mut e, "vit");
6311        assert_eq!(e.vim_mode(), VimMode::Visual);
6312        run_keys(&mut e, "y");
6313        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6314    }
6315
6316    #[test]
6317    fn vat_visually_selects_around_tag() {
6318        let mut e = editor_with("x<b>foo</b>y");
6319        e.jump_cursor(0, 5);
6320        run_keys(&mut e, "vat");
6321        assert_eq!(e.vim_mode(), VimMode::Visual);
6322        run_keys(&mut e, "y");
6323        assert_eq!(e.registers().read('"').unwrap().text, "<b>foo</b>");
6324    }
6325
6326    // ─── Text-object coverage (d operator, inner + around) ───────────
6327
6328    #[test]
6329    #[allow(non_snake_case)]
6330    fn diW_deletes_inner_big_word() {
6331        let mut e = editor_with("foo.bar baz");
6332        e.jump_cursor(0, 2);
6333        run_keys(&mut e, "diW");
6334        // Big word treats `foo.bar` as one token.
6335        assert_eq!(e.buffer().lines()[0], " baz");
6336    }
6337
6338    #[test]
6339    #[allow(non_snake_case)]
6340    fn daW_deletes_around_big_word() {
6341        let mut e = editor_with("foo.bar baz");
6342        e.jump_cursor(0, 2);
6343        run_keys(&mut e, "daW");
6344        assert_eq!(e.buffer().lines()[0], "baz");
6345    }
6346
6347    #[test]
6348    fn di_double_quote_deletes_inside() {
6349        let mut e = editor_with("a \"hello\" b");
6350        e.jump_cursor(0, 4);
6351        run_keys(&mut e, "di\"");
6352        assert_eq!(e.buffer().lines()[0], "a \"\" b");
6353    }
6354
6355    #[test]
6356    fn da_double_quote_deletes_around() {
6357        // `da"` eats the trailing space — matches vim's around-whitespace rule.
6358        let mut e = editor_with("a \"hello\" b");
6359        e.jump_cursor(0, 4);
6360        run_keys(&mut e, "da\"");
6361        assert_eq!(e.buffer().lines()[0], "a b");
6362    }
6363
6364    #[test]
6365    fn di_single_quote_deletes_inside() {
6366        let mut e = editor_with("x 'foo' y");
6367        e.jump_cursor(0, 4);
6368        run_keys(&mut e, "di'");
6369        assert_eq!(e.buffer().lines()[0], "x '' y");
6370    }
6371
6372    #[test]
6373    fn da_single_quote_deletes_around() {
6374        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6375        let mut e = editor_with("x 'foo' y");
6376        e.jump_cursor(0, 4);
6377        run_keys(&mut e, "da'");
6378        assert_eq!(e.buffer().lines()[0], "x y");
6379    }
6380
6381    #[test]
6382    fn di_backtick_deletes_inside() {
6383        let mut e = editor_with("p `q` r");
6384        e.jump_cursor(0, 3);
6385        run_keys(&mut e, "di`");
6386        assert_eq!(e.buffer().lines()[0], "p `` r");
6387    }
6388
6389    #[test]
6390    fn da_backtick_deletes_around() {
6391        // `da`` eats the trailing space — matches vim's around-whitespace rule.
6392        let mut e = editor_with("p `q` r");
6393        e.jump_cursor(0, 3);
6394        run_keys(&mut e, "da`");
6395        assert_eq!(e.buffer().lines()[0], "p r");
6396    }
6397
6398    #[test]
6399    fn di_paren_deletes_inside() {
6400        let mut e = editor_with("f(arg)");
6401        e.jump_cursor(0, 3);
6402        run_keys(&mut e, "di(");
6403        assert_eq!(e.buffer().lines()[0], "f()");
6404    }
6405
6406    #[test]
6407    fn di_paren_alias_b_works() {
6408        let mut e = editor_with("f(arg)");
6409        e.jump_cursor(0, 3);
6410        run_keys(&mut e, "dib");
6411        assert_eq!(e.buffer().lines()[0], "f()");
6412    }
6413
6414    #[test]
6415    fn di_bracket_deletes_inside() {
6416        let mut e = editor_with("a[b,c]d");
6417        e.jump_cursor(0, 3);
6418        run_keys(&mut e, "di[");
6419        assert_eq!(e.buffer().lines()[0], "a[]d");
6420    }
6421
6422    #[test]
6423    fn da_bracket_deletes_around() {
6424        let mut e = editor_with("a[b,c]d");
6425        e.jump_cursor(0, 3);
6426        run_keys(&mut e, "da[");
6427        assert_eq!(e.buffer().lines()[0], "ad");
6428    }
6429
6430    #[test]
6431    fn di_brace_deletes_inside() {
6432        let mut e = editor_with("x{y}z");
6433        e.jump_cursor(0, 2);
6434        run_keys(&mut e, "di{");
6435        assert_eq!(e.buffer().lines()[0], "x{}z");
6436    }
6437
6438    #[test]
6439    fn da_brace_deletes_around() {
6440        let mut e = editor_with("x{y}z");
6441        e.jump_cursor(0, 2);
6442        run_keys(&mut e, "da{");
6443        assert_eq!(e.buffer().lines()[0], "xz");
6444    }
6445
6446    #[test]
6447    fn di_brace_alias_capital_b_works() {
6448        let mut e = editor_with("x{y}z");
6449        e.jump_cursor(0, 2);
6450        run_keys(&mut e, "diB");
6451        assert_eq!(e.buffer().lines()[0], "x{}z");
6452    }
6453
6454    #[test]
6455    fn di_angle_deletes_inside() {
6456        let mut e = editor_with("p<q>r");
6457        e.jump_cursor(0, 2);
6458        // `<lt>` so run_keys doesn't treat `<` as the start of a special-key tag.
6459        run_keys(&mut e, "di<lt>");
6460        assert_eq!(e.buffer().lines()[0], "p<>r");
6461    }
6462
6463    #[test]
6464    fn da_angle_deletes_around() {
6465        let mut e = editor_with("p<q>r");
6466        e.jump_cursor(0, 2);
6467        run_keys(&mut e, "da<lt>");
6468        assert_eq!(e.buffer().lines()[0], "pr");
6469    }
6470
6471    #[test]
6472    fn dip_deletes_inner_paragraph() {
6473        let mut e = editor_with("a\nb\nc\n\nd");
6474        e.jump_cursor(1, 0);
6475        run_keys(&mut e, "dip");
6476        // Inner paragraph (rows 0..=2) drops; the trailing blank
6477        // separator + remaining paragraph stay.
6478        assert_eq!(e.buffer().lines(), vec!["".to_string(), "d".into()]);
6479    }
6480
6481    // ─── Operator pipeline spot checks (non-tag text objects) ───────
6482
6483    #[test]
6484    fn sentence_motion_close_paren_jumps_forward() {
6485        let mut e = editor_with("Alpha. Beta. Gamma.");
6486        e.jump_cursor(0, 0);
6487        run_keys(&mut e, ")");
6488        // Lands on the start of "Beta".
6489        assert_eq!(e.cursor(), (0, 7));
6490        run_keys(&mut e, ")");
6491        assert_eq!(e.cursor(), (0, 13));
6492    }
6493
6494    #[test]
6495    fn sentence_motion_open_paren_jumps_backward() {
6496        let mut e = editor_with("Alpha. Beta. Gamma.");
6497        e.jump_cursor(0, 13);
6498        run_keys(&mut e, "(");
6499        // Cursor was at start of "Gamma" (col 13); first `(` walks
6500        // back to the previous sentence's start.
6501        assert_eq!(e.cursor(), (0, 7));
6502        run_keys(&mut e, "(");
6503        assert_eq!(e.cursor(), (0, 0));
6504    }
6505
6506    #[test]
6507    fn sentence_motion_count() {
6508        let mut e = editor_with("A. B. C. D.");
6509        e.jump_cursor(0, 0);
6510        run_keys(&mut e, "3)");
6511        // 3 forward jumps land on "D".
6512        assert_eq!(e.cursor(), (0, 9));
6513    }
6514
6515    #[test]
6516    fn dis_deletes_inner_sentence() {
6517        let mut e = editor_with("First one. Second one. Third one.");
6518        e.jump_cursor(0, 13);
6519        run_keys(&mut e, "dis");
6520        // Removed "Second one." inclusive of its terminator.
6521        assert_eq!(e.buffer().lines()[0], "First one.  Third one.");
6522    }
6523
6524    #[test]
6525    fn das_deletes_around_sentence_with_trailing_space() {
6526        let mut e = editor_with("Alpha. Beta. Gamma.");
6527        e.jump_cursor(0, 8);
6528        run_keys(&mut e, "das");
6529        // `as` swallows the trailing whitespace before the next
6530        // sentence — exactly one space here.
6531        assert_eq!(e.buffer().lines()[0], "Alpha. Gamma.");
6532    }
6533
6534    #[test]
6535    fn dis_handles_double_terminator() {
6536        let mut e = editor_with("Wow!? Next.");
6537        e.jump_cursor(0, 1);
6538        run_keys(&mut e, "dis");
6539        // Run of `!?` collapses into one boundary; sentence body
6540        // including both terminators is removed.
6541        assert_eq!(e.buffer().lines()[0], " Next.");
6542    }
6543
6544    #[test]
6545    fn dis_first_sentence_from_cursor_at_zero() {
6546        let mut e = editor_with("Alpha. Beta.");
6547        e.jump_cursor(0, 0);
6548        run_keys(&mut e, "dis");
6549        assert_eq!(e.buffer().lines()[0], " Beta.");
6550    }
6551
6552    #[test]
6553    fn yis_yanks_inner_sentence() {
6554        let mut e = editor_with("Hello world. Bye.");
6555        e.jump_cursor(0, 5);
6556        run_keys(&mut e, "yis");
6557        assert_eq!(e.registers().read('"').unwrap().text, "Hello world.");
6558    }
6559
6560    #[test]
6561    fn vis_visually_selects_inner_sentence() {
6562        let mut e = editor_with("First. Second.");
6563        e.jump_cursor(0, 1);
6564        run_keys(&mut e, "vis");
6565        assert_eq!(e.vim_mode(), VimMode::Visual);
6566        run_keys(&mut e, "y");
6567        assert_eq!(e.registers().read('"').unwrap().text, "First.");
6568    }
6569
6570    #[test]
6571    fn ciw_changes_inner_word() {
6572        let mut e = editor_with("hello world");
6573        e.jump_cursor(0, 1);
6574        run_keys(&mut e, "ciwHEY<Esc>");
6575        assert_eq!(e.buffer().lines()[0], "HEY world");
6576    }
6577
6578    #[test]
6579    fn yiw_yanks_inner_word() {
6580        let mut e = editor_with("hello world");
6581        e.jump_cursor(0, 1);
6582        run_keys(&mut e, "yiw");
6583        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6584    }
6585
6586    #[test]
6587    fn viw_selects_inner_word() {
6588        let mut e = editor_with("hello world");
6589        e.jump_cursor(0, 2);
6590        run_keys(&mut e, "viw");
6591        assert_eq!(e.vim_mode(), VimMode::Visual);
6592        run_keys(&mut e, "y");
6593        assert_eq!(e.registers().read('"').unwrap().text, "hello");
6594    }
6595
6596    #[test]
6597    fn ci_paren_changes_inside() {
6598        let mut e = editor_with("f(old)");
6599        e.jump_cursor(0, 3);
6600        run_keys(&mut e, "ci(NEW<Esc>");
6601        assert_eq!(e.buffer().lines()[0], "f(NEW)");
6602    }
6603
6604    #[test]
6605    fn yi_double_quote_yanks_inside() {
6606        let mut e = editor_with("say \"hi there\" then");
6607        e.jump_cursor(0, 6);
6608        run_keys(&mut e, "yi\"");
6609        assert_eq!(e.registers().read('"').unwrap().text, "hi there");
6610    }
6611
6612    #[test]
6613    fn vap_visual_selects_around_paragraph() {
6614        let mut e = editor_with("a\nb\n\nc");
6615        e.jump_cursor(0, 0);
6616        run_keys(&mut e, "vap");
6617        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6618        run_keys(&mut e, "y");
6619        // Linewise yank includes the paragraph rows + trailing blank.
6620        let text = e.registers().read('"').unwrap().text.clone();
6621        assert!(text.starts_with("a\nb"));
6622    }
6623
6624    #[test]
6625    fn star_finds_next_occurrence() {
6626        let mut e = editor_with("foo bar foo baz");
6627        run_keys(&mut e, "*");
6628        assert_eq!(e.cursor().1, 8);
6629    }
6630
6631    #[test]
6632    fn star_skips_substring_match() {
6633        // `*` uses `\bfoo\b` so `foobar` is *not* a hit; cursor wraps
6634        // back to the original `foo` at col 0.
6635        let mut e = editor_with("foo foobar baz");
6636        run_keys(&mut e, "*");
6637        assert_eq!(e.cursor().1, 0);
6638    }
6639
6640    #[test]
6641    fn g_star_matches_substring() {
6642        // `g*` drops the boundary; from `foo` at col 0 the next hit is
6643        // inside `foobar` (col 4).
6644        let mut e = editor_with("foo foobar baz");
6645        run_keys(&mut e, "g*");
6646        assert_eq!(e.cursor().1, 4);
6647    }
6648
6649    #[test]
6650    fn g_pound_matches_substring_backward() {
6651        // Start on the last `foo`; `g#` walks backward and lands inside
6652        // `foobar` (col 4).
6653        let mut e = editor_with("foo foobar baz foo");
6654        run_keys(&mut e, "$b");
6655        assert_eq!(e.cursor().1, 15);
6656        run_keys(&mut e, "g#");
6657        assert_eq!(e.cursor().1, 4);
6658    }
6659
6660    #[test]
6661    fn n_repeats_last_search_forward() {
6662        let mut e = editor_with("foo bar foo baz foo");
6663        // `/foo<CR>` jumps past the cursor's current cell, so from
6664        // col 0 the first hit is the second `foo` at col 8.
6665        run_keys(&mut e, "/foo<CR>");
6666        assert_eq!(e.cursor().1, 8);
6667        run_keys(&mut e, "n");
6668        assert_eq!(e.cursor().1, 16);
6669    }
6670
6671    #[test]
6672    fn shift_n_reverses_search() {
6673        let mut e = editor_with("foo bar foo baz foo");
6674        run_keys(&mut e, "/foo<CR>");
6675        run_keys(&mut e, "n");
6676        assert_eq!(e.cursor().1, 16);
6677        run_keys(&mut e, "N");
6678        assert_eq!(e.cursor().1, 8);
6679    }
6680
6681    #[test]
6682    fn n_noop_without_pattern() {
6683        let mut e = editor_with("foo bar");
6684        run_keys(&mut e, "n");
6685        assert_eq!(e.cursor(), (0, 0));
6686    }
6687
6688    #[test]
6689    fn visual_line_preserves_cursor_column() {
6690        // V should never drag the cursor off its natural column — the
6691        // highlight is painted as a post-render overlay instead.
6692        let mut e = editor_with("hello world\nanother one\nbye");
6693        run_keys(&mut e, "lllll"); // col 5
6694        run_keys(&mut e, "V");
6695        assert_eq!(e.vim_mode(), VimMode::VisualLine);
6696        assert_eq!(e.cursor(), (0, 5));
6697        run_keys(&mut e, "j");
6698        assert_eq!(e.cursor(), (1, 5));
6699    }
6700
6701    #[test]
6702    fn visual_line_yank_includes_trailing_newline() {
6703        let mut e = editor_with("aaa\nbbb\nccc");
6704        run_keys(&mut e, "Vjy");
6705        // Two lines yanked — must be `aaa\nbbb\n`, trailing newline preserved.
6706        assert_eq!(e.last_yank.as_deref(), Some("aaa\nbbb\n"));
6707    }
6708
6709    #[test]
6710    fn visual_line_yank_last_line_trailing_newline() {
6711        let mut e = editor_with("aaa\nbbb\nccc");
6712        // Move to the last line and yank with V (final buffer line).
6713        run_keys(&mut e, "jj");
6714        run_keys(&mut e, "Vy");
6715        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6716    }
6717
6718    #[test]
6719    fn yy_on_last_line_has_trailing_newline() {
6720        let mut e = editor_with("aaa\nbbb\nccc");
6721        run_keys(&mut e, "jj");
6722        run_keys(&mut e, "yy");
6723        assert_eq!(e.last_yank.as_deref(), Some("ccc\n"));
6724    }
6725
6726    #[test]
6727    fn yy_in_middle_has_trailing_newline() {
6728        let mut e = editor_with("aaa\nbbb\nccc");
6729        run_keys(&mut e, "j");
6730        run_keys(&mut e, "yy");
6731        assert_eq!(e.last_yank.as_deref(), Some("bbb\n"));
6732    }
6733
6734    #[test]
6735    fn di_single_quote() {
6736        let mut e = editor_with("say 'hello world' now");
6737        e.jump_cursor(0, 7);
6738        run_keys(&mut e, "di'");
6739        assert_eq!(e.buffer().lines()[0], "say '' now");
6740    }
6741
6742    #[test]
6743    fn da_single_quote() {
6744        // `da'` eats the trailing space — matches vim's around-whitespace rule.
6745        let mut e = editor_with("say 'hello' now");
6746        e.jump_cursor(0, 7);
6747        run_keys(&mut e, "da'");
6748        assert_eq!(e.buffer().lines()[0], "say now");
6749    }
6750
6751    #[test]
6752    fn di_backtick() {
6753        let mut e = editor_with("say `hi` now");
6754        e.jump_cursor(0, 5);
6755        run_keys(&mut e, "di`");
6756        assert_eq!(e.buffer().lines()[0], "say `` now");
6757    }
6758
6759    #[test]
6760    fn di_brace() {
6761        let mut e = editor_with("fn { a; b; c }");
6762        e.jump_cursor(0, 7);
6763        run_keys(&mut e, "di{");
6764        assert_eq!(e.buffer().lines()[0], "fn {}");
6765    }
6766
6767    #[test]
6768    fn di_bracket() {
6769        let mut e = editor_with("arr[1, 2, 3]");
6770        e.jump_cursor(0, 5);
6771        run_keys(&mut e, "di[");
6772        assert_eq!(e.buffer().lines()[0], "arr[]");
6773    }
6774
6775    #[test]
6776    fn dab_deletes_around_paren() {
6777        let mut e = editor_with("fn(a, b) + 1");
6778        e.jump_cursor(0, 4);
6779        run_keys(&mut e, "dab");
6780        assert_eq!(e.buffer().lines()[0], "fn + 1");
6781    }
6782
6783    #[test]
6784    fn da_big_b_deletes_around_brace() {
6785        let mut e = editor_with("x = {a: 1}");
6786        e.jump_cursor(0, 6);
6787        run_keys(&mut e, "daB");
6788        assert_eq!(e.buffer().lines()[0], "x = ");
6789    }
6790
6791    #[test]
6792    fn di_big_w_deletes_bigword() {
6793        let mut e = editor_with("foo-bar baz");
6794        e.jump_cursor(0, 2);
6795        run_keys(&mut e, "diW");
6796        assert_eq!(e.buffer().lines()[0], " baz");
6797    }
6798
6799    #[test]
6800    fn visual_select_inner_word() {
6801        let mut e = editor_with("hello world");
6802        e.jump_cursor(0, 2);
6803        run_keys(&mut e, "viw");
6804        assert_eq!(e.vim_mode(), VimMode::Visual);
6805        run_keys(&mut e, "y");
6806        assert_eq!(e.last_yank.as_deref(), Some("hello"));
6807    }
6808
6809    #[test]
6810    fn visual_select_inner_quote() {
6811        let mut e = editor_with("foo \"bar\" baz");
6812        e.jump_cursor(0, 6);
6813        run_keys(&mut e, "vi\"");
6814        run_keys(&mut e, "y");
6815        assert_eq!(e.last_yank.as_deref(), Some("bar"));
6816    }
6817
6818    #[test]
6819    fn visual_select_inner_paren() {
6820        let mut e = editor_with("fn(a, b)");
6821        e.jump_cursor(0, 4);
6822        run_keys(&mut e, "vi(");
6823        run_keys(&mut e, "y");
6824        assert_eq!(e.last_yank.as_deref(), Some("a, b"));
6825    }
6826
6827    #[test]
6828    fn visual_select_outer_brace() {
6829        let mut e = editor_with("{x}");
6830        e.jump_cursor(0, 1);
6831        run_keys(&mut e, "va{");
6832        run_keys(&mut e, "y");
6833        assert_eq!(e.last_yank.as_deref(), Some("{x}"));
6834    }
6835
6836    #[test]
6837    fn ci_paren_forward_scans_when_cursor_before_pair() {
6838        // targets.vim-style: cursor at start of `foo`, ci( jumps to next
6839        // `(...)` pair on the same line and replaces the contents.
6840        let mut e = editor_with("foo(bar)");
6841        e.jump_cursor(0, 0);
6842        run_keys(&mut e, "ci(NEW<Esc>");
6843        assert_eq!(e.buffer().lines()[0], "foo(NEW)");
6844    }
6845
6846    #[test]
6847    fn ci_paren_forward_scans_across_lines() {
6848        let mut e = editor_with("first\nfoo(bar)\nlast");
6849        e.jump_cursor(0, 0);
6850        run_keys(&mut e, "ci(NEW<Esc>");
6851        assert_eq!(e.buffer().lines()[1], "foo(NEW)");
6852    }
6853
6854    #[test]
6855    fn ci_brace_forward_scans_when_cursor_before_pair() {
6856        let mut e = editor_with("let x = {y};");
6857        e.jump_cursor(0, 0);
6858        run_keys(&mut e, "ci{NEW<Esc>");
6859        assert_eq!(e.buffer().lines()[0], "let x = {NEW};");
6860    }
6861
6862    #[test]
6863    fn cit_forward_scans_when_cursor_before_tag() {
6864        // Cursor at column 0 (before `<b>`), cit jumps into the next tag
6865        // pair and replaces its contents.
6866        let mut e = editor_with("text <b>hello</b> rest");
6867        e.jump_cursor(0, 0);
6868        run_keys(&mut e, "citNEW<Esc>");
6869        assert_eq!(e.buffer().lines()[0], "text <b>NEW</b> rest");
6870    }
6871
6872    #[test]
6873    fn dat_forward_scans_when_cursor_before_tag() {
6874        // dat = delete around tag — including the `<b>...</b>` markup.
6875        let mut e = editor_with("text <b>hello</b> rest");
6876        e.jump_cursor(0, 0);
6877        run_keys(&mut e, "dat");
6878        assert_eq!(e.buffer().lines()[0], "text  rest");
6879    }
6880
6881    #[test]
6882    fn ci_paren_still_works_when_cursor_inside() {
6883        // Regression: forward-scan fallback must not break the
6884        // canonical "cursor inside the pair" case.
6885        let mut e = editor_with("fn(a, b)");
6886        e.jump_cursor(0, 4);
6887        run_keys(&mut e, "ci(NEW<Esc>");
6888        assert_eq!(e.buffer().lines()[0], "fn(NEW)");
6889    }
6890
6891    #[test]
6892    fn caw_changes_word_with_trailing_space() {
6893        let mut e = editor_with("hello world");
6894        run_keys(&mut e, "cawfoo<Esc>");
6895        assert_eq!(e.buffer().lines()[0], "fooworld");
6896    }
6897
6898    #[test]
6899    fn visual_char_yank_preserves_raw_text() {
6900        let mut e = editor_with("hello world");
6901        run_keys(&mut e, "vllly");
6902        assert_eq!(e.last_yank.as_deref(), Some("hell"));
6903    }
6904
6905    #[test]
6906    fn single_line_visual_line_selects_full_line_on_yank() {
6907        let mut e = editor_with("hello world\nbye");
6908        run_keys(&mut e, "V");
6909        // Yank the selection — should include the full line + trailing
6910        // newline (linewise yank convention).
6911        run_keys(&mut e, "y");
6912        assert_eq!(e.last_yank.as_deref(), Some("hello world\n"));
6913    }
6914
6915    #[test]
6916    fn visual_line_extends_both_directions() {
6917        let mut e = editor_with("aaa\nbbb\nccc\nddd");
6918        run_keys(&mut e, "jjj"); // row 3, col 0
6919        run_keys(&mut e, "V");
6920        assert_eq!(e.cursor(), (3, 0));
6921        run_keys(&mut e, "k");
6922        // Cursor is free to sit on its natural column — no forced Jump.
6923        assert_eq!(e.cursor(), (2, 0));
6924        run_keys(&mut e, "k");
6925        assert_eq!(e.cursor(), (1, 0));
6926    }
6927
6928    #[test]
6929    fn visual_char_preserves_cursor_column() {
6930        let mut e = editor_with("hello world");
6931        run_keys(&mut e, "lllll"); // col 5
6932        run_keys(&mut e, "v");
6933        assert_eq!(e.cursor(), (0, 5));
6934        run_keys(&mut e, "ll");
6935        assert_eq!(e.cursor(), (0, 7));
6936    }
6937
6938    #[test]
6939    fn visual_char_highlight_bounds_order() {
6940        let mut e = editor_with("abcdef");
6941        run_keys(&mut e, "lll"); // col 3
6942        run_keys(&mut e, "v");
6943        run_keys(&mut e, "hh"); // col 1
6944        // Anchor (0, 3), cursor (0, 1). Bounds ordered: start=(0,1) end=(0,3).
6945        assert_eq!(e.char_highlight(), Some(((0, 1), (0, 3))));
6946    }
6947
6948    #[test]
6949    fn visual_line_highlight_bounds() {
6950        let mut e = editor_with("a\nb\nc");
6951        run_keys(&mut e, "V");
6952        assert_eq!(e.line_highlight(), Some((0, 0)));
6953        run_keys(&mut e, "j");
6954        assert_eq!(e.line_highlight(), Some((0, 1)));
6955        run_keys(&mut e, "j");
6956        assert_eq!(e.line_highlight(), Some((0, 2)));
6957    }
6958
6959    // ─── Basic motions ─────────────────────────────────────────────────────
6960
6961    #[test]
6962    fn h_moves_left() {
6963        let mut e = editor_with("hello");
6964        e.jump_cursor(0, 3);
6965        run_keys(&mut e, "h");
6966        assert_eq!(e.cursor(), (0, 2));
6967    }
6968
6969    #[test]
6970    fn l_moves_right() {
6971        let mut e = editor_with("hello");
6972        run_keys(&mut e, "l");
6973        assert_eq!(e.cursor(), (0, 1));
6974    }
6975
6976    #[test]
6977    fn k_moves_up() {
6978        let mut e = editor_with("a\nb\nc");
6979        e.jump_cursor(2, 0);
6980        run_keys(&mut e, "k");
6981        assert_eq!(e.cursor(), (1, 0));
6982    }
6983
6984    #[test]
6985    fn zero_moves_to_line_start() {
6986        let mut e = editor_with("    hello");
6987        run_keys(&mut e, "$");
6988        run_keys(&mut e, "0");
6989        assert_eq!(e.cursor().1, 0);
6990    }
6991
6992    #[test]
6993    fn caret_moves_to_first_non_blank() {
6994        let mut e = editor_with("    hello");
6995        run_keys(&mut e, "0");
6996        run_keys(&mut e, "^");
6997        assert_eq!(e.cursor().1, 4);
6998    }
6999
7000    #[test]
7001    fn dollar_moves_to_last_char() {
7002        let mut e = editor_with("hello");
7003        run_keys(&mut e, "$");
7004        assert_eq!(e.cursor().1, 4);
7005    }
7006
7007    #[test]
7008    fn dollar_on_empty_line_stays_at_col_zero() {
7009        let mut e = editor_with("");
7010        run_keys(&mut e, "$");
7011        assert_eq!(e.cursor().1, 0);
7012    }
7013
7014    #[test]
7015    fn w_jumps_to_next_word() {
7016        let mut e = editor_with("foo bar baz");
7017        run_keys(&mut e, "w");
7018        assert_eq!(e.cursor().1, 4);
7019    }
7020
7021    #[test]
7022    fn b_jumps_back_a_word() {
7023        let mut e = editor_with("foo bar");
7024        e.jump_cursor(0, 6);
7025        run_keys(&mut e, "b");
7026        assert_eq!(e.cursor().1, 4);
7027    }
7028
7029    #[test]
7030    fn e_jumps_to_word_end() {
7031        let mut e = editor_with("foo bar");
7032        run_keys(&mut e, "e");
7033        assert_eq!(e.cursor().1, 2);
7034    }
7035
7036    // ─── Operators with line-edge and file-edge motions ───────────────────
7037
7038    #[test]
7039    fn d_dollar_deletes_to_eol() {
7040        let mut e = editor_with("hello world");
7041        e.jump_cursor(0, 5);
7042        run_keys(&mut e, "d$");
7043        assert_eq!(e.buffer().lines()[0], "hello");
7044    }
7045
7046    #[test]
7047    fn d_zero_deletes_to_line_start() {
7048        let mut e = editor_with("hello world");
7049        e.jump_cursor(0, 6);
7050        run_keys(&mut e, "d0");
7051        assert_eq!(e.buffer().lines()[0], "world");
7052    }
7053
7054    #[test]
7055    fn d_caret_deletes_to_first_non_blank() {
7056        let mut e = editor_with("    hello");
7057        e.jump_cursor(0, 6);
7058        run_keys(&mut e, "d^");
7059        assert_eq!(e.buffer().lines()[0], "    llo");
7060    }
7061
7062    #[test]
7063    fn d_capital_g_deletes_to_end_of_file() {
7064        let mut e = editor_with("a\nb\nc\nd");
7065        e.jump_cursor(1, 0);
7066        run_keys(&mut e, "dG");
7067        assert_eq!(e.buffer().lines(), &["a".to_string()]);
7068    }
7069
7070    #[test]
7071    fn d_gg_deletes_to_start_of_file() {
7072        let mut e = editor_with("a\nb\nc\nd");
7073        e.jump_cursor(2, 0);
7074        run_keys(&mut e, "dgg");
7075        assert_eq!(e.buffer().lines(), &["d".to_string()]);
7076    }
7077
7078    #[test]
7079    fn cw_is_ce_quirk() {
7080        // `cw` on a non-blank word must NOT eat the trailing whitespace;
7081        // it behaves like `ce` so the replacement lands before the space.
7082        let mut e = editor_with("foo bar");
7083        run_keys(&mut e, "cwxyz<Esc>");
7084        assert_eq!(e.buffer().lines()[0], "xyz bar");
7085    }
7086
7087    // ─── Single-char edits ────────────────────────────────────────────────
7088
7089    #[test]
7090    fn big_d_deletes_to_eol() {
7091        let mut e = editor_with("hello world");
7092        e.jump_cursor(0, 5);
7093        run_keys(&mut e, "D");
7094        assert_eq!(e.buffer().lines()[0], "hello");
7095    }
7096
7097    #[test]
7098    fn big_c_deletes_to_eol_and_inserts() {
7099        let mut e = editor_with("hello world");
7100        e.jump_cursor(0, 5);
7101        run_keys(&mut e, "C!<Esc>");
7102        assert_eq!(e.buffer().lines()[0], "hello!");
7103    }
7104
7105    #[test]
7106    fn j_joins_next_line_with_space() {
7107        let mut e = editor_with("hello\nworld");
7108        run_keys(&mut e, "J");
7109        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7110    }
7111
7112    #[test]
7113    fn j_strips_leading_whitespace_on_join() {
7114        let mut e = editor_with("hello\n    world");
7115        run_keys(&mut e, "J");
7116        assert_eq!(e.buffer().lines(), &["hello world".to_string()]);
7117    }
7118
7119    #[test]
7120    fn big_x_deletes_char_before_cursor() {
7121        let mut e = editor_with("hello");
7122        e.jump_cursor(0, 3);
7123        run_keys(&mut e, "X");
7124        assert_eq!(e.buffer().lines()[0], "helo");
7125    }
7126
7127    #[test]
7128    fn s_substitutes_char_and_enters_insert() {
7129        let mut e = editor_with("hello");
7130        run_keys(&mut e, "sX<Esc>");
7131        assert_eq!(e.buffer().lines()[0], "Xello");
7132    }
7133
7134    #[test]
7135    fn count_x_deletes_many() {
7136        let mut e = editor_with("abcdef");
7137        run_keys(&mut e, "3x");
7138        assert_eq!(e.buffer().lines()[0], "def");
7139    }
7140
7141    // ─── Paste ────────────────────────────────────────────────────────────
7142
7143    #[test]
7144    fn p_pastes_charwise_after_cursor() {
7145        let mut e = editor_with("hello");
7146        run_keys(&mut e, "yw");
7147        run_keys(&mut e, "$p");
7148        assert_eq!(e.buffer().lines()[0], "hellohello");
7149    }
7150
7151    #[test]
7152    fn capital_p_pastes_charwise_before_cursor() {
7153        let mut e = editor_with("hello");
7154        // Yank "he" (2 chars) then paste it before the cursor.
7155        run_keys(&mut e, "v");
7156        run_keys(&mut e, "l");
7157        run_keys(&mut e, "y");
7158        run_keys(&mut e, "$P");
7159        // After yank cursor is at 0; $ goes to end (col 4), P pastes
7160        // before cursor — "hell" + "he" + "o" = "hellheo".
7161        assert_eq!(e.buffer().lines()[0], "hellheo");
7162    }
7163
7164    #[test]
7165    fn p_pastes_linewise_below() {
7166        let mut e = editor_with("one\ntwo\nthree");
7167        run_keys(&mut e, "yy");
7168        run_keys(&mut e, "p");
7169        assert_eq!(
7170            e.buffer().lines(),
7171            &[
7172                "one".to_string(),
7173                "one".to_string(),
7174                "two".to_string(),
7175                "three".to_string()
7176            ]
7177        );
7178    }
7179
7180    #[test]
7181    fn capital_p_pastes_linewise_above() {
7182        let mut e = editor_with("one\ntwo");
7183        e.jump_cursor(1, 0);
7184        run_keys(&mut e, "yy");
7185        run_keys(&mut e, "P");
7186        assert_eq!(
7187            e.buffer().lines(),
7188            &["one".to_string(), "two".to_string(), "two".to_string()]
7189        );
7190    }
7191
7192    // ─── Reverse word search ──────────────────────────────────────────────
7193
7194    #[test]
7195    fn hash_finds_previous_occurrence() {
7196        let mut e = editor_with("foo bar foo baz foo");
7197        // Move to the third 'foo' then #.
7198        e.jump_cursor(0, 16);
7199        run_keys(&mut e, "#");
7200        assert_eq!(e.cursor().1, 8);
7201    }
7202
7203    // ─── VisualLine delete / change ───────────────────────────────────────
7204
7205    #[test]
7206    fn visual_line_delete_removes_full_lines() {
7207        let mut e = editor_with("a\nb\nc\nd");
7208        run_keys(&mut e, "Vjd");
7209        assert_eq!(e.buffer().lines(), &["c".to_string(), "d".to_string()]);
7210    }
7211
7212    #[test]
7213    fn visual_line_change_leaves_blank_line() {
7214        let mut e = editor_with("a\nb\nc");
7215        run_keys(&mut e, "Vjc");
7216        assert_eq!(e.vim_mode(), VimMode::Insert);
7217        run_keys(&mut e, "X<Esc>");
7218        // `Vjc` wipes rows 0-1's contents and leaves a blank line in
7219        // their place (vim convention). Typing `X` lands on that blank
7220        // first line.
7221        assert_eq!(e.buffer().lines(), &["X".to_string(), "c".to_string()]);
7222    }
7223
7224    #[test]
7225    fn cc_leaves_blank_line() {
7226        let mut e = editor_with("a\nb\nc");
7227        e.jump_cursor(1, 0);
7228        run_keys(&mut e, "ccX<Esc>");
7229        assert_eq!(
7230            e.buffer().lines(),
7231            &["a".to_string(), "X".to_string(), "c".to_string()]
7232        );
7233    }
7234
7235    // ─── Scrolling ────────────────────────────────────────────────────────
7236
7237    // ─── WORD motions (W/B/E) ─────────────────────────────────────────────
7238
7239    #[test]
7240    fn big_w_skips_hyphens() {
7241        // `w` stops at `-`; `W` treats the whole `foo-bar` as one WORD.
7242        let mut e = editor_with("foo-bar baz");
7243        run_keys(&mut e, "W");
7244        assert_eq!(e.cursor().1, 8);
7245    }
7246
7247    #[test]
7248    fn big_w_crosses_lines() {
7249        let mut e = editor_with("foo-bar\nbaz-qux");
7250        run_keys(&mut e, "W");
7251        assert_eq!(e.cursor(), (1, 0));
7252    }
7253
7254    #[test]
7255    fn big_b_skips_hyphens() {
7256        let mut e = editor_with("foo-bar baz");
7257        e.jump_cursor(0, 9);
7258        run_keys(&mut e, "B");
7259        assert_eq!(e.cursor().1, 8);
7260        run_keys(&mut e, "B");
7261        assert_eq!(e.cursor().1, 0);
7262    }
7263
7264    #[test]
7265    fn big_e_jumps_to_big_word_end() {
7266        let mut e = editor_with("foo-bar baz");
7267        run_keys(&mut e, "E");
7268        assert_eq!(e.cursor().1, 6);
7269        run_keys(&mut e, "E");
7270        assert_eq!(e.cursor().1, 10);
7271    }
7272
7273    #[test]
7274    fn dw_with_big_word_variant() {
7275        // `dW` uses the WORD motion, so `foo-bar` deletes as a unit.
7276        let mut e = editor_with("foo-bar baz");
7277        run_keys(&mut e, "dW");
7278        assert_eq!(e.buffer().lines()[0], "baz");
7279    }
7280
7281    // ─── Insert-mode Ctrl shortcuts ──────────────────────────────────────
7282
7283    #[test]
7284    fn insert_ctrl_w_deletes_word_back() {
7285        let mut e = editor_with("");
7286        run_keys(&mut e, "i");
7287        for c in "hello world".chars() {
7288            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7289        }
7290        run_keys(&mut e, "<C-w>");
7291        assert_eq!(e.buffer().lines()[0], "hello ");
7292    }
7293
7294    #[test]
7295    fn insert_ctrl_w_at_col0_joins_with_prev_word() {
7296        // Vim with default `backspace=indent,eol,start`: Ctrl-W at the
7297        // start of a row joins to the previous line and deletes the
7298        // word now before the cursor.
7299        let mut e = editor_with("hello\nworld");
7300        e.jump_cursor(1, 0);
7301        run_keys(&mut e, "i");
7302        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7303        // "hello" was the only word on row 0; it gets deleted, leaving
7304        // "world" on a single line.
7305        assert_eq!(e.buffer().lines(), vec!["world".to_string()]);
7306        assert_eq!(e.cursor(), (0, 0));
7307    }
7308
7309    #[test]
7310    fn insert_ctrl_w_at_col0_keeps_prefix_words() {
7311        let mut e = editor_with("foo bar\nbaz");
7312        e.jump_cursor(1, 0);
7313        run_keys(&mut e, "i");
7314        e.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
7315        // Joins lines, then deletes the trailing "bar" of the prev line.
7316        assert_eq!(e.buffer().lines(), vec!["foo baz".to_string()]);
7317        assert_eq!(e.cursor(), (0, 4));
7318    }
7319
7320    #[test]
7321    fn insert_ctrl_u_deletes_to_line_start() {
7322        let mut e = editor_with("");
7323        run_keys(&mut e, "i");
7324        for c in "hello world".chars() {
7325            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7326        }
7327        run_keys(&mut e, "<C-u>");
7328        assert_eq!(e.buffer().lines()[0], "");
7329    }
7330
7331    #[test]
7332    fn insert_ctrl_o_runs_one_normal_command() {
7333        let mut e = editor_with("hello world");
7334        // Enter insert, then Ctrl-o dw (delete a word while in insert).
7335        run_keys(&mut e, "A");
7336        assert_eq!(e.vim_mode(), VimMode::Insert);
7337        // Move cursor back to start of "hello" for the Ctrl-o dw.
7338        e.jump_cursor(0, 0);
7339        run_keys(&mut e, "<C-o>");
7340        assert_eq!(e.vim_mode(), VimMode::Normal);
7341        run_keys(&mut e, "dw");
7342        // After the command completes, back in insert.
7343        assert_eq!(e.vim_mode(), VimMode::Insert);
7344        assert_eq!(e.buffer().lines()[0], "world");
7345    }
7346
7347    // ─── Sticky column across vertical motion ────────────────────────────
7348
7349    #[test]
7350    fn j_through_empty_line_preserves_column() {
7351        let mut e = editor_with("hello world\n\nanother line");
7352        // Park cursor at col 6 on row 0.
7353        run_keys(&mut e, "llllll");
7354        assert_eq!(e.cursor(), (0, 6));
7355        // j into the empty line — cursor clamps to (1, 0) visually, but
7356        // sticky col stays at 6.
7357        run_keys(&mut e, "j");
7358        assert_eq!(e.cursor(), (1, 0));
7359        // j onto a longer row — sticky col restores us to col 6.
7360        run_keys(&mut e, "j");
7361        assert_eq!(e.cursor(), (2, 6));
7362    }
7363
7364    #[test]
7365    fn j_through_shorter_line_preserves_column() {
7366        let mut e = editor_with("hello world\nhi\nanother line");
7367        run_keys(&mut e, "lllllll"); // col 7
7368        run_keys(&mut e, "j"); // short line — clamps to col 1
7369        assert_eq!(e.cursor(), (1, 1));
7370        run_keys(&mut e, "j");
7371        assert_eq!(e.cursor(), (2, 7));
7372    }
7373
7374    #[test]
7375    fn esc_from_insert_sticky_matches_visible_cursor() {
7376        // Cursor at col 12, I (moves to col 4), type "X" (col 5), Esc
7377        // backs to col 4 — sticky must mirror that visible col so j
7378        // lands at col 4 of the next row, not col 5 or col 12.
7379        let mut e = editor_with("    this is a line\n    another one of a similar size");
7380        e.jump_cursor(0, 12);
7381        run_keys(&mut e, "I");
7382        assert_eq!(e.cursor(), (0, 4));
7383        run_keys(&mut e, "X<Esc>");
7384        assert_eq!(e.cursor(), (0, 4));
7385        run_keys(&mut e, "j");
7386        assert_eq!(e.cursor(), (1, 4));
7387    }
7388
7389    #[test]
7390    fn esc_from_insert_sticky_tracks_inserted_chars() {
7391        let mut e = editor_with("xxxxxxx\nyyyyyyy");
7392        run_keys(&mut e, "i");
7393        run_keys(&mut e, "abc<Esc>");
7394        assert_eq!(e.cursor(), (0, 2));
7395        run_keys(&mut e, "j");
7396        assert_eq!(e.cursor(), (1, 2));
7397    }
7398
7399    #[test]
7400    fn esc_from_insert_sticky_tracks_arrow_nav() {
7401        let mut e = editor_with("xxxxxx\nyyyyyy");
7402        run_keys(&mut e, "i");
7403        run_keys(&mut e, "abc");
7404        for _ in 0..2 {
7405            e.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
7406        }
7407        run_keys(&mut e, "<Esc>");
7408        assert_eq!(e.cursor(), (0, 0));
7409        run_keys(&mut e, "j");
7410        assert_eq!(e.cursor(), (1, 0));
7411    }
7412
7413    #[test]
7414    fn esc_from_insert_at_col_14_followed_by_j() {
7415        // User-reported regression: cursor at col 14, i, type "test "
7416        // (5 chars → col 19), Esc → col 18. j must land at col 18.
7417        let line = "x".repeat(30);
7418        let buf = format!("{line}\n{line}");
7419        let mut e = editor_with(&buf);
7420        e.jump_cursor(0, 14);
7421        run_keys(&mut e, "i");
7422        for c in "test ".chars() {
7423            e.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
7424        }
7425        run_keys(&mut e, "<Esc>");
7426        assert_eq!(e.cursor(), (0, 18));
7427        run_keys(&mut e, "j");
7428        assert_eq!(e.cursor(), (1, 18));
7429    }
7430
7431    #[test]
7432    fn linewise_paste_resets_sticky_column() {
7433        // yy then p lands the cursor on the first non-blank of the
7434        // pasted line; the next j must not drag back to the old
7435        // sticky column.
7436        let mut e = editor_with("    hello\naaaaaaaa\nbye");
7437        run_keys(&mut e, "llllll"); // col 6, sticky = 6
7438        run_keys(&mut e, "yy");
7439        run_keys(&mut e, "j"); // into row 1 col 6
7440        run_keys(&mut e, "p"); // paste below row 1 — cursor on "    hello"
7441        // Cursor should be at (2, 4) — first non-blank of the pasted line.
7442        assert_eq!(e.cursor(), (2, 4));
7443        // j should then preserve col 4, not jump back to 6.
7444        run_keys(&mut e, "j");
7445        assert_eq!(e.cursor(), (3, 2));
7446    }
7447
7448    #[test]
7449    fn horizontal_motion_resyncs_sticky_column() {
7450        // Starting col 6 on row 0, go back to col 3, then down through
7451        // an empty row. The sticky col should be 3 (from the last `h`
7452        // sequence), not 6.
7453        let mut e = editor_with("hello world\n\nanother line");
7454        run_keys(&mut e, "llllll"); // col 6
7455        run_keys(&mut e, "hhh"); // col 3
7456        run_keys(&mut e, "jj");
7457        assert_eq!(e.cursor(), (2, 3));
7458    }
7459
7460    // ─── Visual block ────────────────────────────────────────────────────
7461
7462    #[test]
7463    fn ctrl_v_enters_visual_block() {
7464        let mut e = editor_with("aaa\nbbb\nccc");
7465        run_keys(&mut e, "<C-v>");
7466        assert_eq!(e.vim_mode(), VimMode::VisualBlock);
7467    }
7468
7469    #[test]
7470    fn visual_block_esc_returns_to_normal() {
7471        let mut e = editor_with("aaa\nbbb\nccc");
7472        run_keys(&mut e, "<C-v>");
7473        run_keys(&mut e, "<Esc>");
7474        assert_eq!(e.vim_mode(), VimMode::Normal);
7475    }
7476
7477    #[test]
7478    fn visual_block_delete_removes_column_range() {
7479        let mut e = editor_with("hello\nworld\nhappy");
7480        // Move off col 0 first so the block starts mid-row.
7481        run_keys(&mut e, "l");
7482        run_keys(&mut e, "<C-v>");
7483        run_keys(&mut e, "jj");
7484        run_keys(&mut e, "ll");
7485        run_keys(&mut e, "d");
7486        // Deletes cols 1-3 on every row — "ell" / "orl" / "app".
7487        assert_eq!(
7488            e.buffer().lines(),
7489            &["ho".to_string(), "wd".to_string(), "hy".to_string()]
7490        );
7491    }
7492
7493    #[test]
7494    fn visual_block_yank_joins_with_newlines() {
7495        let mut e = editor_with("hello\nworld\nhappy");
7496        run_keys(&mut e, "<C-v>");
7497        run_keys(&mut e, "jj");
7498        run_keys(&mut e, "ll");
7499        run_keys(&mut e, "y");
7500        assert_eq!(e.last_yank.as_deref(), Some("hel\nwor\nhap"));
7501    }
7502
7503    #[test]
7504    fn visual_block_replace_fills_block() {
7505        let mut e = editor_with("hello\nworld\nhappy");
7506        run_keys(&mut e, "<C-v>");
7507        run_keys(&mut e, "jj");
7508        run_keys(&mut e, "ll");
7509        run_keys(&mut e, "rx");
7510        assert_eq!(
7511            e.buffer().lines(),
7512            &[
7513                "xxxlo".to_string(),
7514                "xxxld".to_string(),
7515                "xxxpy".to_string()
7516            ]
7517        );
7518    }
7519
7520    #[test]
7521    fn visual_block_insert_repeats_across_rows() {
7522        let mut e = editor_with("hello\nworld\nhappy");
7523        run_keys(&mut e, "<C-v>");
7524        run_keys(&mut e, "jj");
7525        run_keys(&mut e, "I");
7526        run_keys(&mut e, "# <Esc>");
7527        assert_eq!(
7528            e.buffer().lines(),
7529            &[
7530                "# hello".to_string(),
7531                "# world".to_string(),
7532                "# happy".to_string()
7533            ]
7534        );
7535    }
7536
7537    #[test]
7538    fn block_highlight_returns_none_outside_block_mode() {
7539        let mut e = editor_with("abc");
7540        assert!(e.block_highlight().is_none());
7541        run_keys(&mut e, "v");
7542        assert!(e.block_highlight().is_none());
7543        run_keys(&mut e, "<Esc>V");
7544        assert!(e.block_highlight().is_none());
7545    }
7546
7547    #[test]
7548    fn block_highlight_bounds_track_anchor_and_cursor() {
7549        let mut e = editor_with("aaaa\nbbbb\ncccc");
7550        run_keys(&mut e, "ll"); // cursor (0, 2)
7551        run_keys(&mut e, "<C-v>");
7552        run_keys(&mut e, "jh"); // cursor (1, 1)
7553        // anchor = (0, 2), cursor = (1, 1) → top=0 bot=1 left=1 right=2.
7554        assert_eq!(e.block_highlight(), Some((0, 1, 1, 2)));
7555    }
7556
7557    #[test]
7558    fn visual_block_delete_handles_short_lines() {
7559        // Middle row is shorter than the block's right column.
7560        let mut e = editor_with("hello\nhi\nworld");
7561        run_keys(&mut e, "l"); // col 1
7562        run_keys(&mut e, "<C-v>");
7563        run_keys(&mut e, "jjll"); // cursor (2, 3)
7564        run_keys(&mut e, "d");
7565        // Row 0: delete cols 1-3 ("ell") → "ho".
7566        // Row 1: only 2 chars ("hi"); block starts at col 1, so just "i"
7567        //        gets removed → "h".
7568        // Row 2: delete cols 1-3 ("orl") → "wd".
7569        assert_eq!(
7570            e.buffer().lines(),
7571            &["ho".to_string(), "h".to_string(), "wd".to_string()]
7572        );
7573    }
7574
7575    #[test]
7576    fn visual_block_yank_pads_short_lines_with_empties() {
7577        let mut e = editor_with("hello\nhi\nworld");
7578        run_keys(&mut e, "l");
7579        run_keys(&mut e, "<C-v>");
7580        run_keys(&mut e, "jjll");
7581        run_keys(&mut e, "y");
7582        // Row 0 chars 1-3 = "ell"; row 1 chars 1- (only "i"); row 2 "orl".
7583        assert_eq!(e.last_yank.as_deref(), Some("ell\ni\norl"));
7584    }
7585
7586    #[test]
7587    fn visual_block_replace_skips_past_eol() {
7588        // Block extends past the end of every row in column range;
7589        // replace should leave lines shorter than `left` untouched.
7590        let mut e = editor_with("ab\ncd\nef");
7591        // Put cursor at col 1 (last char), extend block 5 columns right.
7592        run_keys(&mut e, "l");
7593        run_keys(&mut e, "<C-v>");
7594        run_keys(&mut e, "jjllllll");
7595        run_keys(&mut e, "rX");
7596        // Every row had only col 0..=1; block covers col 1..=7 → only
7597        // col 1 is in range on each row, so just that cell changes.
7598        assert_eq!(
7599            e.buffer().lines(),
7600            &["aX".to_string(), "cX".to_string(), "eX".to_string()]
7601        );
7602    }
7603
7604    #[test]
7605    fn visual_block_with_empty_line_in_middle() {
7606        let mut e = editor_with("abcd\n\nefgh");
7607        run_keys(&mut e, "<C-v>");
7608        run_keys(&mut e, "jjll"); // cursor (2, 2)
7609        run_keys(&mut e, "d");
7610        // Row 0 cols 0-2 removed → "d". Row 1 empty → untouched.
7611        // Row 2 cols 0-2 removed → "h".
7612        assert_eq!(
7613            e.buffer().lines(),
7614            &["d".to_string(), "".to_string(), "h".to_string()]
7615        );
7616    }
7617
7618    #[test]
7619    fn block_insert_pads_empty_lines_to_block_column() {
7620        // Middle line is empty; block I at column 3 should pad the empty
7621        // line with spaces so the inserted text lines up.
7622        let mut e = editor_with("this is a line\n\nthis is a line");
7623        e.jump_cursor(0, 3);
7624        run_keys(&mut e, "<C-v>");
7625        run_keys(&mut e, "jj");
7626        run_keys(&mut e, "I");
7627        run_keys(&mut e, "XX<Esc>");
7628        assert_eq!(
7629            e.buffer().lines(),
7630            &[
7631                "thiXXs is a line".to_string(),
7632                "   XX".to_string(),
7633                "thiXXs is a line".to_string()
7634            ]
7635        );
7636    }
7637
7638    #[test]
7639    fn block_insert_pads_short_lines_to_block_column() {
7640        let mut e = editor_with("aaaaa\nbb\naaaaa");
7641        e.jump_cursor(0, 3);
7642        run_keys(&mut e, "<C-v>");
7643        run_keys(&mut e, "jj");
7644        run_keys(&mut e, "I");
7645        run_keys(&mut e, "Y<Esc>");
7646        // Row 1 "bb" is shorter than col 3 — pad with one space then Y.
7647        assert_eq!(
7648            e.buffer().lines(),
7649            &[
7650                "aaaYaa".to_string(),
7651                "bb Y".to_string(),
7652                "aaaYaa".to_string()
7653            ]
7654        );
7655    }
7656
7657    #[test]
7658    fn visual_block_append_repeats_across_rows() {
7659        let mut e = editor_with("foo\nbar\nbaz");
7660        run_keys(&mut e, "<C-v>");
7661        run_keys(&mut e, "jj");
7662        // Single-column block (anchor col = cursor col = 0); `A` appends
7663        // after column 0 on every row.
7664        run_keys(&mut e, "A");
7665        run_keys(&mut e, "!<Esc>");
7666        assert_eq!(
7667            e.buffer().lines(),
7668            &["f!oo".to_string(), "b!ar".to_string(), "b!az".to_string()]
7669        );
7670    }
7671
7672    // ─── `/` / `?` search prompt ─────────────────────────────────────────
7673
7674    #[test]
7675    fn slash_opens_forward_search_prompt() {
7676        let mut e = editor_with("hello world");
7677        run_keys(&mut e, "/");
7678        let p = e.search_prompt().expect("prompt should be active");
7679        assert!(p.text.is_empty());
7680        assert!(p.forward);
7681    }
7682
7683    #[test]
7684    fn question_opens_backward_search_prompt() {
7685        let mut e = editor_with("hello world");
7686        run_keys(&mut e, "?");
7687        let p = e.search_prompt().expect("prompt should be active");
7688        assert!(!p.forward);
7689    }
7690
7691    #[test]
7692    fn search_prompt_typing_updates_pattern_live() {
7693        let mut e = editor_with("foo bar\nbaz");
7694        run_keys(&mut e, "/bar");
7695        assert_eq!(e.search_prompt().unwrap().text, "bar");
7696        // Pattern set on the engine search state for live highlight.
7697        assert!(e.search_state().pattern.is_some());
7698    }
7699
7700    #[test]
7701    fn search_prompt_backspace_and_enter() {
7702        let mut e = editor_with("hello world\nagain");
7703        run_keys(&mut e, "/worlx");
7704        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
7705        assert_eq!(e.search_prompt().unwrap().text, "worl");
7706        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7707        // Prompt closed, last_search set, cursor advanced to match.
7708        assert!(e.search_prompt().is_none());
7709        assert_eq!(e.last_search(), Some("worl"));
7710        assert_eq!(e.cursor(), (0, 6));
7711    }
7712
7713    #[test]
7714    fn empty_search_prompt_enter_repeats_last_search() {
7715        let mut e = editor_with("foo bar foo baz foo");
7716        run_keys(&mut e, "/foo");
7717        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7718        assert_eq!(e.cursor().1, 8);
7719        // Empty `/<CR>` should advance to the next match, not clear last_search.
7720        run_keys(&mut e, "/");
7721        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7722        assert_eq!(e.cursor().1, 16);
7723        assert_eq!(e.last_search(), Some("foo"));
7724    }
7725
7726    #[test]
7727    fn search_history_records_committed_patterns() {
7728        let mut e = editor_with("alpha beta gamma");
7729        run_keys(&mut e, "/alpha<CR>");
7730        run_keys(&mut e, "/beta<CR>");
7731        // Newest entry at the back.
7732        let history = e.vim.search_history.clone();
7733        assert_eq!(history, vec!["alpha", "beta"]);
7734    }
7735
7736    #[test]
7737    fn search_history_dedupes_consecutive_repeats() {
7738        let mut e = editor_with("foo bar foo");
7739        run_keys(&mut e, "/foo<CR>");
7740        run_keys(&mut e, "/foo<CR>");
7741        run_keys(&mut e, "/bar<CR>");
7742        run_keys(&mut e, "/bar<CR>");
7743        // Two distinct entries; the duplicates collapsed.
7744        assert_eq!(e.vim.search_history.clone(), vec!["foo", "bar"]);
7745    }
7746
7747    #[test]
7748    fn ctrl_p_walks_history_backward() {
7749        let mut e = editor_with("alpha beta gamma");
7750        run_keys(&mut e, "/alpha<CR>");
7751        run_keys(&mut e, "/beta<CR>");
7752        // Open a fresh prompt; Ctrl-P pulls in the newest entry.
7753        run_keys(&mut e, "/");
7754        assert_eq!(e.search_prompt().unwrap().text, "");
7755        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7756        assert_eq!(e.search_prompt().unwrap().text, "beta");
7757        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7758        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7759        // At the oldest entry; further Ctrl-P is a no-op.
7760        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7761        assert_eq!(e.search_prompt().unwrap().text, "alpha");
7762    }
7763
7764    #[test]
7765    fn ctrl_n_walks_history_forward_after_ctrl_p() {
7766        let mut e = editor_with("a b c");
7767        run_keys(&mut e, "/a<CR>");
7768        run_keys(&mut e, "/b<CR>");
7769        run_keys(&mut e, "/c<CR>");
7770        run_keys(&mut e, "/");
7771        // Walk back to "a", then forward again.
7772        for _ in 0..3 {
7773            e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7774        }
7775        assert_eq!(e.search_prompt().unwrap().text, "a");
7776        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7777        assert_eq!(e.search_prompt().unwrap().text, "b");
7778        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7779        assert_eq!(e.search_prompt().unwrap().text, "c");
7780        // Past the newest — stays at "c".
7781        e.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
7782        assert_eq!(e.search_prompt().unwrap().text, "c");
7783    }
7784
7785    #[test]
7786    fn typing_after_history_walk_resets_cursor() {
7787        let mut e = editor_with("foo");
7788        run_keys(&mut e, "/foo<CR>");
7789        run_keys(&mut e, "/");
7790        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7791        assert_eq!(e.search_prompt().unwrap().text, "foo");
7792        // User edits — append a char. Next Ctrl-P should restart from
7793        // the newest entry, not continue walking older.
7794        e.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
7795        assert_eq!(e.search_prompt().unwrap().text, "foox");
7796        e.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
7797        assert_eq!(e.search_prompt().unwrap().text, "foo");
7798    }
7799
7800    #[test]
7801    fn empty_backward_search_prompt_enter_repeats_last_search() {
7802        let mut e = editor_with("foo bar foo baz foo");
7803        // Forward to col 8, then `?<CR>` should walk backward to col 0.
7804        run_keys(&mut e, "/foo");
7805        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7806        assert_eq!(e.cursor().1, 8);
7807        run_keys(&mut e, "?");
7808        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7809        assert_eq!(e.cursor().1, 0);
7810        assert_eq!(e.last_search(), Some("foo"));
7811    }
7812
7813    #[test]
7814    fn search_prompt_esc_cancels_but_keeps_last_search() {
7815        let mut e = editor_with("foo bar\nbaz");
7816        run_keys(&mut e, "/bar");
7817        e.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
7818        assert!(e.search_prompt().is_none());
7819        assert_eq!(e.last_search(), Some("bar"));
7820    }
7821
7822    #[test]
7823    fn search_then_n_and_shift_n_navigate() {
7824        let mut e = editor_with("foo bar foo baz foo");
7825        run_keys(&mut e, "/foo");
7826        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7827        // `/foo` + Enter jumps forward; we land on the next match after col 0.
7828        assert_eq!(e.cursor().1, 8);
7829        run_keys(&mut e, "n");
7830        assert_eq!(e.cursor().1, 16);
7831        run_keys(&mut e, "N");
7832        assert_eq!(e.cursor().1, 8);
7833    }
7834
7835    #[test]
7836    fn question_mark_searches_backward_on_enter() {
7837        let mut e = editor_with("foo bar foo baz");
7838        e.jump_cursor(0, 10);
7839        run_keys(&mut e, "?foo");
7840        e.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7841        // Cursor jumps backward to the closest match before col 10.
7842        assert_eq!(e.cursor(), (0, 8));
7843    }
7844
7845    // ─── P6 quick wins (Y, gJ, ge / gE) ──────────────────────────────────
7846
7847    #[test]
7848    fn big_y_yanks_to_end_of_line() {
7849        let mut e = editor_with("hello world");
7850        e.jump_cursor(0, 6);
7851        run_keys(&mut e, "Y");
7852        assert_eq!(e.last_yank.as_deref(), Some("world"));
7853    }
7854
7855    #[test]
7856    fn big_y_from_line_start_yanks_full_line() {
7857        let mut e = editor_with("hello world");
7858        run_keys(&mut e, "Y");
7859        assert_eq!(e.last_yank.as_deref(), Some("hello world"));
7860    }
7861
7862    #[test]
7863    fn gj_joins_without_inserting_space() {
7864        let mut e = editor_with("hello\n    world");
7865        run_keys(&mut e, "gJ");
7866        // No space inserted, leading whitespace preserved.
7867        assert_eq!(e.buffer().lines(), &["hello    world".to_string()]);
7868    }
7869
7870    #[test]
7871    fn gj_noop_on_last_line() {
7872        let mut e = editor_with("only");
7873        run_keys(&mut e, "gJ");
7874        assert_eq!(e.buffer().lines(), &["only".to_string()]);
7875    }
7876
7877    #[test]
7878    fn ge_jumps_to_previous_word_end() {
7879        let mut e = editor_with("foo bar baz");
7880        e.jump_cursor(0, 5);
7881        run_keys(&mut e, "ge");
7882        assert_eq!(e.cursor(), (0, 2));
7883    }
7884
7885    #[test]
7886    fn ge_respects_word_class() {
7887        // Small-word `ge` treats `-` as its own word, so from mid-"bar"
7888        // it lands on the `-` rather than end of "foo".
7889        let mut e = editor_with("foo-bar baz");
7890        e.jump_cursor(0, 5);
7891        run_keys(&mut e, "ge");
7892        assert_eq!(e.cursor(), (0, 3));
7893    }
7894
7895    #[test]
7896    fn big_ge_treats_hyphens_as_part_of_word() {
7897        // `gE` uses WORD (whitespace-delimited) semantics so it skips
7898        // over the `-` and lands on the end of "foo-bar".
7899        let mut e = editor_with("foo-bar baz");
7900        e.jump_cursor(0, 10);
7901        run_keys(&mut e, "gE");
7902        assert_eq!(e.cursor(), (0, 6));
7903    }
7904
7905    #[test]
7906    fn ge_crosses_line_boundary() {
7907        let mut e = editor_with("foo\nbar");
7908        e.jump_cursor(1, 0);
7909        run_keys(&mut e, "ge");
7910        assert_eq!(e.cursor(), (0, 2));
7911    }
7912
7913    #[test]
7914    fn dge_deletes_to_end_of_previous_word() {
7915        let mut e = editor_with("foo bar baz");
7916        e.jump_cursor(0, 8);
7917        // d + ge from 'b' of "baz": range is ge → col 6 ('r' of bar),
7918        // inclusive, so cols 6-8 ("r b") are cut.
7919        run_keys(&mut e, "dge");
7920        assert_eq!(e.buffer().lines()[0], "foo baaz");
7921    }
7922
7923    #[test]
7924    fn ctrl_scroll_keys_do_not_panic() {
7925        // Viewport-less test: just exercise the code paths so a regression
7926        // in the scroll dispatch surfaces as a panic or assertion failure.
7927        let mut e = editor_with(
7928            (0..50)
7929                .map(|i| format!("line{i}"))
7930                .collect::<Vec<_>>()
7931                .join("\n")
7932                .as_str(),
7933        );
7934        run_keys(&mut e, "<C-f>");
7935        run_keys(&mut e, "<C-b>");
7936        // No explicit assert beyond "didn't panic".
7937        assert!(!e.buffer().lines().is_empty());
7938    }
7939
7940    /// Regression: arrow-navigation during a count-insert session must
7941    /// not pull unrelated rows into the "inserted" replay string.
7942    /// Before the fix, `before_lines` only snapshotted the entry row,
7943    /// so the diff at Esc spuriously saw the navigated-over row as
7944    /// part of the insert — count-replay then duplicated cross-row
7945    /// content across the buffer.
7946    #[test]
7947    fn count_insert_with_arrow_nav_does_not_leak_rows() {
7948        let mut e = Editor::new(
7949            hjkl_buffer::Buffer::new(),
7950            crate::types::DefaultHost::new(),
7951            crate::types::Options::default(),
7952        );
7953        e.set_content("row0\nrow1\nrow2");
7954        // `3i`, type X, arrow down, Esc.
7955        run_keys(&mut e, "3iX<Down><Esc>");
7956        // Row 0 keeps the originally-typed X.
7957        assert!(e.buffer().lines()[0].contains('X'));
7958        // Row 1 must not contain a fragment of row 0 ("row0") — that
7959        // was the buggy leak from the before-diff window.
7960        assert!(
7961            !e.buffer().lines()[1].contains("row0"),
7962            "row1 leaked row0 contents: {:?}",
7963            e.buffer().lines()[1]
7964        );
7965        // Buffer stays the same number of rows — no extra lines
7966        // injected by a multi-line "inserted" replay.
7967        assert_eq!(e.buffer().lines().len(), 3);
7968    }
7969
7970    // ─── Viewport scroll / jump tests ─────────────────────────────────
7971
7972    fn editor_with_rows(n: usize, viewport: u16) -> Editor {
7973        let mut e = Editor::new(
7974            hjkl_buffer::Buffer::new(),
7975            crate::types::DefaultHost::new(),
7976            crate::types::Options::default(),
7977        );
7978        let body = (0..n)
7979            .map(|i| format!("  line{}", i))
7980            .collect::<Vec<_>>()
7981            .join("\n");
7982        e.set_content(&body);
7983        e.set_viewport_height(viewport);
7984        e
7985    }
7986
7987    #[test]
7988    fn ctrl_d_moves_cursor_half_page_down() {
7989        let mut e = editor_with_rows(100, 20);
7990        run_keys(&mut e, "<C-d>");
7991        assert_eq!(e.cursor().0, 10);
7992    }
7993
7994    fn editor_with_wrap_lines(lines: &[&str], viewport: u16, text_width: u16) -> Editor {
7995        let mut e = Editor::new(
7996            hjkl_buffer::Buffer::new(),
7997            crate::types::DefaultHost::new(),
7998            crate::types::Options::default(),
7999        );
8000        e.set_content(&lines.join("\n"));
8001        e.set_viewport_height(viewport);
8002        let v = e.host_mut().viewport_mut();
8003        v.height = viewport;
8004        v.width = text_width;
8005        v.text_width = text_width;
8006        v.wrap = hjkl_buffer::Wrap::Char;
8007        e.settings_mut().wrap = hjkl_buffer::Wrap::Char;
8008        e
8009    }
8010
8011    #[test]
8012    fn scrolloff_wrap_keeps_cursor_off_bottom_edge() {
8013        // 10 doc rows, each wraps to 3 segments → 30 screen rows.
8014        // Viewport height 12, margin = SCROLLOFF.min(11/2) = 5,
8015        // max bottom = 11 - 5 = 6. Plenty of headroom past row 4.
8016        let lines = ["aaaabbbbcccc"; 10];
8017        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8018        e.jump_cursor(4, 0);
8019        e.ensure_cursor_in_scrolloff();
8020        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8021        assert!(csr <= 6, "csr={csr}");
8022    }
8023
8024    #[test]
8025    fn scrolloff_wrap_keeps_cursor_off_top_edge() {
8026        let lines = ["aaaabbbbcccc"; 10];
8027        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8028        // Force top down then bring cursor up so the top-edge margin
8029        // path runs.
8030        e.jump_cursor(7, 0);
8031        e.ensure_cursor_in_scrolloff();
8032        e.jump_cursor(2, 0);
8033        e.ensure_cursor_in_scrolloff();
8034        let csr = e.buffer().cursor_screen_row(e.host().viewport()).unwrap();
8035        // SCROLLOFF.min((height - 1) / 2) = 5.min(5) = 5.
8036        assert!(csr >= 5, "csr={csr}");
8037    }
8038
8039    #[test]
8040    fn scrolloff_wrap_clamps_top_at_buffer_end() {
8041        let lines = ["aaaabbbbcccc"; 5];
8042        let mut e = editor_with_wrap_lines(&lines, 12, 4);
8043        e.jump_cursor(4, 11);
8044        e.ensure_cursor_in_scrolloff();
8045        // max_top_for_height(12) on 15 screen rows: row 4 (3 segs) +
8046        // row 3 (3 segs) + row 2 (3 segs) + row 1 (3 segs) = 12 —
8047        // max_top = row 1. Margin can't be honoured at EOF (matches
8048        // vim's behaviour — scrolloff is a soft constraint).
8049        let top = e.host().viewport().top_row;
8050        assert_eq!(top, 1);
8051    }
8052
8053    #[test]
8054    fn ctrl_u_moves_cursor_half_page_up() {
8055        let mut e = editor_with_rows(100, 20);
8056        e.jump_cursor(50, 0);
8057        run_keys(&mut e, "<C-u>");
8058        assert_eq!(e.cursor().0, 40);
8059    }
8060
8061    #[test]
8062    fn ctrl_f_moves_cursor_full_page_down() {
8063        let mut e = editor_with_rows(100, 20);
8064        run_keys(&mut e, "<C-f>");
8065        // One full page ≈ h - 2 (overlap).
8066        assert_eq!(e.cursor().0, 18);
8067    }
8068
8069    #[test]
8070    fn ctrl_b_moves_cursor_full_page_up() {
8071        let mut e = editor_with_rows(100, 20);
8072        e.jump_cursor(50, 0);
8073        run_keys(&mut e, "<C-b>");
8074        assert_eq!(e.cursor().0, 32);
8075    }
8076
8077    #[test]
8078    fn ctrl_d_lands_on_first_non_blank() {
8079        let mut e = editor_with_rows(100, 20);
8080        run_keys(&mut e, "<C-d>");
8081        // "  line10" — first non-blank is col 2.
8082        assert_eq!(e.cursor().1, 2);
8083    }
8084
8085    #[test]
8086    fn ctrl_d_clamps_at_end_of_buffer() {
8087        let mut e = editor_with_rows(5, 20);
8088        run_keys(&mut e, "<C-d>");
8089        assert_eq!(e.cursor().0, 4);
8090    }
8091
8092    #[test]
8093    fn capital_h_jumps_to_viewport_top() {
8094        let mut e = editor_with_rows(100, 10);
8095        e.jump_cursor(50, 0);
8096        e.set_viewport_top(45);
8097        let top = e.host().viewport().top_row;
8098        run_keys(&mut e, "H");
8099        assert_eq!(e.cursor().0, top);
8100        assert_eq!(e.cursor().1, 2);
8101    }
8102
8103    #[test]
8104    fn capital_l_jumps_to_viewport_bottom() {
8105        let mut e = editor_with_rows(100, 10);
8106        e.jump_cursor(50, 0);
8107        e.set_viewport_top(45);
8108        let top = e.host().viewport().top_row;
8109        run_keys(&mut e, "L");
8110        assert_eq!(e.cursor().0, top + 9);
8111    }
8112
8113    #[test]
8114    fn capital_m_jumps_to_viewport_middle() {
8115        let mut e = editor_with_rows(100, 10);
8116        e.jump_cursor(50, 0);
8117        e.set_viewport_top(45);
8118        let top = e.host().viewport().top_row;
8119        run_keys(&mut e, "M");
8120        // 10-row viewport: middle is top + 4.
8121        assert_eq!(e.cursor().0, top + 4);
8122    }
8123
8124    #[test]
8125    fn g_capital_m_lands_at_line_midpoint() {
8126        let mut e = editor_with("hello world!"); // 12 chars
8127        run_keys(&mut e, "gM");
8128        // floor(12 / 2) = 6.
8129        assert_eq!(e.cursor(), (0, 6));
8130    }
8131
8132    #[test]
8133    fn g_capital_m_on_empty_line_stays_at_zero() {
8134        let mut e = editor_with("");
8135        run_keys(&mut e, "gM");
8136        assert_eq!(e.cursor(), (0, 0));
8137    }
8138
8139    #[test]
8140    fn g_capital_m_uses_current_line_only() {
8141        // Each line's midpoint is independent of others.
8142        let mut e = editor_with("a\nlonglongline"); // line 1: 12 chars
8143        e.jump_cursor(1, 0);
8144        run_keys(&mut e, "gM");
8145        assert_eq!(e.cursor(), (1, 6));
8146    }
8147
8148    #[test]
8149    fn capital_h_count_offsets_from_top() {
8150        let mut e = editor_with_rows(100, 10);
8151        e.jump_cursor(50, 0);
8152        e.set_viewport_top(45);
8153        let top = e.host().viewport().top_row;
8154        run_keys(&mut e, "3H");
8155        assert_eq!(e.cursor().0, top + 2);
8156    }
8157
8158    // ─── Jumplist tests ───────────────────────────────────────────────
8159
8160    #[test]
8161    fn ctrl_o_returns_to_pre_g_position() {
8162        let mut e = editor_with_rows(50, 20);
8163        e.jump_cursor(5, 2);
8164        run_keys(&mut e, "G");
8165        assert_eq!(e.cursor().0, 49);
8166        run_keys(&mut e, "<C-o>");
8167        assert_eq!(e.cursor(), (5, 2));
8168    }
8169
8170    #[test]
8171    fn ctrl_i_redoes_jump_after_ctrl_o() {
8172        let mut e = editor_with_rows(50, 20);
8173        e.jump_cursor(5, 2);
8174        run_keys(&mut e, "G");
8175        let post = e.cursor();
8176        run_keys(&mut e, "<C-o>");
8177        run_keys(&mut e, "<C-i>");
8178        assert_eq!(e.cursor(), post);
8179    }
8180
8181    #[test]
8182    fn new_jump_clears_forward_stack() {
8183        let mut e = editor_with_rows(50, 20);
8184        e.jump_cursor(5, 2);
8185        run_keys(&mut e, "G");
8186        run_keys(&mut e, "<C-o>");
8187        run_keys(&mut e, "gg");
8188        run_keys(&mut e, "<C-i>");
8189        assert_eq!(e.cursor().0, 0);
8190    }
8191
8192    #[test]
8193    fn ctrl_o_on_empty_stack_is_noop() {
8194        let mut e = editor_with_rows(10, 20);
8195        e.jump_cursor(3, 1);
8196        run_keys(&mut e, "<C-o>");
8197        assert_eq!(e.cursor(), (3, 1));
8198    }
8199
8200    #[test]
8201    fn asterisk_search_pushes_jump() {
8202        let mut e = editor_with("foo bar\nbaz foo end");
8203        e.jump_cursor(0, 0);
8204        run_keys(&mut e, "*");
8205        let after = e.cursor();
8206        assert_ne!(after, (0, 0));
8207        run_keys(&mut e, "<C-o>");
8208        assert_eq!(e.cursor(), (0, 0));
8209    }
8210
8211    #[test]
8212    fn h_viewport_jump_is_recorded() {
8213        let mut e = editor_with_rows(100, 10);
8214        e.jump_cursor(50, 0);
8215        e.set_viewport_top(45);
8216        let pre = e.cursor();
8217        run_keys(&mut e, "H");
8218        assert_ne!(e.cursor(), pre);
8219        run_keys(&mut e, "<C-o>");
8220        assert_eq!(e.cursor(), pre);
8221    }
8222
8223    #[test]
8224    fn j_k_motion_does_not_push_jump() {
8225        let mut e = editor_with_rows(50, 20);
8226        e.jump_cursor(5, 0);
8227        run_keys(&mut e, "jjj");
8228        run_keys(&mut e, "<C-o>");
8229        assert_eq!(e.cursor().0, 8);
8230    }
8231
8232    #[test]
8233    fn jumplist_caps_at_100() {
8234        let mut e = editor_with_rows(200, 20);
8235        for i in 0..101 {
8236            e.jump_cursor(i, 0);
8237            run_keys(&mut e, "G");
8238        }
8239        assert!(e.vim.jump_back.len() <= 100);
8240    }
8241
8242    #[test]
8243    fn tab_acts_as_ctrl_i() {
8244        let mut e = editor_with_rows(50, 20);
8245        e.jump_cursor(5, 2);
8246        run_keys(&mut e, "G");
8247        let post = e.cursor();
8248        run_keys(&mut e, "<C-o>");
8249        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8250        assert_eq!(e.cursor(), post);
8251    }
8252
8253    // ─── Mark tests ───────────────────────────────────────────────────
8254
8255    #[test]
8256    fn ma_then_backtick_a_jumps_exact() {
8257        let mut e = editor_with_rows(50, 20);
8258        e.jump_cursor(5, 3);
8259        run_keys(&mut e, "ma");
8260        e.jump_cursor(20, 0);
8261        run_keys(&mut e, "`a");
8262        assert_eq!(e.cursor(), (5, 3));
8263    }
8264
8265    #[test]
8266    fn ma_then_apostrophe_a_lands_on_first_non_blank() {
8267        let mut e = editor_with_rows(50, 20);
8268        // "  line5" — first non-blank is col 2.
8269        e.jump_cursor(5, 6);
8270        run_keys(&mut e, "ma");
8271        e.jump_cursor(30, 4);
8272        run_keys(&mut e, "'a");
8273        assert_eq!(e.cursor(), (5, 2));
8274    }
8275
8276    #[test]
8277    fn goto_mark_pushes_jumplist() {
8278        let mut e = editor_with_rows(50, 20);
8279        e.jump_cursor(10, 2);
8280        run_keys(&mut e, "mz");
8281        e.jump_cursor(3, 0);
8282        run_keys(&mut e, "`z");
8283        assert_eq!(e.cursor(), (10, 2));
8284        run_keys(&mut e, "<C-o>");
8285        assert_eq!(e.cursor(), (3, 0));
8286    }
8287
8288    #[test]
8289    fn goto_missing_mark_is_noop() {
8290        let mut e = editor_with_rows(50, 20);
8291        e.jump_cursor(3, 1);
8292        run_keys(&mut e, "`q");
8293        assert_eq!(e.cursor(), (3, 1));
8294    }
8295
8296    #[test]
8297    fn uppercase_mark_stored_under_uppercase_key() {
8298        let mut e = editor_with_rows(50, 20);
8299        e.jump_cursor(5, 3);
8300        run_keys(&mut e, "mA");
8301        // 0.0.36: uppercase marks land in the unified `Editor::marks`
8302        // map under the uppercase key — not under 'a'.
8303        assert_eq!(e.mark('A'), Some((5, 3)));
8304        assert!(e.mark('a').is_none());
8305    }
8306
8307    #[test]
8308    fn mark_survives_document_shrink_via_clamp() {
8309        let mut e = editor_with_rows(50, 20);
8310        e.jump_cursor(40, 4);
8311        run_keys(&mut e, "mx");
8312        // Shrink the buffer to 10 rows.
8313        e.set_content("a\nb\nc\nd\ne");
8314        run_keys(&mut e, "`x");
8315        // Mark clamped to last row, col 0 (short line).
8316        let (r, _) = e.cursor();
8317        assert!(r <= 4);
8318    }
8319
8320    #[test]
8321    fn g_semicolon_walks_back_through_edits() {
8322        let mut e = editor_with("alpha\nbeta\ngamma");
8323        // Two distinct edits — cells (0, 0) → InsertChar lands cursor
8324        // at (0, 1), (2, 0) → (2, 1).
8325        e.jump_cursor(0, 0);
8326        run_keys(&mut e, "iX<Esc>");
8327        e.jump_cursor(2, 0);
8328        run_keys(&mut e, "iY<Esc>");
8329        // First g; lands on the most recent entry's exact cell.
8330        run_keys(&mut e, "g;");
8331        assert_eq!(e.cursor(), (2, 1));
8332        // Second g; walks to the older entry.
8333        run_keys(&mut e, "g;");
8334        assert_eq!(e.cursor(), (0, 1));
8335        // Past the oldest — no-op.
8336        run_keys(&mut e, "g;");
8337        assert_eq!(e.cursor(), (0, 1));
8338    }
8339
8340    #[test]
8341    fn g_comma_walks_forward_after_g_semicolon() {
8342        let mut e = editor_with("a\nb\nc");
8343        e.jump_cursor(0, 0);
8344        run_keys(&mut e, "iX<Esc>");
8345        e.jump_cursor(2, 0);
8346        run_keys(&mut e, "iY<Esc>");
8347        run_keys(&mut e, "g;");
8348        run_keys(&mut e, "g;");
8349        assert_eq!(e.cursor(), (0, 1));
8350        run_keys(&mut e, "g,");
8351        assert_eq!(e.cursor(), (2, 1));
8352    }
8353
8354    #[test]
8355    fn new_edit_during_walk_trims_forward_entries() {
8356        let mut e = editor_with("a\nb\nc\nd");
8357        e.jump_cursor(0, 0);
8358        run_keys(&mut e, "iX<Esc>"); // entry 0 → (0, 1)
8359        e.jump_cursor(2, 0);
8360        run_keys(&mut e, "iY<Esc>"); // entry 1 → (2, 1)
8361        // Walk back twice to land on entry 0.
8362        run_keys(&mut e, "g;");
8363        run_keys(&mut e, "g;");
8364        assert_eq!(e.cursor(), (0, 1));
8365        // New edit while walking discards entries forward of the cursor.
8366        run_keys(&mut e, "iZ<Esc>");
8367        // No newer entry left to walk to.
8368        run_keys(&mut e, "g,");
8369        // Cursor stays where the latest edit landed it.
8370        assert_ne!(e.cursor(), (2, 1));
8371    }
8372
8373    // gq* tests moved to crates/hjkl-editor/tests/vim_ex_integration.rs
8374    // — they exercise the vim FSM through ex commands which now live in
8375    // a sibling crate. cargo dev-dep cycles produce duplicate type IDs
8376    // so the integration must run from the editor side.
8377
8378    #[test]
8379    fn capital_mark_set_and_jump() {
8380        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
8381        e.jump_cursor(2, 1);
8382        run_keys(&mut e, "mA");
8383        // Move away.
8384        e.jump_cursor(0, 0);
8385        // Jump back via `'A`.
8386        run_keys(&mut e, "'A");
8387        // Linewise jump → row preserved, col first non-blank (here 0).
8388        assert_eq!(e.cursor().0, 2);
8389    }
8390
8391    #[test]
8392    fn capital_mark_survives_set_content() {
8393        let mut e = editor_with("first buffer line\nsecond");
8394        e.jump_cursor(1, 3);
8395        run_keys(&mut e, "mA");
8396        // Swap buffer content (host loading a different tab).
8397        e.set_content("totally different content\non many\nrows of text");
8398        // `'A` should still jump to (1, 3) — it survived the swap.
8399        e.jump_cursor(0, 0);
8400        run_keys(&mut e, "'A");
8401        assert_eq!(e.cursor().0, 1);
8402    }
8403
8404    // capital_mark_shows_in_marks_listing moved to
8405    // crates/hjkl-editor/tests/vim_ex_integration.rs (depends on the
8406    // ex `marks` command).
8407
8408    #[test]
8409    fn capital_mark_shifts_with_edit() {
8410        let mut e = editor_with("a\nb\nc\nd");
8411        e.jump_cursor(3, 0);
8412        run_keys(&mut e, "mA");
8413        // Delete the first row — `A` should shift up to row 2.
8414        e.jump_cursor(0, 0);
8415        run_keys(&mut e, "dd");
8416        e.jump_cursor(0, 0);
8417        run_keys(&mut e, "'A");
8418        assert_eq!(e.cursor().0, 2);
8419    }
8420
8421    #[test]
8422    fn mark_below_delete_shifts_up() {
8423        let mut e = editor_with("a\nb\nc\nd\ne");
8424        // Set mark `a` on row 3 (the `d`).
8425        e.jump_cursor(3, 0);
8426        run_keys(&mut e, "ma");
8427        // Go back to row 0 and `dd`.
8428        e.jump_cursor(0, 0);
8429        run_keys(&mut e, "dd");
8430        // Mark `a` should now point at row 2 — its content stayed `d`.
8431        e.jump_cursor(0, 0);
8432        run_keys(&mut e, "'a");
8433        assert_eq!(e.cursor().0, 2);
8434        assert_eq!(e.buffer().line(2).unwrap(), "d");
8435    }
8436
8437    #[test]
8438    fn mark_on_deleted_row_is_dropped() {
8439        let mut e = editor_with("a\nb\nc\nd");
8440        // Mark `a` on row 1 (`b`).
8441        e.jump_cursor(1, 0);
8442        run_keys(&mut e, "ma");
8443        // Delete row 1.
8444        run_keys(&mut e, "dd");
8445        // The row that held `a` is gone; `'a` should be a no-op now.
8446        e.jump_cursor(2, 0);
8447        run_keys(&mut e, "'a");
8448        // Cursor stays on row 2 — `'a` no-ops on missing marks.
8449        assert_eq!(e.cursor().0, 2);
8450    }
8451
8452    #[test]
8453    fn mark_above_edit_unchanged() {
8454        let mut e = editor_with("a\nb\nc\nd\ne");
8455        // Mark `a` on row 0.
8456        e.jump_cursor(0, 0);
8457        run_keys(&mut e, "ma");
8458        // Delete row 3.
8459        e.jump_cursor(3, 0);
8460        run_keys(&mut e, "dd");
8461        // Mark `a` should still point at row 0.
8462        e.jump_cursor(2, 0);
8463        run_keys(&mut e, "'a");
8464        assert_eq!(e.cursor().0, 0);
8465    }
8466
8467    #[test]
8468    fn mark_shifts_down_after_insert() {
8469        let mut e = editor_with("a\nb\nc");
8470        // Mark `a` on row 2 (`c`).
8471        e.jump_cursor(2, 0);
8472        run_keys(&mut e, "ma");
8473        // Open a new line above row 0 with `O\nfoo<Esc>`.
8474        e.jump_cursor(0, 0);
8475        run_keys(&mut e, "Onew<Esc>");
8476        // Buffer is now ["new", "a", "b", "c"]; mark `a` should track
8477        // the original content row → 3.
8478        e.jump_cursor(0, 0);
8479        run_keys(&mut e, "'a");
8480        assert_eq!(e.cursor().0, 3);
8481        assert_eq!(e.buffer().line(3).unwrap(), "c");
8482    }
8483
8484    // ─── Search / jumplist interaction ───────────────────────────────
8485
8486    #[test]
8487    fn forward_search_commit_pushes_jump() {
8488        let mut e = editor_with("alpha beta\nfoo target end\nmore");
8489        e.jump_cursor(0, 0);
8490        run_keys(&mut e, "/target<CR>");
8491        // Cursor moved to the match.
8492        assert_ne!(e.cursor(), (0, 0));
8493        // Ctrl-o returns to the pre-search position.
8494        run_keys(&mut e, "<C-o>");
8495        assert_eq!(e.cursor(), (0, 0));
8496    }
8497
8498    #[test]
8499    fn search_commit_no_match_does_not_push_jump() {
8500        let mut e = editor_with("alpha beta\nfoo end");
8501        e.jump_cursor(0, 3);
8502        let pre_len = e.vim.jump_back.len();
8503        run_keys(&mut e, "/zzznotfound<CR>");
8504        // No match → cursor stays, jumplist shouldn't grow.
8505        assert_eq!(e.vim.jump_back.len(), pre_len);
8506    }
8507
8508    // ─── Phase 7b: migration buffer cursor sync ──────────────────────
8509
8510    #[test]
8511    fn buffer_cursor_mirrors_textarea_after_horizontal_motion() {
8512        let mut e = editor_with("hello world");
8513        run_keys(&mut e, "lll");
8514        let (row, col) = e.cursor();
8515        assert_eq!(e.buffer.cursor().row, row);
8516        assert_eq!(e.buffer.cursor().col, col);
8517    }
8518
8519    #[test]
8520    fn buffer_cursor_mirrors_textarea_after_vertical_motion() {
8521        let mut e = editor_with("aaaa\nbbbb\ncccc");
8522        run_keys(&mut e, "jj");
8523        let (row, col) = e.cursor();
8524        assert_eq!(e.buffer.cursor().row, row);
8525        assert_eq!(e.buffer.cursor().col, col);
8526    }
8527
8528    #[test]
8529    fn buffer_cursor_mirrors_textarea_after_word_motion() {
8530        let mut e = editor_with("foo bar baz");
8531        run_keys(&mut e, "ww");
8532        let (row, col) = e.cursor();
8533        assert_eq!(e.buffer.cursor().row, row);
8534        assert_eq!(e.buffer.cursor().col, col);
8535    }
8536
8537    #[test]
8538    fn buffer_cursor_mirrors_textarea_after_jump_motion() {
8539        let mut e = editor_with("a\nb\nc\nd\ne");
8540        run_keys(&mut e, "G");
8541        let (row, col) = e.cursor();
8542        assert_eq!(e.buffer.cursor().row, row);
8543        assert_eq!(e.buffer.cursor().col, col);
8544    }
8545
8546    #[test]
8547    fn editor_sticky_col_tracks_horizontal_motion() {
8548        let mut e = editor_with("longline\nhi\nlongline");
8549        // `fl` from col 0 lands on the next `l` past the cursor —
8550        // "longline" → second `l` is at col 4. Horizontal motion
8551        // should refresh sticky to that column so the next `j`
8552        // picks it up across the short row.
8553        run_keys(&mut e, "fl");
8554        let landed = e.cursor().1;
8555        assert!(landed > 0, "fl should have moved");
8556        run_keys(&mut e, "j");
8557        // Editor is the single owner of sticky_col (0.0.28). The
8558        // sticky value was set from the post-`fl` column.
8559        assert_eq!(e.sticky_col(), Some(landed));
8560    }
8561
8562    #[test]
8563    fn buffer_content_mirrors_textarea_after_insert() {
8564        let mut e = editor_with("hello");
8565        run_keys(&mut e, "iXYZ<Esc>");
8566        let text = e.buffer().lines().join("\n");
8567        assert_eq!(e.buffer.as_string(), text);
8568    }
8569
8570    #[test]
8571    fn buffer_content_mirrors_textarea_after_delete() {
8572        let mut e = editor_with("alpha bravo charlie");
8573        run_keys(&mut e, "dw");
8574        let text = e.buffer().lines().join("\n");
8575        assert_eq!(e.buffer.as_string(), text);
8576    }
8577
8578    #[test]
8579    fn buffer_content_mirrors_textarea_after_dd() {
8580        let mut e = editor_with("a\nb\nc\nd");
8581        run_keys(&mut e, "jdd");
8582        let text = e.buffer().lines().join("\n");
8583        assert_eq!(e.buffer.as_string(), text);
8584    }
8585
8586    #[test]
8587    fn buffer_content_mirrors_textarea_after_open_line() {
8588        let mut e = editor_with("foo\nbar");
8589        run_keys(&mut e, "oNEW<Esc>");
8590        let text = e.buffer().lines().join("\n");
8591        assert_eq!(e.buffer.as_string(), text);
8592    }
8593
8594    #[test]
8595    fn buffer_content_mirrors_textarea_after_paste() {
8596        let mut e = editor_with("hello");
8597        run_keys(&mut e, "yy");
8598        run_keys(&mut e, "p");
8599        let text = e.buffer().lines().join("\n");
8600        assert_eq!(e.buffer.as_string(), text);
8601    }
8602
8603    #[test]
8604    fn buffer_selection_none_in_normal_mode() {
8605        let e = editor_with("foo bar");
8606        assert!(e.buffer_selection().is_none());
8607    }
8608
8609    #[test]
8610    fn buffer_selection_char_in_visual_mode() {
8611        use hjkl_buffer::{Position, Selection};
8612        let mut e = editor_with("hello world");
8613        run_keys(&mut e, "vlll");
8614        assert_eq!(
8615            e.buffer_selection(),
8616            Some(Selection::Char {
8617                anchor: Position::new(0, 0),
8618                head: Position::new(0, 3),
8619            })
8620        );
8621    }
8622
8623    #[test]
8624    fn buffer_selection_line_in_visual_line_mode() {
8625        use hjkl_buffer::Selection;
8626        let mut e = editor_with("a\nb\nc\nd");
8627        run_keys(&mut e, "Vj");
8628        assert_eq!(
8629            e.buffer_selection(),
8630            Some(Selection::Line {
8631                anchor_row: 0,
8632                head_row: 1,
8633            })
8634        );
8635    }
8636
8637    #[test]
8638    fn wrapscan_off_blocks_wrap_around() {
8639        let mut e = editor_with("first\nsecond\nthird\n");
8640        e.settings_mut().wrapscan = false;
8641        // Place cursor on row 2 ("third") and search for "first".
8642        e.jump_cursor(2, 0);
8643        run_keys(&mut e, "/first<CR>");
8644        // No wrap → cursor stays on row 2.
8645        assert_eq!(e.cursor().0, 2, "wrapscan off should block wrap");
8646        // Re-enable wrapscan and try again.
8647        e.settings_mut().wrapscan = true;
8648        run_keys(&mut e, "/first<CR>");
8649        assert_eq!(e.cursor().0, 0, "wrapscan on should wrap to row 0");
8650    }
8651
8652    #[test]
8653    fn smartcase_uppercase_pattern_stays_sensitive() {
8654        let mut e = editor_with("foo\nFoo\nBAR\n");
8655        e.settings_mut().ignore_case = true;
8656        e.settings_mut().smartcase = true;
8657        // All-lowercase pattern → ignorecase wins → compiled regex
8658        // is case-insensitive.
8659        run_keys(&mut e, "/foo<CR>");
8660        let r1 = e
8661            .search_state()
8662            .pattern
8663            .as_ref()
8664            .unwrap()
8665            .as_str()
8666            .to_string();
8667        assert!(r1.starts_with("(?i)"), "lowercase under smartcase: {r1}");
8668        // Uppercase letter → smartcase flips back to case-sensitive.
8669        run_keys(&mut e, "/Foo<CR>");
8670        let r2 = e
8671            .search_state()
8672            .pattern
8673            .as_ref()
8674            .unwrap()
8675            .as_str()
8676            .to_string();
8677        assert!(!r2.starts_with("(?i)"), "mixed-case under smartcase: {r2}");
8678    }
8679
8680    #[test]
8681    fn enter_with_autoindent_copies_leading_whitespace() {
8682        let mut e = editor_with("    foo");
8683        e.jump_cursor(0, 7);
8684        run_keys(&mut e, "i<CR>");
8685        assert_eq!(e.buffer.line(1).unwrap(), "    ");
8686    }
8687
8688    #[test]
8689    fn enter_without_autoindent_inserts_bare_newline() {
8690        let mut e = editor_with("    foo");
8691        e.settings_mut().autoindent = false;
8692        e.jump_cursor(0, 7);
8693        run_keys(&mut e, "i<CR>");
8694        assert_eq!(e.buffer.line(1).unwrap(), "");
8695    }
8696
8697    #[test]
8698    fn iskeyword_default_treats_alnum_underscore_as_word() {
8699        let mut e = editor_with("foo_bar baz");
8700        // `*` searches for the word at the cursor — picks up everything
8701        // matching iskeyword. With default spec, `foo_bar` is one word,
8702        // so the search pattern should bound that whole token.
8703        e.jump_cursor(0, 0);
8704        run_keys(&mut e, "*");
8705        let p = e
8706            .search_state()
8707            .pattern
8708            .as_ref()
8709            .unwrap()
8710            .as_str()
8711            .to_string();
8712        assert!(p.contains("foo_bar"), "default iskeyword: {p}");
8713    }
8714
8715    #[test]
8716    fn w_motion_respects_custom_iskeyword() {
8717        // `foo-bar baz`. With the default spec, `-` is NOT a word char,
8718        // so `foo` / `-` / `bar` / ` ` / `baz` are 5 transitions and a
8719        // single `w` from col 0 lands on `-` (col 3).
8720        let mut e = editor_with("foo-bar baz");
8721        run_keys(&mut e, "w");
8722        assert_eq!(e.cursor().1, 3, "default iskeyword: {:?}", e.cursor());
8723        // Re-set with `-` (45) treated as a word char. Now `foo-bar` is
8724        // one token; `w` from col 0 should jump to `baz` (col 8).
8725        let mut e2 = editor_with("foo-bar baz");
8726        e2.set_iskeyword("@,_,45");
8727        run_keys(&mut e2, "w");
8728        assert_eq!(e2.cursor().1, 8, "dash-as-word: {:?}", e2.cursor());
8729    }
8730
8731    #[test]
8732    fn iskeyword_with_dash_treats_dash_as_word_char() {
8733        let mut e = editor_with("foo-bar baz");
8734        e.settings_mut().iskeyword = "@,_,45".to_string();
8735        e.jump_cursor(0, 0);
8736        run_keys(&mut e, "*");
8737        let p = e
8738            .search_state()
8739            .pattern
8740            .as_ref()
8741            .unwrap()
8742            .as_str()
8743            .to_string();
8744        assert!(p.contains("foo-bar"), "dash-as-word: {p}");
8745    }
8746
8747    #[test]
8748    fn timeoutlen_drops_pending_g_prefix() {
8749        use std::time::{Duration, Instant};
8750        let mut e = editor_with("a\nb\nc");
8751        e.jump_cursor(2, 0);
8752        // First `g` lands us in g-pending state.
8753        run_keys(&mut e, "g");
8754        assert!(matches!(e.vim.pending, super::Pending::G));
8755        // Push last_input timestamps into the past beyond the default
8756        // timeout. 0.0.29 (Patch B) drives `:set timeoutlen` off
8757        // `Host::now()` (monotonic Duration), so shrink the timeout
8758        // window to a nanosecond and zero out the host slot — any
8759        // wall-clock progress between this line and the next step
8760        // exceeds it. The Instant-flavoured field is rewound for
8761        // snapshot tests that still observe it directly.
8762        e.settings.timeout_len = Duration::from_nanos(0);
8763        e.vim.last_input_at = Some(Instant::now() - Duration::from_secs(60));
8764        e.vim.last_input_host_at = Some(Duration::ZERO);
8765        // Second `g` arrives "late" — timeout fires, prefix is cleared,
8766        // and the bare `g` is re-dispatched: nothing happens at the
8767        // engine level because `g` alone isn't a complete command.
8768        run_keys(&mut e, "g");
8769        // Cursor must still be at row 2 — `gg` was NOT completed.
8770        assert_eq!(e.cursor().0, 2, "timeout must abandon g-prefix");
8771    }
8772
8773    #[test]
8774    fn undobreak_on_breaks_group_at_arrow_motion() {
8775        let mut e = editor_with("");
8776        // i a a a <Left> b b b <Esc> u
8777        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
8778        // Default settings.undo_break_on_motion = true, so `u` only
8779        // reverses the `bbb` run; `aaa` remains.
8780        let line = e.buffer.line(0).unwrap_or("").to_string();
8781        assert!(line.contains("aaa"), "after undobreak: {line:?}");
8782        assert!(!line.contains("bbb"), "bbb should be undone: {line:?}");
8783    }
8784
8785    #[test]
8786    fn undobreak_off_keeps_full_run_in_one_group() {
8787        let mut e = editor_with("");
8788        e.settings_mut().undo_break_on_motion = false;
8789        run_keys(&mut e, "iaaa<Left>bbb<Esc>u");
8790        // With undobreak off, the whole insert (aaa<Left>bbb) is one
8791        // group — `u` reverts back to empty.
8792        assert_eq!(e.buffer.line(0).unwrap_or(""), "");
8793    }
8794
8795    #[test]
8796    fn undobreak_round_trips_through_options() {
8797        let e = editor_with("");
8798        let opts = e.current_options();
8799        assert!(opts.undo_break_on_motion);
8800        let mut e2 = editor_with("");
8801        let mut new_opts = opts.clone();
8802        new_opts.undo_break_on_motion = false;
8803        e2.apply_options(&new_opts);
8804        assert!(!e2.current_options().undo_break_on_motion);
8805    }
8806
8807    #[test]
8808    fn undo_levels_cap_drops_oldest() {
8809        let mut e = editor_with("abcde");
8810        e.settings_mut().undo_levels = 3;
8811        run_keys(&mut e, "ra");
8812        run_keys(&mut e, "lrb");
8813        run_keys(&mut e, "lrc");
8814        run_keys(&mut e, "lrd");
8815        run_keys(&mut e, "lre");
8816        assert_eq!(e.undo_stack_len(), 3);
8817    }
8818
8819    #[test]
8820    fn tab_inserts_literal_tab_when_noexpandtab() {
8821        let mut e = editor_with("");
8822        // 0.2.0: expandtab now defaults on (modern). Opt out for the
8823        // literal-tab test.
8824        e.settings_mut().expandtab = false;
8825        e.settings_mut().softtabstop = 0;
8826        run_keys(&mut e, "i");
8827        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8828        assert_eq!(e.buffer.line(0).unwrap(), "\t");
8829    }
8830
8831    #[test]
8832    fn tab_inserts_spaces_when_expandtab() {
8833        let mut e = editor_with("");
8834        e.settings_mut().expandtab = true;
8835        e.settings_mut().tabstop = 4;
8836        run_keys(&mut e, "i");
8837        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8838        assert_eq!(e.buffer.line(0).unwrap(), "    ");
8839    }
8840
8841    #[test]
8842    fn tab_with_softtabstop_fills_to_next_boundary() {
8843        // sts=4, cursor at col 2 → Tab inserts 2 spaces (to col 4).
8844        let mut e = editor_with("ab");
8845        e.settings_mut().expandtab = true;
8846        e.settings_mut().tabstop = 8;
8847        e.settings_mut().softtabstop = 4;
8848        run_keys(&mut e, "A"); // append at end (col 2)
8849        e.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
8850        assert_eq!(e.buffer.line(0).unwrap(), "ab  ");
8851    }
8852
8853    #[test]
8854    fn backspace_deletes_softtab_run() {
8855        // sts=4, line "    x" with cursor at col 4 → Backspace deletes
8856        // the whole 4-space run instead of one char.
8857        let mut e = editor_with("    x");
8858        e.settings_mut().softtabstop = 4;
8859        // Move to col 4 (start of 'x'), then enter insert.
8860        run_keys(&mut e, "fxi");
8861        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
8862        assert_eq!(e.buffer.line(0).unwrap(), "x");
8863    }
8864
8865    #[test]
8866    fn backspace_falls_back_to_single_char_when_run_not_aligned() {
8867        // sts=4, but cursor at col 5 (one space past the boundary) →
8868        // Backspace deletes only the one trailing space.
8869        let mut e = editor_with("     x");
8870        e.settings_mut().softtabstop = 4;
8871        run_keys(&mut e, "fxi");
8872        e.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
8873        assert_eq!(e.buffer.line(0).unwrap(), "    x");
8874    }
8875
8876    #[test]
8877    fn readonly_blocks_insert_mutation() {
8878        let mut e = editor_with("hello");
8879        e.settings_mut().readonly = true;
8880        run_keys(&mut e, "iX<Esc>");
8881        assert_eq!(e.buffer.line(0).unwrap(), "hello");
8882    }
8883
8884    #[cfg(feature = "ratatui")]
8885    #[test]
8886    fn intern_ratatui_style_dedups_repeated_styles() {
8887        use ratatui::style::{Color, Style};
8888        let mut e = editor_with("");
8889        let red = Style::default().fg(Color::Red);
8890        let blue = Style::default().fg(Color::Blue);
8891        let id_r1 = e.intern_ratatui_style(red);
8892        let id_r2 = e.intern_ratatui_style(red);
8893        let id_b = e.intern_ratatui_style(blue);
8894        assert_eq!(id_r1, id_r2);
8895        assert_ne!(id_r1, id_b);
8896        assert_eq!(e.style_table().len(), 2);
8897    }
8898
8899    #[cfg(feature = "ratatui")]
8900    #[test]
8901    fn install_ratatui_syntax_spans_translates_styled_spans() {
8902        use ratatui::style::{Color, Style};
8903        let mut e = editor_with("SELECT foo");
8904        e.install_ratatui_syntax_spans(vec![vec![(0, 6, Style::default().fg(Color::Red))]]);
8905        let by_row = e.buffer_spans();
8906        assert_eq!(by_row.len(), 1);
8907        assert_eq!(by_row[0].len(), 1);
8908        assert_eq!(by_row[0][0].start_byte, 0);
8909        assert_eq!(by_row[0][0].end_byte, 6);
8910        let id = by_row[0][0].style;
8911        assert_eq!(e.style_table()[id as usize].fg, Some(Color::Red));
8912    }
8913
8914    #[cfg(feature = "ratatui")]
8915    #[test]
8916    fn install_ratatui_syntax_spans_clamps_sentinel_end() {
8917        use ratatui::style::{Color, Style};
8918        let mut e = editor_with("hello");
8919        e.install_ratatui_syntax_spans(vec![vec![(
8920            0,
8921            usize::MAX,
8922            Style::default().fg(Color::Blue),
8923        )]]);
8924        let by_row = e.buffer_spans();
8925        assert_eq!(by_row[0][0].end_byte, 5);
8926    }
8927
8928    #[cfg(feature = "ratatui")]
8929    #[test]
8930    fn install_ratatui_syntax_spans_drops_zero_width() {
8931        use ratatui::style::{Color, Style};
8932        let mut e = editor_with("abc");
8933        e.install_ratatui_syntax_spans(vec![vec![(2, 2, Style::default().fg(Color::Red))]]);
8934        assert!(e.buffer_spans()[0].is_empty());
8935    }
8936
8937    #[test]
8938    fn named_register_yank_into_a_then_paste_from_a() {
8939        let mut e = editor_with("hello world\nsecond");
8940        run_keys(&mut e, "\"ayw");
8941        // `yw` over "hello world" yanks "hello " (word + trailing space).
8942        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
8943        // Move to second line then paste from "a.
8944        run_keys(&mut e, "j0\"aP");
8945        assert_eq!(e.buffer().lines()[1], "hello second");
8946    }
8947
8948    #[test]
8949    fn capital_r_overstrikes_chars() {
8950        let mut e = editor_with("hello");
8951        e.jump_cursor(0, 0);
8952        run_keys(&mut e, "RXY<Esc>");
8953        // 'h' and 'e' replaced; 'llo' kept.
8954        assert_eq!(e.buffer().lines()[0], "XYllo");
8955    }
8956
8957    #[test]
8958    fn capital_r_at_eol_appends() {
8959        let mut e = editor_with("hi");
8960        e.jump_cursor(0, 1);
8961        // Cursor on the final 'i'; replace it then keep typing past EOL.
8962        run_keys(&mut e, "RXYZ<Esc>");
8963        assert_eq!(e.buffer().lines()[0], "hXYZ");
8964    }
8965
8966    #[test]
8967    fn capital_r_count_does_not_repeat_overstrike_char_by_char() {
8968        // Vim's `2R` replays the *whole session* on Esc, not each char.
8969        // We don't model that fully, but the basic R should at least
8970        // not crash on empty session count handling.
8971        let mut e = editor_with("abc");
8972        e.jump_cursor(0, 0);
8973        run_keys(&mut e, "RX<Esc>");
8974        assert_eq!(e.buffer().lines()[0], "Xbc");
8975    }
8976
8977    #[test]
8978    fn ctrl_r_in_insert_pastes_named_register() {
8979        let mut e = editor_with("hello world");
8980        // Yank "hello " into "a".
8981        run_keys(&mut e, "\"ayw");
8982        assert_eq!(e.registers().read('a').unwrap().text, "hello ");
8983        // Open a fresh line, enter insert, Ctrl-R a.
8984        run_keys(&mut e, "o");
8985        assert_eq!(e.vim_mode(), VimMode::Insert);
8986        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
8987        e.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
8988        assert_eq!(e.buffer().lines()[1], "hello ");
8989        // Cursor sits at end of inserted payload (col 6).
8990        assert_eq!(e.cursor(), (1, 6));
8991        // Stayed in insert mode; next char appends.
8992        assert_eq!(e.vim_mode(), VimMode::Insert);
8993        e.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
8994        assert_eq!(e.buffer().lines()[1], "hello X");
8995    }
8996
8997    #[test]
8998    fn ctrl_r_with_unnamed_register() {
8999        let mut e = editor_with("foo");
9000        run_keys(&mut e, "yiw");
9001        run_keys(&mut e, "A ");
9002        // Unnamed register paste via `"`.
9003        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9004        e.handle_key(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
9005        assert_eq!(e.buffer().lines()[0], "foo foo");
9006    }
9007
9008    #[test]
9009    fn ctrl_r_unknown_selector_is_no_op() {
9010        let mut e = editor_with("abc");
9011        run_keys(&mut e, "A");
9012        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9013        // `?` isn't a valid register selector — paste skipped, the
9014        // armed flag still clears so the next key types normally.
9015        e.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
9016        e.handle_key(KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::NONE));
9017        assert_eq!(e.buffer().lines()[0], "abcZ");
9018    }
9019
9020    #[test]
9021    fn ctrl_r_multiline_register_pastes_with_newlines() {
9022        let mut e = editor_with("alpha\nbeta\ngamma");
9023        // Yank two whole lines into "b".
9024        run_keys(&mut e, "\"byy");
9025        run_keys(&mut e, "j\"byy");
9026        // Linewise yanks include trailing \n; second yank into uppercase
9027        // would append, but lowercase "b" overwrote — ensure we have a
9028        // multi-line payload by yanking 2 lines linewise via V.
9029        run_keys(&mut e, "ggVj\"by");
9030        let payload = e.registers().read('b').unwrap().text.clone();
9031        assert!(payload.contains('\n'));
9032        run_keys(&mut e, "Go");
9033        e.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9034        e.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
9035        // The buffer should now contain the original 3 lines plus the
9036        // pasted 2-line payload (with its own newline) on its own line.
9037        let total_lines = e.buffer().lines().len();
9038        assert!(total_lines >= 5);
9039    }
9040
9041    #[test]
9042    fn yank_zero_holds_last_yank_after_delete() {
9043        let mut e = editor_with("hello world");
9044        run_keys(&mut e, "yw");
9045        let yanked = e.registers().read('0').unwrap().text.clone();
9046        assert!(!yanked.is_empty());
9047        // Delete a word; "0 should still hold the original yank.
9048        run_keys(&mut e, "dw");
9049        assert_eq!(e.registers().read('0').unwrap().text, yanked);
9050        // "1 holds the just-deleted text (non-empty, regardless of exact contents).
9051        assert!(!e.registers().read('1').unwrap().text.is_empty());
9052    }
9053
9054    #[test]
9055    fn delete_ring_rotates_through_one_through_nine() {
9056        let mut e = editor_with("a b c d e f g h i j");
9057        // Delete each word — each delete pushes onto "1, shifting older.
9058        for _ in 0..3 {
9059            run_keys(&mut e, "dw");
9060        }
9061        // Most recent delete is in "1.
9062        let r1 = e.registers().read('1').unwrap().text.clone();
9063        let r2 = e.registers().read('2').unwrap().text.clone();
9064        let r3 = e.registers().read('3').unwrap().text.clone();
9065        assert!(!r1.is_empty() && !r2.is_empty() && !r3.is_empty());
9066        assert_ne!(r1, r2);
9067        assert_ne!(r2, r3);
9068    }
9069
9070    #[test]
9071    fn capital_register_appends_to_lowercase() {
9072        let mut e = editor_with("foo bar");
9073        run_keys(&mut e, "\"ayw");
9074        let first = e.registers().read('a').unwrap().text.clone();
9075        assert!(first.contains("foo"));
9076        // Yank again into "A — appends to "a.
9077        run_keys(&mut e, "w\"Ayw");
9078        let combined = e.registers().read('a').unwrap().text.clone();
9079        assert!(combined.starts_with(&first));
9080        assert!(combined.contains("bar"));
9081    }
9082
9083    #[test]
9084    fn zf_in_visual_line_creates_closed_fold() {
9085        let mut e = editor_with("a\nb\nc\nd\ne");
9086        // VisualLine over rows 1..=3 then zf.
9087        e.jump_cursor(1, 0);
9088        run_keys(&mut e, "Vjjzf");
9089        assert_eq!(e.buffer().folds().len(), 1);
9090        let f = e.buffer().folds()[0];
9091        assert_eq!(f.start_row, 1);
9092        assert_eq!(f.end_row, 3);
9093        assert!(f.closed);
9094    }
9095
9096    #[test]
9097    fn zfj_in_normal_creates_two_row_fold() {
9098        let mut e = editor_with("a\nb\nc\nd\ne");
9099        e.jump_cursor(1, 0);
9100        run_keys(&mut e, "zfj");
9101        assert_eq!(e.buffer().folds().len(), 1);
9102        let f = e.buffer().folds()[0];
9103        assert_eq!(f.start_row, 1);
9104        assert_eq!(f.end_row, 2);
9105        assert!(f.closed);
9106        // Cursor stays where it started.
9107        assert_eq!(e.cursor().0, 1);
9108    }
9109
9110    #[test]
9111    fn zf_with_count_folds_count_rows() {
9112        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9113        e.jump_cursor(0, 0);
9114        // `zf3j` — fold rows 0..=3.
9115        run_keys(&mut e, "zf3j");
9116        assert_eq!(e.buffer().folds().len(), 1);
9117        let f = e.buffer().folds()[0];
9118        assert_eq!(f.start_row, 0);
9119        assert_eq!(f.end_row, 3);
9120    }
9121
9122    #[test]
9123    fn zfk_folds_upward_range() {
9124        let mut e = editor_with("a\nb\nc\nd\ne");
9125        e.jump_cursor(3, 0);
9126        run_keys(&mut e, "zfk");
9127        let f = e.buffer().folds()[0];
9128        // start_row = min(3, 2) = 2, end_row = max(3, 2) = 3.
9129        assert_eq!(f.start_row, 2);
9130        assert_eq!(f.end_row, 3);
9131    }
9132
9133    #[test]
9134    fn zf_capital_g_folds_to_bottom() {
9135        let mut e = editor_with("a\nb\nc\nd\ne");
9136        e.jump_cursor(1, 0);
9137        // `G` is a single-char motion; folds rows 1..=4.
9138        run_keys(&mut e, "zfG");
9139        let f = e.buffer().folds()[0];
9140        assert_eq!(f.start_row, 1);
9141        assert_eq!(f.end_row, 4);
9142    }
9143
9144    #[test]
9145    fn zfgg_folds_to_top_via_operator_pipeline() {
9146        let mut e = editor_with("a\nb\nc\nd\ne");
9147        e.jump_cursor(3, 0);
9148        // `gg` is a 2-key chord (Pending::OpG path) — `zfgg` works
9149        // because `zf` arms `Pending::Op { Fold }` which already knows
9150        // how to wait for `g` then `g`.
9151        run_keys(&mut e, "zfgg");
9152        let f = e.buffer().folds()[0];
9153        assert_eq!(f.start_row, 0);
9154        assert_eq!(f.end_row, 3);
9155    }
9156
9157    #[test]
9158    fn zfip_folds_paragraph_via_text_object() {
9159        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta\nepsilon");
9160        e.jump_cursor(1, 0);
9161        // `ip` is a text object — same operator pipeline routes it.
9162        run_keys(&mut e, "zfip");
9163        assert_eq!(e.buffer().folds().len(), 1);
9164        let f = e.buffer().folds()[0];
9165        assert_eq!(f.start_row, 0);
9166        assert_eq!(f.end_row, 2);
9167    }
9168
9169    #[test]
9170    fn zfap_folds_paragraph_with_trailing_blank() {
9171        let mut e = editor_with("alpha\nbeta\ngamma\n\ndelta");
9172        e.jump_cursor(0, 0);
9173        // `ap` includes the trailing blank line.
9174        run_keys(&mut e, "zfap");
9175        let f = e.buffer().folds()[0];
9176        assert_eq!(f.start_row, 0);
9177        assert_eq!(f.end_row, 3);
9178    }
9179
9180    #[test]
9181    fn zf_paragraph_motion_folds_to_blank() {
9182        let mut e = editor_with("alpha\nbeta\n\ngamma");
9183        e.jump_cursor(0, 0);
9184        // `}` jumps to the blank-line boundary; fold spans rows 0..=2.
9185        run_keys(&mut e, "zf}");
9186        let f = e.buffer().folds()[0];
9187        assert_eq!(f.start_row, 0);
9188        assert_eq!(f.end_row, 2);
9189    }
9190
9191    #[test]
9192    fn za_toggles_fold_under_cursor() {
9193        let mut e = editor_with("a\nb\nc\nd");
9194        e.buffer_mut().add_fold(1, 2, true);
9195        e.jump_cursor(1, 0);
9196        run_keys(&mut e, "za");
9197        assert!(!e.buffer().folds()[0].closed);
9198        run_keys(&mut e, "za");
9199        assert!(e.buffer().folds()[0].closed);
9200    }
9201
9202    #[test]
9203    fn zr_opens_all_folds_zm_closes_all() {
9204        let mut e = editor_with("a\nb\nc\nd\ne\nf");
9205        e.buffer_mut().add_fold(0, 1, true);
9206        e.buffer_mut().add_fold(2, 3, true);
9207        e.buffer_mut().add_fold(4, 5, true);
9208        run_keys(&mut e, "zR");
9209        assert!(e.buffer().folds().iter().all(|f| !f.closed));
9210        run_keys(&mut e, "zM");
9211        assert!(e.buffer().folds().iter().all(|f| f.closed));
9212    }
9213
9214    #[test]
9215    fn ze_clears_all_folds() {
9216        let mut e = editor_with("a\nb\nc\nd");
9217        e.buffer_mut().add_fold(0, 1, true);
9218        e.buffer_mut().add_fold(2, 3, false);
9219        run_keys(&mut e, "zE");
9220        assert!(e.buffer().folds().is_empty());
9221    }
9222
9223    #[test]
9224    fn g_underscore_jumps_to_last_non_blank() {
9225        let mut e = editor_with("hello world   ");
9226        run_keys(&mut e, "g_");
9227        // Last non-blank is 'd' at col 10.
9228        assert_eq!(e.cursor().1, 10);
9229    }
9230
9231    #[test]
9232    fn gj_and_gk_alias_j_and_k() {
9233        let mut e = editor_with("a\nb\nc");
9234        run_keys(&mut e, "gj");
9235        assert_eq!(e.cursor().0, 1);
9236        run_keys(&mut e, "gk");
9237        assert_eq!(e.cursor().0, 0);
9238    }
9239
9240    #[test]
9241    fn paragraph_motions_walk_blank_lines() {
9242        let mut e = editor_with("first\nblock\n\nsecond\nblock\n\nthird");
9243        run_keys(&mut e, "}");
9244        assert_eq!(e.cursor().0, 2);
9245        run_keys(&mut e, "}");
9246        assert_eq!(e.cursor().0, 5);
9247        run_keys(&mut e, "{");
9248        assert_eq!(e.cursor().0, 2);
9249    }
9250
9251    #[test]
9252    fn gv_reenters_last_visual_selection() {
9253        let mut e = editor_with("alpha\nbeta\ngamma");
9254        run_keys(&mut e, "Vj");
9255        // Exit visual.
9256        run_keys(&mut e, "<Esc>");
9257        assert_eq!(e.vim_mode(), VimMode::Normal);
9258        // gv re-enters VisualLine.
9259        run_keys(&mut e, "gv");
9260        assert_eq!(e.vim_mode(), VimMode::VisualLine);
9261    }
9262
9263    #[test]
9264    fn o_in_visual_swaps_anchor_and_cursor() {
9265        let mut e = editor_with("hello world");
9266        // v then move right 4 — anchor at col 0, cursor at col 4.
9267        run_keys(&mut e, "vllll");
9268        assert_eq!(e.cursor().1, 4);
9269        // o swaps; cursor jumps to anchor (col 0).
9270        run_keys(&mut e, "o");
9271        assert_eq!(e.cursor().1, 0);
9272        // Anchor now at original cursor (col 4).
9273        assert_eq!(e.vim.visual_anchor, (0, 4));
9274    }
9275
9276    #[test]
9277    fn editing_inside_fold_invalidates_it() {
9278        let mut e = editor_with("a\nb\nc\nd");
9279        e.buffer_mut().add_fold(1, 2, true);
9280        e.jump_cursor(1, 0);
9281        // Insert a char on a row covered by the fold.
9282        run_keys(&mut e, "iX<Esc>");
9283        // Fold should be gone — vim opens (drops) folds on edit.
9284        assert!(e.buffer().folds().is_empty());
9285    }
9286
9287    #[test]
9288    fn zd_removes_fold_under_cursor() {
9289        let mut e = editor_with("a\nb\nc\nd");
9290        e.buffer_mut().add_fold(1, 2, true);
9291        e.jump_cursor(2, 0);
9292        run_keys(&mut e, "zd");
9293        assert!(e.buffer().folds().is_empty());
9294    }
9295
9296    #[test]
9297    fn take_fold_ops_observes_z_keystroke_dispatch() {
9298        // 0.0.38 (Patch C-δ.4): every `z…` keystroke routes through
9299        // `Editor::apply_fold_op`, which queues a `FoldOp` for hosts to
9300        // observe via `take_fold_ops` AND applies the op locally so
9301        // buffer fold storage stays in sync.
9302        use crate::types::FoldOp;
9303        let mut e = editor_with("a\nb\nc\nd");
9304        e.buffer_mut().add_fold(1, 2, true);
9305        e.jump_cursor(1, 0);
9306        // Drain any queue from the buffer setup above (none expected,
9307        // but be defensive).
9308        let _ = e.take_fold_ops();
9309        run_keys(&mut e, "zo");
9310        run_keys(&mut e, "zM");
9311        let ops = e.take_fold_ops();
9312        assert_eq!(ops.len(), 2);
9313        assert!(matches!(ops[0], FoldOp::OpenAt(1)));
9314        assert!(matches!(ops[1], FoldOp::CloseAll));
9315        // Second drain returns empty.
9316        assert!(e.take_fold_ops().is_empty());
9317    }
9318
9319    #[test]
9320    fn edit_pipeline_emits_invalidate_fold_op() {
9321        // The edit pipeline routes its fold invalidation through
9322        // `apply_fold_op` so hosts can observe + dedupe.
9323        use crate::types::FoldOp;
9324        let mut e = editor_with("a\nb\nc\nd");
9325        e.buffer_mut().add_fold(1, 2, true);
9326        e.jump_cursor(1, 0);
9327        let _ = e.take_fold_ops();
9328        run_keys(&mut e, "iX<Esc>");
9329        let ops = e.take_fold_ops();
9330        assert!(
9331            ops.iter().any(|op| matches!(op, FoldOp::Invalidate { .. })),
9332            "expected at least one Invalidate op, got {ops:?}"
9333        );
9334    }
9335
9336    #[test]
9337    fn dot_mark_jumps_to_last_edit_position() {
9338        let mut e = editor_with("alpha\nbeta\ngamma\ndelta");
9339        e.jump_cursor(2, 0);
9340        // Insert at line 2 — sets last_edit_pos.
9341        run_keys(&mut e, "iX<Esc>");
9342        let after_edit = e.cursor();
9343        // Move away.
9344        run_keys(&mut e, "gg");
9345        assert_eq!(e.cursor().0, 0);
9346        // `'.` jumps back to the edit's row (linewise variant).
9347        run_keys(&mut e, "'.");
9348        assert_eq!(e.cursor().0, after_edit.0);
9349    }
9350
9351    #[test]
9352    fn quote_quote_returns_to_pre_jump_position() {
9353        let mut e = editor_with_rows(50, 20);
9354        e.jump_cursor(10, 2);
9355        let before = e.cursor();
9356        // `G` is a big jump — pushes (10, 2) onto jump_back.
9357        run_keys(&mut e, "G");
9358        assert_ne!(e.cursor(), before);
9359        // `''` jumps back to the pre-jump position (linewise).
9360        run_keys(&mut e, "''");
9361        assert_eq!(e.cursor().0, before.0);
9362    }
9363
9364    #[test]
9365    fn backtick_backtick_restores_exact_pre_jump_pos() {
9366        let mut e = editor_with_rows(50, 20);
9367        e.jump_cursor(7, 3);
9368        let before = e.cursor();
9369        run_keys(&mut e, "G");
9370        run_keys(&mut e, "``");
9371        assert_eq!(e.cursor(), before);
9372    }
9373
9374    #[test]
9375    fn macro_record_and_replay_basic() {
9376        let mut e = editor_with("foo\nbar\nbaz");
9377        // Record into "a": insert "X" at line start, exit insert.
9378        run_keys(&mut e, "qaIX<Esc>jq");
9379        assert_eq!(e.buffer().lines()[0], "Xfoo");
9380        // Replay on the next two lines.
9381        run_keys(&mut e, "@a");
9382        assert_eq!(e.buffer().lines()[1], "Xbar");
9383        // @@ replays the last-played macro.
9384        run_keys(&mut e, "j@@");
9385        assert_eq!(e.buffer().lines()[2], "Xbaz");
9386    }
9387
9388    #[test]
9389    fn macro_count_replays_n_times() {
9390        let mut e = editor_with("a\nb\nc\nd\ne");
9391        // Record "j" — move down once.
9392        run_keys(&mut e, "qajq");
9393        assert_eq!(e.cursor().0, 1);
9394        // Replay 3 times via 3@a.
9395        run_keys(&mut e, "3@a");
9396        assert_eq!(e.cursor().0, 4);
9397    }
9398
9399    #[test]
9400    fn macro_capital_q_appends_to_lowercase_register() {
9401        let mut e = editor_with("hello");
9402        run_keys(&mut e, "qall<Esc>q");
9403        run_keys(&mut e, "qAhh<Esc>q");
9404        // Macros + named registers share storage now: register `a`
9405        // holds the encoded keystrokes from both recordings.
9406        let text = e.registers().read('a').unwrap().text.clone();
9407        assert!(text.contains("ll<Esc>"));
9408        assert!(text.contains("hh<Esc>"));
9409    }
9410
9411    #[test]
9412    fn buffer_selection_block_in_visual_block_mode() {
9413        use hjkl_buffer::{Position, Selection};
9414        let mut e = editor_with("aaaa\nbbbb\ncccc");
9415        run_keys(&mut e, "<C-v>jl");
9416        assert_eq!(
9417            e.buffer_selection(),
9418            Some(Selection::Block {
9419                anchor: Position::new(0, 0),
9420                head: Position::new(1, 1),
9421            })
9422        );
9423    }
9424
9425    // ─── Audit batch: lock in known-good behaviour ───────────────────────
9426
9427    #[test]
9428    fn n_after_question_mark_keeps_walking_backward() {
9429        // After committing a `?` search, `n` should continue in the
9430        // backward direction; `N` flips forward.
9431        let mut e = editor_with("foo bar foo baz foo end");
9432        e.jump_cursor(0, 22);
9433        run_keys(&mut e, "?foo<CR>");
9434        assert_eq!(e.cursor().1, 16);
9435        run_keys(&mut e, "n");
9436        assert_eq!(e.cursor().1, 8);
9437        run_keys(&mut e, "N");
9438        assert_eq!(e.cursor().1, 16);
9439    }
9440
9441    #[test]
9442    fn nested_macro_chord_records_literal_keys() {
9443        // `qa@bq` should capture `@` and `b` as literal keys in `a`,
9444        // not as a macro-replay invocation. Replay then re-runs them.
9445        let mut e = editor_with("alpha\nbeta\ngamma");
9446        // First record `b` as a noop-ish macro: just `l` (move right).
9447        run_keys(&mut e, "qblq");
9448        // Now record `a` as: enter insert, type X, exit, then trigger
9449        // `@b` which should run the macro inline during recording too.
9450        run_keys(&mut e, "qaIX<Esc>q");
9451        // `@a` re-runs the captured key sequence on a different line.
9452        e.jump_cursor(1, 0);
9453        run_keys(&mut e, "@a");
9454        assert_eq!(e.buffer().lines()[1], "Xbeta");
9455    }
9456
9457    #[test]
9458    fn shift_gt_motion_indents_one_line() {
9459        // `>w` over a single-line buffer should indent that line by
9460        // one shiftwidth — operator routes through the operator
9461        // pipeline like `dw` / `cw`.
9462        let mut e = editor_with("hello world");
9463        run_keys(&mut e, ">w");
9464        assert_eq!(e.buffer().lines()[0], "  hello world");
9465    }
9466
9467    #[test]
9468    fn shift_lt_motion_outdents_one_line() {
9469        let mut e = editor_with("    hello world");
9470        run_keys(&mut e, "<lt>w");
9471        // Outdent strips up to one shiftwidth (default 2).
9472        assert_eq!(e.buffer().lines()[0], "  hello world");
9473    }
9474
9475    #[test]
9476    fn shift_gt_text_object_indents_paragraph() {
9477        let mut e = editor_with("alpha\nbeta\ngamma\n\nrest");
9478        e.jump_cursor(0, 0);
9479        run_keys(&mut e, ">ip");
9480        assert_eq!(e.buffer().lines()[0], "  alpha");
9481        assert_eq!(e.buffer().lines()[1], "  beta");
9482        assert_eq!(e.buffer().lines()[2], "  gamma");
9483        // Blank separator + the next paragraph stay untouched.
9484        assert_eq!(e.buffer().lines()[4], "rest");
9485    }
9486
9487    #[test]
9488    fn ctrl_o_runs_exactly_one_normal_command() {
9489        // `Ctrl-O dw` returns to insert after the single `dw`. A
9490        // second `Ctrl-O` is needed for another normal command.
9491        let mut e = editor_with("alpha beta gamma");
9492        e.jump_cursor(0, 0);
9493        run_keys(&mut e, "i");
9494        e.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
9495        run_keys(&mut e, "dw");
9496        // First `dw` ran in normal; we're back in insert.
9497        assert_eq!(e.vim_mode(), VimMode::Insert);
9498        // Typing a char now inserts.
9499        run_keys(&mut e, "X");
9500        assert_eq!(e.buffer().lines()[0], "Xbeta gamma");
9501    }
9502
9503    #[test]
9504    fn macro_replay_respects_mode_switching() {
9505        // Recording `iX<Esc>0` should leave us in normal mode at col 0
9506        // after replay — the embedded Esc in the macro must drop the
9507        // replayed insert session.
9508        let mut e = editor_with("hi");
9509        run_keys(&mut e, "qaiX<Esc>0q");
9510        assert_eq!(e.vim_mode(), VimMode::Normal);
9511        // Replay on a fresh line.
9512        e.set_content("yo");
9513        run_keys(&mut e, "@a");
9514        assert_eq!(e.vim_mode(), VimMode::Normal);
9515        assert_eq!(e.cursor().1, 0);
9516        assert_eq!(e.buffer().lines()[0], "Xyo");
9517    }
9518
9519    #[test]
9520    fn macro_recorded_text_round_trips_through_register() {
9521        // After the macros-in-registers unification, recording into
9522        // `a` writes the encoded keystroke text into register `a`'s
9523        // slot. `@a` decodes back to inputs and replays.
9524        let mut e = editor_with("");
9525        run_keys(&mut e, "qaiX<Esc>q");
9526        let text = e.registers().read('a').unwrap().text.clone();
9527        assert!(text.starts_with("iX"));
9528        // Replay inserts another X at the cursor.
9529        run_keys(&mut e, "@a");
9530        assert_eq!(e.buffer().lines()[0], "XX");
9531    }
9532
9533    #[test]
9534    fn dot_after_macro_replays_macros_last_change() {
9535        // After `@a` runs a macro whose last mutation was an insert,
9536        // `.` should repeat that final change, not the whole macro.
9537        let mut e = editor_with("ab\ncd\nef");
9538        // Record: insert 'X' at line start, then move down. The last
9539        // mutation is the insert — `.` should re-apply just that.
9540        run_keys(&mut e, "qaIX<Esc>jq");
9541        assert_eq!(e.buffer().lines()[0], "Xab");
9542        run_keys(&mut e, "@a");
9543        assert_eq!(e.buffer().lines()[1], "Xcd");
9544        // `.` from the new cursor row repeats the last edit (the
9545        // insert `X`), not the whole macro (which would also `j`).
9546        let row_before_dot = e.cursor().0;
9547        run_keys(&mut e, ".");
9548        assert!(e.buffer().lines()[row_before_dot].starts_with('X'));
9549    }
9550
9551    // ── smartindent tests ────────────────────────────────────────────────
9552
9553    /// Build an editor with 4-space settings (expandtab, shiftwidth=4,
9554    /// softtabstop=4) for smartindent tests. Does NOT inherit the
9555    /// shiftwidth=2 override from `editor_with`.
9556    fn si_editor(content: &str) -> Editor {
9557        let opts = crate::types::Options {
9558            shiftwidth: 4,
9559            softtabstop: 4,
9560            expandtab: true,
9561            smartindent: true,
9562            autoindent: true,
9563            ..crate::types::Options::default()
9564        };
9565        let mut e = Editor::new(
9566            hjkl_buffer::Buffer::new(),
9567            crate::types::DefaultHost::new(),
9568            opts,
9569        );
9570        e.set_content(content);
9571        e
9572    }
9573
9574    #[test]
9575    fn smartindent_bumps_indent_after_open_brace() {
9576        // "fn foo() {" + Enter → new line has 4 spaces of indent
9577        let mut e = si_editor("fn foo() {");
9578        e.jump_cursor(0, 10); // after the `{`
9579        run_keys(&mut e, "i<CR>");
9580        assert_eq!(
9581            e.buffer().lines()[1],
9582            "    ",
9583            "smartindent should bump one shiftwidth after {{"
9584        );
9585    }
9586
9587    #[test]
9588    fn smartindent_no_bump_when_off() {
9589        // Same input but smartindent=false → just copies prev leading ws
9590        // (which is empty on "fn foo() {"), so new line is empty.
9591        let mut e = si_editor("fn foo() {");
9592        e.settings_mut().smartindent = false;
9593        e.jump_cursor(0, 10);
9594        run_keys(&mut e, "i<CR>");
9595        assert_eq!(
9596            e.buffer().lines()[1],
9597            "",
9598            "without smartindent, no bump: new line copies empty leading ws"
9599        );
9600    }
9601
9602    #[test]
9603    fn smartindent_uses_tab_when_noexpandtab() {
9604        // noexpandtab + prev line ends in `{` → new line starts with `\t`
9605        let opts = crate::types::Options {
9606            shiftwidth: 4,
9607            softtabstop: 0,
9608            expandtab: false,
9609            smartindent: true,
9610            autoindent: true,
9611            ..crate::types::Options::default()
9612        };
9613        let mut e = Editor::new(
9614            hjkl_buffer::Buffer::new(),
9615            crate::types::DefaultHost::new(),
9616            opts,
9617        );
9618        e.set_content("fn foo() {");
9619        e.jump_cursor(0, 10);
9620        run_keys(&mut e, "i<CR>");
9621        assert_eq!(
9622            e.buffer().lines()[1],
9623            "\t",
9624            "noexpandtab: smartindent bump inserts a literal tab"
9625        );
9626    }
9627
9628    #[test]
9629    fn smartindent_dedent_on_close_brace() {
9630        // Line is "    " (4 spaces), cursor at col 4, type `}` →
9631        // leading spaces stripped, `}` at col 0.
9632        let mut e = si_editor("fn foo() {");
9633        // Add a second line with only indentation.
9634        e.set_content("fn foo() {\n    ");
9635        e.jump_cursor(1, 4); // end of "    "
9636        run_keys(&mut e, "i}");
9637        assert_eq!(
9638            e.buffer().lines()[1],
9639            "}",
9640            "close brace on whitespace-only line should dedent"
9641        );
9642        assert_eq!(e.cursor(), (1, 1), "cursor should be after the `}}`");
9643    }
9644
9645    #[test]
9646    fn smartindent_no_dedent_when_off() {
9647        // Same setup but smartindent=false → `}` appended normally.
9648        let mut e = si_editor("fn foo() {\n    ");
9649        e.settings_mut().smartindent = false;
9650        e.jump_cursor(1, 4);
9651        run_keys(&mut e, "i}");
9652        assert_eq!(
9653            e.buffer().lines()[1],
9654            "    }",
9655            "without smartindent, `}}` just appends at cursor"
9656        );
9657    }
9658
9659    #[test]
9660    fn smartindent_no_dedent_mid_line() {
9661        // Line has "    let x = 1", cursor after `1`; type `}` → no
9662        // dedent because chars before cursor aren't all whitespace.
9663        let mut e = si_editor("    let x = 1");
9664        e.jump_cursor(0, 13); // after `1`
9665        run_keys(&mut e, "i}");
9666        assert_eq!(
9667            e.buffer().lines()[0],
9668            "    let x = 1}",
9669            "mid-line `}}` should not dedent"
9670        );
9671    }
9672
9673    // ─── Vim-compat divergence fixes (issue #24) ─────────────────────
9674
9675    // Fix #1: x/X populate the unnamed register.
9676    #[test]
9677    fn count_5x_fills_unnamed_register() {
9678        let mut e = editor_with("hello world\n");
9679        e.jump_cursor(0, 0);
9680        run_keys(&mut e, "5x");
9681        assert_eq!(e.buffer().lines()[0], " world");
9682        assert_eq!(e.cursor(), (0, 0));
9683        assert_eq!(e.yank(), "hello");
9684    }
9685
9686    #[test]
9687    fn x_fills_unnamed_register_single_char() {
9688        let mut e = editor_with("abc\n");
9689        e.jump_cursor(0, 0);
9690        run_keys(&mut e, "x");
9691        assert_eq!(e.buffer().lines()[0], "bc");
9692        assert_eq!(e.yank(), "a");
9693    }
9694
9695    #[test]
9696    fn big_x_fills_unnamed_register() {
9697        let mut e = editor_with("hello\n");
9698        e.jump_cursor(0, 3);
9699        run_keys(&mut e, "X");
9700        assert_eq!(e.buffer().lines()[0], "helo");
9701        assert_eq!(e.yank(), "l");
9702    }
9703
9704    // Fix #2: G lands on last content row, not phantom trailing-empty row.
9705    #[test]
9706    fn g_motion_trailing_newline_lands_on_last_content_row() {
9707        let mut e = editor_with("foo\nbar\nbaz\n");
9708        e.jump_cursor(0, 0);
9709        run_keys(&mut e, "G");
9710        // buffer is stored as ["foo","bar","baz",""] — G must land on row 2 ("baz").
9711        assert_eq!(
9712            e.cursor().0,
9713            2,
9714            "G should land on row 2 (baz), not row 3 (phantom empty)"
9715        );
9716    }
9717
9718    // Fix #3: dd on last line clamps cursor to new last content row.
9719    #[test]
9720    fn dd_last_line_clamps_cursor_to_new_last_row() {
9721        let mut e = editor_with("foo\nbar\n");
9722        e.jump_cursor(1, 0);
9723        run_keys(&mut e, "dd");
9724        assert_eq!(e.buffer().lines()[0], "foo");
9725        assert_eq!(
9726            e.cursor(),
9727            (0, 0),
9728            "cursor should clamp to row 0 after dd on last content line"
9729        );
9730    }
9731
9732    // Fix #4: d$ cursor lands on last char, not one past.
9733    #[test]
9734    fn d_dollar_cursor_on_last_char() {
9735        let mut e = editor_with("hello world\n");
9736        e.jump_cursor(0, 5);
9737        run_keys(&mut e, "d$");
9738        assert_eq!(e.buffer().lines()[0], "hello");
9739        assert_eq!(
9740            e.cursor(),
9741            (0, 4),
9742            "d$ should leave cursor on col 4, not col 5"
9743        );
9744    }
9745
9746    // Fix #5: undo clamps cursor to last valid normal-mode col.
9747    #[test]
9748    fn undo_insert_clamps_cursor_to_last_valid_col() {
9749        let mut e = editor_with("hello\n");
9750        e.jump_cursor(0, 5); // one-past-last, as in oracle initial_cursor
9751        run_keys(&mut e, "a world<Esc>u");
9752        assert_eq!(e.buffer().lines()[0], "hello");
9753        assert_eq!(
9754            e.cursor(),
9755            (0, 4),
9756            "undo should clamp cursor to col 4 on 'hello'"
9757        );
9758    }
9759
9760    // Fix #6: da" eats trailing whitespace when present.
9761    #[test]
9762    fn da_doublequote_eats_trailing_whitespace() {
9763        let mut e = editor_with("say \"hello\" there\n");
9764        e.jump_cursor(0, 6);
9765        run_keys(&mut e, "da\"");
9766        assert_eq!(e.buffer().lines()[0], "say there");
9767        assert_eq!(e.cursor().1, 4, "cursor should be at col 4 after da\"");
9768    }
9769
9770    // Fix #7: daB cursor off-by-one — clamp to new last col.
9771    #[test]
9772    fn dab_cursor_col_clamped_after_delete() {
9773        let mut e = editor_with("fn x() {\n    body\n}\n");
9774        e.jump_cursor(1, 4);
9775        run_keys(&mut e, "daB");
9776        assert_eq!(e.buffer().lines()[0], "fn x() ");
9777        assert_eq!(
9778            e.cursor(),
9779            (0, 6),
9780            "daB should leave cursor at col 6, not 7"
9781        );
9782    }
9783
9784    // Fix #8: diB preserves surrounding newlines on multi-line block.
9785    #[test]
9786    fn dib_preserves_surrounding_newlines() {
9787        let mut e = editor_with("{\n    body\n}\n");
9788        e.jump_cursor(1, 4);
9789        run_keys(&mut e, "diB");
9790        assert_eq!(e.buffer().lines()[0], "{");
9791        assert_eq!(e.buffer().lines()[1], "}");
9792        assert_eq!(e.cursor().0, 1, "cursor should be on the '}}' line");
9793    }
9794}