use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Viewport {
pub width: u32,
pub height: u32,
pub device_scale_factor: f64,
pub mobile: bool,
pub has_touch: bool,
pub user_agent: Option<String>,
}
impl Viewport {
pub fn custom(width: u32, height: u32) -> Self {
Self {
width,
height,
device_scale_factor: 1.0,
mobile: false,
has_touch: false,
user_agent: None,
}
}
pub fn mobile(
width: u32,
height: u32,
device_scale_factor: f64,
user_agent: impl Into<String>,
) -> Self {
Self {
width,
height,
device_scale_factor,
mobile: true,
has_touch: true,
user_agent: Some(user_agent.into()),
}
}
}
struct Preset {
name: &'static str,
viewport: fn() -> Viewport,
}
macro_rules! presets {
($($name:literal => $mk:expr),+ $(,)?) => {
&[$(Preset { name: $name, viewport: $mk }),+]
};
}
static PRESETS: &[Preset] = presets! {
"iPhone 16 Pro Max" => || Viewport::mobile(430, 932, 3.0,
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 \
(KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
"iPhone 16" => || Viewport::mobile(393, 852, 3.0,
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 \
(KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
"iPhone SE" => || Viewport::mobile(375, 667, 2.0,
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 \
(KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
"Pixel 7" => || Viewport::mobile(412, 915, 2.625,
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/128.0.0.0 Mobile Safari/537.36"),
"Galaxy S20" => || Viewport::mobile(360, 800, 4.0,
"Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/128.0.0.0 Mobile Safari/537.36"),
"iPad Pro 11" => || Viewport::mobile(834, 1194, 2.0,
"Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 \
(KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
"iPad Mini" => || Viewport::mobile(768, 1024, 2.0,
"Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 \
(KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"),
"Tablet" => || Viewport::mobile(800, 1280, 2.0,
"Mozilla/5.0 (Linux; Android 14; Tablet) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/128.0.0.0 Safari/537.36"),
"Desktop 1080p" => || Viewport::custom(1920, 1080),
"Desktop 1440p" => || Viewport::custom(2560, 1440),
"Laptop" => || Viewport::custom(1366, 768),
};
pub fn preset(name: &str) -> Option<Viewport> {
let needle = name.trim();
PRESETS.iter().find(|p| p.name.eq_ignore_ascii_case(needle)).map(|p| (p.viewport)())
}
pub fn preset_names() -> Vec<&'static str> {
PRESETS.iter().map(|p| p.name).collect()
}
pub fn all_presets() -> Vec<(&'static str, Viewport)> {
PRESETS.iter().map(|p| (p.name, (p.viewport)())).collect()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ScrollTarget {
Viewports(f64),
Pixels(i64),
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "test harness")]
mod tests {
use super::*;
#[test]
fn preset_lookup_is_case_insensitive_and_trims() {
let a = preset("iPhone 16 Pro Max").expect("known preset");
let b = preset(" iphone 16 pro max ").expect("case-insensitive match");
assert_eq!(a, b);
assert!(preset("Nokia 3310 Pro").is_none());
}
#[test]
fn desktop_presets_are_not_mobile() {
let vp = preset("Desktop 1080p").expect("known preset");
assert!(!vp.mobile);
assert!(!vp.has_touch);
assert_eq!((vp.width, vp.height), (1920, 1080));
}
#[test]
fn phone_presets_are_mobile_with_touch_and_ua() {
let vp = preset("Pixel 7").expect("known preset");
assert!(vp.mobile);
assert!(vp.has_touch);
assert!(vp.user_agent.is_some());
}
#[test]
fn preset_names_match_table_and_are_all_resolvable() {
let names = preset_names();
assert_eq!(names.len(), PRESETS.len());
for name in names {
assert!(preset(name).is_some(), "preset {name:?} should resolve");
}
}
}