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.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.code_unit_at(index + 1)?))
118 }
119 0xDC00 => {
120 code_point_from_surrogates(i32::from(text.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.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}