Skip to main content

redox_core/
motion.rs

1//! High-level editor navigation logic (motions).
2//!
3//! This module is intentionally **UI-agnostic** and depends only on core editor
4//! types like [`TextBuffer`] and [`Pos`]. It provides a stable API to build
5//! Vim-like behavior on top of (e.g. `w`, `gg`, `G`, `0`, `$`, etc.).
6//!
7//! Design goals:
8//! - Keep motions deterministic and side-effect-free.
9//! - Keep indexing consistent with `redox-core`: `Pos { line, col }` where `col`
10//!   is in **char units** (Ropey model).
11//! - Centralize motion semantics here so frontends (TUI/GUI) only project the
12//!   resulting document cursor into their own viewport/cell coordinate systems.
13//!
14//! Notes:
15//! - Word motions here currently use `TextBuffer`'s existing word helpers
16//!   (`word_start_before`, `word_end_after`), which in turn use `buffer::util::is_word_char`.
17//! - This module keeps motion semantics centralized so frontends remain thin.
18//!
19//! This file defines:
20//! - [`Motion`] enum: the set of supported navigation intents.
21//! - [`apply_motion`] / [`apply_motion_n`]: apply motions to a cursor position.
22
23use crate::{Pos, TextBuffer};
24
25/// A navigation intent (motion) that transforms a document cursor.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Motion {
28    /// Move left by one char.
29    Left,
30    /// Move right by one char.
31    Right,
32    /// Move up by one line.
33    Up,
34    /// Move down by one line.
35    Down,
36
37    /// Go to first line of file (`gg`).
38    FileStart,
39
40    /// Go to last line of file (`G`). Column is clamped to that line.
41    FileEnd,
42
43    /// Go to start of line (`0`-ish).
44    LineStart,
45
46    /// Go to the first non-whitespace character on the line (`_`-ish).
47    LineFirstNonWhitespace,
48
49    /// Go to end of line (`$`-ish), i.e. `line_len_chars(line)`.
50    LineEnd,
51
52    /// Move to start of previous word (`b`-ish).
53    WordStartBefore,
54
55    /// Move to start of next word (`w`-ish).
56    WordStartAfter,
57
58    /// Move to end of next word (`e`-ish).
59    WordEndAfter,
60
61    /// Move onto the next matching character on the current line (`f`-ish).
62    FindChar(char),
63
64    /// Move just before the next matching character on the current line (`t`-ish).
65    TillChar(char),
66
67    /// Move onto the previous matching character on the current line (`F`-ish).
68    FindCharBefore(char),
69
70    /// Move just after the previous matching character on the current line (`T`-ish).
71    TillCharBefore(char),
72
73    /// Jump to the delimiter paired with the delimiter under the cursor (`%`-ish).
74    MatchDelimiter,
75}
76
77/// Apply a single `Motion` to a given cursor position.
78///
79/// This function is pure: it never mutates the buffer, and always returns a
80/// position clamped to valid buffer bounds.
81#[inline]
82pub fn apply_motion(buffer: &TextBuffer, cursor: Pos, motion: Motion) -> Pos {
83    let cursor = buffer.clamp_pos(cursor);
84
85    match motion {
86        Motion::Left => buffer.move_left(cursor),
87        Motion::Right => buffer.move_right(cursor),
88        Motion::Up => buffer.move_up(cursor),
89        Motion::Down => buffer.move_down(cursor),
90
91        Motion::FileStart => {
92            let target_col = if cursor.line == 0 { 0 } else { cursor.col };
93            buffer.clamp_pos(Pos::new(0, target_col))
94        }
95
96        Motion::FileEnd => {
97            let last = buffer.len_lines().saturating_sub(1);
98            buffer.clamp_pos(Pos::new(last, cursor.col))
99        }
100
101        Motion::LineStart => Pos::new(cursor.line, 0),
102
103        Motion::LineFirstNonWhitespace => {
104            let line = buffer.clamp_line(cursor.line);
105            Pos::new(line, buffer.line_first_non_whitespace_col(line))
106        }
107
108        Motion::LineEnd => {
109            let line = buffer.clamp_line(cursor.line);
110            let end_col = buffer.line_len_chars(line);
111            Pos::new(line, end_col)
112        }
113
114        Motion::WordStartBefore => buffer.word_start_before(cursor),
115
116        Motion::WordStartAfter => buffer.word_start_after(cursor),
117
118        Motion::WordEndAfter => buffer.word_end_after(cursor),
119
120        Motion::FindChar(needle) => buffer
121            .find_char_after_on_line(cursor, needle)
122            .unwrap_or(cursor),
123
124        Motion::TillChar(needle) => buffer
125            .find_char_after_on_line(cursor, needle)
126            .map(|target| {
127                if target.col > 0 {
128                    Pos::new(target.line, target.col - 1)
129                } else {
130                    target
131                }
132            })
133            .unwrap_or(cursor),
134
135        Motion::FindCharBefore(needle) => buffer
136            .find_char_before_on_line(cursor, needle)
137            .unwrap_or(cursor),
138
139        Motion::TillCharBefore(needle) => buffer
140            .find_char_before_on_line(cursor, needle)
141            .map(|target| Pos::new(target.line, target.col.saturating_add(1)))
142            .unwrap_or(cursor),
143
144        Motion::MatchDelimiter => buffer.matching_delimiter(cursor).unwrap_or(cursor),
145    }
146}
147
148/// Apply a motion using operator semantics for the resulting half-open range end.
149pub fn apply_motion_for_operator(
150    buffer: &TextBuffer,
151    cursor: Pos,
152    motion: Motion,
153    count: usize,
154) -> Pos {
155    match motion {
156        Motion::FindChar(needle) => {
157            let mut current = buffer.clamp_pos(cursor);
158            let mut target = None;
159            for _ in 0..count.max(1) {
160                let Some(found) = buffer.find_char_after_on_line(current, needle) else {
161                    return cursor;
162                };
163                target = Some(found);
164                current = found;
165            }
166            target
167                .map(|found| buffer.move_right(found))
168                .unwrap_or(cursor)
169        }
170        Motion::TillChar(needle) => {
171            let mut current = buffer.clamp_pos(cursor);
172            let mut target = None;
173            for _ in 0..count.max(1) {
174                let Some(found) = buffer.find_char_after_on_line(current, needle) else {
175                    return cursor;
176                };
177                target = Some(found);
178                current = found;
179            }
180            target.unwrap_or(cursor)
181        }
182        Motion::FindCharBefore(needle) => {
183            let mut current = buffer.clamp_pos(cursor);
184            let mut target = None;
185            for _ in 0..count.max(1) {
186                let Some(found) = buffer.find_char_before_on_line(current, needle) else {
187                    return cursor;
188                };
189                target = Some(found);
190                current = found;
191            }
192            target.unwrap_or(cursor)
193        }
194        Motion::TillCharBefore(needle) => {
195            let mut current = buffer.clamp_pos(cursor);
196            let mut target = None;
197            for _ in 0..count.max(1) {
198                let Some(found) = buffer.find_char_before_on_line(current, needle) else {
199                    return cursor;
200                };
201                let after_found = Pos::new(found.line, found.col.saturating_add(1));
202                target = Some(after_found);
203                current = found;
204            }
205            target.unwrap_or(cursor)
206        }
207        Motion::MatchDelimiter => {
208            let target = apply_motion(buffer, cursor, motion);
209            if target > cursor {
210                buffer.move_right(target)
211            } else {
212                target
213            }
214        }
215        _ => apply_motion_n(buffer, cursor, motion, count),
216    }
217}
218
219/// Apply a motion `count` times (Vim-style numeric prefix).
220///
221/// - If `count == 0`, this returns `cursor` unchanged.
222/// - Motions are applied iteratively so they can clamp naturally at boundaries.
223pub fn apply_motion_n(buffer: &TextBuffer, cursor: Pos, motion: Motion, count: usize) -> Pos {
224    if motion == Motion::MatchDelimiter {
225        return if count == 0 {
226            buffer.clamp_pos(cursor)
227        } else {
228            apply_motion(buffer, cursor, motion)
229        };
230    }
231
232    let mut cur = buffer.clamp_pos(cursor);
233    for _ in 0..count {
234        let next = apply_motion(buffer, cur, motion);
235        // If the motion stops making progress (EOF/top/etc.), stop early.
236        if next == cur {
237            break;
238        }
239        cur = next;
240    }
241    cur
242}
243
244/// Convenience helpers for motions that take a count.
245pub mod helpers {
246    use super::{Motion, apply_motion_n};
247    use crate::{Pos, TextBuffer};
248
249    /// Move forward by words (`w`-ish) by applying `WordStartAfter` repeatedly.
250    #[inline]
251    pub fn word_forward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
252        apply_motion_n(buffer, cursor, Motion::WordStartAfter, count)
253    }
254
255    /// Move backward by words (`b`-ish) by applying `WordStartBefore` repeatedly.
256    #[inline]
257    pub fn word_backward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
258        apply_motion_n(buffer, cursor, Motion::WordStartBefore, count)
259    }
260
261    /// Move to the first line (`gg`).
262    #[inline]
263    pub fn gg(buffer: &TextBuffer, cursor: Pos) -> Pos {
264        super::apply_motion(buffer, cursor, Motion::FileStart)
265    }
266
267    /// Move to the last line (`G`).
268    #[inline]
269    pub fn file_end(buffer: &TextBuffer, cursor: Pos) -> Pos {
270        super::apply_motion(buffer, cursor, Motion::FileEnd)
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn motion_count_zero_is_noop() {
280        let b = TextBuffer::from_str("abc\n");
281        let p = Pos::new(0, 2);
282        assert_eq!(apply_motion_n(&b, p, Motion::Left, 0), p);
283        assert_eq!(apply_motion_n(&b, p, Motion::WordEndAfter, 0), p);
284    }
285
286    #[test]
287    fn gg_goes_to_first_line_and_clamps_column() {
288        let b = TextBuffer::from_str("a\nbb\nccc\n");
289        let p = Pos::new(2, 2);
290        let p2 = apply_motion(&b, p, Motion::FileStart);
291        assert_eq!(p2.line, 0);
292        // first line is "a" so col clamps to 1
293        assert_eq!(p2.col, 1);
294    }
295
296    #[test]
297    fn file_end_goes_to_last_line_and_clamps_column() {
298        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
299        // So "a\nbb\nccc\n" has 4 lines: "a", "bb", "ccc", "".
300        let b = TextBuffer::from_str("a\nbb\nccc\n");
301        let p = Pos::new(0, 10);
302        let p2 = apply_motion(&b, p, Motion::FileEnd);
303
304        // Last line is the empty line after the trailing newline.
305        assert_eq!(p2.line, 3);
306        assert_eq!(p2.col, 0);
307    }
308
309    #[test]
310    fn line_start_and_line_end_work() {
311        let b = TextBuffer::from_str("  hello\nworld!\n");
312        let p = Pos::new(1, 2);
313
314        let start = apply_motion(&b, p, Motion::LineStart);
315        assert_eq!(start, Pos::new(1, 0));
316
317        let end = apply_motion(&b, p, Motion::LineEnd);
318        assert_eq!(end, Pos::new(1, 6));
319
320        let first_non_whitespace = apply_motion(&b, Pos::new(0, 5), Motion::LineFirstNonWhitespace);
321        assert_eq!(first_non_whitespace, Pos::new(0, 2));
322    }
323
324    #[test]
325    fn first_non_whitespace_clamps_to_line_end_for_blank_lines() {
326        let b = TextBuffer::from_str("   \n\t\t\n");
327
328        assert_eq!(
329            apply_motion(&b, Pos::new(0, 0), Motion::LineFirstNonWhitespace),
330            Pos::new(0, 3)
331        );
332        assert_eq!(
333            apply_motion(&b, Pos::new(1, 0), Motion::LineFirstNonWhitespace),
334            Pos::new(1, 2)
335        );
336    }
337
338    #[test]
339    fn left_right_clamp_at_bounds() {
340        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
341        // Right at end-of-line can advance onto that empty line.
342        let b = TextBuffer::from_str("ab\n");
343        let p0 = Pos::new(0, 0);
344        assert_eq!(apply_motion(&b, p0, Motion::Left), Pos::new(0, 0));
345
346        // Moving right from end-of-line steps onto the newline, which maps to the next (empty) line at col 0.
347        let p_end = Pos::new(0, 2);
348        assert_eq!(apply_motion(&b, p_end, Motion::Right), Pos::new(1, 0));
349    }
350
351    #[test]
352    fn up_down_preserve_column_when_possible() {
353        let b = TextBuffer::from_str("aaaa\nb\ncccccc\n");
354        let p = Pos::new(0, 3);
355
356        let down = apply_motion(&b, p, Motion::Down);
357        // line 1 is "b" so col clamps to 1
358        assert_eq!(down, Pos::new(1, 1));
359
360        let down2 = apply_motion(&b, down, Motion::Down);
361        // line 2 is long enough, so col stays 1
362        assert_eq!(down2, Pos::new(2, 1));
363
364        let up = apply_motion(&b, down2, Motion::Up);
365        assert_eq!(up, Pos::new(1, 1));
366    }
367
368    #[test]
369    fn word_motions_ascii_smoke() {
370        let b = TextBuffer::from_str("abc  def_12!\n");
371        let p = Pos::new(0, 6); // in "def_12"
372
373        let start = apply_motion(&b, p, Motion::WordStartBefore);
374        assert_eq!(start, Pos::new(0, 5));
375
376        let end = apply_motion(&b, start, Motion::WordEndAfter);
377        assert_eq!(end, Pos::new(0, 10));
378    }
379
380    #[test]
381    fn repeated_word_forward_stops_at_eof() {
382        let b = TextBuffer::from_str("a b c\n");
383        let p = Pos::new(0, 0);
384        let p2 = apply_motion_n(&b, p, Motion::WordEndAfter, 100);
385
386        // Vim's 'e' motion stops at the last character of the last word.
387        assert_eq!(p2, Pos::new(0, 5));
388    }
389
390    #[test]
391    fn word_start_after_visits_symbol_tokens() {
392        let b = TextBuffer::from_str("(normal/insert/command)\n");
393        let mut p = Pos::new(0, 0);
394
395        p = apply_motion(&b, p, Motion::WordStartAfter);
396        assert_eq!(p, Pos::new(0, 1)); // normal
397
398        p = apply_motion(&b, p, Motion::WordStartAfter);
399        assert_eq!(p, Pos::new(0, 7)); // /
400
401        p = apply_motion(&b, p, Motion::WordStartAfter);
402        assert_eq!(p, Pos::new(0, 8)); // insert
403
404        p = apply_motion(&b, p, Motion::WordStartAfter);
405        assert_eq!(p, Pos::new(0, 14)); // /
406
407        p = apply_motion(&b, p, Motion::WordStartAfter);
408        assert_eq!(p, Pos::new(0, 15)); // command
409
410        p = apply_motion(&b, p, Motion::WordStartAfter);
411        assert_eq!(p, Pos::new(0, 22)); // )
412    }
413
414    #[test]
415    fn word_start_before_stops_on_symbol_token() {
416        let b = TextBuffer::from_str("(normal/insert)\n");
417        let p = Pos::new(0, 15); // after ')'
418        let p2 = apply_motion(&b, p, Motion::WordStartBefore);
419        assert_eq!(p2, Pos::new(0, 14)); // )
420    }
421
422    #[test]
423    fn word_end_after_can_land_on_symbol_token() {
424        let b = TextBuffer::from_str("alpha / beta\n");
425
426        let p = Pos::new(0, 0);
427        let p = apply_motion(&b, p, Motion::WordEndAfter);
428        assert_eq!(p, Pos::new(0, 4)); // alpha
429
430        let p = apply_motion(&b, p, Motion::WordEndAfter);
431        assert_eq!(p, Pos::new(0, 6)); // /
432    }
433
434    #[test]
435    fn find_and_till_char_stay_on_current_line() {
436        let b = TextBuffer::from_str("alpha beta alpha\n");
437        let cursor = Pos::new(0, 0);
438
439        assert_eq!(
440            apply_motion(&b, cursor, Motion::FindChar('b')),
441            Pos::new(0, 6)
442        );
443        assert_eq!(
444            apply_motion(&b, cursor, Motion::TillChar('b')),
445            Pos::new(0, 5)
446        );
447        assert_eq!(
448            apply_motion_n(&b, cursor, Motion::FindChar('a'), 2),
449            Pos::new(0, 9)
450        );
451    }
452
453    #[test]
454    fn backward_find_and_till_char_stay_on_current_line() {
455        let b = TextBuffer::from_str("alpha beta alpha\n");
456        let cursor = Pos::new(0, 15);
457
458        assert_eq!(
459            apply_motion(&b, cursor, Motion::FindCharBefore('b')),
460            Pos::new(0, 6)
461        );
462        assert_eq!(
463            apply_motion(&b, cursor, Motion::TillCharBefore('b')),
464            Pos::new(0, 7)
465        );
466        assert_eq!(
467            apply_motion_n(&b, cursor, Motion::FindCharBefore('a'), 2),
468            Pos::new(0, 9)
469        );
470    }
471
472    #[test]
473    fn match_delimiter_jumps_between_pair_endpoints() {
474        let b = TextBuffer::from_str("fn main() {\n    call([x]);\n}\n");
475
476        assert_eq!(
477            apply_motion(&b, Pos::new(0, 7), Motion::MatchDelimiter),
478            Pos::new(0, 8)
479        );
480        assert_eq!(
481            apply_motion(&b, Pos::new(0, 8), Motion::MatchDelimiter),
482            Pos::new(0, 7)
483        );
484        assert_eq!(
485            apply_motion(&b, Pos::new(0, 10), Motion::MatchDelimiter),
486            Pos::new(2, 0)
487        );
488        assert_eq!(
489            apply_motion(&b, Pos::new(1, 9), Motion::MatchDelimiter),
490            Pos::new(1, 11)
491        );
492    }
493
494    #[test]
495    fn match_delimiter_supports_symmetric_and_angle_delimiters() {
496        let b = TextBuffer::from_str("let s = \"a \\\" b\"; let tag = <x>`tick`\n");
497
498        assert_eq!(
499            apply_motion(&b, Pos::new(0, 8), Motion::MatchDelimiter),
500            Pos::new(0, 15)
501        );
502        assert_eq!(
503            apply_motion(&b, Pos::new(0, 15), Motion::MatchDelimiter),
504            Pos::new(0, 8)
505        );
506        assert_eq!(
507            apply_motion(&b, Pos::new(0, 28), Motion::MatchDelimiter),
508            Pos::new(0, 30)
509        );
510        assert_eq!(
511            apply_motion(&b, Pos::new(0, 31), Motion::MatchDelimiter),
512            Pos::new(0, 36)
513        );
514    }
515
516    #[test]
517    fn operator_find_char_includes_the_target_character() {
518        let b = TextBuffer::from_str("alpha beta\n");
519        let cursor = Pos::new(0, 0);
520
521        assert_eq!(
522            apply_motion_for_operator(&b, cursor, Motion::FindChar('b'), 1),
523            Pos::new(0, 7)
524        );
525        assert_eq!(
526            apply_motion_for_operator(&b, cursor, Motion::TillChar('b'), 1),
527            Pos::new(0, 6)
528        );
529    }
530}