Skip to main content

morph_ir/
serializer.rs

1//! IR → JSON serializer for the dev socket.
2//!
3//! Mirrors `morph/ir/serializer.py` (`IRSerializer`): windows, keyframes,
4//! recursive nodes and full style dicts, using the exact key names the C++
5//! dev runtime (`runtime/cpp/dev/ir_deserializer.h`) parses. Optional node
6//! fields are only emitted when set, matching Python's `to_dict`.
7
8use std::collections::{HashMap, HashSet};
9
10use serde_json::{Map, Value};
11
12use crate::transforms::{LengthComp, LengthUnit, TransformOp};
13use crate::{IRAnimation, IRKeyframe, IRNode, IRStyle, IRWindow};
14
15/// Serializes IR trees to JSON for the dev socket.
16pub struct IRSerializer;
17
18impl IRSerializer {
19    /// Build the root payload. `logic_so_path` is attached when the logic
20    /// library has been compiled, so the dev runtime can dlopen it.
21    pub fn to_dict(windows: &[IRWindow], logic_so_path: Option<&str>) -> Value {
22        let mut root = Map::new();
23        root.insert("type".to_string(), Value::String("app".to_string()));
24        root.insert(
25            "windows".to_string(),
26            Value::Array(windows.iter().map(Self::window).collect()),
27        );
28        if let Some(path) = logic_so_path {
29            root.insert("logic_so_path".to_string(), Value::String(path.to_string()));
30        }
31        Value::Object(root)
32    }
33
34    /// Serialize the root payload to a JSON string.
35    pub fn to_json(
36        windows: &[IRWindow],
37        logic_so_path: Option<&str>,
38    ) -> Result<String, serde_json::Error> {
39        serde_json::to_string(&Self::to_dict(windows, logic_so_path))
40    }
41
42    fn window(w: &IRWindow) -> Value {
43        let mut out = Map::new();
44        out.insert("id".to_string(), Value::String(w.window_id.clone()));
45        out.insert("title".to_string(), Value::String(w.title.clone()));
46        out.insert("width".to_string(), Value::from(w.width));
47        out.insert("height".to_string(), Value::from(w.height));
48        out.insert("visible".to_string(), Value::from(w.visible));
49        out.insert("renderer".to_string(), Value::String(w.renderer.clone()));
50        out.insert("nodes".to_string(), Value::Array(w.nodes.iter().map(Self::node).collect()));
51        out.insert("startup_logs".to_string(), strings(&w.startup_logs));
52        out.insert("premain_functions".to_string(), strings(&w.premain_functions));
53        out.insert("extra_headers".to_string(), strings(&w.extra_headers));
54        out.insert(
55            "state_vars".to_string(),
56            Value::Array(w.state_vars.iter().map(string_map).collect()),
57        );
58        out.insert(
59            "effect_decls".to_string(),
60            Value::Array(w.effect_decls.iter().map(string_map).collect()),
61        );
62        out.insert(
63            "cpp_imports".to_string(),
64            Value::Array(w.cpp_imports.iter().map(string_map).collect()),
65        );
66        out.insert("keyframes".to_string(), Self::keyframes_dict(&w.keyframes));
67        if let Some(v) = w.min_width {
68            out.insert("min_width".to_string(), Value::from(v));
69        }
70        if let Some(v) = w.max_width {
71            out.insert("max_width".to_string(), Value::from(v));
72        }
73        if let Some(v) = w.min_height {
74            out.insert("min_height".to_string(), Value::from(v));
75        }
76        if let Some(v) = w.max_height {
77            out.insert("max_height".to_string(), Value::from(v));
78        }
79        Value::Object(out)
80    }
81
82    fn keyframes_dict(keyframes: &HashMap<String, Vec<IRKeyframe>>) -> Value {
83        let mut names: Vec<&String> = keyframes.keys().collect();
84        names.sort();
85        let mut out = Map::new();
86        for name in names {
87            let frames = keyframes.get(name).map_or(&[] as &[IRKeyframe], Vec::as_slice);
88            out.insert(name.clone(), Value::Array(frames.iter().map(Self::keyframe).collect()));
89        }
90        Value::Object(out)
91    }
92
93    fn keyframe(kf: &IRKeyframe) -> Value {
94        let mut out = Map::new();
95        out.insert("offset".to_string(), num(kf.offset));
96        out.insert("style".to_string(), Self::keyframe_style(kf));
97        out.insert("raw".to_string(), string_map(&kf.raw));
98        Value::Object(out)
99    }
100
101    /// Partial style dict — only fields the keyframe explicitly declares.
102    ///
103    /// Mirrors `_keyframe_style_dict`: presence in the JSON always means
104    /// "declared", so default-compare heuristics never drop a legitimate
105    /// declaration like `opacity: 1`.
106    fn keyframe_style(kf: &IRKeyframe) -> Value {
107        let full = Self::style(&kf.style);
108        let keep: HashSet<&str> = if kf.declared.is_empty() {
109            fallback_declared(&kf.style)
110        } else {
111            kf.declared.iter().map(String::as_str).collect()
112        };
113        match full {
114            Value::Object(map) => {
115                Value::Object(map.into_iter().filter(|(k, _)| keep.contains(k.as_str())).collect())
116            }
117            other => other,
118        }
119    }
120
121    fn animations(anims: &[IRAnimation]) -> Value {
122        Value::Array(
123            anims
124                .iter()
125                .map(|a| {
126                    let mut out = Map::new();
127                    out.insert("name".to_string(), Value::String(a.name.clone()));
128                    out.insert("duration".to_string(), num(a.duration));
129                    out.insert("easing".to_string(), Value::String(a.easing.clone()));
130                    out.insert("delay".to_string(), num(a.delay));
131                    out.insert("iterations".to_string(), num(a.iterations));
132                    out.insert("direction".to_string(), Value::String(a.direction.clone()));
133                    out.insert("fill_mode".to_string(), Value::String(a.fill_mode.clone()));
134                    out.insert("play_state".to_string(), Value::String(a.play_state.clone()));
135                    Value::Object(out)
136                })
137                .collect(),
138        )
139    }
140
141    fn style(s: &IRStyle) -> Value {
142        let mut out = Map::new();
143        out.insert("bg_color".to_string(), floats(&s.bg_color));
144        out.insert("color".to_string(), floats(&s.color));
145        out.insert("width".to_string(), opt_num(s.width));
146        out.insert("min_width".to_string(), opt_num(s.min_width));
147        out.insert("max_width".to_string(), opt_num(s.max_width));
148        out.insert("height".to_string(), opt_num(s.height));
149        out.insert("min_height".to_string(), opt_num(s.min_height));
150        out.insert("max_height".to_string(), opt_num(s.max_height));
151        out.insert("margin".to_string(), floats(&s.margin));
152        out.insert(
153            "margin_auto".to_string(),
154            Value::Array(s.margin_auto.iter().map(|b| Value::from(*b)).collect()),
155        );
156        out.insert("padding".to_string(), floats(&s.padding));
157        out.insert("border_radius".to_string(), num(s.border_radius));
158        out.insert("font_size".to_string(), num(s.font_size));
159        out.insert("font_weight".to_string(), Value::String(s.font_weight.clone()));
160        out.insert("text_align".to_string(), Value::String(s.text_align.clone()));
161        out.insert("display".to_string(), Value::String(s.display.clone()));
162        out.insert("flex_dir".to_string(), Value::String(s.flex_dir.clone()));
163        out.insert("flex_grow".to_string(), num(s.flex_grow));
164        out.insert("flex_shrink".to_string(), num(s.flex_shrink));
165        out.insert("flex_basis".to_string(), Value::String(s.flex_basis.clone()));
166        out.insert("gap".to_string(), num(s.gap));
167        out.insert("overflow".to_string(), Value::String(s.overflow.clone()));
168        out.insert("position".to_string(), Value::String(s.position.clone()));
169        out.insert("left".to_string(), opt_num(s.left));
170        out.insert("right".to_string(), opt_num(s.right));
171        out.insert("top".to_string(), opt_num(s.top));
172        out.insert("bottom".to_string(), opt_num(s.bottom));
173        out.insert("justify_content".to_string(), Value::String(s.justify_content.clone()));
174        out.insert("align_items".to_string(), Value::String(s.align_items.clone()));
175        out.insert("flex_wrap".to_string(), Value::String(s.flex_wrap.clone()));
176        out.insert("cursor".to_string(), Value::String(s.cursor.clone()));
177        out.insert("scrollbar_width".to_string(), num(s.scrollbar_width));
178        out.insert("scrollbar_track_color".to_string(), floats(&s.scrollbar_track_color));
179        out.insert("scrollbar_thumb_color".to_string(), floats(&s.scrollbar_thumb_color));
180        out.insert("scrollbar_border_radius".to_string(), num(s.scrollbar_border_radius));
181        out.insert("border_width".to_string(), num(s.border_width));
182        out.insert("border_color".to_string(), floats(&s.border_color));
183        out.insert("border_style".to_string(), Value::String(s.border_style.clone()));
184        out.insert("box_sizing".to_string(), Value::String(s.box_sizing.clone()));
185        out.insert("z_index".to_string(), s.z_index.map_or(Value::Null, Value::from));
186        out.insert("opacity".to_string(), num(s.opacity));
187        out.insert(
188            "transform_ops".to_string(),
189            match s.transform_ops.as_deref().unwrap_or(&[]) {
190                [] => Value::Null,
191                ops => Value::Array(ops.iter().map(Self::transform_op).collect()),
192            },
193        );
194        out.insert(
195            "transform_matrix".to_string(),
196            s.transform_matrix.map_or(Value::Null, |m| floats(&m)),
197        );
198        out.insert(
199            "transform_origin".to_string(),
200            s.transform_origin_resolved
201                .map_or(Value::Null, |(x, y)| Value::Array(vec![num(x), num(y)])),
202        );
203        out.insert(
204            "transform_origin_raw".to_string(),
205            s.transform_origin.map_or(Value::Null, |((x, x_pct), (y, y_pct))| {
206                Value::Array(vec![
207                    Value::Array(vec![num(x), Value::from(x_pct)]),
208                    Value::Array(vec![num(y), Value::from(y_pct)]),
209                ])
210            }),
211        );
212        Value::Object(out)
213    }
214
215    /// Serialize a transform op in the same tuple shape Python emits
216    /// (`("rotate", 45.0)` → `["rotate", 45.0]`).
217    fn transform_op(op: &TransformOp) -> Value {
218        match op {
219            TransformOp::Matrix(m) => {
220                Value::Array(vec![Value::String("matrix".to_string()), floats(m)])
221            }
222            TransformOp::Matrix3d(m) => {
223                Value::Array(vec![Value::String("matrix3d".to_string()), floats(m)])
224            }
225            TransformOp::Perspective(v) => {
226                Value::Array(vec![Value::String("perspective".to_string()), num(*v)])
227            }
228            TransformOp::Rotate(d) => {
229                Value::Array(vec![Value::String("rotate".to_string()), num(*d)])
230            }
231            TransformOp::RotateX(d) => {
232                Value::Array(vec![Value::String("rotatex".to_string()), num(*d)])
233            }
234            TransformOp::RotateY(d) => {
235                Value::Array(vec![Value::String("rotatey".to_string()), num(*d)])
236            }
237            TransformOp::RotateZ(d) => {
238                Value::Array(vec![Value::String("rotatez".to_string()), num(*d)])
239            }
240            TransformOp::Rotate3d(x, y, z, d) => {
241                Value::Array(vec![Value::String("rotate3d".to_string()), floats(&[*x, *y, *z, *d])])
242            }
243            TransformOp::Translate(a, b) => Value::Array(vec![
244                Value::String("translate".to_string()),
245                Value::Array(vec![length_comp(*a), length_comp(*b)]),
246            ]),
247            TransformOp::Translate3d(a, b, c) => Value::Array(vec![
248                Value::String("translate3d".to_string()),
249                Value::Array(vec![length_comp(*a), length_comp(*b), length_comp(*c)]),
250            ]),
251            TransformOp::Scale(x, y) => Value::Array(vec![
252                Value::String("scale".to_string()),
253                Value::Array(vec![num(*x), num(*y)]),
254            ]),
255            TransformOp::Scale3d(x, y, z) => Value::Array(vec![
256                Value::String("scale3d".to_string()),
257                Value::Array(vec![num(*x), num(*y), num(*z)]),
258            ]),
259            TransformOp::Skew(x, y) => Value::Array(vec![
260                Value::String("skew".to_string()),
261                Value::Array(vec![num(*x), num(*y)]),
262            ]),
263        }
264    }
265
266    fn node(n: &IRNode) -> Value {
267        let mut out = Map::new();
268        out.insert("id".to_string(), Value::String(n.node_id.clone()));
269        out.insert("type".to_string(), Value::String(n.node_type.clone()));
270        out.insert("x".to_string(), num(n.x));
271        out.insert("y".to_string(), num(n.y));
272        out.insert("w".to_string(), num(n.w));
273        out.insert("h".to_string(), num(n.h));
274        out.insert("text".to_string(), Value::String(n.text_content.clone()));
275        out.insert("attrs".to_string(), string_map(&n.attrs));
276        out.insert("style".to_string(), Self::style(&n.style));
277        out.insert(
278            "children".to_string(),
279            Value::Array(n.children.iter().map(Self::node).collect()),
280        );
281        out.insert(
282            "events".to_string(),
283            Value::Array(
284                n.events
285                    .iter()
286                    .map(|e| {
287                        let mut ev = Map::new();
288                        ev.insert("trigger".to_string(), Value::String(e.trigger.clone()));
289                        ev.insert("action".to_string(), Value::String(e.action.clone()));
290                        ev.insert("target".to_string(), Value::String(e.target.clone()));
291                        Value::Object(ev)
292                    })
293                    .collect(),
294            ),
295        );
296        if !n.reactive_text.is_empty() {
297            out.insert("reactive_text".to_string(), Value::String(n.reactive_text.clone()));
298        }
299        if !n.reactive_class.is_empty() {
300            out.insert("reactive_class".to_string(), Value::String(n.reactive_class.clone()));
301        }
302        if !n.reactive_style.is_empty() {
303            out.insert("reactive_style".to_string(), string_map(&n.reactive_style));
304        }
305        if !n.reactive_attrs.is_empty() {
306            out.insert("reactive_attrs".to_string(), string_map(&n.reactive_attrs));
307        }
308        if !n.class_conditional_effects.is_empty() {
309            out.insert(
310                "class_conditional_effects".to_string(),
311                Value::Array(
312                    n.class_conditional_effects
313                        .iter()
314                        .map(|fx| {
315                            Value::Array(vec![
316                                Value::String(fx.condition.clone()),
317                                string_map(&fx.on_styles),
318                                string_map(&fx.off_styles),
319                            ])
320                        })
321                        .collect(),
322                ),
323            );
324        }
325        if !n.condition_expr.is_empty() {
326            out.insert("condition_expr".to_string(), Value::String(n.condition_expr.clone()));
327            out.insert(
328                "then_nodes".to_string(),
329                Value::Array(n.then_nodes.iter().map(Self::node).collect()),
330            );
331            out.insert(
332                "else_nodes".to_string(),
333                Value::Array(n.else_nodes.iter().map(Self::node).collect()),
334            );
335        }
336        if !n.list_expr.is_empty() {
337            out.insert("list_expr".to_string(), Value::String(n.list_expr.clone()));
338            out.insert("list_key_expr".to_string(), Value::String(n.list_key_expr.clone()));
339        }
340        if let Some(tmpl) = n.item_template.as_deref() {
341            out.insert("item_template".to_string(), Self::node(tmpl));
342        }
343        if let Some(hover) = n.hover_style.as_ref() {
344            out.insert("hover_style".to_string(), Self::style(hover));
345        }
346        if let Some(active) = n.active_style.as_ref() {
347            out.insert("active_style".to_string(), Self::style(active));
348        }
349        if n.transition_duration > 0.0 {
350            out.insert("transition_duration".to_string(), num(n.transition_duration));
351            out.insert("transition_easing".to_string(), Value::String(n.transition_easing.clone()));
352        }
353        if !n.animations.is_empty() {
354            out.insert("animations".to_string(), Self::animations(&n.animations));
355        }
356        if !n.hover_animations.is_empty() {
357            out.insert("hover_animations".to_string(), Self::animations(&n.hover_animations));
358        }
359        Value::Object(out)
360    }
361}
362
363/// Finite float as JSON, mirroring Python's `_clean_inf` (inf/NaN → null).
364fn num(v: f32) -> Value {
365    if v.is_finite() { Value::from(v) } else { Value::Null }
366}
367
368fn opt_num(v: Option<f32>) -> Value {
369    v.map_or(Value::Null, num)
370}
371
372fn floats(values: &[f32]) -> Value {
373    Value::Array(values.iter().map(|v| num(*v)).collect())
374}
375
376fn strings(values: &[String]) -> Value {
377    Value::Array(values.iter().map(|s| Value::String(s.clone())).collect())
378}
379
380fn string_map(map: &HashMap<String, String>) -> Value {
381    let mut keys: Vec<&String> = map.keys().collect();
382    keys.sort();
383    Value::Object(
384        keys.into_iter()
385            .map(|k| {
386                let v = map.get(k).map_or("", String::as_str);
387                (k.clone(), Value::String(v.to_string()))
388            })
389            .collect(),
390    )
391}
392
393fn length_comp(comp: LengthComp) -> Value {
394    let unit = match comp.1 {
395        LengthUnit::Px => "px",
396        LengthUnit::Pct => "%",
397    };
398    Value::Array(vec![num(comp.0), Value::String(unit.to_string())])
399}
400
401/// Fallback declared-field set when a keyframe carries no explicit
402/// `declared` list, mirroring Python's `_keyframe_style_dict` fallback.
403fn fallback_declared(style: &IRStyle) -> HashSet<&'static str> {
404    let mut keep = HashSet::new();
405    if style.opacity != 1.0 {
406        keep.insert("opacity");
407    }
408    if style.bg_color != [0.0, 0.0, 0.0, 0.0] {
409        keep.insert("bg_color");
410    }
411    if style.color != [0.0, 0.0, 0.0, 1.0] {
412        keep.insert("color");
413    }
414    if style.border_radius != 0.0 {
415        keep.insert("border_radius");
416    }
417    if style.font_size != 16.0 {
418        keep.insert("font_size");
419    }
420    if style.width.is_some() {
421        keep.insert("width");
422    }
423    if style.height.is_some() {
424        keep.insert("height");
425    }
426    if style.left.is_some() {
427        keep.insert("left");
428    }
429    if style.top.is_some() {
430        keep.insert("top");
431    }
432    keep
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn sample_window() -> IRWindow {
440        let mut window = IRWindow {
441            window_id: "win_0".to_string(),
442            title: "Test".to_string(),
443            width: 800,
444            height: 600,
445            visible: true,
446            renderer: String::new(),
447            ..Default::default()
448        };
449        let mut node = IRNode {
450            node_id: "node_0000".to_string(),
451            node_type: "div".to_string(),
452            text_content: "hi".to_string(),
453            x: 1.0,
454            y: 2.0,
455            w: 3.0,
456            h: 4.0,
457            ..Default::default()
458        };
459        node.style.bg_color = [1.0, 0.0, 0.0, 1.0];
460        node.attrs.insert("data-x".to_string(), "1".to_string());
461        window.nodes.push(node);
462        window
463    }
464
465    #[test]
466    fn root_shape_windows_and_type() {
467        let value = IRSerializer::to_dict(&[sample_window()], None);
468        assert_eq!(value["type"], Value::String("app".to_string()));
469        assert_eq!(value["windows"][0]["id"], Value::String("win_0".to_string()));
470        assert_eq!(value["windows"][0]["title"], Value::String("Test".to_string()));
471        assert!(value.get("logic_so_path").is_none());
472    }
473
474    #[test]
475    fn logic_so_path_attached_at_root() {
476        let value = IRSerializer::to_dict(&[sample_window()], Some("/tmp/logic.so"));
477        assert_eq!(value["logic_so_path"], Value::String("/tmp/logic.so".to_string()));
478    }
479
480    #[test]
481    fn node_shape_matches_dev_protocol() {
482        let value = IRSerializer::to_dict(&[sample_window()], None);
483        let node = &value["windows"][0]["nodes"][0];
484        assert_eq!(node["id"], Value::String("node_0000".to_string()));
485        assert_eq!(node["type"], Value::String("div".to_string()));
486        assert_eq!(node["text"], Value::String("hi".to_string()));
487        assert_eq!(node["x"], Value::from(1.0f32));
488        assert_eq!(node["attrs"]["data-x"], Value::String("1".to_string()));
489        assert_eq!(
490            node["style"]["bg_color"],
491            Value::Array(vec![
492                Value::from(1.0f32),
493                Value::from(0.0f32),
494                Value::from(0.0f32),
495                Value::from(1.0f32),
496            ])
497        );
498        assert!(node.get("reactive_text").is_none());
499        assert!(node.get("hover_style").is_none());
500        assert!(node.get("animations").is_none());
501    }
502
503    #[test]
504    fn unset_optionals_serialize_as_null() {
505        let value = IRSerializer::to_dict(&[sample_window()], None);
506        let style = &value["windows"][0]["nodes"][0]["style"];
507        assert_eq!(style["width"], Value::Null);
508        assert_eq!(style["z_index"], Value::Null);
509        assert_eq!(style["transform_ops"], Value::Null);
510        assert_eq!(style["transform_matrix"], Value::Null);
511        assert_eq!(style["transform_origin"], Value::Null);
512    }
513
514    #[test]
515    fn non_finite_floats_become_null() {
516        let mut window = sample_window();
517        window.nodes[0].style.opacity = f32::INFINITY;
518        let value = IRSerializer::to_dict(&[window], None);
519        assert_eq!(value["windows"][0]["nodes"][0]["style"]["opacity"], Value::Null);
520    }
521
522    #[test]
523    fn transform_serialization_shape() {
524        use crate::transforms::TransformOp;
525        let mut window = sample_window();
526        window.nodes[0].style.transform_ops =
527            Some(vec![TransformOp::Rotate(45.0), TransformOp::Scale(2.0, 3.0)]);
528        window.nodes[0].style.transform_matrix = Some([1.0; 16]);
529        window.nodes[0].style.transform_origin_resolved = Some((0.0, 1.0));
530        window.nodes[0].style.transform_origin = Some(((0.0, false), (100.0, true)));
531        let value = IRSerializer::to_dict(&[window], None);
532        let style = &value["windows"][0]["nodes"][0]["style"];
533        assert_eq!(
534            style["transform_ops"],
535            serde_json::json!([["rotate", 45.0], ["scale", [2.0, 3.0]]])
536        );
537        assert_eq!(style["transform_matrix"][0], Value::from(1.0f32));
538        assert_eq!(style["transform_origin"], serde_json::json!([0.0, 1.0]));
539        assert_eq!(style["transform_origin_raw"], serde_json::json!([[0.0, false], [100.0, true]]));
540    }
541
542    #[test]
543    fn keyframes_keep_only_declared_fields() {
544        let mut window = sample_window();
545        let mut kf = IRKeyframe {
546            offset: 0.5,
547            style: IRStyle::new(),
548            declared: vec!["opacity".to_string()],
549            raw: HashMap::new(),
550        };
551        kf.style.opacity = 0.25;
552        kf.style.bg_color = [1.0, 1.0, 1.0, 1.0];
553        window.keyframes.insert("fade".to_string(), vec![kf]);
554        let value = IRSerializer::to_dict(&[window], None);
555        let frame = &value["windows"][0]["keyframes"]["fade"][0];
556        assert_eq!(frame["offset"], Value::from(0.5f32));
557        assert_eq!(frame["style"]["opacity"], Value::from(0.25f32));
558        assert!(frame["style"].get("bg_color").is_none());
559    }
560
561    #[test]
562    fn to_json_round_trips() {
563        let text = IRSerializer::to_json(&[sample_window()], None).unwrap();
564        let parsed: Value = serde_json::from_str(&text).unwrap();
565        assert_eq!(parsed["type"], Value::String("app".to_string()));
566    }
567}