1use crate::models::{
7 ButtonElement, CanvasElement, ChartElement, ContainerElement, DateElement, DividerElement,
8 GaugeElement, GridElement, HStackElement, ImageElement, LabelElement, LinkElement, ListElement,
9 ProgressElement, ShapeElement, TextElement, TimerElement, ToggleElement, VStackElement,
10 WidgetConfig, WidgetElement, ZStackElement,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum WidgetPlatform {
16 Ios,
17 Macos,
18 Android,
19 Desktop,
20 Windows,
22}
23
24impl WidgetPlatform {
25 pub fn all() -> [WidgetPlatform; 5] {
26 [
27 WidgetPlatform::Ios,
28 WidgetPlatform::Macos,
29 WidgetPlatform::Android,
30 WidgetPlatform::Desktop,
31 WidgetPlatform::Windows,
32 ]
33 }
34
35 pub fn as_str(self) -> &'static str {
36 match self {
37 WidgetPlatform::Ios => "ios",
38 WidgetPlatform::Macos => "macos",
39 WidgetPlatform::Android => "android",
40 WidgetPlatform::Desktop => "desktop",
41 WidgetPlatform::Windows => "windows",
42 }
43 }
44
45 pub fn current() -> WidgetPlatform {
47 if cfg!(target_os = "ios") {
48 WidgetPlatform::Ios
49 } else if cfg!(target_os = "macos") {
50 WidgetPlatform::Macos
51 } else if cfg!(target_os = "android") {
52 WidgetPlatform::Android
53 } else if cfg!(target_os = "windows") {
54 WidgetPlatform::Windows
56 } else {
57 WidgetPlatform::Desktop
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Support {
65 Full,
66 Degraded,
67 Unsupported,
68}
69
70impl Support {
71 pub fn as_str(self) -> &'static str {
72 match self {
73 Support::Full => "full",
74 Support::Degraded => "degraded",
75 Support::Unsupported => "unsupported",
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy)]
82pub struct CapabilityEntry {
83 pub element: &'static str,
84 pub platform: WidgetPlatform,
85 pub support: Support,
86 pub note: &'static str,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct CapabilityWarning {
92 pub path: String,
93 pub element: String,
94 pub platform: WidgetPlatform,
95 pub support: Support,
96 pub note: String,
97}
98
99pub const ELEMENT_TYPES: &[&str] = &[
101 "vstack",
102 "hstack",
103 "zstack",
104 "grid",
105 "container",
106 "text",
107 "image",
108 "progress",
109 "gauge",
110 "button",
111 "toggle",
112 "divider",
113 "spacer",
114 "date",
115 "chart",
116 "list",
117 "link",
118 "shape",
119 "timer",
120 "canvas",
121 "label",
122];
123
124pub use crate::snapshot::{CORE_ELEMENTS, EXTENDED_ELEMENTS};
125pub const FEATURE_KEYS: &[&str] = &[
127 "image.url",
128 "image.systemName",
129 "background.gradient",
130 "canvas.path",
131 "timer.live",
132];
133
134fn cell(
135 element: &'static str,
136 platform: WidgetPlatform,
137 support: Support,
138 note: &'static str,
139) -> CapabilityEntry {
140 CapabilityEntry {
141 element,
142 platform,
143 support,
144 note,
145 }
146}
147
148fn apple_full(element: &'static str) -> [CapabilityEntry; 2] {
149 [
150 cell(element, WidgetPlatform::Ios, Support::Full, ""),
151 cell(element, WidgetPlatform::Macos, Support::Full, ""),
152 ]
153}
154
155pub fn capability_table() -> Vec<CapabilityEntry> {
157 use Support::*;
158 use WidgetPlatform::*;
159
160 let mut out = Vec::with_capacity(ELEMENT_TYPES.len() * 5 + FEATURE_KEYS.len() * 5);
161
162 let layout = [
163 "vstack",
164 "hstack",
165 "grid",
166 "container",
167 "text",
168 "spacer",
169 "divider",
170 "label",
171 "progress",
172 "button",
173 "toggle",
174 "date",
175 "link",
176 ];
177 for el in layout {
178 out.extend_from_slice(&apple_full(el));
179 out.push(cell(el, Android, Full, ""));
180 out.push(cell(el, Desktop, Full, ""));
181 out.push(cell(el, Windows, Full, "Adaptive Cards 1.5"));
182 }
183
184 out.extend_from_slice(&apple_full("zstack"));
186 out.push(cell("zstack", Android, Full, ""));
187 out.push(cell("zstack", Desktop, Full, ""));
188 out.push(cell(
189 "zstack",
190 Windows,
191 Degraded,
192 "rasterized PNG overlay when possible; else flattened Container",
193 ));
194
195 out.extend_from_slice(&apple_full("shape"));
196 out.push(cell("shape", Android, Full, ""));
197 out.push(cell("shape", Desktop, Full, ""));
198 out.push(cell("shape", Windows, Degraded, "rasterized PNG"));
199
200 out.extend_from_slice(&apple_full("gauge"));
201 out.push(cell("gauge", Android, Full, ""));
202 out.push(cell("gauge", Desktop, Full, ""));
203 out.push(cell("gauge", Windows, Degraded, "rasterized PNG"));
204
205 out.extend_from_slice(&apple_full("image"));
207 out.push(cell(
208 "image",
209 Android,
210 Full,
211 "url via localPath preprocess; see image.systemName",
212 ));
213 out.push(cell("image", Desktop, Full, ""));
214 out.push(cell(
215 "image",
216 Windows,
217 Full,
218 "url/data URI; see image.systemName",
219 ));
220
221 out.extend_from_slice(&apple_full("chart"));
222 out.push(cell("chart", Android, Full, "bitmap bar/line/area/pie"));
223 out.push(cell("chart", Desktop, Full, "SVG"));
224 out.push(cell("chart", Windows, Degraded, "rasterized PNG"));
225
226 out.extend_from_slice(&apple_full("list"));
227 out.push(cell(
228 "list",
229 Android,
230 Full,
231 "Column chunking; soft cap ~50 items",
232 ));
233 out.push(cell("list", Desktop, Full, ""));
234 out.push(cell("list", Windows, Degraded, "Adaptive Cards Table"));
235
236 out.extend_from_slice(&apple_full("timer"));
237 out.push(cell(
238 "timer",
239 Android,
240 Full,
241 "Chronometer via AndroidRemoteViews",
242 ));
243 out.push(cell("timer", Desktop, Full, "setInterval"));
244 out.push(cell(
245 "timer",
246 Windows,
247 Degraded,
248 "provider minute push + static TextBlock",
249 ));
250
251 out.extend_from_slice(&apple_full("canvas"));
252 out.push(cell(
253 "canvas",
254 Android,
255 Degraded,
256 "bitmap canvas (full SVG path via PathParser)",
257 ));
258 out.push(cell("canvas", Desktop, Full, "SVG"));
259 out.push(cell("canvas", Windows, Degraded, "rasterized PNG"));
260
261 out.push(cell(
263 "image.url",
264 Ios,
265 Full,
266 "host prefetch to data URI on setWidgetConfig",
267 ));
268 out.push(cell(
269 "image.url",
270 Macos,
271 Full,
272 "host prefetch to data URI on setWidgetConfig",
273 ));
274 out.push(cell(
275 "image.url",
276 Android,
277 Full,
278 "preprocess to localPath on setWidgetConfig",
279 ));
280 out.push(cell("image.url", Desktop, Full, ""));
281 out.push(cell("image.url", Windows, Full, "Adaptive Cards Image.url"));
282
283 out.push(cell("image.systemName", Ios, Full, "SF Symbols"));
284 out.push(cell("image.systemName", Macos, Full, "SF Symbols"));
285 out.push(cell(
286 "image.systemName",
287 Android,
288 Degraded,
289 "SF→Material / emoji map (not SF Symbols)",
290 ));
291 out.push(cell(
292 "image.systemName",
293 Desktop,
294 Degraded,
295 "SF→Material / emoji map (not SF Symbols)",
296 ));
297 out.push(cell(
298 "image.systemName",
299 Windows,
300 Degraded,
301 "emoji TextBlock by default; glyph PNG Image with feature rasterize",
302 ));
303
304 out.push(cell(
305 "background.gradient",
306 Ios,
307 Full,
308 "linear/radial/angular SwiftUI",
309 ));
310 out.push(cell(
311 "background.gradient",
312 Macos,
313 Full,
314 "linear/radial/angular SwiftUI",
315 ));
316 out.push(cell(
317 "background.gradient",
318 Android,
319 Full,
320 "baked bitmap at LocalSize / frame",
321 ));
322 out.push(cell(
323 "background.gradient",
324 Desktop,
325 Full,
326 "linear/radial/angular CSS/SVG",
327 ));
328 out.push(cell(
329 "background.gradient",
330 Windows,
331 Degraded,
332 "rasterized PNG backgroundImage when rasterize enabled",
333 ));
334
335 out.push(cell("canvas.path", Ios, Full, "SVG path grammar"));
336 out.push(cell("canvas.path", Macos, Full, "SVG path grammar"));
337 out.push(cell(
338 "canvas.path",
339 Android,
340 Full,
341 "PathParser full SVG path",
342 ));
343 out.push(cell("canvas.path", Desktop, Full, "SVG path"));
344 out.push(cell("canvas.path", Windows, Degraded, "rasterized via SVG"));
345
346 out.push(cell("timer.live", Ios, Full, "Text(..., .timer)"));
347 out.push(cell("timer.live", Macos, Full, "Text(..., .timer)"));
348 out.push(cell(
349 "timer.live",
350 Android,
351 Full,
352 "Chronometer via AndroidRemoteViews",
353 ));
354 out.push(cell("timer.live", Desktop, Full, "JS interval"));
355 out.push(cell(
356 "timer.live",
357 Windows,
358 Degraded,
359 "provider pushes UpdateWidget ~1/min",
360 ));
361
362 out
363}
364
365use std::sync::OnceLock;
366
367pub fn support_for(element: &str, platform: WidgetPlatform) -> CapabilityEntry {
369 static TABLE: OnceLock<Vec<CapabilityEntry>> = OnceLock::new();
370 let table = TABLE.get_or_init(capability_table);
371 table
372 .iter()
373 .find(|e| e.element == element && e.platform == platform)
374 .copied()
375 .unwrap_or(CapabilityEntry {
376 element: "",
377 platform,
378 support: Support::Unsupported,
379 note: "unknown element",
380 })
381}
382
383impl WidgetElement {
384 pub fn type_name(&self) -> &'static str {
386 match self {
387 WidgetElement::VStack(_) => "vstack",
388 WidgetElement::HStack(_) => "hstack",
389 WidgetElement::ZStack(_) => "zstack",
390 WidgetElement::Grid(_) => "grid",
391 WidgetElement::Container(_) => "container",
392 WidgetElement::Text(_) => "text",
393 WidgetElement::Image(_) => "image",
394 WidgetElement::Progress(_) => "progress",
395 WidgetElement::Gauge(_) => "gauge",
396 WidgetElement::Button(_) => "button",
397 WidgetElement::Toggle(_) => "toggle",
398 WidgetElement::Divider(_) => "divider",
399 WidgetElement::Spacer(_) => "spacer",
400 WidgetElement::Date(_) => "date",
401 WidgetElement::Chart(_) => "chart",
402 WidgetElement::List(_) => "list",
403 WidgetElement::Link(_) => "link",
404 WidgetElement::Shape(_) => "shape",
405 WidgetElement::Timer(_) => "timer",
406 WidgetElement::Canvas(_) => "canvas",
407 WidgetElement::Label(_) => "label",
408 }
409 }
410
411 fn children_ref(&self) -> &[WidgetElement] {
412 match self {
413 WidgetElement::VStack(VStackElement { children, .. })
414 | WidgetElement::HStack(HStackElement { children, .. })
415 | WidgetElement::ZStack(ZStackElement { children, .. })
416 | WidgetElement::Grid(GridElement { children, .. })
417 | WidgetElement::Container(ContainerElement { children, .. })
418 | WidgetElement::Link(LinkElement { children, .. }) => children,
419 _ => &[],
420 }
421 }
422
423 fn style_background_is_gradient(&self) -> bool {
424 use crate::models::{BackgroundValue, ElementStyle};
425 let style: Option<&ElementStyle> = match self {
426 WidgetElement::VStack(VStackElement { style, .. })
427 | WidgetElement::HStack(HStackElement { style, .. })
428 | WidgetElement::ZStack(ZStackElement { style, .. })
429 | WidgetElement::Grid(GridElement { style, .. })
430 | WidgetElement::Container(ContainerElement { style, .. })
431 | WidgetElement::Text(TextElement { style, .. })
432 | WidgetElement::Image(ImageElement { style, .. })
433 | WidgetElement::Progress(ProgressElement { style, .. })
434 | WidgetElement::Gauge(GaugeElement { style, .. })
435 | WidgetElement::Button(ButtonElement { style, .. })
436 | WidgetElement::Toggle(ToggleElement { style, .. })
437 | WidgetElement::Divider(DividerElement { style, .. })
438 | WidgetElement::Date(DateElement { style, .. })
439 | WidgetElement::Chart(ChartElement { style, .. })
440 | WidgetElement::List(ListElement { style, .. })
441 | WidgetElement::Link(LinkElement { style, .. })
442 | WidgetElement::Shape(ShapeElement { style, .. })
443 | WidgetElement::Timer(TimerElement { style, .. })
444 | WidgetElement::Canvas(CanvasElement { style, .. })
445 | WidgetElement::Label(LabelElement { style, .. }) => Some(style),
446 WidgetElement::Spacer(_) => None,
447 };
448 matches!(
449 style.and_then(|s| s.background.as_ref()),
450 Some(BackgroundValue::Gradient(_))
451 )
452 }
453}
454
455fn push_warn(
456 out: &mut Vec<CapabilityWarning>,
457 path: &str,
458 element: &str,
459 platform: WidgetPlatform,
460) {
461 let entry = support_for(element, platform);
462 if entry.support == Support::Full {
463 return;
464 }
465 out.push(CapabilityWarning {
466 path: path.to_string(),
467 element: element.to_string(),
468 platform,
469 support: entry.support,
470 note: entry.note.to_string(),
471 });
472}
473
474fn walk_element(
475 el: &WidgetElement,
476 path: &str,
477 platform: WidgetPlatform,
478 out: &mut Vec<CapabilityWarning>,
479) {
480 let ty = el.type_name();
481 push_warn(out, path, ty, platform);
482
483 if el.style_background_is_gradient() {
484 push_warn(out, path, "background.gradient", platform);
485 }
486
487 if let WidgetElement::Image(ImageElement {
488 url, system_name, ..
489 }) = el
490 {
491 if url.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
492 push_warn(out, path, "image.url", platform);
493 }
494 if system_name.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
495 push_warn(out, path, "image.systemName", platform);
496 }
497 }
498
499 if let WidgetElement::Timer(_) = el {
500 push_warn(out, path, "timer.live", platform);
501 }
502
503 if let WidgetElement::Canvas(CanvasElement { elements, .. }) = el {
504 if elements
505 .iter()
506 .any(|c| matches!(c, crate::models::CanvasDrawCommand::Path { .. }))
507 {
508 push_warn(out, path, "canvas.path", platform);
509 }
510 }
511
512 for (i, child) in el.children_ref().iter().enumerate() {
513 walk_element(child, &format!("{path}/{ty}[{i}]"), platform, out);
514 }
515}
516
517pub fn validate_config(config: &WidgetConfig, platform: WidgetPlatform) -> Vec<CapabilityWarning> {
519 let mut out = Vec::new();
520 if let Some(el) = &config.small {
521 walk_element(el, "small", platform, &mut out);
522 }
523 if let Some(el) = &config.medium {
524 walk_element(el, "medium", platform, &mut out);
525 }
526 if let Some(el) = &config.large {
527 walk_element(el, "large", platform, &mut out);
528 }
529 out
530}
531
532pub fn log_capabilities(config: &WidgetConfig) {
534 let platform = WidgetPlatform::current();
535 for w in validate_config(config, platform) {
536 log::warn!(
537 "widget capability {}: {} at {} on {} — {}",
538 w.support.as_str(),
539 w.element,
540 w.path,
541 w.platform.as_str(),
542 w.note
543 );
544 }
545}
546
547pub fn render_capabilities_json() -> String {
549 use serde_json::{json, Map, Value};
550
551 let mut elements = Map::new();
552 for el in ELEMENT_TYPES {
553 let mut platforms = Map::new();
554 for p in WidgetPlatform::all() {
555 let e = support_for(el, p);
556 platforms.insert(
557 p.as_str().to_string(),
558 json!({
559 "support": e.support.as_str(),
560 "note": e.note,
561 }),
562 );
563 }
564 elements.insert((*el).to_string(), Value::Object(platforms));
565 }
566
567 let mut features = Map::new();
568 for feat in FEATURE_KEYS {
569 let mut platforms = Map::new();
570 for p in WidgetPlatform::all() {
571 let e = support_for(feat, p);
572 platforms.insert(
573 p.as_str().to_string(),
574 json!({
575 "support": e.support.as_str(),
576 "note": e.note,
577 }),
578 );
579 }
580 features.insert((*feat).to_string(), Value::Object(platforms));
581 }
582
583 let doc = json!({
584 "version": 1,
585 "platforms": WidgetPlatform::all().map(|p| p.as_str()),
586 "core": CORE_ELEMENTS,
587 "extended": EXTENDED_ELEMENTS,
588 "elements": elements,
589 "features": features,
590 });
591 format!(
592 "{}\n",
593 serde_json::to_string_pretty(&doc).expect("serialize capabilities.json")
594 )
595}
596
597pub fn measure_element_coverage(
600 root: &std::path::Path,
601) -> std::collections::BTreeMap<String, std::collections::BTreeMap<String, usize>> {
602 use serde_json::Value;
603 use std::collections::{BTreeMap, BTreeSet};
604
605 let cases_dir = root.join("tests/cases");
606 let golden_root = root.join("tests/golden");
607 let fixtures_root = root.join("tests/fixtures");
608
609 fn walk_types(node: &Value, out: &mut BTreeSet<String>) {
610 match node {
611 Value::Object(map) => {
612 if let Some(Value::String(t)) = map.get("type") {
613 out.insert(t.clone());
614 }
615 for v in map.values() {
616 walk_types(v, out);
617 }
618 }
619 Value::Array(arr) => {
620 for v in arr {
621 walk_types(v, out);
622 }
623 }
624 _ => {}
625 }
626 }
627
628 let mut out: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
629 let Ok(entries) = std::fs::read_dir(&cases_dir) else {
630 return out;
631 };
632
633 for entry in entries.flatten() {
634 let path = entry.path();
635 if path.extension().and_then(|e| e.to_str()) != Some("json") {
636 continue;
637 }
638 let case_name = path
639 .file_stem()
640 .and_then(|s| s.to_str())
641 .unwrap_or_default()
642 .to_string();
643 let Ok(raw) = std::fs::read_to_string(&path) else {
644 continue;
645 };
646 let Ok(case): Result<Value, _> = serde_json::from_str(&raw) else {
647 continue;
648 };
649 let fixture = case.get("fixture").and_then(|v| v.as_str()).unwrap_or("");
650 if fixture.is_empty() {
651 continue;
652 }
653 let fixture_path = {
654 let with_json = fixtures_root.join(format!("{fixture}.json"));
655 if with_json.exists() {
656 with_json
657 } else {
658 fixtures_root.join(fixture)
659 }
660 };
661 let Ok(fx_raw) = std::fs::read_to_string(&fixture_path) else {
662 continue;
663 };
664 let Ok(fx): Result<Value, _> = serde_json::from_str(&fx_raw) else {
665 continue;
666 };
667 let mut types = BTreeSet::new();
668 walk_types(&fx, &mut types);
669
670 let platforms: Vec<String> = if let Some(Value::Array(arr)) = case.get("platforms") {
671 arr.iter()
672 .filter_map(|v| v.as_str().map(|s| s.to_string()))
673 .collect()
674 } else {
675 vec![
676 "desktop".into(),
677 "ios".into(),
678 "macos".into(),
679 "android".into(),
680 "linux".into(),
681 ]
682 };
683
684 for platform in platforms {
685 let png = golden_root.join(&platform).join(format!("{case_name}.png"));
686 if !png.exists() {
687 continue;
688 }
689 for ty in &types {
690 *out.entry(ty.clone())
691 .or_default()
692 .entry(platform.clone())
693 .or_default() += 1;
694 }
695 }
696 }
697 out
698}
699
700pub fn render_capabilities_coverage_json() -> String {
702 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
703 let measured = measure_element_coverage(root);
704 let mut elements = serde_json::Map::new();
705 for el in ELEMENT_TYPES {
706 let mut platforms = serde_json::Map::new();
707 let row = measured.get(*el);
708 for p in ["desktop", "ios", "macos", "android", "linux", "windows"] {
709 let n = row.and_then(|m| m.get(p)).copied().unwrap_or(0);
710 platforms.insert(p.to_string(), serde_json::json!(n));
711 }
712 elements.insert((*el).to_string(), serde_json::Value::Object(platforms));
713 }
714 let doc = serde_json::json!({
715 "version": 1,
716 "source": "tests/cases + tests/golden",
717 "note": "Counts of golden PNGs whose fixture contains each element type. Hand matrix remains schemas/capabilities.json.",
718 "elements": elements,
719 });
720 format!(
721 "{}\n",
722 serde_json::to_string_pretty(&doc).expect("serialize coverage")
723 )
724}
725
726pub fn render_capability_matrix_md() -> String {
729 let mut notes: Vec<String> = Vec::new();
730 let mut md = String::from(
731 "# Capability matrix (element × platform)\n\n\
732 Table is authored in `src/capabilities.rs`; this page embeds it automatically.\n\n",
733 );
734
735 md.push_str("### Element lists\n\n");
736 md.push_str(&format!(
737 "**Core** (`CORE_ELEMENTS` in `src/snapshot.rs`): {}\n\n",
738 linked_element_list(CORE_ELEMENTS)
739 ));
740 md.push_str(&format!(
741 "**Extended** (`EXTENDED_ELEMENTS`): {}\n\n",
742 linked_element_list(EXTENDED_ELEMENTS)
743 ));
744
745 md.push_str("### Core\n\n");
746 md.push_str(&platform_table_header("Element"));
747 for el in CORE_ELEMENTS {
748 md.push_str(&element_row(el, &mut notes));
749 }
750
751 md.push_str("\n### Extended\n\n");
752 md.push_str(&platform_table_header("Element"));
753 for el in EXTENDED_ELEMENTS {
754 md.push_str(&element_row(el, &mut notes));
755 }
756
757 md.push_str("\n### Feature notes\n\n");
758 md.push_str(&platform_table_header("Feature"));
759 for feat in FEATURE_KEYS {
760 md.push_str(&feature_row(feat, &mut notes));
761 }
762
763 md.push_str("\n### Choosing a surface set\n\n");
764 md.push_str(
765 "Pick the platforms you ship, then stay in the **full** set for that profile. \
766 Degraded cells still render, but check the [Notes](#notes) and element pages.\n\n",
767 );
768 md.push_str(&profile_block(
769 "Apple only",
770 "iOS + macOS",
771 &[WidgetPlatform::Ios, WidgetPlatform::Macos],
772 ));
773 md.push_str(&profile_block(
774 "Apple + Android",
775 "iOS + macOS + Android",
776 &[
777 WidgetPlatform::Ios,
778 WidgetPlatform::Macos,
779 WidgetPlatform::Android,
780 ],
781 ));
782 md.push_str(&profile_block(
783 "All five matrix columns",
784 "iOS + macOS + Android + Desktop + Windows",
785 &WidgetPlatform::all(),
786 ));
787
788 if !notes.is_empty() {
789 md.push_str("\n### Notes\n\n");
790 for (i, note) in notes.iter().enumerate() {
791 md.push_str(&format!("{}. {}\n", i + 1, note));
792 }
793 }
794 md
795}
796
797fn platform_table_header(first: &str) -> String {
798 format!(
799 "| {first} | iOS | macOS | Android | Desktop | Windows |\n\
800 |---------|-----|-------|---------|---------|----------|\n"
801 )
802}
803
804fn linked_element_list(els: &[&str]) -> String {
805 els.iter()
806 .map(|e| format!("[`{e}`]({})", element_doc_href(e)))
807 .collect::<Vec<_>>()
808 .join(", ")
809}
810
811fn element_row(el: &str, notes: &mut Vec<String>) -> String {
812 format!(
813 "| [`{el}`]({}) | {} | {} | {} | {} | {} |\n",
814 element_doc_href(el),
815 cell_md(&support_for(el, WidgetPlatform::Ios), notes),
816 cell_md(&support_for(el, WidgetPlatform::Macos), notes),
817 cell_md(&support_for(el, WidgetPlatform::Android), notes),
818 cell_md(&support_for(el, WidgetPlatform::Desktop), notes),
819 cell_md(&support_for(el, WidgetPlatform::Windows), notes),
820 )
821}
822
823fn feature_row(feat: &str, notes: &mut Vec<String>) -> String {
824 format!(
825 "| [`{feat}`]({}) | {} | {} | {} | {} | {} |\n",
826 element_doc_href(feat),
827 cell_md(&support_for(feat, WidgetPlatform::Ios), notes),
828 cell_md(&support_for(feat, WidgetPlatform::Macos), notes),
829 cell_md(&support_for(feat, WidgetPlatform::Android), notes),
830 cell_md(&support_for(feat, WidgetPlatform::Desktop), notes),
831 cell_md(&support_for(feat, WidgetPlatform::Windows), notes),
832 )
833}
834
835fn profile_block(title: &str, platforms_label: &str, platforms: &[WidgetPlatform]) -> String {
836 let (full_core, watch_core) = partition_by_full(CORE_ELEMENTS, platforms);
837 let (full_ext, watch_ext) = partition_by_full(EXTENDED_ELEMENTS, platforms);
838 let mut out = format!("#### {title}\n\nPlatforms: **{platforms_label}**.\n\n");
839 out.push_str(&format!(
840 "- **Full core:** {}\n",
841 if full_core.is_empty() {
842 "_none_".into()
843 } else {
844 linked_element_list(&full_core)
845 }
846 ));
847 if !watch_core.is_empty() {
848 out.push_str(&format!(
849 "- **Core with degraded/unsupported cells:** {}\n",
850 linked_element_list(&watch_core)
851 ));
852 }
853 out.push_str(&format!(
854 "- **Full extended:** {}\n",
855 if full_ext.is_empty() {
856 "_none_".into()
857 } else {
858 linked_element_list(&full_ext)
859 }
860 ));
861 if !watch_ext.is_empty() {
862 out.push_str(&format!(
863 "- **Extended with degraded/unsupported cells:** {}\n",
864 linked_element_list(&watch_ext)
865 ));
866 }
867 out.push('\n');
868 out
869}
870
871fn partition_by_full<'a>(
872 els: &[&'a str],
873 platforms: &[WidgetPlatform],
874) -> (Vec<&'a str>, Vec<&'a str>) {
875 let mut full = Vec::new();
876 let mut watch = Vec::new();
877 for el in els {
878 let all_full = platforms
879 .iter()
880 .all(|p| support_for(el, *p).support == Support::Full);
881 if all_full {
882 full.push(*el);
883 } else {
884 watch.push(*el);
885 }
886 }
887 (full, watch)
888}
889
890fn element_doc_href(key: &str) -> String {
892 let base = key.split('.').next().unwrap_or(key);
893 if key == "background.gradient" {
894 return "/elements/style".into();
895 }
896 let page = match base {
897 "vstack" | "hstack" | "zstack" | "grid" | "container" => "layout",
898 "text" | "label" | "date" | "timer" => "text",
899 "image" | "shape" | "canvas" => "media",
900 "progress" | "gauge" | "chart" | "list" => "data",
901 "button" | "toggle" | "link" => "interactive",
902 "spacer" | "divider" => "spacing",
903 _ => return "/elements/".into(),
904 };
905 format!("/elements/{page}#{base}")
906}
907
908fn cell_md(e: &CapabilityEntry, notes: &mut Vec<String>) -> String {
909 if e.note.is_empty() {
910 return e.support.as_str().to_string();
911 }
912 let idx = note_index(notes, e.note);
913 format!("{}<sup>{}</sup>", e.support.as_str(), idx)
914}
915
916fn note_index(notes: &mut Vec<String>, note: &str) -> usize {
917 if let Some(i) = notes.iter().position(|n| n == note) {
918 return i + 1;
919 }
920 notes.push(note.to_string());
921 notes.len()
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927 use crate::models::{
928 ChartDataPoint, ChartElement, ChartType, ImageElement, WidgetConfig, WidgetElement,
929 };
930
931 #[test]
932 fn all_element_types_have_five_platforms() {
933 for el in ELEMENT_TYPES {
934 for p in WidgetPlatform::all() {
935 let e = support_for(el, p);
936 assert_eq!(e.element, *el);
937 assert_eq!(e.platform, p);
938 }
939 }
940 }
941
942 #[test]
943 fn validate_flags_image_url_on_ios_is_full_after_prefetch() {
944 let cfg = WidgetConfig {
945 version: 1,
946 small: Some(WidgetElement::Image(ImageElement {
947 system_name: None,
948 data: None,
949 url: Some("https://example.com/a.png".into()),
950 size: Some(32.0),
951 color: None,
952 content_mode: None,
953 style: Default::default(),
954 })),
955 medium: None,
956 large: None,
957 };
958 let warns = validate_config(&cfg, WidgetPlatform::Ios);
959 assert!(
960 !warns
961 .iter()
962 .any(|w| w.element == "image.url" && w.support == Support::Unsupported),
963 "{warns:?}"
964 );
965 assert!(!warns.iter().any(|w| w.element == "image.url"), "{warns:?}");
967 }
968
969 #[test]
970 fn matrix_markdown_mentions_vstack() {
971 let md = render_capability_matrix_md();
972 assert!(md.contains("`vstack`"));
973 assert!(md.contains("image.url"));
974 assert!(md.contains("### Core\n"));
975 assert!(!md.contains("## Core "));
976 assert!(md.contains("/elements/layout#vstack"));
977 assert!(md.contains("### Choosing a surface set"));
978 assert!(md.contains("<sup>"));
979 }
980
981 #[test]
982 fn chart_roundtrip_type_name() {
983 let el = WidgetElement::Chart(ChartElement {
984 chart_type: ChartType::Bar,
985 chart_data: vec![ChartDataPoint {
986 label: "a".into(),
987 value: 1.0,
988 color: None,
989 }],
990 tint: None,
991 style: Default::default(),
992 });
993 assert_eq!(el.type_name(), "chart");
994 }
995}
996
997#[cfg(test)]
998mod write_docs {
999 #[test]
1000 fn capability_matrix_doc_matches() {
1001 let expected = super::render_capability_matrix_md();
1002 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1003 .join("docs/generated/capability-matrix.md");
1004 if !path.exists() {
1005 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1006 std::fs::write(&path, &expected).unwrap();
1007 return;
1008 }
1009 let on_disk = std::fs::read_to_string(&path).unwrap();
1010 assert_eq!(
1011 on_disk, expected,
1012 "docs/generated/capability-matrix.md drifted — regenerate with:\n\
1013 cargo test --lib write_docs::capability_matrix_doc_matches -- --ignored\n\
1014 or delete the file and re-run this test"
1015 );
1016 }
1017
1018 #[test]
1019 fn capabilities_json_matches() {
1020 let expected = super::render_capabilities_json();
1021 let path =
1022 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas/capabilities.json");
1023 if !path.exists() {
1024 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1025 std::fs::write(&path, &expected).unwrap();
1026 return;
1027 }
1028 let on_disk = std::fs::read_to_string(&path).unwrap();
1029 assert_eq!(
1030 on_disk, expected,
1031 "schemas/capabilities.json drifted — delete it and re-run this test, or:\n\
1032 cargo test --lib capabilities::write_docs::capabilities_json_matches"
1033 );
1034 }
1035
1036 #[test]
1037 fn capabilities_coverage_json_matches() {
1038 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1039 if !root.join("tests/golden/desktop").is_dir() {
1041 return;
1042 }
1043 let expected = super::render_capabilities_coverage_json();
1044 let path = root.join("schemas/capabilities.coverage.json");
1045 if !path.exists() {
1046 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1047 std::fs::write(&path, &expected).unwrap();
1048 return;
1049 }
1050 let on_disk = std::fs::read_to_string(&path).unwrap();
1051 assert_eq!(
1052 on_disk, expected,
1053 "schemas/capabilities.coverage.json drifted — delete it and re-run this test"
1054 );
1055 }
1056
1057 #[test]
1058 fn full_cells_have_golden_coverage() {
1059 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1060 if !root.join("tests/golden/desktop").is_dir() {
1061 return;
1062 }
1063 let measured = super::measure_element_coverage(root);
1064 let checks: &[(&str, super::WidgetPlatform)] = &[
1066 ("desktop", super::WidgetPlatform::Desktop),
1067 ("ios", super::WidgetPlatform::Ios),
1068 ("macos", super::WidgetPlatform::Macos),
1069 ("android", super::WidgetPlatform::Android),
1070 ("linux", super::WidgetPlatform::Desktop),
1071 ];
1072 let mut missing = Vec::new();
1073 for el in super::CORE_ELEMENTS {
1074 for (plat_key, platform) in checks {
1075 let entry = super::support_for(el, *platform);
1076 if entry.support != super::Support::Full {
1077 continue;
1078 }
1079 let covered = measured
1080 .get(*el)
1081 .and_then(|m| m.get(*plat_key))
1082 .copied()
1083 .unwrap_or(0);
1084 if covered == 0 {
1085 missing.push(format!("{el}@{plat_key}"));
1086 }
1087 }
1088 }
1089 assert!(
1090 missing.is_empty(),
1091 "core elements marked full lack golden coverage: {missing:?}"
1092 );
1093 }
1094}