1use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17
18use crate::generated::catalog as g;
19
20pub const STYLE_STATES: &[&str] = g::STYLE_STATES;
22
23const SIZE_FEATURES: [&str; 4] = ["min-width", "max-width", "min-height", "max-height"];
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct MediaCondition {
29 pub feature: String,
31 pub value: String,
34}
35
36fn digits(s: &str) -> bool {
37 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
38}
39
40fn decimal(s: &str) -> bool {
42 match s.split_once('.') {
43 Some((i, f)) => digits(i) && digits(f),
44 None => digits(s),
45 }
46}
47
48pub fn breakpoint_ref(value: &str) -> Option<&str> {
50 let name = value.strip_prefix("$breakpoint.")?;
51 (!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric())).then_some(name)
52}
53
54pub fn parse_media_condition(key: &str) -> Option<MediaCondition> {
57 let inner = key.strip_prefix("@media (")?.strip_suffix(')')?;
58 let (feature, value) = inner.split_once(": ")?;
59 let ok = match feature {
60 f if SIZE_FEATURES.contains(&f) => value.strip_suffix("px").is_some_and(decimal) || breakpoint_ref(value).is_some(),
61 "orientation" => matches!(value, "portrait" | "landscape"),
62 "hover" => matches!(value, "hover" | "none"),
63 "prefers-reduced-motion" => matches!(value, "reduce" | "no-preference"),
64 "form-factor" => FormFactor::parse(value).is_some(),
65 _ => false,
66 };
67 ok.then(|| MediaCondition { feature: feature.to_string(), value: value.to_string() })
68}
69
70pub fn is_media_key(key: &str) -> bool {
72 parse_media_condition(key).is_some()
73}
74
75pub fn is_condition_key(key: &str) -> bool {
77 is_media_key(key) || STYLE_STATES.contains(&key)
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum FormFactor {
85 Phone,
86 Wide,
87}
88
89impl FormFactor {
90 pub fn parse(s: &str) -> Option<FormFactor> {
91 match s {
92 "phone" => Some(FormFactor::Phone),
93 "wide" => Some(FormFactor::Wide),
94 _ => None,
95 }
96 }
97
98 pub fn as_str(self) -> &'static str {
99 match self {
100 FormFactor::Phone => "phone",
101 FormFactor::Wide => "wide",
102 }
103 }
104}
105
106pub const DEFAULT_FORM_FACTOR_BREAKPOINT: f32 = 768.0;
108
109pub fn form_factor_of(ctx: &ConditionContext) -> FormFactor {
112 if let Some(f) = ctx.form_factor {
113 return f;
114 }
115 let md = ctx.breakpoints.get("md").copied().unwrap_or(DEFAULT_FORM_FACTOR_BREAKPOINT);
116 if ctx.width < md {
117 FormFactor::Phone
118 } else {
119 FormFactor::Wide
120 }
121}
122
123#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct ConditionContext {
129 pub width: f32,
130 #[serde(default)]
131 pub height: Option<f32>,
132 #[serde(default)]
134 pub hover: bool,
135 #[serde(default)]
137 pub reduced_motion: bool,
138 #[serde(default)]
141 pub states: Vec<String>,
142 #[serde(default)]
144 pub breakpoints: IndexMap<String, f32>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub form_factor: Option<FormFactor>,
149}
150
151pub fn default_breakpoints() -> IndexMap<String, f32> {
153 [("sm", 640.0), ("md", 768.0), ("lg", 1024.0), ("xl", 1280.0)].into_iter().map(|(k, v)| (k.to_string(), v)).collect()
154}
155
156fn size_value(value: &str, breakpoints: &IndexMap<String, f32>) -> Option<f32> {
157 if let Some(name) = breakpoint_ref(value) {
158 return breakpoints.get(name).copied();
159 }
160 value.strip_suffix("px").unwrap_or(value).parse::<f32>().ok()
161}
162
163pub fn media_matches(condition: &MediaCondition, ctx: &ConditionContext) -> bool {
165 match condition.feature.as_str() {
166 f if SIZE_FEATURES.contains(&f) => {
167 let Some(n) = size_value(&condition.value, &ctx.breakpoints) else { return false };
168 let size = if f.ends_with("width") { Some(ctx.width) } else { ctx.height };
169 let Some(size) = size else { return false };
170 if f.starts_with("min") {
171 size >= n
172 } else {
173 size < n
174 }
175 }
176 "orientation" => {
177 let portrait = ctx.height.is_some_and(|h| h >= ctx.width);
178 condition.value == if portrait { "portrait" } else { "landscape" }
179 }
180 "hover" => condition.value == if ctx.hover { "hover" } else { "none" },
181 "prefers-reduced-motion" => condition.value == if ctx.reduced_motion { "reduce" } else { "no-preference" },
182 "form-factor" => condition.value == form_factor_of(ctx).as_str(),
183 _ => false,
184 }
185}
186
187pub fn resolve_conditions(style: &Map<String, Value>, ctx: &ConditionContext) -> Map<String, Value> {
191 let mut out = Map::new();
192 for (k, v) in style {
193 if !is_condition_key(k) {
194 out.insert(k.clone(), v.clone());
195 }
196 }
197 for (k, v) in style {
198 let Some(condition) = parse_media_condition(k) else { continue };
199 let Some(block) = v.as_object() else { continue };
200 if media_matches(&condition, ctx) {
201 for (bk, bv) in block {
202 out.insert(bk.clone(), bv.clone());
203 }
204 }
205 }
206 for key in STYLE_STATES {
207 let state = &key[1..];
208 if !ctx.states.iter().any(|s| s == state) {
209 continue;
210 }
211 if let Some(block) = style.get(*key).and_then(Value::as_object) {
212 for (bk, bv) in block {
213 out.insert(bk.clone(), bv.clone());
214 }
215 }
216 }
217 out
218}
219
220pub fn active_breakpoint(width: f32, breakpoints: &IndexMap<String, f32>) -> Option<String> {
223 let mut out = None;
224 for name in crate::macros::BREAKPOINTS.iter() {
225 if breakpoints.get(name).is_some_and(|px| width >= *px) {
226 out = Some(name.clone());
227 }
228 }
229 out
230}
231
232pub fn depends_on_height(style: &Map<String, Value>) -> bool {
235 style.keys().filter_map(|k| parse_media_condition(k)).any(|c| c.feature.ends_with("height") || c.feature == "orientation")
236}
237
238pub fn has_state_blocks(style: &Map<String, Value>) -> bool {
241 STYLE_STATES.iter().any(|k| style.contains_key(*k))
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use serde_json::json;
248
249 #[test]
250 fn the_grammar() {
251 for key in [
252 "@media (min-width: 600px)",
253 "@media (max-width: 37.5px)",
254 "@media (min-height: $breakpoint.md)",
255 "@media (orientation: portrait)",
256 "@media (hover: none)",
257 "@media (prefers-reduced-motion: reduce)",
258 "@media (form-factor: phone)",
259 "@media (form-factor: wide)",
260 ] {
261 assert!(is_media_key(key), "{key}");
262 }
263 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"] {
264 assert!(!is_media_key(key), "{key}");
265 }
266 assert!(is_condition_key(":hover") && is_condition_key(":focus-visible") && is_condition_key(":pressed"));
267 }
268
269 #[test]
270 fn max_is_strict_and_unknown_tokens_never_match() {
271 let style = json!({"gap": 4, "@media (max-width: $breakpoint.md)": {"gap": 1}, "@media (min-width: $breakpoint.huge)": {"gap": 99}});
272 let ctx = |w: f32| ConditionContext { width: w, breakpoints: default_breakpoints(), ..Default::default() };
273 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0))["gap"], json!(1));
274 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0))["gap"], json!(4));
275 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(5000.0))["gap"], json!(4));
276 assert_eq!(active_breakpoint(700.0, &default_breakpoints()).as_deref(), Some("sm"));
277 assert_eq!(active_breakpoint(500.0, &default_breakpoints()), None);
278 assert_eq!(active_breakpoint(1280.0, &default_breakpoints()).as_deref(), Some("xl"));
279 }
280
281 #[test]
282 fn form_factor_from_the_host_else_the_width() {
283 let style = json!({"gap": 4, "@media (form-factor: phone)": {"gap": 8}, "@media (form-factor: wide)": {"gap": 16}});
284 let ctx = |w: f32, f: Option<FormFactor>| ConditionContext { width: w, breakpoints: default_breakpoints(), form_factor: f, ..Default::default() };
285 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(767.0, None))["gap"], json!(8));
286 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(768.0, None))["gap"], json!(16));
287 assert_eq!(resolve_conditions(style.as_object().unwrap(), &ctx(1200.0, Some(FormFactor::Phone)))["gap"], json!(8));
288 assert_eq!(form_factor_of(&ConditionContext { width: 500.0, ..Default::default() }), FormFactor::Phone);
290 }
291}