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//! - Many Vim details (e.g. inclusive/exclusive, operator-pending behavior,
18//!   `iskeyword`, sentence/paragraph motions) can be layered later without
19//!   changing the frontend.
20//!
21//! This file defines:
22//! - [`Motion`] enum: the set of supported navigation intents.
23//! - [`apply_motion`] / [`apply_motion_n`]: apply motions to a cursor position.
24
25use crate::{Pos, TextBuffer};
26
27/// A navigation intent (motion) that transforms a document cursor.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum Motion {
30    /// Move left by one char.
31    Left,
32    /// Move right by one char.
33    Right,
34    /// Move up by one line.
35    Up,
36    /// Move down by one line.
37    Down,
38
39    /// Go to first line of file (`gg`).
40    FileStart,
41
42    /// Go to last line of file (`G`). Column is clamped to that line.
43    FileEnd,
44
45    /// Go to start of line (`0`-ish).
46    LineStart,
47
48    /// Go to end of line (`$`-ish), i.e. `line_len_chars(line)`.
49    LineEnd,
50
51    /// Move to start of previous word (`b`-ish).
52    WordStartBefore,
53
54    /// Move to start of next word (`w`-ish).
55    WordStartAfter,
56
57    /// Move to end of next word (`e`-ish).
58    WordEndAfter,
59}
60
61/// Apply a single `Motion` to a given cursor position.
62///
63/// This function is pure: it never mutates the buffer, and always returns a
64/// position clamped to valid buffer bounds.
65#[inline]
66pub fn apply_motion(buffer: &TextBuffer, cursor: Pos, motion: Motion) -> Pos {
67    let cursor = buffer.clamp_pos(cursor);
68
69    match motion {
70        Motion::Left => buffer.move_left(cursor),
71        Motion::Right => buffer.move_right(cursor),
72        Motion::Up => buffer.move_up(cursor),
73        Motion::Down => buffer.move_down(cursor),
74
75        Motion::FileStart => {
76            let target_col = if cursor.line == 0 { 0 } else { cursor.col };
77            buffer.clamp_pos(Pos::new(0, target_col))
78        }
79
80        Motion::FileEnd => {
81            let last = buffer.len_lines().saturating_sub(1);
82            buffer.clamp_pos(Pos::new(last, cursor.col))
83        }
84
85        Motion::LineStart => Pos::new(cursor.line, 0),
86
87        Motion::LineEnd => {
88            let line = buffer.clamp_line(cursor.line);
89            let end_col = buffer.line_len_chars(line);
90            Pos::new(line, end_col)
91        }
92
93        Motion::WordStartBefore => buffer.word_start_before(cursor),
94
95        Motion::WordStartAfter => buffer.word_start_after(cursor),
96
97        Motion::WordEndAfter => buffer.word_end_after(cursor),
98    }
99}
100
101/// Apply a motion `count` times (Vim-style numeric prefix).
102///
103/// - If `count == 0`, this returns `cursor` unchanged.
104/// - Motions are applied iteratively so they can clamp naturally at boundaries.
105pub fn apply_motion_n(buffer: &TextBuffer, cursor: Pos, motion: Motion, count: usize) -> Pos {
106    let mut cur = buffer.clamp_pos(cursor);
107    for _ in 0..count {
108        let next = apply_motion(buffer, cur, motion);
109        // If the motion stops making progress (EOF/top/etc.), stop early.
110        if next == cur {
111            break;
112        }
113        cur = next;
114    }
115    cur
116}
117
118/// Convenience helpers for motions that take a count.
119pub mod helpers {
120    use super::{apply_motion_n, Motion};
121    use crate::{Pos, TextBuffer};
122
123    /// Move forward by words (`w`-ish) by applying `WordStartAfter` repeatedly.
124    #[inline]
125    pub fn word_forward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
126        apply_motion_n(buffer, cursor, Motion::WordStartAfter, count)
127    }
128
129    /// Move backward by words (`b`-ish) by applying `WordStartBefore` repeatedly.
130    #[inline]
131    pub fn word_backward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
132        apply_motion_n(buffer, cursor, Motion::WordStartBefore, count)
133    }
134
135    /// Move to the first line (`gg`).
136    #[inline]
137    pub fn gg(buffer: &TextBuffer, cursor: Pos) -> Pos {
138        super::apply_motion(buffer, cursor, Motion::FileStart)
139    }
140
141    /// Move to the last line (`G`).
142    #[inline]
143    pub fn file_end(buffer: &TextBuffer, cursor: Pos) -> Pos {
144        super::apply_motion(buffer, cursor, Motion::FileEnd)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn motion_count_zero_is_noop() {
154        let b = TextBuffer::from_str("abc\n");
155        let p = Pos::new(0, 2);
156        assert_eq!(apply_motion_n(&b, p, Motion::Left, 0), p);
157        assert_eq!(apply_motion_n(&b, p, Motion::WordEndAfter, 0), p);
158    }
159
160    #[test]
161    fn gg_goes_to_first_line_and_clamps_column() {
162        let b = TextBuffer::from_str("a\nbb\nccc\n");
163        let p = Pos::new(2, 2);
164        let p2 = apply_motion(&b, p, Motion::FileStart);
165        assert_eq!(p2.line, 0);
166        // first line is "a" so col clamps to 1
167        assert_eq!(p2.col, 1);
168    }
169
170    #[test]
171    fn file_end_goes_to_last_line_and_clamps_column() {
172        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
173        // So "a\nbb\nccc\n" has 4 lines: "a", "bb", "ccc", "".
174        let b = TextBuffer::from_str("a\nbb\nccc\n");
175        let p = Pos::new(0, 10);
176        let p2 = apply_motion(&b, p, Motion::FileEnd);
177
178        // Last line is the empty line after the trailing newline.
179        assert_eq!(p2.line, 3);
180        assert_eq!(p2.col, 0);
181    }
182
183    #[test]
184    fn line_start_and_line_end_work() {
185        let b = TextBuffer::from_str("hello\nworld!\n");
186        let p = Pos::new(1, 2);
187
188        let start = apply_motion(&b, p, Motion::LineStart);
189        assert_eq!(start, Pos::new(1, 0));
190
191        let end = apply_motion(&b, p, Motion::LineEnd);
192        assert_eq!(end, Pos::new(1, 6));
193    }
194
195    #[test]
196    fn left_right_clamp_at_bounds() {
197        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
198        // Right at end-of-line can advance onto that empty line.
199        let b = TextBuffer::from_str("ab\n");
200        let p0 = Pos::new(0, 0);
201        assert_eq!(apply_motion(&b, p0, Motion::Left), Pos::new(0, 0));
202
203        // Moving right from end-of-line steps onto the newline, which maps to the next (empty) line at col 0.
204        let p_end = Pos::new(0, 2);
205        assert_eq!(apply_motion(&b, p_end, Motion::Right), Pos::new(1, 0));
206    }
207
208    #[test]
209    fn up_down_preserve_column_when_possible() {
210        let b = TextBuffer::from_str("aaaa\nb\ncccccc\n");
211        let p = Pos::new(0, 3);
212
213        let down = apply_motion(&b, p, Motion::Down);
214        // line 1 is "b" so col clamps to 1
215        assert_eq!(down, Pos::new(1, 1));
216
217        let down2 = apply_motion(&b, down, Motion::Down);
218        // line 2 is long enough, so col stays 1
219        assert_eq!(down2, Pos::new(2, 1));
220
221        let up = apply_motion(&b, down2, Motion::Up);
222        assert_eq!(up, Pos::new(1, 1));
223    }
224
225    #[test]
226    fn word_motions_ascii_smoke() {
227        let b = TextBuffer::from_str("abc  def_12!\n");
228        let p = Pos::new(0, 6); // in "def_12"
229
230        let start = apply_motion(&b, p, Motion::WordStartBefore);
231        assert_eq!(start, Pos::new(0, 5));
232
233        let end = apply_motion(&b, start, Motion::WordEndAfter);
234        assert_eq!(end, Pos::new(0, 10));
235    }
236
237    #[test]
238    fn repeated_word_forward_stops_at_eof() {
239        let b = TextBuffer::from_str("a b c\n");
240        let p = Pos::new(0, 0);
241        let p2 = apply_motion_n(&b, p, Motion::WordEndAfter, 100);
242
243        // Vim's 'e' motion stops at the last character of the last word.
244        assert_eq!(p2, Pos::new(0, 5));
245    }
246
247    #[test]
248    fn word_start_after_visits_symbol_tokens() {
249        let b = TextBuffer::from_str("(normal/insert/command)\n");
250        let mut p = Pos::new(0, 0);
251
252        p = apply_motion(&b, p, Motion::WordStartAfter);
253        assert_eq!(p, Pos::new(0, 1)); // normal
254
255        p = apply_motion(&b, p, Motion::WordStartAfter);
256        assert_eq!(p, Pos::new(0, 7)); // /
257
258        p = apply_motion(&b, p, Motion::WordStartAfter);
259        assert_eq!(p, Pos::new(0, 8)); // insert
260
261        p = apply_motion(&b, p, Motion::WordStartAfter);
262        assert_eq!(p, Pos::new(0, 14)); // /
263
264        p = apply_motion(&b, p, Motion::WordStartAfter);
265        assert_eq!(p, Pos::new(0, 15)); // command
266
267        p = apply_motion(&b, p, Motion::WordStartAfter);
268        assert_eq!(p, Pos::new(0, 22)); // )
269    }
270
271    #[test]
272    fn word_start_before_stops_on_symbol_token() {
273        let b = TextBuffer::from_str("(normal/insert)\n");
274        let p = Pos::new(0, 15); // after ')'
275        let p2 = apply_motion(&b, p, Motion::WordStartBefore);
276        assert_eq!(p2, Pos::new(0, 14)); // )
277    }
278
279    #[test]
280    fn word_end_after_can_land_on_symbol_token() {
281        let b = TextBuffer::from_str("alpha / beta\n");
282
283        let p = Pos::new(0, 0);
284        let p = apply_motion(&b, p, Motion::WordEndAfter);
285        assert_eq!(p, Pos::new(0, 4)); // alpha
286
287        let p = apply_motion(&b, p, Motion::WordEndAfter);
288        assert_eq!(p, Pos::new(0, 6)); // /
289    }
290}