Skip to main content

hjkl_vim/
normal.rs

1//! Phase 6.6e: normal-mode FSM body relocated from `hjkl-engine::vim`.
2//!
3//! Dispatched by [`crate::dispatch_input`] for all non-insert,
4//! non-search-prompt modes (Normal, Visual, VisualLine, VisualBlock).
5use crate::vim::{op_is_change, parse_motion, search_selected_text, visual_selection_for_search};
6use hjkl_engine::{
7    FsmMode, Host, Input, Key, LastChange, Motion, Operator, Pending, ScrollDir, VimMode,
8};
9
10// Re-export sneak variants for shorter usage in this module.
11use hjkl_engine::Pending::{OpSneakFirst, OpSneakSecond, SneakFirst, SneakSecond};
12
13use crate::VimEditorExt;
14
15// ─── Public entry point ────────────────────────────────────────────────────
16
17/// Drive the normal / visual / operator-pending FSM for one keystroke.
18///
19/// Returns `true` when the input was consumed. Every key is consumed in
20/// these modes (unknown keys swallow silently to avoid TUI bubbling).
21pub fn step_normal<H: Host>(
22    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
23    input: Input,
24) -> bool {
25    // Consume digits first — except '0' at start of count (that's LineStart).
26    if let Key::Char(d @ '0'..='9') = input.key
27        && !input.ctrl
28        && !input.alt
29        && !matches!(
30            ed.pending(),
31            Pending::Replace
32                | Pending::Find { .. }
33                | Pending::OpFind { .. }
34                | Pending::VisualTextObj { .. }
35                // Pendings whose next key is a literal NAME, not a count, so a
36                // digit selects e.g. `"1` (numbered register), `` `1 `` /
37                // `'1` (numbered mark), `q1` (macro register) — not a count.
38                | Pending::SelectRegister
39                | Pending::SetMark
40                | Pending::GotoMarkLine
41                | Pending::GotoMarkChar
42                | Pending::RecordMacroTarget
43                | Pending::PlayMacroTarget { .. }
44                | SneakFirst { .. }
45                | SneakSecond { .. }
46                | OpSneakFirst { .. }
47                | OpSneakSecond { .. }
48        )
49        && (d != '0' || ed.count() > 0)
50    {
51        ed.accumulate_count_digit(d as usize - '0' as usize);
52        return true;
53    }
54
55    // Handle pending two-key sequences first.
56    match ed.take_pending() {
57        Pending::Replace => return handle_replace(ed, input),
58        Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
59        Pending::OpFind {
60            op,
61            count1,
62            forward,
63            till,
64        } => return handle_op_find_target(ed, input, op, count1, forward, till),
65        Pending::G => return handle_after_g(ed, input),
66        Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
67        Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
68        Pending::OpTextObj { op, count1, inner } => {
69            return handle_text_object(ed, input, op, count1, inner);
70        }
71        Pending::VisualTextObj { inner } => {
72            return handle_visual_text_obj(ed, input, inner);
73        }
74        Pending::Z => return handle_after_z(ed, input),
75        Pending::SetMark => return handle_set_mark(ed, input),
76        Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
77        Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
78        Pending::SelectRegister => return handle_select_register(ed, input),
79        Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
80        Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
81        Pending::SquareBracketOpen => {
82            let cnt = ed.take_count();
83            return handle_after_square_bracket_open(ed, input, cnt);
84        }
85        Pending::SquareBracketClose => {
86            let cnt = ed.take_count();
87            return handle_after_square_bracket_close(ed, input, cnt);
88        }
89        Pending::OpSquareBracketOpen { op, count1 } => {
90            return handle_op_after_square_bracket_open(ed, input, op, count1);
91        }
92        Pending::OpSquareBracketClose { op, count1 } => {
93            return handle_op_after_square_bracket_close(ed, input, op, count1);
94        }
95        SneakFirst { forward, count } => {
96            return handle_sneak_first(ed, input, forward, count);
97        }
98        SneakSecond { c1, forward, count } => {
99            return handle_sneak_second(ed, input, c1, forward, count);
100        }
101        OpSneakFirst {
102            op,
103            count1,
104            forward,
105        } => {
106            return handle_op_sneak_first(ed, input, op, count1, forward);
107        }
108        OpSneakSecond {
109            op,
110            count1,
111            c1,
112            forward,
113        } => {
114            return handle_op_sneak_second(ed, input, op, count1, c1, forward);
115        }
116        Pending::None => {}
117    }
118
119    // Whether the user typed an explicit count before this key (`take_count`
120    // defaults to 1, erasing the distinction — capture it first).
121    let had_explicit_count = ed.count() > 0;
122    let count = ed.take_count();
123
124    // Common normal / visual keys.
125    match input.key {
126        Key::Esc => {
127            // BLAME is a Normal-only read-only view; Esc leaves it (returning
128            // to a plain Normal view) as well as clearing any pending state.
129            ed.exit_blame();
130            ed.force_normal();
131            return true;
132        }
133        Key::Char('v') if !input.ctrl && ed.fsm_mode() == FsmMode::Normal => {
134            ed.set_visual_anchor(ed.cursor());
135            ed.set_mode(VimMode::Visual);
136            // B5: `[count]v` extends the initial selection to `count`
137            // chars from the anchor — verified against real nvim: the
138            // selection stays within the CURRENT LINE, clamped at
139            // one-past-the-last-char (absorbing the trailing newline into
140            // an operator's range, same as `v$`) once `count` reaches or
141            // exceeds the remaining line length; it never wraps onto the
142            // next line's real characters no matter how large `count` is
143            // (`3v` through `8v` on a 2-char line all select identically).
144            if had_explicit_count && count > 1 {
145                let (row, col) = ed.cursor();
146                let line_chars = hjkl_engine::buf_helpers::buf_line_chars(ed.buffer(), row);
147                let target_col = (col + count - 1).min(line_chars);
148                ed.jump_cursor(row, target_col);
149            }
150            return true;
151        }
152        Key::Char('V') if !input.ctrl && ed.fsm_mode() == FsmMode::Normal => {
153            let (row, _) = ed.cursor();
154            ed.set_visual_line_anchor(row);
155            ed.set_mode(VimMode::VisualLine);
156            // B5: `[count]V` extends the initial selection to `count`
157            // lines from the anchor, clamped to the buffer's LAST CONTENT
158            // row — `buf_row_count` counts ropey's phantom trailing row
159            // (the empty remainder after a buffer's own trailing `\n`), so
160            // clamping to `total - 1` directly would land the cursor one
161            // row past the real content (`:h phantom row`-style bug, same
162            // class as H2's linewise-delete clamp).
163            if had_explicit_count && count > 1 {
164                let target_row = (row + count - 1).min(crate::vim::last_content_row(ed));
165                ed.jump_cursor(target_row, 0);
166            }
167            return true;
168        }
169        Key::Char('v') if !input.ctrl && ed.fsm_mode() == FsmMode::VisualLine => {
170            ed.set_visual_anchor(ed.cursor());
171            ed.set_mode(VimMode::Visual);
172            return true;
173        }
174        Key::Char('V') if !input.ctrl && ed.fsm_mode() == FsmMode::Visual => {
175            let (row, _) = ed.cursor();
176            ed.set_visual_line_anchor(row);
177            ed.set_mode(VimMode::VisualLine);
178            return true;
179        }
180        Key::Char('v') if input.ctrl && ed.fsm_mode() == FsmMode::Normal => {
181            let cur = ed.cursor();
182            ed.set_block_anchor(cur);
183            ed.set_block_vcol(cur.1);
184            ed.set_block_to_eol(false);
185            ed.set_mode(VimMode::VisualBlock);
186            return true;
187        }
188        Key::Char('v') if input.ctrl && ed.fsm_mode() == FsmMode::VisualBlock => {
189            // Second Ctrl-v exits block mode back to Normal.
190            ed.set_mode(VimMode::Normal);
191            return true;
192        }
193        // `o` in visual modes — swap anchor and cursor so the user
194        // can extend the other end of the selection.
195        Key::Char('o') if !input.ctrl => match ed.fsm_mode() {
196            FsmMode::Visual => {
197                let cur = ed.cursor();
198                let anchor = ed.visual_anchor();
199                ed.set_visual_anchor(cur);
200                ed.jump_cursor(anchor.0, anchor.1);
201                return true;
202            }
203            FsmMode::VisualLine => {
204                let cur_row = ed.cursor().0;
205                let anchor_row = ed.visual_line_anchor();
206                ed.set_visual_line_anchor(cur_row);
207                ed.jump_cursor(anchor_row, 0);
208                return true;
209            }
210            FsmMode::VisualBlock => {
211                let cur = ed.cursor();
212                let anchor = ed.block_anchor();
213                ed.set_block_anchor(cur);
214                ed.set_block_vcol(anchor.1);
215                ed.jump_cursor(anchor.0, anchor.1);
216                return true;
217            }
218            _ => {}
219        },
220        _ => {}
221    }
222
223    // Visual mode: `p` / `P` replace the selection with the register.
224    if ed.is_visual() && !input.ctrl && matches!(input.key, Key::Char('p') | Key::Char('P')) {
225        ed.visual_paste(matches!(input.key, Key::Char('P')));
226        return true;
227    }
228
229    // Visual mode: `J` joins the selected lines (with a space).
230    if ed.is_visual() && !input.ctrl && input.key == Key::Char('J') {
231        ed.visual_join(true);
232        return true;
233    }
234
235    // Visual mode: operators act on the current selection. The leading count
236    // (drained into `count` above) multiplies indent levels (`2>` = two
237    // shiftwidths); other visual operators ignore it.
238    if ed.is_visual()
239        && let Some(op) = visual_operator(&input)
240    {
241        ed.apply_visual_operator(op, count.max(1));
242        return true;
243    }
244
245    // B2: charwise (`v`) / linewise (`V`) Visual mode `r<ch>` — replace
246    // every character in the selection with `ch`. `visual_operator()`
247    // deliberately has no `r` arm (it isn't an operator: it takes its own
248    // pending char, like normal-mode `r`), so without this arm bare `r`
249    // fell through every branch to the unknown-key no-op — leaving Visual
250    // mode active — and the NEXT key got redispatched as a fresh visual
251    // command (e.g. `vllrx` silently swallowed `r`, then `x` deleted the
252    // still-active selection: real data loss, not a replace).
253    if matches!(ed.fsm_mode(), FsmMode::Visual | FsmMode::VisualLine)
254        && !input.ctrl
255        && input.key == Key::Char('r')
256    {
257        ed.set_pending(Pending::Replace);
258        return true;
259    }
260
261    // VisualBlock: extra commands beyond the standard y/d/c/x — `r`
262    // replaces the block with a single char, `I` / `A` enter insert
263    // mode at the block's left / right edge and repeat on every row.
264    if ed.fsm_mode() == FsmMode::VisualBlock && !input.ctrl {
265        match input.key {
266            Key::Char('r') => {
267                ed.set_pending(Pending::Replace);
268                return true;
269            }
270            Key::Char('I') => {
271                let (top, bot, left, _right) = ed.visual_block_bounds();
272                // `[count]I` repeats the typed text `count` times on every
273                // row (`:h v_b_I` + count) — verified against nvim v0.12.4:
274                // `<C-v>jj2Ix` → "xx" on each row.
275                ed.visual_block_insert_at_left(top, bot, left, count.max(1));
276                return true;
277            }
278            Key::Char('A') => {
279                // vim `v_b_A`: append one past the block's right column on
280                // EVERY row, padding short rows to reach it first (`:h
281                // v_b_A`). The old `.min(line_char_count(top))` clamp here
282                // capped the column by the TOP row's length alone, so on
283                // longer rows the typed text landed inside the block
284                // instead of past its right edge — `visual_block_append_
285                // at_right` now does the per-row padding itself.
286                //
287                // Ragged (`$` was pressed — `:h v_b_$`): append at EACH
288                // row's own EOL instead, so the top row's insertion column
289                // is that row's own current length rather than a fixed
290                // `right + 1`.
291                let (top, bot, left, right) = ed.visual_block_bounds();
292                let col = if ed.block_to_eol() {
293                    ed.line_char_count(top)
294                } else {
295                    right + 1
296                };
297                // `[count]A` repeats the typed text `count` times on every
298                // row (`:h v_b_A` + count) — verified against nvim v0.12.4:
299                // `<C-v>jj2A!` → "!!" on each row.
300                ed.visual_block_append_at_right(top, bot, col, left, count.max(1));
301                return true;
302            }
303            // Uppercase block operators beyond the standard set (verified
304            // against nvim v0.12.4):
305            //   `D` — delete from the block's left column to EOL on every row.
306            //   `C` — change from the block's left column to EOL on every row.
307            //   `X` — delete the rectangle (identical to block `d`).
308            //   `Y` — yank the rectangle (identical to block `y`).
309            // `D`/`C` force the ragged (`$`-style) right edge so the operation
310            // always extends to each row's own end of line.
311            Key::Char('D') => {
312                ed.set_block_to_eol(true);
313                ed.apply_visual_operator(Operator::Delete, 1);
314                return true;
315            }
316            Key::Char('C') => {
317                ed.set_block_to_eol(true);
318                ed.apply_visual_operator(Operator::Change, 1);
319                return true;
320            }
321            Key::Char('X') => {
322                ed.apply_visual_operator(Operator::Delete, 1);
323                return true;
324            }
325            Key::Char('Y') => {
326                ed.apply_visual_operator(Operator::Yank, 1);
327                return true;
328            }
329            // `S` / `R` change the block's WHOLE rows linewise (like `Vc`) —
330            // verified against nvim v0.12.4: `<C-v>jSxx<Esc>` replaces both
331            // selected lines with "xx". Reuse the VisualLine change path.
332            Key::Char('S') | Key::Char('R') => {
333                let (top, bot, _left, _right) = ed.visual_block_bounds();
334                ed.set_visual_line_anchor(top);
335                ed.jump_cursor(bot, 0);
336                ed.set_mode(VimMode::VisualLine);
337                ed.apply_visual_operator(Operator::Change, 1);
338                return true;
339            }
340            _ => {}
341        }
342    }
343
344    // Visual mode: `i` / `a` start a text-object extension.
345    if matches!(
346        ed.fsm_mode(),
347        FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
348    ) && !input.ctrl
349        && matches!(input.key, Key::Char('i') | Key::Char('a'))
350    {
351        let inner = matches!(input.key, Key::Char('i'));
352        if had_explicit_count {
353            ed.set_count(count);
354        }
355        ed.set_pending(Pending::VisualTextObj { inner });
356        return true;
357    }
358
359    // Ctrl-prefixed scrolling + misc. Vim semantics: Ctrl-d / Ctrl-u
360    // move the cursor by half a window, Ctrl-f / Ctrl-b by a full
361    // window. Viewport follows the cursor. Cursor lands on the first
362    // non-blank of the target row (matches vim).
363    if input.ctrl
364        && let Key::Char(c) = input.key
365    {
366        match c {
367            'd' => {
368                ed.scroll_half_page(ScrollDir::Down, count);
369                return true;
370            }
371            'u' => {
372                ed.scroll_half_page(ScrollDir::Up, count);
373                return true;
374            }
375            'f' => {
376                ed.scroll_full_page(ScrollDir::Down, count);
377                return true;
378            }
379            'b' => {
380                ed.scroll_full_page(ScrollDir::Up, count);
381                return true;
382            }
383            'e' if ed.fsm_mode() == FsmMode::Normal => {
384                ed.scroll_line(ScrollDir::Down, count);
385                return true;
386            }
387            'y' if ed.fsm_mode() == FsmMode::Normal => {
388                ed.scroll_line(ScrollDir::Up, count);
389                return true;
390            }
391            'r' => {
392                // `<C-r>` is branch-local (follows last_child), unlike `g+`.
393                ed.redo_by_steps(count.max(1));
394                return true;
395            }
396            'a' if ed.fsm_mode() == FsmMode::Normal => {
397                ed.adjust_number(count.max(1) as i64);
398                return true;
399            }
400            // Visual `<C-a>` — add the same amount to each selected line's
401            // first number (uniform). `g<C-a>` (sequential) takes the g path.
402            'a' if ed.is_visual() => {
403                ed.adjust_number_visual(count.max(1) as i64, false);
404                return true;
405            }
406            'x' if ed.is_visual() => {
407                ed.adjust_number_visual(-(count.max(1) as i64), false);
408                return true;
409            }
410            'x' if ed.fsm_mode() == FsmMode::Normal => {
411                ed.adjust_number(-(count.max(1) as i64));
412                return true;
413            }
414            'o' if ed.fsm_mode() == FsmMode::Normal => {
415                ed.jump_back(count);
416                return true;
417            }
418            'i' if ed.fsm_mode() == FsmMode::Normal => {
419                ed.jump_forward(count);
420                return true;
421            }
422            _ => {}
423        }
424    }
425
426    // `Tab` in normal mode is also `Ctrl-i` — vim aliases them.
427    if !input.ctrl && input.key == Key::Tab && ed.fsm_mode() == FsmMode::Normal {
428        ed.jump_forward(count);
429        return true;
430    }
431
432    // `[count]%` — go to the line at `count` percent of the file. With no
433    // count, `%` is the match-pair motion (handled by `parse_motion` below).
434    if !input.ctrl && input.key == Key::Char('%') && had_explicit_count {
435        ed.goto_percent(count);
436        return true;
437    }
438
439    // Visual `*` / `#` search the selected text rather than dispatching the
440    // normal-mode word-under-cursor motion.
441    if ed.is_visual()
442        && !input.ctrl
443        && !input.alt
444        && let Key::Char(ch @ ('*' | '#')) = input.key
445    {
446        let head = ed.cursor();
447        let (start, selected) = visual_selection_for_search(ed);
448        ed.jump_cursor(start.0, start.1);
449        if !search_selected_text(ed, &selected, ch == '*', count) {
450            ed.jump_cursor(head.0, head.1);
451        }
452        ed.set_mode(VimMode::Normal);
453        return true;
454    }
455
456    // Motion-only commands.
457    if let Some(motion) = parse_motion(&input) {
458        ed.execute_motion(motion.clone(), count);
459        // Block mode: maintain the virtual column across j/k clamps.
460        if ed.fsm_mode() == FsmMode::VisualBlock {
461            ed.update_block_vcol(&motion);
462        }
463        if let Motion::Find { ch, forward, till } = motion {
464            ed.set_last_find(Some((ch, forward, till)));
465        }
466        return true;
467    }
468
469    // `.` dot-repeat: vim *replaces* the stored count with an explicit
470    // `[count].` (`:h .`) — `3x` then `2.` deletes 2, not 6. Pass 0 when the
471    // user typed no count so the engine reuses the change's original count.
472    // Handled here (not in `handle_normal_only`) because that helper only
473    // sees the count-defaulted-to-1 value and loses `had_explicit_count`.
474    if ed.fsm_mode() == FsmMode::Normal
475        && !input.ctrl
476        && !input.alt
477        && !input.shift
478        && input.key == Key::Char('.')
479    {
480        ed.replay_last_change(if had_explicit_count { count } else { 0 });
481        return true;
482    }
483
484    // Mode transitions + pure normal-mode commands (not applicable in visual).
485    if ed.fsm_mode() == FsmMode::Normal && handle_normal_only(ed, &input, count) {
486        return true;
487    }
488
489    // Operator triggers in normal mode.
490    if ed.fsm_mode() == FsmMode::Normal
491        && let Key::Char(op_ch) = input.key
492        && !input.ctrl
493        && let Some(op) = char_to_operator(op_ch)
494    {
495        ed.set_pending(Pending::Op { op, count1: count });
496        return true;
497    }
498
499    // `f`/`F`/`t`/`T` entry.
500    if ed.fsm_mode() == FsmMode::Normal
501        && let Some((forward, till)) = find_entry(&input)
502    {
503        ed.set_count(count);
504        ed.set_pending(Pending::Find { forward, till });
505        return true;
506    }
507
508    // `g` prefix. Available in Normal and the Visual modes (visual `gu`/`gU`/
509    // `g~`, `gq`/`gw`, `g<C-a>`/`g<C-x>`, and the `gg`/`ge` extend motions).
510    if !input.ctrl
511        && input.key == Key::Char('g')
512        && matches!(
513            ed.fsm_mode(),
514            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
515        )
516    {
517        ed.set_count(count);
518        ed.set_pending(Pending::G);
519        return true;
520    }
521
522    // `z` prefix (zz / zt / zb / zh / zl / zH / zL — cursor-relative
523    // viewport scrolls). B13: re-arm the count the digit-accumulation
524    // above already consumed via `take_count()` (line ~126) so
525    // `handle_after_z`'s own `take_count()` sees `[count]` instead of the
526    // default 1 — mirrors the sibling `g` prefix handler just above.
527    if !input.ctrl
528        && input.key == Key::Char('z')
529        && matches!(
530            ed.fsm_mode(),
531            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
532        )
533    {
534        ed.set_count(count);
535        ed.set_pending(Pending::Z);
536        return true;
537    }
538
539    // `[` prefix (section motions `[[` / `[]`). Available in Normal and Visual modes.
540    if !input.ctrl
541        && input.key == Key::Char('[')
542        && matches!(
543            ed.fsm_mode(),
544            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
545        )
546    {
547        ed.set_count(count);
548        ed.set_pending(Pending::SquareBracketOpen);
549        return true;
550    }
551
552    // `]` prefix (section motions `]]` / `][`). Available in Normal and Visual modes.
553    if !input.ctrl
554        && input.key == Key::Char(']')
555        && matches!(
556            ed.fsm_mode(),
557            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
558        )
559    {
560        ed.set_count(count);
561        ed.set_pending(Pending::SquareBracketClose);
562        return true;
563    }
564
565    // Mark set / jump entries. `m` arms the set-mark pending state;
566    // `'` and `` ` `` arm the goto states (linewise vs charwise). The
567    // mark letter is consumed on the next keystroke.
568    // In visual modes, `` ` `` also arms GotoMarkChar so the cursor can
569    // extend the selection to a mark position (e.g. `` `[v`] `` idiom).
570    if !input.ctrl
571        && matches!(
572            ed.fsm_mode(),
573            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
574        )
575        && input.key == Key::Char('`')
576    {
577        ed.set_pending(Pending::GotoMarkChar);
578        return true;
579    }
580
581    // `"x` picks the register the next operator writes or reads. Vim takes it
582    // in visual mode too, right before the operator (`vll"ad`), so this arm
583    // covers the visual modes the same way the `` ` `` one above does. When it
584    // was Normal-only the `"` fell through unconsumed, `a` armed the
585    // around-text-object chord, and the operator key was eaten as that
586    // chord's target — the whole sequence did nothing and the selection
587    // stayed up.
588    if !input.ctrl
589        && matches!(
590            ed.fsm_mode(),
591            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
592        )
593        && input.key == Key::Char('"')
594    {
595        ed.set_pending(Pending::SelectRegister);
596        return true;
597    }
598
599    if !input.ctrl && ed.fsm_mode() == FsmMode::Normal {
600        match input.key {
601            Key::Char('m') => {
602                ed.set_pending(Pending::SetMark);
603                return true;
604            }
605            Key::Char('\'') => {
606                ed.set_pending(Pending::GotoMarkLine);
607                return true;
608            }
609            Key::Char('`') => {
610                // Already handled above for all visual modes + normal.
611                ed.set_pending(Pending::GotoMarkChar);
612                return true;
613            }
614            Key::Char('@') => {
615                // Open the macro-play chord. Next char names the
616                // register; `@@` re-plays the last-played macro.
617                // Stash any count so the chord can multiply replays.
618                ed.set_pending(Pending::PlayMacroTarget { count });
619                return true;
620            }
621            Key::Char('q') if ed.recording_macro().is_none() => {
622                // Open the macro-record chord. The bare-q stop is
623                // handled at the top of `step` so it's not consumed
624                // as another open. Recording-in-progress falls through
625                // here and is treated as a no-op (matches vim).
626                ed.set_pending(Pending::RecordMacroTarget);
627                return true;
628            }
629            _ => {}
630        }
631    }
632
633    // Unknown key — swallow so it doesn't bubble into the TUI layer.
634    true
635}
636
637// ─── Phase 6.6a thin dispatcher ───────────────────────────────────────────
638
639/// Normal-only commands (not motion, not operator, not applicable in visual).
640fn handle_normal_only<H: Host>(
641    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
642    input: &Input,
643    count: usize,
644) -> bool {
645    if input.ctrl {
646        return false;
647    }
648    match input.key {
649        Key::Char('i') => {
650            ed.enter_insert_i(count);
651            true
652        }
653        Key::Char('I') => {
654            ed.enter_insert_shift_i(count);
655            true
656        }
657        Key::Char('a') => {
658            ed.enter_insert_a(count);
659            true
660        }
661        Key::Char('A') => {
662            ed.enter_insert_shift_a(count);
663            true
664        }
665        Key::Char('R') => {
666            ed.enter_replace_mode(count);
667            true
668        }
669        Key::Char('o') => {
670            ed.open_line_below(count);
671            true
672        }
673        Key::Char('O') => {
674            ed.open_line_above(count);
675            true
676        }
677        Key::Char('x') => {
678            ed.delete_char_forward(count);
679            true
680        }
681        Key::Char('X') => {
682            ed.delete_char_backward(count);
683            true
684        }
685        Key::Char('~') => {
686            ed.toggle_case_at_cursor(count);
687            true
688        }
689        Key::Char('J') => {
690            ed.join_line(count);
691            true
692        }
693        Key::Char('D') => {
694            ed.delete_to_eol(count);
695            true
696        }
697        Key::Char('Y') => {
698            ed.yank_to_eol(count);
699            true
700        }
701        Key::Char('C') => {
702            ed.change_to_eol(count);
703            true
704        }
705        Key::Char('s') => {
706            if ed.settings().motion_sneak {
707                // vim-sneak: `s` enters SneakFirst (forward). The count is
708                // threaded through the pending payload only — stashing it in
709                // the editor accumulator too would leak it into the command
710                // that follows the sneak (nothing on this path takes it back).
711                ed.set_pending(SneakFirst {
712                    forward: true,
713                    count,
714                });
715            } else {
716                ed.substitute_char(count);
717            }
718            true
719        }
720        Key::Char('S') => {
721            if ed.settings().motion_sneak {
722                // vim-sneak: `S` enters SneakFirst (backward). Count threads
723                // through the pending payload only (see `s` above).
724                ed.set_pending(SneakFirst {
725                    forward: false,
726                    count,
727                });
728            } else {
729                ed.substitute_line(count);
730            }
731            true
732        }
733        Key::Char('p') => {
734            ed.paste_after(count);
735            true
736        }
737        Key::Char('P') => {
738            ed.paste_before(count);
739            true
740        }
741        Key::Char('&') => {
742            // `&` — repeat last `:s` on the current line (no flags).
743            ed.ampersand_repeat();
744            true
745        }
746        Key::Char('u') => {
747            // `u` is branch-local (walks to the parent state), unlike `g-`
748            // which walks the whole tree by change number.
749            ed.undo_by_steps(count.max(1));
750            true
751        }
752        Key::Char('U') => {
753            // `U` — restore the last-changed line (`:h U`). Vim ignores
754            // any count. `undo_line` handles the "nothing to restore"
755            // no-op and the undo/toggle semantics itself.
756            ed.undo_line();
757            true
758        }
759        Key::Char('r') => {
760            ed.set_count(count);
761            ed.set_pending(Pending::Replace);
762            true
763        }
764        Key::Char('/') => {
765            ed.enter_search(true);
766            true
767        }
768        Key::Char('?') => {
769            ed.enter_search(false);
770            true
771        }
772        _ => false,
773    }
774}
775
776// ─── Pending chord handlers ────────────────────────────────────────────────
777
778fn handle_set_mark<H: Host>(
779    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
780    input: Input,
781) -> bool {
782    if let Key::Char(c) = input.key {
783        ed.set_mark_at_cursor(c);
784    }
785    true
786}
787
788fn handle_select_register<H: Host>(
789    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
790    input: Input,
791) -> bool {
792    if let Key::Char(c) = input.key {
793        ed.set_pending_register(c);
794    }
795    true
796}
797
798fn handle_record_macro_target<H: Host>(
799    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
800    input: Input,
801) -> bool {
802    if let Key::Char(c) = input.key
803        && (c.is_ascii_alphabetic() || c.is_ascii_digit())
804    {
805        ed.set_recording_macro(Some(c));
806        // For `qA` (capital), seed the buffer with the existing
807        // lowercase recording so the new keystrokes append.
808        if c.is_ascii_uppercase() {
809            let lower = c.to_ascii_lowercase();
810            // Seed `recording_keys` with the existing register's text
811            // decoded back to inputs, so capital-register append
812            // continues from where the previous recording left off.
813            let text = ed
814                .with_registers(|r| r.read(lower).map(|s| s.text.clone()))
815                .unwrap_or_default();
816            ed.set_recording_keys(hjkl_engine::decode_macro(&text));
817        } else {
818            ed.set_recording_keys(vec![]);
819        }
820    }
821    true
822}
823
824fn handle_play_macro_target<H: Host>(
825    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
826    input: Input,
827    count: usize,
828) -> bool {
829    let reg = match input.key {
830        Key::Char('@') => ed.last_macro(),
831        Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
832            Some(c.to_ascii_lowercase())
833        }
834        _ => None,
835    };
836    let Some(reg) = reg else {
837        return true;
838    };
839    // Read the macro text from the named register and decode back to
840    // an Input stream. Empty / unset registers replay nothing.
841    let text = match ed.with_registers(|r| r.read(reg).cloned()) {
842        Some(slot) if !slot.text.is_empty() => slot.text,
843        _ => return true,
844    };
845    let keys = hjkl_engine::decode_macro(&text);
846    ed.set_last_macro(Some(reg));
847    // Replay-recursion guard: a register whose text plays itself (e.g.
848    // register `a` holding "@a") would otherwise recurse through
849    // `dispatch_input` until the stack overflows. Vim bounds nested
850    // replay via 'maxmapdepth'; we cap the same way and silently stop
851    // at the limit.
852    const MAX_REPLAY_DEPTH: usize = 100;
853    thread_local! {
854        static REPLAY_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
855    }
856    let depth = REPLAY_DEPTH.with(std::cell::Cell::get);
857    if depth >= MAX_REPLAY_DEPTH {
858        return true;
859    }
860    REPLAY_DEPTH.with(|d| d.set(depth + 1));
861    let times = count.max(1);
862    let was_replaying = ed.is_replaying_macro_raw();
863    ed.set_replaying_macro_raw(true);
864    // One undo group per `@reg` invocation (incl. `[count]@reg`): nvim reverts
865    // a whole macro replay — and every repeat of it — with a single `u`. The
866    // group is re-entrant, so a macro that itself plays a macro still collapses
867    // to one step. Verified against nvim v0.12.4.
868    {
869        let _undo_group = ed.undo_group();
870        for _ in 0..times {
871            for k in keys.iter().copied() {
872                crate::dispatch_input(ed, k);
873            }
874        }
875    }
876    ed.set_replaying_macro_raw(was_replaying);
877    REPLAY_DEPTH.with(|d| d.set(depth));
878    true
879}
880
881fn handle_goto_mark<H: Host>(
882    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
883    input: Input,
884    linewise: bool,
885) -> bool {
886    let Key::Char(c) = input.key else {
887        return true;
888    };
889    // CrossBuffer results are silently ignored here — the FSM has no
890    // mechanism to switch buffers. The app layer handles uppercase marks
891    // through chord_routing + apply_mark_jump. Lowercase/special marks
892    // always resolve in the same buffer. Uppercase marks that are in the
893    // same buffer (current_buffer_id matches) execute the jump normally.
894    if linewise {
895        let _ = ed.try_goto_mark_line(c);
896    } else {
897        let _ = ed.try_goto_mark_char(c);
898    }
899    true
900}
901
902fn handle_after_op<H: Host>(
903    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
904    input: Input,
905    op: Operator,
906    count1: usize,
907) -> bool {
908    // Inner count after operator (e.g. d3w): accumulate in state.count.
909    if let Key::Char(d @ '0'..='9') = input.key
910        && !input.ctrl
911        && (d != '0' || ed.count() > 0)
912    {
913        ed.accumulate_count_digit(d as usize - '0' as usize);
914        ed.set_pending(Pending::Op { op, count1 });
915        return true;
916    }
917
918    // Esc cancels.
919    if input.key == Key::Esc {
920        ed.reset_count();
921        return true;
922    }
923
924    // Same-letter: dd / cc / yy / gUU / guu / g~~ / >> / <<. Fold has
925    // no doubled form in vim — `zfzf` is two `zf` chords, not a line
926    // op — so skip the branch entirely.
927    let double_ch = match op {
928        Operator::Delete => Some('d'),
929        Operator::Change => Some('c'),
930        Operator::Yank => Some('y'),
931        Operator::Indent => Some('>'),
932        Operator::Outdent => Some('<'),
933        Operator::Uppercase => Some('U'),
934        Operator::Lowercase => Some('u'),
935        Operator::ToggleCase => Some('~'),
936        Operator::Fold => None,
937        // `gqq` reflows the current line — vim's doubled form for the
938        // reflow operator is the second `q` after `gq`.
939        Operator::Reflow => Some('q'),
940        // `gww` reflows the current line keeping the cursor — second `w` after `gw`.
941        Operator::ReflowKeepCursor => Some('w'),
942        // `==` auto-indents the current line.
943        Operator::AutoIndent => Some('='),
944        // `!!` filters the current line — vim's doubled form.
945        Operator::Filter => Some('!'),
946        // `gcc` toggles comment on the current line — doubled 'c' after `gc`.
947        Operator::Comment => Some('c'),
948        // `g??` rot13s the current line — doubled '?' after `g?`.
949        Operator::Rot13 => Some('?'),
950    };
951    if let Key::Char(c) = input.key
952        && !input.ctrl
953        && Some(c) == double_ch
954    {
955        let count2 = ed.take_count();
956        let total = count1.max(1).saturating_mul(count2.max(1));
957        ed.apply_op_double(op, total);
958        return true;
959    }
960
961    // Text object: `i` or `a`.
962    if let Key::Char('i') | Key::Char('a') = input.key
963        && !input.ctrl
964    {
965        let inner = matches!(input.key, Key::Char('i'));
966        ed.set_pending(Pending::OpTextObj { op, count1, inner });
967        return true;
968    }
969
970    // `g` — awaiting `g` for `gg`.
971    if input.key == Key::Char('g') && !input.ctrl {
972        ed.set_pending(Pending::OpG { op, count1 });
973        return true;
974    }
975
976    // `[` / `]` — section-motion prefix in operator-pending context (d[[ etc).
977    if !input.ctrl && input.key == Key::Char('[') {
978        ed.set_pending(Pending::OpSquareBracketOpen { op, count1 });
979        return true;
980    }
981    if !input.ctrl && input.key == Key::Char(']') {
982        ed.set_pending(Pending::OpSquareBracketClose { op, count1 });
983        return true;
984    }
985
986    // `f`/`F`/`t`/`T` with pending target.
987    if let Some((forward, till)) = find_entry(&input) {
988        ed.set_pending(Pending::OpFind {
989            op,
990            count1,
991            forward,
992            till,
993        });
994        return true;
995    }
996
997    // `s`/`S` sneak with operator pending (e.g. `dsab`).
998    if ed.settings().motion_sneak
999        && let Key::Char(sc) = input.key
1000        && !input.ctrl
1001        && matches!(sc, 's' | 'S')
1002    {
1003        let forward = sc == 's';
1004        ed.set_pending(OpSneakFirst {
1005            op,
1006            count1,
1007            forward,
1008        });
1009        return true;
1010    }
1011
1012    // `/` / `?` — operator + search motion (`d/pat`, `c/pat`, `y/pat`). Opens
1013    // the search prompt in operator-pending mode; the operator runs over the
1014    // range to the match on commit.
1015    if !input.ctrl && matches!(input.key, Key::Char('/') | Key::Char('?')) {
1016        let forward = input.key == Key::Char('/');
1017        ed.enter_search_op(forward, op, count1);
1018        return true;
1019    }
1020
1021    // Motion.
1022    let count2 = ed.take_count();
1023    let total = count1.max(1).saturating_mul(count2.max(1));
1024    if let Some(motion) = parse_motion(&input) {
1025        let motion = match motion {
1026            Motion::FindRepeat { reverse } => match ed.last_find() {
1027                Some((ch, forward, till)) => Motion::Find {
1028                    ch,
1029                    forward: if reverse { !forward } else { forward },
1030                    till,
1031                },
1032                None => return true,
1033            },
1034            // Vim quirk (`:h cw`): `cw`/`cW` act like `ce`/`cE` — but ONLY when
1035            // the cursor is on a non-blank. On whitespace, `cw` behaves like
1036            // `dw` (changes just the whitespace up to the next word), so the
1037            // conversion is skipped.
1038            Motion::WordFwd
1039                if op == Operator::Change
1040                    && ed.char_at_cursor().is_some_and(|c| !c.is_whitespace()) =>
1041            {
1042                Motion::WordEnd
1043            }
1044            Motion::BigWordFwd
1045                if op == Operator::Change
1046                    && ed.char_at_cursor().is_some_and(|c| !c.is_whitespace()) =>
1047            {
1048                Motion::BigWordEnd
1049            }
1050            m => m,
1051        };
1052        // Peeked before the operator consumes it, so `.` can restore it
1053        // (`:h redo-register`).
1054        let register = ed.pending_register();
1055        ed.apply_op_with_motion_direct(op, &motion, total);
1056        if let Motion::Find { ch, forward, till } = &motion {
1057            ed.set_last_find(Some((*ch, *forward, *till)));
1058        }
1059        // Record for dot-repeat: change ops (d/c) plus the buffer-mutating
1060        // indent ops (`>j` / `<j` etc.).
1061        if !ed.is_replaying()
1062            && (op_is_change(op) || matches!(op, Operator::Indent | Operator::Outdent))
1063        {
1064            ed.set_last_change(Some(LastChange::OpMotion {
1065                op,
1066                motion,
1067                count: total,
1068                inserted: None,
1069                register,
1070            }));
1071        }
1072        return true;
1073    }
1074
1075    // Unknown — cancel the operator.
1076    true
1077}
1078
1079fn handle_op_after_g<H: Host>(
1080    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1081    input: Input,
1082    op: Operator,
1083    count1: usize,
1084) -> bool {
1085    // Consume the inner count first so a cancelled chord (ctrl-key /
1086    // non-char) doesn't leak it into the next command.
1087    let count2 = ed.take_count();
1088    if input.ctrl {
1089        return true;
1090    }
1091    let total = count1.max(1).saturating_mul(count2.max(1));
1092    if let Key::Char(ch) = input.key {
1093        ed.apply_op_g(op, ch, total);
1094    }
1095    true
1096}
1097
1098fn handle_after_g<H: Host>(
1099    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1100    input: Input,
1101) -> bool {
1102    let count = ed.take_count();
1103    // Visual-mode `g`-commands apply to the active selection rather than
1104    // entering operator-pending the way the Normal-mode forms do.
1105    if ed.is_visual() {
1106        if input.ctrl {
1107            // `g<C-a>` / `g<C-x>` — sequential increment over the selection.
1108            if let Key::Char(c) = input.key {
1109                match c {
1110                    'a' => ed.adjust_number_visual(count.max(1) as i64, true),
1111                    'x' => ed.adjust_number_visual(-(count.max(1) as i64), true),
1112                    _ => {}
1113                }
1114            }
1115            return true;
1116        }
1117        if let Key::Char(c) = input.key {
1118            match c {
1119                'u' => ed.apply_visual_operator(Operator::Lowercase, count.max(1)),
1120                'U' => ed.apply_visual_operator(Operator::Uppercase, count.max(1)),
1121                '~' => ed.apply_visual_operator(Operator::ToggleCase, count.max(1)),
1122                '?' => ed.apply_visual_operator(Operator::Rot13, count.max(1)),
1123                'q' => ed.apply_visual_operator(Operator::Reflow, count.max(1)),
1124                'w' => ed.apply_visual_operator(Operator::ReflowKeepCursor, count.max(1)),
1125                // `gJ` — join the selected lines without a space.
1126                'J' => ed.visual_join(false),
1127                // Extend-the-selection motions go through the shared body.
1128                'g' | 'e' | 'E' | '_' | 'j' | 'k' | 'M' | 'm' | '*' | '#' => ed.after_g(c, count),
1129                // Other g-commands have no visual meaning here — swallow.
1130                _ => {}
1131            }
1132        }
1133        return true;
1134    }
1135    // Extract the char and delegate to the shared apply_after_g body.
1136    // Non-char keys (ctrl sequences etc.) are silently ignored.
1137    if let Key::Char(ch) = input.key {
1138        ed.after_g(ch, count);
1139    }
1140    true
1141}
1142
1143fn handle_after_z<H: Host>(
1144    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1145    input: Input,
1146) -> bool {
1147    let count = ed.take_count();
1148    // Extract the char and delegate to the shared apply_after_z body.
1149    // Non-char keys (ctrl sequences etc.) are silently ignored.
1150    if let Key::Char(ch) = input.key {
1151        ed.after_z(ch, count);
1152    }
1153    true
1154}
1155
1156fn handle_replace<H: Host>(
1157    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1158    input: Input,
1159) -> bool {
1160    // Consume the stashed count up front so a cancelled chord (Esc or any
1161    // non-char key) doesn't leak it into the next command.
1162    let count = ed.take_count();
1163    if let Key::Char(ch) = input.key {
1164        if ed.fsm_mode() == FsmMode::VisualBlock {
1165            ed.replace_block_char(ch);
1166            return true;
1167        }
1168        // B2: charwise / linewise Visual `r<ch>` — replace the whole
1169        // selection, not a single char at the cursor (that's the
1170        // normal-mode-only `replace_char_at` path below).
1171        if matches!(ed.fsm_mode(), FsmMode::Visual | FsmMode::VisualLine) {
1172            ed.visual_replace_char(ch);
1173            return true;
1174        }
1175        ed.replace_char_at(ch, count.max(1));
1176        if !ed.is_replaying() {
1177            ed.set_last_change(Some(LastChange::ReplaceChar {
1178                ch,
1179                count: count.max(1),
1180            }));
1181        }
1182    }
1183    true
1184}
1185
1186fn handle_find_target<H: Host>(
1187    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1188    input: Input,
1189    forward: bool,
1190    till: bool,
1191) -> bool {
1192    // Consume the count first: a cancelled chord (Esc / non-char) must not
1193    // leak the stashed count into the next command.
1194    let count = ed.take_count();
1195    let Key::Char(ch) = input.key else {
1196        return true;
1197    };
1198    ed.find_char(ch, forward, till, count.max(1));
1199    true
1200}
1201
1202fn handle_op_find_target<H: Host>(
1203    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1204    input: Input,
1205    op: Operator,
1206    count1: usize,
1207    forward: bool,
1208    till: bool,
1209) -> bool {
1210    // Consume the inner count first so a cancelled chord doesn't leak it.
1211    let count2 = ed.take_count();
1212    let Key::Char(ch) = input.key else {
1213        return true;
1214    };
1215    let total = count1.max(1).saturating_mul(count2.max(1));
1216    ed.apply_op_find(op, ch, forward, till, total);
1217    true
1218}
1219
1220fn handle_text_object<H: Host>(
1221    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1222    input: Input,
1223    op: Operator,
1224    count1: usize,
1225    inner: bool,
1226) -> bool {
1227    // Counts multiply across the operator and the text object: both `2di{` and
1228    // `d2i{` target the 2nd enclosing pair. For bracket objects this selects
1229    // the Nth enclosing pair; non-bracket objects ignore the count (as in vim).
1230    // Consumed before the char check so a cancelled chord doesn't leak it.
1231    let count2 = ed.take_count();
1232    let Key::Char(ch) = input.key else {
1233        return true;
1234    };
1235    let total = count1.max(1).saturating_mul(count2.max(1));
1236    // Delegate to shared implementation; unknown chars are a no-op (return true
1237    // to consume the key from the FSM regardless).
1238    ed.apply_op_text_obj(op, ch, inner, total);
1239    true
1240}
1241
1242fn handle_visual_text_obj<H: Host>(
1243    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1244    input: Input,
1245    inner: bool,
1246) -> bool {
1247    let count = ed.take_count();
1248    let Key::Char(ch) = input.key else {
1249        return true;
1250    };
1251    ed.visual_text_obj_extend_counted(ch, inner, count);
1252    true
1253}
1254
1255// ─── Section-motion chord handlers ────────────────────────────────────────
1256
1257/// `[[` — backward to previous `{` at col 0; `[]` — backward to `}` at col 0.
1258fn handle_after_square_bracket_open<H: Host>(
1259    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1260    input: Input,
1261    count: usize,
1262) -> bool {
1263    // `[p` / `[P` — indent-adjusted paste ABOVE the current line.
1264    if let Key::Char('p' | 'P') = input.key {
1265        ed.paste_reindent(true, count.max(1));
1266        return true;
1267    }
1268    let motion = match input.key {
1269        Key::Char('[') => Motion::SectionBackward,
1270        Key::Char(']') => Motion::SectionEndBackward,
1271        // `[(` / `[{` — previous unmatched open bracket.
1272        Key::Char('(') => Motion::UnmatchedBracket {
1273            forward: false,
1274            open: '(',
1275        },
1276        Key::Char('{') => Motion::UnmatchedBracket {
1277            forward: false,
1278            open: '{',
1279        },
1280        _ => return true, // unknown second key — cancel silently
1281    };
1282    ed.execute_motion(motion, count);
1283    true
1284}
1285
1286/// `]]` — forward to next `{` at col 0; `][` — forward to `}` at col 0.
1287fn handle_after_square_bracket_close<H: Host>(
1288    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1289    input: Input,
1290    count: usize,
1291) -> bool {
1292    // `]p` — indent-adjusted paste BELOW; `]P` — indent-adjusted paste ABOVE.
1293    match input.key {
1294        Key::Char('p') => {
1295            ed.paste_reindent(false, count.max(1));
1296            return true;
1297        }
1298        Key::Char('P') => {
1299            ed.paste_reindent(true, count.max(1));
1300            return true;
1301        }
1302        _ => {}
1303    }
1304    let motion = match input.key {
1305        Key::Char(']') => Motion::SectionForward,
1306        Key::Char('[') => Motion::SectionEndForward,
1307        // `])` / `]}` — next unmatched close bracket.
1308        Key::Char(')') => Motion::UnmatchedBracket {
1309            forward: true,
1310            open: '(',
1311        },
1312        Key::Char('}') => Motion::UnmatchedBracket {
1313            forward: true,
1314            open: '{',
1315        },
1316        _ => return true,
1317    };
1318    ed.execute_motion(motion, count);
1319    true
1320}
1321
1322/// Operator + `[[` / `[]`.
1323fn handle_op_after_square_bracket_open<H: Host>(
1324    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1325    input: Input,
1326    op: Operator,
1327    count1: usize,
1328) -> bool {
1329    // Consume the inner count first so an unknown second key (cancel path)
1330    // doesn't leak it into the next command.
1331    let count2 = ed.take_count();
1332    let motion = match input.key {
1333        Key::Char('[') => Motion::SectionBackward,
1334        Key::Char(']') => Motion::SectionEndBackward,
1335        Key::Char('(') => Motion::UnmatchedBracket {
1336            forward: false,
1337            open: '(',
1338        },
1339        Key::Char('{') => Motion::UnmatchedBracket {
1340            forward: false,
1341            open: '{',
1342        },
1343        _ => return true,
1344    };
1345    let total = count1.max(1).saturating_mul(count2.max(1));
1346    ed.apply_op_with_motion_direct(op, &motion, total);
1347    true
1348}
1349
1350/// Operator + `]]` / `][`.
1351fn handle_op_after_square_bracket_close<H: Host>(
1352    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1353    input: Input,
1354    op: Operator,
1355    count1: usize,
1356) -> bool {
1357    // Consume the inner count first (mirrors the `[`-prefix handler).
1358    let count2 = ed.take_count();
1359    let motion = match input.key {
1360        Key::Char(']') => Motion::SectionForward,
1361        Key::Char('[') => Motion::SectionEndForward,
1362        Key::Char(')') => Motion::UnmatchedBracket {
1363            forward: true,
1364            open: '(',
1365        },
1366        Key::Char('}') => Motion::UnmatchedBracket {
1367            forward: true,
1368            open: '{',
1369        },
1370        _ => return true,
1371    };
1372    let total = count1.max(1).saturating_mul(count2.max(1));
1373    ed.apply_op_with_motion_direct(op, &motion, total);
1374    true
1375}
1376
1377// ─── Pure utility helpers (no Editor mutation) ─────────────────────────────
1378
1379fn char_to_operator(c: char) -> Option<Operator> {
1380    match c {
1381        'd' => Some(Operator::Delete),
1382        'c' => Some(Operator::Change),
1383        'y' => Some(Operator::Yank),
1384        '>' => Some(Operator::Indent),
1385        '<' => Some(Operator::Outdent),
1386        '=' => Some(Operator::AutoIndent),
1387        _ => None,
1388    }
1389}
1390
1391fn visual_operator(input: &Input) -> Option<Operator> {
1392    if input.ctrl {
1393        return None;
1394    }
1395    match input.key {
1396        Key::Char('y') => Some(Operator::Yank),
1397        Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
1398        Key::Char('c') | Key::Char('s') => Some(Operator::Change),
1399        // Case operators — shift forms apply to the active selection.
1400        Key::Char('U') => Some(Operator::Uppercase),
1401        Key::Char('u') => Some(Operator::Lowercase),
1402        Key::Char('~') => Some(Operator::ToggleCase),
1403        // Indent operators on selection.
1404        Key::Char('>') => Some(Operator::Indent),
1405        Key::Char('<') => Some(Operator::Outdent),
1406        // Auto-indent selection.
1407        Key::Char('=') => Some(Operator::AutoIndent),
1408        _ => None,
1409    }
1410}
1411
1412fn find_entry(input: &Input) -> Option<(bool, bool)> {
1413    if input.ctrl {
1414        return None;
1415    }
1416    match input.key {
1417        Key::Char('f') => Some((true, false)),
1418        Key::Char('F') => Some((false, false)),
1419        Key::Char('t') => Some((true, true)),
1420        Key::Char('T') => Some((false, true)),
1421        _ => None,
1422    }
1423}
1424
1425// ─── Sneak chord handlers ──────────────────────────────────────────────────
1426
1427/// Handle the first char of a bare sneak (no operator).
1428/// Transitions to `SneakSecond` so the second char can be captured.
1429///
1430/// State machine: `SneakFirst` → char1 → `SneakSecond { c1 }`
1431///                `SneakSecond` → char2 → `apply_sneak(c1, c2)`
1432///                Either state + Esc/non-char → cancel.
1433fn handle_sneak_first<H: Host>(
1434    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1435    input: Input,
1436    forward: bool,
1437    count: usize,
1438) -> bool {
1439    match input.key {
1440        Key::Esc => {
1441            // Cancel silently.
1442            true
1443        }
1444        Key::Char(c1) => {
1445            // Store char1, wait for char2 via SneakSecond.
1446            ed.set_pending(hjkl_engine::Pending::SneakSecond { c1, forward, count });
1447            true
1448        }
1449        _ => {
1450            // Non-char key (other than Esc) cancels.
1451            true
1452        }
1453    }
1454}
1455
1456/// Handle the second char of a bare sneak: we have char1, this is char2.
1457/// Execute the jump.
1458fn handle_sneak_second<H: Host>(
1459    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1460    input: Input,
1461    c1: char,
1462    forward: bool,
1463    count: usize,
1464) -> bool {
1465    match input.key {
1466        Key::Esc => true, // Cancel.
1467        Key::Char(c2) => {
1468            ed.sneak(c1, c2, forward, count.max(1));
1469            true
1470        }
1471        _ => true, // Cancel on non-char.
1472    }
1473}
1474
1475/// Handle the first char of an op+sneak (`dsXY` — this is `X`).
1476fn handle_op_sneak_first<H: Host>(
1477    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1478    input: Input,
1479    op: Operator,
1480    count1: usize,
1481    forward: bool,
1482) -> bool {
1483    match input.key {
1484        Key::Esc => {
1485            // Cancel — drop any inner count so it doesn't leak.
1486            ed.reset_count();
1487            true
1488        }
1489        Key::Char(c1) => {
1490            ed.set_pending(hjkl_engine::Pending::OpSneakSecond {
1491                op,
1492                count1,
1493                c1,
1494                forward,
1495            });
1496            true
1497        }
1498        _ => {
1499            ed.reset_count();
1500            true
1501        }
1502    }
1503}
1504
1505/// Handle the second char of an op+sneak (`dsXY` — this is `Y`).
1506fn handle_op_sneak_second<H: Host>(
1507    ed: &mut hjkl_engine::Editor<hjkl_buffer::View, H>,
1508    input: Input,
1509    op: Operator,
1510    count1: usize,
1511    c1: char,
1512    forward: bool,
1513) -> bool {
1514    // Consume the inner count first so a cancelled chord doesn't leak it.
1515    let count2 = ed.take_count();
1516    match input.key {
1517        Key::Esc => true,
1518        Key::Char(c2) => {
1519            let total = count1.max(1).saturating_mul(count2.max(1));
1520            ed.apply_op_sneak(op, c1, c2, forward, total);
1521            true
1522        }
1523        _ => true,
1524    }
1525}