Skip to main content

agg_gui/
input_profile.rs

1//! Runtime hint describing the user's primary input device.
2//!
3//! Distinct from [`crate::platform::Platform`] (which tracks the OS family
4//! for shortcut labels — Cmd vs. Ctrl) because a Mac user with a
5//! touchscreen MacBook and an iPad user both run `Platform::MacOS` but
6//! need very different text-entry experiences.
7//!
8//! The input profile drives features that should only exist on mobile
9//! touch devices:
10//!
11//! - The agg-gui on-screen software keyboard
12//!   ([`crate::widgets::on_screen_keyboard`])
13//! - Hit-target padding around small interactive widgets (future)
14//! - Long-press gesture timing (future)
15//!
16//! Native builds default to [`InputProfile::Desktop`]. WASM hosts call
17//! [`set_input_profile`] after sniffing `navigator.userAgent` +
18//! `matchMedia("(pointer: coarse)")` so the agg-gui-side mobile features
19//! activate. The host can also call [`platform_from_name`] /
20//! [`set_platform`](crate::platform::set_platform) so shortcut labels match
21//! the user's keyboard while the on-screen keyboard mimics their phone OS.
22
23use std::sync::atomic::{AtomicU8, Ordering};
24
25/// Where keyboard / pointer events originate and how text entry should
26/// behave.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum InputProfile {
29    /// Physical keyboard + precise pointer (mouse / trackpad). The default.
30    /// No on-screen keyboard.
31    Desktop,
32    /// iPhone / iPad / iPad-mode Safari. Touch primary, no physical
33    /// keyboard. On-screen keyboard renders with iOS-style chrome
34    /// (rounded keys, light surface, blue accent).
35    MobileIOS,
36    /// Android phone or tablet (Chrome / Firefox / Samsung Internet).
37    /// On-screen keyboard renders with Material-style chrome (flatter
38    /// keys, system accent).
39    MobileAndroid,
40    /// Touch device we can't otherwise classify — e.g. a Linux tablet.
41    /// On-screen keyboard renders with a neutral default.
42    MobileOther,
43}
44
45impl InputProfile {
46    /// `true` when the profile implies the user has no physical keyboard
47    /// and the on-screen keyboard should be available.
48    pub fn is_mobile_touch(self) -> bool {
49        matches!(
50            self,
51            InputProfile::MobileIOS | InputProfile::MobileAndroid | InputProfile::MobileOther
52        )
53    }
54
55    /// Recommended [`crate::ux_scale`] multiplier for this profile.
56    /// `1.0` for desktop; ~`1.7` for mobile touch (phones held at
57    /// arm's length need ~44 px touch targets and ~17 px body text,
58    /// which is roughly 1.7× what reads well on a desktop monitor).
59    ///
60    /// Apps that want a different feel can override with
61    /// [`crate::ux_scale::set_ux_scale`] *after* the profile is
62    /// applied — accessibility settings, for example.
63    pub fn recommended_ux_scale(self) -> f64 {
64        match self {
65            InputProfile::Desktop => 1.0,
66            InputProfile::MobileIOS | InputProfile::MobileAndroid | InputProfile::MobileOther => {
67                1.7
68            }
69        }
70    }
71}
72
73static CURRENT: AtomicU8 = AtomicU8::new(profile_code(InputProfile::Desktop));
74
75/// Replace the global input profile. Call once at startup from the
76/// platform shell after detecting the device, and at most once more
77/// if the device changes (e.g. a tablet docked into a desktop mode).
78///
79/// **Deliberately does NOT change [`crate::ux_scale`].** Earlier
80/// drafts auto-applied [`InputProfile::recommended_ux_scale`] here,
81/// but that meant programmatic profile changes (e.g. a demo's
82/// "preview as iPhone" radio) silently resized the entire UI, which
83/// is a surprise. The platform shell is the only place that knows
84/// whether the user is really on a touch device; it calls
85/// `set_ux_scale` explicitly. Demos / sandboxes can flip
86/// `InputProfile` without affecting on-screen UI scale.
87pub fn set_input_profile(profile: InputProfile) {
88    CURRENT.store(profile_code(profile), Ordering::Relaxed);
89}
90
91/// Read the global input profile.
92pub fn current_input_profile() -> InputProfile {
93    profile_from_code(CURRENT.load(Ordering::Relaxed))
94}
95
96/// Convenience: detect mobile-touch from current profile.
97pub fn is_mobile_touch() -> bool {
98    current_input_profile().is_mobile_touch()
99}
100
101/// Parse a coarse browser identifier ("iPhone", "iPad", "Android", …)
102/// into an [`InputProfile`]. Defaults to [`InputProfile::Desktop`] so a
103/// non-matching string (any desktop UA) keeps mobile features disabled.
104///
105/// `pointer_coarse` should reflect `window.matchMedia('(pointer: coarse)')`
106/// — true on iPad-mode Safari that hides "iPad" from the UA, false on a
107/// MacBook trackpad. Set it to `false` if you don't have a reliable read.
108pub fn input_profile_from_hint(user_agent_or_platform: &str, pointer_coarse: bool) -> InputProfile {
109    let ua = user_agent_or_platform.to_ascii_lowercase();
110    if ua.contains("iphone") || ua.contains("ipad") || ua.contains("ipod") {
111        return InputProfile::MobileIOS;
112    }
113    if ua.contains("android") {
114        return InputProfile::MobileAndroid;
115    }
116    // iPad-mode Safari masquerades as macOS in the UA. Coarse-pointer +
117    // mac signals an iPad-class device in practice.
118    if pointer_coarse && (ua.contains("mac") || ua.contains("darwin")) {
119        return InputProfile::MobileIOS;
120    }
121    if pointer_coarse {
122        return InputProfile::MobileOther;
123    }
124    InputProfile::Desktop
125}
126
127const fn profile_code(p: InputProfile) -> u8 {
128    match p {
129        InputProfile::Desktop => 0,
130        InputProfile::MobileIOS => 1,
131        InputProfile::MobileAndroid => 2,
132        InputProfile::MobileOther => 3,
133    }
134}
135
136fn profile_from_code(c: u8) -> InputProfile {
137    match c {
138        1 => InputProfile::MobileIOS,
139        2 => InputProfile::MobileAndroid,
140        3 => InputProfile::MobileOther,
141        _ => InputProfile::Desktop,
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn ua_routes_to_correct_profile() {
151        assert_eq!(
152            input_profile_from_hint("Mozilla/5.0 (iPhone; CPU iPhone OS 17_4)", true),
153            InputProfile::MobileIOS
154        );
155        assert_eq!(
156            input_profile_from_hint("Mozilla/5.0 (Linux; Android 14; Pixel 8)", true),
157            InputProfile::MobileAndroid
158        );
159        // iPad-mode Safari reports macOS in the UA but the pointer-coarse
160        // hint pulls us back to MobileIOS.
161        assert_eq!(
162            input_profile_from_hint(
163                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit",
164                true
165            ),
166            InputProfile::MobileIOS
167        );
168        // Same UA without a coarse pointer = desktop Mac.
169        assert_eq!(
170            input_profile_from_hint(
171                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit",
172                false
173            ),
174            InputProfile::Desktop
175        );
176        // Unknown touch device.
177        assert_eq!(
178            input_profile_from_hint("CrOS x86_64", true),
179            InputProfile::MobileOther
180        );
181    }
182
183    #[test]
184    fn is_mobile_touch_helper() {
185        assert!(!InputProfile::Desktop.is_mobile_touch());
186        assert!(InputProfile::MobileIOS.is_mobile_touch());
187        assert!(InputProfile::MobileAndroid.is_mobile_touch());
188        assert!(InputProfile::MobileOther.is_mobile_touch());
189    }
190}