1use indexmap::IndexMap;
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::generated::catalog as g;
17
18pub const STYLE_STATES: &[&str] = g::STYLE_STATES;
20
21const SIZE_FEATURES: [&str; 4] = ["min-width", "max-width", "min-height", "max-height"];
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct MediaCondition {
27 pub feature: String,
29 pub value: String,
32}
33
34fn digits(s: &str) -> bool {
35 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
36}
37
38fn decimal(s: &str) -> bool {
40 match s.split_once('.') {
41 Some((i, f)) => digits(i) && digits(f),
42 None => digits(s),
43 }
44}
45
46pub fn breakpoint_ref(value: &str) -> Option<&str> {
48 let name = value.strip_prefix("$breakpoint.")?;
49 (!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric())).then_some(name)
50}
51
52pub fn parse_media_condition(key: &str) -> Option<MediaCondition> {
55 let inner = key.strip_prefix("@media (")?.strip_suffix(')')?;
56 let (feature, value) = inner.split_once(": ")?;
57 let ok = match feature {
58 f if SIZE_FEATURES.contains(&f) => value.strip_suffix("px").is_some_and(decimal) || breakpoint_ref(value).is_some(),
59 "orientation" => matches!(value, "portrait" | "landscape"),
60 "hover" => matches!(value, "hover" | "none"),
61 "prefers-reduced-motion" => matches!(value, "reduce" | "no-preference"),
62 _ => false,
63 };
64 ok.then(|| MediaCondition { feature: feature.to_string(), value: value.to_string() })
65}
66
67pub fn is_media_key(key: &str) -> bool {
69 parse_media_condition(key).is_some()
70}
71
72pub fn is_condition_key(key: &str) -> bool {
74 is_media_key(key) || STYLE_STATES.contains(&key)
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct ConditionContext {
83 pub width: f32,
84 #[serde(default)]
85 pub height: Option<f32>,
86 #[serde(default)]
88 pub hover: bool,
89 #[serde(default)]
91 pub reduced_motion: bool,
92 #[serde(default)]
95 pub states: Vec<String>,
96 #[serde(default)]
98 pub breakpoints: IndexMap<String, f32>,
99}
100
101pub fn default_breakpoints() -> IndexMap<String, f32> {
103 [("sm", 640.0), ("md", 768.0), ("lg", 1024.0), ("xl", 1280.0)].into_iter().map(|(k, v)| (k.to_string(), v)).collect()
104}
105
106fn size_value(value: &str, breakpoints: &IndexMap<String, f32>) -> Option<f32> {
107 if let Some(name) = breakpoint_ref(value) {
108 return breakpoints.get(name).copied();
109 }
110 value.strip_suffix("px").unwrap_or(value).parse::<f32>().ok()
111}
112
113pub fn media_matches(condition: &MediaCondition, ctx: &ConditionContext) -> bool {
115 match condition.feature.as_str() {
116 f if SIZE_FEATURES.contains(&f) => {
117 let Some(n) = size_value(&condition.value, &ctx.breakpoints) else { return false };
118 let size = if f.ends_with("width") { Some(ctx.width) } else { ctx.height };
119 let Some(size) = size else { return false };
120 if f.starts_with("min") {
121 size >= n
122 } else {
123 size < n
124 }
125 }
126 "orientation" => {
127 let portrait = ctx.height.is_some_and(|h| h >= ctx.width);
128 condition.value == if portrait { "portrait" } else { "landscape" }
129 }
130 "hover" => condition.value == if ctx.hover { "hover" } else { "none" },
131 "prefers-reduced-motion" => condition.value == if ctx.reduced_motion { "reduce" } else { "no-preference" },
132 _ => false,
133 }
134}
135
136pub fn resolve_conditions(style: &Map<String, Value>, ctx: &ConditionContext) -> Map<String, Value> {
140 let mut out = Map::new();
141 for (k, v) in style {
142 if !is_condition_key(k) {
143 out.insert(k.clone(), v.clone());
144 }
145 }
146 for (k, v) in style {
147 let Some(condition) = parse_media_condition(k) else { continue };
148 let Some(block) = v.as_object() else { continue };
149 if media_matches(&condition, ctx) {
150 for (bk, bv) in block {
151 out.insert(bk.clone(), bv.clone());
152 }
153 }
154 }
155 for key in STYLE_STATES {
156 let state = &key[1..];
157 if !ctx.states.iter().any(|s| s == state) {
158 continue;
159 }
160 if let Some(block) = style.get(*key).and_then(Value::as_object) {
161 for (bk, bv) in block {
162 out.insert(bk.clone(), bv.clone());
163 }
164 }
165 }
166 out
167}
168
169pub fn active_breakpoint(width: f32, breakpoints: &IndexMap<String, f32>) -> Option<String> {
172 let mut out = None;
173 for name in crate::macros::BREAKPOINTS.iter() {
174 if breakpoints.get(name).is_some_and(|px| width >= *px) {
175 out = Some(name.clone());
176 }
177 }
178 out
179}
180
181pub fn depends_on_height(style: &Map<String, Value>) -> bool {
184 style.keys().filter_map(|k| parse_media_condition(k)).any(|c| c.feature.ends_with("height") || c.feature == "orientation")
185}
186
187pub fn has_state_blocks(style: &Map<String, Value>) -> bool {
190 STYLE_STATES.iter().any(|k| style.contains_key(*k))
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use serde_json::json;
197
198 #[test]
199 fn the_grammar() {
200 for key in [
201 "@media (min-width: 600px)",
202 "@media (max-width: 37.5px)",
203 "@media (min-height: $breakpoint.md)",
204 "@media (orientation: portrait)",
205 "@media (hover: none)",
206 "@media (prefers-reduced-motion: reduce)",
207 ] {
208 assert!(is_media_key(key), "{key}");
209 }
210 for key in ["@media (min-width: 600)", "@media (width: 600px)", "@media (orientation: up)", "@media (min-width: $spacing.md)", "@media screen", ":focus"] {
211 assert!(!is_media_key(key), "{key}");
212 }
213 assert!(is_condition_key(":hover") && is_condition_key(":focus-visible") && is_condition_key(":pressed"));
214 }
215
216 #[test]
217 fn max_is_strict_and_unknown_tokens_never_match() {
218 let style = json!({"gap": 4, "@media (max-width: $breakpoint.md)": {"gap": 1}, "@media (min-width: $breakpoint.huge)": {"gap": 99}});
219 let ctx = |w: f32| ConditionContext { width: w, breakpoints: default_breakpoints(), ..Default::default() };
220 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0))["gap"], json!(1));
221 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0))["gap"], json!(4));
222 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(5000.0))["gap"], json!(4));
223 assert_eq!(active_breakpoint(700.0, &default_breakpoints()).as_deref(), Some("sm"));
224 assert_eq!(active_breakpoint(500.0, &default_breakpoints()), None);
225 assert_eq!(active_breakpoint(1280.0, &default_breakpoints()).as_deref(), Some("xl"));
226 }
227}