1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum WidgetPlatform {
16 Ios,
17 Macos,
18 Android,
19 Desktop,
20 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 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 WidgetPlatform::Windows
56 } else {
57 WidgetPlatform::Desktop
58 }
59 }
60}
61
62#[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#[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#[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
99pub 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};
127pub 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
170pub 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 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 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 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 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 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 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
635pub 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 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
819pub 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
860pub 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
875pub 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
925pub 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
1089fn 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 #[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 #[test]
1178 fn validate_config_warnings_by_platform() {
1179 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 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 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}