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