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}