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