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