Skip to main content

teksilo_platform/
text_input_hook.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The platform's text input system, past what winit forwards.
5//!
6//! winit hands an application two kinds of text: what a key press produced,
7//! and the end of an IME composition the user typed. The text input system
8//! sends more than that, and winit 0.30 drops the rest:
9//!
10//! - **macOS.** Emoji & Symbols, Dictation, AutoFill and Writing Tools all
11//!   write through `NSTextInputClient` on winit's view, outside any key
12//!   press. winit commits an `insertText:` only while it holds marked text,
13//!   so the string is discarded. The same view answers every question about
14//!   the document (`selectedRange`, `attributedSubstringForProposedRange:`)
15//!   with "nothing", so nothing that reads or corrects text can work.
16//! - **Windows.** winit reads the input system through IMM32 alone. The
17//!   emoji panel and voice typing reach it as IMM results, so they arrive as
18//!   compositions: nothing tells voice typing apart without a TSF text store,
19//!   and winit cannot end a composition the field has already kept.
20//!
21//! This module attaches to each window's native surface once it exists and
22//! detaches when it closes. Text the input system inserts outside a key press
23//! is posted to the application as an [`ImeCommitPayload`], which `teksilo-app`
24//! delivers to the window's focused widget as a
25//! [`WidgetEvent::ImeCommit`](teksilo_core::WidgetEvent::ImeCommit). Which
26//! part of the input system the text came from is decided by one rule shared
27//! by the platforms, `classify`.
28//!
29//! The native surface also answers the input system's questions about the
30//! document (where the selection is, what the text of a range is, where a
31//! range is drawn, which character is under a point) from the document of the
32//! window's focused text widget, which its tree's
33//! [`TextSurfaces`] reach; `answers` holds the part of that which needs no
34//! platform.
35//!
36//! A key press the input method takes for itself, without typing or
37//! composing anything, must not type its text either; one it answers with a
38//! commit of its own is the input method's, as the key that ends a
39//! composition is; and one it types as another character (a full-width
40//! space) types that character: [`key_taken_by_input_method`] tells the
41//! application, while it handles the key, which it was.
42//!
43//! Where it stands, measured on macOS 27: Emoji & Symbols, the press-and-hold
44//! accent popup, Dictation and Writing Tools (in its panel) insert into the
45//! focused field. Dictation is told apart from a composition through private
46//! AppKit methods (see the macOS backend). A composition its input method
47//! leaves marked when the user switches to another input source is kept as
48//! committed where it is, and the input method's later delivery of it is
49//! dropped. On Windows the hook has the input method complete a composition
50//! the field ends, and tells the field what it committed
51//! ([`finish_composition`]); answers the input method's requests about the
52//! document (where a character is drawn, the text around the composition);
53//! and when asked, it traces.
54//!
55//! With `TEKSILO_TEXT_INPUT_TRACE=1` set the hooks also log every call they
56//! see to stderr, which is how the per-OS behaviour they rely on was measured.
57//! `TEKSILO_TEXT_INPUT_TRACE=stacks` adds, on macOS, a short call stack to the
58//! input method's calls inside a key press, and every `respondsToSelector:`
59//! asked of the view inside one.
60
61use std::ops::Range;
62use std::sync::atomic::{AtomicBool, Ordering};
63use std::sync::{Arc, OnceLock};
64
65use teksilo_core::raw_handle::ParentHandle;
66use teksilo_core::text_surface::TextSurfaces;
67use teksilo_core::window::TeksiloWindowId;
68use teksilo_core::{AppEventPoster, ImeOrigin};
69
70mod answers;
71mod composition;
72mod dictation;
73mod imm_completion;
74mod imm_requests;
75mod key_press;
76mod key_trace;
77mod left_behind;
78#[cfg(target_os = "macos")]
79mod macos;
80mod tsf_lock;
81mod tsf_mirror;
82#[cfg(target_os = "windows")]
83mod windows;
84mod winit_ime;
85mod write_half;
86
87/// Whether `TEKSILO_TEXT_INPUT_TRACE` is set to anything but `0`. Read once.
88pub fn trace_enabled() -> bool {
89    static ENABLED: OnceLock<bool> = OnceLock::new();
90    *ENABLED.get_or_init(|| {
91        std::env::var_os("TEKSILO_TEXT_INPUT_TRACE").is_some_and(|value| value != "0")
92    })
93}
94
95/// Whether `TEKSILO_TEXT_INPUT_TRACE` is `stacks`, which turns tracing on
96/// and adds the detail for telling who answers a key: a short call stack on
97/// the input method's calls inside a key press, and every
98/// `respondsToSelector:` asked of the view inside one. Read once.
99#[allow(dead_code)] // Only the macOS backend writes stacks.
100pub(crate) fn trace_stacks_enabled() -> bool {
101    static ENABLED: OnceLock<bool> = OnceLock::new();
102    *ENABLED.get_or_init(|| {
103        std::env::var_os("TEKSILO_TEXT_INPUT_TRACE").is_some_and(|value| value == "stacks")
104    })
105}
106
107/// Write one trace line to stderr, if tracing is on.
108#[allow(dead_code)] // Only the macOS and Windows backends trace.
109pub(crate) fn trace(args: std::fmt::Arguments<'_>) {
110    if trace_enabled() {
111        eprintln!("[text-input] {args}");
112    }
113}
114
115/// Text the platform's input system committed into a window outside what
116/// winit reports, posted through the window's
117/// [`AppEventPoster::post_external`]. `teksilo-app` delivers it to the
118/// window's focused widget as a
119/// [`WidgetEvent::ImeCommit`](teksilo_core::WidgetEvent::ImeCommit) with the
120/// same fields; a payload for a window that has closed since is dropped.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ImeCommitPayload {
123    /// The window whose native surface received the text.
124    pub window_id_owner: TeksiloWindowId,
125    /// The committed text.
126    pub text: String,
127    /// Which part of the input system committed it.
128    pub origin: ImeOrigin,
129    /// The range of the focused field's text it replaces, in that field's
130    /// character offsets, when the input system named one. `None` replaces
131    /// the selection, or inserts at the caret.
132    pub replacement: Option<Range<usize>>,
133}
134
135/// Text the platform's input system inserted outside a key press, already
136/// written into the window's focused field through its write half (macOS,
137/// design decision 7 of `engineering/docs/tsf-text-store.md`): posted
138/// through the window's [`AppEventPoster::post_external`] so that
139/// `teksilo-app` does what it does for a commit, the field's own work
140/// aside: the user's activation, the window's empty-preedit dedup reset, a
141/// settle and a redraw. A payload for a window that has closed since is
142/// dropped.
143#[doc(hidden)]
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct ImeWrittenPayload {
146    /// The window whose focused field took the text.
147    pub window_id_owner: TeksiloWindowId,
148}
149
150/// What the platform tells about text that arrived outside a key press, for
151/// [`classify`].
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
153#[cfg_attr(
154    not(target_os = "macos"),
155    allow(dead_code, reason = "only the macOS hook classifies, for now")
156)]
157pub(crate) struct CommitSignals {
158    /// The window held a composition (marked text), which the text ends.
159    pub(crate) had_marked_text: bool,
160    /// The text carries alternatives for some of its words (on macOS, the
161    /// `NSTextAlternatives` attribute anywhere in the attributed string).
162    pub(crate) carries_text_alternatives: bool,
163    /// The platform's dictation is running on the window, or has ended with
164    /// its last words still being composed: see
165    /// [`DictationSession`](dictation::DictationSession) for what opens and
166    /// closes it.
167    pub(crate) dictation_session: bool,
168    /// A writing assistant was opened on the window and the user has not
169    /// pressed a key in it since (macOS Writing Tools).
170    pub(crate) writing_tools_session: bool,
171}
172
173/// Which part of the input system committed text that arrived outside a key
174/// press, from what the platform tells about it, in this order:
175///
176/// - text carrying alternatives, or arriving during a dictation session:
177///   [`ImeOrigin::Dictation`];
178/// - during a Writing Tools session: [`ImeOrigin::WritingTools`];
179/// - ending a composition: [`ImeOrigin::Composition`];
180/// - otherwise the input system inserted it on its own (Emoji & Symbols, an
181///   accent picked from the press-and-hold popup, AutoFill):
182///   [`ImeOrigin::Insertion`].
183///
184/// What was measured on macOS 27: Emoji & Symbols and the accent popup
185/// insert an `NSString` with no marked text outside a key press; Writing
186/// Tools replaces the selection with one `NSString` outside a key press,
187/// inside the session `showWritingTools:` opens; dictation streams its words
188/// through marked text (`setMarkedText:`) and commits them as a plain
189/// `NSString`, with **no** `NSTextAlternatives`, so only its session tells
190/// it from a composition. When the user stops dictation, its end is
191/// reported before the last sentence is committed, so the session stays
192/// open for that commit. The session is read from private AppKit methods
193/// (see the macOS backend and
194/// [`DictationSession`](dictation::DictationSession)); where they are
195/// missing it opens only when the Edit menu starts dictation, and dictation
196/// from the dictation key reads as [`ImeOrigin::Composition`]. Alternatives
197/// still count, for a platform or a release that does attach them.
198#[cfg_attr(
199    not(target_os = "macos"),
200    allow(dead_code, reason = "only the macOS hook classifies, for now")
201)]
202pub(crate) fn classify(signals: CommitSignals) -> ImeOrigin {
203    if signals.carries_text_alternatives || signals.dictation_session {
204        ImeOrigin::Dictation
205    } else if signals.writing_tools_session {
206        ImeOrigin::WritingTools
207    } else if signals.had_marked_text {
208        ImeOrigin::Composition
209    } else {
210        ImeOrigin::Insertion
211    }
212}
213
214/// What the platform's input method made of a key press, when it did not
215/// hand the key back as it was, as [`key_taken_by_input_method`] answers.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub enum TakenKey {
218    /// It had the key and did nothing with it: the press-and-hold accent
219    /// popup taking a repeat of the held letter, or the key that picks an
220    /// accent. The key still counts, for shortcuts and navigation; its text
221    /// is not typed.
222    Swallowed,
223    /// It answered the key with a commit of its own and handed nothing back:
224    /// 2-Set Korean answering Space with nothing composed by marking a space
225    /// and committing it. The press is the input method's, as the key that
226    /// ends a composition is, which winit does not report: it reaches no
227    /// widget, and its text is not typed a second time.
228    Committed,
229    /// It typed the key, with nothing composed, as this text rather than the
230    /// key's own characters: Kotoeri's full-width space (U+3000) for Space,
231    /// Pinyin's ";" for ";". The key goes on with this text in place of the
232    /// characters winit reports for it.
233    Substituted(String),
234}
235
236/// What the platform's input method took of the key press `window_id` is
237/// handling right now, if anything: see [`TakenKey`].
238///
239/// Ask it while handling winit's `KeyboardInput` for a pressed key, which
240/// macOS delivers from inside the view's `keyDown:`, after the input method
241/// has had the key. Outside a key press, for another window, for a key the
242/// input method never saw or handed back as it was (typed its own
243/// characters, or passed it on as a command), and on every platform but
244/// macOS, `None`: the key and its text go on as winit reports them. See the
245/// macOS backend for what is tracked.
246pub fn key_taken_by_input_method(window_id: TeksiloWindowId) -> Option<TakenKey> {
247    #[cfg(target_os = "macos")]
248    {
249        macos::key_taken_by_input_method(window_id)
250    }
251    #[cfg(not(target_os = "macos"))]
252    {
253        let _ = window_id;
254        None
255    }
256}
257
258/// How the platform ended a window's composition, as [`finish_composition`]
259/// reports it.
260#[derive(Debug, Clone, Default, PartialEq, Eq)]
261pub struct FinishedComposition {
262    /// The text the input method committed for the composition as it ended,
263    /// for the field to keep in place of the text the composition showed.
264    /// `None` keeps what it showed: on macOS, and on Windows when the input
265    /// method committed nothing, or only an empty string.
266    pub committed: Option<String>,
267    /// What winit delivers as `Ime::Commit` for the window once the
268    /// application's handler returns, in order, an empty string included: the
269    /// copy of the commit above, and anything else winit reads again. The
270    /// application drops them: the field has kept the composition, and the
271    /// caret has moved since. Empty on macOS.
272    pub late_commits: Vec<String>,
273}
274
275/// End the window's composition (a click elsewhere, a focus change), and say
276/// what the input method committed for it.
277///
278/// Called from inside the application's handler while the focused field
279/// still holds the composition; the field keeps the text right after.
280/// Without this the input method still holds the composition and delivers
281/// its text later, at the old place or in another field. `None` means the
282/// platform did not do it, and the caller falls back to resetting the input
283/// method by toggling winit's IME on the window (`set_ime_allowed(false)`
284/// then `set_ime_allowed(true)`); the field keeps what the composition
285/// showed.
286///
287/// macOS does it for an attached window: the view ends winit's marked text
288/// and has the input method discard its composition, and the field keeps
289/// what it showed, as TextEdit does. Windows asks the input method to
290/// complete the composition in the window's input context (`ImmNotifyIME`
291/// with `CPS_COMPLETE`), as Notepad's text control does, and the field keeps
292/// what the input method commits (Microsoft Pinyin's "nihao" for the
293/// "ni'hao" it showed); winit's own copy of that commit is in
294/// [`FinishedComposition::late_commits`]. Elsewhere, and for a window with
295/// no input context, `None`.
296pub fn finish_composition(window_id: TeksiloWindowId) -> Option<FinishedComposition> {
297    #[cfg(target_os = "macos")]
298    {
299        macos::finish_composition(window_id).then(FinishedComposition::default)
300    }
301    #[cfg(target_os = "windows")]
302    {
303        windows::finish_composition(window_id)
304    }
305    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
306    {
307        let _ = window_id;
308        None
309    }
310}
311
312/// The range of the focused field's text (its character offsets) that the
313/// macOS input method gave its latest `setMarkedText:` as the text the
314/// composition replaces, taken once.
315///
316/// The app calls it while it translates winit's `Ime::Preedit` for the
317/// window, and sets the result as the `replacement` of
318/// [`WidgetEvent::ImeComposition`](teksilo_core::WidgetEvent::ImeComposition).
319/// `None` when the input method named none, for another window, a second
320/// time for the same update, and on every platform but macOS.
321pub fn take_marked_replacement(window_id: TeksiloWindowId) -> Option<Range<usize>> {
322    #[cfg(target_os = "macos")]
323    {
324        macos::take_marked_replacement(window_id)
325    }
326    #[cfg(not(target_os = "macos"))]
327    {
328        let _ = window_id;
329        None
330    }
331}
332
333/// Which path the platform's text input system takes to a focused text
334/// field: the application's choice (`TeksiloAppBuilder::windows_text_store`,
335/// on by default), or `TEKSILO_TSF` (read once) when it names a mode.
336/// Windows only; `Off` everywhere else.
337///
338/// The TSF text store (`engineering/docs/tsf-text-store.md`) answers text
339/// services from the focused field's document and writes what they write
340/// into it.
341#[doc(hidden)]
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum TextStoreMode {
344    /// IMM32 alone, through winit.
345    Off,
346    /// A TSF text store for every focused field that describes its
347    /// document, and winit's IME off there (the default; `TEKSILO_TSF=1`).
348    Store,
349    /// `TEKSILO_TSF=associated`: the same store, with winit's IME (its IMM
350    /// context) left on, for comparing the two (M10).
351    StoreKeepingImm,
352}
353
354/// Whether the application asked for the text store
355/// (`TeksiloAppBuilder::windows_text_store`), before any window exists.
356static STORE_PREFERRED: AtomicBool = AtomicBool::new(true);
357
358/// Ask for the text store, or not, from the application's builder. Read by
359/// [`text_store_mode`] when `TEKSILO_TSF` says nothing; set before the first
360/// window is attached.
361#[doc(hidden)]
362pub fn set_text_store_preferred(preferred: bool) {
363    STORE_PREFERRED.store(preferred, Ordering::Relaxed);
364}
365
366/// The [`TextStoreMode`] this process runs with: `TEKSILO_TSF` when it is
367/// set to a mode (`0` turns the store off whatever the application asked,
368/// `1` turns it on), else what the application asked for
369/// ([`set_text_store_preferred`]). Off on every platform but Windows.
370#[doc(hidden)]
371pub fn text_store_mode() -> TextStoreMode {
372    static FROM_ENVIRONMENT: OnceLock<Option<TextStoreMode>> = OnceLock::new();
373    if !cfg!(target_os = "windows") {
374        return TextStoreMode::Off;
375    }
376    let from_environment = *FROM_ENVIRONMENT
377        .get_or_init(|| parse_text_store_mode(std::env::var("TEKSILO_TSF").ok().as_deref()));
378    from_environment.unwrap_or(if STORE_PREFERRED.load(Ordering::Relaxed) {
379        TextStoreMode::Store
380    } else {
381        TextStoreMode::Off
382    })
383}
384
385/// The [`TextStoreMode`] a window runs with: [`text_store_mode`], but
386/// [`TextStoreMode::Off`] for a window that has no store, because TSF could
387/// not be activated on its thread or the window was never attached. With the
388/// store on by default, a window whose store did not start keeps IMM32
389/// rather than losing its input method.
390#[doc(hidden)]
391pub fn text_store_mode_for(window: &ParentHandle) -> TextStoreMode {
392    #[cfg(target_os = "windows")]
393    {
394        windows::text_store_mode_for(window)
395    }
396    #[cfg(not(target_os = "windows"))]
397    {
398        let _ = window;
399        TextStoreMode::Off
400    }
401}
402
403/// Whether the active input method is an IMM32 one that has no TSF text
404/// service (a keyboard layout handle whose device half starts with `E`):
405/// it reaches a field only through IMM32, so a field takes the IMM path
406/// while it is active. Always `false` but on Windows.
407#[doc(hidden)]
408pub fn legacy_input_method_active() -> bool {
409    #[cfg(target_os = "windows")]
410    {
411        windows::legacy_input_method_active()
412    }
413    #[cfg(not(target_os = "windows"))]
414    {
415        false
416    }
417}
418
419/// `TEKSILO_TSF`'s value as a mode: `0` (or `off`), `1` (or `store`) and
420/// `associated`; `None` when unset or anything else, which leaves the
421/// choice to the application.
422fn parse_text_store_mode(value: Option<&str>) -> Option<TextStoreMode> {
423    match value.map(str::trim) {
424        Some("0" | "off") => Some(TextStoreMode::Off),
425        Some("1" | "store") => Some(TextStoreMode::Store),
426        Some("associated") => Some(TextStoreMode::StoreKeepingImm),
427        _ => None,
428    }
429}
430
431/// What has the keyboard focus in a window, as the text store sees it.
432#[doc(hidden)]
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub enum StoreFocus {
435    /// No text field: TSF gets an empty, disabled context.
436    None,
437    /// A secure field (a password): a context of its own that holds no text
438    /// and takes no input method (keyboard disabled), whose input scope is
439    /// `IS_PASSWORD` so that the touch keyboard shows its password layout,
440    /// with speech turned off on the thread while it has the focus (design
441    /// section 6).
442    Secure {
443        /// The focused widget.
444        widget: teksilo_core::WidgetId,
445    },
446    /// A text field that does not describe its document (the terminal): the
447    /// IMM path, CUAS's own document.
448    Imm,
449    /// A text field that describes its document: a store of its own, a new
450    /// one each time a widget takes the focus.
451    Document {
452        /// The focused widget.
453        widget: teksilo_core::WidgetId,
454    },
455}
456
457/// End the composition a text service holds in the window's text store, if
458/// one is open, and let the text service write its last text (a Japanese
459/// trailing `n` becomes `ん`): what `teksilo-app` does before it dispatches a
460/// press, so that the press hit-tests the text the text service left
461/// (`engineering/docs/tsf-text-store.md`, 4.11). Main thread only, outside
462/// the dispatch of an event. Whether a composition was open: the caller then
463/// lays the window out before it dispatches. Does nothing unless
464/// [`text_store_mode`] is on.
465#[doc(hidden)]
466pub fn terminate_text_store_composition(window: &ParentHandle) -> bool {
467    #[cfg(target_os = "windows")]
468    {
469        windows::terminate_text_store_composition(window)
470    }
471    #[cfg(not(target_os = "windows"))]
472    {
473        let _ = window;
474        false
475    }
476}
477
478/// Whether a text service took a key (it reached the window as its key-up
479/// only) since the last call: a key of a chord the window never saw go
480/// down, for the bare-Alt menu, which `teksilo-app` asks as Alt goes down and
481/// as it comes up. Always `false` unless [`text_store_mode`] is on.
482#[doc(hidden)]
483pub fn take_key_taken_by_text_store() -> bool {
484    #[cfg(target_os = "windows")]
485    {
486        windows::take_key_taken_by_text_store()
487    }
488    #[cfg(not(target_os = "windows"))]
489    {
490        false
491    }
492}
493
494/// Asks `teksilo-app` to call [`set_text_store_focus`] for a window again,
495/// between dispatches: the window's text store has a lock request waiting
496/// for changes it could not report yet, or skipped a call made while a text
497/// service held a lock. `teksilo-app` does what it does after a dispatched
498/// event (`settle_ime`); a payload for a window that has closed since is
499/// dropped.
500#[doc(hidden)]
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub struct TextStorePayload {
503    /// The window whose text store asks.
504    pub window_id_owner: TeksiloWindowId,
505}
506
507/// Give the window's text store `focus`, and bring its copy of the focused
508/// document up to date. Main thread only, from the application's handling
509/// of an event (it makes TSF calls, which can deliver messages), after
510/// every dispatched event. Does nothing unless [`text_store_mode`] is on.
511#[doc(hidden)]
512pub fn set_text_store_focus(window: &ParentHandle, focus: StoreFocus) {
513    #[cfg(target_os = "windows")]
514    windows::set_text_store_focus(window, focus);
515    #[cfg(not(target_os = "windows"))]
516    let _ = (window, focus);
517}
518
519/// Attach to a window's native surface. Main thread only, once the window
520/// exists. A no-op where the platform needs nothing.
521///
522/// `poster` takes the text the input system inserts outside what winit
523/// reports. `surfaces` are the window tree's
524/// ([`WidgetTree::text_surfaces`](teksilo_core::WidgetTree::text_surfaces)):
525/// the native surface answers the input system's questions about the
526/// document (the selection, the text, where a range is drawn) from the one
527/// that has the keyboard focus.
528pub fn attach(
529    window_id: TeksiloWindowId,
530    parent: &ParentHandle,
531    poster: Arc<dyn AppEventPoster>,
532    surfaces: TextSurfaces,
533) {
534    #[cfg(target_os = "macos")]
535    macos::attach(window_id, parent, poster, surfaces);
536    #[cfg(target_os = "windows")]
537    windows::attach(window_id, parent, poster, surfaces);
538    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
539    let _ = (window_id, parent, poster, surfaces);
540}
541
542/// Forget a window. Safe to call for a window that was never attached.
543pub fn detach(window_id: TeksiloWindowId) {
544    #[cfg(target_os = "macos")]
545    macos::detach(window_id);
546    #[cfg(target_os = "windows")]
547    windows::detach(window_id);
548    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
549    let _ = window_id;
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn the_environment_decides_the_text_store_only_when_it_names_a_mode() {
558        assert_eq!(parse_text_store_mode(None), None);
559        assert_eq!(parse_text_store_mode(Some("")), None);
560        assert_eq!(parse_text_store_mode(Some("0")), Some(TextStoreMode::Off));
561        assert_eq!(parse_text_store_mode(Some("off")), Some(TextStoreMode::Off));
562        assert_eq!(parse_text_store_mode(Some("1")), Some(TextStoreMode::Store));
563        assert_eq!(
564            parse_text_store_mode(Some(" store ")),
565            Some(TextStoreMode::Store)
566        );
567        assert_eq!(
568            parse_text_store_mode(Some("associated")),
569            Some(TextStoreMode::StoreKeepingImm)
570        );
571    }
572
573    #[test]
574    fn classify_follows_the_precedence_dictation_writing_tools_marked_text() {
575        use ImeOrigin::{Composition, Dictation, Insertion, WritingTools};
576        // Every combination of the four signals.
577        for bits in 0..16u8 {
578            let signals = CommitSignals {
579                had_marked_text: bits & 1 != 0,
580                carries_text_alternatives: bits & 2 != 0,
581                dictation_session: bits & 4 != 0,
582                writing_tools_session: bits & 8 != 0,
583            };
584            let expected = if signals.carries_text_alternatives || signals.dictation_session {
585                Dictation
586            } else if signals.writing_tools_session {
587                WritingTools
588            } else if signals.had_marked_text {
589                Composition
590            } else {
591                Insertion
592            };
593            assert_eq!(classify(signals), expected, "{signals:?}");
594        }
595        // The cases the macOS backend meets, spelled out.
596        let measured = [
597            // Emoji & Symbols, an accent picked from the popup (measured).
598            (CommitSignals::default(), Insertion),
599            // A Japanese candidate clicked with the mouse (not measured yet).
600            (
601                CommitSignals {
602                    had_marked_text: true,
603                    ..CommitSignals::default()
604                },
605                Composition,
606            ),
607            // Dictation: marked text, no alternatives, inside its session
608            // (measured).
609            (
610                CommitSignals {
611                    had_marked_text: true,
612                    dictation_session: true,
613                    ..CommitSignals::default()
614                },
615                Dictation,
616            ),
617            // Writing Tools' Replace (measured).
618            (
619                CommitSignals {
620                    writing_tools_session: true,
621                    ..CommitSignals::default()
622                },
623                WritingTools,
624            ),
625            // Dictating while the Writing Tools session is still open.
626            (
627                CommitSignals {
628                    had_marked_text: true,
629                    dictation_session: true,
630                    writing_tools_session: true,
631                    ..CommitSignals::default()
632                },
633                Dictation,
634            ),
635        ];
636        for (signals, expected) in measured {
637            assert_eq!(classify(signals), expected, "{signals:?}");
638        }
639    }
640}