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 => 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}