1use crate::capabilities::{support_for, Support, WidgetPlatform};
8use crate::models::{ColorValue, FontWeight, TextElement, TextStyle, WidgetConfig, WidgetElement};
9
10#[derive(Debug, Clone)]
12pub struct NormalizedConfig {
13 pub config: WidgetConfig,
15 pub degraded: Vec<String>,
17}
18
19pub fn normalize(cfg: &WidgetConfig, target: WidgetPlatform) -> NormalizedConfig {
21 let mut degraded = Vec::new();
22 let mut config = cfg.clone();
23 for el in config.layouts_mut() {
24 walk_element(el, target, &mut degraded);
25 }
26 degraded.sort();
27 degraded.dedup();
28 NormalizedConfig { config, degraded }
29}
30
31fn walk_element(el: &mut WidgetElement, target: WidgetPlatform, degraded: &mut Vec<String>) {
32 let type_name = el.type_name();
33 match support_for(type_name, target).support {
34 Support::Unsupported => {
35 degraded.push(format!("{type_name}:unsupported"));
36 }
37 Support::Degraded => {
38 degraded.push(format!("{type_name}:degraded"));
39 }
40 Support::Full => {}
41 }
42
43 match el {
44 WidgetElement::VStack(v) => {
45 if v.spacing.is_none() {
46 v.spacing = Some(0.0);
47 }
48 for c in &mut v.children {
49 walk_element(c, target, degraded);
50 }
51 }
52 WidgetElement::HStack(v) => {
53 if v.spacing.is_none() {
54 v.spacing = Some(0.0);
55 }
56 for c in &mut v.children {
57 walk_element(c, target, degraded);
58 }
59 }
60 WidgetElement::ZStack(v) => {
61 for c in &mut v.children {
62 walk_element(c, target, degraded);
63 }
64 }
65 WidgetElement::Grid(v) => {
66 if v.spacing.is_none() {
67 v.spacing = Some(0.0);
68 }
69 for c in &mut v.children {
70 walk_element(c, target, degraded);
71 }
72 }
73 WidgetElement::Container(v) => {
74 for c in &mut v.children {
75 walk_element(c, target, degraded);
76 }
77 }
78 WidgetElement::Link(v) => {
79 for c in &mut v.children {
80 walk_element(c, target, degraded);
81 }
82 }
83 WidgetElement::Text(t) => normalize_text(t),
84 WidgetElement::Date(d) => {
85 if let Some(ref mut c) = d.color {
86 *c = expand_semantic_color(c);
87 }
88 }
89 WidgetElement::Timer(t) => {
90 if let Some(ref mut c) = t.color {
91 *c = expand_semantic_color(c);
92 }
93 }
94 WidgetElement::Repeat(r) => walk_element(&mut r.template, target, degraded),
95 WidgetElement::Use(_) => {}
96 WidgetElement::Label(l) => {
97 if let Some(ref mut c) = l.color {
98 *c = expand_semantic_color(c);
99 }
100 if let Some(ref mut c) = l.icon_color {
101 *c = expand_semantic_color(c);
102 }
103 }
104 WidgetElement::Image(i) => {
105 if let Some(ref mut c) = i.color {
106 *c = expand_semantic_color(c);
107 }
108 if i.url.is_some() && matches!(target, WidgetPlatform::Ios | WidgetPlatform::Macos) {
109 if i.data.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
111 degraded.push("image.url:prefetch-required".into());
112 }
113 }
114 }
115 WidgetElement::Button(b) => {
116 if let Some(ref mut c) = b.color {
117 *c = expand_semantic_color(c);
118 }
119 if let Some(ref mut c) = b.background_color {
120 *c = expand_semantic_color(c);
121 }
122 }
123 WidgetElement::Toggle(t) => {
124 if let Some(ref mut c) = t.tint {
125 *c = expand_semantic_color(c);
126 }
127 }
128 WidgetElement::Progress(p) => {
129 if let Some(ref mut c) = p.color {
130 *c = expand_semantic_color(c);
131 }
132 if let Some(ref mut c) = p.tint {
133 *c = expand_semantic_color(c);
134 }
135 }
136 WidgetElement::Gauge(g) => {
137 if let Some(ref mut c) = g.color {
138 *c = expand_semantic_color(c);
139 }
140 if let Some(ref mut c) = g.tint {
141 *c = expand_semantic_color(c);
142 }
143 }
144 WidgetElement::Divider(d) => {
145 if let Some(ref mut c) = d.color {
146 *c = expand_semantic_color(c);
147 }
148 }
149 WidgetElement::List(l) => {
150 if let Some(ref mut c) = l.color {
151 *c = expand_semantic_color(c);
152 }
153 if l.spacing.is_none() {
154 l.spacing = Some(4.0);
155 }
156 }
157 WidgetElement::Chart(c) => {
158 if let Some(ref mut t) = c.tint {
159 *t = expand_semantic_color(t);
160 }
161 }
162 WidgetElement::Shape(s) => {
163 if let Some(ref mut c) = s.fill {
164 *c = expand_semantic_color(c);
165 }
166 if let Some(ref mut c) = s.stroke {
167 *c = expand_semantic_color(c);
168 }
169 }
170 WidgetElement::Canvas(_) | WidgetElement::Spacer(_) => {}
171 }
172
173 expand_style_colors(el);
175}
176
177fn expand_style_colors(el: &mut WidgetElement) {
178 let style = match el {
179 WidgetElement::VStack(v) => Some(&mut v.style),
180 WidgetElement::HStack(v) => Some(&mut v.style),
181 WidgetElement::ZStack(v) => Some(&mut v.style),
182 WidgetElement::Grid(v) => Some(&mut v.style),
183 WidgetElement::Container(v) => Some(&mut v.style),
184 WidgetElement::Text(v) => Some(&mut v.style),
185 WidgetElement::Image(v) => Some(&mut v.style),
186 WidgetElement::Progress(v) => Some(&mut v.style),
187 WidgetElement::Gauge(v) => Some(&mut v.style),
188 WidgetElement::Button(v) => Some(&mut v.style),
189 WidgetElement::Toggle(v) => Some(&mut v.style),
190 WidgetElement::Divider(v) => Some(&mut v.style),
191 WidgetElement::Spacer(_) => None,
192 WidgetElement::Date(v) => Some(&mut v.style),
193 WidgetElement::Chart(v) => Some(&mut v.style),
194 WidgetElement::List(v) => Some(&mut v.style),
195 WidgetElement::Link(v) => Some(&mut v.style),
196 WidgetElement::Shape(v) => Some(&mut v.style),
197 WidgetElement::Timer(v) => Some(&mut v.style),
198 WidgetElement::Label(v) => Some(&mut v.style),
199 WidgetElement::Repeat(v) => Some(&mut v.style),
200 WidgetElement::Use(v) => Some(&mut v.style),
201 WidgetElement::Canvas(v) => Some(&mut v.style),
202 };
203 if let Some(style) = style {
204 if let Some(ref mut bg) = style.background {
205 if let crate::models::BackgroundValue::Solid(s) = bg {
206 if let Some(pair) = semantic_pair(s) {
207 *bg = crate::models::BackgroundValue::Adaptive {
208 light: pair.0.into(),
209 dark: pair.1.into(),
210 };
211 }
212 }
213 }
214 if let Some(ref mut border) = style.border {
215 if let Some((light, dark)) = semantic_pair(&border.color) {
216 let _ = light;
218 border.color = dark.to_string();
219 }
220 }
221 }
222}
223
224fn normalize_text(t: &mut TextElement) {
225 if let Some(ts) = t.text_style.take() {
226 let (size, weight) = text_style_metrics(&ts);
227 if t.font_size.is_none() {
228 t.font_size = Some(size);
229 }
230 if t.font_weight.is_none() {
231 t.font_weight = weight;
232 }
233 }
234 if let Some(ref mut c) = t.color {
235 *c = expand_semantic_color(c);
236 }
237}
238
239fn text_style_metrics(ts: &TextStyle) -> (f64, Option<FontWeight>) {
241 match ts {
242 TextStyle::LargeTitle => (34.0, Some(FontWeight::Regular)),
243 TextStyle::Title => (28.0, Some(FontWeight::Regular)),
244 TextStyle::Title2 => (22.0, Some(FontWeight::Regular)),
245 TextStyle::Title3 => (20.0, Some(FontWeight::Regular)),
246 TextStyle::Headline => (17.0, Some(FontWeight::Semibold)),
247 TextStyle::Body => (17.0, Some(FontWeight::Regular)),
248 TextStyle::Callout => (16.0, Some(FontWeight::Regular)),
249 TextStyle::Subheadline => (15.0, Some(FontWeight::Regular)),
250 TextStyle::Footnote => (13.0, Some(FontWeight::Regular)),
251 TextStyle::Caption => (12.0, Some(FontWeight::Regular)),
252 TextStyle::Caption2 => (11.0, Some(FontWeight::Regular)),
253 }
254}
255
256fn expand_semantic_color(c: &ColorValue) -> ColorValue {
257 match c {
258 ColorValue::Solid(s) => {
259 if let Some((light, dark)) = semantic_pair(s) {
260 ColorValue::Adaptive {
261 light: light.to_string(),
262 dark: dark.to_string(),
263 }
264 } else {
265 c.clone()
266 }
267 }
268 ColorValue::Adaptive { .. } => c.clone(),
269 }
270}
271
272fn semantic_pair(name: &str) -> Option<(&'static str, &'static str)> {
273 match name {
274 "label" => Some(("#000000", "#FFFFFF")),
275 "secondaryLabel" => Some(("#3C3C43", "#EBEBF5")),
276 "systemBackground" => Some(("#FFFFFF", "#000000")),
277 "secondarySystemBackground" => Some(("#F2F2F7", "#1C1C1E")),
278 "accent" => Some(("#007AFF", "#0A84FF")),
279 "separator" => Some(("#C6C6C8", "#545458")),
280 _ => None,
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::models::{text, vstack, BackgroundValue};
288 use std::fs;
289 use std::path::PathBuf;
290
291 fn cfg(v: serde_json::Value) -> WidgetConfig {
292 serde_json::from_value(v).unwrap()
293 }
294
295 fn adaptive(c: Option<ColorValue>) -> (String, String) {
296 match c {
297 Some(ColorValue::Adaptive { light, dark }) => (light, dark),
298 other => panic!("expected adaptive, got {other:?}"),
299 }
300 }
301
302 #[test]
305 fn semantic_colors_expand_through_every_layout() {
306 for (name, light, dark) in [
308 ("label", "#000000", "#FFFFFF"),
309 ("secondaryLabel", "#3C3C43", "#EBEBF5"),
310 ("systemBackground", "#FFFFFF", "#000000"),
311 ("secondarySystemBackground", "#F2F2F7", "#1C1C1E"),
312 ("accent", "#007AFF", "#0A84FF"),
313 ("separator", "#C6C6C8", "#545458"),
314 ] {
315 assert_eq!(semantic_pair(name), Some((light, dark)), "{name}");
316 }
317 assert_eq!(semantic_pair("#123456"), None);
318
319 let mut el = text("Hi");
321 el.color = Some(ColorValue::Solid("label".into()));
322 let out = normalize(&WidgetConfig::small(el), WidgetPlatform::Desktop);
323 let WidgetElement::Text(t) = out.config.small.unwrap() else {
324 panic!("expected text")
325 };
326 assert_eq!(
327 adaptive(t.color),
328 ("#000000".to_string(), "#FFFFFF".to_string())
329 );
330
331 let out = normalize(
333 &cfg(
334 serde_json::json!({"small": {"type": "vstack", "background": "systemBackground",
335 "border": {"color": "separator", "width": 1}}}),
336 ),
337 WidgetPlatform::Desktop,
338 );
339 let WidgetElement::VStack(v) = out.config.small.unwrap() else {
340 panic!("expected vstack")
341 };
342 match v.style.background {
343 Some(BackgroundValue::Adaptive { light, dark }) => {
344 assert_eq!((light.as_str(), dark.as_str()), ("#FFFFFF", "#000000"))
345 }
346 other => panic!("{other:?}"),
347 }
348 assert_eq!(v.style.border.unwrap().color, "#545458");
349
350 let out = normalize(
352 &cfg(
353 serde_json::json!({"entries": [{"at": 1, "small": {"type": "text", "content": "x", "color": "label"}}]}),
354 ),
355 WidgetPlatform::Desktop,
356 );
357 let WidgetElement::Text(t) = out.config.entries[0].small.clone().unwrap() else {
358 panic!("expected text")
359 };
360 assert!(matches!(t.color, Some(ColorValue::Adaptive { .. })));
361 }
362
363 #[test]
365 fn text_style_and_stack_spacing_become_explicit() {
366 let mut el = text("Hi");
368 el.text_style = Some(TextStyle::Title);
369 el.font_size = None;
370 let out = normalize(&WidgetConfig::small(el), WidgetPlatform::Desktop);
371 let WidgetElement::Text(t) = out.config.small.unwrap() else {
372 panic!("expected text")
373 };
374 assert!(t.text_style.is_none());
375 assert_eq!(t.font_size, Some(28.0));
376 assert!(matches!(t.font_weight, Some(FontWeight::Regular)));
377
378 let cfg = WidgetConfig::small(vstack(vec![text("a").into(), text("b").into()]));
380 let WidgetElement::VStack(v) = normalize(&cfg, WidgetPlatform::Ios).config.small.unwrap()
381 else {
382 panic!("expected vstack")
383 };
384 assert_eq!(v.spacing, Some(0.0));
385 }
386
387 #[test]
390 fn image_url_prefetch_degradation_by_platform_and_data() {
391 let url_only =
392 cfg(serde_json::json!({"small": {"type": "image", "url": "https://x/a.png"}}));
393 assert_eq!(
394 normalize(&url_only, WidgetPlatform::Ios).degraded,
395 ["image.url:prefetch-required"]
396 );
397 assert!(normalize(&url_only, WidgetPlatform::Android)
398 .degraded
399 .is_empty());
400 let with_data = cfg(
401 serde_json::json!({"small": {"type": "image", "url": "https://x/a.png", "data": "QQ=="}}),
402 );
403 assert!(normalize(&with_data, WidgetPlatform::Ios)
404 .degraded
405 .is_empty());
406 let no_url = cfg(serde_json::json!({"small": {"type": "image", "systemName": "star"}}));
407 assert!(!normalize(&no_url, WidgetPlatform::Ios)
408 .degraded
409 .iter()
410 .any(|d| d.contains("prefetch")));
411 }
412
413 #[test]
416 fn config_pipeline_parse_normalize_validate_transpile() {
417 use crate::adaptive_card::{to_adaptive_card_for_size, validate_card_structure};
418 use crate::capabilities::validate_config;
419
420 let raw = cfg(serde_json::json!({
422 "small": {"type": "vstack", "children": [
423 {"type": "text", "content": "Now", "textStyle": "title", "color": "label"},
424 {"type": "image", "url": "https://x/a.png"},
425 {"type": "timer", "targetDate": "2030-01-01T00:00:00Z"}
426 ]}
427 }));
428 let ios = normalize(&raw, WidgetPlatform::Ios);
430 assert!(ios
431 .degraded
432 .contains(&"image.url:prefetch-required".to_string()));
433 let win = normalize(&raw, WidgetPlatform::Windows);
434 assert!(
435 win.degraded.contains(&"timer:degraded".to_string()),
436 "{:?}",
437 win.degraded
438 );
439 assert!(!win.degraded.iter().any(|d| d.contains("prefetch")));
440 let WidgetElement::VStack(v) = win.config.small.clone().unwrap() else {
441 panic!("expected vstack")
442 };
443 assert_eq!(v.spacing, Some(0.0));
444 let warns = validate_config(&win.config, WidgetPlatform::Windows);
446 assert!(warns.iter().any(|w| w.element == "timer.live"), "{warns:?}");
447 let card = to_adaptive_card_for_size(&win.config, "small")
450 .unwrap()
451 .card;
452 validate_card_structure(&card).unwrap();
453 assert!(card.to_string().contains("Now"), "{card}");
454 }
455
456 #[test]
458 fn fixtures_normalize_without_panic() {
459 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
460 let mut n = 0;
461 for entry in fs::read_dir(&root).unwrap() {
462 let entry = entry.unwrap();
463 if entry.path().is_dir() {
464 for f in fs::read_dir(entry.path()).unwrap() {
465 let f = f.unwrap().path();
466 if f.extension().and_then(|e| e.to_str()) != Some("json") {
467 continue;
468 }
469 let raw = fs::read_to_string(&f).unwrap();
470 let cfg: WidgetConfig = match serde_json::from_str(&raw) {
471 Ok(c) => c,
472 Err(_) => continue, };
474 let _ = normalize(&cfg, WidgetPlatform::Desktop);
475 let _ = normalize(&cfg, WidgetPlatform::Windows);
476 n += 1;
477 }
478 }
479 }
480 assert!(n > 10, "expected to normalize many fixtures, got {n}");
481 }
482}