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 hjkl_engine::{
10    FsmMode, Host, Input, Key, LastChange, Motion, Operator, Pending, ScrollDir, VimMode,
11    op_is_change, parse_motion,
12};
13
14// ─── Public entry point ────────────────────────────────────────────────────
15
16/// Drive the normal / visual / operator-pending FSM for one keystroke.
17///
18/// Returns `true` when the input was consumed. Every key is consumed in
19/// these modes (unknown keys swallow silently to avoid TUI bubbling).
20pub fn step_normal<H: Host>(
21    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
22    input: Input,
23) -> bool {
24    // Consume digits first — except '0' at start of count (that's LineStart).
25    if let Key::Char(d @ '0'..='9') = input.key
26        && !input.ctrl
27        && !input.alt
28        && !matches!(
29            ed.pending(),
30            Pending::Replace
31                | Pending::Find { .. }
32                | Pending::OpFind { .. }
33                | Pending::VisualTextObj { .. }
34        )
35        && (d != '0' || ed.count() > 0)
36    {
37        ed.accumulate_count_digit(d as usize - '0' as usize);
38        return true;
39    }
40
41    // Handle pending two-key sequences first.
42    match ed.take_pending() {
43        Pending::Replace => return handle_replace(ed, input),
44        Pending::Find { forward, till } => return handle_find_target(ed, input, forward, till),
45        Pending::OpFind {
46            op,
47            count1,
48            forward,
49            till,
50        } => return handle_op_find_target(ed, input, op, count1, forward, till),
51        Pending::G => return handle_after_g(ed, input),
52        Pending::OpG { op, count1 } => return handle_op_after_g(ed, input, op, count1),
53        Pending::Op { op, count1 } => return handle_after_op(ed, input, op, count1),
54        Pending::OpTextObj { op, count1, inner } => {
55            return handle_text_object(ed, input, op, count1, inner);
56        }
57        Pending::VisualTextObj { inner } => {
58            return handle_visual_text_obj(ed, input, inner);
59        }
60        Pending::Z => return handle_after_z(ed, input),
61        Pending::SetMark => return handle_set_mark(ed, input),
62        Pending::GotoMarkLine => return handle_goto_mark(ed, input, true),
63        Pending::GotoMarkChar => return handle_goto_mark(ed, input, false),
64        Pending::SelectRegister => return handle_select_register(ed, input),
65        Pending::RecordMacroTarget => return handle_record_macro_target(ed, input),
66        Pending::PlayMacroTarget { count } => return handle_play_macro_target(ed, input, count),
67        Pending::None => {}
68    }
69
70    let count = ed.take_count();
71
72    // Common normal / visual keys.
73    match input.key {
74        Key::Esc => {
75            ed.force_normal();
76            return true;
77        }
78        Key::Char('v') if !input.ctrl && ed.fsm_mode() == FsmMode::Normal => {
79            ed.set_visual_anchor(ed.cursor());
80            ed.set_mode(VimMode::Visual);
81            return true;
82        }
83        Key::Char('V') if !input.ctrl && ed.fsm_mode() == FsmMode::Normal => {
84            let (row, _) = ed.cursor();
85            ed.set_visual_line_anchor(row);
86            ed.set_mode(VimMode::VisualLine);
87            return true;
88        }
89        Key::Char('v') if !input.ctrl && ed.fsm_mode() == FsmMode::VisualLine => {
90            ed.set_visual_anchor(ed.cursor());
91            ed.set_mode(VimMode::Visual);
92            return true;
93        }
94        Key::Char('V') if !input.ctrl && ed.fsm_mode() == FsmMode::Visual => {
95            let (row, _) = ed.cursor();
96            ed.set_visual_line_anchor(row);
97            ed.set_mode(VimMode::VisualLine);
98            return true;
99        }
100        Key::Char('v') if input.ctrl && ed.fsm_mode() == FsmMode::Normal => {
101            let cur = ed.cursor();
102            ed.set_block_anchor(cur);
103            ed.set_block_vcol(cur.1);
104            ed.set_mode(VimMode::VisualBlock);
105            return true;
106        }
107        Key::Char('v') if input.ctrl && ed.fsm_mode() == FsmMode::VisualBlock => {
108            // Second Ctrl-v exits block mode back to Normal.
109            ed.set_mode(VimMode::Normal);
110            return true;
111        }
112        // `o` in visual modes — swap anchor and cursor so the user
113        // can extend the other end of the selection.
114        Key::Char('o') if !input.ctrl => match ed.fsm_mode() {
115            FsmMode::Visual => {
116                let cur = ed.cursor();
117                let anchor = ed.visual_anchor();
118                ed.set_visual_anchor(cur);
119                ed.jump_cursor(anchor.0, anchor.1);
120                return true;
121            }
122            FsmMode::VisualLine => {
123                let cur_row = ed.cursor().0;
124                let anchor_row = ed.visual_line_anchor();
125                ed.set_visual_line_anchor(cur_row);
126                ed.jump_cursor(anchor_row, 0);
127                return true;
128            }
129            FsmMode::VisualBlock => {
130                let cur = ed.cursor();
131                let anchor = ed.block_anchor();
132                ed.set_block_anchor(cur);
133                ed.set_block_vcol(anchor.1);
134                ed.jump_cursor(anchor.0, anchor.1);
135                return true;
136            }
137            _ => {}
138        },
139        _ => {}
140    }
141
142    // Visual mode: operators act on the current selection.
143    if ed.is_visual()
144        && let Some(op) = visual_operator(&input)
145    {
146        ed.apply_visual_operator(op);
147        return true;
148    }
149
150    // VisualBlock: extra commands beyond the standard y/d/c/x — `r`
151    // replaces the block with a single char, `I` / `A` enter insert
152    // mode at the block's left / right edge and repeat on every row.
153    if ed.fsm_mode() == FsmMode::VisualBlock && !input.ctrl {
154        match input.key {
155            Key::Char('r') => {
156                ed.set_pending(Pending::Replace);
157                return true;
158            }
159            Key::Char('I') => {
160                let (top, bot, left, _right) = ed.visual_block_bounds();
161                ed.visual_block_insert_at_left(top, bot, left);
162                return true;
163            }
164            Key::Char('A') => {
165                let (top, bot, _left, right) = ed.visual_block_bounds();
166                let line_len = ed.line_char_count(top);
167                let col = (right + 1).min(line_len);
168                ed.visual_block_append_at_right(top, bot, col);
169                return true;
170            }
171            _ => {}
172        }
173    }
174
175    // Visual mode: `i` / `a` start a text-object extension.
176    if matches!(ed.fsm_mode(), FsmMode::Visual | FsmMode::VisualLine)
177        && !input.ctrl
178        && matches!(input.key, Key::Char('i') | Key::Char('a'))
179    {
180        let inner = matches!(input.key, Key::Char('i'));
181        ed.set_pending(Pending::VisualTextObj { inner });
182        return true;
183    }
184
185    // Ctrl-prefixed scrolling + misc. Vim semantics: Ctrl-d / Ctrl-u
186    // move the cursor by half a window, Ctrl-f / Ctrl-b by a full
187    // window. Viewport follows the cursor. Cursor lands on the first
188    // non-blank of the target row (matches vim).
189    if input.ctrl
190        && let Key::Char(c) = input.key
191    {
192        match c {
193            'd' => {
194                ed.scroll_half_page(ScrollDir::Down, count);
195                return true;
196            }
197            'u' => {
198                ed.scroll_half_page(ScrollDir::Up, count);
199                return true;
200            }
201            'f' => {
202                ed.scroll_full_page(ScrollDir::Down, count);
203                return true;
204            }
205            'b' => {
206                ed.scroll_full_page(ScrollDir::Up, count);
207                return true;
208            }
209            'e' if ed.fsm_mode() == FsmMode::Normal => {
210                ed.scroll_line(ScrollDir::Down, count);
211                return true;
212            }
213            'y' if ed.fsm_mode() == FsmMode::Normal => {
214                ed.scroll_line(ScrollDir::Up, count);
215                return true;
216            }
217            'r' => {
218                ed.redo();
219                return true;
220            }
221            'a' if ed.fsm_mode() == FsmMode::Normal => {
222                ed.adjust_number(count.max(1) as i64);
223                return true;
224            }
225            'x' if ed.fsm_mode() == FsmMode::Normal => {
226                ed.adjust_number(-(count.max(1) as i64));
227                return true;
228            }
229            'o' if ed.fsm_mode() == FsmMode::Normal => {
230                ed.jump_back(count);
231                return true;
232            }
233            'i' if ed.fsm_mode() == FsmMode::Normal => {
234                ed.jump_forward(count);
235                return true;
236            }
237            _ => {}
238        }
239    }
240
241    // `Tab` in normal mode is also `Ctrl-i` — vim aliases them.
242    if !input.ctrl && input.key == Key::Tab && ed.fsm_mode() == FsmMode::Normal {
243        ed.jump_forward(count);
244        return true;
245    }
246
247    // Motion-only commands.
248    if let Some(motion) = parse_motion(&input) {
249        ed.execute_motion(motion.clone(), count);
250        // Block mode: maintain the virtual column across j/k clamps.
251        if ed.fsm_mode() == FsmMode::VisualBlock {
252            ed.update_block_vcol(&motion);
253        }
254        if let Motion::Find { ch, forward, till } = motion {
255            ed.set_last_find(Some((ch, forward, till)));
256        }
257        return true;
258    }
259
260    // Mode transitions + pure normal-mode commands (not applicable in visual).
261    if ed.fsm_mode() == FsmMode::Normal && handle_normal_only(ed, &input, count) {
262        return true;
263    }
264
265    // Operator triggers in normal mode.
266    if ed.fsm_mode() == FsmMode::Normal
267        && let Key::Char(op_ch) = input.key
268        && !input.ctrl
269        && let Some(op) = char_to_operator(op_ch)
270    {
271        ed.set_pending(Pending::Op { op, count1: count });
272        return true;
273    }
274
275    // `f`/`F`/`t`/`T` entry.
276    if ed.fsm_mode() == FsmMode::Normal
277        && let Some((forward, till)) = find_entry(&input)
278    {
279        ed.set_count(count);
280        ed.set_pending(Pending::Find { forward, till });
281        return true;
282    }
283
284    // `g` prefix.
285    if !input.ctrl && input.key == Key::Char('g') && ed.fsm_mode() == FsmMode::Normal {
286        ed.set_count(count);
287        ed.set_pending(Pending::G);
288        return true;
289    }
290
291    // `z` prefix (zz / zt / zb — cursor-relative viewport scrolls).
292    if !input.ctrl
293        && input.key == Key::Char('z')
294        && matches!(
295            ed.fsm_mode(),
296            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
297        )
298    {
299        ed.set_pending(Pending::Z);
300        return true;
301    }
302
303    // Mark set / jump entries. `m` arms the set-mark pending state;
304    // `'` and `` ` `` arm the goto states (linewise vs charwise). The
305    // mark letter is consumed on the next keystroke.
306    // In visual modes, `` ` `` also arms GotoMarkChar so the cursor can
307    // extend the selection to a mark position (e.g. `` `[v`] `` idiom).
308    if !input.ctrl
309        && matches!(
310            ed.fsm_mode(),
311            FsmMode::Normal | FsmMode::Visual | FsmMode::VisualLine | FsmMode::VisualBlock
312        )
313        && input.key == Key::Char('`')
314    {
315        ed.set_pending(Pending::GotoMarkChar);
316        return true;
317    }
318    if !input.ctrl && ed.fsm_mode() == FsmMode::Normal {
319        match input.key {
320            Key::Char('m') => {
321                ed.set_pending(Pending::SetMark);
322                return true;
323            }
324            Key::Char('\'') => {
325                ed.set_pending(Pending::GotoMarkLine);
326                return true;
327            }
328            Key::Char('`') => {
329                // Already handled above for all visual modes + normal.
330                ed.set_pending(Pending::GotoMarkChar);
331                return true;
332            }
333            Key::Char('"') => {
334                // Open the register-selector chord. The next char picks
335                // a register that the next y/d/c/p uses.
336                ed.set_pending(Pending::SelectRegister);
337                return true;
338            }
339            Key::Char('@') => {
340                // Open the macro-play chord. Next char names the
341                // register; `@@` re-plays the last-played macro.
342                // Stash any count so the chord can multiply replays.
343                ed.set_pending(Pending::PlayMacroTarget { count });
344                return true;
345            }
346            Key::Char('q') if ed.recording_macro().is_none() => {
347                // Open the macro-record chord. The bare-q stop is
348                // handled at the top of `step` so it's not consumed
349                // as another open. Recording-in-progress falls through
350                // here and is treated as a no-op (matches vim).
351                ed.set_pending(Pending::RecordMacroTarget);
352                return true;
353            }
354            _ => {}
355        }
356    }
357
358    // Unknown key — swallow so it doesn't bubble into the TUI layer.
359    true
360}
361
362// ─── Phase 6.6a thin dispatcher ───────────────────────────────────────────
363
364/// Normal-only commands (not motion, not operator, not applicable in visual).
365fn handle_normal_only<H: Host>(
366    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
367    input: &Input,
368    count: usize,
369) -> bool {
370    if input.ctrl {
371        return false;
372    }
373    match input.key {
374        Key::Char('i') => {
375            ed.enter_insert_i(count);
376            true
377        }
378        Key::Char('I') => {
379            ed.enter_insert_shift_i(count);
380            true
381        }
382        Key::Char('a') => {
383            ed.enter_insert_a(count);
384            true
385        }
386        Key::Char('A') => {
387            ed.enter_insert_shift_a(count);
388            true
389        }
390        Key::Char('R') => {
391            ed.enter_replace_mode(count);
392            true
393        }
394        Key::Char('o') => {
395            ed.open_line_below(count);
396            true
397        }
398        Key::Char('O') => {
399            ed.open_line_above(count);
400            true
401        }
402        Key::Char('x') => {
403            ed.delete_char_forward(count);
404            true
405        }
406        Key::Char('X') => {
407            ed.delete_char_backward(count);
408            true
409        }
410        Key::Char('~') => {
411            ed.toggle_case_at_cursor(count);
412            true
413        }
414        Key::Char('J') => {
415            ed.join_line(count);
416            true
417        }
418        Key::Char('D') => {
419            ed.delete_to_eol();
420            true
421        }
422        Key::Char('Y') => {
423            ed.yank_to_eol(count);
424            true
425        }
426        Key::Char('C') => {
427            ed.change_to_eol();
428            true
429        }
430        Key::Char('s') => {
431            ed.substitute_char(count);
432            true
433        }
434        Key::Char('S') => {
435            ed.substitute_line(count);
436            true
437        }
438        Key::Char('p') => {
439            ed.paste_after(count);
440            true
441        }
442        Key::Char('P') => {
443            ed.paste_before(count);
444            true
445        }
446        Key::Char('u') => {
447            ed.undo();
448            true
449        }
450        Key::Char('r') => {
451            ed.set_count(count);
452            ed.set_pending(Pending::Replace);
453            true
454        }
455        Key::Char('/') => {
456            ed.enter_search(true);
457            true
458        }
459        Key::Char('?') => {
460            ed.enter_search(false);
461            true
462        }
463        Key::Char('.') => {
464            ed.replay_last_change(count);
465            true
466        }
467        _ => false,
468    }
469}
470
471// ─── Pending chord handlers ────────────────────────────────────────────────
472
473fn handle_set_mark<H: Host>(
474    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
475    input: Input,
476) -> bool {
477    if let Key::Char(c) = input.key {
478        ed.set_mark_at_cursor(c);
479    }
480    true
481}
482
483fn handle_select_register<H: Host>(
484    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
485    input: Input,
486) -> bool {
487    if let Key::Char(c) = input.key {
488        ed.set_pending_register(c);
489    }
490    true
491}
492
493fn handle_record_macro_target<H: Host>(
494    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
495    input: Input,
496) -> bool {
497    if let Key::Char(c) = input.key
498        && (c.is_ascii_alphabetic() || c.is_ascii_digit())
499    {
500        ed.set_recording_macro(Some(c));
501        // For `qA` (capital), seed the buffer with the existing
502        // lowercase recording so the new keystrokes append.
503        if c.is_ascii_uppercase() {
504            let lower = c.to_ascii_lowercase();
505            // Seed `recording_keys` with the existing register's text
506            // decoded back to inputs, so capital-register append
507            // continues from where the previous recording left off.
508            let text = ed
509                .registers()
510                .read(lower)
511                .map(|s| s.text.clone())
512                .unwrap_or_default();
513            ed.set_recording_keys(hjkl_engine::decode_macro(&text));
514        } else {
515            ed.set_recording_keys(vec![]);
516        }
517    }
518    true
519}
520
521fn handle_play_macro_target<H: Host>(
522    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
523    input: Input,
524    count: usize,
525) -> bool {
526    let reg = match input.key {
527        Key::Char('@') => ed.last_macro(),
528        Key::Char(c) if c.is_ascii_alphabetic() || c.is_ascii_digit() => {
529            Some(c.to_ascii_lowercase())
530        }
531        _ => None,
532    };
533    let Some(reg) = reg else {
534        return true;
535    };
536    // Read the macro text from the named register and decode back to
537    // an Input stream. Empty / unset registers replay nothing.
538    let text = match ed.registers().read(reg) {
539        Some(slot) if !slot.text.is_empty() => slot.text.clone(),
540        _ => return true,
541    };
542    let keys = hjkl_engine::decode_macro(&text);
543    ed.set_last_macro(Some(reg));
544    let times = count.max(1);
545    let was_replaying = ed.is_replaying_macro_raw();
546    ed.set_replaying_macro_raw(true);
547    for _ in 0..times {
548        for k in keys.iter().copied() {
549            crate::dispatch_input(ed, k);
550        }
551    }
552    ed.set_replaying_macro_raw(was_replaying);
553    true
554}
555
556fn handle_goto_mark<H: Host>(
557    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
558    input: Input,
559    linewise: bool,
560) -> bool {
561    let Key::Char(c) = input.key else {
562        return true;
563    };
564    if linewise {
565        ed.goto_mark_line(c);
566    } else {
567        ed.goto_mark_char(c);
568    }
569    true
570}
571
572fn handle_after_op<H: Host>(
573    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
574    input: Input,
575    op: Operator,
576    count1: usize,
577) -> bool {
578    // Inner count after operator (e.g. d3w): accumulate in state.count.
579    if let Key::Char(d @ '0'..='9') = input.key
580        && !input.ctrl
581        && (d != '0' || ed.count() > 0)
582    {
583        ed.accumulate_count_digit(d as usize - '0' as usize);
584        ed.set_pending(Pending::Op { op, count1 });
585        return true;
586    }
587
588    // Esc cancels.
589    if input.key == Key::Esc {
590        ed.reset_count();
591        return true;
592    }
593
594    // Same-letter: dd / cc / yy / gUU / guu / g~~ / >> / <<. Fold has
595    // no doubled form in vim — `zfzf` is two `zf` chords, not a line
596    // op — so skip the branch entirely.
597    let double_ch = match op {
598        Operator::Delete => Some('d'),
599        Operator::Change => Some('c'),
600        Operator::Yank => Some('y'),
601        Operator::Indent => Some('>'),
602        Operator::Outdent => Some('<'),
603        Operator::Uppercase => Some('U'),
604        Operator::Lowercase => Some('u'),
605        Operator::ToggleCase => Some('~'),
606        Operator::Fold => None,
607        // `gqq` reflows the current line — vim's doubled form for the
608        // reflow operator is the second `q` after `gq`.
609        Operator::Reflow => Some('q'),
610        // `==` auto-indents the current line.
611        Operator::AutoIndent => Some('='),
612    };
613    if let Key::Char(c) = input.key
614        && !input.ctrl
615        && Some(c) == double_ch
616    {
617        let count2 = ed.take_count();
618        let total = count1.max(1) * count2.max(1);
619        ed.apply_op_double(op, total);
620        return true;
621    }
622
623    // Text object: `i` or `a`.
624    if let Key::Char('i') | Key::Char('a') = input.key
625        && !input.ctrl
626    {
627        let inner = matches!(input.key, Key::Char('i'));
628        ed.set_pending(Pending::OpTextObj { op, count1, inner });
629        return true;
630    }
631
632    // `g` — awaiting `g` for `gg`.
633    if input.key == Key::Char('g') && !input.ctrl {
634        ed.set_pending(Pending::OpG { op, count1 });
635        return true;
636    }
637
638    // `f`/`F`/`t`/`T` with pending target.
639    if let Some((forward, till)) = find_entry(&input) {
640        ed.set_pending(Pending::OpFind {
641            op,
642            count1,
643            forward,
644            till,
645        });
646        return true;
647    }
648
649    // Motion.
650    let count2 = ed.take_count();
651    let total = count1.max(1) * count2.max(1);
652    if let Some(motion) = parse_motion(&input) {
653        let motion = match motion {
654            Motion::FindRepeat { reverse } => match ed.last_find() {
655                Some((ch, forward, till)) => Motion::Find {
656                    ch,
657                    forward: if reverse { !forward } else { forward },
658                    till,
659                },
660                None => return true,
661            },
662            // Vim quirk: `cw` / `cW` are `ce` / `cE` — don't include
663            // trailing whitespace so the user's replacement text lands
664            // before the following word's leading space.
665            Motion::WordFwd if op == Operator::Change => Motion::WordEnd,
666            Motion::BigWordFwd if op == Operator::Change => Motion::BigWordEnd,
667            m => m,
668        };
669        ed.apply_op_with_motion_direct(op, &motion, total);
670        if let Motion::Find { ch, forward, till } = &motion {
671            ed.set_last_find(Some((*ch, *forward, *till)));
672        }
673        if !ed.is_replaying() && op_is_change(op) {
674            ed.set_last_change(Some(LastChange::OpMotion {
675                op,
676                motion,
677                count: total,
678                inserted: None,
679            }));
680        }
681        return true;
682    }
683
684    // Unknown — cancel the operator.
685    true
686}
687
688fn handle_op_after_g<H: Host>(
689    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
690    input: Input,
691    op: Operator,
692    count1: usize,
693) -> bool {
694    if input.ctrl {
695        return true;
696    }
697    let count2 = ed.take_count();
698    let total = count1.max(1) * count2.max(1);
699    if let Key::Char(ch) = input.key {
700        ed.apply_op_g(op, ch, total);
701    }
702    true
703}
704
705fn handle_after_g<H: Host>(
706    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
707    input: Input,
708) -> bool {
709    let count = ed.take_count();
710    // Extract the char and delegate to the shared apply_after_g body.
711    // Non-char keys (ctrl sequences etc.) are silently ignored.
712    if let Key::Char(ch) = input.key {
713        ed.after_g(ch, count);
714    }
715    true
716}
717
718fn handle_after_z<H: Host>(
719    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
720    input: Input,
721) -> bool {
722    let count = ed.take_count();
723    // Extract the char and delegate to the shared apply_after_z body.
724    // Non-char keys (ctrl sequences etc.) are silently ignored.
725    if let Key::Char(ch) = input.key {
726        ed.after_z(ch, count);
727    }
728    true
729}
730
731fn handle_replace<H: Host>(
732    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
733    input: Input,
734) -> bool {
735    if let Key::Char(ch) = input.key {
736        if ed.fsm_mode() == FsmMode::VisualBlock {
737            ed.replace_block_char(ch);
738            return true;
739        }
740        let count = ed.take_count();
741        ed.replace_char_at(ch, count.max(1));
742        if !ed.is_replaying() {
743            ed.set_last_change(Some(LastChange::ReplaceChar {
744                ch,
745                count: count.max(1),
746            }));
747        }
748    }
749    true
750}
751
752fn handle_find_target<H: Host>(
753    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
754    input: Input,
755    forward: bool,
756    till: bool,
757) -> bool {
758    let Key::Char(ch) = input.key else {
759        return true;
760    };
761    let count = ed.take_count();
762    ed.find_char(ch, forward, till, count.max(1));
763    true
764}
765
766fn handle_op_find_target<H: Host>(
767    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
768    input: Input,
769    op: Operator,
770    count1: usize,
771    forward: bool,
772    till: bool,
773) -> bool {
774    let Key::Char(ch) = input.key else {
775        return true;
776    };
777    let count2 = ed.take_count();
778    let total = count1.max(1) * count2.max(1);
779    ed.apply_op_find(op, ch, forward, till, total);
780    true
781}
782
783fn handle_text_object<H: Host>(
784    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
785    input: Input,
786    op: Operator,
787    _count1: usize,
788    inner: bool,
789) -> bool {
790    let Key::Char(ch) = input.key else {
791        return true;
792    };
793    // Delegate to shared implementation; unknown chars are a no-op (return true
794    // to consume the key from the FSM regardless).
795    ed.apply_op_text_obj(op, ch, inner, 1);
796    true
797}
798
799fn handle_visual_text_obj<H: Host>(
800    ed: &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
801    input: Input,
802    inner: bool,
803) -> bool {
804    let Key::Char(ch) = input.key else {
805        return true;
806    };
807    ed.visual_text_obj_extend(ch, inner);
808    true
809}
810
811// ─── Pure utility helpers (no Editor mutation) ─────────────────────────────
812
813fn char_to_operator(c: char) -> Option<Operator> {
814    match c {
815        'd' => Some(Operator::Delete),
816        'c' => Some(Operator::Change),
817        'y' => Some(Operator::Yank),
818        '>' => Some(Operator::Indent),
819        '<' => Some(Operator::Outdent),
820        '=' => Some(Operator::AutoIndent),
821        _ => None,
822    }
823}
824
825fn visual_operator(input: &Input) -> Option<Operator> {
826    if input.ctrl {
827        return None;
828    }
829    match input.key {
830        Key::Char('y') => Some(Operator::Yank),
831        Key::Char('d') | Key::Char('x') => Some(Operator::Delete),
832        Key::Char('c') | Key::Char('s') => Some(Operator::Change),
833        // Case operators — shift forms apply to the active selection.
834        Key::Char('U') => Some(Operator::Uppercase),
835        Key::Char('u') => Some(Operator::Lowercase),
836        Key::Char('~') => Some(Operator::ToggleCase),
837        // Indent operators on selection.
838        Key::Char('>') => Some(Operator::Indent),
839        Key::Char('<') => Some(Operator::Outdent),
840        // Auto-indent selection.
841        Key::Char('=') => Some(Operator::AutoIndent),
842        _ => None,
843    }
844}
845
846fn find_entry(input: &Input) -> Option<(bool, bool)> {
847    if input.ctrl {
848        return None;
849    }
850    match input.key {
851        Key::Char('f') => Some((true, false)),
852        Key::Char('F') => Some((false, false)),
853        Key::Char('t') => Some((true, true)),
854        Key::Char('T') => Some((false, true)),
855        _ => None,
856    }
857}