Skip to main content

egui/text_selection/
text_cursor_state.rs

1//! Text cursor changes/interaction, without modifying the text.
2
3use epaint::text::{ByteIndex, ByteRangeExt as _, CharIndex, 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 == CharIndex::ZERO {
133        CCursorRange::two(ccursor, ccursor_next_line(text, ccursor))
134    } else {
135        let it = text.chars();
136        let mut it = it.skip(ccursor.index.0 - 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 min = ccursor_previous_line(text, ccursor);
149                    let max = ccursor_next_line(text, ccursor);
150                    CCursorRange::two(min, max)
151                } else {
152                    let max = ccursor_next_line(text, ccursor);
153                    CCursorRange::two(ccursor, 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 = CharIndex(text.chars().count());
182    let reversed: String = text.graphemes(true).rev().collect();
183    let boundary = next_word_boundary_char_index(&reversed, num_chars - ccursor.index);
184    CCursor {
185        index: num_chars - boundary.min(num_chars),
186        prefer_next_row: true,
187    }
188}
189
190fn ccursor_previous_line(text: &str, ccursor: CCursor) -> CCursor {
191    let num_chars = CharIndex(text.chars().count());
192    let boundary = next_line_boundary_char_index(text.chars().rev(), num_chars - ccursor.index);
193    CCursor {
194        index: num_chars - boundary,
195        prefer_next_row: true,
196    }
197}
198
199fn next_word_boundary_char_index(text: &str, cursor_ci: CharIndex) -> CharIndex {
200    let mut current_char_idx = CharIndex::ZERO;
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(
235    it: impl Iterator<Item = char>,
236    mut index: CharIndex,
237) -> CharIndex {
238    let mut it = it.skip(index.0);
239    if let Some(_first) = it.next() {
240        index += 1;
241
242        if let Some(second) = it.next() {
243            index += 1;
244            for next in it {
245                if is_linebreak(next) != is_linebreak(second) {
246                    break;
247                }
248                index += 1;
249            }
250        }
251    }
252    index
253}
254
255pub fn is_word_char(c: char) -> bool {
256    c.is_alphanumeric() || c == '_'
257}
258
259fn is_linebreak(c: char) -> bool {
260    c == '\r' || c == '\n'
261}
262
263/// Accepts and returns character offset (NOT byte offset!).
264pub fn find_line_start(text: &str, current_index: CCursor) -> CCursor {
265    let byte_idx = byte_index_from_char_index(text, current_index.index);
266    let text_before = (ByteIndex::ZERO..byte_idx).slice(text);
267
268    if let Some(last_newline_byte) = text_before.rfind('\n') {
269        let char_idx = char_index_from_byte_index(text, ByteIndex(last_newline_byte + 1));
270        CCursor::new(char_idx)
271    } else {
272        CCursor::new(0)
273    }
274}
275
276pub fn byte_index_from_char_index(s: &str, char_index: CharIndex) -> ByteIndex {
277    for (ci, (bi, _)) in s.char_indices().enumerate() {
278        if ci == char_index.0 {
279            return ByteIndex(bi);
280        }
281    }
282    ByteIndex(s.len())
283}
284
285pub fn char_index_from_byte_index(input: &str, byte_index: ByteIndex) -> CharIndex {
286    for (ci, (bi, _)) in input.char_indices().enumerate() {
287        if bi == byte_index.0 {
288            return CharIndex(ci);
289        }
290    }
291
292    // `byte_index` is at or past the end of the string (or not on a char boundary):
293    // return the total number of characters.
294    CharIndex(input.chars().count())
295}
296
297pub fn slice_char_range(s: &str, char_range: std::ops::Range<CharIndex>) -> &str {
298    assert!(
299        char_range.start <= char_range.end,
300        "Invalid range, start must be less than end, but start = {}, end = {}",
301        char_range.start,
302        char_range.end
303    );
304    let start_byte = byte_index_from_char_index(s, char_range.start);
305    let end_byte = byte_index_from_char_index(s, char_range.end);
306    (start_byte..end_byte).slice(s)
307}
308
309/// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates.
310pub fn cursor_rect(galley: &Galley, cursor: &CCursor, row_height: f32) -> Rect {
311    let mut cursor_pos = galley.pos_from_cursor(*cursor);
312
313    // Handle completely empty galleys
314    cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height);
315
316    cursor_pos = cursor_pos.expand(1.5); // slightly above/below row
317
318    cursor_pos
319}
320
321#[cfg(test)]
322mod test {
323    use super::*;
324
325    #[test]
326    fn test_next_word_boundary_char_index() {
327        // ASCII only
328        let text = "abc d3f g_h i-j";
329        assert_eq!(next_word_boundary_char_index(text, CharIndex(1)).0, 3);
330        assert_eq!(next_word_boundary_char_index(text, CharIndex(3)).0, 7);
331        assert_eq!(next_word_boundary_char_index(text, CharIndex(9)).0, 11);
332        assert_eq!(next_word_boundary_char_index(text, CharIndex(12)).0, 13);
333        assert_eq!(next_word_boundary_char_index(text, CharIndex(13)).0, 15);
334        assert_eq!(next_word_boundary_char_index(text, CharIndex(15)).0, 15);
335
336        assert_eq!(next_word_boundary_char_index("", CharIndex(0)).0, 0);
337        assert_eq!(next_word_boundary_char_index("", CharIndex(1)).0, 0);
338
339        // ASCII only
340        let text = "abc.def.ghi";
341        assert_eq!(next_word_boundary_char_index(text, CharIndex(1)).0, 3);
342        assert_eq!(next_word_boundary_char_index(text, CharIndex(3)).0, 7);
343        assert_eq!(next_word_boundary_char_index(text, CharIndex(7)).0, 11);
344
345        // Unicode graphemes, some of which consist of multiple Unicode characters,
346        // !!! Unicode character is not always what is tranditionally considered a character,
347        // the values below are correct despite not seeming that way on the first look,
348        // handling of and around emojis is kind of weird and is not consistent across
349        // text editors and browsers
350        let text = "β€οΈπŸ‘ skvΔ›lΓ‘ knihovna πŸ‘β€οΈ";
351        assert_eq!(next_word_boundary_char_index(text, CharIndex(0)).0, 2);
352        assert_eq!(next_word_boundary_char_index(text, CharIndex(2)).0, 3); // this does not skip the space between thumbs-up and 'skvΔ›lΓ‘'
353        assert_eq!(next_word_boundary_char_index(text, CharIndex(6)).0, 10);
354        assert_eq!(next_word_boundary_char_index(text, CharIndex(9)).0, 10);
355        assert_eq!(next_word_boundary_char_index(text, CharIndex(12)).0, 19);
356        assert_eq!(next_word_boundary_char_index(text, CharIndex(15)).0, 19);
357        assert_eq!(next_word_boundary_char_index(text, CharIndex(19)).0, 20);
358        assert_eq!(next_word_boundary_char_index(text, CharIndex(20)).0, 21);
359    }
360
361    #[test]
362    fn test_previous_word() {
363        let text = "abc def ghi";
364        assert_eq!(ccursor_previous_word(text, CCursor::new(7)).index.0, 4);
365        assert_eq!(ccursor_previous_word(text, CCursor::new(5)).index.0, 4);
366        assert_eq!(ccursor_previous_word(text, CCursor::new(4)).index.0, 0);
367        assert_eq!(ccursor_previous_word(text, CCursor::new(0)).index.0, 0);
368    }
369
370    #[test]
371    fn test_next_word() {
372        let text = "abc def ghi";
373        assert_eq!(ccursor_next_word(text, CCursor::new(0)).index.0, 3);
374        assert_eq!(ccursor_next_word(text, CCursor::new(3)).index.0, 7);
375        assert_eq!(ccursor_next_word(text, CCursor::new(7)).index.0, 11);
376        assert_eq!(ccursor_next_word(text, CCursor::new(11)).index.0, 11);
377    }
378
379    #[test]
380    fn test_index_conversion_roundtrip() {
381        // "Γ©" is 2 bytes, "πŸ‘" is 4 bytes.
382        let text = "aΓ©πŸ‘b";
383        let char_count = text.chars().count(); // 4
384        assert_eq!(char_count, 4);
385
386        // char -> byte, including the end index
387        assert_eq!(byte_index_from_char_index(text, CharIndex(0)).0, 0);
388        assert_eq!(byte_index_from_char_index(text, CharIndex(1)).0, 1);
389        assert_eq!(byte_index_from_char_index(text, CharIndex(2)).0, 3);
390        assert_eq!(byte_index_from_char_index(text, CharIndex(3)).0, 7);
391        assert_eq!(byte_index_from_char_index(text, CharIndex(4)).0, 8);
392        // Past the end clamps to the byte length:
393        assert_eq!(
394            byte_index_from_char_index(text, CharIndex(99)).0,
395            text.len()
396        );
397
398        // byte -> char, including the end index
399        assert_eq!(char_index_from_byte_index(text, ByteIndex(0)).0, 0);
400        assert_eq!(char_index_from_byte_index(text, ByteIndex(1)).0, 1);
401        assert_eq!(char_index_from_byte_index(text, ByteIndex(3)).0, 2);
402        assert_eq!(char_index_from_byte_index(text, ByteIndex(7)).0, 3);
403        // The end byte index must map to the character count, not to some byte offset:
404        assert_eq!(char_index_from_byte_index(text, ByteIndex(text.len())).0, 4);
405        // Past the end clamps to the character count:
406        assert_eq!(char_index_from_byte_index(text, ByteIndex(99)).0, 4);
407
408        // Empty string:
409        assert_eq!(byte_index_from_char_index("", CharIndex(0)).0, 0);
410        assert_eq!(char_index_from_byte_index("", ByteIndex(0)).0, 0);
411    }
412
413    #[test]
414    fn test_select_word_at() {
415        // CCursorRange::two(min, max) sets primary=max, secondary=min
416        let text = "hello world";
417        let range = select_word_at(text, CCursor::new(2));
418        let (lo, hi) = (
419            range.primary.index.min(range.secondary.index),
420            range.primary.index.max(range.secondary.index),
421        );
422        assert_eq!(lo.0, 0);
423        assert_eq!(hi.0, 5);
424
425        let range = select_word_at(text, CCursor::new(8));
426        let (lo, hi) = (
427            range.primary.index.min(range.secondary.index),
428            range.primary.index.max(range.secondary.index),
429        );
430        assert_eq!(lo.0, 6);
431        assert_eq!(hi.0, 11);
432    }
433
434    #[test]
435    fn test_word_boundary_large_text_performance() {
436        // Before the O(nΒ²) β†’ O(n) fix, this would take minutes on large text.
437        let large_text = "word ".repeat(200_000); // ~1MB
438        let len = large_text.chars().count();
439
440        let start = std::time::Instant::now();
441
442        let next = ccursor_next_word(&large_text, CCursor::new(len - 10));
443        assert!(next.index.0 <= len);
444
445        let prev = ccursor_previous_word(&large_text, CCursor::new(len - 10));
446        assert!(prev.index.0 < len);
447
448        let range = select_word_at(&large_text, CCursor::new(len - 3));
449        let lo = range.primary.index.min(range.secondary.index);
450        let hi = range.primary.index.max(range.secondary.index);
451        assert!(lo < hi, "Expected a non-empty word selection");
452
453        let elapsed = start.elapsed();
454        assert!(
455            elapsed.as_secs() < 5,
456            "Word boundary operations on 1MB text took {elapsed:?}, expected < 5s"
457        );
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_previous_word_graphemes() {
467        let cases = [
468            ("", 0, 0),
469            ("hello", 0, 0),
470            ("hello", "hello".chars().count(), 0),
471            ("hello world", 6, 0),
472            ("hello world", 8, 6),
473            ("hello world", "hello world".chars().count(), 6),
474            ("hello world   ", "hello world   ".chars().count(), 6),
475            ("hello   world", "hello   world".chars().count(), 8),
476            ("   ", "   ".chars().count(), 0),
477            ("hello, world", "hello, world".chars().count(), 7),
478            ("www.example.com", "www.example.com".chars().count(), 12),
479            ("μ•ˆλ…•! 😊 세상", 8, 6),
480            ("β€οΈπŸ‘ skvΔ›lΓ‘ knihovna πŸ‘β€οΈ", 18, 11),
481            (
482                "a e\u{301} b",
483                "a e\u{301} b".chars().count(),
484                "a e\u{301} ".chars().count(),
485            ),
486            (
487                "hi πŸ™‚ world",
488                "hi πŸ™‚ world".chars().count(),
489                "hi πŸ™‚ ".chars().count(),
490            ),
491            (
492                "hi πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ world",
493                "hi πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ world".chars().count(),
494                "hi πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ ".chars().count(),
495            ),
496        ];
497
498        for (text, cursor, expected) in cases {
499            let result = ccursor_previous_word(text, CCursor::new(cursor));
500            assert_eq!(
501                result.index.0, expected,
502                "text={text:?}, cursor={cursor}, got={}, expected={expected}",
503                result.index.0
504            );
505        }
506    }
507}