Skip to main content

hpx_browser/css_cascade/
media.rs

1use crate::css_parser::{ComponentValue, Token, TokenKind};
2
3#[derive(Debug, Clone)]
4pub struct MediaFeatures {
5    pub width: f64,
6    pub height: f64,
7    pub device_pixel_ratio: f64,
8    pub prefers_color_scheme: ColorScheme,
9    pub prefers_reduced_motion: ReducedMotion,
10    pub pointer: PointerType,
11    pub hover: HoverCapability,
12    pub scripting: Scripting,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ColorScheme {
17    Light,
18    Dark,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ReducedMotion {
23    NoPreference,
24    Reduce,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PointerType {
29    None,
30    Coarse,
31    Fine,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum HoverCapability {
36    None,
37    Hover,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Scripting {
42    None,
43    Enabled,
44}
45
46impl Default for MediaFeatures {
47    fn default() -> Self {
48        Self {
49            width: 1920.0,
50            height: 1080.0,
51            device_pixel_ratio: 1.0,
52            prefers_color_scheme: ColorScheme::Light,
53            prefers_reduced_motion: ReducedMotion::NoPreference,
54            pointer: PointerType::Fine,
55            hover: HoverCapability::Hover,
56            scripting: Scripting::Enabled,
57        }
58    }
59}
60
61pub fn evaluate_media_query(prelude: &[ComponentValue<'_>], features: &MediaFeatures) -> bool {
62    let text = prelude_to_text(prelude);
63    let text = text.trim().to_ascii_lowercase();
64
65    if text.is_empty() || text == "all" {
66        return true;
67    }
68
69    if text == "screen" {
70        return true;
71    }
72
73    if text == "print" {
74        return false;
75    }
76
77    if text.contains(',') {
78        return text
79            .split(',')
80            .any(|part| evaluate_single_query(part.trim(), features));
81    }
82
83    evaluate_single_query(&text, features)
84}
85
86fn evaluate_single_query(query: &str, features: &MediaFeatures) -> bool {
87    let query = query.trim();
88
89    let query = query
90        .strip_prefix("screen and ")
91        .or_else(|| query.strip_prefix("all and "))
92        .unwrap_or(query);
93
94    if let Some(inner) = query.strip_prefix("not ") {
95        return !evaluate_single_query(inner.trim(), features);
96    }
97
98    let query = query.trim_start_matches('(').trim_end_matches(')');
99
100    if let Some((feature, value)) = query.split_once(':') {
101        return evaluate_feature(feature.trim(), value.trim(), features);
102    }
103
104    if let Some((feature, value)) = query.split_once(">=") {
105        return evaluate_range(feature.trim(), ">=", value.trim(), features);
106    }
107    if let Some((feature, value)) = query.split_once("<=") {
108        return evaluate_range(feature.trim(), "<=", value.trim(), features);
109    }
110    if let Some((feature, value)) = query.split_once('>') {
111        return evaluate_range(feature.trim(), ">", value.trim(), features);
112    }
113    if let Some((feature, value)) = query.split_once('<') {
114        return evaluate_range(feature.trim(), "<", value.trim(), features);
115    }
116
117    match query {
118        "hover" => features.hover == HoverCapability::Hover,
119        "pointer" => features.pointer != PointerType::None,
120        "color" => true,
121        "scripting" => features.scripting == Scripting::Enabled,
122        _ => true,
123    }
124}
125
126fn evaluate_feature(feature: &str, value: &str, features: &MediaFeatures) -> bool {
127    match feature {
128        "min-width" => parse_px(value).is_some_and(|v| features.width >= v),
129        "max-width" => parse_px(value).is_some_and(|v| features.width <= v),
130        "min-height" => parse_px(value).is_some_and(|v| features.height >= v),
131        "max-height" => parse_px(value).is_some_and(|v| features.height <= v),
132        "width" => parse_px(value).is_some_and(|v| (features.width - v).abs() < 0.01),
133        "height" => parse_px(value).is_some_and(|v| (features.height - v).abs() < 0.01),
134        "prefers-color-scheme" => match value {
135            "dark" => features.prefers_color_scheme == ColorScheme::Dark,
136            "light" => features.prefers_color_scheme == ColorScheme::Light,
137            _ => false,
138        },
139        "prefers-reduced-motion" => match value {
140            "reduce" => features.prefers_reduced_motion == ReducedMotion::Reduce,
141            "no-preference" => features.prefers_reduced_motion == ReducedMotion::NoPreference,
142            _ => false,
143        },
144        "pointer" => match value {
145            "fine" => features.pointer == PointerType::Fine,
146            "coarse" => features.pointer == PointerType::Coarse,
147            "none" => features.pointer == PointerType::None,
148            _ => false,
149        },
150        "hover" => match value {
151            "hover" => features.hover == HoverCapability::Hover,
152            "none" => features.hover == HoverCapability::None,
153            _ => false,
154        },
155        _ => true,
156    }
157}
158
159fn evaluate_range(feature: &str, op: &str, value: &str, features: &MediaFeatures) -> bool {
160    let feature_val = match feature {
161        "width" => features.width,
162        "height" => features.height,
163        _ => return true,
164    };
165    let target = match parse_px(value) {
166        Some(v) => v,
167        None => return true,
168    };
169    match op {
170        ">" => feature_val > target,
171        ">=" => feature_val >= target,
172        "<" => feature_val < target,
173        "<=" => feature_val <= target,
174        _ => true,
175    }
176}
177
178fn parse_px(s: &str) -> Option<f64> {
179    let s = s.trim().trim_end_matches("px").trim();
180    s.parse::<f64>().ok()
181}
182
183fn prelude_to_text(prelude: &[ComponentValue<'_>]) -> String {
184    let mut s = String::new();
185    for cv in prelude {
186        match cv {
187            ComponentValue::Token(Token { kind, .. }) => match kind {
188                TokenKind::Ident(v) => s.push_str(v),
189                TokenKind::Number { value, .. } => s.push_str(&value.to_string()),
190                TokenKind::Dimension { value, unit, .. } => {
191                    s.push_str(&value.to_string());
192                    s.push_str(unit);
193                }
194                TokenKind::Whitespace => s.push(' '),
195                TokenKind::Colon => s.push(':'),
196                TokenKind::Comma => s.push(','),
197                TokenKind::Delim(c) => s.push(*c),
198                _ => {}
199            },
200            ComponentValue::SimpleBlock(b) => {
201                s.push(b.token);
202                s.push_str(&prelude_to_text(&b.value));
203                match b.token {
204                    '{' => s.push('}'),
205                    '[' => s.push(']'),
206                    '(' => s.push(')'),
207                    _ => {}
208                }
209            }
210            ComponentValue::Function(f) => {
211                s.push_str(f.name);
212                s.push('(');
213                s.push_str(&prelude_to_text(&f.arguments));
214                s.push(')');
215            }
216        }
217    }
218    s
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn features() -> MediaFeatures {
226        MediaFeatures::default()
227    }
228
229    fn eval(css: &str) -> bool {
230        let input = format!("@media {} {{}}", css);
231        let (stylesheet, _) = crate::css_parser::parse_stylesheet(&input);
232        if let Some(crate::css_parser::Rule::At(at)) = stylesheet.rules.first() {
233            evaluate_media_query(&at.prelude, &features())
234        } else {
235            panic!("Expected @media rule");
236        }
237    }
238
239    #[test]
240    fn screen() {
241        assert!(eval("screen"));
242    }
243
244    #[test]
245    fn print_false() {
246        assert!(!eval("print"));
247    }
248
249    #[test]
250    fn min_width_matches() {
251        assert!(eval("(min-width: 768px)"));
252    }
253
254    #[test]
255    fn min_width_no_match() {
256        assert!(!eval("(min-width: 2000px)"));
257    }
258
259    #[test]
260    fn prefers_color_scheme_light() {
261        assert!(eval("(prefers-color-scheme: light)"));
262    }
263
264    #[test]
265    fn range_syntax() {
266        assert!(eval("(width > 768px)"));
267    }
268}