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        "flattened Container, no overlay",
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: systemName / url differ
201    out.extend_from_slice(&apple_full("image"));
202    out.push(cell(
203        "image",
204        Android,
205        Degraded,
206        "systemName via glyph map; url via localPath preprocess",
207    ));
208    out.push(cell("image", Desktop, Full, ""));
209    out.push(cell(
210        "image",
211        Windows,
212        Degraded,
213        "url/data URI; systemName unsupported",
214    ));
215
216    out.extend_from_slice(&apple_full("chart"));
217    out.push(cell(
218        "chart",
219        Android,
220        Degraded,
221        "simplified bar/line rendering",
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        "Glance LazyColumn; depth/children limited",
232    ));
233    out.push(cell("list", Desktop, Full, ""));
234    out.push(cell("list", Windows, Degraded, "flattened TextBlocks"));
235
236    out.extend_from_slice(&apple_full("timer"));
237    out.push(cell(
238        "timer",
239        Android,
240        Degraded,
241        "static snapshot, not live Chronometer in all hosts",
242    ));
243    out.push(cell("timer", Desktop, Full, "setInterval"));
244    out.push(cell(
245        "timer",
246        Windows,
247        Degraded,
248        "static TextBlock of targetDate",
249    ));
250
251    out.extend_from_slice(&apple_full("canvas"));
252    out.push(cell(
253        "canvas",
254        Android,
255        Degraded,
256        "bitmap canvas; path support limited",
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        Unsupported,
266        "prefetch into shared container not wired",
267    ));
268    out.push(cell(
269        "image.url",
270        Macos,
271        Unsupported,
272        "prefetch into shared container not wired",
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        "glyph / drawable name map",
290    ));
291    out.push(cell(
292        "image.systemName",
293        Desktop,
294        Degraded,
295        "placeholder glyph",
296    ));
297    out.push(cell(
298        "image.systemName",
299        Windows,
300        Unsupported,
301        "no SF Symbols on Adaptive Cards",
302    ));
303
304    out.push(cell(
305        "background.gradient",
306        Ios,
307        Degraded,
308        "linear primary; radial/angular limited",
309    ));
310    out.push(cell(
311        "background.gradient",
312        Macos,
313        Degraded,
314        "linear primary; radial/angular limited",
315    ));
316    out.push(cell(
317        "background.gradient",
318        Android,
319        Degraded,
320        "first color stop only (Glance)",
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        Unsupported,
332        "Container style=emphasis only",
333    ));
334
335    out.push(cell("canvas.path", Ios, Degraded, "M/L/H/V/Z subset"));
336    out.push(cell("canvas.path", Macos, Degraded, "M/L/H/V/Z subset"));
337    out.push(cell(
338        "canvas.path",
339        Android,
340        Degraded,
341        "limited path commands",
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        Unsupported,
352        "no live timer in Glance snapshot",
353    ));
354    out.push(cell("timer.live", Desktop, Full, "JS interval"));
355    out.push(cell("timer.live", Windows, Unsupported, "static only"));
356
357    out
358}
359
360use std::sync::OnceLock;
361
362/// Lookup support for an element or feature key.
363pub fn support_for(element: &str, platform: WidgetPlatform) -> CapabilityEntry {
364    static TABLE: OnceLock<Vec<CapabilityEntry>> = OnceLock::new();
365    let table = TABLE.get_or_init(capability_table);
366    table
367        .iter()
368        .find(|e| e.element == element && e.platform == platform)
369        .copied()
370        .unwrap_or(CapabilityEntry {
371            element: "",
372            platform,
373            support: Support::Unsupported,
374            note: "unknown element",
375        })
376}
377
378impl WidgetElement {
379    /// Serde `type` tag for this element.
380    pub fn type_name(&self) -> &'static str {
381        match self {
382            WidgetElement::VStack(_) => "vstack",
383            WidgetElement::HStack(_) => "hstack",
384            WidgetElement::ZStack(_) => "zstack",
385            WidgetElement::Grid(_) => "grid",
386            WidgetElement::Container(_) => "container",
387            WidgetElement::Text(_) => "text",
388            WidgetElement::Image(_) => "image",
389            WidgetElement::Progress(_) => "progress",
390            WidgetElement::Gauge(_) => "gauge",
391            WidgetElement::Button(_) => "button",
392            WidgetElement::Toggle(_) => "toggle",
393            WidgetElement::Divider(_) => "divider",
394            WidgetElement::Spacer(_) => "spacer",
395            WidgetElement::Date(_) => "date",
396            WidgetElement::Chart(_) => "chart",
397            WidgetElement::List(_) => "list",
398            WidgetElement::Link(_) => "link",
399            WidgetElement::Shape(_) => "shape",
400            WidgetElement::Timer(_) => "timer",
401            WidgetElement::Canvas(_) => "canvas",
402            WidgetElement::Label(_) => "label",
403        }
404    }
405
406    fn children_ref(&self) -> &[WidgetElement] {
407        match self {
408            WidgetElement::VStack(VStackElement { children, .. })
409            | WidgetElement::HStack(HStackElement { children, .. })
410            | WidgetElement::ZStack(ZStackElement { children, .. })
411            | WidgetElement::Grid(GridElement { children, .. })
412            | WidgetElement::Container(ContainerElement { children, .. })
413            | WidgetElement::Link(LinkElement { children, .. }) => children,
414            _ => &[],
415        }
416    }
417
418    fn style_background_is_gradient(&self) -> bool {
419        use crate::models::{BackgroundValue, ElementStyle};
420        let style: Option<&ElementStyle> = match self {
421            WidgetElement::VStack(VStackElement { style, .. })
422            | WidgetElement::HStack(HStackElement { style, .. })
423            | WidgetElement::ZStack(ZStackElement { style, .. })
424            | WidgetElement::Grid(GridElement { style, .. })
425            | WidgetElement::Container(ContainerElement { style, .. })
426            | WidgetElement::Text(TextElement { style, .. })
427            | WidgetElement::Image(ImageElement { style, .. })
428            | WidgetElement::Progress(ProgressElement { style, .. })
429            | WidgetElement::Gauge(GaugeElement { style, .. })
430            | WidgetElement::Button(ButtonElement { style, .. })
431            | WidgetElement::Toggle(ToggleElement { style, .. })
432            | WidgetElement::Divider(DividerElement { style, .. })
433            | WidgetElement::Date(DateElement { style, .. })
434            | WidgetElement::Chart(ChartElement { style, .. })
435            | WidgetElement::List(ListElement { style, .. })
436            | WidgetElement::Link(LinkElement { style, .. })
437            | WidgetElement::Shape(ShapeElement { style, .. })
438            | WidgetElement::Timer(TimerElement { style, .. })
439            | WidgetElement::Canvas(CanvasElement { style, .. })
440            | WidgetElement::Label(LabelElement { style, .. }) => Some(style),
441            WidgetElement::Spacer(_) => None,
442        };
443        matches!(
444            style.and_then(|s| s.background.as_ref()),
445            Some(BackgroundValue::Gradient(_))
446        )
447    }
448}
449
450fn push_warn(
451    out: &mut Vec<CapabilityWarning>,
452    path: &str,
453    element: &str,
454    platform: WidgetPlatform,
455) {
456    let entry = support_for(element, platform);
457    if entry.support == Support::Full {
458        return;
459    }
460    out.push(CapabilityWarning {
461        path: path.to_string(),
462        element: element.to_string(),
463        platform,
464        support: entry.support,
465        note: entry.note.to_string(),
466    });
467}
468
469fn walk_element(
470    el: &WidgetElement,
471    path: &str,
472    platform: WidgetPlatform,
473    out: &mut Vec<CapabilityWarning>,
474) {
475    let ty = el.type_name();
476    push_warn(out, path, ty, platform);
477
478    if el.style_background_is_gradient() {
479        push_warn(out, path, "background.gradient", platform);
480    }
481
482    if let WidgetElement::Image(ImageElement {
483        url, system_name, ..
484    }) = el
485    {
486        if url.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
487            push_warn(out, path, "image.url", platform);
488        }
489        if system_name.as_ref().map(|s| !s.is_empty()).unwrap_or(false) {
490            push_warn(out, path, "image.systemName", platform);
491        }
492    }
493
494    if let WidgetElement::Timer(_) = el {
495        push_warn(out, path, "timer.live", platform);
496    }
497
498    if let WidgetElement::Canvas(CanvasElement { elements, .. }) = el {
499        if elements
500            .iter()
501            .any(|c| matches!(c, crate::models::CanvasDrawCommand::Path { .. }))
502        {
503            push_warn(out, path, "canvas.path", platform);
504        }
505    }
506
507    for (i, child) in el.children_ref().iter().enumerate() {
508        walk_element(child, &format!("{path}/{ty}[{i}]"), platform, out);
509    }
510}
511
512/// Walk config layouts and collect non-full capability warnings for `platform`.
513pub fn validate_config(config: &WidgetConfig, platform: WidgetPlatform) -> Vec<CapabilityWarning> {
514    let mut out = Vec::new();
515    if let Some(el) = &config.small {
516        walk_element(el, "small", platform, &mut out);
517    }
518    if let Some(el) = &config.medium {
519        walk_element(el, "medium", platform, &mut out);
520    }
521    if let Some(el) = &config.large {
522        walk_element(el, "large", platform, &mut out);
523    }
524    out
525}
526
527/// Log warnings for the current compile target (non-blocking).
528pub fn log_capabilities(config: &WidgetConfig) {
529    let platform = WidgetPlatform::current();
530    for w in validate_config(config, platform) {
531        log::warn!(
532            "widget capability {}: {} at {} on {} — {}",
533            w.support.as_str(),
534            w.element,
535            w.path,
536            w.platform.as_str(),
537            w.note
538        );
539    }
540}
541
542/// Render markdown capability matrix (elements only, not feature keys).
543pub fn render_capability_matrix_md() -> String {
544    let mut md = String::from(
545        "# Capability matrix (element × platform)\n\n\
546         Generated from `tauri_plugin_widgets::capabilities`. Do not edit by hand.\n\n\
547         ## Core (strict snapshot contract)\n\n\
548         | Element | iOS | macOS | Android | Desktop | Windows |\n\
549         |---------|-----|-------|---------|---------|----------|\n",
550    );
551
552    for el in CORE_ELEMENTS {
553        let ios = support_for(el, WidgetPlatform::Ios);
554        let mac = support_for(el, WidgetPlatform::Macos);
555        let and = support_for(el, WidgetPlatform::Android);
556        let desk = support_for(el, WidgetPlatform::Desktop);
557        let win = support_for(el, WidgetPlatform::Windows);
558        md.push_str(&format!(
559            "| `{el}` | {} | {} | {} | {} | {} |\n",
560            cell_md(&ios),
561            cell_md(&mac),
562            cell_md(&and),
563            cell_md(&desk),
564            cell_md(&win),
565        ));
566    }
567
568    md.push_str(
569        "\n## Extended (best-effort, platform-dependent)\n\n\
570         | Element | iOS | macOS | Android | Desktop | Windows |\n\
571         |---------|-----|-------|---------|---------|----------|\n",
572    );
573    for el in EXTENDED_ELEMENTS {
574        let ios = support_for(el, WidgetPlatform::Ios);
575        let mac = support_for(el, WidgetPlatform::Macos);
576        let and = support_for(el, WidgetPlatform::Android);
577        let desk = support_for(el, WidgetPlatform::Desktop);
578        let win = support_for(el, WidgetPlatform::Windows);
579        md.push_str(&format!(
580            "| `{el}` | {} | {} | {} | {} | {} |\n",
581            cell_md(&ios),
582            cell_md(&mac),
583            cell_md(&and),
584            cell_md(&desk),
585            cell_md(&win),
586        ));
587    }
588
589    md.push_str("\n## Feature notes\n\n");
590    md.push_str("| Feature | iOS | macOS | Android | Desktop | Windows |\n");
591    md.push_str("|---------|-----|-------|---------|---------|----------|\n");
592    for feat in FEATURE_KEYS {
593        let ios = support_for(feat, WidgetPlatform::Ios);
594        let mac = support_for(feat, WidgetPlatform::Macos);
595        let and = support_for(feat, WidgetPlatform::Android);
596        let desk = support_for(feat, WidgetPlatform::Desktop);
597        let win = support_for(feat, WidgetPlatform::Windows);
598        md.push_str(&format!(
599            "| `{feat}` | {} | {} | {} | {} | {} |\n",
600            cell_md(&ios),
601            cell_md(&mac),
602            cell_md(&and),
603            cell_md(&desk),
604            cell_md(&win),
605        ));
606    }
607    md
608}
609
610fn cell_md(e: &CapabilityEntry) -> String {
611    if e.note.is_empty() {
612        e.support.as_str().to_string()
613    } else {
614        format!("{} ({})", e.support.as_str(), e.note)
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::models::{
622        ChartDataPoint, ChartElement, ChartType, ImageElement, WidgetConfig, WidgetElement,
623    };
624
625    #[test]
626    fn all_element_types_have_five_platforms() {
627        for el in ELEMENT_TYPES {
628            for p in WidgetPlatform::all() {
629                let e = support_for(el, p);
630                assert_eq!(e.element, *el);
631                assert_eq!(e.platform, p);
632            }
633        }
634    }
635
636    #[test]
637    fn validate_flags_image_url_on_ios() {
638        let cfg = WidgetConfig {
639            version: 1,
640            small: Some(WidgetElement::Image(ImageElement {
641                system_name: None,
642                data: None,
643                url: Some("https://example.com/a.png".into()),
644                size: Some(32.0),
645                color: None,
646                content_mode: None,
647                style: Default::default(),
648            })),
649            medium: None,
650            large: None,
651        };
652        let warns = validate_config(&cfg, WidgetPlatform::Ios);
653        assert!(
654            warns
655                .iter()
656                .any(|w| w.element == "image.url" && w.support == Support::Unsupported),
657            "{warns:?}"
658        );
659    }
660
661    #[test]
662    fn matrix_markdown_mentions_vstack() {
663        let md = render_capability_matrix_md();
664        assert!(md.contains("`vstack`"));
665        assert!(md.contains("image.url"));
666    }
667
668    #[test]
669    fn chart_roundtrip_type_name() {
670        let el = WidgetElement::Chart(ChartElement {
671            chart_type: ChartType::Bar,
672            chart_data: vec![ChartDataPoint {
673                label: "a".into(),
674                value: 1.0,
675                color: None,
676            }],
677            tint: None,
678            style: Default::default(),
679        });
680        assert_eq!(el.type_name(), "chart");
681    }
682}
683
684#[cfg(test)]
685mod write_docs {
686    #[test]
687    fn capability_matrix_doc_matches() {
688        let expected = super::render_capability_matrix_md();
689        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
690            .join("docs/guide/_generated/capability-matrix.md");
691        if !path.exists() {
692            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
693            std::fs::write(&path, &expected).unwrap();
694            return;
695        }
696        let on_disk = std::fs::read_to_string(&path).unwrap();
697        assert_eq!(
698            on_disk, expected,
699            "docs/guide/_generated/capability-matrix.md drifted — regenerate with:\n\
700             cargo test --lib write_docs::capability_matrix_doc_matches -- --ignored\n\
701             or delete the file and re-run this test"
702        );
703    }
704}