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}