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