1use crate::id::NodeId;
6use crate::model::*;
7use petgraph::graph::NodeIndex;
8use std::fmt::Write;
9
10#[must_use]
12pub fn emit_document(graph: &SceneGraph) -> String {
13 let mut out = String::with_capacity(1024);
14
15 let has_imports = !graph.imports.is_empty();
17 let has_styles = !graph.styles.is_empty();
18 let children = graph.children(graph.root);
19 let has_constraints = graph.graph.node_indices().any(|idx| {
20 graph.graph[idx]
21 .constraints
22 .iter()
23 .any(|c| !matches!(c, Constraint::Position { .. }))
24 });
25 let has_edges = !graph.edges.is_empty();
26 let section_count =
27 has_imports as u8 + has_styles as u8 + has_constraints as u8 + has_edges as u8;
28 let use_separators = section_count >= 2;
29
30 for import in &graph.imports {
32 let _ = writeln!(out, "import \"{}\" as {}", import.path, import.namespace);
33 }
34 if has_imports {
35 out.push('\n');
36 }
37
38 if use_separators && has_styles {
40 out.push_str("# ─── Themes ───\n\n");
41 }
42 let mut styles: Vec<_> = graph.styles.iter().collect();
43 styles.sort_by_key(|(id, _)| id.as_str().to_string());
44 for (name, style) in &styles {
45 emit_style_block(&mut out, name, style, 0);
46 out.push('\n');
47 }
48
49 if use_separators && !children.is_empty() {
51 out.push_str("# ─── Layout ───\n\n");
52 }
53 for child_idx in &children {
54 emit_node(&mut out, graph, *child_idx, 0);
55 out.push('\n');
56 }
57
58 if use_separators && has_constraints {
60 out.push_str("# ─── Constraints ───\n\n");
61 }
62 for idx in graph.graph.node_indices() {
63 let node = &graph.graph[idx];
64 for constraint in &node.constraints {
65 if matches!(constraint, Constraint::Position { .. }) {
66 continue; }
68 emit_constraint(&mut out, &node.id, constraint);
69 }
70 }
71
72 if use_separators && has_edges {
74 if has_constraints {
75 out.push('\n');
76 }
77 out.push_str("# ─── Flows ───\n\n");
78 }
79 for edge in &graph.edges {
80 emit_edge(&mut out, edge, graph);
81 }
82
83 out
84}
85
86fn indent(out: &mut String, depth: usize) {
87 for _ in 0..depth {
88 out.push_str(" ");
89 }
90}
91
92fn emit_style_block(out: &mut String, name: &NodeId, style: &Style, depth: usize) {
93 indent(out, depth);
94 writeln!(out, "theme {} {{", name.as_str()).unwrap();
95
96 if let Some(ref fill) = style.fill {
97 emit_paint_prop(out, "fill", fill, depth + 1);
98 }
99 if let Some(ref font) = style.font {
100 emit_font_prop(out, font, depth + 1);
101 }
102 if let Some(radius) = style.corner_radius {
103 indent(out, depth + 1);
104 writeln!(out, "corner: {}", format_num(radius)).unwrap();
105 }
106 if let Some(opacity) = style.opacity {
107 indent(out, depth + 1);
108 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
109 }
110 if let Some(ref shadow) = style.shadow {
111 indent(out, depth + 1);
112 writeln!(
113 out,
114 "shadow: ({},{},{},{})",
115 format_num(shadow.offset_x),
116 format_num(shadow.offset_y),
117 format_num(shadow.blur),
118 shadow.color.to_hex()
119 )
120 .unwrap();
121 }
122 if style.text_align.is_some() || style.text_valign.is_some() {
124 let h = match style.text_align {
125 Some(TextAlign::Left) => "left",
126 Some(TextAlign::Right) => "right",
127 _ => "center",
128 };
129 let v = match style.text_valign {
130 Some(TextVAlign::Top) => "top",
131 Some(TextVAlign::Bottom) => "bottom",
132 _ => "middle",
133 };
134 indent(out, depth + 1);
135 writeln!(out, "align: {h} {v}").unwrap();
136 }
137
138 indent(out, depth);
139 out.push_str("}\n");
140}
141
142fn emit_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, depth: usize) {
143 let node = &graph.graph[idx];
144
145 if matches!(node.kind, NodeKind::Group | NodeKind::Frame { .. })
150 && graph.children(idx).is_empty()
151 && node.annotations.is_empty()
152 && node.use_styles.is_empty()
153 && node.animations.is_empty()
154 && !has_inline_styles(&node.style)
155 {
156 return;
157 }
158
159 for comment in &node.comments {
161 indent(out, depth);
162 writeln!(out, "# {comment}").unwrap();
163 }
164
165 indent(out, depth);
166
167 match &node.kind {
169 NodeKind::Root => return,
170 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
171 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
172 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
173 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
174 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
175 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
176 NodeKind::Text { content } => {
177 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
178 }
179 }
180
181 out.push_str(" {\n");
182
183 emit_annotations(out, &node.annotations, depth + 1);
185
186 let children = graph.children(idx);
189 for child_idx in &children {
190 emit_node(out, graph, *child_idx, depth + 1);
191 }
192
193 if let NodeKind::Frame { layout, .. } = &node.kind {
197 match layout {
198 LayoutMode::Free => {}
199 LayoutMode::Column { gap, pad } => {
200 indent(out, depth + 1);
201 writeln!(
202 out,
203 "layout: column gap={} pad={}",
204 format_num(*gap),
205 format_num(*pad)
206 )
207 .unwrap();
208 }
209 LayoutMode::Row { gap, pad } => {
210 indent(out, depth + 1);
211 writeln!(
212 out,
213 "layout: row gap={} pad={}",
214 format_num(*gap),
215 format_num(*pad)
216 )
217 .unwrap();
218 }
219 LayoutMode::Grid { cols, gap, pad } => {
220 indent(out, depth + 1);
221 writeln!(
222 out,
223 "layout: grid cols={cols} gap={} pad={}",
224 format_num(*gap),
225 format_num(*pad)
226 )
227 .unwrap();
228 }
229 }
230 }
231
232 match &node.kind {
234 NodeKind::Rect { width, height } => {
235 indent(out, depth + 1);
236 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
237 }
238 NodeKind::Frame { width, height, .. } => {
239 indent(out, depth + 1);
240 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
241 }
242 NodeKind::Ellipse { rx, ry } => {
243 indent(out, depth + 1);
244 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
245 }
246 _ => {}
247 }
248
249 if let NodeKind::Frame { clip: true, .. } = &node.kind {
251 indent(out, depth + 1);
252 writeln!(out, "clip: true").unwrap();
253 }
254
255 for style_ref in &node.use_styles {
257 indent(out, depth + 1);
258 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
259 }
260
261 if let Some(ref fill) = node.style.fill {
263 emit_paint_prop(out, "fill", fill, depth + 1);
264 }
265 if let Some(ref stroke) = node.style.stroke {
266 indent(out, depth + 1);
267 match &stroke.paint {
268 Paint::Solid(c) => {
269 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap()
270 }
271 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
272 }
273 }
274 if let Some(radius) = node.style.corner_radius {
275 indent(out, depth + 1);
276 writeln!(out, "corner: {}", format_num(radius)).unwrap();
277 }
278 if let Some(ref font) = node.style.font {
279 emit_font_prop(out, font, depth + 1);
280 }
281 if let Some(opacity) = node.style.opacity {
282 indent(out, depth + 1);
283 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
284 }
285 if let Some(ref shadow) = node.style.shadow {
286 indent(out, depth + 1);
287 writeln!(
288 out,
289 "shadow: ({},{},{},{})",
290 format_num(shadow.offset_x),
291 format_num(shadow.offset_y),
292 format_num(shadow.blur),
293 shadow.color.to_hex()
294 )
295 .unwrap();
296 }
297
298 if node.style.text_align.is_some() || node.style.text_valign.is_some() {
300 let h = match node.style.text_align {
301 Some(TextAlign::Left) => "left",
302 Some(TextAlign::Right) => "right",
303 _ => "center",
304 };
305 let v = match node.style.text_valign {
306 Some(TextVAlign::Top) => "top",
307 Some(TextVAlign::Bottom) => "bottom",
308 _ => "middle",
309 };
310 indent(out, depth + 1);
311 writeln!(out, "align: {h} {v}").unwrap();
312 }
313
314 for constraint in &node.constraints {
316 if let Constraint::Position { x, y } = constraint {
317 if *x != 0.0 {
318 indent(out, depth + 1);
319 writeln!(out, "x: {}", format_num(*x)).unwrap();
320 }
321 if *y != 0.0 {
322 indent(out, depth + 1);
323 writeln!(out, "y: {}", format_num(*y)).unwrap();
324 }
325 }
326 }
327
328 for anim in &node.animations {
330 emit_anim(out, anim, depth + 1);
331 }
332
333 indent(out, depth);
334 out.push_str("}\n");
335}
336
337fn emit_annotations(out: &mut String, annotations: &[Annotation], depth: usize) {
338 if annotations.is_empty() {
339 return;
340 }
341
342 if annotations.len() == 1
344 && let Annotation::Description(s) = &annotations[0]
345 {
346 indent(out, depth);
347 writeln!(out, "spec \"{s}\"").unwrap();
348 return;
349 }
350
351 indent(out, depth);
353 out.push_str("spec {\n");
354 for ann in annotations {
355 indent(out, depth + 1);
356 match ann {
357 Annotation::Description(s) => writeln!(out, "\"{s}\"").unwrap(),
358 Annotation::Accept(s) => writeln!(out, "accept: \"{s}\"").unwrap(),
359 Annotation::Status(s) => writeln!(out, "status: {s}").unwrap(),
360 Annotation::Priority(s) => writeln!(out, "priority: {s}").unwrap(),
361 Annotation::Tag(s) => writeln!(out, "tag: {s}").unwrap(),
362 }
363 }
364 indent(out, depth);
365 out.push_str("}\n");
366}
367
368fn emit_paint_prop(out: &mut String, name: &str, paint: &Paint, depth: usize) {
369 indent(out, depth);
370 match paint {
371 Paint::Solid(c) => {
372 let hex = c.to_hex();
373 let hint = color_hint(&hex);
374 if hint.is_empty() {
375 writeln!(out, "{name}: {hex}").unwrap();
376 } else {
377 writeln!(out, "{name}: {hex} # {hint}").unwrap();
378 }
379 }
380 Paint::LinearGradient { angle, stops } => {
381 write!(out, "{name}: linear({}deg", format_num(*angle)).unwrap();
382 for stop in stops {
383 write!(out, ", {} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
384 }
385 writeln!(out, ")").unwrap();
386 }
387 Paint::RadialGradient { stops } => {
388 write!(out, "{name}: radial(").unwrap();
389 for (i, stop) in stops.iter().enumerate() {
390 if i > 0 {
391 write!(out, ", ").unwrap();
392 }
393 write!(out, "{} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
394 }
395 writeln!(out, ")").unwrap();
396 }
397 }
398}
399
400fn emit_font_prop(out: &mut String, font: &FontSpec, depth: usize) {
401 indent(out, depth);
402 let weight_str = weight_number_to_name(font.weight);
403 writeln!(
404 out,
405 "font: \"{}\" {} {}",
406 font.family,
407 weight_str,
408 format_num(font.size)
409 )
410 .unwrap();
411}
412
413fn weight_number_to_name(weight: u16) -> &'static str {
415 match weight {
416 100 => "thin",
417 200 => "extralight",
418 300 => "light",
419 400 => "regular",
420 500 => "medium",
421 600 => "semibold",
422 700 => "bold",
423 800 => "extrabold",
424 900 => "black",
425 _ => "400", }
427}
428
429fn color_hint(hex: &str) -> &'static str {
431 let hex = hex.trim_start_matches('#');
432 let bytes = hex.as_bytes();
433 let Some((r, g, b)) = (match bytes.len() {
434 3 | 4 => {
435 let r = crate::model::hex_val(bytes[0]).unwrap_or(0) * 17;
436 let g = crate::model::hex_val(bytes[1]).unwrap_or(0) * 17;
437 let b = crate::model::hex_val(bytes[2]).unwrap_or(0) * 17;
438 Some((r, g, b))
439 }
440 6 | 8 => {
441 let r = (crate::model::hex_val(bytes[0]).unwrap_or(0) << 4)
442 | crate::model::hex_val(bytes[1]).unwrap_or(0);
443 let g = (crate::model::hex_val(bytes[2]).unwrap_or(0) << 4)
444 | crate::model::hex_val(bytes[3]).unwrap_or(0);
445 let b = (crate::model::hex_val(bytes[4]).unwrap_or(0) << 4)
446 | crate::model::hex_val(bytes[5]).unwrap_or(0);
447 Some((r, g, b))
448 }
449 _ => None,
450 }) else {
451 return "";
452 };
453
454 let max = r.max(g).max(b);
456 let min = r.min(g).min(b);
457 let diff = max - min;
458 if diff < 15 {
459 return match max {
460 0..=30 => "black",
461 31..=200 => "gray",
462 _ => "white",
463 };
464 }
465
466 let rf = r as f32;
468 let gf = g as f32;
469 let bf = b as f32;
470 let hue = if max == r {
471 60.0 * (((gf - bf) / diff as f32) % 6.0)
472 } else if max == g {
473 60.0 * (((bf - rf) / diff as f32) + 2.0)
474 } else {
475 60.0 * (((rf - gf) / diff as f32) + 4.0)
476 };
477 let hue = if hue < 0.0 { hue + 360.0 } else { hue };
478
479 match hue as u16 {
480 0..=14 | 346..=360 => "red",
481 15..=39 => "orange",
482 40..=64 => "yellow",
483 65..=79 => "lime",
484 80..=159 => "green",
485 160..=179 => "teal",
486 180..=199 => "cyan",
487 200..=259 => "blue",
488 260..=279 => "purple",
489 280..=319 => "pink",
490 320..=345 => "rose",
491 _ => "",
492 }
493}
494
495fn emit_anim(out: &mut String, anim: &AnimKeyframe, depth: usize) {
496 indent(out, depth);
497 let trigger = match &anim.trigger {
498 AnimTrigger::Hover => "hover",
499 AnimTrigger::Press => "press",
500 AnimTrigger::Enter => "enter",
501 AnimTrigger::Custom(s) => s.as_str(),
502 };
503 writeln!(out, "when :{trigger} {{").unwrap();
504
505 if let Some(ref fill) = anim.properties.fill {
506 emit_paint_prop(out, "fill", fill, depth + 1);
507 }
508 if let Some(opacity) = anim.properties.opacity {
509 indent(out, depth + 1);
510 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
511 }
512 if let Some(scale) = anim.properties.scale {
513 indent(out, depth + 1);
514 writeln!(out, "scale: {}", format_num(scale)).unwrap();
515 }
516 if let Some(rotate) = anim.properties.rotate {
517 indent(out, depth + 1);
518 writeln!(out, "rotate: {}", format_num(rotate)).unwrap();
519 }
520
521 let ease_name = match &anim.easing {
522 Easing::Linear => "linear",
523 Easing::EaseIn => "ease_in",
524 Easing::EaseOut => "ease_out",
525 Easing::EaseInOut => "ease_in_out",
526 Easing::Spring => "spring",
527 Easing::CubicBezier(_, _, _, _) => "cubic",
528 };
529 indent(out, depth + 1);
530 writeln!(out, "ease: {ease_name} {}ms", anim.duration_ms).unwrap();
531
532 indent(out, depth);
533 out.push_str("}\n");
534}
535
536fn emit_constraint(out: &mut String, node_id: &NodeId, constraint: &Constraint) {
537 match constraint {
538 Constraint::CenterIn(target) => {
539 writeln!(
540 out,
541 "@{} -> center_in: {}",
542 node_id.as_str(),
543 target.as_str()
544 )
545 .unwrap();
546 }
547 Constraint::Offset { from, dx, dy } => {
548 writeln!(
549 out,
550 "@{} -> offset: @{} {}, {}",
551 node_id.as_str(),
552 from.as_str(),
553 format_num(*dx),
554 format_num(*dy)
555 )
556 .unwrap();
557 }
558 Constraint::FillParent { pad } => {
559 writeln!(
560 out,
561 "@{} -> fill_parent: {}",
562 node_id.as_str(),
563 format_num(*pad)
564 )
565 .unwrap();
566 }
567 Constraint::Position { .. } => {
568 }
570 }
571}
572
573fn emit_edge(out: &mut String, edge: &Edge, graph: &SceneGraph) {
574 writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
575
576 emit_annotations(out, &edge.annotations, 1);
578
579 if let Some(text_id) = edge.text_child
581 && let Some(node) = graph.get_by_id(text_id)
582 && let NodeKind::Text { content } = &node.kind
583 {
584 writeln!(out, " text @{} \"{}\" {{}}", text_id.as_str(), content).unwrap();
585 }
586
587 match &edge.from {
589 EdgeAnchor::Node(id) => writeln!(out, " from: @{}", id.as_str()).unwrap(),
590 EdgeAnchor::Point(x, y) => {
591 writeln!(out, " from: {} {}", format_num(*x), format_num(*y)).unwrap()
592 }
593 }
594 match &edge.to {
595 EdgeAnchor::Node(id) => writeln!(out, " to: @{}", id.as_str()).unwrap(),
596 EdgeAnchor::Point(x, y) => {
597 writeln!(out, " to: {} {}", format_num(*x), format_num(*y)).unwrap()
598 }
599 }
600
601 for style_ref in &edge.use_styles {
603 writeln!(out, " use: {}", style_ref.as_str()).unwrap();
604 }
605
606 if let Some(ref stroke) = edge.style.stroke {
608 match &stroke.paint {
609 Paint::Solid(c) => {
610 writeln!(out, " stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
611 }
612 _ => {
613 writeln!(out, " stroke: #000 {}", format_num(stroke.width)).unwrap();
614 }
615 }
616 }
617
618 if let Some(opacity) = edge.style.opacity {
620 writeln!(out, " opacity: {}", format_num(opacity)).unwrap();
621 }
622
623 if edge.arrow != ArrowKind::None {
625 let name = match edge.arrow {
626 ArrowKind::None => "none",
627 ArrowKind::Start => "start",
628 ArrowKind::End => "end",
629 ArrowKind::Both => "both",
630 };
631 writeln!(out, " arrow: {name}").unwrap();
632 }
633
634 if edge.curve != CurveKind::Straight {
636 let name = match edge.curve {
637 CurveKind::Straight => "straight",
638 CurveKind::Smooth => "smooth",
639 CurveKind::Step => "step",
640 };
641 writeln!(out, " curve: {name}").unwrap();
642 }
643
644 if let Some(ref flow) = edge.flow {
646 let kind = match flow.kind {
647 FlowKind::Pulse => "pulse",
648 FlowKind::Dash => "dash",
649 };
650 writeln!(out, " flow: {} {}ms", kind, flow.duration_ms).unwrap();
651 }
652
653 if let Some((ox, oy)) = edge.label_offset {
655 writeln!(out, " label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
656 }
657
658 for anim in &edge.animations {
660 emit_anim(out, anim, 1);
661 }
662
663 out.push_str("}\n");
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Eq)]
673pub enum ReadMode {
674 Full,
676 Structure,
678 Layout,
680 Design,
682 Spec,
684 Visual,
686 When,
688 Edges,
690}
691
692#[must_use]
703pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
704 if mode == ReadMode::Full {
705 return emit_document(graph);
706 }
707
708 let mut out = String::with_capacity(1024);
709
710 let children = graph.children(graph.root);
711 let include_themes = matches!(mode, ReadMode::Design | ReadMode::Visual);
712 let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
713 let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
714
715 if include_themes && !graph.styles.is_empty() {
717 let mut styles: Vec<_> = graph.styles.iter().collect();
718 styles.sort_by_key(|(id, _)| id.as_str().to_string());
719 for (name, style) in &styles {
720 emit_style_block(&mut out, name, style, 0);
721 out.push('\n');
722 }
723 }
724
725 for child_idx in &children {
727 emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
728 out.push('\n');
729 }
730
731 if include_constraints {
733 for idx in graph.graph.node_indices() {
734 let node = &graph.graph[idx];
735 for constraint in &node.constraints {
736 if matches!(constraint, Constraint::Position { .. }) {
737 continue;
738 }
739 emit_constraint(&mut out, &node.id, constraint);
740 }
741 }
742 }
743
744 if include_edges {
746 for edge in &graph.edges {
747 emit_edge(&mut out, edge, graph);
748 out.push('\n');
749 }
750 }
751
752 out
753}
754
755fn emit_node_filtered(
757 out: &mut String,
758 graph: &SceneGraph,
759 idx: NodeIndex,
760 depth: usize,
761 mode: ReadMode,
762) {
763 let node = &graph.graph[idx];
764
765 if matches!(node.kind, NodeKind::Root) {
766 return;
767 }
768
769 indent(out, depth);
770
771 match &node.kind {
773 NodeKind::Root => return,
774 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
775 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
776 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
777 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
778 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
779 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
780 NodeKind::Text { content } => {
781 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
782 }
783 }
784
785 out.push_str(" {\n");
786
787 if mode == ReadMode::Spec {
789 emit_annotations(out, &node.annotations, depth + 1);
790 }
791
792 let children = graph.children(idx);
794 for child_idx in &children {
795 emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
796 }
797
798 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
800 emit_layout_mode_filtered(out, &node.kind, depth + 1);
801 }
802
803 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
805 emit_dimensions_filtered(out, &node.kind, depth + 1);
806 }
807
808 if matches!(mode, ReadMode::Design | ReadMode::Visual) {
810 for style_ref in &node.use_styles {
811 indent(out, depth + 1);
812 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
813 }
814 if let Some(ref fill) = node.style.fill {
815 emit_paint_prop(out, "fill", fill, depth + 1);
816 }
817 if let Some(ref stroke) = node.style.stroke {
818 indent(out, depth + 1);
819 match &stroke.paint {
820 Paint::Solid(c) => {
821 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
822 }
823 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
824 }
825 }
826 if let Some(radius) = node.style.corner_radius {
827 indent(out, depth + 1);
828 writeln!(out, "corner: {}", format_num(radius)).unwrap();
829 }
830 if let Some(ref font) = node.style.font {
831 emit_font_prop(out, font, depth + 1);
832 }
833 if let Some(opacity) = node.style.opacity {
834 indent(out, depth + 1);
835 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
836 }
837 }
838
839 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
841 for constraint in &node.constraints {
842 if let Constraint::Position { x, y } = constraint {
843 if *x != 0.0 {
844 indent(out, depth + 1);
845 writeln!(out, "x: {}", format_num(*x)).unwrap();
846 }
847 if *y != 0.0 {
848 indent(out, depth + 1);
849 writeln!(out, "y: {}", format_num(*y)).unwrap();
850 }
851 }
852 }
853 }
854
855 if matches!(mode, ReadMode::When | ReadMode::Visual) {
857 for anim in &node.animations {
858 emit_anim(out, anim, depth + 1);
859 }
860 }
861
862 indent(out, depth);
863 out.push_str("}\n");
864}
865
866fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
868 let layout = match kind {
869 NodeKind::Frame { layout, .. } => layout,
870 _ => return, };
872 match layout {
873 LayoutMode::Free => {}
874 LayoutMode::Column { gap, pad } => {
875 indent(out, depth);
876 writeln!(
877 out,
878 "layout: column gap={} pad={}",
879 format_num(*gap),
880 format_num(*pad)
881 )
882 .unwrap();
883 }
884 LayoutMode::Row { gap, pad } => {
885 indent(out, depth);
886 writeln!(
887 out,
888 "layout: row gap={} pad={}",
889 format_num(*gap),
890 format_num(*pad)
891 )
892 .unwrap();
893 }
894 LayoutMode::Grid { cols, gap, pad } => {
895 indent(out, depth);
896 writeln!(
897 out,
898 "layout: grid cols={cols} gap={} pad={}",
899 format_num(*gap),
900 format_num(*pad)
901 )
902 .unwrap();
903 }
904 }
905}
906
907fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
909 match kind {
910 NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
911 indent(out, depth);
912 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
913 }
914 NodeKind::Ellipse { rx, ry } => {
915 indent(out, depth);
916 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
917 }
918 _ => {}
919 }
920}
921
922#[must_use]
930pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
931 let mut out = String::with_capacity(512);
932 writeln!(out, "# Spec: {title}\n").unwrap();
933
934 let children = graph.children(graph.root);
936 for child_idx in &children {
937 emit_spec_node(&mut out, graph, *child_idx, 2);
938 }
939
940 if !graph.edges.is_empty() {
942 out.push_str("\n---\n\n## Flows\n\n");
943 for edge in &graph.edges {
944 let from_str = match &edge.from {
945 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
946 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
947 };
948 let to_str = match &edge.to {
949 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
950 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
951 };
952 write!(out, "- **{}** → **{}**", from_str, to_str).unwrap();
953 if let Some(text_id) = edge.text_child
954 && let Some(node) = graph.get_by_id(text_id)
955 && let NodeKind::Text { content } = &node.kind
956 {
957 write!(out, " — {content}").unwrap();
958 }
959 out.push('\n');
960 emit_spec_annotations(&mut out, &edge.annotations, " ");
961 }
962 }
963
964 out
965}
966
967fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
968 let node = &graph.graph[idx];
969
970 let has_annotations = !node.annotations.is_empty();
972 let children = graph.children(idx);
973 let has_annotated_children = children
974 .iter()
975 .any(|c| has_annotations_recursive(graph, *c));
976
977 if !has_annotations && !has_annotated_children {
978 return;
979 }
980
981 let hashes = "#".repeat(heading_level.min(6));
983 let kind_label = match &node.kind {
984 NodeKind::Root => return,
985 NodeKind::Generic => "spec",
986 NodeKind::Group => "group",
987 NodeKind::Frame { .. } => "frame",
988 NodeKind::Rect { .. } => "rect",
989 NodeKind::Ellipse { .. } => "ellipse",
990 NodeKind::Path { .. } => "path",
991 NodeKind::Text { .. } => "text",
992 };
993 writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
994
995 emit_spec_annotations(out, &node.annotations, "");
997
998 for child_idx in &children {
1000 emit_spec_node(out, graph, *child_idx, heading_level + 1);
1001 }
1002}
1003
1004fn has_annotations_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
1005 let node = &graph.graph[idx];
1006 if !node.annotations.is_empty() {
1007 return true;
1008 }
1009 graph
1010 .children(idx)
1011 .iter()
1012 .any(|c| has_annotations_recursive(graph, *c))
1013}
1014
1015fn emit_spec_annotations(out: &mut String, annotations: &[Annotation], prefix: &str) {
1016 for ann in annotations {
1017 match ann {
1018 Annotation::Description(s) => writeln!(out, "{prefix}> {s}").unwrap(),
1019 Annotation::Accept(s) => writeln!(out, "{prefix}- [ ] {s}").unwrap(),
1020 Annotation::Status(s) => writeln!(out, "{prefix}- **Status:** {s}").unwrap(),
1021 Annotation::Priority(s) => writeln!(out, "{prefix}- **Priority:** {s}").unwrap(),
1022 Annotation::Tag(s) => writeln!(out, "{prefix}- **Tag:** {s}").unwrap(),
1023 }
1024 }
1025 if !annotations.is_empty() {
1026 out.push('\n');
1027 }
1028}
1029
1030fn has_inline_styles(style: &Style) -> bool {
1032 style.fill.is_some()
1033 || style.stroke.is_some()
1034 || style.font.is_some()
1035 || style.corner_radius.is_some()
1036 || style.opacity.is_some()
1037 || style.shadow.is_some()
1038 || style.text_align.is_some()
1039 || style.text_valign.is_some()
1040 || style.scale.is_some()
1041}
1042
1043fn format_num(n: f32) -> String {
1045 if n == n.floor() {
1046 format!("{}", n as i32)
1047 } else {
1048 format!("{n:.2}")
1049 .trim_end_matches('0')
1050 .trim_end_matches('.')
1051 .to_string()
1052 }
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use super::*;
1058 use crate::parser::parse_document;
1059
1060 #[test]
1061 fn roundtrip_simple() {
1062 let input = r#"
1063rect @box {
1064 w: 100
1065 h: 50
1066 fill: #FF0000
1067}
1068"#;
1069 let graph = parse_document(input).unwrap();
1070 let output = emit_document(&graph);
1071
1072 let graph2 = parse_document(&output).expect("re-parse of emitted output failed");
1074 let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1075
1076 match &node2.kind {
1077 NodeKind::Rect { width, height } => {
1078 assert_eq!(*width, 100.0);
1079 assert_eq!(*height, 50.0);
1080 }
1081 _ => panic!("expected Rect"),
1082 }
1083 }
1084
1085 #[test]
1086 fn roundtrip_ellipse() {
1087 let input = r#"
1088ellipse @dot {
1089 w: 40 h: 40
1090 fill: #00FF00
1091}
1092"#;
1093 let graph = parse_document(input).unwrap();
1094 let output = emit_document(&graph);
1095 let graph2 = parse_document(&output).expect("re-parse of ellipse failed");
1096 let node = graph2.get_by_id(NodeId::intern("dot")).unwrap();
1097 match &node.kind {
1098 NodeKind::Ellipse { rx, ry } => {
1099 assert_eq!(*rx, 40.0);
1100 assert_eq!(*ry, 40.0);
1101 }
1102 _ => panic!("expected Ellipse"),
1103 }
1104 }
1105
1106 #[test]
1107 fn roundtrip_text_with_font() {
1108 let input = r#"
1109text @title "Hello" {
1110 font: "Inter" 700 32
1111 fill: #1A1A2E
1112}
1113"#;
1114 let graph = parse_document(input).unwrap();
1115 let output = emit_document(&graph);
1116 let graph2 = parse_document(&output).expect("re-parse of text failed");
1117 let node = graph2.get_by_id(NodeId::intern("title")).unwrap();
1118 match &node.kind {
1119 NodeKind::Text { content } => assert_eq!(content, "Hello"),
1120 _ => panic!("expected Text"),
1121 }
1122 let font = node.style.font.as_ref().expect("font missing");
1123 assert_eq!(font.family, "Inter");
1124 assert_eq!(font.weight, 700);
1125 assert_eq!(font.size, 32.0);
1126 }
1127
1128 #[test]
1129 fn roundtrip_nested_group() {
1130 let input = r#"
1131group @card {
1132 layout: column gap=16 pad=24
1133
1134 text @heading "Title" {
1135 font: "Inter" 600 20
1136 fill: #333333
1137 }
1138
1139 rect @body {
1140 w: 300 h: 200
1141 fill: #F5F5F5
1142 }
1143}
1144"#;
1145 let graph = parse_document(input).unwrap();
1146 let output = emit_document(&graph);
1147 let graph2 = parse_document(&output).expect("re-parse of nested group failed");
1148 let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
1149 assert_eq!(graph2.children(card_idx).len(), 2);
1150 }
1151
1152 #[test]
1153 fn roundtrip_animation() {
1154 let input = r#"
1155rect @btn {
1156 w: 200 h: 48
1157 fill: #6C5CE7
1158
1159 anim :hover {
1160 fill: #5A4BD1
1161 scale: 1.02
1162 ease: spring 300ms
1163 }
1164}
1165"#;
1166 let graph = parse_document(input).unwrap();
1167 let output = emit_document(&graph);
1168 let graph2 = parse_document(&output).expect("re-parse of animation failed");
1169 let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1170 assert_eq!(btn.animations.len(), 1);
1171 assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1172 }
1173
1174 #[test]
1175 fn roundtrip_style_and_use() {
1176 let input = r#"
1177style accent {
1178 fill: #6C5CE7
1179 corner: 10
1180}
1181
1182rect @btn {
1183 w: 200 h: 48
1184 use: accent
1185}
1186"#;
1187 let graph = parse_document(input).unwrap();
1188 let output = emit_document(&graph);
1189 let graph2 = parse_document(&output).expect("re-parse of style+use failed");
1190 assert!(graph2.styles.contains_key(&NodeId::intern("accent")));
1191 let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1192 assert_eq!(btn.use_styles.len(), 1);
1193 }
1194
1195 #[test]
1196 fn roundtrip_annotation_description() {
1197 let input = r#"
1198rect @box {
1199 spec "Primary container for content"
1200 w: 100 h: 50
1201 fill: #FF0000
1202}
1203"#;
1204 let graph = parse_document(input).unwrap();
1205 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1206 assert_eq!(node.annotations.len(), 1);
1207 assert_eq!(
1208 node.annotations[0],
1209 Annotation::Description("Primary container for content".into())
1210 );
1211
1212 let output = emit_document(&graph);
1213 let graph2 = parse_document(&output).expect("re-parse of annotation failed");
1214 let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1215 assert_eq!(node2.annotations.len(), 1);
1216 assert_eq!(node2.annotations[0], node.annotations[0]);
1217 }
1218
1219 #[test]
1220 fn roundtrip_annotation_accept() {
1221 let input = r#"
1222rect @login_btn {
1223 spec {
1224 accept: "disabled state when fields empty"
1225 accept: "loading spinner during auth"
1226 }
1227 w: 280 h: 48
1228 fill: #6C5CE7
1229}
1230"#;
1231 let graph = parse_document(input).unwrap();
1232 let btn = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1233 assert_eq!(btn.annotations.len(), 2);
1234 assert_eq!(
1235 btn.annotations[0],
1236 Annotation::Accept("disabled state when fields empty".into())
1237 );
1238 assert_eq!(
1239 btn.annotations[1],
1240 Annotation::Accept("loading spinner during auth".into())
1241 );
1242
1243 let output = emit_document(&graph);
1244 let graph2 = parse_document(&output).expect("re-parse of accept annotation failed");
1245 let btn2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1246 assert_eq!(btn2.annotations, btn.annotations);
1247 }
1248
1249 #[test]
1250 fn roundtrip_annotation_status_priority() {
1251 let input = r#"
1252rect @card {
1253 spec {
1254 status: doing
1255 priority: high
1256 tag: mvp
1257 }
1258 w: 300 h: 200
1259}
1260"#;
1261 let graph = parse_document(input).unwrap();
1262 let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1263 assert_eq!(card.annotations.len(), 3);
1264 assert_eq!(card.annotations[0], Annotation::Status("doing".into()));
1265 assert_eq!(card.annotations[1], Annotation::Priority("high".into()));
1266 assert_eq!(card.annotations[2], Annotation::Tag("mvp".into()));
1267
1268 let output = emit_document(&graph);
1269 let graph2 =
1270 parse_document(&output).expect("re-parse of status/priority/tag annotation failed");
1271 let card2 = graph2.get_by_id(NodeId::intern("card")).unwrap();
1272 assert_eq!(card2.annotations, card.annotations);
1273 }
1274
1275 #[test]
1276 fn roundtrip_annotation_nested() {
1277 let input = r#"
1278group @form {
1279 layout: column gap=16 pad=32
1280 spec "User authentication entry point"
1281
1282 rect @email {
1283 spec {
1284 accept: "validates email format"
1285 }
1286 w: 280 h: 44
1287 }
1288}
1289"#;
1290 let graph = parse_document(input).unwrap();
1291 let form = graph.get_by_id(NodeId::intern("form")).unwrap();
1292 assert_eq!(form.annotations.len(), 1);
1293 let email = graph.get_by_id(NodeId::intern("email")).unwrap();
1294 assert_eq!(email.annotations.len(), 1);
1295
1296 let output = emit_document(&graph);
1297 let graph2 = parse_document(&output).expect("re-parse of nested annotation failed");
1298 let form2 = graph2.get_by_id(NodeId::intern("form")).unwrap();
1299 assert_eq!(form2.annotations, form.annotations);
1300 let email2 = graph2.get_by_id(NodeId::intern("email")).unwrap();
1301 assert_eq!(email2.annotations, email.annotations);
1302 }
1303
1304 #[test]
1305 fn parse_annotation_freeform() {
1306 let input = r#"
1307rect @widget {
1308 spec {
1309 "Description line"
1310 accept: "criterion one"
1311 status: done
1312 priority: low
1313 tag: design
1314 }
1315 w: 100 h: 100
1316}
1317"#;
1318 let graph = parse_document(input).unwrap();
1319 let w = graph.get_by_id(NodeId::intern("widget")).unwrap();
1320 assert_eq!(w.annotations.len(), 5);
1321 assert_eq!(
1322 w.annotations[0],
1323 Annotation::Description("Description line".into())
1324 );
1325 assert_eq!(w.annotations[1], Annotation::Accept("criterion one".into()));
1326 assert_eq!(w.annotations[2], Annotation::Status("done".into()));
1327 assert_eq!(w.annotations[3], Annotation::Priority("low".into()));
1328 assert_eq!(w.annotations[4], Annotation::Tag("design".into()));
1329 }
1330
1331 #[test]
1332 fn roundtrip_edge_basic() {
1333 let input = r#"
1334rect @box_a {
1335 w: 100 h: 50
1336}
1337
1338rect @box_b {
1339 w: 100 h: 50
1340}
1341
1342edge @a_to_b {
1343 from: @box_a
1344 to: @box_b
1345 label: "next step"
1346 arrow: end
1347}
1348"#;
1349 let graph = parse_document(input).unwrap();
1350 assert_eq!(graph.edges.len(), 1);
1351 let edge = &graph.edges[0];
1352 assert_eq!(edge.id.as_str(), "a_to_b");
1353 assert_eq!(edge.from, EdgeAnchor::Node(NodeId::intern("box_a")));
1354 assert_eq!(edge.to, EdgeAnchor::Node(NodeId::intern("box_b")));
1355 let text_id = edge.text_child.expect("text_child should be set");
1357 assert_eq!(text_id.as_str(), "_a_to_b_label");
1358 let text_node = graph.get_by_id(text_id).expect("text node should exist");
1359 if let NodeKind::Text { content } = &text_node.kind {
1360 assert_eq!(content, "next step");
1361 } else {
1362 panic!("expected Text node");
1363 }
1364 assert_eq!(edge.arrow, ArrowKind::End);
1365
1366 let output = emit_document(&graph);
1368 assert!(output.contains("text @_a_to_b_label \"next step\" {}"));
1369
1370 let graph2 = parse_document(&output).expect("roundtrip failed");
1371 assert_eq!(graph2.edges.len(), 1);
1372 let edge2 = &graph2.edges[0];
1373 assert_eq!(edge2.from, EdgeAnchor::Node(NodeId::intern("box_a")));
1374 assert_eq!(edge2.to, EdgeAnchor::Node(NodeId::intern("box_b")));
1375 assert!(edge2.text_child.is_some());
1376 assert_eq!(edge2.arrow, ArrowKind::End);
1377 }
1378
1379 #[test]
1380 fn roundtrip_edge_styled() {
1381 let input = r#"
1382rect @s1 { w: 50 h: 50 }
1383rect @s2 { w: 50 h: 50 }
1384
1385edge @flow {
1386 from: @s1
1387 to: @s2
1388 stroke: #6C5CE7 2
1389 arrow: both
1390 curve: smooth
1391}
1392"#;
1393 let graph = parse_document(input).unwrap();
1394 assert_eq!(graph.edges.len(), 1);
1395 let edge = &graph.edges[0];
1396 assert_eq!(edge.arrow, ArrowKind::Both);
1397 assert_eq!(edge.curve, CurveKind::Smooth);
1398 assert!(edge.style.stroke.is_some());
1399
1400 let output = emit_document(&graph);
1401 let graph2 = parse_document(&output).expect("styled edge roundtrip failed");
1402 let edge2 = &graph2.edges[0];
1403 assert_eq!(edge2.arrow, ArrowKind::Both);
1404 assert_eq!(edge2.curve, CurveKind::Smooth);
1405 }
1406
1407 #[test]
1408 fn roundtrip_edge_with_annotations() {
1409 let input = r#"
1410rect @login { w: 200 h: 100 }
1411rect @dashboard { w: 200 h: 100 }
1412
1413edge @login_flow {
1414 spec {
1415 "Main authentication flow"
1416 accept: "must redirect within 2s"
1417 }
1418 from: @login
1419 to: @dashboard
1420 label: "on success"
1421 arrow: end
1422}
1423"#;
1424 let graph = parse_document(input).unwrap();
1425 let edge = &graph.edges[0];
1426 assert_eq!(edge.annotations.len(), 2);
1427 assert_eq!(
1428 edge.annotations[0],
1429 Annotation::Description("Main authentication flow".into())
1430 );
1431 assert_eq!(
1432 edge.annotations[1],
1433 Annotation::Accept("must redirect within 2s".into())
1434 );
1435
1436 let output = emit_document(&graph);
1437 let graph2 = parse_document(&output).expect("annotated edge roundtrip failed");
1438 let edge2 = &graph2.edges[0];
1439 assert_eq!(edge2.annotations, edge.annotations);
1440 }
1441
1442 #[test]
1443 fn roundtrip_generic_node() {
1444 let input = r#"
1445@login_btn {
1446 spec {
1447 "Primary CTA — triggers login API call"
1448 accept: "disabled when fields empty"
1449 status: doing
1450 }
1451}
1452"#;
1453 let graph = parse_document(input).unwrap();
1454 let node = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1455 assert!(matches!(node.kind, NodeKind::Generic));
1456 assert_eq!(node.annotations.len(), 3);
1457
1458 let output = emit_document(&graph);
1459 assert!(output.contains("@login_btn {"));
1460 assert!(!output.contains("rect @login_btn"));
1462 assert!(!output.contains("group @login_btn"));
1463
1464 let graph2 = parse_document(&output).expect("re-parse of generic node failed");
1465 let node2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1466 assert!(matches!(node2.kind, NodeKind::Generic));
1467 assert_eq!(node2.annotations, node.annotations);
1468 }
1469
1470 #[test]
1471 fn roundtrip_generic_nested() {
1472 let input = r#"
1473group @form {
1474 layout: column gap=16 pad=32
1475
1476 @email_input {
1477 spec {
1478 "Email field"
1479 accept: "validates format on blur"
1480 }
1481 }
1482
1483 @password_input {
1484 spec {
1485 "Password field"
1486 accept: "min 8 chars"
1487 }
1488 }
1489}
1490"#;
1491 let graph = parse_document(input).unwrap();
1492 let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1493 assert_eq!(graph.children(form_idx).len(), 2);
1494
1495 let email = graph.get_by_id(NodeId::intern("email_input")).unwrap();
1496 assert!(matches!(email.kind, NodeKind::Generic));
1497 assert_eq!(email.annotations.len(), 2);
1498
1499 let output = emit_document(&graph);
1500 let graph2 = parse_document(&output).expect("re-parse of nested generic failed");
1501 let email2 = graph2.get_by_id(NodeId::intern("email_input")).unwrap();
1502 assert!(matches!(email2.kind, NodeKind::Generic));
1503 assert_eq!(email2.annotations, email.annotations);
1504 }
1505
1506 #[test]
1507 fn parse_generic_with_properties() {
1508 let input = r#"
1509@card {
1510 fill: #FFFFFF
1511 corner: 8
1512}
1513"#;
1514 let graph = parse_document(input).unwrap();
1515 let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1516 assert!(matches!(card.kind, NodeKind::Generic));
1517 assert!(card.style.fill.is_some());
1518 assert_eq!(card.style.corner_radius, Some(8.0));
1519 }
1520
1521 #[test]
1522 fn roundtrip_edge_with_trigger_anim() {
1523 let input = r#"
1524rect @a { w: 50 h: 50 }
1525rect @b { w: 50 h: 50 }
1526
1527edge @hover_edge {
1528 from: @a
1529 to: @b
1530 stroke: #6C5CE7 2
1531 arrow: end
1532
1533 anim :hover {
1534 opacity: 0.5
1535 ease: ease_out 200ms
1536 }
1537}
1538"#;
1539 let graph = parse_document(input).unwrap();
1540 assert_eq!(graph.edges.len(), 1);
1541 let edge = &graph.edges[0];
1542 assert_eq!(edge.animations.len(), 1);
1543 assert_eq!(edge.animations[0].trigger, AnimTrigger::Hover);
1544 assert_eq!(edge.animations[0].duration_ms, 200);
1545
1546 let output = emit_document(&graph);
1547 let graph2 = parse_document(&output).expect("trigger anim roundtrip failed");
1548 let edge2 = &graph2.edges[0];
1549 assert_eq!(edge2.animations.len(), 1);
1550 assert_eq!(edge2.animations[0].trigger, AnimTrigger::Hover);
1551 }
1552
1553 #[test]
1554 fn roundtrip_edge_with_flow() {
1555 let input = r#"
1556rect @src { w: 50 h: 50 }
1557rect @dst { w: 50 h: 50 }
1558
1559edge @data {
1560 from: @src
1561 to: @dst
1562 arrow: end
1563 flow: pulse 800ms
1564}
1565"#;
1566 let graph = parse_document(input).unwrap();
1567 let edge = &graph.edges[0];
1568 assert!(edge.flow.is_some());
1569 let flow = edge.flow.unwrap();
1570 assert_eq!(flow.kind, FlowKind::Pulse);
1571 assert_eq!(flow.duration_ms, 800);
1572
1573 let output = emit_document(&graph);
1574 let graph2 = parse_document(&output).expect("flow roundtrip failed");
1575 let edge2 = &graph2.edges[0];
1576 let flow2 = edge2.flow.unwrap();
1577 assert_eq!(flow2.kind, FlowKind::Pulse);
1578 assert_eq!(flow2.duration_ms, 800);
1579 }
1580
1581 #[test]
1582 fn roundtrip_edge_dash_flow() {
1583 let input = r#"
1584rect @x { w: 50 h: 50 }
1585rect @y { w: 50 h: 50 }
1586
1587edge @dashed {
1588 from: @x
1589 to: @y
1590 stroke: #EF4444 1
1591 flow: dash 400ms
1592 arrow: both
1593 curve: step
1594}
1595"#;
1596 let graph = parse_document(input).unwrap();
1597 let edge = &graph.edges[0];
1598 let flow = edge.flow.unwrap();
1599 assert_eq!(flow.kind, FlowKind::Dash);
1600 assert_eq!(flow.duration_ms, 400);
1601 assert_eq!(edge.arrow, ArrowKind::Both);
1602 assert_eq!(edge.curve, CurveKind::Step);
1603
1604 let output = emit_document(&graph);
1605 let graph2 = parse_document(&output).expect("dash flow roundtrip failed");
1606 let edge2 = &graph2.edges[0];
1607 let flow2 = edge2.flow.unwrap();
1608 assert_eq!(flow2.kind, FlowKind::Dash);
1609 assert_eq!(flow2.duration_ms, 400);
1610 }
1611
1612 #[test]
1613 fn roundtrip_edge_point_anchors() {
1614 let input = r#"
1615edge @standalone {
1616 from: 100 200
1617 to: 300 150
1618 arrow: end
1619}
1620"#;
1621 let graph = parse_document(input).unwrap();
1622 assert_eq!(graph.edges.len(), 1);
1623 let edge = &graph.edges[0];
1624 assert_eq!(edge.from, EdgeAnchor::Point(100.0, 200.0));
1625 assert_eq!(edge.to, EdgeAnchor::Point(300.0, 150.0));
1626 assert_eq!(edge.arrow, ArrowKind::End);
1627
1628 let output = emit_document(&graph);
1629 assert!(output.contains("from: 100 200"));
1630 assert!(output.contains("to: 300 150"));
1631
1632 let graph2 = parse_document(&output).expect("point anchor roundtrip failed");
1633 let edge2 = &graph2.edges[0];
1634 assert_eq!(edge2.from, EdgeAnchor::Point(100.0, 200.0));
1635 assert_eq!(edge2.to, EdgeAnchor::Point(300.0, 150.0));
1636 }
1637
1638 #[test]
1639 fn roundtrip_edge_mixed_anchors() {
1640 let input = r#"
1641rect @start { w: 50 h: 50 }
1642
1643edge @dangling {
1644 from: @start
1645 to: 400 300
1646 arrow: end
1647}
1648"#;
1649 let graph = parse_document(input).unwrap();
1650 assert_eq!(graph.edges.len(), 1);
1651 let edge = &graph.edges[0];
1652 assert_eq!(edge.from, EdgeAnchor::Node(NodeId::intern("start")));
1653 assert_eq!(edge.to, EdgeAnchor::Point(400.0, 300.0));
1654
1655 let output = emit_document(&graph);
1656 assert!(output.contains("from: @start"));
1657 assert!(output.contains("to: 400 300"));
1658
1659 let graph2 = parse_document(&output).expect("mixed anchor roundtrip failed");
1660 let edge2 = &graph2.edges[0];
1661 assert_eq!(edge2.from, EdgeAnchor::Node(NodeId::intern("start")));
1662 assert_eq!(edge2.to, EdgeAnchor::Point(400.0, 300.0));
1663 }
1664
1665 #[test]
1666 fn parse_edge_omitted_anchors_default() {
1667 let input = r#"
1668edge @no_endpoints {
1669 arrow: end
1670}
1671"#;
1672 let graph = parse_document(input).unwrap();
1673 assert_eq!(graph.edges.len(), 1);
1674 let edge = &graph.edges[0];
1675 assert_eq!(edge.from, EdgeAnchor::Point(0.0, 0.0));
1676 assert_eq!(edge.to, EdgeAnchor::Point(0.0, 0.0));
1677 }
1678
1679 #[test]
1680 fn test_spec_markdown_basic() {
1681 let input = r#"
1682rect @login_btn {
1683 spec {
1684 "Primary CTA for login"
1685 accept: "disabled when fields empty"
1686 status: doing
1687 priority: high
1688 tag: auth
1689 }
1690 w: 280 h: 48
1691 fill: #6C5CE7
1692}
1693"#;
1694 let graph = parse_document(input).unwrap();
1695 let md = emit_spec_markdown(&graph, "login.fd");
1696
1697 assert!(md.starts_with("# Spec: login.fd\n"));
1698 assert!(md.contains("## @login_btn `rect`"));
1699 assert!(md.contains("> Primary CTA for login"));
1700 assert!(md.contains("- [ ] disabled when fields empty"));
1701 assert!(md.contains("- **Status:** doing"));
1702 assert!(md.contains("- **Priority:** high"));
1703 assert!(md.contains("- **Tag:** auth"));
1704 assert!(!md.contains("280"));
1706 assert!(!md.contains("6C5CE7"));
1707 }
1708
1709 #[test]
1710 fn test_spec_markdown_nested() {
1711 let input = r#"
1712group @form {
1713 layout: column gap=16 pad=32
1714 spec {
1715 "Shipping address form"
1716 accept: "autofill from saved addresses"
1717 }
1718
1719 rect @email {
1720 spec {
1721 "Email input"
1722 accept: "validates email format"
1723 }
1724 w: 280 h: 44
1725 }
1726
1727 rect @no_annotations {
1728 w: 100 h: 50
1729 fill: #CCC
1730 }
1731}
1732"#;
1733 let graph = parse_document(input).unwrap();
1734 let md = emit_spec_markdown(&graph, "checkout.fd");
1735
1736 assert!(md.contains("## @form `group`"));
1737 assert!(md.contains("### @email `rect`"));
1738 assert!(md.contains("> Shipping address form"));
1739 assert!(md.contains("- [ ] autofill from saved addresses"));
1740 assert!(md.contains("- [ ] validates email format"));
1741 assert!(!md.contains("no_annotations"));
1743 }
1744
1745 #[test]
1746 fn test_spec_markdown_with_edges() {
1747 let input = r#"
1748rect @login { w: 200 h: 100 }
1749rect @dashboard {
1750 spec "Main dashboard"
1751 w: 200 h: 100
1752}
1753
1754edge @auth_flow {
1755 spec {
1756 "Authentication flow"
1757 accept: "redirect within 2s"
1758 }
1759 from: @login
1760 to: @dashboard
1761 label: "on success"
1762 arrow: end
1763}
1764"#;
1765 let graph = parse_document(input).unwrap();
1766 let md = emit_spec_markdown(&graph, "flow.fd");
1767
1768 assert!(md.contains("## Flows"));
1769 assert!(md.contains("**@login** → **@dashboard**"));
1770 assert!(md.contains("on success"));
1771 assert!(md.contains("> Authentication flow"));
1772 assert!(md.contains("- [ ] redirect within 2s"));
1773 }
1774
1775 #[test]
1776 fn roundtrip_import_basic() {
1777 let input = "import \"components/buttons.fd\" as btn\nrect @hero { w: 200 h: 100 }\n";
1778 let graph = parse_document(input).unwrap();
1779 assert_eq!(graph.imports.len(), 1);
1780 assert_eq!(graph.imports[0].path, "components/buttons.fd");
1781 assert_eq!(graph.imports[0].namespace, "btn");
1782
1783 let output = emit_document(&graph);
1784 assert!(output.contains("import \"components/buttons.fd\" as btn"));
1785
1786 let graph2 = parse_document(&output).expect("re-parse of import failed");
1787 assert_eq!(graph2.imports.len(), 1);
1788 assert_eq!(graph2.imports[0].path, "components/buttons.fd");
1789 assert_eq!(graph2.imports[0].namespace, "btn");
1790 }
1791
1792 #[test]
1793 fn roundtrip_import_multiple() {
1794 let input = "import \"tokens.fd\" as tokens\nimport \"buttons.fd\" as btn\nrect @box { w: 50 h: 50 }\n";
1795 let graph = parse_document(input).unwrap();
1796 assert_eq!(graph.imports.len(), 2);
1797 assert_eq!(graph.imports[0].namespace, "tokens");
1798 assert_eq!(graph.imports[1].namespace, "btn");
1799
1800 let output = emit_document(&graph);
1801 let graph2 = parse_document(&output).expect("re-parse of multiple imports failed");
1802 assert_eq!(graph2.imports.len(), 2);
1803 assert_eq!(graph2.imports[0].namespace, "tokens");
1804 assert_eq!(graph2.imports[1].namespace, "btn");
1805 }
1806
1807 #[test]
1808 fn parse_import_without_alias_errors() {
1809 let input = "import \"missing_alias.fd\"\nrect @box { w: 50 h: 50 }\n";
1810 let result = parse_document(input);
1812 assert!(result.is_err());
1813 }
1814
1815 #[test]
1816 fn roundtrip_comment_preserved() {
1817 let input = r#"
1819# This is a section header
1820rect @box {
1821 w: 100 h: 50
1822 fill: #FF0000
1823}
1824"#;
1825 let graph = parse_document(input).unwrap();
1826 let output = emit_document(&graph);
1827 assert!(
1828 output.contains("# This is a section header"),
1829 "comment should appear in emitted output: {output}"
1830 );
1831 let graph2 = parse_document(&output).expect("re-parse of commented document failed");
1833 let node = graph2.get_by_id(NodeId::intern("box")).unwrap();
1834 assert_eq!(node.comments, vec!["This is a section header"]);
1835 }
1836
1837 #[test]
1838 fn roundtrip_multiple_comments_preserved() {
1839 let input = r#"
1840# Header section
1841# Subheading
1842rect @panel {
1843 w: 300 h: 200
1844}
1845"#;
1846 let graph = parse_document(input).unwrap();
1847 let output = emit_document(&graph);
1848 let graph2 = parse_document(&output).expect("re-parse failed");
1849 let node = graph2.get_by_id(NodeId::intern("panel")).unwrap();
1850 assert_eq!(node.comments.len(), 2);
1851 assert_eq!(node.comments[0], "Header section");
1852 assert_eq!(node.comments[1], "Subheading");
1853 }
1854
1855 #[test]
1856 fn roundtrip_inline_position() {
1857 let input = r#"
1858rect @placed {
1859 x: 100
1860 y: 200
1861 w: 50 h: 50
1862 fill: #FF0000
1863}
1864"#;
1865 let graph = parse_document(input).unwrap();
1866 let node = graph.get_by_id(NodeId::intern("placed")).unwrap();
1867
1868 assert!(
1870 node.constraints
1871 .iter()
1872 .any(|c| matches!(c, Constraint::Position { .. })),
1873 "should have Position constraint"
1874 );
1875
1876 let output = emit_document(&graph);
1878 assert!(output.contains("x: 100"), "should emit x: inline");
1879 assert!(output.contains("y: 200"), "should emit y: inline");
1880 assert!(
1881 !output.contains("-> absolute"),
1882 "should NOT emit old absolute arrow"
1883 );
1884 assert!(
1885 !output.contains("-> position"),
1886 "should NOT emit position arrow"
1887 );
1888
1889 let graph2 = parse_document(&output).expect("re-parse of inline position failed");
1891 let node2 = graph2.get_by_id(NodeId::intern("placed")).unwrap();
1892 let pos = node2
1893 .constraints
1894 .iter()
1895 .find_map(|c| match c {
1896 Constraint::Position { x, y } => Some((*x, *y)),
1897 _ => None,
1898 })
1899 .expect("Position constraint missing after roundtrip");
1900 assert_eq!(pos, (100.0, 200.0));
1901 }
1902
1903 #[test]
1904 fn emit_children_before_styles() {
1905 let input = r#"
1906rect @box {
1907 w: 200 h: 100
1908 fill: #FF0000
1909 corner: 10
1910 text @label "Hello" {
1911 fill: #FFFFFF
1912 font: "Inter" 600 14
1913 }
1914 anim :hover {
1915 fill: #CC0000
1916 ease: ease_out 200ms
1917 }
1918}
1919"#;
1920 let graph = parse_document(input).unwrap();
1921 let output = emit_document(&graph);
1922
1923 let child_pos = output.find("text @label").expect("child missing");
1925 let fill_pos = output.find("fill: #FF0000").expect("fill missing");
1926 let corner_pos = output.find("corner: 10").expect("corner missing");
1927 let anim_pos = output.find("when :hover").expect("when missing");
1928
1929 assert!(
1930 child_pos < fill_pos,
1931 "children should appear before fill: child_pos={child_pos} fill_pos={fill_pos}"
1932 );
1933 assert!(
1934 child_pos < corner_pos,
1935 "children should appear before corner"
1936 );
1937 assert!(fill_pos < anim_pos, "fill should appear before animations");
1938 }
1939
1940 #[test]
1941 fn emit_section_separators() {
1942 let input = r#"
1943style accent {
1944 fill: #6C5CE7
1945}
1946
1947rect @a {
1948 w: 100 h: 50
1949}
1950
1951rect @b {
1952 w: 100 h: 50
1953}
1954
1955edge @flow {
1956 from: @a
1957 to: @b
1958 arrow: end
1959}
1960
1961@a -> center_in: canvas
1962"#;
1963 let graph = parse_document(input).unwrap();
1964 let output = emit_document(&graph);
1965
1966 assert!(
1967 output.contains("# ─── Themes ───"),
1968 "should have Themes separator"
1969 );
1970 assert!(
1971 output.contains("# ─── Layout ───"),
1972 "should have Layout separator"
1973 );
1974 assert!(
1975 output.contains("# ─── Flows ───"),
1976 "should have Flows separator"
1977 );
1978 }
1979
1980 #[test]
1981 fn roundtrip_children_before_styles() {
1982 let input = r#"
1983group @card {
1984 layout: column gap=12 pad=20
1985 text @title "Dashboard" {
1986 font: "Inter" 600 20
1987 fill: #111111
1988 }
1989 rect @body {
1990 w: 300 h: 200
1991 fill: #F5F5F5
1992 }
1993 fill: #FFFFFF
1994 corner: 8
1995 shadow: (0,2,8,#00000011)
1996}
1997"#;
1998 let graph = parse_document(input).unwrap();
1999 let output = emit_document(&graph);
2000
2001 let graph2 = parse_document(&output).expect("re-parse of reordered output failed");
2003 let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
2004 assert_eq!(
2005 graph2.children(card_idx).len(),
2006 2,
2007 "card should still have 2 children after roundtrip"
2008 );
2009
2010 let child_pos = output.find("text @title").expect("child missing");
2012 let fill_pos = output.find("fill: #FFFFFF").expect("card fill missing");
2013 assert!(
2014 child_pos < fill_pos,
2015 "children should appear before parent fill"
2016 );
2017 }
2018
2019 #[test]
2020 fn roundtrip_theme_keyword() {
2021 let input = r#"
2023theme accent {
2024 fill: #6C5CE7
2025 corner: 12
2026}
2027
2028rect @btn {
2029 w: 120 h: 40
2030 use: accent
2031}
2032"#;
2033 let graph = parse_document(input).unwrap();
2034 let output = emit_document(&graph);
2035
2036 assert!(
2038 output.contains("theme accent"),
2039 "should emit `theme` keyword"
2040 );
2041 assert!(
2042 !output.contains("style accent"),
2043 "should NOT emit `style` keyword"
2044 );
2045
2046 let graph2 = parse_document(&output).expect("re-parse of theme output failed");
2048 assert!(
2049 graph2.styles.contains_key(&NodeId::intern("accent")),
2050 "theme definition should survive roundtrip"
2051 );
2052 }
2053
2054 #[test]
2055 fn roundtrip_when_keyword() {
2056 let input = r#"
2058rect @btn {
2059 w: 120 h: 40
2060 fill: #6C5CE7
2061 when :hover {
2062 fill: #5A4BD1
2063 ease: ease_out 200ms
2064 }
2065}
2066"#;
2067 let graph = parse_document(input).unwrap();
2068 let output = emit_document(&graph);
2069
2070 assert!(output.contains("when :hover"), "should emit `when` keyword");
2072 assert!(
2073 !output.contains("anim :hover"),
2074 "should NOT emit `anim` keyword"
2075 );
2076
2077 let graph2 = parse_document(&output).expect("re-parse of when output failed");
2079 let node = graph2.get_by_id(NodeId::intern("btn")).unwrap();
2080 assert_eq!(
2081 node.animations.len(),
2082 1,
2083 "animation should survive roundtrip"
2084 );
2085 assert_eq!(
2086 node.animations[0].trigger,
2087 AnimTrigger::Hover,
2088 "trigger should be Hover"
2089 );
2090 }
2091
2092 #[test]
2093 fn parse_old_style_keyword_compat() {
2094 let input = r#"
2096style accent {
2097 fill: #6C5CE7
2098}
2099
2100rect @btn {
2101 w: 120 h: 40
2102 use: accent
2103}
2104"#;
2105 let graph = parse_document(input).unwrap();
2106 assert!(
2107 graph.styles.contains_key(&NodeId::intern("accent")),
2108 "old `style` keyword should parse into a theme definition"
2109 );
2110
2111 let output = emit_document(&graph);
2113 assert!(
2114 output.contains("theme accent"),
2115 "emitter should upgrade `style` to `theme`"
2116 );
2117 }
2118
2119 #[test]
2120 fn parse_old_anim_keyword_compat() {
2121 let input = r#"
2123rect @btn {
2124 w: 120 h: 40
2125 fill: #6C5CE7
2126 anim :press {
2127 scale: 0.95
2128 ease: spring 150ms
2129 }
2130}
2131"#;
2132 let graph = parse_document(input).unwrap();
2133 let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2134 assert_eq!(
2135 node.animations.len(),
2136 1,
2137 "old `anim` keyword should parse into animation"
2138 );
2139 assert_eq!(
2140 node.animations[0].trigger,
2141 AnimTrigger::Press,
2142 "trigger should be Press"
2143 );
2144
2145 let output = emit_document(&graph);
2147 assert!(
2148 output.contains("when :press"),
2149 "emitter should upgrade `anim` to `when`"
2150 );
2151 }
2152
2153 #[test]
2154 fn roundtrip_theme_import() {
2155 let input = r#"
2157import "tokens.fd" as tokens
2158
2159theme card_base {
2160 fill: #FFFFFF
2161 corner: 16
2162}
2163
2164rect @card {
2165 w: 300 h: 200
2166 use: card_base
2167}
2168"#;
2169 let graph = parse_document(input).unwrap();
2170 let output = emit_document(&graph);
2171
2172 assert!(
2174 output.contains("import \"tokens.fd\" as tokens"),
2175 "import should survive roundtrip"
2176 );
2177 assert!(
2178 output.contains("theme card_base"),
2179 "theme should survive roundtrip"
2180 );
2181
2182 let graph2 = parse_document(&output).expect("re-parse failed");
2184 assert_eq!(graph2.imports.len(), 1, "import count should survive");
2185 assert!(
2186 graph2.styles.contains_key(&NodeId::intern("card_base")),
2187 "theme def should survive"
2188 );
2189 }
2190
2191 #[test]
2194 fn roundtrip_empty_group() {
2195 let input = "group @empty {\n}\n";
2197 let graph = parse_document(input).unwrap();
2198 let output = emit_document(&graph);
2199 assert!(
2200 !output.contains("@empty"),
2201 "empty group should be stripped on emit, got: {output}"
2202 );
2203 }
2204
2205 #[test]
2206 fn emit_strips_empty_frame() {
2207 let input = "frame @lonely {\n w: 200 h: 100\n}\n";
2209 let graph = parse_document(input).unwrap();
2210 let output = emit_document(&graph);
2211 assert!(
2212 !output.contains("@lonely"),
2213 "empty frame should be stripped on emit, got: {output}"
2214 );
2215 }
2216
2217 #[test]
2218 fn emit_keeps_styled_empty_frame() {
2219 let input = "frame @styled {\n w: 200 h: 100\n fill: #FF0000\n}\n";
2221 let graph = parse_document(input).unwrap();
2222 let output = emit_document(&graph);
2223 assert!(
2224 output.contains("@styled"),
2225 "styled empty frame should be preserved, got: {output}"
2226 );
2227 }
2228
2229 #[test]
2230 fn emit_keeps_group_with_children() {
2231 let input = "group @parent {\n rect @child {\n w: 40 h: 20\n }\n}\n";
2233 let graph = parse_document(input).unwrap();
2234 let output = emit_document(&graph);
2235 assert!(
2236 output.contains("@parent"),
2237 "group with children should be preserved, got: {output}"
2238 );
2239 }
2240
2241 #[test]
2242 fn roundtrip_deeply_nested_groups() {
2243 let input = r#"
2244group @outer {
2245 group @middle {
2246 group @inner {
2247 rect @leaf {
2248 w: 40 h: 20
2249 fill: #FF0000
2250 }
2251 }
2252 }
2253}
2254"#;
2255 let graph = parse_document(input).unwrap();
2256 let output = emit_document(&graph);
2257 let graph2 = parse_document(&output).expect("re-parse of nested groups failed");
2258 let leaf = graph2.get_by_id(NodeId::intern("leaf")).unwrap();
2259 assert!(matches!(leaf.kind, NodeKind::Rect { .. }));
2260 let inner_idx = graph2.index_of(NodeId::intern("inner")).unwrap();
2262 assert_eq!(graph2.children(inner_idx).len(), 1);
2263 let middle_idx = graph2.index_of(NodeId::intern("middle")).unwrap();
2264 assert_eq!(graph2.children(middle_idx).len(), 1);
2265 }
2266
2267 #[test]
2268 fn roundtrip_unicode_text() {
2269 let input = "text @emoji \"Hello 🎨 café 日本語\" {\n fill: #333333\n}\n";
2270 let graph = parse_document(input).unwrap();
2271 let output = emit_document(&graph);
2272 assert!(
2273 output.contains("Hello 🎨 café 日本語"),
2274 "unicode should survive emit"
2275 );
2276 let graph2 = parse_document(&output).expect("re-parse of unicode failed");
2277 let node = graph2.get_by_id(NodeId::intern("emoji")).unwrap();
2278 match &node.kind {
2279 NodeKind::Text { content } => {
2280 assert!(content.contains("🎨"));
2281 assert!(content.contains("café"));
2282 assert!(content.contains("日本語"));
2283 }
2284 _ => panic!("expected Text node"),
2285 }
2286 }
2287
2288 #[test]
2289 fn roundtrip_spec_all_fields() {
2290 let input = r#"
2291rect @full_spec {
2292 spec {
2293 "Full specification node"
2294 accept: "all fields present"
2295 status: doing
2296 priority: high
2297 tag: mvp, auth
2298 }
2299 w: 100 h: 50
2300}
2301"#;
2302 let graph = parse_document(input).unwrap();
2303 let node = graph.get_by_id(NodeId::intern("full_spec")).unwrap();
2304 assert_eq!(node.annotations.len(), 5, "should have 5 annotations");
2305
2306 let output = emit_document(&graph);
2307 let graph2 = parse_document(&output).expect("re-parse of full spec failed");
2308 let node2 = graph2.get_by_id(NodeId::intern("full_spec")).unwrap();
2309 assert_eq!(node2.annotations.len(), 5);
2310 assert_eq!(node2.annotations, node.annotations);
2311 }
2312
2313 #[test]
2314 fn roundtrip_path_node() {
2315 let input = "path @sketch {\n}\n";
2316 let graph = parse_document(input).unwrap();
2317 let output = emit_document(&graph);
2318 let graph2 = parse_document(&output).expect("re-parse of path failed");
2319 let node = graph2.get_by_id(NodeId::intern("sketch")).unwrap();
2320 assert!(matches!(node.kind, NodeKind::Path { .. }));
2321 }
2322
2323 #[test]
2324 fn roundtrip_gradient_linear() {
2325 let input = r#"
2326rect @grad {
2327 w: 200 h: 100
2328 fill: linear(90deg, #FF0000 0, #0000FF 1)
2329}
2330"#;
2331 let graph = parse_document(input).unwrap();
2332 let node = graph.get_by_id(NodeId::intern("grad")).unwrap();
2333 assert!(matches!(
2334 node.style.fill,
2335 Some(Paint::LinearGradient { .. })
2336 ));
2337
2338 let output = emit_document(&graph);
2339 assert!(output.contains("linear("), "should emit linear gradient");
2340 let graph2 = parse_document(&output).expect("re-parse of linear gradient failed");
2341 let node2 = graph2.get_by_id(NodeId::intern("grad")).unwrap();
2342 assert!(matches!(
2343 node2.style.fill,
2344 Some(Paint::LinearGradient { .. })
2345 ));
2346 }
2347
2348 #[test]
2349 fn roundtrip_gradient_radial() {
2350 let input = r#"
2351rect @radial_box {
2352 w: 100 h: 100
2353 fill: radial(#FFFFFF 0, #000000 1)
2354}
2355"#;
2356 let graph = parse_document(input).unwrap();
2357 let node = graph.get_by_id(NodeId::intern("radial_box")).unwrap();
2358 assert!(matches!(
2359 node.style.fill,
2360 Some(Paint::RadialGradient { .. })
2361 ));
2362
2363 let output = emit_document(&graph);
2364 assert!(output.contains("radial("), "should emit radial gradient");
2365 let graph2 = parse_document(&output).expect("re-parse of radial gradient failed");
2366 let node2 = graph2.get_by_id(NodeId::intern("radial_box")).unwrap();
2367 assert!(matches!(
2368 node2.style.fill,
2369 Some(Paint::RadialGradient { .. })
2370 ));
2371 }
2372
2373 #[test]
2374 fn roundtrip_shadow_property() {
2375 let input = r#"
2376rect @shadowed {
2377 w: 200 h: 100
2378 shadow: (0,4,20,#000000)
2379}
2380"#;
2381 let graph = parse_document(input).unwrap();
2382 let node = graph.get_by_id(NodeId::intern("shadowed")).unwrap();
2383 let shadow = node.style.shadow.as_ref().expect("shadow should exist");
2384 assert_eq!(shadow.blur, 20.0);
2385
2386 let output = emit_document(&graph);
2387 assert!(output.contains("shadow:"), "should emit shadow");
2388 let graph2 = parse_document(&output).expect("re-parse of shadow failed");
2389 let node2 = graph2.get_by_id(NodeId::intern("shadowed")).unwrap();
2390 let shadow2 = node2.style.shadow.as_ref().expect("shadow should survive");
2391 assert_eq!(shadow2.offset_y, 4.0);
2392 assert_eq!(shadow2.blur, 20.0);
2393 }
2394
2395 #[test]
2396 fn roundtrip_opacity() {
2397 let input = r#"
2398rect @faded {
2399 w: 100 h: 100
2400 fill: #6C5CE7
2401 opacity: 0.5
2402}
2403"#;
2404 let graph = parse_document(input).unwrap();
2405 let node = graph.get_by_id(NodeId::intern("faded")).unwrap();
2406 assert_eq!(node.style.opacity, Some(0.5));
2407
2408 let output = emit_document(&graph);
2409 let graph2 = parse_document(&output).expect("re-parse of opacity failed");
2410 let node2 = graph2.get_by_id(NodeId::intern("faded")).unwrap();
2411 assert_eq!(node2.style.opacity, Some(0.5));
2412 }
2413
2414 #[test]
2415 fn roundtrip_clip_frame() {
2416 let input = r#"
2417frame @clipped {
2418 w: 300 h: 200
2419 clip: true
2420 fill: #FFFFFF
2421 corner: 12
2422}
2423"#;
2424 let graph = parse_document(input).unwrap();
2425 let output = emit_document(&graph);
2426 assert!(output.contains("clip: true"), "should emit clip");
2427 let graph2 = parse_document(&output).expect("re-parse of clip frame failed");
2428 let node = graph2.get_by_id(NodeId::intern("clipped")).unwrap();
2429 match &node.kind {
2430 NodeKind::Frame { clip, .. } => assert!(clip, "clip should be true"),
2431 _ => panic!("expected Frame node"),
2432 }
2433 }
2434
2435 #[test]
2436 fn roundtrip_multiple_animations() {
2437 let input = r#"
2438rect @animated {
2439 w: 120 h: 40
2440 fill: #6C5CE7
2441 when :hover {
2442 fill: #5A4BD1
2443 scale: 1.05
2444 ease: ease_out 200ms
2445 }
2446 when :press {
2447 scale: 0.95
2448 ease: spring 150ms
2449 }
2450}
2451"#;
2452 let graph = parse_document(input).unwrap();
2453 let node = graph.get_by_id(NodeId::intern("animated")).unwrap();
2454 assert_eq!(node.animations.len(), 2, "should have 2 animations");
2455
2456 let output = emit_document(&graph);
2457 let graph2 = parse_document(&output).expect("re-parse of multi-anim failed");
2458 let node2 = graph2.get_by_id(NodeId::intern("animated")).unwrap();
2459 assert_eq!(node2.animations.len(), 2);
2460 assert_eq!(node2.animations[0].trigger, AnimTrigger::Hover);
2461 assert_eq!(node2.animations[1].trigger, AnimTrigger::Press);
2462 }
2463
2464 #[test]
2465 fn roundtrip_inline_spec_shorthand() {
2466 let input = r#"
2467rect @btn {
2468 spec "Primary action button"
2469 w: 180 h: 48
2470 fill: #6C5CE7
2471}
2472"#;
2473 let graph = parse_document(input).unwrap();
2474 let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2475 assert_eq!(node.annotations.len(), 1);
2476 assert!(matches!(
2477 &node.annotations[0],
2478 Annotation::Description(d) if d == "Primary action button"
2479 ));
2480
2481 let output = emit_document(&graph);
2482 let graph2 = parse_document(&output).expect("re-parse of inline spec failed");
2483 let node2 = graph2.get_by_id(NodeId::intern("btn")).unwrap();
2484 assert_eq!(node2.annotations, node.annotations);
2485 }
2486
2487 #[test]
2488 fn roundtrip_layout_modes() {
2489 let input = r#"
2490frame @col {
2491 w: 400 h: 300
2492 layout: column gap=16 pad=24
2493 rect @c1 { w: 100 h: 50 }
2494}
2495
2496frame @rw {
2497 w: 400 h: 300
2498 layout: row gap=8 pad=12
2499 rect @r1 { w: 50 h: 50 }
2500}
2501
2502frame @grd {
2503 w: 400 h: 300
2504 layout: grid cols=2 gap=10 pad=20
2505 rect @g1 { w: 80 h: 80 }
2506}
2507"#;
2508 let graph = parse_document(input).unwrap();
2509 let output = emit_document(&graph);
2510 assert!(output.contains("layout: column gap=16 pad=24"));
2511 assert!(output.contains("layout: row gap=8 pad=12"));
2512 assert!(output.contains("layout: grid cols=2 gap=10 pad=20"));
2513
2514 let graph2 = parse_document(&output).expect("re-parse of layout modes failed");
2515 let col = graph2.get_by_id(NodeId::intern("col")).unwrap();
2516 assert!(matches!(col.kind, NodeKind::Frame { .. }));
2517 let rw = graph2.get_by_id(NodeId::intern("rw")).unwrap();
2518 assert!(matches!(rw.kind, NodeKind::Frame { .. }));
2519 let grd = graph2.get_by_id(NodeId::intern("grd")).unwrap();
2520 assert!(matches!(grd.kind, NodeKind::Frame { .. }));
2521 }
2522
2523 fn make_test_graph() -> SceneGraph {
2526 let input = r#"
2528theme accent {
2529 fill: #6C5CE7
2530 font: "Inter" bold 16
2531}
2532
2533frame @container {
2534 w: 600 h: 400
2535 layout: column gap=16 pad=24
2536
2537 rect @card {
2538 w: 200 h: 100
2539 use: accent
2540 fill: #FFFFFF
2541 corner: 12
2542 spec {
2543 "Main card component"
2544 status: done
2545 }
2546 when :hover {
2547 fill: #F0EDFF
2548 scale: 1.05
2549 ease: ease_out 200ms
2550 }
2551 }
2552
2553 text @label "Hello" {
2554 font: "Inter" regular 14
2555 fill: #333333
2556 x: 20
2557 y: 40
2558 }
2559}
2560
2561edge @card_to_label {
2562 from: @card
2563 to: @label
2564 label: "displays"
2565}
2566"#;
2567 parse_document(input).unwrap()
2568 }
2569
2570 #[test]
2571 fn emit_filtered_full_matches_emit_document() {
2572 let graph = make_test_graph();
2573 let full = emit_filtered(&graph, ReadMode::Full);
2574 let doc = emit_document(&graph);
2575 assert_eq!(full, doc, "Full mode should be identical to emit_document");
2576 }
2577
2578 #[test]
2579 fn emit_filtered_structure() {
2580 let graph = make_test_graph();
2581 let out = emit_filtered(&graph, ReadMode::Structure);
2582 assert!(out.contains("frame @container"), "should include frame");
2584 assert!(out.contains("rect @card"), "should include rect");
2585 assert!(out.contains("text @label"), "should include text");
2586 assert!(!out.contains("fill:"), "no fill in structure mode");
2588 assert!(!out.contains("spec"), "no spec in structure mode");
2589 assert!(!out.contains("when"), "no when in structure mode");
2590 assert!(!out.contains("theme"), "no theme in structure mode");
2591 assert!(!out.contains("edge"), "no edges in structure mode");
2592 }
2593
2594 #[test]
2595 fn emit_filtered_layout() {
2596 let graph = make_test_graph();
2597 let out = emit_filtered(&graph, ReadMode::Layout);
2598 assert!(out.contains("layout: column"), "should include layout");
2600 assert!(out.contains("w: 200 h: 100"), "should include dims");
2601 assert!(out.contains("x: 20"), "should include position");
2602 assert!(!out.contains("fill:"), "no fill in layout mode");
2604 assert!(!out.contains("theme"), "no theme in layout mode");
2605 assert!(!out.contains("when :hover"), "no when in layout mode");
2606 }
2607
2608 #[test]
2609 fn emit_filtered_design() {
2610 let graph = make_test_graph();
2611 let out = emit_filtered(&graph, ReadMode::Design);
2612 assert!(out.contains("theme accent"), "should include theme");
2614 assert!(out.contains("use: accent"), "should include use ref");
2615 assert!(out.contains("fill:"), "should include fill");
2616 assert!(out.contains("corner: 12"), "should include corner");
2617 assert!(!out.contains("layout:"), "no layout in design mode");
2619 assert!(!out.contains("w: 200"), "no dims in design mode");
2620 assert!(!out.contains("when :hover"), "no when in design mode");
2621 }
2622
2623 #[test]
2624 fn emit_filtered_spec() {
2625 let graph = make_test_graph();
2626 let out = emit_filtered(&graph, ReadMode::Spec);
2627 assert!(out.contains("spec"), "should include spec");
2629 assert!(out.contains("Main card component"), "should include desc");
2630 assert!(out.contains("status: done"), "should include status");
2631 assert!(!out.contains("fill:"), "no fill in spec mode");
2633 assert!(!out.contains("when"), "no when in spec mode");
2634 }
2635
2636 #[test]
2637 fn emit_filtered_visual() {
2638 let graph = make_test_graph();
2639 let out = emit_filtered(&graph, ReadMode::Visual);
2640 assert!(out.contains("theme accent"), "should include theme");
2642 assert!(out.contains("layout: column"), "should include layout");
2643 assert!(out.contains("w: 200 h: 100"), "should include dims");
2644 assert!(out.contains("fill:"), "should include fill");
2645 assert!(out.contains("corner: 12"), "should include corner");
2646 assert!(out.contains("when :hover"), "should include when");
2647 assert!(out.contains("scale: 1.05"), "should include anim props");
2648 assert!(out.contains("edge @card_to_label"), "should include edges");
2649 assert!(
2651 !out.contains("Main card component"),
2652 "no spec desc in visual mode"
2653 );
2654 }
2655
2656 #[test]
2657 fn emit_filtered_when() {
2658 let graph = make_test_graph();
2659 let out = emit_filtered(&graph, ReadMode::When);
2660 assert!(out.contains("when :hover"), "should include when");
2662 assert!(out.contains("scale: 1.05"), "should include anim props");
2663 assert!(!out.contains("corner:"), "no corner in when mode");
2665 assert!(!out.contains("w: 200"), "no dims in when mode");
2666 assert!(!out.contains("theme"), "no theme in when mode");
2667 assert!(!out.contains("spec"), "no spec in when mode");
2668 }
2669
2670 #[test]
2671 fn emit_filtered_edges() {
2672 let graph = make_test_graph();
2673 let out = emit_filtered(&graph, ReadMode::Edges);
2674 assert!(out.contains("edge @card_to_label"), "should include edge");
2676 assert!(out.contains("from: @card"), "should include from");
2677 assert!(out.contains("to: @label"), "should include to");
2678 assert!(
2679 out.contains("text @_card_to_label_label \"displays\" {}"),
2680 "should include text child"
2681 );
2682 assert!(!out.contains("fill:"), "no fill in edges mode");
2684 assert!(!out.contains("when"), "no when in edges mode");
2685 }
2686
2687 #[test]
2688 fn roundtrip_no_duplicate_separators() {
2689 let input = r#"
2692theme accent {
2693 fill: #A29BFE
2694}
2695
2696rect @a {
2697 w: 100 h: 50
2698 fill: #FF0000
2699}
2700
2701rect @b {
2702 w: 80 h: 40
2703}
2704
2705edge @link {
2706 from: @a
2707 to: @b
2708}
2709"#;
2710 let graph = parse_document(input).unwrap();
2711 let pass1 = emit_document(&graph);
2712
2713 assert!(
2715 pass1.contains("# ─── Themes ───"),
2716 "pass 1 should have Themes separator: {pass1}"
2717 );
2718 assert!(
2719 pass1.contains("# ─── Layout ───"),
2720 "pass 1 should have Layout separator: {pass1}"
2721 );
2722
2723 let graph2 = parse_document(&pass1).expect("re-parse failed");
2725 let pass2 = emit_document(&graph2);
2726
2727 let themes_count = pass2.matches("# ─── Themes ───").count();
2729 let layout_count = pass2.matches("# ─── Layout ───").count();
2730 let flows_count = pass2.matches("# ─── Flows ───").count();
2731 assert_eq!(themes_count, 1, "Themes separator duplicated: {pass2}");
2732 assert_eq!(layout_count, 1, "Layout separator duplicated: {pass2}");
2733 assert_eq!(flows_count, 1, "Flows separator duplicated: {pass2}");
2734
2735 let graph3 = parse_document(&pass2).expect("third parse failed");
2737 let pass3 = emit_document(&graph3);
2738 assert_eq!(
2739 pass3.matches("# ─── Layout ───").count(),
2740 1,
2741 "Layout separator grew after 3 round-trips: {pass3}"
2742 );
2743 }
2744
2745 #[test]
2746 fn roundtrip_user_comments_not_stripped() {
2747 let input = r#"
2749theme dark {
2750 fill: #1A1A2E
2751}
2752
2753# Settings panel
2754rect @settings {
2755 w: 300 h: 200
2756}
2757
2758rect @other {
2759 w: 50 h: 50
2760}
2761
2762edge @link {
2763 from: @settings
2764 to: @other
2765}
2766"#;
2767 let graph = parse_document(input).unwrap();
2768 let output = emit_document(&graph);
2769 assert!(
2770 output.contains("# Settings panel"),
2771 "user comment should survive: {output}"
2772 );
2773 let node = graph.get_by_id(NodeId::intern("settings")).unwrap();
2775 assert_eq!(
2776 node.comments,
2777 vec!["Settings panel"],
2778 "only user comment should be attached, not separators"
2779 );
2780 }
2781}