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    "repeat",
123    "use",
124];
125
126pub use crate::snapshot::{CORE_ELEMENTS, EXTENDED_ELEMENTS};
127/// Feature flags checked in addition to element type (e.g. image.url).
128pub const FEATURE_KEYS: &[&str] = &[
129    "image.url",
130    "image.systemName",
131    "background.gradient",
132    "canvas.path",
133    "timer.live",
134    "config.entries",
135    "config.strings",
136    "config.source",
137    "config.instance",
138    "style.privacySensitive",
139    "style.accessibilityLabel",
140    "style.bind",
141    "style.when",
142    "style.transition",
143    "config.state",
144    "activity",
145    "activity.template",
146    "activity.island",
147];
148
149fn cell(
150    element: &'static str,
151    platform: WidgetPlatform,
152    support: Support,
153    note: &'static str,
154) -> CapabilityEntry {
155    CapabilityEntry {
156        element,
157        platform,
158        support,
159        note,
160    }
161}
162
163fn apple_full(element: &'static str) -> [CapabilityEntry; 2] {
164    [
165        cell(element, WidgetPlatform::Ios, Support::Full, ""),
166        cell(element, WidgetPlatform::Macos, Support::Full, ""),
167    ]
168}
169
170/// Full capability table (elements + feature keys).
171pub fn capability_table() -> Vec<CapabilityEntry> {
172    use Support::*;
173    use WidgetPlatform::*;
174
175    let mut out = Vec::with_capacity(ELEMENT_TYPES.len() * 5 + FEATURE_KEYS.len() * 5);
176
177    let layout = [
178        "vstack",
179        "hstack",
180        "grid",
181        "container",
182        "text",
183        "spacer",
184        "divider",
185        "label",
186        "progress",
187        "button",
188        "toggle",
189        "date",
190        "link",
191    ];
192    for el in layout {
193        out.extend_from_slice(&apple_full(el));
194        out.push(cell(el, Android, Full, ""));
195        out.push(cell(el, Desktop, Full, ""));
196        out.push(cell(el, Windows, Full, "Adaptive Cards 1.5"));
197    }
198
199    // zstack — degraded flatten; shape — rasterized PNG
200    out.extend_from_slice(&apple_full("zstack"));
201    out.push(cell("zstack", Android, Full, ""));
202    out.push(cell("zstack", Desktop, Full, ""));
203    out.push(cell(
204        "zstack",
205        Windows,
206        Degraded,
207        "rasterized PNG overlay when possible; else flattened Container",
208    ));
209
210    out.extend_from_slice(&apple_full("shape"));
211    out.push(cell("shape", Android, Full, ""));
212    out.push(cell("shape", Desktop, Full, ""));
213    out.push(cell("shape", Windows, Degraded, "rasterized PNG"));
214
215    out.extend_from_slice(&apple_full("gauge"));
216    out.push(cell("gauge", Android, Full, ""));
217    out.push(cell("gauge", Desktop, Full, ""));
218    out.push(cell("gauge", Windows, Degraded, "rasterized PNG"));
219
220    // image (url/data): Full everywhere — systemName fidelity lives in feature row.
221    out.extend_from_slice(&apple_full("image"));
222    out.push(cell(
223        "image",
224        Android,
225        Full,
226        "url via localPath preprocess; see image.systemName",
227    ));
228    out.push(cell("image", Desktop, Full, ""));
229    out.push(cell(
230        "image",
231        Windows,
232        Full,
233        "url/data URI; see image.systemName",
234    ));
235
236    out.extend_from_slice(&apple_full("chart"));
237    out.push(cell("chart", Android, Full, "bitmap bar/line/area/pie"));
238    out.push(cell("chart", Desktop, Full, "SVG"));
239    out.push(cell("chart", Windows, Degraded, "rasterized PNG"));
240
241    out.extend_from_slice(&apple_full("list"));
242    out.push(cell(
243        "list",
244        Android,
245        Full,
246        "Column chunking; soft cap ~50 items",
247    ));
248    out.push(cell("list", Desktop, Full, ""));
249    out.push(cell("list", Windows, Degraded, "Adaptive Cards Table"));
250
251    out.extend_from_slice(&apple_full("timer"));
252    out.push(cell(
253        "timer",
254        Android,
255        Full,
256        "Chronometer via AndroidRemoteViews",
257    ));
258    out.push(cell("timer", Desktop, Full, "setInterval"));
259    out.push(cell(
260        "timer",
261        Windows,
262        Degraded,
263        "provider minute push + static TextBlock",
264    ));
265
266    out.extend_from_slice(&apple_full("canvas"));
267    out.push(cell(
268        "canvas",
269        Android,
270        Degraded,
271        "bitmap canvas (full SVG path via PathParser)",
272    ));
273    out.push(cell("canvas", Desktop, Full, "SVG"));
274    out.push(cell("canvas", Windows, Degraded, "rasterized PNG"));
275
276    // Feature rows
277    out.push(cell(
278        "image.url",
279        Ios,
280        Full,
281        "host prefetch to data URI on setWidgetConfig",
282    ));
283    out.push(cell(
284        "image.url",
285        Macos,
286        Full,
287        "host prefetch to data URI on setWidgetConfig",
288    ));
289    out.push(cell(
290        "image.url",
291        Android,
292        Full,
293        "preprocess to localPath on setWidgetConfig",
294    ));
295    out.push(cell("image.url", Desktop, Full, ""));
296    out.push(cell("image.url", Windows, Full, "Adaptive Cards Image.url"));
297
298    out.push(cell("image.systemName", Ios, Full, "SF Symbols"));
299    out.push(cell("image.systemName", Macos, Full, "SF Symbols"));
300    out.push(cell(
301        "image.systemName",
302        Android,
303        Degraded,
304        "SF→Material / emoji map (not SF Symbols)",
305    ));
306    out.push(cell(
307        "image.systemName",
308        Desktop,
309        Degraded,
310        "SF→Material / emoji map (not SF Symbols)",
311    ));
312    out.push(cell(
313        "image.systemName",
314        Windows,
315        Degraded,
316        "emoji TextBlock by default; glyph PNG Image with feature rasterize",
317    ));
318
319    out.push(cell(
320        "background.gradient",
321        Ios,
322        Full,
323        "linear/radial/angular SwiftUI",
324    ));
325    out.push(cell(
326        "background.gradient",
327        Macos,
328        Full,
329        "linear/radial/angular SwiftUI",
330    ));
331    out.push(cell(
332        "background.gradient",
333        Android,
334        Full,
335        "baked bitmap at LocalSize / frame",
336    ));
337    out.push(cell(
338        "background.gradient",
339        Desktop,
340        Full,
341        "linear/radial/angular CSS/SVG",
342    ));
343    out.push(cell(
344        "background.gradient",
345        Windows,
346        Degraded,
347        "rasterized PNG backgroundImage when rasterize enabled",
348    ));
349
350    out.push(cell("canvas.path", Ios, Full, "SVG path grammar"));
351    out.push(cell("canvas.path", Macos, Full, "SVG path grammar"));
352    out.push(cell(
353        "canvas.path",
354        Android,
355        Full,
356        "PathParser full SVG path",
357    ));
358    out.push(cell("canvas.path", Desktop, Full, "SVG path"));
359    out.push(cell("canvas.path", Windows, Degraded, "rasterized via SVG"));
360
361    out.push(cell("timer.live", Ios, Full, "Text(..., .timer)"));
362    out.push(cell("timer.live", Macos, Full, "Text(..., .timer)"));
363    out.push(cell(
364        "timer.live",
365        Android,
366        Full,
367        "Chronometer via AndroidRemoteViews",
368    ));
369    out.push(cell("timer.live", Desktop, Full, "JS interval"));
370    out.push(cell(
371        "timer.live",
372        Windows,
373        Degraded,
374        "provider pushes UpdateWidget ~1/min",
375    ));
376
377    // Render-time resolution (src/resolve.rs and its Swift / Kotlin / TS ports).
378    out.push(cell(
379        "config.entries",
380        Ios,
381        Full,
382        "WidgetKit timeline entries",
383    ));
384    out.push(cell(
385        "config.entries",
386        Macos,
387        Full,
388        "WidgetKit timeline entries",
389    ));
390    out.push(cell(
391        "config.entries",
392        Android,
393        Full,
394        "inexact alarm redraw (may run late in Doze)",
395    ));
396    out.push(cell("config.entries", Desktop, Full, "renderer timer"));
397    out.push(cell(
398        "config.entries",
399        Windows,
400        Degraded,
401        "card rebuilt by the host while the app runs",
402    ));
403    for p in [Ios, Macos, Android, Desktop, Windows] {
404        out.push(cell("config.strings", p, Full, ""));
405    }
406    out.push(cell(
407        "config.source",
408        Ios,
409        Full,
410        "fetched in the timeline (8 s budget)",
411    ));
412    out.push(cell(
413        "config.source",
414        Macos,
415        Full,
416        "fetched in the timeline (8 s budget)",
417    ));
418    out.push(cell(
419        "config.source",
420        Android,
421        Full,
422        "fetched on redraw, alarm when due",
423    ));
424    out.push(cell(
425        "config.source",
426        Desktop,
427        Full,
428        "fetched while the window is open",
429    ));
430    out.push(cell(
431        "config.source",
432        Windows,
433        Unsupported,
434        "board cannot fetch; push setWidgetData from the app",
435    ));
436    out.push(cell(
437        "config.instance",
438        Ios,
439        Full,
440        "Edit Widget choice (TauriInstanceIntent)",
441    ));
442    out.push(cell(
443        "config.instance",
444        Macos,
445        Full,
446        "Edit Widget choice (TauriInstanceIntent)",
447    ));
448    out.push(cell("config.instance", Android, Full, "appWidgetId"));
449    out.push(cell(
450        "config.instance",
451        Desktop,
452        Full,
453        "window instance / label",
454    ));
455    out.push(cell("config.instance", Windows, Full, "Widgets Board id"));
456    out.push(cell(
457        "style.privacySensitive",
458        Ios,
459        Full,
460        ".privacySensitive()",
461    ));
462    out.push(cell(
463        "style.privacySensitive",
464        Macos,
465        Full,
466        ".privacySensitive()",
467    ));
468    out.push(cell(
469        "style.privacySensitive",
470        Android,
471        Unsupported,
472        "no home-screen redaction",
473    ));
474    out.push(cell(
475        "style.privacySensitive",
476        Desktop,
477        Unsupported,
478        "data-privacy-sensitive attribute only",
479    ));
480    out.push(cell(
481        "style.privacySensitive",
482        Windows,
483        Unsupported,
484        "no redaction",
485    ));
486    out.push(cell("style.accessibilityLabel", Ios, Full, ""));
487    out.push(cell("style.accessibilityLabel", Macos, Full, ""));
488    out.push(cell(
489        "style.accessibilityLabel",
490        Android,
491        Full,
492        "Glance semantics contentDescription",
493    ));
494    out.push(cell(
495        "style.accessibilityLabel",
496        Desktop,
497        Full,
498        "aria-label",
499    ));
500    out.push(cell("style.accessibilityLabel", Windows, Unsupported, ""));
501    for p in [Ios, Macos, Android, Desktop, Windows] {
502        out.push(cell("style.bind", p, Full, ""));
503    }
504    // Expanded by the shared resolve step before any renderer draws.
505    for p in [Ios, Macos, Android, Desktop, Windows] {
506        out.push(cell("repeat", p, Full, "expanded before rendering"));
507        out.push(cell("use", p, Full, "expanded before rendering"));
508        out.push(cell("style.when", p, Full, "evaluated before rendering"));
509    }
510    // Contract v3.
511    for p in [Ios, Macos] {
512        out.push(cell(
513            "style.transition",
514            p,
515            Full,
516            "contentTransition(.numericText()) / .transition",
517        ));
518        out.push(cell(
519            "config.state",
520            p,
521            Full,
522            "set taps run an AppIntent in the extension (iOS 17 / macOS 14)",
523        ));
524    }
525    out.push(cell(
526        "style.transition",
527        Android,
528        Unsupported,
529        "ignored (Glance has no content transitions)",
530    ));
531    out.push(cell(
532        "style.transition",
533        Desktop,
534        Full,
535        "animation when the content changes",
536    ));
537    out.push(cell(
538        "style.transition",
539        Windows,
540        Unsupported,
541        "ignored (Adaptive Cards)",
542    ));
543    out.push(cell(
544        "config.state",
545        Android,
546        Full,
547        "set taps handled by the widget's broadcast receiver",
548    ));
549    out.push(cell(
550        "config.state",
551        Desktop,
552        Full,
553        "set taps handled by the widget window",
554    ));
555    out.push(cell(
556        "config.state",
557        Windows,
558        Degraded,
559        "Widgets Board shows the state; set taps are not handled",
560    ));
561    for p in [Ios, Macos, Android, Desktop, Windows] {
562        out.push(cell(
563            "activity.template",
564            p,
565            Full,
566            "layout stored on the device, data-only updates",
567        ));
568    }
569    out.push(cell(
570        "activity",
571        Ios,
572        Full,
573        "Live Activity + Dynamic Island (iOS 16.2+)",
574    ));
575    out.push(cell(
576        "activity",
577        Macos,
578        Degraded,
579        "island pill at the notch (default) or corner window; no Lock Screen",
580    ));
581    out.push(cell(
582        "activity",
583        Android,
584        Full,
585        "ongoing notification; Live Update chip on Android 16",
586    ));
587    out.push(cell(
588        "activity",
589        Desktop,
590        Degraded,
591        "top-centre island pill (default) or corner window; no Lock Screen",
592    ));
593    out.push(cell(
594        "activity",
595        Windows,
596        Degraded,
597        "top-centre island pill (default) or corner window; no Lock Screen",
598    ));
599    out.push(cell(
600        "activity.island",
601        Ios,
602        Full,
603        "Dynamic Island compact / minimal / expanded regions",
604    ));
605    out.push(cell(
606        "activity.island",
607        Macos,
608        Full,
609        "pill around the notch, under the menu bar without one; hover expands",
610    ));
611    out.push(cell(
612        "activity.island",
613        Android,
614        Unsupported,
615        "no island; the Live Update chip shows shortCriticalText",
616    ));
617    out.push(cell(
618        "activity.island",
619        Desktop,
620        Full,
621        "top-centre pill: X11 dock window, Wayland layer-shell overlay",
622    ));
623    out.push(cell(
624        "activity.island",
625        Windows,
626        Full,
627        "top-centre topmost pill, no taskbar button, no focus steal",
628    ));
629
630    out
631}
632
633use std::sync::OnceLock;
634
635/// Lookup support for an element or feature key.
636pub fn support_for(element: &str, platform: WidgetPlatform) -> CapabilityEntry {
637    static TABLE: OnceLock<Vec<CapabilityEntry>> = OnceLock::new();
638    let table = TABLE.get_or_init(capability_table);
639    table
640        .iter()
641        .find(|e| e.element == element && e.platform == platform)
642        .copied()
643        .unwrap_or(CapabilityEntry {
644            element: "",
645            platform,
646            support: Support::Unsupported,
647            note: "unknown element",
648        })
649}
650
651impl WidgetElement {
652    /// Serde `type` tag for this element.
653    pub fn type_name(&self) -> &'static str {
654        match self {
655            WidgetElement::VStack(_) => "vstack",
656            WidgetElement::HStack(_) => "hstack",
657            WidgetElement::ZStack(_) => "zstack",
658            WidgetElement::Grid(_) => "grid",
659            WidgetElement::Container(_) => "container",
660            WidgetElement::Text(_) => "text",
661            WidgetElement::Image(_) => "image",
662            WidgetElement::Progress(_) => "progress",
663            WidgetElement::Gauge(_) => "gauge",
664            WidgetElement::Button(_) => "button",
665            WidgetElement::Toggle(_) => "toggle",
666            WidgetElement::Divider(_) => "divider",
667            WidgetElement::Spacer(_) => "spacer",
668            WidgetElement::Date(_) => "date",
669            WidgetElement::Chart(_) => "chart",
670            WidgetElement::List(_) => "list",
671            WidgetElement::Link(_) => "link",
672            WidgetElement::Shape(_) => "shape",
673            WidgetElement::Timer(_) => "timer",
674            WidgetElement::Canvas(_) => "canvas",
675            WidgetElement::Label(_) => "label",
676            WidgetElement::Repeat(_) => "repeat",
677            WidgetElement::Use(_) => "use",
678        }
679    }
680
681    fn children_ref(&self) -> &[WidgetElement] {
682        match self {
683            WidgetElement::VStack(VStackElement { children, .. })
684            | WidgetElement::HStack(HStackElement { children, .. })
685            | WidgetElement::ZStack(ZStackElement { children, .. })
686            | WidgetElement::Grid(GridElement { children, .. })
687            | WidgetElement::Container(ContainerElement { children, .. })
688            | WidgetElement::Link(LinkElement { children, .. }) => children,
689            WidgetElement::Repeat(r) => std::slice::from_ref(&*r.template),
690            _ => &[],
691        }
692    }
693
694    fn style_ref(&self) -> Option<&crate::models::ElementStyle> {
695        match self {
696            WidgetElement::VStack(VStackElement { style, .. })
697            | WidgetElement::HStack(HStackElement { style, .. })
698            | WidgetElement::ZStack(ZStackElement { style, .. })
699            | WidgetElement::Grid(GridElement { style, .. })
700            | WidgetElement::Container(ContainerElement { style, .. })
701            | WidgetElement::Text(TextElement { style, .. })
702            | WidgetElement::Image(ImageElement { style, .. })
703            | WidgetElement::Progress(ProgressElement { style, .. })
704            | WidgetElement::Gauge(GaugeElement { style, .. })
705            | WidgetElement::Button(ButtonElement { style, .. })
706            | WidgetElement::Toggle(ToggleElement { style, .. })
707            | WidgetElement::Divider(DividerElement { style, .. })
708            | WidgetElement::Date(DateElement { style, .. })
709            | WidgetElement::Chart(ChartElement { style, .. })
710            | WidgetElement::List(ListElement { style, .. })
711            | WidgetElement::Link(LinkElement { style, .. })
712            | WidgetElement::Shape(ShapeElement { style, .. })
713            | WidgetElement::Timer(TimerElement { style, .. })
714            | WidgetElement::Canvas(CanvasElement { style, .. })
715            | WidgetElement::Label(LabelElement { style, .. }) => Some(style),
716            WidgetElement::Repeat(r) => Some(&r.style),
717            WidgetElement::Use(u) => Some(&u.style),
718            WidgetElement::Spacer(_) => None,
719        }
720    }
721
722    fn style_background_is_gradient(&self) -> bool {
723        matches!(
724            self.style_ref().and_then(|s| s.background.as_ref()),
725            Some(crate::models::BackgroundValue::Gradient(_))
726        )
727    }
728}
729
730fn push_warn(
731    out: &mut Vec<CapabilityWarning>,
732    path: &str,
733    element: &str,
734    platform: WidgetPlatform,
735) {
736    let entry = support_for(element, platform);
737    if entry.support == Support::Full {
738        return;
739    }
740    out.push(CapabilityWarning {
741        path: path.to_string(),
742        element: element.to_string(),
743        platform,
744        support: entry.support,
745        note: entry.note.to_string(),
746    });
747}
748
749fn walk_element(
750    el: &WidgetElement,
751    path: &str,
752    platform: WidgetPlatform,
753    out: &mut Vec<CapabilityWarning>,
754) {
755    let ty = el.type_name();
756    push_warn(out, path, ty, platform);
757
758    if el.style_background_is_gradient() {
759        push_warn(out, path, "background.gradient", platform);
760    }
761    if let Some(style) = el.style_ref() {
762        if style.privacy_sensitive == Some(true) {
763            push_warn(out, path, "style.privacySensitive", platform);
764        }
765        if style
766            .accessibility_label
767            .as_deref()
768            .is_some_and(|l| !l.is_empty())
769        {
770            push_warn(out, path, "style.accessibilityLabel", platform);
771        }
772        if style
773            .transition
774            .is_some_and(|t| t != crate::models::Transition::None)
775        {
776            push_warn(out, path, "style.transition", platform);
777        }
778    }
779    let sets_state = match el {
780        WidgetElement::Button(b) => b.set.is_some(),
781        WidgetElement::Toggle(t) => t.set.is_some(),
782        WidgetElement::Link(l) => l.set.is_some(),
783        _ => false,
784    };
785    if sets_state {
786        push_warn(out, path, "config.state", platform);
787    }
788
789    if let WidgetElement::Image(ImageElement {
790        url, system_name, ..
791    }) = el
792    {
793        if url.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
794            push_warn(out, path, "image.url", platform);
795        }
796        if system_name.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
797            push_warn(out, path, "image.systemName", platform);
798        }
799    }
800
801    if let WidgetElement::Timer(_) = el {
802        push_warn(out, path, "timer.live", platform);
803    }
804
805    if let WidgetElement::Canvas(CanvasElement { elements, .. }) = el {
806        if elements
807            .iter()
808            .any(|c| matches!(c, crate::models::CanvasDrawCommand::Path { .. }))
809        {
810            push_warn(out, path, "canvas.path", platform);
811        }
812    }
813
814    for (i, child) in el.children_ref().iter().enumerate() {
815        walk_element(child, &format!("{path}/{ty}[{i}]"), platform, out);
816    }
817}
818
819/// Walk config layouts and collect non-full capability warnings for `platform`.
820pub fn validate_config(config: &WidgetConfig, platform: WidgetPlatform) -> Vec<CapabilityWarning> {
821    let mut out = Vec::new();
822    if let Some(el) = &config.small {
823        walk_element(el, "small", platform, &mut out);
824    }
825    if let Some(el) = &config.medium {
826        walk_element(el, "medium", platform, &mut out);
827    }
828    if let Some(el) = &config.large {
829        walk_element(el, "large", platform, &mut out);
830    }
831    for (i, entry) in config.entries.iter().enumerate() {
832        if i == 0 {
833            push_warn(&mut out, "entries", "config.entries", platform);
834        }
835        for (family, el) in [
836            ("small", &entry.small),
837            ("medium", &entry.medium),
838            ("large", &entry.large),
839        ] {
840            if let Some(el) = el {
841                walk_element(el, &format!("entries[{i}].{family}"), platform, &mut out);
842            }
843        }
844    }
845    if config.source.is_some() {
846        push_warn(&mut out, "source", "config.source", platform);
847    }
848    if config.state.is_some() {
849        push_warn(&mut out, "state", "config.state", platform);
850    }
851    if config
852        .transition
853        .is_some_and(|t| t != crate::models::Transition::None)
854    {
855        push_warn(&mut out, "transition", "style.transition", platform);
856    }
857    out
858}
859
860/// Log warnings for the current compile target (non-blocking).
861pub fn log_capabilities(config: &WidgetConfig) {
862    let platform = WidgetPlatform::current();
863    for w in validate_config(config, platform) {
864        log::warn!(
865            "widget capability {}: {} at {} on {} — {}",
866            w.support.as_str(),
867            w.element,
868            w.path,
869            w.platform.as_str(),
870            w.note
871        );
872    }
873}
874
875/// Machine-readable capability matrix for CLI `validate` and docs tooling.
876pub fn render_capabilities_json() -> String {
877    use serde_json::{json, Map, Value};
878
879    let mut elements = Map::new();
880    for el in ELEMENT_TYPES {
881        let mut platforms = Map::new();
882        for p in WidgetPlatform::all() {
883            let e = support_for(el, p);
884            platforms.insert(
885                p.as_str().to_string(),
886                json!({
887                    "support": e.support.as_str(),
888                    "note": e.note,
889                }),
890            );
891        }
892        elements.insert((*el).to_string(), Value::Object(platforms));
893    }
894
895    let mut features = Map::new();
896    for feat in FEATURE_KEYS {
897        let mut platforms = Map::new();
898        for p in WidgetPlatform::all() {
899            let e = support_for(feat, p);
900            platforms.insert(
901                p.as_str().to_string(),
902                json!({
903                    "support": e.support.as_str(),
904                    "note": e.note,
905                }),
906            );
907        }
908        features.insert((*feat).to_string(), Value::Object(platforms));
909    }
910
911    let doc = json!({
912        "version": 1,
913        "platforms": WidgetPlatform::all().map(|p| p.as_str()),
914        "core": CORE_ELEMENTS,
915        "extended": EXTENDED_ELEMENTS,
916        "elements": elements,
917        "features": features,
918    });
919    format!(
920        "{}\n",
921        serde_json::to_string_pretty(&doc).expect("serialize capabilities.json")
922    )
923}
924
925/// Render markdown capability matrix for docs (`###` headings — embed under
926/// a single `## Capability matrix` on the tiers page).
927pub fn render_capability_matrix_md() -> String {
928    let mut notes: Vec<String> = Vec::new();
929    let mut md = String::from(
930        "# Capability matrix (element × platform)\n\n\
931         Table is authored in `src/capabilities.rs`; this page embeds it automatically.\n\n",
932    );
933
934    md.push_str("### Element lists\n\n");
935    md.push_str(&format!(
936        "**Core** (`CORE_ELEMENTS` in `src/snapshot.rs`): {}\n\n",
937        linked_element_list(CORE_ELEMENTS)
938    ));
939    md.push_str(&format!(
940        "**Extended** (`EXTENDED_ELEMENTS`): {}\n\n",
941        linked_element_list(EXTENDED_ELEMENTS)
942    ));
943
944    md.push_str("### Core\n\n");
945    md.push_str(&platform_table_header("Element"));
946    for el in CORE_ELEMENTS {
947        md.push_str(&element_row(el, &mut notes));
948    }
949
950    md.push_str("\n### Extended\n\n");
951    md.push_str(&platform_table_header("Element"));
952    for el in EXTENDED_ELEMENTS {
953        md.push_str(&element_row(el, &mut notes));
954    }
955
956    md.push_str("\n### Feature notes\n\n");
957    md.push_str(&platform_table_header("Feature"));
958    for feat in FEATURE_KEYS {
959        md.push_str(&feature_row(feat, &mut notes));
960    }
961
962    md.push_str("\n### Choosing a surface set\n\n");
963    md.push_str(
964        "Pick the platforms you ship, then stay in the **full** set for that profile. \
965         Degraded cells still render, but check the [Notes](#notes) and element pages.\n\n",
966    );
967    md.push_str(&profile_block(
968        "Apple only",
969        "iOS + macOS",
970        &[WidgetPlatform::Ios, WidgetPlatform::Macos],
971    ));
972    md.push_str(&profile_block(
973        "Apple + Android",
974        "iOS + macOS + Android",
975        &[
976            WidgetPlatform::Ios,
977            WidgetPlatform::Macos,
978            WidgetPlatform::Android,
979        ],
980    ));
981    md.push_str(&profile_block(
982        "All five matrix columns",
983        "iOS + macOS + Android + Desktop + Windows",
984        &WidgetPlatform::all(),
985    ));
986
987    if !notes.is_empty() {
988        md.push_str("\n### Notes\n\n");
989        for (i, note) in notes.iter().enumerate() {
990            md.push_str(&format!("{}. {}\n", i + 1, note));
991        }
992    }
993    md
994}
995
996fn platform_table_header(first: &str) -> String {
997    format!(
998        "| {first} | iOS | macOS | Android | Desktop | Windows |\n\
999         |---------|-----|-------|---------|---------|----------|\n"
1000    )
1001}
1002
1003fn linked_element_list(els: &[&str]) -> String {
1004    els.iter()
1005        .map(|e| format!("[`{e}`]({})", element_doc_href(e)))
1006        .collect::<Vec<_>>()
1007        .join(", ")
1008}
1009
1010fn element_row(el: &str, notes: &mut Vec<String>) -> String {
1011    format!(
1012        "| [`{el}`]({}) | {} | {} | {} | {} | {} |\n",
1013        element_doc_href(el),
1014        cell_md(&support_for(el, WidgetPlatform::Ios), notes),
1015        cell_md(&support_for(el, WidgetPlatform::Macos), notes),
1016        cell_md(&support_for(el, WidgetPlatform::Android), notes),
1017        cell_md(&support_for(el, WidgetPlatform::Desktop), notes),
1018        cell_md(&support_for(el, WidgetPlatform::Windows), notes),
1019    )
1020}
1021
1022fn feature_row(feat: &str, notes: &mut Vec<String>) -> String {
1023    format!(
1024        "| [`{feat}`]({}) | {} | {} | {} | {} | {} |\n",
1025        element_doc_href(feat),
1026        cell_md(&support_for(feat, WidgetPlatform::Ios), notes),
1027        cell_md(&support_for(feat, WidgetPlatform::Macos), notes),
1028        cell_md(&support_for(feat, WidgetPlatform::Android), notes),
1029        cell_md(&support_for(feat, WidgetPlatform::Desktop), notes),
1030        cell_md(&support_for(feat, WidgetPlatform::Windows), notes),
1031    )
1032}
1033
1034fn profile_block(title: &str, platforms_label: &str, platforms: &[WidgetPlatform]) -> String {
1035    let (full_core, watch_core) = partition_by_full(CORE_ELEMENTS, platforms);
1036    let (full_ext, watch_ext) = partition_by_full(EXTENDED_ELEMENTS, platforms);
1037    let mut out = format!("#### {title}\n\nPlatforms: **{platforms_label}**.\n\n");
1038    out.push_str(&format!(
1039        "- **Full core:** {}\n",
1040        if full_core.is_empty() {
1041            "_none_".into()
1042        } else {
1043            linked_element_list(&full_core)
1044        }
1045    ));
1046    if !watch_core.is_empty() {
1047        out.push_str(&format!(
1048            "- **Core with degraded/unsupported cells:** {}\n",
1049            linked_element_list(&watch_core)
1050        ));
1051    }
1052    out.push_str(&format!(
1053        "- **Full extended:** {}\n",
1054        if full_ext.is_empty() {
1055            "_none_".into()
1056        } else {
1057            linked_element_list(&full_ext)
1058        }
1059    ));
1060    if !watch_ext.is_empty() {
1061        out.push_str(&format!(
1062            "- **Extended with degraded/unsupported cells:** {}\n",
1063            linked_element_list(&watch_ext)
1064        ));
1065    }
1066    out.push('\n');
1067    out
1068}
1069
1070fn partition_by_full<'a>(
1071    els: &[&'a str],
1072    platforms: &[WidgetPlatform],
1073) -> (Vec<&'a str>, Vec<&'a str>) {
1074    let mut full = Vec::new();
1075    let mut watch = Vec::new();
1076    for el in els {
1077        let all_full = platforms
1078            .iter()
1079            .all(|p| support_for(el, *p).support == Support::Full);
1080        if all_full {
1081            full.push(*el);
1082        } else {
1083            watch.push(*el);
1084        }
1085    }
1086    (full, watch)
1087}
1088
1089/// Docs path for an element or feature key (`image.url` → image page).
1090fn element_doc_href(key: &str) -> String {
1091    let base = key.split('.').next().unwrap_or(key);
1092    if key == "background.gradient" || base == "style" {
1093        return "/elements/style".into();
1094    }
1095    match key {
1096        "config.entries" => return "/features/timelines".into(),
1097        "config.strings" => return "/features/dynamic-content#localized-strings-strings".into(),
1098        "config.source" => return "/features/remote-data".into(),
1099        "config.state" => return "/features/interactive#local-state-state-set".into(),
1100        "activity.template" => return "/features/live-activities".into(),
1101        "config.instance" => return "/features/instances".into(),
1102        "activity.island" => return "/features/dynamic-island".into(),
1103        "activity" => return "/features/live-activities".into(),
1104        _ => {}
1105    }
1106    let page = match base {
1107        "vstack" | "hstack" | "zstack" | "grid" | "container" => "layout",
1108        "text" | "label" | "date" | "timer" => "text",
1109        "image" | "shape" | "canvas" => "media",
1110        "progress" | "gauge" | "chart" | "list" => "data",
1111        "button" | "toggle" | "link" => "interactive",
1112        "spacer" | "divider" => "spacing",
1113        _ => return "/elements/".into(),
1114    };
1115    format!("/elements/{page}#{base}")
1116}
1117
1118fn cell_md(e: &CapabilityEntry, notes: &mut Vec<String>) -> String {
1119    if e.note.is_empty() {
1120        return e.support.as_str().to_string();
1121    }
1122    let idx = note_index(notes, e.note);
1123    format!("{}<sup>{}</sup>", e.support.as_str(), idx)
1124}
1125
1126fn note_index(notes: &mut Vec<String>, note: &str) -> usize {
1127    if let Some(i) = notes.iter().position(|n| n == note) {
1128        return i + 1;
1129    }
1130    notes.push(note.to_string());
1131    notes.len()
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::*;
1137
1138    fn cfg(v: serde_json::Value) -> WidgetConfig {
1139        serde_json::from_value(v).unwrap()
1140    }
1141
1142    fn found(ws: &[CapabilityWarning]) -> Vec<(String, String, &'static str)> {
1143        ws.iter()
1144            .map(|w| (w.path.clone(), w.element.clone(), w.support.as_str()))
1145            .collect()
1146    }
1147
1148    /// The matrix: every element type has a cell per platform → the rendered Markdown names
1149    /// elements, features, sections, doc links and footnotes → element type names match it.
1150    #[test]
1151    fn matrix_has_every_cell_and_renders_to_markdown() {
1152        for el in ELEMENT_TYPES {
1153            for p in WidgetPlatform::all() {
1154                let e = support_for(el, p);
1155                assert_eq!(e.element, *el);
1156                assert_eq!(e.platform, p);
1157            }
1158        }
1159        let md = render_capability_matrix_md();
1160        assert!(md.contains("`vstack`"));
1161        assert!(md.contains("image.url"));
1162        assert!(md.contains("### Core\n"));
1163        assert!(!md.contains("## Core "));
1164        assert!(md.contains("/elements/layout#vstack"));
1165        assert!(md.contains("### Choosing a surface set"));
1166        assert!(md.contains("<sup>"));
1167        let chart = cfg(
1168            serde_json::json!({"small": {"type": "chart", "chartType": "bar",
1169            "chartData": [{"label": "a", "value": 1}]}}),
1170        );
1171        assert_eq!(chart.small.unwrap().type_name(), "chart");
1172    }
1173
1174    /// `validate_config` per platform: Windows walks children, entries and config features
1175    /// (exact warning list) → iOS (full renderer, `image.url` is full after prefetch) has
1176    /// none → Android flags only the SF-symbol name → an empty config has none anywhere.
1177    #[test]
1178    fn validate_config_warnings_by_platform() {
1179        // 1. Windows: nested children, timeline entries and config-level features.
1180        let c = cfg(serde_json::json!({
1181            "small": {"type": "vstack", "children": [
1182                {"type": "zstack", "background": {"gradientType": "linear", "colors": ["#000", "#fff"]}},
1183                {"type": "timer", "targetDate": "2030-01-01T00:00:00Z"},
1184                {"type": "canvas", "width": 10, "height": 10, "elements": [{"draw": "path", "d": "M0 0"}]},
1185                {"type": "text", "content": "x", "privacySensitive": true, "accessibilityLabel": "X"},
1186                {"type": "text", "content": "y", "privacySensitive": false, "accessibilityLabel": ""}
1187            ]},
1188            "entries": [{"at": 1, "medium": {"type": "shape", "shapeType": "circle"}}],
1189            "source": {"url": "https://a"}
1190        }));
1191        let w = found(&validate_config(&c, WidgetPlatform::Windows));
1192        let want = [
1193            ("small/vstack[0]", "zstack", "degraded"),
1194            ("small/vstack[0]", "background.gradient", "degraded"),
1195            ("small/vstack[1]", "timer", "degraded"),
1196            ("small/vstack[1]", "timer.live", "degraded"),
1197            ("small/vstack[2]", "canvas", "degraded"),
1198            ("small/vstack[2]", "canvas.path", "degraded"),
1199            ("small/vstack[3]", "style.privacySensitive", "unsupported"),
1200            ("small/vstack[3]", "style.accessibilityLabel", "unsupported"),
1201            ("entries", "config.entries", "degraded"),
1202            ("entries[0].medium", "shape", "degraded"),
1203            ("source", "config.source", "unsupported"),
1204        ];
1205        for (p, e, s) in want {
1206            assert!(
1207                w.contains(&(p.into(), e.into(), s)),
1208                "missing {p} {e} {s} in {w:?}"
1209            );
1210        }
1211        assert_eq!(w.len(), want.len(), "{w:?}");
1212
1213        // 2. iOS / Android on images: url is full on iOS (prefetched), systemName degrades
1214        //    on Android; empty url / systemName are not checked.
1215        let c = cfg(serde_json::json!({
1216            "small": {"type": "image", "url": "https://example.com/a.png", "size": 32},
1217            "medium": {"type": "image", "url": "https://x/a.png", "systemName": "star"},
1218            "large": {"type": "image", "url": "", "systemName": ""},
1219            "strings": {"en": {"a": "b"}}
1220        }));
1221        assert!(validate_config(&c, WidgetPlatform::Ios).is_empty());
1222        assert_eq!(
1223            found(&validate_config(&c, WidgetPlatform::Android)),
1224            vec![("medium".into(), "image.systemName".into(), "degraded")]
1225        );
1226
1227        // 3. Nothing to warn about.
1228        assert!(validate_config(&WidgetConfig::default(), WidgetPlatform::Windows).is_empty());
1229    }
1230}
1231
1232#[cfg(test)]
1233mod write_docs {
1234    #[test]
1235    fn capability_matrix_doc_matches() {
1236        let expected = super::render_capability_matrix_md();
1237        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1238            .join("docs/generated/capability-matrix.md");
1239        if !path.exists() {
1240            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1241            std::fs::write(&path, &expected).unwrap();
1242            return;
1243        }
1244        let on_disk = std::fs::read_to_string(&path).unwrap();
1245        assert_eq!(
1246            on_disk, expected,
1247            "docs/generated/capability-matrix.md drifted — regenerate with:\n\
1248             cargo test --lib write_docs::capability_matrix_doc_matches -- --ignored\n\
1249             or delete the file and re-run this test"
1250        );
1251    }
1252
1253    #[test]
1254    fn capabilities_json_matches() {
1255        let expected = super::render_capabilities_json();
1256        let path =
1257            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas/capabilities.json");
1258        if !path.exists() {
1259            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1260            std::fs::write(&path, &expected).unwrap();
1261            return;
1262        }
1263        let on_disk = std::fs::read_to_string(&path).unwrap();
1264        assert_eq!(
1265            on_disk, expected,
1266            "schemas/capabilities.json drifted — delete it and re-run this test, or:\n\
1267             cargo test --lib capabilities::write_docs::capabilities_json_matches"
1268        );
1269    }
1270}