ailloli_ui_core/event/
ime.rs

1//! Input Method Editor composition and commit payloads.
2
3use crate::Point;
4
5/// In-progress IME composition (preedit) text and selection.
6///
7/// # Examples
8///
9/// ```
10/// use ailloli_ui_core::event::ImePreedit;
11/// let preedit = ImePreedit::try_new("é", Some((0, 2)))?;
12/// assert_eq!(preedit.selection(), Some((0, 2)));
13/// # Ok::<(), ailloli_ui_core::event::ImePreeditError>(())
14/// ```
15#[non_exhaustive]
16#[derive(Debug, Clone, PartialEq)]
17pub struct ImePreedit {
18    /// Current uncommitted UTF-8 composition text; an empty string is valid.
19    pub text: String,
20    /// Caret/selection range inside `text` as UTF-8 byte indices.
21    ///
22    /// `None` means the provider did not expose a range. Equal indices denote
23    /// a caret; distinct indices denote the selected preedit span.
24    pub selection: Option<(usize, usize)>,
25}
26
27/// Invalid UTF-8 byte selection associated with preedit text.
28///
29/// Possible errors distinguish reversed, out-of-bounds, and non-character-
30/// boundary selections.
31///
32/// # Examples
33///
34/// ```
35/// use ailloli_ui_core::event::{ImePreedit, ImePreeditError};
36/// assert_eq!(ImePreedit::try_new("é", Some((1, 2))).unwrap_err(), ImePreeditError::SelectionNotOnCharBoundary);
37/// ```
38#[non_exhaustive]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
40pub enum ImePreeditError {
41    /// The start byte index is greater than the end index.
42    #[error("IME preedit selection start must not exceed its end")]
43    ReversedSelection,
44    /// The end byte index exceeds the preedit string length.
45    #[error("IME preedit selection is outside the text")]
46    SelectionOutOfBounds,
47    /// At least one byte index splits a UTF-8 encoded scalar value.
48    #[error("IME preedit selection is not aligned to UTF-8 character boundaries")]
49    SelectionNotOnCharBoundary,
50}
51
52impl ImePreedit {
53    /// Creates preedit text without an explicit selection.
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use ailloli_ui_core::event::ImePreedit;
59    /// assert_eq!(ImePreedit::new("かな").selection(), None);
60    /// ```
61    pub fn new(text: impl Into<String>) -> Self {
62        Self {
63            text: text.into(),
64            selection: None,
65        }
66    }
67
68    /// Creates preedit text after validating UTF-8 byte selection indices.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ImePreeditError`] when a supplied range is reversed, exceeds
73    /// the text length, or is not aligned to UTF-8 character boundaries.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use ailloli_ui_core::event::ImePreedit;
79    /// let preedit = ImePreedit::try_new("かな", Some((0, 3)))?;
80    /// assert_eq!(preedit.text(), "かな");
81    /// # Ok::<(), ailloli_ui_core::event::ImePreeditError>(())
82    /// ```
83    pub fn try_new(
84        text: impl Into<String>,
85        selection: Option<(usize, usize)>,
86    ) -> Result<Self, ImePreeditError> {
87        let mut value = Self::new(text);
88        value.selection = selection;
89        value.validate()?;
90        Ok(value)
91    }
92
93    /// Current preedit text.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use ailloli_ui_core::event::ImePreedit;
99    /// assert_eq!(ImePreedit::new("かな").text(), "かな");
100    /// ```
101    pub fn text(&self) -> &str {
102        &self.text
103    }
104
105    /// Caret/selection range as validated UTF-8 byte indices.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use ailloli_ui_core::event::ImePreedit;
111    /// assert_eq!(ImePreedit::try_new("abc", Some((1, 1)))?.selection(), Some((1, 1)));
112    /// # Ok::<(), ailloli_ui_core::event::ImePreeditError>(())
113    /// ```
114    pub const fn selection(&self) -> Option<(usize, usize)> {
115        self.selection
116    }
117
118    /// Consumes the payload and returns its text.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use ailloli_ui_core::event::ImePreedit;
124    /// assert_eq!(ImePreedit::new("かな").into_text(), "かな");
125    /// ```
126    pub fn into_text(self) -> String {
127        self.text
128    }
129
130    /// Validates selection ordering, bounds, and UTF-8 character boundaries.
131    ///
132    /// # Errors
133    ///
134    /// Returns the first violated range invariant without including composition
135    /// text in the error, so potentially sensitive input is not echoed.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use ailloli_ui_core::event::ImePreedit;
141    /// assert!(ImePreedit::try_new("abc", Some((0, 3)))?.validate().is_ok());
142    /// # Ok::<(), ailloli_ui_core::event::ImePreeditError>(())
143    /// ```
144    pub fn validate(&self) -> Result<(), ImePreeditError> {
145        let Some((start, end)) = self.selection else {
146            return Ok(());
147        };
148        if start > end {
149            return Err(ImePreeditError::ReversedSelection);
150        }
151        if end > self.text.len() {
152            return Err(ImePreeditError::SelectionOutOfBounds);
153        }
154        if !self.text.is_char_boundary(start) || !self.text.is_char_boundary(end) {
155            return Err(ImePreeditError::SelectionNotOnCharBoundary);
156        }
157        Ok(())
158    }
159}
160
161/// Input Method Editor events for CJK and similar input.
162///
163/// Possible values cover enabled, preedit, commit, end, and disabled phases.
164///
165/// # Examples
166///
167/// ```
168/// use ailloli_ui_core::event::ImeEvent;
169/// assert_eq!(ImeEvent::commit("文").committed_text(), Some("文"));
170/// ```
171#[non_exhaustive]
172#[derive(Debug, Clone, PartialEq)]
173pub enum ImeEvent {
174    /// The platform enabled text composition for the focused input.
175    Enabled,
176    /// Composition started or updated.
177    Preedit {
178        /// Current validated composition payload.
179        preedit: ImePreedit,
180        /// Associated cursor position in logical window coordinates when available.
181        pos: Option<Point>,
182    },
183    /// Final committed text.
184    Commit {
185        /// UTF-8 text to insert; an empty commit is preserved.
186        text: String,
187    },
188    /// Composition ended or cleared.
189    End,
190    /// The platform disabled text composition for the focused input.
191    Disabled,
192}
193
194impl ImeEvent {
195    /// Creates an IME-enabled notification.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use ailloli_ui_core::event::ImeEvent;
201    /// assert!(matches!(ImeEvent::enabled(), ImeEvent::Enabled));
202    /// ```
203    pub const fn enabled() -> Self {
204        Self::Enabled
205    }
206
207    /// Creates a preedit update from an already constructed payload.
208    ///
209    /// This const constructor does not call [`ImePreedit::validate`]; use
210    /// [`Self::try_preedit`] for untrusted provider indices.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use ailloli_ui_core::event::{ImeEvent, ImePreedit};
216    /// assert!(ImeEvent::preedit(ImePreedit::new("文"), None).as_preedit().is_some());
217    /// ```
218    pub const fn preedit(preedit: ImePreedit, pos: Option<Point>) -> Self {
219        Self::Preedit { preedit, pos }
220    }
221
222    /// Validates and creates a preedit update from raw UTF-8 byte indices.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`ImePreeditError`] for an invalid selection range.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use ailloli_ui_core::event::ImeEvent;
232    /// let event = ImeEvent::try_preedit("é", Some((0, 2)), None)?;
233    /// assert_eq!(event.as_preedit().unwrap().0.text(), "é");
234    /// # Ok::<(), ailloli_ui_core::event::ImePreeditError>(())
235    /// ```
236    pub fn try_preedit(
237        text: impl Into<String>,
238        selection: Option<(usize, usize)>,
239        pos: Option<Point>,
240    ) -> Result<Self, ImePreeditError> {
241        Ok(Self::preedit(ImePreedit::try_new(text, selection)?, pos))
242    }
243
244    /// Creates a committed-text event.
245    ///
246    /// # Examples
247    ///
248    /// ```
249    /// use ailloli_ui_core::event::ImeEvent;
250    /// assert_eq!(ImeEvent::commit("文").committed_text(), Some("文"));
251    /// ```
252    pub fn commit(text: impl Into<String>) -> Self {
253        Self::Commit { text: text.into() }
254    }
255
256    /// Creates the legacy composition-end notification.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use ailloli_ui_core::event::ImeEvent;
262    /// assert!(matches!(ImeEvent::end(), ImeEvent::End));
263    /// ```
264    pub const fn end() -> Self {
265        Self::End
266    }
267
268    /// Creates an IME-disabled notification.
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// use ailloli_ui_core::event::ImeEvent;
274    /// assert!(matches!(ImeEvent::disabled(), ImeEvent::Disabled));
275    /// ```
276    pub const fn disabled() -> Self {
277        Self::Disabled
278    }
279
280    /// Returns the preedit payload and provider position, when applicable.
281    ///
282    /// # Examples
283    ///
284    /// ```
285    /// use ailloli_ui_core::{event::{ImeEvent, ImePreedit}, Point};
286    /// let event = ImeEvent::preedit(ImePreedit::new("文"), Some(Point::new(1.0, 2.0)));
287    /// assert_eq!(event.as_preedit().unwrap().1, Some(Point::new(1.0, 2.0)));
288    /// ```
289    pub const fn as_preedit(&self) -> Option<(&ImePreedit, Option<Point>)> {
290        match self {
291            Self::Preedit { preedit, pos } => Some((preedit, *pos)),
292            Self::Enabled | Self::Commit { .. } | Self::End | Self::Disabled => None,
293        }
294    }
295
296    /// Returns committed text when this is a commit event.
297    ///
298    /// # Examples
299    ///
300    /// ```
301    /// use ailloli_ui_core::event::ImeEvent;
302    /// assert_eq!(ImeEvent::enabled().committed_text(), None);
303    /// ```
304    pub fn committed_text(&self) -> Option<&str> {
305        match self {
306            Self::Commit { text } => Some(text),
307            Self::Enabled | Self::Preedit { .. } | Self::End | Self::Disabled => None,
308        }
309    }
310}