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::{WidgetConfig, WidgetElement, VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, GaugeElement, ButtonElement, ToggleElement, DividerElement, DateElement, ChartElement, ListElement, LinkElement, ShapeElement, TimerElement, CanvasElement, LabelElement};
7
8/// Target renderer family.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum WidgetPlatform {
11    Ios,
12    Macos,
13    Android,
14    Desktop,
15    /// Windows Widgets Board via Adaptive Cards (WinAppSDK).
16    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    /// Platform matching the current compile target.
41    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            // Prefer Widgets Board Adaptive Cards on Windows.
50            WidgetPlatform::Windows
51        } else {
52            WidgetPlatform::Desktop
53        }
54    }
55}
56
57/// How well an element (or feature) is supported on a platform.
58#[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/// One matrix cell.
76#[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/// Warning produced while walking a config tree.
85#[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
94/// Wire `type` names for every [`WidgetElement`] variant (serde rename).
95pub 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};
120/// Feature flags checked in addition to element type (e.g. image.url).
121pub 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
150/// Full capability table (elements + feature keys).
151pub 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    // zstack — degraded flatten; shape — rasterized PNG
180    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    // image (url/data): Full everywhere — systemName fidelity lives in feature row.
201    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    // 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
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
704/// Measured golden coverage artifact (`schemas/capabilities.coverage.json`).
705pub 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
730/// Render markdown capability matrix for docs (`###` headings — embed under
731/// a single `## Capability matrix` on the tiers page).
732pub 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
894/// Docs path for an element or feature key (`image.url` → image page).
895fn 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        // Full cells do not emit warnings.
970        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        // Packaged crates.io tree omits PNG goldens — skip measured drift there.
1047        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        // Mature golden stands. Windows is manual / sparse — excluded.
1072        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}