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);
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) {
560 writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
561
562 emit_annotations(out, &edge.annotations, 1);
564
565 writeln!(out, " from: @{}", edge.from.as_str()).unwrap();
567 writeln!(out, " to: @{}", edge.to.as_str()).unwrap();
568
569 if let Some(ref label) = edge.label {
571 writeln!(out, " label: \"{label}\"").unwrap();
572 }
573
574 for style_ref in &edge.use_styles {
576 writeln!(out, " use: {}", style_ref.as_str()).unwrap();
577 }
578
579 if let Some(ref stroke) = edge.style.stroke {
581 match &stroke.paint {
582 Paint::Solid(c) => {
583 writeln!(out, " stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
584 }
585 _ => {
586 writeln!(out, " stroke: #000 {}", format_num(stroke.width)).unwrap();
587 }
588 }
589 }
590
591 if let Some(opacity) = edge.style.opacity {
593 writeln!(out, " opacity: {}", format_num(opacity)).unwrap();
594 }
595
596 if edge.arrow != ArrowKind::None {
598 let name = match edge.arrow {
599 ArrowKind::None => "none",
600 ArrowKind::Start => "start",
601 ArrowKind::End => "end",
602 ArrowKind::Both => "both",
603 };
604 writeln!(out, " arrow: {name}").unwrap();
605 }
606
607 if edge.curve != CurveKind::Straight {
609 let name = match edge.curve {
610 CurveKind::Straight => "straight",
611 CurveKind::Smooth => "smooth",
612 CurveKind::Step => "step",
613 };
614 writeln!(out, " curve: {name}").unwrap();
615 }
616
617 if let Some(ref flow) = edge.flow {
619 let kind = match flow.kind {
620 FlowKind::Pulse => "pulse",
621 FlowKind::Dash => "dash",
622 };
623 writeln!(out, " flow: {} {}ms", kind, flow.duration_ms).unwrap();
624 }
625
626 if let Some((ox, oy)) = edge.label_offset {
628 writeln!(out, " label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
629 }
630
631 for anim in &edge.animations {
633 emit_anim(out, anim, 1);
634 }
635
636 out.push_str("}\n");
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
646pub enum ReadMode {
647 Full,
649 Structure,
651 Layout,
653 Design,
655 Spec,
657 Visual,
659 When,
661 Edges,
663}
664
665#[must_use]
676pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
677 if mode == ReadMode::Full {
678 return emit_document(graph);
679 }
680
681 let mut out = String::with_capacity(1024);
682
683 let children = graph.children(graph.root);
684 let include_themes = matches!(mode, ReadMode::Design | ReadMode::Visual);
685 let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
686 let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
687
688 if include_themes && !graph.styles.is_empty() {
690 let mut styles: Vec<_> = graph.styles.iter().collect();
691 styles.sort_by_key(|(id, _)| id.as_str().to_string());
692 for (name, style) in &styles {
693 emit_style_block(&mut out, name, style, 0);
694 out.push('\n');
695 }
696 }
697
698 for child_idx in &children {
700 emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
701 out.push('\n');
702 }
703
704 if include_constraints {
706 for idx in graph.graph.node_indices() {
707 let node = &graph.graph[idx];
708 for constraint in &node.constraints {
709 if matches!(constraint, Constraint::Position { .. }) {
710 continue;
711 }
712 emit_constraint(&mut out, &node.id, constraint);
713 }
714 }
715 }
716
717 if include_edges {
719 for edge in &graph.edges {
720 emit_edge(&mut out, edge);
721 out.push('\n');
722 }
723 }
724
725 out
726}
727
728fn emit_node_filtered(
730 out: &mut String,
731 graph: &SceneGraph,
732 idx: NodeIndex,
733 depth: usize,
734 mode: ReadMode,
735) {
736 let node = &graph.graph[idx];
737
738 if matches!(node.kind, NodeKind::Root) {
739 return;
740 }
741
742 indent(out, depth);
743
744 match &node.kind {
746 NodeKind::Root => return,
747 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
748 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
749 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
750 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
751 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
752 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
753 NodeKind::Text { content } => {
754 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
755 }
756 }
757
758 out.push_str(" {\n");
759
760 if mode == ReadMode::Spec {
762 emit_annotations(out, &node.annotations, depth + 1);
763 }
764
765 let children = graph.children(idx);
767 for child_idx in &children {
768 emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
769 }
770
771 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
773 emit_layout_mode_filtered(out, &node.kind, depth + 1);
774 }
775
776 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
778 emit_dimensions_filtered(out, &node.kind, depth + 1);
779 }
780
781 if matches!(mode, ReadMode::Design | ReadMode::Visual) {
783 for style_ref in &node.use_styles {
784 indent(out, depth + 1);
785 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
786 }
787 if let Some(ref fill) = node.style.fill {
788 emit_paint_prop(out, "fill", fill, depth + 1);
789 }
790 if let Some(ref stroke) = node.style.stroke {
791 indent(out, depth + 1);
792 match &stroke.paint {
793 Paint::Solid(c) => {
794 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
795 }
796 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
797 }
798 }
799 if let Some(radius) = node.style.corner_radius {
800 indent(out, depth + 1);
801 writeln!(out, "corner: {}", format_num(radius)).unwrap();
802 }
803 if let Some(ref font) = node.style.font {
804 emit_font_prop(out, font, depth + 1);
805 }
806 if let Some(opacity) = node.style.opacity {
807 indent(out, depth + 1);
808 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
809 }
810 }
811
812 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
814 for constraint in &node.constraints {
815 if let Constraint::Position { x, y } = constraint {
816 if *x != 0.0 {
817 indent(out, depth + 1);
818 writeln!(out, "x: {}", format_num(*x)).unwrap();
819 }
820 if *y != 0.0 {
821 indent(out, depth + 1);
822 writeln!(out, "y: {}", format_num(*y)).unwrap();
823 }
824 }
825 }
826 }
827
828 if matches!(mode, ReadMode::When | ReadMode::Visual) {
830 for anim in &node.animations {
831 emit_anim(out, anim, depth + 1);
832 }
833 }
834
835 indent(out, depth);
836 out.push_str("}\n");
837}
838
839fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
841 let layout = match kind {
842 NodeKind::Frame { layout, .. } => layout,
843 _ => return, };
845 match layout {
846 LayoutMode::Free => {}
847 LayoutMode::Column { gap, pad } => {
848 indent(out, depth);
849 writeln!(
850 out,
851 "layout: column gap={} pad={}",
852 format_num(*gap),
853 format_num(*pad)
854 )
855 .unwrap();
856 }
857 LayoutMode::Row { gap, pad } => {
858 indent(out, depth);
859 writeln!(
860 out,
861 "layout: row gap={} pad={}",
862 format_num(*gap),
863 format_num(*pad)
864 )
865 .unwrap();
866 }
867 LayoutMode::Grid { cols, gap, pad } => {
868 indent(out, depth);
869 writeln!(
870 out,
871 "layout: grid cols={cols} gap={} pad={}",
872 format_num(*gap),
873 format_num(*pad)
874 )
875 .unwrap();
876 }
877 }
878}
879
880fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
882 match kind {
883 NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
884 indent(out, depth);
885 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
886 }
887 NodeKind::Ellipse { rx, ry } => {
888 indent(out, depth);
889 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
890 }
891 _ => {}
892 }
893}
894
895#[must_use]
903pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
904 let mut out = String::with_capacity(512);
905 writeln!(out, "# Spec: {title}\n").unwrap();
906
907 let children = graph.children(graph.root);
909 for child_idx in &children {
910 emit_spec_node(&mut out, graph, *child_idx, 2);
911 }
912
913 if !graph.edges.is_empty() {
915 out.push_str("\n---\n\n## Flows\n\n");
916 for edge in &graph.edges {
917 write!(
918 out,
919 "- **@{}** → **@{}**",
920 edge.from.as_str(),
921 edge.to.as_str()
922 )
923 .unwrap();
924 if let Some(ref label) = edge.label {
925 write!(out, " — {label}").unwrap();
926 }
927 out.push('\n');
928 emit_spec_annotations(&mut out, &edge.annotations, " ");
929 }
930 }
931
932 out
933}
934
935fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
936 let node = &graph.graph[idx];
937
938 let has_annotations = !node.annotations.is_empty();
940 let children = graph.children(idx);
941 let has_annotated_children = children
942 .iter()
943 .any(|c| has_annotations_recursive(graph, *c));
944
945 if !has_annotations && !has_annotated_children {
946 return;
947 }
948
949 let hashes = "#".repeat(heading_level.min(6));
951 let kind_label = match &node.kind {
952 NodeKind::Root => return,
953 NodeKind::Generic => "spec",
954 NodeKind::Group => "group",
955 NodeKind::Frame { .. } => "frame",
956 NodeKind::Rect { .. } => "rect",
957 NodeKind::Ellipse { .. } => "ellipse",
958 NodeKind::Path { .. } => "path",
959 NodeKind::Text { .. } => "text",
960 };
961 writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
962
963 emit_spec_annotations(out, &node.annotations, "");
965
966 for child_idx in &children {
968 emit_spec_node(out, graph, *child_idx, heading_level + 1);
969 }
970}
971
972fn has_annotations_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
973 let node = &graph.graph[idx];
974 if !node.annotations.is_empty() {
975 return true;
976 }
977 graph
978 .children(idx)
979 .iter()
980 .any(|c| has_annotations_recursive(graph, *c))
981}
982
983fn emit_spec_annotations(out: &mut String, annotations: &[Annotation], prefix: &str) {
984 for ann in annotations {
985 match ann {
986 Annotation::Description(s) => writeln!(out, "{prefix}> {s}").unwrap(),
987 Annotation::Accept(s) => writeln!(out, "{prefix}- [ ] {s}").unwrap(),
988 Annotation::Status(s) => writeln!(out, "{prefix}- **Status:** {s}").unwrap(),
989 Annotation::Priority(s) => writeln!(out, "{prefix}- **Priority:** {s}").unwrap(),
990 Annotation::Tag(s) => writeln!(out, "{prefix}- **Tag:** {s}").unwrap(),
991 }
992 }
993 if !annotations.is_empty() {
994 out.push('\n');
995 }
996}
997
998fn format_num(n: f32) -> String {
1000 if n == n.floor() {
1001 format!("{}", n as i32)
1002 } else {
1003 format!("{n:.2}")
1004 .trim_end_matches('0')
1005 .trim_end_matches('.')
1006 .to_string()
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use crate::parser::parse_document;
1014
1015 #[test]
1016 fn roundtrip_simple() {
1017 let input = r#"
1018rect @box {
1019 w: 100
1020 h: 50
1021 fill: #FF0000
1022}
1023"#;
1024 let graph = parse_document(input).unwrap();
1025 let output = emit_document(&graph);
1026
1027 let graph2 = parse_document(&output).expect("re-parse of emitted output failed");
1029 let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1030
1031 match &node2.kind {
1032 NodeKind::Rect { width, height } => {
1033 assert_eq!(*width, 100.0);
1034 assert_eq!(*height, 50.0);
1035 }
1036 _ => panic!("expected Rect"),
1037 }
1038 }
1039
1040 #[test]
1041 fn roundtrip_ellipse() {
1042 let input = r#"
1043ellipse @dot {
1044 w: 40 h: 40
1045 fill: #00FF00
1046}
1047"#;
1048 let graph = parse_document(input).unwrap();
1049 let output = emit_document(&graph);
1050 let graph2 = parse_document(&output).expect("re-parse of ellipse failed");
1051 let node = graph2.get_by_id(NodeId::intern("dot")).unwrap();
1052 match &node.kind {
1053 NodeKind::Ellipse { rx, ry } => {
1054 assert_eq!(*rx, 40.0);
1055 assert_eq!(*ry, 40.0);
1056 }
1057 _ => panic!("expected Ellipse"),
1058 }
1059 }
1060
1061 #[test]
1062 fn roundtrip_text_with_font() {
1063 let input = r#"
1064text @title "Hello" {
1065 font: "Inter" 700 32
1066 fill: #1A1A2E
1067}
1068"#;
1069 let graph = parse_document(input).unwrap();
1070 let output = emit_document(&graph);
1071 let graph2 = parse_document(&output).expect("re-parse of text failed");
1072 let node = graph2.get_by_id(NodeId::intern("title")).unwrap();
1073 match &node.kind {
1074 NodeKind::Text { content } => assert_eq!(content, "Hello"),
1075 _ => panic!("expected Text"),
1076 }
1077 let font = node.style.font.as_ref().expect("font missing");
1078 assert_eq!(font.family, "Inter");
1079 assert_eq!(font.weight, 700);
1080 assert_eq!(font.size, 32.0);
1081 }
1082
1083 #[test]
1084 fn roundtrip_nested_group() {
1085 let input = r#"
1086group @card {
1087 layout: column gap=16 pad=24
1088
1089 text @heading "Title" {
1090 font: "Inter" 600 20
1091 fill: #333333
1092 }
1093
1094 rect @body {
1095 w: 300 h: 200
1096 fill: #F5F5F5
1097 }
1098}
1099"#;
1100 let graph = parse_document(input).unwrap();
1101 let output = emit_document(&graph);
1102 let graph2 = parse_document(&output).expect("re-parse of nested group failed");
1103 let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
1104 assert_eq!(graph2.children(card_idx).len(), 2);
1105 }
1106
1107 #[test]
1108 fn roundtrip_animation() {
1109 let input = r#"
1110rect @btn {
1111 w: 200 h: 48
1112 fill: #6C5CE7
1113
1114 anim :hover {
1115 fill: #5A4BD1
1116 scale: 1.02
1117 ease: spring 300ms
1118 }
1119}
1120"#;
1121 let graph = parse_document(input).unwrap();
1122 let output = emit_document(&graph);
1123 let graph2 = parse_document(&output).expect("re-parse of animation failed");
1124 let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1125 assert_eq!(btn.animations.len(), 1);
1126 assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1127 }
1128
1129 #[test]
1130 fn roundtrip_style_and_use() {
1131 let input = r#"
1132style accent {
1133 fill: #6C5CE7
1134 corner: 10
1135}
1136
1137rect @btn {
1138 w: 200 h: 48
1139 use: accent
1140}
1141"#;
1142 let graph = parse_document(input).unwrap();
1143 let output = emit_document(&graph);
1144 let graph2 = parse_document(&output).expect("re-parse of style+use failed");
1145 assert!(graph2.styles.contains_key(&NodeId::intern("accent")));
1146 let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1147 assert_eq!(btn.use_styles.len(), 1);
1148 }
1149
1150 #[test]
1151 fn roundtrip_annotation_description() {
1152 let input = r#"
1153rect @box {
1154 spec "Primary container for content"
1155 w: 100 h: 50
1156 fill: #FF0000
1157}
1158"#;
1159 let graph = parse_document(input).unwrap();
1160 let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1161 assert_eq!(node.annotations.len(), 1);
1162 assert_eq!(
1163 node.annotations[0],
1164 Annotation::Description("Primary container for content".into())
1165 );
1166
1167 let output = emit_document(&graph);
1168 let graph2 = parse_document(&output).expect("re-parse of annotation failed");
1169 let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1170 assert_eq!(node2.annotations.len(), 1);
1171 assert_eq!(node2.annotations[0], node.annotations[0]);
1172 }
1173
1174 #[test]
1175 fn roundtrip_annotation_accept() {
1176 let input = r#"
1177rect @login_btn {
1178 spec {
1179 accept: "disabled state when fields empty"
1180 accept: "loading spinner during auth"
1181 }
1182 w: 280 h: 48
1183 fill: #6C5CE7
1184}
1185"#;
1186 let graph = parse_document(input).unwrap();
1187 let btn = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1188 assert_eq!(btn.annotations.len(), 2);
1189 assert_eq!(
1190 btn.annotations[0],
1191 Annotation::Accept("disabled state when fields empty".into())
1192 );
1193 assert_eq!(
1194 btn.annotations[1],
1195 Annotation::Accept("loading spinner during auth".into())
1196 );
1197
1198 let output = emit_document(&graph);
1199 let graph2 = parse_document(&output).expect("re-parse of accept annotation failed");
1200 let btn2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1201 assert_eq!(btn2.annotations, btn.annotations);
1202 }
1203
1204 #[test]
1205 fn roundtrip_annotation_status_priority() {
1206 let input = r#"
1207rect @card {
1208 spec {
1209 status: doing
1210 priority: high
1211 tag: mvp
1212 }
1213 w: 300 h: 200
1214}
1215"#;
1216 let graph = parse_document(input).unwrap();
1217 let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1218 assert_eq!(card.annotations.len(), 3);
1219 assert_eq!(card.annotations[0], Annotation::Status("doing".into()));
1220 assert_eq!(card.annotations[1], Annotation::Priority("high".into()));
1221 assert_eq!(card.annotations[2], Annotation::Tag("mvp".into()));
1222
1223 let output = emit_document(&graph);
1224 let graph2 =
1225 parse_document(&output).expect("re-parse of status/priority/tag annotation failed");
1226 let card2 = graph2.get_by_id(NodeId::intern("card")).unwrap();
1227 assert_eq!(card2.annotations, card.annotations);
1228 }
1229
1230 #[test]
1231 fn roundtrip_annotation_nested() {
1232 let input = r#"
1233group @form {
1234 layout: column gap=16 pad=32
1235 spec "User authentication entry point"
1236
1237 rect @email {
1238 spec {
1239 accept: "validates email format"
1240 }
1241 w: 280 h: 44
1242 }
1243}
1244"#;
1245 let graph = parse_document(input).unwrap();
1246 let form = graph.get_by_id(NodeId::intern("form")).unwrap();
1247 assert_eq!(form.annotations.len(), 1);
1248 let email = graph.get_by_id(NodeId::intern("email")).unwrap();
1249 assert_eq!(email.annotations.len(), 1);
1250
1251 let output = emit_document(&graph);
1252 let graph2 = parse_document(&output).expect("re-parse of nested annotation failed");
1253 let form2 = graph2.get_by_id(NodeId::intern("form")).unwrap();
1254 assert_eq!(form2.annotations, form.annotations);
1255 let email2 = graph2.get_by_id(NodeId::intern("email")).unwrap();
1256 assert_eq!(email2.annotations, email.annotations);
1257 }
1258
1259 #[test]
1260 fn parse_annotation_freeform() {
1261 let input = r#"
1262rect @widget {
1263 spec {
1264 "Description line"
1265 accept: "criterion one"
1266 status: done
1267 priority: low
1268 tag: design
1269 }
1270 w: 100 h: 100
1271}
1272"#;
1273 let graph = parse_document(input).unwrap();
1274 let w = graph.get_by_id(NodeId::intern("widget")).unwrap();
1275 assert_eq!(w.annotations.len(), 5);
1276 assert_eq!(
1277 w.annotations[0],
1278 Annotation::Description("Description line".into())
1279 );
1280 assert_eq!(w.annotations[1], Annotation::Accept("criterion one".into()));
1281 assert_eq!(w.annotations[2], Annotation::Status("done".into()));
1282 assert_eq!(w.annotations[3], Annotation::Priority("low".into()));
1283 assert_eq!(w.annotations[4], Annotation::Tag("design".into()));
1284 }
1285
1286 #[test]
1287 fn roundtrip_edge_basic() {
1288 let input = r#"
1289rect @box_a {
1290 w: 100 h: 50
1291}
1292
1293rect @box_b {
1294 w: 100 h: 50
1295}
1296
1297edge @a_to_b {
1298 from: @box_a
1299 to: @box_b
1300 label: "next step"
1301 arrow: end
1302}
1303"#;
1304 let graph = parse_document(input).unwrap();
1305 assert_eq!(graph.edges.len(), 1);
1306 let edge = &graph.edges[0];
1307 assert_eq!(edge.id.as_str(), "a_to_b");
1308 assert_eq!(edge.from.as_str(), "box_a");
1309 assert_eq!(edge.to.as_str(), "box_b");
1310 assert_eq!(edge.label.as_deref(), Some("next step"));
1311 assert_eq!(edge.arrow, ArrowKind::End);
1312
1313 let output = emit_document(&graph);
1315 let graph2 = parse_document(&output).expect("roundtrip failed");
1316 assert_eq!(graph2.edges.len(), 1);
1317 let edge2 = &graph2.edges[0];
1318 assert_eq!(edge2.from.as_str(), "box_a");
1319 assert_eq!(edge2.to.as_str(), "box_b");
1320 assert_eq!(edge2.label.as_deref(), Some("next step"));
1321 assert_eq!(edge2.arrow, ArrowKind::End);
1322 }
1323
1324 #[test]
1325 fn roundtrip_edge_styled() {
1326 let input = r#"
1327rect @s1 { w: 50 h: 50 }
1328rect @s2 { w: 50 h: 50 }
1329
1330edge @flow {
1331 from: @s1
1332 to: @s2
1333 stroke: #6C5CE7 2
1334 arrow: both
1335 curve: smooth
1336}
1337"#;
1338 let graph = parse_document(input).unwrap();
1339 assert_eq!(graph.edges.len(), 1);
1340 let edge = &graph.edges[0];
1341 assert_eq!(edge.arrow, ArrowKind::Both);
1342 assert_eq!(edge.curve, CurveKind::Smooth);
1343 assert!(edge.style.stroke.is_some());
1344
1345 let output = emit_document(&graph);
1346 let graph2 = parse_document(&output).expect("styled edge roundtrip failed");
1347 let edge2 = &graph2.edges[0];
1348 assert_eq!(edge2.arrow, ArrowKind::Both);
1349 assert_eq!(edge2.curve, CurveKind::Smooth);
1350 }
1351
1352 #[test]
1353 fn roundtrip_edge_with_annotations() {
1354 let input = r#"
1355rect @login { w: 200 h: 100 }
1356rect @dashboard { w: 200 h: 100 }
1357
1358edge @login_flow {
1359 spec {
1360 "Main authentication flow"
1361 accept: "must redirect within 2s"
1362 }
1363 from: @login
1364 to: @dashboard
1365 label: "on success"
1366 arrow: end
1367}
1368"#;
1369 let graph = parse_document(input).unwrap();
1370 let edge = &graph.edges[0];
1371 assert_eq!(edge.annotations.len(), 2);
1372 assert_eq!(
1373 edge.annotations[0],
1374 Annotation::Description("Main authentication flow".into())
1375 );
1376 assert_eq!(
1377 edge.annotations[1],
1378 Annotation::Accept("must redirect within 2s".into())
1379 );
1380
1381 let output = emit_document(&graph);
1382 let graph2 = parse_document(&output).expect("annotated edge roundtrip failed");
1383 let edge2 = &graph2.edges[0];
1384 assert_eq!(edge2.annotations, edge.annotations);
1385 }
1386
1387 #[test]
1388 fn roundtrip_generic_node() {
1389 let input = r#"
1390@login_btn {
1391 spec {
1392 "Primary CTA — triggers login API call"
1393 accept: "disabled when fields empty"
1394 status: doing
1395 }
1396}
1397"#;
1398 let graph = parse_document(input).unwrap();
1399 let node = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1400 assert!(matches!(node.kind, NodeKind::Generic));
1401 assert_eq!(node.annotations.len(), 3);
1402
1403 let output = emit_document(&graph);
1404 assert!(output.contains("@login_btn {"));
1405 assert!(!output.contains("rect @login_btn"));
1407 assert!(!output.contains("group @login_btn"));
1408
1409 let graph2 = parse_document(&output).expect("re-parse of generic node failed");
1410 let node2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1411 assert!(matches!(node2.kind, NodeKind::Generic));
1412 assert_eq!(node2.annotations, node.annotations);
1413 }
1414
1415 #[test]
1416 fn roundtrip_generic_nested() {
1417 let input = r#"
1418group @form {
1419 layout: column gap=16 pad=32
1420
1421 @email_input {
1422 spec {
1423 "Email field"
1424 accept: "validates format on blur"
1425 }
1426 }
1427
1428 @password_input {
1429 spec {
1430 "Password field"
1431 accept: "min 8 chars"
1432 }
1433 }
1434}
1435"#;
1436 let graph = parse_document(input).unwrap();
1437 let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1438 assert_eq!(graph.children(form_idx).len(), 2);
1439
1440 let email = graph.get_by_id(NodeId::intern("email_input")).unwrap();
1441 assert!(matches!(email.kind, NodeKind::Generic));
1442 assert_eq!(email.annotations.len(), 2);
1443
1444 let output = emit_document(&graph);
1445 let graph2 = parse_document(&output).expect("re-parse of nested generic failed");
1446 let email2 = graph2.get_by_id(NodeId::intern("email_input")).unwrap();
1447 assert!(matches!(email2.kind, NodeKind::Generic));
1448 assert_eq!(email2.annotations, email.annotations);
1449 }
1450
1451 #[test]
1452 fn parse_generic_with_properties() {
1453 let input = r#"
1454@card {
1455 fill: #FFFFFF
1456 corner: 8
1457}
1458"#;
1459 let graph = parse_document(input).unwrap();
1460 let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1461 assert!(matches!(card.kind, NodeKind::Generic));
1462 assert!(card.style.fill.is_some());
1463 assert_eq!(card.style.corner_radius, Some(8.0));
1464 }
1465
1466 #[test]
1467 fn roundtrip_edge_with_trigger_anim() {
1468 let input = r#"
1469rect @a { w: 50 h: 50 }
1470rect @b { w: 50 h: 50 }
1471
1472edge @hover_edge {
1473 from: @a
1474 to: @b
1475 stroke: #6C5CE7 2
1476 arrow: end
1477
1478 anim :hover {
1479 opacity: 0.5
1480 ease: ease_out 200ms
1481 }
1482}
1483"#;
1484 let graph = parse_document(input).unwrap();
1485 assert_eq!(graph.edges.len(), 1);
1486 let edge = &graph.edges[0];
1487 assert_eq!(edge.animations.len(), 1);
1488 assert_eq!(edge.animations[0].trigger, AnimTrigger::Hover);
1489 assert_eq!(edge.animations[0].duration_ms, 200);
1490
1491 let output = emit_document(&graph);
1492 let graph2 = parse_document(&output).expect("trigger anim roundtrip failed");
1493 let edge2 = &graph2.edges[0];
1494 assert_eq!(edge2.animations.len(), 1);
1495 assert_eq!(edge2.animations[0].trigger, AnimTrigger::Hover);
1496 }
1497
1498 #[test]
1499 fn roundtrip_edge_with_flow() {
1500 let input = r#"
1501rect @src { w: 50 h: 50 }
1502rect @dst { w: 50 h: 50 }
1503
1504edge @data {
1505 from: @src
1506 to: @dst
1507 arrow: end
1508 flow: pulse 800ms
1509}
1510"#;
1511 let graph = parse_document(input).unwrap();
1512 let edge = &graph.edges[0];
1513 assert!(edge.flow.is_some());
1514 let flow = edge.flow.unwrap();
1515 assert_eq!(flow.kind, FlowKind::Pulse);
1516 assert_eq!(flow.duration_ms, 800);
1517
1518 let output = emit_document(&graph);
1519 let graph2 = parse_document(&output).expect("flow roundtrip failed");
1520 let edge2 = &graph2.edges[0];
1521 let flow2 = edge2.flow.unwrap();
1522 assert_eq!(flow2.kind, FlowKind::Pulse);
1523 assert_eq!(flow2.duration_ms, 800);
1524 }
1525
1526 #[test]
1527 fn roundtrip_edge_dash_flow() {
1528 let input = r#"
1529rect @x { w: 50 h: 50 }
1530rect @y { w: 50 h: 50 }
1531
1532edge @dashed {
1533 from: @x
1534 to: @y
1535 stroke: #EF4444 1
1536 flow: dash 400ms
1537 arrow: both
1538 curve: step
1539}
1540"#;
1541 let graph = parse_document(input).unwrap();
1542 let edge = &graph.edges[0];
1543 let flow = edge.flow.unwrap();
1544 assert_eq!(flow.kind, FlowKind::Dash);
1545 assert_eq!(flow.duration_ms, 400);
1546 assert_eq!(edge.arrow, ArrowKind::Both);
1547 assert_eq!(edge.curve, CurveKind::Step);
1548
1549 let output = emit_document(&graph);
1550 let graph2 = parse_document(&output).expect("dash flow roundtrip failed");
1551 let edge2 = &graph2.edges[0];
1552 let flow2 = edge2.flow.unwrap();
1553 assert_eq!(flow2.kind, FlowKind::Dash);
1554 assert_eq!(flow2.duration_ms, 400);
1555 }
1556
1557 #[test]
1558 fn test_spec_markdown_basic() {
1559 let input = r#"
1560rect @login_btn {
1561 spec {
1562 "Primary CTA for login"
1563 accept: "disabled when fields empty"
1564 status: doing
1565 priority: high
1566 tag: auth
1567 }
1568 w: 280 h: 48
1569 fill: #6C5CE7
1570}
1571"#;
1572 let graph = parse_document(input).unwrap();
1573 let md = emit_spec_markdown(&graph, "login.fd");
1574
1575 assert!(md.starts_with("# Spec: login.fd\n"));
1576 assert!(md.contains("## @login_btn `rect`"));
1577 assert!(md.contains("> Primary CTA for login"));
1578 assert!(md.contains("- [ ] disabled when fields empty"));
1579 assert!(md.contains("- **Status:** doing"));
1580 assert!(md.contains("- **Priority:** high"));
1581 assert!(md.contains("- **Tag:** auth"));
1582 assert!(!md.contains("280"));
1584 assert!(!md.contains("6C5CE7"));
1585 }
1586
1587 #[test]
1588 fn test_spec_markdown_nested() {
1589 let input = r#"
1590group @form {
1591 layout: column gap=16 pad=32
1592 spec {
1593 "Shipping address form"
1594 accept: "autofill from saved addresses"
1595 }
1596
1597 rect @email {
1598 spec {
1599 "Email input"
1600 accept: "validates email format"
1601 }
1602 w: 280 h: 44
1603 }
1604
1605 rect @no_annotations {
1606 w: 100 h: 50
1607 fill: #CCC
1608 }
1609}
1610"#;
1611 let graph = parse_document(input).unwrap();
1612 let md = emit_spec_markdown(&graph, "checkout.fd");
1613
1614 assert!(md.contains("## @form `group`"));
1615 assert!(md.contains("### @email `rect`"));
1616 assert!(md.contains("> Shipping address form"));
1617 assert!(md.contains("- [ ] autofill from saved addresses"));
1618 assert!(md.contains("- [ ] validates email format"));
1619 assert!(!md.contains("no_annotations"));
1621 }
1622
1623 #[test]
1624 fn test_spec_markdown_with_edges() {
1625 let input = r#"
1626rect @login { w: 200 h: 100 }
1627rect @dashboard {
1628 spec "Main dashboard"
1629 w: 200 h: 100
1630}
1631
1632edge @auth_flow {
1633 spec {
1634 "Authentication flow"
1635 accept: "redirect within 2s"
1636 }
1637 from: @login
1638 to: @dashboard
1639 label: "on success"
1640 arrow: end
1641}
1642"#;
1643 let graph = parse_document(input).unwrap();
1644 let md = emit_spec_markdown(&graph, "flow.fd");
1645
1646 assert!(md.contains("## Flows"));
1647 assert!(md.contains("**@login** → **@dashboard**"));
1648 assert!(md.contains("on success"));
1649 assert!(md.contains("> Authentication flow"));
1650 assert!(md.contains("- [ ] redirect within 2s"));
1651 }
1652
1653 #[test]
1654 fn roundtrip_import_basic() {
1655 let input = "import \"components/buttons.fd\" as btn\nrect @hero { w: 200 h: 100 }\n";
1656 let graph = parse_document(input).unwrap();
1657 assert_eq!(graph.imports.len(), 1);
1658 assert_eq!(graph.imports[0].path, "components/buttons.fd");
1659 assert_eq!(graph.imports[0].namespace, "btn");
1660
1661 let output = emit_document(&graph);
1662 assert!(output.contains("import \"components/buttons.fd\" as btn"));
1663
1664 let graph2 = parse_document(&output).expect("re-parse of import failed");
1665 assert_eq!(graph2.imports.len(), 1);
1666 assert_eq!(graph2.imports[0].path, "components/buttons.fd");
1667 assert_eq!(graph2.imports[0].namespace, "btn");
1668 }
1669
1670 #[test]
1671 fn roundtrip_import_multiple() {
1672 let input = "import \"tokens.fd\" as tokens\nimport \"buttons.fd\" as btn\nrect @box { w: 50 h: 50 }\n";
1673 let graph = parse_document(input).unwrap();
1674 assert_eq!(graph.imports.len(), 2);
1675 assert_eq!(graph.imports[0].namespace, "tokens");
1676 assert_eq!(graph.imports[1].namespace, "btn");
1677
1678 let output = emit_document(&graph);
1679 let graph2 = parse_document(&output).expect("re-parse of multiple imports failed");
1680 assert_eq!(graph2.imports.len(), 2);
1681 assert_eq!(graph2.imports[0].namespace, "tokens");
1682 assert_eq!(graph2.imports[1].namespace, "btn");
1683 }
1684
1685 #[test]
1686 fn parse_import_without_alias_errors() {
1687 let input = "import \"missing_alias.fd\"\nrect @box { w: 50 h: 50 }\n";
1688 let result = parse_document(input);
1690 assert!(result.is_err());
1691 }
1692
1693 #[test]
1694 fn roundtrip_comment_preserved() {
1695 let input = r#"
1697# This is a section header
1698rect @box {
1699 w: 100 h: 50
1700 fill: #FF0000
1701}
1702"#;
1703 let graph = parse_document(input).unwrap();
1704 let output = emit_document(&graph);
1705 assert!(
1706 output.contains("# This is a section header"),
1707 "comment should appear in emitted output: {output}"
1708 );
1709 let graph2 = parse_document(&output).expect("re-parse of commented document failed");
1711 let node = graph2.get_by_id(NodeId::intern("box")).unwrap();
1712 assert_eq!(node.comments, vec!["This is a section header"]);
1713 }
1714
1715 #[test]
1716 fn roundtrip_multiple_comments_preserved() {
1717 let input = r#"
1718# Header section
1719# Subheading
1720rect @panel {
1721 w: 300 h: 200
1722}
1723"#;
1724 let graph = parse_document(input).unwrap();
1725 let output = emit_document(&graph);
1726 let graph2 = parse_document(&output).expect("re-parse failed");
1727 let node = graph2.get_by_id(NodeId::intern("panel")).unwrap();
1728 assert_eq!(node.comments.len(), 2);
1729 assert_eq!(node.comments[0], "Header section");
1730 assert_eq!(node.comments[1], "Subheading");
1731 }
1732
1733 #[test]
1734 fn roundtrip_inline_position() {
1735 let input = r#"
1736rect @placed {
1737 x: 100
1738 y: 200
1739 w: 50 h: 50
1740 fill: #FF0000
1741}
1742"#;
1743 let graph = parse_document(input).unwrap();
1744 let node = graph.get_by_id(NodeId::intern("placed")).unwrap();
1745
1746 assert!(
1748 node.constraints
1749 .iter()
1750 .any(|c| matches!(c, Constraint::Position { .. })),
1751 "should have Position constraint"
1752 );
1753
1754 let output = emit_document(&graph);
1756 assert!(output.contains("x: 100"), "should emit x: inline");
1757 assert!(output.contains("y: 200"), "should emit y: inline");
1758 assert!(
1759 !output.contains("-> absolute"),
1760 "should NOT emit old absolute arrow"
1761 );
1762 assert!(
1763 !output.contains("-> position"),
1764 "should NOT emit position arrow"
1765 );
1766
1767 let graph2 = parse_document(&output).expect("re-parse of inline position failed");
1769 let node2 = graph2.get_by_id(NodeId::intern("placed")).unwrap();
1770 let pos = node2
1771 .constraints
1772 .iter()
1773 .find_map(|c| match c {
1774 Constraint::Position { x, y } => Some((*x, *y)),
1775 _ => None,
1776 })
1777 .expect("Position constraint missing after roundtrip");
1778 assert_eq!(pos, (100.0, 200.0));
1779 }
1780
1781 #[test]
1782 fn emit_children_before_styles() {
1783 let input = r#"
1784rect @box {
1785 w: 200 h: 100
1786 fill: #FF0000
1787 corner: 10
1788 text @label "Hello" {
1789 fill: #FFFFFF
1790 font: "Inter" 600 14
1791 }
1792 anim :hover {
1793 fill: #CC0000
1794 ease: ease_out 200ms
1795 }
1796}
1797"#;
1798 let graph = parse_document(input).unwrap();
1799 let output = emit_document(&graph);
1800
1801 let child_pos = output.find("text @label").expect("child missing");
1803 let fill_pos = output.find("fill: #FF0000").expect("fill missing");
1804 let corner_pos = output.find("corner: 10").expect("corner missing");
1805 let anim_pos = output.find("when :hover").expect("when missing");
1806
1807 assert!(
1808 child_pos < fill_pos,
1809 "children should appear before fill: child_pos={child_pos} fill_pos={fill_pos}"
1810 );
1811 assert!(
1812 child_pos < corner_pos,
1813 "children should appear before corner"
1814 );
1815 assert!(fill_pos < anim_pos, "fill should appear before animations");
1816 }
1817
1818 #[test]
1819 fn emit_section_separators() {
1820 let input = r#"
1821style accent {
1822 fill: #6C5CE7
1823}
1824
1825rect @a {
1826 w: 100 h: 50
1827}
1828
1829rect @b {
1830 w: 100 h: 50
1831}
1832
1833edge @flow {
1834 from: @a
1835 to: @b
1836 arrow: end
1837}
1838
1839@a -> center_in: canvas
1840"#;
1841 let graph = parse_document(input).unwrap();
1842 let output = emit_document(&graph);
1843
1844 assert!(
1845 output.contains("# ─── Themes ───"),
1846 "should have Themes separator"
1847 );
1848 assert!(
1849 output.contains("# ─── Layout ───"),
1850 "should have Layout separator"
1851 );
1852 assert!(
1853 output.contains("# ─── Flows ───"),
1854 "should have Flows separator"
1855 );
1856 }
1857
1858 #[test]
1859 fn roundtrip_children_before_styles() {
1860 let input = r#"
1861group @card {
1862 layout: column gap=12 pad=20
1863 text @title "Dashboard" {
1864 font: "Inter" 600 20
1865 fill: #111111
1866 }
1867 rect @body {
1868 w: 300 h: 200
1869 fill: #F5F5F5
1870 }
1871 fill: #FFFFFF
1872 corner: 8
1873 shadow: (0,2,8,#00000011)
1874}
1875"#;
1876 let graph = parse_document(input).unwrap();
1877 let output = emit_document(&graph);
1878
1879 let graph2 = parse_document(&output).expect("re-parse of reordered output failed");
1881 let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
1882 assert_eq!(
1883 graph2.children(card_idx).len(),
1884 2,
1885 "card should still have 2 children after roundtrip"
1886 );
1887
1888 let child_pos = output.find("text @title").expect("child missing");
1890 let fill_pos = output.find("fill: #FFFFFF").expect("card fill missing");
1891 assert!(
1892 child_pos < fill_pos,
1893 "children should appear before parent fill"
1894 );
1895 }
1896
1897 #[test]
1898 fn roundtrip_theme_keyword() {
1899 let input = r#"
1901theme accent {
1902 fill: #6C5CE7
1903 corner: 12
1904}
1905
1906rect @btn {
1907 w: 120 h: 40
1908 use: accent
1909}
1910"#;
1911 let graph = parse_document(input).unwrap();
1912 let output = emit_document(&graph);
1913
1914 assert!(
1916 output.contains("theme accent"),
1917 "should emit `theme` keyword"
1918 );
1919 assert!(
1920 !output.contains("style accent"),
1921 "should NOT emit `style` keyword"
1922 );
1923
1924 let graph2 = parse_document(&output).expect("re-parse of theme output failed");
1926 assert!(
1927 graph2.styles.contains_key(&NodeId::intern("accent")),
1928 "theme definition should survive roundtrip"
1929 );
1930 }
1931
1932 #[test]
1933 fn roundtrip_when_keyword() {
1934 let input = r#"
1936rect @btn {
1937 w: 120 h: 40
1938 fill: #6C5CE7
1939 when :hover {
1940 fill: #5A4BD1
1941 ease: ease_out 200ms
1942 }
1943}
1944"#;
1945 let graph = parse_document(input).unwrap();
1946 let output = emit_document(&graph);
1947
1948 assert!(output.contains("when :hover"), "should emit `when` keyword");
1950 assert!(
1951 !output.contains("anim :hover"),
1952 "should NOT emit `anim` keyword"
1953 );
1954
1955 let graph2 = parse_document(&output).expect("re-parse of when output failed");
1957 let node = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1958 assert_eq!(
1959 node.animations.len(),
1960 1,
1961 "animation should survive roundtrip"
1962 );
1963 assert_eq!(
1964 node.animations[0].trigger,
1965 AnimTrigger::Hover,
1966 "trigger should be Hover"
1967 );
1968 }
1969
1970 #[test]
1971 fn parse_old_style_keyword_compat() {
1972 let input = r#"
1974style accent {
1975 fill: #6C5CE7
1976}
1977
1978rect @btn {
1979 w: 120 h: 40
1980 use: accent
1981}
1982"#;
1983 let graph = parse_document(input).unwrap();
1984 assert!(
1985 graph.styles.contains_key(&NodeId::intern("accent")),
1986 "old `style` keyword should parse into a theme definition"
1987 );
1988
1989 let output = emit_document(&graph);
1991 assert!(
1992 output.contains("theme accent"),
1993 "emitter should upgrade `style` to `theme`"
1994 );
1995 }
1996
1997 #[test]
1998 fn parse_old_anim_keyword_compat() {
1999 let input = r#"
2001rect @btn {
2002 w: 120 h: 40
2003 fill: #6C5CE7
2004 anim :press {
2005 scale: 0.95
2006 ease: spring 150ms
2007 }
2008}
2009"#;
2010 let graph = parse_document(input).unwrap();
2011 let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2012 assert_eq!(
2013 node.animations.len(),
2014 1,
2015 "old `anim` keyword should parse into animation"
2016 );
2017 assert_eq!(
2018 node.animations[0].trigger,
2019 AnimTrigger::Press,
2020 "trigger should be Press"
2021 );
2022
2023 let output = emit_document(&graph);
2025 assert!(
2026 output.contains("when :press"),
2027 "emitter should upgrade `anim` to `when`"
2028 );
2029 }
2030
2031 #[test]
2032 fn roundtrip_theme_import() {
2033 let input = r#"
2035import "tokens.fd" as tokens
2036
2037theme card_base {
2038 fill: #FFFFFF
2039 corner: 16
2040}
2041
2042rect @card {
2043 w: 300 h: 200
2044 use: card_base
2045}
2046"#;
2047 let graph = parse_document(input).unwrap();
2048 let output = emit_document(&graph);
2049
2050 assert!(
2052 output.contains("import \"tokens.fd\" as tokens"),
2053 "import should survive roundtrip"
2054 );
2055 assert!(
2056 output.contains("theme card_base"),
2057 "theme should survive roundtrip"
2058 );
2059
2060 let graph2 = parse_document(&output).expect("re-parse failed");
2062 assert_eq!(graph2.imports.len(), 1, "import count should survive");
2063 assert!(
2064 graph2.styles.contains_key(&NodeId::intern("card_base")),
2065 "theme def should survive"
2066 );
2067 }
2068
2069 #[test]
2072 fn roundtrip_empty_group() {
2073 let input = "group @empty {\n}\n";
2074 let graph = parse_document(input).unwrap();
2075 let output = emit_document(&graph);
2076 let graph2 = parse_document(&output).expect("re-parse of empty group failed");
2077 let node = graph2.get_by_id(NodeId::intern("empty")).unwrap();
2078 assert!(matches!(node.kind, NodeKind::Group));
2079 }
2080
2081 #[test]
2082 fn roundtrip_deeply_nested_groups() {
2083 let input = r#"
2084group @outer {
2085 group @middle {
2086 group @inner {
2087 rect @leaf {
2088 w: 40 h: 20
2089 fill: #FF0000
2090 }
2091 }
2092 }
2093}
2094"#;
2095 let graph = parse_document(input).unwrap();
2096 let output = emit_document(&graph);
2097 let graph2 = parse_document(&output).expect("re-parse of nested groups failed");
2098 let leaf = graph2.get_by_id(NodeId::intern("leaf")).unwrap();
2099 assert!(matches!(leaf.kind, NodeKind::Rect { .. }));
2100 let inner_idx = graph2.index_of(NodeId::intern("inner")).unwrap();
2102 assert_eq!(graph2.children(inner_idx).len(), 1);
2103 let middle_idx = graph2.index_of(NodeId::intern("middle")).unwrap();
2104 assert_eq!(graph2.children(middle_idx).len(), 1);
2105 }
2106
2107 #[test]
2108 fn roundtrip_unicode_text() {
2109 let input = "text @emoji \"Hello 🎨 café 日本語\" {\n fill: #333333\n}\n";
2110 let graph = parse_document(input).unwrap();
2111 let output = emit_document(&graph);
2112 assert!(
2113 output.contains("Hello 🎨 café 日本語"),
2114 "unicode should survive emit"
2115 );
2116 let graph2 = parse_document(&output).expect("re-parse of unicode failed");
2117 let node = graph2.get_by_id(NodeId::intern("emoji")).unwrap();
2118 match &node.kind {
2119 NodeKind::Text { content } => {
2120 assert!(content.contains("🎨"));
2121 assert!(content.contains("café"));
2122 assert!(content.contains("日本語"));
2123 }
2124 _ => panic!("expected Text node"),
2125 }
2126 }
2127
2128 #[test]
2129 fn roundtrip_spec_all_fields() {
2130 let input = r#"
2131rect @full_spec {
2132 spec {
2133 "Full specification node"
2134 accept: "all fields present"
2135 status: doing
2136 priority: high
2137 tag: mvp, auth
2138 }
2139 w: 100 h: 50
2140}
2141"#;
2142 let graph = parse_document(input).unwrap();
2143 let node = graph.get_by_id(NodeId::intern("full_spec")).unwrap();
2144 assert_eq!(node.annotations.len(), 5, "should have 5 annotations");
2145
2146 let output = emit_document(&graph);
2147 let graph2 = parse_document(&output).expect("re-parse of full spec failed");
2148 let node2 = graph2.get_by_id(NodeId::intern("full_spec")).unwrap();
2149 assert_eq!(node2.annotations.len(), 5);
2150 assert_eq!(node2.annotations, node.annotations);
2151 }
2152
2153 #[test]
2154 fn roundtrip_path_node() {
2155 let input = "path @sketch {\n}\n";
2156 let graph = parse_document(input).unwrap();
2157 let output = emit_document(&graph);
2158 let graph2 = parse_document(&output).expect("re-parse of path failed");
2159 let node = graph2.get_by_id(NodeId::intern("sketch")).unwrap();
2160 assert!(matches!(node.kind, NodeKind::Path { .. }));
2161 }
2162
2163 #[test]
2164 fn roundtrip_gradient_linear() {
2165 let input = r#"
2166rect @grad {
2167 w: 200 h: 100
2168 fill: linear(90deg, #FF0000 0, #0000FF 1)
2169}
2170"#;
2171 let graph = parse_document(input).unwrap();
2172 let node = graph.get_by_id(NodeId::intern("grad")).unwrap();
2173 assert!(matches!(
2174 node.style.fill,
2175 Some(Paint::LinearGradient { .. })
2176 ));
2177
2178 let output = emit_document(&graph);
2179 assert!(output.contains("linear("), "should emit linear gradient");
2180 let graph2 = parse_document(&output).expect("re-parse of linear gradient failed");
2181 let node2 = graph2.get_by_id(NodeId::intern("grad")).unwrap();
2182 assert!(matches!(
2183 node2.style.fill,
2184 Some(Paint::LinearGradient { .. })
2185 ));
2186 }
2187
2188 #[test]
2189 fn roundtrip_gradient_radial() {
2190 let input = r#"
2191rect @radial_box {
2192 w: 100 h: 100
2193 fill: radial(#FFFFFF 0, #000000 1)
2194}
2195"#;
2196 let graph = parse_document(input).unwrap();
2197 let node = graph.get_by_id(NodeId::intern("radial_box")).unwrap();
2198 assert!(matches!(
2199 node.style.fill,
2200 Some(Paint::RadialGradient { .. })
2201 ));
2202
2203 let output = emit_document(&graph);
2204 assert!(output.contains("radial("), "should emit radial gradient");
2205 let graph2 = parse_document(&output).expect("re-parse of radial gradient failed");
2206 let node2 = graph2.get_by_id(NodeId::intern("radial_box")).unwrap();
2207 assert!(matches!(
2208 node2.style.fill,
2209 Some(Paint::RadialGradient { .. })
2210 ));
2211 }
2212
2213 #[test]
2214 fn roundtrip_shadow_property() {
2215 let input = r#"
2216rect @shadowed {
2217 w: 200 h: 100
2218 shadow: (0,4,20,#000000)
2219}
2220"#;
2221 let graph = parse_document(input).unwrap();
2222 let node = graph.get_by_id(NodeId::intern("shadowed")).unwrap();
2223 let shadow = node.style.shadow.as_ref().expect("shadow should exist");
2224 assert_eq!(shadow.blur, 20.0);
2225
2226 let output = emit_document(&graph);
2227 assert!(output.contains("shadow:"), "should emit shadow");
2228 let graph2 = parse_document(&output).expect("re-parse of shadow failed");
2229 let node2 = graph2.get_by_id(NodeId::intern("shadowed")).unwrap();
2230 let shadow2 = node2.style.shadow.as_ref().expect("shadow should survive");
2231 assert_eq!(shadow2.offset_y, 4.0);
2232 assert_eq!(shadow2.blur, 20.0);
2233 }
2234
2235 #[test]
2236 fn roundtrip_opacity() {
2237 let input = r#"
2238rect @faded {
2239 w: 100 h: 100
2240 fill: #6C5CE7
2241 opacity: 0.5
2242}
2243"#;
2244 let graph = parse_document(input).unwrap();
2245 let node = graph.get_by_id(NodeId::intern("faded")).unwrap();
2246 assert_eq!(node.style.opacity, Some(0.5));
2247
2248 let output = emit_document(&graph);
2249 let graph2 = parse_document(&output).expect("re-parse of opacity failed");
2250 let node2 = graph2.get_by_id(NodeId::intern("faded")).unwrap();
2251 assert_eq!(node2.style.opacity, Some(0.5));
2252 }
2253
2254 #[test]
2255 fn roundtrip_clip_frame() {
2256 let input = r#"
2257frame @clipped {
2258 w: 300 h: 200
2259 clip: true
2260 fill: #FFFFFF
2261 corner: 12
2262}
2263"#;
2264 let graph = parse_document(input).unwrap();
2265 let output = emit_document(&graph);
2266 assert!(output.contains("clip: true"), "should emit clip");
2267 let graph2 = parse_document(&output).expect("re-parse of clip frame failed");
2268 let node = graph2.get_by_id(NodeId::intern("clipped")).unwrap();
2269 match &node.kind {
2270 NodeKind::Frame { clip, .. } => assert!(clip, "clip should be true"),
2271 _ => panic!("expected Frame node"),
2272 }
2273 }
2274
2275 #[test]
2276 fn roundtrip_multiple_animations() {
2277 let input = r#"
2278rect @animated {
2279 w: 120 h: 40
2280 fill: #6C5CE7
2281 when :hover {
2282 fill: #5A4BD1
2283 scale: 1.05
2284 ease: ease_out 200ms
2285 }
2286 when :press {
2287 scale: 0.95
2288 ease: spring 150ms
2289 }
2290}
2291"#;
2292 let graph = parse_document(input).unwrap();
2293 let node = graph.get_by_id(NodeId::intern("animated")).unwrap();
2294 assert_eq!(node.animations.len(), 2, "should have 2 animations");
2295
2296 let output = emit_document(&graph);
2297 let graph2 = parse_document(&output).expect("re-parse of multi-anim failed");
2298 let node2 = graph2.get_by_id(NodeId::intern("animated")).unwrap();
2299 assert_eq!(node2.animations.len(), 2);
2300 assert_eq!(node2.animations[0].trigger, AnimTrigger::Hover);
2301 assert_eq!(node2.animations[1].trigger, AnimTrigger::Press);
2302 }
2303
2304 #[test]
2305 fn roundtrip_inline_spec_shorthand() {
2306 let input = r#"
2307rect @btn {
2308 spec "Primary action button"
2309 w: 180 h: 48
2310 fill: #6C5CE7
2311}
2312"#;
2313 let graph = parse_document(input).unwrap();
2314 let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2315 assert_eq!(node.annotations.len(), 1);
2316 assert!(matches!(
2317 &node.annotations[0],
2318 Annotation::Description(d) if d == "Primary action button"
2319 ));
2320
2321 let output = emit_document(&graph);
2322 let graph2 = parse_document(&output).expect("re-parse of inline spec failed");
2323 let node2 = graph2.get_by_id(NodeId::intern("btn")).unwrap();
2324 assert_eq!(node2.annotations, node.annotations);
2325 }
2326
2327 #[test]
2328 fn roundtrip_layout_modes() {
2329 let input = r#"
2330frame @col {
2331 w: 400 h: 300
2332 layout: column gap=16 pad=24
2333 rect @c1 { w: 100 h: 50 }
2334}
2335
2336frame @rw {
2337 w: 400 h: 300
2338 layout: row gap=8 pad=12
2339 rect @r1 { w: 50 h: 50 }
2340}
2341
2342frame @grd {
2343 w: 400 h: 300
2344 layout: grid cols=2 gap=10 pad=20
2345 rect @g1 { w: 80 h: 80 }
2346}
2347"#;
2348 let graph = parse_document(input).unwrap();
2349 let output = emit_document(&graph);
2350 assert!(output.contains("layout: column gap=16 pad=24"));
2351 assert!(output.contains("layout: row gap=8 pad=12"));
2352 assert!(output.contains("layout: grid cols=2 gap=10 pad=20"));
2353
2354 let graph2 = parse_document(&output).expect("re-parse of layout modes failed");
2355 let col = graph2.get_by_id(NodeId::intern("col")).unwrap();
2356 assert!(matches!(col.kind, NodeKind::Frame { .. }));
2357 let rw = graph2.get_by_id(NodeId::intern("rw")).unwrap();
2358 assert!(matches!(rw.kind, NodeKind::Frame { .. }));
2359 let grd = graph2.get_by_id(NodeId::intern("grd")).unwrap();
2360 assert!(matches!(grd.kind, NodeKind::Frame { .. }));
2361 }
2362
2363 fn make_test_graph() -> SceneGraph {
2366 let input = r#"
2368theme accent {
2369 fill: #6C5CE7
2370 font: "Inter" bold 16
2371}
2372
2373frame @container {
2374 w: 600 h: 400
2375 layout: column gap=16 pad=24
2376
2377 rect @card {
2378 w: 200 h: 100
2379 use: accent
2380 fill: #FFFFFF
2381 corner: 12
2382 spec {
2383 "Main card component"
2384 status: done
2385 }
2386 when :hover {
2387 fill: #F0EDFF
2388 scale: 1.05
2389 ease: ease_out 200ms
2390 }
2391 }
2392
2393 text @label "Hello" {
2394 font: "Inter" regular 14
2395 fill: #333333
2396 x: 20
2397 y: 40
2398 }
2399}
2400
2401edge @card_to_label {
2402 from: @card
2403 to: @label
2404 label: "displays"
2405}
2406"#;
2407 parse_document(input).unwrap()
2408 }
2409
2410 #[test]
2411 fn emit_filtered_full_matches_emit_document() {
2412 let graph = make_test_graph();
2413 let full = emit_filtered(&graph, ReadMode::Full);
2414 let doc = emit_document(&graph);
2415 assert_eq!(full, doc, "Full mode should be identical to emit_document");
2416 }
2417
2418 #[test]
2419 fn emit_filtered_structure() {
2420 let graph = make_test_graph();
2421 let out = emit_filtered(&graph, ReadMode::Structure);
2422 assert!(out.contains("frame @container"), "should include frame");
2424 assert!(out.contains("rect @card"), "should include rect");
2425 assert!(out.contains("text @label"), "should include text");
2426 assert!(!out.contains("fill:"), "no fill in structure mode");
2428 assert!(!out.contains("spec"), "no spec in structure mode");
2429 assert!(!out.contains("when"), "no when in structure mode");
2430 assert!(!out.contains("theme"), "no theme in structure mode");
2431 assert!(!out.contains("edge"), "no edges in structure mode");
2432 }
2433
2434 #[test]
2435 fn emit_filtered_layout() {
2436 let graph = make_test_graph();
2437 let out = emit_filtered(&graph, ReadMode::Layout);
2438 assert!(out.contains("layout: column"), "should include layout");
2440 assert!(out.contains("w: 200 h: 100"), "should include dims");
2441 assert!(out.contains("x: 20"), "should include position");
2442 assert!(!out.contains("fill:"), "no fill in layout mode");
2444 assert!(!out.contains("theme"), "no theme in layout mode");
2445 assert!(!out.contains("when :hover"), "no when in layout mode");
2446 }
2447
2448 #[test]
2449 fn emit_filtered_design() {
2450 let graph = make_test_graph();
2451 let out = emit_filtered(&graph, ReadMode::Design);
2452 assert!(out.contains("theme accent"), "should include theme");
2454 assert!(out.contains("use: accent"), "should include use ref");
2455 assert!(out.contains("fill:"), "should include fill");
2456 assert!(out.contains("corner: 12"), "should include corner");
2457 assert!(!out.contains("layout:"), "no layout in design mode");
2459 assert!(!out.contains("w: 200"), "no dims in design mode");
2460 assert!(!out.contains("when :hover"), "no when in design mode");
2461 }
2462
2463 #[test]
2464 fn emit_filtered_spec() {
2465 let graph = make_test_graph();
2466 let out = emit_filtered(&graph, ReadMode::Spec);
2467 assert!(out.contains("spec"), "should include spec");
2469 assert!(out.contains("Main card component"), "should include desc");
2470 assert!(out.contains("status: done"), "should include status");
2471 assert!(!out.contains("fill:"), "no fill in spec mode");
2473 assert!(!out.contains("when"), "no when in spec mode");
2474 }
2475
2476 #[test]
2477 fn emit_filtered_visual() {
2478 let graph = make_test_graph();
2479 let out = emit_filtered(&graph, ReadMode::Visual);
2480 assert!(out.contains("theme accent"), "should include theme");
2482 assert!(out.contains("layout: column"), "should include layout");
2483 assert!(out.contains("w: 200 h: 100"), "should include dims");
2484 assert!(out.contains("fill:"), "should include fill");
2485 assert!(out.contains("corner: 12"), "should include corner");
2486 assert!(out.contains("when :hover"), "should include when");
2487 assert!(out.contains("scale: 1.05"), "should include anim props");
2488 assert!(out.contains("edge @card_to_label"), "should include edges");
2489 assert!(
2491 !out.contains("Main card component"),
2492 "no spec desc in visual mode"
2493 );
2494 }
2495
2496 #[test]
2497 fn emit_filtered_when() {
2498 let graph = make_test_graph();
2499 let out = emit_filtered(&graph, ReadMode::When);
2500 assert!(out.contains("when :hover"), "should include when");
2502 assert!(out.contains("scale: 1.05"), "should include anim props");
2503 assert!(!out.contains("corner:"), "no corner in when mode");
2505 assert!(!out.contains("w: 200"), "no dims in when mode");
2506 assert!(!out.contains("theme"), "no theme in when mode");
2507 assert!(!out.contains("spec"), "no spec in when mode");
2508 }
2509
2510 #[test]
2511 fn emit_filtered_edges() {
2512 let graph = make_test_graph();
2513 let out = emit_filtered(&graph, ReadMode::Edges);
2514 assert!(out.contains("edge @card_to_label"), "should include edge");
2516 assert!(out.contains("from: @card"), "should include from");
2517 assert!(out.contains("to: @label"), "should include to");
2518 assert!(out.contains("label: \"displays\""), "should include label");
2519 assert!(!out.contains("fill:"), "no fill in edges mode");
2521 assert!(!out.contains("when"), "no when in edges mode");
2522 }
2523}