Skip to main content

datui_lib/widgets/textarea/
cursor.rs

1//! Cursor movement over a text buffer.
2//!
3//! Positions are `(row, col)` pairs where `row` indexes [`super::TextArea::lines`]
4//! and `col` is a *character* index inside that line. Display width only matters
5//! at render time, so nothing in this module deals in terminal columns.
6
7/// A cursor movement request.
8///
9/// Passed to [`super::TextArea::move_cursor`].
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CursorMove {
12    /// One character to the right, wrapping to the next line.
13    Forward,
14    /// One character to the left, wrapping to the previous line.
15    Back,
16    /// One line up, keeping the column where possible.
17    Up,
18    /// One line down, keeping the column where possible.
19    Down,
20    /// Start of the current line.
21    Head,
22    /// End of the current line.
23    End,
24    /// Start of the first line.
25    Top,
26    /// End of the last line.
27    Bottom,
28    /// Start of the next word.
29    WordForward,
30    /// Start of the previous word.
31    WordBack,
32    /// Up by `n` lines, clamped at the first line.
33    UpBy(usize),
34    /// Down by `n` lines, clamped at the last line.
35    DownBy(usize),
36    /// An absolute position, clamped into the buffer.
37    Jump(usize, usize),
38}
39
40/// Character classes used to decide where a word starts and ends.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum CharClass {
43    Space,
44    Word,
45    Punctuation,
46}
47
48fn class_of(c: char) -> CharClass {
49    if c.is_whitespace() {
50        CharClass::Space
51    } else if c.is_alphanumeric() || c == '_' {
52        CharClass::Word
53    } else {
54        CharClass::Punctuation
55    }
56}
57
58/// Index of the start of the word after `col`, or the line length when the rest
59/// of the line holds no further word.
60pub(super) fn next_word_start(chars: &[char], col: usize) -> usize {
61    let len = chars.len();
62    let mut i = col.min(len);
63    if i == len {
64        return len;
65    }
66    // Step over the run the cursor currently sits in, then over any whitespace.
67    let start_class = class_of(chars[i]);
68    if start_class != CharClass::Space {
69        while i < len && class_of(chars[i]) == start_class {
70            i += 1;
71        }
72    }
73    while i < len && class_of(chars[i]) == CharClass::Space {
74        i += 1;
75    }
76    i
77}
78
79/// Index of the start of the word before `col`, or 0.
80pub(super) fn prev_word_start(chars: &[char], col: usize) -> usize {
81    let mut i = col.min(chars.len());
82    while i > 0 && class_of(chars[i - 1]) == CharClass::Space {
83        i -= 1;
84    }
85    if i == 0 {
86        return 0;
87    }
88    let class = class_of(chars[i - 1]);
89    while i > 0 && class_of(chars[i - 1]) == class {
90        i -= 1;
91    }
92    i
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn chars(s: &str) -> Vec<char> {
100        s.chars().collect()
101    }
102
103    #[test]
104    fn next_word_start_skips_run_then_whitespace() {
105        let c = chars("hello world");
106        assert_eq!(next_word_start(&c, 0), 6);
107        assert_eq!(next_word_start(&c, 3), 6);
108        assert_eq!(next_word_start(&c, 6), 11);
109        assert_eq!(next_word_start(&c, 11), 11);
110    }
111
112    #[test]
113    fn next_word_start_treats_punctuation_as_its_own_run() {
114        let c = chars("a == b");
115        assert_eq!(next_word_start(&c, 0), 2);
116        assert_eq!(next_word_start(&c, 2), 5);
117    }
118
119    #[test]
120    fn prev_word_start_walks_back_over_whitespace() {
121        let c = chars("hello world");
122        assert_eq!(prev_word_start(&c, 11), 6);
123        assert_eq!(prev_word_start(&c, 6), 0);
124        assert_eq!(prev_word_start(&c, 0), 0);
125        assert_eq!(prev_word_start(&c, 8), 6);
126    }
127
128    #[test]
129    fn word_moves_handle_empty_lines() {
130        let c: Vec<char> = Vec::new();
131        assert_eq!(next_word_start(&c, 0), 0);
132        assert_eq!(prev_word_start(&c, 0), 0);
133    }
134
135    #[test]
136    fn word_moves_are_unicode_aware() {
137        let c = chars("héllo wörld");
138        assert_eq!(next_word_start(&c, 0), 6);
139        assert_eq!(prev_word_start(&c, 11), 6);
140    }
141}