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::{
26    cell::{Cell, RefCell},
27    rc::Rc,
28};
29
30/// Callbacks a platform installs to control its on-screen keyboard.
31///
32/// Implementations must be idempotent: `show_keyboard` may be invoked while
33/// the keyboard is already visible (every tap on a text field re-requests it)
34/// and `hide_keyboard` may race a keyboard the user already dismissed.
35pub trait PlatformTextInputHandler {
36    /// A text field gained focus; the platform should show its soft keyboard.
37    fn show_keyboard(&self);
38    /// No text field is focused anymore; the platform should hide its soft
39    /// keyboard.
40    fn hide_keyboard(&self);
41}
42
43/// Per-app-context storage for the installed platform handler.
44pub(crate) struct PlatformTextInputState {
45    handler: RefCell<Option<Rc<dyn PlatformTextInputHandler>>>,
46    /// Whether the framework has asked the platform to show the keyboard and
47    /// not yet asked it to hide. Gates `hide_keyboard` so repeated
48    /// "no field focused" checks forward at most one hide per shown keyboard.
49    keyboard_requested: Cell<bool>,
50}
51
52impl PlatformTextInputState {
53    pub(crate) fn new() -> Self {
54        Self {
55            handler: RefCell::new(None),
56            keyboard_requested: Cell::new(false),
57        }
58    }
59
60    fn set_handler(&self, handler: Option<Rc<dyn PlatformTextInputHandler>>) {
61        *self.handler.borrow_mut() = handler;
62        self.keyboard_requested.set(false);
63    }
64
65    fn handler(&self) -> Option<Rc<dyn PlatformTextInputHandler>> {
66        self.handler.borrow().clone()
67    }
68}
69
70/// Installs the platform soft-keyboard handler for the current app context.
71///
72/// Replaces any previously installed handler. Must be called inside an app
73/// context (platform runtimes go through
74/// `AppShell::set_platform_text_input`).
75pub fn set_platform_text_input_handler(handler: Rc<dyn PlatformTextInputHandler>) {
76    crate::render_state::with_text_input_session(|state| state.set_handler(Some(handler)));
77}
78
79/// Removes the installed platform soft-keyboard handler, if any.
80pub fn clear_platform_text_input_handler() {
81    crate::render_state::with_text_input_session(|state| state.set_handler(None));
82}
83
84/// Notifies the platform that a text field gained focus.
85///
86/// Called by the text-field focus manager after the focus transition has been
87/// recorded, so the platform callback observes consistent focus state.
88pub(crate) fn notify_text_input_focus_gained() {
89    let handler = crate::render_state::with_text_input_session(|state| {
90        let handler = state.handler();
91        if handler.is_some() {
92            state.keyboard_requested.set(true);
93        }
94        handler
95    });
96    // Invoke outside the state borrow: the platform callback may re-enter the
97    // framework (e.g. logging hooks or JNI callbacks that pump events).
98    if let Some(handler) = handler {
99        handler.show_keyboard();
100    }
101}
102
103/// Notifies the platform that no text field is focused anymore.
104///
105/// Forwarded to the platform only when a keyboard request is outstanding, so
106/// this is safe to call repeatedly (the focus manager calls it from lazy
107/// stale-focus detection on every key event without a focused field).
108pub(crate) fn notify_text_input_focus_lost() {
109    let handler = crate::render_state::with_text_input_session(|state| {
110        if !state.keyboard_requested.replace(false) {
111            return None;
112        }
113        state.handler()
114    });
115    if let Some(handler) = handler {
116        handler.hide_keyboard();
117    }
118}
119
120/// Notifies the framework that the host app was paused (backgrounded — e.g.
121/// Android `onPause`).
122///
123/// Any outstanding soft-keyboard request is withdrawn and the platform is told
124/// to hide its keyboard, clearing the "keyboard shown" state so it cannot
125/// survive into the next resume. Without this, a platform that remembers the
126/// last editor view (Android's `InputMethodManager`) re-shows the keyboard when
127/// the app returns to the foreground even though the framework no longer has a
128/// focused field. Gated on an outstanding request, so it is a no-op when the
129/// keyboard was not showing.
130pub fn notify_app_paused() {
131    // Same effect as losing focus, but semantically "the app went away": the
132    // field may still be focused, we simply must not leave a shown-keyboard
133    // request dangling across the pause.
134    notify_text_input_focus_lost();
135}
136
137/// Notifies the framework that the host app resumed (foregrounded — e.g.
138/// Android `onResume`).
139///
140/// The soft keyboard is **never** auto-shown on resume, even when a text field
141/// is still focused. A warm resume (return from HOME / task switch / back-exit
142/// then relaunch) restores the process with the field's focus and caret intact,
143/// but the framework must not resurrect the keyboard for it: the platform's
144/// `InputMethodManager` remembers the last editor and would otherwise pop the
145/// keyboard back open on its own. The user brings it back by tapping the field
146/// (which re-requests it through `notify_text_input_focus_gained`).
147///
148/// Always returns `false` so the platform runtime force-hides the OS-restored
149/// keyboard. Pruning stale focus here keeps the keyboard-request bookkeeping
150/// consistent (a focused-but-detached field is dropped and its outstanding
151/// request withdrawn) without ever calling `show`.
152pub fn notify_app_resumed() -> bool {
153    // Prune stale focus (a detached field withdraws its keyboard request), but
154    // never re-request the keyboard: resume must leave it hidden.
155    let _ = crate::text_field_focus::has_focused_field();
156    false
157}
158
159#[cfg(test)]
160mod tests {
161    use std::cell::RefCell as StdRefCell;
162
163    use super::*;
164
165    #[derive(Default)]
166    struct RecordingHandler {
167        calls: StdRefCell<Vec<&'static str>>,
168    }
169
170    impl PlatformTextInputHandler for RecordingHandler {
171        fn show_keyboard(&self) {
172            self.calls.borrow_mut().push("show");
173        }
174
175        fn hide_keyboard(&self) {
176            self.calls.borrow_mut().push("hide");
177        }
178    }
179
180    fn install_recording_handler() -> Rc<RecordingHandler> {
181        let handler = Rc::new(RecordingHandler::default());
182        set_platform_text_input_handler(handler.clone());
183        handler
184    }
185
186    #[test]
187    fn focus_gained_shows_keyboard() {
188        let _app_context = crate::render_state::app_context_test_scope();
189        let handler = install_recording_handler();
190
191        notify_text_input_focus_gained();
192
193        assert_eq!(*handler.calls.borrow(), vec!["show"]);
194    }
195
196    #[test]
197    fn focus_lost_hides_keyboard_once() {
198        let _app_context = crate::render_state::app_context_test_scope();
199        let handler = install_recording_handler();
200
201        notify_text_input_focus_gained();
202        notify_text_input_focus_lost();
203        // Stale-focus detection can fire "lost" repeatedly; only one hide
204        // should reach the platform.
205        notify_text_input_focus_lost();
206
207        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
208    }
209
210    #[test]
211    fn focus_lost_without_prior_show_is_not_forwarded() {
212        let _app_context = crate::render_state::app_context_test_scope();
213        let handler = install_recording_handler();
214
215        notify_text_input_focus_lost();
216
217        assert!(handler.calls.borrow().is_empty());
218    }
219
220    #[test]
221    fn repeated_focus_gain_reshows_keyboard() {
222        let _app_context = crate::render_state::app_context_test_scope();
223        let handler = install_recording_handler();
224
225        // Tapping an already-focused field must re-request the keyboard: the
226        // user may have dismissed it without the framework knowing.
227        notify_text_input_focus_gained();
228        notify_text_input_focus_gained();
229
230        assert_eq!(*handler.calls.borrow(), vec!["show", "show"]);
231    }
232
233    #[test]
234    fn notifications_without_handler_are_noops() {
235        let _app_context = crate::render_state::app_context_test_scope();
236        notify_text_input_focus_gained();
237        notify_text_input_focus_lost();
238    }
239
240    #[test]
241    fn clearing_handler_stops_notifications() {
242        let _app_context = crate::render_state::app_context_test_scope();
243        let handler = install_recording_handler();
244
245        notify_text_input_focus_gained();
246        clear_platform_text_input_handler();
247        notify_text_input_focus_lost();
248
249        assert_eq!(*handler.calls.borrow(), vec!["show"]);
250    }
251
252    struct NoopFocusHandler;
253    impl crate::text_field_focus::FocusedTextFieldHandler for NoopFocusHandler {
254        fn handle_key(&self, _: &crate::key_event::KeyEvent) -> bool {
255            false
256        }
257        fn insert_text(&self, _: &str) {}
258        fn delete_surrounding(&self, _: usize, _: usize) {}
259        fn copy_selection(&self) -> Option<String> {
260            None
261        }
262        fn cut_selection(&self) -> Option<String> {
263            None
264        }
265        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
266    }
267
268    fn focus_a_field() -> Rc<std::cell::RefCell<bool>> {
269        let focus = Rc::new(std::cell::RefCell::new(false));
270        crate::text_field_focus::request_focus(Rc::clone(&focus), Rc::new(NoopFocusHandler), 0);
271        focus
272    }
273
274    #[test]
275    fn resume_without_a_focused_field_does_not_show_the_keyboard() {
276        let _app_context = crate::render_state::app_context_test_scope();
277        let handler = install_recording_handler();
278
279        // The soft keyboard was shown earlier and the app was paused (hidden).
280        // Coming back to the foreground with nothing focused must NOT re-show
281        // it — this is the reported "keyboard re-opens on resume" bug.
282        notify_text_input_focus_gained();
283        notify_app_paused();
284        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
285
286        assert!(!notify_app_resumed());
287        assert_eq!(
288            *handler.calls.borrow(),
289            vec!["show", "hide"],
290            "resume with no focused field must not re-show the keyboard"
291        );
292    }
293
294    #[test]
295    fn resume_never_reshows_the_keyboard_even_for_a_focused_field() {
296        let _app_context = crate::render_state::app_context_test_scope();
297        let handler = install_recording_handler();
298
299        // A focused field shows the keyboard.
300        let focus = focus_a_field();
301        assert_eq!(*handler.calls.borrow(), vec!["show"]);
302
303        // Pause withdraws it.
304        notify_app_paused();
305        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
306
307        // Resume must NOT bring it back even though the field is still focused
308        // (the reported warm-resume bug): the keyboard stays hidden until the
309        // user taps the field again. Resume reports `false` so the platform
310        // force-hides the OS-restored keyboard.
311        assert!(!notify_app_resumed());
312        assert_eq!(
313            *handler.calls.borrow(),
314            vec!["show", "hide"],
315            "resume must leave the keyboard hidden"
316        );
317
318        // Tapping the still-focused field re-requests the keyboard.
319        crate::text_field_focus::request_focus(Rc::clone(&focus), Rc::new(NoopFocusHandler), 0);
320        assert_eq!(
321            *handler.calls.borrow(),
322            vec!["show", "hide", "show"],
323            "tapping the field after resume re-shows the keyboard"
324        );
325
326        crate::text_field_focus::clear_focus();
327    }
328
329    #[test]
330    fn cold_start_with_no_focus_does_not_show_the_keyboard() {
331        // Bug 5: on a fresh launch the platform runtime calls `notify_app_resumed`
332        // once and only re-shows the keyboard when a field is actually focused.
333        // With nothing focused (a brand-new app context, e.g. a cold restart) it
334        // must return false and never call `show`, so the runtime knows to force
335        // the OS-restored keyboard hidden instead.
336        let _app_context = crate::render_state::app_context_test_scope();
337        let handler = install_recording_handler();
338
339        assert!(
340            !notify_app_resumed(),
341            "a launch with no focused field must not re-open the keyboard"
342        );
343        assert!(
344            handler.calls.borrow().is_empty(),
345            "no platform show/hide should be requested for an unfocused cold start"
346        );
347    }
348
349    #[test]
350    fn pause_is_a_noop_when_the_keyboard_was_not_showing() {
351        let _app_context = crate::render_state::app_context_test_scope();
352        let handler = install_recording_handler();
353
354        notify_app_paused();
355        assert!(
356            handler.calls.borrow().is_empty(),
357            "pausing without a shown keyboard must not call the platform"
358        );
359    }
360}