we (web engine): Experimental web browser project to understand the limits of Claude
1//! CSS Media Queries Level 4 (core subset).
2//!
3//! Parses and evaluates media queries such as
4//! `screen and (max-width: 600px)` or `(prefers-color-scheme: dark)`.
5
6use crate::tokenizer::Token;
7
8// ---------------------------------------------------------------------------
9// Media context (evaluation environment)
10// ---------------------------------------------------------------------------
11
12/// The environment against which media queries are evaluated.
13#[derive(Debug, Clone)]
14pub struct MediaContext {
15 pub viewport_width: f32,
16 pub viewport_height: f32,
17 pub color_scheme: ColorScheme,
18 pub reduced_motion: ReducedMotion,
19}
20
21impl MediaContext {
22 /// Create a context from viewport dimensions with sensible defaults.
23 pub fn from_viewport(width: f32, height: f32) -> Self {
24 Self {
25 viewport_width: width,
26 viewport_height: height,
27 color_scheme: ColorScheme::Light,
28 reduced_motion: ReducedMotion::NoPreference,
29 }
30 }
31
32 /// Viewport as `(width, height)` tuple for existing code.
33 pub fn viewport(&self) -> (f32, f32) {
34 (self.viewport_width, self.viewport_height)
35 }
36}
37
38impl Default for MediaContext {
39 fn default() -> Self {
40 Self::from_viewport(0.0, 0.0)
41 }
42}
43
44/// User's preferred color scheme.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ColorScheme {
47 Light,
48 Dark,
49}
50
51/// User's reduced-motion preference.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ReducedMotion {
54 NoPreference,
55 Reduce,
56}
57
58// ---------------------------------------------------------------------------
59// Media query AST
60// ---------------------------------------------------------------------------
61
62/// Comma-separated list of media queries. Evaluates `true` if **any** matches.
63#[derive(Debug, Clone, PartialEq)]
64pub struct MediaQueryList {
65 pub queries: Vec<MediaQuery>,
66}
67
68/// A single media query: `[not|only] [type] [and (feature)]*`
69#[derive(Debug, Clone, PartialEq)]
70pub struct MediaQuery {
71 pub modifier: Modifier,
72 pub media_type: MediaType,
73 pub features: Vec<MediaFeature>,
74}
75
76/// Optional modifier on a media query.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Modifier {
79 None,
80 Not,
81 Only,
82}
83
84/// Media type.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum MediaType {
87 All,
88 Screen,
89 Print,
90}
91
92/// A single media feature condition inside parentheses.
93#[derive(Debug, Clone, PartialEq)]
94pub enum MediaFeature {
95 /// Width comparison (covers `width`, `min-width`, `max-width`, range syntax).
96 Width(Comparison, f32),
97 /// Height comparison (covers `height`, `min-height`, `max-height`, range syntax).
98 Height(Comparison, f32),
99 /// `prefers-color-scheme: light | dark`
100 PrefersColorScheme(ColorScheme),
101 /// `prefers-reduced-motion: reduce | no-preference`
102 PrefersReducedMotion(ReducedMotion),
103}
104
105/// Comparison operator for range features.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum Comparison {
108 Eq,
109 Gt,
110 Gte,
111 Lt,
112 Lte,
113}
114
115// ---------------------------------------------------------------------------
116// Evaluation
117// ---------------------------------------------------------------------------
118
119impl MediaQueryList {
120 /// An empty list matches everything (per spec).
121 pub fn evaluate(&self, ctx: &MediaContext) -> bool {
122 if self.queries.is_empty() {
123 return true;
124 }
125 self.queries.iter().any(|q| q.evaluate(ctx))
126 }
127}
128
129impl MediaQuery {
130 pub fn evaluate(&self, ctx: &MediaContext) -> bool {
131 let type_matches = match self.media_type {
132 MediaType::All | MediaType::Screen => true,
133 MediaType::Print => false,
134 };
135
136 let features_match = self.features.iter().all(|f| f.evaluate(ctx));
137 let result = type_matches && features_match;
138
139 match self.modifier {
140 Modifier::Not => !result,
141 Modifier::Only | Modifier::None => result,
142 }
143 }
144}
145
146impl MediaFeature {
147 pub fn evaluate(&self, ctx: &MediaContext) -> bool {
148 match self {
149 MediaFeature::Width(cmp, val) => compare(*cmp, ctx.viewport_width, *val),
150 MediaFeature::Height(cmp, val) => compare(*cmp, ctx.viewport_height, *val),
151 MediaFeature::PrefersColorScheme(scheme) => ctx.color_scheme == *scheme,
152 MediaFeature::PrefersReducedMotion(motion) => ctx.reduced_motion == *motion,
153 }
154 }
155}
156
157fn compare(cmp: Comparison, actual: f32, expected: f32) -> bool {
158 match cmp {
159 Comparison::Eq => (actual - expected).abs() < 0.5,
160 Comparison::Gt => actual > expected,
161 Comparison::Gte => actual >= expected,
162 Comparison::Lt => actual < expected,
163 Comparison::Lte => actual <= expected,
164 }
165}
166
167// ---------------------------------------------------------------------------
168// Parsing
169// ---------------------------------------------------------------------------
170
171/// Parse a media query list from tokens (the prelude between `@media` and `{`).
172pub fn parse_media_query_list(tokens: &[Token]) -> MediaQueryList {
173 let mut queries = Vec::new();
174 let mut start = 0;
175
176 for i in 0..tokens.len() {
177 if matches!(tokens[i], Token::Comma) {
178 if let Some(q) = parse_single_query(&tokens[start..i]) {
179 queries.push(q);
180 }
181 start = i + 1;
182 }
183 }
184
185 // Last (or only) segment.
186 if start <= tokens.len() {
187 if let Some(q) = parse_single_query(&tokens[start..]) {
188 queries.push(q);
189 }
190 }
191
192 MediaQueryList { queries }
193}
194
195/// Parse a single media query (no commas).
196fn parse_single_query(tokens: &[Token]) -> Option<MediaQuery> {
197 let tokens: Vec<&Token> = tokens
198 .iter()
199 .filter(|t| !matches!(t, Token::Whitespace))
200 .collect();
201 if tokens.is_empty() {
202 return None;
203 }
204
205 let mut pos = 0;
206
207 // Optional modifier: `not` | `only`
208 let modifier = match tokens.get(pos) {
209 Some(Token::Ident(s)) if s.eq_ignore_ascii_case("not") => {
210 pos += 1;
211 Modifier::Not
212 }
213 Some(Token::Ident(s)) if s.eq_ignore_ascii_case("only") => {
214 pos += 1;
215 Modifier::Only
216 }
217 _ => Modifier::None,
218 };
219
220 // Optional media type: `screen` | `print` | `all`
221 let mut media_type = MediaType::All;
222 let mut has_explicit_type = false;
223
224 if let Some(Token::Ident(s)) = tokens.get(pos) {
225 match s.to_ascii_lowercase().as_str() {
226 "screen" => {
227 media_type = MediaType::Screen;
228 has_explicit_type = true;
229 pos += 1;
230 }
231 "print" => {
232 media_type = MediaType::Print;
233 has_explicit_type = true;
234 pos += 1;
235 }
236 "all" => {
237 media_type = MediaType::All;
238 has_explicit_type = true;
239 pos += 1;
240 }
241 _ => {}
242 }
243 }
244
245 // If we have a modifier but no recognized type, and the next token is an ident
246 // (not a paren), treat it as an unknown type that never matches.
247 if modifier != Modifier::None && !has_explicit_type {
248 if let Some(Token::Ident(_)) = tokens.get(pos) {
249 // Unknown media type — skip the ident. Evaluated as never-matching
250 // (the `not` modifier will invert it).
251 pos += 1;
252 }
253 }
254
255 // Parse `and (feature)` clauses.
256 let mut features = Vec::new();
257 loop {
258 if pos >= tokens.len() {
259 break;
260 }
261
262 // Expect `and` between type and features, or between features.
263 if let Some(Token::Ident(s)) = tokens.get(pos) {
264 if s.eq_ignore_ascii_case("and") {
265 pos += 1;
266 if pos >= tokens.len() {
267 break;
268 }
269 }
270 }
271
272 // Expect `(` ... `)`
273 if !matches!(tokens.get(pos), Some(Token::LeftParen)) {
274 break;
275 }
276 pos += 1;
277
278 // Collect tokens until matching `)`.
279 let feature_start = pos;
280 let mut depth = 1u32;
281 while pos < tokens.len() && depth > 0 {
282 match tokens[pos] {
283 Token::LeftParen => depth += 1,
284 Token::RightParen => {
285 depth -= 1;
286 if depth == 0 {
287 break;
288 }
289 }
290 _ => {}
291 }
292 pos += 1;
293 }
294 let feature_end = pos;
295 if pos < tokens.len() {
296 pos += 1; // skip `)`
297 }
298
299 let feature_tokens: Vec<&Token> = tokens[feature_start..feature_end].to_vec();
300 let parsed = parse_media_feature(&feature_tokens);
301 features.extend(parsed);
302 }
303
304 // A modifier without an explicit type or features isn't useful, but still valid.
305 Some(MediaQuery {
306 modifier,
307 media_type,
308 features,
309 })
310}
311
312/// Parse a single media feature from the tokens inside parentheses.
313/// Returns zero or more features (double ranges produce two).
314fn parse_media_feature(tokens: &[&Token]) -> Vec<MediaFeature> {
315 if tokens.is_empty() {
316 return Vec::new();
317 }
318
319 // Try traditional syntax first: `name : value`
320 if let Some(colon_pos) = tokens.iter().position(|t| matches!(t, Token::Colon)) {
321 let name_tokens = &tokens[..colon_pos];
322 let value_tokens = &tokens[colon_pos + 1..];
323
324 if name_tokens.len() == 1 {
325 if let Token::Ident(name) = name_tokens[0] {
326 if let Some(f) = parse_named_feature(&name.to_ascii_lowercase(), value_tokens) {
327 return vec![f];
328 }
329 }
330 }
331 return Vec::new();
332 }
333
334 // Try range syntax.
335 parse_range_feature(tokens)
336}
337
338/// Parse a feature with traditional `name: value` syntax.
339fn parse_named_feature(name: &str, value_tokens: &[&Token]) -> Option<MediaFeature> {
340 match name {
341 "min-width" => Some(MediaFeature::Width(
342 Comparison::Gte,
343 parse_length(value_tokens)?,
344 )),
345 "max-width" => Some(MediaFeature::Width(
346 Comparison::Lte,
347 parse_length(value_tokens)?,
348 )),
349 "width" => Some(MediaFeature::Width(
350 Comparison::Eq,
351 parse_length(value_tokens)?,
352 )),
353 "min-height" => Some(MediaFeature::Height(
354 Comparison::Gte,
355 parse_length(value_tokens)?,
356 )),
357 "max-height" => Some(MediaFeature::Height(
358 Comparison::Lte,
359 parse_length(value_tokens)?,
360 )),
361 "height" => Some(MediaFeature::Height(
362 Comparison::Eq,
363 parse_length(value_tokens)?,
364 )),
365 "prefers-color-scheme" => {
366 let Token::Ident(v) = value_tokens.first()? else {
367 return None;
368 };
369 match v.to_ascii_lowercase().as_str() {
370 "light" => Some(MediaFeature::PrefersColorScheme(ColorScheme::Light)),
371 "dark" => Some(MediaFeature::PrefersColorScheme(ColorScheme::Dark)),
372 _ => None,
373 }
374 }
375 "prefers-reduced-motion" => {
376 let Token::Ident(v) = value_tokens.first()? else {
377 return None;
378 };
379 match v.to_ascii_lowercase().as_str() {
380 "reduce" => Some(MediaFeature::PrefersReducedMotion(ReducedMotion::Reduce)),
381 "no-preference" => Some(MediaFeature::PrefersReducedMotion(
382 ReducedMotion::NoPreference,
383 )),
384 _ => None,
385 }
386 }
387 _ => None,
388 }
389}
390
391/// Parse range syntax: `name op value`, `value op name`, or
392/// `value op name op value` (double range).
393fn parse_range_feature(tokens: &[&Token]) -> Vec<MediaFeature> {
394 // Find comparison operator positions.
395 let mut comparisons: Vec<(usize, usize, Comparison)> = Vec::new(); // (start, len, op)
396
397 let mut i = 0;
398 while i < tokens.len() {
399 if i + 1 < tokens.len() {
400 if let (Token::Delim('>'), Token::Delim('=')) = (tokens[i], tokens[i + 1]) {
401 comparisons.push((i, 2, Comparison::Gte));
402 i += 2;
403 continue;
404 }
405 if let (Token::Delim('<'), Token::Delim('=')) = (tokens[i], tokens[i + 1]) {
406 comparisons.push((i, 2, Comparison::Lte));
407 i += 2;
408 continue;
409 }
410 }
411 match tokens[i] {
412 Token::Delim('>') => {
413 comparisons.push((i, 1, Comparison::Gt));
414 i += 1;
415 continue;
416 }
417 Token::Delim('<') => {
418 comparisons.push((i, 1, Comparison::Lt));
419 i += 1;
420 continue;
421 }
422 Token::Delim('=') => {
423 comparisons.push((i, 1, Comparison::Eq));
424 i += 1;
425 continue;
426 }
427 _ => {}
428 }
429 i += 1;
430 }
431
432 if comparisons.len() == 1 {
433 let (op_pos, op_len, cmp) = comparisons[0];
434 let left = &tokens[..op_pos];
435 let right = &tokens[op_pos + op_len..];
436
437 if let Some(name) = extract_feature_name(left) {
438 // `name op value`
439 if let Some(val) = parse_length(right) {
440 if let Some(f) = make_dimension_feature(&name, cmp, val) {
441 return vec![f];
442 }
443 }
444 } else if let Some(name) = extract_feature_name(right) {
445 // `value op name` → flip
446 if let Some(val) = parse_length(left) {
447 if let Some(f) = make_dimension_feature(&name, flip_comparison(cmp), val) {
448 return vec![f];
449 }
450 }
451 }
452 } else if comparisons.len() == 2 {
453 // Double range: `value op1 name op2 value`
454 let (op1_pos, op1_len, cmp1) = comparisons[0];
455 let (op2_pos, op2_len, cmp2) = comparisons[1];
456
457 let left = &tokens[..op1_pos];
458 let middle = &tokens[op1_pos + op1_len..op2_pos];
459 let right = &tokens[op2_pos + op2_len..];
460
461 if let Some(name) = extract_feature_name(middle) {
462 if let (Some(low), Some(high)) = (parse_length(left), parse_length(right)) {
463 // `low cmp1 name cmp2 high` means `name flip(cmp1) low AND name cmp2 high`
464 let f1 = make_dimension_feature(&name, flip_comparison(cmp1), low);
465 let f2 = make_dimension_feature(&name, cmp2, high);
466 if let (Some(a), Some(b)) = (f1, f2) {
467 return vec![a, b];
468 }
469 }
470 }
471 }
472
473 Vec::new()
474}
475
476/// Extract a feature name from a single-ident token slice.
477fn extract_feature_name(tokens: &[&Token]) -> Option<String> {
478 if tokens.len() == 1 {
479 if let Token::Ident(s) = tokens[0] {
480 return Some(s.to_ascii_lowercase());
481 }
482 }
483 None
484}
485
486/// Create a Width or Height feature from a dimension name and comparison.
487fn make_dimension_feature(name: &str, cmp: Comparison, value: f32) -> Option<MediaFeature> {
488 match name {
489 "width" => Some(MediaFeature::Width(cmp, value)),
490 "height" => Some(MediaFeature::Height(cmp, value)),
491 _ => None,
492 }
493}
494
495/// Flip a comparison for reversed range syntax: `value op name` → `name flip(op) value`.
496fn flip_comparison(cmp: Comparison) -> Comparison {
497 match cmp {
498 Comparison::Gt => Comparison::Lt,
499 Comparison::Gte => Comparison::Lte,
500 Comparison::Lt => Comparison::Gt,
501 Comparison::Lte => Comparison::Gte,
502 Comparison::Eq => Comparison::Eq,
503 }
504}
505
506/// Parse a pixel length from value tokens. Returns the value in `px`.
507fn parse_length(tokens: &[&Token]) -> Option<f32> {
508 if tokens.len() != 1 {
509 return None;
510 }
511 match tokens[0] {
512 Token::Dimension(val, _, unit) if unit.eq_ignore_ascii_case("px") => Some(*val as f32),
513 Token::Dimension(val, _, unit) if unit.eq_ignore_ascii_case("em") => {
514 Some(*val as f32 * 16.0)
515 }
516 Token::Dimension(val, _, unit) if unit.eq_ignore_ascii_case("rem") => {
517 Some(*val as f32 * 16.0)
518 }
519 Token::Number(val, _) if *val == 0.0 => Some(0.0),
520 _ => None,
521 }
522}
523
524// ---------------------------------------------------------------------------
525// Tests
526// ---------------------------------------------------------------------------
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::tokenizer::Tokenizer;
532
533 /// Helper: tokenize a media query prelude (no @media keyword or braces).
534 fn parse_mq(input: &str) -> MediaQueryList {
535 let tokens = Tokenizer::tokenize(input);
536 // Remove EOF
537 let tokens: Vec<Token> = tokens
538 .into_iter()
539 .filter(|t| !matches!(t, Token::Eof))
540 .collect();
541 parse_media_query_list(&tokens)
542 }
543
544 fn ctx(w: f32, h: f32) -> MediaContext {
545 MediaContext::from_viewport(w, h)
546 }
547
548 fn dark_ctx(w: f32, h: f32) -> MediaContext {
549 MediaContext {
550 viewport_width: w,
551 viewport_height: h,
552 color_scheme: ColorScheme::Dark,
553 reduced_motion: ReducedMotion::NoPreference,
554 }
555 }
556
557 // --- Parsing ---
558
559 #[test]
560 fn parse_media_type_screen() {
561 let mq = parse_mq("screen");
562 assert_eq!(mq.queries.len(), 1);
563 assert_eq!(mq.queries[0].media_type, MediaType::Screen);
564 assert_eq!(mq.queries[0].modifier, Modifier::None);
565 assert!(mq.queries[0].features.is_empty());
566 }
567
568 #[test]
569 fn parse_media_type_print() {
570 let mq = parse_mq("print");
571 assert_eq!(mq.queries[0].media_type, MediaType::Print);
572 }
573
574 #[test]
575 fn parse_media_type_all() {
576 let mq = parse_mq("all");
577 assert_eq!(mq.queries[0].media_type, MediaType::All);
578 }
579
580 #[test]
581 fn parse_not_modifier() {
582 let mq = parse_mq("not print");
583 assert_eq!(mq.queries[0].modifier, Modifier::Not);
584 assert_eq!(mq.queries[0].media_type, MediaType::Print);
585 }
586
587 #[test]
588 fn parse_only_modifier() {
589 let mq = parse_mq("only screen");
590 assert_eq!(mq.queries[0].modifier, Modifier::Only);
591 assert_eq!(mq.queries[0].media_type, MediaType::Screen);
592 }
593
594 #[test]
595 fn parse_min_width() {
596 let mq = parse_mq("(min-width: 768px)");
597 assert_eq!(mq.queries[0].features.len(), 1);
598 assert_eq!(
599 mq.queries[0].features[0],
600 MediaFeature::Width(Comparison::Gte, 768.0)
601 );
602 }
603
604 #[test]
605 fn parse_max_width() {
606 let mq = parse_mq("(max-width: 600px)");
607 assert_eq!(
608 mq.queries[0].features[0],
609 MediaFeature::Width(Comparison::Lte, 600.0)
610 );
611 }
612
613 #[test]
614 fn parse_screen_and_feature() {
615 let mq = parse_mq("screen and (max-width: 600px)");
616 assert_eq!(mq.queries[0].media_type, MediaType::Screen);
617 assert_eq!(mq.queries[0].features.len(), 1);
618 assert_eq!(
619 mq.queries[0].features[0],
620 MediaFeature::Width(Comparison::Lte, 600.0)
621 );
622 }
623
624 #[test]
625 fn parse_multiple_features() {
626 let mq = parse_mq("screen and (min-width: 400px) and (max-width: 800px)");
627 assert_eq!(mq.queries[0].features.len(), 2);
628 assert_eq!(
629 mq.queries[0].features[0],
630 MediaFeature::Width(Comparison::Gte, 400.0)
631 );
632 assert_eq!(
633 mq.queries[0].features[1],
634 MediaFeature::Width(Comparison::Lte, 800.0)
635 );
636 }
637
638 #[test]
639 fn parse_comma_separated() {
640 let mq = parse_mq("screen, print");
641 assert_eq!(mq.queries.len(), 2);
642 assert_eq!(mq.queries[0].media_type, MediaType::Screen);
643 assert_eq!(mq.queries[1].media_type, MediaType::Print);
644 }
645
646 #[test]
647 fn parse_range_gte() {
648 let mq = parse_mq("(width >= 768px)");
649 assert_eq!(
650 mq.queries[0].features[0],
651 MediaFeature::Width(Comparison::Gte, 768.0)
652 );
653 }
654
655 #[test]
656 fn parse_range_lt() {
657 let mq = parse_mq("(width < 600px)");
658 assert_eq!(
659 mq.queries[0].features[0],
660 MediaFeature::Width(Comparison::Lt, 600.0)
661 );
662 }
663
664 #[test]
665 fn parse_range_reversed() {
666 let mq = parse_mq("(768px <= width)");
667 assert_eq!(
668 mq.queries[0].features[0],
669 MediaFeature::Width(Comparison::Gte, 768.0)
670 );
671 }
672
673 #[test]
674 fn parse_double_range() {
675 let mq = parse_mq("(400px <= width <= 1200px)");
676 assert_eq!(mq.queries[0].features.len(), 2);
677 assert_eq!(
678 mq.queries[0].features[0],
679 MediaFeature::Width(Comparison::Gte, 400.0)
680 );
681 assert_eq!(
682 mq.queries[0].features[1],
683 MediaFeature::Width(Comparison::Lte, 1200.0)
684 );
685 }
686
687 #[test]
688 fn parse_prefers_color_scheme_dark() {
689 let mq = parse_mq("(prefers-color-scheme: dark)");
690 assert_eq!(
691 mq.queries[0].features[0],
692 MediaFeature::PrefersColorScheme(ColorScheme::Dark)
693 );
694 }
695
696 #[test]
697 fn parse_prefers_color_scheme_light() {
698 let mq = parse_mq("(prefers-color-scheme: light)");
699 assert_eq!(
700 mq.queries[0].features[0],
701 MediaFeature::PrefersColorScheme(ColorScheme::Light)
702 );
703 }
704
705 #[test]
706 fn parse_prefers_reduced_motion() {
707 let mq = parse_mq("(prefers-reduced-motion: reduce)");
708 assert_eq!(
709 mq.queries[0].features[0],
710 MediaFeature::PrefersReducedMotion(ReducedMotion::Reduce)
711 );
712 }
713
714 #[test]
715 fn parse_height_features() {
716 let mq = parse_mq("(min-height: 500px)");
717 assert_eq!(
718 mq.queries[0].features[0],
719 MediaFeature::Height(Comparison::Gte, 500.0)
720 );
721 }
722
723 // --- Evaluation ---
724
725 #[test]
726 fn eval_screen_matches() {
727 let mq = parse_mq("screen");
728 assert!(mq.evaluate(&ctx(800.0, 600.0)));
729 }
730
731 #[test]
732 fn eval_print_does_not_match() {
733 let mq = parse_mq("print");
734 assert!(!mq.evaluate(&ctx(800.0, 600.0)));
735 }
736
737 #[test]
738 fn eval_not_print_matches() {
739 let mq = parse_mq("not print");
740 assert!(mq.evaluate(&ctx(800.0, 600.0)));
741 }
742
743 #[test]
744 fn eval_min_width_matches() {
745 let mq = parse_mq("(min-width: 768px)");
746 assert!(mq.evaluate(&ctx(1024.0, 768.0)));
747 assert!(!mq.evaluate(&ctx(500.0, 768.0)));
748 }
749
750 #[test]
751 fn eval_max_width_matches() {
752 let mq = parse_mq("(max-width: 600px)");
753 assert!(mq.evaluate(&ctx(400.0, 300.0)));
754 assert!(!mq.evaluate(&ctx(800.0, 600.0)));
755 }
756
757 #[test]
758 fn eval_width_range() {
759 let mq = parse_mq("(width >= 768px)");
760 assert!(mq.evaluate(&ctx(768.0, 600.0)));
761 assert!(mq.evaluate(&ctx(1024.0, 600.0)));
762 assert!(!mq.evaluate(&ctx(767.0, 600.0)));
763 }
764
765 #[test]
766 fn eval_double_range() {
767 let mq = parse_mq("(400px <= width <= 1200px)");
768 assert!(mq.evaluate(&ctx(800.0, 600.0)));
769 assert!(!mq.evaluate(&ctx(300.0, 600.0)));
770 assert!(!mq.evaluate(&ctx(1300.0, 600.0)));
771 }
772
773 #[test]
774 fn eval_comma_or_semantics() {
775 let mq = parse_mq("screen, print");
776 assert!(mq.evaluate(&ctx(800.0, 600.0)));
777
778 let mq2 = parse_mq("print, (min-width: 768px)");
779 assert!(mq2.evaluate(&ctx(1024.0, 768.0)));
780 assert!(!mq2.evaluate(&ctx(500.0, 400.0)));
781 }
782
783 #[test]
784 fn eval_prefers_color_scheme_dark() {
785 let mq = parse_mq("(prefers-color-scheme: dark)");
786 assert!(mq.evaluate(&dark_ctx(800.0, 600.0)));
787 assert!(!mq.evaluate(&ctx(800.0, 600.0)));
788 }
789
790 #[test]
791 fn eval_screen_and_min_width() {
792 let mq = parse_mq("screen and (min-width: 768px)");
793 assert!(mq.evaluate(&ctx(1024.0, 768.0)));
794 assert!(!mq.evaluate(&ctx(500.0, 400.0)));
795 }
796
797 #[test]
798 fn eval_not_screen_does_not_match() {
799 let mq = parse_mq("not screen");
800 assert!(!mq.evaluate(&ctx(800.0, 600.0)));
801 }
802
803 #[test]
804 fn eval_only_screen_matches() {
805 let mq = parse_mq("only screen");
806 assert!(mq.evaluate(&ctx(800.0, 600.0)));
807 }
808
809 #[test]
810 fn eval_empty_query_list_matches() {
811 let mq = MediaQueryList {
812 queries: Vec::new(),
813 };
814 assert!(mq.evaluate(&ctx(800.0, 600.0)));
815 }
816
817 #[test]
818 fn eval_all_type_matches() {
819 let mq = parse_mq("all");
820 assert!(mq.evaluate(&ctx(800.0, 600.0)));
821 }
822
823 #[test]
824 fn eval_height_features() {
825 let mq = parse_mq("(min-height: 500px)");
826 assert!(mq.evaluate(&ctx(800.0, 600.0)));
827 assert!(!mq.evaluate(&ctx(800.0, 400.0)));
828 }
829
830 #[test]
831 fn eval_multiple_and_features() {
832 let mq = parse_mq("screen and (min-width: 400px) and (max-width: 1200px)");
833 assert!(mq.evaluate(&ctx(800.0, 600.0)));
834 assert!(!mq.evaluate(&ctx(300.0, 600.0)));
835 assert!(!mq.evaluate(&ctx(1300.0, 600.0)));
836 }
837
838 #[test]
839 fn eval_reduced_motion() {
840 let mq = parse_mq("(prefers-reduced-motion: reduce)");
841 let reduced = MediaContext {
842 viewport_width: 800.0,
843 viewport_height: 600.0,
844 color_scheme: ColorScheme::Light,
845 reduced_motion: ReducedMotion::Reduce,
846 };
847 assert!(mq.evaluate(&reduced));
848 assert!(!mq.evaluate(&ctx(800.0, 600.0)));
849 }
850}