Skip to main content

hjkl_buffer/
motion.rs

1//! Vim-shaped cursor motions on top of [`crate::Buffer`].
2//!
3//! All motions clamp to the buffer's content; none of them wrap to
4//! the previous / next line. `move_right_in_line` stops at the last
5//! character; the operator-context variant `move_right_to_end`
6//! allows one position past it so `dl` deletes the final char.
7//! Vertical motions (`move_up` / `move_down`) honour `sticky_col`
8//! so bouncing through a shorter row doesn't drag the cursor back
9//! to col 0.
10
11use crate::{Buffer, Position};
12
13/// Returns the char count of `line` — the column you'd see when the
14/// cursor is parked one past the end.
15fn line_chars(line: &str) -> usize {
16    line.chars().count()
17}
18
19/// Last valid column for normal-mode motions (`hjkl`, etc.).
20/// Empty rows clamp at 0; otherwise it's `chars - 1`.
21fn last_col(line: &str) -> usize {
22    line_chars(line).saturating_sub(1)
23}
24
25/// Pick a target column inside the screen segment `[start, end)` for
26/// a `gj` / `gk` step that wants `visual_col` cells from the segment
27/// start. Clamps to the segment's last position and to the line's
28/// last char so the cursor never lands past the line end.
29fn clamp_to_segment(start: usize, end: usize, visual_col: usize, line: &str) -> usize {
30    let line_max = last_col(line);
31    let seg_max = if end > start { end - 1 } else { start };
32    let want = start.saturating_add(visual_col);
33    want.min(seg_max).min(line_max).max(start.min(line_max))
34}
35
36impl Buffer {
37    // ── Horizontal motions ──────────────────────────────────────
38
39    /// `h` — clamps at column 0; never wraps to the previous line.
40    pub fn move_left(&mut self, count: usize) {
41        let cursor = self.cursor();
42        let new_col = cursor.col.saturating_sub(count.max(1));
43        self.set_cursor(Position::new(cursor.row, new_col));
44        self.refresh_sticky_col_from_cursor();
45    }
46
47    /// `l` — clamps at the last char on the line. Operator
48    /// callers wanting "one past end" use [`Buffer::move_right_to_end`].
49    pub fn move_right_in_line(&mut self, count: usize) {
50        let cursor = self.cursor();
51        let line = self.line(cursor.row).unwrap_or("");
52        let limit = last_col(line);
53        let new_col = (cursor.col + count.max(1)).min(limit);
54        self.set_cursor(Position::new(cursor.row, new_col));
55        self.refresh_sticky_col_from_cursor();
56    }
57
58    /// Operator-context `l`: allowed past the last char so a range
59    /// motion includes it. Clamps at `chars()` (one past end).
60    pub fn move_right_to_end(&mut self, count: usize) {
61        let cursor = self.cursor();
62        let line = self.line(cursor.row).unwrap_or("");
63        let limit = line_chars(line);
64        let new_col = (cursor.col + count.max(1)).min(limit);
65        self.set_cursor(Position::new(cursor.row, new_col));
66        self.refresh_sticky_col_from_cursor();
67    }
68
69    /// `0` — first column of the current row.
70    pub fn move_line_start(&mut self) {
71        let row = self.cursor().row;
72        self.set_cursor(Position::new(row, 0));
73        self.refresh_sticky_col_from_cursor();
74    }
75
76    /// `^` — first non-blank column. On a blank line it lands on 0.
77    pub fn move_first_non_blank(&mut self) {
78        let row = self.cursor().row;
79        let col = self
80            .line(row)
81            .unwrap_or("")
82            .chars()
83            .position(|c| !c.is_whitespace())
84            .unwrap_or(0);
85        self.set_cursor(Position::new(row, col));
86        self.refresh_sticky_col_from_cursor();
87    }
88
89    /// `$` — last char on the row. Empty rows stay at column 0.
90    pub fn move_line_end(&mut self) {
91        let row = self.cursor().row;
92        let col = last_col(self.line(row).unwrap_or(""));
93        self.set_cursor(Position::new(row, col));
94        self.refresh_sticky_col_from_cursor();
95    }
96
97    /// `g_` — last non-blank char on the row. Empty / all-blank rows
98    /// stay at column 0.
99    pub fn move_last_non_blank(&mut self) {
100        let row = self.cursor().row;
101        let line = self.line(row).unwrap_or("");
102        let col = line
103            .char_indices()
104            .rev()
105            .find(|(_, c)| !c.is_whitespace())
106            .map(|(byte, _)| line[..byte].chars().count())
107            .unwrap_or(0);
108        self.set_cursor(Position::new(row, col));
109        self.refresh_sticky_col_from_cursor();
110    }
111
112    /// `{` — previous blank line above the cursor, or row 0.
113    pub fn move_paragraph_prev(&mut self, count: usize) {
114        let mut row = self.cursor().row;
115        for _ in 0..count.max(1) {
116            if row == 0 {
117                break;
118            }
119            // Step over any contiguous blank rows the cursor sits on
120            // so a single press doesn't stick.
121            let mut r = row.saturating_sub(1);
122            while r > 0 && self.line(r).is_some_and(|l| l.is_empty()) {
123                r -= 1;
124            }
125            while r > 0 && self.line(r).is_some_and(|l| !l.is_empty()) {
126                r -= 1;
127            }
128            row = r;
129        }
130        self.set_cursor(Position::new(row, 0));
131        self.refresh_sticky_col_from_cursor();
132    }
133
134    /// `}` — next blank line below the cursor, or last row.
135    pub fn move_paragraph_next(&mut self, count: usize) {
136        let last = self.row_count().saturating_sub(1);
137        let mut row = self.cursor().row;
138        for _ in 0..count.max(1) {
139            if row >= last {
140                break;
141            }
142            let mut r = row.saturating_add(1);
143            while r < last && self.line(r).is_some_and(|l| l.is_empty()) {
144                r += 1;
145            }
146            while r < last && self.line(r).is_some_and(|l| !l.is_empty()) {
147                r += 1;
148            }
149            row = r;
150        }
151        self.set_cursor(Position::new(row, 0));
152        self.refresh_sticky_col_from_cursor();
153    }
154
155    // ── Vertical motions ────────────────────────────────────────
156
157    /// `k` — `count` rows up; sticky col preserved across short rows.
158    pub fn move_up(&mut self, count: usize) {
159        self.move_vertical(-(count.max(1) as isize));
160    }
161
162    /// `j` — `count` rows down; sticky col preserved across short rows.
163    pub fn move_down(&mut self, count: usize) {
164        self.move_vertical(count.max(1) as isize);
165    }
166
167    /// `gk` — `count` visual rows up. With `Wrap::None` (or before
168    /// the host has published `text_width`), falls back to `move_up`
169    /// so existing tests + non-wrap callers behave unchanged. Under
170    /// wrap, walks one screen segment at a time, crossing into the
171    /// previous doc row only after exhausting the current row's
172    /// segments.
173    pub fn move_screen_up(&mut self, count: usize) {
174        self.move_screen_vertical(-(count.max(1) as isize));
175    }
176
177    /// `gj` — `count` visual rows down. See [`Buffer::move_screen_up`].
178    pub fn move_screen_down(&mut self, count: usize) {
179        self.move_screen_vertical(count.max(1) as isize);
180    }
181
182    /// `gg` — first row, first non-blank.
183    pub fn move_top(&mut self) {
184        self.set_cursor(Position::new(0, 0));
185        self.move_first_non_blank();
186    }
187
188    /// `G` — last row (or `count - 1` when `count > 0`), first non-blank.
189    /// `count = 0` (the unprefixed form) jumps to the buffer's bottom.
190    pub fn move_bottom(&mut self, count: usize) {
191        let last = self.row_count().saturating_sub(1);
192        let target = if count == 0 {
193            last
194        } else {
195            (count - 1).min(last)
196        };
197        self.set_cursor(Position::new(target, 0));
198        self.move_first_non_blank();
199    }
200
201    // ── Word motions ────────────────────────────────────────────
202
203    /// `w` / `W` — start of next word. `big = true` treats every
204    /// non-whitespace run as one word (vim's WORD).
205    pub fn move_word_fwd(&mut self, big: bool, count: usize) {
206        for _ in 0..count.max(1) {
207            let from = self.cursor();
208            if let Some(next) = next_word_start(self, from, big) {
209                self.set_cursor(next);
210            } else {
211                break;
212            }
213        }
214        self.refresh_sticky_col_from_cursor();
215    }
216
217    /// `b` / `B` — start of previous word.
218    pub fn move_word_back(&mut self, big: bool, count: usize) {
219        for _ in 0..count.max(1) {
220            let from = self.cursor();
221            if let Some(prev) = prev_word_start(self, from, big) {
222                self.set_cursor(prev);
223            } else {
224                break;
225            }
226        }
227        self.refresh_sticky_col_from_cursor();
228    }
229
230    /// `%` — jump to the matching bracket. Walks the buffer
231    /// counting nesting depth so nested pairs resolve correctly.
232    /// Returns `true` when the cursor moved.
233    pub fn match_bracket(&mut self) -> bool {
234        let cursor = self.cursor();
235        let line = match self.line(cursor.row) {
236            Some(l) => l,
237            None => return false,
238        };
239        let ch = match line.chars().nth(cursor.col) {
240            Some(c) => c,
241            None => return false,
242        };
243        let (open, close, forward) = match ch {
244            '(' => ('(', ')', true),
245            ')' => ('(', ')', false),
246            '[' => ('[', ']', true),
247            ']' => ('[', ']', false),
248            '{' => ('{', '}', true),
249            '}' => ('{', '}', false),
250            '<' => ('<', '>', true),
251            '>' => ('<', '>', false),
252            _ => return false,
253        };
254        let mut depth: i32 = 0;
255        if forward {
256            let mut r = cursor.row;
257            let mut c = cursor.col;
258            loop {
259                let chars: Vec<char> = self.line(r).unwrap_or("").chars().collect();
260                while c < chars.len() {
261                    let here = chars[c];
262                    if here == open {
263                        depth += 1;
264                    } else if here == close {
265                        depth -= 1;
266                        if depth == 0 {
267                            self.set_cursor(Position::new(r, c));
268                            self.refresh_sticky_col_from_cursor();
269                            return true;
270                        }
271                    }
272                    c += 1;
273                }
274                if r + 1 >= self.row_count() {
275                    return false;
276                }
277                r += 1;
278                c = 0;
279            }
280        } else {
281            let mut r = cursor.row;
282            let mut c = cursor.col as isize;
283            loop {
284                let chars: Vec<char> = self.line(r).unwrap_or("").chars().collect();
285                while c >= 0 {
286                    let here = chars[c as usize];
287                    if here == close {
288                        depth += 1;
289                    } else if here == open {
290                        depth -= 1;
291                        if depth == 0 {
292                            self.set_cursor(Position::new(r, c as usize));
293                            self.refresh_sticky_col_from_cursor();
294                            return true;
295                        }
296                    }
297                    c -= 1;
298                }
299                if r == 0 {
300                    return false;
301                }
302                r -= 1;
303                c = self.line(r).unwrap_or("").chars().count() as isize - 1;
304            }
305        }
306    }
307
308    /// `f` / `F` / `t` / `T` — find `ch` on the current row.
309    /// `forward = true` searches right of the cursor; `till = true`
310    /// stops one cell short of the match (the `t`/`T` semantic).
311    /// Returns `true` when the cursor moved.
312    pub fn find_char_on_line(&mut self, ch: char, forward: bool, till: bool) -> bool {
313        let cursor = self.cursor();
314        let line = match self.line(cursor.row) {
315            Some(l) => l,
316            None => return false,
317        };
318        let chars: Vec<char> = line.chars().collect();
319        if chars.is_empty() {
320            return false;
321        }
322        let target_col = if forward {
323            chars
324                .iter()
325                .enumerate()
326                .skip(cursor.col + 1)
327                .find(|(_, c)| **c == ch)
328                .map(|(i, _)| if till { i.saturating_sub(1) } else { i })
329        } else {
330            (0..cursor.col)
331                .rev()
332                .find(|&i| chars[i] == ch)
333                .map(|i| if till { i + 1 } else { i })
334        };
335        match target_col {
336            Some(col) => {
337                self.set_cursor(Position::new(cursor.row, col));
338                self.refresh_sticky_col_from_cursor();
339                true
340            }
341            None => false,
342        }
343    }
344
345    /// `e` / `E` — end of current/next word.
346    pub fn move_word_end(&mut self, big: bool, count: usize) {
347        for _ in 0..count.max(1) {
348            let from = self.cursor();
349            if let Some(end) = next_word_end(self, from, big) {
350                self.set_cursor(end);
351            } else {
352                break;
353            }
354        }
355        self.refresh_sticky_col_from_cursor();
356    }
357
358    /// `H` — top of the visible viewport plus `offset` rows
359    /// (0-based; vim uses 1-based count where bare `H` = 0). Lands
360    /// on the first non-blank of the resolved row.
361    pub fn move_viewport_top(&mut self, offset: usize) {
362        let v = self.viewport();
363        let last = self.row_count().saturating_sub(1);
364        let target = v.top_row.saturating_add(offset).min(last);
365        self.set_cursor(Position::new(target, 0));
366        self.move_first_non_blank();
367    }
368
369    /// `M` — middle row of the visible viewport.
370    pub fn move_viewport_middle(&mut self) {
371        let v = self.viewport();
372        let last = self.row_count().saturating_sub(1);
373        let height = v.height as usize;
374        let visible_bot = v.top_row.saturating_add(height.saturating_sub(1)).min(last);
375        let mid = v.top_row + (visible_bot - v.top_row) / 2;
376        self.set_cursor(Position::new(mid, 0));
377        self.move_first_non_blank();
378    }
379
380    /// `L` — bottom of the visible viewport, minus `offset` rows.
381    pub fn move_viewport_bottom(&mut self, offset: usize) {
382        let v = self.viewport();
383        let last = self.row_count().saturating_sub(1);
384        let height = v.height as usize;
385        let visible_bot = v.top_row.saturating_add(height.saturating_sub(1)).min(last);
386        let target = visible_bot.saturating_sub(offset).max(v.top_row);
387        self.set_cursor(Position::new(target, 0));
388        self.move_first_non_blank();
389    }
390
391    /// `ge` / `gE` — end of previous word. Walks backward until
392    /// the cursor sits on the last char of a word (the next char
393    /// is a different kind, or end-of-line).
394    pub fn move_word_end_back(&mut self, big: bool, count: usize) {
395        for _ in 0..count.max(1) {
396            let from = self.cursor();
397            match prev_word_end(self, from, big) {
398                Some(p) => self.set_cursor(p),
399                None => break,
400            }
401        }
402        self.refresh_sticky_col_from_cursor();
403    }
404
405    // ── Internals ──────────────────────────────────────────────
406
407    fn move_screen_vertical(&mut self, delta: isize) {
408        let v = self.viewport();
409        if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
410            self.move_vertical(delta);
411            return;
412        }
413        // Snapshot the visual col (offset within the current segment)
414        // up front so a chain of `gj` / `gk` presses lands at the
415        // same visual column even when crossing short visual lines.
416        let cursor = self.cursor();
417        let line = self.line(cursor.row).unwrap_or("");
418        let segs = crate::wrap::wrap_segments(line, v.text_width, v.wrap);
419        let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
420        let visual_col = cursor.col.saturating_sub(segs[seg_idx].0);
421        let down = delta > 0;
422        for _ in 0..delta.unsigned_abs() {
423            if !self.step_screen(down, visual_col) {
424                break;
425            }
426        }
427        self.set_sticky_col(Some(self.cursor().col));
428    }
429
430    /// One visual-row step under wrap. Returns false when stepping
431    /// would leave the buffer (top of buffer for `down=false`,
432    /// bottom for `down=true`).
433    fn step_screen(&mut self, down: bool, visual_col: usize) -> bool {
434        let v = self.viewport();
435        let cursor = self.cursor();
436        let line = self.line(cursor.row).unwrap_or("");
437        let segs = crate::wrap::wrap_segments(line, v.text_width, v.wrap);
438        let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
439        if down {
440            if seg_idx + 1 < segs.len() {
441                let (s, e) = segs[seg_idx + 1];
442                let target = clamp_to_segment(s, e, visual_col, line);
443                self.set_cursor(Position::new(cursor.row, target));
444                return true;
445            }
446            let Some(next_row) = self.next_visible_row(cursor.row) else {
447                return false;
448            };
449            let next_line = self.line(next_row).unwrap_or("");
450            let next_segs = crate::wrap::wrap_segments(next_line, v.text_width, v.wrap);
451            let (s, e) = next_segs[0];
452            let target = clamp_to_segment(s, e, visual_col, next_line);
453            self.set_cursor(Position::new(next_row, target));
454            true
455        } else {
456            if seg_idx > 0 {
457                let (s, e) = segs[seg_idx - 1];
458                let target = clamp_to_segment(s, e, visual_col, line);
459                self.set_cursor(Position::new(cursor.row, target));
460                return true;
461            }
462            let Some(prev_row) = self.prev_visible_row(cursor.row) else {
463                return false;
464            };
465            let prev_line = self.line(prev_row).unwrap_or("");
466            let prev_segs = crate::wrap::wrap_segments(prev_line, v.text_width, v.wrap);
467            let (s, e) = *prev_segs.last().unwrap_or(&(0, 0));
468            let target = clamp_to_segment(s, e, visual_col, prev_line);
469            self.set_cursor(Position::new(prev_row, target));
470            true
471        }
472    }
473
474    fn move_vertical(&mut self, delta: isize) {
475        let cursor = self.cursor();
476        let want = self.sticky_col().unwrap_or(cursor.col);
477        // Sticky col only bootstraps from the cursor on the first
478        // vertical move; subsequent moves read it back so a short
479        // row clamping us to col 3 doesn't lose the desired col 12.
480        self.set_sticky_col(Some(want));
481        // Walk one visible row at a time so closed folds count as one
482        // visual line. Stops at top/bottom of buffer.
483        let mut target_row = cursor.row;
484        if delta < 0 {
485            for _ in 0..(-delta) as usize {
486                match self.prev_visible_row(target_row) {
487                    Some(r) => target_row = r,
488                    None => break,
489                }
490            }
491        } else {
492            for _ in 0..delta as usize {
493                match self.next_visible_row(target_row) {
494                    Some(r) => target_row = r,
495                    None => break,
496                }
497            }
498        }
499        let line = self.line(target_row).unwrap_or("");
500        let max_col = last_col(line);
501        let target_col = want.min(max_col);
502        self.set_cursor(Position::new(target_row, target_col));
503    }
504
505    /// Horizontal motions resync the sticky col so the next
506    /// `j` / `k` aims at the new char position.
507    fn refresh_sticky_col_from_cursor(&mut self) {
508        let col = self.cursor().col;
509        self.set_sticky_col(Some(col));
510    }
511}
512
513/// True if `c` qualifies as a word character (vim's small `w`).
514fn is_word(c: char) -> bool {
515    c.is_alphanumeric() || c == '_'
516}
517
518/// Classify a char into vim's three "word kinds" so transitions
519/// between them can drive `w` / `b` / `e`. `Big = true` collapses
520/// `Word` and `Punct` into one bucket.
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522enum CharKind {
523    Word,
524    Punct,
525    Space,
526}
527
528fn char_kind(c: char, big: bool) -> CharKind {
529    if c.is_whitespace() {
530        CharKind::Space
531    } else if big || is_word(c) {
532        // `Big` collapses Word + Punct into a single non-space bucket
533        // so `W` / `B` / `E` skip across punctuation runs.
534        CharKind::Word
535    } else {
536        CharKind::Punct
537    }
538}
539
540/// Step one position forward, wrapping into the next row.
541fn step_forward(buf: &Buffer, pos: Position) -> Option<Position> {
542    let line = buf.line(pos.row)?;
543    let len = line_chars(line);
544    if pos.col + 1 < len {
545        return Some(Position::new(pos.row, pos.col + 1));
546    }
547    if pos.row + 1 < buf.row_count() {
548        return Some(Position::new(pos.row + 1, 0));
549    }
550    None
551}
552
553/// Step one position back, wrapping into the previous row.
554fn step_back(buf: &Buffer, pos: Position) -> Option<Position> {
555    if pos.col > 0 {
556        return Some(Position::new(pos.row, pos.col - 1));
557    }
558    if pos.row == 0 {
559        return None;
560    }
561    let prev_row = pos.row - 1;
562    let prev_len = line_chars(buf.line(prev_row).unwrap_or(""));
563    Some(Position::new(prev_row, prev_len.saturating_sub(1)))
564}
565
566fn char_at(buf: &Buffer, pos: Position) -> Option<char> {
567    buf.line(pos.row)?.chars().nth(pos.col)
568}
569
570fn next_word_start(buf: &Buffer, from: Position, big: bool) -> Option<Position> {
571    let start_kind = char_at(buf, from).map(|c| char_kind(c, big));
572    let mut cur = from;
573    // Skip the rest of the current word kind. Vim treats line
574    // breaks as whitespace separators for `w`, so a row crossing
575    // implicitly ends the current word — break and let the
576    // skip-space pass handle anything beyond.
577    if let Some(kind) = start_kind {
578        while char_at(buf, cur).map(|c| char_kind(c, big)) == Some(kind) {
579            let prev_row = cur.row;
580            match step_forward(buf, cur) {
581                Some(next) => {
582                    cur = next;
583                    if next.row != prev_row {
584                        break;
585                    }
586                }
587                None => return Some(end_of_buffer(buf)),
588            }
589        }
590    }
591    // Skip whitespace runs (within row + across rows) to land on
592    // the next non-space char.
593    while char_at(buf, cur).map(|c| char_kind(c, big)) == Some(CharKind::Space) {
594        match step_forward(buf, cur) {
595            Some(next) => cur = next,
596            None => return Some(end_of_buffer(buf)),
597        }
598    }
599    Some(cur)
600}
601
602/// One past the last char of the last row — vim's "end of buffer"
603/// for operator-context word motions, so `yw` at end-of-line yanks
604/// up to and including the last char.
605fn end_of_buffer(buf: &Buffer) -> Position {
606    let last_row = buf.row_count().saturating_sub(1);
607    let last_line = buf.line(last_row).unwrap_or("");
608    Position::new(last_row, line_chars(last_line))
609}
610
611fn prev_word_start(buf: &Buffer, from: Position, big: bool) -> Option<Position> {
612    let mut cur = step_back(buf, from)?;
613    // Skip whitespace backwards.
614    while char_at(buf, cur).map(|c| char_kind(c, big)) == Some(CharKind::Space) {
615        cur = step_back(buf, cur)?;
616    }
617    let target_kind = char_at(buf, cur).map(|c| char_kind(c, big))?;
618    // Walk back while the previous char is still the same kind.
619    loop {
620        let Some(prev) = step_back(buf, cur) else {
621            return Some(cur);
622        };
623        if char_at(buf, prev).map(|c| char_kind(c, big)) == Some(target_kind) {
624            cur = prev;
625        } else {
626            return Some(cur);
627        }
628    }
629}
630
631/// `ge` / `gE` — walk back to the end of the previous word. The
632/// stopping rule mirrors `next_word_end`'s definition of "end":
633/// non-whitespace position whose next char is a different kind
634/// (or end-of-line / end-of-buffer).
635fn prev_word_end(buf: &Buffer, from: Position, big: bool) -> Option<Position> {
636    let mut cur = step_back(buf, from)?;
637    loop {
638        // Skip whitespace; if it spans across a row boundary, the
639        // step_back walk handles the row crossing for us.
640        if char_at(buf, cur).map(|c| char_kind(c, big)) == Some(CharKind::Space) {
641            cur = step_back(buf, cur)?;
642            continue;
643        }
644        let here = char_kind_or_space(buf, cur, big);
645        let next = next_char_kind_in_row(buf, cur, big);
646        let same = if big {
647            here != CharKind::Space && next != CharKind::Space
648        } else {
649            here == next
650        };
651        if !same {
652            return Some(cur);
653        }
654        cur = step_back(buf, cur)?;
655    }
656}
657
658/// Returns the kind of the char at `pos`, treating an out-of-line
659/// position as `Space`. Used by `prev_word_end` so the stopping
660/// rule matches the original sqeel-vim helper that synthesised an
661/// implicit whitespace at end-of-line.
662fn char_kind_or_space(buf: &Buffer, pos: Position, big: bool) -> CharKind {
663    char_at(buf, pos)
664        .map(|c| char_kind(c, big))
665        .unwrap_or(CharKind::Space)
666}
667
668/// Kind of the next char on the same row as `pos`. End-of-line
669/// counts as `Space` — vim treats line breaks as separators for
670/// `e` / `ge` end-of-word detection.
671fn next_char_kind_in_row(buf: &Buffer, pos: Position, big: bool) -> CharKind {
672    let line = buf.line(pos.row).unwrap_or("");
673    let len = line_chars(line);
674    if pos.col + 1 < len {
675        char_kind_or_space(buf, Position::new(pos.row, pos.col + 1), big)
676    } else {
677        CharKind::Space
678    }
679}
680
681fn next_word_end(buf: &Buffer, from: Position, big: bool) -> Option<Position> {
682    // Vim's `e` advances at least one cell, then walks forward
683    // until the *next* char is a different kind (or eof).
684    let mut cur = step_forward(buf, from)?;
685    while char_at(buf, cur).map(|c| char_kind(c, big)) == Some(CharKind::Space) {
686        cur = step_forward(buf, cur)?;
687    }
688    let kind = char_at(buf, cur).map(|c| char_kind(c, big))?;
689    loop {
690        let Some(next) = step_forward(buf, cur) else {
691            return Some(cur);
692        };
693        if char_at(buf, next).map(|c| char_kind(c, big)) == Some(kind) {
694            cur = next;
695        } else {
696            return Some(cur);
697        }
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    fn at(b: &Buffer) -> Position {
706        b.cursor()
707    }
708
709    #[test]
710    fn move_left_clamps_at_zero() {
711        let mut b = Buffer::from_str("abcd");
712        b.move_right_in_line(3);
713        assert_eq!(at(&b), Position::new(0, 3));
714        b.move_left(10);
715        assert_eq!(at(&b), Position::new(0, 0));
716    }
717
718    #[test]
719    fn move_left_does_not_wrap_to_prev_row() {
720        let mut b = Buffer::from_str("abc\ndef");
721        b.move_down(1);
722        assert_eq!(at(&b).row, 1);
723        b.move_left(99);
724        assert_eq!(at(&b), Position::new(1, 0));
725    }
726
727    #[test]
728    fn move_right_in_line_stops_at_last_char() {
729        let mut b = Buffer::from_str("abcd");
730        b.move_right_in_line(99);
731        assert_eq!(at(&b), Position::new(0, 3));
732    }
733
734    #[test]
735    fn move_right_to_end_allows_one_past() {
736        let mut b = Buffer::from_str("abcd");
737        b.move_right_to_end(99);
738        assert_eq!(at(&b), Position::new(0, 4));
739    }
740
741    #[test]
742    fn move_line_start_end() {
743        let mut b = Buffer::from_str("  hello");
744        b.move_line_end();
745        assert_eq!(at(&b), Position::new(0, 6));
746        b.move_line_start();
747        assert_eq!(at(&b), Position::new(0, 0));
748        b.move_first_non_blank();
749        assert_eq!(at(&b), Position::new(0, 2));
750    }
751
752    #[test]
753    fn move_line_end_on_empty_row_stays_at_zero() {
754        let mut b = Buffer::from_str("");
755        b.move_line_end();
756        assert_eq!(at(&b), Position::new(0, 0));
757    }
758
759    #[test]
760    fn move_down_preserves_sticky_col_across_short_row() {
761        let mut b = Buffer::from_str("hello world\nhi\nlong line again");
762        b.move_right_in_line(7);
763        assert_eq!(at(&b), Position::new(0, 7));
764        b.move_down(1);
765        assert_eq!(at(&b).row, 1);
766        // Short row clamps to col 1 (last char of "hi").
767        assert_eq!(at(&b).col, 1);
768        b.move_down(1);
769        // Sticky col 7 restored on the longer row.
770        assert_eq!(at(&b), Position::new(2, 7));
771    }
772
773    #[test]
774    fn move_down_skips_closed_fold() {
775        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
776        b.add_fold(1, 3, true);
777        // From row 0, `j` should land on row 4 — the fold collapses
778        // rows 1..=3 into a single visual line at row 1.
779        b.move_down(1);
780        assert_eq!(at(&b).row, 1);
781        b.move_down(1);
782        assert_eq!(at(&b).row, 4);
783    }
784
785    #[test]
786    fn move_up_skips_closed_fold() {
787        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
788        b.add_fold(1, 3, true);
789        b.set_cursor(Position::new(4, 0));
790        b.move_up(1);
791        assert_eq!(at(&b).row, 1);
792        b.move_up(1);
793        assert_eq!(at(&b).row, 0);
794    }
795
796    #[test]
797    fn open_fold_is_walked_normally() {
798        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
799        b.add_fold(1, 3, false);
800        // Open fold: every row is visible, plain row-by-row stepping.
801        b.move_down(2);
802        assert_eq!(at(&b).row, 2);
803    }
804
805    #[test]
806    fn move_top_lands_on_first_non_blank() {
807        let mut b = Buffer::from_str("    indented\nrow2");
808        b.move_down(1);
809        b.move_top();
810        assert_eq!(at(&b), Position::new(0, 4));
811    }
812
813    #[test]
814    fn move_bottom_with_count_jumps_to_line() {
815        let mut b = Buffer::from_str("a\n  b\nc\nd");
816        b.move_bottom(2);
817        assert_eq!(at(&b), Position::new(1, 2));
818    }
819
820    #[test]
821    fn move_bottom_zero_jumps_to_last_row() {
822        let mut b = Buffer::from_str("a\nb\nc");
823        b.move_bottom(0);
824        assert_eq!(at(&b), Position::new(2, 0));
825    }
826
827    #[test]
828    fn move_word_fwd_skips_whitespace_runs() {
829        let mut b = Buffer::from_str("foo bar  baz");
830        b.move_word_fwd(false, 1);
831        assert_eq!(at(&b), Position::new(0, 4));
832        b.move_word_fwd(false, 1);
833        assert_eq!(at(&b), Position::new(0, 9));
834    }
835
836    #[test]
837    fn move_word_fwd_separates_word_from_punct_in_small_w() {
838        let mut b = Buffer::from_str("foo.bar");
839        b.move_word_fwd(false, 1);
840        assert_eq!(at(&b), Position::new(0, 3));
841        b.move_word_fwd(false, 1);
842        assert_eq!(at(&b), Position::new(0, 4));
843    }
844
845    #[test]
846    fn move_word_fwd_big_collapses_word_and_punct() {
847        let mut b = Buffer::from_str("foo.bar baz");
848        b.move_word_fwd(true, 1);
849        assert_eq!(at(&b), Position::new(0, 8));
850    }
851
852    #[test]
853    fn move_word_back_lands_on_word_start() {
854        let mut b = Buffer::from_str("foo bar baz");
855        b.move_line_end();
856        assert_eq!(at(&b), Position::new(0, 10));
857        b.move_word_back(false, 1);
858        assert_eq!(at(&b), Position::new(0, 8));
859        b.move_word_back(false, 2);
860        assert_eq!(at(&b), Position::new(0, 0));
861    }
862
863    #[test]
864    fn move_word_end_lands_on_last_char() {
865        let mut b = Buffer::from_str("foo bar");
866        b.move_word_end(false, 1);
867        assert_eq!(at(&b), Position::new(0, 2));
868        b.move_word_end(false, 1);
869        assert_eq!(at(&b), Position::new(0, 6));
870    }
871
872    #[test]
873    fn find_char_forward_lands_on_match() {
874        let mut b = Buffer::from_str("foo,bar,baz");
875        assert!(b.find_char_on_line(',', true, false));
876        assert_eq!(at(&b), Position::new(0, 3));
877        assert!(b.find_char_on_line(',', true, false));
878        assert_eq!(at(&b), Position::new(0, 7));
879    }
880
881    #[test]
882    fn find_char_till_stops_one_short() {
883        let mut b = Buffer::from_str("foo,bar");
884        assert!(b.find_char_on_line(',', true, true));
885        assert_eq!(at(&b), Position::new(0, 2));
886    }
887
888    #[test]
889    fn find_char_backward_lands_on_match() {
890        let mut b = Buffer::from_str("foo,bar,baz");
891        b.set_cursor(Position::new(0, 10));
892        assert!(b.find_char_on_line(',', false, false));
893        assert_eq!(at(&b), Position::new(0, 7));
894    }
895
896    #[test]
897    fn find_char_no_match_returns_false() {
898        let mut b = Buffer::from_str("hello");
899        assert!(!b.find_char_on_line('z', true, false));
900        assert_eq!(at(&b), Position::new(0, 0));
901    }
902
903    #[test]
904    fn move_viewport_top_with_offset() {
905        let mut b = Buffer::from_str("a\nb\nc\nd\ne\nf");
906        b.viewport_mut().top_row = 1;
907        b.viewport_mut().height = 4;
908        b.move_viewport_top(2);
909        assert_eq!(at(&b), Position::new(3, 0));
910    }
911
912    #[test]
913    fn move_viewport_middle_picks_center_of_visible() {
914        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
915        b.viewport_mut().top_row = 0;
916        b.viewport_mut().height = 5;
917        b.move_viewport_middle();
918        assert_eq!(at(&b), Position::new(2, 0));
919    }
920
921    #[test]
922    fn move_viewport_bottom_with_offset() {
923        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
924        b.viewport_mut().top_row = 0;
925        b.viewport_mut().height = 5;
926        b.move_viewport_bottom(1);
927        assert_eq!(at(&b), Position::new(3, 0));
928    }
929
930    #[test]
931    fn move_word_end_back_lands_on_prev_word_end() {
932        let mut b = Buffer::from_str("foo bar baz");
933        b.set_cursor(Position::new(0, 9));
934        b.move_word_end_back(false, 1);
935        assert_eq!(at(&b), Position::new(0, 6));
936        b.move_word_end_back(false, 1);
937        assert_eq!(at(&b), Position::new(0, 2));
938    }
939
940    #[test]
941    fn move_word_end_back_big_skips_punct() {
942        let mut b = Buffer::from_str("foo-bar qux");
943        b.set_cursor(Position::new(0, 10));
944        b.move_word_end_back(true, 1);
945        assert_eq!(at(&b), Position::new(0, 6));
946    }
947
948    #[test]
949    fn move_word_end_back_crosses_lines() {
950        let mut b = Buffer::from_str("abc\ndef");
951        b.set_cursor(Position::new(1, 2));
952        b.move_word_end_back(false, 1);
953        assert_eq!(at(&b), Position::new(0, 2));
954    }
955
956    #[test]
957    fn match_bracket_pairs_within_line() {
958        let mut b = Buffer::from_str("if (x + y) {");
959        b.set_cursor(Position::new(0, 3));
960        assert!(b.match_bracket());
961        assert_eq!(at(&b), Position::new(0, 9));
962        assert!(b.match_bracket());
963        assert_eq!(at(&b), Position::new(0, 3));
964    }
965
966    #[test]
967    fn match_bracket_handles_nesting() {
968        let mut b = Buffer::from_str("((x))");
969        b.set_cursor(Position::new(0, 0));
970        assert!(b.match_bracket());
971        assert_eq!(at(&b), Position::new(0, 4));
972    }
973
974    #[test]
975    fn match_bracket_crosses_lines() {
976        let mut b = Buffer::from_str("{\n  x\n}");
977        b.set_cursor(Position::new(0, 0));
978        assert!(b.match_bracket());
979        assert_eq!(at(&b), Position::new(2, 0));
980    }
981
982    #[test]
983    fn match_bracket_returns_false_off_bracket() {
984        let mut b = Buffer::from_str("hello");
985        assert!(!b.match_bracket());
986    }
987
988    #[test]
989    fn motion_count_zero_treated_as_one() {
990        let mut b = Buffer::from_str("abcd");
991        b.move_right_in_line(0);
992        assert_eq!(at(&b), Position::new(0, 1));
993    }
994
995    fn enable_wrap(b: &mut Buffer, mode: crate::Wrap, text_width: u16) {
996        let v = b.viewport_mut();
997        v.wrap = mode;
998        v.text_width = text_width;
999        v.height = 10;
1000    }
1001
1002    #[test]
1003    fn screen_down_falls_back_to_move_down_when_wrap_off() {
1004        let mut b = Buffer::from_str("a\nb\nc");
1005        b.move_screen_down(1);
1006        assert_eq!(at(&b), Position::new(1, 0));
1007        b.move_screen_down(1);
1008        assert_eq!(at(&b), Position::new(2, 0));
1009    }
1010
1011    #[test]
1012    fn screen_down_walks_within_wrapped_row() {
1013        // 12-char line, width 4 → segments (0,4), (4,8), (8,12).
1014        let mut b = Buffer::from_str("aaaabbbbcccc\nx");
1015        enable_wrap(&mut b, crate::Wrap::Char, 4);
1016        b.set_cursor(Position::new(0, 1));
1017        b.move_screen_down(1);
1018        // visual_col = 1 → next segment starts at 4 → land col 5.
1019        assert_eq!(at(&b), Position::new(0, 5));
1020        b.move_screen_down(1);
1021        assert_eq!(at(&b), Position::new(0, 9));
1022        // Past the last segment crosses to the next doc row.
1023        b.move_screen_down(1);
1024        assert_eq!(at(&b), Position::new(1, 0));
1025    }
1026
1027    #[test]
1028    fn screen_up_walks_within_wrapped_row() {
1029        let mut b = Buffer::from_str("aaaabbbbcccc");
1030        enable_wrap(&mut b, crate::Wrap::Char, 4);
1031        b.set_cursor(Position::new(0, 9));
1032        b.move_screen_up(1);
1033        // visual_col = 9 - 8 = 1 → previous segment col = 4 + 1 = 5.
1034        assert_eq!(at(&b), Position::new(0, 5));
1035        b.move_screen_up(1);
1036        assert_eq!(at(&b), Position::new(0, 1));
1037        // Already on first segment of first row — no further move.
1038        b.move_screen_up(1);
1039        assert_eq!(at(&b), Position::new(0, 1));
1040    }
1041
1042    #[test]
1043    fn screen_down_clamps_to_short_segment() {
1044        // First row wraps into a 6-char then a 2-char segment; second
1045        // row is only 1 char. Visual col 4 should clamp to row 1's
1046        // last col (0) when crossing into the short row.
1047        let mut b = Buffer::from_str("aaaaaabb\nx");
1048        enable_wrap(&mut b, crate::Wrap::Char, 6);
1049        b.set_cursor(Position::new(0, 4));
1050        b.move_screen_down(1);
1051        // visual_col = 4 → segment 1 is (6, 8); want=10 clamps to 7.
1052        assert_eq!(at(&b), Position::new(0, 7));
1053        b.move_screen_down(1);
1054        // crosses into row 1, segment (0, 1) — clamps to col 0.
1055        assert_eq!(at(&b), Position::new(1, 0));
1056    }
1057
1058    #[test]
1059    fn screen_down_count_compounds() {
1060        let mut b = Buffer::from_str("aaaabbbbcccc");
1061        enable_wrap(&mut b, crate::Wrap::Char, 4);
1062        b.set_cursor(Position::new(0, 0));
1063        b.move_screen_down(2);
1064        assert_eq!(at(&b), Position::new(0, 8));
1065    }
1066}