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