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}