Skip to main content

hjkl_vim/vim/
motion.rs

1//! Vim FSM: motion.
2//!
3//! Split out of the monolithic `vim.rs` (#267 follow-up).
4
5use hjkl_vim_types::{LastHorizontalMotion, Mode, Motion};
6
7use hjkl_engine::input::{Input, Key};
8
9use super::*;
10use crate::vim_state::{vim, vim_mut};
11use hjkl_engine::buf_helpers::{
12    buf_cursor_pos, buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc,
13};
14use hjkl_engine::{Editor, Move};
15
16/// Parse the first key of a normal/visual-mode motion. Returns `None` for
17/// keys that don't start a motion (operator keys, command keys, etc.).
18/// Promoted to `pub` in Phase 6.6e so `hjkl-vim::normal` can call it.
19pub fn parse_motion(input: &Input) -> Option<Motion> {
20    if input.ctrl {
21        // `<C-h>` is vim's `<BS>` — a wrapping left motion. (The hjkl app
22        // rebinds `<C-h>` to window-focus-left before it reaches the engine;
23        // this keeps it correct for engine consumers that don't override it.)
24        if input.key == Key::Char('h') {
25            return Some(Motion::BackspaceBack);
26        }
27        return None;
28    }
29    match input.key {
30        Key::Char('h') | Key::Left => Some(Motion::Left),
31        Key::Char('l') | Key::Right => Some(Motion::Right),
32        // `<Space>`/`<BS>` are vim's right/left motions that WRAP at line ends
33        // (default `whichwrap=b,s`), unlike `l`/`h`/arrows which never wrap.
34        // Operators (`d<Space>`/`d<BS>`) act on one char mid-line like `dl`/`dh`.
35        Key::Char(' ') => Some(Motion::SpaceFwd),
36        Key::Backspace => Some(Motion::BackspaceBack),
37        Key::Char('j') | Key::Down => Some(Motion::Down),
38        // `+` / `<CR>` — first non-blank of next line (linewise, count-aware).
39        Key::Char('+') | Key::Enter => Some(Motion::FirstNonBlankNextLine),
40        // `-` — first non-blank of previous line (linewise, count-aware).
41        Key::Char('-') => Some(Motion::FirstNonBlankPrevLine),
42        // `_` — first non-blank of current line, or count-1 lines down (linewise).
43        Key::Char('_') => Some(Motion::FirstNonBlankLine),
44        Key::Char('k') | Key::Up => Some(Motion::Up),
45        Key::Char('w') => Some(Motion::WordFwd),
46        Key::Char('W') => Some(Motion::BigWordFwd),
47        Key::Char('b') => Some(Motion::WordBack),
48        Key::Char('B') => Some(Motion::BigWordBack),
49        Key::Char('e') => Some(Motion::WordEnd),
50        Key::Char('E') => Some(Motion::BigWordEnd),
51        Key::Char('0') | Key::Home => Some(Motion::LineStart),
52        Key::Char('^') => Some(Motion::FirstNonBlank),
53        Key::Char('$') | Key::End => Some(Motion::LineEnd),
54        Key::Char('G') => Some(Motion::FileBottom),
55        Key::Char('%') => Some(Motion::MatchBracket),
56        Key::Char(';') => Some(Motion::FindRepeat { reverse: false }),
57        Key::Char(',') => Some(Motion::FindRepeat { reverse: true }),
58        Key::Char('*') => Some(Motion::WordAtCursor {
59            forward: true,
60            whole_word: true,
61        }),
62        Key::Char('#') => Some(Motion::WordAtCursor {
63            forward: false,
64            whole_word: true,
65        }),
66        Key::Char('n') => Some(Motion::SearchNext { reverse: false }),
67        Key::Char('N') => Some(Motion::SearchNext { reverse: true }),
68        Key::Char('H') => Some(Motion::ViewportTop),
69        Key::Char('M') => Some(Motion::ViewportMiddle),
70        Key::Char('L') => Some(Motion::ViewportBottom),
71        Key::Char('{') => Some(Motion::ParagraphPrev),
72        Key::Char('}') => Some(Motion::ParagraphNext),
73        Key::Char('(') => Some(Motion::SentencePrev),
74        Key::Char(')') => Some(Motion::SentenceNext),
75        Key::Char('|') => Some(Motion::GotoColumn),
76        _ => None,
77    }
78}
79pub fn execute_motion<H: hjkl_engine::types::Host>(
80    ed: &mut Editor<hjkl_buffer::View, H>,
81    motion: Motion,
82    count: usize,
83) {
84    let count = count.clamp(1, MAX_COUNT);
85    // `;`/`,` smart fallback: if the last horizontal motion was a sneak
86    // digraph, repeat via apply_sneak instead of find-char.
87    if let Motion::FindRepeat { reverse } = motion
88        && vim(ed).last_horizontal_motion == LastHorizontalMotion::Sneak
89    {
90        if let Some(((c1, c2), fwd)) = vim(ed).last_sneak {
91            let effective_fwd = if reverse { !fwd } else { fwd };
92            apply_sneak(ed, c1, c2, effective_fwd, count);
93        }
94        return;
95    }
96    // FindRepeat needs the stored direction. A `;`/`,` repeat of a `t`/`T`
97    // find must skip an immediately-adjacent match (vim's repeat quirk); flag
98    // it so the `Motion::Find` dispatch below passes `skip_adjacent`.
99    let motion = match motion {
100        Motion::FindRepeat { reverse } => match vim(ed).last_find {
101            Some((ch, forward, till)) => {
102                vim_mut(ed).find_repeat_skip = true;
103                Motion::Find {
104                    ch,
105                    forward: if reverse { !forward } else { forward },
106                    till,
107                }
108            }
109            None => return,
110        },
111        other => other,
112    };
113    let pre_pos = ed.cursor();
114    apply_motion_cursor(ed, &motion, count);
115    let post_pos = ed.cursor();
116    if is_big_jump(&motion) && pre_pos != post_pos {
117        ed.push_jump(pre_pos);
118    }
119    // Phase 1 (backlog §1.6): the motion names its own curswant semantics.
120    // The landed cursor is re-moved through the matching `Move` variant —
121    // for the cursor itself this is a no-op (the motion already landed and
122    // clamped), and it rewrites `sticky_col` to the class's rule, replacing
123    // the old post-hoc `apply_sticky_col` catch-all.
124    match motion_class(&motion) {
125        MotionClass::Vertical => {
126            // Re-run the sticky clamp on the landed row. The vertical motion
127            // fns already clamped there, so this only confirms the
128            // un-clamped want stays stored for the next vertical motion.
129            ed.move_cursor(Move::Vertical { row: ed.cursor().0 });
130        }
131        MotionClass::Jump => {
132            // Re-jump to the landed spot; `jump_cursor` sets sticky to the
133            // landed column (vim resets curswant on every explicit jump).
134            let pos = ed.cursor();
135            ed.move_cursor(Move::Jump {
136                row: pos.0,
137                col: pos.1,
138            });
139        }
140        MotionClass::Horizontal => {
141            // Sticky tracks the landed column.
142            ed.move_cursor(Move::Horizontal { col: ed.cursor().1 });
143        }
144    }
145    // Phase 7b: keep the migration buffer's cursor + viewport in
146    // lockstep with the textarea after every motion. Once 7c lands
147    // (motions ported onto the buffer's API), this flips: the
148    // buffer becomes authoritative and the textarea mirrors it.
149    ed.sync_buffer_from_textarea();
150}
151/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
152/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
153/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
154/// extend the highlighted region correctly.
155///
156/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
157/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
158/// safe — the function's own match arm handles the no-op case.
159pub fn execute_motion_with_block_vcol<H: hjkl_engine::types::Host>(
160    ed: &mut Editor<hjkl_buffer::View, H>,
161    motion: Motion,
162    count: usize,
163) {
164    let motion_copy = motion.clone();
165    execute_motion(ed, motion, count);
166    if vim(ed).mode == Mode::VisualBlock {
167        update_block_vcol(ed, &motion_copy);
168    }
169}
170/// Execute a `hjkl_engine::MotionKind` cursor motion. Called by the host's
171/// `Editor::apply_motion` controller method — the keymap dispatch path for
172/// Phase 3a of kryptic-sh/hjkl#69.
173///
174/// Maps each variant to the same internal primitives used by the engine FSM
175/// so cursor, sticky column, scroll, and sync semantics are identical.
176///
177/// # Visual-mode post-motion sync audit (2026-05-13)
178///
179/// After `execute_motion`, two things are conditional on visual mode:
180///
181/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
182///    called when `mode == Mode::VisualBlock`.  This is replicated here via
183///    `execute_motion_with_block_vcol` for every motion variant below.
184///
185/// 2. **`last_find` update** — `Motion::Find` is dispatched through
186///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
187///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
188///    path writes `last_find` in `apply_find_char` (called from
189///    `Editor::find_char`), so no gap exists here.
190///
191/// No VisualLine-specific or Visual-specific post-motion work exists in the
192/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
193/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
194/// mark update in `step()` fires only on visual→normal transition, not after
195/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
196/// fix already applied above.
197pub fn apply_motion_kind<H: hjkl_engine::types::Host>(
198    ed: &mut Editor<hjkl_buffer::View, H>,
199    kind: hjkl_engine::MotionKind,
200    count: usize,
201) {
202    let count = count.max(1);
203    match kind {
204        hjkl_engine::MotionKind::CharLeft => {
205            execute_motion_with_block_vcol(ed, Motion::Left, count);
206        }
207        hjkl_engine::MotionKind::CharRight => {
208            execute_motion_with_block_vcol(ed, Motion::Right, count);
209        }
210        hjkl_engine::MotionKind::LineDown => {
211            execute_motion_with_block_vcol(ed, Motion::Down, count);
212        }
213        hjkl_engine::MotionKind::LineUp => {
214            execute_motion_with_block_vcol(ed, Motion::Up, count);
215        }
216        hjkl_engine::MotionKind::FirstNonBlankDown => {
217            // `+`: move down `count` lines then land on first non-blank.
218            // Not a big-jump (no jump-list entry). The landed first
219            // non-blank column becomes the sticky target — vim's `+` sets
220            // curswant to where it lands (jump class). Mirrors
221            // scroll_cursor_rows semantics but goes through the fold-aware
222            // buffer motion path.
223            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
224            let mut sticky = ed.sticky_col();
225            let tabstop = ed.settings().tabstop;
226            hjkl_engine::motions::move_down(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
227            ed.set_sticky_col(sticky);
228            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
229            let pos = ed.cursor();
230            ed.move_cursor(Move::Jump {
231                row: pos.0,
232                col: pos.1,
233            });
234            ed.sync_buffer_from_textarea();
235        }
236        hjkl_engine::MotionKind::FirstNonBlankUp => {
237            // `-`: move up `count` lines then land on first non-blank.
238            // Same pattern as FirstNonBlankDown, direction reversed.
239            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
240            let mut sticky = ed.sticky_col();
241            let tabstop = ed.settings().tabstop;
242            hjkl_engine::motions::move_up(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
243            ed.set_sticky_col(sticky);
244            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
245            let pos = ed.cursor();
246            ed.move_cursor(Move::Jump {
247                row: pos.0,
248                col: pos.1,
249            });
250            ed.sync_buffer_from_textarea();
251        }
252        hjkl_engine::MotionKind::WordForward => {
253            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
254        }
255        hjkl_engine::MotionKind::BigWordForward => {
256            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
257        }
258        hjkl_engine::MotionKind::WordBackward => {
259            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
260        }
261        hjkl_engine::MotionKind::BigWordBackward => {
262            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
263        }
264        hjkl_engine::MotionKind::WordEnd => {
265            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
266        }
267        hjkl_engine::MotionKind::BigWordEnd => {
268            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
269        }
270        hjkl_engine::MotionKind::LineStart => {
271            // `0` / `<Home>`: first column of the current line.
272            // count is ignored — matches vim `0` semantics.
273            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
274        }
275        hjkl_engine::MotionKind::FirstNonBlank => {
276            // `^`: first non-blank column on the current line.
277            // count is ignored — matches vim `^` semantics.
278            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
279        }
280        hjkl_engine::MotionKind::GotoLine => {
281            // `G`: bare `G` → last line; `count G` → jump to line `count`.
282            // apply_motion_kind normalises the raw count to count.max(1)
283            // above, so count == 1 means "bare G" (last line) and count > 1
284            // means "go to line N". execute_motion's FileBottom arm applies
285            // the same `count > 1` check before calling move_bottom, so the
286            // convention aligns: pass count straight through.
287            // FileBottom is vertical — update_block_vcol is a no-op here
288            // (preserves vcol), so the helper is safe to use.
289            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
290        }
291        hjkl_engine::MotionKind::LineEnd => {
292            // `[count]$` / `<End>`: move count-1 lines down then land
293            // at the last character of the destination line.
294            execute_motion_with_block_vcol(ed, Motion::LineEnd, count);
295        }
296        hjkl_engine::MotionKind::FindRepeat => {
297            // `;` — repeat last f/F/t/T in the same direction.
298            // execute_motion resolves FindRepeat via vim(ed).last_find;
299            // no-op if no prior find exists (None arm returns early).
300            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
301        }
302        hjkl_engine::MotionKind::FindRepeatReverse => {
303            // `,` — repeat last f/F/t/T in the reverse direction.
304            // execute_motion resolves FindRepeat via vim(ed).last_find;
305            // no-op if no prior find exists (None arm returns early).
306            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
307        }
308        hjkl_engine::MotionKind::BracketMatch => {
309            // `%` — jump to the matching bracket.
310            // count is passed through; engine-side matching_bracket handles
311            // the no-match case as a no-op (cursor stays). Engine FSM arm
312            // for `%` in parse_motion is kept intact for macro-replay.
313            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
314        }
315        hjkl_engine::MotionKind::ViewportTop => {
316            // `H` — cursor to top of visible viewport, then count-1 rows down.
317            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
318            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
319        }
320        hjkl_engine::MotionKind::ViewportMiddle => {
321            // `M` — cursor to middle of visible viewport; count ignored.
322            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
323            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
324        }
325        hjkl_engine::MotionKind::ViewportBottom => {
326            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
327            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
328            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
329        }
330        hjkl_engine::MotionKind::HalfPageDown => {
331            // `<C-d>` — half page down, count multiplies the distance.
332            // Calls scroll_cursor_rows directly rather than adding a Motion enum
333            // variant, keeping engine Motion churn minimal.
334            {
335                let d = ed.viewport_half_rows(count) as isize;
336                ed.scroll_cursor_rows(d);
337            }
338        }
339        hjkl_engine::MotionKind::HalfPageUp => {
340            // `<C-u>` — half page up, count multiplies the distance.
341            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
342            {
343                let d = -(ed.viewport_half_rows(count) as isize);
344                ed.scroll_cursor_rows(d);
345            }
346        }
347        hjkl_engine::MotionKind::FullPageDown => {
348            // `<C-f>` — full page down (2-line overlap), count multiplies.
349            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
350            {
351                let d = ed.viewport_full_rows(count) as isize;
352                ed.scroll_cursor_rows(d);
353            }
354        }
355        hjkl_engine::MotionKind::FullPageUp => {
356            // `<C-b>` — full page up (2-line overlap), count multiplies.
357            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
358            {
359                let d = -(ed.viewport_full_rows(count) as isize);
360                ed.scroll_cursor_rows(d);
361            }
362        }
363        hjkl_engine::MotionKind::FirstNonBlankLine => {
364            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
365        }
366        hjkl_engine::MotionKind::SectionBackward => {
367            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
368        }
369        hjkl_engine::MotionKind::SectionForward => {
370            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
371        }
372        hjkl_engine::MotionKind::SectionEndBackward => {
373            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
374        }
375        hjkl_engine::MotionKind::SectionEndForward => {
376            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
377        }
378        // `MotionKind` is `#[non_exhaustive]` and now lives in another crate, so
379        // the engine can add a motion this FSM has never heard of. Ignoring it
380        // is the honest response: a discipline cannot execute a motion it has
381        // no binding for. Every variant that exists today is handled above.
382        _ => {}
383    }
384}
385/// Which of the three `Move` curswant classes a plain motion belongs to
386/// (backlog §1.6 phase 1). Each variant names its own curswant semantics,
387/// replacing the old post-hoc `apply_sticky_col` catch-all;
388/// `execute_motion` re-moves the landed cursor through the matching `Move`
389/// variant.
390///
391/// There is deliberately no `Raw` class: `Move::Raw` is for pure
392/// repositions owned by surrounding code (operator park/restore, host
393/// state sync), and no plain motion is one — every motion either preserves
394/// the sticky column (Vertical) or syncs it to the landed column (Jump /
395/// Horizontal). The match below is exhaustive, so a Motion variant added
396/// later must be classified here to compile.
397///
398/// See [`hjkl_engine::Move`] for the semantics of each class.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400enum MotionClass {
401    /// `j` / `k` and their screen-line equivalents — preserve the sticky
402    /// column across rows.
403    Vertical,
404    /// Explicit jumps — the landing column is a target, not a relative
405    /// step; vim resets curswant to the landed column.
406    Jump,
407    /// Relative moves within a row (or a row-relative target like `$`, `^`,
408    /// `f`) — sticky tracks the landed column.
409    Horizontal,
410}
411
412fn motion_class(motion: &Motion) -> MotionClass {
413    use MotionClass::*;
414    match motion {
415        // Vertical: only these preserve the sticky column. Everything else
416        // (search, gg / G, word jumps, ...) lands at the match's own column
417        // so the sticky value syncs to the new cursor column.
418        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown => Vertical,
419        // Jump: explicit target landings — `gg`/`G`, `%`, `[(`/`])`,
420        // `*`/`#`, `n`/`N`, `H`/`M`/`L`, `{`/`}`, `(`/`)`, `[[`/`]]`/
421        // `[]`/`][`, and the first-non-blank linewise motions `+`/`-`/`_`
422        // (vim sets curswant to the landed first-non-blank column).
423        Motion::FileTop
424        | Motion::FileBottom
425        | Motion::MatchBracket
426        | Motion::UnmatchedBracket { .. }
427        | Motion::WordAtCursor { .. }
428        | Motion::SearchNext { .. }
429        | Motion::ViewportTop
430        | Motion::ViewportMiddle
431        | Motion::ViewportBottom
432        | Motion::ParagraphPrev
433        | Motion::ParagraphNext
434        | Motion::SentencePrev
435        | Motion::SentenceNext
436        | Motion::SectionBackward
437        | Motion::SectionForward
438        | Motion::SectionEndBackward
439        | Motion::SectionEndForward
440        | Motion::FirstNonBlankNextLine
441        | Motion::FirstNonBlankPrevLine
442        | Motion::FirstNonBlankLine => Jump,
443        // Horizontal: moves whose landed column is the sticky target on the
444        // same row. `FindRepeat` is resolved to `Find` (or the sneak
445        // early-return) before it ever reaches the classifier.
446        Motion::Left
447        | Motion::Right
448        | Motion::SpaceFwd
449        | Motion::BackspaceBack
450        | Motion::WordFwd
451        | Motion::BigWordFwd
452        | Motion::WordBack
453        | Motion::BigWordBack
454        | Motion::WordEnd
455        | Motion::BigWordEnd
456        | Motion::WordEndBack
457        | Motion::BigWordEndBack
458        | Motion::LineStart
459        | Motion::FirstNonBlank
460        | Motion::LineEnd
461        | Motion::Find { .. }
462        | Motion::FindRepeat { .. }
463        | Motion::LastNonBlank
464        | Motion::LineMiddle
465        | Motion::ScreenLineMiddle
466        | Motion::GotoColumn => Horizontal,
467        // No Motion variant classifies Raw — see the enum docs.
468    }
469}
470pub fn apply_motion_cursor<H: hjkl_engine::types::Host>(
471    ed: &mut Editor<hjkl_buffer::View, H>,
472    motion: &Motion,
473    count: usize,
474) {
475    apply_motion_cursor_ctx(ed, motion, count, false)
476}
477pub fn apply_motion_cursor_ctx<H: hjkl_engine::types::Host>(
478    ed: &mut Editor<hjkl_buffer::View, H>,
479    motion: &Motion,
480    count: usize,
481    as_operator: bool,
482) {
483    // Clamp the count where it fans out into the per-motion `0..count` walk.
484    // Two bounds:
485    //  - vim's documented ceiling (`:h count`) for folded counts; and
486    //  - the buffer's character count, since a motion can never make progress
487    //    past the end of the buffer — without this a pathological prefix
488    //    (`999999999w`, `<big>dw`) would spin the walk up to ~1e9 times,
489    //    freezing the UI, even though the result is identical to stopping at
490    //    the buffer edge.
491    let count = count
492        .min(MAX_COUNT)
493        .min(ed.buffer().rope().len_chars().saturating_add(1));
494    match motion {
495        Motion::Left => {
496            // `h` — View clamps at col 0 (no wrap), matching vim.
497            hjkl_engine::motions::move_left(ed.buffer_mut(), count);
498        }
499        Motion::Right => {
500            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
501            // one past the last char so the range includes it; cursor
502            // context clamps at the last char.
503            if as_operator {
504                hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), count);
505            } else {
506                hjkl_engine::motions::move_right_in_line(ed.buffer_mut(), count);
507            }
508        }
509        Motion::SpaceFwd => {
510            // `<Space>` — wraps to next line at EOL in cursor context; mid-line
511            // char delete like `l` under an operator (`d<Space>`).
512            if as_operator {
513                hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), count);
514            } else {
515                hjkl_engine::motions::move_space_fwd(ed.buffer_mut(), count);
516            }
517        }
518        Motion::BackspaceBack => {
519            // `<BS>` — wraps to prev line's last char at BOL in cursor context;
520            // mid-line char move like `h` under an operator (`d<BS>`).
521            if as_operator {
522                hjkl_engine::motions::move_left(ed.buffer_mut(), count);
523            } else {
524                hjkl_engine::motions::move_backspace_back(ed.buffer_mut(), count);
525            }
526        }
527        Motion::Up => {
528            // Final col is clamped by the `Move::Vertical` re-move in
529            // `execute_motion` below — push the post-move row to the
530            // textarea and let the vertical clamp finish the work.
531            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
532            let mut sticky = ed.sticky_col();
533            let tabstop = ed.settings().tabstop;
534            hjkl_engine::motions::move_up(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
535            ed.set_sticky_col(sticky);
536        }
537        Motion::Down => {
538            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
539            let mut sticky = ed.sticky_col();
540            let tabstop = ed.settings().tabstop;
541            hjkl_engine::motions::move_down(ed.buffer_mut(), &folds, count, &mut sticky, tabstop);
542            ed.set_sticky_col(sticky);
543        }
544        Motion::ScreenUp => {
545            let v = *ed.host().viewport();
546            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
547            let mut sticky = ed.sticky_col();
548            let tabstop = ed.settings().tabstop;
549            hjkl_engine::motions::move_screen_up(
550                ed.buffer_mut(),
551                &folds,
552                &v,
553                count,
554                &mut sticky,
555                tabstop,
556            );
557            ed.set_sticky_col(sticky);
558        }
559        Motion::ScreenDown => {
560            let v = *ed.host().viewport();
561            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
562            let mut sticky = ed.sticky_col();
563            let tabstop = ed.settings().tabstop;
564            hjkl_engine::motions::move_screen_down(
565                ed.buffer_mut(),
566                &folds,
567                &v,
568                count,
569                &mut sticky,
570                tabstop,
571            );
572            ed.set_sticky_col(sticky);
573        }
574        Motion::WordFwd => {
575            let iskeyword = ed.settings().iskeyword.clone();
576            hjkl_engine::motions::move_word_fwd(ed.buffer_mut(), false, count, &iskeyword);
577            // Cursor context: clamp to the last char of the line so a counted
578            // `w` past EOF never lands past the last character.
579            if !as_operator {
580                let (row, col) = ed.cursor();
581                let line_len = buf_line_chars(ed.buffer(), row);
582                if col > line_len.saturating_sub(1) {
583                    ed.jump_cursor(row, line_len.saturating_sub(1));
584                }
585            }
586        }
587        Motion::WordBack => {
588            let iskeyword = ed.settings().iskeyword.clone();
589            hjkl_engine::motions::move_word_back(ed.buffer_mut(), false, count, &iskeyword);
590        }
591        Motion::WordEnd => {
592            let iskeyword = ed.settings().iskeyword.clone();
593            hjkl_engine::motions::move_word_end(ed.buffer_mut(), false, count, &iskeyword);
594        }
595        Motion::BigWordFwd => {
596            let iskeyword = ed.settings().iskeyword.clone();
597            hjkl_engine::motions::move_word_fwd(ed.buffer_mut(), true, count, &iskeyword);
598            // Cursor context: clamp to the last char of the line.
599            if !as_operator {
600                let (row, col) = ed.cursor();
601                let line_len = buf_line_chars(ed.buffer(), row);
602                if col > line_len.saturating_sub(1) {
603                    ed.jump_cursor(row, line_len.saturating_sub(1));
604                }
605            }
606        }
607        Motion::BigWordBack => {
608            let iskeyword = ed.settings().iskeyword.clone();
609            hjkl_engine::motions::move_word_back(ed.buffer_mut(), true, count, &iskeyword);
610        }
611        Motion::BigWordEnd => {
612            let iskeyword = ed.settings().iskeyword.clone();
613            hjkl_engine::motions::move_word_end(ed.buffer_mut(), true, count, &iskeyword);
614        }
615        Motion::WordEndBack => {
616            let iskeyword = ed.settings().iskeyword.clone();
617            hjkl_engine::motions::move_word_end_back(ed.buffer_mut(), false, count, &iskeyword);
618        }
619        Motion::BigWordEndBack => {
620            let iskeyword = ed.settings().iskeyword.clone();
621            hjkl_engine::motions::move_word_end_back(ed.buffer_mut(), true, count, &iskeyword);
622        }
623        Motion::LineStart => {
624            hjkl_engine::motions::move_line_start(ed.buffer_mut());
625        }
626        Motion::FirstNonBlank => {
627            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
628        }
629        Motion::LineEnd => {
630            // `[count]$`: move count-1 lines down, then to line end.
631            // Vim normal-mode `$` lands on the last char, not one past it.
632            if count > 1 {
633                // vim's `nv_dollar` runs `cursor_down(count - 1)` first and
634                // aborts the whole command when that FAILS — which it does
635                // only on the last line. A count that overshoots the buffer
636                // still succeeds, clamped (`5$` on three rows lands on row
637                // 2). Without this, `2$` on a one-line buffer moved to the
638                // line end and `2C` / `2D` emptied the line, where vim does
639                // nothing at all.
640                if ed.cursor().0 >= last_content_row(ed) {
641                    return;
642                }
643                let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
644                let mut sticky = ed.sticky_col();
645                let tabstop = ed.settings().tabstop;
646                hjkl_engine::motions::move_down(
647                    ed.buffer_mut(),
648                    &folds,
649                    count - 1,
650                    &mut sticky,
651                    tabstop,
652                );
653                ed.set_sticky_col(sticky);
654            }
655            hjkl_engine::motions::move_line_end(ed.buffer_mut());
656        }
657        Motion::FileTop => {
658            // `count gg` jumps to line `count` (first non-blank);
659            // bare `gg` lands at the top.
660            if count > 1 {
661                hjkl_engine::motions::move_bottom(ed.buffer_mut(), count);
662            } else {
663                hjkl_engine::motions::move_top(ed.buffer_mut());
664            }
665        }
666        Motion::FileBottom => {
667            // `count G` jumps to line `count`; bare `G` lands at
668            // the buffer bottom (`View::move_bottom(0)`).
669            if count > 1 {
670                hjkl_engine::motions::move_bottom(ed.buffer_mut(), count);
671            } else {
672                hjkl_engine::motions::move_bottom(ed.buffer_mut(), 0);
673            }
674        }
675        Motion::Find { ch, forward, till } => {
676            // Skip an adjacent target when this is a `;`/`,` repeat, and on the
677            // 2nd..Nth step of a counted `t`/`T` (the cursor lands one cell
678            // short each time, so a naive repeat would stick).
679            let repeat = std::mem::take(&mut vim_mut(ed).find_repeat_skip);
680            for i in 0..count {
681                let skip_adjacent = repeat || i > 0;
682                if !find_char_on_line(ed, *ch, *forward, *till, skip_adjacent) {
683                    break;
684                }
685            }
686        }
687        Motion::FindRepeat { .. } => {} // already resolved upstream
688        Motion::MatchBracket => {
689            let _ = matching_bracket(ed);
690        }
691        Motion::UnmatchedBracket { forward, open } => {
692            goto_unmatched_bracket(ed, *forward, *open, count);
693        }
694        Motion::WordAtCursor {
695            forward,
696            whole_word,
697        } => {
698            word_at_cursor_search(ed, *forward, *whole_word, count);
699        }
700        Motion::SearchNext { reverse } => {
701            // Re-push the last query so the buffer's search state is
702            // correct even if the host happened to clear it (e.g. while
703            // a Visual mode draw was in progress).
704            if let Some(pattern) = ed.last_search_pattern() {
705                ed.push_search_pattern(&pattern);
706            }
707            if ed.search_state().pattern.is_none() {
708                return;
709            }
710            // `n` repeats the last search in its committed direction;
711            // `N` inverts. So a `?` search makes `n` walk backward and
712            // `N` walk forward.
713            let forward = ed.last_search_forward() != *reverse;
714            for _ in 0..count.max(1) {
715                if forward {
716                    ed.search_advance_forward(true);
717                } else {
718                    ed.search_advance_backward(true);
719                }
720            }
721        }
722        Motion::ViewportTop => {
723            let v = *ed.host().viewport();
724            hjkl_engine::motions::move_viewport_top(ed.buffer_mut(), &v, count.saturating_sub(1));
725        }
726        Motion::ViewportMiddle => {
727            let v = *ed.host().viewport();
728            hjkl_engine::motions::move_viewport_middle(ed.buffer_mut(), &v);
729        }
730        Motion::ViewportBottom => {
731            let v = *ed.host().viewport();
732            hjkl_engine::motions::move_viewport_bottom(
733                ed.buffer_mut(),
734                &v,
735                count.saturating_sub(1),
736            );
737        }
738        Motion::LastNonBlank => {
739            hjkl_engine::motions::move_last_non_blank(ed.buffer_mut());
740        }
741        Motion::LineMiddle => {
742            let row = ed.cursor().0;
743            let line_chars = buf_line_chars(ed.buffer(), row);
744            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
745            // lines stay at col 0.
746            let target = line_chars / 2;
747            ed.jump_cursor(row, target);
748        }
749        Motion::ScreenLineMiddle => {
750            // Vim's `gm`: middle of the *screen* line = column
751            // `viewport_width / 2`, clamped to the last char of the line.
752            let row = ed.cursor().0;
753            let width = ed.host().viewport().width as usize;
754            let last = buf_line_chars(ed.buffer(), row).saturating_sub(1);
755            let target = (width / 2).min(last);
756            ed.jump_cursor(row, target);
757        }
758        Motion::ParagraphPrev => {
759            hjkl_engine::motions::move_paragraph_prev(ed.buffer_mut(), count);
760        }
761        Motion::ParagraphNext => {
762            hjkl_engine::motions::move_paragraph_next(ed.buffer_mut(), count);
763        }
764        // `(` / `)` are all-or-nothing under a count: vim's `findsent` returns
765        // FAIL as soon as one repetition has nowhere left to go, and
766        // `nv_brace` then beeps with the cursor untouched. Moving as far as
767        // possible is what made `2)` on a single sentence-less line land on
768        // the last char instead of staying put.
769        Motion::SentencePrev => {
770            let start = ed.cursor();
771            for _ in 0..count.max(1) {
772                match sentence_boundary(ed, false) {
773                    Some((row, col)) => ed.jump_cursor(row, col),
774                    None => {
775                        ed.jump_cursor(start.0, start.1);
776                        break;
777                    }
778                }
779            }
780        }
781        Motion::SentenceNext => {
782            use crate::vim::text_object::SentenceStep;
783            let start = ed.cursor();
784            let total = count.max(1);
785            for i in 0..total {
786                match crate::vim::text_object::sentence_step_forward(ed) {
787                    SentenceStep::Boundary((row, col)) => ed.jump_cursor(row, col),
788                    // Already parked on the last cell: a no-op repetition,
789                    // not a failure (`9)` at end-of-buffer stays put).
790                    SentenceStep::AtEnd => {}
791                    SentenceStep::EndOfBuffer((row, col)) => {
792                        if i + 1 == total {
793                            ed.jump_cursor(row, col);
794                        } else {
795                            ed.jump_cursor(start.0, start.1);
796                            break;
797                        }
798                    }
799                }
800            }
801        }
802        Motion::SectionBackward => {
803            hjkl_engine::motions::move_section_backward(ed.buffer_mut(), count);
804        }
805        Motion::SectionForward => {
806            hjkl_engine::motions::move_section_forward(ed.buffer_mut(), count);
807        }
808        Motion::SectionEndBackward => {
809            hjkl_engine::motions::move_section_end_backward(ed.buffer_mut(), count);
810        }
811        Motion::SectionEndForward => {
812            hjkl_engine::motions::move_section_end_forward(ed.buffer_mut(), count);
813        }
814        Motion::FirstNonBlankNextLine => {
815            hjkl_engine::motions::move_first_non_blank_next_line(ed.buffer_mut(), count);
816        }
817        Motion::FirstNonBlankPrevLine => {
818            hjkl_engine::motions::move_first_non_blank_prev_line(ed.buffer_mut(), count);
819        }
820        Motion::FirstNonBlankLine => {
821            hjkl_engine::motions::move_first_non_blank_line(ed.buffer_mut(), count);
822        }
823        Motion::GotoColumn => {
824            hjkl_engine::motions::move_goto_column(ed.buffer_mut(), count);
825        }
826    }
827}
828pub fn move_first_non_whitespace<H: hjkl_engine::types::Host>(
829    ed: &mut Editor<hjkl_buffer::View, H>,
830) {
831    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
832    // mutates the textarea content, so the migration buffer hasn't
833    // seen the new lines OR new cursor yet. Mirror the full content
834    // across before delegating, then push the result back so the
835    // textarea reflects the resolved column too.
836    ed.sync_buffer_content_from_textarea();
837    hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
838}
839pub fn find_char_on_line<H: hjkl_engine::types::Host>(
840    ed: &mut Editor<hjkl_buffer::View, H>,
841    ch: char,
842    forward: bool,
843    till: bool,
844    skip_adjacent: bool,
845) -> bool {
846    hjkl_engine::motions::find_char_on_line(ed.buffer_mut(), ch, forward, till, skip_adjacent)
847}
848pub fn matching_bracket<H: hjkl_engine::types::Host>(
849    ed: &mut Editor<hjkl_buffer::View, H>,
850) -> bool {
851    hjkl_engine::motions::match_bracket(ed.buffer_mut())
852}
853/// `[(` / `])` / `[{` / `]}` — move to the `count`-th previous (`forward =
854/// false`) / next (`forward = true`) unmatched bracket of the kind given by
855/// `open` (`(` or `{`). Balanced inner pairs are skipped via a depth counter.
856pub fn goto_unmatched_bracket<H: hjkl_engine::types::Host>(
857    ed: &mut Editor<hjkl_buffer::View, H>,
858    forward: bool,
859    open: char,
860    count: usize,
861) {
862    let close = match open {
863        '(' => ')',
864        '{' => '}',
865        _ => return,
866    };
867    let cursor = buf_cursor_pos(ed.buffer());
868    let rows = buf_row_count(ed.buffer());
869    let target = count.max(1);
870    let mut found = 0usize;
871    let mut depth = 0i32;
872
873    if forward {
874        let mut r = cursor.row;
875        let mut from_col = cursor.col + 1;
876        while r < rows {
877            let line: Vec<char> = buf_line(ed.buffer(), r)
878                .unwrap_or_default()
879                .chars()
880                .collect();
881            let mut ci = from_col;
882            while ci < line.len() {
883                let ch = line[ci];
884                if ch == open {
885                    depth += 1;
886                } else if ch == close {
887                    if depth == 0 {
888                        found += 1;
889                        if found == target {
890                            buf_set_cursor_rc(ed.buffer_mut(), r, ci);
891                            return;
892                        }
893                    } else {
894                        depth -= 1;
895                    }
896                }
897                ci += 1;
898            }
899            r += 1;
900            from_col = 0;
901        }
902    } else {
903        let mut r = cursor.row as isize;
904        // First row scans from the column left of the cursor; earlier rows from
905        // their last column (`isize::MAX` clamps to `len - 1`).
906        let mut from_col = cursor.col as isize - 1;
907        while r >= 0 {
908            let line: Vec<char> = buf_line(ed.buffer(), r as usize)
909                .unwrap_or_default()
910                .chars()
911                .collect();
912            let mut ci = from_col.min(line.len() as isize - 1);
913            while ci >= 0 {
914                let ch = line[ci as usize];
915                if ch == close {
916                    depth += 1;
917                } else if ch == open {
918                    if depth == 0 {
919                        found += 1;
920                        if found == target {
921                            buf_set_cursor_rc(ed.buffer_mut(), r as usize, ci as usize);
922                            return;
923                        }
924                    } else {
925                        depth -= 1;
926                    }
927                }
928                ci -= 1;
929            }
930            r -= 1;
931            from_col = isize::MAX;
932        }
933    }
934}
935pub fn word_at_cursor_search<H: hjkl_engine::types::Host>(
936    ed: &mut Editor<hjkl_buffer::View, H>,
937    forward: bool,
938    whole_word: bool,
939    count: usize,
940) {
941    let (row, col) = ed.cursor();
942    let line: String = buf_line(ed.buffer(), row).unwrap_or_default();
943    let chars: Vec<char> = line.chars().collect();
944    if chars.is_empty() {
945        return;
946    }
947    // Expand around cursor to a word boundary.
948    let spec = ed.settings().iskeyword.clone();
949    let is_word = |c: char| is_keyword_char(c, &spec);
950    let mut start = col.min(chars.len().saturating_sub(1));
951    if is_word(chars[start]) {
952        while start > 0 && is_word(chars[start - 1]) {
953            start -= 1;
954        }
955    } else {
956        // B19: the cursor isn't on a keyword char. `:h star` — both `*`
957        // and `#` advance FORWARD to the next keyword char on this line
958        // before extracting the word; only the SEARCH that follows differs
959        // by direction. Anchor the actual cursor at the found word's start
960        // so the subsequent search-advance call's skip-current logic
961        // treats it as the current match and steps to the true next (or,
962        // for `#`, previous) occurrence — matching nvim, which lands on
963        // the *second* occurrence from punctuation, not the nearest one.
964        while start < chars.len() && !is_word(chars[start]) {
965            start += 1;
966        }
967        if start >= chars.len() {
968            return;
969        }
970        buf_set_cursor_rc(ed.buffer_mut(), row, start);
971    }
972    let mut end = start;
973    while end < chars.len() && is_word(chars[end]) {
974        end += 1;
975    }
976    if end <= start {
977        return;
978    }
979    let word: String = chars[start..end].iter().collect();
980    let escaped = regex_escape(&word);
981    let pattern = if whole_word {
982        format!(r"\b{escaped}\b")
983    } else {
984        escaped
985    };
986    ed.push_search_pattern(&pattern);
987    if ed.search_state().pattern.is_none() {
988        return;
989    }
990    // Remember the query so `n` / `N` keep working after the jump.
991    ed.set_last_search_pattern_only(Some(pattern));
992    ed.set_last_search_forward_only(forward);
993    for _ in 0..count.max(1) {
994        if forward {
995            ed.search_advance_forward(true);
996        } else {
997            ed.search_advance_backward(true);
998        }
999    }
1000}
1001pub fn regex_escape(s: &str) -> String {
1002    let mut out = String::with_capacity(s.len());
1003    for c in s.chars() {
1004        if matches!(
1005            c,
1006            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
1007        ) {
1008            out.push('\\');
1009        }
1010        out.push(c);
1011    }
1012    out
1013}