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)]
100pub enum 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    /// `={motion}` — auto-indent the line range using shiftwidth-based
187    /// bracket depth counting (v1 dumb reindent). Always linewise.
188    /// See `auto_indent_range` for the algorithm and its limitations.
189    AutoIndent,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum Motion {
194    Left,
195    Right,
196    Up,
197    Down,
198    WordFwd,
199    BigWordFwd,
200    WordBack,
201    BigWordBack,
202    WordEnd,
203    BigWordEnd,
204    /// `ge` — backward word end.
205    WordEndBack,
206    /// `gE` — backward WORD end.
207    BigWordEndBack,
208    LineStart,
209    FirstNonBlank,
210    LineEnd,
211    FileTop,
212    FileBottom,
213    Find {
214        ch: char,
215        forward: bool,
216        till: bool,
217    },
218    FindRepeat {
219        reverse: bool,
220    },
221    MatchBracket,
222    WordAtCursor {
223        forward: bool,
224        /// `*` / `#` use `\bword\b` boundaries; `g*` / `g#` drop them so
225        /// the search hits substrings (e.g. `foo` matches inside `foobar`).
226        whole_word: bool,
227    },
228    /// `n` / `N` — repeat the last `/` or `?` search.
229    SearchNext {
230        reverse: bool,
231    },
232    /// `H` — cursor to viewport top (plus `count - 1` rows down).
233    ViewportTop,
234    /// `M` — cursor to viewport middle.
235    ViewportMiddle,
236    /// `L` — cursor to viewport bottom (minus `count - 1` rows up).
237    ViewportBottom,
238    /// `g_` — last non-blank char on the line.
239    LastNonBlank,
240    /// `gM` — cursor to the middle char column of the current line
241    /// (`floor(chars / 2)`). Vim's variant ignoring screen wrap.
242    LineMiddle,
243    /// `{` — previous paragraph (preceding blank line, or top).
244    ParagraphPrev,
245    /// `}` — next paragraph (following blank line, or bottom).
246    ParagraphNext,
247    /// `(` — previous sentence boundary.
248    SentencePrev,
249    /// `)` — next sentence boundary.
250    SentenceNext,
251    /// `gj` — `count` visual rows down (one screen segment per step
252    /// under `:set wrap`; falls back to `Down` otherwise).
253    ScreenDown,
254    /// `gk` — `count` visual rows up; mirror of [`Motion::ScreenDown`].
255    ScreenUp,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum TextObject {
260    Word {
261        big: bool,
262    },
263    Quote(char),
264    Bracket(char),
265    Paragraph,
266    /// `it` / `at` — XML/HTML-style tag pair. `inner = true` covers
267    /// content between `>` and `</`; `inner = false` covers the open
268    /// tag through the close tag inclusive.
269    XmlTag,
270    /// `is` / `as` — sentence: a run ending at `.`, `?`, or `!`
271    /// followed by whitespace or end-of-line. `inner = true` covers
272    /// the sentence text only; `inner = false` includes trailing
273    /// whitespace.
274    Sentence,
275}
276
277/// Classification determines how operators treat the range end.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum RangeKind {
280    /// Range end is exclusive (end column not included). Typical: h, l, w, 0, $.
281    Exclusive,
282    /// Range end is inclusive. Typical: e, f, t, %.
283    Inclusive,
284    /// Whole lines from top row to bottom row. Typical: j, k, gg, G.
285    Linewise,
286}
287
288// ─── Dot-repeat storage ────────────────────────────────────────────────────
289
290/// Information needed to replay a mutating change via `.`.
291#[derive(Debug, Clone)]
292pub enum LastChange {
293    /// Operator over a motion.
294    OpMotion {
295        op: Operator,
296        motion: Motion,
297        count: usize,
298        inserted: Option<String>,
299    },
300    /// Operator over a text-object.
301    OpTextObj {
302        op: Operator,
303        obj: TextObject,
304        inner: bool,
305        inserted: Option<String>,
306    },
307    /// `dd`, `cc`, `yy` with a count.
308    LineOp {
309        op: Operator,
310        count: usize,
311        inserted: Option<String>,
312    },
313    /// `x`, `X` with a count.
314    CharDel { forward: bool, count: usize },
315    /// `r<ch>` with a count.
316    ReplaceChar { ch: char, count: usize },
317    /// `~` with a count.
318    ToggleCase { count: usize },
319    /// `J` with a count.
320    JoinLine { count: usize },
321    /// `p` / `P` with a count.
322    Paste { before: bool, count: usize },
323    /// `D` (delete to EOL).
324    DeleteToEol { inserted: Option<String> },
325    /// `o` / `O` + the inserted text.
326    OpenLine { above: bool, inserted: String },
327    /// `i`/`I`/`a`/`A` + inserted text.
328    InsertAt {
329        entry: InsertEntry,
330        inserted: String,
331        count: usize,
332    },
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum InsertEntry {
337    I,
338    A,
339    ShiftI,
340    ShiftA,
341}
342
343// ─── VimState ──────────────────────────────────────────────────────────────
344
345#[derive(Default)]
346pub struct VimState {
347    /// Internal FSM mode. Kept in sync with `current_mode` after every
348    /// `step`. Phase 6.6b: promoted from private to `pub` so the FSM
349    /// body (moving to hjkl-vim in 6.6c–6.6g) can read/write it directly
350    /// until the migration is complete.
351    pub mode: Mode,
352    /// Two-key chord in progress. `Pending::None` when idle.
353    pub pending: Pending,
354    /// Digit prefix accumulated before an operator or motion. `0` means
355    /// no prefix was typed (treated as 1 by most commands).
356    pub count: usize,
357    /// Last `f`/`F`/`t`/`T` target, for `;` / `,` repeat.
358    pub last_find: Option<(char, bool, bool)>,
359    /// Most-recent mutating command for `.` dot-repeat.
360    pub last_change: Option<LastChange>,
361    /// Captured on insert-mode entry: count, buffer snapshot, entry kind.
362    pub insert_session: Option<InsertSession>,
363    /// (row, col) anchor for char-wise Visual mode. Set on entry, used
364    /// to compute the highlight range and the operator range without
365    /// relying on tui-textarea's live selection.
366    pub visual_anchor: (usize, usize),
367    /// Row anchor for VisualLine mode.
368    pub visual_line_anchor: usize,
369    /// (row, col) anchor for VisualBlock mode. The live cursor is the
370    /// opposite corner.
371    pub block_anchor: (usize, usize),
372    /// Intended "virtual" column for the block's active corner. j/k
373    /// clamp cursor.col to shorter rows, which would collapse the
374    /// block across ragged content — so we remember the desired column
375    /// separately and use it for block bounds / insert-column
376    /// computations. Updated by h/l only.
377    pub block_vcol: usize,
378    /// Track whether the last yank/cut was linewise (drives `p`/`P` layout).
379    pub yank_linewise: bool,
380    /// Active register selector — set by `"reg` prefix, consumed by
381    /// the next y / d / c / p. `None` falls back to the unnamed `"`.
382    pub pending_register: Option<char>,
383    /// Recording target — set by `q{reg}`, cleared by a bare `q`.
384    /// While `Some`, every consumed `Input` is appended to
385    /// `recording_keys`.
386    pub recording_macro: Option<char>,
387    /// Keys recorded into the in-progress macro. On `q` finish, these
388    /// are encoded via [`crate::input::encode_macro`] and written to
389    /// the matching named register slot, so macros and yanks share a
390    /// single store.
391    pub recording_keys: Vec<crate::input::Input>,
392    /// Set during `@reg` replay so the recorder doesn't capture the
393    /// replayed keystrokes a second time.
394    pub replaying_macro: bool,
395    /// Last register played via `@reg`. `@@` re-plays this one.
396    pub last_macro: Option<char>,
397    /// Position of the most recent buffer mutation. Surfaced via
398    /// the `'.` / `` `. `` marks for quick "back to last edit".
399    pub last_edit_pos: Option<(usize, usize)>,
400    /// Position where the cursor was when insert mode last exited (Esc).
401    /// Used by `gi` to return to the exact (row, col) where the user
402    /// last typed, matching vim's `:h gi`.
403    pub last_insert_pos: Option<(usize, usize)>,
404    /// Bounded ring of recent edit positions (newest at the back).
405    /// `g;` walks toward older entries, `g,` toward newer ones. Capped
406    /// at [`CHANGE_LIST_MAX`].
407    pub change_list: Vec<(usize, usize)>,
408    /// Index into `change_list` while walking. `None` outside a walk —
409    /// any new edit clears it (and trims forward entries past it).
410    pub change_list_cursor: Option<usize>,
411    /// Snapshot of the last visual selection for `gv` re-entry.
412    /// Stored on every Visual / VisualLine / VisualBlock exit.
413    pub last_visual: Option<LastVisual>,
414    /// `zz` / `zt` / `zb` set this so the end-of-step scrolloff
415    /// pass doesn't override the user's explicit viewport pinning.
416    /// Cleared every step.
417    pub viewport_pinned: bool,
418    /// Set while replaying `.` / last-change so we don't re-record it.
419    pub replaying: bool,
420    /// Entered Normal from Insert via `Ctrl-o`; after the next complete
421    /// normal-mode command we return to Insert.
422    pub one_shot_normal: bool,
423    /// Live `/` or `?` prompt. `None` outside search-prompt mode.
424    pub search_prompt: Option<SearchPrompt>,
425    /// Most recent committed search pattern. Surfaced to host apps via
426    /// [`Editor::last_search`] so their status line can render a hint
427    /// and so `n` / `N` have something to repeat.
428    pub last_search: Option<String>,
429    /// Direction of the last committed search. `n` repeats this; `N`
430    /// inverts it. Defaults to forward so a never-searched buffer's
431    /// `n` still walks downward.
432    pub last_search_forward: bool,
433    /// Back half of the jumplist — `Ctrl-o` pops from here. Populated
434    /// with the pre-motion cursor when a "big jump" motion fires
435    /// (`gg`/`G`, `%`, `*`/`#`, `n`/`N`, `H`/`M`/`L`, committed `/` or
436    /// `?`). Capped at 100 entries.
437    pub jump_back: Vec<(usize, usize)>,
438    /// Forward half — `Ctrl-i` pops from here. Cleared by any new big
439    /// jump, matching vim's "branch off trims forward history" rule.
440    pub jump_fwd: Vec<(usize, usize)>,
441    /// Set by `Ctrl-R` in insert mode while waiting for the register
442    /// selector. The next typed char names the register; its contents
443    /// are inserted inline at the cursor and the flag clears.
444    pub insert_pending_register: bool,
445    /// Stashed start position for the `[` mark on a Change operation.
446    /// Set to `top` before the cut in `run_operator_over_range` (Change
447    /// arm); consumed by `finish_insert_session` on Esc-from-insert
448    /// when the reason is `AfterChange`. Mirrors vim's `:h '[` / `:h ']`
449    /// rule that `[` = start of change, `]` = last typed char on exit.
450    pub change_mark_start: Option<(usize, usize)>,
451    /// Bounded history of committed `/` / `?` search patterns. Newest
452    /// entries are at the back; capped at [`SEARCH_HISTORY_MAX`] to
453    /// avoid unbounded growth on long sessions.
454    pub search_history: Vec<String>,
455    /// Index into `search_history` while the user walks past patterns
456    /// in the prompt via `Ctrl-P` / `Ctrl-N`. `None` outside that walk
457    /// — typing or backspacing in the prompt resets it so the next
458    /// `Ctrl-P` starts from the most recent entry again.
459    pub search_history_cursor: Option<usize>,
460    /// Wall-clock instant of the last keystroke. Drives the
461    /// `:set timeoutlen` multi-key timeout — if `now() - last_input_at`
462    /// exceeds the configured budget, any pending prefix is cleared
463    /// before the new key dispatches. `None` before the first key.
464    /// 0.0.29 (Patch B): `:set timeoutlen` math now reads
465    /// [`crate::types::Host::now`] via `last_input_host_at`. This
466    /// `Instant`-flavoured field stays for snapshot tests that still
467    /// observe it directly.
468    pub last_input_at: Option<std::time::Instant>,
469    /// `Host::now()` reading at the last keystroke. Drives
470    /// `:set timeoutlen` so macro replay / headless drivers stay
471    /// deterministic regardless of wall-clock skew.
472    pub last_input_host_at: Option<core::time::Duration>,
473    /// Canonical current mode. Mirrors `mode` (the FSM-internal field)
474    /// AND is written by every Phase 6.3 primitive (`set_mode`,
475    /// `enter_visual_char_bridge`, …). Once the FSM is gone this is the
476    /// sole source of truth; until then both fields are kept in sync.
477    /// Initialized to `Normal` via `#[derive(Default)]`.
478    pub(crate) current_mode: crate::VimMode,
479}
480
481pub(crate) const SEARCH_HISTORY_MAX: usize = 100;
482pub(crate) const CHANGE_LIST_MAX: usize = 100;
483
484/// Active `/` or `?` search prompt. Text mutations drive the textarea's
485/// live search pattern so matches highlight as the user types.
486#[derive(Debug, Clone)]
487pub struct SearchPrompt {
488    pub text: String,
489    pub cursor: usize,
490    pub forward: bool,
491}
492
493#[derive(Debug, Clone)]
494pub struct InsertSession {
495    pub count: usize,
496    /// Min/max row visited during this session. Widens on every key.
497    pub row_min: usize,
498    pub row_max: usize,
499    /// Snapshot of the full buffer at session entry. Used to diff the
500    /// affected row window at finish without being fooled by cursor
501    /// navigation through rows the user never edited.
502    pub before_lines: Vec<String>,
503    pub reason: InsertReason,
504}
505
506#[derive(Debug, Clone)]
507pub enum InsertReason {
508    /// Plain entry via i/I/a/A — recorded as `InsertAt`.
509    Enter(InsertEntry),
510    /// Entry via `o`/`O` — records OpenLine on Esc.
511    Open { above: bool },
512    /// Entry via an operator's change side-effect. Retro-fills the
513    /// stored last-change's `inserted` field on Esc.
514    AfterChange,
515    /// Entry via `C` (delete to EOL + insert).
516    DeleteToEol,
517    /// Entry via an insert triggered during dot-replay — don't touch
518    /// last_change because the outer replay will restore it.
519    ReplayOnly,
520    /// `I` or `A` from VisualBlock: insert the typed text at `col` on
521    /// every row in `top..=bot`. `col` is the start column for `I`, the
522    /// one-past-block-end column for `A`.
523    BlockEdge { top: usize, bot: usize, col: usize },
524    /// `c` from VisualBlock: block content deleted, then user types
525    /// replacement text replicated across all block rows on Esc. Cursor
526    /// advances to the last typed char after replication (unlike BlockEdge
527    /// which leaves cursor at the insertion column).
528    BlockChange { top: usize, bot: usize, col: usize },
529    /// `R` — Replace mode. Each typed char overwrites the cell under
530    /// the cursor instead of inserting; at end-of-line the session
531    /// falls through to insert (same as vim).
532    Replace,
533}
534
535/// Saved visual-mode anchor + cursor for `gv` (re-enters the last
536/// visual selection). `mode` carries which visual flavour to
537/// restore; `anchor` / `cursor` mean different things per flavour:
538///
539/// - `Visual`     — `anchor` is the char-wise visual anchor.
540/// - `VisualLine` — `anchor.0` is the `visual_line_anchor` row;
541///   `anchor.1` is unused.
542/// - `VisualBlock`— `anchor` is `block_anchor`, `block_vcol` is the
543///   sticky vcol that survives j/k clamping.
544#[derive(Debug, Clone, Copy)]
545pub struct LastVisual {
546    pub mode: Mode,
547    pub anchor: (usize, usize),
548    pub cursor: (usize, usize),
549    pub block_vcol: usize,
550}
551
552impl VimState {
553    pub fn public_mode(&self) -> VimMode {
554        match self.mode {
555            Mode::Normal => VimMode::Normal,
556            Mode::Insert => VimMode::Insert,
557            Mode::Visual => VimMode::Visual,
558            Mode::VisualLine => VimMode::VisualLine,
559            Mode::VisualBlock => VimMode::VisualBlock,
560        }
561    }
562
563    pub fn force_normal(&mut self) {
564        self.mode = Mode::Normal;
565        self.pending = Pending::None;
566        self.count = 0;
567        self.insert_session = None;
568        // Phase 6.3: keep current_mode in sync for callers that bypass step().
569        self.current_mode = crate::VimMode::Normal;
570    }
571
572    /// Reset every prefix-tracking field so the next keystroke starts
573    /// a fresh sequence. Drives `:set timeoutlen` — when the user
574    /// pauses past the configured budget, `hjkl_vim::dispatch_input` calls
575    /// this before dispatching the new key.
576    ///
577    /// Resets: `pending`, `count`, `pending_register`,
578    /// `insert_pending_register`. Does NOT touch `mode`,
579    /// `insert_session`, marks, jump list, or visual anchors —
580    /// those aren't part of the in-flight chord.
581    pub(crate) fn clear_pending_prefix(&mut self) {
582        self.pending = Pending::None;
583        self.count = 0;
584        self.pending_register = None;
585        self.insert_pending_register = false;
586    }
587
588    /// Widen the active insert session's row window to include `row`. Called
589    /// by the Phase 6.1 public `Editor::insert_*` methods after each
590    /// mutation so `finish_insert_session` diffs the right range on Esc.
591    /// No-op when no insert session is active (e.g. calling from Normal mode).
592    pub(crate) fn widen_insert_row(&mut self, row: usize) {
593        if let Some(ref mut session) = self.insert_session {
594            session.row_min = session.row_min.min(row);
595            session.row_max = session.row_max.max(row);
596        }
597    }
598
599    pub fn is_visual(&self) -> bool {
600        matches!(
601            self.mode,
602            Mode::Visual | Mode::VisualLine | Mode::VisualBlock
603        )
604    }
605
606    pub fn is_visual_char(&self) -> bool {
607        self.mode == Mode::Visual
608    }
609
610    /// The pending repeat count (typed digits before a motion/operator),
611    /// or `None` when no digits are pending. Zero is treated as absent.
612    pub(crate) fn pending_count_val(&self) -> Option<u32> {
613        if self.count == 0 {
614            None
615        } else {
616            Some(self.count as u32)
617        }
618    }
619
620    /// `true` when an in-flight chord is awaiting more keys. Inverse of
621    /// `matches!(self.pending, Pending::None)`.
622    pub(crate) fn is_chord_pending(&self) -> bool {
623        !matches!(self.pending, Pending::None)
624    }
625
626    /// Return a single char representing the pending operator, if any.
627    /// Used by host apps (status line "showcmd" area) to display e.g.
628    /// `d`, `y`, `c` while waiting for a motion.
629    pub(crate) fn pending_op_char(&self) -> Option<char> {
630        let op = match &self.pending {
631            Pending::Op { op, .. }
632            | Pending::OpTextObj { op, .. }
633            | Pending::OpG { op, .. }
634            | Pending::OpFind { op, .. } => Some(*op),
635            _ => None,
636        };
637        op.map(|o| match o {
638            Operator::Delete => 'd',
639            Operator::Change => 'c',
640            Operator::Yank => 'y',
641            Operator::Uppercase => 'U',
642            Operator::Lowercase => 'u',
643            Operator::ToggleCase => '~',
644            Operator::Indent => '>',
645            Operator::Outdent => '<',
646            Operator::Fold => 'z',
647            Operator::Reflow => 'q',
648            Operator::AutoIndent => '=',
649        })
650    }
651}
652
653// ─── Entry point ───────────────────────────────────────────────────────────
654
655/// Open the `/` (forward) or `?` (backward) search prompt. Clears any
656/// live search highlight until the user commits a query. `last_search`
657/// is preserved so an empty `<CR>` can re-run the previous pattern.
658pub(crate) fn enter_search<H: crate::types::Host>(
659    ed: &mut Editor<hjkl_buffer::Buffer, H>,
660    forward: bool,
661) {
662    ed.vim.search_prompt = Some(SearchPrompt {
663        text: String::new(),
664        cursor: 0,
665        forward,
666    });
667    ed.vim.search_history_cursor = None;
668    // 0.0.37: clear via the engine search state (the buffer-side
669    // bridge from 0.0.35 was removed in this patch — the `BufferView`
670    // renderer reads the pattern from `Editor::search_state()`).
671    ed.set_search_pattern(None);
672}
673
674/// `g;` / `g,` body. `dir = -1` walks toward older entries (g;),
675/// `dir = 1` toward newer (g,). `count` repeats the step. Stops at
676/// the ends of the ring; off-ring positions are silently ignored.
677fn walk_change_list<H: crate::types::Host>(
678    ed: &mut Editor<hjkl_buffer::Buffer, H>,
679    dir: isize,
680    count: usize,
681) {
682    if ed.vim.change_list.is_empty() {
683        return;
684    }
685    let len = ed.vim.change_list.len();
686    let mut idx: isize = match (ed.vim.change_list_cursor, dir) {
687        (None, -1) => len as isize - 1,
688        (None, 1) => return, // already past the newest entry
689        (Some(i), -1) => i as isize - 1,
690        (Some(i), 1) => i as isize + 1,
691        _ => return,
692    };
693    for _ in 1..count {
694        let next = idx + dir;
695        if next < 0 || next >= len as isize {
696            break;
697        }
698        idx = next;
699    }
700    if idx < 0 || idx >= len as isize {
701        return;
702    }
703    let idx = idx as usize;
704    ed.vim.change_list_cursor = Some(idx);
705    let (row, col) = ed.vim.change_list[idx];
706    ed.jump_cursor(row, col);
707}
708
709/// `Ctrl-R {reg}` body — insert the named register's contents at the
710/// cursor as charwise text. Embedded newlines split lines naturally via
711/// `Edit::InsertStr`. Unknown selectors and empty slots are no-ops so
712/// stray keystrokes don't mutate the buffer.
713fn insert_register_text<H: crate::types::Host>(
714    ed: &mut Editor<hjkl_buffer::Buffer, H>,
715    selector: char,
716) {
717    use hjkl_buffer::Edit;
718    let text = match ed.registers().read(selector) {
719        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
720        _ => return,
721    };
722    ed.sync_buffer_content_from_textarea();
723    let cursor = buf_cursor_pos(&ed.buffer);
724    ed.mutate_edit(Edit::InsertStr {
725        at: cursor,
726        text: text.clone(),
727    });
728    // Advance cursor to the end of the inserted payload — multi-line
729    // pastes land on the last inserted row at the post-text column.
730    let mut row = cursor.row;
731    let mut col = cursor.col;
732    for ch in text.chars() {
733        if ch == '\n' {
734            row += 1;
735            col = 0;
736        } else {
737            col += 1;
738        }
739    }
740    buf_set_cursor_rc(&mut ed.buffer, row, col);
741    ed.push_buffer_cursor_to_textarea();
742    ed.mark_content_dirty();
743    if let Some(ref mut session) = ed.vim.insert_session {
744        session.row_min = session.row_min.min(row);
745        session.row_max = session.row_max.max(row);
746    }
747}
748
749/// Compute the indent string to insert at the start of a new line
750/// after Enter is pressed at `cursor`. Walks the smartindent rules:
751///
752/// - autoindent off → empty string
753/// - autoindent on  → copy prev line's leading whitespace
754/// - smartindent on → bump one `shiftwidth` if prev line's last
755///   non-whitespace char is `{` / `(` / `[`
756///
757/// Indent unit (used for the smartindent bump):
758///
759/// - `expandtab && softtabstop > 0` → `softtabstop` spaces
760/// - `expandtab` → `shiftwidth` spaces
761/// - `!expandtab` → one literal `\t`
762///
763/// This is the placeholder for a future tree-sitter indent provider:
764/// when a language has an `indents.scm` query, the engine will route
765/// the same call through that provider and only fall back to this
766/// heuristic when no query matches.
767pub(super) fn compute_enter_indent(settings: &crate::editor::Settings, prev_line: &str) -> String {
768    if !settings.autoindent {
769        return String::new();
770    }
771    // Copy the prev line's leading whitespace (autoindent base).
772    let base: String = prev_line
773        .chars()
774        .take_while(|c| *c == ' ' || *c == '\t')
775        .collect();
776
777    if settings.smartindent {
778        // If the last non-whitespace character is an open bracket, bump
779        // indent by one unit. This is the heuristic seam: a tree-sitter
780        // `indents.scm` provider would replace this branch.
781        let last_non_ws = prev_line.chars().rev().find(|c| !c.is_whitespace());
782        if matches!(last_non_ws, Some('{' | '(' | '[')) {
783            let unit = if settings.expandtab {
784                if settings.softtabstop > 0 {
785                    " ".repeat(settings.softtabstop)
786                } else {
787                    " ".repeat(settings.shiftwidth)
788                }
789            } else {
790                "\t".to_string()
791            };
792            return format!("{base}{unit}");
793        }
794    }
795
796    base
797}
798
799/// Strip one indent unit from the beginning of `line` and insert `ch`
800/// instead. Returns `true` when it consumed the keystroke (dedent +
801/// insert), `false` when the caller should insert normally.
802///
803/// Dedent fires when:
804///   - `smartindent` is on
805///   - `ch` is `}` / `)` / `]`
806///   - all bytes BEFORE the cursor on the current line are whitespace
807///   - there is at least one full indent unit of leading whitespace
808fn try_dedent_close_bracket<H: crate::types::Host>(
809    ed: &mut Editor<hjkl_buffer::Buffer, H>,
810    cursor: hjkl_buffer::Position,
811    ch: char,
812) -> bool {
813    use hjkl_buffer::{Edit, MotionKind, Position};
814
815    if !ed.settings.smartindent {
816        return false;
817    }
818    if !matches!(ch, '}' | ')' | ']') {
819        return false;
820    }
821
822    let line = match buf_line(&ed.buffer, cursor.row) {
823        Some(l) => l.to_string(),
824        None => return false,
825    };
826
827    // All chars before cursor must be whitespace.
828    let before: String = line.chars().take(cursor.col).collect();
829    if !before.chars().all(|c| c == ' ' || c == '\t') {
830        return false;
831    }
832    if before.is_empty() {
833        // Nothing to strip — just insert normally (cursor at col 0).
834        return false;
835    }
836
837    // Compute indent unit.
838    let unit_len: usize = if ed.settings.expandtab {
839        if ed.settings.softtabstop > 0 {
840            ed.settings.softtabstop
841        } else {
842            ed.settings.shiftwidth
843        }
844    } else {
845        // Tab: one literal tab character.
846        1
847    };
848
849    // Check there's at least one full unit to strip.
850    let strip_len = if ed.settings.expandtab {
851        // Count leading spaces; need at least `unit_len`.
852        let spaces = before.chars().filter(|c| *c == ' ').count();
853        if spaces < unit_len {
854            return false;
855        }
856        unit_len
857    } else {
858        // noexpandtab: strip one leading tab.
859        if !before.starts_with('\t') {
860            return false;
861        }
862        1
863    };
864
865    // Delete the leading `strip_len` chars of the current line.
866    ed.mutate_edit(Edit::DeleteRange {
867        start: Position::new(cursor.row, 0),
868        end: Position::new(cursor.row, strip_len),
869        kind: MotionKind::Char,
870    });
871    // Insert the close bracket at column 0 (after the delete the cursor
872    // is still positioned at the end of the remaining whitespace; the
873    // delete moved the text so the cursor is now at col = before.len() -
874    // strip_len).
875    let new_col = cursor.col.saturating_sub(strip_len);
876    ed.mutate_edit(Edit::InsertChar {
877        at: Position::new(cursor.row, new_col),
878        ch,
879    });
880    true
881}
882
883fn finish_insert_session<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
884    let Some(session) = ed.vim.insert_session.take() else {
885        return;
886    };
887    let lines = buf_lines_to_vec(&ed.buffer);
888    // Clamp both slices to their respective bounds — the buffer may have
889    // grown (Enter splits rows) or shrunk (Backspace joins rows) during
890    // the session, so row_max can overshoot either side.
891    let after_end = session.row_max.min(lines.len().saturating_sub(1));
892    let before_end = session
893        .row_max
894        .min(session.before_lines.len().saturating_sub(1));
895    let before = if before_end >= session.row_min && session.row_min < session.before_lines.len() {
896        session.before_lines[session.row_min..=before_end].join("\n")
897    } else {
898        String::new()
899    };
900    let after = if after_end >= session.row_min && session.row_min < lines.len() {
901        lines[session.row_min..=after_end].join("\n")
902    } else {
903        String::new()
904    };
905    let inserted = extract_inserted(&before, &after);
906    if !inserted.is_empty() && session.count > 1 && !ed.vim.replaying {
907        use hjkl_buffer::{Edit, Position};
908        for _ in 0..session.count - 1 {
909            let (row, col) = ed.cursor();
910            ed.mutate_edit(Edit::InsertStr {
911                at: Position::new(row, col),
912                text: inserted.clone(),
913            });
914        }
915    }
916    // Helper: replicate `inserted` text across block rows top+1..=bot at `col`,
917    // padding short rows to reach `col` first. Returns without touching the
918    // cursor — callers position the cursor afterward according to their needs.
919    fn replicate_block_text<H: crate::types::Host>(
920        ed: &mut Editor<hjkl_buffer::Buffer, H>,
921        inserted: &str,
922        top: usize,
923        bot: usize,
924        col: usize,
925    ) {
926        use hjkl_buffer::{Edit, Position};
927        for r in (top + 1)..=bot {
928            let line_len = buf_line_chars(&ed.buffer, r);
929            if col > line_len {
930                let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
931                ed.mutate_edit(Edit::InsertStr {
932                    at: Position::new(r, line_len),
933                    text: pad,
934                });
935            }
936            ed.mutate_edit(Edit::InsertStr {
937                at: Position::new(r, col),
938                text: inserted.to_string(),
939            });
940        }
941    }
942
943    if let InsertReason::BlockEdge { top, bot, col } = session.reason {
944        // `I` / `A` from VisualBlock: replicate text across rows; cursor
945        // stays at the block-start column (vim leaves cursor there).
946        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
947            replicate_block_text(ed, &inserted, top, bot, col);
948            buf_set_cursor_rc(&mut ed.buffer, top, col);
949            ed.push_buffer_cursor_to_textarea();
950        }
951        return;
952    }
953    if let InsertReason::BlockChange { top, bot, col } = session.reason {
954        // `c` from VisualBlock: replicate text across rows; cursor advances
955        // to `col + ins_chars` (pre-step-back) so the Esc step-back lands
956        // on the last typed char (col + ins_chars - 1), matching nvim.
957        if !inserted.is_empty() && top < bot && !ed.vim.replaying {
958            replicate_block_text(ed, &inserted, top, bot, col);
959            let ins_chars = inserted.chars().count();
960            let line_len = buf_line_chars(&ed.buffer, top);
961            let target_col = (col + ins_chars).min(line_len);
962            buf_set_cursor_rc(&mut ed.buffer, top, target_col);
963            ed.push_buffer_cursor_to_textarea();
964        }
965        return;
966    }
967    if ed.vim.replaying {
968        return;
969    }
970    match session.reason {
971        InsertReason::Enter(entry) => {
972            ed.vim.last_change = Some(LastChange::InsertAt {
973                entry,
974                inserted,
975                count: session.count,
976            });
977        }
978        InsertReason::Open { above } => {
979            ed.vim.last_change = Some(LastChange::OpenLine { above, inserted });
980        }
981        InsertReason::AfterChange => {
982            if let Some(
983                LastChange::OpMotion { inserted: ins, .. }
984                | LastChange::OpTextObj { inserted: ins, .. }
985                | LastChange::LineOp { inserted: ins, .. },
986            ) = ed.vim.last_change.as_mut()
987            {
988                *ins = Some(inserted);
989            }
990            // Vim `:h '[` / `:h ']`: on change, `[` = start of the
991            // changed range (stashed before the cut), `]` = the cursor
992            // at Esc time (last inserted char, before the step-back).
993            // When nothing was typed cursor still sits at the change
994            // start, satisfying vim's "both at start" parity for `c<m><Esc>`.
995            if let Some(start) = ed.vim.change_mark_start.take() {
996                let end = ed.cursor();
997                ed.set_mark('[', start);
998                ed.set_mark(']', end);
999            }
1000        }
1001        InsertReason::DeleteToEol => {
1002            ed.vim.last_change = Some(LastChange::DeleteToEol {
1003                inserted: Some(inserted),
1004            });
1005        }
1006        InsertReason::ReplayOnly => {}
1007        InsertReason::BlockEdge { .. } => unreachable!("handled above"),
1008        InsertReason::BlockChange { .. } => unreachable!("handled above"),
1009        InsertReason::Replace => {
1010            // Record overstrike sessions as DeleteToEol-style — replay
1011            // re-types each character but doesn't try to restore prior
1012            // content (vim's R has its own replay path; this is the
1013            // pragmatic approximation).
1014            ed.vim.last_change = Some(LastChange::DeleteToEol {
1015                inserted: Some(inserted),
1016            });
1017        }
1018    }
1019}
1020
1021pub(crate) fn begin_insert<H: crate::types::Host>(
1022    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1023    count: usize,
1024    reason: InsertReason,
1025) {
1026    let record = !matches!(reason, InsertReason::ReplayOnly);
1027    if record {
1028        ed.push_undo();
1029    }
1030    let reason = if ed.vim.replaying {
1031        InsertReason::ReplayOnly
1032    } else {
1033        reason
1034    };
1035    let (row, _) = ed.cursor();
1036    ed.vim.insert_session = Some(InsertSession {
1037        count,
1038        row_min: row,
1039        row_max: row,
1040        before_lines: buf_lines_to_vec(&ed.buffer),
1041        reason,
1042    });
1043    ed.vim.mode = Mode::Insert;
1044    // Phase 6.3: keep current_mode in sync for callers that bypass step().
1045    ed.vim.current_mode = crate::VimMode::Insert;
1046}
1047
1048/// `:set undobreak` semantics for insert-mode motions. When the
1049/// toggle is on, a non-character keystroke that moves the cursor
1050/// (arrow keys, Home/End, mouse click) ends the current undo group
1051/// and starts a new one mid-session. After this, a subsequent `u`
1052/// in normal mode reverts only the post-break run, leaving the
1053/// pre-break edits in place — matching vim's behaviour.
1054///
1055/// Implementation: snapshot the current buffer onto the undo stack
1056/// (the new break point) and reset the active `InsertSession`'s
1057/// `before_lines` so `finish_insert_session`'s diff window only
1058/// captures the post-break run for `last_change` / dot-repeat.
1059///
1060/// During replay we skip the break — replay shouldn't pollute the
1061/// undo stack with intra-replay snapshots.
1062pub(crate) fn break_undo_group_in_insert<H: crate::types::Host>(
1063    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1064) {
1065    if !ed.settings.undo_break_on_motion {
1066        return;
1067    }
1068    if ed.vim.replaying {
1069        return;
1070    }
1071    if ed.vim.insert_session.is_none() {
1072        return;
1073    }
1074    ed.push_undo();
1075    let n = crate::types::Query::line_count(&ed.buffer) as usize;
1076    let mut lines: Vec<String> = Vec::with_capacity(n);
1077    for r in 0..n {
1078        lines.push(crate::types::Query::line(&ed.buffer, r as u32).to_string());
1079    }
1080    let row = crate::types::Cursor::cursor(&ed.buffer).line as usize;
1081    if let Some(ref mut session) = ed.vim.insert_session {
1082        session.before_lines = lines;
1083        session.row_min = row;
1084        session.row_max = row;
1085    }
1086}
1087
1088// ─── Phase 6.1: public insert-mode primitives ──────────────────────────────
1089//
1090// Each `pub(crate)` free function below implements one insert-mode action.
1091// hjkl-vim's insert dispatcher calls them through `Editor::insert_*` methods.
1092// External callers can also invoke the public Editor methods directly.
1093//
1094// Invariants every function upholds:
1095//   - Opens with `ed.sync_buffer_content_from_textarea()` (no-op, kept for
1096//     forward compatibility once textarea is gone).
1097//   - All buffer mutations go through `ed.mutate_edit(...)` so dirty flag,
1098//     undo, change-list, content-edit fan-out all fire uniformly.
1099//   - Navigation-only functions call `break_undo_group_in_insert` when the
1100//     FSM did so, then return `false` (no mutation).
1101//   - After mutations, `ed.push_buffer_cursor_to_textarea()` is called
1102//     (currently a no-op but kept for migration hygiene).
1103//   - Returns `true` when the buffer was mutated, `false` otherwise.
1104
1105/// Insert a single character at the cursor. Handles replace-mode overstrike
1106/// (when `InsertSession::reason` is `Replace`) and smart-indent dedent of
1107/// closing brackets (}/)]/). Returns `true`.
1108pub(crate) fn insert_char_bridge<H: crate::types::Host>(
1109    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1110    ch: char,
1111) -> bool {
1112    use hjkl_buffer::{Edit, MotionKind, Position};
1113    ed.sync_buffer_content_from_textarea();
1114    let cursor = buf_cursor_pos(&ed.buffer);
1115    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1116    let in_replace = matches!(
1117        ed.vim.insert_session.as_ref().map(|s| &s.reason),
1118        Some(InsertReason::Replace)
1119    );
1120    if in_replace && cursor.col < line_chars {
1121        ed.mutate_edit(Edit::DeleteRange {
1122            start: cursor,
1123            end: Position::new(cursor.row, cursor.col + 1),
1124            kind: MotionKind::Char,
1125        });
1126        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1127    } else if !try_dedent_close_bracket(ed, cursor, ch) {
1128        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
1129    }
1130    ed.push_buffer_cursor_to_textarea();
1131    true
1132}
1133
1134/// Insert a newline at the cursor, applying autoindent / smartindent.
1135/// Returns `true`.
1136pub(crate) fn insert_newline_bridge<H: crate::types::Host>(
1137    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1138) -> bool {
1139    use hjkl_buffer::Edit;
1140    ed.sync_buffer_content_from_textarea();
1141    let cursor = buf_cursor_pos(&ed.buffer);
1142    let prev_line = buf_line(&ed.buffer, cursor.row)
1143        .unwrap_or_default()
1144        .to_string();
1145    let indent = compute_enter_indent(&ed.settings, &prev_line);
1146    let text = format!("\n{indent}");
1147    ed.mutate_edit(Edit::InsertStr { at: cursor, text });
1148    ed.push_buffer_cursor_to_textarea();
1149    true
1150}
1151
1152/// Insert a tab character (or spaces up to the next softtabstop boundary when
1153/// `expandtab` is set). Returns `true`.
1154pub(crate) fn insert_tab_bridge<H: crate::types::Host>(
1155    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1156) -> bool {
1157    use hjkl_buffer::Edit;
1158    ed.sync_buffer_content_from_textarea();
1159    let cursor = buf_cursor_pos(&ed.buffer);
1160    if ed.settings.expandtab {
1161        let sts = ed.settings.softtabstop;
1162        let n = if sts > 0 {
1163            sts - (cursor.col % sts)
1164        } else {
1165            ed.settings.tabstop.max(1)
1166        };
1167        ed.mutate_edit(Edit::InsertStr {
1168            at: cursor,
1169            text: " ".repeat(n),
1170        });
1171    } else {
1172        ed.mutate_edit(Edit::InsertChar {
1173            at: cursor,
1174            ch: '\t',
1175        });
1176    }
1177    ed.push_buffer_cursor_to_textarea();
1178    true
1179}
1180
1181/// Delete the character before the cursor (vim Backspace / `^H`). With
1182/// `softtabstop` active, deletes the entire soft-tab run at an aligned
1183/// boundary. Joins with the previous line when at column 0. Returns
1184/// `true` when something was deleted, `false` at the very start of the
1185/// buffer.
1186pub(crate) fn insert_backspace_bridge<H: crate::types::Host>(
1187    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1188) -> bool {
1189    use hjkl_buffer::{Edit, MotionKind, Position};
1190    ed.sync_buffer_content_from_textarea();
1191    let cursor = buf_cursor_pos(&ed.buffer);
1192    let sts = ed.settings.softtabstop;
1193    if sts > 0 && cursor.col >= sts && cursor.col.is_multiple_of(sts) {
1194        let line = buf_line(&ed.buffer, cursor.row).unwrap_or_default();
1195        let chars: Vec<char> = line.chars().collect();
1196        let run_start = cursor.col - sts;
1197        if (run_start..cursor.col).all(|i| chars.get(i).copied() == Some(' ')) {
1198            ed.mutate_edit(Edit::DeleteRange {
1199                start: Position::new(cursor.row, run_start),
1200                end: cursor,
1201                kind: MotionKind::Char,
1202            });
1203            ed.push_buffer_cursor_to_textarea();
1204            return true;
1205        }
1206    }
1207    let result = if cursor.col > 0 {
1208        ed.mutate_edit(Edit::DeleteRange {
1209            start: Position::new(cursor.row, cursor.col - 1),
1210            end: cursor,
1211            kind: MotionKind::Char,
1212        });
1213        true
1214    } else if cursor.row > 0 {
1215        let prev_row = cursor.row - 1;
1216        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1217        ed.mutate_edit(Edit::JoinLines {
1218            row: prev_row,
1219            count: 1,
1220            with_space: false,
1221        });
1222        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1223        true
1224    } else {
1225        false
1226    };
1227    ed.push_buffer_cursor_to_textarea();
1228    result
1229}
1230
1231/// Delete the character under the cursor (vim `Delete`). Joins with the
1232/// next line when at end-of-line. Returns `true` when something was deleted.
1233pub(crate) fn insert_delete_bridge<H: crate::types::Host>(
1234    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1235) -> bool {
1236    use hjkl_buffer::{Edit, MotionKind, Position};
1237    ed.sync_buffer_content_from_textarea();
1238    let cursor = buf_cursor_pos(&ed.buffer);
1239    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1240    let result = if cursor.col < line_chars {
1241        ed.mutate_edit(Edit::DeleteRange {
1242            start: cursor,
1243            end: Position::new(cursor.row, cursor.col + 1),
1244            kind: MotionKind::Char,
1245        });
1246        buf_set_cursor_pos(&mut ed.buffer, cursor);
1247        true
1248    } else if cursor.row + 1 < buf_row_count(&ed.buffer) {
1249        ed.mutate_edit(Edit::JoinLines {
1250            row: cursor.row,
1251            count: 1,
1252            with_space: false,
1253        });
1254        buf_set_cursor_pos(&mut ed.buffer, cursor);
1255        true
1256    } else {
1257        false
1258    };
1259    ed.push_buffer_cursor_to_textarea();
1260    result
1261}
1262
1263/// Direction for insert-mode arrow movement.
1264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1265pub enum InsertDir {
1266    Left,
1267    Right,
1268    Up,
1269    Down,
1270}
1271
1272/// Move the cursor one step in `dir`, breaking the undo group per
1273/// `undo_break_on_motion`. Returns `false` (no mutation).
1274pub(crate) fn insert_arrow_bridge<H: crate::types::Host>(
1275    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1276    dir: InsertDir,
1277) -> bool {
1278    ed.sync_buffer_content_from_textarea();
1279    match dir {
1280        InsertDir::Left => {
1281            crate::motions::move_left(&mut ed.buffer, 1);
1282        }
1283        InsertDir::Right => {
1284            crate::motions::move_right_to_end(&mut ed.buffer, 1);
1285        }
1286        InsertDir::Up => {
1287            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1288            crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1289        }
1290        InsertDir::Down => {
1291            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1292            crate::motions::move_down(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1293        }
1294    }
1295    break_undo_group_in_insert(ed);
1296    ed.push_buffer_cursor_to_textarea();
1297    false
1298}
1299
1300/// Move the cursor to the start of the current line, breaking the undo group.
1301/// Returns `false` (no mutation).
1302pub(crate) fn insert_home_bridge<H: crate::types::Host>(
1303    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1304) -> bool {
1305    ed.sync_buffer_content_from_textarea();
1306    crate::motions::move_line_start(&mut ed.buffer);
1307    break_undo_group_in_insert(ed);
1308    ed.push_buffer_cursor_to_textarea();
1309    false
1310}
1311
1312/// Move the cursor to the end of the current line, breaking the undo group.
1313/// Returns `false` (no mutation).
1314pub(crate) fn insert_end_bridge<H: crate::types::Host>(
1315    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1316) -> bool {
1317    ed.sync_buffer_content_from_textarea();
1318    crate::motions::move_line_end(&mut ed.buffer);
1319    break_undo_group_in_insert(ed);
1320    ed.push_buffer_cursor_to_textarea();
1321    false
1322}
1323
1324/// Scroll up one full viewport height, moving the cursor with it.
1325/// Breaks the undo group. Returns `false` (no mutation).
1326pub(crate) fn insert_pageup_bridge<H: crate::types::Host>(
1327    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1328    viewport_h: u16,
1329) -> bool {
1330    let rows = viewport_h.saturating_sub(2).max(1) as isize;
1331    scroll_cursor_rows(ed, -rows);
1332    false
1333}
1334
1335/// Scroll down one full viewport height, moving the cursor with it.
1336/// Breaks the undo group. Returns `false` (no mutation).
1337pub(crate) fn insert_pagedown_bridge<H: crate::types::Host>(
1338    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1339    viewport_h: u16,
1340) -> bool {
1341    let rows = viewport_h.saturating_sub(2).max(1) as isize;
1342    scroll_cursor_rows(ed, rows);
1343    false
1344}
1345
1346/// Delete from the cursor back to the start of the previous word (`Ctrl-W`).
1347/// At col 0, joins with the previous line (vim semantics). Returns `true`
1348/// when something was deleted.
1349pub(crate) fn insert_ctrl_w_bridge<H: crate::types::Host>(
1350    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1351) -> bool {
1352    use hjkl_buffer::{Edit, MotionKind};
1353    ed.sync_buffer_content_from_textarea();
1354    let cursor = buf_cursor_pos(&ed.buffer);
1355    if cursor.row == 0 && cursor.col == 0 {
1356        return true;
1357    }
1358    crate::motions::move_word_back(&mut ed.buffer, false, 1, &ed.settings.iskeyword);
1359    let word_start = buf_cursor_pos(&ed.buffer);
1360    if word_start == cursor {
1361        return true;
1362    }
1363    buf_set_cursor_pos(&mut ed.buffer, cursor);
1364    ed.mutate_edit(Edit::DeleteRange {
1365        start: word_start,
1366        end: cursor,
1367        kind: MotionKind::Char,
1368    });
1369    ed.push_buffer_cursor_to_textarea();
1370    true
1371}
1372
1373/// Delete from the cursor back to the start of the current line (`Ctrl-U`).
1374/// No-op when already at column 0. Returns `true` when something was deleted.
1375pub(crate) fn insert_ctrl_u_bridge<H: crate::types::Host>(
1376    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1377) -> bool {
1378    use hjkl_buffer::{Edit, MotionKind, Position};
1379    ed.sync_buffer_content_from_textarea();
1380    let cursor = buf_cursor_pos(&ed.buffer);
1381    if cursor.col > 0 {
1382        ed.mutate_edit(Edit::DeleteRange {
1383            start: Position::new(cursor.row, 0),
1384            end: cursor,
1385            kind: MotionKind::Char,
1386        });
1387        ed.push_buffer_cursor_to_textarea();
1388    }
1389    true
1390}
1391
1392/// Delete one character backwards (`Ctrl-H`) — alias for Backspace in insert
1393/// mode. Joins with the previous line when at col 0. Returns `true` when
1394/// something was deleted.
1395pub(crate) fn insert_ctrl_h_bridge<H: crate::types::Host>(
1396    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1397) -> bool {
1398    use hjkl_buffer::{Edit, MotionKind, Position};
1399    ed.sync_buffer_content_from_textarea();
1400    let cursor = buf_cursor_pos(&ed.buffer);
1401    if cursor.col > 0 {
1402        ed.mutate_edit(Edit::DeleteRange {
1403            start: Position::new(cursor.row, cursor.col - 1),
1404            end: cursor,
1405            kind: MotionKind::Char,
1406        });
1407    } else if cursor.row > 0 {
1408        let prev_row = cursor.row - 1;
1409        let prev_chars = buf_line_chars(&ed.buffer, prev_row);
1410        ed.mutate_edit(Edit::JoinLines {
1411            row: prev_row,
1412            count: 1,
1413            with_space: false,
1414        });
1415        buf_set_cursor_rc(&mut ed.buffer, prev_row, prev_chars);
1416    }
1417    ed.push_buffer_cursor_to_textarea();
1418    true
1419}
1420
1421/// Indent the current line by one `shiftwidth` and shift the cursor right by
1422/// the same amount (`Ctrl-T`). Returns `true`.
1423pub(crate) fn insert_ctrl_t_bridge<H: crate::types::Host>(
1424    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1425) -> bool {
1426    let (row, col) = ed.cursor();
1427    let sw = ed.settings().shiftwidth;
1428    indent_rows(ed, row, row, 1);
1429    ed.jump_cursor(row, col + sw);
1430    true
1431}
1432
1433/// Outdent the current line by up to one `shiftwidth` and shift the cursor
1434/// left by the amount stripped (`Ctrl-D`). Returns `true`.
1435pub(crate) fn insert_ctrl_d_bridge<H: crate::types::Host>(
1436    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1437) -> bool {
1438    let (row, col) = ed.cursor();
1439    let before_len = buf_line_bytes(&ed.buffer, row);
1440    outdent_rows(ed, row, row, 1);
1441    let after_len = buf_line_bytes(&ed.buffer, row);
1442    let stripped = before_len.saturating_sub(after_len);
1443    let new_col = col.saturating_sub(stripped);
1444    ed.jump_cursor(row, new_col);
1445    true
1446}
1447
1448/// Enter "one-shot normal" mode (`Ctrl-O`): suspend insert for the next
1449/// complete normal-mode command, then return to insert. Returns `false`
1450/// (no buffer mutation — only mode state changes).
1451pub(crate) fn insert_ctrl_o_bridge<H: crate::types::Host>(
1452    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1453) -> bool {
1454    ed.vim.one_shot_normal = true;
1455    ed.vim.mode = Mode::Normal;
1456    // Phase 6.3: keep current_mode in sync for callers that bypass step().
1457    ed.vim.current_mode = crate::VimMode::Normal;
1458    false
1459}
1460
1461/// Arm the register-paste selector (`Ctrl-R`): the next typed character
1462/// names the register whose text will be inserted inline. Returns `false`
1463/// (no buffer mutation yet — mutation happens when the register char arrives).
1464pub(crate) fn insert_ctrl_r_bridge<H: crate::types::Host>(
1465    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1466) -> bool {
1467    ed.vim.insert_pending_register = true;
1468    false
1469}
1470
1471/// Paste the contents of `reg` at the cursor (the body of `Ctrl-R {reg}`).
1472/// Unknown or empty registers are a no-op. Returns `true` when text was
1473/// inserted.
1474pub(crate) fn insert_paste_register_bridge<H: crate::types::Host>(
1475    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1476    reg: char,
1477) -> bool {
1478    insert_register_text(ed, reg);
1479    // insert_register_text already calls mark_content_dirty internally;
1480    // return true to signal that the session row window should be widened.
1481    true
1482}
1483
1484/// Exit insert mode to Normal: finish the insert session, step the cursor one
1485/// cell left (vim convention), record the `gi` target, and update the sticky
1486/// column. Returns `true` (always consumed — even if no buffer mutation, the
1487/// mode change itself is a meaningful step).
1488pub(crate) fn leave_insert_to_normal_bridge<H: crate::types::Host>(
1489    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1490) -> bool {
1491    finish_insert_session(ed);
1492    ed.vim.mode = Mode::Normal;
1493    // Phase 6.3: keep current_mode in sync for callers that bypass step().
1494    ed.vim.current_mode = crate::VimMode::Normal;
1495    let col = ed.cursor().1;
1496    ed.vim.last_insert_pos = Some(ed.cursor());
1497    if col > 0 {
1498        crate::motions::move_left(&mut ed.buffer, 1);
1499        ed.push_buffer_cursor_to_textarea();
1500    }
1501    ed.sticky_col = Some(ed.cursor().1);
1502    true
1503}
1504
1505// ─── Phase 6.2: normal-mode primitive bridges ──────────────────────────────
1506
1507/// Scroll direction for `scroll_full_page`, `scroll_half_page`, and
1508/// `scroll_line` controller methods.
1509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1510pub enum ScrollDir {
1511    /// Move forward / downward (toward end of buffer).
1512    Down,
1513    /// Move backward / upward (toward start of buffer).
1514    Up,
1515}
1516
1517// ── Insert-mode entry bridges ──────────────────────────────────────────────
1518
1519/// `i` — begin Insert at the cursor. `count` is stored in the session for
1520/// insert-exit replay. Returns `true`.
1521pub(crate) fn enter_insert_i_bridge<H: crate::types::Host>(
1522    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1523    count: usize,
1524) {
1525    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
1526}
1527
1528/// `I` — move to first non-blank then begin Insert. `count` stored for replay.
1529pub(crate) fn enter_insert_shift_i_bridge<H: crate::types::Host>(
1530    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1531    count: usize,
1532) {
1533    move_first_non_whitespace(ed);
1534    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftI));
1535}
1536
1537/// `a` — advance past the cursor char then begin Insert. `count` for replay.
1538pub(crate) fn enter_insert_a_bridge<H: crate::types::Host>(
1539    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1540    count: usize,
1541) {
1542    crate::motions::move_right_to_end(&mut ed.buffer, 1);
1543    ed.push_buffer_cursor_to_textarea();
1544    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::A));
1545}
1546
1547/// `A` — move to end-of-line then begin Insert. `count` for replay.
1548pub(crate) fn enter_insert_shift_a_bridge<H: crate::types::Host>(
1549    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1550    count: usize,
1551) {
1552    crate::motions::move_line_end(&mut ed.buffer);
1553    crate::motions::move_right_to_end(&mut ed.buffer, 1);
1554    ed.push_buffer_cursor_to_textarea();
1555    begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::ShiftA));
1556}
1557
1558/// `o` — open a new line below the cursor and begin Insert.
1559pub(crate) fn open_line_below_bridge<H: crate::types::Host>(
1560    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1561    count: usize,
1562) {
1563    use hjkl_buffer::{Edit, Position};
1564    ed.push_undo();
1565    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: false });
1566    ed.sync_buffer_content_from_textarea();
1567    let row = buf_cursor_pos(&ed.buffer).row;
1568    let line_chars = buf_line_chars(&ed.buffer, row);
1569    let prev_line = buf_line(&ed.buffer, row).unwrap_or_default();
1570    let indent = compute_enter_indent(&ed.settings, prev_line);
1571    ed.mutate_edit(Edit::InsertStr {
1572        at: Position::new(row, line_chars),
1573        text: format!("\n{indent}"),
1574    });
1575    ed.push_buffer_cursor_to_textarea();
1576}
1577
1578/// `O` — open a new line above the cursor and begin Insert.
1579pub(crate) fn open_line_above_bridge<H: crate::types::Host>(
1580    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1581    count: usize,
1582) {
1583    use hjkl_buffer::{Edit, Position};
1584    ed.push_undo();
1585    begin_insert_noundo(ed, count.max(1), InsertReason::Open { above: true });
1586    ed.sync_buffer_content_from_textarea();
1587    let row = buf_cursor_pos(&ed.buffer).row;
1588    let indent = if row > 0 {
1589        let above = buf_line(&ed.buffer, row - 1).unwrap_or_default();
1590        compute_enter_indent(&ed.settings, above)
1591    } else {
1592        let cur = buf_line(&ed.buffer, row).unwrap_or_default();
1593        cur.chars()
1594            .take_while(|c| *c == ' ' || *c == '\t')
1595            .collect::<String>()
1596    };
1597    ed.mutate_edit(Edit::InsertStr {
1598        at: Position::new(row, 0),
1599        text: format!("{indent}\n"),
1600    });
1601    let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
1602    crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
1603    let new_row = buf_cursor_pos(&ed.buffer).row;
1604    buf_set_cursor_rc(&mut ed.buffer, new_row, indent.chars().count());
1605    ed.push_buffer_cursor_to_textarea();
1606}
1607
1608/// `R` — enter Replace mode (overstrike). `count` stored for replay.
1609pub(crate) fn enter_replace_mode_bridge<H: crate::types::Host>(
1610    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1611    count: usize,
1612) {
1613    begin_insert(ed, count.max(1), InsertReason::Replace);
1614}
1615
1616// ── Char / line ops ────────────────────────────────────────────────────────
1617
1618/// `x` — delete `count` chars forward from the cursor, writing to the unnamed
1619/// register. Records `LastChange::CharDel` for dot-repeat.
1620pub(crate) fn delete_char_forward_bridge<H: crate::types::Host>(
1621    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1622    count: usize,
1623) {
1624    do_char_delete(ed, true, count.max(1));
1625    if !ed.vim.replaying {
1626        ed.vim.last_change = Some(LastChange::CharDel {
1627            forward: true,
1628            count: count.max(1),
1629        });
1630    }
1631}
1632
1633/// `X` — delete `count` chars backward from the cursor, writing to the unnamed
1634/// register. Records `LastChange::CharDel` for dot-repeat.
1635pub(crate) fn delete_char_backward_bridge<H: crate::types::Host>(
1636    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1637    count: usize,
1638) {
1639    do_char_delete(ed, false, count.max(1));
1640    if !ed.vim.replaying {
1641        ed.vim.last_change = Some(LastChange::CharDel {
1642            forward: false,
1643            count: count.max(1),
1644        });
1645    }
1646}
1647
1648/// `s` — substitute `count` chars (delete then enter Insert). Equivalent to
1649/// `cl`. Records `LastChange::OpMotion` for dot-repeat.
1650pub(crate) fn substitute_char_bridge<H: crate::types::Host>(
1651    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1652    count: usize,
1653) {
1654    use hjkl_buffer::{Edit, MotionKind, Position};
1655    ed.push_undo();
1656    ed.sync_buffer_content_from_textarea();
1657    for _ in 0..count.max(1) {
1658        let cursor = buf_cursor_pos(&ed.buffer);
1659        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
1660        if cursor.col >= line_chars {
1661            break;
1662        }
1663        ed.mutate_edit(Edit::DeleteRange {
1664            start: cursor,
1665            end: Position::new(cursor.row, cursor.col + 1),
1666            kind: MotionKind::Char,
1667        });
1668    }
1669    ed.push_buffer_cursor_to_textarea();
1670    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
1671    if !ed.vim.replaying {
1672        ed.vim.last_change = Some(LastChange::OpMotion {
1673            op: Operator::Change,
1674            motion: Motion::Right,
1675            count: count.max(1),
1676            inserted: None,
1677        });
1678    }
1679}
1680
1681/// `S` — substitute the whole line (delete line contents then enter Insert).
1682/// Equivalent to `cc`. Records `LastChange::LineOp` for dot-repeat.
1683pub(crate) fn substitute_line_bridge<H: crate::types::Host>(
1684    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1685    count: usize,
1686) {
1687    execute_line_op(ed, Operator::Change, count.max(1));
1688    if !ed.vim.replaying {
1689        ed.vim.last_change = Some(LastChange::LineOp {
1690            op: Operator::Change,
1691            count: count.max(1),
1692            inserted: None,
1693        });
1694    }
1695}
1696
1697/// `D` — delete from the cursor to end-of-line, writing to the unnamed
1698/// register. Cursor parks on the new last char. Records for dot-repeat.
1699pub(crate) fn delete_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1700    ed.push_undo();
1701    delete_to_eol(ed);
1702    crate::motions::move_left(&mut ed.buffer, 1);
1703    ed.push_buffer_cursor_to_textarea();
1704    if !ed.vim.replaying {
1705        ed.vim.last_change = Some(LastChange::DeleteToEol { inserted: None });
1706    }
1707}
1708
1709/// `C` — change from the cursor to end-of-line (delete then enter Insert).
1710/// Equivalent to `c$`. Shares the delete path with `D`.
1711pub(crate) fn change_to_eol_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1712    ed.push_undo();
1713    delete_to_eol(ed);
1714    begin_insert_noundo(ed, 1, InsertReason::DeleteToEol);
1715}
1716
1717/// `Y` — yank from the cursor to end-of-line (same as `y$` in Vim 8 default).
1718pub(crate) fn yank_to_eol_bridge<H: crate::types::Host>(
1719    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1720    count: usize,
1721) {
1722    apply_op_with_motion(ed, Operator::Yank, &Motion::LineEnd, count.max(1));
1723}
1724
1725/// `J` — join `count` lines (default 2) onto the current one, inserting a
1726/// single space between each pair (vim semantics). Records for dot-repeat.
1727pub(crate) fn join_line_bridge<H: crate::types::Host>(
1728    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1729    count: usize,
1730) {
1731    for _ in 0..count.max(1) {
1732        ed.push_undo();
1733        join_line(ed);
1734    }
1735    if !ed.vim.replaying {
1736        ed.vim.last_change = Some(LastChange::JoinLine {
1737            count: count.max(1),
1738        });
1739    }
1740}
1741
1742/// `~` — toggle the case of `count` chars from the cursor, advancing right.
1743/// Records `LastChange::ToggleCase` for dot-repeat.
1744pub(crate) fn toggle_case_at_cursor_bridge<H: crate::types::Host>(
1745    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1746    count: usize,
1747) {
1748    for _ in 0..count.max(1) {
1749        ed.push_undo();
1750        toggle_case_at_cursor(ed);
1751    }
1752    if !ed.vim.replaying {
1753        ed.vim.last_change = Some(LastChange::ToggleCase {
1754            count: count.max(1),
1755        });
1756    }
1757}
1758
1759/// `p` — paste the unnamed register (or `"reg` register) after the cursor.
1760/// Linewise yanks open a new line below; charwise pastes inline.
1761/// Records `LastChange::Paste` for dot-repeat.
1762pub(crate) fn paste_after_bridge<H: crate::types::Host>(
1763    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1764    count: usize,
1765) {
1766    do_paste(ed, false, count.max(1));
1767    if !ed.vim.replaying {
1768        ed.vim.last_change = Some(LastChange::Paste {
1769            before: false,
1770            count: count.max(1),
1771        });
1772    }
1773}
1774
1775/// `P` — paste the unnamed register (or `"reg` register) before the cursor.
1776/// Linewise yanks open a new line above; charwise pastes inline.
1777/// Records `LastChange::Paste` for dot-repeat.
1778pub(crate) fn paste_before_bridge<H: crate::types::Host>(
1779    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1780    count: usize,
1781) {
1782    do_paste(ed, true, count.max(1));
1783    if !ed.vim.replaying {
1784        ed.vim.last_change = Some(LastChange::Paste {
1785            before: true,
1786            count: count.max(1),
1787        });
1788    }
1789}
1790
1791// ── Jump bridges ───────────────────────────────────────────────────────────
1792
1793/// `<C-o>` — jump back `count` entries in the jumplist, saving the current
1794/// position on the forward stack so `<C-i>` can return.
1795pub(crate) fn jump_back_bridge<H: crate::types::Host>(
1796    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1797    count: usize,
1798) {
1799    for _ in 0..count.max(1) {
1800        jump_back(ed);
1801    }
1802}
1803
1804/// `<C-i>` / `Tab` — redo `count` jumps on the forward stack, saving the
1805/// current position on the backward stack.
1806pub(crate) fn jump_forward_bridge<H: crate::types::Host>(
1807    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1808    count: usize,
1809) {
1810    for _ in 0..count.max(1) {
1811        jump_forward(ed);
1812    }
1813}
1814
1815// ── Scroll bridges ─────────────────────────────────────────────────────────
1816
1817/// `<C-f>` / `<C-b>` — scroll the cursor by one full viewport height
1818/// (`h - 2` rows to preserve two-line overlap). `count` multiplies.
1819pub(crate) fn scroll_full_page_bridge<H: crate::types::Host>(
1820    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1821    dir: ScrollDir,
1822    count: usize,
1823) {
1824    let rows = viewport_full_rows(ed, count) as isize;
1825    match dir {
1826        ScrollDir::Down => scroll_cursor_rows(ed, rows),
1827        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
1828    }
1829}
1830
1831/// `<C-d>` / `<C-u>` — scroll the cursor by half the viewport height.
1832/// `count` multiplies.
1833pub(crate) fn scroll_half_page_bridge<H: crate::types::Host>(
1834    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1835    dir: ScrollDir,
1836    count: usize,
1837) {
1838    let rows = viewport_half_rows(ed, count) as isize;
1839    match dir {
1840        ScrollDir::Down => scroll_cursor_rows(ed, rows),
1841        ScrollDir::Up => scroll_cursor_rows(ed, -rows),
1842    }
1843}
1844
1845/// `<C-e>` / `<C-y>` — scroll the viewport `count` lines without moving the
1846/// cursor (cursor is clamped to the new visible region if it would go
1847/// off-screen). `<C-e>` scrolls down; `<C-y>` scrolls up.
1848pub(crate) fn scroll_line_bridge<H: crate::types::Host>(
1849    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1850    dir: ScrollDir,
1851    count: usize,
1852) {
1853    let n = count.max(1);
1854    let total = buf_row_count(&ed.buffer);
1855    let last = total.saturating_sub(1);
1856    let h = ed.viewport_height_value() as usize;
1857    let vp = ed.host().viewport();
1858    let cur_top = vp.top_row;
1859    let new_top = match dir {
1860        ScrollDir::Down => (cur_top + n).min(last),
1861        ScrollDir::Up => cur_top.saturating_sub(n),
1862    };
1863    ed.set_viewport_top(new_top);
1864    // Clamp cursor to stay within the new visible region.
1865    let (row, col) = ed.cursor();
1866    let bot = (new_top + h).saturating_sub(1).min(last);
1867    let clamped = row.max(new_top).min(bot);
1868    if clamped != row {
1869        buf_set_cursor_rc(&mut ed.buffer, clamped, col);
1870        ed.push_buffer_cursor_to_textarea();
1871    }
1872}
1873
1874// ── Search bridges ─────────────────────────────────────────────────────────
1875
1876/// `n` / `N` — repeat the last search `count` times. `forward = true` means
1877/// repeat in the original search direction; `false` inverts it (like `N`).
1878pub(crate) fn search_repeat_bridge<H: crate::types::Host>(
1879    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1880    forward: bool,
1881    count: usize,
1882) {
1883    if let Some(pattern) = ed.vim.last_search.clone() {
1884        ed.push_search_pattern(&pattern);
1885    }
1886    if ed.search_state().pattern.is_none() {
1887        return;
1888    }
1889    let go_forward = ed.vim.last_search_forward == forward;
1890    for _ in 0..count.max(1) {
1891        if go_forward {
1892            ed.search_advance_forward(true);
1893        } else {
1894            ed.search_advance_backward(true);
1895        }
1896    }
1897    ed.push_buffer_cursor_to_textarea();
1898}
1899
1900/// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
1901/// `forward` picks search direction; `whole_word` wraps in `\b...\b`.
1902/// `count` repeats the advance.
1903pub(crate) fn word_search_bridge<H: crate::types::Host>(
1904    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1905    forward: bool,
1906    whole_word: bool,
1907    count: usize,
1908) {
1909    word_at_cursor_search(ed, forward, whole_word, count.max(1));
1910}
1911
1912// ── Undo / redo confirmation wrappers (already public on Editor) ───────────
1913
1914/// `u` bridge — identical to `do_undo`; retained for Phase 6.6b audit.
1915/// The FSM now calls `ed.undo()` directly (Phase 6.6a).
1916#[allow(dead_code)]
1917#[inline]
1918pub(crate) fn do_undo_bridge<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
1919    do_undo(ed);
1920}
1921
1922// ─── Phase 6.3: visual-mode primitive bridges ──────────────────────────────
1923//
1924// Each `pub(crate)` free function is the extractable body of one visual-mode
1925// transition. These bridges set `vim.mode` directly AND write `current_mode`
1926// so that `Editor::vim_mode()` can read from the stable field without going
1927// through `public_mode()`.
1928//
1929// Pattern identical to Phase 6.1 / 6.2:
1930//   - Bridge fn is `pub(crate) fn *_bridge<H: Host>(ed, …)` in this file.
1931//   - Public wrapper is `pub fn *(&mut self, …)` in `editor.rs` with rustdoc.
1932
1933/// Helper — set both the FSM-internal `mode` and the stable `current_mode`
1934/// field in one call. Every Phase 6.3 bridge that changes mode calls this so
1935/// `vim_mode()` stays correct without going through the FSM's `step()` loop.
1936#[inline]
1937pub(crate) fn set_vim_mode_bridge<H: crate::types::Host>(
1938    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1939    mode: Mode,
1940) {
1941    ed.vim.mode = mode;
1942    ed.vim.current_mode = ed.vim.public_mode();
1943}
1944
1945/// `v` from Normal — enter charwise Visual mode. Anchors at the current
1946/// cursor position; the cursor IS the live end of the selection.
1947pub(crate) fn enter_visual_char_bridge<H: crate::types::Host>(
1948    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1949) {
1950    let cur = ed.cursor();
1951    ed.vim.visual_anchor = cur;
1952    set_vim_mode_bridge(ed, Mode::Visual);
1953}
1954
1955/// `V` from Normal — enter linewise Visual mode. Anchors the whole line
1956/// containing the current cursor; `o` still swaps the anchor row.
1957pub(crate) fn enter_visual_line_bridge<H: crate::types::Host>(
1958    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1959) {
1960    let (row, _) = ed.cursor();
1961    ed.vim.visual_line_anchor = row;
1962    set_vim_mode_bridge(ed, Mode::VisualLine);
1963}
1964
1965/// `<C-v>` from Normal — enter Visual-block mode. Anchors at the current
1966/// cursor; `block_vcol` is seeded from the cursor column so h/l navigation
1967/// preserves the desired virtual column.
1968pub(crate) fn enter_visual_block_bridge<H: crate::types::Host>(
1969    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1970) {
1971    let cur = ed.cursor();
1972    ed.vim.block_anchor = cur;
1973    ed.vim.block_vcol = cur.1;
1974    set_vim_mode_bridge(ed, Mode::VisualBlock);
1975}
1976
1977/// Esc from any visual mode — set `<` / `>` marks (per `:h v_:`), stash the
1978/// selection for `gv` re-entry, and return to Normal. Replicates the
1979/// `pre_visual_snapshot` logic in `step()` so callers outside the FSM get
1980/// identical behaviour.
1981pub(crate) fn exit_visual_to_normal_bridge<H: crate::types::Host>(
1982    ed: &mut Editor<hjkl_buffer::Buffer, H>,
1983) {
1984    // Build the same snapshot that `step()` captures at pre-step time.
1985    let snap: Option<LastVisual> = match ed.vim.mode {
1986        Mode::Visual => Some(LastVisual {
1987            mode: Mode::Visual,
1988            anchor: ed.vim.visual_anchor,
1989            cursor: ed.cursor(),
1990            block_vcol: 0,
1991        }),
1992        Mode::VisualLine => Some(LastVisual {
1993            mode: Mode::VisualLine,
1994            anchor: (ed.vim.visual_line_anchor, 0),
1995            cursor: ed.cursor(),
1996            block_vcol: 0,
1997        }),
1998        Mode::VisualBlock => Some(LastVisual {
1999            mode: Mode::VisualBlock,
2000            anchor: ed.vim.block_anchor,
2001            cursor: ed.cursor(),
2002            block_vcol: ed.vim.block_vcol,
2003        }),
2004        _ => None,
2005    };
2006    // Transition to Normal first (matches FSM order).
2007    ed.vim.pending = Pending::None;
2008    ed.vim.count = 0;
2009    ed.vim.insert_session = None;
2010    set_vim_mode_bridge(ed, Mode::Normal);
2011    // Set `<` / `>` marks and stash `last_visual` — mirrors the post-step
2012    // logic in `step()` that fires when a visual → non-visual transition
2013    // is detected.
2014    if let Some(snap) = snap {
2015        let (lo, hi) = match snap.mode {
2016            Mode::Visual => {
2017                if snap.anchor <= snap.cursor {
2018                    (snap.anchor, snap.cursor)
2019                } else {
2020                    (snap.cursor, snap.anchor)
2021                }
2022            }
2023            Mode::VisualLine => {
2024                let r_lo = snap.anchor.0.min(snap.cursor.0);
2025                let r_hi = snap.anchor.0.max(snap.cursor.0);
2026                let last_col = ed
2027                    .buffer()
2028                    .lines()
2029                    .get(r_hi)
2030                    .map(|l| l.chars().count().saturating_sub(1))
2031                    .unwrap_or(0);
2032                ((r_lo, 0), (r_hi, last_col))
2033            }
2034            Mode::VisualBlock => {
2035                let (r1, c1) = snap.anchor;
2036                let (r2, c2) = snap.cursor;
2037                ((r1.min(r2), c1.min(c2)), (r1.max(r2), c1.max(c2)))
2038            }
2039            _ => {
2040                if snap.anchor <= snap.cursor {
2041                    (snap.anchor, snap.cursor)
2042                } else {
2043                    (snap.cursor, snap.anchor)
2044                }
2045            }
2046        };
2047        ed.set_mark('<', lo);
2048        ed.set_mark('>', hi);
2049        ed.vim.last_visual = Some(snap);
2050    }
2051}
2052
2053/// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor
2054/// without mutating the selection range. In charwise mode the cursor jumps
2055/// to the old anchor and the anchor takes the old cursor. In linewise mode
2056/// the anchor *row* swaps with the current cursor row. In block mode the
2057/// block corners swap.
2058pub(crate) fn visual_o_toggle_bridge<H: crate::types::Host>(
2059    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2060) {
2061    match ed.vim.mode {
2062        Mode::Visual => {
2063            let cur = ed.cursor();
2064            let anchor = ed.vim.visual_anchor;
2065            ed.vim.visual_anchor = cur;
2066            ed.jump_cursor(anchor.0, anchor.1);
2067        }
2068        Mode::VisualLine => {
2069            let cur_row = ed.cursor().0;
2070            let anchor_row = ed.vim.visual_line_anchor;
2071            ed.vim.visual_line_anchor = cur_row;
2072            ed.jump_cursor(anchor_row, 0);
2073        }
2074        Mode::VisualBlock => {
2075            let cur = ed.cursor();
2076            let anchor = ed.vim.block_anchor;
2077            ed.vim.block_anchor = cur;
2078            ed.vim.block_vcol = anchor.1;
2079            ed.jump_cursor(anchor.0, anchor.1);
2080        }
2081        _ => {}
2082    }
2083}
2084
2085/// `gv` — restore the last visual selection (mode + anchor + cursor).
2086/// No-op if no selection was ever stored. Mirrors the `gv` arm in
2087/// `handle_normal_g`.
2088pub(crate) fn reenter_last_visual_bridge<H: crate::types::Host>(
2089    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2090) {
2091    if let Some(snap) = ed.vim.last_visual {
2092        match snap.mode {
2093            Mode::Visual => {
2094                ed.vim.visual_anchor = snap.anchor;
2095                set_vim_mode_bridge(ed, Mode::Visual);
2096            }
2097            Mode::VisualLine => {
2098                ed.vim.visual_line_anchor = snap.anchor.0;
2099                set_vim_mode_bridge(ed, Mode::VisualLine);
2100            }
2101            Mode::VisualBlock => {
2102                ed.vim.block_anchor = snap.anchor;
2103                ed.vim.block_vcol = snap.block_vcol;
2104                set_vim_mode_bridge(ed, Mode::VisualBlock);
2105            }
2106            _ => {}
2107        }
2108        ed.jump_cursor(snap.cursor.0, snap.cursor.1);
2109    }
2110}
2111
2112/// Direct mode-transition entry point for external controllers (e.g.
2113/// hjkl-vim). Sets both the FSM-internal `mode` and the stable
2114/// `current_mode`. Use sparingly — prefer the semantic primitives
2115/// (`enter_visual_char_bridge`, `enter_insert_i_bridge`, …) which also
2116/// set up the required bookkeeping (anchors, sessions, …).
2117pub(crate) fn set_mode_bridge<H: crate::types::Host>(
2118    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2119    mode: crate::VimMode,
2120) {
2121    let internal = match mode {
2122        crate::VimMode::Normal => Mode::Normal,
2123        crate::VimMode::Insert => Mode::Insert,
2124        crate::VimMode::Visual => Mode::Visual,
2125        crate::VimMode::VisualLine => Mode::VisualLine,
2126        crate::VimMode::VisualBlock => Mode::VisualBlock,
2127    };
2128    ed.vim.mode = internal;
2129    ed.vim.current_mode = mode;
2130}
2131
2132// ─── Normal / Visual / Operator-pending dispatcher removed in Phase 6.6g.3 ──
2133//
2134// `step_normal` and all private dispatch helpers (handle_after_op,
2135// handle_after_g, handle_after_z, handle_normal_only, etc.) were deleted.
2136// The canonical FSM body lives in `hjkl-vim::normal`. Use
2137// `hjkl_vim::dispatch_input` as the entry point.
2138//
2139// DELETED FUNCTION SIGNATURE (for archaeology):
2140// pub(crate) fn step_normal<H: crate::types::Host>(ed: ..., input: Input) -> bool {
2141
2142/// `m{ch}` — public controller entry point. Validates `ch` (must be
2143/// alphanumeric to match vim's mark-name rules) and records the current
2144/// cursor position under that name. Promoted to the public surface in 0.6.7
2145/// so the hjkl-vim `PendingState::SetMark` reducer can dispatch
2146/// `EngineCmd::SetMark` without re-entering the engine FSM.
2147/// `handle_set_mark` delegates here to avoid logic duplication.
2148pub(crate) fn set_mark_at_cursor<H: crate::types::Host>(
2149    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2150    ch: char,
2151) {
2152    if ch.is_ascii_lowercase() || ch.is_ascii_uppercase() {
2153        // 0.0.36: lowercase + uppercase marks share the unified
2154        // `Editor::marks` map. Uppercase entries survive
2155        // `set_content` so they persist across tab swaps within the
2156        // same Editor (the map lives on the Editor, not the buffer).
2157        let pos = ed.cursor();
2158        ed.set_mark(ch, pos);
2159    }
2160    // Invalid chars silently no-op (mirrors handle_set_mark behaviour).
2161}
2162
2163/// `'<ch>` / `` `<ch> `` — public controller entry point. Validates `ch`
2164/// against the set of legal mark names (lowercase, uppercase, special:
2165/// `'`/`` ` ``/`.`/`[`/`]`/`<`/`>`), resolves the target position, and
2166/// jumps the cursor. `linewise = true` → row only, col snaps to first
2167/// non-blank; `linewise = false` → exact (row, col). Called by
2168/// `Editor::goto_mark_line` / `Editor::goto_mark_char` so that hjkl-vim's
2169/// `PendingState::GotoMarkLine` / `GotoMarkChar` reducers can dispatch
2170/// without re-entering the engine FSM.
2171pub(crate) fn goto_mark<H: crate::types::Host>(
2172    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2173    ch: char,
2174    linewise: bool,
2175) {
2176    let target = match ch {
2177        'a'..='z' | 'A'..='Z' => ed.mark(ch),
2178        '\'' | '`' => ed.vim.jump_back.last().copied(),
2179        '.' => ed.vim.last_edit_pos,
2180        '[' | ']' | '<' | '>' => ed.mark(ch),
2181        _ => None,
2182    };
2183    let Some((row, col)) = target else {
2184        return;
2185    };
2186    let pre = ed.cursor();
2187    let (r, c_clamped) = clamp_pos(ed, (row, col));
2188    if linewise {
2189        buf_set_cursor_rc(&mut ed.buffer, r, 0);
2190        ed.push_buffer_cursor_to_textarea();
2191        move_first_non_whitespace(ed);
2192    } else {
2193        buf_set_cursor_rc(&mut ed.buffer, r, c_clamped);
2194        ed.push_buffer_cursor_to_textarea();
2195    }
2196    if ed.cursor() != pre {
2197        ed.push_jump(pre);
2198    }
2199    ed.sticky_col = Some(ed.cursor().1);
2200}
2201
2202/// `true` when `op` records a `last_change` entry for dot-repeat purposes.
2203/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can use it without
2204/// duplicating the logic.
2205pub fn op_is_change(op: Operator) -> bool {
2206    matches!(op, Operator::Delete | Operator::Change)
2207}
2208
2209// ─── Jumplist (Ctrl-o / Ctrl-i) ────────────────────────────────────────────
2210
2211/// Max jumplist depth. Matches vim default.
2212pub(crate) const JUMPLIST_MAX: usize = 100;
2213
2214/// `Ctrl-o` — jump back to the most recent pre-jump position. Saves
2215/// the current cursor onto the forward stack so `Ctrl-i` can return.
2216fn jump_back<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2217    let Some(target) = ed.vim.jump_back.pop() else {
2218        return;
2219    };
2220    let cur = ed.cursor();
2221    ed.vim.jump_fwd.push(cur);
2222    let (r, c) = clamp_pos(ed, target);
2223    ed.jump_cursor(r, c);
2224    ed.sticky_col = Some(c);
2225}
2226
2227/// `Ctrl-i` / `Tab` — redo the last `Ctrl-o`. Saves the current cursor
2228/// onto the back stack.
2229fn jump_forward<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2230    let Some(target) = ed.vim.jump_fwd.pop() else {
2231        return;
2232    };
2233    let cur = ed.cursor();
2234    ed.vim.jump_back.push(cur);
2235    if ed.vim.jump_back.len() > JUMPLIST_MAX {
2236        ed.vim.jump_back.remove(0);
2237    }
2238    let (r, c) = clamp_pos(ed, target);
2239    ed.jump_cursor(r, c);
2240    ed.sticky_col = Some(c);
2241}
2242
2243/// Clamp a stored `(row, col)` to the live buffer in case edits
2244/// shrunk the document between push and pop.
2245fn clamp_pos<H: crate::types::Host>(
2246    ed: &Editor<hjkl_buffer::Buffer, H>,
2247    pos: (usize, usize),
2248) -> (usize, usize) {
2249    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2250    let r = pos.0.min(last_row);
2251    let line_len = buf_line_chars(&ed.buffer, r);
2252    let c = pos.1.min(line_len.saturating_sub(1));
2253    (r, c)
2254}
2255
2256/// True for motions that vim treats as jumps (pushed onto the jumplist).
2257fn is_big_jump(motion: &Motion) -> bool {
2258    matches!(
2259        motion,
2260        Motion::FileTop
2261            | Motion::FileBottom
2262            | Motion::MatchBracket
2263            | Motion::WordAtCursor { .. }
2264            | Motion::SearchNext { .. }
2265            | Motion::ViewportTop
2266            | Motion::ViewportMiddle
2267            | Motion::ViewportBottom
2268    )
2269}
2270
2271// ─── Scroll helpers (Ctrl-d / Ctrl-u / Ctrl-f / Ctrl-b) ────────────────────
2272
2273/// Half-viewport row count, with a floor of 1 so tiny / un-rendered
2274/// viewports still step by a single row. `count` multiplies.
2275fn viewport_half_rows<H: crate::types::Host>(
2276    ed: &Editor<hjkl_buffer::Buffer, H>,
2277    count: usize,
2278) -> usize {
2279    let h = ed.viewport_height_value() as usize;
2280    (h / 2).max(1).saturating_mul(count.max(1))
2281}
2282
2283/// Full-viewport row count. Vim conventionally keeps 2 lines of overlap
2284/// between successive `Ctrl-f` pages; we approximate with `h - 2`.
2285fn viewport_full_rows<H: crate::types::Host>(
2286    ed: &Editor<hjkl_buffer::Buffer, H>,
2287    count: usize,
2288) -> usize {
2289    let h = ed.viewport_height_value() as usize;
2290    h.saturating_sub(2).max(1).saturating_mul(count.max(1))
2291}
2292
2293/// Move the cursor by `delta` rows (positive = down, negative = up),
2294/// clamp to the document, then land at the first non-blank on the new
2295/// row. The textarea viewport auto-scrolls to keep the cursor visible
2296/// when the cursor pushes off-screen.
2297fn scroll_cursor_rows<H: crate::types::Host>(
2298    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2299    delta: isize,
2300) {
2301    if delta == 0 {
2302        return;
2303    }
2304    ed.sync_buffer_content_from_textarea();
2305    let (row, _) = ed.cursor();
2306    let last_row = buf_row_count(&ed.buffer).saturating_sub(1);
2307    let target = (row as isize + delta).max(0).min(last_row as isize) as usize;
2308    buf_set_cursor_rc(&mut ed.buffer, target, 0);
2309    crate::motions::move_first_non_blank(&mut ed.buffer);
2310    ed.push_buffer_cursor_to_textarea();
2311    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
2312}
2313
2314// ─── Motion parsing ────────────────────────────────────────────────────────
2315
2316/// Parse the first key of a normal/visual-mode motion. Returns `None` for
2317/// keys that don't start a motion (operator keys, command keys, etc.).
2318/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
2319pub fn parse_motion(input: &Input) -> Option<Motion> {
2320    if input.ctrl {
2321        return None;
2322    }
2323    match input.key {
2324        Key::Char('h') | Key::Backspace | Key::Left => Some(Motion::Left),
2325        Key::Char('l') | Key::Right => Some(Motion::Right),
2326        Key::Char('j') | Key::Down | Key::Enter => Some(Motion::Down),
2327        Key::Char('k') | Key::Up => Some(Motion::Up),
2328        Key::Char('w') => Some(Motion::WordFwd),
2329        Key::Char('W') => Some(Motion::BigWordFwd),
2330        Key::Char('b') => Some(Motion::WordBack),
2331        Key::Char('B') => Some(Motion::BigWordBack),
2332        Key::Char('e') => Some(Motion::WordEnd),
2333        Key::Char('E') => Some(Motion::BigWordEnd),
2334        Key::Char('0') | Key::Home => Some(Motion::LineStart),
2335        Key::Char('^') => Some(Motion::FirstNonBlank),
2336        Key::Char('$') | Key::End => Some(Motion::LineEnd),
2337        Key::Char('G') => Some(Motion::FileBottom),
2338        Key::Char('%') => Some(Motion::MatchBracket),
2339        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
2340        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
2341        Key::Char('*') => Some(Motion::WordAtCursor {
2342            forward: true,
2343            whole_word: true,
2344        }),
2345        Key::Char('#') => Some(Motion::WordAtCursor {
2346            forward: false,
2347            whole_word: true,
2348        }),
2349        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
2350        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
2351        Key::Char('H') => Some(Motion::ViewportTop),
2352        Key::Char('M') => Some(Motion::ViewportMiddle),
2353        Key::Char('L') => Some(Motion::ViewportBottom),
2354        Key::Char('{') => Some(Motion::ParagraphPrev),
2355        Key::Char('}') => Some(Motion::ParagraphNext),
2356        Key::Char('(') => Some(Motion::SentencePrev),
2357        Key::Char(')') => Some(Motion::SentenceNext),
2358        _ => None,
2359    }
2360}
2361
2362// ─── Motion execution ──────────────────────────────────────────────────────
2363
2364pub(crate) fn execute_motion<H: crate::types::Host>(
2365    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2366    motion: Motion,
2367    count: usize,
2368) {
2369    let count = count.max(1);
2370    // FindRepeat needs the stored direction.
2371    let motion = match motion {
2372        Motion::FindRepeat { reverse } => match ed.vim.last_find {
2373            Some((ch, forward, till)) => Motion::Find {
2374                ch,
2375                forward: if reverse { !forward } else { forward },
2376                till,
2377            },
2378            None => return,
2379        },
2380        other => other,
2381    };
2382    let pre_pos = ed.cursor();
2383    let pre_col = pre_pos.1;
2384    apply_motion_cursor(ed, &motion, count);
2385    let post_pos = ed.cursor();
2386    if is_big_jump(&motion) && pre_pos != post_pos {
2387        ed.push_jump(pre_pos);
2388    }
2389    apply_sticky_col(ed, &motion, pre_col);
2390    // Phase 7b: keep the migration buffer's cursor + viewport in
2391    // lockstep with the textarea after every motion. Once 7c lands
2392    // (motions ported onto the buffer's API), this flips: the
2393    // buffer becomes authoritative and the textarea mirrors it.
2394    ed.sync_buffer_from_textarea();
2395}
2396
2397// ─── Keymap-layer motion controller ────────────────────────────────────────
2398
2399/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
2400/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
2401/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
2402/// extend the highlighted region correctly.
2403///
2404/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
2405/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
2406/// safe — the function's own match arm handles the no-op case.
2407fn execute_motion_with_block_vcol<H: crate::types::Host>(
2408    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2409    motion: Motion,
2410    count: usize,
2411) {
2412    let motion_copy = motion.clone();
2413    execute_motion(ed, motion, count);
2414    if ed.vim.mode == Mode::VisualBlock {
2415        update_block_vcol(ed, &motion_copy);
2416    }
2417}
2418
2419/// Execute a `crate::MotionKind` cursor motion. Called by the host's
2420/// `Editor::apply_motion` controller method — the keymap dispatch path for
2421/// Phase 3a of kryptic-sh/hjkl#69.
2422///
2423/// Maps each variant to the same internal primitives used by the engine FSM
2424/// so cursor, sticky column, scroll, and sync semantics are identical.
2425///
2426/// # Visual-mode post-motion sync audit (2026-05-13)
2427///
2428/// After `execute_motion`, two things are conditional on visual mode:
2429///
2430/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
2431///    called when `mode == Mode::VisualBlock`.  This is replicated here via
2432///    `execute_motion_with_block_vcol` for every motion variant below.
2433///
2434/// 2. **`last_find` update** — `Motion::Find` is dispatched through
2435///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
2436///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
2437///    path writes `last_find` in `apply_find_char` (called from
2438///    `Editor::find_char`), so no gap exists here.
2439///
2440/// No VisualLine-specific or Visual-specific post-motion work exists in the
2441/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
2442/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
2443/// mark update in `step()` fires only on visual→normal transition, not after
2444/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
2445/// fix already applied above.
2446pub(crate) fn apply_motion_kind<H: crate::types::Host>(
2447    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2448    kind: crate::MotionKind,
2449    count: usize,
2450) {
2451    let count = count.max(1);
2452    match kind {
2453        crate::MotionKind::CharLeft => {
2454            execute_motion_with_block_vcol(ed, Motion::Left, count);
2455        }
2456        crate::MotionKind::CharRight => {
2457            execute_motion_with_block_vcol(ed, Motion::Right, count);
2458        }
2459        crate::MotionKind::LineDown => {
2460            execute_motion_with_block_vcol(ed, Motion::Down, count);
2461        }
2462        crate::MotionKind::LineUp => {
2463            execute_motion_with_block_vcol(ed, Motion::Up, count);
2464        }
2465        crate::MotionKind::FirstNonBlankDown => {
2466            // `+`: move down `count` lines then land on first non-blank.
2467            // Not a big-jump (no jump-list entry), sticky col set to the
2468            // landed column (first non-blank). Mirrors scroll_cursor_rows
2469            // semantics but goes through the fold-aware buffer motion path.
2470            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2471            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2472            crate::motions::move_first_non_blank(&mut ed.buffer);
2473            ed.push_buffer_cursor_to_textarea();
2474            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
2475            ed.sync_buffer_from_textarea();
2476        }
2477        crate::MotionKind::FirstNonBlankUp => {
2478            // `-`: move up `count` lines then land on first non-blank.
2479            // Same pattern as FirstNonBlankDown, direction reversed.
2480            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2481            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2482            crate::motions::move_first_non_blank(&mut ed.buffer);
2483            ed.push_buffer_cursor_to_textarea();
2484            ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
2485            ed.sync_buffer_from_textarea();
2486        }
2487        crate::MotionKind::WordForward => {
2488            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
2489        }
2490        crate::MotionKind::BigWordForward => {
2491            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
2492        }
2493        crate::MotionKind::WordBackward => {
2494            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
2495        }
2496        crate::MotionKind::BigWordBackward => {
2497            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
2498        }
2499        crate::MotionKind::WordEnd => {
2500            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
2501        }
2502        crate::MotionKind::BigWordEnd => {
2503            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
2504        }
2505        crate::MotionKind::LineStart => {
2506            // `0` / `<Home>`: first column of the current line.
2507            // count is ignored — matches vim `0` semantics.
2508            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
2509        }
2510        crate::MotionKind::FirstNonBlank => {
2511            // `^`: first non-blank column on the current line.
2512            // count is ignored — matches vim `^` semantics.
2513            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
2514        }
2515        crate::MotionKind::GotoLine => {
2516            // `G`: bare `G` → last line; `count G` → jump to line `count`.
2517            // apply_motion_kind normalises the raw count to count.max(1)
2518            // above, so count == 1 means "bare G" (last line) and count > 1
2519            // means "go to line N". execute_motion's FileBottom arm applies
2520            // the same `count > 1` check before calling move_bottom, so the
2521            // convention aligns: pass count straight through.
2522            // FileBottom is vertical — update_block_vcol is a no-op here
2523            // (preserves vcol), so the helper is safe to use.
2524            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
2525        }
2526        crate::MotionKind::LineEnd => {
2527            // `$` / `<End>`: last character on the current line.
2528            // count is ignored at the keymap-path level (vim `N$` moves
2529            // down N-1 lines then lands at line-end; not yet wired).
2530            execute_motion_with_block_vcol(ed, Motion::LineEnd, 1);
2531        }
2532        crate::MotionKind::FindRepeat => {
2533            // `;` — repeat last f/F/t/T in the same direction.
2534            // execute_motion resolves FindRepeat via ed.vim.last_find;
2535            // no-op if no prior find exists (None arm returns early).
2536            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
2537        }
2538        crate::MotionKind::FindRepeatReverse => {
2539            // `,` — repeat last f/F/t/T in the reverse direction.
2540            // execute_motion resolves FindRepeat via ed.vim.last_find;
2541            // no-op if no prior find exists (None arm returns early).
2542            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
2543        }
2544        crate::MotionKind::BracketMatch => {
2545            // `%` — jump to the matching bracket.
2546            // count is passed through; engine-side matching_bracket handles
2547            // the no-match case as a no-op (cursor stays). Engine FSM arm
2548            // for `%` in parse_motion is kept intact for macro-replay.
2549            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
2550        }
2551        crate::MotionKind::ViewportTop => {
2552            // `H` — cursor to top of visible viewport, then count-1 rows down.
2553            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
2554            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
2555        }
2556        crate::MotionKind::ViewportMiddle => {
2557            // `M` — cursor to middle of visible viewport; count ignored.
2558            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
2559            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
2560        }
2561        crate::MotionKind::ViewportBottom => {
2562            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
2563            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
2564            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
2565        }
2566        crate::MotionKind::HalfPageDown => {
2567            // `<C-d>` — half page down, count multiplies the distance.
2568            // Calls scroll_cursor_rows directly rather than adding a Motion enum
2569            // variant, keeping engine Motion churn minimal.
2570            scroll_cursor_rows(ed, viewport_half_rows(ed, count) as isize);
2571        }
2572        crate::MotionKind::HalfPageUp => {
2573            // `<C-u>` — half page up, count multiplies the distance.
2574            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
2575            scroll_cursor_rows(ed, -(viewport_half_rows(ed, count) as isize));
2576        }
2577        crate::MotionKind::FullPageDown => {
2578            // `<C-f>` — full page down (2-line overlap), count multiplies.
2579            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
2580            scroll_cursor_rows(ed, viewport_full_rows(ed, count) as isize);
2581        }
2582        crate::MotionKind::FullPageUp => {
2583            // `<C-b>` — full page up (2-line overlap), count multiplies.
2584            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
2585            scroll_cursor_rows(ed, -(viewport_full_rows(ed, count) as isize));
2586        }
2587    }
2588}
2589
2590/// Restore the cursor to the sticky column after vertical motions and
2591/// sync the sticky column to the current column after horizontal ones.
2592/// `pre_col` is the cursor column captured *before* the motion — used
2593/// to bootstrap the sticky value on the very first motion.
2594fn apply_sticky_col<H: crate::types::Host>(
2595    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2596    motion: &Motion,
2597    pre_col: usize,
2598) {
2599    if is_vertical_motion(motion) {
2600        let want = ed.sticky_col.unwrap_or(pre_col);
2601        // Record the desired column so the next vertical motion sees
2602        // it even if we currently clamped to a shorter row.
2603        ed.sticky_col = Some(want);
2604        let (row, _) = ed.cursor();
2605        let line_len = buf_line_chars(&ed.buffer, row);
2606        // Clamp to the last char on non-empty lines (vim normal-mode
2607        // never parks the cursor one past end of line). Empty lines
2608        // collapse to col 0.
2609        let max_col = line_len.saturating_sub(1);
2610        let target = want.min(max_col);
2611        // raw primitive: this function MUST preserve the un-clamped `want`
2612        // already stored in `ed.sticky_col`; `jump_cursor` would overwrite
2613        // it with the clamped `target`.
2614        buf_set_cursor_rc(&mut ed.buffer, row, target);
2615    } else {
2616        // Horizontal motion or non-motion: sticky column tracks the
2617        // new cursor column so the *next* vertical motion aims there.
2618        ed.sticky_col = Some(ed.cursor().1);
2619    }
2620}
2621
2622fn is_vertical_motion(motion: &Motion) -> bool {
2623    // Only j / k preserve the sticky column. Everything else (search,
2624    // gg / G, word jumps, etc.) lands at the match's own column so the
2625    // sticky value should sync to the new cursor column.
2626    matches!(
2627        motion,
2628        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
2629    )
2630}
2631
2632fn apply_motion_cursor<H: crate::types::Host>(
2633    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2634    motion: &Motion,
2635    count: usize,
2636) {
2637    apply_motion_cursor_ctx(ed, motion, count, false)
2638}
2639
2640pub(crate) fn apply_motion_cursor_ctx<H: crate::types::Host>(
2641    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2642    motion: &Motion,
2643    count: usize,
2644    as_operator: bool,
2645) {
2646    match motion {
2647        Motion::Left => {
2648            // `h` — Buffer clamps at col 0 (no wrap), matching vim.
2649            crate::motions::move_left(&mut ed.buffer, count);
2650            ed.push_buffer_cursor_to_textarea();
2651        }
2652        Motion::Right => {
2653            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
2654            // one past the last char so the range includes it; cursor
2655            // context clamps at the last char.
2656            if as_operator {
2657                crate::motions::move_right_to_end(&mut ed.buffer, count);
2658            } else {
2659                crate::motions::move_right_in_line(&mut ed.buffer, count);
2660            }
2661            ed.push_buffer_cursor_to_textarea();
2662        }
2663        Motion::Up => {
2664            // Final col is set by `apply_sticky_col` below — push the
2665            // post-move row to the textarea and let sticky tracking
2666            // finish the work.
2667            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2668            crate::motions::move_up(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2669            ed.push_buffer_cursor_to_textarea();
2670        }
2671        Motion::Down => {
2672            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2673            crate::motions::move_down(&mut ed.buffer, &folds, count, &mut ed.sticky_col);
2674            ed.push_buffer_cursor_to_textarea();
2675        }
2676        Motion::ScreenUp => {
2677            let v = *ed.host.viewport();
2678            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2679            crate::motions::move_screen_up(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2680            ed.push_buffer_cursor_to_textarea();
2681        }
2682        Motion::ScreenDown => {
2683            let v = *ed.host.viewport();
2684            let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
2685            crate::motions::move_screen_down(&mut ed.buffer, &folds, &v, count, &mut ed.sticky_col);
2686            ed.push_buffer_cursor_to_textarea();
2687        }
2688        Motion::WordFwd => {
2689            crate::motions::move_word_fwd(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2690            ed.push_buffer_cursor_to_textarea();
2691        }
2692        Motion::WordBack => {
2693            crate::motions::move_word_back(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2694            ed.push_buffer_cursor_to_textarea();
2695        }
2696        Motion::WordEnd => {
2697            crate::motions::move_word_end(&mut ed.buffer, false, count, &ed.settings.iskeyword);
2698            ed.push_buffer_cursor_to_textarea();
2699        }
2700        Motion::BigWordFwd => {
2701            crate::motions::move_word_fwd(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2702            ed.push_buffer_cursor_to_textarea();
2703        }
2704        Motion::BigWordBack => {
2705            crate::motions::move_word_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2706            ed.push_buffer_cursor_to_textarea();
2707        }
2708        Motion::BigWordEnd => {
2709            crate::motions::move_word_end(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2710            ed.push_buffer_cursor_to_textarea();
2711        }
2712        Motion::WordEndBack => {
2713            crate::motions::move_word_end_back(
2714                &mut ed.buffer,
2715                false,
2716                count,
2717                &ed.settings.iskeyword,
2718            );
2719            ed.push_buffer_cursor_to_textarea();
2720        }
2721        Motion::BigWordEndBack => {
2722            crate::motions::move_word_end_back(&mut ed.buffer, true, count, &ed.settings.iskeyword);
2723            ed.push_buffer_cursor_to_textarea();
2724        }
2725        Motion::LineStart => {
2726            crate::motions::move_line_start(&mut ed.buffer);
2727            ed.push_buffer_cursor_to_textarea();
2728        }
2729        Motion::FirstNonBlank => {
2730            crate::motions::move_first_non_blank(&mut ed.buffer);
2731            ed.push_buffer_cursor_to_textarea();
2732        }
2733        Motion::LineEnd => {
2734            // Vim normal-mode `$` lands on the last char, not one past it.
2735            crate::motions::move_line_end(&mut ed.buffer);
2736            ed.push_buffer_cursor_to_textarea();
2737        }
2738        Motion::FileTop => {
2739            // `count gg` jumps to line `count` (first non-blank);
2740            // bare `gg` lands at the top.
2741            if count > 1 {
2742                crate::motions::move_bottom(&mut ed.buffer, count);
2743            } else {
2744                crate::motions::move_top(&mut ed.buffer);
2745            }
2746            ed.push_buffer_cursor_to_textarea();
2747        }
2748        Motion::FileBottom => {
2749            // `count G` jumps to line `count`; bare `G` lands at
2750            // the buffer bottom (`Buffer::move_bottom(0)`).
2751            if count > 1 {
2752                crate::motions::move_bottom(&mut ed.buffer, count);
2753            } else {
2754                crate::motions::move_bottom(&mut ed.buffer, 0);
2755            }
2756            ed.push_buffer_cursor_to_textarea();
2757        }
2758        Motion::Find { ch, forward, till } => {
2759            for _ in 0..count {
2760                if !find_char_on_line(ed, *ch, *forward, *till) {
2761                    break;
2762                }
2763            }
2764        }
2765        Motion::FindRepeat { .. } => {} // already resolved upstream
2766        Motion::MatchBracket => {
2767            let _ = matching_bracket(ed);
2768        }
2769        Motion::WordAtCursor {
2770            forward,
2771            whole_word,
2772        } => {
2773            word_at_cursor_search(ed, *forward, *whole_word, count);
2774        }
2775        Motion::SearchNext { reverse } => {
2776            // Re-push the last query so the buffer's search state is
2777            // correct even if the host happened to clear it (e.g. while
2778            // a Visual mode draw was in progress).
2779            if let Some(pattern) = ed.vim.last_search.clone() {
2780                ed.push_search_pattern(&pattern);
2781            }
2782            if ed.search_state().pattern.is_none() {
2783                return;
2784            }
2785            // `n` repeats the last search in its committed direction;
2786            // `N` inverts. So a `?` search makes `n` walk backward and
2787            // `N` walk forward.
2788            let forward = ed.vim.last_search_forward != *reverse;
2789            for _ in 0..count.max(1) {
2790                if forward {
2791                    ed.search_advance_forward(true);
2792                } else {
2793                    ed.search_advance_backward(true);
2794                }
2795            }
2796            ed.push_buffer_cursor_to_textarea();
2797        }
2798        Motion::ViewportTop => {
2799            let v = *ed.host().viewport();
2800            crate::motions::move_viewport_top(&mut ed.buffer, &v, count.saturating_sub(1));
2801            ed.push_buffer_cursor_to_textarea();
2802        }
2803        Motion::ViewportMiddle => {
2804            let v = *ed.host().viewport();
2805            crate::motions::move_viewport_middle(&mut ed.buffer, &v);
2806            ed.push_buffer_cursor_to_textarea();
2807        }
2808        Motion::ViewportBottom => {
2809            let v = *ed.host().viewport();
2810            crate::motions::move_viewport_bottom(&mut ed.buffer, &v, count.saturating_sub(1));
2811            ed.push_buffer_cursor_to_textarea();
2812        }
2813        Motion::LastNonBlank => {
2814            crate::motions::move_last_non_blank(&mut ed.buffer);
2815            ed.push_buffer_cursor_to_textarea();
2816        }
2817        Motion::LineMiddle => {
2818            let row = ed.cursor().0;
2819            let line_chars = buf_line_chars(&ed.buffer, row);
2820            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
2821            // lines stay at col 0.
2822            let target = line_chars / 2;
2823            ed.jump_cursor(row, target);
2824        }
2825        Motion::ParagraphPrev => {
2826            crate::motions::move_paragraph_prev(&mut ed.buffer, count);
2827            ed.push_buffer_cursor_to_textarea();
2828        }
2829        Motion::ParagraphNext => {
2830            crate::motions::move_paragraph_next(&mut ed.buffer, count);
2831            ed.push_buffer_cursor_to_textarea();
2832        }
2833        Motion::SentencePrev => {
2834            for _ in 0..count.max(1) {
2835                if let Some((row, col)) = sentence_boundary(ed, false) {
2836                    ed.jump_cursor(row, col);
2837                }
2838            }
2839        }
2840        Motion::SentenceNext => {
2841            for _ in 0..count.max(1) {
2842                if let Some((row, col)) = sentence_boundary(ed, true) {
2843                    ed.jump_cursor(row, col);
2844                }
2845            }
2846        }
2847    }
2848}
2849
2850fn move_first_non_whitespace<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
2851    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
2852    // mutates the textarea content, so the migration buffer hasn't
2853    // seen the new lines OR new cursor yet. Mirror the full content
2854    // across before delegating, then push the result back so the
2855    // textarea reflects the resolved column too.
2856    ed.sync_buffer_content_from_textarea();
2857    crate::motions::move_first_non_blank(&mut ed.buffer);
2858    ed.push_buffer_cursor_to_textarea();
2859}
2860
2861fn find_char_on_line<H: crate::types::Host>(
2862    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2863    ch: char,
2864    forward: bool,
2865    till: bool,
2866) -> bool {
2867    let moved = crate::motions::find_char_on_line(&mut ed.buffer, ch, forward, till);
2868    if moved {
2869        ed.push_buffer_cursor_to_textarea();
2870    }
2871    moved
2872}
2873
2874fn matching_bracket<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) -> bool {
2875    let moved = crate::motions::match_bracket(&mut ed.buffer);
2876    if moved {
2877        ed.push_buffer_cursor_to_textarea();
2878    }
2879    moved
2880}
2881
2882fn word_at_cursor_search<H: crate::types::Host>(
2883    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2884    forward: bool,
2885    whole_word: bool,
2886    count: usize,
2887) {
2888    let (row, col) = ed.cursor();
2889    let line: String = buf_line(&ed.buffer, row).unwrap_or("").to_string();
2890    let chars: Vec<char> = line.chars().collect();
2891    if chars.is_empty() {
2892        return;
2893    }
2894    // Expand around cursor to a word boundary.
2895    let spec = ed.settings().iskeyword.clone();
2896    let is_word = |c: char| is_keyword_char(c, &spec);
2897    let mut start = col.min(chars.len().saturating_sub(1));
2898    while start > 0 && is_word(chars[start - 1]) {
2899        start -= 1;
2900    }
2901    let mut end = start;
2902    while end < chars.len() && is_word(chars[end]) {
2903        end += 1;
2904    }
2905    if end <= start {
2906        return;
2907    }
2908    let word: String = chars[start..end].iter().collect();
2909    let escaped = regex_escape(&word);
2910    let pattern = if whole_word {
2911        format!(r"\b{escaped}\b")
2912    } else {
2913        escaped
2914    };
2915    ed.push_search_pattern(&pattern);
2916    if ed.search_state().pattern.is_none() {
2917        return;
2918    }
2919    // Remember the query so `n` / `N` keep working after the jump.
2920    ed.vim.last_search = Some(pattern);
2921    ed.vim.last_search_forward = forward;
2922    for _ in 0..count.max(1) {
2923        if forward {
2924            ed.search_advance_forward(true);
2925        } else {
2926            ed.search_advance_backward(true);
2927        }
2928    }
2929    ed.push_buffer_cursor_to_textarea();
2930}
2931
2932fn regex_escape(s: &str) -> String {
2933    let mut out = String::with_capacity(s.len());
2934    for c in s.chars() {
2935        if matches!(
2936            c,
2937            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
2938        ) {
2939            out.push('\\');
2940        }
2941        out.push(c);
2942    }
2943    out
2944}
2945
2946// ─── Operator application ──────────────────────────────────────────────────
2947
2948/// Public(crate) entry: apply operator over the motion identified by a raw
2949/// char key. Called by `Editor::apply_op_motion` (the public controller API)
2950/// so the hjkl-vim pending-state reducer can dispatch `ApplyOpMotion` without
2951/// re-entering the FSM.
2952///
2953/// Applies standard vim quirks:
2954/// - `cw` / `cW` → `ce` / `cE`
2955/// - `FindRepeat` → resolves against `last_find`
2956/// - Updates `last_find` and `last_change` per existing conventions.
2957///
2958/// No-op when `motion_key` does not produce a known motion.
2959pub(crate) fn apply_op_motion_key<H: crate::types::Host>(
2960    ed: &mut Editor<hjkl_buffer::Buffer, H>,
2961    op: Operator,
2962    motion_key: char,
2963    total_count: usize,
2964) {
2965    let input = Input {
2966        key: Key::Char(motion_key),
2967        ctrl: false,
2968        alt: false,
2969        shift: false,
2970    };
2971    let Some(motion) = parse_motion(&input) else {
2972        return;
2973    };
2974    let motion = match motion {
2975        Motion::FindRepeat { reverse } => match ed.vim.last_find {
2976            Some((ch, forward, till)) => Motion::Find {
2977                ch,
2978                forward: if reverse { !forward } else { forward },
2979                till,
2980            },
2981            None => return,
2982        },
2983        // Vim quirk: `cw` / `cW` → `ce` / `cE`.
2984        Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
2985        Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
2986        m => m,
2987    };
2988    apply_op_with_motion(ed, op, &motion, total_count);
2989    if let Motion::Find { ch, forward, till } = &motion {
2990        ed.vim.last_find = Some((*ch, *forward, *till));
2991    }
2992    if !ed.vim.replaying && op_is_change(op) {
2993        ed.vim.last_change = Some(LastChange::OpMotion {
2994            op,
2995            motion,
2996            count: total_count,
2997            inserted: None,
2998        });
2999    }
3000}
3001
3002/// Public(crate) entry: apply doubled-letter line op (`dd`/`yy`/`cc`/`>>`/`<<`).
3003/// Called by `Editor::apply_op_double` (the public controller API).
3004pub(crate) fn apply_op_double<H: crate::types::Host>(
3005    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3006    op: Operator,
3007    total_count: usize,
3008) {
3009    execute_line_op(ed, op, total_count);
3010    if !ed.vim.replaying {
3011        ed.vim.last_change = Some(LastChange::LineOp {
3012            op,
3013            count: total_count,
3014            inserted: None,
3015        });
3016    }
3017}
3018
3019/// Shared implementation: apply operator over a g-chord motion or case-op
3020/// linewise form. Called by `Editor::apply_op_g` (the public controller API)
3021/// so the hjkl-vim reducer can dispatch `ApplyOpG` without re-entering the FSM.
3022///
3023/// - If `op` is Uppercase/Lowercase/ToggleCase and `ch` matches the op's char
3024///   (`U`/`u`/`~`): executes the line op and updates `last_change`.
3025/// - Otherwise, maps `ch` to a motion (`g`→FileTop, `e`→WordEndBack,
3026///   `E`→BigWordEndBack, `j`→ScreenDown, `k`→ScreenUp) and applies. Unknown
3027///   chars are silently ignored (no-op), matching the engine FSM's behaviour.
3028pub(crate) fn apply_op_g_inner<H: crate::types::Host>(
3029    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3030    op: Operator,
3031    ch: char,
3032    total_count: usize,
3033) {
3034    // Case-op linewise form: `gUgU`, `gugu`, `g~g~` — same effect as
3035    // `gUU` / `guu` / `g~~`.
3036    if matches!(
3037        op,
3038        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase
3039    ) {
3040        let op_char = match op {
3041            Operator::Uppercase => 'U',
3042            Operator::Lowercase => 'u',
3043            Operator::ToggleCase => '~',
3044            _ => unreachable!(),
3045        };
3046        if ch == op_char {
3047            execute_line_op(ed, op, total_count);
3048            if !ed.vim.replaying {
3049                ed.vim.last_change = Some(LastChange::LineOp {
3050                    op,
3051                    count: total_count,
3052                    inserted: None,
3053                });
3054            }
3055            return;
3056        }
3057    }
3058    let motion = match ch {
3059        'g' => Motion::FileTop,
3060        'e' => Motion::WordEndBack,
3061        'E' => Motion::BigWordEndBack,
3062        'j' => Motion::ScreenDown,
3063        'k' => Motion::ScreenUp,
3064        _ => return, // Unknown char — no-op.
3065    };
3066    apply_op_with_motion(ed, op, &motion, total_count);
3067    if !ed.vim.replaying && op_is_change(op) {
3068        ed.vim.last_change = Some(LastChange::OpMotion {
3069            op,
3070            motion,
3071            count: total_count,
3072            inserted: None,
3073        });
3074    }
3075}
3076
3077/// Public(crate) entry point for bare `g<x>`. Applies the g-chord effect
3078/// given the char `ch` and pre-captured `count`. Called by `Editor::after_g`
3079/// (the public controller API) so the hjkl-vim pending-state reducer can
3080/// dispatch `AfterGChord` without re-entering the FSM.
3081pub(crate) fn apply_after_g<H: crate::types::Host>(
3082    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3083    ch: char,
3084    count: usize,
3085) {
3086    match ch {
3087        'g' => {
3088            // gg — top / jump to line count.
3089            let pre = ed.cursor();
3090            if count > 1 {
3091                ed.jump_cursor(count - 1, 0);
3092            } else {
3093                ed.jump_cursor(0, 0);
3094            }
3095            move_first_non_whitespace(ed);
3096            // Update sticky_col to the first-non-blank column so j/k after
3097            // gg aim for the correct column per vim semantics.
3098            ed.sticky_col = Some(ed.cursor().1);
3099            if ed.cursor() != pre {
3100                ed.push_jump(pre);
3101            }
3102        }
3103        'e' => execute_motion(ed, Motion::WordEndBack, count),
3104        'E' => execute_motion(ed, Motion::BigWordEndBack, count),
3105        // `g_` — last non-blank on the line.
3106        '_' => execute_motion(ed, Motion::LastNonBlank, count),
3107        // `gM` — middle char column of the current line.
3108        'M' => execute_motion(ed, Motion::LineMiddle, count),
3109        // `gv` — re-enter the last visual selection.
3110        // Phase 6.6a: drive through the public Editor API.
3111        'v' => ed.reenter_last_visual(),
3112        // `gj` / `gk` — display-line down / up. Walks one screen
3113        // segment at a time under `:set wrap`; falls back to `j`/`k`
3114        // when wrap is off (Buffer::move_screen_* handles the branch).
3115        'j' => execute_motion(ed, Motion::ScreenDown, count),
3116        'k' => execute_motion(ed, Motion::ScreenUp, count),
3117        // Case operators: `gU` / `gu` / `g~`. Enter operator-pending
3118        // so the next input is treated as the motion / text object /
3119        // shorthand double (`gUU`, `guu`, `g~~`).
3120        'U' => {
3121            ed.vim.pending = Pending::Op {
3122                op: Operator::Uppercase,
3123                count1: count,
3124            };
3125        }
3126        'u' => {
3127            ed.vim.pending = Pending::Op {
3128                op: Operator::Lowercase,
3129                count1: count,
3130            };
3131        }
3132        '~' => {
3133            ed.vim.pending = Pending::Op {
3134                op: Operator::ToggleCase,
3135                count1: count,
3136            };
3137        }
3138        'q' => {
3139            // `gq{motion}` — text reflow operator. Subsequent motion
3140            // / textobj rides the same operator pipeline.
3141            ed.vim.pending = Pending::Op {
3142                op: Operator::Reflow,
3143                count1: count,
3144            };
3145        }
3146        'J' => {
3147            // `gJ` — join line below without inserting a space.
3148            for _ in 0..count.max(1) {
3149                ed.push_undo();
3150                join_line_raw(ed);
3151            }
3152            if !ed.vim.replaying {
3153                ed.vim.last_change = Some(LastChange::JoinLine {
3154                    count: count.max(1),
3155                });
3156            }
3157        }
3158        'd' => {
3159            // `gd` — goto definition. hjkl-engine doesn't run an LSP
3160            // itself; raise an intent the host drains and routes to
3161            // `sqls`. The cursor stays put here — the host moves it
3162            // once it has the target location.
3163            ed.pending_lsp = Some(crate::editor::LspIntent::GotoDefinition);
3164        }
3165        // `gi` — go to last-insert position and re-enter insert mode.
3166        // Matches vim's `:h gi`: moves to the `'^` mark position (the
3167        // cursor where insert mode was last active, before Esc step-back)
3168        // and enters insert mode there.
3169        'i' => {
3170            if let Some((row, col)) = ed.vim.last_insert_pos {
3171                ed.jump_cursor(row, col);
3172            }
3173            begin_insert(ed, count.max(1), InsertReason::Enter(InsertEntry::I));
3174        }
3175        // `g;` / `g,` — walk the change list. `g;` toward older
3176        // entries, `g,` toward newer.
3177        ';' => walk_change_list(ed, -1, count.max(1)),
3178        ',' => walk_change_list(ed, 1, count.max(1)),
3179        // `g*` / `g#` — like `*` / `#` but match substrings (no `\b`
3180        // boundary anchors), so the cursor on `foo` finds it inside
3181        // `foobar` too.
3182        '*' => execute_motion(
3183            ed,
3184            Motion::WordAtCursor {
3185                forward: true,
3186                whole_word: false,
3187            },
3188            count,
3189        ),
3190        '#' => execute_motion(
3191            ed,
3192            Motion::WordAtCursor {
3193                forward: false,
3194                whole_word: false,
3195            },
3196            count,
3197        ),
3198        _ => {}
3199    }
3200}
3201
3202/// Public(crate) entry point for bare `z<x>`. Applies the z-chord effect
3203/// given the char `ch` and pre-captured `count`. Called by `Editor::after_z`
3204/// (the public controller API) so the hjkl-vim pending-state reducer can
3205/// dispatch `AfterZChord` without re-entering the engine FSM.
3206pub(crate) fn apply_after_z<H: crate::types::Host>(
3207    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3208    ch: char,
3209    count: usize,
3210) {
3211    use crate::editor::CursorScrollTarget;
3212    let row = ed.cursor().0;
3213    match ch {
3214        'z' => {
3215            ed.scroll_cursor_to(CursorScrollTarget::Center);
3216            ed.vim.viewport_pinned = true;
3217        }
3218        't' => {
3219            ed.scroll_cursor_to(CursorScrollTarget::Top);
3220            ed.vim.viewport_pinned = true;
3221        }
3222        'b' => {
3223            ed.scroll_cursor_to(CursorScrollTarget::Bottom);
3224            ed.vim.viewport_pinned = true;
3225        }
3226        // Folds — operate on the fold under the cursor (or the
3227        // whole buffer for `R` / `M`). Routed through
3228        // [`Editor::apply_fold_op`] (0.0.38 Patch C-δ.4) so the host
3229        // can observe / veto each op via [`Editor::take_fold_ops`].
3230        'o' => {
3231            ed.apply_fold_op(crate::types::FoldOp::OpenAt(row));
3232        }
3233        'c' => {
3234            ed.apply_fold_op(crate::types::FoldOp::CloseAt(row));
3235        }
3236        'a' => {
3237            ed.apply_fold_op(crate::types::FoldOp::ToggleAt(row));
3238        }
3239        'R' => {
3240            ed.apply_fold_op(crate::types::FoldOp::OpenAll);
3241        }
3242        'M' => {
3243            ed.apply_fold_op(crate::types::FoldOp::CloseAll);
3244        }
3245        'E' => {
3246            ed.apply_fold_op(crate::types::FoldOp::ClearAll);
3247        }
3248        'd' => {
3249            ed.apply_fold_op(crate::types::FoldOp::RemoveAt(row));
3250        }
3251        'f' => {
3252            if matches!(
3253                ed.vim.mode,
3254                Mode::Visual | Mode::VisualLine | Mode::VisualBlock
3255            ) {
3256                // `zf` over a Visual selection creates a fold spanning
3257                // anchor → cursor.
3258                let anchor_row = match ed.vim.mode {
3259                    Mode::VisualLine => ed.vim.visual_line_anchor,
3260                    Mode::VisualBlock => ed.vim.block_anchor.0,
3261                    _ => ed.vim.visual_anchor.0,
3262                };
3263                let cur = ed.cursor().0;
3264                let top = anchor_row.min(cur);
3265                let bot = anchor_row.max(cur);
3266                ed.apply_fold_op(crate::types::FoldOp::Add {
3267                    start_row: top,
3268                    end_row: bot,
3269                    closed: true,
3270                });
3271                ed.vim.mode = Mode::Normal;
3272            } else {
3273                // `zf{motion}` / `zf{textobj}` — route through the
3274                // operator pipeline. `Operator::Fold` reuses every
3275                // motion / text-object / `g`-prefix branch the other
3276                // operators get.
3277                ed.vim.pending = Pending::Op {
3278                    op: Operator::Fold,
3279                    count1: count,
3280                };
3281            }
3282        }
3283        _ => {}
3284    }
3285}
3286
3287/// Public(crate) entry point for bare `f<x>` / `F<x>` / `t<x>` / `T<x>`.
3288/// Applies the motion and records `last_find` for `;` / `,` repeat.
3289/// Called by `Editor::find_char` (the public controller API) so the
3290/// hjkl-vim pending-state reducer can dispatch `FindChar` without
3291/// re-entering the FSM.
3292pub(crate) fn apply_find_char<H: crate::types::Host>(
3293    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3294    ch: char,
3295    forward: bool,
3296    till: bool,
3297    count: usize,
3298) {
3299    execute_motion(ed, Motion::Find { ch, forward, till }, count.max(1));
3300    ed.vim.last_find = Some((ch, forward, till));
3301}
3302
3303/// Public(crate) entry: apply operator over a find motion (`df<x>` etc.).
3304/// Called by `Editor::apply_op_find` (the public controller API) so the
3305/// hjkl-vim `PendingState::OpFind` reducer can dispatch `ApplyOpFind` without
3306/// re-entering the FSM. `handle_op_find_target` now delegates here to avoid
3307/// logic duplication.
3308pub(crate) fn apply_op_find_motion<H: crate::types::Host>(
3309    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3310    op: Operator,
3311    ch: char,
3312    forward: bool,
3313    till: bool,
3314    total_count: usize,
3315) {
3316    let motion = Motion::Find { ch, forward, till };
3317    apply_op_with_motion(ed, op, &motion, total_count);
3318    ed.vim.last_find = Some((ch, forward, till));
3319    if !ed.vim.replaying && op_is_change(op) {
3320        ed.vim.last_change = Some(LastChange::OpMotion {
3321            op,
3322            motion,
3323            count: total_count,
3324            inserted: None,
3325        });
3326    }
3327}
3328
3329/// Shared implementation: map `ch` to `TextObject`, apply the operator, and
3330/// record `last_change`. Returns `false` when `ch` is not a known text-object
3331/// kind (caller should treat as a no-op). Called by `Editor::apply_op_text_obj`
3332/// (the public controller API) so hjkl-vim can dispatch without re-entering the FSM.
3333///
3334/// `_total_count` is accepted for API symmetry with `apply_op_find_motion` /
3335/// `apply_op_motion_key` but is currently unused — text objects don't repeat
3336/// in vim's current grammar. Kept for future-proofing.
3337pub(crate) fn apply_op_text_obj_inner<H: crate::types::Host>(
3338    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3339    op: Operator,
3340    ch: char,
3341    inner: bool,
3342    _total_count: usize,
3343) -> bool {
3344    // total_count unused — text objects don't repeat in vim's current grammar.
3345    // Kept for API symmetry with apply_op_motion / apply_op_find.
3346    let obj = match ch {
3347        'w' => TextObject::Word { big: false },
3348        'W' => TextObject::Word { big: true },
3349        '"' | '\'' | '`' => TextObject::Quote(ch),
3350        '(' | ')' | 'b' => TextObject::Bracket('('),
3351        '[' | ']' => TextObject::Bracket('['),
3352        '{' | '}' | 'B' => TextObject::Bracket('{'),
3353        '<' | '>' => TextObject::Bracket('<'),
3354        'p' => TextObject::Paragraph,
3355        't' => TextObject::XmlTag,
3356        's' => TextObject::Sentence,
3357        _ => return false,
3358    };
3359    apply_op_with_text_object(ed, op, obj, inner);
3360    if !ed.vim.replaying && op_is_change(op) {
3361        ed.vim.last_change = Some(LastChange::OpTextObj {
3362            op,
3363            obj,
3364            inner,
3365            inserted: None,
3366        });
3367    }
3368    true
3369}
3370
3371/// Move `pos` back by one character, clamped to (0, 0).
3372pub(crate) fn retreat_one<H: crate::types::Host>(
3373    ed: &Editor<hjkl_buffer::Buffer, H>,
3374    pos: (usize, usize),
3375) -> (usize, usize) {
3376    let (r, c) = pos;
3377    if c > 0 {
3378        (r, c - 1)
3379    } else if r > 0 {
3380        let prev_len = buf_line_bytes(&ed.buffer, r - 1);
3381        (r - 1, prev_len)
3382    } else {
3383        (0, 0)
3384    }
3385}
3386
3387/// Variant of begin_insert that doesn't push_undo (caller already did).
3388fn begin_insert_noundo<H: crate::types::Host>(
3389    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3390    count: usize,
3391    reason: InsertReason,
3392) {
3393    let reason = if ed.vim.replaying {
3394        InsertReason::ReplayOnly
3395    } else {
3396        reason
3397    };
3398    let (row, _) = ed.cursor();
3399    ed.vim.insert_session = Some(InsertSession {
3400        count,
3401        row_min: row,
3402        row_max: row,
3403        before_lines: buf_lines_to_vec(&ed.buffer),
3404        reason,
3405    });
3406    ed.vim.mode = Mode::Insert;
3407    // Phase 6.3: keep current_mode in sync for callers that bypass step().
3408    ed.vim.current_mode = crate::VimMode::Insert;
3409}
3410
3411// ─── Operator × Motion application ─────────────────────────────────────────
3412
3413pub(crate) fn apply_op_with_motion<H: crate::types::Host>(
3414    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3415    op: Operator,
3416    motion: &Motion,
3417    count: usize,
3418) {
3419    let start = ed.cursor();
3420    // Tentatively apply motion to find the endpoint. Operator context
3421    // so `l` on the last char advances past-last (standard vim
3422    // exclusive-motion endpoint behaviour), enabling `dl` / `cl` /
3423    // `yl` to cover the final char.
3424    apply_motion_cursor_ctx(ed, motion, count, true);
3425    let end = ed.cursor();
3426    let kind = motion_kind(motion);
3427    // Restore cursor before selecting (so Yank leaves cursor at start).
3428    ed.jump_cursor(start.0, start.1);
3429    run_operator_over_range(ed, op, start, end, kind);
3430}
3431
3432fn apply_op_with_text_object<H: crate::types::Host>(
3433    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3434    op: Operator,
3435    obj: TextObject,
3436    inner: bool,
3437) {
3438    let Some((start, end, kind)) = text_object_range(ed, obj, inner) else {
3439        return;
3440    };
3441    ed.jump_cursor(start.0, start.1);
3442    run_operator_over_range(ed, op, start, end, kind);
3443}
3444
3445fn motion_kind(motion: &Motion) -> RangeKind {
3446    match motion {
3447        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => RangeKind::Linewise,
3448        Motion::FileTop | Motion::FileBottom => RangeKind::Linewise,
3449        Motion::ViewportTop | Motion::ViewportMiddle | Motion::ViewportBottom => {
3450            RangeKind::Linewise
3451        }
3452        Motion::WordEnd | Motion::BigWordEnd | Motion::WordEndBack | Motion::BigWordEndBack => {
3453            RangeKind::Inclusive
3454        }
3455        Motion::Find { .. } => RangeKind::Inclusive,
3456        Motion::MatchBracket => RangeKind::Inclusive,
3457        // `$` now lands on the last char — operator ranges include it.
3458        Motion::LineEnd => RangeKind::Inclusive,
3459        _ => RangeKind::Exclusive,
3460    }
3461}
3462
3463fn run_operator_over_range<H: crate::types::Host>(
3464    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3465    op: Operator,
3466    start: (usize, usize),
3467    end: (usize, usize),
3468    kind: RangeKind,
3469) {
3470    let (top, bot) = order(start, end);
3471    // Charwise empty range (same position) — nothing to act on. For Linewise
3472    // the range `top == bot` means "operate on this one line" which is
3473    // perfectly valid (e.g. `Vd` on a single-line VisualLine selection).
3474    if top == bot && !matches!(kind, RangeKind::Linewise) {
3475        return;
3476    }
3477
3478    match op {
3479        Operator::Yank => {
3480            let text = read_vim_range(ed, top, bot, kind);
3481            if !text.is_empty() {
3482                ed.record_yank_to_host(text.clone());
3483                ed.record_yank(text, matches!(kind, RangeKind::Linewise));
3484            }
3485            // Vim `:h '[` / `:h ']`: after a yank `[` = first yanked char,
3486            // `]` = last yanked char. Mode-aware: linewise snaps to line
3487            // edges; charwise uses the actual inclusive endpoint.
3488            let rbr = match kind {
3489                RangeKind::Linewise => {
3490                    let last_col = buf_line_chars(&ed.buffer, bot.0).saturating_sub(1);
3491                    (bot.0, last_col)
3492                }
3493                RangeKind::Inclusive => (bot.0, bot.1),
3494                RangeKind::Exclusive => (bot.0, bot.1.saturating_sub(1)),
3495            };
3496            ed.set_mark('[', top);
3497            ed.set_mark(']', rbr);
3498            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3499            ed.push_buffer_cursor_to_textarea();
3500        }
3501        Operator::Delete => {
3502            ed.push_undo();
3503            cut_vim_range(ed, top, bot, kind);
3504            // After a charwise / inclusive delete the buffer cursor is
3505            // placed at `start` by the edit path. In Normal mode the
3506            // cursor max col is `line_len - 1`; clamp it here so e.g.
3507            // `d$` doesn't leave the cursor one past the new line end.
3508            if !matches!(kind, RangeKind::Linewise) {
3509                clamp_cursor_to_normal_mode(ed);
3510            }
3511            ed.vim.mode = Mode::Normal;
3512            // Vim `:h '[` / `:h ']`: after a delete both marks park at
3513            // the cursor position where the deletion collapsed (the join
3514            // point). Set after the cut and clamp so the position is final.
3515            let pos = ed.cursor();
3516            ed.set_mark('[', pos);
3517            ed.set_mark(']', pos);
3518        }
3519        Operator::Change => {
3520            // Vim `:h '[`: `[` is set to the start of the changed range
3521            // before the cut. `]` is deferred to insert-exit (AfterChange
3522            // path in finish_insert_session) where the cursor sits on the
3523            // last inserted char.
3524            ed.vim.change_mark_start = Some(top);
3525            ed.push_undo();
3526            cut_vim_range(ed, top, bot, kind);
3527            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
3528        }
3529        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
3530            apply_case_op_to_selection(ed, op, top, bot, kind);
3531        }
3532        Operator::Indent | Operator::Outdent => {
3533            // Indent / outdent are always linewise even when triggered
3534            // by a char-wise motion (e.g. `>w` indents the whole line).
3535            ed.push_undo();
3536            if op == Operator::Indent {
3537                indent_rows(ed, top.0, bot.0, 1);
3538            } else {
3539                outdent_rows(ed, top.0, bot.0, 1);
3540            }
3541            ed.vim.mode = Mode::Normal;
3542        }
3543        Operator::Fold => {
3544            // Always linewise — fold the spanned rows regardless of the
3545            // motion's natural kind. Cursor lands on `top.0` to mirror
3546            // the visual `zf` path.
3547            if bot.0 >= top.0 {
3548                ed.apply_fold_op(crate::types::FoldOp::Add {
3549                    start_row: top.0,
3550                    end_row: bot.0,
3551                    closed: true,
3552                });
3553            }
3554            buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
3555            ed.push_buffer_cursor_to_textarea();
3556            ed.vim.mode = Mode::Normal;
3557        }
3558        Operator::Reflow => {
3559            ed.push_undo();
3560            reflow_rows(ed, top.0, bot.0);
3561            ed.vim.mode = Mode::Normal;
3562        }
3563        Operator::AutoIndent => {
3564            // Always linewise — like Indent/Outdent.
3565            ed.push_undo();
3566            auto_indent_rows(ed, top.0, bot.0);
3567            ed.vim.mode = Mode::Normal;
3568        }
3569    }
3570}
3571
3572// ─── Phase 4a pub range-mutation bridges ───────────────────────────────────
3573//
3574// These are `pub(crate)` entry points called by the five new pub methods on
3575// `Editor` (`delete_range`, `yank_range`, `change_range`, `indent_range`,
3576// `case_range`). They set `pending_register` from the caller-supplied char
3577// before delegating to the existing internal helpers so register semantics
3578// (unnamed `"`, named `"a`–`"z`, delete ring) are honoured exactly as in the
3579// FSM path.
3580//
3581// Do NOT call `run_operator_over_range` for Indent/Outdent or the three case
3582// operators — those share the FSM path but have dedicated parameter shapes
3583// (signed count, Operator-as-CaseOp) that map more cleanly to their own
3584// helpers.
3585
3586/// Delete the range `[start, end)` (interpretation determined by `kind`) and
3587/// stash the deleted text in `register`. `'"'` is the unnamed register.
3588pub(crate) fn delete_range_bridge<H: crate::types::Host>(
3589    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3590    start: (usize, usize),
3591    end: (usize, usize),
3592    kind: RangeKind,
3593    register: char,
3594) {
3595    ed.vim.pending_register = Some(register);
3596    run_operator_over_range(ed, Operator::Delete, start, end, kind);
3597}
3598
3599/// Yank (copy) the range `[start, end)` into `register` without mutating the
3600/// buffer. `'"'` is the unnamed register.
3601pub(crate) fn yank_range_bridge<H: crate::types::Host>(
3602    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3603    start: (usize, usize),
3604    end: (usize, usize),
3605    kind: RangeKind,
3606    register: char,
3607) {
3608    ed.vim.pending_register = Some(register);
3609    run_operator_over_range(ed, Operator::Yank, start, end, kind);
3610}
3611
3612/// Delete the range `[start, end)` and enter Insert mode (vim `c` operator).
3613/// The deleted text is stashed in `register`. Mode transitions to Insert on
3614/// return; the caller must not issue further normal-mode ops until the insert
3615/// session ends.
3616pub(crate) fn change_range_bridge<H: crate::types::Host>(
3617    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3618    start: (usize, usize),
3619    end: (usize, usize),
3620    kind: RangeKind,
3621    register: char,
3622) {
3623    ed.vim.pending_register = Some(register);
3624    run_operator_over_range(ed, Operator::Change, start, end, kind);
3625}
3626
3627/// Indent (`count > 0`) or outdent (`count < 0`) the row span `[start.0,
3628/// end.0]`. `shiftwidth` overrides the editor's `settings().shiftwidth` for
3629/// this call; pass `0` to use the editor setting. The column parts of `start`
3630/// / `end` are ignored — indent is always linewise.
3631pub(crate) fn indent_range_bridge<H: crate::types::Host>(
3632    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3633    start: (usize, usize),
3634    end: (usize, usize),
3635    count: i32,
3636    shiftwidth: u32,
3637) {
3638    if count == 0 {
3639        return;
3640    }
3641    let (top_row, bot_row) = if start.0 <= end.0 {
3642        (start.0, end.0)
3643    } else {
3644        (end.0, start.0)
3645    };
3646    // Temporarily override shiftwidth when the caller provides one.
3647    let original_sw = ed.settings().shiftwidth;
3648    if shiftwidth > 0 {
3649        ed.settings_mut().shiftwidth = shiftwidth as usize;
3650    }
3651    ed.push_undo();
3652    let abs_count = count.unsigned_abs() as usize;
3653    if count > 0 {
3654        indent_rows(ed, top_row, bot_row, abs_count);
3655    } else {
3656        outdent_rows(ed, top_row, bot_row, abs_count);
3657    }
3658    if shiftwidth > 0 {
3659        ed.settings_mut().shiftwidth = original_sw;
3660    }
3661    ed.vim.mode = Mode::Normal;
3662}
3663
3664/// Apply a case transformation (`Uppercase` / `Lowercase` / `ToggleCase`) to
3665/// the range `[start, end)`. Only the three case `Operator` variants are valid;
3666/// other variants are silently ignored (no-op).
3667pub(crate) fn case_range_bridge<H: crate::types::Host>(
3668    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3669    start: (usize, usize),
3670    end: (usize, usize),
3671    kind: RangeKind,
3672    op: Operator,
3673) {
3674    match op {
3675        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {}
3676        _ => return,
3677    }
3678    let (top, bot) = order(start, end);
3679    apply_case_op_to_selection(ed, op, top, bot, kind);
3680}
3681
3682// ─── Phase 4e pub block-shape range-mutation bridges ───────────────────────
3683//
3684// These are `pub(crate)` entry points called by the four new pub methods on
3685// `Editor` (`delete_block`, `yank_block`, `change_block`, `indent_block`).
3686// They set `pending_register` from the caller-supplied char then delegate to
3687// `apply_block_operator` (after temporarily installing the 4-corner block as
3688// the engine's virtual VisualBlock selection). The editor's VisualBlock state
3689// fields (`block_anchor`, `block_vcol`) are overwritten, the op fires, then
3690// the fields are restored to their pre-call values. This ensures the engine's
3691// register / undo / mode semantics are exercised without requiring the caller
3692// to already be in VisualBlock mode.
3693//
3694// `indent_block` is a separate helper — it does not use `apply_block_operator`
3695// because indent/outdent are always linewise for blocks (vim behaviour).
3696
3697/// Delete a rectangular VisualBlock selection. `top_row`/`bot_row` are
3698/// inclusive line bounds; `left_col`/`right_col` are inclusive char-column
3699/// bounds. Short lines that don't reach `right_col` lose only the chars
3700/// that exist (ragged-edge, matching engine FSM). `register` is honoured;
3701/// `'"'` selects the unnamed register.
3702pub(crate) fn delete_block_bridge<H: crate::types::Host>(
3703    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3704    top_row: usize,
3705    bot_row: usize,
3706    left_col: usize,
3707    right_col: usize,
3708    register: char,
3709) {
3710    ed.vim.pending_register = Some(register);
3711    let saved_anchor = ed.vim.block_anchor;
3712    let saved_vcol = ed.vim.block_vcol;
3713    ed.vim.block_anchor = (top_row, left_col);
3714    ed.vim.block_vcol = right_col;
3715    // Compute clamped col before the mutable borrow for buf_set_cursor_rc.
3716    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
3717    // Place cursor at bot_row / right_col so block_bounds resolves correctly.
3718    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
3719    apply_block_operator(ed, Operator::Delete);
3720    // Restore — block_anchor/vcol are only meaningful in VisualBlock mode;
3721    // after the op we're in Normal so restoring is a no-op for the user but
3722    // keeps state coherent if the caller inspects fields.
3723    ed.vim.block_anchor = saved_anchor;
3724    ed.vim.block_vcol = saved_vcol;
3725}
3726
3727/// Yank a rectangular VisualBlock selection into `register`.
3728pub(crate) fn yank_block_bridge<H: crate::types::Host>(
3729    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3730    top_row: usize,
3731    bot_row: usize,
3732    left_col: usize,
3733    right_col: usize,
3734    register: char,
3735) {
3736    ed.vim.pending_register = Some(register);
3737    let saved_anchor = ed.vim.block_anchor;
3738    let saved_vcol = ed.vim.block_vcol;
3739    ed.vim.block_anchor = (top_row, left_col);
3740    ed.vim.block_vcol = right_col;
3741    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
3742    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
3743    apply_block_operator(ed, Operator::Yank);
3744    ed.vim.block_anchor = saved_anchor;
3745    ed.vim.block_vcol = saved_vcol;
3746}
3747
3748/// Delete a rectangular VisualBlock selection and enter Insert mode (`c`).
3749/// The deleted text is stashed in `register`. Mode is Insert on return.
3750pub(crate) fn change_block_bridge<H: crate::types::Host>(
3751    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3752    top_row: usize,
3753    bot_row: usize,
3754    left_col: usize,
3755    right_col: usize,
3756    register: char,
3757) {
3758    ed.vim.pending_register = Some(register);
3759    let saved_anchor = ed.vim.block_anchor;
3760    let saved_vcol = ed.vim.block_vcol;
3761    ed.vim.block_anchor = (top_row, left_col);
3762    ed.vim.block_vcol = right_col;
3763    let clamped = right_col.min(buf_line_chars(&ed.buffer, bot_row).saturating_sub(1));
3764    buf_set_cursor_rc(&mut ed.buffer, bot_row, clamped);
3765    apply_block_operator(ed, Operator::Change);
3766    ed.vim.block_anchor = saved_anchor;
3767    ed.vim.block_vcol = saved_vcol;
3768}
3769
3770/// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
3771/// Column bounds are ignored — vim's block indent is always linewise.
3772/// `count == 0` is a no-op.
3773pub(crate) fn indent_block_bridge<H: crate::types::Host>(
3774    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3775    top_row: usize,
3776    bot_row: usize,
3777    count: i32,
3778) {
3779    if count == 0 {
3780        return;
3781    }
3782    ed.push_undo();
3783    let abs = count.unsigned_abs() as usize;
3784    if count > 0 {
3785        indent_rows(ed, top_row, bot_row, abs);
3786    } else {
3787        outdent_rows(ed, top_row, bot_row, abs);
3788    }
3789    ed.vim.mode = Mode::Normal;
3790}
3791
3792/// Auto-indent (v1 dumb shiftwidth) the row span `[start.0, end.0]`. Column
3793/// parts are ignored — auto-indent is always linewise. See
3794/// `auto_indent_rows` for the algorithm and its v1 limitations.
3795pub(crate) fn auto_indent_range_bridge<H: crate::types::Host>(
3796    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3797    start: (usize, usize),
3798    end: (usize, usize),
3799) {
3800    let (top_row, bot_row) = if start.0 <= end.0 {
3801        (start.0, end.0)
3802    } else {
3803        (end.0, start.0)
3804    };
3805    ed.push_undo();
3806    auto_indent_rows(ed, top_row, bot_row);
3807    ed.vim.mode = Mode::Normal;
3808}
3809
3810// ─── Phase 4b pub text-object resolution bridges ───────────────────────────
3811//
3812// These are `pub(crate)` entry points called by the four new pub methods on
3813// `Editor` (`text_object_inner_word`, `text_object_around_word`,
3814// `text_object_inner_big_word`, `text_object_around_big_word`). They delegate
3815// to `word_text_object` — the existing private resolver — without touching any
3816// operator, register, or mode state. Pure functions: only `&Editor` required.
3817
3818/// Resolve the range of `iw` (inner word) at the current cursor position.
3819/// Returns `None` if no word exists at the cursor.
3820pub(crate) fn text_object_inner_word_bridge<H: crate::types::Host>(
3821    ed: &Editor<hjkl_buffer::Buffer, H>,
3822) -> Option<((usize, usize), (usize, usize))> {
3823    word_text_object(ed, true, false)
3824}
3825
3826/// Resolve the range of `aw` (around word) at the current cursor position.
3827/// Includes trailing whitespace (or leading whitespace if no trailing exists).
3828pub(crate) fn text_object_around_word_bridge<H: crate::types::Host>(
3829    ed: &Editor<hjkl_buffer::Buffer, H>,
3830) -> Option<((usize, usize), (usize, usize))> {
3831    word_text_object(ed, false, false)
3832}
3833
3834/// Resolve the range of `iW` (inner WORD) at the current cursor position.
3835/// A WORD is any run of non-whitespace characters (no punctuation splitting).
3836pub(crate) fn text_object_inner_big_word_bridge<H: crate::types::Host>(
3837    ed: &Editor<hjkl_buffer::Buffer, H>,
3838) -> Option<((usize, usize), (usize, usize))> {
3839    word_text_object(ed, true, true)
3840}
3841
3842/// Resolve the range of `aW` (around WORD) at the current cursor position.
3843/// Includes trailing whitespace (or leading whitespace if no trailing exists).
3844pub(crate) fn text_object_around_big_word_bridge<H: crate::types::Host>(
3845    ed: &Editor<hjkl_buffer::Buffer, H>,
3846) -> Option<((usize, usize), (usize, usize))> {
3847    word_text_object(ed, false, true)
3848}
3849
3850// ─── Phase 4c pub text-object resolution bridges (quote + bracket) ──────────
3851//
3852// `pub(crate)` entry points called by the four new pub methods on `Editor`
3853// (`text_object_inner_quote`, `text_object_around_quote`,
3854// `text_object_inner_bracket`, `text_object_around_bracket`). They delegate to
3855// `quote_text_object` / `bracket_text_object` — the existing private resolvers
3856// — without touching any operator, register, or mode state.
3857//
3858// `bracket_text_object` returns `Option<(Pos, Pos, RangeKind)>`; the bridges
3859// strip the `RangeKind` tag so callers see a uniform
3860// `Option<((usize,usize),(usize,usize))>` shape, consistent with 4b.
3861
3862/// Resolve the range of `i<quote>` (inner quote) at the current cursor
3863/// position. `quote` is one of `'"'`, `'\''`, or `` '`' ``. Returns `None`
3864/// when the cursor's line contains fewer than two occurrences of `quote`.
3865pub(crate) fn text_object_inner_quote_bridge<H: crate::types::Host>(
3866    ed: &Editor<hjkl_buffer::Buffer, H>,
3867    quote: char,
3868) -> Option<((usize, usize), (usize, usize))> {
3869    quote_text_object(ed, quote, true)
3870}
3871
3872/// Resolve the range of `a<quote>` (around quote) at the current cursor
3873/// position. Includes surrounding whitespace on one side per vim semantics.
3874pub(crate) fn text_object_around_quote_bridge<H: crate::types::Host>(
3875    ed: &Editor<hjkl_buffer::Buffer, H>,
3876    quote: char,
3877) -> Option<((usize, usize), (usize, usize))> {
3878    quote_text_object(ed, quote, false)
3879}
3880
3881/// Resolve the range of `i<bracket>` (inner bracket pair). `open` must be
3882/// one of `'('`, `'{'`, `'['`, `'<'`; the corresponding close is derived
3883/// internally. Returns `None` when no enclosing pair is found. The returned
3884/// range excludes the bracket characters themselves. Multi-line bracket pairs
3885/// whose content spans more than one line are reported as a charwise range
3886/// covering the first content character through the last content character
3887/// (RangeKind metadata is stripped — callers receive start/end only).
3888pub(crate) fn text_object_inner_bracket_bridge<H: crate::types::Host>(
3889    ed: &Editor<hjkl_buffer::Buffer, H>,
3890    open: char,
3891) -> Option<((usize, usize), (usize, usize))> {
3892    bracket_text_object(ed, open, true).map(|(s, e, _kind)| (s, e))
3893}
3894
3895/// Resolve the range of `a<bracket>` (around bracket pair). Includes the
3896/// bracket characters themselves. `open` must be one of `'('`, `'{'`, `'['`,
3897/// `'<'`.
3898pub(crate) fn text_object_around_bracket_bridge<H: crate::types::Host>(
3899    ed: &Editor<hjkl_buffer::Buffer, H>,
3900    open: char,
3901) -> Option<((usize, usize), (usize, usize))> {
3902    bracket_text_object(ed, open, false).map(|(s, e, _kind)| (s, e))
3903}
3904
3905// ── Sentence bridges (is / as) ─────────────────────────────────────────────
3906
3907/// Resolve the range of `is` (inner sentence) at the cursor. Excludes
3908/// trailing whitespace.
3909pub(crate) fn text_object_inner_sentence_bridge<H: crate::types::Host>(
3910    ed: &Editor<hjkl_buffer::Buffer, H>,
3911) -> Option<((usize, usize), (usize, usize))> {
3912    sentence_text_object(ed, true)
3913}
3914
3915/// Resolve the range of `as` (around sentence) at the cursor. Includes
3916/// trailing whitespace.
3917pub(crate) fn text_object_around_sentence_bridge<H: crate::types::Host>(
3918    ed: &Editor<hjkl_buffer::Buffer, H>,
3919) -> Option<((usize, usize), (usize, usize))> {
3920    sentence_text_object(ed, false)
3921}
3922
3923// ── Paragraph bridges (ip / ap) ────────────────────────────────────────────
3924
3925/// Resolve the range of `ip` (inner paragraph) at the cursor. A paragraph
3926/// is a block of non-blank lines bounded by blank lines or buffer edges.
3927pub(crate) fn text_object_inner_paragraph_bridge<H: crate::types::Host>(
3928    ed: &Editor<hjkl_buffer::Buffer, H>,
3929) -> Option<((usize, usize), (usize, usize))> {
3930    paragraph_text_object(ed, true)
3931}
3932
3933/// Resolve the range of `ap` (around paragraph) at the cursor. Includes one
3934/// trailing blank line when present.
3935pub(crate) fn text_object_around_paragraph_bridge<H: crate::types::Host>(
3936    ed: &Editor<hjkl_buffer::Buffer, H>,
3937) -> Option<((usize, usize), (usize, usize))> {
3938    paragraph_text_object(ed, false)
3939}
3940
3941// ── Tag bridges (it / at) ──────────────────────────────────────────────────
3942
3943/// Resolve the range of `it` (inner tag) at the cursor. Matches XML/HTML-style
3944/// `<tag>...</tag>` pairs; returns the range of inner content between the open
3945/// and close tags.
3946pub(crate) fn text_object_inner_tag_bridge<H: crate::types::Host>(
3947    ed: &Editor<hjkl_buffer::Buffer, H>,
3948) -> Option<((usize, usize), (usize, usize))> {
3949    tag_text_object(ed, true)
3950}
3951
3952/// Resolve the range of `at` (around tag) at the cursor. Includes the open
3953/// and close tag delimiters themselves.
3954pub(crate) fn text_object_around_tag_bridge<H: crate::types::Host>(
3955    ed: &Editor<hjkl_buffer::Buffer, H>,
3956) -> Option<((usize, usize), (usize, usize))> {
3957    tag_text_object(ed, false)
3958}
3959
3960/// Greedy word-wrap the rows in `[top, bot]` to `settings.textwidth`.
3961/// Splits on blank-line boundaries so paragraph structure is
3962/// preserved. Each paragraph's words are joined with single spaces
3963/// before re-wrapping.
3964fn reflow_rows<H: crate::types::Host>(
3965    ed: &mut Editor<hjkl_buffer::Buffer, H>,
3966    top: usize,
3967    bot: usize,
3968) {
3969    let width = ed.settings().textwidth.max(1);
3970    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
3971    let bot = bot.min(lines.len().saturating_sub(1));
3972    if top > bot {
3973        return;
3974    }
3975    let original = lines[top..=bot].to_vec();
3976    let mut wrapped: Vec<String> = Vec::new();
3977    let mut paragraph: Vec<String> = Vec::new();
3978    let flush = |para: &mut Vec<String>, out: &mut Vec<String>, width: usize| {
3979        if para.is_empty() {
3980            return;
3981        }
3982        let words = para.join(" ");
3983        let mut current = String::new();
3984        for word in words.split_whitespace() {
3985            let extra = if current.is_empty() {
3986                word.chars().count()
3987            } else {
3988                current.chars().count() + 1 + word.chars().count()
3989            };
3990            if extra > width && !current.is_empty() {
3991                out.push(std::mem::take(&mut current));
3992                current.push_str(word);
3993            } else if current.is_empty() {
3994                current.push_str(word);
3995            } else {
3996                current.push(' ');
3997                current.push_str(word);
3998            }
3999        }
4000        if !current.is_empty() {
4001            out.push(current);
4002        }
4003        para.clear();
4004    };
4005    for line in &original {
4006        if line.trim().is_empty() {
4007            flush(&mut paragraph, &mut wrapped, width);
4008            wrapped.push(String::new());
4009        } else {
4010            paragraph.push(line.clone());
4011        }
4012    }
4013    flush(&mut paragraph, &mut wrapped, width);
4014
4015    // Splice back. push_undo above means `u` reverses.
4016    let after: Vec<String> = lines.split_off(bot + 1);
4017    lines.truncate(top);
4018    lines.extend(wrapped);
4019    lines.extend(after);
4020    ed.restore(lines, (top, 0));
4021    ed.mark_content_dirty();
4022}
4023
4024/// Transform the range `[top, bot]` (vim `RangeKind`) in place with
4025/// the given case operator. Cursor lands on `top` afterward — vim
4026/// convention for `gU{motion}` / `gu{motion}` / `g~{motion}`.
4027/// Preserves the textarea yank buffer (vim's case operators don't
4028/// touch registers).
4029fn apply_case_op_to_selection<H: crate::types::Host>(
4030    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4031    op: Operator,
4032    top: (usize, usize),
4033    bot: (usize, usize),
4034    kind: RangeKind,
4035) {
4036    use hjkl_buffer::Edit;
4037    ed.push_undo();
4038    let saved_yank = ed.yank().to_string();
4039    let saved_yank_linewise = ed.vim.yank_linewise;
4040    let selection = cut_vim_range(ed, top, bot, kind);
4041    let transformed = match op {
4042        Operator::Uppercase => selection.to_uppercase(),
4043        Operator::Lowercase => selection.to_lowercase(),
4044        Operator::ToggleCase => toggle_case_str(&selection),
4045        _ => unreachable!(),
4046    };
4047    if !transformed.is_empty() {
4048        let cursor = buf_cursor_pos(&ed.buffer);
4049        ed.mutate_edit(Edit::InsertStr {
4050            at: cursor,
4051            text: transformed,
4052        });
4053    }
4054    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4055    ed.push_buffer_cursor_to_textarea();
4056    ed.set_yank(saved_yank);
4057    ed.vim.yank_linewise = saved_yank_linewise;
4058    ed.vim.mode = Mode::Normal;
4059}
4060
4061/// Prepend `count * shiftwidth` spaces to each row in `[top, bot]`.
4062/// Rows that are empty are skipped (vim leaves blank lines alone when
4063/// indenting). `shiftwidth` is read from `editor.settings()` so
4064/// `:set shiftwidth=N` takes effect on the next operation.
4065fn indent_rows<H: crate::types::Host>(
4066    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4067    top: usize,
4068    bot: usize,
4069    count: usize,
4070) {
4071    ed.sync_buffer_content_from_textarea();
4072    let width = ed.settings().shiftwidth * count.max(1);
4073    let pad: String = " ".repeat(width);
4074    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4075    let bot = bot.min(lines.len().saturating_sub(1));
4076    for line in lines.iter_mut().take(bot + 1).skip(top) {
4077        if !line.is_empty() {
4078            line.insert_str(0, &pad);
4079        }
4080    }
4081    // Restore cursor to first non-blank of the top row so the next
4082    // vertical motion aims sensibly — matches vim's `>>` convention.
4083    ed.restore(lines, (top, 0));
4084    move_first_non_whitespace(ed);
4085}
4086
4087/// Remove up to `count * shiftwidth` leading spaces (or tabs) from
4088/// each row in `[top, bot]`. Rows with less leading whitespace have
4089/// all their indent stripped, not clipped to zero length.
4090fn outdent_rows<H: crate::types::Host>(
4091    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4092    top: usize,
4093    bot: usize,
4094    count: usize,
4095) {
4096    ed.sync_buffer_content_from_textarea();
4097    let width = ed.settings().shiftwidth * count.max(1);
4098    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4099    let bot = bot.min(lines.len().saturating_sub(1));
4100    for line in lines.iter_mut().take(bot + 1).skip(top) {
4101        let strip: usize = line
4102            .chars()
4103            .take(width)
4104            .take_while(|c| *c == ' ' || *c == '\t')
4105            .count();
4106        if strip > 0 {
4107            let byte_len: usize = line.chars().take(strip).map(|c| c.len_utf8()).sum();
4108            line.drain(..byte_len);
4109        }
4110    }
4111    ed.restore(lines, (top, 0));
4112    move_first_non_whitespace(ed);
4113}
4114
4115/// Count the number of open/close bracket pairs on a single line for the
4116/// auto-indent depth scanner. Only bare bracket scanning — does NOT handle
4117/// string literals or comments (v1 limitation, documented on
4118/// `auto_indent_range_bridge`).
4119/// Net bracket count `(open - close)` for a single line, skipping
4120/// brackets inside `//` line comments, `"..."` string literals, and
4121/// `'X'` char literals.
4122///
4123/// String / char escapes (`\"`, `\'`, `\\`) are honored so the closing
4124/// quote isn't missed when the literal contains a backslash.
4125///
4126/// Limitations:
4127/// - Block comments `/* ... */` are NOT tracked across lines (a single
4128///   line `/* foo { bar } */` is correctly skipped only because the
4129///   `/*` and `*/` are on the same line and we'd see `{` after `/*`).
4130///   For v1 we leave this since block comments mid-code are rare.
4131/// - Raw string literals `r"..."` / `r#"..."#` are NOT special-cased.
4132/// - Lifetime annotations like `'a` look like an unterminated char
4133///   literal — handled by the heuristic that a char literal MUST close
4134///   within the line; if the closing `'` isn't found, treat the `'` as
4135///   a normal character (lifetime).
4136///
4137/// Pre-fix the scan was naive — `//! ... }` on a doc comment
4138/// decremented depth, cascading wrong indentation through the rest of
4139/// the file. This caused ~19% of lines to mis-indent on a real Rust
4140/// source diagnostic.
4141fn bracket_net(line: &str) -> i32 {
4142    let mut net: i32 = 0;
4143    let mut chars = line.chars().peekable();
4144    while let Some(ch) = chars.next() {
4145        match ch {
4146            // `//` → rest of line is a comment, stop.
4147            '/' if chars.peek() == Some(&'/') => return net,
4148            '"' => {
4149                // String literal — consume until unescaped closing `"`.
4150                while let Some(c) = chars.next() {
4151                    match c {
4152                        '\\' => {
4153                            chars.next();
4154                        } // skip escape byte
4155                        '"' => break,
4156                        _ => {}
4157                    }
4158                }
4159            }
4160            '\'' => {
4161                // Char literal OR lifetime. A char literal closes within
4162                // a few chars (one or two for escapes). A lifetime is
4163                // `'ident` with no closing quote.
4164                //
4165                // Strategy: peek ahead for a closing `'`. If found
4166                // within ~4 chars, consume as char literal. Otherwise
4167                // treat the `'` as the start of a lifetime — leave the
4168                // remaining chars to be scanned normally.
4169                let saved: Vec<char> = chars.clone().take(5).collect();
4170                let close_idx = if saved.first() == Some(&'\\') {
4171                    saved.iter().skip(2).position(|&c| c == '\'').map(|p| p + 2)
4172                } else {
4173                    saved.iter().skip(1).position(|&c| c == '\'').map(|p| p + 1)
4174                };
4175                if let Some(idx) = close_idx {
4176                    for _ in 0..=idx {
4177                        chars.next();
4178                    }
4179                }
4180                // If no close found, leave chars alone — lifetime path.
4181            }
4182            '{' | '(' | '[' => net += 1,
4183            '}' | ')' | ']' => net -= 1,
4184            _ => {}
4185        }
4186    }
4187    net
4188}
4189
4190/// Reindent rows `[top, bot]` using shiftwidth-based bracket-depth counting.
4191///
4192/// The indent for each line is computed as follows:
4193/// 1. Scan all rows from 0 up to the target row, accumulating a bracket depth
4194///    (`depth`) from net open − close brackets per line. The scan starts at row
4195///    0 to give correct depth for code that appears mid-buffer.
4196/// 2. For the target line, peek at its first non-whitespace character:
4197///    if it is a close bracket (`}`, `)`, `]`) then `effective_depth =
4198///    depth.saturating_sub(1)`; otherwise `effective_depth = depth`.
4199/// 3. Strip the line's existing leading whitespace and prepend
4200///    `effective_depth × indent_unit` where `indent_unit` is `"\t"` when
4201///    `expandtab == false` or `" " × shiftwidth` when `expandtab == true`.
4202/// 4. Empty / whitespace-only lines are left empty (no trailing whitespace).
4203/// 5. After computing the new line, advance `depth` by the line's bracket
4204///    net count (open − close), where the leading close-bracket already
4205///    contributed `−1` to the net of its own line.
4206///
4207/// **v1 limitation**: the bracket scan is naive — it does not skip brackets
4208/// inside string literals (`"{"`, `'['`) or comments (`// {`). Code with
4209/// such patterns will produce incorrect indent depths. Tree-sitter / LSP
4210/// indentation is deferred to a follow-up.
4211fn auto_indent_rows<H: crate::types::Host>(
4212    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4213    top: usize,
4214    bot: usize,
4215) {
4216    ed.sync_buffer_content_from_textarea();
4217    let shiftwidth = ed.settings().shiftwidth;
4218    let expandtab = ed.settings().expandtab;
4219    let indent_unit: String = if expandtab {
4220        " ".repeat(shiftwidth)
4221    } else {
4222        "\t".to_string()
4223    };
4224
4225    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4226    let bot = bot.min(lines.len().saturating_sub(1));
4227
4228    // Accumulate bracket depth from row 0 up to `top - 1` so we start with
4229    // the correct depth for the first line of the target range.
4230    let mut depth: i32 = 0;
4231    for line in lines.iter().take(top) {
4232        depth += bracket_net(line);
4233        if depth < 0 {
4234            depth = 0;
4235        }
4236    }
4237
4238    for line in lines.iter_mut().take(bot + 1).skip(top) {
4239        let trimmed_owned = line.trim_start().to_owned();
4240        // Empty / whitespace-only lines stay empty.
4241        if trimmed_owned.is_empty() {
4242            *line = String::new();
4243            // depth contribution from an empty line is zero; no bracket scan needed.
4244            continue;
4245        }
4246
4247        // Detect leading close-bracket for effective depth.
4248        let starts_with_close = trimmed_owned
4249            .chars()
4250            .next()
4251            .is_some_and(|c| matches!(c, '}' | ')' | ']'));
4252        // Chain continuation: a line starting with `.` (e.g. `.foo()`)
4253        // hangs off the previous expression and gets one extra indent
4254        // level, matching cargo fmt / clang-format conventions for
4255        // method chains like:
4256        //   let x = foo()
4257        //       .bar()
4258        //       .baz();
4259        // Range expressions (`..`) and try-chains (`?.`) are out of
4260        // scope for v1 — single leading `.` is the common case.
4261        let starts_with_dot = trimmed_owned.starts_with('.')
4262            && !trimmed_owned.starts_with("..")
4263            && !trimmed_owned.starts_with(".;");
4264        let effective_depth = if starts_with_close {
4265            depth.saturating_sub(1)
4266        } else if starts_with_dot {
4267            depth.saturating_add(1)
4268        } else {
4269            depth
4270        } as usize;
4271
4272        // Build new line: indent × depth + stripped content.
4273        let new_line = format!("{}{}", indent_unit.repeat(effective_depth), trimmed_owned);
4274
4275        // Advance depth by this line's net bracket count (scan trimmed content).
4276        depth += bracket_net(&trimmed_owned);
4277        if depth < 0 {
4278            depth = 0;
4279        }
4280
4281        *line = new_line;
4282    }
4283
4284    // Restore cursor to the first non-blank of `top` (vim parity for `==`).
4285    ed.restore(lines, (top, 0));
4286    move_first_non_whitespace(ed);
4287    // Record the touched row range so the host can display a visual flash.
4288    ed.last_indent_range = Some((top, bot));
4289}
4290
4291fn toggle_case_str(s: &str) -> String {
4292    s.chars()
4293        .map(|c| {
4294            if c.is_lowercase() {
4295                c.to_uppercase().next().unwrap_or(c)
4296            } else if c.is_uppercase() {
4297                c.to_lowercase().next().unwrap_or(c)
4298            } else {
4299                c
4300            }
4301        })
4302        .collect()
4303}
4304
4305fn order(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
4306    if a <= b { (a, b) } else { (b, a) }
4307}
4308
4309/// Clamp the buffer cursor to normal-mode valid position: col may not
4310/// exceed `line.chars().count().saturating_sub(1)` (or 0 on an empty
4311/// line). Vim applies this clamp on every return to Normal mode after an
4312/// operator or Esc-from-insert.
4313fn clamp_cursor_to_normal_mode<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
4314    let (row, col) = ed.cursor();
4315    let line_chars = buf_line_chars(&ed.buffer, row);
4316    let max_col = line_chars.saturating_sub(1);
4317    if col > max_col {
4318        buf_set_cursor_rc(&mut ed.buffer, row, max_col);
4319        ed.push_buffer_cursor_to_textarea();
4320    }
4321}
4322
4323// ─── dd/cc/yy ──────────────────────────────────────────────────────────────
4324
4325fn execute_line_op<H: crate::types::Host>(
4326    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4327    op: Operator,
4328    count: usize,
4329) {
4330    let (row, col) = ed.cursor();
4331    let total = buf_row_count(&ed.buffer);
4332    let end_row = (row + count.saturating_sub(1)).min(total.saturating_sub(1));
4333
4334    match op {
4335        Operator::Yank => {
4336            // yy must not move the cursor.
4337            let text = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
4338            if !text.is_empty() {
4339                ed.record_yank_to_host(text.clone());
4340                ed.record_yank(text, true);
4341            }
4342            // Vim `:h '[` / `:h ']`: yy/Nyy — linewise yank; `[` =
4343            // (top_row, 0), `]` = (bot_row, last_col).
4344            let last_col = buf_line_chars(&ed.buffer, end_row).saturating_sub(1);
4345            ed.set_mark('[', (row, 0));
4346            ed.set_mark(']', (end_row, last_col));
4347            buf_set_cursor_rc(&mut ed.buffer, row, col);
4348            ed.push_buffer_cursor_to_textarea();
4349            ed.vim.mode = Mode::Normal;
4350        }
4351        Operator::Delete => {
4352            ed.push_undo();
4353            let deleted_through_last = end_row + 1 >= total;
4354            cut_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
4355            // Vim's `dd` / `Ndd` leaves the cursor on the *first
4356            // non-blank* of the line that now occupies `row` — or, if
4357            // the deletion consumed the last line, the line above it.
4358            let total_after = buf_row_count(&ed.buffer);
4359            let raw_target = if deleted_through_last {
4360                row.saturating_sub(1).min(total_after.saturating_sub(1))
4361            } else {
4362                row.min(total_after.saturating_sub(1))
4363            };
4364            // Clamp off the trailing phantom empty row that arises from a
4365            // buffer with a trailing newline (stored as ["...", ""]). If
4366            // the target row is the trailing empty row and there is a real
4367            // content row above it, use that instead — matching vim's view
4368            // that the trailing `\n` is a terminator, not a separator.
4369            let target_row = if raw_target > 0
4370                && raw_target + 1 == total_after
4371                && buf_line(&ed.buffer, raw_target)
4372                    .map(str::is_empty)
4373                    .unwrap_or(false)
4374            {
4375                raw_target - 1
4376            } else {
4377                raw_target
4378            };
4379            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
4380            ed.push_buffer_cursor_to_textarea();
4381            move_first_non_whitespace(ed);
4382            ed.sticky_col = Some(ed.cursor().1);
4383            ed.vim.mode = Mode::Normal;
4384            // Vim `:h '[` / `:h ']`: dd/Ndd — both marks park at the
4385            // post-delete cursor position (the join point).
4386            let pos = ed.cursor();
4387            ed.set_mark('[', pos);
4388            ed.set_mark(']', pos);
4389        }
4390        Operator::Change => {
4391            // `cc` / `3cc`: wipe contents of the covered lines but leave
4392            // a single blank line so insert-mode opens on it. Done as two
4393            // edits: drop rows past the first, then clear row `row`.
4394            use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4395            // Vim `:h '[`: stash change start for `]` deferral on insert-exit.
4396            ed.vim.change_mark_start = Some((row, 0));
4397            ed.push_undo();
4398            ed.sync_buffer_content_from_textarea();
4399            // Read the cut payload first so yank reflects every line.
4400            let payload = read_vim_range(ed, (row, col), (end_row, 0), RangeKind::Linewise);
4401            if end_row > row {
4402                ed.mutate_edit(Edit::DeleteRange {
4403                    start: Position::new(row + 1, 0),
4404                    end: Position::new(end_row, 0),
4405                    kind: BufKind::Line,
4406                });
4407            }
4408            let line_chars = buf_line_chars(&ed.buffer, row);
4409            if line_chars > 0 {
4410                ed.mutate_edit(Edit::DeleteRange {
4411                    start: Position::new(row, 0),
4412                    end: Position::new(row, line_chars),
4413                    kind: BufKind::Char,
4414                });
4415            }
4416            if !payload.is_empty() {
4417                ed.record_yank_to_host(payload.clone());
4418                ed.record_delete(payload, true);
4419            }
4420            buf_set_cursor_rc(&mut ed.buffer, row, 0);
4421            ed.push_buffer_cursor_to_textarea();
4422            begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4423        }
4424        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4425            // `gUU` / `guu` / `g~~` — linewise case transform over
4426            // [row, end_row]. Preserve cursor on `row` (first non-blank
4427            // lines up with vim's behaviour).
4428            apply_case_op_to_selection(ed, op, (row, col), (end_row, 0), RangeKind::Linewise);
4429            // After case-op on a linewise range vim puts the cursor on
4430            // the first non-blank of the starting line.
4431            move_first_non_whitespace(ed);
4432        }
4433        Operator::Indent | Operator::Outdent => {
4434            // `>>` / `N>>` / `<<` / `N<<` — linewise indent / outdent.
4435            ed.push_undo();
4436            if op == Operator::Indent {
4437                indent_rows(ed, row, end_row, 1);
4438            } else {
4439                outdent_rows(ed, row, end_row, 1);
4440            }
4441            ed.sticky_col = Some(ed.cursor().1);
4442            ed.vim.mode = Mode::Normal;
4443        }
4444        // No doubled form — `zfzf` is two consecutive `zf` chords.
4445        Operator::Fold => unreachable!("Fold has no line-op double"),
4446        Operator::Reflow => {
4447            // `gqq` / `Ngqq` — reflow `count` rows starting at the cursor.
4448            ed.push_undo();
4449            reflow_rows(ed, row, end_row);
4450            move_first_non_whitespace(ed);
4451            ed.sticky_col = Some(ed.cursor().1);
4452            ed.vim.mode = Mode::Normal;
4453        }
4454        Operator::AutoIndent => {
4455            // `==` / `N==` — auto-indent `count` rows starting at cursor.
4456            ed.push_undo();
4457            auto_indent_rows(ed, row, end_row);
4458            ed.sticky_col = Some(ed.cursor().1);
4459            ed.vim.mode = Mode::Normal;
4460        }
4461    }
4462}
4463
4464// ─── Visual mode operators ─────────────────────────────────────────────────
4465
4466pub(crate) fn apply_visual_operator<H: crate::types::Host>(
4467    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4468    op: Operator,
4469) {
4470    match ed.vim.mode {
4471        Mode::VisualLine => {
4472            let cursor_row = buf_cursor_pos(&ed.buffer).row;
4473            let top = cursor_row.min(ed.vim.visual_line_anchor);
4474            let bot = cursor_row.max(ed.vim.visual_line_anchor);
4475            ed.vim.yank_linewise = true;
4476            match op {
4477                Operator::Yank => {
4478                    let text = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
4479                    if !text.is_empty() {
4480                        ed.record_yank_to_host(text.clone());
4481                        ed.record_yank(text, true);
4482                    }
4483                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4484                    ed.push_buffer_cursor_to_textarea();
4485                    ed.vim.mode = Mode::Normal;
4486                }
4487                Operator::Delete => {
4488                    ed.push_undo();
4489                    cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
4490                    ed.vim.mode = Mode::Normal;
4491                }
4492                Operator::Change => {
4493                    // Vim `Vc`: wipe the line contents but leave a blank
4494                    // line in place so insert-mode starts on an empty row.
4495                    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
4496                    ed.push_undo();
4497                    ed.sync_buffer_content_from_textarea();
4498                    let payload = read_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
4499                    if bot > top {
4500                        ed.mutate_edit(Edit::DeleteRange {
4501                            start: Position::new(top + 1, 0),
4502                            end: Position::new(bot, 0),
4503                            kind: BufKind::Line,
4504                        });
4505                    }
4506                    let line_chars = buf_line_chars(&ed.buffer, top);
4507                    if line_chars > 0 {
4508                        ed.mutate_edit(Edit::DeleteRange {
4509                            start: Position::new(top, 0),
4510                            end: Position::new(top, line_chars),
4511                            kind: BufKind::Char,
4512                        });
4513                    }
4514                    if !payload.is_empty() {
4515                        ed.record_yank_to_host(payload.clone());
4516                        ed.record_delete(payload, true);
4517                    }
4518                    buf_set_cursor_rc(&mut ed.buffer, top, 0);
4519                    ed.push_buffer_cursor_to_textarea();
4520                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4521                }
4522                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4523                    let bot = buf_cursor_pos(&ed.buffer)
4524                        .row
4525                        .max(ed.vim.visual_line_anchor);
4526                    apply_case_op_to_selection(ed, op, (top, 0), (bot, 0), RangeKind::Linewise);
4527                    move_first_non_whitespace(ed);
4528                }
4529                Operator::Indent | Operator::Outdent => {
4530                    ed.push_undo();
4531                    let (cursor_row, _) = ed.cursor();
4532                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4533                    if op == Operator::Indent {
4534                        indent_rows(ed, top, bot, 1);
4535                    } else {
4536                        outdent_rows(ed, top, bot, 1);
4537                    }
4538                    ed.vim.mode = Mode::Normal;
4539                }
4540                Operator::Reflow => {
4541                    ed.push_undo();
4542                    let (cursor_row, _) = ed.cursor();
4543                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4544                    reflow_rows(ed, top, bot);
4545                    ed.vim.mode = Mode::Normal;
4546                }
4547                Operator::AutoIndent => {
4548                    ed.push_undo();
4549                    let (cursor_row, _) = ed.cursor();
4550                    let bot = cursor_row.max(ed.vim.visual_line_anchor);
4551                    auto_indent_rows(ed, top, bot);
4552                    ed.vim.mode = Mode::Normal;
4553                }
4554                // Visual `zf` is handled inline in `handle_after_z`,
4555                // never routed through this dispatcher.
4556                Operator::Fold => unreachable!("Visual zf takes its own path"),
4557            }
4558        }
4559        Mode::Visual => {
4560            ed.vim.yank_linewise = false;
4561            let anchor = ed.vim.visual_anchor;
4562            let cursor = ed.cursor();
4563            let (top, bot) = order(anchor, cursor);
4564            match op {
4565                Operator::Yank => {
4566                    let text = read_vim_range(ed, top, bot, RangeKind::Inclusive);
4567                    if !text.is_empty() {
4568                        ed.record_yank_to_host(text.clone());
4569                        ed.record_yank(text, false);
4570                    }
4571                    buf_set_cursor_rc(&mut ed.buffer, top.0, top.1);
4572                    ed.push_buffer_cursor_to_textarea();
4573                    ed.vim.mode = Mode::Normal;
4574                }
4575                Operator::Delete => {
4576                    ed.push_undo();
4577                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
4578                    ed.vim.mode = Mode::Normal;
4579                }
4580                Operator::Change => {
4581                    ed.push_undo();
4582                    cut_vim_range(ed, top, bot, RangeKind::Inclusive);
4583                    begin_insert_noundo(ed, 1, InsertReason::AfterChange);
4584                }
4585                Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4586                    // Anchor stays where the visual selection started.
4587                    let anchor = ed.vim.visual_anchor;
4588                    let cursor = ed.cursor();
4589                    let (top, bot) = order(anchor, cursor);
4590                    apply_case_op_to_selection(ed, op, top, bot, RangeKind::Inclusive);
4591                }
4592                Operator::Indent | Operator::Outdent => {
4593                    ed.push_undo();
4594                    let anchor = ed.vim.visual_anchor;
4595                    let cursor = ed.cursor();
4596                    let (top, bot) = order(anchor, cursor);
4597                    if op == Operator::Indent {
4598                        indent_rows(ed, top.0, bot.0, 1);
4599                    } else {
4600                        outdent_rows(ed, top.0, bot.0, 1);
4601                    }
4602                    ed.vim.mode = Mode::Normal;
4603                }
4604                Operator::Reflow => {
4605                    ed.push_undo();
4606                    let anchor = ed.vim.visual_anchor;
4607                    let cursor = ed.cursor();
4608                    let (top, bot) = order(anchor, cursor);
4609                    reflow_rows(ed, top.0, bot.0);
4610                    ed.vim.mode = Mode::Normal;
4611                }
4612                Operator::AutoIndent => {
4613                    ed.push_undo();
4614                    let anchor = ed.vim.visual_anchor;
4615                    let cursor = ed.cursor();
4616                    let (top, bot) = order(anchor, cursor);
4617                    auto_indent_rows(ed, top.0, bot.0);
4618                    ed.vim.mode = Mode::Normal;
4619                }
4620                Operator::Fold => unreachable!("Visual zf takes its own path"),
4621            }
4622        }
4623        Mode::VisualBlock => apply_block_operator(ed, op),
4624        _ => {}
4625    }
4626}
4627
4628/// Compute `(top_row, bot_row, left_col, right_col)` for the current
4629/// VisualBlock selection. Columns are inclusive on both ends. Uses the
4630/// tracked virtual column (updated by h/l, preserved across j/k) so
4631/// ragged / empty rows don't collapse the block's width.
4632fn block_bounds<H: crate::types::Host>(
4633    ed: &Editor<hjkl_buffer::Buffer, H>,
4634) -> (usize, usize, usize, usize) {
4635    let (ar, ac) = ed.vim.block_anchor;
4636    let (cr, _) = ed.cursor();
4637    let cc = ed.vim.block_vcol;
4638    let top = ar.min(cr);
4639    let bot = ar.max(cr);
4640    let left = ac.min(cc);
4641    let right = ac.max(cc);
4642    (top, bot, left, right)
4643}
4644
4645/// Update the virtual column after a motion in VisualBlock mode.
4646/// Horizontal motions sync `block_vcol` to the new cursor column;
4647/// vertical / non-h/l motions leave it alone so the intended column
4648/// survives clamping to shorter lines.
4649pub(crate) fn update_block_vcol<H: crate::types::Host>(
4650    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4651    motion: &Motion,
4652) {
4653    match motion {
4654        Motion::Left
4655        | Motion::Right
4656        | Motion::WordFwd
4657        | Motion::BigWordFwd
4658        | Motion::WordBack
4659        | Motion::BigWordBack
4660        | Motion::WordEnd
4661        | Motion::BigWordEnd
4662        | Motion::WordEndBack
4663        | Motion::BigWordEndBack
4664        | Motion::LineStart
4665        | Motion::FirstNonBlank
4666        | Motion::LineEnd
4667        | Motion::Find { .. }
4668        | Motion::FindRepeat { .. }
4669        | Motion::MatchBracket => {
4670            ed.vim.block_vcol = ed.cursor().1;
4671        }
4672        // Up / Down / FileTop / FileBottom / Search — preserve vcol.
4673        _ => {}
4674    }
4675}
4676
4677/// Yank / delete / change / replace a rectangular selection. Yanked text
4678/// is stored as one string per row joined with `\n` so pasting reproduces
4679/// the block as sequential lines. (Vim's true block-paste reinserts as
4680/// columns; we render the content with our char-wise paste path.)
4681fn apply_block_operator<H: crate::types::Host>(
4682    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4683    op: Operator,
4684) {
4685    let (top, bot, left, right) = block_bounds(ed);
4686    // Snapshot the block text for yank / clipboard.
4687    let yank = block_yank(ed, top, bot, left, right);
4688
4689    match op {
4690        Operator::Yank => {
4691            if !yank.is_empty() {
4692                ed.record_yank_to_host(yank.clone());
4693                ed.record_yank(yank, false);
4694            }
4695            ed.vim.mode = Mode::Normal;
4696            ed.jump_cursor(top, left);
4697        }
4698        Operator::Delete => {
4699            ed.push_undo();
4700            delete_block_contents(ed, top, bot, left, right);
4701            if !yank.is_empty() {
4702                ed.record_yank_to_host(yank.clone());
4703                ed.record_delete(yank, false);
4704            }
4705            ed.vim.mode = Mode::Normal;
4706            ed.jump_cursor(top, left);
4707        }
4708        Operator::Change => {
4709            ed.push_undo();
4710            delete_block_contents(ed, top, bot, left, right);
4711            if !yank.is_empty() {
4712                ed.record_yank_to_host(yank.clone());
4713                ed.record_delete(yank, false);
4714            }
4715            ed.jump_cursor(top, left);
4716            begin_insert_noundo(
4717                ed,
4718                1,
4719                InsertReason::BlockChange {
4720                    top,
4721                    bot,
4722                    col: left,
4723                },
4724            );
4725        }
4726        Operator::Uppercase | Operator::Lowercase | Operator::ToggleCase => {
4727            ed.push_undo();
4728            transform_block_case(ed, op, top, bot, left, right);
4729            ed.vim.mode = Mode::Normal;
4730            ed.jump_cursor(top, left);
4731        }
4732        Operator::Indent | Operator::Outdent => {
4733            // VisualBlock `>` / `<` falls back to linewise indent over
4734            // the block's row range — vim does the same (column-wise
4735            // indent/outdent doesn't make sense).
4736            ed.push_undo();
4737            if op == Operator::Indent {
4738                indent_rows(ed, top, bot, 1);
4739            } else {
4740                outdent_rows(ed, top, bot, 1);
4741            }
4742            ed.vim.mode = Mode::Normal;
4743        }
4744        Operator::Fold => unreachable!("Visual zf takes its own path"),
4745        Operator::Reflow => {
4746            // Reflow over the block falls back to linewise reflow over
4747            // the row range — column slicing for `gq` doesn't make
4748            // sense.
4749            ed.push_undo();
4750            reflow_rows(ed, top, bot);
4751            ed.vim.mode = Mode::Normal;
4752        }
4753        Operator::AutoIndent => {
4754            // AutoIndent over the block falls back to linewise
4755            // auto-indent over the row range.
4756            ed.push_undo();
4757            auto_indent_rows(ed, top, bot);
4758            ed.vim.mode = Mode::Normal;
4759        }
4760    }
4761}
4762
4763/// In-place case transform over the rectangular block
4764/// `(top..=bot, left..=right)`. Rows shorter than `left` are left
4765/// untouched — vim behaves the same way (ragged blocks).
4766fn transform_block_case<H: crate::types::Host>(
4767    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4768    op: Operator,
4769    top: usize,
4770    bot: usize,
4771    left: usize,
4772    right: usize,
4773) {
4774    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4775    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4776        let chars: Vec<char> = lines[r].chars().collect();
4777        if left >= chars.len() {
4778            continue;
4779        }
4780        let end = (right + 1).min(chars.len());
4781        let head: String = chars[..left].iter().collect();
4782        let mid: String = chars[left..end].iter().collect();
4783        let tail: String = chars[end..].iter().collect();
4784        let transformed = match op {
4785            Operator::Uppercase => mid.to_uppercase(),
4786            Operator::Lowercase => mid.to_lowercase(),
4787            Operator::ToggleCase => toggle_case_str(&mid),
4788            _ => mid,
4789        };
4790        lines[r] = format!("{head}{transformed}{tail}");
4791    }
4792    let saved_yank = ed.yank().to_string();
4793    let saved_linewise = ed.vim.yank_linewise;
4794    ed.restore(lines, (top, left));
4795    ed.set_yank(saved_yank);
4796    ed.vim.yank_linewise = saved_linewise;
4797}
4798
4799fn block_yank<H: crate::types::Host>(
4800    ed: &Editor<hjkl_buffer::Buffer, H>,
4801    top: usize,
4802    bot: usize,
4803    left: usize,
4804    right: usize,
4805) -> String {
4806    let lines = buf_lines_to_vec(&ed.buffer);
4807    let mut rows: Vec<String> = Vec::new();
4808    for r in top..=bot {
4809        let line = match lines.get(r) {
4810            Some(l) => l,
4811            None => break,
4812        };
4813        let chars: Vec<char> = line.chars().collect();
4814        let end = (right + 1).min(chars.len());
4815        if left >= chars.len() {
4816            rows.push(String::new());
4817        } else {
4818            rows.push(chars[left..end].iter().collect());
4819        }
4820    }
4821    rows.join("\n")
4822}
4823
4824fn delete_block_contents<H: crate::types::Host>(
4825    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4826    top: usize,
4827    bot: usize,
4828    left: usize,
4829    right: usize,
4830) {
4831    use hjkl_buffer::{Edit, MotionKind, Position};
4832    ed.sync_buffer_content_from_textarea();
4833    let last_row = bot.min(buf_row_count(&ed.buffer).saturating_sub(1));
4834    if last_row < top {
4835        return;
4836    }
4837    ed.mutate_edit(Edit::DeleteRange {
4838        start: Position::new(top, left),
4839        end: Position::new(last_row, right),
4840        kind: MotionKind::Block,
4841    });
4842    ed.push_buffer_cursor_to_textarea();
4843}
4844
4845/// Replace each character cell in the block with `ch`.
4846pub(crate) fn block_replace<H: crate::types::Host>(
4847    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4848    ch: char,
4849) {
4850    let (top, bot, left, right) = block_bounds(ed);
4851    ed.push_undo();
4852    ed.sync_buffer_content_from_textarea();
4853    let mut lines: Vec<String> = buf_lines_to_vec(&ed.buffer);
4854    for r in top..=bot.min(lines.len().saturating_sub(1)) {
4855        let chars: Vec<char> = lines[r].chars().collect();
4856        if left >= chars.len() {
4857            continue;
4858        }
4859        let end = (right + 1).min(chars.len());
4860        let before: String = chars[..left].iter().collect();
4861        let middle: String = std::iter::repeat_n(ch, end - left).collect();
4862        let after: String = chars[end..].iter().collect();
4863        lines[r] = format!("{before}{middle}{after}");
4864    }
4865    reset_textarea_lines(ed, lines);
4866    ed.vim.mode = Mode::Normal;
4867    ed.jump_cursor(top, left);
4868}
4869
4870/// Replace buffer content with `lines` while preserving the cursor.
4871/// Used by indent / outdent / block_replace to wholesale rewrite
4872/// rows without going through the per-edit funnel.
4873fn reset_textarea_lines<H: crate::types::Host>(
4874    ed: &mut Editor<hjkl_buffer::Buffer, H>,
4875    lines: Vec<String>,
4876) {
4877    let cursor = ed.cursor();
4878    crate::types::BufferEdit::replace_all(&mut ed.buffer, &lines.join("\n"));
4879    buf_set_cursor_rc(&mut ed.buffer, cursor.0, cursor.1);
4880    ed.mark_content_dirty();
4881}
4882
4883// ─── Visual-line helpers ───────────────────────────────────────────────────
4884
4885// ─── Text-object range computation ─────────────────────────────────────────
4886
4887/// Cursor position as `(row, col)`.
4888type Pos = (usize, usize);
4889
4890/// Returns `(start, end, kind)` where `end` is *exclusive* (one past the
4891/// last character to act on). `kind` is `Linewise` for line-oriented text
4892/// objects like paragraphs and `Exclusive` otherwise.
4893pub(crate) fn text_object_range<H: crate::types::Host>(
4894    ed: &Editor<hjkl_buffer::Buffer, H>,
4895    obj: TextObject,
4896    inner: bool,
4897) -> Option<(Pos, Pos, RangeKind)> {
4898    match obj {
4899        TextObject::Word { big } => {
4900            word_text_object(ed, inner, big).map(|(s, e)| (s, e, RangeKind::Exclusive))
4901        }
4902        TextObject::Quote(q) => {
4903            quote_text_object(ed, q, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
4904        }
4905        TextObject::Bracket(open) => bracket_text_object(ed, open, inner),
4906        TextObject::Paragraph => {
4907            paragraph_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Linewise))
4908        }
4909        TextObject::XmlTag => tag_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive)),
4910        TextObject::Sentence => {
4911            sentence_text_object(ed, inner).map(|(s, e)| (s, e, RangeKind::Exclusive))
4912        }
4913    }
4914}
4915
4916/// `(` / `)` — walk to the next sentence boundary in `forward` direction.
4917/// Returns `(row, col)` of the boundary's first non-whitespace cell, or
4918/// `None` when already at the buffer's edge in that direction.
4919fn sentence_boundary<H: crate::types::Host>(
4920    ed: &Editor<hjkl_buffer::Buffer, H>,
4921    forward: bool,
4922) -> Option<(usize, usize)> {
4923    let lines = buf_lines_to_vec(&ed.buffer);
4924    if lines.is_empty() {
4925        return None;
4926    }
4927    let pos_to_idx = |pos: (usize, usize)| -> usize {
4928        let mut idx = 0;
4929        for line in lines.iter().take(pos.0) {
4930            idx += line.chars().count() + 1;
4931        }
4932        idx + pos.1
4933    };
4934    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
4935        for (r, line) in lines.iter().enumerate() {
4936            let len = line.chars().count();
4937            if idx <= len {
4938                return (r, idx);
4939            }
4940            idx -= len + 1;
4941        }
4942        let last = lines.len().saturating_sub(1);
4943        (last, lines[last].chars().count())
4944    };
4945    let mut chars: Vec<char> = Vec::new();
4946    for (r, line) in lines.iter().enumerate() {
4947        chars.extend(line.chars());
4948        if r + 1 < lines.len() {
4949            chars.push('\n');
4950        }
4951    }
4952    if chars.is_empty() {
4953        return None;
4954    }
4955    let total = chars.len();
4956    let cursor_idx = pos_to_idx(ed.cursor()).min(total - 1);
4957    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
4958
4959    if forward {
4960        // Walk forward looking for a terminator run followed by
4961        // whitespace; land on the first non-whitespace cell after.
4962        let mut i = cursor_idx + 1;
4963        while i < total {
4964            if is_terminator(chars[i]) {
4965                while i + 1 < total && is_terminator(chars[i + 1]) {
4966                    i += 1;
4967                }
4968                if i + 1 >= total {
4969                    return None;
4970                }
4971                if chars[i + 1].is_whitespace() {
4972                    let mut j = i + 1;
4973                    while j < total && chars[j].is_whitespace() {
4974                        j += 1;
4975                    }
4976                    if j >= total {
4977                        return None;
4978                    }
4979                    return Some(idx_to_pos(j));
4980                }
4981            }
4982            i += 1;
4983        }
4984        None
4985    } else {
4986        // Walk backward to find the start of the current sentence (if
4987        // we're already at the start, jump to the previous sentence's
4988        // start instead).
4989        let find_start = |from: usize| -> Option<usize> {
4990            let mut start = from;
4991            while start > 0 {
4992                let prev = chars[start - 1];
4993                if prev.is_whitespace() {
4994                    let mut k = start - 1;
4995                    while k > 0 && chars[k - 1].is_whitespace() {
4996                        k -= 1;
4997                    }
4998                    if k > 0 && is_terminator(chars[k - 1]) {
4999                        break;
5000                    }
5001                }
5002                start -= 1;
5003            }
5004            while start < total && chars[start].is_whitespace() {
5005                start += 1;
5006            }
5007            (start < total).then_some(start)
5008        };
5009        let current_start = find_start(cursor_idx)?;
5010        if current_start < cursor_idx {
5011            return Some(idx_to_pos(current_start));
5012        }
5013        // Already at the sentence start — step over the boundary into
5014        // the previous sentence and find its start.
5015        let mut k = current_start;
5016        while k > 0 && chars[k - 1].is_whitespace() {
5017            k -= 1;
5018        }
5019        if k == 0 {
5020            return None;
5021        }
5022        let prev_start = find_start(k - 1)?;
5023        Some(idx_to_pos(prev_start))
5024    }
5025}
5026
5027/// `is` / `as` — sentence: text up to and including the next sentence
5028/// terminator (`.`, `?`, `!`). Vim treats `.`/`?`/`!` followed by
5029/// whitespace (or end-of-line) as a boundary; runs of consecutive
5030/// terminators stay attached to the same sentence. `as` extends to
5031/// include trailing whitespace; `is` does not.
5032fn sentence_text_object<H: crate::types::Host>(
5033    ed: &Editor<hjkl_buffer::Buffer, H>,
5034    inner: bool,
5035) -> Option<((usize, usize), (usize, usize))> {
5036    let lines = buf_lines_to_vec(&ed.buffer);
5037    if lines.is_empty() {
5038        return None;
5039    }
5040    // Flatten the buffer so a sentence can span lines (vim's behaviour).
5041    // Newlines count as whitespace for boundary detection.
5042    let pos_to_idx = |pos: (usize, usize)| -> usize {
5043        let mut idx = 0;
5044        for line in lines.iter().take(pos.0) {
5045            idx += line.chars().count() + 1;
5046        }
5047        idx + pos.1
5048    };
5049    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
5050        for (r, line) in lines.iter().enumerate() {
5051            let len = line.chars().count();
5052            if idx <= len {
5053                return (r, idx);
5054            }
5055            idx -= len + 1;
5056        }
5057        let last = lines.len().saturating_sub(1);
5058        (last, lines[last].chars().count())
5059    };
5060    let mut chars: Vec<char> = Vec::new();
5061    for (r, line) in lines.iter().enumerate() {
5062        chars.extend(line.chars());
5063        if r + 1 < lines.len() {
5064            chars.push('\n');
5065        }
5066    }
5067    if chars.is_empty() {
5068        return None;
5069    }
5070
5071    let cursor_idx = pos_to_idx(ed.cursor()).min(chars.len() - 1);
5072    let is_terminator = |c: char| matches!(c, '.' | '?' | '!');
5073
5074    // Walk backward from cursor to find the start of the current
5075    // sentence. A boundary is: whitespace immediately after a run of
5076    // terminators (or start-of-buffer).
5077    let mut start = cursor_idx;
5078    while start > 0 {
5079        let prev = chars[start - 1];
5080        if prev.is_whitespace() {
5081            // Check if the whitespace follows a terminator — if so,
5082            // we've crossed a sentence boundary; the sentence begins
5083            // at the first non-whitespace cell *after* this run.
5084            let mut k = start - 1;
5085            while k > 0 && chars[k - 1].is_whitespace() {
5086                k -= 1;
5087            }
5088            if k > 0 && is_terminator(chars[k - 1]) {
5089                break;
5090            }
5091        }
5092        start -= 1;
5093    }
5094    // Skip leading whitespace (vim doesn't include it in the
5095    // sentence body).
5096    while start < chars.len() && chars[start].is_whitespace() {
5097        start += 1;
5098    }
5099    if start >= chars.len() {
5100        return None;
5101    }
5102
5103    // Walk forward to the sentence end (last terminator before the
5104    // next whitespace boundary).
5105    let mut end = start;
5106    while end < chars.len() {
5107        if is_terminator(chars[end]) {
5108            // Consume any consecutive terminators (e.g. `?!`).
5109            while end + 1 < chars.len() && is_terminator(chars[end + 1]) {
5110                end += 1;
5111            }
5112            // If followed by whitespace or end-of-buffer, that's the
5113            // boundary.
5114            if end + 1 >= chars.len() || chars[end + 1].is_whitespace() {
5115                break;
5116            }
5117        }
5118        end += 1;
5119    }
5120    // Inclusive end → exclusive end_idx.
5121    let end_idx = (end + 1).min(chars.len());
5122
5123    let final_end = if inner {
5124        end_idx
5125    } else {
5126        // `as`: include trailing whitespace (but stop before the next
5127        // newline so we don't gobble a paragraph break — vim keeps
5128        // sentences within a paragraph for the trailing-ws extension).
5129        let mut e = end_idx;
5130        while e < chars.len() && chars[e].is_whitespace() && chars[e] != '\n' {
5131            e += 1;
5132        }
5133        e
5134    };
5135
5136    Some((idx_to_pos(start), idx_to_pos(final_end)))
5137}
5138
5139/// `it` / `at` — XML tag pair text object. Builds a flat char index of
5140/// the buffer, walks `<...>` tokens to pair tags via a stack, and
5141/// returns the innermost pair containing the cursor.
5142fn tag_text_object<H: crate::types::Host>(
5143    ed: &Editor<hjkl_buffer::Buffer, H>,
5144    inner: bool,
5145) -> Option<((usize, usize), (usize, usize))> {
5146    let lines = buf_lines_to_vec(&ed.buffer);
5147    if lines.is_empty() {
5148        return None;
5149    }
5150    // Flatten char positions so we can compare cursor against tag
5151    // ranges without per-row arithmetic. `\n` between lines counts as
5152    // a single char.
5153    let pos_to_idx = |pos: (usize, usize)| -> usize {
5154        let mut idx = 0;
5155        for line in lines.iter().take(pos.0) {
5156            idx += line.chars().count() + 1;
5157        }
5158        idx + pos.1
5159    };
5160    let idx_to_pos = |mut idx: usize| -> (usize, usize) {
5161        for (r, line) in lines.iter().enumerate() {
5162            let len = line.chars().count();
5163            if idx <= len {
5164                return (r, idx);
5165            }
5166            idx -= len + 1;
5167        }
5168        let last = lines.len().saturating_sub(1);
5169        (last, lines[last].chars().count())
5170    };
5171    let mut chars: Vec<char> = Vec::new();
5172    for (r, line) in lines.iter().enumerate() {
5173        chars.extend(line.chars());
5174        if r + 1 < lines.len() {
5175            chars.push('\n');
5176        }
5177    }
5178    let cursor_idx = pos_to_idx(ed.cursor());
5179
5180    // Walk `<...>` tokens. Track open tags on a stack; on a matching
5181    // close pop and consider the pair a candidate when the cursor lies
5182    // inside its content range. Innermost wins (replace whenever a
5183    // tighter range turns up). Also track the first complete pair that
5184    // starts at or after the cursor so we can fall back to a forward
5185    // scan (targets.vim-style) when the cursor isn't inside any tag.
5186    let mut stack: Vec<(usize, usize, String)> = Vec::new(); // (open_start, content_start, name)
5187    let mut innermost: Option<(usize, usize, usize, usize)> = None;
5188    let mut next_after: Option<(usize, usize, usize, usize)> = None;
5189    let mut i = 0;
5190    while i < chars.len() {
5191        if chars[i] != '<' {
5192            i += 1;
5193            continue;
5194        }
5195        let mut j = i + 1;
5196        while j < chars.len() && chars[j] != '>' {
5197            j += 1;
5198        }
5199        if j >= chars.len() {
5200            break;
5201        }
5202        let inside: String = chars[i + 1..j].iter().collect();
5203        let close_end = j + 1;
5204        let trimmed = inside.trim();
5205        if trimmed.starts_with('!') || trimmed.starts_with('?') {
5206            i = close_end;
5207            continue;
5208        }
5209        if let Some(rest) = trimmed.strip_prefix('/') {
5210            let name = rest.split_whitespace().next().unwrap_or("").to_string();
5211            if !name.is_empty()
5212                && let Some(stack_idx) = stack.iter().rposition(|(_, _, n)| *n == name)
5213            {
5214                let (open_start, content_start, _) = stack[stack_idx].clone();
5215                stack.truncate(stack_idx);
5216                let content_end = i;
5217                let candidate = (open_start, content_start, content_end, close_end);
5218                if cursor_idx >= content_start && cursor_idx <= content_end {
5219                    innermost = match innermost {
5220                        Some((_, cs, ce, _)) if cs <= content_start && content_end <= ce => {
5221                            Some(candidate)
5222                        }
5223                        None => Some(candidate),
5224                        existing => existing,
5225                    };
5226                } else if open_start >= cursor_idx && next_after.is_none() {
5227                    next_after = Some(candidate);
5228                }
5229            }
5230        } else if !trimmed.ends_with('/') {
5231            let name: String = trimmed
5232                .split(|c: char| c.is_whitespace() || c == '/')
5233                .next()
5234                .unwrap_or("")
5235                .to_string();
5236            if !name.is_empty() {
5237                stack.push((i, close_end, name));
5238            }
5239        }
5240        i = close_end;
5241    }
5242
5243    let (open_start, content_start, content_end, close_end) = innermost.or(next_after)?;
5244    if inner {
5245        Some((idx_to_pos(content_start), idx_to_pos(content_end)))
5246    } else {
5247        Some((idx_to_pos(open_start), idx_to_pos(close_end)))
5248    }
5249}
5250
5251fn is_wordchar(c: char) -> bool {
5252    c.is_alphanumeric() || c == '_'
5253}
5254
5255// `is_keyword_char` lives in hjkl-buffer (used by word motions);
5256// engine re-uses it via `hjkl_buffer::is_keyword_char` so there's
5257// one parser, one default, one bug surface.
5258pub(crate) use hjkl_buffer::is_keyword_char;
5259
5260fn word_text_object<H: crate::types::Host>(
5261    ed: &Editor<hjkl_buffer::Buffer, H>,
5262    inner: bool,
5263    big: bool,
5264) -> Option<((usize, usize), (usize, usize))> {
5265    let (row, col) = ed.cursor();
5266    let line = buf_line(&ed.buffer, row)?;
5267    let chars: Vec<char> = line.chars().collect();
5268    if chars.is_empty() {
5269        return None;
5270    }
5271    let at = col.min(chars.len().saturating_sub(1));
5272    let classify = |c: char| -> u8 {
5273        if c.is_whitespace() {
5274            0
5275        } else if big || is_wordchar(c) {
5276            1
5277        } else {
5278            2
5279        }
5280    };
5281    let cls = classify(chars[at]);
5282    let mut start = at;
5283    while start > 0 && classify(chars[start - 1]) == cls {
5284        start -= 1;
5285    }
5286    let mut end = at;
5287    while end + 1 < chars.len() && classify(chars[end + 1]) == cls {
5288        end += 1;
5289    }
5290    // Byte-offset helpers.
5291    let char_byte = |i: usize| {
5292        if i >= chars.len() {
5293            line.len()
5294        } else {
5295            line.char_indices().nth(i).map(|(b, _)| b).unwrap_or(0)
5296        }
5297    };
5298    let mut start_col = char_byte(start);
5299    // Exclusive end: byte index of char AFTER the last-included char.
5300    let mut end_col = char_byte(end + 1);
5301    if !inner {
5302        // `aw` — include trailing whitespace; if there's no trailing ws, absorb leading ws.
5303        let mut t = end + 1;
5304        let mut included_trailing = false;
5305        while t < chars.len() && chars[t].is_whitespace() {
5306            included_trailing = true;
5307            t += 1;
5308        }
5309        if included_trailing {
5310            end_col = char_byte(t);
5311        } else {
5312            let mut s = start;
5313            while s > 0 && chars[s - 1].is_whitespace() {
5314                s -= 1;
5315            }
5316            start_col = char_byte(s);
5317        }
5318    }
5319    Some(((row, start_col), (row, end_col)))
5320}
5321
5322fn quote_text_object<H: crate::types::Host>(
5323    ed: &Editor<hjkl_buffer::Buffer, H>,
5324    q: char,
5325    inner: bool,
5326) -> Option<((usize, usize), (usize, usize))> {
5327    let (row, col) = ed.cursor();
5328    let line = buf_line(&ed.buffer, row)?;
5329    let bytes = line.as_bytes();
5330    let q_byte = q as u8;
5331    // Find opening and closing quote on the same line.
5332    let mut positions: Vec<usize> = Vec::new();
5333    for (i, &b) in bytes.iter().enumerate() {
5334        if b == q_byte {
5335            positions.push(i);
5336        }
5337    }
5338    if positions.len() < 2 {
5339        return None;
5340    }
5341    let mut open_idx: Option<usize> = None;
5342    let mut close_idx: Option<usize> = None;
5343    for pair in positions.chunks(2) {
5344        if pair.len() < 2 {
5345            break;
5346        }
5347        if col >= pair[0] && col <= pair[1] {
5348            open_idx = Some(pair[0]);
5349            close_idx = Some(pair[1]);
5350            break;
5351        }
5352        if col < pair[0] {
5353            open_idx = Some(pair[0]);
5354            close_idx = Some(pair[1]);
5355            break;
5356        }
5357    }
5358    let open = open_idx?;
5359    let close = close_idx?;
5360    // End columns are *exclusive* — one past the last character to act on.
5361    if inner {
5362        if close <= open + 1 {
5363            return None;
5364        }
5365        Some(((row, open + 1), (row, close)))
5366    } else {
5367        // `da<q>` — "around" includes the surrounding whitespace on one
5368        // side: trailing whitespace if any exists after the closing quote;
5369        // otherwise leading whitespace before the opening quote. This
5370        // matches vim's `:help text-objects` behaviour and avoids leaving
5371        // a double-space when the quoted span sits mid-sentence.
5372        let after_close = close + 1; // byte index after closing quote
5373        if after_close < bytes.len() && bytes[after_close].is_ascii_whitespace() {
5374            // Eat trailing whitespace run.
5375            let mut end = after_close;
5376            while end < bytes.len() && bytes[end].is_ascii_whitespace() {
5377                end += 1;
5378            }
5379            Some(((row, open), (row, end)))
5380        } else if open > 0 && bytes[open - 1].is_ascii_whitespace() {
5381            // Eat leading whitespace run.
5382            let mut start = open;
5383            while start > 0 && bytes[start - 1].is_ascii_whitespace() {
5384                start -= 1;
5385            }
5386            Some(((row, start), (row, close + 1)))
5387        } else {
5388            Some(((row, open), (row, close + 1)))
5389        }
5390    }
5391}
5392
5393fn bracket_text_object<H: crate::types::Host>(
5394    ed: &Editor<hjkl_buffer::Buffer, H>,
5395    open: char,
5396    inner: bool,
5397) -> Option<(Pos, Pos, RangeKind)> {
5398    let close = match open {
5399        '(' => ')',
5400        '[' => ']',
5401        '{' => '}',
5402        '<' => '>',
5403        _ => return None,
5404    };
5405    let (row, col) = ed.cursor();
5406    let lines = buf_lines_to_vec(&ed.buffer);
5407    let lines = lines.as_slice();
5408    // Walk backward from cursor to find unbalanced opening. When the
5409    // cursor isn't inside any pair, fall back to scanning forward for
5410    // the next opening bracket (targets.vim-style: `ci(` works when
5411    // cursor is before the `(` on the same line or below).
5412    let open_pos = find_open_bracket(lines, row, col, open, close)
5413        .or_else(|| find_next_open(lines, row, col, open))?;
5414    let close_pos = find_close_bracket(lines, open_pos.0, open_pos.1 + 1, open, close)?;
5415    // End positions are *exclusive*.
5416    if inner {
5417        // Multi-line `iB` / `i{` etc: vim deletes the full lines between
5418        // the braces (linewise), preserving the `{` and `}` lines
5419        // themselves and the newlines that directly abut them. E.g.:
5420        //   {\n    body\n}\n  →  {\n}\n    (cursor on `}` line)
5421        // Single-line `i{` falls back to charwise exclusive.
5422        if close_pos.0 > open_pos.0 + 1 {
5423            // There is at least one line strictly between open and close.
5424            let inner_row_start = open_pos.0 + 1;
5425            let inner_row_end = close_pos.0 - 1;
5426            let end_col = lines
5427                .get(inner_row_end)
5428                .map(|l| l.chars().count())
5429                .unwrap_or(0);
5430            return Some((
5431                (inner_row_start, 0),
5432                (inner_row_end, end_col),
5433                RangeKind::Linewise,
5434            ));
5435        }
5436        let inner_start = advance_pos(lines, open_pos);
5437        if inner_start.0 > close_pos.0
5438            || (inner_start.0 == close_pos.0 && inner_start.1 >= close_pos.1)
5439        {
5440            return None;
5441        }
5442        Some((inner_start, close_pos, RangeKind::Exclusive))
5443    } else {
5444        Some((
5445            open_pos,
5446            advance_pos(lines, close_pos),
5447            RangeKind::Exclusive,
5448        ))
5449    }
5450}
5451
5452fn find_open_bracket(
5453    lines: &[String],
5454    row: usize,
5455    col: usize,
5456    open: char,
5457    close: char,
5458) -> Option<(usize, usize)> {
5459    let mut depth: i32 = 0;
5460    let mut r = row;
5461    let mut c = col as isize;
5462    loop {
5463        let cur = &lines[r];
5464        let chars: Vec<char> = cur.chars().collect();
5465        // Clamp `c` to the line length: callers may seed `col` past
5466        // EOL on virtual-cursor lines (e.g., insert mode after `o`)
5467        // so direct indexing would panic on empty / short lines.
5468        if (c as usize) >= chars.len() {
5469            c = chars.len() as isize - 1;
5470        }
5471        while c >= 0 {
5472            let ch = chars[c as usize];
5473            if ch == close {
5474                depth += 1;
5475            } else if ch == open {
5476                if depth == 0 {
5477                    return Some((r, c as usize));
5478                }
5479                depth -= 1;
5480            }
5481            c -= 1;
5482        }
5483        if r == 0 {
5484            return None;
5485        }
5486        r -= 1;
5487        c = lines[r].chars().count() as isize - 1;
5488    }
5489}
5490
5491fn find_close_bracket(
5492    lines: &[String],
5493    row: usize,
5494    start_col: usize,
5495    open: char,
5496    close: char,
5497) -> Option<(usize, usize)> {
5498    let mut depth: i32 = 0;
5499    let mut r = row;
5500    let mut c = start_col;
5501    loop {
5502        let cur = &lines[r];
5503        let chars: Vec<char> = cur.chars().collect();
5504        while c < chars.len() {
5505            let ch = chars[c];
5506            if ch == open {
5507                depth += 1;
5508            } else if ch == close {
5509                if depth == 0 {
5510                    return Some((r, c));
5511                }
5512                depth -= 1;
5513            }
5514            c += 1;
5515        }
5516        if r + 1 >= lines.len() {
5517            return None;
5518        }
5519        r += 1;
5520        c = 0;
5521    }
5522}
5523
5524/// Forward scan from `(row, col)` for the next occurrence of `open`.
5525/// Multi-line. Used by bracket text objects to support targets.vim-style
5526/// "search forward when not currently inside a pair" behaviour.
5527fn find_next_open(lines: &[String], row: usize, col: usize, open: char) -> Option<(usize, usize)> {
5528    let mut r = row;
5529    let mut c = col;
5530    while r < lines.len() {
5531        let chars: Vec<char> = lines[r].chars().collect();
5532        while c < chars.len() {
5533            if chars[c] == open {
5534                return Some((r, c));
5535            }
5536            c += 1;
5537        }
5538        r += 1;
5539        c = 0;
5540    }
5541    None
5542}
5543
5544fn advance_pos(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
5545    let (r, c) = pos;
5546    let line_len = lines[r].chars().count();
5547    if c < line_len {
5548        (r, c + 1)
5549    } else if r + 1 < lines.len() {
5550        (r + 1, 0)
5551    } else {
5552        pos
5553    }
5554}
5555
5556fn paragraph_text_object<H: crate::types::Host>(
5557    ed: &Editor<hjkl_buffer::Buffer, H>,
5558    inner: bool,
5559) -> Option<((usize, usize), (usize, usize))> {
5560    let (row, _) = ed.cursor();
5561    let lines = buf_lines_to_vec(&ed.buffer);
5562    if lines.is_empty() {
5563        return None;
5564    }
5565    // A paragraph is a run of non-blank lines.
5566    let is_blank = |r: usize| lines.get(r).map(|s| s.trim().is_empty()).unwrap_or(true);
5567    if is_blank(row) {
5568        return None;
5569    }
5570    let mut top = row;
5571    while top > 0 && !is_blank(top - 1) {
5572        top -= 1;
5573    }
5574    let mut bot = row;
5575    while bot + 1 < lines.len() && !is_blank(bot + 1) {
5576        bot += 1;
5577    }
5578    // For `ap`, include one trailing blank line if present.
5579    if !inner && bot + 1 < lines.len() && is_blank(bot + 1) {
5580        bot += 1;
5581    }
5582    let end_col = lines[bot].chars().count();
5583    Some(((top, 0), (bot, end_col)))
5584}
5585
5586// ─── Individual commands ───────────────────────────────────────────────────
5587
5588/// Read the text in a vim-shaped range without mutating. Used by
5589/// `Operator::Yank` so we can pipe the same range translation as
5590/// [`cut_vim_range`] but skip the delete + inverse extraction.
5591fn read_vim_range<H: crate::types::Host>(
5592    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5593    start: (usize, usize),
5594    end: (usize, usize),
5595    kind: RangeKind,
5596) -> String {
5597    let (top, bot) = order(start, end);
5598    ed.sync_buffer_content_from_textarea();
5599    let lines = buf_lines_to_vec(&ed.buffer);
5600    match kind {
5601        RangeKind::Linewise => {
5602            let lo = top.0;
5603            let hi = bot.0.min(lines.len().saturating_sub(1));
5604            let mut text = lines[lo..=hi].join("\n");
5605            text.push('\n');
5606            text
5607        }
5608        RangeKind::Inclusive | RangeKind::Exclusive => {
5609            let inclusive = matches!(kind, RangeKind::Inclusive);
5610            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
5611            let mut out = String::new();
5612            for row in top.0..=bot.0 {
5613                let line = lines.get(row).map(String::as_str).unwrap_or("");
5614                let lo = if row == top.0 { top.1 } else { 0 };
5615                let hi_unclamped = if row == bot.0 {
5616                    if inclusive { bot.1 + 1 } else { bot.1 }
5617                } else {
5618                    line.chars().count() + 1
5619                };
5620                let row_chars: Vec<char> = line.chars().collect();
5621                let hi = hi_unclamped.min(row_chars.len());
5622                if lo < hi {
5623                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
5624                }
5625                if row < bot.0 {
5626                    out.push('\n');
5627                }
5628            }
5629            out
5630        }
5631    }
5632}
5633
5634/// Cut a vim-shaped range through the Buffer edit funnel and return
5635/// the deleted text. Translates vim's `RangeKind`
5636/// (Linewise/Inclusive/Exclusive) into the buffer's
5637/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
5638/// position adjustment so inclusive motions actually include the bot
5639/// cell. Pushes the cut text into both `last_yank` and the textarea
5640/// yank buffer (still observed by `p`/`P` until the paste path is
5641/// ported), and updates `yank_linewise` for linewise cuts.
5642fn cut_vim_range<H: crate::types::Host>(
5643    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5644    start: (usize, usize),
5645    end: (usize, usize),
5646    kind: RangeKind,
5647) -> String {
5648    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
5649    let (top, bot) = order(start, end);
5650    ed.sync_buffer_content_from_textarea();
5651    let (buf_start, buf_end, buf_kind) = match kind {
5652        RangeKind::Linewise => (
5653            Position::new(top.0, 0),
5654            Position::new(bot.0, 0),
5655            BufKind::Line,
5656        ),
5657        RangeKind::Inclusive => {
5658            let line_chars = buf_line_chars(&ed.buffer, bot.0);
5659            // Advance one cell past `bot` so the buffer's exclusive
5660            // `cut_chars` actually drops the inclusive endpoint. Wrap
5661            // to the next row when bot already sits on the last char.
5662            let next = if bot.1 < line_chars {
5663                Position::new(bot.0, bot.1 + 1)
5664            } else if bot.0 + 1 < buf_row_count(&ed.buffer) {
5665                Position::new(bot.0 + 1, 0)
5666            } else {
5667                Position::new(bot.0, line_chars)
5668            };
5669            (Position::new(top.0, top.1), next, BufKind::Char)
5670        }
5671        RangeKind::Exclusive => (
5672            Position::new(top.0, top.1),
5673            Position::new(bot.0, bot.1),
5674            BufKind::Char,
5675        ),
5676    };
5677    let inverse = ed.mutate_edit(Edit::DeleteRange {
5678        start: buf_start,
5679        end: buf_end,
5680        kind: buf_kind,
5681    });
5682    let text = match inverse {
5683        Edit::InsertStr { text, .. } => text,
5684        _ => String::new(),
5685    };
5686    if !text.is_empty() {
5687        ed.record_yank_to_host(text.clone());
5688        ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise));
5689    }
5690    ed.push_buffer_cursor_to_textarea();
5691    text
5692}
5693
5694/// `D` / `C` — delete from cursor to end of line through the edit
5695/// funnel. Mirrors the deleted text into both `ed.last_yank` and the
5696/// textarea's yank buffer (still observed by `p`/`P` until the paste
5697/// path is ported). Cursor lands at the deletion start so the caller
5698/// can decide whether to step it left (`D`) or open insert mode (`C`).
5699fn delete_to_eol<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5700    use hjkl_buffer::{Edit, MotionKind, Position};
5701    ed.sync_buffer_content_from_textarea();
5702    let cursor = buf_cursor_pos(&ed.buffer);
5703    let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5704    if cursor.col >= line_chars {
5705        return;
5706    }
5707    let inverse = ed.mutate_edit(Edit::DeleteRange {
5708        start: cursor,
5709        end: Position::new(cursor.row, line_chars),
5710        kind: MotionKind::Char,
5711    });
5712    if let Edit::InsertStr { text, .. } = inverse
5713        && !text.is_empty()
5714    {
5715        ed.record_yank_to_host(text.clone());
5716        ed.vim.yank_linewise = false;
5717        ed.set_yank(text);
5718    }
5719    buf_set_cursor_pos(&mut ed.buffer, cursor);
5720    ed.push_buffer_cursor_to_textarea();
5721}
5722
5723fn do_char_delete<H: crate::types::Host>(
5724    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5725    forward: bool,
5726    count: usize,
5727) {
5728    use hjkl_buffer::{Edit, MotionKind, Position};
5729    ed.push_undo();
5730    ed.sync_buffer_content_from_textarea();
5731    // Collect deleted chars so we can write them to the unnamed register
5732    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
5733    let mut deleted = String::new();
5734    for _ in 0..count {
5735        let cursor = buf_cursor_pos(&ed.buffer);
5736        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5737        if forward {
5738            // `x` — delete the char under the cursor. Vim no-ops on
5739            // an empty line; the buffer would drop a row otherwise.
5740            if cursor.col >= line_chars {
5741                continue;
5742            }
5743            let inverse = ed.mutate_edit(Edit::DeleteRange {
5744                start: cursor,
5745                end: Position::new(cursor.row, cursor.col + 1),
5746                kind: MotionKind::Char,
5747            });
5748            if let Edit::InsertStr { text, .. } = inverse {
5749                deleted.push_str(&text);
5750            }
5751        } else {
5752            // `X` — delete the char before the cursor.
5753            if cursor.col == 0 {
5754                continue;
5755            }
5756            let inverse = ed.mutate_edit(Edit::DeleteRange {
5757                start: Position::new(cursor.row, cursor.col - 1),
5758                end: cursor,
5759                kind: MotionKind::Char,
5760            });
5761            if let Edit::InsertStr { text, .. } = inverse {
5762                // X deletes backwards; prepend so the register text
5763                // matches reading order (first deleted char first).
5764                deleted = text + &deleted;
5765            }
5766        }
5767    }
5768    if !deleted.is_empty() {
5769        ed.record_yank_to_host(deleted.clone());
5770        ed.record_delete(deleted, false);
5771    }
5772    ed.push_buffer_cursor_to_textarea();
5773}
5774
5775/// Vim `Ctrl-a` / `Ctrl-x` — find the next decimal number at or after the
5776/// cursor on the current line, add `delta`, leave the cursor on the last
5777/// digit of the result. No-op if the line has no digits to the right.
5778pub(crate) fn adjust_number<H: crate::types::Host>(
5779    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5780    delta: i64,
5781) -> bool {
5782    use hjkl_buffer::{Edit, MotionKind, Position};
5783    ed.sync_buffer_content_from_textarea();
5784    let cursor = buf_cursor_pos(&ed.buffer);
5785    let row = cursor.row;
5786    let chars: Vec<char> = match buf_line(&ed.buffer, row) {
5787        Some(l) => l.chars().collect(),
5788        None => return false,
5789    };
5790    let Some(digit_start) = (cursor.col..chars.len()).find(|&i| chars[i].is_ascii_digit()) else {
5791        return false;
5792    };
5793    let span_start = if digit_start > 0 && chars[digit_start - 1] == '-' {
5794        digit_start - 1
5795    } else {
5796        digit_start
5797    };
5798    let mut span_end = digit_start;
5799    while span_end < chars.len() && chars[span_end].is_ascii_digit() {
5800        span_end += 1;
5801    }
5802    let s: String = chars[span_start..span_end].iter().collect();
5803    let Ok(n) = s.parse::<i64>() else {
5804        return false;
5805    };
5806    let new_s = n.saturating_add(delta).to_string();
5807
5808    ed.push_undo();
5809    let span_start_pos = Position::new(row, span_start);
5810    let span_end_pos = Position::new(row, span_end);
5811    ed.mutate_edit(Edit::DeleteRange {
5812        start: span_start_pos,
5813        end: span_end_pos,
5814        kind: MotionKind::Char,
5815    });
5816    ed.mutate_edit(Edit::InsertStr {
5817        at: span_start_pos,
5818        text: new_s.clone(),
5819    });
5820    let new_len = new_s.chars().count();
5821    buf_set_cursor_rc(&mut ed.buffer, row, span_start + new_len.saturating_sub(1));
5822    ed.push_buffer_cursor_to_textarea();
5823    true
5824}
5825
5826pub(crate) fn replace_char<H: crate::types::Host>(
5827    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5828    ch: char,
5829    count: usize,
5830) {
5831    use hjkl_buffer::{Edit, MotionKind, Position};
5832    ed.push_undo();
5833    ed.sync_buffer_content_from_textarea();
5834    for _ in 0..count {
5835        let cursor = buf_cursor_pos(&ed.buffer);
5836        let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5837        if cursor.col >= line_chars {
5838            break;
5839        }
5840        ed.mutate_edit(Edit::DeleteRange {
5841            start: cursor,
5842            end: Position::new(cursor.row, cursor.col + 1),
5843            kind: MotionKind::Char,
5844        });
5845        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
5846    }
5847    // Vim leaves the cursor on the last replaced char.
5848    crate::motions::move_left(&mut ed.buffer, 1);
5849    ed.push_buffer_cursor_to_textarea();
5850}
5851
5852fn toggle_case_at_cursor<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5853    use hjkl_buffer::{Edit, MotionKind, Position};
5854    ed.sync_buffer_content_from_textarea();
5855    let cursor = buf_cursor_pos(&ed.buffer);
5856    let Some(c) = buf_line(&ed.buffer, cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
5857        return;
5858    };
5859    let toggled = if c.is_uppercase() {
5860        c.to_lowercase().next().unwrap_or(c)
5861    } else {
5862        c.to_uppercase().next().unwrap_or(c)
5863    };
5864    ed.mutate_edit(Edit::DeleteRange {
5865        start: cursor,
5866        end: Position::new(cursor.row, cursor.col + 1),
5867        kind: MotionKind::Char,
5868    });
5869    ed.mutate_edit(Edit::InsertChar {
5870        at: cursor,
5871        ch: toggled,
5872    });
5873}
5874
5875fn join_line<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5876    use hjkl_buffer::{Edit, Position};
5877    ed.sync_buffer_content_from_textarea();
5878    let row = buf_cursor_pos(&ed.buffer).row;
5879    if row + 1 >= buf_row_count(&ed.buffer) {
5880        return;
5881    }
5882    let cur_line = buf_line(&ed.buffer, row).unwrap_or("").to_string();
5883    let next_raw = buf_line(&ed.buffer, row + 1).unwrap_or("").to_string();
5884    let next_trimmed = next_raw.trim_start();
5885    let cur_chars = cur_line.chars().count();
5886    let next_chars = next_raw.chars().count();
5887    // `J` inserts a single space iff both sides are non-empty after
5888    // stripping the next line's leading whitespace.
5889    let separator = if !cur_line.is_empty() && !next_trimmed.is_empty() {
5890        " "
5891    } else {
5892        ""
5893    };
5894    let joined = format!("{cur_line}{separator}{next_trimmed}");
5895    ed.mutate_edit(Edit::Replace {
5896        start: Position::new(row, 0),
5897        end: Position::new(row + 1, next_chars),
5898        with: joined,
5899    });
5900    // Vim parks the cursor on the inserted space — or at the join
5901    // point when no space went in (which is the same column either
5902    // way, since the space sits exactly at `cur_chars`).
5903    buf_set_cursor_rc(&mut ed.buffer, row, cur_chars);
5904    ed.push_buffer_cursor_to_textarea();
5905}
5906
5907/// `gJ` — join the next line onto the current one without inserting a
5908/// separating space or stripping leading whitespace.
5909fn join_line_raw<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
5910    use hjkl_buffer::Edit;
5911    ed.sync_buffer_content_from_textarea();
5912    let row = buf_cursor_pos(&ed.buffer).row;
5913    if row + 1 >= buf_row_count(&ed.buffer) {
5914        return;
5915    }
5916    let join_col = buf_line_chars(&ed.buffer, row);
5917    ed.mutate_edit(Edit::JoinLines {
5918        row,
5919        count: 1,
5920        with_space: false,
5921    });
5922    // Vim leaves the cursor at the join point (end of original line).
5923    buf_set_cursor_rc(&mut ed.buffer, row, join_col);
5924    ed.push_buffer_cursor_to_textarea();
5925}
5926
5927fn do_paste<H: crate::types::Host>(
5928    ed: &mut Editor<hjkl_buffer::Buffer, H>,
5929    before: bool,
5930    count: usize,
5931) {
5932    use hjkl_buffer::{Edit, Position};
5933    ed.push_undo();
5934    // Resolve the source register: `"reg` prefix (consumed) or the
5935    // unnamed register otherwise. Read text + linewise from the
5936    // selected slot rather than the global `vim.yank_linewise` so
5937    // pasting from `"0` after a delete still uses the yank's layout.
5938    let selector = ed.vim.pending_register.take();
5939    let (yank, linewise) = match selector.and_then(|c| ed.registers().read(c)) {
5940        Some(slot) => (slot.text.clone(), slot.linewise),
5941        // Read both fields from the unnamed slot rather than mixing the
5942        // slot's text with `vim.yank_linewise`. The cached vim flag is
5943        // per-editor, so a register imported from another editor (e.g.
5944        // cross-buffer yank/paste) carried the wrong linewise without
5945        // this — pasting a linewise yank inserted at the char cursor.
5946        None => {
5947            let s = &ed.registers().unnamed;
5948            (s.text.clone(), s.linewise)
5949        }
5950    };
5951    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
5952    // the final paste, `]` = last inserted char of the final paste.
5953    // We track (lo, hi) across iterations; the last value wins.
5954    let mut paste_mark: Option<((usize, usize), (usize, usize))> = None;
5955    for _ in 0..count {
5956        ed.sync_buffer_content_from_textarea();
5957        let yank = yank.clone();
5958        if yank.is_empty() {
5959            continue;
5960        }
5961        if linewise {
5962            // Linewise paste: insert payload as fresh row(s) above
5963            // (`P`) or below (`p`) the cursor's row. Cursor lands on
5964            // the first non-blank of the first pasted line.
5965            let text = yank.trim_matches('\n').to_string();
5966            let row = buf_cursor_pos(&ed.buffer).row;
5967            let target_row = if before {
5968                ed.mutate_edit(Edit::InsertStr {
5969                    at: Position::new(row, 0),
5970                    text: format!("{text}\n"),
5971                });
5972                row
5973            } else {
5974                let line_chars = buf_line_chars(&ed.buffer, row);
5975                ed.mutate_edit(Edit::InsertStr {
5976                    at: Position::new(row, line_chars),
5977                    text: format!("\n{text}"),
5978                });
5979                row + 1
5980            };
5981            buf_set_cursor_rc(&mut ed.buffer, target_row, 0);
5982            crate::motions::move_first_non_blank(&mut ed.buffer);
5983            ed.push_buffer_cursor_to_textarea();
5984            // Linewise: `[` = (target_row, 0), `]` = (bot_row, last_col).
5985            let payload_lines = text.lines().count().max(1);
5986            let bot_row = target_row + payload_lines - 1;
5987            let bot_last_col = buf_line_chars(&ed.buffer, bot_row).saturating_sub(1);
5988            paste_mark = Some(((target_row, 0), (bot_row, bot_last_col)));
5989        } else {
5990            // Charwise paste. `P` inserts at cursor (shifting cell
5991            // right); `p` inserts after cursor (advance one cell
5992            // first, clamped to the end of the line).
5993            let cursor = buf_cursor_pos(&ed.buffer);
5994            let at = if before {
5995                cursor
5996            } else {
5997                let line_chars = buf_line_chars(&ed.buffer, cursor.row);
5998                Position::new(cursor.row, (cursor.col + 1).min(line_chars))
5999            };
6000            ed.mutate_edit(Edit::InsertStr {
6001                at,
6002                text: yank.clone(),
6003            });
6004            // Vim parks the cursor on the last char of the pasted
6005            // text (do_insert_str leaves it one past the end).
6006            crate::motions::move_left(&mut ed.buffer, 1);
6007            ed.push_buffer_cursor_to_textarea();
6008            // Charwise: `[` = insert start, `]` = cursor (last pasted char).
6009            let lo = (at.row, at.col);
6010            let hi = ed.cursor();
6011            paste_mark = Some((lo, hi));
6012        }
6013    }
6014    if let Some((lo, hi)) = paste_mark {
6015        ed.set_mark('[', lo);
6016        ed.set_mark(']', hi);
6017    }
6018    // Any paste re-anchors the sticky column to the new cursor position.
6019    ed.sticky_col = Some(buf_cursor_pos(&ed.buffer).col);
6020}
6021
6022pub(crate) fn do_undo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
6023    if let Some((lines, cursor)) = ed.undo_stack.pop() {
6024        let current = ed.snapshot();
6025        ed.redo_stack.push(current);
6026        ed.restore(lines, cursor);
6027    }
6028    ed.vim.mode = Mode::Normal;
6029    // The restored cursor came from a snapshot taken in insert mode
6030    // (before the insert started) and may be past the last valid
6031    // normal-mode column. Clamp it now, same as Esc-from-insert does.
6032    clamp_cursor_to_normal_mode(ed);
6033}
6034
6035pub(crate) fn do_redo<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
6036    if let Some((lines, cursor)) = ed.redo_stack.pop() {
6037        let current = ed.snapshot();
6038        ed.undo_stack.push(current);
6039        ed.cap_undo();
6040        ed.restore(lines, cursor);
6041    }
6042    ed.vim.mode = Mode::Normal;
6043}
6044
6045// ─── Dot repeat ────────────────────────────────────────────────────────────
6046
6047/// Replay-side helper: insert `text` at the cursor through the
6048/// edit funnel, then leave insert mode (the original change ended
6049/// with Esc, so the dot-repeat must end the same way — including
6050/// the cursor step-back vim does on Esc-from-insert).
6051fn replay_insert_and_finish<H: crate::types::Host>(
6052    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6053    text: &str,
6054) {
6055    use hjkl_buffer::{Edit, Position};
6056    let cursor = ed.cursor();
6057    ed.mutate_edit(Edit::InsertStr {
6058        at: Position::new(cursor.0, cursor.1),
6059        text: text.to_string(),
6060    });
6061    if ed.vim.insert_session.take().is_some() {
6062        if ed.cursor().1 > 0 {
6063            crate::motions::move_left(&mut ed.buffer, 1);
6064            ed.push_buffer_cursor_to_textarea();
6065        }
6066        ed.vim.mode = Mode::Normal;
6067    }
6068}
6069
6070pub(crate) fn replay_last_change<H: crate::types::Host>(
6071    ed: &mut Editor<hjkl_buffer::Buffer, H>,
6072    outer_count: usize,
6073) {
6074    let Some(change) = ed.vim.last_change.clone() else {
6075        return;
6076    };
6077    ed.vim.replaying = true;
6078    let scale = if outer_count > 0 { outer_count } else { 1 };
6079    match change {
6080        LastChange::OpMotion {
6081            op,
6082            motion,
6083            count,
6084            inserted,
6085        } => {
6086            let total = count.max(1) * scale;
6087            apply_op_with_motion(ed, op, &motion, total);
6088            if let Some(text) = inserted {
6089                replay_insert_and_finish(ed, &text);
6090            }
6091        }
6092        LastChange::OpTextObj {
6093            op,
6094            obj,
6095            inner,
6096            inserted,
6097        } => {
6098            apply_op_with_text_object(ed, op, obj, inner);
6099            if let Some(text) = inserted {
6100                replay_insert_and_finish(ed, &text);
6101            }
6102        }
6103        LastChange::LineOp {
6104            op,
6105            count,
6106            inserted,
6107        } => {
6108            let total = count.max(1) * scale;
6109            execute_line_op(ed, op, total);
6110            if let Some(text) = inserted {
6111                replay_insert_and_finish(ed, &text);
6112            }
6113        }
6114        LastChange::CharDel { forward, count } => {
6115            do_char_delete(ed, forward, count * scale);
6116        }
6117        LastChange::ReplaceChar { ch, count } => {
6118            replace_char(ed, ch, count * scale);
6119        }
6120        LastChange::ToggleCase { count } => {
6121            for _ in 0..count * scale {
6122                ed.push_undo();
6123                toggle_case_at_cursor(ed);
6124            }
6125        }
6126        LastChange::JoinLine { count } => {
6127            for _ in 0..count * scale {
6128                ed.push_undo();
6129                join_line(ed);
6130            }
6131        }
6132        LastChange::Paste { before, count } => {
6133            do_paste(ed, before, count * scale);
6134        }
6135        LastChange::DeleteToEol { inserted } => {
6136            use hjkl_buffer::{Edit, Position};
6137            ed.push_undo();
6138            delete_to_eol(ed);
6139            if let Some(text) = inserted {
6140                let cursor = ed.cursor();
6141                ed.mutate_edit(Edit::InsertStr {
6142                    at: Position::new(cursor.0, cursor.1),
6143                    text,
6144                });
6145            }
6146        }
6147        LastChange::OpenLine { above, inserted } => {
6148            use hjkl_buffer::{Edit, Position};
6149            ed.push_undo();
6150            ed.sync_buffer_content_from_textarea();
6151            let row = buf_cursor_pos(&ed.buffer).row;
6152            if above {
6153                ed.mutate_edit(Edit::InsertStr {
6154                    at: Position::new(row, 0),
6155                    text: "\n".to_string(),
6156                });
6157                let folds = crate::buffer_impl::SnapshotFoldProvider::from_buffer(&ed.buffer);
6158                crate::motions::move_up(&mut ed.buffer, &folds, 1, &mut ed.sticky_col);
6159            } else {
6160                let line_chars = buf_line_chars(&ed.buffer, row);
6161                ed.mutate_edit(Edit::InsertStr {
6162                    at: Position::new(row, line_chars),
6163                    text: "\n".to_string(),
6164                });
6165            }
6166            ed.push_buffer_cursor_to_textarea();
6167            let cursor = ed.cursor();
6168            ed.mutate_edit(Edit::InsertStr {
6169                at: Position::new(cursor.0, cursor.1),
6170                text: inserted,
6171            });
6172        }
6173        LastChange::InsertAt {
6174            entry,
6175            inserted,
6176            count,
6177        } => {
6178            use hjkl_buffer::{Edit, Position};
6179            ed.push_undo();
6180            match entry {
6181                InsertEntry::I => {}
6182                InsertEntry::ShiftI => move_first_non_whitespace(ed),
6183                InsertEntry::A => {
6184                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
6185                    ed.push_buffer_cursor_to_textarea();
6186                }
6187                InsertEntry::ShiftA => {
6188                    crate::motions::move_line_end(&mut ed.buffer);
6189                    crate::motions::move_right_to_end(&mut ed.buffer, 1);
6190                    ed.push_buffer_cursor_to_textarea();
6191                }
6192            }
6193            for _ in 0..count.max(1) {
6194                let cursor = ed.cursor();
6195                ed.mutate_edit(Edit::InsertStr {
6196                    at: Position::new(cursor.0, cursor.1),
6197                    text: inserted.clone(),
6198                });
6199            }
6200        }
6201    }
6202    ed.vim.replaying = false;
6203}
6204
6205// ─── Extracting inserted text for replay ───────────────────────────────────
6206
6207fn extract_inserted(before: &str, after: &str) -> String {
6208    let before_chars: Vec<char> = before.chars().collect();
6209    let after_chars: Vec<char> = after.chars().collect();
6210    if after_chars.len() <= before_chars.len() {
6211        return String::new();
6212    }
6213    let prefix = before_chars
6214        .iter()
6215        .zip(after_chars.iter())
6216        .take_while(|(a, b)| a == b)
6217        .count();
6218    let max_suffix = before_chars.len() - prefix;
6219    let suffix = before_chars
6220        .iter()
6221        .rev()
6222        .zip(after_chars.iter().rev())
6223        .take(max_suffix)
6224        .take_while(|(a, b)| a == b)
6225        .count();
6226    after_chars[prefix..after_chars.len() - suffix]
6227        .iter()
6228        .collect()
6229}
6230
6231// ─── Tests ────────────────────────────────────────────────────────────────