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