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