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