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/// Notifies the framework that the host app was paused (backgrounded — e.g.
119/// Android `onPause`).
120///
121/// Any outstanding soft-keyboard request is withdrawn and the platform is told
122/// to hide its keyboard, clearing the "keyboard shown" state so it cannot
123/// survive into the next resume. Without this, a platform that remembers the
124/// last editor view (Android's `InputMethodManager`) re-shows the keyboard when
125/// the app returns to the foreground even though the framework no longer has a
126/// focused field. Gated on an outstanding request, so it is a no-op when the
127/// keyboard was not showing.
128pub fn notify_app_paused() {
129    // Same effect as losing focus, but semantically "the app went away": the
130    // field may still be focused, we simply must not leave a shown-keyboard
131    // request dangling across the pause.
132    notify_text_input_focus_lost();
133}
134
135/// Notifies the framework that the host app resumed (foregrounded — e.g.
136/// Android `onResume`).
137///
138/// Re-requests the soft keyboard **only** when a text field is actually
139/// focused, so an app that comes back with nothing focused does not have the
140/// keyboard pop open. Returns whether the keyboard was re-requested.
141pub fn notify_app_resumed() -> bool {
142    // Read focus first (this also prunes stale focus), then decide — do not
143    // hold the session borrow across the focus query.
144    if !crate::text_field_focus::has_focused_field() {
145        return false;
146    }
147    notify_text_input_focus_gained();
148    true
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::cell::RefCell as StdRefCell;
155
156    #[derive(Default)]
157    struct RecordingHandler {
158        calls: StdRefCell<Vec<&'static str>>,
159    }
160
161    impl PlatformTextInputHandler for RecordingHandler {
162        fn show_keyboard(&self) {
163            self.calls.borrow_mut().push("show");
164        }
165
166        fn hide_keyboard(&self) {
167            self.calls.borrow_mut().push("hide");
168        }
169    }
170
171    fn install_recording_handler() -> Rc<RecordingHandler> {
172        let handler = Rc::new(RecordingHandler::default());
173        set_platform_text_input_handler(handler.clone());
174        handler
175    }
176
177    #[test]
178    fn focus_gained_shows_keyboard() {
179        let _app_context = crate::render_state::app_context_test_scope();
180        let handler = install_recording_handler();
181
182        notify_text_input_focus_gained();
183
184        assert_eq!(*handler.calls.borrow(), vec!["show"]);
185    }
186
187    #[test]
188    fn focus_lost_hides_keyboard_once() {
189        let _app_context = crate::render_state::app_context_test_scope();
190        let handler = install_recording_handler();
191
192        notify_text_input_focus_gained();
193        notify_text_input_focus_lost();
194        // Stale-focus detection can fire "lost" repeatedly; only one hide
195        // should reach the platform.
196        notify_text_input_focus_lost();
197
198        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
199    }
200
201    #[test]
202    fn focus_lost_without_prior_show_is_not_forwarded() {
203        let _app_context = crate::render_state::app_context_test_scope();
204        let handler = install_recording_handler();
205
206        notify_text_input_focus_lost();
207
208        assert!(handler.calls.borrow().is_empty());
209    }
210
211    #[test]
212    fn repeated_focus_gain_reshows_keyboard() {
213        let _app_context = crate::render_state::app_context_test_scope();
214        let handler = install_recording_handler();
215
216        // Tapping an already-focused field must re-request the keyboard: the
217        // user may have dismissed it without the framework knowing.
218        notify_text_input_focus_gained();
219        notify_text_input_focus_gained();
220
221        assert_eq!(*handler.calls.borrow(), vec!["show", "show"]);
222    }
223
224    #[test]
225    fn notifications_without_handler_are_noops() {
226        let _app_context = crate::render_state::app_context_test_scope();
227        notify_text_input_focus_gained();
228        notify_text_input_focus_lost();
229    }
230
231    #[test]
232    fn clearing_handler_stops_notifications() {
233        let _app_context = crate::render_state::app_context_test_scope();
234        let handler = install_recording_handler();
235
236        notify_text_input_focus_gained();
237        clear_platform_text_input_handler();
238        notify_text_input_focus_lost();
239
240        assert_eq!(*handler.calls.borrow(), vec!["show"]);
241    }
242
243    struct NoopFocusHandler;
244    impl crate::text_field_focus::FocusedTextFieldHandler for NoopFocusHandler {
245        fn handle_key(&self, _: &crate::key_event::KeyEvent) -> bool {
246            false
247        }
248        fn insert_text(&self, _: &str) {}
249        fn delete_surrounding(&self, _: usize, _: usize) {}
250        fn copy_selection(&self) -> Option<String> {
251            None
252        }
253        fn cut_selection(&self) -> Option<String> {
254            None
255        }
256        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
257    }
258
259    fn focus_a_field() -> Rc<std::cell::RefCell<bool>> {
260        let focus = Rc::new(std::cell::RefCell::new(false));
261        crate::text_field_focus::request_focus(Rc::clone(&focus), Rc::new(NoopFocusHandler));
262        focus
263    }
264
265    #[test]
266    fn resume_without_a_focused_field_does_not_show_the_keyboard() {
267        let _app_context = crate::render_state::app_context_test_scope();
268        let handler = install_recording_handler();
269
270        // The soft keyboard was shown earlier and the app was paused (hidden).
271        // Coming back to the foreground with nothing focused must NOT re-show
272        // it — this is the reported "keyboard re-opens on resume" bug.
273        notify_text_input_focus_gained();
274        notify_app_paused();
275        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
276
277        assert!(!notify_app_resumed());
278        assert_eq!(
279            *handler.calls.borrow(),
280            vec!["show", "hide"],
281            "resume with no focused field must not re-show the keyboard"
282        );
283    }
284
285    #[test]
286    fn pause_hides_and_resume_restores_the_keyboard_for_a_focused_field() {
287        let _app_context = crate::render_state::app_context_test_scope();
288        let handler = install_recording_handler();
289
290        // A focused field shows the keyboard.
291        let _focus = focus_a_field();
292        assert_eq!(*handler.calls.borrow(), vec!["show"]);
293
294        // Pause withdraws it.
295        notify_app_paused();
296        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
297
298        // Resume with the field still focused brings it back.
299        assert!(notify_app_resumed());
300        assert_eq!(*handler.calls.borrow(), vec!["show", "hide", "show"]);
301
302        crate::text_field_focus::clear_focus();
303    }
304
305    #[test]
306    fn pause_is_a_noop_when_the_keyboard_was_not_showing() {
307        let _app_context = crate::render_state::app_context_test_scope();
308        let handler = install_recording_handler();
309
310        notify_app_paused();
311        assert!(
312            handler.calls.borrow().is_empty(),
313            "pausing without a shown keyboard must not call the platform"
314        );
315    }
316}