1use crate::capabilities::{support_for, Support, WidgetPlatform};
8use crate::models::{
9 ColorValue, FontWeight, TextElement, TextStyle, WidgetConfig, WidgetElement,
10};
11
12#[derive(Debug, Clone)]
14pub struct NormalizedConfig {
15 pub config: WidgetConfig,
17 pub degraded: Vec<String>,
19}
20
21pub fn normalize(cfg: &WidgetConfig, target: WidgetPlatform) -> NormalizedConfig {
23 let mut degraded = Vec::new();
24 let mut config = cfg.clone();
25 if let Some(el) = config.small.as_mut() {
26 walk_element(el, target, &mut degraded);
27 }
28 if let Some(el) = config.medium.as_mut() {
29 walk_element(el, target, &mut degraded);
30 }
31 if let Some(el) = config.large.as_mut() {
32 walk_element(el, target, &mut degraded);
33 }
34 degraded.sort();
35 degraded.dedup();
36 NormalizedConfig { config, degraded }
37}
38
39fn walk_element(el: &mut WidgetElement, target: WidgetPlatform, degraded: &mut Vec<String>) {
40 let type_name = el.type_name();
41 match support_for(type_name, target).support {
42 Support::Unsupported => {
43 degraded.push(format!("{type_name}:unsupported"));
44 }
45 Support::Degraded => {
46 degraded.push(format!("{type_name}:degraded"));
47 }
48 Support::Full => {}
49 }
50
51 match el {
52 WidgetElement::VStack(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::HStack(v) => {
61 if v.spacing.is_none() {
62 v.spacing = Some(0.0);
63 }
64 for c in &mut v.children {
65 walk_element(c, target, degraded);
66 }
67 }
68 WidgetElement::ZStack(v) => {
69 for c in &mut v.children {
70 walk_element(c, target, degraded);
71 }
72 }
73 WidgetElement::Grid(v) => {
74 if v.spacing.is_none() {
75 v.spacing = Some(0.0);
76 }
77 for c in &mut v.children {
78 walk_element(c, target, degraded);
79 }
80 }
81 WidgetElement::Container(v) => {
82 for c in &mut v.children {
83 walk_element(c, target, degraded);
84 }
85 }
86 WidgetElement::Link(v) => {
87 for c in &mut v.children {
88 walk_element(c, target, degraded);
89 }
90 }
91 WidgetElement::Text(t) => normalize_text(t),
92 WidgetElement::Date(d) => {
93 if let Some(ref mut c) = d.color {
94 *c = expand_semantic_color(c);
95 }
96 }
97 WidgetElement::Timer(t) => {
98 if let Some(ref mut c) = t.color {
99 *c = expand_semantic_color(c);
100 }
101 }
102 WidgetElement::Label(l) => {
103 if let Some(ref mut c) = l.color {
104 *c = expand_semantic_color(c);
105 }
106 if let Some(ref mut c) = l.icon_color {
107 *c = expand_semantic_color(c);
108 }
109 }
110 WidgetElement::Image(i) => {
111 if let Some(ref mut c) = i.color {
112 *c = expand_semantic_color(c);
113 }
114 if i.url.is_some() && matches!(target, WidgetPlatform::Ios | WidgetPlatform::Macos) {
115 if i.data.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
117 degraded.push("image.url:prefetch-required".into());
118 }
119 }
120 }
121 WidgetElement::Button(b) => {
122 if let Some(ref mut c) = b.color {
123 *c = expand_semantic_color(c);
124 }
125 if let Some(ref mut c) = b.background_color {
126 *c = expand_semantic_color(c);
127 }
128 }
129 WidgetElement::Toggle(t) => {
130 if let Some(ref mut c) = t.tint {
131 *c = expand_semantic_color(c);
132 }
133 }
134 WidgetElement::Progress(p) => {
135 if let Some(ref mut c) = p.color {
136 *c = expand_semantic_color(c);
137 }
138 if let Some(ref mut c) = p.tint {
139 *c = expand_semantic_color(c);
140 }
141 }
142 WidgetElement::Gauge(g) => {
143 if let Some(ref mut c) = g.color {
144 *c = expand_semantic_color(c);
145 }
146 if let Some(ref mut c) = g.tint {
147 *c = expand_semantic_color(c);
148 }
149 }
150 WidgetElement::Divider(d) => {
151 if let Some(ref mut c) = d.color {
152 *c = expand_semantic_color(c);
153 }
154 }
155 WidgetElement::List(l) => {
156 if let Some(ref mut c) = l.color {
157 *c = expand_semantic_color(c);
158 }
159 if l.spacing.is_none() {
160 l.spacing = Some(4.0);
161 }
162 }
163 WidgetElement::Chart(c) => {
164 if let Some(ref mut t) = c.tint {
165 *t = expand_semantic_color(t);
166 }
167 }
168 WidgetElement::Shape(s) => {
169 if let Some(ref mut c) = s.fill {
170 *c = expand_semantic_color(c);
171 }
172 if let Some(ref mut c) = s.stroke {
173 *c = expand_semantic_color(c);
174 }
175 }
176 WidgetElement::Canvas(_) | WidgetElement::Spacer(_) => {}
177 }
178
179 expand_style_colors(el);
181}
182
183fn expand_style_colors(el: &mut WidgetElement) {
184 let style = match el {
185 WidgetElement::VStack(v) => Some(&mut v.style),
186 WidgetElement::HStack(v) => Some(&mut v.style),
187 WidgetElement::ZStack(v) => Some(&mut v.style),
188 WidgetElement::Grid(v) => Some(&mut v.style),
189 WidgetElement::Container(v) => Some(&mut v.style),
190 WidgetElement::Text(v) => Some(&mut v.style),
191 WidgetElement::Image(v) => Some(&mut v.style),
192 WidgetElement::Progress(v) => Some(&mut v.style),
193 WidgetElement::Gauge(v) => Some(&mut v.style),
194 WidgetElement::Button(v) => Some(&mut v.style),
195 WidgetElement::Toggle(v) => Some(&mut v.style),
196 WidgetElement::Divider(v) => Some(&mut v.style),
197 WidgetElement::Spacer(_) => None,
198 WidgetElement::Date(v) => Some(&mut v.style),
199 WidgetElement::Chart(v) => Some(&mut v.style),
200 WidgetElement::List(v) => Some(&mut v.style),
201 WidgetElement::Link(v) => Some(&mut v.style),
202 WidgetElement::Shape(v) => Some(&mut v.style),
203 WidgetElement::Timer(v) => Some(&mut v.style),
204 WidgetElement::Label(v) => Some(&mut v.style),
205 WidgetElement::Canvas(v) => Some(&mut v.style),
206 };
207 if let Some(style) = style {
208 if let Some(ref mut bg) = style.background {
209 if let crate::models::BackgroundValue::Solid(s) = bg {
210 if let Some(pair) = semantic_pair(s) {
211 *bg = crate::models::BackgroundValue::Adaptive {
212 light: pair.0.into(),
213 dark: pair.1.into(),
214 };
215 }
216 }
217 }
218 if let Some(ref mut border) = style.border {
219 if let Some((light, dark)) = semantic_pair(&border.color) {
220 let _ = light;
222 border.color = dark.to_string();
223 }
224 }
225 }
226}
227
228fn normalize_text(t: &mut TextElement) {
229 if let Some(ts) = t.text_style.take() {
230 let (size, weight) = text_style_metrics(&ts);
231 if t.font_size.is_none() {
232 t.font_size = Some(size);
233 }
234 if t.font_weight.is_none() {
235 t.font_weight = weight;
236 }
237 }
238 if let Some(ref mut c) = t.color {
239 *c = expand_semantic_color(c);
240 }
241}
242
243fn text_style_metrics(ts: &TextStyle) -> (f64, Option<FontWeight>) {
245 match ts {
246 TextStyle::LargeTitle => (34.0, Some(FontWeight::Regular)),
247 TextStyle::Title => (28.0, Some(FontWeight::Regular)),
248 TextStyle::Title2 => (22.0, Some(FontWeight::Regular)),
249 TextStyle::Title3 => (20.0, Some(FontWeight::Regular)),
250 TextStyle::Headline => (17.0, Some(FontWeight::Semibold)),
251 TextStyle::Body => (17.0, Some(FontWeight::Regular)),
252 TextStyle::Callout => (16.0, Some(FontWeight::Regular)),
253 TextStyle::Subheadline => (15.0, Some(FontWeight::Regular)),
254 TextStyle::Footnote => (13.0, Some(FontWeight::Regular)),
255 TextStyle::Caption => (12.0, Some(FontWeight::Regular)),
256 TextStyle::Caption2 => (11.0, Some(FontWeight::Regular)),
257 }
258}
259
260fn expand_semantic_color(c: &ColorValue) -> ColorValue {
261 match c {
262 ColorValue::Solid(s) => {
263 if let Some((light, dark)) = semantic_pair(s) {
264 ColorValue::Adaptive {
265 light: light.to_string(),
266 dark: dark.to_string(),
267 }
268 } else {
269 c.clone()
270 }
271 }
272 ColorValue::Adaptive { .. } => c.clone(),
273 }
274}
275
276fn semantic_pair(name: &str) -> Option<(&'static str, &'static str)> {
277 match name {
278 "label" => Some(("#000000", "#FFFFFF")),
279 "secondaryLabel" => Some(("#3C3C43", "#EBEBF5")),
280 "systemBackground" => Some(("#FFFFFF", "#000000")),
281 "secondarySystemBackground" => Some(("#F2F2F7", "#1C1C1E")),
282 "accent" => Some(("#007AFF", "#0A84FF")),
283 "separator" => Some(("#C6C6C8", "#545458")),
284 _ => None,
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::models::{text, vstack, FontWeight, TextStyle, WidgetConfig};
292 use std::fs;
293 use std::path::PathBuf;
294
295 #[test]
296 fn text_style_becomes_font_size() {
297 let mut el = text("Hi");
298 el.text_style = Some(TextStyle::Title);
299 el.font_size = None;
300 let cfg = WidgetConfig::small(el);
301 let out = normalize(&cfg, WidgetPlatform::Desktop);
302 match out.config.small.unwrap() {
303 WidgetElement::Text(t) => {
304 assert!(t.text_style.is_none());
305 assert_eq!(t.font_size, Some(28.0));
306 assert!(matches!(t.font_weight, Some(FontWeight::Regular)));
307 }
308 other => panic!("expected text, got {other:?}"),
309 }
310 }
311
312 #[test]
313 fn stack_spacing_defaults_to_zero() {
314 let cfg = WidgetConfig::small(vstack(vec![text("a").into(), text("b").into()]));
315 let out = normalize(&cfg, WidgetPlatform::Ios);
316 match out.config.small.unwrap() {
317 WidgetElement::VStack(v) => assert_eq!(v.spacing, Some(0.0)),
318 other => panic!("expected vstack, got {other:?}"),
319 }
320 }
321
322 #[test]
323 fn semantic_label_becomes_adaptive() {
324 let mut el = text("Hi");
325 el.color = Some(ColorValue::Solid("label".into()));
326 let out = normalize(&WidgetConfig::small(el), WidgetPlatform::Desktop);
327 match out.config.small.unwrap() {
328 WidgetElement::Text(t) => match t.color.unwrap() {
329 ColorValue::Adaptive { light, dark } => {
330 assert_eq!(light, "#000000");
331 assert_eq!(dark, "#FFFFFF");
332 }
333 other => panic!("expected adaptive, got {other:?}"),
334 },
335 other => panic!("expected text, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn fixtures_normalize_without_panic() {
341 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
342 let mut n = 0;
343 for entry in fs::read_dir(&root).unwrap() {
344 let entry = entry.unwrap();
345 if entry.path().is_dir() {
346 for f in fs::read_dir(entry.path()).unwrap() {
347 let f = f.unwrap().path();
348 if f.extension().and_then(|e| e.to_str()) != Some("json") {
349 continue;
350 }
351 let raw = fs::read_to_string(&f).unwrap();
352 let cfg: WidgetConfig = match serde_json::from_str(&raw) {
353 Ok(c) => c,
354 Err(_) => continue, };
356 let _ = normalize(&cfg, WidgetPlatform::Desktop);
357 let _ = normalize(&cfg, WidgetPlatform::Windows);
358 n += 1;
359 }
360 }
361 }
362 assert!(n > 10, "expected to normalize many fixtures, got {n}");
363 }
364}