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