Skip to main content

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}