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 => code_point_from_surrogates(
117 code_unit_at_index,
118 i32::from(text.clone().code_unit_at(index + 1)?),
119 ),
120 0xDC00 => code_point_from_surrogates(
121 i32::from(text.clone().code_unit_at(index - 1)?),
122 code_unit_at_index,
123 ),
124 _ => code_unit_at_index,
125 })
126}
127
128fn is_newline(code_point: i32) -> bool {
129 // Carriage Return is not treated as a hard line break.
130 matches!(
131 code_point,
132 0x000A | // Line Feed
133 0x0085 | // New Line
134 0x000B | // Form Feed
135 0x000C | // Vertical Feed
136 0x2028 | // Line Separator
137 0x2029 // Paragraph Separator
138 )
139}
140
141/// Dart matches `[\p{Space_Separator}\p{Punctuation}]`.
142fn is_space_separator_or_punctuation(code_point: i32) -> bool {
143 let Some(character) = u32::try_from(code_point).ok().and_then(char::from_u32) else {
144 return false;
145 };
146 matches!(
147 get_general_category(character),
148 GeneralCategory::SpaceSeparator
149 | GeneralCategory::ConnectorPunctuation
150 | GeneralCategory::DashPunctuation
151 | GeneralCategory::OpenPunctuation
152 | GeneralCategory::ClosePunctuation
153 | GeneralCategory::InitialPunctuation
154 | GeneralCategory::FinalPunctuation
155 | GeneralCategory::OtherPunctuation
156 )
157}
158
159fn skip_spaces_and_punctuations(text: &InlineSpanRef, offset: i32, forward: bool) -> bool {
160 // Use code point since some punctuations are supplementary characters.
161 // "inner" here refers to the code unit that's before the break in the
162 // search direction (`forward`).
163 let inner_code_point = code_point_at(text, if forward { offset - 1 } else { offset });
164 let outer_code_unit = text
165 .clone()
166 .code_unit_at(if forward { offset } else { offset - 1 });
167
168 // Make sure the hard break rules in UAX#29 take precedence over the ones we
169 // add below. Luckily there're only 4 hard break rules for word breaks, and
170 // dictionary based breaking does not introduce new hard breaks:
171 // https://unicode-org.github.io/icu/userguide/boundaryanalysis/break-rules.html#word-dictionaries
172 //
173 // WB1 & WB2: always break at the start or the end of the text.
174 let Some(inner_code_point) = inner_code_point else {
175 return true;
176 };
177 let Some(outer_code_unit) = outer_code_unit else {
178 return true;
179 };
180 // WB3a & WB3b: always break before and after newlines.
181 let hard_break_rules_apply =
182 is_newline(inner_code_point) || is_newline(i32::from(outer_code_unit));
183 hard_break_rules_apply || !is_space_separator_or_punctuation(inner_code_point)
184}
185
186/// Dart's `_UntilTextBoundary`: a boundary that keeps searching past the breaks its
187/// predicate rejects.
188struct UntilTextBoundary {
189 text_boundary: Box<dyn TextBoundary>,
190 predicate: Rc<UntilPredicate>,
191}
192
193impl TextBoundary for UntilTextBoundary {
194 fn get_leading_text_boundary_at(&self, app: &mut App, position: i32) -> Option<i32> {
195 if position < 0 {
196 return None;
197 }
198 let offset = self
199 .text_boundary
200 .get_leading_text_boundary_at(app, position)?;
201 if (self.predicate)(offset, false) {
202 Some(offset)
203 } else {
204 self.get_leading_text_boundary_at(app, offset - 1)
205 }
206 }
207
208 fn get_trailing_text_boundary_at(&self, app: &mut App, position: i32) -> Option<i32> {
209 let offset = self
210 .text_boundary
211 .get_trailing_text_boundary_at(app, position.max(0))?;
212 if (self.predicate)(offset, true) {
213 Some(offset)
214 } else {
215 self.get_trailing_text_boundary_at(app, offset)
216 }
217 }
218}