Skip to main content

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}