ui/input/text.rs
1//! Pure offset math, kept free of gpui so it can be unit-tested.
2
3use super::*;
4
5/// UTF-16 offset (what the platform IME speaks) → byte offset.
6pub fn offset_from_utf16(text: &str, offset: usize) -> usize {
7 let mut utf8_offset = 0;
8 let mut utf16_count = 0;
9 for ch in text.chars() {
10 if utf16_count >= offset {
11 break;
12 }
13 utf16_count += ch.len_utf16();
14 utf8_offset += ch.len_utf8();
15 }
16 utf8_offset
17}
18
19/// Byte offset → UTF-16 offset.
20pub fn offset_to_utf16(text: &str, offset: usize) -> usize {
21 let mut utf16_offset = 0;
22 let mut utf8_count = 0;
23 for ch in text.chars() {
24 if utf8_count >= offset {
25 break;
26 }
27 utf8_count += ch.len_utf8();
28 utf16_offset += ch.len_utf16();
29 }
30 utf16_offset
31}
32
33/// Platform range → byte range, clamped to character boundaries in `text`.
34pub fn range_from_utf16(text: &str, range: Range<usize>) -> Range<usize> {
35 offset_from_utf16(text, range.start)..offset_from_utf16(text, range.end)
36}
37
38/// Byte range → platform range.
39pub fn range_to_utf16(text: &str, range: Range<usize>) -> Range<usize> {
40 offset_to_utf16(text, range.start)..offset_to_utf16(text, range.end)
41}
42
43/// An IME selection is relative to the replacement text, not the document.
44pub fn composition_selection(
45 text: &str,
46 start: usize,
47 selection: Option<Range<usize>>,
48) -> Range<usize> {
49 let range = selection
50 .map(|range| range_from_utf16(text, range))
51 .unwrap_or(text.len()..text.len());
52 start + range.start..start + range.end
53}
54
55/// Previous *grapheme* boundary, so arrow keys and backspace step over a flag
56/// emoji or a combining mark as one unit instead of splitting it into pieces
57/// that render as garbage.
58pub fn previous_boundary(text: &str, offset: usize) -> usize {
59 text.grapheme_indices(true)
60 .rev()
61 .find_map(|(idx, _)| (idx < offset).then_some(idx))
62 .unwrap_or(0)
63}
64
65/// Next grapheme boundary; clamps to the end of the text.
66pub fn next_boundary(text: &str, offset: usize) -> usize {
67 text.grapheme_indices(true)
68 .find_map(|(idx, _)| (idx > offset).then_some(idx))
69 .unwrap_or(text.len())
70}
71
72/// Whether an edit continues the group the last one opened, rather than
73/// starting an undo step of its own.
74///
75/// Two conditions, both structural: the same kind of edit, landing where the
76/// last one left the caret. Deliberately not "within N milliseconds" — a time
77/// threshold is a number nobody has measured, and adjacency is what actually
78/// distinguishes a run of typing from a fresh thought somewhere else.
79pub fn joins_group(last: Option<(EditKind, usize)>, kind: EditKind, at: usize) -> bool {
80 last.is_some_and(|(last_kind, offset)| last_kind == kind && at == offset)
81}
82
83/// The line breaks a field of this shape is allowed to hold.
84///
85/// CRLF is folded to LF whatever the shape: `shape_text` splits on `\n` alone,
86/// so a surviving `\r` shapes as a glyph and puts every offset after it out by
87/// one. A single-line field then keeps the text but not the breaks — a pasted
88/// newline becomes a space rather than silently truncating what was pasted.
89///
90/// The invariant this buys: a [`Shape::Line`] field's content never contains a
91/// newline, so nothing downstream has to ask whether it might.
92pub fn normalize(text: &str, shape: Shape) -> String {
93 let text = text.replace("\r\n", "\n").replace('\r', "\n");
94 if shape.is_multiline() {
95 text
96 } else {
97 text.replace('\n', " ")
98 }
99}
100
101/// Start of the logical line holding `offset` — the byte after the previous
102/// newline.
103///
104/// Logical, not visual: with soft wrapping these two readings diverge, and
105/// `ctrl-a` here goes to the start of the whole paragraph rather than stopping
106/// at the wrap. That is emacs' `C-a`, and a deliberate divergence from macOS
107/// `NSTextView`, which stops at the visual row. On text with no newline — every
108/// [`Shape::Line`] field — the two are identical.
109pub fn line_start(text: &str, offset: usize) -> usize {
110 text[..offset].rfind('\n').map_or(0, |at| at + 1)
111}
112
113/// End of the logical line holding `offset` — the byte before the next newline.
114pub fn line_end(text: &str, offset: usize) -> usize {
115 text[offset..]
116 .find('\n')
117 .map_or(text.len(), |at| offset + at)
118}
119
120/// A word-bound segment counts as a word if it has any alphanumeric content;
121/// whitespace and punctuation runs are the things word motion skips over.
122pub(super) fn is_word(segment: &str) -> bool {
123 segment.chars().any(char::is_alphanumeric)
124}
125
126/// Start of the word at or before `offset` — option-left.
127///
128/// Word units are Unicode word bounds (UAX#29), not space-delimited runs. In
129/// practice that means `foo.bar` and `foo_bar` are ONE word — a dot or
130/// underscore between letters does not break — while `a-b`, `path/to/file` and
131/// `foo, bar` do break. Good defaults for identifiers and paths, and verified
132/// against the segmenter rather than assumed (see tests).
133pub fn previous_word_boundary(text: &str, offset: usize) -> usize {
134 text.split_word_bound_indices()
135 .filter(|(start, _)| *start < offset)
136 .rfind(|(_, segment)| is_word(segment))
137 .map(|(start, _)| start)
138 .unwrap_or(0)
139}
140
141/// End of the word at or after `offset` — option-right.
142pub fn next_word_boundary(text: &str, offset: usize) -> usize {
143 text.split_word_bound_indices()
144 .filter(|(start, segment)| start + segment.len() > offset)
145 .find(|(_, segment)| is_word(segment))
146 .map(|(start, segment)| start + segment.len())
147 .unwrap_or(text.len())
148}