Skip to main content

hjkl_engine/
vim.rs

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