Skip to main content

incular_text/
boundary.rs

1//! Text boundaries used by keyboard caret movement and deletion.
2//!
3//! These are ports of Flutter 3.41's `TextBoundary` family
4//! (`services/text_boundary.dart`) and of `WordBoundary.moveByWordBoundary`
5//! (`painting/text_painter.dart`), over UTF-8 byte offsets instead of UTF-16
6//! code units. Line boundaries depend on the laid-out text and live with the
7//! text layout; everything here is a pure function of the string.
8
9use std::{cell::RefCell, rc::Rc};
10
11use icu_properties::{CodePointMapData, props::GeneralCategory};
12use icu_segmenter::options::WordBreakInvariantOptions;
13use icu_segmenter::{GraphemeClusterSegmenter, WordSegmenter};
14
15type WordBreaks = Rc<dyn Fn(&str) -> Option<Vec<usize>>>;
16
17thread_local! {
18    static WORD_SEGMENTER: RefCell<Option<WordBreaks>> = const { RefCell::new(None) };
19}
20
21/// Replaces word segmentation on the calling (UI) thread. `segment` returns
22/// the ascending UTF-8 byte offsets of a text's word boundaries, including
23/// `0` and its length, or `None` to use the built-in segmenter.
24///
25/// The built-in segmenter follows UAX #29 everywhere. On native targets it
26/// also segments Chinese, Japanese, Thai, Lao, Khmer and Burmese with ICU's
27/// dictionaries and models; a browser build leaves those out (they would add
28/// about 2 MB to every page) and its host supplies the browser's segmenter
29/// here instead.
30pub fn set_word_segmenter(segment: impl Fn(&str) -> Option<Vec<usize>> + 'static) {
31    WORD_SEGMENTER.set(Some(Rc::new(segment)));
32}
33
34fn word_boundaries(text: &str) -> Vec<usize> {
35    if let Some(segment) = WORD_SEGMENTER.with_borrow(Clone::clone)
36        && let Some(boundaries) = segment(text)
37    {
38        return boundaries;
39    }
40    #[cfg(not(target_arch = "wasm32"))]
41    let segmenter = WordSegmenter::new_auto(WordBreakInvariantOptions::default());
42    #[cfg(target_arch = "wasm32")]
43    let segmenter =
44        WordSegmenter::new_for_non_complex_scripts(WordBreakInvariantOptions::default());
45    segmenter.segment_str(text).collect()
46}
47
48/// The string-only boundary kinds a caret can move or delete by.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum TextBoundaryKind {
51    /// Grapheme clusters (`CharacterBoundary`).
52    Character,
53    /// Words with spaces and punctuation skipped (`moveByWordBoundary`).
54    Word,
55    /// Paragraphs ended by a line terminator (`ParagraphBoundary`).
56    Paragraph,
57    /// The whole text (`DocumentBoundary`).
58    Document,
59}
60
61impl TextBoundaryKind {
62    /// Flutter's `TextBoundary.getLeadingTextBoundaryAt`: the boundary at or
63    /// before `position`, or `None` when there is none.
64    #[must_use]
65    pub fn leading_at(self, text: &str, position: isize) -> Option<usize> {
66        match self {
67            Self::Character => character_leading(text, position),
68            Self::Word => word_leading(text, position),
69            Self::Paragraph => paragraph_leading(text, position),
70            Self::Document => (position >= 0).then_some(0),
71        }
72    }
73
74    /// Flutter's `TextBoundary.getTrailingTextBoundaryAt`: the boundary after
75    /// `position`, or `None` when there is none.
76    #[must_use]
77    pub fn trailing_at(self, text: &str, position: isize) -> Option<usize> {
78        match self {
79            Self::Character => character_trailing(text, position),
80            Self::Word => word_trailing(text, position),
81            Self::Paragraph => paragraph_trailing(text, position),
82            Self::Document => ((position.max(0) as usize) < text.len()).then_some(text.len()),
83        }
84    }
85
86    /// Flutter's `_moveBeyondTextBoundary`: the closest boundary to `extent`
87    /// that is not `extent` itself (unless already at the start or end of the
88    /// text), in the given direction.
89    #[must_use]
90    pub fn move_beyond(self, text: &str, extent: usize, forward: bool) -> usize {
91        let extent = floor_char_boundary(text, extent);
92        if forward {
93            self.trailing_at(text, extent as isize)
94                .unwrap_or(text.len())
95        } else {
96            // x - 1 avoids getting stuck at a boundary that maps to itself.
97            self.leading_at(text, previous_unit(text, extent))
98                .unwrap_or(0)
99        }
100    }
101}
102
103/// The byte offset one character before `offset`, or -1 at the start.
104fn previous_unit(text: &str, offset: usize) -> isize {
105    text[..offset]
106        .char_indices()
107        .next_back()
108        .map_or(-1, |(index, _)| index as isize)
109}
110
111fn floor_char_boundary(text: &str, offset: usize) -> usize {
112    let mut offset = offset.min(text.len());
113    while offset > 0 && !text.is_char_boundary(offset) {
114        offset -= 1;
115    }
116    offset
117}
118
119fn clamp_position(text: &str, position: isize) -> usize {
120    floor_char_boundary(text, position.max(0) as usize)
121}
122
123// ----- Character -----
124
125fn grapheme_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
126    GraphemeClusterSegmenter::new().segment_str(text)
127}
128
129fn character_leading(text: &str, position: isize) -> Option<usize> {
130    if position < 0 {
131        return None;
132    }
133    let position = clamp_position(text, position);
134    grapheme_boundaries(text)
135        .take_while(|boundary| *boundary <= position)
136        .last()
137        .or(Some(0))
138}
139
140fn character_trailing(text: &str, position: isize) -> Option<usize> {
141    let position = clamp_position(text, position);
142    if position >= text.len() {
143        return None;
144    }
145    grapheme_boundaries(text)
146        .find(|boundary| *boundary > position)
147        .or(Some(text.len()))
148}
149
150// ----- Word -----
151
152fn is_newline(c: char) -> bool {
153    // WordBoundary._isNewline: a carriage return is not a hard line break.
154    matches!(
155        c,
156        '\u{000A}' | '\u{0085}' | '\u{000B}' | '\u{000C}' | '\u{2028}' | '\u{2029}'
157    )
158}
159
160fn is_space_separator_or_punctuation(c: char) -> bool {
161    matches!(
162        CodePointMapData::<GeneralCategory>::new().get(c),
163        GeneralCategory::SpaceSeparator
164            | GeneralCategory::ConnectorPunctuation
165            | GeneralCategory::DashPunctuation
166            | GeneralCategory::OpenPunctuation
167            | GeneralCategory::ClosePunctuation
168            | GeneralCategory::InitialPunctuation
169            | GeneralCategory::FinalPunctuation
170            | GeneralCategory::OtherPunctuation
171    )
172}
173
174/// `Paragraph.getWordBoundary`: the UAX #29 word containing `position`.
175fn word_range_at(text: &str, position: usize) -> (usize, usize) {
176    let mut start = 0;
177    let mut end = text.len();
178    for boundary in word_boundaries(text) {
179        if boundary <= position {
180            start = boundary;
181        } else {
182            end = boundary;
183            break;
184        }
185    }
186    if position >= text.len() {
187        start = text.len();
188    }
189    (start, end)
190}
191
192/// `WordBoundary._skipSpacesAndPunctuations`: whether a word-break `offset`
193/// is a place a word move may stop at. Breaks next to spaces or punctuation
194/// are skipped, except where a hard break (text edge, newline) applies.
195fn stops_at(text: &str, offset: usize, forward: bool) -> bool {
196    let before = text[..offset].chars().next_back();
197    let after = text[offset..].chars().next();
198    // "inner" is the character before the break in the search direction.
199    let (inner, outer) = if forward {
200        (before, after)
201    } else {
202        (after, before)
203    };
204    let hard_break = match (inner, outer) {
205        (Some(inner), Some(outer)) => is_newline(inner) || is_newline(outer),
206        _ => true,
207    };
208    hard_break || inner.is_some_and(|c| !is_space_separator_or_punctuation(c))
209}
210
211fn word_leading(text: &str, position: isize) -> Option<usize> {
212    let mut position = position;
213    loop {
214        if position < 0 {
215            return None;
216        }
217        let (start, _) = word_range_at(text, clamp_position(text, position));
218        if stops_at(text, start, false) {
219            return Some(start);
220        }
221        position = start as isize - 1;
222    }
223}
224
225fn word_trailing(text: &str, position: isize) -> Option<usize> {
226    let mut position = clamp_position(text, position);
227    loop {
228        let (_, end) = word_range_at(text, position);
229        if stops_at(text, end, true) {
230            return Some(end);
231        }
232        position = end;
233    }
234}
235
236// ----- Paragraph -----
237
238fn is_line_terminator(c: char) -> bool {
239    matches!(
240        c,
241        '\u{000A}' | '\u{000B}' | '\u{000C}' | '\u{000D}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
242    )
243}
244
245fn paragraph_leading(text: &str, position: isize) -> Option<usize> {
246    if position < 0 || text.is_empty() {
247        return None;
248    }
249    let position = clamp_position(text, position);
250    if position >= text.len() {
251        return Some(text.len());
252    }
253    if position == 0 {
254        return Some(0);
255    }
256    let mut index = position;
257    let char_at = |index: usize| text[index..].chars().next();
258    let char_before = |index: usize| text[..index].chars().next_back();
259    // A CRLF pair counts as one terminator: step back over both.
260    if index > 1 && char_at(index) == Some('\n') && char_before(index) == Some('\r') {
261        index -= 2;
262    } else if char_at(index).is_some_and(is_line_terminator) {
263        index = previous_unit(text, index).max(0) as usize;
264    }
265    while index > 0 {
266        if let Some(c) = char_at(index)
267            && is_line_terminator(c)
268        {
269            return Some(index + c.len_utf8());
270        }
271        index = previous_unit(text, index).max(0) as usize;
272    }
273    Some(0)
274}
275
276fn paragraph_trailing(text: &str, position: isize) -> Option<usize> {
277    if text.is_empty() {
278        return None;
279    }
280    if position < 0 {
281        return Some(0);
282    }
283    let position = clamp_position(text, position);
284    if position >= text.len() {
285        return None;
286    }
287    let mut index = position;
288    loop {
289        let c = text[index..].chars().next()?;
290        if is_line_terminator(c) {
291            // A CRLF pair ends the paragraph after both characters.
292            let next = index + c.len_utf8();
293            return Some(if c == '\r' && text[next..].starts_with('\n') {
294                next + 1
295            } else {
296                next
297            });
298        }
299        index += c.len_utf8();
300        if index == text.len() {
301            return Some(index);
302        }
303    }
304}
305
306/// Flutter's `TextPainter.getWordBoundary`: the UAX #29 word segment that
307/// contains `offset` (whitespace and punctuation runs are segments too), as
308/// a `(start, end)` byte range. An offset at the end of the text yields the
309/// empty range there.
310#[must_use]
311pub fn word_boundary_at(text: &str, offset: usize) -> (usize, usize) {
312    let offset = floor_char_boundary(text, offset);
313    if offset >= text.len() {
314        return (text.len(), text.len());
315    }
316    let boundaries = word_boundaries(text);
317    let mut start = 0;
318    for boundary in boundaries {
319        if boundary > offset {
320            return (start, boundary);
321        }
322        start = boundary;
323    }
324    (start, text.len())
325}
326
327/// Flutter's `TextLayoutMetrics.isWhitespace` for one character.
328#[must_use]
329pub const fn is_layout_whitespace(c: char) -> bool {
330    matches!(
331        c as u32,
332        0x0009..=0x000D
333            | 0x001C..=0x0020
334            | 0x00A0
335            | 0x1680
336            | 0x2000..=0x200A
337            | 0x202F
338            | 0x205F
339            | 0x3000
340    )
341}