Skip to main content

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}