teksilo_core/text_input_document.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TextInputDocument`: a text widget's document as the platform's text input
5//! system reads it.
6//!
7//! # Why the framework has to answer this
8//!
9//! A platform's text features do not only insert text; they read the
10//! document they insert into. On macOS, Dictation, the press-and-hold accent
11//! popup and Writing Tools each start by asking the view where the selection
12//! is (`selectedRange`), and Writing Tools then reads the selected text
13//! (`attributedSubstringForProposedRange:actualRange:`). A view that answers
14//! "nowhere" and "nothing" gets nothing back: dictation never starts, the
15//! accent popup never shows, Writing Tools says "Try a longer selection". The
16//! same view is asked where a range is on screen
17//! (`firstRectForCharacterRange:actualRange:`) to place a candidate window or
18//! a popover, and which character is under a point
19//! (`characterIndexForPoint:`).
20//!
21//! There is one native view per window and many text widgets in it, so the
22//! view can only answer for the focused one, and only that widget knows its
23//! own document. This trait is how it says what it knows. A widget reaches it
24//! through [`TextSurface::input_document`](crate::text_surface::TextSurface::input_document),
25//! so a platform bridge holding the window's
26//! [`TextSurfaces`](crate::text_surface::TextSurfaces) answers for whichever
27//! text widget has focus without knowing which kind it is.
28//!
29//! # Offsets
30//!
31//! Every offset crossing this trait is a **character** offset (a count of
32//! Unicode scalar values) into the widget's document, the space its caret,
33//! its selection and an [`ImeCommit`](crate::event::WidgetEvent::ImeCommit)'s
34//! replacement range are in. The platforms count in UTF-16 code units instead
35//! (an `NSRange` does), so a character outside the Basic Multilingual Plane,
36//! an emoji, is one offset here and two there. [`Utf16Index`] converts
37//! between the two over the same text, and
38//! [`utf16_index`](TextInputDocument::utf16_index) hands out the one for the
39//! document as it stands.
40//!
41//! # Coordinates
42//!
43//! Every point and rectangle is in **window** logical coordinates, the space
44//! [`WidgetEvent::PointerDown`](crate::event::WidgetEvent::PointerDown)
45//! positions arrive in and the IME cursor area is reported in. The bridge
46//! converts to the platform's screen space.
47//!
48//! # Busy and unanswerable
49//!
50//! The bridge is called by the platform whenever the platform likes, which
51//! can be while the widget is in the middle of its own work with its state
52//! borrowed. Every query that returns an `Option` answers `None` then, and
53//! the boolean queries give the cautious answer their documentation names,
54//! rather than panicking. `None` is also the answer when there is nothing to
55//! say: no layout yet, a point outside the text, a secure field's text.
56//!
57//! # Typing not yet inserted
58//!
59//! A widget that batches typed characters until its next frame must answer as
60//! if they were in the document: the input system names offsets in the text
61//! the user has typed, and the commit that follows a question is applied to
62//! that text. The framework's widgets insert the batch before answering, as
63//! their frame tick would; see each implementation for why that is safe
64//! there.
65
66use std::ops::Range;
67use std::rc::Rc;
68
69use teksilo_canvas::{Point, Rect};
70
71/// A text widget's document, as a platform's text input system reads it.
72///
73/// See the [module documentation](self) for the offsets (characters), the
74/// coordinates (window-logical) and what `None` means. Object-safe: a bridge
75/// holds `&dyn TextInputDocument` for whichever widget has focus.
76pub trait TextInputDocument {
77 /// Can the document answer now **without changing anything**? Asking
78 /// changes nothing, and when the answer is `true` neither do the queries
79 /// that follow, until the application runs again: they insert no typing,
80 /// run no callback and request no frame.
81 ///
82 /// `false` while the widget is in the middle of its own work, and while
83 /// it holds typing not yet inserted, which a query would insert first
84 /// (see the [module documentation](self#typing-not-yet-inserted)), firing
85 /// the application's change callbacks. A bridge called where application
86 /// code must not run (Windows asks from inside a window procedure, ahead
87 /// of winit) asks it first and answers as if there were no document when
88 /// it is `false`. None of the other queries can serve as this question.
89 ///
90 /// `true` by default: a document that batches nothing and never holds its
91 /// state across a call into the platform answers every query or `None`,
92 /// changing nothing.
93 fn is_available(&self) -> bool {
94 true
95 }
96
97 /// The length of the document, in characters.
98 fn char_count(&self) -> Option<usize>;
99
100 /// The text of `range`, clamped to the document.
101 ///
102 /// In the document's own character space: a paragraph break is one `\n`,
103 /// a line break inside a paragraph is `U+2028`, an embedded object (an
104 /// image, a table's anchor) is its `U+FFFC`, so offsets into the answer
105 /// are offsets into the document. `None` for a secure field, whatever its
106 /// reveal state: its text does not leave it.
107 fn text_in(&self, range: Range<usize>) -> Option<String>;
108
109 /// The selection, lowest offset first, whichever end the caret is at. An
110 /// empty range is a caret with nothing selected.
111 fn selection(&self) -> Option<Range<usize>>;
112
113 /// The caret: the end of the selection that moves.
114 fn caret(&self) -> Option<usize>;
115
116 /// The range the input method's composition occupies, while one is in
117 /// progress: its preedit, inserted tentatively in the document (macOS
118 /// calls it marked text). `None` when nothing is being composed.
119 fn composition_range(&self) -> Option<Range<usize>>;
120
121 /// Where the first line of `range` is drawn, and which characters of
122 /// `range` that line holds.
123 ///
124 /// `range` is clamped to the document. A range that wraps onto later
125 /// lines answers for its first line only, and
126 /// [`covered`](FirstLineRect::covered) ends where that line does, so a
127 /// caller wanting every line asks again from there. For a non-empty range
128 /// `covered` is never empty, so such a walk always advances. An empty
129 /// range answers with the caret rectangle at its offset.
130 ///
131 /// A secure field that shows nothing at all (no echo) answers every range
132 /// with the same rectangle, at the start of its empty line, covering the
133 /// whole range: its geometry would otherwise tell the text's length.
134 ///
135 /// `None` before the widget's first layout.
136 fn first_line_rect(&self, range: Range<usize>) -> Option<FirstLineRect>;
137
138 /// The character offset nearest `window_point`.
139 ///
140 /// `None` outside the [`visible_text_rect`](Self::visible_text_rect), and
141 /// for a secure field that shows nothing. Inside it, a point beside a
142 /// line answers that line's nearest end, and a point below the last line
143 /// the end of the document.
144 fn offset_at_point(&self, window_point: Point) -> Option<usize>;
145
146 /// The rectangle the widget shows its text in, the part of the document
147 /// that is on screen.
148 fn visible_text_rect(&self) -> Option<Rect>;
149
150 /// Does the widget take text? `false` for a read-only one, which refuses
151 /// an input-system commit, and while the widget is busy.
152 fn is_editable(&self) -> bool;
153
154 /// Is this a secure field, a password? Its text is never given out (see
155 /// [`text_in`](Self::text_in)), and a bridge offers it none of the
156 /// features that would read it or write into it from outside the
157 /// keyboard. `true` while the widget is busy, the cautious answer.
158 fn is_secure(&self) -> bool;
159
160 /// May the system's Writing Tools read and replace this widget's text?
161 /// The application's `writing_tools` setting; always `false` for a
162 /// secure field, and while the widget is busy.
163 fn writing_tools_allowed(&self) -> bool;
164
165 /// The UTF-16 index of the document as it stands, for converting
166 /// offsets.
167 ///
168 /// Built once per change of the document's text and shared until the
169 /// next, so a caller converting several offsets for one question asks
170 /// for it once. The conversion methods below go through it.
171 fn utf16_index(&self) -> Option<Rc<Utf16Index>>;
172
173 /// A character offset as a UTF-16 offset. See
174 /// [`Utf16Index::char_to_utf16`].
175 fn char_to_utf16(&self, char_offset: usize) -> Option<usize> {
176 Some(self.utf16_index()?.char_to_utf16(char_offset))
177 }
178
179 /// A UTF-16 offset as a character offset. See
180 /// [`Utf16Index::utf16_to_char`].
181 fn utf16_to_char(&self, utf16_offset: usize) -> Option<usize> {
182 Some(self.utf16_index()?.utf16_to_char(utf16_offset))
183 }
184
185 /// A character range as a UTF-16 range. See
186 /// [`Utf16Index::char_range_to_utf16`].
187 fn char_range_to_utf16(&self, range: Range<usize>) -> Option<Range<usize>> {
188 Some(self.utf16_index()?.char_range_to_utf16(range))
189 }
190
191 /// A UTF-16 range as a character range. See
192 /// [`Utf16Index::utf16_range_to_chars`].
193 fn utf16_range_to_chars(&self, range: Range<usize>) -> Option<Range<usize>> {
194 Some(self.utf16_index()?.utf16_range_to_chars(range))
195 }
196
197 /// Insert typing the widget batched for its next frame, now, firing what
198 /// its frame tick would (the change callbacks, the arrivals report).
199 /// Returns whether anything was inserted.
200 ///
201 /// Call it only where a change may be reported: from the application's
202 /// handling of an event, never inside a platform lock nor ahead of the
203 /// toolkit in a window procedure. After it,
204 /// [`is_available`](Self::is_available) answers `true` unless the widget
205 /// is busy. `false` by default: a document that batches nothing has
206 /// nothing to insert.
207 fn flush_pending_input(&self) -> bool {
208 false
209 }
210
211 /// The document's revisions: of its text, its selection and its layout.
212 /// Each grows whenever the thing it counts changes, and never goes back.
213 ///
214 /// A platform bridge keeps the revisions it last reported and asks again
215 /// after every event: what grew is what it reports (`OnTextChange`,
216 /// `OnSelectionChange`, `OnLayoutChange` on Windows). `None` by default,
217 /// and while the widget is busy: the bridge then compares the text itself.
218 fn revisions(&self) -> Option<InputRevisions> {
219 None
220 }
221
222 /// What changed in the text since text revision `revision`, merged into
223 /// one range, and what caused it. Ask it only when
224 /// [`revisions`](Self::revisions) says the text revision grew.
225 ///
226 /// `None` when the widget no longer knows (the revision is too old, or a
227 /// change came with no range): the caller treats the whole text as
228 /// changed. `None` by default.
229 fn text_changed_since(&self, revision: u64) -> Option<TextChanged> {
230 let _ = revision;
231 None
232 }
233}
234
235/// The revisions [`TextInputDocument::revisions`] answers.
236#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
237pub struct InputRevisions {
238 /// Grows with every change of the text.
239 pub text: u64,
240 /// Grows with every move of the selection or the caret.
241 pub selection: u64,
242 /// Grows whenever the text is drawn elsewhere (a relayout, a scroll).
243 pub layout: u64,
244}
245
246/// What caused a change of the text.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum ChangeCause {
249 /// Writes through [`TextInputEditor`], the platform's input system: a
250 /// bridge does not report these back to it.
251 InputSystem,
252 /// Anything else: typing, a paste, an undo, the application.
253 Other,
254}
255
256/// A change of the text, as [`TextInputDocument::text_changed_since`]
257/// answers it: the text `start..old_end` became `start..new_end`, in
258/// character offsets.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub struct TextChanged {
261 pub start: usize,
262 pub old_end: usize,
263 pub new_end: usize,
264 /// [`ChangeCause::InputSystem`] only when every change merged here came
265 /// from the input system.
266 pub cause: ChangeCause,
267}
268
269/// What a write through [`TextInputEditor::replace_text`] may do.
270#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
271pub struct WriteOptions {
272 /// Say where the text would land, and change nothing (`QueryInsert`,
273 /// `TS_IAS_QUERYONLY` on Windows).
274 pub dry_run: bool,
275 /// A correction: the new text takes the replaced text's character format
276 /// (`TS_ST_CORRECTION`).
277 pub correction: bool,
278 /// Who the text comes from, when the input system says: the field
279 /// reports what lands under that origin's `EditSource` (teksilo-widgets),
280 /// and dictated text or a Writing Tools rewrite goes in through the
281 /// field's rules like any other write (a line break makes a paragraph in
282 /// a rich text editor, a single-line field drops it). `None` is
283 /// the input system's ordinary write, reported as an input method's.
284 /// A write inside a composition ignores it: the composition's text is
285 /// reported under the origin [`TextInputEditor::end_composition`] is
286 /// handed.
287 pub origin: Option<crate::ImeOrigin>,
288}
289
290/// Where a write landed, in the document's character offsets.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct Landed {
293 /// The range the new text occupies.
294 pub inserted: Range<usize>,
295 /// The range it replaced, before the write.
296 pub replaced: Range<usize>,
297}
298
299/// Why a [`TextInputEditor`] refused a write.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
301#[non_exhaustive]
302pub enum WriteRefused {
303 /// The document takes no writes from the input system (the default).
304 #[error("the document takes no writes from the input system")]
305 Unsupported,
306 /// The field is read-only.
307 #[error("the field is read-only")]
308 ReadOnly,
309 /// The range is outside the document, or ends before it starts.
310 #[error("the range is outside the document")]
311 InvalidRange,
312 /// The widget is in the middle of its own work, or holds typing not
313 /// inserted yet ([`TextInputDocument::flush_pending_input`]).
314 #[error("the widget is busy")]
315 Busy,
316}
317
318/// How a stretch of a composition is drawn: TSF's display attributes, the
319/// underlines of macOS marked text, Android's composing spans.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321#[non_exhaustive]
322pub enum SegmentStyle {
323 /// Text being typed, not converted yet.
324 Input,
325 /// A converted clause.
326 Converted,
327 /// The converted clause the input method is working on.
328 Target,
329 /// The unconverted clause the input method is working on.
330 TargetInput,
331}
332
333/// A styled stretch of a composition.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct CompositionSegment {
336 /// Relative to the composition's start, in characters.
337 pub range: Range<usize>,
338 pub style: SegmentStyle,
339}
340
341/// The half of a text widget's document a platform's input system writes
342/// through, beside the [`TextInputDocument`] it reads: a TSF text store on
343/// Windows writes under its locks, and reads back at once what it wrote.
344///
345/// A widget hands it out with
346/// [`TextSurface::input_editor`](crate::text_surface::TextSurface::input_editor),
347/// `None` for a field that takes no text. Every method has a default that
348/// refuses or does nothing, so a custom widget that implements none of them
349/// keeps working: the platform treats it as read-only, or takes another path.
350///
351/// # The batch
352///
353/// Writes come between [`begin_input_batch`](Self::begin_input_batch) and
354/// [`end_input_batch`](Self::end_input_batch) (a TSF lock). Inside it the
355/// widget changes only its own state; every effect the application can
356/// observe (its callbacks, signal writes) is queued, and runs, in order, at
357/// the end of the outermost batch. The batch is also where the writes
358/// outside a composition are grouped into one undo step.
359///
360/// # A composition is a range of text
361///
362/// The input system writes its composition into the document itself, then
363/// marks it ([`mark_composition`](Self::mark_composition)); it is not a
364/// preedit the widget inserts and withdraws. While one is marked, writes
365/// change the document without an undo record, and the field's rules
366/// (filters, a length cap, a single line) wait: they apply to the final text
367/// at [`end_composition`](Self::end_composition), which records it as one
368/// undo step.
369pub trait TextInputEditor {
370 /// Replace `range` with `text`. Outside a composition the field's rules
371 /// apply and what landed is reported; inside one, `text` lands as given.
372 /// A `\r\n` or a lone `\r` lands as one line break.
373 fn replace_text(
374 &self,
375 range: Range<usize>,
376 text: &str,
377 options: WriteOptions,
378 ) -> Result<Landed, WriteRefused> {
379 let _ = (range, text, options);
380 Err(WriteRefused::Unsupported)
381 }
382
383 /// Set the selection, the caret at its start when `caret_at_start`.
384 fn set_selection(
385 &self,
386 selection: Range<usize>,
387 caret_at_start: bool,
388 ) -> Result<(), WriteRefused> {
389 let _ = (selection, caret_at_start);
390 Err(WriteRefused::Unsupported)
391 }
392
393 /// Mark `range` of the document as the input system's composition, drawn
394 /// as `segments` say. The first mark starts the composition (over text
395 /// already in the document, for a reconversion, or as an empty range at
396 /// the caret); a later one moves or restyles it.
397 fn mark_composition(
398 &self,
399 range: Range<usize>,
400 segments: &[CompositionSegment],
401 ) -> Result<(), WriteRefused> {
402 let _ = (range, segments);
403 Err(WriteRefused::Unsupported)
404 }
405
406 /// The composition is over: its text stays, under the field's rules, as
407 /// one undo step, reported as arriving through `origin`'s channel.
408 fn end_composition(&self, origin: crate::ImeOrigin) -> Result<(), WriteRefused> {
409 let _ = origin;
410 Err(WriteRefused::Unsupported)
411 }
412
413 /// Start a batch of writes. Batches nest.
414 fn begin_input_batch(&self) {}
415
416 /// End a batch of writes; at the end of the outermost one, what the
417 /// batch queued runs.
418 fn end_input_batch(&self) {}
419}
420
421/// The answer to [`TextInputDocument::first_line_rect`].
422#[derive(Debug, Clone, PartialEq)]
423pub struct FirstLineRect {
424 /// The rectangle, in window-logical coordinates: from the left of the
425 /// line's first character in the range to the right of its last, one
426 /// line tall. At least 1 wide, so a caret has a width.
427 pub rect: Rect,
428 /// The characters of the asked range that `rect` covers: the start of
429 /// the asked range (clamped) up to the end of the range or of its first
430 /// line, whichever comes first.
431 pub covered: Range<usize>,
432}
433
434/// Character offsets and UTF-16 offsets over one text, both ways.
435///
436/// A character takes one UTF-16 code unit unless it is outside the Basic
437/// Multilingual Plane (above `U+FFFF`, most emoji), when it takes two, a
438/// surrogate pair. So the index keeps only where those characters are.
439///
440/// # Invariant
441///
442/// `supplementary` holds, in ascending order, the character offset of every
443/// character above `U+FFFF`. The UTF-16 offset of character offset `c` is
444/// `c` plus the number of entries below `c`, and the `k`-th entry (from 0)
445/// starts at UTF-16 offset `supplementary[k] + k`, which also ascends. Both
446/// conversions are a binary search over it: `O(log s)` for `s` such
447/// characters, nothing at all for a text with none, and the index takes
448/// `s` words. Building it reads the text once.
449///
450/// # Rounding
451///
452/// A UTF-16 offset can fall between the two halves of a surrogate pair, in
453/// the middle of a character, where no character offset is. A single offset
454/// is moved back to the start of that character; a range is widened to take
455/// in the whole character at either end. Every conversion clamps to the
456/// text.
457#[derive(Debug, Clone, Default, PartialEq, Eq)]
458pub struct Utf16Index {
459 char_count: usize,
460 supplementary: Vec<usize>,
461}
462
463impl Utf16Index {
464 /// Index `text`. Reads it once.
465 pub fn new(text: &str) -> Self {
466 let mut char_count = 0;
467 let mut supplementary = Vec::new();
468 for c in text.chars() {
469 if c.len_utf16() == 2 {
470 supplementary.push(char_count);
471 }
472 char_count += 1;
473 }
474 Self {
475 char_count,
476 supplementary,
477 }
478 }
479
480 /// The length of the text, in characters.
481 pub fn char_count(&self) -> usize {
482 self.char_count
483 }
484
485 /// The length of the text, in UTF-16 code units.
486 pub fn utf16_len(&self) -> usize {
487 self.char_count + self.supplementary.len()
488 }
489
490 /// The UTF-16 offset of character offset `char_offset`, clamped to the
491 /// end of the text.
492 pub fn char_to_utf16(&self, char_offset: usize) -> usize {
493 let c = char_offset.min(self.char_count);
494 c + self.supplementary.partition_point(|&s| s < c)
495 }
496
497 /// The character offset of UTF-16 offset `utf16_offset`, clamped to the
498 /// end of the text. An offset between the halves of a surrogate pair is
499 /// the start of that character.
500 pub fn utf16_to_char(&self, utf16_offset: usize) -> usize {
501 self.utf16_to_char_rounding(utf16_offset, false)
502 }
503
504 /// A character range as the UTF-16 range over the same characters. Both
505 /// ends are clamped; an end before the start is taken as the start.
506 pub fn char_range_to_utf16(&self, range: Range<usize>) -> Range<usize> {
507 let start = self.char_to_utf16(range.start);
508 let end = self.char_to_utf16(range.end).max(start);
509 start..end
510 }
511
512 /// A UTF-16 range as the character range over the same characters,
513 /// widened to whole characters: a start in the middle of a surrogate
514 /// pair moves back to the character's start, an end in the middle moves
515 /// on past the character. An empty range stays empty, an insertion point
516 /// at its start's character offset, so it never takes in a character
517 /// it only touched. Both ends are clamped; an end before the start is
518 /// taken as the start.
519 pub fn utf16_range_to_chars(&self, range: Range<usize>) -> Range<usize> {
520 let start = self.utf16_to_char_rounding(range.start, false);
521 if range.end <= range.start {
522 return start..start;
523 }
524 let end = self.utf16_to_char_rounding(range.end, true).max(start);
525 start..end
526 }
527
528 /// The character at `utf16_offset`, or the one after it when `up` and
529 /// the offset is in the middle of a surrogate pair.
530 fn utf16_to_char_rounding(&self, utf16_offset: usize, up: bool) -> usize {
531 let u = utf16_offset.min(self.utf16_len());
532 // How many supplementary characters end at or before `u`: the `k`-th
533 // spans UTF-16 offsets `s + k` and `s + k + 1`, so it ends at
534 // `s + k + 2`, and that end ascends with `k`.
535 let (mut low, mut high) = (0, self.supplementary.len());
536 while low < high {
537 let middle = (low + high) / 2;
538 if self.supplementary[middle] + middle + 2 <= u {
539 low = middle + 1;
540 } else {
541 high = middle;
542 }
543 }
544 let ended = low;
545 let char_offset = u - ended;
546 // The next one may start before `u`, which then sits between its
547 // halves: `char_offset` is one past its start.
548 let splits_a_pair = self
549 .supplementary
550 .get(ended)
551 .is_some_and(|&s| s + ended < u);
552 if splits_a_pair && !up {
553 char_offset - 1
554 } else {
555 char_offset
556 }
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 /// The conversions, checked against the standard library's own UTF-16
565 /// encoding of every prefix.
566 fn brute_char_to_utf16(text: &str, c: usize) -> usize {
567 text.chars().take(c).map(char::len_utf16).sum()
568 }
569
570 #[test]
571 fn ascii_converts_one_for_one() {
572 let index = Utf16Index::new("hello");
573 assert_eq!(index.char_count(), 5);
574 assert_eq!(index.utf16_len(), 5);
575 for c in 0..=5 {
576 assert_eq!(index.char_to_utf16(c), c);
577 assert_eq!(index.utf16_to_char(c), c);
578 }
579 }
580
581 #[test]
582 fn a_supplementary_character_takes_two_units_and_one_offset() {
583 // "a😀b": the emoji is character 1 and UTF-16 units 1 and 2.
584 let text = "a\u{1F600}b";
585 let index = Utf16Index::new(text);
586 assert_eq!(index.char_count(), 3);
587 assert_eq!(index.utf16_len(), 4);
588 assert_eq!(index.utf16_len(), text.encode_utf16().count());
589 assert_eq!(index.char_to_utf16(0), 0);
590 assert_eq!(index.char_to_utf16(1), 1);
591 assert_eq!(index.char_to_utf16(2), 3, "past the pair");
592 assert_eq!(index.char_to_utf16(3), 4);
593 assert_eq!(index.utf16_to_char(0), 0);
594 assert_eq!(index.utf16_to_char(1), 1);
595 assert_eq!(index.utf16_to_char(2), 1, "between the halves: the emoji");
596 assert_eq!(index.utf16_to_char(3), 2);
597 assert_eq!(index.utf16_to_char(4), 3);
598 }
599
600 #[test]
601 fn every_offset_round_trips_and_matches_the_standard_library() {
602 // BMP specials on purpose: a paragraph break, U+2028, an object
603 // anchor and an accented letter each take one unit, like ASCII.
604 let text = "\u{1F600}\u{1F600}x\n\u{2028}\u{FFFC}\u{E9}\u{1F44D}\u{1F3FD}end\u{10FFFF}";
605 let index = Utf16Index::new(text);
606 let chars = text.chars().count();
607 assert_eq!(index.char_count(), chars);
608 assert_eq!(index.utf16_len(), text.encode_utf16().count());
609 for c in 0..=chars {
610 let u = index.char_to_utf16(c);
611 assert_eq!(u, brute_char_to_utf16(text, c), "char {c}");
612 assert_eq!(index.utf16_to_char(u), c, "round trip from char {c}");
613 }
614 }
615
616 #[test]
617 fn offsets_past_the_end_clamp() {
618 let index = Utf16Index::new("a\u{1F600}");
619 assert_eq!(index.char_to_utf16(99), 3);
620 assert_eq!(index.utf16_to_char(99), 2);
621 assert_eq!(index.char_range_to_utf16(1..99), 1..3);
622 assert_eq!(index.utf16_range_to_chars(0..99), 0..2);
623 assert_eq!(index.utf16_range_to_chars(50..99), 2..2);
624 }
625
626 #[test]
627 fn a_range_splitting_a_pair_widens_to_the_whole_character() {
628 let index = Utf16Index::new("a\u{1F600}b");
629 // Units 2..3 are the emoji's second half and nothing else.
630 assert_eq!(index.utf16_range_to_chars(2..3), 1..2);
631 // Units 1..2 are its first half.
632 assert_eq!(index.utf16_range_to_chars(1..2), 1..2);
633 // An empty range between the halves is an insertion point at the
634 // emoji's start: replacing it must not delete the emoji.
635 assert_eq!(index.utf16_range_to_chars(2..2), 1..1);
636 assert_eq!(index.char_range_to_utf16(1..2), 1..3);
637 }
638
639 #[test]
640 #[expect(
641 clippy::reversed_empty_ranges,
642 reason = "a platform may hand one over; the conversion must not panic on it"
643 )]
644 fn a_reversed_range_collapses_at_its_start() {
645 let index = Utf16Index::new("abc");
646 assert_eq!(index.char_range_to_utf16(2..1), 2..2);
647 assert_eq!(index.utf16_range_to_chars(2..1), 2..2);
648 }
649
650 /// A widget that implemented the trait before the write half and the
651 /// journal existed, and implements none of it.
652 struct Older;
653
654 impl TextInputDocument for Older {
655 fn char_count(&self) -> Option<usize> {
656 Some(0)
657 }
658 fn text_in(&self, _: Range<usize>) -> Option<String> {
659 Some(String::new())
660 }
661 fn selection(&self) -> Option<Range<usize>> {
662 Some(0..0)
663 }
664 fn caret(&self) -> Option<usize> {
665 Some(0)
666 }
667 fn composition_range(&self) -> Option<Range<usize>> {
668 None
669 }
670 fn first_line_rect(&self, _: Range<usize>) -> Option<FirstLineRect> {
671 None
672 }
673 fn offset_at_point(&self, _: Point) -> Option<usize> {
674 None
675 }
676 fn visible_text_rect(&self) -> Option<Rect> {
677 None
678 }
679 fn is_editable(&self) -> bool {
680 true
681 }
682 fn is_secure(&self) -> bool {
683 false
684 }
685 fn writing_tools_allowed(&self) -> bool {
686 false
687 }
688 fn utf16_index(&self) -> Option<Rc<Utf16Index>> {
689 Some(Rc::new(Utf16Index::default()))
690 }
691 }
692
693 impl TextInputEditor for Older {}
694
695 /// The defaults keep an older implementer working: available, nothing to
696 /// flush, no journal (the bridge compares the text itself), and every
697 /// write refused, so the platform treats it as read-only.
698 #[test]
699 fn a_custom_implementer_keeps_the_defaults() {
700 let older = Older;
701 assert!(older.is_available());
702 assert!(!older.flush_pending_input());
703 assert_eq!(older.revisions(), None);
704 assert_eq!(older.text_changed_since(0), None);
705 let editor: &dyn TextInputEditor = &older;
706 assert_eq!(
707 editor.replace_text(0..0, "x", WriteOptions::default()),
708 Err(WriteRefused::Unsupported)
709 );
710 assert_eq!(
711 editor.set_selection(0..0, false),
712 Err(WriteRefused::Unsupported)
713 );
714 assert_eq!(
715 editor.mark_composition(0..0, &[]),
716 Err(WriteRefused::Unsupported)
717 );
718 assert_eq!(
719 editor.end_composition(crate::ImeOrigin::Composition),
720 Err(WriteRefused::Unsupported)
721 );
722 editor.begin_input_batch();
723 editor.end_input_batch();
724 }
725
726 #[test]
727 fn an_empty_text_answers_zero() {
728 let index = Utf16Index::new("");
729 assert_eq!(index, Utf16Index::default());
730 assert_eq!(index.char_to_utf16(3), 0);
731 assert_eq!(index.utf16_to_char(3), 0);
732 }
733}