1use crate::models::{WidgetConfig, WidgetElement, VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, GaugeElement, ButtonElement, ToggleElement, DividerElement, DateElement, ChartElement, ListElement, LinkElement, ShapeElement, TimerElement, CanvasElement, LabelElement};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum WidgetPlatform {
11 Ios,
12 Macos,
13 Android,
14 Desktop,
15 Windows,
17}
18
19impl WidgetPlatform {
20 pub fn all() -> [WidgetPlatform; 5] {
21 [
22 WidgetPlatform::Ios,
23 WidgetPlatform::Macos,
24 WidgetPlatform::Android,
25 WidgetPlatform::Desktop,
26 WidgetPlatform::Windows,
27 ]
28 }
29
30 pub fn as_str(self) -> &'static str {
31 match self {
32 WidgetPlatform::Ios => "ios",
33 WidgetPlatform::Macos => "macos",
34 WidgetPlatform::Android => "android",
35 WidgetPlatform::Desktop => "desktop",
36 WidgetPlatform::Windows => "windows",
37 }
38 }
39
40 pub fn current() -> WidgetPlatform {
42 if cfg!(target_os = "ios") {
43 WidgetPlatform::Ios
44 } else if cfg!(target_os = "macos") {
45 WidgetPlatform::Macos
46 } else if cfg!(target_os = "android") {
47 WidgetPlatform::Android
48 } else if cfg!(target_os = "windows") {
49 WidgetPlatform::Windows
51 } else {
52 WidgetPlatform::Desktop
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Support {
60 Full,
61 Degraded,
62 Unsupported,
63}
64
65impl Support {
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Support::Full => "full",
69 Support::Degraded => "degraded",
70 Support::Unsupported => "unsupported",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct CapabilityEntry {
78 pub element: &'static str,
79 pub platform: WidgetPlatform,
80 pub support: Support,
81 pub note: &'static str,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CapabilityWarning {
87 pub path: String,
88 pub element: String,
89 pub platform: WidgetPlatform,
90 pub support: Support,
91 pub note: String,
92}
93
94pub const ELEMENT_TYPES: &[&str] = &[
96 "vstack",
97 "hstack",
98 "zstack",
99 "grid",
100 "container",
101 "text",
102 "image",
103 "progress",
104 "gauge",
105 "button",
106 "toggle",
107 "divider",
108 "spacer",
109 "date",
110 "chart",
111 "list",
112 "link",
113 "shape",
114 "timer",
115 "canvas",
116 "label",
117];
118
119pub use crate::snapshot::{CORE_ELEMENTS, EXTENDED_ELEMENTS};
120pub const FEATURE_KEYS: &[&str] = &[
122 "image.url",
123 "image.systemName",
124 "background.gradient",
125 "canvas.path",
126 "timer.live",
127];
128
129fn cell(
130 element: &'static str,
131 platform: WidgetPlatform,
132 support: Support,
133 note: &'static str,
134) -> CapabilityEntry {
135 CapabilityEntry {
136 element,
137 platform,
138 support,
139 note,
140 }
141}
142
143fn apple_full(element: &'static str) -> [CapabilityEntry; 2] {
144 [
145 cell(element, WidgetPlatform::Ios, Support::Full, ""),
146 cell(element, WidgetPlatform::Macos, Support::Full, ""),
147 ]
148}
149
150pub fn capability_table() -> Vec<CapabilityEntry> {
152 use Support::*;
153 use WidgetPlatform::*;
154
155 let mut out = Vec::with_capacity(ELEMENT_TYPES.len() * 5 + FEATURE_KEYS.len() * 5);
156
157 let layout = [
158 "vstack",
159 "hstack",
160 "grid",
161 "container",
162 "text",
163 "spacer",
164 "divider",
165 "label",
166 "progress",
167 "button",
168 "toggle",
169 "date",
170 "link",
171 ];
172 for el in layout {
173 out.extend_from_slice(&apple_full(el));
174 out.push(cell(el, Android, Full, ""));
175 out.push(cell(el, Desktop, Full, ""));
176 out.push(cell(el, Windows, Full, "Adaptive Cards 1.5"));
177 }
178
179 out.extend_from_slice(&apple_full("zstack"));
181 out.push(cell("zstack", Android, Full, ""));
182 out.push(cell("zstack", Desktop, Full, ""));
183 out.push(cell(
184 "zstack",
185 Windows,
186 Degraded,
187 "rasterized PNG overlay when possible; else flattened Container",
188 ));
189
190 out.extend_from_slice(&apple_full("shape"));
191 out.push(cell("shape", Android, Full, ""));
192 out.push(cell("shape", Desktop, Full, ""));
193 out.push(cell("shape", Windows, Degraded, "rasterized PNG"));
194
195 out.extend_from_slice(&apple_full("gauge"));
196 out.push(cell("gauge", Android, Full, ""));
197 out.push(cell("gauge", Desktop, Full, ""));
198 out.push(cell("gauge", Windows, Degraded, "rasterized PNG"));
199
200 out.extend_from_slice(&apple_full("image"));
202 out.push(cell(
203 "image",
204 Android,
205 Full,
206 "url via localPath preprocess; see image.systemName",
207 ));
208 out.push(cell("image", Desktop, Full, ""));
209 out.push(cell(
210 "image",
211 Windows,
212 Full,
213 "url/data URI; see image.systemName",
214 ));
215
216 out.extend_from_slice(&apple_full("chart"));
217 out.push(cell(
218 "chart",
219 Android,
220 Full,
221 "bitmap bar/line/area/pie",
222 ));
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
650 .get("fixture")
651 .and_then(|v| v.as_str())
652 .unwrap_or("");
653 if fixture.is_empty() {
654 continue;
655 }
656 let fixture_path = {
657 let with_json = fixtures_root.join(format!("{fixture}.json"));
658 if with_json.exists() {
659 with_json
660 } else {
661 fixtures_root.join(fixture)
662 }
663 };
664 let Ok(fx_raw) = std::fs::read_to_string(&fixture_path) else {
665 continue;
666 };
667 let Ok(fx): Result<Value, _> = serde_json::from_str(&fx_raw) else {
668 continue;
669 };
670 let mut types = BTreeSet::new();
671 walk_types(&fx, &mut types);
672
673 let platforms: Vec<String> = if let Some(Value::Array(arr)) = case.get("platforms") {
674 arr.iter()
675 .filter_map(|v| v.as_str().map(|s| s.to_string()))
676 .collect()
677 } else {
678 vec![
679 "desktop".into(),
680 "ios".into(),
681 "macos".into(),
682 "android".into(),
683 "linux".into(),
684 ]
685 };
686
687 for platform in platforms {
688 let png = golden_root.join(&platform).join(format!("{case_name}.png"));
689 if !png.exists() {
690 continue;
691 }
692 for ty in &types {
693 *out
694 .entry(ty.clone())
695 .or_default()
696 .entry(platform.clone())
697 .or_default() += 1;
698 }
699 }
700 }
701 out
702}
703
704pub fn render_capabilities_coverage_json() -> String {
706 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
707 let measured = measure_element_coverage(root);
708 let mut elements = serde_json::Map::new();
709 for el in ELEMENT_TYPES {
710 let mut platforms = serde_json::Map::new();
711 let row = measured.get(*el);
712 for p in ["desktop", "ios", "macos", "android", "linux", "windows"] {
713 let n = row.and_then(|m| m.get(p)).copied().unwrap_or(0);
714 platforms.insert(p.to_string(), serde_json::json!(n));
715 }
716 elements.insert((*el).to_string(), serde_json::Value::Object(platforms));
717 }
718 let doc = serde_json::json!({
719 "version": 1,
720 "source": "tests/cases + tests/golden",
721 "note": "Counts of golden PNGs whose fixture contains each element type. Hand matrix remains schemas/capabilities.json.",
722 "elements": elements,
723 });
724 format!(
725 "{}\n",
726 serde_json::to_string_pretty(&doc).expect("serialize coverage")
727 )
728}
729
730pub fn render_capability_matrix_md() -> String {
733 let mut notes: Vec<String> = Vec::new();
734 let mut md = String::from(
735 "# Capability matrix (element × platform)\n\n\
736 Table is authored in `src/capabilities.rs`; this page embeds it automatically.\n\n",
737 );
738
739 md.push_str("### Element lists\n\n");
740 md.push_str(&format!(
741 "**Core** (`CORE_ELEMENTS` in `src/snapshot.rs`): {}\n\n",
742 linked_element_list(CORE_ELEMENTS)
743 ));
744 md.push_str(&format!(
745 "**Extended** (`EXTENDED_ELEMENTS`): {}\n\n",
746 linked_element_list(EXTENDED_ELEMENTS)
747 ));
748
749 md.push_str("### Core\n\n");
750 md.push_str(&platform_table_header("Element"));
751 for el in CORE_ELEMENTS {
752 md.push_str(&element_row(el, &mut notes));
753 }
754
755 md.push_str("\n### Extended\n\n");
756 md.push_str(&platform_table_header("Element"));
757 for el in EXTENDED_ELEMENTS {
758 md.push_str(&element_row(el, &mut notes));
759 }
760
761 md.push_str("\n### Feature notes\n\n");
762 md.push_str(&platform_table_header("Feature"));
763 for feat in FEATURE_KEYS {
764 md.push_str(&feature_row(feat, &mut notes));
765 }
766
767 md.push_str("\n### Choosing a surface set\n\n");
768 md.push_str(
769 "Pick the platforms you ship, then stay in the **full** set for that profile. \
770 Degraded cells still render, but check the [Notes](#notes) and element pages.\n\n",
771 );
772 md.push_str(&profile_block(
773 "Apple only",
774 "iOS + macOS",
775 &[WidgetPlatform::Ios, WidgetPlatform::Macos],
776 ));
777 md.push_str(&profile_block(
778 "Apple + Android",
779 "iOS + macOS + Android",
780 &[
781 WidgetPlatform::Ios,
782 WidgetPlatform::Macos,
783 WidgetPlatform::Android,
784 ],
785 ));
786 md.push_str(&profile_block(
787 "All five matrix columns",
788 "iOS + macOS + Android + Desktop + Windows",
789 &WidgetPlatform::all(),
790 ));
791
792 if !notes.is_empty() {
793 md.push_str("\n### Notes {#notes}\n\n");
794 for (i, note) in notes.iter().enumerate() {
795 md.push_str(&format!("{}. {}\n", i + 1, note));
796 }
797 }
798 md
799}
800
801fn platform_table_header(first: &str) -> String {
802 format!(
803 "| {first} | iOS | macOS | Android | Desktop | Windows |\n\
804 |---------|-----|-------|---------|---------|----------|\n"
805 )
806}
807
808fn linked_element_list(els: &[&str]) -> String {
809 els.iter()
810 .map(|e| format!("[`{e}`]({})", element_doc_href(e)))
811 .collect::<Vec<_>>()
812 .join(", ")
813}
814
815fn element_row(el: &str, notes: &mut Vec<String>) -> String {
816 format!(
817 "| [`{el}`]({}) | {} | {} | {} | {} | {} |\n",
818 element_doc_href(el),
819 cell_md(&support_for(el, WidgetPlatform::Ios), notes),
820 cell_md(&support_for(el, WidgetPlatform::Macos), notes),
821 cell_md(&support_for(el, WidgetPlatform::Android), notes),
822 cell_md(&support_for(el, WidgetPlatform::Desktop), notes),
823 cell_md(&support_for(el, WidgetPlatform::Windows), notes),
824 )
825}
826
827fn feature_row(feat: &str, notes: &mut Vec<String>) -> String {
828 format!(
829 "| [`{feat}`]({}) | {} | {} | {} | {} | {} |\n",
830 element_doc_href(feat),
831 cell_md(&support_for(feat, WidgetPlatform::Ios), notes),
832 cell_md(&support_for(feat, WidgetPlatform::Macos), notes),
833 cell_md(&support_for(feat, WidgetPlatform::Android), notes),
834 cell_md(&support_for(feat, WidgetPlatform::Desktop), notes),
835 cell_md(&support_for(feat, WidgetPlatform::Windows), notes),
836 )
837}
838
839fn profile_block(title: &str, platforms_label: &str, platforms: &[WidgetPlatform]) -> String {
840 let (full_core, watch_core) = partition_by_full(CORE_ELEMENTS, platforms);
841 let (full_ext, watch_ext) = partition_by_full(EXTENDED_ELEMENTS, platforms);
842 let mut out = format!("#### {title}\n\nPlatforms: **{platforms_label}**.\n\n");
843 out.push_str(&format!(
844 "- **Full core:** {}\n",
845 if full_core.is_empty() {
846 "_none_".into()
847 } else {
848 linked_element_list(&full_core)
849 }
850 ));
851 if !watch_core.is_empty() {
852 out.push_str(&format!(
853 "- **Core with degraded/unsupported cells:** {}\n",
854 linked_element_list(&watch_core)
855 ));
856 }
857 out.push_str(&format!(
858 "- **Full extended:** {}\n",
859 if full_ext.is_empty() {
860 "_none_".into()
861 } else {
862 linked_element_list(&full_ext)
863 }
864 ));
865 if !watch_ext.is_empty() {
866 out.push_str(&format!(
867 "- **Extended with degraded/unsupported cells:** {}\n",
868 linked_element_list(&watch_ext)
869 ));
870 }
871 out.push('\n');
872 out
873}
874
875fn partition_by_full<'a>(
876 els: &[&'a str],
877 platforms: &[WidgetPlatform],
878) -> (Vec<&'a str>, Vec<&'a str>) {
879 let mut full = Vec::new();
880 let mut watch = Vec::new();
881 for el in els {
882 let all_full = platforms.iter().all(|p| {
883 support_for(el, *p).support == Support::Full
884 });
885 if all_full {
886 full.push(*el);
887 } else {
888 watch.push(*el);
889 }
890 }
891 (full, watch)
892}
893
894fn element_doc_href(key: &str) -> String {
896 let base = key.split('.').next().unwrap_or(key);
897 if key == "background.gradient" {
898 return "/elements/style".into();
899 }
900 let page = match base {
901 "vstack" | "hstack" | "zstack" | "grid" | "container" => "layout",
902 "text" | "label" | "date" | "timer" => "text",
903 "image" | "shape" | "canvas" => "media",
904 "progress" | "gauge" | "chart" | "list" => "data",
905 "button" | "toggle" | "link" => "interactive",
906 "spacer" | "divider" => "spacing",
907 _ => return "/elements/".into(),
908 };
909 format!("/elements/{page}#el-{base}")
910}
911
912fn cell_md(e: &CapabilityEntry, notes: &mut Vec<String>) -> String {
913 if e.note.is_empty() {
914 return e.support.as_str().to_string();
915 }
916 let idx = note_index(notes, e.note);
917 format!("{}<sup>{}</sup>", e.support.as_str(), idx)
918}
919
920fn note_index(notes: &mut Vec<String>, note: &str) -> usize {
921 if let Some(i) = notes.iter().position(|n| n == note) {
922 return i + 1;
923 }
924 notes.push(note.to_string());
925 notes.len()
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931 use crate::models::{
932 ChartDataPoint, ChartElement, ChartType, ImageElement, WidgetConfig, WidgetElement,
933 };
934
935 #[test]
936 fn all_element_types_have_five_platforms() {
937 for el in ELEMENT_TYPES {
938 for p in WidgetPlatform::all() {
939 let e = support_for(el, p);
940 assert_eq!(e.element, *el);
941 assert_eq!(e.platform, p);
942 }
943 }
944 }
945
946 #[test]
947 fn validate_flags_image_url_on_ios_is_full_after_prefetch() {
948 let cfg = WidgetConfig {
949 version: 1,
950 small: Some(WidgetElement::Image(ImageElement {
951 system_name: None,
952 data: None,
953 url: Some("https://example.com/a.png".into()),
954 size: Some(32.0),
955 color: None,
956 content_mode: None,
957 style: Default::default(),
958 })),
959 medium: None,
960 large: None,
961 };
962 let warns = validate_config(&cfg, WidgetPlatform::Ios);
963 assert!(
964 !warns
965 .iter()
966 .any(|w| w.element == "image.url" && w.support == Support::Unsupported),
967 "{warns:?}"
968 );
969 assert!(
971 !warns.iter().any(|w| w.element == "image.url"),
972 "{warns:?}"
973 );
974 }
975
976 #[test]
977 fn matrix_markdown_mentions_vstack() {
978 let md = render_capability_matrix_md();
979 assert!(md.contains("`vstack`"));
980 assert!(md.contains("image.url"));
981 assert!(md.contains("### Core\n"));
982 assert!(!md.contains("## Core "));
983 assert!(md.contains("/elements/layout#el-vstack"));
984 assert!(md.contains("### Choosing a surface set"));
985 assert!(md.contains("<sup>"));
986 }
987
988 #[test]
989 fn chart_roundtrip_type_name() {
990 let el = WidgetElement::Chart(ChartElement {
991 chart_type: ChartType::Bar,
992 chart_data: vec![ChartDataPoint {
993 label: "a".into(),
994 value: 1.0,
995 color: None,
996 }],
997 tint: None,
998 style: Default::default(),
999 });
1000 assert_eq!(el.type_name(), "chart");
1001 }
1002}
1003
1004#[cfg(test)]
1005mod write_docs {
1006 #[test]
1007 fn capability_matrix_doc_matches() {
1008 let expected = super::render_capability_matrix_md();
1009 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1010 .join("docs/guide/_generated/capability-matrix.md");
1011 if !path.exists() {
1012 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1013 std::fs::write(&path, &expected).unwrap();
1014 return;
1015 }
1016 let on_disk = std::fs::read_to_string(&path).unwrap();
1017 assert_eq!(
1018 on_disk, expected,
1019 "docs/guide/_generated/capability-matrix.md drifted — regenerate with:\n\
1020 cargo test --lib write_docs::capability_matrix_doc_matches -- --ignored\n\
1021 or delete the file and re-run this test"
1022 );
1023 }
1024
1025 #[test]
1026 fn capabilities_json_matches() {
1027 let expected = super::render_capabilities_json();
1028 let path =
1029 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas/capabilities.json");
1030 if !path.exists() {
1031 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1032 std::fs::write(&path, &expected).unwrap();
1033 return;
1034 }
1035 let on_disk = std::fs::read_to_string(&path).unwrap();
1036 assert_eq!(
1037 on_disk, expected,
1038 "schemas/capabilities.json drifted — delete it and re-run this test, or:\n\
1039 cargo test --lib capabilities::write_docs::capabilities_json_matches"
1040 );
1041 }
1042
1043 #[test]
1044 fn capabilities_coverage_json_matches() {
1045 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1046 if !root.join("tests/golden/desktop").is_dir() {
1048 return;
1049 }
1050 let expected = super::render_capabilities_coverage_json();
1051 let path = root.join("schemas/capabilities.coverage.json");
1052 if !path.exists() {
1053 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1054 std::fs::write(&path, &expected).unwrap();
1055 return;
1056 }
1057 let on_disk = std::fs::read_to_string(&path).unwrap();
1058 assert_eq!(
1059 on_disk, expected,
1060 "schemas/capabilities.coverage.json drifted — delete it and re-run this test"
1061 );
1062 }
1063
1064 #[test]
1065 fn full_cells_have_golden_coverage() {
1066 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1067 if !root.join("tests/golden/desktop").is_dir() {
1068 return;
1069 }
1070 let measured = super::measure_element_coverage(root);
1071 let checks: &[(&str, super::WidgetPlatform)] = &[
1073 ("desktop", super::WidgetPlatform::Desktop),
1074 ("ios", super::WidgetPlatform::Ios),
1075 ("macos", super::WidgetPlatform::Macos),
1076 ("android", super::WidgetPlatform::Android),
1077 ("linux", super::WidgetPlatform::Desktop),
1078 ];
1079 let mut missing = Vec::new();
1080 for el in super::CORE_ELEMENTS {
1081 for (plat_key, platform) in checks {
1082 let entry = super::support_for(el, *platform);
1083 if entry.support != super::Support::Full {
1084 continue;
1085 }
1086 let covered = measured
1087 .get(*el)
1088 .and_then(|m| m.get(*plat_key))
1089 .copied()
1090 .unwrap_or(0);
1091 if covered == 0 {
1092 missing.push(format!("{el}@{plat_key}"));
1093 }
1094 }
1095 }
1096 assert!(
1097 missing.is_empty(),
1098 "core elements marked full lack golden coverage: {missing:?}"
1099 );
1100 }
1101}