Skip to main content

gpui_rhai/
text_edit.rs

1use std::ops::Range;
2use std::time::{Duration, Instant};
3
4use unicode_segmentation::UnicodeSegmentation;
5
6#[derive(Clone, Debug, Default, Eq, PartialEq)]
7pub struct TextBuffer {
8    pub(crate) content: String,
9    pub(crate) selected: Range<usize>,
10    pub(crate) selection_reversed: bool,
11    pub(crate) marked: Option<Range<usize>>,
12    max_length: Option<usize>,
13    undo: Vec<TextRevision>,
14    redo: Vec<TextRevision>,
15    composition_base: Option<TextRevision>,
16    last_edit: Option<LastEdit>,
17}
18
19const HISTORY_LIMIT: usize = 100;
20const COALESCE_INTERVAL: Duration = Duration::from_millis(750);
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23struct TextRevision {
24    content: String,
25    selected: Range<usize>,
26    selection_reversed: bool,
27}
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30enum EditKind {
31    Insert,
32    Delete,
33    Replace,
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37struct LastEdit {
38    kind: EditKind,
39    at: Instant,
40    cursor: usize,
41}
42
43impl TextBuffer {
44    #[must_use]
45    pub fn new(content: impl Into<String>) -> Self {
46        let content = content.into();
47        let cursor = content.len();
48        Self {
49            content,
50            selected: cursor..cursor,
51            selection_reversed: false,
52            marked: None,
53            max_length: None,
54            undo: Vec::new(),
55            redo: Vec::new(),
56            composition_base: None,
57            last_edit: None,
58        }
59    }
60
61    #[must_use]
62    pub fn content(&self) -> &str {
63        &self.content
64    }
65
66    #[must_use]
67    pub fn selection(&self) -> &Range<usize> {
68        &self.selected
69    }
70
71    #[must_use]
72    pub fn marked(&self) -> Option<&Range<usize>> {
73        self.marked.as_ref()
74    }
75
76    #[must_use]
77    pub fn grapheme_count(&self) -> usize {
78        self.content.graphemes(true).count()
79    }
80
81    pub(crate) fn set_max_length(&mut self, max_length: Option<usize>) {
82        self.max_length = max_length;
83    }
84
85    #[must_use]
86    pub(crate) fn cursor_offset(&self) -> usize {
87        if self.selection_reversed {
88            self.selected.start
89        } else {
90            self.selected.end
91        }
92    }
93
94    pub(crate) fn move_to(&mut self, offset: usize) {
95        let offset = self.clamp_boundary(offset);
96        self.selected = offset..offset;
97        self.selection_reversed = false;
98        self.last_edit = None;
99    }
100
101    pub(crate) fn select_to(&mut self, offset: usize) {
102        let offset = self.clamp_boundary(offset);
103        if self.selection_reversed {
104            self.selected.start = offset;
105        } else {
106            self.selected.end = offset;
107        }
108        if self.selected.end < self.selected.start {
109            self.selection_reversed = !self.selection_reversed;
110            self.selected = self.selected.end..self.selected.start;
111        }
112        self.last_edit = None;
113    }
114
115    #[must_use]
116    pub(crate) fn previous_boundary(&self, offset: usize) -> usize {
117        self.content
118            .grapheme_indices(true)
119            .rev()
120            .find_map(|(index, _)| (index < offset).then_some(index))
121            .unwrap_or(0)
122    }
123
124    #[must_use]
125    pub(crate) fn next_boundary(&self, offset: usize) -> usize {
126        self.content
127            .grapheme_indices(true)
128            .find_map(|(index, _)| (index > offset).then_some(index))
129            .unwrap_or(self.content.len())
130    }
131
132    #[must_use]
133    pub(crate) fn line_start(&self, offset: usize) -> usize {
134        let offset = self.clamp_boundary(offset);
135        self.content[..offset]
136            .rfind('\n')
137            .map_or(0, |index| index + 1)
138    }
139
140    #[must_use]
141    pub(crate) fn line_end(&self, offset: usize) -> usize {
142        let offset = self.clamp_boundary(offset);
143        self.content[offset..]
144            .find('\n')
145            .map_or(self.content.len(), |index| offset + index)
146    }
147
148    #[must_use]
149    pub(crate) fn offset_from_utf16(&self, offset: usize) -> usize {
150        let mut utf8_offset = 0;
151        let mut utf16_count = 0;
152        for character in self.content.chars() {
153            if utf16_count >= offset {
154                break;
155            }
156            utf16_count += character.len_utf16();
157            utf8_offset += character.len_utf8();
158        }
159        self.clamp_boundary(utf8_offset)
160    }
161
162    #[must_use]
163    pub(crate) fn offset_to_utf16(&self, offset: usize) -> usize {
164        let offset = self.clamp_boundary(offset);
165        let mut utf16_offset = 0;
166        let mut utf8_count = 0;
167        for character in self.content.chars() {
168            if utf8_count >= offset {
169                break;
170            }
171            utf8_count += character.len_utf8();
172            utf16_offset += character.len_utf16();
173        }
174        utf16_offset
175    }
176
177    #[must_use]
178    pub(crate) fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
179        self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
180    }
181
182    #[must_use]
183    pub(crate) fn range_from_utf16(&self, range: &Range<usize>) -> Range<usize> {
184        self.offset_from_utf16(range.start)..self.offset_from_utf16(range.end)
185    }
186
187    pub(crate) fn replace(&mut self, range_utf16: Option<&Range<usize>>, text: &str) {
188        let range = range_utf16
189            .map(|range| self.range_from_utf16(range))
190            .or_else(|| self.marked.clone())
191            .unwrap_or_else(|| self.selected.clone());
192        let insertion = self.fit_replacement(&range, text);
193        if self.content.get(range.clone()) == Some(insertion.as_str()) {
194            self.move_to(range.start + insertion.len());
195            self.marked = None;
196            self.composition_base = None;
197            return;
198        }
199        let kind = if insertion.is_empty() {
200            EditKind::Delete
201        } else if range.is_empty() {
202            EditKind::Insert
203        } else {
204            EditKind::Replace
205        };
206        let coalescible = match kind {
207            EditKind::Insert => {
208                insertion.graphemes(true).count() == 1
209                    && !insertion.chars().any(char::is_whitespace)
210            }
211            EditKind::Delete => true,
212            EditKind::Replace => false,
213        };
214        self.record_before_edit(kind, coalescible);
215        self.content.replace_range(range.clone(), &insertion);
216        self.set_cursor_after_edit(range.start + insertion.len(), kind, coalescible);
217        self.marked = None;
218    }
219
220    pub(crate) fn replace_and_mark(
221        &mut self,
222        range_utf16: Option<&Range<usize>>,
223        text: &str,
224        selected_utf16: Option<Range<usize>>,
225    ) {
226        if self.composition_base.is_none() {
227            self.composition_base = Some(self.revision());
228            self.last_edit = None;
229        }
230        let range = range_utf16
231            .map(|range| self.range_from_utf16(range))
232            .or_else(|| self.marked.clone())
233            .unwrap_or_else(|| self.selected.clone());
234        self.content.replace_range(range.clone(), text);
235        self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
236        self.selected = selected_utf16.map_or_else(
237            || range.start + text.len()..range.start + text.len(),
238            |selection| {
239                let selection = utf16_range_in(text, &selection);
240                range.start + selection.start..range.start + selection.end
241            },
242        );
243    }
244
245    /// Commit marked IME text and enforce `max_length` only inside the marked
246    /// replacement, preserving text after the composition range.
247    pub(crate) fn unmark(&mut self) -> bool {
248        let Some(marked) = self.marked.take() else {
249            return false;
250        };
251        let mut changed = false;
252        if let Some(max_length) = self.max_length {
253            let outside = self.content[..marked.start].graphemes(true).count()
254                + self.content[marked.end..].graphemes(true).count();
255            let capacity = max_length.saturating_sub(outside);
256            let marked_text = self.content[marked.clone()].to_owned();
257            let fitted = take_graphemes(&marked_text, capacity);
258            if fitted != marked_text {
259                self.content.replace_range(marked.clone(), &fitted);
260                self.set_cursor_without_history(marked.start + fitted.len());
261                changed = true;
262            }
263        }
264        self.commit_composition_history();
265        changed
266    }
267
268    pub(crate) fn set_controlled(&mut self, content: &str) {
269        if self.content != content {
270            content.clone_into(&mut self.content);
271            self.move_to(self.content.len());
272            self.marked = None;
273            self.undo.clear();
274            self.redo.clear();
275            self.composition_base = None;
276            self.last_edit = None;
277        }
278    }
279
280    #[must_use]
281    pub fn can_undo(&self) -> bool {
282        !self.undo.is_empty() || self.composition_base.is_some()
283    }
284
285    #[must_use]
286    pub fn can_redo(&self) -> bool {
287        !self.redo.is_empty()
288    }
289
290    pub fn undo(&mut self) -> bool {
291        if let Some(base) = self.composition_base.take() {
292            self.marked = None;
293            self.restore_revision(base);
294            self.last_edit = None;
295            return true;
296        }
297        let Some(previous) = self.undo.pop() else {
298            return false;
299        };
300        self.redo.push(self.revision());
301        self.restore_revision(previous);
302        self.last_edit = None;
303        true
304    }
305
306    pub fn redo(&mut self) -> bool {
307        let Some(next) = self.redo.pop() else {
308            return false;
309        };
310        self.push_undo(self.revision());
311        self.restore_revision(next);
312        self.last_edit = None;
313        true
314    }
315
316    fn fit_replacement(&self, range: &Range<usize>, text: &str) -> String {
317        let Some(max_length) = self.max_length else {
318            return text.to_owned();
319        };
320        let outside = self.content[..range.start].graphemes(true).count()
321            + self.content[range.end..].graphemes(true).count();
322        take_graphemes(text, max_length.saturating_sub(outside))
323    }
324
325    fn clamp_boundary(&self, offset: usize) -> usize {
326        let offset = offset.min(self.content.len());
327        if offset == self.content.len() {
328            return offset;
329        }
330        self.content
331            .grapheme_indices(true)
332            .map(|(index, _)| index)
333            .take_while(|index| *index <= offset)
334            .last()
335            .unwrap_or(0)
336    }
337
338    fn revision(&self) -> TextRevision {
339        TextRevision {
340            content: self.content.clone(),
341            selected: self.selected.clone(),
342            selection_reversed: self.selection_reversed,
343        }
344    }
345
346    fn restore_revision(&mut self, revision: TextRevision) {
347        self.content = revision.content;
348        self.selected = revision.selected;
349        self.selection_reversed = revision.selection_reversed;
350        self.marked = None;
351        self.composition_base = None;
352    }
353
354    fn record_before_edit(&mut self, kind: EditKind, coalescible: bool) {
355        if let Some(base) = self.composition_base.take() {
356            self.push_undo(base);
357            self.redo.clear();
358            return;
359        }
360        let now = Instant::now();
361        let coalesced = coalescible
362            && self.selected.is_empty()
363            && self.last_edit.is_some_and(|last| {
364                last.kind == kind
365                    && last.cursor == self.cursor_offset()
366                    && now.saturating_duration_since(last.at) <= COALESCE_INTERVAL
367            });
368        if !coalesced {
369            self.push_undo(self.revision());
370        }
371        self.redo.clear();
372    }
373
374    fn set_cursor_after_edit(&mut self, offset: usize, kind: EditKind, coalescible: bool) {
375        self.set_cursor_without_history(offset);
376        self.last_edit = coalescible.then(|| LastEdit {
377            kind,
378            at: Instant::now(),
379            cursor: offset,
380        });
381    }
382
383    fn set_cursor_without_history(&mut self, offset: usize) {
384        let offset = self.clamp_boundary(offset);
385        self.selected = offset..offset;
386        self.selection_reversed = false;
387    }
388
389    fn commit_composition_history(&mut self) {
390        let Some(base) = self.composition_base.take() else {
391            return;
392        };
393        if base.content != self.content {
394            self.push_undo(base);
395            self.redo.clear();
396        }
397        self.last_edit = None;
398    }
399
400    fn push_undo(&mut self, revision: TextRevision) {
401        if self.undo.len() == HISTORY_LIMIT {
402            self.undo.remove(0);
403        }
404        self.undo.push(revision);
405    }
406}
407
408fn take_graphemes(value: &str, count: usize) -> String {
409    value.graphemes(true).take(count).collect()
410}
411
412fn utf16_range_in(value: &str, range: &Range<usize>) -> Range<usize> {
413    fn offset(value: &str, target: usize) -> usize {
414        let mut utf8 = 0;
415        let mut utf16 = 0;
416        for character in value.chars() {
417            if utf16 >= target {
418                break;
419            }
420            utf16 += character.len_utf16();
421            utf8 += character.len_utf8();
422        }
423        utf8
424    }
425    offset(value, range.start)..offset(value, range.end)
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn max_length_counts_extended_graphemes_and_credits_selection() {
434        let mut buffer = TextBuffer::new("a👨‍👩‍👧‍👦b");
435        buffer.set_max_length(Some(4));
436        assert_eq!(buffer.grapheme_count(), 3);
437        buffer.move_to(1);
438        buffer.select_to("a👨‍👩‍👧‍👦".len());
439        buffer.replace(None, "🇨🇳x");
440        assert_eq!(buffer.content(), "a🇨🇳xb");
441        assert_eq!(buffer.grapheme_count(), 4);
442    }
443
444    #[test]
445    fn oversized_paste_truncates_at_grapheme_boundary() {
446        let mut buffer = TextBuffer::new("ab");
447        buffer.set_max_length(Some(4));
448        buffer.replace(None, "👩🏽‍💻cd");
449        assert_eq!(buffer.content(), "ab👩🏽‍💻c");
450        assert_eq!(buffer.grapheme_count(), 4);
451    }
452
453    #[test]
454    fn ime_can_temporarily_exceed_then_clamps_only_marked_text() {
455        let mut buffer = TextBuffer::new("ab-tail");
456        buffer.set_max_length(Some(8));
457        buffer.move_to(2);
458        buffer.replace_and_mark(None, "中文输入", Some(4..4));
459        assert_eq!(buffer.content(), "ab中文输入-tail");
460        assert!(buffer.unmark());
461        assert_eq!(buffer.content(), "ab中-tail");
462        assert_eq!(buffer.grapheme_count(), 8);
463    }
464
465    #[test]
466    fn physical_line_boundaries_are_stable() {
467        let buffer = TextBuffer::new("one\ntwo\nthree");
468        assert_eq!(buffer.line_start(6), 4);
469        assert_eq!(buffer.line_end(6), 7);
470    }
471
472    #[test]
473    fn utf16_offsets_never_split_extended_graphemes() {
474        let buffer = TextBuffer::new("e\u{301}👩🏽‍💻");
475        assert_eq!(buffer.offset_from_utf16(1), 0);
476        assert_eq!(buffer.offset_from_utf16(2), "e\u{301}".len());
477        assert!(
478            buffer
479                .content()
480                .grapheme_indices(true)
481                .any(|(index, _)| index == buffer.offset_from_utf16(4))
482        );
483    }
484
485    #[test]
486    fn undo_redo_coalesces_typing_and_restores_selection() {
487        let mut buffer = TextBuffer::new("");
488        buffer.replace(None, "a");
489        buffer.replace(None, "b");
490        buffer.replace(None, "c");
491        assert_eq!(buffer.content(), "abc");
492        assert!(buffer.undo());
493        assert_eq!(buffer.content(), "");
494        assert_eq!(buffer.selection(), &(0..0));
495        assert!(buffer.redo());
496        assert_eq!(buffer.content(), "abc");
497        assert_eq!(buffer.selection(), &(3..3));
498    }
499
500    #[test]
501    fn ime_composition_is_one_revision_and_external_control_resets_history() {
502        let mut buffer = TextBuffer::new("start");
503        buffer.move_to(buffer.content().len());
504        buffer.replace_and_mark(None, "に", Some(1..1));
505        buffer.replace_and_mark(None, "日本", Some(2..2));
506        buffer.unmark();
507        assert_eq!(buffer.content(), "start日本");
508        assert!(buffer.undo());
509        assert_eq!(buffer.content(), "start");
510        assert!(buffer.redo());
511        assert_eq!(buffer.content(), "start日本");
512        buffer.set_controlled("external");
513        assert!(!buffer.can_undo());
514        assert!(!buffer.can_redo());
515    }
516}