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
68/// Apply a single `Motion` to a given cursor position.
69///
70/// This function is pure: it never mutates the buffer, and always returns a
71/// position clamped to valid buffer bounds.
72#[inline]
73pub fn apply_motion(buffer: &TextBuffer, cursor: Pos, motion: Motion) -> Pos {
74    let cursor = buffer.clamp_pos(cursor);
75
76    match motion {
77        Motion::Left => buffer.move_left(cursor),
78        Motion::Right => buffer.move_right(cursor),
79        Motion::Up => buffer.move_up(cursor),
80        Motion::Down => buffer.move_down(cursor),
81
82        Motion::FileStart => {
83            let target_col = if cursor.line == 0 { 0 } else { cursor.col };
84            buffer.clamp_pos(Pos::new(0, target_col))
85        }
86
87        Motion::FileEnd => {
88            let last = buffer.len_lines().saturating_sub(1);
89            buffer.clamp_pos(Pos::new(last, cursor.col))
90        }
91
92        Motion::LineStart => Pos::new(cursor.line, 0),
93
94        Motion::LineFirstNonWhitespace => {
95            let line = buffer.clamp_line(cursor.line);
96            Pos::new(line, buffer.line_first_non_whitespace_col(line))
97        }
98
99        Motion::LineEnd => {
100            let line = buffer.clamp_line(cursor.line);
101            let end_col = buffer.line_len_chars(line);
102            Pos::new(line, end_col)
103        }
104
105        Motion::WordStartBefore => buffer.word_start_before(cursor),
106
107        Motion::WordStartAfter => buffer.word_start_after(cursor),
108
109        Motion::WordEndAfter => buffer.word_end_after(cursor),
110
111        Motion::FindChar(needle) => buffer
112            .find_char_after_on_line(cursor, needle)
113            .unwrap_or(cursor),
114
115        Motion::TillChar(needle) => buffer
116            .find_char_after_on_line(cursor, needle)
117            .map(|target| {
118                if target.col > 0 {
119                    Pos::new(target.line, target.col - 1)
120                } else {
121                    target
122                }
123            })
124            .unwrap_or(cursor),
125    }
126}
127
128/// Apply a motion using operator semantics for the resulting half-open range end.
129pub fn apply_motion_for_operator(
130    buffer: &TextBuffer,
131    cursor: Pos,
132    motion: Motion,
133    count: usize,
134) -> Pos {
135    match motion {
136        Motion::FindChar(needle) => {
137            let mut current = buffer.clamp_pos(cursor);
138            let mut target = None;
139            for _ in 0..count.max(1) {
140                let Some(found) = buffer.find_char_after_on_line(current, needle) else {
141                    return cursor;
142                };
143                target = Some(found);
144                current = found;
145            }
146            target
147                .map(|found| buffer.move_right(found))
148                .unwrap_or(cursor)
149        }
150        Motion::TillChar(needle) => {
151            let mut current = buffer.clamp_pos(cursor);
152            let mut target = None;
153            for _ in 0..count.max(1) {
154                let Some(found) = buffer.find_char_after_on_line(current, needle) else {
155                    return cursor;
156                };
157                target = Some(found);
158                current = found;
159            }
160            target.unwrap_or(cursor)
161        }
162        _ => apply_motion_n(buffer, cursor, motion, count),
163    }
164}
165
166/// Apply a motion `count` times (Vim-style numeric prefix).
167///
168/// - If `count == 0`, this returns `cursor` unchanged.
169/// - Motions are applied iteratively so they can clamp naturally at boundaries.
170pub fn apply_motion_n(buffer: &TextBuffer, cursor: Pos, motion: Motion, count: usize) -> Pos {
171    let mut cur = buffer.clamp_pos(cursor);
172    for _ in 0..count {
173        let next = apply_motion(buffer, cur, motion);
174        // If the motion stops making progress (EOF/top/etc.), stop early.
175        if next == cur {
176            break;
177        }
178        cur = next;
179    }
180    cur
181}
182
183/// Convenience helpers for motions that take a count.
184pub mod helpers {
185    use super::{Motion, apply_motion_n};
186    use crate::{Pos, TextBuffer};
187
188    /// Move forward by words (`w`-ish) by applying `WordStartAfter` repeatedly.
189    #[inline]
190    pub fn word_forward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
191        apply_motion_n(buffer, cursor, Motion::WordStartAfter, count)
192    }
193
194    /// Move backward by words (`b`-ish) by applying `WordStartBefore` repeatedly.
195    #[inline]
196    pub fn word_backward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
197        apply_motion_n(buffer, cursor, Motion::WordStartBefore, count)
198    }
199
200    /// Move to the first line (`gg`).
201    #[inline]
202    pub fn gg(buffer: &TextBuffer, cursor: Pos) -> Pos {
203        super::apply_motion(buffer, cursor, Motion::FileStart)
204    }
205
206    /// Move to the last line (`G`).
207    #[inline]
208    pub fn file_end(buffer: &TextBuffer, cursor: Pos) -> Pos {
209        super::apply_motion(buffer, cursor, Motion::FileEnd)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn motion_count_zero_is_noop() {
219        let b = TextBuffer::from_str("abc\n");
220        let p = Pos::new(0, 2);
221        assert_eq!(apply_motion_n(&b, p, Motion::Left, 0), p);
222        assert_eq!(apply_motion_n(&b, p, Motion::WordEndAfter, 0), p);
223    }
224
225    #[test]
226    fn gg_goes_to_first_line_and_clamps_column() {
227        let b = TextBuffer::from_str("a\nbb\nccc\n");
228        let p = Pos::new(2, 2);
229        let p2 = apply_motion(&b, p, Motion::FileStart);
230        assert_eq!(p2.line, 0);
231        // first line is "a" so col clamps to 1
232        assert_eq!(p2.col, 1);
233    }
234
235    #[test]
236    fn file_end_goes_to_last_line_and_clamps_column() {
237        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
238        // So "a\nbb\nccc\n" has 4 lines: "a", "bb", "ccc", "".
239        let b = TextBuffer::from_str("a\nbb\nccc\n");
240        let p = Pos::new(0, 10);
241        let p2 = apply_motion(&b, p, Motion::FileEnd);
242
243        // Last line is the empty line after the trailing newline.
244        assert_eq!(p2.line, 3);
245        assert_eq!(p2.col, 0);
246    }
247
248    #[test]
249    fn line_start_and_line_end_work() {
250        let b = TextBuffer::from_str("  hello\nworld!\n");
251        let p = Pos::new(1, 2);
252
253        let start = apply_motion(&b, p, Motion::LineStart);
254        assert_eq!(start, Pos::new(1, 0));
255
256        let end = apply_motion(&b, p, Motion::LineEnd);
257        assert_eq!(end, Pos::new(1, 6));
258
259        let first_non_whitespace = apply_motion(&b, Pos::new(0, 5), Motion::LineFirstNonWhitespace);
260        assert_eq!(first_non_whitespace, Pos::new(0, 2));
261    }
262
263    #[test]
264    fn first_non_whitespace_clamps_to_line_end_for_blank_lines() {
265        let b = TextBuffer::from_str("   \n\t\t\n");
266
267        assert_eq!(
268            apply_motion(&b, Pos::new(0, 0), Motion::LineFirstNonWhitespace),
269            Pos::new(0, 3)
270        );
271        assert_eq!(
272            apply_motion(&b, Pos::new(1, 0), Motion::LineFirstNonWhitespace),
273            Pos::new(1, 2)
274        );
275    }
276
277    #[test]
278    fn left_right_clamp_at_bounds() {
279        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
280        // Right at end-of-line can advance onto that empty line.
281        let b = TextBuffer::from_str("ab\n");
282        let p0 = Pos::new(0, 0);
283        assert_eq!(apply_motion(&b, p0, Motion::Left), Pos::new(0, 0));
284
285        // Moving right from end-of-line steps onto the newline, which maps to the next (empty) line at col 0.
286        let p_end = Pos::new(0, 2);
287        assert_eq!(apply_motion(&b, p_end, Motion::Right), Pos::new(1, 0));
288    }
289
290    #[test]
291    fn up_down_preserve_column_when_possible() {
292        let b = TextBuffer::from_str("aaaa\nb\ncccccc\n");
293        let p = Pos::new(0, 3);
294
295        let down = apply_motion(&b, p, Motion::Down);
296        // line 1 is "b" so col clamps to 1
297        assert_eq!(down, Pos::new(1, 1));
298
299        let down2 = apply_motion(&b, down, Motion::Down);
300        // line 2 is long enough, so col stays 1
301        assert_eq!(down2, Pos::new(2, 1));
302
303        let up = apply_motion(&b, down2, Motion::Up);
304        assert_eq!(up, Pos::new(1, 1));
305    }
306
307    #[test]
308    fn word_motions_ascii_smoke() {
309        let b = TextBuffer::from_str("abc  def_12!\n");
310        let p = Pos::new(0, 6); // in "def_12"
311
312        let start = apply_motion(&b, p, Motion::WordStartBefore);
313        assert_eq!(start, Pos::new(0, 5));
314
315        let end = apply_motion(&b, start, Motion::WordEndAfter);
316        assert_eq!(end, Pos::new(0, 10));
317    }
318
319    #[test]
320    fn repeated_word_forward_stops_at_eof() {
321        let b = TextBuffer::from_str("a b c\n");
322        let p = Pos::new(0, 0);
323        let p2 = apply_motion_n(&b, p, Motion::WordEndAfter, 100);
324
325        // Vim's 'e' motion stops at the last character of the last word.
326        assert_eq!(p2, Pos::new(0, 5));
327    }
328
329    #[test]
330    fn word_start_after_visits_symbol_tokens() {
331        let b = TextBuffer::from_str("(normal/insert/command)\n");
332        let mut p = Pos::new(0, 0);
333
334        p = apply_motion(&b, p, Motion::WordStartAfter);
335        assert_eq!(p, Pos::new(0, 1)); // normal
336
337        p = apply_motion(&b, p, Motion::WordStartAfter);
338        assert_eq!(p, Pos::new(0, 7)); // /
339
340        p = apply_motion(&b, p, Motion::WordStartAfter);
341        assert_eq!(p, Pos::new(0, 8)); // insert
342
343        p = apply_motion(&b, p, Motion::WordStartAfter);
344        assert_eq!(p, Pos::new(0, 14)); // /
345
346        p = apply_motion(&b, p, Motion::WordStartAfter);
347        assert_eq!(p, Pos::new(0, 15)); // command
348
349        p = apply_motion(&b, p, Motion::WordStartAfter);
350        assert_eq!(p, Pos::new(0, 22)); // )
351    }
352
353    #[test]
354    fn word_start_before_stops_on_symbol_token() {
355        let b = TextBuffer::from_str("(normal/insert)\n");
356        let p = Pos::new(0, 15); // after ')'
357        let p2 = apply_motion(&b, p, Motion::WordStartBefore);
358        assert_eq!(p2, Pos::new(0, 14)); // )
359    }
360
361    #[test]
362    fn word_end_after_can_land_on_symbol_token() {
363        let b = TextBuffer::from_str("alpha / beta\n");
364
365        let p = Pos::new(0, 0);
366        let p = apply_motion(&b, p, Motion::WordEndAfter);
367        assert_eq!(p, Pos::new(0, 4)); // alpha
368
369        let p = apply_motion(&b, p, Motion::WordEndAfter);
370        assert_eq!(p, Pos::new(0, 6)); // /
371    }
372
373    #[test]
374    fn find_and_till_char_stay_on_current_line() {
375        let b = TextBuffer::from_str("alpha beta alpha\n");
376        let cursor = Pos::new(0, 0);
377
378        assert_eq!(
379            apply_motion(&b, cursor, Motion::FindChar('b')),
380            Pos::new(0, 6)
381        );
382        assert_eq!(
383            apply_motion(&b, cursor, Motion::TillChar('b')),
384            Pos::new(0, 5)
385        );
386        assert_eq!(
387            apply_motion_n(&b, cursor, Motion::FindChar('a'), 2),
388            Pos::new(0, 9)
389        );
390    }
391
392    #[test]
393    fn operator_find_char_includes_the_target_character() {
394        let b = TextBuffer::from_str("alpha beta\n");
395        let cursor = Pos::new(0, 0);
396
397        assert_eq!(
398            apply_motion_for_operator(&b, cursor, Motion::FindChar('b'), 1),
399            Pos::new(0, 7)
400        );
401        assert_eq!(
402            apply_motion_for_operator(&b, cursor, Motion::TillChar('b'), 1),
403            Pos::new(0, 6)
404        );
405    }
406}