Skip to main content

cranpose_ui/
text_field_focus.rs

1//! Focus manager for text fields.
2//!
3//! This module tracks which text field currently has focus, ensuring only one
4//! text field is focused at a time. When a new field requests focus, the
5//! previously focused field is automatically unfocused.
6//!
7//! O(1) key dispatch: The focused field's handler is stored for direct invocation,
8//! avoiding O(N) tree scans on every keystroke.
9
10use std::{
11    cell::{Cell, RefCell},
12    rc::{Rc, Weak},
13};
14
15use crate::key_event::KeyEvent;
16
17/// Snapshot of the focused text field's editable state for platform IMEs.
18///
19/// All offsets are UTF-8 byte offsets into `text`. Platform layers that talk
20/// to UTF-16 based IMEs (Android's `InputConnection`, web composition events)
21/// convert on their side of the boundary.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct ImeEditorState {
24    /// Full text content of the field.
25    pub text: String,
26    /// Selection start (min) in bytes. Equal to `selection_end` for a caret.
27    pub selection_start: usize,
28    /// Selection end (max) in bytes.
29    pub selection_end: usize,
30    /// Active composing (preedit) region in bytes, if any.
31    pub composition: Option<(usize, usize)>,
32    /// Whether the field is single-line (platforms use this to pick the
33    /// keyboard action, e.g. Android `IME_ACTION_DONE` vs newline).
34    pub single_line: bool,
35}
36
37/// Window-space caret geometry for the focused field, used by platforms whose
38/// native text input positions the caret by coordinates rather than key events
39/// (iOS `UITextInput`: spacebar-trackpad cursor movement and tap-to-position).
40/// Caret advances are local logical pixels. Use the conversion methods for
41/// native window rectangles and pointer positions, including transformed fields.
42#[derive(Clone, Debug, Default, PartialEq)]
43pub struct ImeCaretGeometry {
44    /// Local caret x for each character-boundary offset, in text order: entry `k` is
45    /// the caret after `k` characters, so `caret_xs.len() == chars + 1`.
46    pub caret_xs: Vec<f32>,
47    /// Local top y of the (single) caret line.
48    pub top: f32,
49    /// Local height of one line (the caret's height).
50    pub line_height: f32,
51    /// Maps the field's local coordinates into its window.
52    pub local_to_window: cranpose_ui_graphics::ProjectiveTransform,
53}
54
55impl ImeCaretGeometry {
56    /// The native caret's window rectangle after `character` Unicode scalars.
57    pub fn caret_rect(&self, character: usize) -> cranpose_ui_graphics::Rect {
58        self.range_rect(character, character)
59    }
60
61    /// Window bounds of a range whose endpoints count Unicode scalars.
62    pub fn range_rect(&self, start: usize, end: usize) -> cranpose_ui_graphics::Rect {
63        let x = |index: usize| {
64            self.caret_xs
65                .get(index)
66                .or_else(|| self.caret_xs.last())
67                .copied()
68                .unwrap_or(0.0)
69        };
70        let (left, right) = (x(start), x(end));
71        self.local_to_window
72            .bounds_for_rect(cranpose_ui_graphics::Rect {
73                x: left.min(right),
74                y: self.top,
75                width: (right - left).abs().max(2.0),
76                height: self.line_height,
77            })
78    }
79
80    /// Character boundary nearest a window-space pointer position.
81    /// A singular transform has no visible editable plane and returns `None`.
82    pub fn closest_character(&self, position: cranpose_ui_graphics::Point) -> Option<usize> {
83        let local = self.local_to_window.inverse()?.map_point(position);
84        self.caret_xs
85            .iter()
86            .enumerate()
87            .min_by(|(_, a), (_, b)| (*a - local.x).abs().total_cmp(&(*b - local.x).abs()))
88            .map(|(index, _)| index)
89    }
90}
91
92/// Handler trait for focused text field operations.
93/// Stored in focus module for O(1) key/clipboard dispatch.
94pub trait FocusedTextFieldHandler {
95    /// The composition node whose recorded draws show this field's caret and
96    /// selection. Focus changes and caret-blink flips schedule a scoped draw
97    /// repass on it — that state lives outside the draw-observation system,
98    /// so nothing else can name the stale node. `None` only for handlers with
99    /// no scene presence (test doubles, the platform no-op handler).
100    fn node_id(&self) -> Option<cranpose_core::NodeId> {
101        None
102    }
103    /// The node focus and semantics know the field by: the decoration box
104    /// around a decorated field, else [`Self::node_id`].
105    fn target_node(&self) -> Option<cranpose_core::NodeId> {
106        self.node_id()
107    }
108    /// Whether a focus request should open the software keyboard.
109    /// Pointer taps request the keyboard independently of this preference.
110    fn show_keyboard_on_focus(&self) -> bool {
111        true
112    }
113    /// Handle a key event. Returns true if consumed.
114    fn handle_key(&self, event: &KeyEvent) -> bool;
115    /// Insert pasted text.
116    fn insert_text(&self, text: &str);
117    /// Delete text surrounding the cursor or selection.
118    fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize);
119    /// Copy current selection. Returns None if nothing selected.
120    fn copy_selection(&self) -> Option<String>;
121    /// Cut current selection (copy + delete). Returns None if nothing selected.
122    fn cut_selection(&self) -> Option<String>;
123    /// Selects all the field's text (contextual-menu "Select all").
124    fn select_all(&self) {}
125    /// Set IME composition (preedit) state.
126    /// - `text`: The composition text being typed (empty string to clear,
127    ///   which *deletes* the preedit text)
128    /// - `cursor`: Optional cursor position within composition (start, end)
129    fn set_composition(&self, text: &str, cursor: Option<(usize, usize)>);
130    /// Finish the active composition, keeping the composed text as regular
131    /// committed text (Android `finishComposingText` semantics). No-op when
132    /// no composition is active.
133    fn finish_composition(&self) {}
134    /// Mark existing text as the composing region without changing it
135    /// (Android `setComposingRegion` semantics, used by autocorrect to
136    /// re-compose an already committed word). Offsets are UTF-8 bytes;
137    /// implementations clamp them to valid character boundaries.
138    fn set_composing_region(&self, start_bytes: usize, end_bytes: usize) {
139        let _ = (start_bytes, end_bytes);
140    }
141    /// Move the selection/caret to `[start_bytes, end_bytes)` without editing
142    /// text (Android `InputConnection.setSelection` semantics). Used by
143    /// Gboard's spacebar-swipe cursor control, which scrubs the caret by
144    /// repeatedly setting the selection. Offsets are UTF-8 bytes; implementations
145    /// clamp them to valid character boundaries.
146    fn set_selection(&self, start_bytes: usize, end_bytes: usize) {
147        let _ = (start_bytes, end_bytes);
148    }
149    /// Snapshot of the current editable state for platform IMEs
150    /// (`InputConnection` text queries, session seeding). `None` when the
151    /// handler cannot expose its state.
152    fn editor_state(&self) -> Option<ImeEditorState> {
153        None
154    }
155    /// Window-space caret geometry (see [`ImeCaretGeometry`]) for coordinate-based
156    /// platform text input. `None` when the handler cannot expose it (e.g. the
157    /// field has not been laid out yet).
158    fn caret_geometry(&self) -> Option<ImeCaretGeometry> {
159        None
160    }
161}
162
163pub(crate) struct TextFieldFocusState {
164    focused_field: RefCell<Option<Weak<RefCell<bool>>>>,
165    focused_handler: RefCell<Option<Rc<dyn FocusedTextFieldHandler>>>,
166    focused_modal_depth: Cell<usize>,
167    edits: Cell<u64>,
168}
169
170impl TextFieldFocusState {
171    pub(crate) fn new() -> Self {
172        Self {
173            focused_field: RefCell::new(None),
174            focused_handler: RefCell::new(None),
175            focused_modal_depth: Cell::new(0),
176            edits: Cell::new(0),
177        }
178    }
179
180    fn request_focus(
181        &self,
182        is_focused: Rc<RefCell<bool>>,
183        handler: Rc<dyn FocusedTextFieldHandler>,
184        modal_depth: usize,
185    ) {
186        let mut current = self.focused_field.borrow_mut();
187
188        if let Some(ref weak) = *current
189            && let Some(old_focused) = weak.upgrade()
190        {
191            *old_focused.borrow_mut() = false;
192        }
193
194        *is_focused.borrow_mut() = true;
195        *current = Some(Rc::downgrade(&is_focused));
196        *self.focused_handler.borrow_mut() = Some(handler);
197        self.focused_modal_depth.set(modal_depth);
198    }
199
200    fn clear_focus(&self) {
201        let mut current = self.focused_field.borrow_mut();
202
203        if let Some(ref weak) = *current
204            && let Some(focused) = weak.upgrade()
205        {
206            *focused.borrow_mut() = false;
207        }
208
209        *current = None;
210        *self.focused_handler.borrow_mut() = None;
211        self.focused_modal_depth.set(0);
212    }
213
214    fn focused_at_depth(&self, depth: usize) -> bool {
215        self.has_focused_field() && self.focused_modal_depth.get() == depth
216    }
217
218    fn has_focused_field(&self) -> bool {
219        if self.focused_field_is_live() {
220            return true;
221        }
222        self.clear_stale_focus();
223        false
224    }
225
226    fn focused_field_is_live(&self) -> bool {
227        self.focused_field
228            .borrow()
229            .as_ref()
230            .is_some_and(|weak| weak.upgrade().is_some())
231    }
232
233    fn clear_stale_focus(&self) {
234        let stale_node = self
235            .focused_handler
236            .borrow()
237            .as_ref()
238            .and_then(|handler| handler.node_id());
239        let had_entry = self.focused_field.borrow_mut().take().is_some();
240        if !had_entry {
241            return;
242        }
243        self.focused_handler.borrow_mut().take();
244        if let Some(node_id) = stale_node {
245            crate::schedule_draw_repass(node_id);
246        }
247        crate::cursor_animation::stop_cursor_blink();
248    }
249
250    fn focused_handler(&self) -> Option<Rc<dyn FocusedTextFieldHandler>> {
251        if !self.has_focused_field() {
252            return None;
253        }
254        self.focused_handler.borrow().as_ref().cloned()
255    }
256
257    fn editing_handler(&self) -> Option<Rc<dyn FocusedTextFieldHandler>> {
258        let handler = self.focused_handler();
259        if handler.is_some() {
260            self.note_edit();
261        }
262        handler
263    }
264
265    fn note_edit(&self) {
266        self.edits.set(self.edits.get().wrapping_add(1));
267    }
268
269    fn dispatch_key_event(&self, event: &KeyEvent) -> bool {
270        let handled = self
271            .focused_handler()
272            .is_some_and(|handler| handler.handle_key(event));
273        if handled {
274            self.note_edit();
275        }
276        handled
277    }
278
279    fn dispatch_paste(&self, text: &str) -> bool {
280        if let Some(handler) = self.editing_handler() {
281            handler.insert_text(text);
282            true
283        } else {
284            false
285        }
286    }
287
288    fn dispatch_delete_surrounding(&self, before_bytes: usize, after_bytes: usize) -> bool {
289        if let Some(handler) = self.editing_handler() {
290            handler.delete_surrounding(before_bytes, after_bytes);
291            true
292        } else {
293            false
294        }
295    }
296
297    fn dispatch_copy(&self) -> Option<String> {
298        self.focused_handler()
299            .and_then(|handler| handler.copy_selection())
300    }
301
302    fn dispatch_cut(&self) -> Option<String> {
303        self.editing_handler()
304            .and_then(|handler| handler.cut_selection())
305    }
306
307    fn dispatch_select_all(&self) -> bool {
308        if let Some(handler) = self.editing_handler() {
309            handler.select_all();
310            true
311        } else {
312            false
313        }
314    }
315
316    fn dispatch_ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> bool {
317        if let Some(handler) = self.editing_handler() {
318            handler.set_composition(text, cursor);
319            true
320        } else {
321            false
322        }
323    }
324
325    fn dispatch_ime_finish_composing(&self) -> bool {
326        if let Some(handler) = self.editing_handler() {
327            handler.finish_composition();
328            true
329        } else {
330            false
331        }
332    }
333
334    fn dispatch_ime_set_composing_region(&self, start_bytes: usize, end_bytes: usize) -> bool {
335        if let Some(handler) = self.editing_handler() {
336            handler.set_composing_region(start_bytes, end_bytes);
337            true
338        } else {
339            false
340        }
341    }
342
343    fn dispatch_ime_set_selection(&self, start_bytes: usize, end_bytes: usize) -> bool {
344        if let Some(handler) = self.editing_handler() {
345            handler.set_selection(start_bytes, end_bytes);
346            true
347        } else {
348            false
349        }
350    }
351
352    fn focused_editor_state(&self) -> Option<ImeEditorState> {
353        self.focused_handler()
354            .and_then(|handler| handler.editor_state())
355    }
356
357    fn focused_caret_geometry(&self) -> Option<ImeCaretGeometry> {
358        self.focused_handler()
359            .and_then(|handler| handler.caret_geometry())
360    }
361}
362
363/// Requests focus for a text field composed at [`crate::modal::local_modal_depth`]
364/// `modal_depth`.
365///
366/// Refused (a no-op) when `modal_depth` is shallower than the modal depth
367/// that is open right now — a field behind an open dialog must not be
368/// able to steal focus from it. A field inside the innermost dialog (or
369/// outside any dialog, while none is open) has `modal_depth` equal to the
370/// current modal depth and is granted focus normally.
371///
372/// If another text field was previously focused, it will be unfocused first.
373/// The provided `is_focused` handle should be the field's focus state.
374/// The handler is stored for O(1) key dispatch.
375pub fn request_focus(
376    is_focused: Rc<RefCell<bool>>,
377    handler: Rc<dyn FocusedTextFieldHandler>,
378    modal_depth: usize,
379) {
380    if modal_depth < crate::modal::current_modal_depth() {
381        return;
382    }
383
384    let previous_field = focused_field_node();
385    let gaining_field = handler.node_id();
386    let show_keyboard = handler.show_keyboard_on_focus();
387
388    crate::render_state::with_text_field_focus(|state| {
389        state.request_focus(is_focused, handler, modal_depth);
390    });
391
392    for node_id in [previous_field, gaining_field].into_iter().flatten() {
393        crate::schedule_draw_repass(node_id);
394    }
395
396    crate::cursor_animation::start_cursor_blink();
397
398    if show_keyboard {
399        crate::text_input_session::notify_text_input_focus_gained();
400    }
401
402    crate::request_render_invalidation();
403}
404
405pub(crate) fn clear_focus_for_closed_modal(depth: usize) {
406    let owns_focus =
407        crate::render_state::with_text_field_focus(|state| state.focused_at_depth(depth));
408    if owns_focus {
409        clear_focus();
410    }
411}
412
413/// Clears focus from the currently focused text field.
414pub fn clear_focus() {
415    let previous = focused_field_node();
416    let previous_target = focused_field_target();
417    if let Some(node_id) = previous {
418        crate::schedule_draw_repass(node_id);
419    }
420    crate::render_state::with_text_field_focus(TextFieldFocusState::clear_focus);
421    if previous_target.is_some() && crate::focus_dispatch::active_focus_target() == previous_target
422    {
423        crate::focus_dispatch::clear_active_focus();
424    }
425
426    crate::cursor_animation::stop_cursor_blink();
427
428    crate::text_input_session::notify_text_input_focus_lost();
429
430    crate::request_render_invalidation();
431}
432
433/// Returns the composition node of the currently focused text field, if a
434/// field is focused and its handler knows its node.
435pub fn focused_field_node() -> Option<cranpose_core::NodeId> {
436    crate::render_state::with_text_field_focus(|state| {
437        state
438            .focused_handler()
439            .and_then(|handler| handler.node_id())
440    })
441}
442
443/// Returns the node focus and semantics know the focused text field by (see
444/// [`FocusedTextFieldHandler::target_node`]), if a field is focused.
445pub fn focused_field_target() -> Option<cranpose_core::NodeId> {
446    crate::render_state::with_text_field_focus(|state| {
447        state
448            .focused_handler()
449            .and_then(|handler| handler.target_node())
450    })
451}
452
453/// Returns true if any text field currently has focus.
454/// Checks weak ref liveness and clears stale focus state.
455pub fn has_focused_field() -> bool {
456    let has_focus =
457        crate::render_state::with_text_field_focus(TextFieldFocusState::has_focused_field);
458    if !has_focus {
459        crate::text_input_session::notify_text_input_focus_lost();
460    }
461    has_focus
462}
463
464/// Dispatches a key event to the focused text field. Returns true if consumed.
465/// O(1) operation using stored handler.
466pub fn dispatch_key_event(event: &KeyEvent) -> bool {
467    crate::render_state::with_text_field_focus(|state| state.dispatch_key_event(event))
468}
469
470/// Inserts text into the focused text field (paste operation).
471/// O(1) operation using stored handler.
472pub fn dispatch_paste(text: &str) -> bool {
473    crate::render_state::with_text_field_focus(|state| state.dispatch_paste(text))
474}
475
476/// Deletes text surrounding the cursor or selection.
477/// O(1) operation using stored handler.
478pub fn dispatch_delete_surrounding(before_bytes: usize, after_bytes: usize) -> bool {
479    crate::render_state::with_text_field_focus(|state| {
480        state.dispatch_delete_surrounding(before_bytes, after_bytes)
481    })
482}
483
484/// Copies selection from focused text field.
485/// O(1) operation using stored handler.
486pub fn dispatch_copy() -> Option<String> {
487    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_copy)
488}
489
490/// Cuts selection from focused text field (copy + delete).
491/// O(1) operation using stored handler.
492pub fn dispatch_cut() -> Option<String> {
493    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_cut)
494}
495
496/// Selects all the text in the focused text field (contextual-menu "Select
497/// all"). Returns true if a text field was focused.
498pub fn dispatch_select_all() -> bool {
499    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_select_all)
500}
501
502/// Dispatches IME preedit (composition) state to the focused text field.
503/// O(1) operation using stored handler.
504/// Returns true if a text field was focused and received the event.
505pub fn dispatch_ime_preedit(text: &str, cursor: Option<(usize, usize)>) -> bool {
506    crate::render_state::with_text_field_focus(|state| state.dispatch_ime_preedit(text, cursor))
507}
508
509/// Finishes the active composition in the focused text field, keeping the
510/// composed text (Android `finishComposingText` semantics).
511/// Returns true if a text field was focused and received the event.
512pub fn dispatch_ime_finish_composing() -> bool {
513    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_ime_finish_composing)
514}
515
516/// Marks existing text in the focused field as the composing region without
517/// changing it (Android `setComposingRegion` semantics).
518/// Returns true if a text field was focused and received the event.
519pub fn dispatch_ime_set_composing_region(start_bytes: usize, end_bytes: usize) -> bool {
520    crate::render_state::with_text_field_focus(|state| {
521        state.dispatch_ime_set_composing_region(start_bytes, end_bytes)
522    })
523}
524
525/// Moves the focused field's selection/caret to `[start_bytes, end_bytes)`
526/// without editing text (Android `setSelection` semantics; the path Gboard's
527/// spacebar-swipe uses to scrub the cursor).
528/// Returns true if a text field was focused and received the event.
529pub fn dispatch_ime_set_selection(start_bytes: usize, end_bytes: usize) -> bool {
530    crate::render_state::with_text_field_focus(|state| {
531        state.dispatch_ime_set_selection(start_bytes, end_bytes)
532    })
533}
534
535/// How many edits the focused text fields of the current app context took
536/// from platform input: typed, pasted, cut or composed text, deletions and
537/// caret or selection moves. A platform accessibility bridge compares two
538/// counts to tell whether a person typed between them.
539pub fn edit_count() -> u64 {
540    crate::render_state::with_text_field_focus(|state| state.edits.get())
541}
542
543/// Returns a snapshot of the focused text field's editable state for
544/// platform IMEs, or `None` when no field is focused (or the handler does
545/// not expose its state).
546pub fn focused_editor_state() -> Option<ImeEditorState> {
547    crate::render_state::with_text_field_focus(TextFieldFocusState::focused_editor_state)
548}
549
550/// Window-space caret geometry of the focused field (see [`ImeCaretGeometry`]),
551/// or `None` when no field is focused or it exposes no geometry.
552pub fn focused_caret_geometry() -> Option<ImeCaretGeometry> {
553    crate::render_state::with_text_field_focus(TextFieldFocusState::focused_caret_geometry)
554}
555
556#[cfg(test)]
557#[path = "tests/text_field_focus_tests.rs"]
558mod tests;