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