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::cell::RefCell;
11use std::rc::{Rc, Weak};
12
13use crate::key_event::KeyEvent;
14
15/// Snapshot of the focused text field's editable state for platform IMEs.
16///
17/// All offsets are UTF-8 byte offsets into `text`. Platform layers that talk
18/// to UTF-16 based IMEs (Android's `InputConnection`, web composition events)
19/// convert on their side of the boundary.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ImeEditorState {
22    /// Full text content of the field.
23    pub text: String,
24    /// Selection start (min) in bytes. Equal to `selection_end` for a caret.
25    pub selection_start: usize,
26    /// Selection end (max) in bytes.
27    pub selection_end: usize,
28    /// Active composing (preedit) region in bytes, if any.
29    pub composition: Option<(usize, usize)>,
30    /// Whether the field is single-line (platforms use this to pick the
31    /// keyboard action, e.g. Android `IME_ACTION_DONE` vs newline).
32    pub single_line: bool,
33}
34
35/// Window-space caret geometry for the focused field, used by platforms whose
36/// native text input positions the caret by coordinates rather than key events
37/// (iOS `UITextInput`: spacebar-trackpad cursor movement and tap-to-position).
38/// All values are logical pixels in window space.
39#[derive(Clone, Debug, PartialEq)]
40pub struct ImeCaretGeometry {
41    /// Caret x for each character-boundary offset, in text order: entry `k` is
42    /// the caret after `k` characters, so `caret_xs.len() == chars + 1`.
43    pub caret_xs: Vec<f32>,
44    /// Top y of the (single) caret line.
45    pub top: f32,
46    /// Height of one line (the caret's height).
47    pub line_height: f32,
48}
49
50/// Handler trait for focused text field operations.
51/// Stored in focus module for O(1) key/clipboard dispatch.
52pub trait FocusedTextFieldHandler {
53    /// Handle a key event. Returns true if consumed.
54    fn handle_key(&self, event: &KeyEvent) -> bool;
55    /// Insert pasted text.
56    fn insert_text(&self, text: &str);
57    /// Delete text surrounding the cursor or selection.
58    fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize);
59    /// Copy current selection. Returns None if nothing selected.
60    fn copy_selection(&self) -> Option<String>;
61    /// Cut current selection (copy + delete). Returns None if nothing selected.
62    fn cut_selection(&self) -> Option<String>;
63    /// Selects all the field's text (contextual-menu "Select all").
64    fn select_all(&self) {}
65    /// Set IME composition (preedit) state.
66    /// - `text`: The composition text being typed (empty string to clear,
67    ///   which *deletes* the preedit text)
68    /// - `cursor`: Optional cursor position within composition (start, end)
69    fn set_composition(&self, text: &str, cursor: Option<(usize, usize)>);
70    /// Finish the active composition, keeping the composed text as regular
71    /// committed text (Android `finishComposingText` semantics). No-op when
72    /// no composition is active.
73    fn finish_composition(&self) {}
74    /// Mark existing text as the composing region without changing it
75    /// (Android `setComposingRegion` semantics, used by autocorrect to
76    /// re-compose an already committed word). Offsets are UTF-8 bytes;
77    /// implementations clamp them to valid character boundaries.
78    fn set_composing_region(&self, start_bytes: usize, end_bytes: usize) {
79        let _ = (start_bytes, end_bytes);
80    }
81    /// Move the selection/caret to `[start_bytes, end_bytes)` without editing
82    /// text (Android `InputConnection.setSelection` semantics). Used by
83    /// Gboard's spacebar-swipe cursor control, which scrubs the caret by
84    /// repeatedly setting the selection. Offsets are UTF-8 bytes; implementations
85    /// clamp them to valid character boundaries.
86    fn set_selection(&self, start_bytes: usize, end_bytes: usize) {
87        let _ = (start_bytes, end_bytes);
88    }
89    /// Snapshot of the current editable state for platform IMEs
90    /// (`InputConnection` text queries, session seeding). `None` when the
91    /// handler cannot expose its state.
92    fn editor_state(&self) -> Option<ImeEditorState> {
93        None
94    }
95    /// Window-space caret geometry (see [`ImeCaretGeometry`]) for coordinate-based
96    /// platform text input. `None` when the handler cannot expose it (e.g. the
97    /// field has not been laid out yet).
98    fn caret_geometry(&self) -> Option<ImeCaretGeometry> {
99        None
100    }
101}
102
103pub(crate) struct TextFieldFocusState {
104    focused_field: RefCell<Option<Weak<RefCell<bool>>>>,
105    focused_handler: RefCell<Option<Rc<dyn FocusedTextFieldHandler>>>,
106}
107
108impl TextFieldFocusState {
109    pub(crate) fn new() -> Self {
110        Self {
111            focused_field: RefCell::new(None),
112            focused_handler: RefCell::new(None),
113        }
114    }
115
116    fn request_focus(
117        &self,
118        is_focused: Rc<RefCell<bool>>,
119        handler: Rc<dyn FocusedTextFieldHandler>,
120    ) {
121        let mut current = self.focused_field.borrow_mut();
122
123        if let Some(ref weak) = *current {
124            if let Some(old_focused) = weak.upgrade() {
125                *old_focused.borrow_mut() = false;
126            }
127        }
128
129        *is_focused.borrow_mut() = true;
130        *current = Some(Rc::downgrade(&is_focused));
131        *self.focused_handler.borrow_mut() = Some(handler);
132    }
133
134    fn clear_focus(&self) {
135        let mut current = self.focused_field.borrow_mut();
136
137        if let Some(ref weak) = *current {
138            if let Some(focused) = weak.upgrade() {
139                *focused.borrow_mut() = false;
140            }
141        }
142
143        *current = None;
144        *self.focused_handler.borrow_mut() = None;
145    }
146
147    fn has_focused_field(&self) -> bool {
148        let mut current = self.focused_field.borrow_mut();
149        if let Some(ref weak) = *current {
150            if weak.upgrade().is_some() {
151                return true;
152            }
153            *current = None;
154            *self.focused_handler.borrow_mut() = None;
155            crate::cursor_animation::stop_cursor_blink();
156        }
157        false
158    }
159
160    fn focused_handler(&self) -> Option<Rc<dyn FocusedTextFieldHandler>> {
161        if !self.has_focused_field() {
162            return None;
163        }
164        self.focused_handler.borrow().as_ref().cloned()
165    }
166
167    fn dispatch_key_event(&self, event: &KeyEvent) -> bool {
168        if let Some(handler) = self.focused_handler() {
169            handler.handle_key(event)
170        } else {
171            false
172        }
173    }
174
175    fn dispatch_paste(&self, text: &str) -> bool {
176        if let Some(handler) = self.focused_handler() {
177            handler.insert_text(text);
178            true
179        } else {
180            false
181        }
182    }
183
184    fn dispatch_delete_surrounding(&self, before_bytes: usize, after_bytes: usize) -> bool {
185        if let Some(handler) = self.focused_handler() {
186            handler.delete_surrounding(before_bytes, after_bytes);
187            true
188        } else {
189            false
190        }
191    }
192
193    fn dispatch_copy(&self) -> Option<String> {
194        self.focused_handler()
195            .and_then(|handler| handler.copy_selection())
196    }
197
198    fn dispatch_cut(&self) -> Option<String> {
199        self.focused_handler()
200            .and_then(|handler| handler.cut_selection())
201    }
202
203    fn dispatch_select_all(&self) -> bool {
204        if let Some(handler) = self.focused_handler() {
205            handler.select_all();
206            true
207        } else {
208            false
209        }
210    }
211
212    fn dispatch_ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> bool {
213        if let Some(handler) = self.focused_handler() {
214            handler.set_composition(text, cursor);
215            true
216        } else {
217            false
218        }
219    }
220
221    fn dispatch_ime_finish_composing(&self) -> bool {
222        if let Some(handler) = self.focused_handler() {
223            handler.finish_composition();
224            true
225        } else {
226            false
227        }
228    }
229
230    fn dispatch_ime_set_composing_region(&self, start_bytes: usize, end_bytes: usize) -> bool {
231        if let Some(handler) = self.focused_handler() {
232            handler.set_composing_region(start_bytes, end_bytes);
233            true
234        } else {
235            false
236        }
237    }
238
239    fn dispatch_ime_set_selection(&self, start_bytes: usize, end_bytes: usize) -> bool {
240        if let Some(handler) = self.focused_handler() {
241            handler.set_selection(start_bytes, end_bytes);
242            true
243        } else {
244            false
245        }
246    }
247
248    fn focused_editor_state(&self) -> Option<ImeEditorState> {
249        self.focused_handler()
250            .and_then(|handler| handler.editor_state())
251    }
252
253    fn focused_caret_geometry(&self) -> Option<ImeCaretGeometry> {
254        self.focused_handler()
255            .and_then(|handler| handler.caret_geometry())
256    }
257}
258
259/// Requests focus for a text field.
260///
261/// If another text field was previously focused, it will be unfocused first.
262/// The provided `is_focused` handle should be the field's focus state.
263/// The handler is stored for O(1) key dispatch.
264pub fn request_focus(is_focused: Rc<RefCell<bool>>, handler: Rc<dyn FocusedTextFieldHandler>) {
265    crate::render_state::with_text_field_focus(|state| state.request_focus(is_focused, handler));
266
267    // Start cursor blink animation (timer-based, not continuous redraw)
268    crate::cursor_animation::start_cursor_blink();
269
270    // Tell the platform to show its soft keyboard (fires on every focus
271    // request on purpose - see text_input_session module docs).
272    crate::text_input_session::notify_text_input_focus_gained();
273
274    // Only render invalidation needed - cursor is drawn via create_draw_closure()
275    // which checks focus at draw time. No layout change occurs on focus.
276    crate::request_render_invalidation();
277}
278
279/// Clears focus from the currently focused text field.
280pub fn clear_focus() {
281    crate::render_state::with_text_field_focus(|state| state.clear_focus());
282
283    // Stop cursor blink animation
284    crate::cursor_animation::stop_cursor_blink();
285
286    // Tell the platform to hide its soft keyboard.
287    crate::text_input_session::notify_text_input_focus_lost();
288
289    crate::request_render_invalidation();
290}
291
292/// Returns true if any text field currently has focus.
293/// Checks weak ref liveness and clears stale focus state.
294pub fn has_focused_field() -> bool {
295    let has_focus = crate::render_state::with_text_field_focus(|state| state.has_focused_field());
296    if !has_focus {
297        // The focused field may have just been detected as stale (removed from
298        // the composition without clear_focus). Hide the soft keyboard; this
299        // is a gated no-op when no keyboard request is outstanding.
300        crate::text_input_session::notify_text_input_focus_lost();
301    }
302    has_focus
303}
304
305// ============================================================================
306// O(1) Dispatch Functions - Bypass tree scan by using stored handler
307// ============================================================================
308
309/// Dispatches a key event to the focused text field. Returns true if consumed.
310/// O(1) operation using stored handler.
311pub fn dispatch_key_event(event: &KeyEvent) -> bool {
312    crate::render_state::with_text_field_focus(|state| state.dispatch_key_event(event))
313}
314
315/// Inserts text into the focused text field (paste operation).
316/// O(1) operation using stored handler.
317pub fn dispatch_paste(text: &str) -> bool {
318    crate::render_state::with_text_field_focus(|state| state.dispatch_paste(text))
319}
320
321/// Deletes text surrounding the cursor or selection.
322/// O(1) operation using stored handler.
323pub fn dispatch_delete_surrounding(before_bytes: usize, after_bytes: usize) -> bool {
324    crate::render_state::with_text_field_focus(|state| {
325        state.dispatch_delete_surrounding(before_bytes, after_bytes)
326    })
327}
328
329/// Copies selection from focused text field.
330/// O(1) operation using stored handler.
331pub fn dispatch_copy() -> Option<String> {
332    crate::render_state::with_text_field_focus(|state| state.dispatch_copy())
333}
334
335/// Cuts selection from focused text field (copy + delete).
336/// O(1) operation using stored handler.
337pub fn dispatch_cut() -> Option<String> {
338    crate::render_state::with_text_field_focus(|state| state.dispatch_cut())
339}
340
341/// Selects all the text in the focused text field (contextual-menu "Select
342/// all"). Returns true if a text field was focused.
343pub fn dispatch_select_all() -> bool {
344    crate::render_state::with_text_field_focus(|state| state.dispatch_select_all())
345}
346
347/// Dispatches IME preedit (composition) state to the focused text field.
348/// O(1) operation using stored handler.
349/// Returns true if a text field was focused and received the event.
350pub fn dispatch_ime_preedit(text: &str, cursor: Option<(usize, usize)>) -> bool {
351    crate::render_state::with_text_field_focus(|state| state.dispatch_ime_preedit(text, cursor))
352}
353
354/// Finishes the active composition in the focused text field, keeping the
355/// composed text (Android `finishComposingText` semantics).
356/// Returns true if a text field was focused and received the event.
357pub fn dispatch_ime_finish_composing() -> bool {
358    crate::render_state::with_text_field_focus(|state| state.dispatch_ime_finish_composing())
359}
360
361/// Marks existing text in the focused field as the composing region without
362/// changing it (Android `setComposingRegion` semantics).
363/// Returns true if a text field was focused and received the event.
364pub fn dispatch_ime_set_composing_region(start_bytes: usize, end_bytes: usize) -> bool {
365    crate::render_state::with_text_field_focus(|state| {
366        state.dispatch_ime_set_composing_region(start_bytes, end_bytes)
367    })
368}
369
370/// Moves the focused field's selection/caret to `[start_bytes, end_bytes)`
371/// without editing text (Android `setSelection` semantics; the path Gboard's
372/// spacebar-swipe uses to scrub the cursor).
373/// Returns true if a text field was focused and received the event.
374pub fn dispatch_ime_set_selection(start_bytes: usize, end_bytes: usize) -> bool {
375    crate::render_state::with_text_field_focus(|state| {
376        state.dispatch_ime_set_selection(start_bytes, end_bytes)
377    })
378}
379
380/// Returns a snapshot of the focused text field's editable state for
381/// platform IMEs, or `None` when no field is focused (or the handler does
382/// not expose its state).
383pub fn focused_editor_state() -> Option<ImeEditorState> {
384    crate::render_state::with_text_field_focus(|state| state.focused_editor_state())
385}
386
387/// Window-space caret geometry of the focused field (see [`ImeCaretGeometry`]),
388/// or `None` when no field is focused or it exposes no geometry.
389pub fn focused_caret_geometry() -> Option<ImeCaretGeometry> {
390    crate::render_state::with_text_field_focus(|state| state.focused_caret_geometry())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::cell::Cell;
397
398    // Mock handler for testing
399    struct MockHandler;
400    impl FocusedTextFieldHandler for MockHandler {
401        fn handle_key(&self, _: &KeyEvent) -> bool {
402            false
403        }
404        fn insert_text(&self, _: &str) {}
405        fn delete_surrounding(&self, _: usize, _: usize) {}
406        fn copy_selection(&self) -> Option<String> {
407            None
408        }
409        fn cut_selection(&self) -> Option<String> {
410            None
411        }
412        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
413    }
414
415    fn mock_handler() -> Rc<dyn FocusedTextFieldHandler> {
416        Rc::new(MockHandler)
417    }
418
419    #[test]
420    fn request_focus_sets_flag() {
421        let _app_context = crate::render_state::app_context_test_scope();
422        let focus = Rc::new(RefCell::new(false));
423        request_focus(focus.clone(), mock_handler());
424        assert!(*focus.borrow());
425        clear_focus();
426    }
427
428    #[test]
429    fn request_focus_clears_previous() {
430        let _app_context = crate::render_state::app_context_test_scope();
431        let focus1 = Rc::new(RefCell::new(false));
432        let focus2 = Rc::new(RefCell::new(false));
433
434        request_focus(focus1.clone(), mock_handler());
435        assert!(*focus1.borrow());
436
437        request_focus(focus2.clone(), mock_handler());
438        assert!(!*focus1.borrow()); // First should be unfocused
439        assert!(*focus2.borrow()); // Second should be focused
440        clear_focus();
441    }
442
443    #[test]
444    fn clear_focus_unfocuses_current() {
445        let _app_context = crate::render_state::app_context_test_scope();
446        let focus = Rc::new(RefCell::new(false));
447        request_focus(focus.clone(), mock_handler());
448        assert!(*focus.borrow());
449
450        clear_focus();
451        assert!(!*focus.borrow());
452    }
453
454    #[derive(Default)]
455    struct DispatchRecordingHandler {
456        key_count: Cell<usize>,
457        insert_count: Cell<usize>,
458        delete_count: Cell<usize>,
459        copy_count: Cell<usize>,
460        cut_count: Cell<usize>,
461        preedit_count: Cell<usize>,
462        last_delete: Cell<Option<(usize, usize)>>,
463    }
464
465    impl DispatchRecordingHandler {
466        fn bump(cell: &Cell<usize>) {
467            cell.set(cell.get() + 1);
468        }
469
470        fn total_calls(&self) -> usize {
471            self.key_count.get()
472                + self.insert_count.get()
473                + self.delete_count.get()
474                + self.copy_count.get()
475                + self.cut_count.get()
476                + self.preedit_count.get()
477        }
478    }
479
480    impl FocusedTextFieldHandler for DispatchRecordingHandler {
481        fn handle_key(&self, _: &KeyEvent) -> bool {
482            Self::bump(&self.key_count);
483            true
484        }
485
486        fn insert_text(&self, _: &str) {
487            Self::bump(&self.insert_count);
488        }
489
490        fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize) {
491            Self::bump(&self.delete_count);
492            self.last_delete.set(Some((before_bytes, after_bytes)));
493        }
494
495        fn copy_selection(&self) -> Option<String> {
496            Self::bump(&self.copy_count);
497            Some("copy".to_string())
498        }
499
500        fn cut_selection(&self) -> Option<String> {
501            Self::bump(&self.cut_count);
502            Some("cut".to_string())
503        }
504
505        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {
506            Self::bump(&self.preedit_count);
507        }
508    }
509
510    #[test]
511    fn dispatch_delete_surrounding_calls_handler() {
512        let _app_context = crate::render_state::app_context_test_scope();
513        let focus = Rc::new(RefCell::new(false));
514        let handler = Rc::new(DispatchRecordingHandler::default());
515
516        request_focus(Rc::clone(&focus), handler.clone());
517        assert!(dispatch_delete_surrounding(3, 1));
518        assert_eq!(handler.last_delete.get(), Some((3, 1)));
519
520        clear_focus();
521    }
522
523    #[test]
524    fn dispatch_clears_stale_focus_owner_before_invoking_handler() {
525        let _app_context = crate::render_state::app_context_test_scope();
526        let handler = Rc::new(DispatchRecordingHandler::default());
527
528        {
529            let focus = Rc::new(RefCell::new(false));
530            request_focus(Rc::clone(&focus), handler.clone());
531            assert!(has_focused_field());
532        }
533
534        let key_event = KeyEvent::key_down(crate::key_event::KeyCode::A, "a");
535
536        assert!(!dispatch_key_event(&key_event));
537        assert!(!dispatch_paste("stale paste"));
538        assert!(!dispatch_delete_surrounding(2, 1));
539        assert_eq!(dispatch_copy(), None);
540        assert_eq!(dispatch_cut(), None);
541        assert!(!dispatch_ime_preedit("preedit", Some((1, 1))));
542        assert!(!has_focused_field());
543        assert_eq!(
544            handler.total_calls(),
545            0,
546            "stale focused-field handlers must not receive input"
547        );
548    }
549
550    #[derive(Default)]
551    struct KeyboardProbe {
552        calls: RefCell<Vec<&'static str>>,
553    }
554
555    impl crate::text_input_session::PlatformTextInputHandler for KeyboardProbe {
556        fn show_keyboard(&self) {
557            self.calls.borrow_mut().push("show");
558        }
559
560        fn hide_keyboard(&self) {
561            self.calls.borrow_mut().push("hide");
562        }
563    }
564
565    #[test]
566    fn focus_transitions_drive_platform_keyboard() {
567        let _app_context = crate::render_state::app_context_test_scope();
568        let keyboard = Rc::new(KeyboardProbe::default());
569        crate::text_input_session::set_platform_text_input_handler(keyboard.clone());
570
571        let focus = Rc::new(RefCell::new(false));
572        request_focus(focus.clone(), mock_handler());
573        assert_eq!(*keyboard.calls.borrow(), vec!["show"]);
574
575        // Tapping the (already focused) field again must re-request the
576        // keyboard: the user may have dismissed it with the back gesture.
577        request_focus(focus, mock_handler());
578        assert_eq!(*keyboard.calls.borrow(), vec!["show", "show"]);
579
580        clear_focus();
581        assert_eq!(*keyboard.calls.borrow(), vec!["show", "show", "hide"]);
582    }
583
584    #[test]
585    fn stale_focus_detection_hides_platform_keyboard() {
586        let _app_context = crate::render_state::app_context_test_scope();
587        let keyboard = Rc::new(KeyboardProbe::default());
588        crate::text_input_session::set_platform_text_input_handler(keyboard.clone());
589
590        {
591            let focus = Rc::new(RefCell::new(false));
592            request_focus(focus, mock_handler());
593            // The focused field's Rc is dropped here (field removed from the
594            // composition without an explicit clear_focus).
595        }
596
597        assert!(!has_focused_field());
598        assert_eq!(*keyboard.calls.borrow(), vec!["show", "hide"]);
599
600        // Repeated stale checks must not re-hide.
601        assert!(!has_focused_field());
602        assert_eq!(*keyboard.calls.borrow(), vec!["show", "hide"]);
603    }
604
605    #[test]
606    fn text_field_focus_is_scoped_by_app_context() {
607        let _app_context = crate::render_state::app_context_test_scope();
608        let first = crate::render_state::AppContext::new_with_density(1.0);
609        let second = crate::render_state::AppContext::new_with_density(1.0);
610        let first_focus = Rc::new(RefCell::new(false));
611        let second_focus = Rc::new(RefCell::new(false));
612
613        first.enter(|| {
614            request_focus(first_focus.clone(), mock_handler());
615            assert!(has_focused_field());
616            assert!(*first_focus.borrow());
617        });
618
619        second.enter(|| {
620            assert!(!has_focused_field());
621            request_focus(second_focus.clone(), mock_handler());
622            assert!(has_focused_field());
623            assert!(*second_focus.borrow());
624        });
625
626        first.enter(|| {
627            assert!(has_focused_field());
628            assert!(*first_focus.borrow());
629            clear_focus();
630            assert!(!has_focused_field());
631            assert!(!*first_focus.borrow());
632        });
633
634        second.enter(|| {
635            assert!(has_focused_field());
636            assert!(*second_focus.borrow());
637            clear_focus();
638        });
639    }
640}