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}