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}