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    /// The node focus and semantics know the field by: the decoration box
64    /// around a decorated field, else [`Self::node_id`].
65    fn target_node(&self) -> Option<cranpose_core::NodeId> {
66        self.node_id()
67    }
68    /// Handle a key event. Returns true if consumed.
69    fn handle_key(&self, event: &KeyEvent) -> bool;
70    /// Insert pasted text.
71    fn insert_text(&self, text: &str);
72    /// Delete text surrounding the cursor or selection.
73    fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize);
74    /// Copy current selection. Returns None if nothing selected.
75    fn copy_selection(&self) -> Option<String>;
76    /// Cut current selection (copy + delete). Returns None if nothing selected.
77    fn cut_selection(&self) -> Option<String>;
78    /// Selects all the field's text (contextual-menu "Select all").
79    fn select_all(&self) {}
80    /// Set IME composition (preedit) state.
81    /// - `text`: The composition text being typed (empty string to clear,
82    ///   which *deletes* the preedit text)
83    /// - `cursor`: Optional cursor position within composition (start, end)
84    fn set_composition(&self, text: &str, cursor: Option<(usize, usize)>);
85    /// Finish the active composition, keeping the composed text as regular
86    /// committed text (Android `finishComposingText` semantics). No-op when
87    /// no composition is active.
88    fn finish_composition(&self) {}
89    /// Mark existing text as the composing region without changing it
90    /// (Android `setComposingRegion` semantics, used by autocorrect to
91    /// re-compose an already committed word). Offsets are UTF-8 bytes;
92    /// implementations clamp them to valid character boundaries.
93    fn set_composing_region(&self, start_bytes: usize, end_bytes: usize) {
94        let _ = (start_bytes, end_bytes);
95    }
96    /// Move the selection/caret to `[start_bytes, end_bytes)` without editing
97    /// text (Android `InputConnection.setSelection` semantics). Used by
98    /// Gboard's spacebar-swipe cursor control, which scrubs the caret by
99    /// repeatedly setting the selection. Offsets are UTF-8 bytes; implementations
100    /// clamp them to valid character boundaries.
101    fn set_selection(&self, start_bytes: usize, end_bytes: usize) {
102        let _ = (start_bytes, end_bytes);
103    }
104    /// Snapshot of the current editable state for platform IMEs
105    /// (`InputConnection` text queries, session seeding). `None` when the
106    /// handler cannot expose its state.
107    fn editor_state(&self) -> Option<ImeEditorState> {
108        None
109    }
110    /// Window-space caret geometry (see [`ImeCaretGeometry`]) for coordinate-based
111    /// platform text input. `None` when the handler cannot expose it (e.g. the
112    /// field has not been laid out yet).
113    fn caret_geometry(&self) -> Option<ImeCaretGeometry> {
114        None
115    }
116}
117
118pub(crate) struct TextFieldFocusState {
119    focused_field: RefCell<Option<Weak<RefCell<bool>>>>,
120    focused_handler: RefCell<Option<Rc<dyn FocusedTextFieldHandler>>>,
121    focused_modal_depth: Cell<usize>,
122}
123
124impl TextFieldFocusState {
125    pub(crate) fn new() -> Self {
126        Self {
127            focused_field: RefCell::new(None),
128            focused_handler: RefCell::new(None),
129            focused_modal_depth: Cell::new(0),
130        }
131    }
132
133    fn request_focus(
134        &self,
135        is_focused: Rc<RefCell<bool>>,
136        handler: Rc<dyn FocusedTextFieldHandler>,
137        modal_depth: usize,
138    ) {
139        let mut current = self.focused_field.borrow_mut();
140
141        if let Some(ref weak) = *current
142            && let Some(old_focused) = weak.upgrade()
143        {
144            *old_focused.borrow_mut() = false;
145        }
146
147        *is_focused.borrow_mut() = true;
148        *current = Some(Rc::downgrade(&is_focused));
149        *self.focused_handler.borrow_mut() = Some(handler);
150        self.focused_modal_depth.set(modal_depth);
151    }
152
153    fn clear_focus(&self) {
154        let mut current = self.focused_field.borrow_mut();
155
156        if let Some(ref weak) = *current
157            && let Some(focused) = weak.upgrade()
158        {
159            *focused.borrow_mut() = false;
160        }
161
162        *current = None;
163        *self.focused_handler.borrow_mut() = None;
164        self.focused_modal_depth.set(0);
165    }
166
167    fn focused_at_depth(&self, depth: usize) -> bool {
168        self.has_focused_field() && self.focused_modal_depth.get() == depth
169    }
170
171    fn has_focused_field(&self) -> bool {
172        if self.focused_field_is_live() {
173            return true;
174        }
175        self.clear_stale_focus();
176        false
177    }
178
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    fn clear_stale_focus(&self) {
187        let stale_node = self
188            .focused_handler
189            .borrow()
190            .as_ref()
191            .and_then(|handler| handler.node_id());
192        let had_entry = self.focused_field.borrow_mut().take().is_some();
193        if !had_entry {
194            return;
195        }
196        self.focused_handler.borrow_mut().take();
197        if let Some(node_id) = stale_node {
198            crate::schedule_draw_repass(node_id);
199        }
200        crate::cursor_animation::stop_cursor_blink();
201    }
202
203    fn focused_handler(&self) -> Option<Rc<dyn FocusedTextFieldHandler>> {
204        if !self.has_focused_field() {
205            return None;
206        }
207        self.focused_handler.borrow().as_ref().cloned()
208    }
209
210    fn dispatch_key_event(&self, event: &KeyEvent) -> bool {
211        if let Some(handler) = self.focused_handler() {
212            handler.handle_key(event)
213        } else {
214            false
215        }
216    }
217
218    fn dispatch_paste(&self, text: &str) -> bool {
219        if let Some(handler) = self.focused_handler() {
220            handler.insert_text(text);
221            true
222        } else {
223            false
224        }
225    }
226
227    fn dispatch_delete_surrounding(&self, before_bytes: usize, after_bytes: usize) -> bool {
228        if let Some(handler) = self.focused_handler() {
229            handler.delete_surrounding(before_bytes, after_bytes);
230            true
231        } else {
232            false
233        }
234    }
235
236    fn dispatch_copy(&self) -> Option<String> {
237        self.focused_handler()
238            .and_then(|handler| handler.copy_selection())
239    }
240
241    fn dispatch_cut(&self) -> Option<String> {
242        self.focused_handler()
243            .and_then(|handler| handler.cut_selection())
244    }
245
246    fn dispatch_select_all(&self) -> bool {
247        if let Some(handler) = self.focused_handler() {
248            handler.select_all();
249            true
250        } else {
251            false
252        }
253    }
254
255    fn dispatch_ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> bool {
256        if let Some(handler) = self.focused_handler() {
257            handler.set_composition(text, cursor);
258            true
259        } else {
260            false
261        }
262    }
263
264    fn dispatch_ime_finish_composing(&self) -> bool {
265        if let Some(handler) = self.focused_handler() {
266            handler.finish_composition();
267            true
268        } else {
269            false
270        }
271    }
272
273    fn dispatch_ime_set_composing_region(&self, start_bytes: usize, end_bytes: usize) -> bool {
274        if let Some(handler) = self.focused_handler() {
275            handler.set_composing_region(start_bytes, end_bytes);
276            true
277        } else {
278            false
279        }
280    }
281
282    fn dispatch_ime_set_selection(&self, start_bytes: usize, end_bytes: usize) -> bool {
283        if let Some(handler) = self.focused_handler() {
284            handler.set_selection(start_bytes, end_bytes);
285            true
286        } else {
287            false
288        }
289    }
290
291    fn focused_editor_state(&self) -> Option<ImeEditorState> {
292        self.focused_handler()
293            .and_then(|handler| handler.editor_state())
294    }
295
296    fn focused_caret_geometry(&self) -> Option<ImeCaretGeometry> {
297        self.focused_handler()
298            .and_then(|handler| handler.caret_geometry())
299    }
300}
301
302/// Requests focus for a text field composed at [`crate::modal::local_modal_depth`]
303/// `modal_depth`.
304///
305/// Refused (a no-op) when `modal_depth` is shallower than the modal depth
306/// that is open right now — a field behind an open dialog must not be
307/// able to steal focus from it. A field inside the innermost dialog (or
308/// outside any dialog, while none is open) has `modal_depth` equal to the
309/// current modal depth and is granted focus normally.
310///
311/// If another text field was previously focused, it will be unfocused first.
312/// The provided `is_focused` handle should be the field's focus state.
313/// The handler is stored for O(1) key dispatch.
314pub fn request_focus(
315    is_focused: Rc<RefCell<bool>>,
316    handler: Rc<dyn FocusedTextFieldHandler>,
317    modal_depth: usize,
318) {
319    if modal_depth < crate::modal::current_modal_depth() {
320        return;
321    }
322
323    let previous_field = focused_field_node();
324    let gaining_field = handler.node_id();
325
326    crate::render_state::with_text_field_focus(|state| {
327        state.request_focus(is_focused, handler, modal_depth);
328    });
329
330    for node_id in [previous_field, gaining_field].into_iter().flatten() {
331        crate::schedule_draw_repass(node_id);
332    }
333
334    crate::cursor_animation::start_cursor_blink();
335
336    crate::text_input_session::notify_text_input_focus_gained();
337
338    crate::request_render_invalidation();
339}
340
341pub(crate) fn clear_focus_for_closed_modal(depth: usize) {
342    let owns_focus =
343        crate::render_state::with_text_field_focus(|state| state.focused_at_depth(depth));
344    if owns_focus {
345        clear_focus();
346    }
347}
348
349/// Clears focus from the currently focused text field.
350pub fn clear_focus() {
351    let previous = focused_field_node();
352    let previous_target = focused_field_target();
353    if let Some(node_id) = previous {
354        crate::schedule_draw_repass(node_id);
355    }
356    crate::render_state::with_text_field_focus(TextFieldFocusState::clear_focus);
357    if previous_target.is_some() && crate::focus_dispatch::active_focus_target() == previous_target
358    {
359        crate::focus_dispatch::clear_active_focus();
360    }
361
362    crate::cursor_animation::stop_cursor_blink();
363
364    crate::text_input_session::notify_text_input_focus_lost();
365
366    crate::request_render_invalidation();
367}
368
369/// Returns the composition node of the currently focused text field, if a
370/// field is focused and its handler knows its node.
371pub fn focused_field_node() -> Option<cranpose_core::NodeId> {
372    crate::render_state::with_text_field_focus(|state| {
373        state
374            .focused_handler()
375            .and_then(|handler| handler.node_id())
376    })
377}
378
379/// Returns the node focus and semantics know the focused text field by (see
380/// [`FocusedTextFieldHandler::target_node`]), if a field is focused.
381pub fn focused_field_target() -> Option<cranpose_core::NodeId> {
382    crate::render_state::with_text_field_focus(|state| {
383        state
384            .focused_handler()
385            .and_then(|handler| handler.target_node())
386    })
387}
388
389/// Returns true if any text field currently has focus.
390/// Checks weak ref liveness and clears stale focus state.
391pub fn has_focused_field() -> bool {
392    let has_focus =
393        crate::render_state::with_text_field_focus(TextFieldFocusState::has_focused_field);
394    if !has_focus {
395        crate::text_input_session::notify_text_input_focus_lost();
396    }
397    has_focus
398}
399
400/// Dispatches a key event to the focused text field. Returns true if consumed.
401/// O(1) operation using stored handler.
402pub fn dispatch_key_event(event: &KeyEvent) -> bool {
403    crate::render_state::with_text_field_focus(|state| state.dispatch_key_event(event))
404}
405
406/// Inserts text into the focused text field (paste operation).
407/// O(1) operation using stored handler.
408pub fn dispatch_paste(text: &str) -> bool {
409    crate::render_state::with_text_field_focus(|state| state.dispatch_paste(text))
410}
411
412/// Deletes text surrounding the cursor or selection.
413/// O(1) operation using stored handler.
414pub fn dispatch_delete_surrounding(before_bytes: usize, after_bytes: usize) -> bool {
415    crate::render_state::with_text_field_focus(|state| {
416        state.dispatch_delete_surrounding(before_bytes, after_bytes)
417    })
418}
419
420/// Copies selection from focused text field.
421/// O(1) operation using stored handler.
422pub fn dispatch_copy() -> Option<String> {
423    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_copy)
424}
425
426/// Cuts selection from focused text field (copy + delete).
427/// O(1) operation using stored handler.
428pub fn dispatch_cut() -> Option<String> {
429    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_cut)
430}
431
432/// Selects all the text in the focused text field (contextual-menu "Select
433/// all"). Returns true if a text field was focused.
434pub fn dispatch_select_all() -> bool {
435    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_select_all)
436}
437
438/// Dispatches IME preedit (composition) state to the focused text field.
439/// O(1) operation using stored handler.
440/// Returns true if a text field was focused and received the event.
441pub fn dispatch_ime_preedit(text: &str, cursor: Option<(usize, usize)>) -> bool {
442    crate::render_state::with_text_field_focus(|state| state.dispatch_ime_preedit(text, cursor))
443}
444
445/// Finishes the active composition in the focused text field, keeping the
446/// composed text (Android `finishComposingText` semantics).
447/// Returns true if a text field was focused and received the event.
448pub fn dispatch_ime_finish_composing() -> bool {
449    crate::render_state::with_text_field_focus(TextFieldFocusState::dispatch_ime_finish_composing)
450}
451
452/// Marks existing text in the focused field as the composing region without
453/// changing it (Android `setComposingRegion` semantics).
454/// Returns true if a text field was focused and received the event.
455pub fn dispatch_ime_set_composing_region(start_bytes: usize, end_bytes: usize) -> bool {
456    crate::render_state::with_text_field_focus(|state| {
457        state.dispatch_ime_set_composing_region(start_bytes, end_bytes)
458    })
459}
460
461/// Moves the focused field's selection/caret to `[start_bytes, end_bytes)`
462/// without editing text (Android `setSelection` semantics; the path Gboard's
463/// spacebar-swipe uses to scrub the cursor).
464/// Returns true if a text field was focused and received the event.
465pub fn dispatch_ime_set_selection(start_bytes: usize, end_bytes: usize) -> bool {
466    crate::render_state::with_text_field_focus(|state| {
467        state.dispatch_ime_set_selection(start_bytes, end_bytes)
468    })
469}
470
471/// Returns a snapshot of the focused text field's editable state for
472/// platform IMEs, or `None` when no field is focused (or the handler does
473/// not expose its state).
474pub fn focused_editor_state() -> Option<ImeEditorState> {
475    crate::render_state::with_text_field_focus(TextFieldFocusState::focused_editor_state)
476}
477
478/// Window-space caret geometry of the focused field (see [`ImeCaretGeometry`]),
479/// or `None` when no field is focused or it exposes no geometry.
480pub fn focused_caret_geometry() -> Option<ImeCaretGeometry> {
481    crate::render_state::with_text_field_focus(TextFieldFocusState::focused_caret_geometry)
482}
483
484#[cfg(test)]
485#[path = "tests/text_field_focus_tests.rs"]
486mod tests;