Skip to main content

hjkl_vim/
editor_ext.rs

1//! [`VimEditorExt`] — vim-discipline accessor methods on the engine
2//! [`Editor`], migrated out of `hjkl-engine` (#267 / #265 G3).
3//!
4//! These read the vim FSM state (`Editor::vim`) to answer render/selection
5//! questions. They belong to the vim *discipline*, not the mode-agnostic
6//! engine core, so they live here — a blanket trait impl on
7//! `Editor<View, H>`. As `VimState` finishes relocating into this crate,
8//! more of the engine's vim accessors move onto this trait; call sites pick
9//! them up with `use hjkl_vim::VimEditorExt`.
10
11use crate::vim::{
12    AbbrevTrigger, InsertDir, InsertReason, LastVisual, Motion, Operator, RangeKind, TextObject,
13};
14use hjkl_engine::input::Input;
15use hjkl_engine::types::{Highlight, HighlightKind, Host, Pos};
16use hjkl_engine::{Editor, FsmMode, MarkJump, MotionKind, VimMode};
17
18/// Move a position back by one character, wrapping to the end of the previous
19/// line when at column 0. Clamps at the buffer start `(0, 0)`. Used to render
20/// exclusive (VSCode) char selections via the inclusive buffer-tui paint path.
21///
22/// Was `Editor::dec_pos_one_char` in the engine; it exists only to serve
23/// [`VimEditorExt::buffer_selection`], so it moved here with it.
24fn dec_pos_one_char<H: Host>(
25    ed: &Editor<hjkl_buffer::View, H>,
26    p: hjkl_buffer::Position,
27) -> hjkl_buffer::Position {
28    use hjkl_buffer::Position;
29    if p.col > 0 {
30        return Position::new(p.row, p.col - 1);
31    }
32    if p.row > 0 {
33        let prev = p.row - 1;
34        let len = ed.line(prev).map_or(0, |l| l.chars().count());
35        return Position::new(prev, len);
36    }
37    Position::new(0, 0)
38}
39
40/// Common post-mutation sync for the `insert_*` primitives.
41///
42/// The vim FSM's `step` runs `ensure_cursor_in_scrolloff` at the end of every
43/// normal/visual motion; insert-mode primitives bypass `step` and must
44/// self-correct or the cursor scrolls off the viewport (held Enter, multi-line
45/// backspace at BOL, arrow keys at edge, etc.).
46///
47/// Marks the content dirty, widens the insert row's autoindent tracking, and
48/// re-checks scrolloff. Was `Editor::after_insert_mutation` (#267) — it exists
49/// only to serve the insert primitives, so it moved here with them.
50fn after_insert_mutation<H: Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
51    ed.mark_content_dirty();
52    let (row, _) = ed.cursor();
53    crate::vim_state::vim_mut(ed).widen_insert_row(row);
54    ed.ensure_cursor_in_scrolloff();
55}
56
57/// Like [`after_insert_mutation`] but for cursor-only insert ops that do not
58/// change content (arrows, Home/End, PageUp/Down). Skips the dirty mark.
59fn after_insert_motion<H: Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
60    let (row, _) = ed.cursor();
61    crate::vim_state::vim_mut(ed).widen_insert_row(row);
62    ed.ensure_cursor_in_scrolloff();
63}
64
65/// Vim-discipline read accessors layered onto every `Editor<View, H>`.
66///
67/// Blanket-implemented below; bring it into scope with
68/// `use hjkl_vim::VimEditorExt` to call these on an `Editor`.
69pub trait VimEditorExt {
70    /// VisualBlock selection bounds as `(top, bot, left, right)` — inclusive
71    /// rows and inclusive columns, derived from the block anchor and the
72    /// cursor's sticky column. Meaningful only while in VisualBlock mode;
73    /// callers that need the "are we in block mode?" guard use
74    /// [`VimEditorExt::block_highlight`] instead.
75    fn visual_block_bounds(&self) -> (usize, usize, usize, usize);
76
77    /// The VisualBlock highlight rectangle `(top, bot, left, right)`, or
78    /// `None` when the editor is not in VisualBlock mode.
79    fn block_highlight(&self) -> Option<(usize, usize, usize, usize)>;
80
81    /// Start/end `(row, col)` of the active char-wise Visual selection,
82    /// positionally ordered. `None` when not in Visual mode.
83    ///
84    /// When [`hjkl_engine::editor::Settings::selection_exclusive`] is `false`
85    /// (default, vim behaviour): both endpoints are **inclusive** — the cells
86    /// at `start` and `end` are both selected.
87    ///
88    /// When it is `true` (VSCode bar-cursor behaviour): the range is
89    /// **half-open** — `start` is included but `end` is the first cell that is
90    /// NOT selected (the caret sits before it). If the selection is empty
91    /// (`anchor == cursor`) `None` is returned so callers do not need to check
92    /// for zero-length ranges.
93    fn char_highlight(&self) -> Option<((usize, usize), (usize, usize))>;
94
95    /// Return the half-open exclusive char-visual range `(start, end)` where
96    /// `end` is the first cell NOT selected (the caret position). `None`
97    /// when not in Visual mode or the selection is empty.
98    ///
99    /// Convenience accessor for the VSCode dispatcher; avoids duplicating
100    /// the anchor/cursor ordering logic at the call site.
101    fn visual_char_range_exclusive(&self) -> Option<((usize, usize), (usize, usize))>;
102
103    /// Top/bottom rows of the active VisualLine selection (inclusive).
104    /// `None` when we're not in VisualLine mode.
105    fn line_highlight(&self) -> Option<(usize, usize)>;
106
107    /// Active selection in `hjkl_buffer::Selection` shape. `None` when not in
108    /// a Visual mode. The host hands this straight to `BufferView`.
109    fn buffer_selection(&self) -> Option<hjkl_buffer::Selection>;
110
111    /// Active visual selection as a SPEC [`Highlight`] with
112    /// [`HighlightKind::Selection`].
113    ///
114    /// Returns `None` when the editor isn't in a Visual mode. Visual-line and
115    /// visual-block selections collapse to the bounding char range of the
116    /// selection — the SPEC `Selection` kind doesn't carry sub-line info
117    /// today; hosts that need full line / block geometry continue to read
118    /// [`VimEditorExt::buffer_selection`] (the legacy `hjkl_buffer::Selection`
119    /// shape).
120    fn selection_highlight(&self) -> Option<Highlight>;
121
122    // ─── Text-object resolution (hjkl#70) ──────────────────────────────────
123    //
124    // Pure functions — no cursor mutation, no mode change, no register write.
125    // Each delegates to the `crate::vim::text_object_*_bridge` resolvers,
126    // which remain in the engine until vim.rs itself relocates (#267).
127    //
128    // Return value: `Some((start, end))` where both positions are `(row, col)`
129    // char-column pairs and `end` is *exclusive* (one past the last char to act
130    // on), matching the convention used by `delete_range` / `yank_range` / etc.
131    //
132    // Quote methods take the quote char itself (`'"'`, `'\''`, `` '`' ``).
133    // Bracket methods take the OPEN bracket char (`'('`, `'{'`, `'['`, `'<'`);
134    // close-bracket variants are NOT accepted — the grammar layer normalises
135    // close→open before calling these.
136
137    /// Resolve the range of `iw` (inner word) at the cursor.
138    ///
139    /// An inner word is the contiguous run of keyword characters (or
140    /// punctuation characters if the cursor is on punctuation) under the
141    /// cursor, without surrounding whitespace. Whitespace-only positions
142    /// return `None`.
143    fn text_object_inner_word(&self) -> Option<((usize, usize), (usize, usize))>;
144
145    /// Resolve the range of `aw` (around word) at the cursor.
146    ///
147    /// Like `iw` but extends the range to include trailing whitespace after
148    /// the word. If no trailing whitespace exists, leading whitespace before
149    /// the word is absorbed instead (vim `:help text-objects` behaviour).
150    fn text_object_around_word(&self) -> Option<((usize, usize), (usize, usize))>;
151
152    /// Resolve the range of `iW` (inner WORD) at the cursor.
153    ///
154    /// A WORD is any contiguous run of non-whitespace characters — punctuation
155    /// is not a word boundary.
156    fn text_object_inner_big_word(&self) -> Option<((usize, usize), (usize, usize))>;
157
158    /// Resolve the range of `aW` (around WORD) at the cursor.
159    fn text_object_around_big_word(&self) -> Option<((usize, usize), (usize, usize))>;
160
161    /// Resolve the range of `i<quote>` (inner quote) at the cursor.
162    ///
163    /// Excludes the quote characters themselves. `None` when the cursor's line
164    /// contains fewer than two occurrences of `quote`, or no matching pair can
165    /// be found around or ahead of the cursor.
166    fn text_object_inner_quote(&self, quote: char) -> Option<((usize, usize), (usize, usize))>;
167
168    /// Resolve the range of `a<quote>` (around quote) at the cursor.
169    ///
170    /// Like `i<quote>` but includes the quote characters plus surrounding
171    /// whitespace on one side: trailing after the closing quote if any exists,
172    /// otherwise leading before the opening quote.
173    fn text_object_around_quote(&self, quote: char) -> Option<((usize, usize), (usize, usize))>;
174
175    /// Resolve the range of `i<bracket>` (inner bracket pair) at the cursor.
176    ///
177    /// The cursor may be anywhere inside the pair or on a bracket character.
178    /// When not inside any pair the resolver falls back to a forward scan
179    /// (targets.vim-style: `ci(` works when the cursor is before `(`).
180    /// Multi-line pairs are supported.
181    fn text_object_inner_bracket(&self, open: char) -> Option<((usize, usize), (usize, usize))>;
182
183    /// Resolve the range of `a<bracket>` (around bracket pair) at the cursor.
184    ///
185    /// Like `i<bracket>` but includes the bracket characters themselves.
186    fn text_object_around_bracket(&self, open: char) -> Option<((usize, usize), (usize, usize))>;
187
188    /// Resolve `is` (inner sentence) at the cursor.
189    ///
190    /// Excludes trailing whitespace. Sentence boundaries follow vim's `is`
191    /// semantics (period / `?` / `!` followed by whitespace or
192    /// end-of-paragraph).
193    fn text_object_inner_sentence(&self) -> Option<((usize, usize), (usize, usize))>;
194
195    /// Resolve `as` (around sentence) at the cursor.
196    ///
197    /// Like `is` but includes trailing whitespace after the terminator.
198    fn text_object_around_sentence(&self) -> Option<((usize, usize), (usize, usize))>;
199
200    /// Resolve `ip` (inner paragraph) at the cursor.
201    ///
202    /// A paragraph is a block of non-blank lines bounded by blank lines or
203    /// buffer edges. `None` when the cursor is on a blank line.
204    fn text_object_inner_paragraph(&self) -> Option<((usize, usize), (usize, usize))>;
205
206    /// Resolve `ap` (around paragraph) at the cursor.
207    ///
208    /// Like `ip` but includes one trailing blank line when present.
209    fn text_object_around_paragraph(&self) -> Option<((usize, usize), (usize, usize))>;
210
211    /// Resolve `it` (inner tag) at the cursor.
212    ///
213    /// Matches XML/HTML-style `<tag>...</tag>` pairs, returning the content
214    /// between the open and close tags (excluding the tags themselves).
215    fn text_object_inner_tag(&self) -> Option<((usize, usize), (usize, usize))>;
216
217    /// Resolve `at` (around tag) at the cursor.
218    ///
219    /// Like `it` but includes the open and close tag delimiters.
220    fn text_object_around_tag(&self) -> Option<((usize, usize), (usize, usize))>;
221
222    // ─── Range-mutation primitives (hjkl#70) ───────────────────────────────
223    //
224    // These do not consume input — the caller (the visual-mode operator path)
225    // has already resolved the range from the visual selection before calling
226    // in. Normal-mode op dispatch continues to use `apply_op_motion` /
227    // `apply_op_double` / `apply_op_find` / `apply_op_text_obj`.
228
229    /// Delete the region `[start, end)` and stash the removed text in
230    /// `register`. `'"'` selects the unnamed register (vim default);
231    /// `'a'`–`'z'` select named registers.
232    fn delete_range(
233        &mut self,
234        start: (usize, usize),
235        end: (usize, usize),
236        kind: RangeKind,
237        register: char,
238    );
239
240    /// Yank (copy) the region `[start, end)` into `register` without mutating
241    /// the buffer. `'"'` selects the unnamed register; `'0'` the yank-only
242    /// register; `'a'`–`'z'` select named registers.
243    fn yank_range(
244        &mut self,
245        start: (usize, usize),
246        end: (usize, usize),
247        kind: RangeKind,
248        register: char,
249    );
250
251    /// Delete the region `[start, end)` and transition to Insert mode (vim `c`
252    /// operator). The deleted text is stashed in `register`. On return the
253    /// editor is in Insert mode; the caller must not issue further normal-mode
254    /// ops until the insert session ends.
255    fn change_range(
256        &mut self,
257        start: (usize, usize),
258        end: (usize, usize),
259        kind: RangeKind,
260        register: char,
261    );
262
263    /// Indent (`count > 0`) or outdent (`count < 0`) the row span
264    /// `[start.0, end.0]`. Column components are ignored — indent is always
265    /// linewise. `shiftwidth` overrides the editor's configured shiftwidth for
266    /// this call; pass `0` to use the current editor setting. `count == 0` is
267    /// a no-op.
268    fn indent_range(
269        &mut self,
270        start: (usize, usize),
271        end: (usize, usize),
272        count: i32,
273        shiftwidth: u32,
274    );
275
276    /// Apply a case transformation (`Operator::Uppercase` /
277    /// `Operator::Lowercase` / `Operator::ToggleCase`) to the region
278    /// `[start, end)`. Other `Operator` variants are silently ignored (no-op).
279    /// Registers are left untouched — vim's case operators do not write to
280    /// registers.
281    fn case_range(
282        &mut self,
283        start: (usize, usize),
284        end: (usize, usize),
285        kind: RangeKind,
286        op: Operator,
287    );
288
289    // ─── Block-shape range-mutation primitives (hjkl#70) ───────────────────
290    //
291    // Rectangular VisualBlock operations. `top_row`/`bot_row` are inclusive
292    // line indices; `left_col`/`right_col` are inclusive char-column bounds.
293    // Ragged-edge handling (short lines not reaching `right_col`) matches the
294    // engine FSM's `apply_block_operator` path — short lines lose only the
295    // chars that exist. `register` is the target; `'"'` selects unnamed.
296
297    /// Delete a rectangular VisualBlock selection.
298    fn delete_block(
299        &mut self,
300        top_row: usize,
301        bot_row: usize,
302        left_col: usize,
303        right_col: usize,
304        register: char,
305    );
306
307    /// Yank a rectangular VisualBlock selection into `register` without
308    /// mutating the buffer.
309    fn yank_block(
310        &mut self,
311        top_row: usize,
312        bot_row: usize,
313        left_col: usize,
314        right_col: usize,
315        register: char,
316    );
317
318    /// Delete a rectangular VisualBlock selection and enter Insert mode (`c`
319    /// operator). Mode is Insert on return.
320    fn change_block(
321        &mut self,
322        top_row: usize,
323        bot_row: usize,
324        left_col: usize,
325        right_col: usize,
326        register: char,
327    );
328
329    /// Indent (`count > 0`) or outdent (`count < 0`) rows `top_row..=bot_row`.
330    /// Column bounds are ignored — vim's block indent is always linewise.
331    /// `count == 0` is a no-op.
332    fn indent_block(
333        &mut self,
334        top_row: usize,
335        bot_row: usize,
336        left_col: usize,
337        right_col: usize,
338        count: i32,
339    );
340
341    /// Auto-indent (v1 dumb shiftwidth) the row span `[start.0, end.0]`.
342    /// Column components are ignored — auto-indent is always linewise.
343    ///
344    /// The algorithm is a naive bracket-depth counter: it scans the buffer
345    /// from row 0 to compute the correct depth at `start.0`, then for each
346    /// line in the target range strips existing leading whitespace and
347    /// prepends `depth × indent_unit`. Lines whose first non-whitespace
348    /// character is a close bracket get one fewer indent level. Empty /
349    /// whitespace-only lines are cleared. After the operation the cursor lands
350    /// on the first non-whitespace character of `start_row` (vim parity `==`).
351    ///
352    /// **v1 limitation**: the bracket scan does not detect brackets inside
353    /// string literals or comments.
354    fn auto_indent_range(&mut self, start: (usize, usize), end: (usize, usize));
355
356    // ─── Paste ─────────────────────────────────────────────────────────────
357
358    /// `p` — paste the unnamed register (or the register selected via `"r`)
359    /// after the cursor. Linewise content opens a new line below; charwise
360    /// content is inserted inline. Records `Paste { before: false }` for `.`.
361    fn paste_after(&mut self, count: usize);
362
363    /// `P` — paste the unnamed register (or the `"r` register) before the
364    /// cursor. Linewise content opens a new line above; charwise is inline.
365    /// Records `Paste { before: true }` for dot-repeat.
366    fn paste_before(&mut self, count: usize);
367
368    /// `gp` / `gP` — paste like `p`/`P` but leave the cursor just after the
369    /// pasted text. `before = true` for `gP`.
370    fn paste_cursor_after(&mut self, before: bool, count: usize);
371
372    /// `]p` / `[p` — linewise paste with the pasted block reindented to match
373    /// the current line. `before = true` for `[p`.
374    fn paste_reindent(&mut self, before: bool, count: usize);
375
376    /// Visual-mode `p` / `P` — replace the active selection with the register.
377    /// `before = true` for `P` (preserves the source register).
378    fn visual_paste(&mut self, before: bool);
379
380    // ─── Visual-mode operators ─────────────────────────────────────────────
381
382    /// Visual-mode `<C-a>`/`<C-x>` (uniform) and `g<C-a>`/`g<C-x>`
383    /// (`sequential`) — adjust the first number on each selected line.
384    fn adjust_number_visual(&mut self, delta: i64, sequential: bool);
385
386    /// Normal-mode `&` — repeat the last `:s` on the current line (no flags).
387    fn ampersand_repeat(&mut self);
388
389    /// Visual-mode `J` (`with_space = true`) / `gJ` (`false`) — join the
390    /// selected lines into one.
391    fn visual_join(&mut self, with_space: bool);
392
393    /// `[count]%` — jump to the line at `count` percent of the file.
394    fn goto_percent(&mut self, count: usize);
395
396    // ─── Jumplist motion ───────────────────────────────────────────────────
397
398    /// `<C-o>` — jump back `count` entries in the jumplist, saving the current
399    /// position on the forward stack so `<C-i>` can return.
400    fn jump_back(&mut self, count: usize);
401
402    /// `<C-i>` / `Tab` — redo `count` entries on the forward jumplist stack,
403    /// saving the current position on the backward stack.
404    fn jump_forward(&mut self, count: usize);
405
406    // ─── Search ────────────────────────────────────────────────────────────
407
408    /// `n` — repeat the last `/` or `?` search `count` times in its original
409    /// direction. `forward = true` keeps the direction; `false` inverts (`N`).
410    fn search_repeat(&mut self, forward: bool, count: usize);
411
412    /// `*` / `#` / `g*` / `g#` — search for the word under the cursor.
413    /// `forward` chooses direction; `whole_word` wraps the pattern in `\b`
414    /// anchors (true for `*` / `#`, false for `g*` / `g#`). `count` repeats.
415    fn word_search(&mut self, forward: bool, whole_word: bool, count: usize);
416
417    // ─── Chord appliers ────────────────────────────────────────────────────
418    //
419    // Each applies a completed chord with a pre-captured count, so the
420    // pending-state reducers can dispatch without re-entering the engine FSM.
421
422    /// `r<x>` — replace the char under the cursor with `ch`, `count` times.
423    /// Cursor ends on the last replaced char; one undo snapshot at start.
424    fn replace_char_at(&mut self, ch: char, count: usize);
425
426    /// `f`/`F`/`t`/`T` — find `ch` on the current line. `forward` chooses
427    /// direction, `till` stops one char short. Records `last_find` for `;`/`,`.
428    fn find_char(&mut self, ch: char, forward: bool, till: bool, count: usize);
429
430    /// Apply the g-chord effect for `g<ch>` with a pre-captured `count`.
431    fn after_g(&mut self, ch: char, count: usize);
432
433    /// Apply the z-chord effect for `z<ch>` with a pre-captured `count` —
434    /// `zz`/`zt`/`zb` (scroll-cursor), the fold ops, and `zf`.
435    fn after_z(&mut self, ch: char, count: usize);
436
437    // ─── Operator dispatch ─────────────────────────────────────────────────
438
439    /// Apply an operator over a single-key motion (e.g. `dw`, `d$`, `dG`).
440    /// The engine resolves `motion_key` to a `Motion` via `parse_motion`.
441    /// `total_count` is the folded product of prefix and inner counts. No-op
442    /// when `motion_key` is not a known motion (vim cancels the operator).
443    fn apply_op_motion(&mut self, op: Operator, motion_key: char, total_count: usize);
444
445    /// Apply a doubled-letter line op (`dd` / `yy` / `cc` / `>>` / `<<`).
446    fn apply_op_double(&mut self, op: Operator, total_count: usize);
447
448    /// Apply an operator over a find motion (`df<x>` / `dF<x>` / `dt<x>` /
449    /// `dT<x>`). Records `last_find` for `;` / `,` repeat and updates
450    /// `last_change` when `op` is Change (dot-repeat).
451    fn apply_op_find(&mut self, op: Operator, ch: char, forward: bool, till: bool, count: usize);
452
453    /// Apply an operator over a text-object range (`diw` / `daw` / `di"` …).
454    /// Unknown `ch` values are silently ignored, matching the FSM.
455    fn apply_op_text_obj(&mut self, op: Operator, ch: char, inner: bool, total_count: usize);
456
457    /// Apply an operator over a g-chord motion or case-op linewise form
458    /// (`dgg` / `dge` / `dgE` / `dgj` / `dgk` / `gUgU` …).
459    fn apply_op_g(&mut self, op: Operator, ch: char, total_count: usize);
460
461    // ─── Mode transitions ──────────────────────────────────────────────────
462    //
463    // Both the FSM and these wrappers write `current_mode`, so `vim_mode()`
464    // returns correct values regardless of which path performed the
465    // transition.
466
467    /// The current vim mode (Normal / Insert / Visual / VisualLine / VisualBlock).
468    fn vim_mode(&self) -> VimMode;
469
470    /// `v` from Normal — enter charwise Visual mode, anchoring the selection
471    /// at the current cursor position.
472    fn enter_visual_char(&mut self);
473
474    /// `V` from Normal — enter linewise Visual mode, anchoring on the current
475    /// line. Motions extend the selection by whole lines.
476    fn enter_visual_line(&mut self);
477
478    /// `<C-v>` from Normal — enter Visual-block mode. The selection is a
479    /// rectangle whose corners are the anchor and the live cursor.
480    fn enter_visual_block(&mut self);
481
482    /// Esc from any visual mode — set `<` / `>` marks, stash the selection for
483    /// `gv` re-entry, then return to Normal mode.
484    fn exit_visual_to_normal(&mut self);
485
486    /// `o` in Visual / VisualLine / VisualBlock — swap the cursor and anchor so
487    /// the user can extend the other end of the selection. Does NOT mutate the
488    /// selection range; only the active endpoint changes.
489    fn visual_o_toggle(&mut self);
490
491    /// `gv` — restore the last visual selection (mode + anchor + cursor
492    /// position). No-op when no visual selection has been exited yet.
493    fn reenter_last_visual(&mut self);
494
495    /// Direct mode-transition entry point. Sets both the internal FSM mode and
496    /// the stable `current_mode` field read by `vim_mode()`.
497    ///
498    /// Prefer the semantic primitives (`enter_visual_char`, `enter_insert_i`,
499    /// …) which also set up required bookkeeping (anchors, sessions, …). Use
500    /// `set_mode` only when you need a raw mode flip without side-effects.
501    fn set_mode(&mut self, mode: VimMode);
502
503    // ─── Visual anchors ────────────────────────────────────────────────────
504
505    /// The charwise Visual-mode anchor `(row, col)`.
506    fn visual_anchor(&self) -> (usize, usize);
507    /// Set the charwise Visual-mode anchor.
508    fn set_visual_anchor(&mut self, anchor: (usize, usize));
509    /// The linewise Visual-mode anchor row.
510    fn visual_line_anchor(&self) -> usize;
511    /// Set the linewise Visual-mode anchor row.
512    fn set_visual_line_anchor(&mut self, row: usize);
513    /// The VisualBlock anchor `(row, col)`.
514    fn block_anchor(&self) -> (usize, usize);
515    /// Set the VisualBlock anchor.
516    fn set_block_anchor(&mut self, anchor: (usize, usize));
517    /// The VisualBlock sticky (virtual) column.
518    fn block_vcol(&self) -> usize;
519    /// Set the VisualBlock sticky (virtual) column.
520    fn set_block_vcol(&mut self, vcol: usize);
521    /// Whether the VisualBlock selection is "ragged" (`$` was pressed —
522    /// `:h v_b_$`): every row resolves its own right edge to its own EOL
523    /// instead of the block's fixed `right` column.
524    fn block_to_eol(&self) -> bool;
525    /// Set the VisualBlock ragged (`$`) flag.
526    fn set_block_to_eol(&mut self, to_eol: bool);
527
528    // ─── Yank / register staging ───────────────────────────────────────────
529
530    /// Set the pending `"r` register selector without consuming it.
531    fn set_pending_register_raw(&mut self, reg: Option<char>);
532    /// Take (and clear) the pending `"r` register selector.
533    fn take_pending_register_raw(&mut self) -> Option<char>;
534
535    // ─── Macro recording / replay ──────────────────────────────────────────
536
537    /// Register currently being recorded into via `q{reg}`, if any.
538    fn recording_macro(&self) -> Option<char>;
539    /// Set (or clear) the register being recorded into.
540    fn set_recording_macro(&mut self, reg: Option<char>);
541    /// Append an input to the in-flight macro recording.
542    fn push_recording_key(&mut self, input: Input);
543    /// Take (and clear) the recorded macro keys.
544    fn take_recording_keys(&mut self) -> Vec<Input>;
545    /// Replace the recorded macro keys wholesale.
546    fn set_recording_keys(&mut self, keys: Vec<Input>);
547    /// Number of keys recorded so far.
548    fn recording_keys_len(&self) -> usize;
549    /// Whether a macro is currently being replayed.
550    fn is_replaying_macro_raw(&self) -> bool;
551    /// Set the macro-replay flag.
552    fn set_replaying_macro_raw(&mut self, v: bool);
553    /// The last macro register played, for `@@`.
554    fn last_macro(&self) -> Option<char>;
555    /// Set the last macro register played.
556    fn set_last_macro(&mut self, reg: Option<char>);
557
558    // ─── Last insert / visual / viewport ───────────────────────────────────
559
560    /// Position where the last insert session ended (`gi`).
561    fn last_insert_pos(&self) -> Option<(usize, usize)>;
562    /// Set the last insert-session end position.
563    fn set_last_insert_pos(&mut self, pos: Option<(usize, usize)>);
564    /// Snapshot of the last visual selection, for `gv`.
565    fn last_visual(&self) -> Option<LastVisual>;
566    /// Set the last-visual snapshot.
567    fn set_last_visual(&mut self, snap: Option<LastVisual>);
568    /// Whether `Ctrl-R` is armed and awaiting a register name.
569    fn insert_pending_register(&self) -> bool;
570    /// Set the `Ctrl-R` pending-register flag.
571    fn set_insert_pending_register(&mut self, v: bool);
572
573    // ─── Change-mark start ─────────────────────────────────────────────────
574
575    /// The stashed `[` mark start for a Change operation, or `None`.
576    fn change_mark_start(&self) -> Option<(usize, usize)>;
577    /// Take (and clear) the stashed `[` mark start.
578    fn take_change_mark_start(&mut self) -> Option<(usize, usize)>;
579    /// Set the stashed `[` mark start.
580    fn set_change_mark_start(&mut self, pos: Option<(usize, usize)>);
581
582    // ─── Visual / motion / search primitives ───────────────────────────────
583    //
584    // Vim *semantics* — motions, operators over selections, block-edge insert,
585    // search entry. These do not belong on a mode-agnostic rope editor; the
586    // engine keeps the raw buffer primitives (cursor, line reads, edits) and
587    // the vim discipline layers meaning on top (#265 / #267).
588
589    /// `true` when the editor is in any visual mode (Visual / VisualLine /
590    /// VisualBlock).
591    fn is_visual(&self) -> bool;
592
593    /// Apply `op` over `motion` with `count` repetitions, taking the full
594    /// vim-quirks path (operator context for `l`, clamping, etc.).
595    fn apply_op_with_motion_direct(&mut self, op: Operator, motion: &Motion, count: usize);
596
597    /// `Ctrl-a` / `Ctrl-x` — adjust the number under or after the cursor.
598    /// `delta = 1` increments, `-1` decrements; larger deltas multiply as in
599    /// vim's `5<C-a>`.
600    fn adjust_number(&mut self, delta: i64);
601
602    /// Open the `/` or `?` search prompt. `forward = true` for `/`.
603    fn enter_search(&mut self, forward: bool);
604
605    /// `d/pat` / `c/pat` / `y/pat` — open the search prompt in operator-pending
606    /// mode so the operator applies over the range to the match on commit.
607    fn enter_search_op(&mut self, forward: bool, op: Operator, count: usize);
608
609    /// Apply a pending operator-search over the exclusive charwise range from
610    /// `origin` to the current cursor (the just-found match position).
611    fn apply_op_search_range(&mut self, op: Operator, origin: (usize, usize));
612
613    /// VisualBlock `I` — enter Insert at the left edge of the block.
614    /// `count` repeats the typed text on every row (`[count]I`).
615    fn visual_block_insert_at_left(&mut self, top: usize, bot: usize, col: usize, count: usize);
616
617    /// VisualBlock `A` — enter Insert at the right edge of the block.
618    /// `col` is the append/typed column (one past the block's right edge,
619    /// or the ragged `$` column); `left` is the block's own left edge,
620    /// where the cursor lands on Esc (verified against real nvim — `A`'s
621    /// post-Esc cursor is NOT `col` on a block wider than one column).
622    /// `count` repeats the typed text on every row (`[count]A`).
623    fn visual_block_append_at_right(
624        &mut self,
625        top: usize,
626        bot: usize,
627        col: usize,
628        left: usize,
629        count: usize,
630    );
631
632    /// Execute a motion, pushing to the jumplist for big jumps and updating the
633    /// sticky column.
634    fn execute_motion(&mut self, motion: Motion, count: usize);
635
636    /// Update the VisualBlock virtual column after a motion. Horizontal motions
637    /// sync `block_vcol` to the cursor column; vertical motions leave it alone
638    /// so the intended column survives clamping to shorter rows.
639    fn update_block_vcol(&mut self, motion: &Motion);
640
641    /// Apply `op` over the current visual selection (char-wise, linewise, or
642    /// block).
643    fn apply_visual_operator(&mut self, op: Operator, count: usize);
644
645    /// VisualBlock `r<ch>` — replace every character cell in the block with
646    /// `ch`.
647    fn replace_block_char(&mut self, ch: char);
648
649    /// Charwise (`v`) / linewise (`V`) Visual-mode `r<ch>` — replace every
650    /// character in the selection with `ch` (B2). Newlines are preserved;
651    /// registers are untouched.
652    fn visual_replace_char(&mut self, ch: char);
653
654    /// Visual-mode `i<ch>` / `a<ch>` — extend the selection to cover the text
655    /// object identified by `ch`.
656    fn visual_text_obj_extend(&mut self, ch: char, inner: bool);
657
658    // ─── Insert-mode primitives ────────────────────────────────────────────
659    //
660    // Each wraps a `crate::vim::insert_*_bridge` and, when the bridge
661    // reports a mutation, runs the post-mutation sync (dirty mark, insert-row
662    // widening, scrolloff correction). Callers must ensure the editor is in
663    // Insert (or Replace) mode first.
664
665    /// Insert `ch` at the cursor. In Replace mode, overstrike the cell under
666    /// the cursor instead; at end-of-line, always appends. With `smartindent`,
667    /// closing brackets trigger a one-unit dedent on an otherwise-whitespace
668    /// line.
669    fn insert_char(&mut self, ch: char);
670    /// Insert a newline, applying autoindent / smartindent.
671    fn insert_newline(&mut self);
672    /// Insert a tab (or spaces to the next `softtabstop` boundary under
673    /// `expandtab`).
674    fn insert_tab(&mut self);
675    /// Backspace. Deletes a whole soft-tab run at an aligned boundary under
676    /// `softtabstop`; joins with the previous line at column 0.
677    fn insert_backspace(&mut self);
678    /// Delete the char under the cursor; joins with the next line at EOL.
679    fn insert_delete(&mut self);
680    /// Arrow-key motion in Insert, breaking the undo group per
681    /// `undo_break_on_motion`.
682    fn insert_arrow(&mut self, dir: InsertDir);
683    /// Home in Insert.
684    fn insert_home(&mut self);
685    /// End in Insert.
686    fn insert_end(&mut self);
687    /// PageUp in Insert.
688    fn insert_pageup(&mut self, viewport_h: u16);
689    /// PageDown in Insert.
690    fn insert_pagedown(&mut self, viewport_h: u16);
691    /// `Ctrl-W` — delete the word before the cursor.
692    fn insert_ctrl_w(&mut self);
693    /// `Ctrl-U` — delete to the start of the line.
694    fn insert_ctrl_u(&mut self);
695    /// `Ctrl-H` — backspace equivalent.
696    fn insert_ctrl_h(&mut self);
697    /// `Ctrl-O` — arm a one-shot Normal-mode command.
698    fn insert_ctrl_o_arm(&mut self);
699    /// `Ctrl-R` — arm register paste; the next char names the register.
700    fn insert_ctrl_r_arm(&mut self);
701    /// `Ctrl-T` — indent the current line one `shiftwidth`.
702    fn insert_ctrl_t(&mut self);
703    /// `Ctrl-D` — dedent the current line one `shiftwidth`.
704    fn insert_ctrl_d(&mut self);
705    /// `Ctrl-A` — insert the text typed during the most recent insert
706    /// session (vim's "." register).
707    fn insert_ctrl_a(&mut self);
708    /// `Ctrl-E` — insert the char in the same column of the line below.
709    fn insert_ctrl_e(&mut self);
710    /// `Ctrl-Y` — insert the char in the same column of the line above.
711    fn insert_ctrl_y(&mut self);
712    /// Paste register `reg` at the cursor (the `Ctrl-R` follow-up).
713    fn insert_paste_register(&mut self, reg: char);
714    /// `Ctrl-[` — expand any pending abbreviation (Esc-equivalent trigger).
715    fn insert_ctrl_bracket(&mut self);
716    /// Esc from Insert — end the insert session and return to Normal.
717    fn leave_insert_to_normal(&mut self);
718
719    // ─── Insert-mode entry ─────────────────────────────────────────────────
720
721    /// `i` — insert before the cursor, `count` times on commit.
722    fn enter_insert_i(&mut self, count: usize);
723    /// `I` — insert at the first non-blank of the line.
724    fn enter_insert_shift_i(&mut self, count: usize);
725    /// `a` — append after the cursor.
726    fn enter_insert_a(&mut self, count: usize);
727    /// `A` — append at end-of-line.
728    fn enter_insert_shift_a(&mut self, count: usize);
729    /// `o` — open a new line below and insert.
730    fn open_line_below(&mut self, count: usize);
731    /// `O` — open a new line above and insert.
732    fn open_line_above(&mut self, count: usize);
733    /// `R` — enter Replace mode.
734    fn enter_replace_mode(&mut self, count: usize);
735
736    // ─── Normal-mode edit primitives ───────────────────────────────────────
737
738    /// `x` — delete `count` chars forward.
739    fn delete_char_forward(&mut self, count: usize);
740    /// `X` — delete `count` chars backward.
741    fn delete_char_backward(&mut self, count: usize);
742    /// `s` — substitute `count` chars (delete then insert).
743    fn substitute_char(&mut self, count: usize);
744    /// `S` — substitute whole lines.
745    fn substitute_line(&mut self, count: usize);
746    /// `D` — delete to end-of-line (`[count]D` extends down count-1 lines).
747    fn delete_to_eol(&mut self, count: usize);
748    /// `C` — change to end-of-line (`[count]C` extends down count-1 lines).
749    fn change_to_eol(&mut self, count: usize);
750    /// `Y` — yank to end-of-line.
751    fn yank_to_eol(&mut self, count: usize);
752    /// `J` — join `count` lines.
753    fn join_line(&mut self, count: usize);
754    /// `~` — toggle case of `count` chars, advancing right.
755    fn toggle_case_at_cursor(&mut self, count: usize);
756
757    // ─── Vim mark commands ─────────────────────────────────────────────────
758    //
759    // Mark *storage* (`Editor::mark` / `set_mark` / `marks()` / `file_marks()`
760    // / the global-mark map) stays on the engine: a mark is a positional
761    // bookmark, which is an editor concern that other seams already consume
762    // (hjkl-ex backs `:marks` and `'a` line addressing with it, and LSP /
763    // quickfix / bookmark features could too).
764    //
765    // What lives here is the vim *command* layer on top of that storage — the
766    // `m` / `'` / `` ` `` keybindings, which decide linewise vs charwise jump
767    // and push the jumplist. That is vim semantics, not bookmark storage.
768
769    /// `.` — dot-repeat: replay the last buffered change at the cursor. A
770    /// non-zero `count` *replaces* the change's stored count (`:h .` — `3x`
771    /// then `2.` deletes 2, not 6); `count == 0` means no explicit count.
772    fn replay_last_change(&mut self, count: usize);
773
774    /// `m{ch}` — record a mark named `ch` at the current cursor position.
775    /// Invalid chars are silently ignored.
776    fn set_mark_at_cursor(&mut self, ch: char);
777
778    /// `'{ch}` — jump to mark `ch`, linewise (row, first non-blank). Pushes the
779    /// pre-jump position onto the jumplist if the cursor actually moved.
780    fn goto_mark_line(&mut self, ch: char);
781
782    /// `` `{ch} `` — jump to mark `ch`, charwise (exact row + col). Pushes the
783    /// pre-jump position onto the jumplist if the cursor actually moved.
784    fn goto_mark_char(&mut self, ch: char);
785
786    /// Like [`VimEditorExt::goto_mark_line`], but reports cross-buffer jumps:
787    /// uppercase marks (`'A'`–`'Z'`) living in another buffer return
788    /// [`MarkJump::CrossBuffer`] so the app can switch slots first.
789    fn try_goto_mark_line(&mut self, ch: char) -> MarkJump;
790
791    /// Charwise counterpart of [`VimEditorExt::try_goto_mark_line`].
792    fn try_goto_mark_char(&mut self, ch: char) -> MarkJump;
793
794    // ─── Vim FSM state accessors (pending chord, count, mode, macros) ──────
795    //
796    // The FSM in this crate reads and writes VimState through these. They are
797    // pure vim state, so they belong here rather than on the mode-agnostic
798    // engine core (#267).
799
800    /// Return a clone of the current pending chord state.
801    fn pending(&self) -> crate::vim::Pending;
802
803    /// Overwrite the pending chord state.
804    fn set_pending(&mut self, p: crate::vim::Pending);
805
806    /// Atomically take the pending chord, replacing it with `Pending::None`.
807    fn take_pending(&mut self) -> crate::vim::Pending;
808
809    /// Return the raw digit-prefix count (`0` = no prefix typed yet).
810    fn count(&self) -> usize;
811
812    /// Overwrite the digit-prefix count directly. Clamped at
813    /// [`crate::vim::MAX_COUNT`] (vim's documented count ceiling, `:h count`).
814    fn set_count(&mut self, c: usize);
815
816    /// Accumulate one more digit into the count prefix (mirrors `count * 10 + digit`).
817    fn accumulate_count_digit(&mut self, digit: usize);
818
819    /// Reset the count prefix to zero (no pending count).
820    fn reset_count(&mut self);
821
822    /// Consume the count and return it; resets to zero. Returns `1` when no
823    /// prefix was typed (mirrors `take_count` in vim.rs).
824    fn take_count(&mut self) -> usize;
825
826    /// Return the FSM-internal mode (Normal / Insert / Visual / …).
827    fn fsm_mode(&self) -> crate::vim::Mode;
828
829    /// Overwrite the FSM-internal mode without side-effects. Prefer the
830    /// semantic primitives (`enter_insert_i`, `enter_visual_char`, …).
831    fn set_fsm_mode(&mut self, m: crate::vim::Mode);
832
833    /// `true` while the `.` dot-repeat replay is running.
834    fn is_replaying(&self) -> bool;
835
836    /// Set or clear the dot-replay flag.
837    fn set_replaying(&mut self, v: bool);
838
839    /// `true` when we entered Normal from Insert via `Ctrl-o` and will return
840    /// to Insert after the next complete command.
841    fn is_one_shot_normal(&self) -> bool;
842
843    /// Set or clear the Ctrl-o one-shot-normal flag.
844    fn set_one_shot_normal(&mut self, v: bool);
845
846    /// Return the last `f`/`F`/`t`/`T` target as `(char, forward, till)`, or
847    /// `None` before any find command was executed.
848    fn last_find(&self) -> Option<(char, bool, bool)>;
849
850    /// Overwrite the stored last-find target.
851    fn set_last_find(&mut self, target: Option<(char, bool, bool)>);
852
853    /// Perform a vim-sneak style two-char digraph jump. Scans the buffer
854    /// from the current cursor for the `count`-th occurrence of `c1+c2`.
855    /// `forward=true` searches ahead; `forward=false` searches backward.
856    /// Respects `Settings::motion_sneak` — callers (hjkl-vim FSM) should
857    /// already gate on the setting; this method always executes the sneak.
858    fn sneak(&mut self, c1: char, c2: char, forward: bool, count: usize);
859
860    /// Apply an operator over a sneak digraph range. Charwise exclusive —
861    /// deletes from cursor up to (not including) the first char of the match.
862    fn apply_op_sneak(
863        &mut self,
864        op: crate::vim::Operator,
865        c1: char,
866        c2: char,
867        forward: bool,
868        total_count: usize,
869    );
870
871    /// Return the last sneak digraph and direction stored after a sneak motion.
872    /// `Some(((c1, c2), forward))` when a sneak has been performed this session;
873    /// `None` before any sneak. Used by `;`/`,` repeat and tests.
874    fn last_sneak(&self) -> Option<((char, char), bool)>;
875
876    /// Return a clone of the last recorded mutating change, or `None` before
877    /// any change has been made.
878    fn last_change(&self) -> Option<crate::vim::LastChange>;
879
880    /// Overwrite the stored last-change record.
881    fn set_last_change(&mut self, lc: Option<crate::vim::LastChange>);
882
883    /// Borrow the last-change record mutably (e.g. to fill in an `inserted`
884    /// field after the insert session completes).
885    fn last_change_mut(&mut self) -> Option<&mut crate::vim::LastChange>;
886
887    /// Borrow the active insert session, or `None` when not in Insert mode.
888    fn insert_session(&self) -> Option<&crate::vim::InsertSession>;
889
890    /// Borrow the active insert session mutably.
891    fn insert_session_mut(&mut self) -> Option<&mut crate::vim::InsertSession>;
892
893    /// Atomically take the insert session out, leaving `None`.
894    fn take_insert_session(&mut self) -> Option<crate::vim::InsertSession>;
895
896    /// Install a new insert session, replacing any existing one.
897    fn set_insert_session(&mut self, s: Option<crate::vim::InsertSession>);
898
899    // ─── Register selection / chord status / macro controller ──────────────
900
901    /// Return the user's pending register selection (set via `"<reg>` chord
902    /// before an operator). `None` if no register was selected — caller should
903    /// use the unnamed register `"`.
904    ///
905    /// Read-only — does not consume / clear the pending selection. The
906    /// register is cleared by the engine after the next operator fires.
907    ///
908    /// Promoted in 0.6.X for Phase 4e to let the App's visual-op dispatch arm
909    /// honor `"a` + visual op chord sequences.
910    fn pending_register(&self) -> Option<char>;
911
912    /// True when the user's pending register selector is `+` or `*`.
913    /// the host peeks this so it can refresh `sync_clipboard_register`
914    /// only when a clipboard read is actually about to happen.
915    fn pending_register_is_clipboard(&self) -> bool;
916
917    /// Register currently being recorded into via `q{reg}`. `None` when
918    /// no recording is active. Hosts use this to surface a "recording @r"
919    /// indicator in the status line.
920    fn recording_register(&self) -> Option<char>;
921
922    /// Pending repeat count the user has typed but not yet resolved
923    /// (e.g. pressing `5` before `d`). `None` when nothing is pending.
924    /// Hosts surface this in a "showcmd" area.
925    fn pending_count(&self) -> Option<u32>;
926
927    /// The operator character for any in-flight operator that is waiting
928    /// for a motion (e.g. `d` after the user types `d` but before a
929    /// motion). Returns `None` when no operator is pending.
930    fn pending_op(&self) -> Option<char>;
931
932    /// `true` when the engine is in any pending chord state — waiting for
933    /// the next key to complete a command (e.g. `r<char>` replace,
934    /// `f<char>` find, `m<a>` set-mark, `'<a>` goto-mark, operator-pending
935    /// after `d` / `c` / `y`, `g`-prefix continuation, `z`-prefix continuation,
936    /// register selection `"<reg>`, macro recording target, etc).
937    ///
938    /// Hosts use this to bypass their own chord dispatch (keymap tries, etc.)
939    /// and forward keys directly to the engine so in-flight commands can
940    /// complete without the host eating their continuation keys.
941    fn is_chord_pending(&self) -> bool;
942
943    /// `true` when `insert_ctrl_r_arm()` has been called and the dispatcher
944    /// is waiting for the next typed character to name the register to paste.
945    /// The dispatcher should call `insert_paste_register(c)` instead of
946    /// `insert_char(c)` for the next printable key, then the flag auto-clears.
947    ///
948    /// Phase 6.5: exposed so the app-level `dispatch_insert_key` can branch
949    /// without having to drive the full FSM.
950    fn is_insert_register_pending(&self) -> bool;
951
952    /// Clear the `Ctrl-R` register-paste pending flag. Call this immediately
953    /// before `insert_paste_register(c)` in app-level dispatchers so that the
954    /// flag does not persist into the next key. Call before
955    /// `insert_paste_register_bridge` (which `hjkl_vim::insert` does).
956    ///
957    /// Phase 6.5: used by `dispatch_insert_key` in the app crate.
958    fn clear_insert_register_pending(&mut self);
959
960    /// Set `vim.pending_register` to `Some(reg)` if `reg` is a valid register
961    /// selector (`a`–`z`, `A`–`Z`, `0`–`9`, `"`, `+`, `*`, `_`). Invalid
962    /// chars are silently ignored (no-op), matching the engine FSM's
963    /// `handle_select_register` behaviour.
964    ///
965    /// Promoted to the public surface in 0.5.17 so the hjkl-vim
966    /// `PendingState::SelectRegister` reducer can dispatch `SetPendingRegister`
967    /// without re-entering the engine FSM. `handle_select_register` (engine FSM
968    /// path for macro-replay / defensive coverage) delegates here to avoid
969    /// logic duplication.
970    fn set_pending_register(&mut self, reg: char);
971
972    /// Begin recording keystrokes into register `reg`. The caller (app) is
973    /// responsible for stopping the recording via `stop_macro_record` when the
974    /// user presses bare `q`.
975    ///
976    /// - Uppercase `reg` (e.g. `'A'`) appends to the existing lowercase
977    ///   recording by pre-seeding `recording_keys` with the decoded text of the
978    ///   matching lowercase register, matching vim's capital-register append
979    ///   semantics.
980    /// - Lowercase `reg` clears `recording_keys` (fresh recording).
981    /// - Invalid chars (non-alphabetic, non-digit) are silently ignored.
982    ///
983    /// Promoted to the public surface in Phase 5b so the app's
984    /// `route_chord_key` can start a recording without re-entering the engine
985    /// FSM. `handle_record_macro_target` (engine FSM path for macro-replay
986    /// defensive coverage) continues to use the same logic via delegation.
987    fn start_macro_record(&mut self, reg: char);
988
989    /// Finalize the active recording: encode `recording_keys` as text and write
990    /// to the matching (lowercase) named register. Clears both `recording_macro`
991    /// and `recording_keys`. No-ops if no recording is active.
992    ///
993    /// Promoted to the public surface in Phase 5b so the app's `QChord` action
994    /// can stop a recording when the user presses bare `q` without re-entering
995    /// the engine FSM.
996    fn stop_macro_record(&mut self);
997
998    /// Returns `true` while a `q{reg}` recording is in progress.
999    /// Hosts use this to show a "recording @r" status indicator and to decide
1000    /// whether bare `q` should stop the recording or open the `RecordMacroTarget`
1001    /// chord.
1002    fn is_recording_macro(&self) -> bool;
1003
1004    /// Returns `true` while a macro is being replayed. The app sets this flag
1005    /// (via `play_macro`) and clears it (via `end_macro_replay`) around the
1006    /// re-feed loop so the recorder hook can skip double-capture.
1007    fn is_replaying_macro(&self) -> bool;
1008
1009    /// Decode the named register `reg` into a `Vec<hjkl_engine::input::Input>` and
1010    /// prepare for replay, returning ONE iteration of the inputs the app
1011    /// should re-feed through `route_chord_key`.
1012    ///
1013    /// Count semantics live in the HOST: `3@a` replays the returned keys
1014    /// three times by looping (or re-splicing a work queue), never by
1015    /// materializing `keys × count` up front — an unclamped `999999999@a`
1016    /// would otherwise allocate multi-GB before the first key plays
1017    /// (audit R2).
1018    ///
1019    /// Resolves `reg`:
1020    /// - `'@'` → use `vim.last_macro`; returns empty vec if none.
1021    /// - Any other char → lowercase it, read the register, decode.
1022    ///
1023    /// Side-effects:
1024    /// - Sets `vim.last_macro` to the resolved register.
1025    /// - Sets `vim.replaying_macro = true` so the recorder hook skips during
1026    ///   replay. The app calls `end_macro_replay` after the loop finishes.
1027    ///
1028    /// Returns an empty vec (and no side-effects for `'@'`) if the register is
1029    /// unset or empty.
1030    fn play_macro(&mut self, reg: char) -> Vec<hjkl_engine::input::Input>;
1031
1032    /// Clear the `replaying_macro` flag. Called by the app after the
1033    /// re-feed loop in the `PlayMacro` commit arm completes (or aborts).
1034    fn end_macro_replay(&mut self);
1035
1036    /// Append `input` to the active recording (`recording_keys`) if and only
1037    /// if a recording is in progress AND we are not currently replaying.
1038    /// Called by the app's `route_chord_key` recorder hook so that user
1039    /// keystrokes captured through the app-level chord path are recorded
1040    /// (rather than relying solely on the engine FSM's in-step hook).
1041    fn record_input(&mut self, input: hjkl_engine::input::Input);
1042
1043    // ─── Mode reset / mouse-driven selection / operator range probe ────────
1044
1045    /// Force back to Normal mode (used when dismissing completions etc.).
1046    fn force_normal(&mut self);
1047
1048    /// Handle a left-button click at doc-space `(row, col)`. Exits Visual mode
1049    /// if active, breaks the insert-mode undo group (vim parity for
1050    /// `undo_break_on_motion`), then moves the cursor. The EOL clamp is
1051    /// mode-aware (neovim parity): Normal/Visual cap at `len - 1`, Insert
1052    /// allows the one-past-EOL position.
1053    fn mouse_click_doc(&mut self, row: usize, col: usize);
1054
1055    /// Begin a mouse-drag selection: anchor at the cursor and enter
1056    /// Visual-char mode. Idempotent if already in Visual-char.
1057    fn mouse_begin_drag(&mut self);
1058
1059    /// Dry-run `motion_key` and return the `(min_row, max_row)` span between
1060    /// the cursor row and the motion's target row, restoring the cursor
1061    /// afterwards. `None` when `motion_key` is not a known motion.
1062    fn range_for_op_motion(
1063        &mut self,
1064        motion_key: char,
1065        total_count: usize,
1066    ) -> Option<(usize, usize)>;
1067
1068    // ─── Motion dispatch / operator range probes ───────────────────────────
1069
1070    /// Execute a named cursor motion `kind`, repeated `count` times. Maps the
1071    /// keymap-layer `MotionKind` onto the vim motion primitives, bypassing the
1072    /// FSM. Identical cursor semantics to the FSM path — sticky column, scroll
1073    /// sync and big-jump tracking all apply.
1074    fn apply_motion(&mut self, kind: MotionKind, count: usize);
1075
1076    /// Dry-run a `g`-prefixed motion and return `(min_row, max_row)` — for
1077    /// `=gg` / `=gj` etc. `None` for unknown `ch`. The cursor is restored.
1078    fn range_for_op_g(&mut self, ch: char, total_count: usize) -> Option<(usize, usize)>;
1079
1080    /// Dry-run a text object and return `(min_row, max_row)` — for `=iw` /
1081    /// `=ap` etc. `None` for unknown `ch`.
1082    fn range_for_op_text_obj(
1083        &self,
1084        ch: char,
1085        inner: bool,
1086        total_count: usize,
1087    ) -> Option<(usize, usize)>;
1088}
1089
1090impl<H: Host> VimEditorExt for Editor<hjkl_buffer::View, H> {
1091    fn visual_block_bounds(&self) -> (usize, usize, usize, usize) {
1092        let (ar, ac) = crate::vim_state::vim(self).block_anchor;
1093        let (cr, _) = self.cursor();
1094        let cc = crate::vim_state::vim(self).block_vcol;
1095        (ar.min(cr), ar.max(cr), ac.min(cc), ac.max(cc))
1096    }
1097
1098    fn block_highlight(&self) -> Option<(usize, usize, usize, usize)> {
1099        if self.vim_mode() != VimMode::VisualBlock {
1100            return None;
1101        }
1102        let (ar, ac) = crate::vim_state::vim(self).block_anchor;
1103        let cr = self.cursor().0;
1104        let cc = crate::vim_state::vim(self).block_vcol;
1105        Some((ar.min(cr), ar.max(cr), ac.min(cc), ac.max(cc)))
1106    }
1107
1108    fn char_highlight(&self) -> Option<((usize, usize), (usize, usize))> {
1109        if self.vim_mode() != VimMode::Visual {
1110            return None;
1111        }
1112        let anchor = crate::vim_state::vim(self).visual_anchor;
1113        let cursor = self.cursor();
1114        let (start, end) = if anchor <= cursor {
1115            (anchor, cursor)
1116        } else {
1117            (cursor, anchor)
1118        };
1119        if self.settings().selection_exclusive {
1120            // Half-open: start..end (end excluded). Empty when start == end.
1121            if start == end {
1122                return None;
1123            }
1124            Some((start, end))
1125        } else {
1126            // Inclusive (vim default): both endpoints are selected.
1127            Some((start, end))
1128        }
1129    }
1130
1131    fn visual_char_range_exclusive(&self) -> Option<((usize, usize), (usize, usize))> {
1132        if self.vim_mode() != VimMode::Visual {
1133            return None;
1134        }
1135        let anchor = crate::vim_state::vim(self).visual_anchor;
1136        let cursor = self.cursor();
1137        if anchor == cursor {
1138            return None;
1139        }
1140        let (start, end) = if anchor <= cursor {
1141            (anchor, cursor)
1142        } else {
1143            (cursor, anchor)
1144        };
1145        Some((start, end))
1146    }
1147
1148    fn line_highlight(&self) -> Option<(usize, usize)> {
1149        if self.vim_mode() != VimMode::VisualLine {
1150            return None;
1151        }
1152        let anchor = crate::vim_state::vim(self).visual_line_anchor;
1153        let cursor = self.cursor().0;
1154        Some((anchor.min(cursor), anchor.max(cursor)))
1155    }
1156
1157    fn buffer_selection(&self) -> Option<hjkl_buffer::Selection> {
1158        use hjkl_buffer::{Position, Selection};
1159        let (cr, cc) = self.cursor();
1160        match self.vim_mode() {
1161            VimMode::Visual => {
1162                let (ar, ac) = crate::vim_state::vim(self).visual_anchor;
1163                let head = Position::new(cr, cc);
1164                if self.settings().selection_exclusive {
1165                    // Exclusive (VSCode bar-caret): render the half-open char set
1166                    // [start, end) so the cell under the caret is NOT highlighted.
1167                    // The buffer-tui renderer paints `row_span` inclusively, so
1168                    // drop one char off the max end. Empty selection → no
1169                    // highlight (caller is effectively in Insert).
1170                    let anchor_pos = Position::new(ar, ac);
1171                    if anchor_pos == head {
1172                        return None;
1173                    }
1174                    let (start, end) = if (ar, ac) <= (head.row, head.col) {
1175                        (anchor_pos, head)
1176                    } else {
1177                        (head, anchor_pos)
1178                    };
1179                    return Some(Selection::Char {
1180                        anchor: start,
1181                        head: dec_pos_one_char(self, end),
1182                    });
1183                }
1184                Some(Selection::Char {
1185                    anchor: Position::new(ar, ac),
1186                    head,
1187                })
1188            }
1189            VimMode::VisualLine => Some(Selection::Line {
1190                anchor_row: crate::vim_state::vim(self).visual_line_anchor,
1191                head_row: cr,
1192            }),
1193            VimMode::VisualBlock => {
1194                let (ar, ac) = crate::vim_state::vim(self).block_anchor;
1195                let vcol = crate::vim_state::vim(self).block_vcol;
1196                if crate::vim_state::vim(self).block_to_eol {
1197                    // Ragged (`$` — `:h v_b_$`): `Selection::Block::row_span`
1198                    // only ever resolves one fixed `(left, right)` pair for
1199                    // every row. Reuse the SAME `usize::MAX` "cap at the
1200                    // row's actual length" convention `Selection::Line`
1201                    // already uses (see `hjkl_buffer::selection::RowSpan`)
1202                    // by forcing the right corner's col to `usize::MAX` —
1203                    // the renderer then extends every row to its own EOL.
1204                    // Normalise which corner carries `MAX` so it's always
1205                    // the "right" one regardless of anchor/cursor order.
1206                    let left = ac.min(vcol);
1207                    return Some(Selection::Block {
1208                        anchor: Position::new(ar, left),
1209                        head: Position::new(cr, usize::MAX),
1210                    });
1211                }
1212                Some(Selection::Block {
1213                    anchor: Position::new(ar, ac),
1214                    head: Position::new(cr, vcol),
1215                })
1216            }
1217            _ => None,
1218        }
1219    }
1220
1221    fn selection_highlight(&self) -> Option<Highlight> {
1222        let sel = self.buffer_selection()?;
1223        let (start, end) = match sel {
1224            hjkl_buffer::Selection::Char { anchor, head } => {
1225                let a = (anchor.row, anchor.col);
1226                let h = (head.row, head.col);
1227                if a <= h { (a, h) } else { (h, a) }
1228            }
1229            hjkl_buffer::Selection::Line {
1230                anchor_row,
1231                head_row,
1232            } => {
1233                let (top, bot) = if anchor_row <= head_row {
1234                    (anchor_row, head_row)
1235                } else {
1236                    (head_row, anchor_row)
1237                };
1238                let last_col = self.line(bot).map_or(0, |l| l.len());
1239                ((top, 0), (bot, last_col))
1240            }
1241            hjkl_buffer::Selection::Block { anchor, head } => {
1242                let (top, bot) = if anchor.row <= head.row {
1243                    (anchor.row, head.row)
1244                } else {
1245                    (head.row, anchor.row)
1246                };
1247                let (left, right) = if anchor.col <= head.col {
1248                    (anchor.col, head.col)
1249                } else {
1250                    (head.col, anchor.col)
1251                };
1252                ((top, left), (bot, right))
1253            }
1254        };
1255        Some(Highlight {
1256            range: Pos {
1257                line: start.0 as u32,
1258                col: start.1 as u32,
1259            }..Pos {
1260                line: end.0 as u32,
1261                col: end.1 as u32,
1262            },
1263            kind: HighlightKind::Selection,
1264        })
1265    }
1266
1267    // ─── Text-object resolution ────────────────────────────────────────────
1268
1269    fn text_object_inner_word(&self) -> Option<((usize, usize), (usize, usize))> {
1270        crate::vim::text_object_inner_word_bridge(self)
1271    }
1272
1273    fn text_object_around_word(&self) -> Option<((usize, usize), (usize, usize))> {
1274        crate::vim::text_object_around_word_bridge(self)
1275    }
1276
1277    fn text_object_inner_big_word(&self) -> Option<((usize, usize), (usize, usize))> {
1278        crate::vim::text_object_inner_big_word_bridge(self)
1279    }
1280
1281    fn text_object_around_big_word(&self) -> Option<((usize, usize), (usize, usize))> {
1282        crate::vim::text_object_around_big_word_bridge(self)
1283    }
1284
1285    fn text_object_inner_quote(&self, quote: char) -> Option<((usize, usize), (usize, usize))> {
1286        crate::vim::text_object_inner_quote_bridge(self, quote)
1287    }
1288
1289    fn text_object_around_quote(&self, quote: char) -> Option<((usize, usize), (usize, usize))> {
1290        crate::vim::text_object_around_quote_bridge(self, quote)
1291    }
1292
1293    fn text_object_inner_bracket(&self, open: char) -> Option<((usize, usize), (usize, usize))> {
1294        crate::vim::text_object_inner_bracket_bridge(self, open)
1295    }
1296
1297    fn text_object_around_bracket(&self, open: char) -> Option<((usize, usize), (usize, usize))> {
1298        crate::vim::text_object_around_bracket_bridge(self, open)
1299    }
1300
1301    fn text_object_inner_sentence(&self) -> Option<((usize, usize), (usize, usize))> {
1302        crate::vim::text_object_inner_sentence_bridge(self)
1303    }
1304
1305    fn text_object_around_sentence(&self) -> Option<((usize, usize), (usize, usize))> {
1306        crate::vim::text_object_around_sentence_bridge(self)
1307    }
1308
1309    fn text_object_inner_paragraph(&self) -> Option<((usize, usize), (usize, usize))> {
1310        crate::vim::text_object_inner_paragraph_bridge(self)
1311    }
1312
1313    fn text_object_around_paragraph(&self) -> Option<((usize, usize), (usize, usize))> {
1314        crate::vim::text_object_around_paragraph_bridge(self)
1315    }
1316
1317    fn text_object_inner_tag(&self) -> Option<((usize, usize), (usize, usize))> {
1318        crate::vim::text_object_inner_tag_bridge(self)
1319    }
1320
1321    fn text_object_around_tag(&self) -> Option<((usize, usize), (usize, usize))> {
1322        crate::vim::text_object_around_tag_bridge(self)
1323    }
1324
1325    // ─── Range-mutation primitives ─────────────────────────────────────────
1326
1327    fn delete_range(
1328        &mut self,
1329        start: (usize, usize),
1330        end: (usize, usize),
1331        kind: RangeKind,
1332        register: char,
1333    ) {
1334        crate::vim::delete_range_bridge(self, start, end, kind, register);
1335    }
1336
1337    fn yank_range(
1338        &mut self,
1339        start: (usize, usize),
1340        end: (usize, usize),
1341        kind: RangeKind,
1342        register: char,
1343    ) {
1344        crate::vim::yank_range_bridge(self, start, end, kind, register);
1345    }
1346
1347    fn change_range(
1348        &mut self,
1349        start: (usize, usize),
1350        end: (usize, usize),
1351        kind: RangeKind,
1352        register: char,
1353    ) {
1354        crate::vim::change_range_bridge(self, start, end, kind, register);
1355    }
1356
1357    fn indent_range(
1358        &mut self,
1359        start: (usize, usize),
1360        end: (usize, usize),
1361        count: i32,
1362        shiftwidth: u32,
1363    ) {
1364        crate::vim::indent_range_bridge(self, start, end, count, shiftwidth);
1365    }
1366
1367    fn case_range(
1368        &mut self,
1369        start: (usize, usize),
1370        end: (usize, usize),
1371        kind: RangeKind,
1372        op: Operator,
1373    ) {
1374        crate::vim::case_range_bridge(self, start, end, kind, op);
1375    }
1376
1377    // ─── Block-shape range-mutation primitives ─────────────────────────────
1378
1379    fn delete_block(
1380        &mut self,
1381        top_row: usize,
1382        bot_row: usize,
1383        left_col: usize,
1384        right_col: usize,
1385        register: char,
1386    ) {
1387        crate::vim::delete_block_bridge(self, top_row, bot_row, left_col, right_col, register);
1388    }
1389
1390    fn yank_block(
1391        &mut self,
1392        top_row: usize,
1393        bot_row: usize,
1394        left_col: usize,
1395        right_col: usize,
1396        register: char,
1397    ) {
1398        crate::vim::yank_block_bridge(self, top_row, bot_row, left_col, right_col, register);
1399    }
1400
1401    fn change_block(
1402        &mut self,
1403        top_row: usize,
1404        bot_row: usize,
1405        left_col: usize,
1406        right_col: usize,
1407        register: char,
1408    ) {
1409        crate::vim::change_block_bridge(self, top_row, bot_row, left_col, right_col, register);
1410    }
1411
1412    fn indent_block(
1413        &mut self,
1414        top_row: usize,
1415        bot_row: usize,
1416        _left_col: usize,
1417        _right_col: usize,
1418        count: i32,
1419    ) {
1420        crate::vim::indent_block_bridge(self, top_row, bot_row, count);
1421    }
1422
1423    fn auto_indent_range(&mut self, start: (usize, usize), end: (usize, usize)) {
1424        crate::vim::auto_indent_range_bridge(self, start, end);
1425    }
1426
1427    // ─── Paste ─────────────────────────────────────────────────────────────
1428
1429    fn paste_after(&mut self, count: usize) {
1430        crate::vim::paste_after_bridge(self, count);
1431    }
1432
1433    fn paste_before(&mut self, count: usize) {
1434        crate::vim::paste_before_bridge(self, count);
1435    }
1436
1437    fn paste_cursor_after(&mut self, before: bool, count: usize) {
1438        crate::vim::paste_bridge(self, before, count, true, false);
1439    }
1440
1441    fn paste_reindent(&mut self, before: bool, count: usize) {
1442        crate::vim::paste_bridge(self, before, count, false, true);
1443    }
1444
1445    fn visual_paste(&mut self, before: bool) {
1446        crate::vim::visual_paste(self, before);
1447    }
1448
1449    // ─── Visual-mode operators ─────────────────────────────────────────────
1450
1451    fn adjust_number_visual(&mut self, delta: i64, sequential: bool) {
1452        crate::vim::adjust_number_visual(self, delta, sequential);
1453    }
1454
1455    fn ampersand_repeat(&mut self) {
1456        crate::vim::ampersand_repeat(self);
1457    }
1458
1459    fn visual_join(&mut self, with_space: bool) {
1460        crate::vim::visual_join(self, with_space);
1461    }
1462
1463    fn goto_percent(&mut self, count: usize) {
1464        crate::vim::goto_percent(self, count);
1465    }
1466
1467    // ─── Jumplist motion ───────────────────────────────────────────────────
1468
1469    fn jump_back(&mut self, count: usize) {
1470        crate::vim::jump_back_bridge(self, count);
1471    }
1472
1473    fn jump_forward(&mut self, count: usize) {
1474        crate::vim::jump_forward_bridge(self, count);
1475    }
1476
1477    // ─── Search ────────────────────────────────────────────────────────────
1478
1479    fn search_repeat(&mut self, forward: bool, count: usize) {
1480        crate::vim::search_repeat_bridge(self, forward, count);
1481    }
1482
1483    fn word_search(&mut self, forward: bool, whole_word: bool, count: usize) {
1484        crate::vim::word_search_bridge(self, forward, whole_word, count);
1485    }
1486
1487    // ─── Chord appliers ────────────────────────────────────────────────────
1488
1489    fn replace_char_at(&mut self, ch: char, count: usize) {
1490        crate::vim::replace_char(self, ch, count);
1491    }
1492
1493    fn find_char(&mut self, ch: char, forward: bool, till: bool, count: usize) {
1494        crate::vim::apply_find_char(self, ch, forward, till, count.max(1));
1495    }
1496
1497    fn after_g(&mut self, ch: char, count: usize) {
1498        crate::vim::apply_after_g(self, ch, count);
1499    }
1500
1501    fn after_z(&mut self, ch: char, count: usize) {
1502        crate::vim::apply_after_z(self, ch, count);
1503    }
1504
1505    // ─── Operator dispatch ─────────────────────────────────────────────────
1506
1507    fn apply_op_motion(&mut self, op: Operator, motion_key: char, total_count: usize) {
1508        crate::vim::apply_op_motion_key(self, op, motion_key, total_count);
1509    }
1510
1511    fn apply_op_double(&mut self, op: Operator, total_count: usize) {
1512        crate::vim::apply_op_double(self, op, total_count);
1513    }
1514
1515    fn apply_op_find(&mut self, op: Operator, ch: char, forward: bool, till: bool, count: usize) {
1516        crate::vim::apply_op_find_motion(self, op, ch, forward, till, count);
1517    }
1518
1519    fn apply_op_text_obj(&mut self, op: Operator, ch: char, inner: bool, total_count: usize) {
1520        crate::vim::apply_op_text_obj_inner(self, op, ch, inner, total_count);
1521    }
1522
1523    fn apply_op_g(&mut self, op: Operator, ch: char, total_count: usize) {
1524        crate::vim::apply_op_g_inner(self, op, ch, total_count);
1525    }
1526
1527    // ─── Mode transitions ──────────────────────────────────────────────────
1528
1529    fn vim_mode(&self) -> VimMode {
1530        crate::vim_state::vim(self).current_mode
1531    }
1532
1533    fn enter_visual_char(&mut self) {
1534        crate::vim::enter_visual_char_bridge(self);
1535    }
1536
1537    fn enter_visual_line(&mut self) {
1538        crate::vim::enter_visual_line_bridge(self);
1539    }
1540
1541    fn enter_visual_block(&mut self) {
1542        crate::vim::enter_visual_block_bridge(self);
1543    }
1544
1545    fn exit_visual_to_normal(&mut self) {
1546        crate::vim::exit_visual_to_normal_bridge(self);
1547    }
1548
1549    fn visual_o_toggle(&mut self) {
1550        crate::vim::visual_o_toggle_bridge(self);
1551    }
1552
1553    fn reenter_last_visual(&mut self) {
1554        crate::vim::reenter_last_visual_bridge(self);
1555    }
1556
1557    fn set_mode(&mut self, mode: VimMode) {
1558        crate::vim::set_mode_bridge(self, mode);
1559    }
1560
1561    // ─── Visual anchors ────────────────────────────────────────────────────
1562
1563    fn visual_anchor(&self) -> (usize, usize) {
1564        crate::vim_state::vim(self).visual_anchor
1565    }
1566    fn set_visual_anchor(&mut self, anchor: (usize, usize)) {
1567        crate::vim_state::vim_mut(self).visual_anchor = anchor;
1568    }
1569    fn visual_line_anchor(&self) -> usize {
1570        crate::vim_state::vim(self).visual_line_anchor
1571    }
1572    fn set_visual_line_anchor(&mut self, row: usize) {
1573        crate::vim_state::vim_mut(self).visual_line_anchor = row;
1574    }
1575    fn block_anchor(&self) -> (usize, usize) {
1576        crate::vim_state::vim(self).block_anchor
1577    }
1578    fn set_block_anchor(&mut self, anchor: (usize, usize)) {
1579        crate::vim_state::vim_mut(self).block_anchor = anchor;
1580    }
1581    fn block_vcol(&self) -> usize {
1582        crate::vim_state::vim(self).block_vcol
1583    }
1584    fn set_block_vcol(&mut self, vcol: usize) {
1585        crate::vim_state::vim_mut(self).block_vcol = vcol;
1586    }
1587    fn block_to_eol(&self) -> bool {
1588        crate::vim_state::vim(self).block_to_eol
1589    }
1590    fn set_block_to_eol(&mut self, to_eol: bool) {
1591        crate::vim_state::vim_mut(self).block_to_eol = to_eol;
1592    }
1593
1594    // ─── Yank / register staging ───────────────────────────────────────────
1595
1596    fn set_pending_register_raw(&mut self, reg: Option<char>) {
1597        crate::vim_state::vim_mut(self).pending_register = reg;
1598    }
1599    fn take_pending_register_raw(&mut self) -> Option<char> {
1600        crate::vim_state::vim_mut(self).pending_register.take()
1601    }
1602
1603    // ─── Macro recording / replay ──────────────────────────────────────────
1604
1605    fn recording_macro(&self) -> Option<char> {
1606        crate::vim_state::vim(self).recording_macro
1607    }
1608    fn set_recording_macro(&mut self, reg: Option<char>) {
1609        crate::vim_state::vim_mut(self).recording_macro = reg;
1610    }
1611    fn push_recording_key(&mut self, input: Input) {
1612        crate::vim_state::vim_mut(self).recording_keys.push(input);
1613    }
1614    fn take_recording_keys(&mut self) -> Vec<Input> {
1615        std::mem::take(&mut crate::vim_state::vim_mut(self).recording_keys)
1616    }
1617    fn set_recording_keys(&mut self, keys: Vec<Input>) {
1618        crate::vim_state::vim_mut(self).recording_keys = keys;
1619    }
1620    fn recording_keys_len(&self) -> usize {
1621        crate::vim_state::vim(self).recording_keys.len()
1622    }
1623    fn is_replaying_macro_raw(&self) -> bool {
1624        crate::vim_state::vim(self).replaying_macro
1625    }
1626    fn set_replaying_macro_raw(&mut self, v: bool) {
1627        crate::vim_state::vim_mut(self).replaying_macro = v;
1628    }
1629    fn last_macro(&self) -> Option<char> {
1630        crate::vim_state::vim(self).last_macro
1631    }
1632    fn set_last_macro(&mut self, reg: Option<char>) {
1633        crate::vim_state::vim_mut(self).last_macro = reg;
1634    }
1635
1636    // ─── Last insert / visual / viewport ───────────────────────────────────
1637
1638    fn last_insert_pos(&self) -> Option<(usize, usize)> {
1639        crate::vim_state::vim(self).last_insert_pos
1640    }
1641    fn set_last_insert_pos(&mut self, pos: Option<(usize, usize)>) {
1642        crate::vim_state::vim_mut(self).last_insert_pos = pos;
1643    }
1644    fn last_visual(&self) -> Option<LastVisual> {
1645        crate::vim_state::vim(self).last_visual
1646    }
1647    fn set_last_visual(&mut self, snap: Option<LastVisual>) {
1648        crate::vim_state::vim_mut(self).last_visual = snap;
1649    }
1650    fn insert_pending_register(&self) -> bool {
1651        crate::vim_state::vim(self).insert_pending_register
1652    }
1653    fn set_insert_pending_register(&mut self, v: bool) {
1654        crate::vim_state::vim_mut(self).insert_pending_register = v;
1655    }
1656
1657    // ─── Change-mark start ─────────────────────────────────────────────────
1658
1659    fn change_mark_start(&self) -> Option<(usize, usize)> {
1660        crate::vim_state::vim(self).change_mark_start
1661    }
1662    fn take_change_mark_start(&mut self) -> Option<(usize, usize)> {
1663        crate::vim_state::vim_mut(self).change_mark_start.take()
1664    }
1665    fn set_change_mark_start(&mut self, pos: Option<(usize, usize)>) {
1666        crate::vim_state::vim_mut(self).change_mark_start = pos;
1667    }
1668
1669    // ─── Visual / motion / search primitives ───────────────────────────────
1670
1671    fn is_visual(&self) -> bool {
1672        matches!(
1673            crate::vim_state::vim(self).mode,
1674            FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
1675        )
1676    }
1677
1678    fn apply_op_with_motion_direct(&mut self, op: Operator, motion: &Motion, count: usize) {
1679        crate::vim::apply_op_with_motion(self, op, motion, count);
1680    }
1681
1682    fn adjust_number(&mut self, delta: i64) {
1683        crate::vim::adjust_number(self, delta);
1684    }
1685
1686    fn enter_search(&mut self, forward: bool) {
1687        crate::vim::enter_search(self, forward);
1688    }
1689
1690    fn enter_search_op(&mut self, forward: bool, op: Operator, count: usize) {
1691        crate::vim::enter_search_op(self, forward, op, count);
1692    }
1693
1694    fn apply_op_search_range(&mut self, op: Operator, origin: (usize, usize)) {
1695        crate::vim::apply_op_search_range(self, op, origin);
1696    }
1697
1698    fn visual_block_insert_at_left(&mut self, top: usize, bot: usize, col: usize, count: usize) {
1699        self.jump_cursor(top, col);
1700        crate::vim_state::vim_mut(self).mode = FsmMode::Normal;
1701        let undo_depth_before = self.undo_stack_len();
1702        crate::vim::begin_insert(
1703            self,
1704            count,
1705            InsertReason::BlockEdge {
1706                top,
1707                bot,
1708                col,
1709                pad: false,
1710                // `I`'s insertion point already IS the block's left edge —
1711                // but `leave_insert_to_normal_bridge` unconditionally steps
1712                // the cursor back one column after Esc (vim's generic
1713                // leave-insert adjustment), so store one PAST the target
1714                // and let that step-back land exactly on `col`. Verified
1715                // against real nvim at a non-zero left edge (the only
1716                // existing case started at col 0, where the step-back's
1717                // `col > 0` guard happened to no-op and masked this).
1718                cursor_col: col + 1,
1719                // `I` never pads (`pad: false`), so both fields are dead
1720                // here; record the honest values anyway.
1721                pre_pad_len: hjkl_engine::buf_helpers::buf_line_chars(self.buffer(), top),
1722                undo_depth_before,
1723            },
1724        );
1725    }
1726
1727    fn visual_block_append_at_right(
1728        &mut self,
1729        top: usize,
1730        bot: usize,
1731        col: usize,
1732        left: usize,
1733        count: usize,
1734    ) {
1735        // vim `v_b_A`: pad the top row to `col` with spaces before the
1736        // cursor lands there, same as `replicate_block_text` does for
1737        // every other row on Esc. Without this, `jump_cursor` clamps
1738        // `col` down to the row's current length and the typed text
1739        // lands inside the block instead of past its right edge.
1740        //
1741        // The pad must land in the SAME undo group as the insert session
1742        // (vim's block `A` is one `u` step — pad, typed text, and the
1743        // replicated rows all revert together). So push the undo
1744        // checkpoint ourselves before padding, then use
1745        // `begin_insert_noundo` — mirrors the `Operator::Change` block
1746        // path (push_undo, mutate, begin_insert_noundo) in `visual_ops`.
1747        // Record the undo depth just before the push: an EMPTY block-`A`
1748        // leaves that checkpoint as a no-op boundary, and
1749        // `finish_insert_session` consumes it on Esc (when it is still the
1750        // most recent one) so a no-op command leaves the undo tree
1751        // untouched.
1752        let undo_depth_before = self.undo_stack_len();
1753        self.push_undo();
1754        let line_len = hjkl_engine::buf_helpers::buf_line_chars(self.buffer(), top);
1755        if col > line_len {
1756            let pad: String = std::iter::repeat_n(' ', col - line_len).collect();
1757            self.mutate_edit(hjkl_buffer::Edit::InsertStr {
1758                at: hjkl_buffer::Position::new(top, line_len),
1759                text: pad,
1760            });
1761        }
1762        self.jump_cursor(top, col);
1763        crate::vim_state::vim_mut(self).mode = FsmMode::Normal;
1764        crate::vim::begin_insert_noundo(
1765            self,
1766            count,
1767            InsertReason::BlockEdge {
1768                top,
1769                bot,
1770                col,
1771                pad: true,
1772                // Same "one past the target, let the generic Esc step-back
1773                // land exactly there" convention as `visual_block_insert_
1774                // at_left` — see its comment.
1775                cursor_col: left + 1,
1776                // The top row's length before padding — the pad occupies
1777                // `pre_pad_len..col` of the row, which is exactly the range
1778                // to remove on an empty Esc.
1779                pre_pad_len: line_len,
1780                undo_depth_before,
1781            },
1782        );
1783    }
1784
1785    fn execute_motion(&mut self, motion: Motion, count: usize) {
1786        crate::vim::execute_motion(self, motion, count);
1787    }
1788
1789    fn update_block_vcol(&mut self, motion: &Motion) {
1790        crate::vim::update_block_vcol(self, motion);
1791    }
1792
1793    fn apply_visual_operator(&mut self, op: Operator, count: usize) {
1794        crate::vim::apply_visual_operator(self, op, count);
1795    }
1796
1797    fn replace_block_char(&mut self, ch: char) {
1798        crate::vim::block_replace(self, ch);
1799    }
1800
1801    fn visual_replace_char(&mut self, ch: char) {
1802        crate::vim::visual_replace_char(self, ch);
1803    }
1804
1805    fn visual_text_obj_extend(&mut self, ch: char, inner: bool) {
1806        let obj = match ch {
1807            'w' => TextObject::Word { big: false },
1808            'W' => TextObject::Word { big: true },
1809            '"' | '\'' | '`' => TextObject::Quote(ch),
1810            '(' | ')' | 'b' => TextObject::Bracket('('),
1811            '[' | ']' => TextObject::Bracket('['),
1812            '{' | '}' | 'B' => TextObject::Bracket('{'),
1813            '<' | '>' => TextObject::Bracket('<'),
1814            'p' => TextObject::Paragraph,
1815            't' => TextObject::XmlTag,
1816            's' => TextObject::Sentence,
1817            _ => return,
1818        };
1819        let Some((start, end, kind)) = crate::vim::text_object_range(self, obj, inner, 1) else {
1820            return;
1821        };
1822        // B6: `:h v_ip` — when the selection ALREADY exactly equals this
1823        // text object's natural bounds (the user is re-applying `ip`/`ap`/
1824        // etc. to a selection it already produced, e.g. `vipip`), the
1825        // object GROWS instead of re-selecting the identical (so
1826        // no-op-looking) range. `ip`/`ap` alternate paragraph and
1827        // blank-run units this way; growth is implemented generically here
1828        // by probing the SAME text object one row past the current end and
1829        // unioning the two ranges, rather than hand-rolling paragraph-
1830        // specific alternation logic.
1831        // Compare the SELECTION'S END (which the cursor always tracks, by
1832        // this function's own construction below) against the freshly
1833        // computed object's end — NOT the anchor. After a grow, the anchor
1834        // stays pinned at the FIRST application's start while the end keeps
1835        // moving, so an anchor-based check would only ever match once
1836        // (verified against real nvim: `vipipipd`, three applications,
1837        // grows a second time too — the anchor-based check breaks that).
1838        let already_matches = match kind {
1839            RangeKind::Linewise => {
1840                crate::vim_state::vim(self).mode == FsmMode::VisualLine && self.cursor().0 == end.0
1841            }
1842            _ => {
1843                crate::vim_state::vim(self).mode == FsmMode::Visual
1844                    && self.cursor() == crate::vim::retreat_one(self, end)
1845            }
1846        };
1847        // When growing, keep the EXISTING anchor (the first application's
1848        // start) — `start` above is the freshly computed single-object's
1849        // start (e.g. the blank run's own start on a second `ip`), which is
1850        // NOT where the accumulated selection began.
1851        //
1852        // The probe position differs by kind: Linewise units are probed one
1853        // ROW past the current end (`ip`/`ap` grow onto the next line);
1854        // charwise units (`iw`, quotes, brackets, …) are probed at `end`
1855        // directly — `end` for an Exclusive object already points ONE PAST
1856        // the last selected char, i.e. exactly where the next same-line unit
1857        // begins (verified against real nvim: `viwiw` on "foo bar baz"
1858        // grows "foo" to "foo " — the following WHITESPACE run on the SAME
1859        // row, not a jump to the next line).
1860        let (start, end) = if already_matches {
1861            let existing_start = match kind {
1862                RangeKind::Linewise => (crate::vim_state::vim(self).visual_line_anchor, 0),
1863                _ => crate::vim_state::vim(self).visual_anchor,
1864            };
1865            let probe = match kind {
1866                RangeKind::Linewise => (end.0 + 1, 0),
1867                _ => end,
1868            };
1869            let saved_cursor = self.cursor();
1870            self.jump_cursor(probe.0, probe.1);
1871            let grown = crate::vim::text_object_range(self, obj, inner, 1)
1872                .filter(|&(_, _, grown_kind)| grown_kind == kind)
1873                .map(|(_, grown_end, _)| grown_end);
1874            self.jump_cursor(saved_cursor.0, saved_cursor.1);
1875            match grown {
1876                Some(grown_end) if grown_end > end => (existing_start, grown_end),
1877                _ => (existing_start, end),
1878            }
1879        } else {
1880            (start, end)
1881        };
1882        // NOTE: only the WORD objects stay blockwise here (handled above —
1883        // the block keeps its rows and the cursor extends its columns to the
1884        // object end); everything else still collapses out of blockwise
1885        // visual, and that is not always what vim does. Measured on neovim
1886        // 0.12.4 from a `<C-v>j` block:
1887        //
1888        //   - `iw` / `aw` / `iW` / `aW` / `ip` / `is` stay BLOCKWISE and just
1889        //     extend the cursor (so the block keeps its rows and takes the
1890        //     object's columns);
1891        //   - `ib` / `ab` / `iB` collapse to charwise AND to the cursor's
1892        //     single row, which is what this branch does;
1893        //   - `i"` / `it` do nothing at all.
1894        //
1895        // So the remaining mismatches — paragraph / sentence (collapse where
1896        // nvim stays blockwise) and tags (collapse where nvim does nothing) —
1897        // stay tracked in docs/backlog.md §1.5b. Quotes already no-op in hjkl
1898        // too at the measured position (the object is not found from the
1899        // block), matching nvim. The word objects additionally write
1900        // `block_vcol` so `block_bounds` sees the new column.
1901        if crate::vim_state::vim(self).mode == FsmMode::VisualBlock {
1902            // Word objects keep the selection blockwise: the block spans
1903            // anchor-column..object-end-column, matching nvim.
1904            if let TextObject::Word { .. } = obj {
1905                let (er, ec) = crate::vim::retreat_one(self, end);
1906                self.jump_cursor(er, ec);
1907                crate::vim_state::vim_mut(self).block_vcol = ec;
1908                crate::vim_state::vim_mut(self).block_to_eol = false;
1909                return;
1910            }
1911            match kind {
1912                RangeKind::Linewise => {
1913                    crate::vim_state::vim_mut(self).visual_line_anchor = start.0;
1914                    crate::vim_state::vim_mut(self).mode = FsmMode::VisualLine;
1915                    crate::vim_state::vim_mut(self).current_mode = VimMode::VisualLine;
1916                    self.jump_cursor(end.0, 0);
1917                }
1918                _ => {
1919                    crate::vim_state::vim_mut(self).mode = FsmMode::Visual;
1920                    crate::vim_state::vim_mut(self).current_mode = VimMode::Visual;
1921                    crate::vim_state::vim_mut(self).visual_anchor = (start.0, start.1);
1922                    let (er, ec) = crate::vim::retreat_one(self, end);
1923                    self.jump_cursor(er, ec);
1924                }
1925            }
1926            return;
1927        }
1928        match kind {
1929            RangeKind::Linewise => {
1930                crate::vim_state::vim_mut(self).visual_line_anchor = start.0;
1931                crate::vim_state::vim_mut(self).mode = FsmMode::VisualLine;
1932                crate::vim_state::vim_mut(self).current_mode = VimMode::VisualLine;
1933                self.jump_cursor(end.0, 0);
1934            }
1935            _ => {
1936                crate::vim_state::vim_mut(self).mode = FsmMode::Visual;
1937                crate::vim_state::vim_mut(self).current_mode = VimMode::Visual;
1938                crate::vim_state::vim_mut(self).visual_anchor = (start.0, start.1);
1939                let (er, ec) = crate::vim::retreat_one(self, end);
1940                self.jump_cursor(er, ec);
1941            }
1942        }
1943    }
1944
1945    // ─── Insert-mode primitives ────────────────────────────────────────────
1946
1947    fn insert_char(&mut self, ch: char) {
1948        if crate::vim::insert_char_bridge(self, ch) {
1949            after_insert_mutation(self);
1950        }
1951    }
1952
1953    fn insert_newline(&mut self) {
1954        if crate::vim::insert_newline_bridge(self) {
1955            after_insert_mutation(self);
1956        }
1957    }
1958
1959    fn insert_tab(&mut self) {
1960        if crate::vim::insert_tab_bridge(self) {
1961            after_insert_mutation(self);
1962        }
1963    }
1964
1965    fn insert_backspace(&mut self) {
1966        if crate::vim::insert_backspace_bridge(self) {
1967            after_insert_mutation(self);
1968        }
1969    }
1970
1971    fn insert_delete(&mut self) {
1972        if crate::vim::insert_delete_bridge(self) {
1973            after_insert_mutation(self);
1974        }
1975    }
1976
1977    fn insert_arrow(&mut self, dir: InsertDir) {
1978        crate::vim::insert_arrow_bridge(self, dir);
1979        after_insert_motion(self);
1980    }
1981
1982    fn insert_home(&mut self) {
1983        crate::vim::insert_home_bridge(self);
1984        after_insert_motion(self);
1985    }
1986
1987    fn insert_end(&mut self) {
1988        crate::vim::insert_end_bridge(self);
1989        after_insert_motion(self);
1990    }
1991
1992    fn insert_pageup(&mut self, viewport_h: u16) {
1993        crate::vim::insert_pageup_bridge(self, viewport_h);
1994        after_insert_motion(self);
1995    }
1996
1997    fn insert_pagedown(&mut self, viewport_h: u16) {
1998        crate::vim::insert_pagedown_bridge(self, viewport_h);
1999        after_insert_motion(self);
2000    }
2001
2002    fn insert_ctrl_w(&mut self) {
2003        if crate::vim::insert_ctrl_w_bridge(self) {
2004            after_insert_mutation(self);
2005        }
2006    }
2007
2008    fn insert_ctrl_u(&mut self) {
2009        if crate::vim::insert_ctrl_u_bridge(self) {
2010            after_insert_mutation(self);
2011        }
2012    }
2013
2014    fn insert_ctrl_h(&mut self) {
2015        if crate::vim::insert_ctrl_h_bridge(self) {
2016            after_insert_mutation(self);
2017        }
2018    }
2019
2020    fn insert_ctrl_o_arm(&mut self) {
2021        crate::vim::insert_ctrl_o_bridge(self);
2022    }
2023
2024    fn insert_ctrl_r_arm(&mut self) {
2025        crate::vim::insert_ctrl_r_bridge(self);
2026    }
2027
2028    fn insert_ctrl_t(&mut self) {
2029        // Indent-only: no scrolloff re-check (the cursor row does not move).
2030        let mutated = crate::vim::insert_ctrl_t_bridge(self);
2031        if mutated {
2032            self.mark_content_dirty();
2033            let (row, _) = self.cursor();
2034            crate::vim_state::vim_mut(self).widen_insert_row(row);
2035        }
2036    }
2037
2038    fn insert_ctrl_d(&mut self) {
2039        let mutated = crate::vim::insert_ctrl_d_bridge(self);
2040        if mutated {
2041            self.mark_content_dirty();
2042            let (row, _) = self.cursor();
2043            crate::vim_state::vim_mut(self).widen_insert_row(row);
2044        }
2045    }
2046
2047    fn insert_ctrl_a(&mut self) {
2048        if crate::vim::insert_ctrl_a_bridge(self) {
2049            after_insert_mutation(self);
2050        }
2051    }
2052
2053    fn insert_ctrl_e(&mut self) {
2054        if crate::vim::insert_ctrl_e_bridge(self) {
2055            after_insert_mutation(self);
2056        }
2057    }
2058
2059    fn insert_ctrl_y(&mut self) {
2060        if crate::vim::insert_ctrl_y_bridge(self) {
2061            after_insert_mutation(self);
2062        }
2063    }
2064
2065    fn insert_paste_register(&mut self, reg: char) {
2066        crate::vim::insert_paste_register_bridge(self, reg);
2067        let (row, _) = self.cursor();
2068        crate::vim_state::vim_mut(self).widen_insert_row(row);
2069    }
2070
2071    fn insert_ctrl_bracket(&mut self) {
2072        if crate::vim::check_and_apply_abbrev(self, AbbrevTrigger::CtrlBracket) {
2073            after_insert_mutation(self);
2074        }
2075    }
2076
2077    fn leave_insert_to_normal(&mut self) {
2078        crate::vim::leave_insert_to_normal_bridge(self);
2079    }
2080
2081    // ─── Insert-mode entry ─────────────────────────────────────────────────
2082
2083    fn enter_insert_i(&mut self, count: usize) {
2084        crate::vim::enter_insert_i_bridge(self, count);
2085    }
2086
2087    fn enter_insert_shift_i(&mut self, count: usize) {
2088        crate::vim::enter_insert_shift_i_bridge(self, count);
2089    }
2090
2091    fn enter_insert_a(&mut self, count: usize) {
2092        crate::vim::enter_insert_a_bridge(self, count);
2093    }
2094
2095    fn enter_insert_shift_a(&mut self, count: usize) {
2096        crate::vim::enter_insert_shift_a_bridge(self, count);
2097    }
2098
2099    fn open_line_below(&mut self, count: usize) {
2100        crate::vim::open_line_below_bridge(self, count);
2101    }
2102
2103    fn open_line_above(&mut self, count: usize) {
2104        crate::vim::open_line_above_bridge(self, count);
2105    }
2106
2107    fn enter_replace_mode(&mut self, count: usize) {
2108        crate::vim::enter_replace_mode_bridge(self, count);
2109    }
2110
2111    // ─── Normal-mode edit primitives ───────────────────────────────────────
2112
2113    fn delete_char_forward(&mut self, count: usize) {
2114        crate::vim::delete_char_forward_bridge(self, count);
2115    }
2116
2117    fn delete_char_backward(&mut self, count: usize) {
2118        crate::vim::delete_char_backward_bridge(self, count);
2119    }
2120
2121    fn substitute_char(&mut self, count: usize) {
2122        crate::vim::substitute_char_bridge(self, count);
2123    }
2124
2125    fn substitute_line(&mut self, count: usize) {
2126        crate::vim::substitute_line_bridge(self, count);
2127    }
2128
2129    fn delete_to_eol(&mut self, count: usize) {
2130        crate::vim::delete_to_eol_bridge(self, count);
2131    }
2132
2133    fn change_to_eol(&mut self, count: usize) {
2134        crate::vim::change_to_eol_bridge(self, count);
2135    }
2136
2137    fn yank_to_eol(&mut self, count: usize) {
2138        crate::vim::yank_to_eol_bridge(self, count);
2139    }
2140
2141    fn join_line(&mut self, count: usize) {
2142        crate::vim::join_line_bridge(self, count);
2143    }
2144
2145    fn toggle_case_at_cursor(&mut self, count: usize) {
2146        crate::vim::toggle_case_at_cursor_bridge(self, count);
2147    }
2148
2149    // ─── Vim mark commands ─────────────────────────────────────────────────
2150
2151    fn replay_last_change(&mut self, count: usize) {
2152        crate::vim::replay_last_change(self, count);
2153    }
2154
2155    fn set_mark_at_cursor(&mut self, ch: char) {
2156        crate::vim::set_mark_at_cursor(self, ch);
2157    }
2158
2159    fn goto_mark_line(&mut self, ch: char) {
2160        crate::vim::goto_mark(self, ch, true);
2161    }
2162
2163    fn goto_mark_char(&mut self, ch: char) {
2164        crate::vim::goto_mark(self, ch, false);
2165    }
2166
2167    fn try_goto_mark_line(&mut self, ch: char) -> MarkJump {
2168        crate::vim::try_goto_mark(self, ch, true)
2169    }
2170
2171    fn try_goto_mark_char(&mut self, ch: char) -> MarkJump {
2172        crate::vim::try_goto_mark(self, ch, false)
2173    }
2174
2175    // ─── Vim FSM state accessors ───────────────────────────────────────────
2176
2177    fn pending(&self) -> crate::vim::Pending {
2178        crate::vim_state::vim(self).pending.clone()
2179    }
2180
2181    fn set_pending(&mut self, p: crate::vim::Pending) {
2182        crate::vim_state::vim_mut(self).pending = p;
2183    }
2184
2185    fn take_pending(&mut self) -> crate::vim::Pending {
2186        std::mem::take(&mut crate::vim_state::vim_mut(self).pending)
2187    }
2188
2189    fn count(&self) -> usize {
2190        crate::vim_state::vim(self).count
2191    }
2192
2193    fn set_count(&mut self, c: usize) {
2194        crate::vim_state::vim_mut(self).count = c.min(crate::vim::MAX_COUNT);
2195    }
2196
2197    fn accumulate_count_digit(&mut self, digit: usize) {
2198        // Saturate the add too: once the multiply has saturated at
2199        // `usize::MAX`, a plain `+ digit` overflows (panic in debug builds)
2200        // after ~20 typed digits. Then clamp at vim's documented count
2201        // ceiling (`:h count`) so no apply loop can iterate more than
2202        // 999,999,999 times regardless of how many digits were typed.
2203        let v = crate::vim_state::vim_mut(self);
2204        v.count = v
2205            .count
2206            .saturating_mul(10)
2207            .saturating_add(digit)
2208            .min(crate::vim::MAX_COUNT);
2209    }
2210
2211    fn reset_count(&mut self) {
2212        crate::vim_state::vim_mut(self).count = 0;
2213    }
2214
2215    fn take_count(&mut self) -> usize {
2216        if crate::vim_state::vim(self).count > 0 {
2217            let n = crate::vim_state::vim(self).count;
2218            crate::vim_state::vim_mut(self).count = 0;
2219            n
2220        } else {
2221            1
2222        }
2223    }
2224
2225    fn fsm_mode(&self) -> crate::vim::Mode {
2226        crate::vim_state::vim(self).mode
2227    }
2228
2229    fn set_fsm_mode(&mut self, m: crate::vim::Mode) {
2230        crate::vim_state::vim_mut(self).mode = m;
2231        crate::vim_state::vim_mut(self).current_mode =
2232            crate::vim_state::vim_mut(self).public_mode();
2233    }
2234
2235    fn is_replaying(&self) -> bool {
2236        crate::vim_state::vim(self).replaying
2237    }
2238
2239    fn set_replaying(&mut self, v: bool) {
2240        crate::vim_state::vim_mut(self).replaying = v;
2241    }
2242
2243    fn is_one_shot_normal(&self) -> bool {
2244        crate::vim_state::vim(self).one_shot_normal
2245    }
2246
2247    fn set_one_shot_normal(&mut self, v: bool) {
2248        crate::vim_state::vim_mut(self).one_shot_normal = v;
2249    }
2250
2251    fn last_find(&self) -> Option<(char, bool, bool)> {
2252        crate::vim_state::vim(self).last_find
2253    }
2254
2255    fn set_last_find(&mut self, target: Option<(char, bool, bool)>) {
2256        crate::vim_state::vim_mut(self).last_find = target;
2257    }
2258
2259    fn sneak(&mut self, c1: char, c2: char, forward: bool, count: usize) {
2260        crate::vim::apply_sneak(self, c1, c2, forward, count.max(1));
2261    }
2262
2263    fn apply_op_sneak(
2264        &mut self,
2265        op: crate::vim::Operator,
2266        c1: char,
2267        c2: char,
2268        forward: bool,
2269        total_count: usize,
2270    ) {
2271        crate::vim::apply_op_sneak(self, op, c1, c2, forward, total_count);
2272    }
2273
2274    fn last_sneak(&self) -> Option<((char, char), bool)> {
2275        crate::vim_state::vim(self).last_sneak
2276    }
2277
2278    fn last_change(&self) -> Option<crate::vim::LastChange> {
2279        crate::vim_state::vim(self).last_change.clone()
2280    }
2281
2282    fn set_last_change(&mut self, lc: Option<crate::vim::LastChange>) {
2283        crate::vim_state::vim_mut(self).last_change = lc;
2284    }
2285
2286    fn last_change_mut(&mut self) -> Option<&mut crate::vim::LastChange> {
2287        crate::vim_state::vim_mut(self).last_change.as_mut()
2288    }
2289
2290    fn insert_session(&self) -> Option<&crate::vim::InsertSession> {
2291        crate::vim_state::vim(self).insert_session.as_ref()
2292    }
2293
2294    fn insert_session_mut(&mut self) -> Option<&mut crate::vim::InsertSession> {
2295        crate::vim_state::vim_mut(self).insert_session.as_mut()
2296    }
2297
2298    fn take_insert_session(&mut self) -> Option<crate::vim::InsertSession> {
2299        crate::vim_state::vim_mut(self).insert_session.take()
2300    }
2301
2302    fn set_insert_session(&mut self, s: Option<crate::vim::InsertSession>) {
2303        crate::vim_state::vim_mut(self).insert_session = s;
2304    }
2305
2306    // ─── Register selection / chord status / macro controller ──────────────
2307
2308    fn pending_register(&self) -> Option<char> {
2309        crate::vim_state::vim(self).pending_register
2310    }
2311
2312    fn pending_register_is_clipboard(&self) -> bool {
2313        matches!(
2314            crate::vim_state::vim(self).pending_register,
2315            Some('+') | Some('*')
2316        )
2317    }
2318
2319    fn recording_register(&self) -> Option<char> {
2320        crate::vim_state::vim(self).recording_macro
2321    }
2322
2323    fn pending_count(&self) -> Option<u32> {
2324        crate::vim_state::vim(self).pending_count_val()
2325    }
2326
2327    fn pending_op(&self) -> Option<char> {
2328        crate::vim_state::vim(self).pending_op_char()
2329    }
2330
2331    fn is_chord_pending(&self) -> bool {
2332        crate::vim_state::vim(self).is_chord_pending()
2333    }
2334
2335    fn is_insert_register_pending(&self) -> bool {
2336        crate::vim_state::vim(self).insert_pending_register
2337    }
2338
2339    fn clear_insert_register_pending(&mut self) {
2340        crate::vim_state::vim_mut(self).insert_pending_register = false;
2341    }
2342
2343    fn set_pending_register(&mut self, reg: char) {
2344        // `-` is the small-delete register (readable/pasteable, e.g. `"-p`).
2345        if reg.is_ascii_alphanumeric() || matches!(reg, '"' | '+' | '*' | '_' | '-') {
2346            crate::vim_state::vim_mut(self).pending_register = Some(reg);
2347        }
2348        // Invalid chars silently no-op (matches engine FSM behavior).
2349    }
2350
2351    fn start_macro_record(&mut self, reg: char) {
2352        if !(reg.is_ascii_alphabetic() || reg.is_ascii_digit()) {
2353            return;
2354        }
2355        crate::vim_state::vim_mut(self).recording_macro = Some(reg);
2356        if reg.is_ascii_uppercase() {
2357            // Seed recording_keys with the existing lowercase register's text
2358            // decoded back to inputs so capital-register append continues from
2359            // where the previous recording left off.
2360            let lower = reg.to_ascii_lowercase();
2361            let text = self
2362                .with_registers(|r| r.read(lower).map(|s| s.text.clone()))
2363                .unwrap_or_default();
2364            crate::vim_state::vim_mut(self).recording_keys =
2365                hjkl_engine::input::decode_macro(&text);
2366        } else {
2367            crate::vim_state::vim_mut(self).recording_keys.clear();
2368        }
2369    }
2370
2371    fn stop_macro_record(&mut self) {
2372        let Some(reg) = crate::vim_state::vim_mut(self).recording_macro.take() else {
2373            return;
2374        };
2375        let keys = std::mem::take(&mut crate::vim_state::vim_mut(self).recording_keys);
2376        let text = hjkl_engine::input::encode_macro(&keys);
2377        self.set_named_register_text(reg.to_ascii_lowercase(), text);
2378    }
2379
2380    fn is_recording_macro(&self) -> bool {
2381        crate::vim_state::vim(self).recording_macro.is_some()
2382    }
2383
2384    fn is_replaying_macro(&self) -> bool {
2385        crate::vim_state::vim(self).replaying_macro
2386    }
2387
2388    fn play_macro(&mut self, reg: char) -> Vec<hjkl_engine::input::Input> {
2389        let resolved = if reg == '@' {
2390            match crate::vim_state::vim(self).last_macro {
2391                Some(r) => r,
2392                None => return vec![],
2393            }
2394        } else {
2395            reg.to_ascii_lowercase()
2396        };
2397        let text = match self.with_registers(|regs| regs.read(resolved).cloned()) {
2398            Some(slot) if !slot.text.is_empty() => slot.text,
2399            _ => return vec![],
2400        };
2401        let keys = hjkl_engine::input::decode_macro(&text);
2402        crate::vim_state::vim_mut(self).last_macro = Some(resolved);
2403        crate::vim_state::vim_mut(self).replaying_macro = true;
2404        // ONE iteration only — the host loops the count (audit R2). The old
2405        // `keys.repeat(count)` materialized count × keys.len() Inputs up
2406        // front, so `999999999@a` allocated multi-GB before playing a key.
2407        keys
2408    }
2409
2410    fn end_macro_replay(&mut self) {
2411        crate::vim_state::vim_mut(self).replaying_macro = false;
2412    }
2413
2414    fn record_input(&mut self, input: hjkl_engine::input::Input) {
2415        if crate::vim_state::vim(self).recording_macro.is_some()
2416            && !crate::vim_state::vim(self).replaying_macro
2417        {
2418            crate::vim_state::vim_mut(self).recording_keys.push(input);
2419        }
2420    }
2421
2422    // ─── Mode reset / mouse-driven selection / operator range probe ────────
2423
2424    fn force_normal(&mut self) {
2425        crate::vim::force_normal_bridge(self);
2426    }
2427
2428    fn mouse_click_doc(&mut self, row: usize, col: usize) {
2429        crate::vim::mouse_click_doc_bridge(self, row, col);
2430    }
2431
2432    fn mouse_begin_drag(&mut self) {
2433        crate::vim::mouse_begin_drag_bridge(self);
2434    }
2435
2436    fn range_for_op_motion(
2437        &mut self,
2438        motion_key: char,
2439        total_count: usize,
2440    ) -> Option<(usize, usize)> {
2441        crate::vim::range_for_op_motion_bridge(self, motion_key, total_count)
2442    }
2443
2444    // ─── Motion dispatch / operator range probes ───────────────────────────
2445
2446    fn apply_motion(&mut self, kind: MotionKind, count: usize) {
2447        crate::vim::apply_motion_kind(self, kind, count);
2448    }
2449
2450    fn range_for_op_g(&mut self, ch: char, total_count: usize) -> Option<(usize, usize)> {
2451        crate::vim::range_for_op_g_bridge(self, ch, total_count)
2452    }
2453
2454    fn range_for_op_text_obj(
2455        &self,
2456        ch: char,
2457        inner: bool,
2458        total_count: usize,
2459    ) -> Option<(usize, usize)> {
2460        crate::vim::range_for_op_text_obj_bridge(self, ch, inner, total_count)
2461    }
2462}