Skip to main content

termesh_editor/
movement.rs

1//! Cursor motion over a rope. Pure functions on `(text, position)`, so every rule here
2//! is testable without constructing a [`crate::Buffer`].
3//!
4//! **Char-wise, not grapheme-wise, for now.** ADR-0006 §1 puts grapheme awareness at this
5//! layer — one arrow press should cross a whole cluster, so an emoji with a skin-tone
6//! modifier takes one keypress and not three. That refinement belongs here when it lands
7//! and changes nothing above; the change representation stays char-indexed either way,
8//! which is exactly why the ADR separated the two.
9
10use ropey::Rope;
11
12/// The line `pos` sits on.
13pub fn line_of(text: &Rope, pos: usize) -> usize {
14    text.char_to_line(pos.min(text.len_chars()))
15}
16
17/// The column of `pos` within its line, in chars.
18pub fn column_of(text: &Rope, pos: usize) -> usize {
19    let pos = pos.min(text.len_chars());
20    pos - text.line_to_char(text.char_to_line(pos))
21}
22
23/// First char of the line `pos` is on.
24pub fn line_start(text: &Rope, pos: usize) -> usize {
25    text.line_to_char(line_of(text, pos))
26}
27
28/// Last char of the line `pos` is on, *before* its terminator — pressing End should land
29/// at the end of the visible text, not on the far side of the newline.
30pub fn line_end(text: &Rope, pos: usize) -> usize {
31    let line = line_of(text, pos);
32    let start = text.line_to_char(line);
33    let slice = text.line(line);
34    let len = slice.len_chars();
35    let visible = if len > 0 && slice.char(len - 1) == '\n' { len - 1 } else { len };
36    start + visible
37}
38
39pub fn left(text: &Rope, pos: usize) -> usize {
40    let _ = text;
41    pos.saturating_sub(1)
42}
43
44pub fn right(text: &Rope, pos: usize) -> usize {
45    (pos + 1).min(text.len_chars())
46}
47
48/// Move one line up, aiming for `goal` if the caller is tracking a sticky column.
49///
50/// Returns `pos` unchanged on the first line, so holding Up parks the cursor rather than
51/// wrapping around to the end of the file.
52pub fn up(text: &Rope, pos: usize, goal: Option<usize>) -> usize {
53    let line = line_of(text, pos);
54    if line == 0 {
55        return pos;
56    }
57    to_line(text, line - 1, goal.unwrap_or_else(|| column_of(text, pos)))
58}
59
60pub fn down(text: &Rope, pos: usize, goal: Option<usize>) -> usize {
61    let line = line_of(text, pos);
62    if line + 1 >= text.len_lines() {
63        return pos;
64    }
65    to_line(text, line + 1, goal.unwrap_or_else(|| column_of(text, pos)))
66}
67
68/// Land on `line` at `column`, or at its end if the line is shorter.
69///
70/// The column is *not* clamped permanently — that is the caller's sticky-column job. A
71/// cursor that steps down past a short line and back up belongs where it started, which
72/// only works if the goal outlives the clamp.
73fn to_line(text: &Rope, line: usize, column: usize) -> usize {
74    let start = text.line_to_char(line);
75    let end = line_end(text, start);
76    (start + column).min(end)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn rope(s: &str) -> Rope {
84        Rope::from_str(s)
85    }
86
87    #[test]
88    fn columns_and_lines_are_reported_from_the_line_start() {
89        let t = rope("abc\ndefgh\n");
90        assert_eq!((line_of(&t, 0), column_of(&t, 0)), (0, 0));
91        assert_eq!((line_of(&t, 2), column_of(&t, 2)), (0, 2));
92        assert_eq!((line_of(&t, 4), column_of(&t, 4)), (1, 0));
93        assert_eq!((line_of(&t, 7), column_of(&t, 7)), (1, 3));
94    }
95
96    #[test]
97    fn horizontal_motion_stops_at_both_ends_of_the_document() {
98        let t = rope("ab");
99        assert_eq!(left(&t, 0), 0, "no wrapping off the front");
100        assert_eq!(right(&t, 2), 2, "no running off the end");
101        assert_eq!(right(&t, 0), 1);
102        assert_eq!(left(&t, 2), 1);
103    }
104
105    #[test]
106    fn end_lands_before_the_newline_not_after_it() {
107        let t = rope("abc\ndef\n");
108        assert_eq!(line_end(&t, 0), 3, "the end of 'abc', not the start of 'def'");
109        assert_eq!(line_start(&t, 5), 4);
110    }
111
112    #[test]
113    fn end_of_a_final_line_without_a_trailing_newline() {
114        let t = rope("abc\ndef");
115        assert_eq!(line_end(&t, 5), 7);
116    }
117
118    #[test]
119    fn end_of_an_empty_line() {
120        let t = rope("abc\n\ndef");
121        assert_eq!(line_end(&t, 4), 4, "an empty line starts and ends in the same place");
122    }
123
124    #[test]
125    fn vertical_motion_parks_at_the_first_and_last_line() {
126        let t = rope("abc\ndef");
127        assert_eq!(up(&t, 1, None), 1, "already on the first line");
128        assert_eq!(down(&t, 5, None), 5, "already on the last line");
129    }
130
131    #[test]
132    fn moving_down_keeps_the_column() {
133        let t = rope("abcdef\nghijkl\n");
134        assert_eq!(down(&t, 3, None), 10, "column 3 on the next line");
135    }
136
137    #[test]
138    fn a_short_line_clamps_the_column_without_losing_it() {
139        // The sticky-column rule: stepping through a short line and back must return the
140        // cursor to where it started, which only works if the goal survives the clamp.
141        let t = rope("abcdefgh\nxy\nabcdefgh\n");
142        let start = 6; // line 0, column 6
143
144        let goal = Some(column_of(&t, start));
145        let middle = down(&t, start, goal);
146        assert_eq!(column_of(&t, middle), 2, "clamped to the short line");
147
148        let back = down(&t, middle, goal);
149        assert_eq!(column_of(&t, back), 6, "and restored on the next long one");
150    }
151
152    #[test]
153    fn without_a_goal_the_column_is_taken_from_where_we_are() {
154        let t = rope("abcdefgh\nxy\nabcdefgh\n");
155        let middle = down(&t, 6, None);
156        let back = down(&t, middle, None);
157        assert_eq!(column_of(&t, back), 2, "no sticky column, so the clamp sticks");
158    }
159
160    #[test]
161    fn motion_is_by_char_across_multibyte_text() {
162        let t = rope("héllo");
163        assert_eq!(right(&t, 1), 2, "one press crosses 'é' once, not twice");
164        assert_eq!(column_of(&t, 5), 5);
165    }
166
167    #[test]
168    fn positions_past_the_end_are_treated_as_the_end() {
169        let t = rope("abc");
170        assert_eq!(line_of(&t, 99), 0);
171        assert_eq!(column_of(&t, 99), 3);
172    }
173}