agg_gui/widgets/on_screen_keyboard/mod.rs
1//! # On-screen software keyboard
2//!
3//! agg-gui's own touch-input keyboard. Replaces the native iOS / Android
4//! soft keyboard with one we control end-to-end so the user gets:
5//!
6//! - a consistent visual that matches the rest of the agg-gui app (no
7//! browser-chrome reflow, no surprise auto-correct rules, no native
8//! keyboard hiding the focused field at random),
9//! - taps that synthesize the same [`Event::KeyDown`] events a physical
10//! keyboard would produce (so [`TextField`](crate::widgets::TextField),
11//! [`TextArea`](crate::widgets::TextArea), and any future text-bearing
12//! widget Just Works),
13//! - per-OS chrome (iOS / Android / generic) that approximates the
14//! user's muscle memory.
15//!
16//! ## Architecture (follows the combo-popup pattern)
17//!
18//! - The keyboard is **not** a child widget in the tree. It lives in
19//! module-level thread-local state and is painted by [`App::paint`]
20//! after every other global overlay so it always sits on top.
21//! - Mouse / touch events pass through
22//! [`handle_software_keyboard_mouse_down`] /
23//! [`handle_software_keyboard_mouse_move`] /
24//! [`handle_software_keyboard_mouse_up`] *before* the normal hit-test
25//! path; the keyboard either consumes them (a key tap) or returns
26//! `false` so they continue to the widget tree.
27//! - Key taps push synthesized `(Key, Modifiers)` pairs into a queue;
28//! [`App`] drains the queue after each event handler and dispatches
29//! them through the normal [`App::on_key_down`] code path. The
30//! focused [`TextField`] receives `KeyDown { Key::Char('a') }` exactly
31//! like a physical key press.
32//! - Show / hide is driven by the focused widget — when the App's focus
33//! changes to a widget whose [`Widget::accepts_text_input`] returns
34//! `true`, the keyboard slides up. Losing focus slides it down.
35//! - The chrome style follows [`crate::input_profile::current_input_profile`]
36//! so an iPad and a Pixel see different keyboards from the same Rust
37//! binary.
38//!
39//! ## Scope of this first cut
40//!
41//! - Gboard-style US-QWERTY pages (see `docs/Android Keyboard/`):
42//! letters with a number row, `?123` symbols, `=\<` extended
43//! symbols, and a numeric pad for numeric fields.
44//! - Tap-to-type (no long-press, no hold-to-repeat, no predictive bar
45//! yet — the module is structured to grow into those without a
46//! rewrite).
47//! - Layout-driven painting via [`layouts::Layout`] so adding a new
48//! layer or layout is a data change, not a code change.
49
50use crate::draw_ctx::DrawCtx;
51use crate::event::{Key, Modifiers, MouseButton};
52use crate::geometry::{Point, Rect};
53use crate::input_profile::current_input_profile;
54
55pub mod events;
56pub mod key;
57pub mod layouts;
58mod paint;
59pub mod state;
60pub mod style;
61
62use events::push_synthetic_key;
63use layouts::{Layer, Layout};
64use state::{with_state_mut, with_state_ref};
65use style::Style;
66
67// ---------------------------------------------------------------------------
68// Public API
69// ---------------------------------------------------------------------------
70
71/// What kind of input the focused widget wants from the on-screen
72/// keyboard. Drives the initial layer the keyboard slides up into so
73/// numeric fields see the digit pad instead of the letter row — same
74/// hint browsers and native OSes derive from `<input type="number">` /
75/// `UIKeyboardType.numberPad`.
76///
77/// Independent of input-validation: a field set to [`Numeric`] still
78/// receives whatever the user actually types (the keyboard's mode-switch
79/// keys remain available). Pair with [`crate::widgets::TextField::with_char_filter`]
80/// if you also want to reject non-digits.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub enum KeyboardInputMode {
83 /// Regular text — opens the letter layer (or Shifted if the
84 /// auto-cap heuristic fires). The historical default.
85 #[default]
86 Text,
87 /// Digits + arithmetic — opens directly into
88 /// [`KeyboardLayer::NumPad`] so the user can start typing digits
89 /// without tapping the `?123` mode switch first.
90 Numeric,
91}
92
93/// Enable / disable the on-screen keyboard globally. Disabled keyboards
94/// never paint or capture events. The platform shell calls this once at
95/// startup; defaults to `false` so apps that haven't opted in (or
96/// desktop builds) see no behavior change.
97///
98/// Recommended pattern in a platform shell:
99/// ```ignore
100/// let profile = input_profile_from_hint(&user_agent, pointer_coarse);
101/// set_input_profile(profile);
102/// set_enabled(profile.is_mobile_touch());
103/// ```
104pub fn set_enabled(on: bool) {
105 with_state_mut(|s| s.enabled = on);
106}
107
108/// Read the global enabled flag.
109pub fn is_enabled() -> bool {
110 with_state_ref(|s| s.enabled)
111}
112
113/// Whether the keyboard is currently visible (visible-fraction > 0 in the
114/// slide animation). The host shell uses this to (a) skip the native
115/// keyboard hack, and (b) potentially reserve safe-area space.
116pub fn is_visible() -> bool {
117 with_state_ref(|s| s.visible_fraction() > 0.001)
118}
119
120/// Top edge of the keyboard panel in viewport coordinates (Y-up). When
121/// the keyboard is hidden this returns the viewport bottom (i.e. zero
122/// keyboard intrusion). Useful for the App layout to shrink the safe
123/// area so the focused widget doesn't sit under the keyboard.
124pub fn occluded_height(viewport_height: f64) -> f64 {
125 with_state_ref(|s| {
126 if !s.enabled {
127 return 0.0;
128 }
129 let target_h = s.last_panel_height.unwrap_or(0.0);
130 target_h * s.visible_fraction()
131 })
132 .min(viewport_height)
133}
134
135/// Height the keyboard panel WILL occupy when fully open, regardless
136/// of the current slide-animation state. Returned in logical pixels
137/// (Y-up); the panel sits at the bottom of the viewport so its top
138/// edge lies at `y = target_panel_height(...)`.
139///
140/// Computed deterministically from the active input profile + layer,
141/// so callers (notably the keyboard-aware focus auto-scroll) get a
142/// useful answer on the very first focus event — *before* the panel
143/// has ever painted. Falls back to the most-recent painted height
144/// when the layout subsystem isn't ready (no font / no profile);
145/// returns `0.0` when the keyboard is disabled, so call sites need
146/// no extra `is_enabled` check.
147pub fn target_panel_height(viewport_width: f64) -> f64 {
148 with_state_ref(|s| {
149 if !s.enabled {
150 return 0.0;
151 }
152 let style = Style::for_profile(current_input_profile());
153 let layer = s.current_layer;
154 let layout = Layout::for_layer(layer);
155 let computed = layout.compute_panel_height(viewport_width, &style);
156 // Fall back to the last painted height in the (theoretical)
157 // case where the layout function returns a degenerate 0 —
158 // keeps the auto-scroll robust even if a future profile ships
159 // an empty layout by mistake.
160 if computed > 0.0 {
161 computed
162 } else {
163 s.last_panel_height.unwrap_or(0.0)
164 }
165 })
166}
167
168/// Called by [`App`](crate::widget::App) when the focused widget changes.
169/// Causes the keyboard to slide up / down by retargeting the slide tween.
170///
171/// `existing_text` lets the keyboard apply the iOS-style auto-capitalize
172/// heuristic: if the field is empty when it gains focus, the first
173/// letter row starts in [`Layer::Shifted`] so the user's first tap
174/// produces an upper-case letter. After that initial tap the layer
175/// reverts to lowercase (one-shot shift), matching what every mobile
176/// OS does for sentence-start capitalization. `None` (no value
177/// available) is treated as "don't change the layer".
178///
179/// `mode` lets the focused widget opt into the numeric layer — e.g.
180/// a quantity field that wants the digit pad up first. When
181/// [`KeyboardInputMode::Numeric`] is passed the auto-cap heuristic is
182/// skipped and the keyboard opens on [`Layer::NumPad`].
183pub fn set_text_input_focused(focused: bool, existing_text: Option<&str>, mode: KeyboardInputMode) {
184 with_state_mut(|s| {
185 if !s.enabled {
186 return;
187 }
188 s.text_input_focused = focused;
189 let target = if focused { 1.0 } else { 0.0 };
190 s.slide.set_target(target);
191 if focused {
192 match mode {
193 KeyboardInputMode::Numeric => {
194 // Numeric fields skip the sentence-start heuristic;
195 // open directly on the digit pad. Caps-lock is also
196 // reset so a leftover shift toggle from a previous
197 // letter-mode field doesn't carry into the digits.
198 s.current_layer = Layer::NumPad;
199 s.caps_lock = false;
200 s.last_shift_tap = None;
201 }
202 KeyboardInputMode::Text => {
203 if let Some(text) = existing_text {
204 let last_non_space = text.trim_end().chars().last();
205 let sentence_start = match last_non_space {
206 None => true, // empty
207 Some(c) if c == '.' || c == '!' || c == '?' || c == '\n' => true,
208 _ => false,
209 };
210 s.current_layer = if sentence_start {
211 Layer::Shifted
212 } else {
213 Layer::Letters
214 };
215 }
216 }
217 }
218 }
219 crate::animation::request_draw();
220 });
221}
222
223/// Programmatic dismiss — used by the keyboard's close key, and by
224/// host code that wants to hide the keyboard.
225///
226/// Sets a one-shot `dismiss_requested` flag the App drains every
227/// event loop iteration via [`take_dismiss_request`] / `App::drain_keyboard_events`,
228/// which clears focus on the previously-focused text field. That
229/// `FocusLost` is what retargets the keyboard-aware lift back to 0
230/// so the tree slides down alongside the keyboard panel — without
231/// it the panel falls but the lifted tree stays parked above an
232/// empty band where the keyboard used to sit.
233pub fn dismiss() {
234 with_state_mut(|s| {
235 s.text_input_focused = false;
236 s.slide.set_target(0.0);
237 s.dismiss_requested = true;
238 crate::animation::request_draw();
239 });
240}
241
242/// Atomically read-and-clear the dismiss-request flag set by
243/// [`dismiss`]. Called once per event loop iteration by the App so
244/// the focused text field gets a `FocusLost` and the screen-lift
245/// tween retargets back to 0. Returns `true` if a dismiss was pending.
246pub fn take_dismiss_request() -> bool {
247 with_state_mut(|s| {
248 let pending = s.dismiss_requested;
249 s.dismiss_requested = false;
250 pending
251 })
252}
253
254/// `true` if the keyboard wants another frame this paint cycle (slide
255/// animation in flight, or a hold-to-repeat key is active). [`App::wants_draw`]
256/// consults this so the rAF / event loop keeps pumping while the
257/// keyboard has work to do.
258pub fn needs_draw() -> bool {
259 with_state_ref(|s| s.slide.is_animating() || s.key_repeat.is_some())
260}
261
262// ---------------------------------------------------------------------------
263// Paint
264// ---------------------------------------------------------------------------
265
266/// Paint the keyboard panel and its keys. Called by [`App::paint`] last,
267/// after all other global-overlay drains, so the keyboard always sits on
268/// top of normal content, combo popups, tooltips, and modal overlays.
269///
270/// `viewport` is the logical (pre-`device_scale`) viewport size — the
271/// caller is responsible for any `ctx.scale(device_scale, …)` save/restore
272/// wrap (mirrors how combo popups are drained).
273pub fn paint_software_keyboard(ctx: &mut dyn DrawCtx, viewport: crate::geometry::Size) {
274 // Advance the hold-to-repeat state machine first so it has a chance
275 // to fire before the next paint reuses cached key positions.
276 tick_key_repeat();
277
278 let visible_fraction = with_state_mut(|s| s.slide.tick());
279 if visible_fraction <= 0.001 {
280 // Hidden — also clear cached key hit-rects so a stale layout
281 // doesn't leak into the next show cycle.
282 with_state_mut(|s| s.last_painted_keys.clear());
283 return;
284 }
285
286 let style = Style::for_profile(current_input_profile());
287 let layer = with_state_ref(|s| s.current_layer);
288 let layout = Layout::for_layer(layer);
289
290 // Compute panel rect. The fully-extended height is determined by the
291 // layout (rows + paddings); we then slide it up from off-screen by
292 // (1 - visible_fraction) * height.
293 let panel_height = layout.compute_panel_height(viewport.width, &style);
294 let panel_width = viewport.width;
295 with_state_mut(|s| s.last_panel_height = Some(panel_height));
296
297 // Y-up coordinates: panel bottom edge sits at `bottom_y`, panel
298 // ranges [bottom_y, bottom_y + panel_height].
299 let hidden_offset = panel_height * (1.0 - visible_fraction);
300 let bottom_y = -hidden_offset;
301 let panel = Rect::new(0.0, bottom_y, panel_width, panel_height);
302
303 paint_panel_background(ctx, panel, &style);
304
305 // Lay out + paint keys, caching their hit rects for tap dispatch.
306 let painted_keys = layout.paint(ctx, panel, &style, layer);
307 with_state_mut(|s| s.last_painted_keys = painted_keys);
308}
309
310fn paint_panel_background(ctx: &mut dyn DrawCtx, panel: Rect, style: &Style) {
311 ctx.set_fill_color(style.panel_bg);
312 ctx.begin_path();
313 ctx.rect(panel.x, panel.y, panel.width, panel.height);
314 ctx.fill();
315
316 // Top accent line so the keyboard reads as a distinct surface from
317 // whatever the app is painting behind it.
318 ctx.set_stroke_color(style.panel_top_border);
319 ctx.set_line_width(1.0);
320 ctx.begin_path();
321 let top_y = panel.y + panel.height;
322 ctx.move_to(panel.x, top_y);
323 ctx.line_to(panel.x + panel.width, top_y);
324 ctx.stroke();
325}
326
327// ---------------------------------------------------------------------------
328// Pointer routing
329// ---------------------------------------------------------------------------
330
331/// `true` when the keyboard panel currently occupies `pos` and would
332/// consume an event there.
333pub fn contains_point(pos: Point) -> bool {
334 if !is_visible() {
335 return false;
336 }
337 with_state_ref(|s| {
338 let frac = s.slide.value();
339 if frac <= 0.001 {
340 return false;
341 }
342 let panel_height = s.last_panel_height.unwrap_or(0.0);
343 let panel_top = panel_height * frac;
344 // Panel occupies [0, panel_top] in Y-up viewport coords.
345 pos.y >= 0.0 && pos.y <= panel_top
346 })
347}
348
349/// Handle a pointer-down inside the keyboard. Returns `true` if consumed
350/// (the [`App`](crate::widget::App) skips its normal tree dispatch).
351pub fn handle_software_keyboard_mouse_down(
352 pos: Point,
353 button: MouseButton,
354 _modifiers: Modifiers,
355) -> bool {
356 if button != MouseButton::Left {
357 return contains_point(pos);
358 }
359 if !contains_point(pos) {
360 return false;
361 }
362 let hit = find_key_at(pos);
363 with_state_mut(|s| {
364 s.pressed_key_index = hit;
365 s.captured_pointer = true;
366 // Register a hold-to-repeat tracker if the pressed key supports
367 // it (currently Backspace only).
368 s.key_repeat = hit.and_then(|i| {
369 s.last_painted_keys.get(i).and_then(|k| match k.action {
370 key::KeyAction::Backspace => Some(state::KeyRepeatState {
371 key_index: i,
372 pressed_at: web_time::Instant::now(),
373 last_fired_at: None,
374 }),
375 _ => None,
376 })
377 });
378 });
379 if hit.is_some() {
380 crate::animation::request_draw();
381 }
382 true
383}
384
385/// Handle a pointer-move while the keyboard is interactive. Returns
386/// `true` if the keyboard wants to keep the pointer captured.
387pub fn handle_software_keyboard_mouse_move(pos: Point) -> bool {
388 let (captured, _) = with_state_ref(|s| (s.captured_pointer, s.pressed_key_index));
389 if !captured {
390 return false;
391 }
392 // Track hover for visual feedback on a drag inside the keyboard.
393 let new_hit = find_key_at(pos);
394 with_state_mut(|s| {
395 if s.pressed_key_index != new_hit {
396 s.pressed_key_index = new_hit;
397 crate::animation::request_draw();
398 }
399 });
400 true
401}
402
403/// Handle a pointer-up. If the release lands on the same key as the
404/// press, that key fires (`push_synthetic_key`).
405pub fn handle_software_keyboard_mouse_up(
406 pos: Point,
407 button: MouseButton,
408 modifiers: Modifiers,
409) -> bool {
410 let captured = with_state_ref(|s| s.captured_pointer);
411 if !captured {
412 return false;
413 }
414 let pressed = with_state_mut(|s| {
415 let p = s.pressed_key_index.take();
416 s.captured_pointer = false;
417 let repeat_fired = s
418 .key_repeat
419 .map(|r| r.last_fired_at.is_some())
420 .unwrap_or(false);
421 s.key_repeat = None;
422 (p, repeat_fired)
423 });
424 let (pressed_idx, repeat_already_fired) = pressed;
425 if button != MouseButton::Left {
426 crate::animation::request_draw();
427 return true;
428 }
429 let on_panel = contains_point(pos);
430 let final_hit = if on_panel { find_key_at(pos) } else { None };
431 if let (Some(start), Some(end)) = (pressed_idx, final_hit) {
432 // Suppress the tap commit if hold-to-repeat already fired at
433 // least once during the press — otherwise the release would
434 // synthesize one extra Backspace after the user lifted.
435 if start == end && !repeat_already_fired {
436 commit_key_press(end, modifiers);
437 }
438 }
439 crate::animation::request_draw();
440 true
441}
442
443fn find_key_at(pos: Point) -> Option<usize> {
444 with_state_ref(|s| {
445 s.last_painted_keys
446 .iter()
447 .enumerate()
448 .find(|(_, k)| k.rect.contains(pos))
449 .map(|(i, _)| i)
450 })
451}
452
453fn commit_key_press(index: usize, modifiers: Modifiers) {
454 let painted = with_state_ref(|s| s.last_painted_keys.get(index).cloned());
455 let Some(painted) = painted else {
456 return;
457 };
458 // Clear any pending shift-double-tap detection on a non-shift commit
459 // so a Shift tap that's *not* immediately followed by another Shift
460 // tap doesn't accidentally promote to caps-lock when the user later
461 // taps Shift unrelated.
462 let is_shift_action = matches!(painted.action, key::KeyAction::Switch(Layer::Shifted));
463 if !is_shift_action {
464 with_state_mut(|s| s.last_shift_tap = None);
465 }
466 match painted.action {
467 key::KeyAction::Char(c) => {
468 let mut mods = modifiers;
469 let was_shifted = with_state_ref(|s| s.current_layer == Layer::Shifted);
470 if was_shifted {
471 mods.shift = true;
472 }
473 push_synthetic_key(Key::Char(c), mods);
474 // One-shot shift: drop back to base layer after a single
475 // character — unless caps lock is engaged, in which case
476 // stay in Shifted.
477 with_state_mut(|s| {
478 if s.current_layer == Layer::Shifted && !s.caps_lock {
479 s.current_layer = Layer::Letters;
480 }
481 });
482 }
483 key::KeyAction::Backspace => push_synthetic_key(Key::Backspace, modifiers),
484 key::KeyAction::Enter => {
485 push_synthetic_key(Key::Enter, modifiers);
486 }
487 key::KeyAction::Space => push_synthetic_key(Key::Char(' '), modifiers),
488 key::KeyAction::Switch(target) => {
489 handle_layer_switch(target);
490 }
491 key::KeyAction::Dismiss => dismiss(),
492 }
493 crate::animation::request_draw();
494}
495
496/// Apply a layer-switch action with special handling for the Shift
497/// key:
498/// - First tap → toggle into [`Layer::Shifted`] (one-shot upper case).
499/// - Second tap within [`state::SHIFT_DOUBLE_TAP_WINDOW`] → engage caps
500/// lock; keyboard stays Shifted until shift is tapped again.
501/// - Tap while caps lock is on → release caps lock + drop to lowercase.
502/// - Any other layer switch (?123 / ABC / =\< / 1234) just changes the
503/// layer and clears caps-lock state.
504fn handle_layer_switch(target: Layer) {
505 if target == Layer::Shifted || target == Layer::Letters {
506 with_state_mut(|s| {
507 let now = web_time::Instant::now();
508 let recently_tapped = s
509 .last_shift_tap
510 .map(|t| now.duration_since(t) <= state::SHIFT_DOUBLE_TAP_WINDOW)
511 .unwrap_or(false);
512
513 if s.caps_lock {
514 // Caps lock release: tap shift → drop back to lowercase.
515 s.caps_lock = false;
516 s.current_layer = Layer::Letters;
517 s.last_shift_tap = None;
518 } else if recently_tapped {
519 // Double-tap → caps lock on.
520 s.caps_lock = true;
521 s.current_layer = Layer::Shifted;
522 s.last_shift_tap = None;
523 } else {
524 // First tap → one-shot shift (or unshift if currently Shifted).
525 s.current_layer = match s.current_layer {
526 Layer::Shifted => Layer::Letters,
527 _ => Layer::Shifted,
528 };
529 s.last_shift_tap = Some(now);
530 }
531 });
532 } else {
533 with_state_mut(|s| {
534 s.current_layer = target;
535 s.last_shift_tap = None;
536 });
537 }
538}
539
540/// Advance the hold-to-repeat state machine. Called once per paint so
541/// the cadence rides on the animation loop. When the held key has been
542/// down long enough we synthesize a `Backspace` and request another
543/// draw so the loop keeps pumping for the next repeat.
544fn tick_key_repeat() {
545 let now = web_time::Instant::now();
546 let action = with_state_mut(|s| {
547 let Some(repeat) = s.key_repeat.as_mut() else {
548 return None;
549 };
550 // Repeat is only valid while the user is still holding the key
551 // (captured_pointer == true && pressed_key_index matches).
552 if !s.captured_pointer || s.pressed_key_index != Some(repeat.key_index) {
553 s.key_repeat = None;
554 return None;
555 }
556 let held = now.duration_since(repeat.pressed_at);
557 let should_fire = match repeat.last_fired_at {
558 None => held >= state::KeyRepeatState::INITIAL_DELAY,
559 Some(t) => now.duration_since(t) >= state::KeyRepeatState::REPEAT_PERIOD,
560 };
561 if should_fire {
562 let key = s.last_painted_keys.get(repeat.key_index)?.action;
563 repeat.last_fired_at = Some(now);
564 return Some(key);
565 }
566 None
567 });
568 if let Some(action) = action {
569 match action {
570 key::KeyAction::Backspace => {
571 push_synthetic_key(Key::Backspace, Modifiers::default());
572 }
573 _ => {}
574 }
575 // Keep the loop hot for the next tick.
576 crate::animation::request_draw();
577 }
578}
579
580// ---------------------------------------------------------------------------
581// Synthetic key drain (called from App after each pointer event)
582// ---------------------------------------------------------------------------
583
584pub use events::drain_synthetic_keys;
585
586// Re-export common types for ergonomics.
587pub use key::{KeyAction, KeyCap};
588pub use layouts::Layer as KeyboardLayer;
589
590// ---------------------------------------------------------------------------
591// Internal — invoked by App through `crate::widgets::on_screen_keyboard::test_hook`
592// in tests only.
593// ---------------------------------------------------------------------------
594
595#[cfg(test)]
596pub(crate) mod test_hook {
597 use super::*;
598 use crate::animation::Tween;
599 use state::KeyboardState;
600
601 #[allow(dead_code)]
602 pub fn force_layer(layer: Layer) {
603 with_state_mut(|s| s.current_layer = layer);
604 }
605
606 pub fn force_visible() {
607 with_state_mut(|s| {
608 s.enabled = true;
609 s.text_input_focused = true;
610 s.slide = Tween::new(1.0, 0.0);
611 s.last_panel_height = Some(240.0);
612 });
613 }
614
615 pub fn reset() {
616 with_state_mut(|s| {
617 *s = KeyboardState::default();
618 });
619 }
620
621 /// Re-export `handle_layer_switch` so caps-lock behaviour can be
622 /// exercised from cross-module tests without first synthesising a
623 /// full paint pass.
624 pub fn simulate_shift_tap() {
625 super::handle_layer_switch(super::Layer::Shifted);
626 }
627
628 /// Read caps-lock state without exposing the full module state.
629 pub fn caps_lock() -> bool {
630 with_state_ref(|s| s.caps_lock)
631 }
632
633 /// Read current layer for tests.
634 pub fn current_layer() -> Layer {
635 with_state_ref(|s| s.current_layer)
636 }
637}