Skip to main content

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 text.is_empty() {
110        return CCursorRange::one(ccursor);
111    }
112
113    let line_start = find_line_start(text, ccursor);
114    let line_end = ccursor_next_line(text, line_start);
115
116    let line_range = line_start.index..line_end.index;
117    let current_line_text = slice_char_range(text, line_range.clone());
118
119    let relative_idx = ccursor.index - line_start.index;
120    let relative_ccursor = CCursor::new(relative_idx);
121
122    let min = ccursor_previous_word(current_line_text, relative_ccursor);
123    let max = ccursor_next_word(current_line_text, relative_ccursor);
124
125    CCursorRange::two(
126        CCursor::new(line_start.index + min.index),
127        CCursor::new(line_start.index + max.index),
128    )
129}
130
131fn select_line_at(text: &str, ccursor: CCursor) -> CCursorRange {
132    if ccursor.index == 0 {
133        CCursorRange::two(ccursor, ccursor_next_line(text, ccursor))
134    } else {
135        let it = text.chars();
136        let mut it = it.skip(ccursor.index - 1);
137        if let Some(char_before_cursor) = it.next() {
138            if let Some(char_after_cursor) = it.next() {
139                if (!is_linebreak(char_before_cursor)) && (!is_linebreak(char_after_cursor)) {
140                    let min = ccursor_previous_line(text, ccursor + 1);
141                    let max = ccursor_next_line(text, min);
142                    CCursorRange::two(min, max)
143                } else if !is_linebreak(char_before_cursor) {
144                    let min = ccursor_previous_line(text, ccursor);
145                    let max = ccursor_next_line(text, min);
146                    CCursorRange::two(min, max)
147                } else if !is_linebreak(char_after_cursor) {
148                    let max = ccursor_next_line(text, ccursor);
149                    CCursorRange::two(ccursor, max)
150                } else {
151                    let min = ccursor_previous_line(text, ccursor);
152                    let max = ccursor_next_line(text, ccursor);
153                    CCursorRange::two(min, max)
154                }
155            } else {
156                let min = ccursor_previous_line(text, ccursor);
157                CCursorRange::two(min, ccursor)
158            }
159        } else {
160            let max = ccursor_next_line(text, ccursor);
161            CCursorRange::two(ccursor, max)
162        }
163    }
164}
165
166pub fn ccursor_next_word(text: &str, ccursor: CCursor) -> CCursor {
167    CCursor {
168        index: next_word_boundary_char_index(text, ccursor.index),
169        prefer_next_row: false,
170    }
171}
172
173fn ccursor_next_line(text: &str, ccursor: CCursor) -> CCursor {
174    CCursor {
175        index: next_line_boundary_char_index(text.chars(), ccursor.index),
176        prefer_next_row: false,
177    }
178}
179
180pub fn ccursor_previous_word(text: &str, ccursor: CCursor) -> CCursor {
181    let num_chars = text.chars().count();
182    let reversed: String = text.graphemes(true).rev().collect();
183    CCursor {
184        index: num_chars
185            - next_word_boundary_char_index(&reversed, num_chars - ccursor.index).min(num_chars),
186        prefer_next_row: true,
187    }
188}
189
190fn ccursor_previous_line(text: &str, ccursor: CCursor) -> CCursor {
191    let num_chars = text.chars().count();
192    CCursor {
193        index: num_chars
194            - next_line_boundary_char_index(text.chars().rev(), num_chars - ccursor.index),
195        prefer_next_row: true,
196    }
197}
198
199fn next_word_boundary_char_index(text: &str, cursor_ci: usize) -> usize {
200    let mut current_char_idx = 0;
201
202    for (_word_byte_index, word) in text.split_word_bound_indices() {
203        let word_ci = current_char_idx;
204
205        // We consider `.` a word boundary.
206        // At least that's how Mac works when navigating something like `www.example.com`.
207        let mut word_char_count = 0;
208        for chr in word.chars() {
209            let dot_ci = word_ci + word_char_count;
210            if chr == '.' && cursor_ci < dot_ci {
211                return dot_ci;
212            }
213            word_char_count += 1;
214        }
215
216        // Splitting considers contiguous whitespace as one word, such words must be skipped,
217        // this handles cases for example ' abc' (a space and a word), the cursor is at the beginning
218        // (before space) - this jumps at the end of 'abc' (this is consistent with text editors
219        // or browsers)
220        if cursor_ci < word_ci && !all_word_chars(word) {
221            return word_ci;
222        }
223
224        current_char_idx += word_char_count;
225    }
226
227    current_char_idx
228}
229
230fn all_word_chars(text: &str) -> bool {
231    text.chars().all(is_word_char)
232}
233
234fn next_line_boundary_char_index(it: impl Iterator<Item = char>, mut index: usize) -> usize {
235    let mut it = it.skip(index);
236    if let Some(_first) = it.next() {
237        index += 1;
238
239        if let Some(second) = it.next() {
240            index += 1;
241            for next in it {
242                if is_linebreak(next) != is_linebreak(second) {
243                    break;
244                }
245                index += 1;
246            }
247        }
248    }
249    index
250}
251
252pub fn is_word_char(c: char) -> bool {
253    c.is_alphanumeric() || c == '_'
254}
255
256fn is_linebreak(c: char) -> bool {
257    c == '\r' || c == '\n'
258}
259
260/// Accepts and returns character offset (NOT byte offset!).
261pub fn find_line_start(text: &str, current_index: CCursor) -> CCursor {
262    let byte_idx = byte_index_from_char_index(text, current_index.index);
263    let text_before = &text[..byte_idx];
264
265    if let Some(last_newline_byte) = text_before.rfind('\n') {
266        let char_idx = char_index_from_byte_index(text, last_newline_byte + 1);
267        CCursor::new(char_idx)
268    } else {
269        CCursor::new(0)
270    }
271}
272
273pub fn byte_index_from_char_index(s: &str, char_index: usize) -> usize {
274    for (ci, (bi, _)) in s.char_indices().enumerate() {
275        if ci == char_index {
276            return bi;
277        }
278    }
279    s.len()
280}
281
282pub fn char_index_from_byte_index(input: &str, byte_index: usize) -> usize {
283    for (ci, (bi, _)) in input.char_indices().enumerate() {
284        if bi == byte_index {
285            return ci;
286        }
287    }
288
289    input.char_indices().last().map_or(0, |(i, _)| i + 1)
290}
291
292pub fn slice_char_range(s: &str, char_range: std::ops::Range<usize>) -> &str {
293    assert!(
294        char_range.start <= char_range.end,
295        "Invalid range, start must be less than end, but start = {}, end = {}",
296        char_range.start,
297        char_range.end
298    );
299    let start_byte = byte_index_from_char_index(s, char_range.start);
300    let end_byte = byte_index_from_char_index(s, char_range.end);
301    &s[start_byte..end_byte]
302}
303
304/// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates.
305pub fn cursor_rect(galley: &Galley, cursor: &CCursor, row_height: f32) -> Rect {
306    let mut cursor_pos = galley.pos_from_cursor(*cursor);
307
308    // Handle completely empty galleys
309    cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
310
311    cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
312
313    cursor_pos
314}
315
316#[cfg(test)]
317mod test {
318    use super::*;
319
320    #[test]
321    fn test_next_word_boundary_char_index() {
322        // ASCII only
323        let text = "abc d3f g_h i-j";
324        assert_eq!(next_word_boundary_char_index(text, 1), 3);
325        assert_eq!(next_word_boundary_char_index(text, 3), 7);
326        assert_eq!(next_word_boundary_char_index(text, 9), 11);
327        assert_eq!(next_word_boundary_char_index(text, 12), 13);
328        assert_eq!(next_word_boundary_char_index(text, 13), 15);
329        assert_eq!(next_word_boundary_char_index(text, 15), 15);
330
331        assert_eq!(next_word_boundary_char_index("", 0), 0);
332        assert_eq!(next_word_boundary_char_index("", 1), 0);
333
334        // ASCII only
335        let text = "abc.def.ghi";
336        assert_eq!(next_word_boundary_char_index(text, 1), 3);
337        assert_eq!(next_word_boundary_char_index(text, 3), 7);
338        assert_eq!(next_word_boundary_char_index(text, 7), 11);
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
356    #[test]
357    fn test_previous_word() {
358        let text = "abc def ghi";
359        assert_eq!(ccursor_previous_word(text, CCursor::new(7)).index, 4);
360        assert_eq!(ccursor_previous_word(text, CCursor::new(5)).index, 4);
361        assert_eq!(ccursor_previous_word(text, CCursor::new(4)).index, 0);
362        assert_eq!(ccursor_previous_word(text, CCursor::new(0)).index, 0);
363    }
364
365    #[test]
366    fn test_next_word() {
367        let text = "abc def ghi";
368        assert_eq!(ccursor_next_word(text, CCursor::new(0)).index, 3);
369        assert_eq!(ccursor_next_word(text, CCursor::new(3)).index, 7);
370        assert_eq!(ccursor_next_word(text, CCursor::new(7)).index, 11);
371        assert_eq!(ccursor_next_word(text, CCursor::new(11)).index, 11);
372    }
373
374    #[test]
375    fn test_select_word_at() {
376        // CCursorRange::two(min, max) sets primary=max, secondary=min
377        let text = "hello world";
378        let range = select_word_at(text, CCursor::new(2));
379        let (lo, hi) = (
380            range.primary.index.min(range.secondary.index),
381            range.primary.index.max(range.secondary.index),
382        );
383        assert_eq!(lo, 0);
384        assert_eq!(hi, 5);
385
386        let range = select_word_at(text, CCursor::new(8));
387        let (lo, hi) = (
388            range.primary.index.min(range.secondary.index),
389            range.primary.index.max(range.secondary.index),
390        );
391        assert_eq!(lo, 6);
392        assert_eq!(hi, 11);
393    }
394
395    #[test]
396    fn test_word_boundary_large_text_performance() {
397        // Before the O(nΒ²) β†’ O(n) fix, this would take minutes on large text.
398        let large_text = "word ".repeat(200_000); // ~1MB
399        let len = large_text.chars().count();
400
401        let start = std::time::Instant::now();
402
403        let next = ccursor_next_word(&large_text, CCursor::new(len - 10));
404        assert!(next.index <= len);
405
406        let prev = ccursor_previous_word(&large_text, CCursor::new(len - 10));
407        assert!(prev.index < len);
408
409        let range = select_word_at(&large_text, CCursor::new(len - 3));
410        let lo = range.primary.index.min(range.secondary.index);
411        let hi = range.primary.index.max(range.secondary.index);
412        assert!(lo < hi, "Expected a non-empty word selection");
413
414        let elapsed = start.elapsed();
415        assert!(
416            elapsed.as_secs() < 5,
417            "Word boundary operations on 1MB text took {elapsed:?}, expected < 5s"
418        );
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_previous_word_graphemes() {
428        let cases = [
429            ("", 0, 0),
430            ("hello", 0, 0),
431            ("hello", "hello".chars().count(), 0),
432            ("hello world", 6, 0),
433            ("hello world", 8, 6),
434            ("hello world", "hello world".chars().count(), 6),
435            ("hello world   ", "hello world   ".chars().count(), 6),
436            ("hello   world", "hello   world".chars().count(), 8),
437            ("   ", "   ".chars().count(), 0),
438            ("hello, world", "hello, world".chars().count(), 7),
439            ("www.example.com", "www.example.com".chars().count(), 12),
440            ("μ•ˆλ…•! 😊 세상", 8, 6),
441            ("β€οΈπŸ‘ skvΔ›lΓ‘ knihovna πŸ‘β€οΈ", 18, 11),
442            (
443                "a e\u{301} b",
444                "a e\u{301} b".chars().count(),
445                "a e\u{301} ".chars().count(),
446            ),
447            (
448                "hi πŸ™‚ world",
449                "hi πŸ™‚ world".chars().count(),
450                "hi πŸ™‚ ".chars().count(),
451            ),
452            (
453                "hi πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ world",
454                "hi πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ world".chars().count(),
455                "hi πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ ".chars().count(),
456            ),
457        ];
458
459        for (text, cursor, expected) in cases {
460            let result = ccursor_previous_word(text, CCursor::new(cursor));
461            assert_eq!(
462                result.index, expected,
463                "text={text:?}, cursor={cursor}, got={}, expected={expected}",
464                result.index
465            );
466        }
467    }
468}