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