Skip to main content

inset_services/
text_boundary.rs

1//! Flutter counterpart: `services/text_boundary.dart`.
2//!
3//! `LineBoundary` lives in rendering, next to the `RenderEditable` whose line metrics it
4//! reads; see that crate's `PORTING.md`.
5
6use inset_embedder::TextRange;
7use inset_foundation::App;
8use unicode_segmentation::UnicodeSegmentation;
9
10use crate::text_layout_metrics::TextLayoutMetrics;
11
12/// Signature for a predicate that takes an offset into a UTF-16 string, and a
13/// boolean that indicates the search direction.
14pub type UntilPredicate = dyn Fn(i32, bool) -> bool;
15
16/// An interface for retrieving the logical text boundary (as opposed to the
17/// visual boundary) at a given code unit offset in a document.
18///
19/// Either the [`get_text_boundary_at`](Self::get_text_boundary_at) method, or both the
20/// [`get_leading_text_boundary_at`](Self::get_leading_text_boundary_at) method and the
21/// [`get_trailing_text_boundary_at`](Self::get_trailing_text_boundary_at) method
22/// must be implemented.
23pub trait TextBoundary {
24    /// Returns the offset of the closest text boundary before or at the given
25    /// `position`, or `None` if no boundaries can be found.
26    ///
27    /// The return value, if not `None`, is usually less than or equal to `position`.
28    ///
29    /// The range of the return value is given by the closed interval
30    /// `[0, string.length]`.
31    fn get_leading_text_boundary_at(&self, app: &mut App, position: i32) -> Option<i32> {
32        if position < 0 {
33            return None;
34        }
35        let start = self.get_text_boundary_at(app, position).start;
36        (start >= 0).then_some(start)
37    }
38
39    /// Returns the offset of the closest text boundary after the given
40    /// `position`, or `None` if there is no boundary can be found after `position`.
41    ///
42    /// The return value, if not `None`, is usually greater than `position`.
43    ///
44    /// The range of the return value is given by the closed interval
45    /// `[0, string.length]`.
46    fn get_trailing_text_boundary_at(&self, app: &mut App, position: i32) -> Option<i32> {
47        let end = self.get_text_boundary_at(app, position.max(0)).end;
48        (end >= 0).then_some(end)
49    }
50
51    /// Returns the text boundary range that encloses the input position.
52    ///
53    /// The returned [`TextRange`] may contain `-1`, which indicates no boundaries
54    /// can be found in that direction.
55    fn get_text_boundary_at(&self, app: &mut App, position: i32) -> TextRange {
56        let start = self
57            .get_leading_text_boundary_at(app, position)
58            .unwrap_or(-1);
59        let end = self
60            .get_trailing_text_boundary_at(app, position)
61            .unwrap_or(-1);
62        TextRange::new(start, end)
63    }
64}
65
66/// A [`TextBoundary`] subclass for retrieving the range of the grapheme the given
67/// `position` is in.
68///
69/// The class is implemented using the `unicode-segmentation` crate, which supplies the
70/// same UAX #29 grapheme clusters as the `characters` package Dart uses.
71#[derive(Clone, Debug)]
72pub struct CharacterBoundary {
73    text: String,
74}
75
76impl CharacterBoundary {
77    /// Creates a [`CharacterBoundary`] with the text.
78    pub fn new(text: impl Into<String>) -> CharacterBoundary {
79        CharacterBoundary { text: text.into() }
80    }
81}
82
83impl TextBoundary for CharacterBoundary {
84    fn get_leading_text_boundary_at(&self, _app: &mut App, position: i32) -> Option<i32> {
85        if position < 0 {
86            return None;
87        }
88        let grapheme_start = CharacterRange::at(&self.text, position.min(utf16_len(&self.text)))
89            .string_before_length;
90        debug_assert!(CharacterRange::at(&self.text, grapheme_start).is_empty());
91        Some(grapheme_start)
92    }
93
94    fn get_trailing_text_boundary_at(&self, _app: &mut App, position: i32) -> Option<i32> {
95        if position >= utf16_len(&self.text) {
96            return None;
97        }
98        let range_at_position = CharacterRange::at(&self.text, (position + 1).max(0));
99        let next_boundary =
100            range_at_position.string_before_length + range_at_position.current_length;
101        debug_assert!(
102            next_boundary == utf16_len(&self.text)
103                || CharacterRange::at(&self.text, next_boundary).is_empty()
104        );
105        Some(next_boundary)
106    }
107
108    fn get_text_boundary_at(&self, app: &mut App, position: i32) -> TextRange {
109        if position < 0 {
110            return TextRange::new(
111                -1,
112                self.get_trailing_text_boundary_at(app, position)
113                    .unwrap_or(-1),
114            );
115        } else if position >= utf16_len(&self.text) {
116            return TextRange::new(
117                self.get_leading_text_boundary_at(app, position)
118                    .unwrap_or(-1),
119                -1,
120            );
121        }
122        let range_at_position = CharacterRange::at(&self.text, position);
123        if range_at_position.is_empty() {
124            // An empty range means `position` is a grapheme boundary.
125            TextRange::new(
126                range_at_position.string_before_length,
127                self.get_trailing_text_boundary_at(app, position)
128                    .unwrap_or(-1),
129            )
130        } else {
131            TextRange::new(
132                range_at_position.string_before_length,
133                range_at_position.string_before_length + range_at_position.current_length,
134            )
135        }
136    }
137}
138
139/// A text boundary that uses paragraphs as logical boundaries.
140///
141/// A paragraph is defined as the range between line terminators. If no
142/// line terminators exist then the paragraph boundary is the entire document.
143#[derive(Clone, Debug)]
144pub struct ParagraphBoundary {
145    /// Dart indexes its `String` by UTF-16 code unit, which is what the walks below need.
146    text: Vec<u16>,
147}
148
149impl ParagraphBoundary {
150    /// Creates a [`ParagraphBoundary`] with the text.
151    pub fn new(text: &str) -> ParagraphBoundary {
152        ParagraphBoundary {
153            text: text.encode_utf16().collect(),
154        }
155    }
156
157    fn code_unit_at(&self, index: i32) -> i32 {
158        i32::from(self.text[index as usize])
159    }
160
161    fn len(&self) -> i32 {
162        self.text.len() as i32
163    }
164}
165
166impl TextBoundary for ParagraphBoundary {
167    /// Returns the offset representing the start position of the paragraph that
168    /// bounds the given `position`. The returned offset is the position of the code unit
169    /// that follows the line terminator that encloses the desired paragraph.
170    fn get_leading_text_boundary_at(&self, _app: &mut App, position: i32) -> Option<i32> {
171        if position < 0 || self.text.is_empty() {
172            return None;
173        }
174
175        if position >= self.len() {
176            return Some(self.len());
177        }
178
179        if position == 0 {
180            return Some(0);
181        }
182
183        let mut index = position;
184
185        if index > 1 && self.code_unit_at(index) == 0x0A && self.code_unit_at(index - 1) == 0x0D {
186            index -= 2;
187        } else if TextLayoutMetrics::is_line_terminator(self.code_unit_at(index)) {
188            index -= 1;
189        }
190
191        while index > 0 {
192            if TextLayoutMetrics::is_line_terminator(self.code_unit_at(index)) {
193                return Some(index + 1);
194            }
195            index -= 1;
196        }
197
198        Some(index.max(0))
199    }
200
201    /// Returns the offset representing the end position of the paragraph that
202    /// bounds the given `position`. The returned offset is the position of the
203    /// code unit representing the trailing line terminator that encloses the
204    /// desired paragraph.
205    fn get_trailing_text_boundary_at(&self, _app: &mut App, position: i32) -> Option<i32> {
206        if position >= self.len() || self.text.is_empty() {
207            return None;
208        }
209
210        if position < 0 {
211            return Some(0);
212        }
213
214        let mut index = position;
215
216        while !TextLayoutMetrics::is_line_terminator(self.code_unit_at(index)) {
217            index += 1;
218            if index == self.len() {
219                return Some(index);
220            }
221        }
222
223        Some(
224            if index < self.len() - 1
225                && self.code_unit_at(index) == 0x0D
226                && self.code_unit_at(index + 1) == 0x0A
227            {
228                index + 2
229            } else {
230                index + 1
231            },
232        )
233    }
234}
235
236/// A text boundary that uses the entire document as logical boundary.
237#[derive(Clone, Debug)]
238pub struct DocumentBoundary {
239    text: String,
240}
241
242impl DocumentBoundary {
243    /// Creates a [`DocumentBoundary`] with the text.
244    pub fn new(text: impl Into<String>) -> DocumentBoundary {
245        DocumentBoundary { text: text.into() }
246    }
247}
248
249impl TextBoundary for DocumentBoundary {
250    fn get_leading_text_boundary_at(&self, _app: &mut App, position: i32) -> Option<i32> {
251        (position >= 0).then_some(0)
252    }
253
254    fn get_trailing_text_boundary_at(&self, _app: &mut App, position: i32) -> Option<i32> {
255        let length = utf16_len(&self.text);
256        (position < length).then_some(length)
257    }
258}
259
260/// Dart `String.length`: UTF-16 code units.
261fn utf16_len(text: &str) -> i32 {
262    text.encode_utf16().count() as i32
263}
264
265/// The grapheme cluster at a code unit offset, as the `characters` package's
266/// `CharacterRange.at` reports it: empty when the offset is already on a cluster
267/// boundary, and the whole cluster when the offset falls inside one.
268struct CharacterRange {
269    /// The number of code units before the range.
270    string_before_length: i32,
271    /// The code unit length of `current`, the cluster the range covers.
272    current_length: i32,
273}
274
275impl CharacterRange {
276    fn at(text: &str, position: i32) -> CharacterRange {
277        let mut start = 0;
278        for cluster in text.graphemes(true) {
279            if position <= start {
280                break;
281            }
282            let length = utf16_len(cluster);
283            if position < start + length {
284                return CharacterRange {
285                    string_before_length: start,
286                    current_length: length,
287                };
288            }
289            start += length;
290        }
291        CharacterRange {
292            string_before_length: start,
293            current_length: 0,
294        }
295    }
296
297    fn is_empty(&self) -> bool {
298        self.current_length == 0
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use inset_foundation::{App, AppCell};
305
306    use super::{CharacterBoundary, DocumentBoundary, ParagraphBoundary, TextBoundary};
307
308    /// The boundaries below never read the arena; `LineBoundary` in rendering does.
309    fn leading(app: &mut App, boundary: &dyn TextBoundary, position: i32) -> Option<i32> {
310        boundary.get_leading_text_boundary_at(app, position)
311    }
312
313    fn trailing(app: &mut App, boundary: &dyn TextBoundary, position: i32) -> Option<i32> {
314        boundary.get_trailing_text_boundary_at(app, position)
315    }
316
317    /// Dart's `_hasConsistentTextRangeImplementationWithinRange`: the range at a position
318    /// must agree with the two one-sided lookups.
319    fn assert_consistent_within_range(app: &mut App, boundary: &dyn TextBoundary, length: i32) {
320        for position in -1..=length {
321            let range = boundary.get_text_boundary_at(app, position);
322            assert_eq!(
323                range.start,
324                leading(app, boundary, position).unwrap_or(-1),
325                "leading disagrees at {position}"
326            );
327            assert_eq!(
328                range.end,
329                trailing(app, boundary, position).unwrap_or(-1),
330                "trailing disagrees at {position}"
331            );
332        }
333    }
334
335    #[test]
336    fn character_boundary_works() {
337        let cell = AppCell::new();
338        let mut app = cell.borrow_mut();
339        let boundary = CharacterBoundary::new("abc");
340        assert_consistent_within_range(&mut app, &boundary, 3);
341
342        assert_eq!(leading(&mut app, &boundary, -1), None);
343        assert_eq!(trailing(&mut app, &boundary, -1), Some(0));
344
345        assert_eq!(leading(&mut app, &boundary, 0), Some(0));
346        assert_eq!(trailing(&mut app, &boundary, 0), Some(1));
347
348        assert_eq!(leading(&mut app, &boundary, 1), Some(1));
349        assert_eq!(trailing(&mut app, &boundary, 1), Some(2));
350
351        assert_eq!(leading(&mut app, &boundary, 3), Some(3));
352        assert_eq!(trailing(&mut app, &boundary, 3), None);
353
354        assert_eq!(leading(&mut app, &boundary, 4), Some(3));
355        assert_eq!(trailing(&mut app, &boundary, 4), None);
356    }
357
358    #[test]
359    fn character_boundary_works_with_grapheme() {
360        let cell = AppCell::new();
361        let mut app = cell.borrow_mut();
362        // "a❄︎c": the snowflake carries a variation selector, so one grapheme spans two
363        // code units.
364        let text = "a\u{2744}\u{FE0E}c";
365        let boundary = CharacterBoundary::new(text);
366        let length = text.encode_utf16().count() as i32;
367        assert_eq!(length, 4);
368        assert_consistent_within_range(&mut app, &boundary, length);
369
370        assert_eq!(leading(&mut app, &boundary, -1), None);
371        assert_eq!(trailing(&mut app, &boundary, -1), Some(0));
372
373        assert_eq!(leading(&mut app, &boundary, 0), Some(0));
374        assert_eq!(trailing(&mut app, &boundary, 0), Some(1));
375
376        assert_eq!(leading(&mut app, &boundary, 1), Some(1));
377        assert_eq!(trailing(&mut app, &boundary, 1), Some(3));
378
379        assert_eq!(leading(&mut app, &boundary, 2), Some(1));
380        assert_eq!(trailing(&mut app, &boundary, 2), Some(3));
381
382        assert_eq!(leading(&mut app, &boundary, 3), Some(3));
383        assert_eq!(trailing(&mut app, &boundary, 3), Some(4));
384
385        assert_eq!(leading(&mut app, &boundary, length), Some(length));
386        assert_eq!(trailing(&mut app, &boundary, length), None);
387    }
388
389    #[test]
390    fn paragraph_boundary_works_for_simple_cases() {
391        let cell = AppCell::new();
392        let mut app = cell.borrow_mut();
393        // Position enclosed inside of paragraph, "abcd efg h|i\n".
394        let position = 10;
395
396        let boundary = ParagraphBoundary::new("abcd efg hi\njklmno\npqrstuv");
397        // The range includes the line terminator.
398        assert_eq!(leading(&mut app, &boundary, position), Some(0));
399        assert_eq!(trailing(&mut app, &boundary, position), Some(12));
400
401        // This text includes a carriage return followed by a line feed.
402        let boundary = ParagraphBoundary::new("abcd efg hi\r\njklmno\npqrstuv");
403        assert_eq!(leading(&mut app, &boundary, position), Some(0));
404        assert_eq!(trailing(&mut app, &boundary, position), Some(13));
405    }
406
407    #[test]
408    fn paragraph_boundary_works_for_consecutive_line_terminators_involving_crlf() {
409        let cell = AppCell::new();
410        let mut app = cell.borrow_mut();
411        let boundary = ParagraphBoundary::new(concat!(
412            "Now is the time for\n", // 20
413            "all good people\n\r\n", // 20 + 18 => 38
414            "to come to the aid\n",  // 38 + 19 => 57
415            "of their country.",     // 57 + 17 => 74
416        ));
417        assert_eq!(leading(&mut app, &boundary, 56), Some(38));
418        assert_eq!(trailing(&mut app, &boundary, 56), Some(57));
419        assert_eq!(leading(&mut app, &boundary, 38), Some(38));
420        assert_eq!(trailing(&mut app, &boundary, 38), Some(57));
421        assert_eq!(leading(&mut app, &boundary, 37), Some(36));
422        assert_eq!(trailing(&mut app, &boundary, 37), Some(38));
423    }
424
425    #[test]
426    fn paragraph_boundary_works_when_position_is_between_two_crlf() {
427        let cell = AppCell::new();
428        let mut app = cell.borrow_mut();
429        let boundary = ParagraphBoundary::new("abcd efg hi\r\nhello\r\n\n");
430        assert_eq!(leading(&mut app, &boundary, 16), Some(13));
431        assert_eq!(trailing(&mut app, &boundary, 16), Some(20));
432    }
433
434    #[test]
435    fn document_boundary_works() {
436        let cell = AppCell::new();
437        let mut app = cell.borrow_mut();
438        let text = "abcd efg hi\njklmno\npqrstuv";
439        let length = text.len() as i32;
440        let boundary = DocumentBoundary::new(text);
441        assert_consistent_within_range(&mut app, &boundary, length);
442
443        assert_eq!(leading(&mut app, &boundary, -1), None);
444        assert_eq!(trailing(&mut app, &boundary, -1), Some(length));
445
446        assert_eq!(leading(&mut app, &boundary, 0), Some(0));
447        assert_eq!(trailing(&mut app, &boundary, 0), Some(length));
448
449        assert_eq!(leading(&mut app, &boundary, 10), Some(0));
450        assert_eq!(trailing(&mut app, &boundary, 10), Some(length));
451
452        assert_eq!(leading(&mut app, &boundary, length), Some(0));
453        assert_eq!(trailing(&mut app, &boundary, length), None);
454
455        assert_eq!(leading(&mut app, &boundary, length + 1), Some(0));
456        assert_eq!(trailing(&mut app, &boundary, length + 1), None);
457    }
458}