///|
pub(all) struct TextCommandResult {
handled : Bool
changed : Bool
clipboard_text : String?
} derive(Eq, Debug, ToJson)
///|
pub extend TextCommandResult with Debug::{to_repr}
///|
pub extend TextCommandResult with Eq::{not_equal, equal}
///|
pub extend TextCommandResult with ToJson::{to_json}
///|
pub fn TextCommandResult::ignored() -> TextCommandResult {
{ handled: false, changed: false, clipboard_text: None, }
}
///|
pub fn TextCommandResult::handled(
changed? : Bool = false,
clipboard_text? : String? = None,
) -> TextCommandResult {
{ handled: true, changed, clipboard_text, }
}
///|
pub(all) enum TextCaretDirection {
MoveLeft
MoveRight
MoveStart
MoveEnd
}
///|
pub(all) struct TextCaretMoveResult {
caret : Int
selection : TextRange?
}
///|
pub fn active_text_selection(selection : TextRange?) -> TextRange? {
match selection {
Some(selection) => {
let normalized = selection.normalized()
if normalized.is_collapsed() {
None
} else {
Some(normalized)
}
}
None => None
}
}
///|
fn text_selection_anchor(caret : Int, selection : TextRange?) -> Int {
match selection {
Some(selection) =>
if caret == selection.start {
selection.end
} else {
selection.start
}
None => caret
}
}
///|
/// 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 : Int
end : Int
start_offset : Int
end_offset : Int
before_start : Char?
at_start : Char?
before_end : Char?
at_end : Char?
}
///|
fn scan_text_for_range(
text : String,
start~ : Int,
end~ : Int,
) -> 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 : Int) -> Int {
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)
}
}
///|
pub fn move_text_caret(
text : String,
caret : Int,
selection : TextRange?,
direction : TextCaretDirection,
extend_selection~ : Bool,
) -> TextCaretMoveResult {
let length = text_length(text)
let caret = clamp_int(caret, 0, length)
if extend_selection {
let anchor = nearest_boundary_fast(
text,
text_selection_anchor(caret, selection),
)
let next = move_text_caret_index(text, caret, direction)
{
caret: next,
selection: if next == anchor {
None
} else {
Some(TextRange::new(start=anchor, end=next))
},
}
} else {
match active_text_selection(selection) {
Some(selection) => {
let selection = normalize_grapheme_range_fast(text, selection)
match direction {
MoveLeft => { caret: selection.start, selection: None, }
MoveRight => { caret: selection.end, selection: None, }
MoveStart => { caret: 0, selection: None, }
MoveEnd => { caret: length, selection: None, }
}
}
None =>
{
caret: move_text_caret_index(text, caret, direction),
selection: None,
}
}
}
}
///|
/// Returns cp(caret-2) … cp(caret+1) when every window codepoint is
/// ASCII, or None to signal the general fallback. A caret move only
/// depends on the cluster rules at these five codepoints: break
/// decisions reference the flanking pair and, for RI/Extend/Indic
/// context, runs that would place a non-ASCII codepoint inside the
/// window themselves.
fn ascii_window_around_caret(
text : String,
caret~ : Int,
) -> (Char?, Char?, Char?, Char?)? {
let mut index = 0
let mut minus_two : Char? = None
let mut minus_one : Char? = None
let mut at : Char? = None
let mut plus_one : Char? = None
for ch in text {
if index >= caret - 2 && index <= caret + 1 && ch.to_int() >= 128 {
return None
}
if index == caret - 2 {
minus_two = Some(ch)
} else if index == caret - 1 {
minus_one = Some(ch)
} else if index == caret {
at = Some(ch)
} else if index == caret + 1 {
plus_one = Some(ch)
return Some((minus_two, minus_one, at, plus_one))
}
index = index + 1
}
Some((minus_two, minus_one, at, plus_one))
}
///|
fn local_ascii_caret_move(
text : String,
caret : Int,
direction : TextCaretDirection,
length~ : Int,
) -> Int? {
match ascii_window_around_caret(text, caret~) {
None => None
Some((minus_two, minus_one, at, plus_one)) =>
match direction {
MoveLeft =>
if caret <= 0 {
Some(0)
} else {
// CR-LF is one cluster: stepping left out of the pair returns
// to the CR, not onto the glued LF.
match (minus_two, minus_one) {
(Some('\r'), Some('\n')) => Some(caret - 2)
_ => Some(caret - 1)
}
}
MoveRight =>
if caret >= length {
Some(length)
} else {
match (at, plus_one) {
(Some('\r'), Some('\n')) => Some(caret + 2)
_ => Some(caret + 1)
}
}
MoveStart => Some(0)
MoveEnd => Some(length)
}
}
}
///|
fn move_text_caret_index(
text : String,
caret : Int,
direction : TextCaretDirection,
) -> Int {
let length = text_length(text)
let caret = clamp_int(caret, 0, length)
match local_ascii_caret_move(text, caret, direction, length~) {
Some(next) => next
None => {
let boundaries = TextGraphemeBoundaries::new(text~)
let caret = boundaries.clamp_index(caret)
match direction {
MoveLeft => boundaries.previous_boundary(caret)
MoveRight => boundaries.next_boundary(caret)
MoveStart => 0
MoveEnd => boundaries.length()
}
}
}
}
///|
fn text_length(text : String) -> Int {
text.char_length()
}
///|
pub fn selected_text(text : String, selection : TextRange?) -> String {
match active_text_selection(selection) {
Some(selection) => {
let normalized = normalize_grapheme_range_fast(text, selection)
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 {
text.exact_view(start=scan.start_offset, end=scan.end_offset).to_owned()
} else {
text_substring_by_char_range(
text,
normalized.start,
normalized.end,
length=text.char_length(),
)
}
}
None => ""
}
}
///|
/// Replace a grapheme-normalized range in one document scan: returns the
/// next text, the deleted slice, and the normalized start — no char-array
/// or boundary-table copies.
pub fn replace_text_range_edit(
text~ : String,
range : TextRange,
replacement : String,
) -> (String, String, Int) {
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 {
let next_text = text.exact_view(start=0, end=scan.start_offset).to_owned() +
replacement +
text.exact_view(start=scan.end_offset, end=text.length()).to_owned()
let deleted = text
.exact_view(start=scan.start_offset, end=scan.end_offset)
.to_owned()
(next_text, deleted, scan.start)
} else {
let range = normalize_grapheme_range(
TextGraphemeBoundaries::new(text~),
normalized,
)
let length = text.char_length()
let next_text = text_substring_by_char_range(text, 0, range.start, length~) +
replacement +
text_substring_by_char_range(text, range.end, length, length~)
let deleted = text_substring_by_char_range(
text,
range.start,
range.end,
length~,
)
(next_text, deleted, range.start)
}
}
///|
pub fn replace_text_range(
text : String,
range : TextRange,
replacement : String,
) -> String {
replace_text_range_edit(text~, range, replacement).0
}
///|
fn text_substring_by_char_range(
text : String,
start : Int,
end : Int,
length~ : Int,
) -> String {
let start = clamp_int(start, 0, length)
let end = clamp_int(end, start, length)
let utf16_length = text.length()
let start_offset = if start == length {
utf16_length
} else {
text.offset_of_nth_char(start).unwrap_or(utf16_length)
}
let end_offset = if end == length {
utf16_length
} else {
text.offset_of_nth_char(end).unwrap_or(utf16_length)
}
text.exact_view(start=start_offset, end=end_offset).to_owned()
}
///|
test "text editing range helpers preserve unicode scalar offsets" {
let source = "a🤣b"
inspect(
selected_text(source, Some(TextRange::new(start=1, end=2))),
content="🤣",
)
inspect(
replace_text_range(source, TextRange::new(start=1, end=2), "尾"),
content="a尾b",
)
inspect(
replace_text_range(source, TextRange::collapsed(3), "!"),
content="a🤣b!",
)
}
///|
pub fn surrounding_delete_range(
text : String,
caret : Int,
before : Int,
after : Int,
) -> TextRange {
let length = text_length(text)
let before = max_int(0, before)
let after = max_int(0, after)
let range = delete_grapheme_range(
TextGraphemeBoundaries::new(text~),
caret,
before,
after,
)
TextRange::new(
start=clamp_int(range.start, 0, length),
end=clamp_int(range.end, 0, length),
)
}
///|
pub fn text_input_grapheme_selection(
boundaries : TextGraphemeBoundaries,
selection : TextRange?,
) -> TextRange? {
match selection {
Some(selection) => {
let start = boundaries.nearest_boundary(selection.start)
let end = boundaries.nearest_boundary(selection.end)
if start == end {
None
} else {
Some(TextRange::new(start~, end~))
}
}
None => None
}
}
///|
pub fn composition_start_index(
text : String,
caret : Int,
selection : TextRange?,
) -> Int {
match active_text_selection(selection) {
Some(selection) => normalize_grapheme_range_fast(text, selection).start
None => nearest_boundary_fast(text, caret)
}
}
///|
pub fn composition_cursor_offset(
control : TextControlStateContext,
composition : String,
) -> Int {
let length = text_length(composition)
match control.composition_cursor {
Some(cursor) => {
let normalized = cursor.normalized()
TextGraphemeBoundaries::new(text=composition).nearest_boundary(
clamp_int(normalized.end, 0, length),
)
}
None => length
}
}
///|
pub fn is_select_all_shortcut(event : KeyboardEvent) -> Bool {
event.pressed &&
(event.key == "a" || event.key == "A") &&
(event.modifiers.meta || event.modifiers.control)
}
///|
pub fn is_undo_shortcut(event : KeyboardEvent) -> Bool {
event.pressed &&
(event.key == "z" || event.key == "Z") &&
!event.modifiers.shift &&
(event.modifiers.meta || event.modifiers.control)
}
///|
pub fn is_redo_shortcut(event : KeyboardEvent) -> Bool {
event.pressed &&
(
(event.modifiers.shift && (event.key == "z" || event.key == "Z")) ||
(!event.modifiers.shift && (event.key == "y" || event.key == "Y"))
) &&
(event.modifiers.meta || event.modifiers.control)
}
///|
/// Baselines that always build the full boundary table, so the localized
/// fast paths can be cross-checked on every position of tricky documents.
fn full_boundary_replace(
text : String,
range : TextRange,
replacement : String,
) -> String {
let normalized = range.normalized()
let range = normalize_grapheme_range(
TextGraphemeBoundaries::new(text~),
normalized,
)
let length = text_length(text)
text_substring_by_char_range(text, 0, range.start, length~) +
replacement +
text_substring_by_char_range(text, range.end, length, length~)
}
///|
fn full_boundary_move(
text : String,
caret : Int,
direction : TextCaretDirection,
) -> Int {
let boundaries = TextGraphemeBoundaries::new(text~)
let length = boundaries.length()
let caret = boundaries.clamp_index(caret)
match direction {
MoveLeft => boundaries.previous_boundary(caret)
MoveRight => boundaries.next_boundary(caret)
MoveStart => 0
MoveEnd => length
}
}
///|
fn full_boundary_selected(text : String, selection : TextRange) -> String {
let length = text_length(text)
let range = normalize_grapheme_range(
TextGraphemeBoundaries::new(text~),
selection,
)
text_substring_by_char_range(text, range.start, range.end, length~)
}
///|
test "localized editing paths match the full boundary implementation" {
let samples = [
"", "a", "hello world", "a🤣b", "e\u{301} x é\u{300}y", "🇨🇳🇺🇸 flags",
"👨👩👧 family ok", "한글 syllable", "a\r\nb\r\nc", "plain\nlines\nhere\n",
"中文 mixed with latin",
]
let directions = [MoveLeft, MoveRight, MoveStart, MoveEnd]
for text in samples {
let length = text.char_length()
for caret in 0..<=(length + 1) {
for direction in directions {
assert_eq(
move_text_caret_index(text, caret, direction),
full_boundary_move(text, caret, direction),
)
}
for end in [caret, caret + 1, caret + 2, caret + 5, length] {
let range = TextRange::new(start=caret, end~)
assert_eq(
replace_text_range(text, range, "X"),
full_boundary_replace(text, range, "X"),
)
assert_eq(
replace_text_range_edit(text~, range, "尾Y").1,
text_substring_by_char_range(
text,
normalize_grapheme_range(
TextGraphemeBoundaries::new(text~),
range.normalized(),
).start,
normalize_grapheme_range(
TextGraphemeBoundaries::new(text~),
range.normalized(),
).end,
length~,
),
)
assert_eq(
selected_text(text, Some(range)),
full_boundary_selected(text, range.normalized()),
)
}
assert_eq(
composition_start_index(text, caret, None),
TextGraphemeBoundaries::new(text~).nearest_boundary(
clamp_int(caret, 0, length),
),
)
}
}
}