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/// The soft keyboard is **never** auto-shown on resume, even when a text field
139/// is still focused. A warm resume (return from HOME / task switch / back-exit
140/// then relaunch) restores the process with the field's focus and caret intact,
141/// but the framework must not resurrect the keyboard for it: the platform's
142/// `InputMethodManager` remembers the last editor and would otherwise pop the
143/// keyboard back open on its own. The user brings it back by tapping the field
144/// (which re-requests it through [`notify_text_input_focus_gained`]).
145///
146/// Always returns `false` so the platform runtime force-hides the OS-restored
147/// keyboard. Pruning stale focus here keeps the keyboard-request bookkeeping
148/// consistent (a focused-but-detached field is dropped and its outstanding
149/// request withdrawn) without ever calling `show`.
150pub fn notify_app_resumed() -> bool {
151    // Prune stale focus (a detached field withdraws its keyboard request), but
152    // never re-request the keyboard: resume must leave it hidden.
153    let _ = crate::text_field_focus::has_focused_field();
154    false
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::cell::RefCell as StdRefCell;
161
162    #[derive(Default)]
163    struct RecordingHandler {
164        calls: StdRefCell<Vec<&'static str>>,
165    }
166
167    impl PlatformTextInputHandler for RecordingHandler {
168        fn show_keyboard(&self) {
169            self.calls.borrow_mut().push("show");
170        }
171
172        fn hide_keyboard(&self) {
173            self.calls.borrow_mut().push("hide");
174        }
175    }
176
177    fn install_recording_handler() -> Rc<RecordingHandler> {
178        let handler = Rc::new(RecordingHandler::default());
179        set_platform_text_input_handler(handler.clone());
180        handler
181    }
182
183    #[test]
184    fn focus_gained_shows_keyboard() {
185        let _app_context = crate::render_state::app_context_test_scope();
186        let handler = install_recording_handler();
187
188        notify_text_input_focus_gained();
189
190        assert_eq!(*handler.calls.borrow(), vec!["show"]);
191    }
192
193    #[test]
194    fn focus_lost_hides_keyboard_once() {
195        let _app_context = crate::render_state::app_context_test_scope();
196        let handler = install_recording_handler();
197
198        notify_text_input_focus_gained();
199        notify_text_input_focus_lost();
200        // Stale-focus detection can fire "lost" repeatedly; only one hide
201        // should reach the platform.
202        notify_text_input_focus_lost();
203
204        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
205    }
206
207    #[test]
208    fn focus_lost_without_prior_show_is_not_forwarded() {
209        let _app_context = crate::render_state::app_context_test_scope();
210        let handler = install_recording_handler();
211
212        notify_text_input_focus_lost();
213
214        assert!(handler.calls.borrow().is_empty());
215    }
216
217    #[test]
218    fn repeated_focus_gain_reshows_keyboard() {
219        let _app_context = crate::render_state::app_context_test_scope();
220        let handler = install_recording_handler();
221
222        // Tapping an already-focused field must re-request the keyboard: the
223        // user may have dismissed it without the framework knowing.
224        notify_text_input_focus_gained();
225        notify_text_input_focus_gained();
226
227        assert_eq!(*handler.calls.borrow(), vec!["show", "show"]);
228    }
229
230    #[test]
231    fn notifications_without_handler_are_noops() {
232        let _app_context = crate::render_state::app_context_test_scope();
233        notify_text_input_focus_gained();
234        notify_text_input_focus_lost();
235    }
236
237    #[test]
238    fn clearing_handler_stops_notifications() {
239        let _app_context = crate::render_state::app_context_test_scope();
240        let handler = install_recording_handler();
241
242        notify_text_input_focus_gained();
243        clear_platform_text_input_handler();
244        notify_text_input_focus_lost();
245
246        assert_eq!(*handler.calls.borrow(), vec!["show"]);
247    }
248
249    struct NoopFocusHandler;
250    impl crate::text_field_focus::FocusedTextFieldHandler for NoopFocusHandler {
251        fn handle_key(&self, _: &crate::key_event::KeyEvent) -> bool {
252            false
253        }
254        fn insert_text(&self, _: &str) {}
255        fn delete_surrounding(&self, _: usize, _: usize) {}
256        fn copy_selection(&self) -> Option<String> {
257            None
258        }
259        fn cut_selection(&self) -> Option<String> {
260            None
261        }
262        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
263    }
264
265    fn focus_a_field() -> Rc<std::cell::RefCell<bool>> {
266        let focus = Rc::new(std::cell::RefCell::new(false));
267        crate::text_field_focus::request_focus(Rc::clone(&focus), Rc::new(NoopFocusHandler));
268        focus
269    }
270
271    #[test]
272    fn resume_without_a_focused_field_does_not_show_the_keyboard() {
273        let _app_context = crate::render_state::app_context_test_scope();
274        let handler = install_recording_handler();
275
276        // The soft keyboard was shown earlier and the app was paused (hidden).
277        // Coming back to the foreground with nothing focused must NOT re-show
278        // it — this is the reported "keyboard re-opens on resume" bug.
279        notify_text_input_focus_gained();
280        notify_app_paused();
281        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
282
283        assert!(!notify_app_resumed());
284        assert_eq!(
285            *handler.calls.borrow(),
286            vec!["show", "hide"],
287            "resume with no focused field must not re-show the keyboard"
288        );
289    }
290
291    #[test]
292    fn resume_never_reshows_the_keyboard_even_for_a_focused_field() {
293        let _app_context = crate::render_state::app_context_test_scope();
294        let handler = install_recording_handler();
295
296        // A focused field shows the keyboard.
297        let focus = focus_a_field();
298        assert_eq!(*handler.calls.borrow(), vec!["show"]);
299
300        // Pause withdraws it.
301        notify_app_paused();
302        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
303
304        // Resume must NOT bring it back even though the field is still focused
305        // (the reported warm-resume bug): the keyboard stays hidden until the
306        // user taps the field again. Resume reports `false` so the platform
307        // force-hides the OS-restored keyboard.
308        assert!(!notify_app_resumed());
309        assert_eq!(
310            *handler.calls.borrow(),
311            vec!["show", "hide"],
312            "resume must leave the keyboard hidden"
313        );
314
315        // Tapping the still-focused field re-requests the keyboard.
316        crate::text_field_focus::request_focus(Rc::clone(&focus), Rc::new(NoopFocusHandler));
317        assert_eq!(
318            *handler.calls.borrow(),
319            vec!["show", "hide", "show"],
320            "tapping the field after resume re-shows the keyboard"
321        );
322
323        crate::text_field_focus::clear_focus();
324    }
325
326    #[test]
327    fn cold_start_with_no_focus_does_not_show_the_keyboard() {
328        // Bug 5: on a fresh launch the platform runtime calls `notify_app_resumed`
329        // once and only re-shows the keyboard when a field is actually focused.
330        // With nothing focused (a brand-new app context, e.g. a cold restart) it
331        // must return false and never call `show`, so the runtime knows to force
332        // the OS-restored keyboard hidden instead.
333        let _app_context = crate::render_state::app_context_test_scope();
334        let handler = install_recording_handler();
335
336        assert!(
337            !notify_app_resumed(),
338            "a launch with no focused field must not re-open the keyboard"
339        );
340        assert!(
341            handler.calls.borrow().is_empty(),
342            "no platform show/hide should be requested for an unfocused cold start"
343        );
344    }
345
346    #[test]
347    fn pause_is_a_noop_when_the_keyboard_was_not_showing() {
348        let _app_context = crate::render_state::app_context_test_scope();
349        let handler = install_recording_handler();
350
351        notify_app_paused();
352        assert!(
353            handler.calls.borrow().is_empty(),
354            "pausing without a shown keyboard must not call the platform"
355        );
356    }
357}