Skip to main content

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}
149
150/// What a press selects by — a click places a caret, a double-click takes a
151/// word, a triple-click a line — and what a drag from that press extends by.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum Granularity {
154    Char,
155    Word,
156    Line,
157}
158
159impl Granularity {
160    pub fn of_clicks(click_count: usize) -> Self {
161        match click_count {
162            0 | 1 => Self::Char,
163            2 => Self::Word,
164            _ => Self::Line,
165        }
166    }
167
168    /// The span of this unit holding `offset`. A word is the word-bound
169    /// segment touching `offset`, preferring one with alphanumeric content; a
170    /// line is the logical line.
171    pub fn around(self, text: &str, offset: usize) -> Range<usize> {
172        match self {
173            Self::Char => offset..offset,
174            Self::Word => {
175                let segments = || {
176                    text.split_word_bound_indices()
177                        .map(|(start, segment)| (start..start + segment.len(), is_word(segment)))
178                };
179                let after = segments().find(|(range, _)| range.contains(&offset));
180                let before = segments().find(|(range, _)| range.end == offset);
181                match (after, before) {
182                    (Some((range, true)), _) | (_, Some((range, true))) => range,
183                    (Some((range, _)), _) | (_, Some((range, _))) => range,
184                    (None, None) => offset..offset,
185                }
186            }
187            Self::Line => line_start(text, offset)..line_end(text, offset),
188        }
189    }
190}
191
192/// The `(anchor, head)` a drag holds once the pointer is over `span`, the unit
193/// under it, from a press that selected `pressed`. All of `pressed` stays
194/// selected whichever way the drag goes.
195pub fn drag_selection<T: Ord + std::marker::Copy>(pressed: Range<T>, span: Range<T>) -> (T, T) {
196    if span.start < pressed.start {
197        (pressed.end, span.start)
198    } else {
199        (pressed.start, span.end.max(pressed.end))
200    }
201}