1use std::{cell::RefCell, rc::Rc};
10
11use icu_properties::{CodePointMapData, props::GeneralCategory};
12use icu_segmenter::options::WordBreakInvariantOptions;
13use icu_segmenter::{GraphemeClusterSegmenter, WordSegmenter};
14
15type WordBreaks = Rc<dyn Fn(&str) -> Option<Vec<usize>>>;
16
17thread_local! {
18 static WORD_SEGMENTER: RefCell<Option<WordBreaks>> = const { RefCell::new(None) };
19}
20
21pub fn set_word_segmenter(segment: impl Fn(&str) -> Option<Vec<usize>> + 'static) {
31 WORD_SEGMENTER.set(Some(Rc::new(segment)));
32}
33
34fn word_boundaries(text: &str) -> Vec<usize> {
35 if let Some(segment) = WORD_SEGMENTER.with_borrow(Clone::clone)
36 && let Some(boundaries) = segment(text)
37 {
38 return boundaries;
39 }
40 #[cfg(not(target_arch = "wasm32"))]
41 let segmenter = WordSegmenter::new_auto(WordBreakInvariantOptions::default());
42 #[cfg(target_arch = "wasm32")]
43 let segmenter =
44 WordSegmenter::new_for_non_complex_scripts(WordBreakInvariantOptions::default());
45 segmenter.segment_str(text).collect()
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum TextBoundaryKind {
51 Character,
53 Word,
55 Paragraph,
57 Document,
59}
60
61impl TextBoundaryKind {
62 #[must_use]
65 pub fn leading_at(self, text: &str, position: isize) -> Option<usize> {
66 match self {
67 Self::Character => character_leading(text, position),
68 Self::Word => word_leading(text, position),
69 Self::Paragraph => paragraph_leading(text, position),
70 Self::Document => (position >= 0).then_some(0),
71 }
72 }
73
74 #[must_use]
77 pub fn trailing_at(self, text: &str, position: isize) -> Option<usize> {
78 match self {
79 Self::Character => character_trailing(text, position),
80 Self::Word => word_trailing(text, position),
81 Self::Paragraph => paragraph_trailing(text, position),
82 Self::Document => ((position.max(0) as usize) < text.len()).then_some(text.len()),
83 }
84 }
85
86 #[must_use]
90 pub fn move_beyond(self, text: &str, extent: usize, forward: bool) -> usize {
91 let extent = floor_char_boundary(text, extent);
92 if forward {
93 self.trailing_at(text, extent as isize)
94 .unwrap_or(text.len())
95 } else {
96 self.leading_at(text, previous_unit(text, extent))
98 .unwrap_or(0)
99 }
100 }
101}
102
103fn previous_unit(text: &str, offset: usize) -> isize {
105 text[..offset]
106 .char_indices()
107 .next_back()
108 .map_or(-1, |(index, _)| index as isize)
109}
110
111fn floor_char_boundary(text: &str, offset: usize) -> usize {
112 let mut offset = offset.min(text.len());
113 while offset > 0 && !text.is_char_boundary(offset) {
114 offset -= 1;
115 }
116 offset
117}
118
119fn clamp_position(text: &str, position: isize) -> usize {
120 floor_char_boundary(text, position.max(0) as usize)
121}
122
123fn grapheme_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
126 GraphemeClusterSegmenter::new().segment_str(text)
127}
128
129fn character_leading(text: &str, position: isize) -> Option<usize> {
130 if position < 0 {
131 return None;
132 }
133 let position = clamp_position(text, position);
134 grapheme_boundaries(text)
135 .take_while(|boundary| *boundary <= position)
136 .last()
137 .or(Some(0))
138}
139
140fn character_trailing(text: &str, position: isize) -> Option<usize> {
141 let position = clamp_position(text, position);
142 if position >= text.len() {
143 return None;
144 }
145 grapheme_boundaries(text)
146 .find(|boundary| *boundary > position)
147 .or(Some(text.len()))
148}
149
150fn is_newline(c: char) -> bool {
153 matches!(
155 c,
156 '\u{000A}' | '\u{0085}' | '\u{000B}' | '\u{000C}' | '\u{2028}' | '\u{2029}'
157 )
158}
159
160fn is_space_separator_or_punctuation(c: char) -> bool {
161 matches!(
162 CodePointMapData::<GeneralCategory>::new().get(c),
163 GeneralCategory::SpaceSeparator
164 | GeneralCategory::ConnectorPunctuation
165 | GeneralCategory::DashPunctuation
166 | GeneralCategory::OpenPunctuation
167 | GeneralCategory::ClosePunctuation
168 | GeneralCategory::InitialPunctuation
169 | GeneralCategory::FinalPunctuation
170 | GeneralCategory::OtherPunctuation
171 )
172}
173
174fn word_range_at(text: &str, position: usize) -> (usize, usize) {
176 let mut start = 0;
177 let mut end = text.len();
178 for boundary in word_boundaries(text) {
179 if boundary <= position {
180 start = boundary;
181 } else {
182 end = boundary;
183 break;
184 }
185 }
186 if position >= text.len() {
187 start = text.len();
188 }
189 (start, end)
190}
191
192fn stops_at(text: &str, offset: usize, forward: bool) -> bool {
196 let before = text[..offset].chars().next_back();
197 let after = text[offset..].chars().next();
198 let (inner, outer) = if forward {
200 (before, after)
201 } else {
202 (after, before)
203 };
204 let hard_break = match (inner, outer) {
205 (Some(inner), Some(outer)) => is_newline(inner) || is_newline(outer),
206 _ => true,
207 };
208 hard_break || inner.is_some_and(|c| !is_space_separator_or_punctuation(c))
209}
210
211fn word_leading(text: &str, position: isize) -> Option<usize> {
212 let mut position = position;
213 loop {
214 if position < 0 {
215 return None;
216 }
217 let (start, _) = word_range_at(text, clamp_position(text, position));
218 if stops_at(text, start, false) {
219 return Some(start);
220 }
221 position = start as isize - 1;
222 }
223}
224
225fn word_trailing(text: &str, position: isize) -> Option<usize> {
226 let mut position = clamp_position(text, position);
227 loop {
228 let (_, end) = word_range_at(text, position);
229 if stops_at(text, end, true) {
230 return Some(end);
231 }
232 position = end;
233 }
234}
235
236fn is_line_terminator(c: char) -> bool {
239 matches!(
240 c,
241 '\u{000A}' | '\u{000B}' | '\u{000C}' | '\u{000D}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
242 )
243}
244
245fn paragraph_leading(text: &str, position: isize) -> Option<usize> {
246 if position < 0 || text.is_empty() {
247 return None;
248 }
249 let position = clamp_position(text, position);
250 if position >= text.len() {
251 return Some(text.len());
252 }
253 if position == 0 {
254 return Some(0);
255 }
256 let mut index = position;
257 let char_at = |index: usize| text[index..].chars().next();
258 let char_before = |index: usize| text[..index].chars().next_back();
259 if index > 1 && char_at(index) == Some('\n') && char_before(index) == Some('\r') {
261 index -= 2;
262 } else if char_at(index).is_some_and(is_line_terminator) {
263 index = previous_unit(text, index).max(0) as usize;
264 }
265 while index > 0 {
266 if let Some(c) = char_at(index)
267 && is_line_terminator(c)
268 {
269 return Some(index + c.len_utf8());
270 }
271 index = previous_unit(text, index).max(0) as usize;
272 }
273 Some(0)
274}
275
276fn paragraph_trailing(text: &str, position: isize) -> Option<usize> {
277 if text.is_empty() {
278 return None;
279 }
280 if position < 0 {
281 return Some(0);
282 }
283 let position = clamp_position(text, position);
284 if position >= text.len() {
285 return None;
286 }
287 let mut index = position;
288 loop {
289 let c = text[index..].chars().next()?;
290 if is_line_terminator(c) {
291 let next = index + c.len_utf8();
293 return Some(if c == '\r' && text[next..].starts_with('\n') {
294 next + 1
295 } else {
296 next
297 });
298 }
299 index += c.len_utf8();
300 if index == text.len() {
301 return Some(index);
302 }
303 }
304}
305
306#[must_use]
311pub fn word_boundary_at(text: &str, offset: usize) -> (usize, usize) {
312 let offset = floor_char_boundary(text, offset);
313 if offset >= text.len() {
314 return (text.len(), text.len());
315 }
316 let boundaries = word_boundaries(text);
317 let mut start = 0;
318 for boundary in boundaries {
319 if boundary > offset {
320 return (start, boundary);
321 }
322 start = boundary;
323 }
324 (start, text.len())
325}
326
327#[must_use]
329pub const fn is_layout_whitespace(c: char) -> bool {
330 matches!(
331 c as u32,
332 0x0009..=0x000D
333 | 0x001C..=0x0020
334 | 0x00A0
335 | 0x1680
336 | 0x2000..=0x200A
337 | 0x202F
338 | 0x205F
339 | 0x3000
340 )
341}