Skip to main content

inset_rendering/
text_boundary.rs

1//! The [`TextBoundary`] subclasses defined by text layout rather than by the string alone:
2//! Flutter's `LineBoundary` from `services/text_boundary.dart` and its `WordBoundary` from
3//! `painting/text_painter.dart`.
4//!
5//! Both ask a laid-out paragraph where the breaks are, and Inset keeps a laid-out paragraph
6//! in the render object that owns its painter, so both live here with [`RenderEditable`].
7
8use std::rc::Rc;
9
10use inset_embedder::{TextPosition, TextRange};
11use inset_foundation::App;
12use inset_painting::InlineSpanRef;
13use inset_services::{TextBoundary, UntilPredicate};
14use unicode_general_category::{GeneralCategory, get_general_category};
15
16use crate::editable::RenderEditable;
17use crate::object::RenderHandle;
18
19/// A [`TextBoundary`] subclass for locating closest line breaks to a given
20/// `position`.
21///
22/// When the given `position` points to a hard line break, the returned range
23/// is the line's content range before the hard line break, and does not contain
24/// the given `position`. For instance, the line breaks at `position = 1` for
25/// "a\nb" is `[0, 1)`, which does not contain the position `1`.
26#[derive(Clone, Copy, Debug)]
27pub struct LineBoundary {
28    text_layout: RenderHandle<RenderEditable>,
29}
30
31impl LineBoundary {
32    /// Creates a [`LineBoundary`] with the text and layout information.
33    pub fn new(text_layout: RenderHandle<RenderEditable>) -> LineBoundary {
34        LineBoundary { text_layout }
35    }
36}
37
38impl TextBoundary for LineBoundary {
39    fn get_text_boundary_at(&self, app: &mut App, position: i32) -> TextRange {
40        self.text_layout
41            .get_line_at_offset(app, TextPosition::new(position.max(0)))
42            .range()
43    }
44}
45
46/// A [`TextBoundary`] subclass for locating word breaks.
47///
48/// The underlying implementation uses [UAX #29](https://unicode.org/reports/tr29/)
49/// defined default word boundaries.
50///
51/// The default word break rules can be tailored to meet the requirements of
52/// different use cases. For instance, the default rule set keeps horizontal
53/// whitespaces together as a single word, which may not make sense in a
54/// word-counting context -- "hello    world" counts as 3 words instead of 2.
55/// An example is the [`move_by_word_boundary`](Self::move_by_word_boundary) variant, which
56/// is a tailored word-break locator that more closely matches the default behavior of most
57/// platforms and editors when it comes to handling text editing keyboard
58/// shortcuts that move or delete word by word.
59#[derive(Clone)]
60pub struct WordBoundary {
61    text: InlineSpanRef,
62    /// Dart holds the `ui.Paragraph`; a laid-out paragraph lives in the arena here, so the
63    /// render object that owns it answers the query instead.
64    paragraph: RenderHandle<RenderEditable>,
65}
66
67impl WordBoundary {
68    /// Creates a [`WordBoundary`] with the text and layout information.
69    pub(crate) fn new(
70        text: InlineSpanRef,
71        paragraph: RenderHandle<RenderEditable>,
72    ) -> WordBoundary {
73        WordBoundary { text, paragraph }
74    }
75
76    /// Returns a [`TextBoundary`] suitable for handling keyboard navigation
77    /// commands that change the current selection word by word.
78    ///
79    /// This [`TextBoundary`] is used by text widgets in the flutter framework to
80    /// provide default implementation for text editing shortcuts, for example,
81    /// "delete to the previous word".
82    ///
83    /// The implementation applies the same set of rules [`WordBoundary`] uses,
84    /// except that word breaks end on a space separator or a punctuation will be
85    /// skipped, to match the behavior of most platforms. Additional rules may be
86    /// added in the future to better match platform behaviors.
87    pub fn move_by_word_boundary(&self) -> Box<dyn TextBoundary> {
88        let text = Rc::clone(&self.text);
89        Box::new(UntilTextBoundary {
90            text_boundary: Box::new(self.clone()),
91            predicate: Rc::new(move |offset, forward| {
92                skip_spaces_and_punctuations(&text, offset, forward)
93            }),
94        })
95    }
96}
97
98impl TextBoundary for WordBoundary {
99    fn get_text_boundary_at(&self, app: &mut App, position: i32) -> TextRange {
100        self.paragraph
101            .get_word_boundary(app, TextPosition::new(position.max(0)))
102    }
103}
104
105/// Combines two UTF-16 code units (high surrogate + low surrogate) into a
106/// single code point that represents a supplementary character.
107fn code_point_from_surrogates(high_surrogate: i32, low_surrogate: i32) -> i32 {
108    const BASE: i32 = 0x010000 - (0xD800 << 10) - 0xDC00;
109    (high_surrogate << 10) + low_surrogate + BASE
110}
111
112/// Dart's `WordBoundary._codePointAt`: `Runes` gives no random access by code unit offset.
113fn code_point_at(text: &InlineSpanRef, index: i32) -> Option<i32> {
114    let code_unit_at_index = i32::from(text.clone().code_unit_at(index)?);
115    Some(match code_unit_at_index & 0xFC00 {
116        0xD800 => {
117            code_point_from_surrogates(code_unit_at_index, i32::from(text.clone().code_unit_at(index + 1)?))
118        }
119        0xDC00 => {
120            code_point_from_surrogates(i32::from(text.clone().code_unit_at(index - 1)?), code_unit_at_index)
121        }
122        _ => code_unit_at_index,
123    })
124}
125
126fn is_newline(code_point: i32) -> bool {
127    // Carriage Return is not treated as a hard line break.
128    matches!(
129        code_point,
130        0x000A | // Line Feed
131        0x0085 | // New Line
132        0x000B | // Form Feed
133        0x000C | // Vertical Feed
134        0x2028 | // Line Separator
135        0x2029 // Paragraph Separator
136    )
137}
138
139/// Dart matches `[\p{Space_Separator}\p{Punctuation}]`.
140fn is_space_separator_or_punctuation(code_point: i32) -> bool {
141    let Some(character) = u32::try_from(code_point).ok().and_then(char::from_u32) else {
142        return false;
143    };
144    matches!(
145        get_general_category(character),
146        GeneralCategory::SpaceSeparator
147            | GeneralCategory::ConnectorPunctuation
148            | GeneralCategory::DashPunctuation
149            | GeneralCategory::OpenPunctuation
150            | GeneralCategory::ClosePunctuation
151            | GeneralCategory::InitialPunctuation
152            | GeneralCategory::FinalPunctuation
153            | GeneralCategory::OtherPunctuation
154    )
155}
156
157fn skip_spaces_and_punctuations(text: &InlineSpanRef, offset: i32, forward: bool) -> bool {
158    // Use code point since some punctuations are supplementary characters.
159    // "inner" here refers to the code unit that's before the break in the
160    // search direction (`forward`).
161    let inner_code_point = code_point_at(text, if forward { offset - 1 } else { offset });
162    let outer_code_unit = text.clone().code_unit_at(if forward { offset } else { offset - 1 });
163
164    // Make sure the hard break rules in UAX#29 take precedence over the ones we
165    // add below. Luckily there're only 4 hard break rules for word breaks, and
166    // dictionary based breaking does not introduce new hard breaks:
167    // https://unicode-org.github.io/icu/userguide/boundaryanalysis/break-rules.html#word-dictionaries
168    //
169    // WB1 & WB2: always break at the start or the end of the text.
170    let Some(inner_code_point) = inner_code_point else {
171        return true;
172    };
173    let Some(outer_code_unit) = outer_code_unit else {
174        return true;
175    };
176    // WB3a & WB3b: always break before and after newlines.
177    let hard_break_rules_apply =
178        is_newline(inner_code_point) || is_newline(i32::from(outer_code_unit));
179    hard_break_rules_apply || !is_space_separator_or_punctuation(inner_code_point)
180}
181
182/// Dart's `_UntilTextBoundary`: a boundary that keeps searching past the breaks its
183/// predicate rejects.
184struct UntilTextBoundary {
185    text_boundary: Box<dyn TextBoundary>,
186    predicate: Rc<UntilPredicate>,
187}
188
189impl TextBoundary for UntilTextBoundary {
190    fn get_leading_text_boundary_at(&self, app: &mut App, position: i32) -> Option<i32> {
191        if position < 0 {
192            return None;
193        }
194        let offset = self
195            .text_boundary
196            .get_leading_text_boundary_at(app, position)?;
197        if (self.predicate)(offset, false) {
198            Some(offset)
199        } else {
200            self.get_leading_text_boundary_at(app, offset - 1)
201        }
202    }
203
204    fn get_trailing_text_boundary_at(&self, app: &mut App, position: i32) -> Option<i32> {
205        let offset = self
206            .text_boundary
207            .get_trailing_text_boundary_at(app, position.max(0))?;
208        if (self.predicate)(offset, true) {
209            Some(offset)
210        } else {
211            self.get_trailing_text_boundary_at(app, offset)
212        }
213    }
214}