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