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 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}