Skip to main content

cranpose_ui/
text_input_session.rs

1//! Platform text-input session: soft-keyboard visibility hooks.
2//!
3//! Platforms with an on-screen keyboard (Android, iOS, some Linux shells)
4//! install a [`PlatformTextInputHandler`] so the framework can tell them when
5//! editable text gains or loses focus. The text-field focus manager
6//! ([`crate::text_field_focus`]) fires these notifications:
7//!
8//! - a text field acquired focus → [`notify_text_input_focus_gained`] →
9//!   `show_keyboard`
10//! - focus was explicitly cleared, or the focused field left the composition →
11//!   [`notify_text_input_focus_lost`] → `hide_keyboard`
12//!
13//! `show_keyboard` fires on *every* focus request, including taps on an
14//! already-focused field. This is intentional: the user may have dismissed the
15//! keyboard (e.g. Android back gesture) without the framework knowing, and
16//! tapping the field again must bring it back. Platform show/hide calls are
17//! expected to be idempotent. `hide_keyboard` is only forwarded when the
18//! framework previously requested the keyboard, so repeated stale-focus checks
19//! do not spam the platform.
20//!
21//! The handler is stored per [`AppContext`](crate::render_state::AppContext),
22//! like the focus state itself, so multiple app instances in one process do
23//! not observe each other's keyboards.
24
25use std::cell::{Cell, RefCell};
26use std::rc::Rc;
27
28/// Callbacks a platform installs to control its on-screen keyboard.
29///
30/// Implementations must be idempotent: `show_keyboard` may be invoked while
31/// the keyboard is already visible (every tap on a text field re-requests it)
32/// and `hide_keyboard` may race a keyboard the user already dismissed.
33pub trait PlatformTextInputHandler {
34    /// A text field gained focus; the platform should show its soft keyboard.
35    fn show_keyboard(&self);
36    /// No text field is focused anymore; the platform should hide its soft
37    /// keyboard.
38    fn hide_keyboard(&self);
39}
40
41/// Per-app-context storage for the installed platform handler.
42pub(crate) struct PlatformTextInputState {
43    handler: RefCell<Option<Rc<dyn PlatformTextInputHandler>>>,
44    /// Whether the framework has asked the platform to show the keyboard and
45    /// not yet asked it to hide. Gates `hide_keyboard` so repeated
46    /// "no field focused" checks forward at most one hide per shown keyboard.
47    keyboard_requested: Cell<bool>,
48}
49
50impl PlatformTextInputState {
51    pub(crate) fn new() -> Self {
52        Self {
53            handler: RefCell::new(None),
54            keyboard_requested: Cell::new(false),
55        }
56    }
57
58    fn set_handler(&self, handler: Option<Rc<dyn PlatformTextInputHandler>>) {
59        *self.handler.borrow_mut() = handler;
60        self.keyboard_requested.set(false);
61    }
62
63    fn handler(&self) -> Option<Rc<dyn PlatformTextInputHandler>> {
64        self.handler.borrow().clone()
65    }
66}
67
68/// Installs the platform soft-keyboard handler for the current app context.
69///
70/// Replaces any previously installed handler. Must be called inside an app
71/// context (platform runtimes go through
72/// `AppShell::set_platform_text_input`).
73pub fn set_platform_text_input_handler(handler: Rc<dyn PlatformTextInputHandler>) {
74    crate::render_state::with_text_input_session(|state| state.set_handler(Some(handler)));
75}
76
77/// Removes the installed platform soft-keyboard handler, if any.
78pub fn clear_platform_text_input_handler() {
79    crate::render_state::with_text_input_session(|state| state.set_handler(None));
80}
81
82/// Notifies the platform that a text field gained focus.
83///
84/// Called by the text-field focus manager after the focus transition has been
85/// recorded, so the platform callback observes consistent focus state.
86pub(crate) fn notify_text_input_focus_gained() {
87    let handler = crate::render_state::with_text_input_session(|state| {
88        let handler = state.handler();
89        if handler.is_some() {
90            state.keyboard_requested.set(true);
91        }
92        handler
93    });
94    // Invoke outside the state borrow: the platform callback may re-enter the
95    // framework (e.g. logging hooks or JNI callbacks that pump events).
96    if let Some(handler) = handler {
97        handler.show_keyboard();
98    }
99}
100
101/// Notifies the platform that no text field is focused anymore.
102///
103/// Forwarded to the platform only when a keyboard request is outstanding, so
104/// this is safe to call repeatedly (the focus manager calls it from lazy
105/// stale-focus detection on every key event without a focused field).
106pub(crate) fn notify_text_input_focus_lost() {
107    let handler = crate::render_state::with_text_input_session(|state| {
108        if !state.keyboard_requested.replace(false) {
109            return None;
110        }
111        state.handler()
112    });
113    if let Some(handler) = handler {
114        handler.hide_keyboard();
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::cell::RefCell as StdRefCell;
122
123    #[derive(Default)]
124    struct RecordingHandler {
125        calls: StdRefCell<Vec<&'static str>>,
126    }
127
128    impl PlatformTextInputHandler for RecordingHandler {
129        fn show_keyboard(&self) {
130            self.calls.borrow_mut().push("show");
131        }
132
133        fn hide_keyboard(&self) {
134            self.calls.borrow_mut().push("hide");
135        }
136    }
137
138    fn install_recording_handler() -> Rc<RecordingHandler> {
139        let handler = Rc::new(RecordingHandler::default());
140        set_platform_text_input_handler(handler.clone());
141        handler
142    }
143
144    #[test]
145    fn focus_gained_shows_keyboard() {
146        let _app_context = crate::render_state::app_context_test_scope();
147        let handler = install_recording_handler();
148
149        notify_text_input_focus_gained();
150
151        assert_eq!(*handler.calls.borrow(), vec!["show"]);
152    }
153
154    #[test]
155    fn focus_lost_hides_keyboard_once() {
156        let _app_context = crate::render_state::app_context_test_scope();
157        let handler = install_recording_handler();
158
159        notify_text_input_focus_gained();
160        notify_text_input_focus_lost();
161        // Stale-focus detection can fire "lost" repeatedly; only one hide
162        // should reach the platform.
163        notify_text_input_focus_lost();
164
165        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
166    }
167
168    #[test]
169    fn focus_lost_without_prior_show_is_not_forwarded() {
170        let _app_context = crate::render_state::app_context_test_scope();
171        let handler = install_recording_handler();
172
173        notify_text_input_focus_lost();
174
175        assert!(handler.calls.borrow().is_empty());
176    }
177
178    #[test]
179    fn repeated_focus_gain_reshows_keyboard() {
180        let _app_context = crate::render_state::app_context_test_scope();
181        let handler = install_recording_handler();
182
183        // Tapping an already-focused field must re-request the keyboard: the
184        // user may have dismissed it without the framework knowing.
185        notify_text_input_focus_gained();
186        notify_text_input_focus_gained();
187
188        assert_eq!(*handler.calls.borrow(), vec!["show", "show"]);
189    }
190
191    #[test]
192    fn notifications_without_handler_are_noops() {
193        let _app_context = crate::render_state::app_context_test_scope();
194        notify_text_input_focus_gained();
195        notify_text_input_focus_lost();
196    }
197
198    #[test]
199    fn clearing_handler_stops_notifications() {
200        let _app_context = crate::render_state::app_context_test_scope();
201        let handler = install_recording_handler();
202
203        notify_text_input_focus_gained();
204        clear_platform_text_input_handler();
205        notify_text_input_focus_lost();
206
207        assert_eq!(*handler.calls.borrow(), vec!["show"]);
208    }
209}