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//! - 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}