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/// All values are logical pixels in window space.
41#[derive(Clone, Debug, PartialEq)]
42pub struct ImeCaretGeometry {
43    /// Caret x for each character-boundary offset, in text order: entry `k` is
44    /// the caret after `k` characters, so `caret_xs.len() == chars + 1`.
45    pub caret_xs: Vec<f32>,
46    /// Top y of the (single) caret line.
47    pub top: f32,
48    /// Height of one line (the caret's height).
49    pub line_height: f32,
50}
51
52/// Handler trait for focused text field operations.
53/// Stored in focus module for O(1) key/clipboard dispatch.
54pub trait FocusedTextFieldHandler {
55    /// The composition node whose recorded draws show this field's caret and
56    /// selection. Focus changes and caret-blink flips schedule a scoped draw
57    /// repass on it — that state lives outside the draw-observation system,
58    /// so nothing else can name the stale node. `None` only for handlers with
59    /// no scene presence (test doubles, the platform no-op handler).
60    fn node_id(&self) -> Option<cranpose_core::NodeId> {
61        None
62    }
63    /// Handle a key event. Returns true if consumed.
64    fn handle_key(&self, event: &KeyEvent) -> bool;
65    /// Insert pasted text.
66    fn insert_text(&self, text: &str);
67    /// Delete text surrounding the cursor or selection.
68    fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize);
69    /// Copy current selection. Returns None if nothing selected.
70    fn copy_selection(&self) -> Option<String>;
71    /// Cut current selection (copy + delete). Returns None if nothing selected.
72    fn cut_selection(&self) -> Option<String>;
73    /// Selects all the field's text (contextual-menu "Select all").
74    fn select_all(&self) {}
75    /// Set IME composition (preedit) state.
76    /// - `text`: The composition text being typed (empty string to clear,
77    ///   which *deletes* the preedit text)
78    /// - `cursor`: Optional cursor position within composition (start, end)
79    fn set_composition(&self, text: &str, cursor: Option<(usize, usize)>);
80    /// Finish the active composition, keeping the composed text as regular
81    /// committed text (Android `finishComposingText` semantics). No-op when
82    /// no composition is active.
83    fn finish_composition(&self) {}
84    /// Mark existing text as the composing region without changing it
85    /// (Android `setComposingRegion` semantics, used by autocorrect to
86    /// re-compose an already committed word). Offsets are UTF-8 bytes;
87    /// implementations clamp them to valid character boundaries.
88    fn set_composing_region(&self, start_bytes: usize, end_bytes: usize) {
89        let _ = (start_bytes, end_bytes);
90    }
91    /// Move the selection/caret to `[start_bytes, end_bytes)` without editing
92    /// text (Android `InputConnection.setSelection` semantics). Used by
93    /// Gboard's spacebar-swipe cursor control, which scrubs the caret by
94    /// repeatedly setting the selection. Offsets are UTF-8 bytes; implementations
95    /// clamp them to valid character boundaries.
96    fn set_selection(&self, start_bytes: usize, end_bytes: usize) {
97        let _ = (start_bytes, end_bytes);
98    }
99    /// Snapshot of the current editable state for platform IMEs
100    /// (`InputConnection` text queries, session seeding). `None` when the
101    /// handler cannot expose its state.
102    fn editor_state(&self) -> Option<ImeEditorState> {
103        None
104    }
105    /// Window-space caret geometry (see [`ImeCaretGeometry`]) for coordinate-based
106    /// platform text input. `None` when the handler cannot expose it (e.g. the
107    /// field has not been laid out yet).
108    fn caret_geometry(&self) -> Option<ImeCaretGeometry> {
109        None
110    }
111}
112
113pub(crate) struct TextFieldFocusState {
114    focused_field: RefCell<Option<Weak<RefCell<bool>>>>,
115    focused_handler: RefCell<Option<Rc<dyn FocusedTextFieldHandler>>>,
116    focused_modal_depth: Cell<usize>,
117}
118
119impl TextFieldFocusState {
120    pub(crate) fn new() -> Self {
121        Self {
122            focused_field: RefCell::new(None),
123            focused_handler: RefCell::new(None),
124            focused_modal_depth: Cell::new(0),
125        }
126    }
127
128    fn request_focus(
129        &self,
130        is_focused: Rc<RefCell<bool>>,
131        handler: Rc<dyn FocusedTextFieldHandler>,
132        modal_depth: usize,
133    ) {
134        let mut current = self.focused_field.borrow_mut();
135
136        if let Some(ref weak) = *current
137            && let Some(old_focused) = weak.upgrade()
138        {
139            *old_focused.borrow_mut() = false;
140        }
141
142        *is_focused.borrow_mut() = true;
143        *current = Some(Rc::downgrade(&is_focused));
144        *self.focused_handler.borrow_mut() = Some(handler);
145        self.focused_modal_depth.set(modal_depth);
146    }
147
148    fn clear_focus(&self) {
149        let mut current = self.focused_field.borrow_mut();
150
151        if let Some(ref weak) = *current
152            && let Some(focused) = weak.upgrade()
153        {
154            *focused.borrow_mut() = false;
155        }
156
157        *current = None;
158        *self.focused_handler.borrow_mut() = None;
159        self.focused_modal_depth.set(0);
160    }
161
162    fn focused_at_depth(&self, depth: usize) -> bool {
163        self.has_focused_field() && self.focused_modal_depth.get() == depth
164    }
165
166    fn has_focused_field(&self) -> bool {
167        if self.focused_field_is_live() {
168            return true;
169        }
170        self.clear_stale_focus();
171        false
172    }
173
174    fn focused_field_is_live(&self) -> bool {
175        self.focused_field
176            .borrow()
177            .as_ref()
178            .is_some_and(|weak| weak.upgrade().is_some())
179    }
180
181    fn clear_stale_focus(&self) {
182        let stale_node = self
183            .focused_handler
184            .borrow()
185            .as_ref()
186            .and_then(|handler| handler.node_id());
187        let had_entry = self.focused_field.borrow_mut().take().is_some();
188        if !had_entry {
189            return;
190        }
191        self.focused_handler.borrow_mut().take();
192        if let Some(node_id) = stale_node {
193            crate::schedule_draw_repass(node_id);
194        }
195        crate::cursor_animation::stop_cursor_blink();
196    }
197
198    fn focused_handler(&self) -> Option<Rc<dyn FocusedTextFieldHandler>> {
199        if !self.has_focused_field() {
200            return None;
201        }
202        self.focused_handler.borrow().as_ref().cloned()
203    }
204
205    fn dispatch_key_event(&self, event: &KeyEvent) -> bool {
206        if let Some(handler) = self.focused_handler() {
207            handler.handle_key(event)
208        } else {
209            false
210        }
211    }
212
213    fn dispatch_paste(&self, text: &str) -> bool {
214        if let Some(handler) = self.focused_handler() {
215            handler.insert_text(text);
216            true
217        } else {
218            false
219        }
220    }
221
222    fn dispatch_delete_surrounding(&self, before_bytes: usize, after_bytes: usize) -> bool {
223        if let Some(handler) = self.focused_handler() {
224            handler.delete_surrounding(before_bytes, after_bytes);
225            true
226        } else {
227            false
228        }
229    }
230
231    fn dispatch_copy(&self) -> Option<String> {
232        self.focused_handler()
233            .and_then(|handler| handler.copy_selection())
234    }
235
236    fn dispatch_cut(&self) -> Option<String> {
237        self.focused_handler()
238            .and_then(|handler| handler.cut_selection())
239    }
240
241    fn dispatch_select_all(&self) -> bool {
242        if let Some(handler) = self.focused_handler() {
243            handler.select_all();
244            true
245        } else {
246            false
247        }
248    }
249
250    fn dispatch_ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> bool {
251        if let Some(handler) = self.focused_handler() {
252            handler.set_composition(text, cursor);
253            true
254        } else {
255            false
256        }
257    }
258
259    fn dispatch_ime_finish_composing(&self) -> bool {
260        if let Some(handler) = self.focused_handler() {
261            handler.finish_composition();
262            true
263        } else {
264            false
265        }
266    }
267
268    fn dispatch_ime_set_composing_region(&self, start_bytes: usize, end_bytes: usize) -> bool {
269        if let Some(handler) = self.focused_handler() {
270            handler.set_composing_region(start_bytes, end_bytes);
271            true
272        } else {
273            false
274        }
275    }
276
277    fn dispatch_ime_set_selection(&self, start_bytes: usize, end_bytes: usize) -> bool {
278        if let Some(handler) = self.focused_handler() {
279            handler.set_selection(start_bytes, end_bytes);
280            true
281        } else {
282            false
283        }
284    }
285
286    fn focused_editor_state(&self) -> Option<ImeEditorState> {
287        self.focused_handler()
288            .and_then(|handler| handler.editor_state())
289    }
290
291    fn focused_caret_geometry(&self) -> Option<ImeCaretGeometry> {
292        self.focused_handler()
293            .and_then(|handler| handler.caret_geometry())
294    }
295}
296
297/// Requests focus for a text field composed at [`crate::modal::local_modal_depth`]
298/// `modal_depth`.
299///
300/// Refused (a no-op) when `modal_depth` is shallower than the modal depth
301/// that is open right now — a field behind an open dialog must not be
302/// able to steal focus from it. A field inside the innermost dialog (or
303/// outside any dialog, while none is open) has `modal_depth` equal to the
304/// current modal depth and is granted focus normally.
305///
306/// If another text field was previously focused, it will be unfocused first.
307/// The provided `is_focused` handle should be the field's focus state.
308/// The handler is stored for O(1) key dispatch.
309pub fn request_focus(
310    is_focused: Rc<RefCell<bool>>,
311    handler: Rc<dyn FocusedTextFieldHandler>,
312    modal_depth: usize,
313) {
314    if modal_depth < crate::modal::current_modal_depth() {
315        return;
316    }
317
318    let previous_field = focused_field_node();
319    let gaining_field = handler.node_id();
320
321    crate::render_state::with_text_field_focus(|state| {
322        state.request_focus(is_focused, handler, modal_depth);
323    });
324
325    for node_id in [previous_field, gaining_field].into_iter().flatten() {
326        crate::schedule_draw_repass(node_id);
327    }
328
329    crate::cursor_animation::start_cursor_blink();
330
331    crate::text_input_session::notify_text_input_focus_gained();
332
333    crate::request_render_invalidation();
334}
335
336pub(crate) fn clear_focus_for_closed_modal(depth: usize) {
337    let owns_focus =
338        crate::render_state::with_text_field_focus(|state| state.focused_at_depth(depth));
339    if owns_focus {
340        clear_focus();
341    }
342}
343
344/// Clears focus from the currently focused text field.
345pub fn clear_focus() {
346    let previous = focused_field_node();
347    if let Some(node_id) = previous {
348        crate::schedule_draw_repass(node_id);
349    }
350    crate::render_state::with_text_field_focus(TextFieldFocusState::clear_focus);
351    if previous.is_some() && crate::focus_dispatch::active_focus_target() == previous {
352        crate::focus_dispatch::clear_active_focus();
353    }
354
355    crate::cursor_animation::stop_cursor_blink();
356
357    crate::text_input_session::notify_text_input_focus_lost();
358
359    crate::request_render_invalidation();
360}
361
362/// Returns the composition node of the currently focused text field, if a
363/// field is focused and its handler knows its node.
364pub fn focused_field_node() -> Option<cranpose_core::NodeId> {
365    crate::render_state::with_text_field_focus(|state| {
366        state
367            .focused_handler()
368            .and_then(|handler| handler.node_id())
369    })
370}
371
372/// Returns true if any text field currently has focus.
373/// Checks weak ref liveness and clears stale focus state.
374pub fn has_focused_field() -> bool {
375    let has_focus =
376        crate::render_state::with_text_field_focus(TextFieldFocusState::has_focused_field);
377    if !has_focus {
378        crate::text_input_session::notify_text_input_focus_lost();
379    }
380    has_focus
381}
382
383/// Dispatches a key event to the focused text field. Returns true if consumed.
384/// O(1) operation using stored handler.
385pub fn dispatch_key_event(event: &KeyEvent) -> bool {
386    crate::render_state::with_text_field_focus(|state| state.dispatch_key_event(event))
387}
388
389/// Inserts text into the focused text field (paste operation).
390/// O(1) operation using stored handler.
391pub fn dispatch_paste(text: &str) -> bool {
392    crate::render_state::with_text_field_focus(|state| state.dispatch_paste(text))
393}
394
395/// Deletes text surrounding the cursor or selection.
396/// O(1) operation using stored handler.
397pub fn dispatch_delete_surrounding(before_bytes: usize, after_bytes: usize) -> bool {
398    crate::render_state::with_text_field_focus(|state| {
399        state.dispatch_delete_surrounding(before_bytes, after_bytes)
400    })
401}
402
403/// Copies selection from focused text field.
404/// O(1) operation using stored handler.
405pub fn dispatch_copy() -> Option<String> {
406    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_copy)
407}
408
409/// Cuts selection from focused text field (copy + delete).
410/// O(1) operation using stored handler.
411pub fn dispatch_cut() -> Option<String> {
412    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_cut)
413}
414
415/// Selects all the text in the focused text field (contextual-menu "Select
416/// all"). Returns true if a text field was focused.
417pub fn dispatch_select_all() -> bool {
418    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_select_all)
419}
420
421/// Dispatches IME preedit (composition) state to the focused text field.
422/// O(1) operation using stored handler.
423/// Returns true if a text field was focused and received the event.
424pub fn dispatch_ime_preedit(text: &str, cursor: Option<(usize, usize)>) -> bool {
425    crate::render_state::with_text_field_focus(|state| state.dispatch_ime_preedit(text, cursor))
426}
427
428/// Finishes the active composition in the focused text field, keeping the
429/// composed text (Android `finishComposingText` semantics).
430/// Returns true if a text field was focused and received the event.
431pub fn dispatch_ime_finish_composing() -> bool {
432    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_ime_finish_composing)
433}
434
435/// Marks existing text in the focused field as the composing region without
436/// changing it (Android `setComposingRegion` semantics).
437/// Returns true if a text field was focused and received the event.
438pub fn dispatch_ime_set_composing_region(start_bytes: usize, end_bytes: usize) -> bool {
439    crate::render_state::with_text_field_focus(|state| {
440        state.dispatch_ime_set_composing_region(start_bytes, end_bytes)
441    })
442}
443
444/// Moves the focused field's selection/caret to `[start_bytes, end_bytes)`
445/// without editing text (Android `setSelection` semantics; the path Gboard's
446/// spacebar-swipe uses to scrub the cursor).
447/// Returns true if a text field was focused and received the event.
448pub fn dispatch_ime_set_selection(start_bytes: usize, end_bytes: usize) -> bool {
449    crate::render_state::with_text_field_focus(|state| {
450        state.dispatch_ime_set_selection(start_bytes, end_bytes)
451    })
452}
453
454/// Returns a snapshot of the focused text field's editable state for
455/// platform IMEs, or `None` when no field is focused (or the handler does
456/// not expose its state).
457pub fn focused_editor_state() -> Option<ImeEditorState> {
458    crate::render_state::with_text_field_focus(TextFieldFocusState::focused_editor_state)
459}
460
461/// Window-space caret geometry of the focused field (see [`ImeCaretGeometry`]),
462/// or `None` when no field is focused or it exposes no geometry.
463pub fn focused_caret_geometry() -> Option<ImeCaretGeometry> {
464    crate::render_state::with_text_field_focus(TextFieldFocusState::focused_caret_geometry)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    struct MockHandler;
472    impl FocusedTextFieldHandler for MockHandler {
473        fn handle_key(&self, _: &KeyEvent) -> bool {
474            false
475        }
476        fn insert_text(&self, _: &str) {}
477        fn delete_surrounding(&self, _: usize, _: usize) {}
478        fn copy_selection(&self) -> Option<String> {
479            None
480        }
481        fn cut_selection(&self) -> Option<String> {
482            None
483        }
484        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
485    }
486
487    fn mock_handler() -> Rc<dyn FocusedTextFieldHandler> {
488        Rc::new(MockHandler)
489    }
490
491    struct NodeBackedHandler(cranpose_core::NodeId);
492    impl FocusedTextFieldHandler for NodeBackedHandler {
493        fn node_id(&self) -> Option<cranpose_core::NodeId> {
494            Some(self.0)
495        }
496        fn handle_key(&self, _: &KeyEvent) -> bool {
497            false
498        }
499        fn insert_text(&self, _: &str) {}
500        fn delete_surrounding(&self, _: usize, _: usize) {}
501        fn copy_selection(&self) -> Option<String> {
502            None
503        }
504        fn cut_selection(&self) -> Option<String> {
505            None
506        }
507        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
508    }
509
510    #[test]
511    fn focus_transitions_schedule_scoped_draw_repasses_on_both_fields() {
512        let _app_context = crate::render_state::app_context_test_scope();
513        let _ = crate::render_state::take_draw_repass_nodes();
514
515        let first = Rc::new(RefCell::new(false));
516        request_focus(first, Rc::new(NodeBackedHandler(7)), 0);
517        assert!(
518            crate::render_state::take_draw_repass_nodes().contains(&7),
519            "gaining focus must re-record the gaining field's draws"
520        );
521
522        let second = Rc::new(RefCell::new(false));
523        request_focus(second, Rc::new(NodeBackedHandler(9)), 0);
524        let repasses = crate::render_state::take_draw_repass_nodes();
525        assert!(
526            repasses.contains(&7) && repasses.contains(&9),
527            "a focus hand-off must re-record both fields, got {repasses:?}"
528        );
529
530        clear_focus();
531        assert!(
532            crate::render_state::take_draw_repass_nodes().contains(&9),
533            "losing focus must re-record the field that had the caret"
534        );
535    }
536
537    #[test]
538    fn a_blink_transition_schedules_a_scoped_repass_on_the_focused_field() {
539        let _app_context = crate::render_state::app_context_test_scope();
540        let focus = Rc::new(RefCell::new(false));
541        request_focus(focus, Rc::new(NodeBackedHandler(21)), 0);
542        let _ = crate::render_state::take_draw_repass_nodes();
543
544        let past_interval = web_time::Instant::now()
545            + crate::cursor_animation::CursorAnimationState::BLINK_INTERVAL
546            + std::time::Duration::from_millis(1);
547        assert!(
548            crate::cursor_animation::tick_cursor_blink_at(past_interval),
549            "the tick past the interval must flip visibility"
550        );
551        assert!(
552            crate::render_state::take_draw_repass_nodes().contains(&21),
553            "the flip must re-record the focused field's draws"
554        );
555        clear_focus();
556    }
557
558    #[test]
559    fn request_focus_sets_flag() {
560        let _app_context = crate::render_state::app_context_test_scope();
561        let focus = Rc::new(RefCell::new(false));
562        request_focus(focus.clone(), mock_handler(), 0);
563        assert!(*focus.borrow());
564        clear_focus();
565    }
566
567    #[test]
568    fn request_focus_clears_previous() {
569        let _app_context = crate::render_state::app_context_test_scope();
570        let focus1 = Rc::new(RefCell::new(false));
571        let focus2 = Rc::new(RefCell::new(false));
572
573        request_focus(focus1.clone(), mock_handler(), 0);
574        assert!(*focus1.borrow());
575
576        request_focus(focus2.clone(), mock_handler(), 0);
577        assert!(!*focus1.borrow());
578        assert!(*focus2.borrow());
579        clear_focus();
580    }
581
582    #[test]
583    fn clear_focus_unfocuses_current() {
584        let _app_context = crate::render_state::app_context_test_scope();
585        let focus = Rc::new(RefCell::new(false));
586        request_focus(focus.clone(), mock_handler(), 0);
587        assert!(*focus.borrow());
588
589        clear_focus();
590        assert!(!*focus.borrow());
591    }
592
593    #[derive(Default)]
594    struct DispatchRecordingHandler {
595        key_count: Cell<usize>,
596        insert_count: Cell<usize>,
597        delete_count: Cell<usize>,
598        copy_count: Cell<usize>,
599        cut_count: Cell<usize>,
600        preedit_count: Cell<usize>,
601        last_delete: Cell<Option<(usize, usize)>>,
602    }
603
604    impl DispatchRecordingHandler {
605        fn bump(cell: &Cell<usize>) {
606            cell.set(cell.get() + 1);
607        }
608
609        fn total_calls(&self) -> usize {
610            self.key_count.get()
611                + self.insert_count.get()
612                + self.delete_count.get()
613                + self.copy_count.get()
614                + self.cut_count.get()
615                + self.preedit_count.get()
616        }
617    }
618
619    impl FocusedTextFieldHandler for DispatchRecordingHandler {
620        fn handle_key(&self, _: &KeyEvent) -> bool {
621            Self::bump(&self.key_count);
622            true
623        }
624
625        fn insert_text(&self, _: &str) {
626            Self::bump(&self.insert_count);
627        }
628
629        fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize) {
630            Self::bump(&self.delete_count);
631            self.last_delete.set(Some((before_bytes, after_bytes)));
632        }
633
634        fn copy_selection(&self) -> Option<String> {
635            Self::bump(&self.copy_count);
636            Some("copy".to_string())
637        }
638
639        fn cut_selection(&self) -> Option<String> {
640            Self::bump(&self.cut_count);
641            Some("cut".to_string())
642        }
643
644        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {
645            Self::bump(&self.preedit_count);
646        }
647    }
648
649    #[test]
650    fn dispatch_delete_surrounding_calls_handler() {
651        let _app_context = crate::render_state::app_context_test_scope();
652        let focus = Rc::new(RefCell::new(false));
653        let handler = Rc::new(DispatchRecordingHandler::default());
654
655        request_focus(Rc::clone(&focus), handler.clone(), 0);
656        assert!(dispatch_delete_surrounding(3, 1));
657        assert_eq!(handler.last_delete.get(), Some((3, 1)));
658
659        clear_focus();
660    }
661
662    #[test]
663    fn dispatch_clears_stale_focus_owner_before_invoking_handler() {
664        let _app_context = crate::render_state::app_context_test_scope();
665        let handler = Rc::new(DispatchRecordingHandler::default());
666
667        {
668            let focus = Rc::new(RefCell::new(false));
669            request_focus(Rc::clone(&focus), handler.clone(), 0);
670            assert!(has_focused_field());
671        }
672
673        let key_event = KeyEvent::key_down(crate::key_event::KeyCode::A, "a");
674
675        assert!(!dispatch_key_event(&key_event));
676        assert!(!dispatch_paste("stale paste"));
677        assert!(!dispatch_delete_surrounding(2, 1));
678        assert_eq!(dispatch_copy(), None);
679        assert_eq!(dispatch_cut(), None);
680        assert!(!dispatch_ime_preedit("preedit", Some((1, 1))));
681        assert!(!has_focused_field());
682        assert_eq!(
683            handler.total_calls(),
684            0,
685            "stale focused-field handlers must not receive input"
686        );
687    }
688
689    #[test]
690    fn stale_focus_cleanup_after_hidden_blink_does_not_reenter_the_focus_registry_borrow() {
691        let _app_context = crate::render_state::app_context_test_scope();
692
693        let focus = Rc::new(RefCell::new(false));
694        request_focus(focus.clone(), Rc::new(NodeBackedHandler(3)), 0);
695
696        let past_interval = web_time::Instant::now()
697            + crate::cursor_animation::CursorAnimationState::BLINK_INTERVAL
698            + std::time::Duration::from_millis(1);
699        assert!(
700            crate::cursor_animation::tick_cursor_blink_at(past_interval),
701            "the blink must already have toggled to hidden, matching the real \
702             timing where the bug's stop_cursor_blink() call is a visibility \
703             change and therefore reaches invalidate_focused_caret()"
704        );
705
706        drop(focus);
707
708        assert!(
709            !has_focused_field(),
710            "a field dropped without clear_focus must read back as unfocused \
711             instead of panicking on a reentrant borrow of focused_field"
712        );
713    }
714
715    #[test]
716    fn stale_focus_cleanup_repasses_the_node_that_lost_its_caret() {
717        let _app_context = crate::render_state::app_context_test_scope();
718        let _ = crate::render_state::take_draw_repass_nodes();
719
720        let focus = Rc::new(RefCell::new(false));
721        request_focus(focus.clone(), Rc::new(NodeBackedHandler(11)), 0);
722        let _ = crate::render_state::take_draw_repass_nodes();
723
724        drop(focus);
725
726        assert!(!has_focused_field());
727        assert!(
728            crate::render_state::take_draw_repass_nodes().contains(&11),
729            "discovering a stale field lazily must repass its node just like \
730             an explicit clear_focus does, or its caret is left stale on screen"
731        );
732    }
733
734    #[derive(Default)]
735    struct KeyboardProbe {
736        calls: RefCell<Vec<&'static str>>,
737    }
738
739    impl crate::text_input_session::PlatformTextInputHandler for KeyboardProbe {
740        fn show_keyboard(&self) {
741            self.calls.borrow_mut().push("show");
742        }
743
744        fn hide_keyboard(&self) {
745            self.calls.borrow_mut().push("hide");
746        }
747    }
748
749    #[test]
750    fn focus_transitions_drive_platform_keyboard() {
751        let _app_context = crate::render_state::app_context_test_scope();
752        let keyboard = Rc::new(KeyboardProbe::default());
753        crate::text_input_session::set_platform_text_input_handler(keyboard.clone());
754
755        let focus = Rc::new(RefCell::new(false));
756        request_focus(focus.clone(), mock_handler(), 0);
757        assert_eq!(*keyboard.calls.borrow(), vec!["show"]);
758
759        request_focus(focus, mock_handler(), 0);
760        assert_eq!(*keyboard.calls.borrow(), vec!["show", "show"]);
761
762        clear_focus();
763        assert_eq!(*keyboard.calls.borrow(), vec!["show", "show", "hide"]);
764    }
765
766    #[test]
767    fn stale_focus_detection_hides_platform_keyboard() {
768        let _app_context = crate::render_state::app_context_test_scope();
769        let keyboard = Rc::new(KeyboardProbe::default());
770        crate::text_input_session::set_platform_text_input_handler(keyboard.clone());
771
772        {
773            let focus = Rc::new(RefCell::new(false));
774            request_focus(focus, mock_handler(), 0);
775        }
776
777        assert!(!has_focused_field());
778        assert_eq!(*keyboard.calls.borrow(), vec!["show", "hide"]);
779
780        assert!(!has_focused_field());
781        assert_eq!(*keyboard.calls.borrow(), vec!["show", "hide"]);
782    }
783
784    #[test]
785    fn text_field_focus_is_scoped_by_app_context() {
786        let _app_context = crate::render_state::app_context_test_scope();
787        let first = crate::render_state::AppContext::new_with_density(1.0);
788        let second = crate::render_state::AppContext::new_with_density(1.0);
789        let first_focus = Rc::new(RefCell::new(false));
790        let second_focus = Rc::new(RefCell::new(false));
791
792        first.enter(|| {
793            request_focus(first_focus.clone(), mock_handler(), 0);
794            assert!(has_focused_field());
795            assert!(*first_focus.borrow());
796        });
797
798        second.enter(|| {
799            assert!(!has_focused_field());
800            request_focus(second_focus.clone(), mock_handler(), 0);
801            assert!(has_focused_field());
802            assert!(*second_focus.borrow());
803        });
804
805        first.enter(|| {
806            assert!(has_focused_field());
807            assert!(*first_focus.borrow());
808            clear_focus();
809            assert!(!has_focused_field());
810            assert!(!*first_focus.borrow());
811        });
812
813        second.enter(|| {
814            assert!(has_focused_field());
815            assert!(*second_focus.borrow());
816            clear_focus();
817        });
818    }
819}