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