Skip to main content

tauri_plugin_widgets/
capabilities.rs

1//! Platform capability matrix for widget IR elements.
2//!
3//! Source of truth for what each renderer supports. Used for docs generation
4//! and non-blocking warnings on [`crate::Widget::set_widget_config`](set path).
5
6use 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/// Target renderer family.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum WidgetPlatform {
16    Ios,
17    Macos,
18    Android,
19    Desktop,
20    /// Windows Widgets Board via Adaptive Cards (WinAppSDK).
21    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    /// Platform matching the current compile target.
46    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            // Prefer Widgets Board Adaptive Cards on Windows.
55            WidgetPlatform::Windows
56        } else {
57            WidgetPlatform::Desktop
58        }
59    }
60}
61
62/// How well an element (or feature) is supported on a platform.
63#[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/// One matrix cell.
81#[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/// Warning produced while walking a config tree.
90#[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
99/// Wire `type` names for every [`WidgetElement`] variant (serde rename).
100pub 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};
125/// Feature flags checked in addition to element type (e.g. image.url).
126pub 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
155/// Full capability table (elements + feature keys).
156pub 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    // zstack — degraded flatten; shape — rasterized PNG
185    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    // image (url/data): Full everywhere — systemName fidelity lives in feature row.
206    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    // Feature rows
262    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
367/// Lookup support for an element or feature key.
368pub 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    /// Serde `type` tag for this element.
385    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
517/// Walk config layouts and collect non-full capability warnings for `platform`.
518pub 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
532/// Log warnings for the current compile target (non-blocking).
533pub 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
547/// Machine-readable capability matrix for CLI `validate` and docs tooling.
548pub 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
597/// Walk `tests/cases` + `tests/golden/{platform}` and count how many goldens
598/// cover each element type on each platform. Used to keep the hand matrix honest.
599pub 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
700/// Measured golden coverage artifact (`schemas/capabilities.coverage.json`).
701pub 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
726/// Render markdown capability matrix for docs (`###` headings — embed under
727/// a single `## Capability matrix` on the tiers page).
728pub 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
890/// Docs path for an element or feature key (`image.url` → image page).
891fn 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        // Full cells do not emit warnings.
966        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        // Packaged crates.io tree omits PNG goldens — skip measured drift there.
1040        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        // Mature golden stands. Windows is manual / sparse — excluded.
1065        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}