Skip to main content

frust_core/event/
ime.rs

1//! IME vocabulary: composition events, the content-type hint, and the published IME surface.
2
3use std::fmt;
4
5use kurbo::Rect;
6
7use super::key::EditingState;
8
9/// An IME event, delivered down the focus path. Desktop sends `Compose`/`Commit`, mobile bridges
10/// sync whole values (`ApplyEditingState`), and `Enabled`/`Disabled` bracket a session.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum ImeEvent {
13    /// Preedit (marked) text; `cursor` is a byte-indexed selection inside it, as winit reports.
14    Compose {
15        text: String,
16        cursor: Option<(usize, usize)>,
17    },
18    /// Insert the finished composition and clear the composing region.
19    Commit(String),
20    /// Replace the whole editing state (mobile state-sync path).
21    ApplyEditingState(EditingState),
22    Enabled,
23    Disabled,
24}
25
26/// What a focused editable holds — the hint a shell maps onto its platform IME. Masking alone
27/// does not stop a soft keyboard showing or learning the text; only this hint does. Shells branch
28/// security on [`is_secret`](Self::is_secret), never on a `_ =>` arm (the enum is non-exhaustive).
29///
30/// # Platform mapping
31/// | Variant | Android (`InputType` / `EditorInfo.imeOptions`) | iOS (`UITextInputTraits`) | Desktop (winit) |
32/// |---|---|---|---|
33/// | [`Normal`](Self::Normal) | `TYPE_CLASS_TEXT` | platform defaults | `ImePurpose::Normal` |
34/// | [`Password`](Self::Password) | `TYPE_CLASS_TEXT \| TYPE_TEXT_VARIATION_PASSWORD`, plus `TYPE_TEXT_FLAG_NO_SUGGESTIONS` and `IME_FLAG_NO_PERSONALIZED_LEARNING` | `isSecureTextEntry = true`, `textContentType = .password`, `autocorrectionType = .no`, `spellCheckingType = .no`, plus smart-punctuation suppression (see [`Terminal`](Self::Terminal)) | `ImePurpose::Password` |
35/// | [`NoSuggestions`](Self::NoSuggestions) | `TYPE_CLASS_TEXT \| TYPE_TEXT_FLAG_NO_SUGGESTIONS`, plus `IME_FLAG_NO_PERSONALIZED_LEARNING` | `autocorrectionType = .no`, `spellCheckingType = .no`, plus smart-punctuation suppression | no equivalent — `ImePurpose::Normal` |
36/// | [`Terminal`](Self::Terminal) | `TYPE_CLASS_TEXT \| TYPE_TEXT_FLAG_NO_SUGGESTIONS`, plus `IME_FLAG_NO_PERSONALIZED_LEARNING` (same as `NoSuggestions`) | `isSecureTextEntry = false`, `autocorrectionType = .no`, `spellCheckingType = .no`, `smartQuotesType = .no`, `smartDashesType = .no`, `smartInsertDeleteType = .no`, `autocapitalizationType = .none`, `textContentType = nil` | `ImePurpose::Terminal` |
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
38#[non_exhaustive]
39pub enum ImeContentType {
40    /// No hint: platform defaults. What every field publishes unless it opts in.
41    #[default]
42    Normal,
43    /// Secret text: secure entry, with suggestions and personalized learning suppressed.
44    Password,
45    /// Non-secret text never suggested, autocorrected or learned; no secure entry.
46    NoSuggestions,
47    /// Byte-exact entry: `NoSuggestions` plus no smart punctuation or autocapitalization; unmasked.
48    Terminal,
49}
50
51impl ImeContentType {
52    /// Whether the platform must treat the field as secret (secure entry). Both predicates match
53    /// exhaustively, so a new variant fails to compile until it is classified.
54    pub fn is_secret(self) -> bool {
55        match self {
56            Self::Password => true,
57            Self::Normal | Self::NoSuggestions | Self::Terminal => false,
58        }
59    }
60
61    /// Whether the platform must suppress suggestions, autocorrect and word learning.
62    pub fn suppresses_suggestions(self) -> bool {
63        match self {
64            Self::Password | Self::NoSuggestions | Self::Terminal => true,
65            Self::Normal => false,
66        }
67    }
68}
69
70/// The IME surface a focused editable publishes through `EventCtx::publish_ime_state`; the shell
71/// reads it from [`crate::app::RenderRoot::ime_state`]. `editing` is plaintext even for a secret
72/// field (the platform mirror needs it); [`fmt::Debug`] redacts it then.
73#[derive(Clone, PartialEq)]
74pub struct ImeState {
75    /// Whether the focused widget wants IME active.
76    pub active: bool,
77    pub editing: EditingState,
78    /// The caret rectangle in logical coordinates, for candidate placement.
79    pub caret: Option<Rect>,
80    pub content_type: ImeContentType,
81    /// Keep the input surface wired (copy still needs it) without raising an on-screen keyboard.
82    pub suppress_soft_keyboard: bool,
83}
84
85impl Default for ImeState {
86    fn default() -> Self {
87        Self {
88            active: false,
89            editing: EditingState::default(),
90            caret: None,
91            content_type: ImeContentType::Normal,
92            suppress_soft_keyboard: false,
93        }
94    }
95}
96
97impl fmt::Debug for ImeState {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        struct Redacted<'a>(&'a EditingState);
100        impl fmt::Debug for Redacted<'_> {
101            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102                f.debug_struct("EditingState")
103                    .field("text", &"<redacted>")
104                    .field("selection_base", &self.0.selection_base)
105                    .field("selection_extent", &self.0.selection_extent)
106                    .field("composing_base", &self.0.composing_base)
107                    .field("composing_extent", &self.0.composing_extent)
108                    .finish()
109            }
110        }
111
112        let mut s = f.debug_struct("ImeState");
113        s.field("active", &self.active);
114        if self.content_type.is_secret() {
115            s.field("editing", &Redacted(&self.editing));
116        } else {
117            s.field("editing", &self.editing);
118        }
119        s.field("caret", &self.caret)
120            .field("content_type", &self.content_type)
121            .field("suppress_soft_keyboard", &self.suppress_soft_keyboard)
122            .finish()
123    }
124}