hpx-browser 2.4.24

Headless browser engine for hpx: HTML parsing, rendering, CDP, and canvas support
Documentation
use crate::css_parser::{ComponentValue, Token, TokenKind};

#[derive(Debug, Clone)]
pub struct MediaFeatures {
    pub width: f64,
    pub height: f64,
    pub device_pixel_ratio: f64,
    pub prefers_color_scheme: ColorScheme,
    pub prefers_reduced_motion: ReducedMotion,
    pub pointer: PointerType,
    pub hover: HoverCapability,
    pub scripting: Scripting,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorScheme {
    Light,
    Dark,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReducedMotion {
    NoPreference,
    Reduce,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PointerType {
    None,
    Coarse,
    Fine,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HoverCapability {
    None,
    Hover,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scripting {
    None,
    Enabled,
}

impl Default for MediaFeatures {
    fn default() -> Self {
        Self {
            width: 1920.0,
            height: 1080.0,
            device_pixel_ratio: 1.0,
            prefers_color_scheme: ColorScheme::Light,
            prefers_reduced_motion: ReducedMotion::NoPreference,
            pointer: PointerType::Fine,
            hover: HoverCapability::Hover,
            scripting: Scripting::Enabled,
        }
    }
}

pub fn evaluate_media_query(prelude: &[ComponentValue<'_>], features: &MediaFeatures) -> bool {
    let text = prelude_to_text(prelude);
    let text = text.trim().to_ascii_lowercase();

    if text.is_empty() || text == "all" {
        return true;
    }

    if text == "screen" {
        return true;
    }

    if text == "print" {
        return false;
    }

    if text.contains(',') {
        return text
            .split(',')
            .any(|part| evaluate_single_query(part.trim(), features));
    }

    evaluate_single_query(&text, features)
}

fn evaluate_single_query(query: &str, features: &MediaFeatures) -> bool {
    let query = query.trim();

    let query = query
        .strip_prefix("screen and ")
        .or_else(|| query.strip_prefix("all and "))
        .unwrap_or(query);

    if let Some(inner) = query.strip_prefix("not ") {
        return !evaluate_single_query(inner.trim(), features);
    }

    let query = query.trim_start_matches('(').trim_end_matches(')');

    if let Some((feature, value)) = query.split_once(':') {
        return evaluate_feature(feature.trim(), value.trim(), features);
    }

    if let Some((feature, value)) = query.split_once(">=") {
        return evaluate_range(feature.trim(), ">=", value.trim(), features);
    }
    if let Some((feature, value)) = query.split_once("<=") {
        return evaluate_range(feature.trim(), "<=", value.trim(), features);
    }
    if let Some((feature, value)) = query.split_once('>') {
        return evaluate_range(feature.trim(), ">", value.trim(), features);
    }
    if let Some((feature, value)) = query.split_once('<') {
        return evaluate_range(feature.trim(), "<", value.trim(), features);
    }

    match query {
        "hover" => features.hover == HoverCapability::Hover,
        "pointer" => features.pointer != PointerType::None,
        "color" => true,
        "scripting" => features.scripting == Scripting::Enabled,
        _ => true,
    }
}

fn evaluate_feature(feature: &str, value: &str, features: &MediaFeatures) -> bool {
    match feature {
        "min-width" => parse_px(value).is_some_and(|v| features.width >= v),
        "max-width" => parse_px(value).is_some_and(|v| features.width <= v),
        "min-height" => parse_px(value).is_some_and(|v| features.height >= v),
        "max-height" => parse_px(value).is_some_and(|v| features.height <= v),
        "width" => parse_px(value).is_some_and(|v| (features.width - v).abs() < 0.01),
        "height" => parse_px(value).is_some_and(|v| (features.height - v).abs() < 0.01),
        "prefers-color-scheme" => match value {
            "dark" => features.prefers_color_scheme == ColorScheme::Dark,
            "light" => features.prefers_color_scheme == ColorScheme::Light,
            _ => false,
        },
        "prefers-reduced-motion" => match value {
            "reduce" => features.prefers_reduced_motion == ReducedMotion::Reduce,
            "no-preference" => features.prefers_reduced_motion == ReducedMotion::NoPreference,
            _ => false,
        },
        "pointer" => match value {
            "fine" => features.pointer == PointerType::Fine,
            "coarse" => features.pointer == PointerType::Coarse,
            "none" => features.pointer == PointerType::None,
            _ => false,
        },
        "hover" => match value {
            "hover" => features.hover == HoverCapability::Hover,
            "none" => features.hover == HoverCapability::None,
            _ => false,
        },
        _ => true,
    }
}

fn evaluate_range(feature: &str, op: &str, value: &str, features: &MediaFeatures) -> bool {
    let feature_val = match feature {
        "width" => features.width,
        "height" => features.height,
        _ => return true,
    };
    let target = match parse_px(value) {
        Some(v) => v,
        None => return true,
    };
    match op {
        ">" => feature_val > target,
        ">=" => feature_val >= target,
        "<" => feature_val < target,
        "<=" => feature_val <= target,
        _ => true,
    }
}

fn parse_px(s: &str) -> Option<f64> {
    let s = s.trim().trim_end_matches("px").trim();
    s.parse::<f64>().ok()
}

fn prelude_to_text(prelude: &[ComponentValue<'_>]) -> String {
    let mut s = String::new();
    for cv in prelude {
        match cv {
            ComponentValue::Token(Token { kind, .. }) => match kind {
                TokenKind::Ident(v) => s.push_str(v),
                TokenKind::Number { value, .. } => s.push_str(&value.to_string()),
                TokenKind::Dimension { value, unit, .. } => {
                    s.push_str(&value.to_string());
                    s.push_str(unit);
                }
                TokenKind::Whitespace => s.push(' '),
                TokenKind::Colon => s.push(':'),
                TokenKind::Comma => s.push(','),
                TokenKind::Delim(c) => s.push(*c),
                _ => {}
            },
            ComponentValue::SimpleBlock(b) => {
                s.push(b.token);
                s.push_str(&prelude_to_text(&b.value));
                match b.token {
                    '{' => s.push('}'),
                    '[' => s.push(']'),
                    '(' => s.push(')'),
                    _ => {}
                }
            }
            ComponentValue::Function(f) => {
                s.push_str(f.name);
                s.push('(');
                s.push_str(&prelude_to_text(&f.arguments));
                s.push(')');
            }
        }
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn features() -> MediaFeatures {
        MediaFeatures::default()
    }

    fn eval(css: &str) -> bool {
        let input = format!("@media {} {{}}", css);
        let (stylesheet, _) = crate::css_parser::parse_stylesheet(&input);
        if let Some(crate::css_parser::Rule::At(at)) = stylesheet.rules.first() {
            evaluate_media_query(&at.prelude, &features())
        } else {
            panic!("Expected @media rule");
        }
    }

    #[test]
    fn screen() {
        assert!(eval("screen"));
    }

    #[test]
    fn print_false() {
        assert!(!eval("print"));
    }

    #[test]
    fn min_width_matches() {
        assert!(eval("(min-width: 768px)"));
    }

    #[test]
    fn min_width_no_match() {
        assert!(!eval("(min-width: 2000px)"));
    }

    #[test]
    fn prefers_color_scheme_light() {
        assert!(eval("(prefers-color-scheme: light)"));
    }

    #[test]
    fn range_syntax() {
        assert!(eval("(width > 768px)"));
    }
}