zee_edit/
movement.rs

1use ropey::Rope;
2
3use crate::{
4    graphemes::{RopeExt, RopeGraphemes},
5    Cursor,
6};
7
8/// The movement direction
9#[derive(Debug, Copy, Clone, PartialEq, Eq)]
10pub enum Direction {
11    Forward,
12    Backward,
13}
14
15/// Move the cursor horizontally in the specified direction by `count` positions
16/// (grapheme clusters)
17#[inline]
18pub fn move_horizontally(text: &Rope, cursor: &mut Cursor, direction: Direction, count: usize) {
19    let grapheme_start = match direction {
20        Direction::Forward => text.next_grapheme_boundary_n(cursor.range.start, count),
21        Direction::Backward => text.prev_grapheme_boundary_n(cursor.range.start, count),
22    };
23    cursor.range = grapheme_start..text.next_grapheme_boundary(grapheme_start);
24    cursor.visual_horizontal_offset = None;
25}
26
27/// Move the cursor vertically in the specified direction by `count` lines
28#[inline]
29pub fn move_vertically(
30    text: &Rope,
31    cursor: &mut Cursor,
32    tab_width: usize,
33    direction: Direction,
34    count: usize,
35) {
36    // The maximum possible line index in the text
37    let max_line_index = text.len_lines().saturating_sub(1);
38
39    // The current line index under cursor
40    let current_line_index = text.char_to_line(cursor.range.start);
41
42    // Compute the line index where the cursor will be after moving
43    let new_line_index = match direction {
44        // If cursor is not on the last line and moving forward (down), compute
45        // which line we'll end up on
46        Direction::Forward if current_line_index < max_line_index => {
47            std::cmp::min(current_line_index + count, max_line_index)
48        }
49        // If the cursor is on the last line and moving forward (down), move the
50        // cursor to the end of the line instead.
51        Direction::Forward if current_line_index == max_line_index => {
52            move_to_end_of_line(text, cursor);
53            return;
54        }
55        // If cursor is not on the first line and moving backward (up), compute
56        // which line we'll end up on
57        Direction::Backward if current_line_index > 0 => current_line_index.saturating_sub(count),
58        // Otherwise, nothing to do
59        _ => {
60            return;
61        }
62    };
63
64    let current_visual_x = cursor.visual_horizontal_offset.get_or_insert_with(|| {
65        let current_line_start = text.line_to_char(current_line_index);
66        let line_to_cursor = text.slice(current_line_start..cursor.range.start);
67        crate::graphemes::width(tab_width, &line_to_cursor)
68    });
69
70    let new_line = text.line(new_line_index);
71    let mut graphemes = RopeGraphemes::new(&new_line);
72    let mut new_visual_x = 0;
73    let mut char_offset = text.line_to_char(new_line_index);
74    for grapheme in &mut graphemes {
75        let width = crate::graphemes::width(tab_width, &grapheme);
76        if new_visual_x + width > *current_visual_x || grapheme.slice == "\n" {
77            break;
78        }
79        char_offset += grapheme.slice.len_chars();
80        new_visual_x += width;
81    }
82
83    cursor.range = char_offset..text.next_grapheme_boundary(char_offset);
84}
85
86/// Move the cursor in the specified direction by `count` words
87#[inline]
88pub fn move_word(text: &Rope, cursor: &mut Cursor, direction: Direction, count: usize) {
89    match direction {
90        Direction::Forward => {
91            for _ in 0..count {
92                move_forward_word(text, cursor);
93            }
94        }
95        Direction::Backward => {
96            for _ in 0..count {
97                move_backward_word(text, cursor);
98            }
99        }
100    }
101}
102
103/// Move the cursor forward by one word
104#[inline]
105pub fn move_forward_word(text: &Rope, cursor: &mut Cursor) {
106    let first_word_character =
107        skip_while_forward(text, cursor.range.start, |c| !is_word_character(c))
108            .unwrap_or_else(|| text.len_chars());
109    let grapheme_start = skip_while_forward(text, first_word_character, is_word_character)
110        .unwrap_or_else(|| text.len_chars());
111    cursor.range = grapheme_start..text.next_grapheme_boundary(grapheme_start);
112    cursor.visual_horizontal_offset = None;
113}
114
115/// Move the cursor backward by one word
116#[inline]
117pub fn move_backward_word(text: &Rope, cursor: &mut Cursor) {
118    let first_word_character =
119        skip_while_backward(text, cursor.range.start, |c| !is_word_character(c)).unwrap_or(0);
120    let grapheme_start =
121        skip_while_backward(text, first_word_character, is_word_character).unwrap_or(0);
122    cursor.range = grapheme_start..text.next_grapheme_boundary(grapheme_start);
123    cursor.visual_horizontal_offset = None;
124}
125
126/// Move the cursor in the specified direction by `count` paragraphs
127#[inline]
128pub fn move_paragraph(text: &Rope, cursor: &mut Cursor, direction: Direction, count: usize) {
129    match direction {
130        Direction::Forward => {
131            for _ in 0..count {
132                move_forward_paragraph(text, cursor);
133            }
134        }
135        Direction::Backward => {
136            for _ in 0..count {
137                move_backward_paragraph(text, cursor);
138            }
139        }
140    }
141}
142
143/// Move the cursor forward by one paragraph
144#[inline]
145pub fn move_forward_paragraph(text: &Rope, cursor: &mut Cursor) {
146    let current_line = text.char_to_line(cursor.range.start);
147    let lines = text.lines_at(current_line + 1);
148
149    let start = lines
150        .enumerate()
151        .find_map(|(index, line)| {
152            line.chars()
153                .all(char::is_whitespace)
154                .then(|| text.line_to_char(current_line + index + 1))
155        })
156        .unwrap_or_else(|| text.len_chars());
157    cursor.range = start..text.next_grapheme_boundary(start);
158    cursor.visual_horizontal_offset = None;
159}
160
161/// Move the cursor backward by one paragraph
162#[inline]
163pub fn move_backward_paragraph(text: &Rope, cursor: &mut Cursor) {
164    let current_line = text.char_to_line(cursor.range.start);
165    let mut lines = text.lines_at(current_line.saturating_sub(1));
166    lines.reverse();
167
168    let start = lines
169        .enumerate()
170        .find_map(|(index, line)| {
171            line.chars()
172                .all(char::is_whitespace)
173                .then(|| text.line_to_char(current_line.saturating_sub(index + 1)))
174        })
175        .unwrap_or(0);
176    cursor.range = start..text.next_grapheme_boundary(start);
177    cursor.visual_horizontal_offset = None;
178}
179
180/// Move the cursor to the beginning of the current line
181#[inline]
182pub fn move_to_start_of_line(text: &Rope, cursor: &mut Cursor) {
183    let line_start = text.line_to_char(text.char_to_line(cursor.range.start));
184    cursor.range = line_start..text.next_grapheme_boundary(line_start);
185    cursor.visual_horizontal_offset = None;
186}
187
188/// Move the cursor to the end of the current line
189#[inline]
190pub fn move_to_end_of_line(text: &Rope, cursor: &mut Cursor) {
191    let line_index = text.char_to_line(cursor.range.start);
192    let line = text.line(line_index);
193    let line_start = text.line_to_char(line_index);
194    let line_length = line.len_chars();
195    let range_end = line_start + line_length;
196
197    let range_start = if line_length == 0 || line.char(line_length - 1) != '\n' {
198        // If the current line has no newline at the end (we're at the end of the
199        // buffer), create an empty range, i.e. range_start == range_end
200        range_end
201    } else {
202        // Otherwise, select the last character in the line, guaranteed a newline
203        range_end.saturating_sub(1)
204    };
205
206    cursor.range = range_start..range_end;
207    cursor.visual_horizontal_offset = None;
208}
209
210/// Move the cursor to the beginning of the text
211#[inline]
212pub fn move_to_start_of_buffer(text: &Rope, cursor: &mut Cursor) {
213    cursor.range = 0..text.next_grapheme_boundary(0);
214    cursor.visual_horizontal_offset = None;
215}
216
217/// Move the cursor to the end of the text
218#[inline]
219pub fn move_to_end_of_buffer(text: &Rope, cursor: &mut Cursor) {
220    let length = text.len_chars();
221    cursor.range = length..length;
222    cursor.visual_horizontal_offset = None;
223}
224
225#[inline]
226fn skip_while_forward(
227    text: &Rope,
228    position: usize,
229    predicate: impl Fn(char) -> bool,
230) -> Option<usize> {
231    text.chars_at(position)
232        .enumerate()
233        .find_map(|(index, character)| (!predicate(character)).then(|| position + index))
234}
235
236#[inline]
237fn skip_while_backward(
238    text: &Rope,
239    position: usize,
240    predicate: impl Fn(char) -> bool,
241) -> Option<usize> {
242    let mut chars = text.chars_at(position);
243    chars.reverse();
244    chars.enumerate().find_map(|(index, character)| {
245        (!predicate(character)).then(|| position.saturating_sub(index))
246    })
247}
248
249#[inline]
250fn is_word_character(character: char) -> bool {
251    character == '_' || (!character.is_whitespace() && !character.is_ascii_punctuation())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::{super::RopeCursorExt, *};
257    use ropey::Rope;
258
259    // Some test helpers on Cursor
260    impl Cursor {
261        fn move_right(&mut self, text: &Rope) {
262            move_horizontally(text, self, Direction::Forward, 1)
263        }
264
265        fn move_left(&mut self, text: &Rope) {
266            move_horizontally(text, self, Direction::Backward, 1)
267        }
268    }
269
270    fn text_with_cursor(text: impl Into<Rope>) -> (Rope, Cursor) {
271        (text.into(), Cursor::new())
272    }
273
274    #[test]
275    fn move_right_on_empty_text() {
276        let (text, mut cursor) = text_with_cursor("");
277        cursor.move_right(&text);
278        assert_eq!(cursor, Cursor::new());
279
280        let (text, mut cursor) = text_with_cursor("\n");
281        cursor.move_right(&text);
282        assert_eq!(cursor, Cursor::with_range(1..1));
283    }
284
285    #[test]
286    fn move_right_at_the_end() {
287        let (text, mut cursor) = text_with_cursor(TEXT);
288        move_to_end_of_buffer(&text, &mut cursor);
289        let cursor_at_end = cursor.clone();
290        cursor.move_right(&text);
291        assert_eq!(cursor_at_end, cursor);
292        assert_eq!(
293            cursor,
294            Cursor::with_range(text.len_chars()..text.len_chars())
295        );
296    }
297
298    #[test]
299    fn move_left_at_the_begining() {
300        let text = Rope::from(TEXT);
301        let mut cursor = Cursor::new();
302        cursor.move_left(&text);
303        assert_eq!(Cursor::with_range(0..1), cursor);
304    }
305
306    #[test]
307    fn move_wide_grapheme() {
308        let text = Rope::from(MULTI_CHAR_EMOJI);
309        let mut cursor = Cursor::new();
310        move_to_start_of_buffer(&text, &mut cursor);
311        assert_eq!(0..text.len_chars(), cursor.range);
312    }
313
314    #[test]
315    fn move_by_zero_positions() {
316        let (text, mut cursor) = text_with_cursor("Hello\n");
317        move_horizontally(&text, &mut cursor, Direction::Backward, 0);
318        assert_eq!(Cursor::with_range(0..1), cursor);
319        move_horizontally(&text, &mut cursor, Direction::Forward, 0);
320        assert_eq!(Cursor::with_range(0..1), cursor);
321
322        cursor.range = 1..2;
323        move_horizontally(&text, &mut cursor, Direction::Backward, 0);
324        assert_eq!(cursor.range, 1..2);
325        move_horizontally(&text, &mut cursor, Direction::Forward, 0);
326        assert_eq!(cursor.range, 1..2);
327    }
328
329    #[test]
330    fn move_backward_on_empty_text() {
331        let (text, mut cursor) = text_with_cursor("");
332        move_horizontally(&text, &mut cursor, Direction::Backward, 1);
333        assert_eq!(Cursor::new(), cursor);
334    }
335
336    #[test]
337    fn move_backward_at_the_begining() {
338        let (text, mut cursor) = text_with_cursor("The flowers were blooming.\n");
339        move_horizontally(&text, &mut cursor, Direction::Backward, 1);
340        assert_eq!(cursor, Cursor::with_range(0..1),);
341        assert_eq!(text.slice_cursor(&cursor), "T");
342    }
343
344    const TEXT: &str = r#"
345Basic Latin
346    ! " # $ % & ' ( ) *+,-./012ABCDEFGHI` a m  t u v z { | } ~
347CJK
348    ๏ค€ ๏ค ๏ค‚ โ…ง
349"#;
350    const MULTI_CHAR_EMOJI: &str = r#"๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง"#;
351}