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::Editor;
12use hjkl_engine::buf_helpers::{
13    buf_cursor_pos, buf_line, buf_line_chars, buf_row_count, buf_set_cursor_rc,
14};
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(crate) 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    let pre_col = pre_pos.1;
115    apply_motion_cursor(ed, &motion, count);
116    let post_pos = ed.cursor();
117    if is_big_jump(&motion) && pre_pos != post_pos {
118        ed.push_jump(pre_pos);
119    }
120    apply_sticky_col(ed, &motion, pre_col);
121    // Phase 7b: keep the migration buffer's cursor + viewport in
122    // lockstep with the textarea after every motion. Once 7c lands
123    // (motions ported onto the buffer's API), this flips: the
124    // buffer becomes authoritative and the textarea mirrors it.
125    ed.sync_buffer_from_textarea();
126}
127/// Wrapper around `execute_motion` that also syncs `block_vcol` when in
128/// VisualBlock mode. The engine FSM's `step()` already does this (line ~2001);
129/// the keymap path (`apply_motion_kind`) must do the same so VisualBlock h/l
130/// extend the highlighted region correctly.
131///
132/// `update_block_vcol` is only a no-op for vertical / non-horizontal motions
133/// (Up, Down, FileTop, FileBottom, Search), so passing every motion through is
134/// safe — the function's own match arm handles the no-op case.
135pub(crate) fn execute_motion_with_block_vcol<H: hjkl_engine::types::Host>(
136    ed: &mut Editor<hjkl_buffer::View, H>,
137    motion: Motion,
138    count: usize,
139) {
140    let motion_copy = motion.clone();
141    execute_motion(ed, motion, count);
142    if vim(ed).mode == Mode::VisualBlock {
143        update_block_vcol(ed, &motion_copy);
144    }
145}
146/// Execute a `hjkl_engine::MotionKind` cursor motion. Called by the host's
147/// `Editor::apply_motion` controller method — the keymap dispatch path for
148/// Phase 3a of kryptic-sh/hjkl#69.
149///
150/// Maps each variant to the same internal primitives used by the engine FSM
151/// so cursor, sticky column, scroll, and sync semantics are identical.
152///
153/// # Visual-mode post-motion sync audit (2026-05-13)
154///
155/// After `execute_motion`, two things are conditional on visual mode:
156///
157/// 1. **VisualBlock `block_vcol` sync** — `update_block_vcol(ed, &motion)` is
158///    called when `mode == Mode::VisualBlock`.  This is replicated here via
159///    `execute_motion_with_block_vcol` for every motion variant below.
160///
161/// 2. **`last_find` update** — `Motion::Find` is dispatched through
162///    `Pending::Find → apply_find_char` (in hjkl-vim), which writes `last_find`
163///    itself.  A post-motion `last_find` write here would be dead code.  The keymap
164///    path writes `last_find` in `apply_find_char` (called from
165///    `Editor::find_char`), so no gap exists here.
166///
167/// No VisualLine-specific or Visual-specific post-motion work exists in the
168/// FSM: anchors (`visual_anchor`, `visual_line_anchor`, `block_anchor`) are
169/// only written on mode-entry or `o`-swap, never on motion.  The `<`/`>`
170/// mark update in `step()` fires only on visual→normal transition, not after
171/// each motion.  There are **no further sync gaps** beyond the `block_vcol`
172/// fix already applied above.
173pub(crate) fn apply_motion_kind<H: hjkl_engine::types::Host>(
174    ed: &mut Editor<hjkl_buffer::View, H>,
175    kind: hjkl_engine::MotionKind,
176    count: usize,
177) {
178    let count = count.max(1);
179    match kind {
180        hjkl_engine::MotionKind::CharLeft => {
181            execute_motion_with_block_vcol(ed, Motion::Left, count);
182        }
183        hjkl_engine::MotionKind::CharRight => {
184            execute_motion_with_block_vcol(ed, Motion::Right, count);
185        }
186        hjkl_engine::MotionKind::LineDown => {
187            execute_motion_with_block_vcol(ed, Motion::Down, count);
188        }
189        hjkl_engine::MotionKind::LineUp => {
190            execute_motion_with_block_vcol(ed, Motion::Up, count);
191        }
192        hjkl_engine::MotionKind::FirstNonBlankDown => {
193            // `+`: move down `count` lines then land on first non-blank.
194            // Not a big-jump (no jump-list entry), sticky col set to the
195            // landed column (first non-blank). Mirrors scroll_cursor_rows
196            // semantics but goes through the fold-aware buffer motion path.
197            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
198            let mut sticky = ed.sticky_col();
199            hjkl_engine::motions::move_down(ed.buffer_mut(), &folds, count, &mut sticky);
200            ed.set_sticky_col(sticky);
201            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
202            ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
203            ed.sync_buffer_from_textarea();
204        }
205        hjkl_engine::MotionKind::FirstNonBlankUp => {
206            // `-`: move up `count` lines then land on first non-blank.
207            // Same pattern as FirstNonBlankDown, direction reversed.
208            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
209            let mut sticky = ed.sticky_col();
210            hjkl_engine::motions::move_up(ed.buffer_mut(), &folds, count, &mut sticky);
211            ed.set_sticky_col(sticky);
212            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
213            ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
214            ed.sync_buffer_from_textarea();
215        }
216        hjkl_engine::MotionKind::WordForward => {
217            execute_motion_with_block_vcol(ed, Motion::WordFwd, count);
218        }
219        hjkl_engine::MotionKind::BigWordForward => {
220            execute_motion_with_block_vcol(ed, Motion::BigWordFwd, count);
221        }
222        hjkl_engine::MotionKind::WordBackward => {
223            execute_motion_with_block_vcol(ed, Motion::WordBack, count);
224        }
225        hjkl_engine::MotionKind::BigWordBackward => {
226            execute_motion_with_block_vcol(ed, Motion::BigWordBack, count);
227        }
228        hjkl_engine::MotionKind::WordEnd => {
229            execute_motion_with_block_vcol(ed, Motion::WordEnd, count);
230        }
231        hjkl_engine::MotionKind::BigWordEnd => {
232            execute_motion_with_block_vcol(ed, Motion::BigWordEnd, count);
233        }
234        hjkl_engine::MotionKind::LineStart => {
235            // `0` / `<Home>`: first column of the current line.
236            // count is ignored — matches vim `0` semantics.
237            execute_motion_with_block_vcol(ed, Motion::LineStart, 1);
238        }
239        hjkl_engine::MotionKind::FirstNonBlank => {
240            // `^`: first non-blank column on the current line.
241            // count is ignored — matches vim `^` semantics.
242            execute_motion_with_block_vcol(ed, Motion::FirstNonBlank, 1);
243        }
244        hjkl_engine::MotionKind::GotoLine => {
245            // `G`: bare `G` → last line; `count G` → jump to line `count`.
246            // apply_motion_kind normalises the raw count to count.max(1)
247            // above, so count == 1 means "bare G" (last line) and count > 1
248            // means "go to line N". execute_motion's FileBottom arm applies
249            // the same `count > 1` check before calling move_bottom, so the
250            // convention aligns: pass count straight through.
251            // FileBottom is vertical — update_block_vcol is a no-op here
252            // (preserves vcol), so the helper is safe to use.
253            execute_motion_with_block_vcol(ed, Motion::FileBottom, count);
254        }
255        hjkl_engine::MotionKind::LineEnd => {
256            // `$` / `<End>`: last character on the current line.
257            // count is ignored at the keymap-path level (vim `N$` moves
258            // down N-1 lines then lands at line-end; not yet wired).
259            execute_motion_with_block_vcol(ed, Motion::LineEnd, 1);
260        }
261        hjkl_engine::MotionKind::FindRepeat => {
262            // `;` — repeat last f/F/t/T in the same direction.
263            // execute_motion resolves FindRepeat via vim(ed).last_find;
264            // no-op if no prior find exists (None arm returns early).
265            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: false }, count);
266        }
267        hjkl_engine::MotionKind::FindRepeatReverse => {
268            // `,` — repeat last f/F/t/T in the reverse direction.
269            // execute_motion resolves FindRepeat via vim(ed).last_find;
270            // no-op if no prior find exists (None arm returns early).
271            execute_motion_with_block_vcol(ed, Motion::FindRepeat { reverse: true }, count);
272        }
273        hjkl_engine::MotionKind::BracketMatch => {
274            // `%` — jump to the matching bracket.
275            // count is passed through; engine-side matching_bracket handles
276            // the no-match case as a no-op (cursor stays). Engine FSM arm
277            // for `%` in parse_motion is kept intact for macro-replay.
278            execute_motion_with_block_vcol(ed, Motion::MatchBracket, count);
279        }
280        hjkl_engine::MotionKind::ViewportTop => {
281            // `H` — cursor to top of visible viewport, then count-1 rows down.
282            // Engine FSM arm for `H` in parse_motion is kept intact for macro-replay.
283            execute_motion_with_block_vcol(ed, Motion::ViewportTop, count);
284        }
285        hjkl_engine::MotionKind::ViewportMiddle => {
286            // `M` — cursor to middle of visible viewport; count ignored.
287            // Engine FSM arm for `M` in parse_motion is kept intact for macro-replay.
288            execute_motion_with_block_vcol(ed, Motion::ViewportMiddle, count);
289        }
290        hjkl_engine::MotionKind::ViewportBottom => {
291            // `L` — cursor to bottom of visible viewport, then count-1 rows up.
292            // Engine FSM arm for `L` in parse_motion is kept intact for macro-replay.
293            execute_motion_with_block_vcol(ed, Motion::ViewportBottom, count);
294        }
295        hjkl_engine::MotionKind::HalfPageDown => {
296            // `<C-d>` — half page down, count multiplies the distance.
297            // Calls scroll_cursor_rows directly rather than adding a Motion enum
298            // variant, keeping engine Motion churn minimal.
299            {
300                let d = ed.viewport_half_rows(count) as isize;
301                ed.scroll_cursor_rows(d);
302            }
303        }
304        hjkl_engine::MotionKind::HalfPageUp => {
305            // `<C-u>` — half page up, count multiplies the distance.
306            // Direct call mirrors the FSM Ctrl-u arm. No new Motion variant.
307            {
308                let d = -(ed.viewport_half_rows(count) as isize);
309                ed.scroll_cursor_rows(d);
310            }
311        }
312        hjkl_engine::MotionKind::FullPageDown => {
313            // `<C-f>` — full page down (2-line overlap), count multiplies.
314            // Direct call mirrors the FSM Ctrl-f arm. No new Motion variant.
315            {
316                let d = ed.viewport_full_rows(count) as isize;
317                ed.scroll_cursor_rows(d);
318            }
319        }
320        hjkl_engine::MotionKind::FullPageUp => {
321            // `<C-b>` — full page up (2-line overlap), count multiplies.
322            // Direct call mirrors the FSM Ctrl-b arm. No new Motion variant.
323            {
324                let d = -(ed.viewport_full_rows(count) as isize);
325                ed.scroll_cursor_rows(d);
326            }
327        }
328        hjkl_engine::MotionKind::FirstNonBlankLine => {
329            execute_motion_with_block_vcol(ed, Motion::FirstNonBlankLine, count);
330        }
331        hjkl_engine::MotionKind::SectionBackward => {
332            execute_motion_with_block_vcol(ed, Motion::SectionBackward, count);
333        }
334        hjkl_engine::MotionKind::SectionForward => {
335            execute_motion_with_block_vcol(ed, Motion::SectionForward, count);
336        }
337        hjkl_engine::MotionKind::SectionEndBackward => {
338            execute_motion_with_block_vcol(ed, Motion::SectionEndBackward, count);
339        }
340        hjkl_engine::MotionKind::SectionEndForward => {
341            execute_motion_with_block_vcol(ed, Motion::SectionEndForward, count);
342        }
343        // `MotionKind` is `#[non_exhaustive]` and now lives in another crate, so
344        // the engine can add a motion this FSM has never heard of. Ignoring it
345        // is the honest response: a discipline cannot execute a motion it has
346        // no binding for. Every variant that exists today is handled above.
347        _ => {}
348    }
349}
350/// Restore the cursor to the sticky column after vertical motions and
351/// sync the sticky column to the current column after horizontal ones.
352/// `pre_col` is the cursor column captured *before* the motion — used
353/// to bootstrap the sticky value on the very first motion.
354pub(crate) fn apply_sticky_col<H: hjkl_engine::types::Host>(
355    ed: &mut Editor<hjkl_buffer::View, H>,
356    motion: &Motion,
357    pre_col: usize,
358) {
359    if is_vertical_motion(motion) {
360        let want = ed.sticky_col().unwrap_or(pre_col);
361        // Record the desired column so the next vertical motion sees
362        // it even if we currently clamped to a shorter row.
363        ed.set_sticky_col(Some(want));
364        let (row, _) = ed.cursor();
365        let line_len = buf_line_chars(ed.buffer(), row);
366        // Clamp to the last char on non-empty lines (vim normal-mode
367        // never parks the cursor one past end of line). Empty lines
368        // collapse to col 0.
369        let max_col = line_len.saturating_sub(1);
370        let target = want.min(max_col);
371        // raw primitive: this function MUST preserve the un-clamped `want`
372        // already stored in `ed.sticky_col()`; `jump_cursor` would overwrite
373        // it with the clamped `target`.
374        buf_set_cursor_rc(ed.buffer_mut(), row, target);
375    } else {
376        // Horizontal motion or non-motion: sticky column tracks the
377        // new cursor column so the *next* vertical motion aims there.
378        ed.set_sticky_col(Some(ed.cursor().1));
379    }
380}
381pub(crate) fn is_vertical_motion(motion: &Motion) -> bool {
382    // Only j / k preserve the sticky column. Everything else (search,
383    // gg / G, word jumps, etc.) lands at the match's own column so the
384    // sticky value should sync to the new cursor column.
385    matches!(
386        motion,
387        Motion::Up | Motion::Down | Motion::ScreenUp | Motion::ScreenDown
388    )
389}
390pub(crate) fn apply_motion_cursor<H: hjkl_engine::types::Host>(
391    ed: &mut Editor<hjkl_buffer::View, H>,
392    motion: &Motion,
393    count: usize,
394) {
395    apply_motion_cursor_ctx(ed, motion, count, false)
396}
397pub(crate) fn apply_motion_cursor_ctx<H: hjkl_engine::types::Host>(
398    ed: &mut Editor<hjkl_buffer::View, H>,
399    motion: &Motion,
400    count: usize,
401    as_operator: bool,
402) {
403    // Clamp the count where it fans out into the per-motion `0..count` walk.
404    // Two bounds:
405    //  - vim's documented ceiling (`:h count`) for folded counts; and
406    //  - the buffer's character count, since a motion can never make progress
407    //    past the end of the buffer — without this a pathological prefix
408    //    (`999999999w`, `<big>dw`) would spin the walk up to ~1e9 times,
409    //    freezing the UI, even though the result is identical to stopping at
410    //    the buffer edge.
411    let count = count
412        .min(MAX_COUNT)
413        .min(ed.buffer().rope().len_chars().saturating_add(1));
414    match motion {
415        Motion::Left => {
416            // `h` — View clamps at col 0 (no wrap), matching vim.
417            hjkl_engine::motions::move_left(ed.buffer_mut(), count);
418        }
419        Motion::Right => {
420            // `l` — operator-motion context (`dl`/`cl`/`yl`) is allowed
421            // one past the last char so the range includes it; cursor
422            // context clamps at the last char.
423            if as_operator {
424                hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), count);
425            } else {
426                hjkl_engine::motions::move_right_in_line(ed.buffer_mut(), count);
427            }
428        }
429        Motion::SpaceFwd => {
430            // `<Space>` — wraps to next line at EOL in cursor context; mid-line
431            // char delete like `l` under an operator (`d<Space>`).
432            if as_operator {
433                hjkl_engine::motions::move_right_to_end(ed.buffer_mut(), count);
434            } else {
435                hjkl_engine::motions::move_space_fwd(ed.buffer_mut(), count);
436            }
437        }
438        Motion::BackspaceBack => {
439            // `<BS>` — wraps to prev line's last char at BOL in cursor context;
440            // mid-line char move like `h` under an operator (`d<BS>`).
441            if as_operator {
442                hjkl_engine::motions::move_left(ed.buffer_mut(), count);
443            } else {
444                hjkl_engine::motions::move_backspace_back(ed.buffer_mut(), count);
445            }
446        }
447        Motion::Up => {
448            // Final col is set by `apply_sticky_col` below — push the
449            // post-move row to the textarea and let sticky tracking
450            // finish the work.
451            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
452            let mut sticky = ed.sticky_col();
453            hjkl_engine::motions::move_up(ed.buffer_mut(), &folds, count, &mut sticky);
454            ed.set_sticky_col(sticky);
455        }
456        Motion::Down => {
457            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
458            let mut sticky = ed.sticky_col();
459            hjkl_engine::motions::move_down(ed.buffer_mut(), &folds, count, &mut sticky);
460            ed.set_sticky_col(sticky);
461        }
462        Motion::ScreenUp => {
463            let v = *ed.host().viewport();
464            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
465            let mut sticky = ed.sticky_col();
466            hjkl_engine::motions::move_screen_up(ed.buffer_mut(), &folds, &v, count, &mut sticky);
467            ed.set_sticky_col(sticky);
468        }
469        Motion::ScreenDown => {
470            let v = *ed.host().viewport();
471            let folds = hjkl_engine::SnapshotFoldProvider::from_buffer(ed.buffer());
472            let mut sticky = ed.sticky_col();
473            hjkl_engine::motions::move_screen_down(ed.buffer_mut(), &folds, &v, count, &mut sticky);
474            ed.set_sticky_col(sticky);
475        }
476        Motion::WordFwd => {
477            let iskeyword = ed.settings().iskeyword.clone();
478            hjkl_engine::motions::move_word_fwd(ed.buffer_mut(), false, count, &iskeyword);
479        }
480        Motion::WordBack => {
481            let iskeyword = ed.settings().iskeyword.clone();
482            hjkl_engine::motions::move_word_back(ed.buffer_mut(), false, count, &iskeyword);
483        }
484        Motion::WordEnd => {
485            let iskeyword = ed.settings().iskeyword.clone();
486            hjkl_engine::motions::move_word_end(ed.buffer_mut(), false, count, &iskeyword);
487        }
488        Motion::BigWordFwd => {
489            let iskeyword = ed.settings().iskeyword.clone();
490            hjkl_engine::motions::move_word_fwd(ed.buffer_mut(), true, count, &iskeyword);
491        }
492        Motion::BigWordBack => {
493            let iskeyword = ed.settings().iskeyword.clone();
494            hjkl_engine::motions::move_word_back(ed.buffer_mut(), true, count, &iskeyword);
495        }
496        Motion::BigWordEnd => {
497            let iskeyword = ed.settings().iskeyword.clone();
498            hjkl_engine::motions::move_word_end(ed.buffer_mut(), true, count, &iskeyword);
499        }
500        Motion::WordEndBack => {
501            let iskeyword = ed.settings().iskeyword.clone();
502            hjkl_engine::motions::move_word_end_back(ed.buffer_mut(), false, count, &iskeyword);
503        }
504        Motion::BigWordEndBack => {
505            let iskeyword = ed.settings().iskeyword.clone();
506            hjkl_engine::motions::move_word_end_back(ed.buffer_mut(), true, count, &iskeyword);
507        }
508        Motion::LineStart => {
509            hjkl_engine::motions::move_line_start(ed.buffer_mut());
510        }
511        Motion::FirstNonBlank => {
512            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
513        }
514        Motion::LineEnd => {
515            // Vim normal-mode `$` lands on the last char, not one past it.
516            hjkl_engine::motions::move_line_end(ed.buffer_mut());
517        }
518        Motion::FileTop => {
519            // `count gg` jumps to line `count` (first non-blank);
520            // bare `gg` lands at the top.
521            if count > 1 {
522                hjkl_engine::motions::move_bottom(ed.buffer_mut(), count);
523            } else {
524                hjkl_engine::motions::move_top(ed.buffer_mut());
525            }
526        }
527        Motion::FileBottom => {
528            // `count G` jumps to line `count`; bare `G` lands at
529            // the buffer bottom (`View::move_bottom(0)`).
530            if count > 1 {
531                hjkl_engine::motions::move_bottom(ed.buffer_mut(), count);
532            } else {
533                hjkl_engine::motions::move_bottom(ed.buffer_mut(), 0);
534            }
535        }
536        Motion::Find { ch, forward, till } => {
537            // Skip an adjacent target when this is a `;`/`,` repeat, and on the
538            // 2nd..Nth step of a counted `t`/`T` (the cursor lands one cell
539            // short each time, so a naive repeat would stick).
540            let repeat = std::mem::take(&mut vim_mut(ed).find_repeat_skip);
541            for i in 0..count {
542                let skip_adjacent = repeat || i > 0;
543                if !find_char_on_line(ed, *ch, *forward, *till, skip_adjacent) {
544                    break;
545                }
546            }
547        }
548        Motion::FindRepeat { .. } => {} // already resolved upstream
549        Motion::MatchBracket => {
550            let _ = matching_bracket(ed);
551        }
552        Motion::UnmatchedBracket { forward, open } => {
553            goto_unmatched_bracket(ed, *forward, *open, count);
554        }
555        Motion::WordAtCursor {
556            forward,
557            whole_word,
558        } => {
559            word_at_cursor_search(ed, *forward, *whole_word, count);
560        }
561        Motion::SearchNext { reverse } => {
562            // Re-push the last query so the buffer's search state is
563            // correct even if the host happened to clear it (e.g. while
564            // a Visual mode draw was in progress).
565            if let Some(pattern) = ed.last_search_pattern() {
566                ed.push_search_pattern(&pattern);
567            }
568            if ed.search_state().pattern.is_none() {
569                return;
570            }
571            // `n` repeats the last search in its committed direction;
572            // `N` inverts. So a `?` search makes `n` walk backward and
573            // `N` walk forward.
574            let forward = ed.last_search_forward() != *reverse;
575            for _ in 0..count.max(1) {
576                if forward {
577                    ed.search_advance_forward(true);
578                } else {
579                    ed.search_advance_backward(true);
580                }
581            }
582        }
583        Motion::ViewportTop => {
584            let v = *ed.host().viewport();
585            hjkl_engine::motions::move_viewport_top(ed.buffer_mut(), &v, count.saturating_sub(1));
586        }
587        Motion::ViewportMiddle => {
588            let v = *ed.host().viewport();
589            hjkl_engine::motions::move_viewport_middle(ed.buffer_mut(), &v);
590        }
591        Motion::ViewportBottom => {
592            let v = *ed.host().viewport();
593            hjkl_engine::motions::move_viewport_bottom(
594                ed.buffer_mut(),
595                &v,
596                count.saturating_sub(1),
597            );
598        }
599        Motion::LastNonBlank => {
600            hjkl_engine::motions::move_last_non_blank(ed.buffer_mut());
601        }
602        Motion::LineMiddle => {
603            let row = ed.cursor().0;
604            let line_chars = buf_line_chars(ed.buffer(), row);
605            // Vim's `gM`: column = floor(chars / 2). Empty / single-char
606            // lines stay at col 0.
607            let target = line_chars / 2;
608            ed.jump_cursor(row, target);
609        }
610        Motion::ScreenLineMiddle => {
611            // Vim's `gm`: middle of the *screen* line = column
612            // `viewport_width / 2`, clamped to the last char of the line.
613            let row = ed.cursor().0;
614            let width = ed.host().viewport().width as usize;
615            let last = buf_line_chars(ed.buffer(), row).saturating_sub(1);
616            let target = (width / 2).min(last);
617            ed.jump_cursor(row, target);
618        }
619        Motion::ParagraphPrev => {
620            hjkl_engine::motions::move_paragraph_prev(ed.buffer_mut(), count);
621        }
622        Motion::ParagraphNext => {
623            hjkl_engine::motions::move_paragraph_next(ed.buffer_mut(), count);
624        }
625        Motion::SentencePrev => {
626            for _ in 0..count.max(1) {
627                if let Some((row, col)) = sentence_boundary(ed, false) {
628                    ed.jump_cursor(row, col);
629                }
630            }
631        }
632        Motion::SentenceNext => {
633            for _ in 0..count.max(1) {
634                if let Some((row, col)) = sentence_boundary(ed, true) {
635                    ed.jump_cursor(row, col);
636                }
637            }
638        }
639        Motion::SectionBackward => {
640            hjkl_engine::motions::move_section_backward(ed.buffer_mut(), count);
641        }
642        Motion::SectionForward => {
643            hjkl_engine::motions::move_section_forward(ed.buffer_mut(), count);
644        }
645        Motion::SectionEndBackward => {
646            hjkl_engine::motions::move_section_end_backward(ed.buffer_mut(), count);
647        }
648        Motion::SectionEndForward => {
649            hjkl_engine::motions::move_section_end_forward(ed.buffer_mut(), count);
650        }
651        Motion::FirstNonBlankNextLine => {
652            hjkl_engine::motions::move_first_non_blank_next_line(ed.buffer_mut(), count);
653        }
654        Motion::FirstNonBlankPrevLine => {
655            hjkl_engine::motions::move_first_non_blank_prev_line(ed.buffer_mut(), count);
656        }
657        Motion::FirstNonBlankLine => {
658            hjkl_engine::motions::move_first_non_blank_line(ed.buffer_mut(), count);
659        }
660        Motion::GotoColumn => {
661            hjkl_engine::motions::move_goto_column(ed.buffer_mut(), count);
662        }
663    }
664}
665pub(crate) fn move_first_non_whitespace<H: hjkl_engine::types::Host>(
666    ed: &mut Editor<hjkl_buffer::View, H>,
667) {
668    // Some call sites invoke this right after `dd` / `<<` / `>>` etc
669    // mutates the textarea content, so the migration buffer hasn't
670    // seen the new lines OR new cursor yet. Mirror the full content
671    // across before delegating, then push the result back so the
672    // textarea reflects the resolved column too.
673    ed.sync_buffer_content_from_textarea();
674    hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
675}
676pub(crate) fn find_char_on_line<H: hjkl_engine::types::Host>(
677    ed: &mut Editor<hjkl_buffer::View, H>,
678    ch: char,
679    forward: bool,
680    till: bool,
681    skip_adjacent: bool,
682) -> bool {
683    hjkl_engine::motions::find_char_on_line(ed.buffer_mut(), ch, forward, till, skip_adjacent)
684}
685pub(crate) fn matching_bracket<H: hjkl_engine::types::Host>(
686    ed: &mut Editor<hjkl_buffer::View, H>,
687) -> bool {
688    hjkl_engine::motions::match_bracket(ed.buffer_mut())
689}
690/// `[(` / `])` / `[{` / `]}` — move to the `count`-th previous (`forward =
691/// false`) / next (`forward = true`) unmatched bracket of the kind given by
692/// `open` (`(` or `{`). Balanced inner pairs are skipped via a depth counter.
693pub(crate) fn goto_unmatched_bracket<H: hjkl_engine::types::Host>(
694    ed: &mut Editor<hjkl_buffer::View, H>,
695    forward: bool,
696    open: char,
697    count: usize,
698) {
699    let close = match open {
700        '(' => ')',
701        '{' => '}',
702        _ => return,
703    };
704    let cursor = buf_cursor_pos(ed.buffer());
705    let rows = buf_row_count(ed.buffer());
706    let target = count.max(1);
707    let mut found = 0usize;
708    let mut depth = 0i32;
709
710    if forward {
711        let mut r = cursor.row;
712        let mut from_col = cursor.col + 1;
713        while r < rows {
714            let line: Vec<char> = buf_line(ed.buffer(), r)
715                .unwrap_or_default()
716                .chars()
717                .collect();
718            let mut ci = from_col;
719            while ci < line.len() {
720                let ch = line[ci];
721                if ch == open {
722                    depth += 1;
723                } else if ch == close {
724                    if depth == 0 {
725                        found += 1;
726                        if found == target {
727                            buf_set_cursor_rc(ed.buffer_mut(), r, ci);
728                            return;
729                        }
730                    } else {
731                        depth -= 1;
732                    }
733                }
734                ci += 1;
735            }
736            r += 1;
737            from_col = 0;
738        }
739    } else {
740        let mut r = cursor.row as isize;
741        // First row scans from the column left of the cursor; earlier rows from
742        // their last column (`isize::MAX` clamps to `len - 1`).
743        let mut from_col = cursor.col as isize - 1;
744        while r >= 0 {
745            let line: Vec<char> = buf_line(ed.buffer(), r as usize)
746                .unwrap_or_default()
747                .chars()
748                .collect();
749            let mut ci = from_col.min(line.len() as isize - 1);
750            while ci >= 0 {
751                let ch = line[ci as usize];
752                if ch == close {
753                    depth += 1;
754                } else if ch == open {
755                    if depth == 0 {
756                        found += 1;
757                        if found == target {
758                            buf_set_cursor_rc(ed.buffer_mut(), r as usize, ci as usize);
759                            return;
760                        }
761                    } else {
762                        depth -= 1;
763                    }
764                }
765                ci -= 1;
766            }
767            r -= 1;
768            from_col = isize::MAX;
769        }
770    }
771}
772pub(crate) fn word_at_cursor_search<H: hjkl_engine::types::Host>(
773    ed: &mut Editor<hjkl_buffer::View, H>,
774    forward: bool,
775    whole_word: bool,
776    count: usize,
777) {
778    let (row, col) = ed.cursor();
779    let line: String = buf_line(ed.buffer(), row).unwrap_or_default();
780    let chars: Vec<char> = line.chars().collect();
781    if chars.is_empty() {
782        return;
783    }
784    // Expand around cursor to a word boundary.
785    let spec = ed.settings().iskeyword.clone();
786    let is_word = |c: char| is_keyword_char(c, &spec);
787    let mut start = col.min(chars.len().saturating_sub(1));
788    if is_word(chars[start]) {
789        while start > 0 && is_word(chars[start - 1]) {
790            start -= 1;
791        }
792    } else {
793        // B19: the cursor isn't on a keyword char. `:h star` — both `*`
794        // and `#` advance FORWARD to the next keyword char on this line
795        // before extracting the word; only the SEARCH that follows differs
796        // by direction. Anchor the actual cursor at the found word's start
797        // so the subsequent search-advance call's skip-current logic
798        // treats it as the current match and steps to the true next (or,
799        // for `#`, previous) occurrence — matching nvim, which lands on
800        // the *second* occurrence from punctuation, not the nearest one.
801        while start < chars.len() && !is_word(chars[start]) {
802            start += 1;
803        }
804        if start >= chars.len() {
805            return;
806        }
807        buf_set_cursor_rc(ed.buffer_mut(), row, start);
808    }
809    let mut end = start;
810    while end < chars.len() && is_word(chars[end]) {
811        end += 1;
812    }
813    if end <= start {
814        return;
815    }
816    let word: String = chars[start..end].iter().collect();
817    let escaped = regex_escape(&word);
818    let pattern = if whole_word {
819        format!(r"\b{escaped}\b")
820    } else {
821        escaped
822    };
823    ed.push_search_pattern(&pattern);
824    if ed.search_state().pattern.is_none() {
825        return;
826    }
827    // Remember the query so `n` / `N` keep working after the jump.
828    ed.set_last_search_pattern_only(Some(pattern));
829    ed.set_last_search_forward_only(forward);
830    for _ in 0..count.max(1) {
831        if forward {
832            ed.search_advance_forward(true);
833        } else {
834            ed.search_advance_backward(true);
835        }
836    }
837}
838pub(crate) fn regex_escape(s: &str) -> String {
839    let mut out = String::with_capacity(s.len());
840    for c in s.chars() {
841        if matches!(
842            c,
843            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
844        ) {
845            out.push('\\');
846        }
847        out.push(c);
848    }
849    out
850}