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
43pub(crate) struct PlatformTextInputState {
44    handler: RefCell<Option<Rc<dyn PlatformTextInputHandler>>>,
45    keyboard_requested: Cell<bool>,
46}
47
48impl PlatformTextInputState {
49    pub(crate) fn new() -> Self {
50        Self {
51            handler: RefCell::new(None),
52            keyboard_requested: Cell::new(false),
53        }
54    }
55
56    fn set_handler(&self, handler: Option<Rc<dyn PlatformTextInputHandler>>) {
57        *self.handler.borrow_mut() = handler;
58        self.keyboard_requested.set(false);
59    }
60
61    fn handler(&self) -> Option<Rc<dyn PlatformTextInputHandler>> {
62        self.handler.borrow().clone()
63    }
64}
65
66/// Installs the platform soft-keyboard handler for the current app context.
67///
68/// Replaces any previously installed handler. Must be called inside an app
69/// context (platform runtimes go through
70/// `AppShell::set_platform_text_input`).
71pub fn set_platform_text_input_handler(handler: Rc<dyn PlatformTextInputHandler>) {
72    crate::render_state::with_text_input_session(|state| state.set_handler(Some(handler)));
73}
74
75/// Removes the installed platform soft-keyboard handler, if any.
76pub fn clear_platform_text_input_handler() {
77    crate::render_state::with_text_input_session(|state| state.set_handler(None));
78}
79
80pub(crate) fn notify_text_input_focus_gained() {
81    let handler = crate::render_state::with_text_input_session(|state| {
82        let handler = state.handler();
83        if handler.is_some() {
84            state.keyboard_requested.set(true);
85        }
86        handler
87    });
88    if let Some(handler) = handler {
89        handler.show_keyboard();
90    }
91}
92
93pub(crate) fn notify_text_input_focus_lost() {
94    let handler = crate::render_state::with_text_input_session(|state| {
95        if !state.keyboard_requested.replace(false) {
96            return None;
97        }
98        state.handler()
99    });
100    if let Some(handler) = handler {
101        handler.hide_keyboard();
102    }
103}
104
105/// Notifies the framework that the host app was paused (backgrounded — e.g.
106/// Android `onPause`).
107///
108/// Any outstanding soft-keyboard request is withdrawn and the platform is told
109/// to hide its keyboard, clearing the "keyboard shown" state so it cannot
110/// survive into the next resume. Without this, a platform that remembers the
111/// last editor view (Android's `InputMethodManager`) re-shows the keyboard when
112/// the app returns to the foreground even though the framework no longer has a
113/// focused field. Gated on an outstanding request, so it is a no-op when the
114/// keyboard was not showing.
115pub fn notify_app_paused() {
116    notify_text_input_focus_lost();
117}
118
119/// Notifies the framework that the host app resumed (foregrounded — e.g.
120/// Android `onResume`).
121///
122/// The soft keyboard is **never** auto-shown on resume, even when a text field
123/// is still focused. A warm resume (return from HOME / task switch / back-exit
124/// then relaunch) restores the process with the field's focus and caret intact,
125/// but the framework must not resurrect the keyboard for it: the platform's
126/// `InputMethodManager` remembers the last editor and would otherwise pop the
127/// keyboard back open on its own. The user brings it back by tapping the field
128/// (which re-requests it through `notify_text_input_focus_gained`).
129///
130/// Always returns `false` so the platform runtime force-hides the OS-restored
131/// keyboard. Pruning stale focus here keeps the keyboard-request bookkeeping
132/// consistent (a focused-but-detached field is dropped and its outstanding
133/// request withdrawn) without ever calling `show`.
134pub fn notify_app_resumed() -> bool {
135    let _ = crate::text_field_focus::has_focused_field();
136    false
137}
138
139#[cfg(test)]
140mod tests {
141    use std::cell::RefCell as StdRefCell;
142
143    use super::*;
144
145    #[derive(Default)]
146    struct RecordingHandler {
147        calls: StdRefCell<Vec<&'static str>>,
148    }
149
150    impl PlatformTextInputHandler for RecordingHandler {
151        fn show_keyboard(&self) {
152            self.calls.borrow_mut().push("show");
153        }
154
155        fn hide_keyboard(&self) {
156            self.calls.borrow_mut().push("hide");
157        }
158    }
159
160    fn install_recording_handler() -> Rc<RecordingHandler> {
161        let handler = Rc::new(RecordingHandler::default());
162        set_platform_text_input_handler(handler.clone());
163        handler
164    }
165
166    #[test]
167    fn focus_gained_shows_keyboard() {
168        let _app_context = crate::render_state::app_context_test_scope();
169        let handler = install_recording_handler();
170
171        notify_text_input_focus_gained();
172
173        assert_eq!(*handler.calls.borrow(), vec!["show"]);
174    }
175
176    #[test]
177    fn focus_lost_hides_keyboard_once() {
178        let _app_context = crate::render_state::app_context_test_scope();
179        let handler = install_recording_handler();
180
181        notify_text_input_focus_gained();
182        notify_text_input_focus_lost();
183        notify_text_input_focus_lost();
184
185        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
186    }
187
188    #[test]
189    fn focus_lost_without_prior_show_is_not_forwarded() {
190        let _app_context = crate::render_state::app_context_test_scope();
191        let handler = install_recording_handler();
192
193        notify_text_input_focus_lost();
194
195        assert!(handler.calls.borrow().is_empty());
196    }
197
198    #[test]
199    fn repeated_focus_gain_reshows_keyboard() {
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        notify_text_input_focus_gained();
205
206        assert_eq!(*handler.calls.borrow(), vec!["show", "show"]);
207    }
208
209    #[test]
210    fn notifications_without_handler_are_noops() {
211        let _app_context = crate::render_state::app_context_test_scope();
212        notify_text_input_focus_gained();
213        notify_text_input_focus_lost();
214    }
215
216    #[test]
217    fn clearing_handler_stops_notifications() {
218        let _app_context = crate::render_state::app_context_test_scope();
219        let handler = install_recording_handler();
220
221        notify_text_input_focus_gained();
222        clear_platform_text_input_handler();
223        notify_text_input_focus_lost();
224
225        assert_eq!(*handler.calls.borrow(), vec!["show"]);
226    }
227
228    struct NoopFocusHandler;
229    impl crate::text_field_focus::FocusedTextFieldHandler for NoopFocusHandler {
230        fn handle_key(&self, _: &crate::key_event::KeyEvent) -> bool {
231            false
232        }
233        fn insert_text(&self, _: &str) {}
234        fn delete_surrounding(&self, _: usize, _: usize) {}
235        fn copy_selection(&self) -> Option<String> {
236            None
237        }
238        fn cut_selection(&self) -> Option<String> {
239            None
240        }
241        fn set_composition(&self, _: &str, _: Option<(usize, usize)>) {}
242    }
243
244    fn focus_a_field() -> Rc<std::cell::RefCell<bool>> {
245        let focus = Rc::new(std::cell::RefCell::new(false));
246        crate::text_field_focus::request_focus(Rc::clone(&focus), Rc::new(NoopFocusHandler), 0);
247        focus
248    }
249
250    #[test]
251    fn resume_without_a_focused_field_does_not_show_the_keyboard() {
252        let _app_context = crate::render_state::app_context_test_scope();
253        let handler = install_recording_handler();
254
255        notify_text_input_focus_gained();
256        notify_app_paused();
257        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
258
259        assert!(!notify_app_resumed());
260        assert_eq!(
261            *handler.calls.borrow(),
262            vec!["show", "hide"],
263            "resume with no focused field must not re-show the keyboard"
264        );
265    }
266
267    #[test]
268    fn resume_never_reshows_the_keyboard_even_for_a_focused_field() {
269        let _app_context = crate::render_state::app_context_test_scope();
270        let handler = install_recording_handler();
271
272        let focus = focus_a_field();
273        assert_eq!(*handler.calls.borrow(), vec!["show"]);
274
275        notify_app_paused();
276        assert_eq!(*handler.calls.borrow(), vec!["show", "hide"]);
277
278        assert!(!notify_app_resumed());
279        assert_eq!(
280            *handler.calls.borrow(),
281            vec!["show", "hide"],
282            "resume must leave the keyboard hidden"
283        );
284
285        crate::text_field_focus::request_focus(Rc::clone(&focus), Rc::new(NoopFocusHandler), 0);
286        assert_eq!(
287            *handler.calls.borrow(),
288            vec!["show", "hide", "show"],
289            "tapping the field after resume re-shows the keyboard"
290        );
291
292        crate::text_field_focus::clear_focus();
293    }
294
295    #[test]
296    fn cold_start_with_no_focus_does_not_show_the_keyboard() {
297        let _app_context = crate::render_state::app_context_test_scope();
298        let handler = install_recording_handler();
299
300        assert!(
301            !notify_app_resumed(),
302            "a launch with no focused field must not re-open the keyboard"
303        );
304        assert!(
305            handler.calls.borrow().is_empty(),
306            "no platform show/hide should be requested for an unfocused cold start"
307        );
308    }
309
310    #[test]
311    fn pause_is_a_noop_when_the_keyboard_was_not_showing() {
312        let _app_context = crate::render_state::app_context_test_scope();
313        let handler = install_recording_handler();
314
315        notify_app_paused();
316        assert!(
317            handler.calls.borrow().is_empty(),
318            "pausing without a shown keyboard must not call the platform"
319        );
320    }
321}