use log::warn;
use super::values::{Length, Viewport};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Orientation {
Landscape,
Portrait,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct MediaQuery {
pub negated: bool,
pub media_type: Option<bool>,
pub max_width: Option<f32>,
pub min_width: Option<f32>,
pub max_height: Option<f32>,
pub min_height: Option<f32>,
pub orientation: Option<Orientation>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MediaQueryList(pub Vec<MediaQuery>);
impl MediaQueryList {
pub fn matches(&self, viewport: &Viewport) -> bool {
viewport.cols > 0
&& viewport.rows > 0
&& self
.0
.iter()
.any(|q| q.matches(viewport.cols as f32, viewport.rows as f32))
}
}
impl MediaQuery {
fn matches(&self, cols: f32, rows: f32) -> bool {
let fits = |v: f32, max: Option<f32>, min: Option<f32>| {
max.is_none_or(|m| v <= m) && min.is_none_or(|m| v >= m)
};
let orientation_ok = match self.orientation {
Some(Orientation::Landscape) => cols >= rows,
Some(Orientation::Portrait) => rows > cols,
None => true,
};
let result = self.media_type.unwrap_or(true)
&& orientation_ok
&& fits(cols, self.max_width, self.min_width)
&& fits(rows, self.max_height, self.min_height);
result != self.negated
}
}
pub(crate) fn parse_media_query_list(text: &str) -> Option<MediaQueryList> {
let mut queries = Vec::new();
for part in split_top_level_commas(text) {
match parse_one_query(part) {
Some(query) => queries.push(query),
None => warn!("Ignoring unsupported @media query: {part}"),
}
}
if text.trim().is_empty() {
None
} else {
Some(MediaQueryList(queries))
}
}
fn parse_one_query(text: &str) -> Option<MediaQuery> {
let mut query = MediaQuery {
negated: false,
media_type: None,
max_width: None,
min_width: None,
max_height: None,
min_height: None,
orientation: None,
};
let mut rest = text.trim();
loop {
rest = rest.trim();
let Some((word, tail)) = leading_ident(rest) else {
break;
};
match word.to_ascii_lowercase().as_str() {
"only" => rest = tail,
"not" => {
query.negated = !query.negated;
rest = tail;
}
_ => break,
}
}
rest = rest.trim();
let (word, tail) = leading_ident(rest).unwrap_or_default();
match word.to_ascii_lowercase().as_str() {
"" => {}
"screen" | "all" => {
query.media_type = Some(true);
rest = tail;
}
"print" | "speech" => {
query.media_type = Some(false);
rest = tail;
}
_ => {
warn!("Unsupported @media type: {word}");
return None;
}
}
loop {
rest = rest.trim();
if rest.is_empty() {
break;
}
match leading_ident(rest) {
Some((word, tail)) => {
if !word.eq_ignore_ascii_case("and") {
return None;
}
let (feature_text, remainder) = parenthesized_group(tail)?;
parse_feature(feature_text, &mut query)?;
rest = remainder;
}
None => {
let (feature_text, remainder) = parenthesized_group(rest)?;
parse_feature(feature_text, &mut query)?;
rest = remainder;
}
}
}
Some(query)
}
fn leading_ident(text: &str) -> Option<(&str, &str)> {
let end = text
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
.unwrap_or(text.len());
if end == 0 {
return None;
}
Some((&text[..end], &text[end..]))
}
fn parenthesized_group(text: &str) -> Option<(&str, &str)> {
let text = text.trim();
let inner = text.strip_prefix('(')?;
let mut depth = 1usize;
for (i, ch) in inner.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some((&inner[..i], &inner[i + 1..]));
}
}
_ => {}
}
}
None
}
fn parse_feature(text: &str, query: &mut MediaQuery) -> Option<()> {
let inner = text.trim();
let (name, value) = if let Some((n, v)) = inner.split_once(':') {
(n.trim().to_ascii_lowercase(), Some(v.trim()))
} else {
(inner.to_ascii_lowercase(), None)
};
match name.as_str() {
"max-width" | "min-width" | "max-height" | "min-height" => {
let cells = feature_value_cells(value?)?;
let slot = match name.as_str() {
"max-width" => &mut query.max_width,
"min-width" => &mut query.min_width,
"max-height" => &mut query.max_height,
_ => &mut query.min_height,
};
*slot = Some(match *slot {
Some(prev) if name.starts_with("max-") => prev.min(cells),
Some(prev) => prev.max(cells),
None => cells,
});
Some(())
}
"orientation" => {
let keyword = value?.to_ascii_lowercase();
let orientation = match keyword.as_str() {
"landscape" => Orientation::Landscape,
"portrait" => Orientation::Portrait,
_ => {
warn!("Unsupported @media orientation: {keyword}");
return None;
}
};
if query.orientation.is_some_and(|o| o != orientation) {
return None;
}
query.orientation = Some(orientation);
Some(())
}
_ => {
warn!("Unsupported @media feature: {name}");
None
}
}
}
fn feature_value_cells(text: &str) -> Option<f32> {
match Length::parse(text) {
Some(Length::Px(n)) | Some(Length::Ch(n)) => Some(n),
Some(other) => {
warn!("Unsupported @media value unit: {other:?}");
None
}
None => {
warn!("Invalid @media value: {text}");
None
}
}
}
fn split_top_level_commas(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut last = 0usize;
for (i, ch) in s.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
let part = s[last..i].trim();
if !part.is_empty() {
out.push(part);
}
last = i + 1;
}
_ => {}
}
}
if let Some(tail) = s.get(last..) {
let tail = tail.trim();
if !tail.is_empty() {
out.push(tail);
}
}
out
}