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    #[cfg(test)]
38    Jump(usize, usize),
39}
40
41/// Character classes used to decide where a word starts and ends.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum CharClass {
44    Space,
45    Word,
46    Punctuation,
47}
48
49fn class_of(c: char) -> CharClass {
50    if c.is_whitespace() {
51        CharClass::Space
52    } else if c.is_alphanumeric() || c == '_' {
53        CharClass::Word
54    } else {
55        CharClass::Punctuation
56    }
57}
58
59/// Index of the start of the word after `col`, or the line length when the rest
60/// of the line holds no further word.
61pub(super) fn next_word_start(chars: &[char], col: usize) -> usize {
62    let len = chars.len();
63    let mut i = col.min(len);
64    if i == len {
65        return len;
66    }
67    // Step over the run the cursor currently sits in, then over any whitespace.
68    let start_class = class_of(chars[i]);
69    if start_class != CharClass::Space {
70        while i < len && class_of(chars[i]) == start_class {
71            i += 1;
72        }
73    }
74    while i < len && class_of(chars[i]) == CharClass::Space {
75        i += 1;
76    }
77    i
78}
79
80/// Index of the start of the word before `col`, or 0.
81pub(super) fn prev_word_start(chars: &[char], col: usize) -> usize {
82    let mut i = col.min(chars.len());
83    while i > 0 && class_of(chars[i - 1]) == CharClass::Space {
84        i -= 1;
85    }
86    if i == 0 {
87        return 0;
88    }
89    let class = class_of(chars[i - 1]);
90    while i > 0 && class_of(chars[i - 1]) == class {
91        i -= 1;
92    }
93    i
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn chars(s: &str) -> Vec<char> {
101        s.chars().collect()
102    }
103
104    #[test]
105    fn next_word_start_skips_run_then_whitespace() {
106        let c = chars("hello world");
107        assert_eq!(next_word_start(&c, 0), 6);
108        assert_eq!(next_word_start(&c, 3), 6);
109        assert_eq!(next_word_start(&c, 6), 11);
110        assert_eq!(next_word_start(&c, 11), 11);
111    }
112
113    #[test]
114    fn next_word_start_treats_punctuation_as_its_own_run() {
115        let c = chars("a == b");
116        assert_eq!(next_word_start(&c, 0), 2);
117        assert_eq!(next_word_start(&c, 2), 5);
118    }
119
120    #[test]
121    fn prev_word_start_walks_back_over_whitespace() {
122        let c = chars("hello world");
123        assert_eq!(prev_word_start(&c, 11), 6);
124        assert_eq!(prev_word_start(&c, 6), 0);
125        assert_eq!(prev_word_start(&c, 0), 0);
126        assert_eq!(prev_word_start(&c, 8), 6);
127    }
128
129    #[test]
130    fn word_moves_handle_empty_lines() {
131        let c: Vec<char> = Vec::new();
132        assert_eq!(next_word_start(&c, 0), 0);
133        assert_eq!(prev_word_start(&c, 0), 0);
134    }
135
136    #[test]
137    fn word_moves_are_unicode_aware() {
138        let c = chars("héllo wörld");
139        assert_eq!(next_word_start(&c, 0), 6);
140        assert_eq!(prev_word_start(&c, 11), 6);
141    }
142}