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/// The signal the UI **sizing policy** consults to decide whether
102/// interactive targets must meet the touch minimum (see
103/// [`crate::widgets::menu::effective_metrics`]).
104///
105/// True when EITHER:
106/// - the platform declared a mobile-touch profile ([`is_mobile_touch`]) —
107///   the app called [`set_input_profile`] with a mobile variant, so we know
108///   up front the user is on a phone/tablet; OR
109/// - a real touch event has fired this session
110///   ([`crate::touch_state::touch_seen_this_session`]) — a runtime fallback
111///   for shells that *forgot* to call [`set_input_profile`] /
112///   [`crate::ux_scale::set_ux_scale`] (which has happened in real apps).
113///   Without it, such a phone would ship desktop-sized (26 CSS-px) menus;
114///   with it, the first touch flips the latch and the menus can never stay
115///   accidentally tiny.
116///
117/// Deliberately distinct from [`is_mobile_touch`]: that one drives the
118/// on-screen keyboard and other "no physical keyboard" features, which must
119/// NOT turn on just because a touchscreen laptop was tapped once.  Sizing
120/// minimums are safe to raise on any touch input, so this signal is broader.
121pub fn touch_ui_active() -> bool {
122    is_mobile_touch() || crate::touch_state::touch_seen_this_session()
123}
124
125/// Parse a coarse browser identifier ("iPhone", "iPad", "Android", …)
126/// into an [`InputProfile`]. Defaults to [`InputProfile::Desktop`] so a
127/// non-matching string (any desktop UA) keeps mobile features disabled.
128///
129/// `pointer_coarse` should reflect `window.matchMedia('(pointer: coarse)')`
130/// — true on iPad-mode Safari that hides "iPad" from the UA, false on a
131/// MacBook trackpad. Set it to `false` if you don't have a reliable read.
132pub fn input_profile_from_hint(user_agent_or_platform: &str, pointer_coarse: bool) -> InputProfile {
133    let ua = user_agent_or_platform.to_ascii_lowercase();
134    if ua.contains("iphone") || ua.contains("ipad") || ua.contains("ipod") {
135        return InputProfile::MobileIOS;
136    }
137    if ua.contains("android") {
138        return InputProfile::MobileAndroid;
139    }
140    // iPad-mode Safari masquerades as macOS in the UA. Coarse-pointer +
141    // mac signals an iPad-class device in practice.
142    if pointer_coarse && (ua.contains("mac") || ua.contains("darwin")) {
143        return InputProfile::MobileIOS;
144    }
145    if pointer_coarse {
146        return InputProfile::MobileOther;
147    }
148    InputProfile::Desktop
149}
150
151const fn profile_code(p: InputProfile) -> u8 {
152    match p {
153        InputProfile::Desktop => 0,
154        InputProfile::MobileIOS => 1,
155        InputProfile::MobileAndroid => 2,
156        InputProfile::MobileOther => 3,
157    }
158}
159
160fn profile_from_code(c: u8) -> InputProfile {
161    match c {
162        1 => InputProfile::MobileIOS,
163        2 => InputProfile::MobileAndroid,
164        3 => InputProfile::MobileOther,
165        _ => InputProfile::Desktop,
166    }
167}
168
169/// Serialization lock shared by every test that reads OR writes the
170/// process-global input profile [`CURRENT`].
171///
172/// The profile is a process-wide atomic, so under cargo's parallel test
173/// threads one test's `set_input_profile(Desktop)` can be clobbered by a
174/// sibling test's `set_input_profile(MobileIOS)` before the first test
175/// reads it back — a cross-thread flake window. Tests that touch the
176/// profile (menu geometry / widget / strip metric tests, the on-screen
177/// keyboard tests, and this module's own tests) hold this guard for their
178/// full body so at most one runs at a time.
179///
180/// Poison-proof: a panicking test (a failed assertion unwinding while
181/// holding the guard) still leaves the lock usable, so one real failure
182/// can't cascade into spurious `PoisonError` panics across every other
183/// profile test.
184#[cfg(test)]
185pub(crate) fn profile_test_lock() -> std::sync::MutexGuard<'static, ()> {
186    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
187    LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn ua_routes_to_correct_profile() {
196        assert_eq!(
197            input_profile_from_hint("Mozilla/5.0 (iPhone; CPU iPhone OS 17_4)", true),
198            InputProfile::MobileIOS
199        );
200        assert_eq!(
201            input_profile_from_hint("Mozilla/5.0 (Linux; Android 14; Pixel 8)", true),
202            InputProfile::MobileAndroid
203        );
204        // iPad-mode Safari reports macOS in the UA but the pointer-coarse
205        // hint pulls us back to MobileIOS.
206        assert_eq!(
207            input_profile_from_hint(
208                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit",
209                true
210            ),
211            InputProfile::MobileIOS
212        );
213        // Same UA without a coarse pointer = desktop Mac.
214        assert_eq!(
215            input_profile_from_hint(
216                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit",
217                false
218            ),
219            InputProfile::Desktop
220        );
221        // Unknown touch device.
222        assert_eq!(
223            input_profile_from_hint("CrOS x86_64", true),
224            InputProfile::MobileOther
225        );
226    }
227
228    #[test]
229    fn is_mobile_touch_helper() {
230        assert!(!InputProfile::Desktop.is_mobile_touch());
231        assert!(InputProfile::MobileIOS.is_mobile_touch());
232        assert!(InputProfile::MobileAndroid.is_mobile_touch());
233        assert!(InputProfile::MobileOther.is_mobile_touch());
234    }
235
236    #[test]
237    fn touch_ui_active_latches_on_a_real_touch_even_on_desktop_profile() {
238        let _guard = profile_test_lock();
239        // The runtime fallback: a shell that never called `set_input_profile`
240        // stays on the Desktop profile, yet the first real touch must flip
241        // the sizing signal so menus can't stay accidentally tiny.
242        set_input_profile(InputProfile::Desktop);
243        crate::touch_state::clear_last_touch_event_for_testing();
244        assert!(
245            !touch_ui_active(),
246            "desktop profile with no touch yet must not force touch sizing"
247        );
248
249        crate::touch_state::note_touch_event();
250        assert!(
251            touch_ui_active(),
252            "a real touch event must activate touch sizing regardless of profile"
253        );
254
255        // Restore for sibling tests (profile is a process-global atomic).
256        crate::touch_state::clear_last_touch_event_for_testing();
257    }
258
259    #[test]
260    fn touch_ui_active_follows_mobile_profile_without_any_touch() {
261        let _guard = profile_test_lock();
262        // The declared-profile half: an app that DID call `set_input_profile`
263        // gets touch sizing immediately, before any touch has occurred.
264        crate::touch_state::clear_last_touch_event_for_testing();
265        set_input_profile(InputProfile::MobileIOS);
266        assert!(touch_ui_active());
267        set_input_profile(InputProfile::Desktop);
268        assert!(!touch_ui_active());
269    }
270}