Skip to main content

takumi_css/style/
media_query.rs

1use cssparser::*;
2use std::rc::Rc;
3use taffy::Size;
4
5use crate::{
6  Viewport,
7  error::StyleSheetParseError,
8  style::{CalcArena, FromCss, LengthDefaultsToZero, SizingContext},
9};
10
11#[derive(Debug, Clone, PartialEq)]
12enum MediaType {
13  All,
14  Screen,
15  Unsupported(String),
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum MediaFeatureComparison {
20  Equal,
21  Min,
22  Max,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum MediaOrientation {
27  Portrait,
28  Landscape,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32enum MediaFeature {
33  Width(MediaFeatureComparison, LengthDefaultsToZero),
34  Height(MediaFeatureComparison, LengthDefaultsToZero),
35  Orientation(MediaOrientation),
36}
37
38#[derive(Debug, Clone, PartialEq)]
39struct MediaQuery {
40  media_type: MediaType,
41  features: Vec<MediaFeature>,
42  negated: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Default)]
46pub struct MediaQueryList {
47  queries: Vec<MediaQuery>,
48}
49
50impl MediaFeature {
51  fn matches(&self, viewport: Viewport, sizing: &SizingContext) -> bool {
52    match self {
53      Self::Width(comparison, value) => viewport.size.width.is_some_and(|width| {
54        compare_media_feature(*comparison, width as f32, value.to_px(sizing, width as f32))
55      }),
56      Self::Height(comparison, value) => viewport.size.height.is_some_and(|height| {
57        compare_media_feature(
58          *comparison,
59          height as f32,
60          value.to_px(sizing, height as f32),
61        )
62      }),
63      Self::Orientation(MediaOrientation::Portrait) => viewport
64        .size
65        .width
66        .zip(viewport.size.height)
67        .is_some_and(|(width, height)| height >= width),
68      Self::Orientation(MediaOrientation::Landscape) => viewport
69        .size
70        .width
71        .zip(viewport.size.height)
72        .is_some_and(|(width, height)| width > height),
73    }
74  }
75}
76
77impl MediaQuery {
78  fn matches(&self, viewport: Viewport, sizing: &SizingContext) -> bool {
79    let media_type_matches = match &self.media_type {
80      MediaType::All | MediaType::Screen => true,
81      MediaType::Unsupported(_) => false,
82    };
83
84    let mut is_match = media_type_matches
85      && self
86        .features
87        .iter()
88        .all(|feature| feature.matches(viewport, sizing));
89
90    if self.negated {
91      is_match = !is_match;
92    }
93
94    is_match
95  }
96}
97
98impl MediaQueryList {
99  pub(crate) fn parse<'i, 't>(
100    input: &mut Parser<'i, 't>,
101  ) -> Result<Self, ParseError<'i, StyleSheetParseError>> {
102    Ok(Self {
103      queries: input.parse_comma_separated(parse_media_query)?,
104    })
105  }
106
107  pub fn matches(&self, viewport: Viewport) -> bool {
108    if self.queries.is_empty() {
109      return true;
110    }
111
112    let sizing = SizingContext {
113      viewport,
114      container_size: Size::NONE,
115      font_size: viewport.font_size,
116      root_font_size: None,
117      line_height: viewport.font_size,
118      root_line_height: Some(viewport.font_size),
119      calc_arena: Rc::new(CalcArena::default()),
120    };
121
122    self
123      .queries
124      .iter()
125      .any(|query| query.matches(viewport, &sizing))
126  }
127}
128
129fn compare_media_feature(comparison: MediaFeatureComparison, actual: f32, expected: f32) -> bool {
130  const MEDIA_FEATURE_EQUALITY_TOLERANCE: f32 = 0.5;
131
132  match comparison {
133    MediaFeatureComparison::Equal => (actual - expected).abs() <= MEDIA_FEATURE_EQUALITY_TOLERANCE,
134    MediaFeatureComparison::Min => actual >= expected,
135    MediaFeatureComparison::Max => actual <= expected,
136  }
137}
138
139fn parse_media_query<'i, 't>(
140  input: &mut Parser<'i, 't>,
141) -> Result<MediaQuery, ParseError<'i, StyleSheetParseError>> {
142  let mut negated = false;
143  let mut media_type = MediaType::All;
144  let mut features = Vec::new();
145  let mut has_explicit_media_type = false;
146
147  if let Ok(keyword) = input.try_parse(Parser::expect_ident_cloned) {
148    if keyword.eq_ignore_ascii_case("not") {
149      negated = true;
150    } else if !keyword.eq_ignore_ascii_case("only") {
151      media_type = parse_media_type(keyword);
152      has_explicit_media_type = true;
153    }
154
155    // A `not`/`only` modifier may be followed by an optional media type.
156    if !has_explicit_media_type && let Ok(name) = input.try_parse(Parser::expect_ident_cloned) {
157      media_type = parse_media_type(name);
158      has_explicit_media_type = true;
159    }
160  }
161
162  if input
163    .try_parse(|input| parse_media_feature_block(input, &mut features))
164    .is_ok()
165    || has_explicit_media_type
166  {
167    while input
168      .try_parse(|input| input.expect_ident_matching("and"))
169      .is_ok()
170    {
171      parse_media_feature_block(input, &mut features)?;
172    }
173  }
174
175  Ok(MediaQuery {
176    media_type,
177    features,
178    negated,
179  })
180}
181
182fn parse_media_type(name: CowRcStr<'_>) -> MediaType {
183  if name.eq_ignore_ascii_case("all") {
184    MediaType::All
185  } else if name.eq_ignore_ascii_case("screen") {
186    MediaType::Screen
187  } else {
188    MediaType::Unsupported(name.to_string())
189  }
190}
191
192fn parse_media_feature_block<'i, 't>(
193  input: &mut Parser<'i, 't>,
194  features: &mut Vec<MediaFeature>,
195) -> Result<(), ParseError<'i, StyleSheetParseError>> {
196  let location = input.current_source_location();
197  let token = input.next()?;
198  match token {
199    Token::ParenthesisBlock => input.parse_nested_block(|input| {
200      features.push(parse_media_feature(input)?);
201      Ok(())
202    }),
203    _ => Err(location.new_unexpected_token_error(token.clone())),
204  }
205}
206
207fn parse_media_feature<'i, 't>(
208  input: &mut Parser<'i, 't>,
209) -> Result<MediaFeature, ParseError<'i, StyleSheetParseError>> {
210  let feature_name = input.expect_ident_cloned()?;
211  input.expect_colon()?;
212
213  if feature_name.eq_ignore_ascii_case("orientation") {
214    let orientation = input.expect_ident_cloned()?;
215    return if orientation.eq_ignore_ascii_case("portrait") {
216      Ok(MediaFeature::Orientation(MediaOrientation::Portrait))
217    } else if orientation.eq_ignore_ascii_case("landscape") {
218      Ok(MediaFeature::Orientation(MediaOrientation::Landscape))
219    } else {
220      Err(
221        input.new_error(BasicParseErrorKind::UnexpectedToken(Token::Ident(
222          orientation,
223        ))),
224      )
225    };
226  }
227
228  let comparison = if feature_name.eq_ignore_ascii_case("min-width")
229    || feature_name.eq_ignore_ascii_case("min-height")
230  {
231    MediaFeatureComparison::Min
232  } else if feature_name.eq_ignore_ascii_case("max-width")
233    || feature_name.eq_ignore_ascii_case("max-height")
234  {
235    MediaFeatureComparison::Max
236  } else {
237    MediaFeatureComparison::Equal
238  };
239
240  let length = LengthDefaultsToZero::from_css(input).map_err(ParseError::into)?;
241
242  if feature_name.eq_ignore_ascii_case("width")
243    || feature_name.eq_ignore_ascii_case("min-width")
244    || feature_name.eq_ignore_ascii_case("max-width")
245  {
246    Ok(MediaFeature::Width(comparison, length))
247  } else if feature_name.eq_ignore_ascii_case("height")
248    || feature_name.eq_ignore_ascii_case("min-height")
249    || feature_name.eq_ignore_ascii_case("max-height")
250  {
251    Ok(MediaFeature::Height(comparison, length))
252  } else {
253    Err(input.new_custom_error(StyleSheetParseError::unsupported_media_feature()))
254  }
255}