rosin/ime.rs
1//! Types for interacting with the platform's text input APIs.
2
3use std::{borrow::Cow, ops::Range};
4
5use crate::kurbo::{Point, Rect};
6use crate::prelude::*;
7
8/// A lock on the text document allowing the platform backend to query state and apply edits.
9///
10/// Text input is a bidirectional conversation: the application provides the OS with document state
11/// and geometry, and the OS requests edits (typing, paste, IME composition).
12///
13/// ## Coordinate systems
14/// All points and rectangles in this trait are expressed in Viewport Coordinates
15/// (logical pixels relative to the top-left of the viewport).
16///
17/// ## Units for indices and ranges
18/// All indices and ranges are in UTF-8 byte offsets, matching Rust `str`/`String` indexing rules.
19///
20/// Platform backends that use UTF-16 (macOS/Windows) must convert using
21/// `utf8_range_utf16_len` and `utf16_range_to_utf8_range`.
22///
23/// ## Range validity contract
24/// Unless explicitly stated otherwise:
25/// - `start <= end`
26/// - `end <= len()`
27/// - `start` and `end` must be UTF-8 char boundaries [`str::is_char_boundary`].
28///
29/// Implementations may choose to further clamp/adjust to extended grapheme cluster boundaries for
30/// user-facing editing operations, but must do so deterministically.
31pub trait InputHandler {
32 /// Returns `true` if the document contains no text.
33 fn is_empty(&self) -> bool {
34 self.len() == 0
35 }
36
37 /// Returns the total length of the document in UTF-8 bytes.
38 fn len(&self) -> usize;
39
40 /// Returns a view of the document text for `range`.
41 ///
42 /// Implementations should return a borrowed `&str` when possible, but may allocate and return an owned string.
43 fn slice<'a>(&'a self, range: Range<usize>) -> Cow<'a, str>;
44
45 /// Returns the current user selection or caret position, as a UTF-8 byte range.
46 ///
47 /// A caret is represented as an empty range.
48 fn selection(&self) -> Range<usize>;
49
50 /// Updates the selection (caret / highlighted range), as a UTF-8 byte range.
51 ///
52 /// This is often called by the platform backend in response to IME requests to move the caret
53 /// or update the selection during composition, without modifying the document text.
54 fn set_selection(&mut self, selection: Range<usize>);
55
56 /// Returns the range of text currently being composed (marked / pre-edit), if any.
57 ///
58 /// Returns `None` if no IME composition is active.
59 fn composition_range(&self) -> Option<Range<usize>>;
60
61 /// Sets or clears the active composition (marked / pre-edit) range.
62 ///
63 /// `Some(range)` means an IME composition session is active and currently applies to `range`.
64 /// `None` means there is no active composition.
65 ///
66 /// This is typically driven by the platform backend while handling IME "marked text" APIs
67 /// (macOS `setMarkedText`/`unmarkText`, Windows TSF composition events).
68 ///
69 /// Both `range.start` and `range.end` must be `<= self.len()`, and should be char boundaries.
70 ///
71 /// If you clamp/adjust to extended grapheme cluster boundaries, do so deterministically and
72 /// keep internal invariants consistent with `selection()` and subsequent edit operations.
73 fn set_composition_range(&mut self, range: Option<Range<usize>>);
74
75 /// Replaces text in the document with `text`.
76 ///
77 /// This method is the primitive edit operation used for:
78 /// - normal typing, paste, and deletions (committed text), and
79 /// - IME updates (pre-edit / marked text).
80 ///
81 /// Calling this method does not automatically clear or finalize IME composition.
82 /// The caller is responsible for deciding whether this edit represents a composition update,
83 /// a commit/finalization, or an edit outside composition and will update `composition_range()`
84 /// and `selection()` accordingly.
85 fn replace_range(&mut self, range: Range<usize>, text: &str);
86
87 /// Performs a semantic action.
88 ///
89 /// Returns `true` if the action was handled, `false` if it should be handled as a normal keypress.
90 fn handle_action(&mut self, action: Action) -> bool;
91
92 /// Returns the text position closest to `point` (Viewport Coordinates).
93 ///
94 /// Used for hit-testing / mouse placement / some IME queries.
95 fn hit_test_point(&self, point: Point) -> Option<Cursor>;
96
97 /// Returns the bounding box of the text in `range` (Viewport Coordinates).
98 ///
99 /// If `range` has length 0, this should return the bounding box of the caret at that position.
100 ///
101 /// Used by the OS to position IME candidate windows and system dictionaries.
102 fn bounding_box_for_range(&self, range: Range<usize>) -> Option<Rect>;
103
104 /// Returns the number of UTF-16 code units in the provided UTF-8 range.
105 ///
106 /// This is used to map Rust UTF-8 byte ranges to macOS/Windows UTF-16 selection ranges.
107 ///
108 /// The default implementation performs an O(N) scan. Implementors backed by accelerated data
109 /// structures (like ropes) should override this to provide O(log N) lookups.
110 ///
111 /// Returns `None` if the range is invalid or not on UTF-8 char boundaries.
112 fn utf8_range_utf16_len(&self, range: Range<usize>) -> Option<usize> {
113 if range.start > range.end || range.end > self.len() {
114 return None;
115 }
116
117 let slice = self.slice(0..self.len());
118
119 // Ensure boundaries align with UTF-8 char boundaries.
120 if !slice.is_char_boundary(range.start) || !slice.is_char_boundary(range.end) {
121 return None;
122 }
123
124 Some(slice[range].chars().map(|c| c.len_utf16()).sum())
125 }
126
127 /// Converts a UTF-16 code-unit range into a UTF-8 byte range in the document.
128 ///
129 /// This is used to map macOS/Windows requests back to Rust UTF-8 byte ranges.
130 ///
131 /// The default implementation performs an O(N) scan from the start of the document.
132 /// Implementors backed by accelerated data structures (like ropes) should override this to
133 /// provide O(log N) lookups.
134 ///
135 /// Returns `None` if the UTF-16 range is invalid or extends past the document end.
136 fn utf16_range_to_utf8_range(&self, range: Range<usize>) -> Option<Range<usize>> {
137 if range.start > range.end {
138 return None;
139 }
140
141 if range.start == 0 && range.end == 0 {
142 return Some(0..0);
143 }
144
145 let text = self.slice(0..self.len());
146 let eof_byte = text.len();
147
148 let mut current_utf16 = 0usize;
149 let mut byte_start: Option<usize> = None;
150 let mut byte_end: Option<usize> = None;
151
152 for (byte_idx, ch) in text.char_indices() {
153 let next_utf16 = current_utf16 + ch.len_utf16();
154 let next_byte = byte_idx + ch.len_utf8();
155
156 if byte_start.is_none() {
157 if range.start == current_utf16 {
158 byte_start = Some(byte_idx);
159 } else if range.start == next_utf16 {
160 byte_start = Some(next_byte);
161 }
162 }
163
164 if byte_end.is_none() {
165 if range.end == current_utf16 {
166 byte_end = Some(byte_idx);
167 } else if range.end == next_utf16 {
168 byte_end = Some(next_byte);
169 }
170 }
171
172 current_utf16 = next_utf16;
173
174 if byte_start.is_some() && byte_end.is_some() {
175 break;
176 }
177 }
178
179 // Handle EOF positions
180 if byte_start.is_none() && range.start == current_utf16 {
181 byte_start = Some(eof_byte);
182 }
183 if byte_end.is_none() && range.end == current_utf16 {
184 byte_end = Some(eof_byte);
185 }
186
187 // UTF-16 range extends past document end
188 if range.end > current_utf16 {
189 return None;
190 }
191
192 match (byte_start, byte_end) {
193 (Some(s), Some(e)) if s <= e => Some(s..e),
194 _ => None,
195 }
196 }
197}
198
199/// A semantic editing action triggered by user input.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum Action {
202 /// Moves the caret based on the specified movement rules.
203 Move(Movement),
204 /// Moves the caret while extending the current selection.
205 MoveSelecting(Movement),
206 /// Deletes text defined by the specified movement.
207 Delete(Movement),
208 /// Selects a specific semantic unit of text.
209 Select(SelectionUnit),
210 /// Inserts a line break at the current position.
211 InsertNewLine,
212 /// Inserts a tab character or indent.
213 InsertTab,
214 /// Removes a tab character or unindents the current line/selection.
215 InsertBacktab,
216 /// Copies the current selection to the system clipboard.
217 Copy,
218 /// Copies the current selection to the system clipboard and deletes the selected text.
219 Cut,
220 /// Inserts content from the system clipboard at the current cursor position.
221 Paste,
222 /// Cancels the current operation.
223 Cancel,
224}
225
226impl Action {
227 pub fn edits_text(&self) -> bool {
228 matches!(self, Action::Delete(_) | Action::InsertNewLine | Action::InsertTab | Action::InsertBacktab | Action::Cut | Action::Paste)
229 }
230}
231
232/// Defines the granularity and direction of a cursor movement.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum Movement {
235 /// Movement by a single visual character (grapheme cluster).
236 Grapheme(HorizontalDirection),
237 /// Movement by a word boundary.
238 Word(HorizontalDirection),
239 /// Movement to the start or end of the current line.
240 Line(HorizontalDirection),
241 /// Movement to the start or end of the current paragraph.
242 Paragraph(HorizontalDirection),
243 /// Movement to the start or end of the entire document.
244 Document(HorizontalDirection),
245 /// Vertical movement across lines or pages.
246 Vertical(VerticalDirection),
247}
248
249/// A horizontal direction relative to the text layout.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum HorizontalDirection {
252 /// Visual left (or logical backward).
253 Left,
254 /// Visual right (or logical forward).
255 Right,
256}
257
258/// A vertical direction relative to the text layout.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum VerticalDirection {
261 /// Moves up one line.
262 Up,
263 /// Moves down one line.
264 Down,
265 /// Moves up by one viewport height.
266 PageUp,
267 /// Moves down by one viewport height.
268 PageDown,
269}
270
271/// A semantic unit of text to be selected.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum SelectionUnit {
274 /// The word surrounding the current cursor position.
275 Word,
276 /// The line containing the current cursor position.
277 Line,
278 /// The paragraph containing the current cursor position.
279 Paragraph,
280 /// The entire document text.
281 All,
282}