///|
/// A codepoint position whose flanking codepoints are ASCII (or absent)
/// and do not form a CR-LF glue pair is always a grapheme cluster
/// boundary: ASCII codepoints neither extend a preceding cluster nor begin
/// one that a preceding cluster extends.
fn grapheme_trivial_boundary(before : Char?, at : Char?) -> Bool {
let ascii_before = match before {
None => true
Some(ch) => ch.to_int() < 128
}
let ascii_at = match at {
None => true
Some(ch) => ch.to_int() < 128
}
let cr_lf_glue = match (before, at) {
(Some('\r'), Some('\n')) => true
_ => false
}
ascii_before && ascii_at && !cr_lf_glue
}
///|
/// Result of one allocation-free forward scan for two codepoint
/// positions: total codepoint length, the UTF-16 offsets of the clamped
/// positions, and the codepoints flanking them (enough to prove both sit
/// on trivial grapheme boundaries without materializing the boundary
/// table).
priv struct TextRangeScan {
start : Int32
end : Int32
start_offset : Int32
end_offset : Int32
before_start : Char?
at_start : Char?
before_end : Char?
at_end : Char?
}
///|
fn scan_text_for_range(
text : String,
start~ : Int32,
end~ : Int32,
) -> TextRangeScan {
let want_start = max_int(0, start)
let want_end = max_int(0, end)
let mut index = 0
let mut offset = 0
let mut found_start = false
let mut found_end = false
let mut start_offset = 0
let mut end_offset = 0
let mut before_start : Char? = None
let mut at_start : Char? = None
let mut before_end : Char? = None
let mut at_end : Char? = None
let mut previous : Char? = None
for ch in text {
if !found_start && index == want_start {
found_start = true
start_offset = offset
before_start = previous
at_start = Some(ch)
}
if !found_end && index == want_end {
found_end = true
end_offset = offset
before_end = previous
at_end = Some(ch)
}
previous = Some(ch)
offset = offset + ch.utf16_len()
index = index + 1
// Both endpoints resolved: the scan can stop. Callers that need the
// total length only use it in the not-found fallback paths (they pass
// `text.char_length()` there), so truncating `index` here is safe and
// keeps an early-document caret from scanning the whole document.
if found_start && found_end {
break
}
}
if !found_start {
start_offset = offset
before_start = previous
at_start = None
}
if !found_end {
end_offset = offset
before_end = previous
at_end = None
}
{
start: clamp_int(want_start, 0, index),
end: clamp_int(want_end, 0, index),
start_offset,
end_offset,
before_start,
at_start,
before_end,
at_end,
}
}
///|
/// Normalizes a range to grapheme boundaries, skipping the full boundary
/// table when both endpoints already sit on trivial boundaries.
fn normalize_grapheme_range_fast(text : String, range : TextRange) -> TextRange {
let normalized = range.normalized()
let scan = scan_text_for_range(
text,
start=normalized.start,
end=normalized.end,
)
let endpoints_trivial = grapheme_trivial_boundary(
scan.before_start,
scan.at_start,
) &&
grapheme_trivial_boundary(scan.before_end, scan.at_end)
if endpoints_trivial {
TextRange::new(start=scan.start, end=scan.end)
} else {
normalize_grapheme_range(TextGraphemeBoundaries::new(text~), normalized)
}
}
///|
/// The grapheme boundary nearest `index`, skipping the full boundary
/// table when the clamped position sits on a trivial boundary.
fn nearest_boundary_fast(text : String, index : Int32) -> Int32 {
let scan = scan_text_for_range(text, start=index, end=index)
if grapheme_trivial_boundary(scan.before_start, scan.at_start) {
scan.start
} else {
TextGraphemeBoundaries::new(text~).nearest_boundary(scan.start)
}
}
///|
/// What a double-click treats as one selectable unit around a codepoint.
/// `JoinWord` and `JoinBlank` extend over neighbours of the same class;
/// `Alone` is a one-grapheme word, which is how UAX#29 segments Han, kana and
/// Hangul.
priv enum TextWordCharClass {
JoinWord
JoinBlank
Alone
}
///|
/// Codepoints that stand alone: Han, kana, Hangul and the punctuation, symbol,
/// control and pictograph blocks they are written with. This is a bounded block
/// list, not a generated UAX#29 table — see `docs/text-system.md` for exactly
/// what is left out.
fn text_word_alone_char(ch : Char) -> Bool {
ch
is ('\u4E00'..<='\u9FFF'
| '\u3400'..<='\u4DBF'
| '\uF900'..<='\uFAFF'
| '\u3041'..<='\u309F'
| '\u30A0'..<='\u30FF'
| '\uAC00'..<='\uD7A3'
| '\u1100'..<='\u11FF'
| '\u2010'..<='\u2027'
| '\u202A'..<='\u205E'
| '\u2060'..<='\u206F'
| '\u2E00'..<='\u2E7F'
| '\u3001'..<='\u303F'
| '\uFE10'..<='\uFE19'
| '\uFE30'..<='\uFE6F'
| '\uFF01'..<='\uFF20'
| '\uFF3B'..<='\uFF40'
| '\uFF5B'..<='\uFF65'
| '\u00A1'
| '\u00A7'
| '\u00AB'
| '\u00AD'
| '\u00B6'
| '\u00B7'
| '\u00BB'
| '\u00BF'
| '\u200B'..<='\u200F'
| '\u{1F000}'..<='\u{1FAFF}')
}
///|
fn text_word_char_class(ch : Char) -> TextWordCharClass {
if ch.is_whitespace() {
JoinBlank
} else if ch.is_ascii() {
if ch.is_ascii_alphabetic() || ch.is_ascii_digit() || ch == '_' {
JoinWord
} else {
Alone
}
} else if text_word_alone_char(ch) || ch.is_control() {
Alone
} else {
JoinWord
}
}
///|
/// Only two same-class joiners extend a word: a singleton (Han, kana, Hangul,
/// punctuation, control) always stands alone, even next to its own class.
fn text_word_classes_join(
before : TextWordCharClass,
after : TextWordCharClass,
) -> Bool {
match (before, after) {
(JoinWord, JoinWord) | (JoinBlank, JoinBlank) => true
_ => false
}
}
///|
/// `[start, end)` of the run containing char index `target`, in one pass with
/// no allocation. A run closes whenever the class changes or either side is a
/// singleton, so `你好` yields two runs and `ab--cd` three.
fn text_word_run_bounds(text : String, target : Int32) -> (Int32, Int32) {
let mut i : Int32 = 0
let mut previous : TextWordCharClass? = None
let mut start : Int32 = 0
let mut end : Int32 = -1
let mut inside = false
for ch in text {
let class = text_word_char_class(ch)
let opens_run = match previous {
None => true
Some(previous) => !text_word_classes_join(previous, class)
}
if opens_run {
if inside {
end = i
break
} else {
start = i
}
}
if i == target {
inside = true
}
previous = Some(class)
i = i + 1
}
(start, if end < 0 { text_length(text) } else { end })
}
///|
/// The word a double-click selects at char index `index`: a maximal run of
/// same-class codepoints, or one grapheme when the class is `Alone`. `index` is
/// clamped, the result is grapheme-safe (a ZWJ sequence is never cut) and only
/// empty text returns an empty range.
pub fn text_word_range_at(text : String, index : Int32) -> TextRange {
let length = text_length(text)
if length == 0 {
return TextRange::new(start=0, end=0)
}
let (start, end) = text_word_run_bounds(text, clamp_int(index, 0, length - 1))
normalize_grapheme_range_fast(text, TextRange::new(start~, end~))
}