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    /// The [`crate::modal::local_modal_depth`] the currently focused field was
117    /// composed at, so a closing modal can tell whether the field it guarded
118    /// is the one that just went away (see [`Self::focused_at_depth`]).
119    focused_modal_depth: Cell<usize>,
120}
121
122impl TextFieldFocusState {
123    pub(crate) fn new() -> Self {
124        Self {
125            focused_field: RefCell::new(None),
126            focused_handler: RefCell::new(None),
127            focused_modal_depth: Cell::new(0),
128        }
129    }
130
131    fn request_focus(
132        &self,
133        is_focused: Rc<RefCell<bool>>,
134        handler: Rc<dyn FocusedTextFieldHandler>,
135        modal_depth: usize,
136    ) {
137        let mut current = self.focused_field.borrow_mut();
138
139        if let Some(ref weak) = *current
140            && let Some(old_focused) = weak.upgrade()
141        {
142            *old_focused.borrow_mut() = false;
143        }
144
145        *is_focused.borrow_mut() = true;
146        *current = Some(Rc::downgrade(&is_focused));
147        *self.focused_handler.borrow_mut() = Some(handler);
148        self.focused_modal_depth.set(modal_depth);
149    }
150
151    fn clear_focus(&self) {
152        let mut current = self.focused_field.borrow_mut();
153
154        if let Some(ref weak) = *current
155            && let Some(focused) = weak.upgrade()
156        {
157            *focused.borrow_mut() = false;
158        }
159
160        *current = None;
161        *self.focused_handler.borrow_mut() = None;
162        self.focused_modal_depth.set(0);
163    }
164
165    /// Whether a live field is focused and was composed at exactly `depth`.
166    fn focused_at_depth(&self, depth: usize) -> bool {
167        self.has_focused_field() && self.focused_modal_depth.get() == depth
168    }
169
170    fn has_focused_field(&self) -> bool {
171        if self.focused_field_is_live() {
172            return true;
173        }
174        self.clear_stale_focus();
175        false
176    }
177
178    /// Whether the registered field's weak handle still resolves to a live field.
179    fn focused_field_is_live(&self) -> bool {
180        self.focused_field
181            .borrow()
182            .as_ref()
183            .is_some_and(|weak| weak.upgrade().is_some())
184    }
185
186    /// Drops a registration whose field was torn down without going through
187    /// [`Self::clear_focus`] (e.g. composition removed the node). A no-op
188    /// when nothing is registered.
189    fn clear_stale_focus(&self) {
190        let stale_node = self
191            .focused_handler
192            .borrow()
193            .as_ref()
194            .and_then(|handler| handler.node_id());
195        let had_entry = self.focused_field.borrow_mut().take().is_some();
196        if !had_entry {
197            return;
198        }
199        self.focused_handler.borrow_mut().take();
200        if let Some(node_id) = stale_node {
201            crate::schedule_draw_repass(node_id);
202        }
203        crate::cursor_animation::stop_cursor_blink();
204    }
205
206    fn focused_handler(&self) -> Option<Rc<dyn FocusedTextFieldHandler>> {
207        if !self.has_focused_field() {
208            return None;
209        }
210        self.focused_handler.borrow().as_ref().cloned()
211    }
212
213    fn dispatch_key_event(&self, event: &KeyEvent) -> bool {
214        if let Some(handler) = self.focused_handler() {
215            handler.handle_key(event)
216        } else {
217            false
218        }
219    }
220
221    fn dispatch_paste(&self, text: &str) -> bool {
222        if let Some(handler) = self.focused_handler() {
223            handler.insert_text(text);
224            true
225        } else {
226            false
227        }
228    }
229
230    fn dispatch_delete_surrounding(&self, before_bytes: usize, after_bytes: usize) -> bool {
231        if let Some(handler) = self.focused_handler() {
232            handler.delete_surrounding(before_bytes, after_bytes);
233            true
234        } else {
235            false
236        }
237    }
238
239    fn dispatch_copy(&self) -> Option<String> {
240        self.focused_handler()
241            .and_then(|handler| handler.copy_selection())
242    }
243
244    fn dispatch_cut(&self) -> Option<String> {
245        self.focused_handler()
246            .and_then(|handler| handler.cut_selection())
247    }
248
249    fn dispatch_select_all(&self) -> bool {
250        if let Some(handler) = self.focused_handler() {
251            handler.select_all();
252            true
253        } else {
254            false
255        }
256    }
257
258    fn dispatch_ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> bool {
259        if let Some(handler) = self.focused_handler() {
260            handler.set_composition(text, cursor);
261            true
262        } else {
263            false
264        }
265    }
266
267    fn dispatch_ime_finish_composing(&self) -> bool {
268        if let Some(handler) = self.focused_handler() {
269            handler.finish_composition();
270            true
271        } else {
272            false
273        }
274    }
275
276    fn dispatch_ime_set_composing_region(&self, start_bytes: usize, end_bytes: usize) -> bool {
277        if let Some(handler) = self.focused_handler() {
278            handler.set_composing_region(start_bytes, end_bytes);
279            true
280        } else {
281            false
282        }
283    }
284
285    fn dispatch_ime_set_selection(&self, start_bytes: usize, end_bytes: usize) -> bool {
286        if let Some(handler) = self.focused_handler() {
287            handler.set_selection(start_bytes, end_bytes);
288            true
289        } else {
290            false
291        }
292    }
293
294    fn focused_editor_state(&self) -> Option<ImeEditorState> {
295        self.focused_handler()
296            .and_then(|handler| handler.editor_state())
297    }
298
299    fn focused_caret_geometry(&self) -> Option<ImeCaretGeometry> {
300        self.focused_handler()
301            .and_then(|handler| handler.caret_geometry())
302    }
303}
304
305/// Requests focus for a text field composed at [`crate::modal::local_modal_depth`]
306/// `modal_depth`.
307///
308/// Refused (a no-op) when `modal_depth` is shallower than the modal depth
309/// that is open right now — a field behind an open dialog must not be
310/// able to steal focus from it. A field inside the innermost dialog (or
311/// outside any dialog, while none is open) has `modal_depth` equal to the
312/// current modal depth and is granted focus normally.
313///
314/// If another text field was previously focused, it will be unfocused first.
315/// The provided `is_focused` handle should be the field's focus state.
316/// The handler is stored for O(1) key dispatch.
317pub fn request_focus(
318    is_focused: Rc<RefCell<bool>>,
319    handler: Rc<dyn FocusedTextFieldHandler>,
320    modal_depth: usize,
321) {
322    if modal_depth < crate::modal::current_modal_depth() {
323        return;
324    }
325
326    // The caret is drawn from focus state no draw observation can see: both
327    // the field losing focus and the one gaining it must re-record their
328    // draws, and only a scoped repass on each keeps that off the
329    // whole-scene rebuild path.
330    let previous_field = focused_field_node();
331    let gaining_field = handler.node_id();
332
333    crate::render_state::with_text_field_focus(|state| {
334        state.request_focus(is_focused, handler, modal_depth)
335    });
336
337    for node_id in [previous_field, gaining_field].into_iter().flatten() {
338        crate::schedule_draw_repass(node_id);
339    }
340
341    // Start cursor blink animation (timer-based, not continuous redraw)
342    crate::cursor_animation::start_cursor_blink();
343
344    // Tell the platform to show its soft keyboard (fires on every focus
345    // request on purpose - see text_input_session module docs).
346    crate::text_input_session::notify_text_input_focus_gained();
347
348    // Cursor is drawn via create_draw_closure() which checks focus at draw
349    // time. No layout change occurs on focus.
350    crate::request_render_invalidation();
351}
352
353/// Clears focus if the currently focused field was composed at exactly
354/// `depth` — called when the modal that occupied `depth` closes, so a field
355/// that went away with it does not strand the platform keyboard open. A
356/// field at another depth (outside that modal, or inside an unrelated one)
357/// is untouched.
358pub(crate) fn clear_focus_for_closed_modal(depth: usize) {
359    let owns_focus =
360        crate::render_state::with_text_field_focus(|state| state.focused_at_depth(depth));
361    if owns_focus {
362        clear_focus();
363    }
364}
365
366/// Clears focus from the currently focused text field.
367pub fn clear_focus() {
368    // The unfocused field's caret and selection must leave its recorded
369    // draws; capture its node before the registry forgets it.
370    if let Some(node_id) = focused_field_node() {
371        crate::schedule_draw_repass(node_id);
372    }
373    crate::render_state::with_text_field_focus(|state| state.clear_focus());
374
375    // Stop cursor blink animation
376    crate::cursor_animation::stop_cursor_blink();
377
378    // Tell the platform to hide its soft keyboard.
379    crate::text_input_session::notify_text_input_focus_lost();
380
381    crate::request_render_invalidation();
382}
383
384/// Returns the composition node of the currently focused text field, if a
385/// field is focused and its handler knows its node.
386pub fn focused_field_node() -> Option<cranpose_core::NodeId> {
387    crate::render_state::with_text_field_focus(|state| {
388        state
389            .focused_handler()
390            .and_then(|handler| handler.node_id())
391    })
392}
393
394/// Returns true if any text field currently has focus.
395/// Checks weak ref liveness and clears stale focus state.
396pub fn has_focused_field() -> bool {
397    let has_focus = crate::render_state::with_text_field_focus(|state| state.has_focused_field());
398    if !has_focus {
399        // The focused field may have just been detected as stale (removed from
400        // the composition without clear_focus). Hide the soft keyboard; this
401        // is a gated no-op when no keyboard request is outstanding.
402        crate::text_input_session::notify_text_input_focus_lost();
403    }
404    has_focus
405}
406
407// ============================================================================
408// O(1) Dispatch Functions - Bypass tree scan by using stored handler
409// ============================================================================
410
411/// Dispatches a key event to the focused text field. Returns true if consumed.
412/// O(1) operation using stored handler.
413pub fn dispatch_key_event(event: &KeyEvent) -> bool {
414    crate::render_state::with_text_field_focus(|state| state.dispatch_key_event(event))
415}
416
417/// Inserts text into the focused text field (paste operation).
418/// O(1) operation using stored handler.
419pub fn dispatch_paste(text: &str) -> bool {
420    crate::render_state::with_text_field_focus(|state| state.dispatch_paste(text))
421}
422
423/// Deletes text surrounding the cursor or selection.
424/// O(1) operation using stored handler.
425pub fn dispatch_delete_surrounding(before_bytes: usize, after_bytes: usize) -> bool {
426    crate::render_state::with_text_field_focus(|state| {
427        state.dispatch_delete_surrounding(before_bytes, after_bytes)
428    })
429}
430
431/// Copies selection from focused text field.
432/// O(1) operation using stored handler.
433pub fn dispatch_copy() -> Option<String> {
434    crate::render_state::with_text_field_focus(|state| state.dispatch_copy())
435}
436
437/// Cuts selection from focused text field (copy + delete).
438/// O(1) operation using stored handler.
439pub fn dispatch_cut() -> Option<String> {
440    crate::render_state::with_text_field_focus(|state| state.dispatch_cut())
441}
442
443/// Selects all the text in the focused text field (contextual-menu "Select
444/// all"). Returns true if a text field was focused.
445pub fn dispatch_select_all() -> bool {
446    crate::render_state::with_text_field_focus(|state| state.dispatch_select_all())
447}
448
449/// Dispatches IME preedit (composition) state to the focused text field.
450/// O(1) operation using stored handler.
451/// Returns true if a text field was focused and received the event.
452pub fn dispatch_ime_preedit(text: &str, cursor: Option<(usize, usize)>) -> bool {
453    crate::render_state::with_text_field_focus(|state| state.dispatch_ime_preedit(text, cursor))
454}
455
456/// Finishes the active composition in the focused text field, keeping the
457/// composed text (Android `finishComposingText` semantics).
458/// Returns true if a text field was focused and received the event.
459pub fn dispatch_ime_finish_composing() -> bool {
460    crate::render_state::with_text_field_focus(|state| state.dispatch_ime_finish_composing())
461}
462
463/// Marks existing text in the focused field as the composing region without
464/// changing it (Android `setComposingRegion` semantics).
465/// Returns true if a text field was focused and received the event.
466pub fn dispatch_ime_set_composing_region(start_bytes: usize, end_bytes: usize) -> bool {
467    crate::render_state::with_text_field_focus(|state| {
468        state.dispatch_ime_set_composing_region(start_bytes, end_bytes)
469    })
470}
471
472/// Moves the focused field's selection/caret to `[start_bytes, end_bytes)`
473/// without editing text (Android `setSelection` semantics; the path Gboard's
474/// spacebar-swipe uses to scrub the cursor).
475/// Returns true if a text field was focused and received the event.
476pub fn dispatch_ime_set_selection(start_bytes: usize, end_bytes: usize) -> bool {
477    crate::render_state::with_text_field_focus(|state| {
478        state.dispatch_ime_set_selection(start_bytes, end_bytes)
479    })
480}
481
482/// Returns a snapshot of the focused text field's editable state for
483/// platform IMEs, or `None` when no field is focused (or the handler does
484/// not expose its state).
485pub fn focused_editor_state() -> Option<ImeEditorState> {
486    crate::render_state::with_text_field_focus(|state| state.focused_editor_state())
487}
488
489/// Window-space caret geometry of the focused field (see [`ImeCaretGeometry`]),
490/// or `None` when no field is focused or it exposes no geometry.
491pub fn focused_caret_geometry() -> Option<ImeCaretGeometry> {
492    crate::render_state::with_text_field_focus(|state| state.focused_caret_geometry())
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    // Mock handler for testing
500    struct MockHandler;
501    impl FocusedTextFieldHandler for MockHandler {
502        fn handle_key(&self, _: &KeyEvent) -> bool {
503            false
504        }
505        fn insert_text(&self, _: &str) {}
506        fn delete_surrounding(&self, _: usize, _: usize) {}
507        fn copy_selection(&self) -> Option<String> {
508            None
509        }
510        fn cut_selection(&self) -> Option<String> {
511            None
512        }
513        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
514    }
515
516    fn mock_handler() -> Rc<dyn FocusedTextFieldHandler> {
517        Rc::new(MockHandler)
518    }
519
520    // A handler that knows which node draws its caret, like the production
521    // text field handler does.
522    struct NodeBackedHandler(cranpose_core::NodeId);
523    impl FocusedTextFieldHandler for NodeBackedHandler {
524        fn node_id(&self) -> Option<cranpose_core::NodeId> {
525            Some(self.0)
526        }
527        fn handle_key(&self, _: &KeyEvent) -> bool {
528            false
529        }
530        fn insert_text(&self, _: &str) {}
531        fn delete_surrounding(&self, _: usize, _: usize) {}
532        fn copy_selection(&self) -> Option<String> {
533            None
534        }
535        fn cut_selection(&self) -> Option<String> {
536            None
537        }
538        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
539    }
540
541    /// Caret and focus state live outside the draw-observation system, so a
542    /// focus transition must name the stale nodes itself: both the field
543    /// losing the caret and the field gaining it get a scoped draw repass —
544    /// never a whole-scene rebuild.
545    #[test]
546    fn focus_transitions_schedule_scoped_draw_repasses_on_both_fields() {
547        let _app_context = crate::render_state::app_context_test_scope();
548        let _ = crate::render_state::take_draw_repass_nodes();
549
550        let first = Rc::new(RefCell::new(false));
551        request_focus(first.clone(), Rc::new(NodeBackedHandler(7)), 0);
552        assert!(
553            crate::render_state::take_draw_repass_nodes().contains(&7),
554            "gaining focus must re-record the gaining field's draws"
555        );
556
557        let second = Rc::new(RefCell::new(false));
558        request_focus(second.clone(), Rc::new(NodeBackedHandler(9)), 0);
559        let repasses = crate::render_state::take_draw_repass_nodes();
560        assert!(
561            repasses.contains(&7) && repasses.contains(&9),
562            "a focus hand-off must re-record both fields, got {repasses:?}"
563        );
564
565        clear_focus();
566        assert!(
567            crate::render_state::take_draw_repass_nodes().contains(&9),
568            "losing focus must re-record the field that had the caret"
569        );
570    }
571
572    /// A caret blink flip repaints exactly one node. The tick schedules a
573    /// scoped draw repass on the focused field so the frame takes the
574    /// ordinary dirty-node path.
575    #[test]
576    fn a_blink_transition_schedules_a_scoped_repass_on_the_focused_field() {
577        let _app_context = crate::render_state::app_context_test_scope();
578        let focus = Rc::new(RefCell::new(false));
579        request_focus(focus.clone(), Rc::new(NodeBackedHandler(21)), 0);
580        let _ = crate::render_state::take_draw_repass_nodes();
581
582        let past_interval = web_time::Instant::now()
583            + crate::cursor_animation::CursorAnimationState::BLINK_INTERVAL
584            + std::time::Duration::from_millis(1);
585        assert!(
586            crate::cursor_animation::tick_cursor_blink_at(past_interval),
587            "the tick past the interval must flip visibility"
588        );
589        assert!(
590            crate::render_state::take_draw_repass_nodes().contains(&21),
591            "the flip must re-record the focused field's draws"
592        );
593        clear_focus();
594    }
595
596    #[test]
597    fn request_focus_sets_flag() {
598        let _app_context = crate::render_state::app_context_test_scope();
599        let focus = Rc::new(RefCell::new(false));
600        request_focus(focus.clone(), mock_handler(), 0);
601        assert!(*focus.borrow());
602        clear_focus();
603    }
604
605    #[test]
606    fn request_focus_clears_previous() {
607        let _app_context = crate::render_state::app_context_test_scope();
608        let focus1 = Rc::new(RefCell::new(false));
609        let focus2 = Rc::new(RefCell::new(false));
610
611        request_focus(focus1.clone(), mock_handler(), 0);
612        assert!(*focus1.borrow());
613
614        request_focus(focus2.clone(), mock_handler(), 0);
615        assert!(!*focus1.borrow()); // First should be unfocused
616        assert!(*focus2.borrow()); // Second should be focused
617        clear_focus();
618    }
619
620    #[test]
621    fn clear_focus_unfocuses_current() {
622        let _app_context = crate::render_state::app_context_test_scope();
623        let focus = Rc::new(RefCell::new(false));
624        request_focus(focus.clone(), mock_handler(), 0);
625        assert!(*focus.borrow());
626
627        clear_focus();
628        assert!(!*focus.borrow());
629    }
630
631    #[derive(Default)]
632    struct DispatchRecordingHandler {
633        key_count: Cell<usize>,
634        insert_count: Cell<usize>,
635        delete_count: Cell<usize>,
636        copy_count: Cell<usize>,
637        cut_count: Cell<usize>,
638        preedit_count: Cell<usize>,
639        last_delete: Cell<Option<(usize, usize)>>,
640    }
641
642    impl DispatchRecordingHandler {
643        fn bump(cell: &Cell<usize>) {
644            cell.set(cell.get() + 1);
645        }
646
647        fn total_calls(&self) -> usize {
648            self.key_count.get()
649                + self.insert_count.get()
650                + self.delete_count.get()
651                + self.copy_count.get()
652                + self.cut_count.get()
653                + self.preedit_count.get()
654        }
655    }
656
657    impl FocusedTextFieldHandler for DispatchRecordingHandler {
658        fn handle_key(&self, _: &KeyEvent) -> bool {
659            Self::bump(&self.key_count);
660            true
661        }
662
663        fn insert_text(&self, _: &str) {
664            Self::bump(&self.insert_count);
665        }
666
667        fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize) {
668            Self::bump(&self.delete_count);
669            self.last_delete.set(Some((before_bytes, after_bytes)));
670        }
671
672        fn copy_selection(&self) -> Option<String> {
673            Self::bump(&self.copy_count);
674            Some("copy".to_string())
675        }
676
677        fn cut_selection(&self) -> Option<String> {
678            Self::bump(&self.cut_count);
679            Some("cut".to_string())
680        }
681
682        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {
683            Self::bump(&self.preedit_count);
684        }
685    }
686
687    #[test]
688    fn dispatch_delete_surrounding_calls_handler() {
689        let _app_context = crate::render_state::app_context_test_scope();
690        let focus = Rc::new(RefCell::new(false));
691        let handler = Rc::new(DispatchRecordingHandler::default());
692
693        request_focus(Rc::clone(&focus), handler.clone(), 0);
694        assert!(dispatch_delete_surrounding(3, 1));
695        assert_eq!(handler.last_delete.get(), Some((3, 1)));
696
697        clear_focus();
698    }
699
700    #[test]
701    fn dispatch_clears_stale_focus_owner_before_invoking_handler() {
702        let _app_context = crate::render_state::app_context_test_scope();
703        let handler = Rc::new(DispatchRecordingHandler::default());
704
705        {
706            let focus = Rc::new(RefCell::new(false));
707            request_focus(Rc::clone(&focus), handler.clone(), 0);
708            assert!(has_focused_field());
709        }
710
711        let key_event = KeyEvent::key_down(crate::key_event::KeyCode::A, "a");
712
713        assert!(!dispatch_key_event(&key_event));
714        assert!(!dispatch_paste("stale paste"));
715        assert!(!dispatch_delete_surrounding(2, 1));
716        assert_eq!(dispatch_copy(), None);
717        assert_eq!(dispatch_cut(), None);
718        assert!(!dispatch_ime_preedit("preedit", Some((1, 1))));
719        assert!(!has_focused_field());
720        assert_eq!(
721            handler.total_calls(),
722            0,
723            "stale focused-field handlers must not receive input"
724        );
725    }
726
727    #[test]
728    fn stale_focus_cleanup_after_hidden_blink_does_not_reenter_the_focus_registry_borrow() {
729        let _app_context = crate::render_state::app_context_test_scope();
730
731        let focus = Rc::new(RefCell::new(false));
732        request_focus(focus.clone(), Rc::new(NodeBackedHandler(3)), 0);
733
734        let past_interval = web_time::Instant::now()
735            + crate::cursor_animation::CursorAnimationState::BLINK_INTERVAL
736            + std::time::Duration::from_millis(1);
737        assert!(
738            crate::cursor_animation::tick_cursor_blink_at(past_interval),
739            "the blink must already have toggled to hidden, matching the real \
740             timing where the bug's stop_cursor_blink() call is a visibility \
741             change and therefore reaches invalidate_focused_caret()"
742        );
743
744        drop(focus);
745
746        assert!(
747            !has_focused_field(),
748            "a field dropped without clear_focus must read back as unfocused \
749             instead of panicking on a reentrant borrow of focused_field"
750        );
751    }
752
753    #[test]
754    fn stale_focus_cleanup_repasses_the_node_that_lost_its_caret() {
755        let _app_context = crate::render_state::app_context_test_scope();
756        let _ = crate::render_state::take_draw_repass_nodes();
757
758        let focus = Rc::new(RefCell::new(false));
759        request_focus(focus.clone(), Rc::new(NodeBackedHandler(11)), 0);
760        let _ = crate::render_state::take_draw_repass_nodes();
761
762        drop(focus);
763
764        assert!(!has_focused_field());
765        assert!(
766            crate::render_state::take_draw_repass_nodes().contains(&11),
767            "discovering a stale field lazily must repass its node just like \
768             an explicit clear_focus does, or its caret is left stale on screen"
769        );
770    }
771
772    #[derive(Default)]
773    struct KeyboardProbe {
774        calls: RefCell<Vec<&'static str>>,
775    }
776
777    impl crate::text_input_session::PlatformTextInputHandler for KeyboardProbe {
778        fn show_keyboard(&self) {
779            self.calls.borrow_mut().push("show");
780        }
781
782        fn hide_keyboard(&self) {
783            self.calls.borrow_mut().push("hide");
784        }
785    }
786
787    #[test]
788    fn focus_transitions_drive_platform_keyboard() {
789        let _app_context = crate::render_state::app_context_test_scope();
790        let keyboard = Rc::new(KeyboardProbe::default());
791        crate::text_input_session::set_platform_text_input_handler(keyboard.clone());
792
793        let focus = Rc::new(RefCell::new(false));
794        request_focus(focus.clone(), mock_handler(), 0);
795        assert_eq!(*keyboard.calls.borrow(), vec!["show"]);
796
797        // Tapping the (already focused) field again must re-request the
798        // keyboard: the user may have dismissed it with the back gesture.
799        request_focus(focus, mock_handler(), 0);
800        assert_eq!(*keyboard.calls.borrow(), vec!["show", "show"]);
801
802        clear_focus();
803        assert_eq!(*keyboard.calls.borrow(), vec!["show", "show", "hide"]);
804    }
805
806    #[test]
807    fn stale_focus_detection_hides_platform_keyboard() {
808        let _app_context = crate::render_state::app_context_test_scope();
809        let keyboard = Rc::new(KeyboardProbe::default());
810        crate::text_input_session::set_platform_text_input_handler(keyboard.clone());
811
812        {
813            let focus = Rc::new(RefCell::new(false));
814            request_focus(focus, mock_handler(), 0);
815            // The focused field's Rc is dropped here (field removed from the
816            // composition without an explicit clear_focus).
817        }
818
819        assert!(!has_focused_field());
820        assert_eq!(*keyboard.calls.borrow(), vec!["show", "hide"]);
821
822        // Repeated stale checks must not re-hide.
823        assert!(!has_focused_field());
824        assert_eq!(*keyboard.calls.borrow(), vec!["show", "hide"]);
825    }
826
827    #[test]
828    fn text_field_focus_is_scoped_by_app_context() {
829        let _app_context = crate::render_state::app_context_test_scope();
830        let first = crate::render_state::AppContext::new_with_density(1.0);
831        let second = crate::render_state::AppContext::new_with_density(1.0);
832        let first_focus = Rc::new(RefCell::new(false));
833        let second_focus = Rc::new(RefCell::new(false));
834
835        first.enter(|| {
836            request_focus(first_focus.clone(), mock_handler(), 0);
837            assert!(has_focused_field());
838            assert!(*first_focus.borrow());
839        });
840
841        second.enter(|| {
842            assert!(!has_focused_field());
843            request_focus(second_focus.clone(), mock_handler(), 0);
844            assert!(has_focused_field());
845            assert!(*second_focus.borrow());
846        });
847
848        first.enter(|| {
849            assert!(has_focused_field());
850            assert!(*first_focus.borrow());
851            clear_focus();
852            assert!(!has_focused_field());
853            assert!(!*first_focus.borrow());
854        });
855
856        second.enter(|| {
857            assert!(has_focused_field());
858            assert!(*second_focus.borrow());
859            clear_focus();
860        });
861    }
862}