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