use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::generated::catalog as g;
pub const STYLE_STATES: &[&str] = g::STYLE_STATES;
const SIZE_FEATURES: [&str; 4] = ["min-width", "max-width", "min-height", "max-height"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MediaCondition {
pub feature: String,
pub value: String,
}
fn digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn decimal(s: &str) -> bool {
match s.split_once('.') {
Some((i, f)) => digits(i) && digits(f),
None => digits(s),
}
}
pub fn breakpoint_ref(value: &str) -> Option<&str> {
let name = value.strip_prefix("$breakpoint.")?;
(!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric())).then_some(name)
}
pub fn parse_media_condition(key: &str) -> Option<MediaCondition> {
let inner = key.strip_prefix("@media (")?.strip_suffix(')')?;
let (feature, value) = inner.split_once(": ")?;
let ok = match feature {
f if SIZE_FEATURES.contains(&f) => value.strip_suffix("px").is_some_and(decimal) || breakpoint_ref(value).is_some(),
"orientation" => matches!(value, "portrait" | "landscape"),
"hover" => matches!(value, "hover" | "none"),
"prefers-reduced-motion" => matches!(value, "reduce" | "no-preference"),
"form-factor" => FormFactor::parse(value).is_some(),
_ => false,
};
ok.then(|| MediaCondition { feature: feature.to_string(), value: value.to_string() })
}
pub fn is_media_key(key: &str) -> bool {
parse_media_condition(key).is_some()
}
pub fn is_condition_key(key: &str) -> bool {
is_media_key(key) || STYLE_STATES.contains(&key)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FormFactor {
Phone,
Wide,
}
impl FormFactor {
pub fn parse(s: &str) -> Option<FormFactor> {
match s {
"phone" => Some(FormFactor::Phone),
"wide" => Some(FormFactor::Wide),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
FormFactor::Phone => "phone",
FormFactor::Wide => "wide",
}
}
}
pub const DEFAULT_FORM_FACTOR_BREAKPOINT: f32 = 768.0;
pub fn form_factor_of(ctx: &ConditionContext) -> FormFactor {
if let Some(f) = ctx.form_factor {
return f;
}
let md = ctx.breakpoints.get("md").copied().unwrap_or(DEFAULT_FORM_FACTOR_BREAKPOINT);
if ctx.width < md {
FormFactor::Phone
} else {
FormFactor::Wide
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConditionContext {
pub width: f32,
#[serde(default)]
pub height: Option<f32>,
#[serde(default)]
pub hover: bool,
#[serde(default)]
pub reduced_motion: bool,
#[serde(default)]
pub states: Vec<String>,
#[serde(default)]
pub breakpoints: IndexMap<String, f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub form_factor: Option<FormFactor>,
}
pub fn default_breakpoints() -> IndexMap<String, f32> {
[("sm", 640.0), ("md", 768.0), ("lg", 1024.0), ("xl", 1280.0)].into_iter().map(|(k, v)| (k.to_string(), v)).collect()
}
fn size_value(value: &str, breakpoints: &IndexMap<String, f32>) -> Option<f32> {
if let Some(name) = breakpoint_ref(value) {
return breakpoints.get(name).copied();
}
value.strip_suffix("px").unwrap_or(value).parse::<f32>().ok()
}
pub fn media_matches(condition: &MediaCondition, ctx: &ConditionContext) -> bool {
match condition.feature.as_str() {
f if SIZE_FEATURES.contains(&f) => {
let Some(n) = size_value(&condition.value, &ctx.breakpoints) else { return false };
let size = if f.ends_with("width") { Some(ctx.width) } else { ctx.height };
let Some(size) = size else { return false };
if f.starts_with("min") {
size >= n
} else {
size < n
}
}
"orientation" => {
let portrait = ctx.height.is_some_and(|h| h >= ctx.width);
condition.value == if portrait { "portrait" } else { "landscape" }
}
"hover" => condition.value == if ctx.hover { "hover" } else { "none" },
"prefers-reduced-motion" => condition.value == if ctx.reduced_motion { "reduce" } else { "no-preference" },
"form-factor" => condition.value == form_factor_of(ctx).as_str(),
_ => false,
}
}
pub fn resolve_conditions(style: &Map<String, Value>, ctx: &ConditionContext) -> Map<String, Value> {
let mut out = Map::new();
for (k, v) in style {
if !is_condition_key(k) {
out.insert(k.clone(), v.clone());
}
}
for (k, v) in style {
let Some(condition) = parse_media_condition(k) else { continue };
let Some(block) = v.as_object() else { continue };
if media_matches(&condition, ctx) {
for (bk, bv) in block {
out.insert(bk.clone(), bv.clone());
}
}
}
for key in STYLE_STATES {
let state = &key[1..];
if !ctx.states.iter().any(|s| s == state) {
continue;
}
if let Some(block) = style.get(*key).and_then(Value::as_object) {
for (bk, bv) in block {
out.insert(bk.clone(), bv.clone());
}
}
}
out
}
pub fn active_breakpoint(width: f32, breakpoints: &IndexMap<String, f32>) -> Option<String> {
let mut out = None;
for name in crate::macros::BREAKPOINTS.iter() {
if breakpoints.get(name).is_some_and(|px| width >= *px) {
out = Some(name.clone());
}
}
out
}
pub fn depends_on_height(style: &Map<String, Value>) -> bool {
style.keys().filter_map(|k| parse_media_condition(k)).any(|c| c.feature.ends_with("height") || c.feature == "orientation")
}
pub fn has_state_blocks(style: &Map<String, Value>) -> bool {
STYLE_STATES.iter().any(|k| style.contains_key(*k))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_grammar() {
for key in [
"@media (min-width: 600px)",
"@media (max-width: 37.5px)",
"@media (min-height: $breakpoint.md)",
"@media (orientation: portrait)",
"@media (hover: none)",
"@media (prefers-reduced-motion: reduce)",
"@media (form-factor: phone)",
"@media (form-factor: wide)",
] {
assert!(is_media_key(key), "{key}");
}
for key in ["@media (form-factor: tablet)", "@media (min-width: 600)", "@media (width: 600px)", "@media (orientation: up)", "@media (min-width: $spacing.md)", "@media screen", ":focus"] {
assert!(!is_media_key(key), "{key}");
}
assert!(is_condition_key(":hover") && is_condition_key(":focus-visible") && is_condition_key(":pressed"));
}
#[test]
fn max_is_strict_and_unknown_tokens_never_match() {
let style = json!({"gap": 4, "@media (max-width: $breakpoint.md)": {"gap": 1}, "@media (min-width: $breakpoint.huge)": {"gap": 99}});
let ctx = |w: f32| ConditionContext { width: w, breakpoints: default_breakpoints(), ..Default::default() };
assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0))["gap"], json!(1));
assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0))["gap"], json!(4));
assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(5000.0))["gap"], json!(4));
assert_eq!(active_breakpoint(700.0, &default_breakpoints()).as_deref(), Some("sm"));
assert_eq!(active_breakpoint(500.0, &default_breakpoints()), None);
assert_eq!(active_breakpoint(1280.0, &default_breakpoints()).as_deref(), Some("xl"));
}
#[test]
fn form_factor_from_the_host_else_the_width() {
let style = json!({"gap": 4, "@media (form-factor: phone)": {"gap": 8}, "@media (form-factor: wide)": {"gap": 16}});
let ctx = |w: f32, f: Option<FormFactor>| ConditionContext { width: w, breakpoints: default_breakpoints(), form_factor: f, ..Default::default() };
assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0, None))["gap"], json!(8));
assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0, None))["gap"], json!(16));
assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(1200.0, Some(FormFactor::Phone)))["gap"], json!(8));
assert_eq!(form_factor_of(&ConditionContext { width: 500.0, ..Default::default() }), FormFactor::Phone);
}
}