use std::{cell::RefCell, rc::Rc};
use icu_properties::{CodePointMapData, props::GeneralCategory};
use icu_segmenter::options::WordBreakInvariantOptions;
use icu_segmenter::{GraphemeClusterSegmenter, WordSegmenter};
type WordBreaks = Rc<dyn Fn(&str) -> Option<Vec<usize>>>;
thread_local! {
static WORD_SEGMENTER: RefCell<Option<WordBreaks>> = const { RefCell::new(None) };
}
pub fn set_word_segmenter(segment: impl Fn(&str) -> Option<Vec<usize>> + 'static) {
WORD_SEGMENTER.set(Some(Rc::new(segment)));
}
fn word_boundaries(text: &str) -> Vec<usize> {
if let Some(segment) = WORD_SEGMENTER.with_borrow(Clone::clone)
&& let Some(boundaries) = segment(text)
{
return boundaries;
}
#[cfg(not(target_arch = "wasm32"))]
let segmenter = WordSegmenter::new_auto(WordBreakInvariantOptions::default());
#[cfg(target_arch = "wasm32")]
let segmenter =
WordSegmenter::new_for_non_complex_scripts(WordBreakInvariantOptions::default());
segmenter.segment_str(text).collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextBoundaryKind {
Character,
Word,
Paragraph,
Document,
}
impl TextBoundaryKind {
#[must_use]
pub fn leading_at(self, text: &str, position: isize) -> Option<usize> {
match self {
Self::Character => character_leading(text, position),
Self::Word => word_leading(text, position),
Self::Paragraph => paragraph_leading(text, position),
Self::Document => (position >= 0).then_some(0),
}
}
#[must_use]
pub fn trailing_at(self, text: &str, position: isize) -> Option<usize> {
match self {
Self::Character => character_trailing(text, position),
Self::Word => word_trailing(text, position),
Self::Paragraph => paragraph_trailing(text, position),
Self::Document => ((position.max(0) as usize) < text.len()).then_some(text.len()),
}
}
#[must_use]
pub fn move_beyond(self, text: &str, extent: usize, forward: bool) -> usize {
let extent = floor_char_boundary(text, extent);
if forward {
self.trailing_at(text, extent as isize)
.unwrap_or(text.len())
} else {
self.leading_at(text, previous_unit(text, extent))
.unwrap_or(0)
}
}
}
fn previous_unit(text: &str, offset: usize) -> isize {
text[..offset]
.char_indices()
.next_back()
.map_or(-1, |(index, _)| index as isize)
}
fn floor_char_boundary(text: &str, offset: usize) -> usize {
let mut offset = offset.min(text.len());
while offset > 0 && !text.is_char_boundary(offset) {
offset -= 1;
}
offset
}
fn clamp_position(text: &str, position: isize) -> usize {
floor_char_boundary(text, position.max(0) as usize)
}
fn grapheme_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
GraphemeClusterSegmenter::new().segment_str(text)
}
fn character_leading(text: &str, position: isize) -> Option<usize> {
if position < 0 {
return None;
}
let position = clamp_position(text, position);
grapheme_boundaries(text)
.take_while(|boundary| *boundary <= position)
.last()
.or(Some(0))
}
fn character_trailing(text: &str, position: isize) -> Option<usize> {
let position = clamp_position(text, position);
if position >= text.len() {
return None;
}
grapheme_boundaries(text)
.find(|boundary| *boundary > position)
.or(Some(text.len()))
}
fn is_newline(c: char) -> bool {
matches!(
c,
'\u{000A}' | '\u{0085}' | '\u{000B}' | '\u{000C}' | '\u{2028}' | '\u{2029}'
)
}
fn is_space_separator_or_punctuation(c: char) -> bool {
matches!(
CodePointMapData::<GeneralCategory>::new().get(c),
GeneralCategory::SpaceSeparator
| GeneralCategory::ConnectorPunctuation
| GeneralCategory::DashPunctuation
| GeneralCategory::OpenPunctuation
| GeneralCategory::ClosePunctuation
| GeneralCategory::InitialPunctuation
| GeneralCategory::FinalPunctuation
| GeneralCategory::OtherPunctuation
)
}
fn word_range_at(text: &str, position: usize) -> (usize, usize) {
let mut start = 0;
let mut end = text.len();
for boundary in word_boundaries(text) {
if boundary <= position {
start = boundary;
} else {
end = boundary;
break;
}
}
if position >= text.len() {
start = text.len();
}
(start, end)
}
fn stops_at(text: &str, offset: usize, forward: bool) -> bool {
let before = text[..offset].chars().next_back();
let after = text[offset..].chars().next();
let (inner, outer) = if forward {
(before, after)
} else {
(after, before)
};
let hard_break = match (inner, outer) {
(Some(inner), Some(outer)) => is_newline(inner) || is_newline(outer),
_ => true,
};
hard_break || inner.is_some_and(|c| !is_space_separator_or_punctuation(c))
}
fn word_leading(text: &str, position: isize) -> Option<usize> {
let mut position = position;
loop {
if position < 0 {
return None;
}
let (start, _) = word_range_at(text, clamp_position(text, position));
if stops_at(text, start, false) {
return Some(start);
}
position = start as isize - 1;
}
}
fn word_trailing(text: &str, position: isize) -> Option<usize> {
let mut position = clamp_position(text, position);
loop {
let (_, end) = word_range_at(text, position);
if stops_at(text, end, true) {
return Some(end);
}
position = end;
}
}
fn is_line_terminator(c: char) -> bool {
matches!(
c,
'\u{000A}' | '\u{000B}' | '\u{000C}' | '\u{000D}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
)
}
fn paragraph_leading(text: &str, position: isize) -> Option<usize> {
if position < 0 || text.is_empty() {
return None;
}
let position = clamp_position(text, position);
if position >= text.len() {
return Some(text.len());
}
if position == 0 {
return Some(0);
}
let mut index = position;
let char_at = |index: usize| text[index..].chars().next();
let char_before = |index: usize| text[..index].chars().next_back();
if index > 1 && char_at(index) == Some('\n') && char_before(index) == Some('\r') {
index -= 2;
} else if char_at(index).is_some_and(is_line_terminator) {
index = previous_unit(text, index).max(0) as usize;
}
while index > 0 {
if let Some(c) = char_at(index)
&& is_line_terminator(c)
{
return Some(index + c.len_utf8());
}
index = previous_unit(text, index).max(0) as usize;
}
Some(0)
}
fn paragraph_trailing(text: &str, position: isize) -> Option<usize> {
if text.is_empty() {
return None;
}
if position < 0 {
return Some(0);
}
let position = clamp_position(text, position);
if position >= text.len() {
return None;
}
let mut index = position;
loop {
let c = text[index..].chars().next()?;
if is_line_terminator(c) {
let next = index + c.len_utf8();
return Some(if c == '\r' && text[next..].starts_with('\n') {
next + 1
} else {
next
});
}
index += c.len_utf8();
if index == text.len() {
return Some(index);
}
}
}
#[must_use]
pub fn word_boundary_at(text: &str, offset: usize) -> (usize, usize) {
let offset = floor_char_boundary(text, offset);
if offset >= text.len() {
return (text.len(), text.len());
}
let boundaries = word_boundaries(text);
let mut start = 0;
for boundary in boundaries {
if boundary > offset {
return (start, boundary);
}
start = boundary;
}
(start, text.len())
}
#[must_use]
pub const fn is_layout_whitespace(c: char) -> bool {
matches!(
c as u32,
0x0009..=0x000D
| 0x001C..=0x0020
| 0x00A0
| 0x1680
| 0x2000..=0x200A
| 0x202F
| 0x205F
| 0x3000
)
}