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