Skip to main content

tauri_plugin_widgets/adaptive_card/
mod.rs

1//! IR → Adaptive Card 1.5 transpiler for Windows Widgets Board.
2//!
3//! Pure Rust — runs on any host. Elements Adaptive Cards cannot express natively
4//! (`canvas`, `chart`, `gauge`, `shape`, `zstack`, gradients) are rasterized to
5//! PNG data URIs when the `rasterize` feature is enabled; otherwise they may be
6//! skipped / flattened and recorded in [`TranspileResult::skipped`].
7//!
8//! Element conversion is split across the six render groups (`layout`, `text`,
9//! `media`, `data`, `interactive`, `spacing`) plus shared token helpers in
10//! `style`, mirroring the Android/Swift renderers.
11
12mod data;
13mod interactive;
14mod layout;
15mod media;
16mod spacing;
17mod style;
18mod text;
19
20use crate::models::{WidgetConfig, WidgetElement};
21use crate::receipt::SkippedElement;
22use serde_json::{json, Value};
23
24pub(crate) use style::sf_symbol_glyph_public;
25
26/// Result of transpiling one IR root (plus skipped diagnostics).
27#[derive(Debug, Clone)]
28pub struct TranspileResult {
29    pub card: Value,
30    pub skipped: Vec<SkippedElement>,
31}
32
33/// Build an Adaptive Card 1.5 document from a root element.
34pub fn to_adaptive_card(root: &WidgetElement) -> TranspileResult {
35    let mut skipped = Vec::new();
36    let body = el(root, &mut skipped);
37    let card = json!({
38        "type": "AdaptiveCard",
39        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
40        "version": "1.5",
41        "body": [body],
42    });
43    TranspileResult { card, skipped }
44}
45
46/// Pick a size branch from [`WidgetConfig`] and transpile.
47pub fn to_adaptive_card_for_size(config: &WidgetConfig, size: &str) -> Option<TranspileResult> {
48    let root = match size {
49        "large" => config
50            .large
51            .as_ref()
52            .or(config.small.as_ref())
53            .or(config.medium.as_ref()),
54        "medium" => config
55            .medium
56            .as_ref()
57            .or(config.small.as_ref())
58            .or(config.large.as_ref()),
59        _ => config
60            .small
61            .as_ref()
62            .or(config.medium.as_ref())
63            .or(config.large.as_ref()),
64    }?;
65    Some(to_adaptive_card(root))
66}
67
68/// Structural check (no full JSON Schema dependency).
69pub fn validate_card_structure(card: &Value) -> Result<(), String> {
70    let obj = card
71        .as_object()
72        .ok_or_else(|| "card must be an object".to_string())?;
73    if obj.get("type").and_then(|v| v.as_str()) != Some("AdaptiveCard") {
74        return Err("type must be AdaptiveCard".into());
75    }
76    if obj.get("version").and_then(|v| v.as_str()) != Some("1.5") {
77        return Err("version must be 1.5".into());
78    }
79    match obj.get("body") {
80        Some(Value::Array(_)) => Ok(()),
81        _ => Err("body must be an array".into()),
82    }
83}
84
85/// Storage keys written next to the config map for the C# provider.
86pub fn ac_template_key(widget_id: &str) -> String {
87    format!("ac:template:{widget_id}")
88}
89
90pub fn ac_data_key(widget_id: &str) -> String {
91    format!("ac:data:{widget_id}")
92}
93
94/// Dispatch one IR element to its render group.
95fn el(e: &WidgetElement, skipped: &mut Vec<SkippedElement>) -> Value {
96    match e {
97        // layout
98        WidgetElement::VStack(v) => layout::vstack(v, skipped),
99        WidgetElement::HStack(h) => layout::hstack(h, skipped),
100        WidgetElement::Container(c) => layout::container(c, skipped),
101        WidgetElement::Grid(g) => layout::grid(g, skipped),
102        WidgetElement::ZStack(z) => layout::zstack(e, z, skipped),
103
104        // text
105        WidgetElement::Text(t) => text::text(t),
106        WidgetElement::Date(d) => text::date(d),
107        WidgetElement::Timer(t) => text::timer(t),
108        WidgetElement::Label(l) => text::label(l),
109
110        // media
111        WidgetElement::Image(i) => media::image(i),
112        WidgetElement::Shape(_) => media::shape(e, skipped),
113        WidgetElement::Canvas(_) => media::canvas(e, skipped),
114
115        // data
116        WidgetElement::Progress(p) => data::progress(e, p, skipped),
117        WidgetElement::Gauge(_) => data::gauge(e, skipped),
118        WidgetElement::Chart(_) => data::chart(e, skipped),
119        WidgetElement::List(l) => data::list(l),
120
121        // interactive
122        WidgetElement::Button(b) => interactive::button(b),
123        WidgetElement::Toggle(t) => interactive::toggle(t),
124        WidgetElement::Link(l) => interactive::link(l, skipped),
125
126        // spacing
127        WidgetElement::Spacer(_) => spacing::spacer(),
128        WidgetElement::Divider(d) => spacing::divider(d),
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::fs;
136    use std::path::PathBuf;
137
138    fn fixtures_dir() -> PathBuf {
139        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
140    }
141
142    fn snapshots_dir() -> PathBuf {
143        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/adaptive")
144    }
145
146    fn load_config(rel: &str) -> WidgetConfig {
147        let path = fixtures_dir().join(rel);
148        let raw =
149            fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
150        // Fixtures may contain explicit nulls — strip via Value first.
151        let v: Value = serde_json::from_str(&raw).expect("json");
152        serde_json::from_value(v).unwrap_or_else(|e| panic!("parse {rel}: {e}"))
153    }
154
155    #[test]
156    fn weather_small_is_valid_v15() {
157        let cfg = load_config("presets/weather.json");
158        let result = to_adaptive_card_for_size(&cfg, "small").expect("small layout");
159        validate_card_structure(&result.card).unwrap();
160        assert_eq!(result.card["body"].as_array().unwrap().len(), 1);
161        assert_eq!(result.card["body"][0]["type"], "Container");
162    }
163
164    #[test]
165    fn unsupported_nodes_are_skipped() {
166        let cfg: WidgetConfig = serde_json::from_value(json!({
167            "version": 1,
168            "small": {
169                "type": "vstack",
170                "children": [
171                    { "type": "text", "content": "hi" },
172                    { "type": "canvas", "width": 10, "height": 10, "elements": [] },
173                    { "type": "zstack", "children": [
174                        { "type": "text", "content": "a" }
175                    ] }
176                ]
177            }
178        }))
179        .unwrap();
180        let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
181        // zstack flattens; canvas rasterizes
182        let body = serde_json::to_string(&result.card["body"]).unwrap();
183        assert!(body.contains("Container") || body.contains("Image") || body.contains("TextBlock"));
184        validate_card_structure(&result.card).unwrap();
185    }
186
187    #[test]
188    #[cfg(feature = "rasterize")]
189    fn chart_becomes_image_data_uri() {
190        let cfg: WidgetConfig = serde_json::from_value(json!({
191            "version": 1,
192            "small": {
193                "type": "chart",
194                "chartType": "bar",
195                "chartData": [
196                    { "label": "a", "value": 1 },
197                    { "label": "b", "value": 2 }
198                ]
199            }
200        }))
201        .unwrap();
202        let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
203        assert!(result.skipped.is_empty(), "{:?}", result.skipped);
204        assert_eq!(result.card["body"][0]["type"], "Image");
205        let url = result.card["body"][0]["url"].as_str().unwrap();
206        assert!(url.starts_with("data:image/png;base64,"));
207    }
208
209    #[test]
210    fn weather_small_matches_golden_or_writes_hint() {
211        let cfg = load_config("presets/weather.json");
212        let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
213        let golden_path = snapshots_dir().join("weather.small.json");
214        if !golden_path.exists() {
215            fs::create_dir_all(snapshots_dir()).unwrap();
216            let pretty = serde_json::to_string_pretty(&result.card).unwrap();
217            fs::write(&golden_path, pretty).unwrap();
218            // First run creates golden — still assert structure.
219            validate_card_structure(&result.card).unwrap();
220            return;
221        }
222        let expected: Value =
223            serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap();
224        assert_eq!(
225            result.card, expected,
226            "adaptive card drift — update tests/snapshots/adaptive/weather.small.json if intentional"
227        );
228    }
229
230    #[test]
231    fn cases_transpile_without_panic() {
232        let cases_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases");
233        let Ok(entries) = fs::read_dir(&cases_dir) else {
234            return;
235        };
236        for entry in entries.flatten() {
237            let path = entry.path();
238            if path.extension().and_then(|e| e.to_str()) != Some("json") {
239                continue;
240            }
241            let case: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
242            let fixture = case["fixture"].as_str().unwrap_or("");
243            let size = case["size"].as_str().unwrap_or("small");
244            let cfg_path = fixtures_dir().join(format!("{fixture}.json"));
245            if !cfg_path.exists() {
246                continue;
247            }
248            let cfg = load_config(&format!("{fixture}.json"));
249            let Some(result) = to_adaptive_card_for_size(&cfg, size) else {
250                continue;
251            };
252            validate_card_structure(&result.card)
253                .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
254        }
255    }
256}