Skip to main content

hjkl_vim/vim/
motion.rs

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