use std::ops::Range;
use std::rc::Rc;
use teksilo_canvas::{Point, Rect};
pub trait TextInputDocument {
fn is_available(&self) -> bool {
true
}
fn char_count(&self) -> Option<usize>;
fn text_in(&self, range: Range<usize>) -> Option<String>;
fn selection(&self) -> Option<Range<usize>>;
fn caret(&self) -> Option<usize>;
fn composition_range(&self) -> Option<Range<usize>>;
fn first_line_rect(&self, range: Range<usize>) -> Option<FirstLineRect>;
fn offset_at_point(&self, window_point: Point) -> Option<usize>;
fn visible_text_rect(&self) -> Option<Rect>;
fn is_editable(&self) -> bool;
fn is_secure(&self) -> bool;
fn writing_tools_allowed(&self) -> bool;
fn utf16_index(&self) -> Option<Rc<Utf16Index>>;
fn char_to_utf16(&self, char_offset: usize) -> Option<usize> {
Some(self.utf16_index()?.char_to_utf16(char_offset))
}
fn utf16_to_char(&self, utf16_offset: usize) -> Option<usize> {
Some(self.utf16_index()?.utf16_to_char(utf16_offset))
}
fn char_range_to_utf16(&self, range: Range<usize>) -> Option<Range<usize>> {
Some(self.utf16_index()?.char_range_to_utf16(range))
}
fn utf16_range_to_chars(&self, range: Range<usize>) -> Option<Range<usize>> {
Some(self.utf16_index()?.utf16_range_to_chars(range))
}
fn flush_pending_input(&self) -> bool {
false
}
fn revisions(&self) -> Option<InputRevisions> {
None
}
fn text_changed_since(&self, revision: u64) -> Option<TextChanged> {
let _ = revision;
None
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct InputRevisions {
pub text: u64,
pub selection: u64,
pub layout: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeCause {
InputSystem,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextChanged {
pub start: usize,
pub old_end: usize,
pub new_end: usize,
pub cause: ChangeCause,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WriteOptions {
pub dry_run: bool,
pub correction: bool,
pub origin: Option<crate::ImeOrigin>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Landed {
pub inserted: Range<usize>,
pub replaced: Range<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WriteRefused {
#[error("the document takes no writes from the input system")]
Unsupported,
#[error("the field is read-only")]
ReadOnly,
#[error("the range is outside the document")]
InvalidRange,
#[error("the widget is busy")]
Busy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SegmentStyle {
Input,
Converted,
Target,
TargetInput,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompositionSegment {
pub range: Range<usize>,
pub style: SegmentStyle,
}
pub trait TextInputEditor {
fn replace_text(
&self,
range: Range<usize>,
text: &str,
options: WriteOptions,
) -> Result<Landed, WriteRefused> {
let _ = (range, text, options);
Err(WriteRefused::Unsupported)
}
fn set_selection(
&self,
selection: Range<usize>,
caret_at_start: bool,
) -> Result<(), WriteRefused> {
let _ = (selection, caret_at_start);
Err(WriteRefused::Unsupported)
}
fn mark_composition(
&self,
range: Range<usize>,
segments: &[CompositionSegment],
) -> Result<(), WriteRefused> {
let _ = (range, segments);
Err(WriteRefused::Unsupported)
}
fn end_composition(&self, origin: crate::ImeOrigin) -> Result<(), WriteRefused> {
let _ = origin;
Err(WriteRefused::Unsupported)
}
fn begin_input_batch(&self) {}
fn end_input_batch(&self) {}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FirstLineRect {
pub rect: Rect,
pub covered: Range<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Utf16Index {
char_count: usize,
supplementary: Vec<usize>,
}
impl Utf16Index {
pub fn new(text: &str) -> Self {
let mut char_count = 0;
let mut supplementary = Vec::new();
for c in text.chars() {
if c.len_utf16() == 2 {
supplementary.push(char_count);
}
char_count += 1;
}
Self {
char_count,
supplementary,
}
}
pub fn char_count(&self) -> usize {
self.char_count
}
pub fn utf16_len(&self) -> usize {
self.char_count + self.supplementary.len()
}
pub fn char_to_utf16(&self, char_offset: usize) -> usize {
let c = char_offset.min(self.char_count);
c + self.supplementary.partition_point(|&s| s < c)
}
pub fn utf16_to_char(&self, utf16_offset: usize) -> usize {
self.utf16_to_char_rounding(utf16_offset, false)
}
pub fn char_range_to_utf16(&self, range: Range<usize>) -> Range<usize> {
let start = self.char_to_utf16(range.start);
let end = self.char_to_utf16(range.end).max(start);
start..end
}
pub fn utf16_range_to_chars(&self, range: Range<usize>) -> Range<usize> {
let start = self.utf16_to_char_rounding(range.start, false);
if range.end <= range.start {
return start..start;
}
let end = self.utf16_to_char_rounding(range.end, true).max(start);
start..end
}
fn utf16_to_char_rounding(&self, utf16_offset: usize, up: bool) -> usize {
let u = utf16_offset.min(self.utf16_len());
let (mut low, mut high) = (0, self.supplementary.len());
while low < high {
let middle = (low + high) / 2;
if self.supplementary[middle] + middle + 2 <= u {
low = middle + 1;
} else {
high = middle;
}
}
let ended = low;
let char_offset = u - ended;
let splits_a_pair = self
.supplementary
.get(ended)
.is_some_and(|&s| s + ended < u);
if splits_a_pair && !up {
char_offset - 1
} else {
char_offset
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn brute_char_to_utf16(text: &str, c: usize) -> usize {
text.chars().take(c).map(char::len_utf16).sum()
}
#[test]
fn ascii_converts_one_for_one() {
let index = Utf16Index::new("hello");
assert_eq!(index.char_count(), 5);
assert_eq!(index.utf16_len(), 5);
for c in 0..=5 {
assert_eq!(index.char_to_utf16(c), c);
assert_eq!(index.utf16_to_char(c), c);
}
}
#[test]
fn a_supplementary_character_takes_two_units_and_one_offset() {
let text = "a\u{1F600}b";
let index = Utf16Index::new(text);
assert_eq!(index.char_count(), 3);
assert_eq!(index.utf16_len(), 4);
assert_eq!(index.utf16_len(), text.encode_utf16().count());
assert_eq!(index.char_to_utf16(0), 0);
assert_eq!(index.char_to_utf16(1), 1);
assert_eq!(index.char_to_utf16(2), 3, "past the pair");
assert_eq!(index.char_to_utf16(3), 4);
assert_eq!(index.utf16_to_char(0), 0);
assert_eq!(index.utf16_to_char(1), 1);
assert_eq!(index.utf16_to_char(2), 1, "between the halves: the emoji");
assert_eq!(index.utf16_to_char(3), 2);
assert_eq!(index.utf16_to_char(4), 3);
}
#[test]
fn every_offset_round_trips_and_matches_the_standard_library() {
let text = "\u{1F600}\u{1F600}x\n\u{2028}\u{FFFC}\u{E9}\u{1F44D}\u{1F3FD}end\u{10FFFF}";
let index = Utf16Index::new(text);
let chars = text.chars().count();
assert_eq!(index.char_count(), chars);
assert_eq!(index.utf16_len(), text.encode_utf16().count());
for c in 0..=chars {
let u = index.char_to_utf16(c);
assert_eq!(u, brute_char_to_utf16(text, c), "char {c}");
assert_eq!(index.utf16_to_char(u), c, "round trip from char {c}");
}
}
#[test]
fn offsets_past_the_end_clamp() {
let index = Utf16Index::new("a\u{1F600}");
assert_eq!(index.char_to_utf16(99), 3);
assert_eq!(index.utf16_to_char(99), 2);
assert_eq!(index.char_range_to_utf16(1..99), 1..3);
assert_eq!(index.utf16_range_to_chars(0..99), 0..2);
assert_eq!(index.utf16_range_to_chars(50..99), 2..2);
}
#[test]
fn a_range_splitting_a_pair_widens_to_the_whole_character() {
let index = Utf16Index::new("a\u{1F600}b");
assert_eq!(index.utf16_range_to_chars(2..3), 1..2);
assert_eq!(index.utf16_range_to_chars(1..2), 1..2);
assert_eq!(index.utf16_range_to_chars(2..2), 1..1);
assert_eq!(index.char_range_to_utf16(1..2), 1..3);
}
#[test]
#[expect(
clippy::reversed_empty_ranges,
reason = "a platform may hand one over; the conversion must not panic on it"
)]
fn a_reversed_range_collapses_at_its_start() {
let index = Utf16Index::new("abc");
assert_eq!(index.char_range_to_utf16(2..1), 2..2);
assert_eq!(index.utf16_range_to_chars(2..1), 2..2);
}
struct Older;
impl TextInputDocument for Older {
fn char_count(&self) -> Option<usize> {
Some(0)
}
fn text_in(&self, _: Range<usize>) -> Option<String> {
Some(String::new())
}
fn selection(&self) -> Option<Range<usize>> {
Some(0..0)
}
fn caret(&self) -> Option<usize> {
Some(0)
}
fn composition_range(&self) -> Option<Range<usize>> {
None
}
fn first_line_rect(&self, _: Range<usize>) -> Option<FirstLineRect> {
None
}
fn offset_at_point(&self, _: Point) -> Option<usize> {
None
}
fn visible_text_rect(&self) -> Option<Rect> {
None
}
fn is_editable(&self) -> bool {
true
}
fn is_secure(&self) -> bool {
false
}
fn writing_tools_allowed(&self) -> bool {
false
}
fn utf16_index(&self) -> Option<Rc<Utf16Index>> {
Some(Rc::new(Utf16Index::default()))
}
}
impl TextInputEditor for Older {}
#[test]
fn a_custom_implementer_keeps_the_defaults() {
let older = Older;
assert!(older.is_available());
assert!(!older.flush_pending_input());
assert_eq!(older.revisions(), None);
assert_eq!(older.text_changed_since(0), None);
let editor: &dyn TextInputEditor = &older;
assert_eq!(
editor.replace_text(0..0, "x", WriteOptions::default()),
Err(WriteRefused::Unsupported)
);
assert_eq!(
editor.set_selection(0..0, false),
Err(WriteRefused::Unsupported)
);
assert_eq!(
editor.mark_composition(0..0, &[]),
Err(WriteRefused::Unsupported)
);
assert_eq!(
editor.end_composition(crate::ImeOrigin::Composition),
Err(WriteRefused::Unsupported)
);
editor.begin_input_batch();
editor.end_input_batch();
}
#[test]
fn an_empty_text_answers_zero() {
let index = Utf16Index::new("");
assert_eq!(index, Utf16Index::default());
assert_eq!(index.char_to_utf16(3), 0);
assert_eq!(index.utf16_to_char(3), 0);
}
}