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