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("# ─── Styles ───\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 let Some(ref defaults) = graph.edge_defaults {
51 emit_edge_defaults_block(&mut out, defaults);
52 out.push('\n');
53 }
54
55 if use_separators && !children.is_empty() {
57 out.push_str("# ─── Layout ───\n\n");
58 }
59 for child_idx in &children {
60 emit_node(&mut out, graph, *child_idx, 0);
61 out.push('\n');
62 }
63
64 if use_separators && has_constraints {
66 out.push_str("# ─── Constraints ───\n\n");
67 }
68 for idx in graph.graph.node_indices() {
69 let node = &graph.graph[idx];
70 for constraint in &node.constraints {
71 if matches!(constraint, Constraint::Position { .. }) {
72 continue; }
74 emit_constraint(&mut out, &node.id, constraint);
75 }
76 }
77
78 if use_separators && has_edges {
80 if has_constraints {
81 out.push('\n');
82 }
83 out.push_str("# ─── Flows ───\n\n");
84 }
85 for edge in &graph.edges {
86 emit_edge(&mut out, edge, graph, graph.edge_defaults.as_ref());
87 }
88
89 out
90}
91
92fn indent(out: &mut String, depth: usize) {
93 for _ in 0..depth {
94 out.push_str(" ");
95 }
96}
97
98fn emit_style_block(out: &mut String, name: &NodeId, style: &Properties, depth: usize) {
99 indent(out, depth);
100 writeln!(out, "style {} {{", name.as_str()).unwrap();
101
102 if let Some(ref fill) = style.fill {
103 emit_paint_prop(out, "fill", fill, depth + 1);
104 }
105 if let Some(ref font) = style.font {
106 emit_font_prop(out, font, depth + 1);
107 }
108 if let Some(radius) = style.corner_radius {
109 indent(out, depth + 1);
110 writeln!(out, "corner: {}", format_num(radius)).unwrap();
111 }
112 if let Some(opacity) = style.opacity {
113 indent(out, depth + 1);
114 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
115 }
116 if let Some(ref shadow) = style.shadow {
117 indent(out, depth + 1);
118 writeln!(
119 out,
120 "shadow: ({},{},{},{})",
121 format_num(shadow.offset_x),
122 format_num(shadow.offset_y),
123 format_num(shadow.blur),
124 shadow.color.to_hex()
125 )
126 .unwrap();
127 }
128 if style.text_align.is_some() || style.text_valign.is_some() {
130 let h = match style.text_align {
131 Some(TextAlign::Left) => "left",
132 Some(TextAlign::Right) => "right",
133 _ => "center",
134 };
135 let v = match style.text_valign {
136 Some(TextVAlign::Top) => "top",
137 Some(TextVAlign::Bottom) => "bottom",
138 _ => "middle",
139 };
140 indent(out, depth + 1);
141 writeln!(out, "align: {h} {v}").unwrap();
142 }
143
144 indent(out, depth);
145 out.push_str("}\n");
146}
147
148fn emit_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, depth: usize) {
149 let node = &graph.graph[idx];
150
151 if matches!(node.kind, NodeKind::Group | NodeKind::Frame { .. })
156 && graph.children(idx).is_empty()
157 && node.annotations.is_empty()
158 && node.use_styles.is_empty()
159 && node.animations.is_empty()
160 && !has_inline_styles(&node.props)
161 && !matches!(&node.kind, NodeKind::Image { .. })
162 {
163 return;
164 }
165
166 for comment in &node.comments {
168 indent(out, depth);
169 writeln!(out, "# {comment}").unwrap();
170 }
171
172 let auto_comment = generate_auto_comment(node, graph, idx);
175 if let Some(comment) = auto_comment {
176 indent(out, depth);
177 writeln!(out, "# [auto] {comment}").unwrap();
178 }
179
180 indent(out, depth);
181
182 match &node.kind {
184 NodeKind::Root => return,
185 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
186 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
187 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
188 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
189 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
190 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
191 NodeKind::Image { .. } => write!(out, "image @{}", node.id.as_str()).unwrap(),
192 NodeKind::Text { content, .. } => {
193 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
194 }
195 }
196
197 out.push_str(" {\n");
198
199 emit_annotations(out, &node.annotations, depth + 1);
201
202 let children = graph.children(idx);
205 for child_idx in &children {
206 emit_node(out, graph, *child_idx, depth + 1);
207 }
208
209 if let NodeKind::Frame { layout, .. } = &node.kind {
213 match layout {
214 LayoutMode::Free { pad } => {
215 if *pad > 0.0 {
216 indent(out, depth + 1);
217 writeln!(out, "padding: {}", format_num(*pad)).unwrap();
218 }
219 }
220 LayoutMode::Column { gap, pad } => {
221 indent(out, depth + 1);
222 writeln!(
223 out,
224 "layout: column gap={} pad={}",
225 format_num(*gap),
226 format_num(*pad)
227 )
228 .unwrap();
229 }
230 LayoutMode::Row { gap, pad } => {
231 indent(out, depth + 1);
232 writeln!(
233 out,
234 "layout: row gap={} pad={}",
235 format_num(*gap),
236 format_num(*pad)
237 )
238 .unwrap();
239 }
240 LayoutMode::Grid { cols, gap, pad } => {
241 indent(out, depth + 1);
242 writeln!(
243 out,
244 "layout: grid cols={cols} gap={} pad={}",
245 format_num(*gap),
246 format_num(*pad)
247 )
248 .unwrap();
249 }
250 }
251 }
252
253 match &node.kind {
255 NodeKind::Rect { width, height } => {
256 indent(out, depth + 1);
257 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
258 }
259 NodeKind::Frame { width, height, .. } => {
260 indent(out, depth + 1);
261 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
262 }
263 NodeKind::Ellipse { rx, ry } => {
264 indent(out, depth + 1);
265 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
266 }
267 NodeKind::Text {
268 max_width: Some(w), ..
269 } => {
270 indent(out, depth + 1);
271 writeln!(out, "w: {}", format_num(*w)).unwrap();
272 }
273 NodeKind::Image { width, height, .. } => {
274 indent(out, depth + 1);
275 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
276 }
277 _ => {}
278 }
279
280 if let NodeKind::Path { commands } = &node.kind
282 && !commands.is_empty()
283 {
284 indent(out, depth + 1);
285 write!(out, "d:").unwrap();
286 for cmd in commands {
287 match cmd {
288 PathCmd::MoveTo(x, y) => {
289 write!(out, " M {} {}", format_num(*x), format_num(*y)).unwrap()
290 }
291 PathCmd::LineTo(x, y) => {
292 write!(out, " L {} {}", format_num(*x), format_num(*y)).unwrap()
293 }
294 PathCmd::QuadTo(cx, cy, ex, ey) => write!(
295 out,
296 " Q {} {} {} {}",
297 format_num(*cx),
298 format_num(*cy),
299 format_num(*ex),
300 format_num(*ey)
301 )
302 .unwrap(),
303 PathCmd::CubicTo(c1x, c1y, c2x, c2y, ex, ey) => write!(
304 out,
305 " C {} {} {} {} {} {}",
306 format_num(*c1x),
307 format_num(*c1y),
308 format_num(*c2x),
309 format_num(*c2y),
310 format_num(*ex),
311 format_num(*ey)
312 )
313 .unwrap(),
314 PathCmd::Close => write!(out, " Z").unwrap(),
315 }
316 }
317 writeln!(out).unwrap();
318 }
319
320 if let NodeKind::Image { source, fit, .. } = &node.kind {
322 match source {
323 ImageSource::File(path) => {
324 indent(out, depth + 1);
325 writeln!(out, "src: \"{path}\"").unwrap();
326 }
327 }
328 if *fit != ImageFit::Cover {
329 indent(out, depth + 1);
330 let fit_str = match fit {
331 ImageFit::Cover => "cover",
332 ImageFit::Contain => "contain",
333 ImageFit::Fill => "fill",
334 ImageFit::None => "none",
335 };
336 writeln!(out, "fit: {fit_str}").unwrap();
337 }
338 }
339
340 if let NodeKind::Frame { clip: true, .. } = &node.kind {
342 indent(out, depth + 1);
343 writeln!(out, "clip: true").unwrap();
344 }
345
346 for style_ref in &node.use_styles {
348 indent(out, depth + 1);
349 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
350 }
351
352 if let Some(ref fill) = node.props.fill {
354 emit_paint_prop(out, "fill", fill, depth + 1);
355 }
356 if let Some(ref stroke) = node.props.stroke {
357 indent(out, depth + 1);
358 match &stroke.paint {
359 Paint::Solid(c) => {
360 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap()
361 }
362 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
363 }
364 }
365 if let Some(radius) = node.props.corner_radius {
366 indent(out, depth + 1);
367 writeln!(out, "corner: {}", format_num(radius)).unwrap();
368 }
369 if let Some(ref font) = node.props.font {
370 emit_font_prop(out, font, depth + 1);
371 }
372 if let Some(opacity) = node.props.opacity {
373 indent(out, depth + 1);
374 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
375 }
376 if let Some(ref shadow) = node.props.shadow {
377 indent(out, depth + 1);
378 writeln!(
379 out,
380 "shadow: ({},{},{},{})",
381 format_num(shadow.offset_x),
382 format_num(shadow.offset_y),
383 format_num(shadow.blur),
384 shadow.color.to_hex()
385 )
386 .unwrap();
387 }
388
389 if node.props.text_align.is_some() || node.props.text_valign.is_some() {
391 let h = match node.props.text_align {
392 Some(TextAlign::Left) => "left",
393 Some(TextAlign::Right) => "right",
394 _ => "center",
395 };
396 let v = match node.props.text_valign {
397 Some(TextVAlign::Top) => "top",
398 Some(TextVAlign::Bottom) => "bottom",
399 _ => "middle",
400 };
401 indent(out, depth + 1);
402 writeln!(out, "align: {h} {v}").unwrap();
403 }
404
405 if let Some((h, v)) = node.place {
407 indent(out, depth + 1);
408 let place_str = match (h, v) {
409 (HPlace::Center, VPlace::Middle) => "center".to_string(),
410 (HPlace::Left, VPlace::Top) => "top-left".to_string(),
411 (HPlace::Center, VPlace::Top) => "top".to_string(),
412 (HPlace::Right, VPlace::Top) => "top-right".to_string(),
413 (HPlace::Left, VPlace::Middle) => "left middle".to_string(),
414 (HPlace::Right, VPlace::Middle) => "right middle".to_string(),
415 (HPlace::Left, VPlace::Bottom) => "bottom-left".to_string(),
416 (HPlace::Center, VPlace::Bottom) => "bottom".to_string(),
417 (HPlace::Right, VPlace::Bottom) => "bottom-right".to_string(),
418 };
419 writeln!(out, "place: {place_str}").unwrap();
420 }
421
422 for constraint in &node.constraints {
424 if let Constraint::Position { x, y } = constraint {
425 if *x != 0.0 {
426 indent(out, depth + 1);
427 writeln!(out, "x: {}", format_num(*x)).unwrap();
428 }
429 if *y != 0.0 {
430 indent(out, depth + 1);
431 writeln!(out, "y: {}", format_num(*y)).unwrap();
432 }
433 }
434 }
435
436 for anim in &node.animations {
438 emit_anim(out, anim, depth + 1);
439 }
440
441 indent(out, depth);
442 out.push_str("}\n");
443}
444
445fn emit_annotations(out: &mut String, annotations: &[Annotation], depth: usize) {
446 if annotations.is_empty() {
447 return;
448 }
449
450 if annotations.len() == 1
452 && let Annotation::Description(s) = &annotations[0]
453 {
454 indent(out, depth);
455 writeln!(out, "spec \"{s}\"").unwrap();
456 return;
457 }
458
459 indent(out, depth);
461 out.push_str("spec {\n");
462 for ann in annotations {
463 indent(out, depth + 1);
464 match ann {
465 Annotation::Description(s) => writeln!(out, "\"{s}\"").unwrap(),
466 Annotation::Accept(s) => writeln!(out, "accept: \"{s}\"").unwrap(),
467 Annotation::Status(s) => writeln!(out, "status: {s}").unwrap(),
468 Annotation::Priority(s) => writeln!(out, "priority: {s}").unwrap(),
469 Annotation::Tag(s) => writeln!(out, "tag: {s}").unwrap(),
470 }
471 }
472 indent(out, depth);
473 out.push_str("}\n");
474}
475
476fn emit_paint_prop(out: &mut String, name: &str, paint: &Paint, depth: usize) {
477 indent(out, depth);
478 match paint {
479 Paint::Solid(c) => {
480 let hex = c.to_hex();
481 let hint = color_hint(&hex);
482 if hint.is_empty() {
483 writeln!(out, "{name}: {hex}").unwrap();
484 } else {
485 writeln!(out, "{name}: {hex} # {hint}").unwrap();
486 }
487 }
488 Paint::LinearGradient { angle, stops } => {
489 write!(out, "{name}: linear({}deg", format_num(*angle)).unwrap();
490 for stop in stops {
491 write!(out, ", {} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
492 }
493 writeln!(out, ")").unwrap();
494 }
495 Paint::RadialGradient { stops } => {
496 write!(out, "{name}: radial(").unwrap();
497 for (i, stop) in stops.iter().enumerate() {
498 if i > 0 {
499 write!(out, ", ").unwrap();
500 }
501 write!(out, "{} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
502 }
503 writeln!(out, ")").unwrap();
504 }
505 }
506}
507
508fn emit_font_prop(out: &mut String, font: &FontSpec, depth: usize) {
509 indent(out, depth);
510 let weight_str = weight_number_to_name(font.weight);
511 writeln!(
512 out,
513 "font: \"{}\" {} {}",
514 font.family,
515 weight_str,
516 format_num(font.size)
517 )
518 .unwrap();
519}
520
521fn weight_number_to_name(weight: u16) -> &'static str {
523 match weight {
524 100 => "thin",
525 200 => "extralight",
526 300 => "light",
527 400 => "regular",
528 500 => "medium",
529 600 => "semibold",
530 700 => "bold",
531 800 => "extrabold",
532 900 => "black",
533 _ => "400", }
535}
536
537fn color_hint(hex: &str) -> &'static str {
539 let hex = hex.trim_start_matches('#');
540 let bytes = hex.as_bytes();
541 let Some((r, g, b)) = (match bytes.len() {
542 3 | 4 => {
543 let r = crate::model::hex_val(bytes[0]).unwrap_or(0) * 17;
544 let g = crate::model::hex_val(bytes[1]).unwrap_or(0) * 17;
545 let b = crate::model::hex_val(bytes[2]).unwrap_or(0) * 17;
546 Some((r, g, b))
547 }
548 6 | 8 => {
549 let r = (crate::model::hex_val(bytes[0]).unwrap_or(0) << 4)
550 | crate::model::hex_val(bytes[1]).unwrap_or(0);
551 let g = (crate::model::hex_val(bytes[2]).unwrap_or(0) << 4)
552 | crate::model::hex_val(bytes[3]).unwrap_or(0);
553 let b = (crate::model::hex_val(bytes[4]).unwrap_or(0) << 4)
554 | crate::model::hex_val(bytes[5]).unwrap_or(0);
555 Some((r, g, b))
556 }
557 _ => None,
558 }) else {
559 return "";
560 };
561
562 let max = r.max(g).max(b);
564 let min = r.min(g).min(b);
565 let diff = max - min;
566 if diff < 15 {
567 return match max {
568 0..=30 => "black",
569 31..=200 => "gray",
570 _ => "white",
571 };
572 }
573
574 let rf = r as f32;
576 let gf = g as f32;
577 let bf = b as f32;
578 let hue = if max == r {
579 60.0 * (((gf - bf) / diff as f32) % 6.0)
580 } else if max == g {
581 60.0 * (((bf - rf) / diff as f32) + 2.0)
582 } else {
583 60.0 * (((rf - gf) / diff as f32) + 4.0)
584 };
585 let hue = if hue < 0.0 { hue + 360.0 } else { hue };
586
587 match hue as u16 {
588 0..=14 | 346..=360 => "red",
589 15..=39 => "orange",
590 40..=64 => "yellow",
591 65..=79 => "lime",
592 80..=159 => "green",
593 160..=179 => "teal",
594 180..=199 => "cyan",
595 200..=259 => "blue",
596 260..=279 => "purple",
597 280..=319 => "pink",
598 320..=345 => "rose",
599 _ => "",
600 }
601}
602
603fn emit_anim(out: &mut String, anim: &AnimKeyframe, depth: usize) {
604 indent(out, depth);
605 let trigger = match &anim.trigger {
606 AnimTrigger::Hover => "hover",
607 AnimTrigger::Press => "press",
608 AnimTrigger::Enter => "enter",
609 AnimTrigger::Custom(s) => s.as_str(),
610 };
611 writeln!(out, "when :{trigger} {{").unwrap();
612
613 if let Some(ref fill) = anim.properties.fill {
614 emit_paint_prop(out, "fill", fill, depth + 1);
615 }
616 if let Some(opacity) = anim.properties.opacity {
617 indent(out, depth + 1);
618 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
619 }
620 if let Some(scale) = anim.properties.scale {
621 indent(out, depth + 1);
622 writeln!(out, "scale: {}", format_num(scale)).unwrap();
623 }
624 if let Some(rotate) = anim.properties.rotate {
625 indent(out, depth + 1);
626 writeln!(out, "rotate: {}", format_num(rotate)).unwrap();
627 }
628
629 let ease_name = match &anim.easing {
630 Easing::Linear => "linear",
631 Easing::EaseIn => "ease_in",
632 Easing::EaseOut => "ease_out",
633 Easing::EaseInOut => "ease_in_out",
634 Easing::Spring => "spring",
635 Easing::CubicBezier(_, _, _, _) => "cubic",
636 };
637 indent(out, depth + 1);
638 writeln!(out, "ease: {ease_name} {}ms", anim.duration_ms).unwrap();
639
640 indent(out, depth);
641 out.push_str("}\n");
642}
643
644fn emit_constraint(out: &mut String, node_id: &NodeId, constraint: &Constraint) {
645 match constraint {
646 Constraint::CenterIn(target) => {
647 writeln!(
648 out,
649 "@{} -> center_in: {}",
650 node_id.as_str(),
651 target.as_str()
652 )
653 .unwrap();
654 }
655 Constraint::Offset { from, dx, dy } => {
656 writeln!(
657 out,
658 "@{} -> offset: @{} {}, {}",
659 node_id.as_str(),
660 from.as_str(),
661 format_num(*dx),
662 format_num(*dy)
663 )
664 .unwrap();
665 }
666 Constraint::FillParent { pad } => {
667 writeln!(
668 out,
669 "@{} -> fill_parent: {}",
670 node_id.as_str(),
671 format_num(*pad)
672 )
673 .unwrap();
674 }
675 Constraint::Position { .. } => {
676 }
678 }
679}
680
681fn emit_edge_defaults_block(out: &mut String, defaults: &EdgeDefaults) {
682 out.push_str("edge_defaults {\n");
683
684 if let Some(ref stroke) = defaults.props.stroke {
685 match &stroke.paint {
686 Paint::Solid(c) => {
687 writeln!(out, " stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
688 }
689 _ => {
690 writeln!(out, " stroke: #000 {}", format_num(stroke.width)).unwrap();
691 }
692 }
693 }
694 if let Some(opacity) = defaults.props.opacity {
695 writeln!(out, " opacity: {}", format_num(opacity)).unwrap();
696 }
697 if let Some(arrow) = defaults.arrow
698 && arrow != ArrowKind::None
699 {
700 let name = match arrow {
701 ArrowKind::None => "none",
702 ArrowKind::Start => "start",
703 ArrowKind::End => "end",
704 ArrowKind::Both => "both",
705 };
706 writeln!(out, " arrow: {name}").unwrap();
707 }
708 if let Some(curve) = defaults.curve
709 && curve != CurveKind::Straight
710 {
711 let name = match curve {
712 CurveKind::Straight => "straight",
713 CurveKind::Smooth => "smooth",
714 CurveKind::Step => "step",
715 };
716 writeln!(out, " curve: {name}").unwrap();
717 }
718
719 out.push_str("}\n");
720}
721
722fn emit_edge(out: &mut String, edge: &Edge, graph: &SceneGraph, defaults: Option<&EdgeDefaults>) {
723 writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
724
725 emit_annotations(out, &edge.annotations, 1);
727
728 if let Some(text_id) = edge.text_child
730 && let Some(node) = graph.get_by_id(text_id)
731 && let NodeKind::Text { content, .. } = &node.kind
732 {
733 writeln!(out, " text @{} \"{}\" {{}}", text_id.as_str(), content).unwrap();
734 }
735
736 match &edge.from {
738 EdgeAnchor::Node(id) => writeln!(out, " from: @{}", id.as_str()).unwrap(),
739 EdgeAnchor::Point(x, y) => {
740 writeln!(out, " from: {} {}", format_num(*x), format_num(*y)).unwrap()
741 }
742 }
743 match &edge.to {
744 EdgeAnchor::Node(id) => writeln!(out, " to: @{}", id.as_str()).unwrap(),
745 EdgeAnchor::Point(x, y) => {
746 writeln!(out, " to: {} {}", format_num(*x), format_num(*y)).unwrap()
747 }
748 }
749
750 for style_ref in &edge.use_styles {
752 writeln!(out, " use: {}", style_ref.as_str()).unwrap();
753 }
754
755 let stroke_matches_default = defaults
757 .and_then(|d| d.props.stroke.as_ref())
758 .is_some_and(|ds| {
759 edge.props
760 .stroke
761 .as_ref()
762 .is_some_and(|es| stroke_eq(es, ds))
763 });
764 if !stroke_matches_default && let Some(ref stroke) = edge.props.stroke {
765 match &stroke.paint {
766 Paint::Solid(c) => {
767 writeln!(out, " stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
768 }
769 _ => {
770 writeln!(out, " stroke: #000 {}", format_num(stroke.width)).unwrap();
771 }
772 }
773 }
774
775 let opacity_matches_default = defaults.and_then(|d| d.props.opacity).is_some_and(|do_| {
777 edge.props
778 .opacity
779 .is_some_and(|eo| (eo - do_).abs() < 0.001)
780 });
781 if !opacity_matches_default && let Some(opacity) = edge.props.opacity {
782 writeln!(out, " opacity: {}", format_num(opacity)).unwrap();
783 }
784
785 let arrow_matches_default = defaults
787 .and_then(|d| d.arrow)
788 .is_some_and(|da| edge.arrow == da);
789 if !arrow_matches_default && edge.arrow != ArrowKind::None {
790 let name = match edge.arrow {
791 ArrowKind::None => "none",
792 ArrowKind::Start => "start",
793 ArrowKind::End => "end",
794 ArrowKind::Both => "both",
795 };
796 writeln!(out, " arrow: {name}").unwrap();
797 }
798
799 let curve_matches_default = defaults
801 .and_then(|d| d.curve)
802 .is_some_and(|dc| edge.curve == dc);
803 if !curve_matches_default && edge.curve != CurveKind::Straight {
804 let name = match edge.curve {
805 CurveKind::Straight => "straight",
806 CurveKind::Smooth => "smooth",
807 CurveKind::Step => "step",
808 };
809 writeln!(out, " curve: {name}").unwrap();
810 }
811
812 if let Some(ref flow) = edge.flow {
814 let kind = match flow.kind {
815 FlowKind::Pulse => "pulse",
816 FlowKind::Dash => "dash",
817 };
818 writeln!(out, " flow: {} {}ms", kind, flow.duration_ms).unwrap();
819 }
820
821 if let Some((ox, oy)) = edge.label_offset {
823 writeln!(out, " label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
824 }
825
826 for anim in &edge.animations {
828 emit_anim(out, anim, 1);
829 }
830
831 out.push_str("}\n");
832}
833
834fn generate_auto_comment(node: &SceneNode, graph: &SceneGraph, idx: NodeIndex) -> Option<String> {
838 match &node.kind {
839 NodeKind::Root => None,
840 NodeKind::Text { .. } => None,
842 NodeKind::Group => {
843 let count = graph.children(idx).len();
844 if count > 0 {
845 Some(format!("container ({count} children)"))
846 } else {
847 None
848 }
849 }
850 NodeKind::Frame { layout, .. } => {
851 let count = graph.children(idx).len();
852 let layout_str = match layout {
853 LayoutMode::Free { .. } => "free",
854 LayoutMode::Column { .. } => "column",
855 LayoutMode::Row { .. } => "row",
856 LayoutMode::Grid { .. } => "grid",
857 };
858 Some(format!("{layout_str} container ({count} children)"))
859 }
860 _ => {
861 if let Some(first_style) = node.use_styles.first() {
863 Some(format!("styled: {}", first_style.as_str()))
864 } else {
865 let edge_target_ids: Vec<NodeId> = graph
867 .edges
868 .iter()
869 .filter(|e| e.from.node_id() == Some(node.id))
870 .filter_map(|e| e.to.node_id())
871 .collect();
872 if !edge_target_ids.is_empty() {
873 let names: Vec<&str> = edge_target_ids.iter().map(|id| id.as_str()).collect();
874 Some(format!("connects to {}", names.join(", ")))
875 } else {
876 None
877 }
878 }
879 }
880 }
881}
882
883fn stroke_eq(a: &Stroke, b: &Stroke) -> bool {
885 (a.width - b.width).abs() < 0.001 && a.paint == b.paint
886}
887
888#[derive(Debug, Clone, Copy, PartialEq, Eq)]
895pub enum ReadMode {
896 Full,
898 Structure,
900 Layout,
902 Design,
904 Spec,
906 Visual,
908 When,
910 Edges,
912 Diff,
915}
916
917#[must_use]
928pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
929 if mode == ReadMode::Full {
930 return emit_document(graph);
931 }
932 if mode == ReadMode::Diff {
933 return String::from("# Use emit_diff(graph, &snapshot) for Diff mode\n");
935 }
936
937 let mut out = String::with_capacity(1024);
938
939 let children = graph.children(graph.root);
940 let include_styles = matches!(mode, ReadMode::Design | ReadMode::Visual);
941 let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
942 let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
943
944 if include_styles && !graph.styles.is_empty() {
946 let mut styles: Vec<_> = graph.styles.iter().collect();
947 styles.sort_by_key(|(id, _)| id.as_str().to_string());
948 for (name, style) in &styles {
949 emit_style_block(&mut out, name, style, 0);
950 out.push('\n');
951 }
952 }
953
954 for child_idx in &children {
956 emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
957 out.push('\n');
958 }
959
960 if include_constraints {
962 for idx in graph.graph.node_indices() {
963 let node = &graph.graph[idx];
964 for constraint in &node.constraints {
965 if matches!(constraint, Constraint::Position { .. }) {
966 continue;
967 }
968 emit_constraint(&mut out, &node.id, constraint);
969 }
970 }
971 }
972
973 if include_edges {
975 for edge in &graph.edges {
976 emit_edge(&mut out, edge, graph, graph.edge_defaults.as_ref());
977 out.push('\n');
978 }
979 }
980
981 out
982}
983
984fn emit_node_filtered(
986 out: &mut String,
987 graph: &SceneGraph,
988 idx: NodeIndex,
989 depth: usize,
990 mode: ReadMode,
991) {
992 let node = &graph.graph[idx];
993
994 if matches!(node.kind, NodeKind::Root) {
995 return;
996 }
997
998 indent(out, depth);
999
1000 match &node.kind {
1002 NodeKind::Root => return,
1003 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
1004 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
1005 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
1006 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
1007 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
1008 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
1009 NodeKind::Image { .. } => write!(out, "image @{}", node.id.as_str()).unwrap(),
1010 NodeKind::Text { content, .. } => {
1011 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
1012 }
1013 }
1014
1015 out.push_str(" {\n");
1016
1017 if mode == ReadMode::Spec {
1019 emit_annotations(out, &node.annotations, depth + 1);
1020 }
1021
1022 let children = graph.children(idx);
1024 for child_idx in &children {
1025 emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
1026 }
1027
1028 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1030 emit_layout_mode_filtered(out, &node.kind, depth + 1);
1031 }
1032
1033 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1035 emit_dimensions_filtered(out, &node.kind, depth + 1);
1036 }
1037
1038 if matches!(mode, ReadMode::Design | ReadMode::Visual) {
1040 for style_ref in &node.use_styles {
1041 indent(out, depth + 1);
1042 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
1043 }
1044 if let Some(ref fill) = node.props.fill {
1045 emit_paint_prop(out, "fill", fill, depth + 1);
1046 }
1047 if let Some(ref stroke) = node.props.stroke {
1048 indent(out, depth + 1);
1049 match &stroke.paint {
1050 Paint::Solid(c) => {
1051 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
1052 }
1053 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
1054 }
1055 }
1056 if let Some(radius) = node.props.corner_radius {
1057 indent(out, depth + 1);
1058 writeln!(out, "corner: {}", format_num(radius)).unwrap();
1059 }
1060 if let Some(ref font) = node.props.font {
1061 emit_font_prop(out, font, depth + 1);
1062 }
1063 if let Some(opacity) = node.props.opacity {
1064 indent(out, depth + 1);
1065 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
1066 }
1067 }
1068
1069 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1071 for constraint in &node.constraints {
1072 if let Constraint::Position { x, y } = constraint {
1073 if *x != 0.0 {
1074 indent(out, depth + 1);
1075 writeln!(out, "x: {}", format_num(*x)).unwrap();
1076 }
1077 if *y != 0.0 {
1078 indent(out, depth + 1);
1079 writeln!(out, "y: {}", format_num(*y)).unwrap();
1080 }
1081 }
1082 }
1083 }
1084
1085 if matches!(mode, ReadMode::When | ReadMode::Visual) {
1087 for anim in &node.animations {
1088 emit_anim(out, anim, depth + 1);
1089 }
1090 }
1091
1092 indent(out, depth);
1093 out.push_str("}\n");
1094}
1095
1096fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1098 let layout = match kind {
1099 NodeKind::Frame { layout, .. } => layout,
1100 _ => return, };
1102 match layout {
1103 LayoutMode::Free { pad } => {
1104 if *pad > 0.0 {
1105 indent(out, depth);
1106 writeln!(out, "padding: {}", format_num(*pad)).unwrap();
1107 }
1108 }
1109 LayoutMode::Column { gap, pad } => {
1110 indent(out, depth);
1111 writeln!(
1112 out,
1113 "layout: column gap={} pad={}",
1114 format_num(*gap),
1115 format_num(*pad)
1116 )
1117 .unwrap();
1118 }
1119 LayoutMode::Row { gap, pad } => {
1120 indent(out, depth);
1121 writeln!(
1122 out,
1123 "layout: row gap={} pad={}",
1124 format_num(*gap),
1125 format_num(*pad)
1126 )
1127 .unwrap();
1128 }
1129 LayoutMode::Grid { cols, gap, pad } => {
1130 indent(out, depth);
1131 writeln!(
1132 out,
1133 "layout: grid cols={cols} gap={} pad={}",
1134 format_num(*gap),
1135 format_num(*pad)
1136 )
1137 .unwrap();
1138 }
1139 }
1140}
1141
1142fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1144 match kind {
1145 NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
1146 indent(out, depth);
1147 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1148 }
1149 NodeKind::Ellipse { rx, ry } => {
1150 indent(out, depth);
1151 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
1152 }
1153 NodeKind::Image { width, height, .. } => {
1154 indent(out, depth);
1155 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1156 }
1157 _ => {}
1158 }
1159}
1160
1161#[must_use]
1169pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
1170 let mut out = String::with_capacity(512);
1171 writeln!(out, "# Spec: {title}\n").unwrap();
1172
1173 let children = graph.children(graph.root);
1175 for child_idx in &children {
1176 emit_spec_node(&mut out, graph, *child_idx, 2);
1177 }
1178
1179 if !graph.edges.is_empty() {
1181 out.push_str("\n---\n\n## Flows\n\n");
1182 for edge in &graph.edges {
1183 let from_str = match &edge.from {
1184 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1185 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1186 };
1187 let to_str = match &edge.to {
1188 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1189 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1190 };
1191 write!(out, "- **{}** → **{}**", from_str, to_str).unwrap();
1192 if let Some(text_id) = edge.text_child
1193 && let Some(node) = graph.get_by_id(text_id)
1194 && let NodeKind::Text { content, .. } = &node.kind
1195 {
1196 write!(out, " — {content}").unwrap();
1197 }
1198 out.push('\n');
1199 emit_spec_annotations(&mut out, &edge.annotations, " ");
1200 }
1201 }
1202
1203 out
1204}
1205
1206fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
1207 let node = &graph.graph[idx];
1208
1209 let has_annotations = !node.annotations.is_empty();
1211 let children = graph.children(idx);
1212 let has_annotated_children = children
1213 .iter()
1214 .any(|c| has_annotations_recursive(graph, *c));
1215
1216 if !has_annotations && !has_annotated_children {
1217 return;
1218 }
1219
1220 let hashes = "#".repeat(heading_level.min(6));
1222 let kind_label = match &node.kind {
1223 NodeKind::Root => return,
1224 NodeKind::Generic => "spec",
1225 NodeKind::Group => "group",
1226 NodeKind::Frame { .. } => "frame",
1227 NodeKind::Rect { .. } => "rect",
1228 NodeKind::Ellipse { .. } => "ellipse",
1229 NodeKind::Path { .. } => "path",
1230 NodeKind::Image { .. } => "image",
1231 NodeKind::Text { .. } => "text",
1232 };
1233 writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
1234
1235 emit_spec_annotations(out, &node.annotations, "");
1237
1238 for child_idx in &children {
1240 emit_spec_node(out, graph, *child_idx, heading_level + 1);
1241 }
1242}
1243
1244fn has_annotations_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
1245 let node = &graph.graph[idx];
1246 if !node.annotations.is_empty() {
1247 return true;
1248 }
1249 graph
1250 .children(idx)
1251 .iter()
1252 .any(|c| has_annotations_recursive(graph, *c))
1253}
1254
1255fn emit_spec_annotations(out: &mut String, annotations: &[Annotation], prefix: &str) {
1256 for ann in annotations {
1257 match ann {
1258 Annotation::Description(s) => writeln!(out, "{prefix}> {s}").unwrap(),
1259 Annotation::Accept(s) => writeln!(out, "{prefix}- [ ] {s}").unwrap(),
1260 Annotation::Status(s) => writeln!(out, "{prefix}- **Status:** {s}").unwrap(),
1261 Annotation::Priority(s) => writeln!(out, "{prefix}- **Priority:** {s}").unwrap(),
1262 Annotation::Tag(s) => writeln!(out, "{prefix}- **Tag:** {s}").unwrap(),
1263 }
1264 }
1265 if !annotations.is_empty() {
1266 out.push('\n');
1267 }
1268}
1269
1270fn has_inline_styles(style: &Properties) -> bool {
1272 style.fill.is_some()
1273 || style.stroke.is_some()
1274 || style.font.is_some()
1275 || style.corner_radius.is_some()
1276 || style.opacity.is_some()
1277 || style.shadow.is_some()
1278 || style.text_align.is_some()
1279 || style.text_valign.is_some()
1280 || style.scale.is_some()
1281}
1282
1283pub(crate) fn format_num(n: f32) -> String {
1285 if n == n.floor() {
1286 format!("{}", n as i32)
1287 } else {
1288 format!("{n:.1}")
1289 .trim_end_matches('0')
1290 .trim_end_matches('.')
1291 .to_string()
1292 }
1293}
1294
1295use std::collections::hash_map::DefaultHasher;
1298use std::hash::{Hash, Hasher};
1299
1300#[must_use]
1306pub fn snapshot_graph(graph: &SceneGraph) -> GraphSnapshot {
1307 let mut snapshot = GraphSnapshot::default();
1308
1309 for idx in graph.graph.node_indices() {
1311 let node = &graph.graph[idx];
1312 if matches!(node.kind, NodeKind::Root) {
1313 continue;
1314 }
1315 let mut buf = String::new();
1316 emit_node(&mut buf, graph, idx, 0);
1317 let mut hasher = DefaultHasher::new();
1318 buf.hash(&mut hasher);
1319 snapshot.node_hashes.insert(node.id, hasher.finish());
1320 }
1321
1322 for edge in &graph.edges {
1324 let mut buf = String::new();
1325 emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1326 let mut hasher = DefaultHasher::new();
1327 buf.hash(&mut hasher);
1328 snapshot.edge_hashes.insert(edge.id, hasher.finish());
1329 }
1330
1331 snapshot
1332}
1333
1334#[must_use]
1341pub fn emit_diff(graph: &SceneGraph, prev: &GraphSnapshot) -> String {
1342 let mut out = String::with_capacity(512);
1343
1344 for idx in graph.graph.node_indices() {
1346 let node = &graph.graph[idx];
1347 if matches!(node.kind, NodeKind::Root) {
1348 continue;
1349 }
1350
1351 let mut buf = String::new();
1352 emit_node(&mut buf, graph, idx, 0);
1353 let mut hasher = DefaultHasher::new();
1354 buf.hash(&mut hasher);
1355 let current_hash = hasher.finish();
1356
1357 match prev.node_hashes.get(&node.id) {
1358 None => {
1359 out.push_str("+ ");
1361 out.push_str(&buf);
1362 out.push('\n');
1363 }
1364 Some(&prev_hash) if prev_hash != current_hash => {
1365 out.push_str("~ ");
1367 out.push_str(&buf);
1368 out.push('\n');
1369 }
1370 _ => {} }
1372 }
1373
1374 for edge in &graph.edges {
1376 let mut buf = String::new();
1377 emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1378 let mut hasher = DefaultHasher::new();
1379 buf.hash(&mut hasher);
1380 let current_hash = hasher.finish();
1381
1382 match prev.edge_hashes.get(&edge.id) {
1383 None => {
1384 out.push_str("+ ");
1385 out.push_str(&buf);
1386 out.push('\n');
1387 }
1388 Some(&prev_hash) if prev_hash != current_hash => {
1389 out.push_str("~ ");
1390 out.push_str(&buf);
1391 out.push('\n');
1392 }
1393 _ => {}
1394 }
1395 }
1396
1397 for id in prev.node_hashes.keys() {
1399 if graph.get_by_id(*id).is_none() {
1400 writeln!(out, "- @{}", id.as_str()).unwrap();
1401 }
1402 }
1403
1404 let current_edge_ids: std::collections::HashSet<NodeId> =
1406 graph.edges.iter().map(|e| e.id).collect();
1407 for id in prev.edge_hashes.keys() {
1408 if !current_edge_ids.contains(id) {
1409 writeln!(out, "- edge @{}", id.as_str()).unwrap();
1410 }
1411 }
1412
1413 if out.is_empty() {
1414 out.push_str("# No changes\n");
1415 }
1416
1417 out
1418}
1419
1420#[cfg(test)]
1421#[path = "emitter_tests.rs"]
1422mod tests;