egui/text_selection/
text_cursor_state.rs

1//! Text cursor changes/interaction, without modifying the text.
2
3use epaint::text::{Galley, cursor::CCursor};
4use unicode_segmentation::UnicodeSegmentation as _;
5
6use crate::{NumExt as _, Rect, Response, Ui, epaint};
7
8use super::CCursorRange;
9
10/// The state of a text cursor selection.
11///
12/// Used for [`crate::TextEdit`] and [`crate::Label`].
13#[derive(Clone, Copy, Debug, Default)]
14#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
15#[cfg_attr(feature = "serde", serde(default))]
16pub struct TextCursorState {
17    ccursor_range: Option<CCursorRange>,
18}
19
20impl From<CCursorRange> for TextCursorState {
21    fn from(ccursor_range: CCursorRange) -> Self {
22        Self {
23            ccursor_range: Some(ccursor_range),
24        }
25    }
26}
27
28impl TextCursorState {
29    pub fn is_empty(&self) -> bool {
30        self.ccursor_range.is_none()
31    }
32
33    /// The currently selected range of characters.
34    pub fn char_range(&self) -> Option<CCursorRange> {
35        self.ccursor_range
36    }
37
38    /// The currently selected range of characters, clamped within the character
39    /// range of the given [`Galley`].
40    pub fn range(&self, galley: &Galley) -> Option<CCursorRange> {
41        self.ccursor_range.map(|mut range| {
42            range.primary = galley.clamp_cursor(&range.primary);
43            range.secondary = galley.clamp_cursor(&range.secondary);
44            range
45        })
46    }
47
48    /// Sets the currently selected range of characters.
49    pub fn set_char_range(&mut self, ccursor_range: Option<CCursorRange>) {
50        self.ccursor_range = ccursor_range;
51    }
52}
53
54impl TextCursorState {
55    /// Handle clicking and/or dragging text.
56    ///
57    /// Returns `true` if there was interaction.
58    pub fn pointer_interaction(
59        &mut self,
60        ui: &Ui,
61        response: &Response,
62        cursor_at_pointer: CCursor,
63        galley: &Galley,
64        is_being_dragged: bool,
65    ) -> bool {
66        let text = galley.text();
67
68        if response.double_clicked() {
69            // Select word:
70            let ccursor_range = select_word_at(text, cursor_at_pointer);
71            self.set_char_range(Some(ccursor_range));
72            true
73        } else if response.triple_clicked() {
74            // Select line:
75            let ccursor_range = select_line_at(text, cursor_at_pointer);
76            self.set_char_range(Some(ccursor_range));
77            true
78        } else if response.sense.senses_drag() {
79            if response.hovered() && ui.input(|i| i.pointer.any_pressed()) {
80                // The start of a drag (or a click).
81                if ui.input(|i| i.modifiers.shift) {
82                    if let Some(mut cursor_range) = self.range(galley) {
83                        cursor_range.primary = cursor_at_pointer;
84                        self.set_char_range(Some(cursor_range));
85                    } else {
86                        self.set_char_range(Some(CCursorRange::one(cursor_at_pointer)));
87                    }
88                } else {
89                    self.set_char_range(Some(CCursorRange::one(cursor_at_pointer)));
90                }
91                true
92            } else if is_being_dragged {
93                // Drag to select text:
94                if let Some(mut cursor_range) = self.range(galley) {
95                    cursor_range.primary = cursor_at_pointer;
96                    self.set_char_range(Some(cursor_range));
97                }
98                true
99            } else {
100                false
101            }
102        } else {
103            false
104        }
105    }
106}
107
108fn select_word_at(text: &str, ccursor: CCursor) -> CCursorRange {
109    if ccursor.index == 0 {
110        CCursorRange::two(ccursor, ccursor_next_word(text, ccursor))
111    } else {
112        let it = text.chars();
113        let mut it = it.skip(ccursor.index - 1);
114        if let Some(char_before_cursor) = it.next() {
115            if let Some(char_after_cursor) = it.next() {
116                if is_word_char(char_before_cursor) && is_word_char(char_after_cursor) {
117                    let min = ccursor_previous_word(text, ccursor + 1);
118                    let max = ccursor_next_word(text, min);
119                    CCursorRange::two(min, max)
120                } else if is_word_char(char_before_cursor) {
121                    let min = ccursor_previous_word(text, ccursor);
122                    let max = ccursor_next_word(text, min);
123                    CCursorRange::two(min, max)
124                } else if is_word_char(char_after_cursor) {
125                    let max = ccursor_next_word(text, ccursor);
126                    CCursorRange::two(ccursor, max)
127                } else {
128                    let min = ccursor_previous_word(text, ccursor);
129                    let max = ccursor_next_word(text, ccursor);
130                    CCursorRange::two(min, max)
131                }
132            } else {
133                let min = ccursor_previous_word(text, ccursor);
134                CCursorRange::two(min, ccursor)
135            }
136        } else {
137            let max = ccursor_next_word(text, ccursor);
138            CCursorRange::two(ccursor, max)
139        }
140    }
141}
142
143fn select_line_at(text: &str, ccursor: CCursor) -> CCursorRange {
144    if ccursor.index == 0 {
145        CCursorRange::two(ccursor, ccursor_next_line(text, ccursor))
146    } else {
147        let it = text.chars();
148        let mut it = it.skip(ccursor.index - 1);
149        if let Some(char_before_cursor) = it.next() {
150            if let Some(char_after_cursor) = it.next() {
151                if (!is_linebreak(char_before_cursor)) && (!is_linebreak(char_after_cursor)) {
152                    let min = ccursor_previous_line(text, ccursor + 1);
153                    let max = ccursor_next_line(text, min);
154                    CCursorRange::two(min, max)
155                } else if !is_linebreak(char_before_cursor) {
156                    let min = ccursor_previous_line(text, ccursor);
157                    let max = ccursor_next_line(text, min);
158                    CCursorRange::two(min, max)
159                } else if !is_linebreak(char_after_cursor) {
160                    let max = ccursor_next_line(text, ccursor);
161                    CCursorRange::two(ccursor, max)
162                } else {
163                    let min = ccursor_previous_line(text, ccursor);
164                    let max = ccursor_next_line(text, ccursor);
165                    CCursorRange::two(min, max)
166                }
167            } else {
168                let min = ccursor_previous_line(text, ccursor);
169                CCursorRange::two(min, ccursor)
170            }
171        } else {
172            let max = ccursor_next_line(text, ccursor);
173            CCursorRange::two(ccursor, max)
174        }
175    }
176}
177
178pub fn ccursor_next_word(text: &str, ccursor: CCursor) -> CCursor {
179    CCursor {
180        index: next_word_boundary_char_index(text, ccursor.index),
181        prefer_next_row: false,
182    }
183}
184
185fn ccursor_next_line(text: &str, ccursor: CCursor) -> CCursor {
186    CCursor {
187        index: next_line_boundary_char_index(text.chars(), ccursor.index),
188        prefer_next_row: false,
189    }
190}
191
192pub fn ccursor_previous_word(text: &str, ccursor: CCursor) -> CCursor {
193    let num_chars = text.chars().count();
194    let reversed: String = text.graphemes(true).rev().collect();
195    CCursor {
196        index: num_chars
197            - next_word_boundary_char_index(&reversed, num_chars - ccursor.index).min(num_chars),
198        prefer_next_row: true,
199    }
200}
201
202fn ccursor_previous_line(text: &str, ccursor: CCursor) -> CCursor {
203    let num_chars = text.chars().count();
204    CCursor {
205        index: num_chars
206            - next_line_boundary_char_index(text.chars().rev(), num_chars - ccursor.index),
207        prefer_next_row: true,
208    }
209}
210
211fn next_word_boundary_char_index(text: &str, index: usize) -> usize {
212    for word in text.split_word_bound_indices() {
213        // Splitting considers contiguous whitespace as one word, such words must be skipped,
214        // this handles cases for example ' abc' (a space and a word), the cursor is at the beginning
215        // (before space) - this jumps at the end of 'abc' (this is consistent with text editors
216        // or browsers)
217        let ci = char_index_from_byte_index(text, word.0);
218        if ci > index && !skip_word(word.1) {
219            return ci;
220        }
221    }
222
223    char_index_from_byte_index(text, text.len())
224}
225
226fn skip_word(text: &str) -> bool {
227    // skip words that contain anything other than alphanumeric characters and underscore
228    // (i.e. whitespace, dashes, etc.)
229    !text.chars().any(|c| !is_word_char(c))
230}
231
232fn next_line_boundary_char_index(it: impl Iterator<Item = char>, mut index: usize) -> usize {
233    let mut it = it.skip(index);
234    if let Some(_first) = it.next() {
235        index += 1;
236
237        if let Some(second) = it.next() {
238            index += 1;
239            for next in it {
240                if is_linebreak(next) != is_linebreak(second) {
241                    break;
242                }
243                index += 1;
244            }
245        }
246    }
247    index
248}
249
250pub fn is_word_char(c: char) -> bool {
251    c.is_alphanumeric() || c == '_'
252}
253
254fn is_linebreak(c: char) -> bool {
255    c == '\r' || c == '\n'
256}
257
258/// Accepts and returns character offset (NOT byte offset!).
259pub fn find_line_start(text: &str, current_index: CCursor) -> CCursor {
260    // We know that new lines, '\n', are a single byte char, but we have to
261    // work with char offsets because before the new line there may be any
262    // number of multi byte chars.
263    // We need to know the char index to be able to correctly set the cursor
264    // later.
265    let chars_count = text.chars().count();
266
267    let position = text
268        .chars()
269        .rev()
270        .skip(chars_count - current_index.index)
271        .position(|x| x == '\n');
272
273    match position {
274        Some(pos) => CCursor::new(current_index.index - pos),
275        None => CCursor::new(0),
276    }
277}
278
279pub fn byte_index_from_char_index(s: &str, char_index: usize) -> usize {
280    for (ci, (bi, _)) in s.char_indices().enumerate() {
281        if ci == char_index {
282            return bi;
283        }
284    }
285    s.len()
286}
287
288pub fn char_index_from_byte_index(input: &str, byte_index: usize) -> usize {
289    for (ci, (bi, _)) in input.char_indices().enumerate() {
290        if bi == byte_index {
291            return ci;
292        }
293    }
294
295    input.char_indices().last().map_or(0, |(i, _)| i + 1)
296}
297
298pub fn slice_char_range(s: &str, char_range: std::ops::Range<usize>) -> &str {
299    assert!(
300        char_range.start <= char_range.end,
301        "Invalid range, start must be less than end, but start = {}, end = {}",
302        char_range.start,
303        char_range.end
304    );
305    let start_byte = byte_index_from_char_index(s, char_range.start);
306    let end_byte = byte_index_from_char_index(s, char_range.end);
307    &s[start_byte..end_byte]
308}
309
310/// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates.
311pub fn cursor_rect(galley: &Galley, cursor: &CCursor, row_height: f32) -> Rect {
312    let mut cursor_pos = galley.pos_from_cursor(*cursor);
313
314    // Handle completely empty galleys
315    cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
316
317    cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
318
319    cursor_pos
320}
321
322#[cfg(test)]
323mod test {
324    use crate::text_selection::text_cursor_state::next_word_boundary_char_index;
325
326    #[test]
327    fn test_next_word_boundary_char_index() {
328        // ASCII only
329        let text = "abc d3f g_h i-j";
330        assert_eq!(next_word_boundary_char_index(text, 1), 3);
331        assert_eq!(next_word_boundary_char_index(text, 3), 7);
332        assert_eq!(next_word_boundary_char_index(text, 9), 11);
333        assert_eq!(next_word_boundary_char_index(text, 12), 13);
334        assert_eq!(next_word_boundary_char_index(text, 13), 15);
335        assert_eq!(next_word_boundary_char_index(text, 15), 15);
336
337        assert_eq!(next_word_boundary_char_index("", 0), 0);
338        assert_eq!(next_word_boundary_char_index("", 1), 0);
339
340        // Unicode graphemes, some of which consist of multiple Unicode characters,
341        // !!! Unicode character is not always what is tranditionally considered a character,
342        // the values below are correct despite not seeming that way on the first look,
343        // handling of and around emojis is kind of weird and is not consistent across
344        // text editors and browsers
345        let text = "❤️👍 skvělá knihovna 👍❤️";
346        assert_eq!(next_word_boundary_char_index(text, 0), 2);
347        assert_eq!(next_word_boundary_char_index(text, 2), 3); // this does not skip the space between thumbs-up and 'skvělá'
348        assert_eq!(next_word_boundary_char_index(text, 6), 10);
349        assert_eq!(next_word_boundary_char_index(text, 9), 10);
350        assert_eq!(next_word_boundary_char_index(text, 12), 19);
351        assert_eq!(next_word_boundary_char_index(text, 15), 19);
352        assert_eq!(next_word_boundary_char_index(text, 19), 20);
353        assert_eq!(next_word_boundary_char_index(text, 20), 21);
354    }
355}