teksilo-core 0.24.1

Core of the Teksilo GUI framework — widget trait, arena, layout engine, event dispatch, focus, signals and theming.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! `TextInputDocument`: a text widget's document as the platform's text input
//! system reads it.
//!
//! # Why the framework has to answer this
//!
//! A platform's text features do not only insert text; they read the
//! document they insert into. On macOS, Dictation, the press-and-hold accent
//! popup and Writing Tools each start by asking the view where the selection
//! is (`selectedRange`), and Writing Tools then reads the selected text
//! (`attributedSubstringForProposedRange:actualRange:`). A view that answers
//! "nowhere" and "nothing" gets nothing back: dictation never starts, the
//! accent popup never shows, Writing Tools says "Try a longer selection". The
//! same view is asked where a range is on screen
//! (`firstRectForCharacterRange:actualRange:`) to place a candidate window or
//! a popover, and which character is under a point
//! (`characterIndexForPoint:`).
//!
//! There is one native view per window and many text widgets in it, so the
//! view can only answer for the focused one, and only that widget knows its
//! own document. This trait is how it says what it knows. A widget reaches it
//! through [`TextSurface::input_document`](crate::text_surface::TextSurface::input_document),
//! so a platform bridge holding the window's
//! [`TextSurfaces`](crate::text_surface::TextSurfaces) answers for whichever
//! text widget has focus without knowing which kind it is.
//!
//! # Offsets
//!
//! Every offset crossing this trait is a **character** offset (a count of
//! Unicode scalar values) into the widget's document, the space its caret,
//! its selection and an [`ImeCommit`](crate::event::WidgetEvent::ImeCommit)'s
//! replacement range are in. The platforms count in UTF-16 code units instead
//! (an `NSRange` does), so a character outside the Basic Multilingual Plane,
//! an emoji, is one offset here and two there. [`Utf16Index`] converts
//! between the two over the same text, and
//! [`utf16_index`](TextInputDocument::utf16_index) hands out the one for the
//! document as it stands.
//!
//! # Coordinates
//!
//! Every point and rectangle is in **window** logical coordinates, the space
//! [`WidgetEvent::PointerDown`](crate::event::WidgetEvent::PointerDown)
//! positions arrive in and the IME cursor area is reported in. The bridge
//! converts to the platform's screen space.
//!
//! # Busy and unanswerable
//!
//! The bridge is called by the platform whenever the platform likes, which
//! can be while the widget is in the middle of its own work with its state
//! borrowed. Every query that returns an `Option` answers `None` then, and
//! the boolean queries give the cautious answer their documentation names,
//! rather than panicking. `None` is also the answer when there is nothing to
//! say: no layout yet, a point outside the text, a secure field's text.
//!
//! # Typing not yet inserted
//!
//! A widget that batches typed characters until its next frame must answer as
//! if they were in the document: the input system names offsets in the text
//! the user has typed, and the commit that follows a question is applied to
//! that text. The framework's widgets insert the batch before answering, as
//! their frame tick would; see each implementation for why that is safe
//! there.

use std::ops::Range;
use std::rc::Rc;

use teksilo_canvas::{Point, Rect};

/// A text widget's document, as a platform's text input system reads it.
///
/// See the [module documentation](self) for the offsets (characters), the
/// coordinates (window-logical) and what `None` means. Object-safe: a bridge
/// holds `&dyn TextInputDocument` for whichever widget has focus.
pub trait TextInputDocument {
    /// Can the document answer now **without changing anything**? Asking
    /// changes nothing, and when the answer is `true` neither do the queries
    /// that follow, until the application runs again: they insert no typing,
    /// run no callback and request no frame.
    ///
    /// `false` while the widget is in the middle of its own work, and while
    /// it holds typing not yet inserted, which a query would insert first
    /// (see the [module documentation](self#typing-not-yet-inserted)), firing
    /// the application's change callbacks. A bridge called where application
    /// code must not run (Windows asks from inside a window procedure, ahead
    /// of winit) asks it first and answers as if there were no document when
    /// it is `false`. None of the other queries can serve as this question.
    ///
    /// `true` by default: a document that batches nothing and never holds its
    /// state across a call into the platform answers every query or `None`,
    /// changing nothing.
    fn is_available(&self) -> bool {
        true
    }

    /// The length of the document, in characters.
    fn char_count(&self) -> Option<usize>;

    /// The text of `range`, clamped to the document.
    ///
    /// In the document's own character space: a paragraph break is one `\n`,
    /// a line break inside a paragraph is `U+2028`, an embedded object (an
    /// image, a table's anchor) is its `U+FFFC`, so offsets into the answer
    /// are offsets into the document. `None` for a secure field, whatever its
    /// reveal state: its text does not leave it.
    fn text_in(&self, range: Range<usize>) -> Option<String>;

    /// The selection, lowest offset first, whichever end the caret is at. An
    /// empty range is a caret with nothing selected.
    fn selection(&self) -> Option<Range<usize>>;

    /// The caret: the end of the selection that moves.
    fn caret(&self) -> Option<usize>;

    /// The range the input method's composition occupies, while one is in
    /// progress: its preedit, inserted tentatively in the document (macOS
    /// calls it marked text). `None` when nothing is being composed.
    fn composition_range(&self) -> Option<Range<usize>>;

    /// Where the first line of `range` is drawn, and which characters of
    /// `range` that line holds.
    ///
    /// `range` is clamped to the document. A range that wraps onto later
    /// lines answers for its first line only, and
    /// [`covered`](FirstLineRect::covered) ends where that line does, so a
    /// caller wanting every line asks again from there. For a non-empty range
    /// `covered` is never empty, so such a walk always advances. An empty
    /// range answers with the caret rectangle at its offset.
    ///
    /// A secure field that shows nothing at all (no echo) answers every range
    /// with the same rectangle, at the start of its empty line, covering the
    /// whole range: its geometry would otherwise tell the text's length.
    ///
    /// `None` before the widget's first layout.
    fn first_line_rect(&self, range: Range<usize>) -> Option<FirstLineRect>;

    /// The character offset nearest `window_point`.
    ///
    /// `None` outside the [`visible_text_rect`](Self::visible_text_rect), and
    /// for a secure field that shows nothing. Inside it, a point beside a
    /// line answers that line's nearest end, and a point below the last line
    /// the end of the document.
    fn offset_at_point(&self, window_point: Point) -> Option<usize>;

    /// The rectangle the widget shows its text in, the part of the document
    /// that is on screen.
    fn visible_text_rect(&self) -> Option<Rect>;

    /// Does the widget take text? `false` for a read-only one, which refuses
    /// an input-system commit, and while the widget is busy.
    fn is_editable(&self) -> bool;

    /// Is this a secure field, a password? Its text is never given out (see
    /// [`text_in`](Self::text_in)), and a bridge offers it none of the
    /// features that would read it or write into it from outside the
    /// keyboard. `true` while the widget is busy, the cautious answer.
    fn is_secure(&self) -> bool;

    /// May the system's Writing Tools read and replace this widget's text?
    /// The application's `writing_tools` setting; always `false` for a
    /// secure field, and while the widget is busy.
    fn writing_tools_allowed(&self) -> bool;

    /// The UTF-16 index of the document as it stands, for converting
    /// offsets.
    ///
    /// Built once per change of the document's text and shared until the
    /// next, so a caller converting several offsets for one question asks
    /// for it once. The conversion methods below go through it.
    fn utf16_index(&self) -> Option<Rc<Utf16Index>>;

    /// A character offset as a UTF-16 offset. See
    /// [`Utf16Index::char_to_utf16`].
    fn char_to_utf16(&self, char_offset: usize) -> Option<usize> {
        Some(self.utf16_index()?.char_to_utf16(char_offset))
    }

    /// A UTF-16 offset as a character offset. See
    /// [`Utf16Index::utf16_to_char`].
    fn utf16_to_char(&self, utf16_offset: usize) -> Option<usize> {
        Some(self.utf16_index()?.utf16_to_char(utf16_offset))
    }

    /// A character range as a UTF-16 range. See
    /// [`Utf16Index::char_range_to_utf16`].
    fn char_range_to_utf16(&self, range: Range<usize>) -> Option<Range<usize>> {
        Some(self.utf16_index()?.char_range_to_utf16(range))
    }

    /// A UTF-16 range as a character range. See
    /// [`Utf16Index::utf16_range_to_chars`].
    fn utf16_range_to_chars(&self, range: Range<usize>) -> Option<Range<usize>> {
        Some(self.utf16_index()?.utf16_range_to_chars(range))
    }

    /// Insert typing the widget batched for its next frame, now, firing what
    /// its frame tick would (the change callbacks, the arrivals report).
    /// Returns whether anything was inserted.
    ///
    /// Call it only where a change may be reported: from the application's
    /// handling of an event, never inside a platform lock nor ahead of the
    /// toolkit in a window procedure. After it,
    /// [`is_available`](Self::is_available) answers `true` unless the widget
    /// is busy. `false` by default: a document that batches nothing has
    /// nothing to insert.
    fn flush_pending_input(&self) -> bool {
        false
    }

    /// The document's revisions: of its text, its selection and its layout.
    /// Each grows whenever the thing it counts changes, and never goes back.
    ///
    /// A platform bridge keeps the revisions it last reported and asks again
    /// after every event: what grew is what it reports (`OnTextChange`,
    /// `OnSelectionChange`, `OnLayoutChange` on Windows). `None` by default,
    /// and while the widget is busy: the bridge then compares the text itself.
    fn revisions(&self) -> Option<InputRevisions> {
        None
    }

    /// What changed in the text since text revision `revision`, merged into
    /// one range, and what caused it. Ask it only when
    /// [`revisions`](Self::revisions) says the text revision grew.
    ///
    /// `None` when the widget no longer knows (the revision is too old, or a
    /// change came with no range): the caller treats the whole text as
    /// changed. `None` by default.
    fn text_changed_since(&self, revision: u64) -> Option<TextChanged> {
        let _ = revision;
        None
    }
}

/// The revisions [`TextInputDocument::revisions`] answers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct InputRevisions {
    /// Grows with every change of the text.
    pub text: u64,
    /// Grows with every move of the selection or the caret.
    pub selection: u64,
    /// Grows whenever the text is drawn elsewhere (a relayout, a scroll).
    pub layout: u64,
}

/// What caused a change of the text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeCause {
    /// Writes through [`TextInputEditor`], the platform's input system: a
    /// bridge does not report these back to it.
    InputSystem,
    /// Anything else: typing, a paste, an undo, the application.
    Other,
}

/// A change of the text, as [`TextInputDocument::text_changed_since`]
/// answers it: the text `start..old_end` became `start..new_end`, in
/// character offsets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextChanged {
    pub start: usize,
    pub old_end: usize,
    pub new_end: usize,
    /// [`ChangeCause::InputSystem`] only when every change merged here came
    /// from the input system.
    pub cause: ChangeCause,
}

/// What a write through [`TextInputEditor::replace_text`] may do.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WriteOptions {
    /// Say where the text would land, and change nothing (`QueryInsert`,
    /// `TS_IAS_QUERYONLY` on Windows).
    pub dry_run: bool,
    /// A correction: the new text takes the replaced text's character format
    /// (`TS_ST_CORRECTION`).
    pub correction: bool,
    /// Who the text comes from, when the input system says: the field
    /// reports what lands under that origin's `EditSource` (teksilo-widgets),
    /// and dictated text or a Writing Tools rewrite goes in through the
    /// field's rules like any other write (a line break makes a paragraph in
    /// a rich text editor, a single-line field drops it). `None` is
    /// the input system's ordinary write, reported as an input method's.
    /// A write inside a composition ignores it: the composition's text is
    /// reported under the origin [`TextInputEditor::end_composition`] is
    /// handed.
    pub origin: Option<crate::ImeOrigin>,
}

/// Where a write landed, in the document's character offsets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Landed {
    /// The range the new text occupies.
    pub inserted: Range<usize>,
    /// The range it replaced, before the write.
    pub replaced: Range<usize>,
}

/// Why a [`TextInputEditor`] refused a write.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WriteRefused {
    /// The document takes no writes from the input system (the default).
    #[error("the document takes no writes from the input system")]
    Unsupported,
    /// The field is read-only.
    #[error("the field is read-only")]
    ReadOnly,
    /// The range is outside the document, or ends before it starts.
    #[error("the range is outside the document")]
    InvalidRange,
    /// The widget is in the middle of its own work, or holds typing not
    /// inserted yet ([`TextInputDocument::flush_pending_input`]).
    #[error("the widget is busy")]
    Busy,
}

/// How a stretch of a composition is drawn: TSF's display attributes, the
/// underlines of macOS marked text, Android's composing spans.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SegmentStyle {
    /// Text being typed, not converted yet.
    Input,
    /// A converted clause.
    Converted,
    /// The converted clause the input method is working on.
    Target,
    /// The unconverted clause the input method is working on.
    TargetInput,
}

/// A styled stretch of a composition.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompositionSegment {
    /// Relative to the composition's start, in characters.
    pub range: Range<usize>,
    pub style: SegmentStyle,
}

/// The half of a text widget's document a platform's input system writes
/// through, beside the [`TextInputDocument`] it reads: a TSF text store on
/// Windows writes under its locks, and reads back at once what it wrote.
///
/// A widget hands it out with
/// [`TextSurface::input_editor`](crate::text_surface::TextSurface::input_editor),
/// `None` for a field that takes no text. Every method has a default that
/// refuses or does nothing, so a custom widget that implements none of them
/// keeps working: the platform treats it as read-only, or takes another path.
///
/// # The batch
///
/// Writes come between [`begin_input_batch`](Self::begin_input_batch) and
/// [`end_input_batch`](Self::end_input_batch) (a TSF lock). Inside it the
/// widget changes only its own state; every effect the application can
/// observe (its callbacks, signal writes) is queued, and runs, in order, at
/// the end of the outermost batch. The batch is also where the writes
/// outside a composition are grouped into one undo step.
///
/// # A composition is a range of text
///
/// The input system writes its composition into the document itself, then
/// marks it ([`mark_composition`](Self::mark_composition)); it is not a
/// preedit the widget inserts and withdraws. While one is marked, writes
/// change the document without an undo record, and the field's rules
/// (filters, a length cap, a single line) wait: they apply to the final text
/// at [`end_composition`](Self::end_composition), which records it as one
/// undo step.
pub trait TextInputEditor {
    /// Replace `range` with `text`. Outside a composition the field's rules
    /// apply and what landed is reported; inside one, `text` lands as given.
    /// A `\r\n` or a lone `\r` lands as one line break.
    fn replace_text(
        &self,
        range: Range<usize>,
        text: &str,
        options: WriteOptions,
    ) -> Result<Landed, WriteRefused> {
        let _ = (range, text, options);
        Err(WriteRefused::Unsupported)
    }

    /// Set the selection, the caret at its start when `caret_at_start`.
    fn set_selection(
        &self,
        selection: Range<usize>,
        caret_at_start: bool,
    ) -> Result<(), WriteRefused> {
        let _ = (selection, caret_at_start);
        Err(WriteRefused::Unsupported)
    }

    /// Mark `range` of the document as the input system's composition, drawn
    /// as `segments` say. The first mark starts the composition (over text
    /// already in the document, for a reconversion, or as an empty range at
    /// the caret); a later one moves or restyles it.
    fn mark_composition(
        &self,
        range: Range<usize>,
        segments: &[CompositionSegment],
    ) -> Result<(), WriteRefused> {
        let _ = (range, segments);
        Err(WriteRefused::Unsupported)
    }

    /// The composition is over: its text stays, under the field's rules, as
    /// one undo step, reported as arriving through `origin`'s channel.
    fn end_composition(&self, origin: crate::ImeOrigin) -> Result<(), WriteRefused> {
        let _ = origin;
        Err(WriteRefused::Unsupported)
    }

    /// Start a batch of writes. Batches nest.
    fn begin_input_batch(&self) {}

    /// End a batch of writes; at the end of the outermost one, what the
    /// batch queued runs.
    fn end_input_batch(&self) {}
}

/// The answer to [`TextInputDocument::first_line_rect`].
#[derive(Debug, Clone, PartialEq)]
pub struct FirstLineRect {
    /// The rectangle, in window-logical coordinates: from the left of the
    /// line's first character in the range to the right of its last, one
    /// line tall. At least 1 wide, so a caret has a width.
    pub rect: Rect,
    /// The characters of the asked range that `rect` covers: the start of
    /// the asked range (clamped) up to the end of the range or of its first
    /// line, whichever comes first.
    pub covered: Range<usize>,
}

/// Character offsets and UTF-16 offsets over one text, both ways.
///
/// A character takes one UTF-16 code unit unless it is outside the Basic
/// Multilingual Plane (above `U+FFFF`, most emoji), when it takes two, a
/// surrogate pair. So the index keeps only where those characters are.
///
/// # Invariant
///
/// `supplementary` holds, in ascending order, the character offset of every
/// character above `U+FFFF`. The UTF-16 offset of character offset `c` is
/// `c` plus the number of entries below `c`, and the `k`-th entry (from 0)
/// starts at UTF-16 offset `supplementary[k] + k`, which also ascends. Both
/// conversions are a binary search over it: `O(log s)` for `s` such
/// characters, nothing at all for a text with none, and the index takes
/// `s` words. Building it reads the text once.
///
/// # Rounding
///
/// A UTF-16 offset can fall between the two halves of a surrogate pair, in
/// the middle of a character, where no character offset is. A single offset
/// is moved back to the start of that character; a range is widened to take
/// in the whole character at either end. Every conversion clamps to the
/// text.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Utf16Index {
    char_count: usize,
    supplementary: Vec<usize>,
}

impl Utf16Index {
    /// Index `text`. Reads it once.
    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,
        }
    }

    /// The length of the text, in characters.
    pub fn char_count(&self) -> usize {
        self.char_count
    }

    /// The length of the text, in UTF-16 code units.
    pub fn utf16_len(&self) -> usize {
        self.char_count + self.supplementary.len()
    }

    /// The UTF-16 offset of character offset `char_offset`, clamped to the
    /// end of the text.
    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)
    }

    /// The character offset of UTF-16 offset `utf16_offset`, clamped to the
    /// end of the text. An offset between the halves of a surrogate pair is
    /// the start of that character.
    pub fn utf16_to_char(&self, utf16_offset: usize) -> usize {
        self.utf16_to_char_rounding(utf16_offset, false)
    }

    /// A character range as the UTF-16 range over the same characters. Both
    /// ends are clamped; an end before the start is taken as the start.
    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
    }

    /// A UTF-16 range as the character range over the same characters,
    /// widened to whole characters: a start in the middle of a surrogate
    /// pair moves back to the character's start, an end in the middle moves
    /// on past the character. An empty range stays empty, an insertion point
    /// at its start's character offset, so it never takes in a character
    /// it only touched. Both ends are clamped; an end before the start is
    /// taken as the start.
    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
    }

    /// The character at `utf16_offset`, or the one after it when `up` and
    /// the offset is in the middle of a surrogate pair.
    fn utf16_to_char_rounding(&self, utf16_offset: usize, up: bool) -> usize {
        let u = utf16_offset.min(self.utf16_len());
        // How many supplementary characters end at or before `u`: the `k`-th
        // spans UTF-16 offsets `s + k` and `s + k + 1`, so it ends at
        // `s + k + 2`, and that end ascends with `k`.
        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;
        // The next one may start before `u`, which then sits between its
        // halves: `char_offset` is one past its start.
        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::*;

    /// The conversions, checked against the standard library's own UTF-16
    /// encoding of every prefix.
    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() {
        // "a😀b": the emoji is character 1 and UTF-16 units 1 and 2.
        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() {
        // BMP specials on purpose: a paragraph break, U+2028, an object
        // anchor and an accented letter each take one unit, like ASCII.
        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");
        // Units 2..3 are the emoji's second half and nothing else.
        assert_eq!(index.utf16_range_to_chars(2..3), 1..2);
        // Units 1..2 are its first half.
        assert_eq!(index.utf16_range_to_chars(1..2), 1..2);
        // An empty range between the halves is an insertion point at the
        // emoji's start: replacing it must not delete the emoji.
        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);
    }

    /// A widget that implemented the trait before the write half and the
    /// journal existed, and implements none of it.
    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 {}

    /// The defaults keep an older implementer working: available, nothing to
    /// flush, no journal (the bridge compares the text itself), and every
    /// write refused, so the platform treats it as read-only.
    #[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);
    }
}