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}