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}