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.spec.is_none()
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_spec(out, &node.spec, 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 if node.locked {
424 indent(out, depth + 1);
425 writeln!(out, "locked: true").unwrap();
426 }
427
428 for constraint in &node.constraints {
430 if let Constraint::Position { x, y } = constraint {
431 if *x != 0.0 {
432 indent(out, depth + 1);
433 writeln!(out, "x: {}", format_num(*x)).unwrap();
434 }
435 if *y != 0.0 {
436 indent(out, depth + 1);
437 writeln!(out, "y: {}", format_num(*y)).unwrap();
438 }
439 }
440 }
441
442 for anim in &node.animations {
444 emit_anim(out, anim, depth + 1);
445 }
446
447 indent(out, depth);
448 out.push_str("}\n");
449}
450
451fn emit_spec(out: &mut String, spec: &Option<String>, depth: usize) {
452 let content = match spec {
453 Some(s) if !s.is_empty() => s,
454 _ => return,
455 };
456
457 if !content.contains('\n') {
459 indent(out, depth);
460 writeln!(out, "spec \"{content}\"").unwrap();
461 return;
462 }
463
464 indent(out, depth);
466 out.push_str("spec {\n");
467 for line in content.lines() {
468 indent(out, depth + 1);
469 out.push_str(line);
470 out.push('\n');
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 if let Some(delay) = anim.delay_ms {
641 indent(out, depth + 1);
642 writeln!(out, "delay: {delay}ms").unwrap();
643 }
644
645 indent(out, depth);
646 out.push_str("}\n");
647}
648
649fn emit_constraint(out: &mut String, node_id: &NodeId, constraint: &Constraint) {
650 match constraint {
651 Constraint::CenterIn(target) => {
652 writeln!(
653 out,
654 "@{} -> center_in: {}",
655 node_id.as_str(),
656 target.as_str()
657 )
658 .unwrap();
659 }
660 Constraint::Offset { from, dx, dy } => {
661 writeln!(
662 out,
663 "@{} -> offset: @{} {}, {}",
664 node_id.as_str(),
665 from.as_str(),
666 format_num(*dx),
667 format_num(*dy)
668 )
669 .unwrap();
670 }
671 Constraint::FillParent { pad } => {
672 writeln!(
673 out,
674 "@{} -> fill_parent: {}",
675 node_id.as_str(),
676 format_num(*pad)
677 )
678 .unwrap();
679 }
680 Constraint::Position { .. } => {
681 }
683 }
684}
685
686fn emit_edge_defaults_block(out: &mut String, defaults: &EdgeDefaults) {
687 out.push_str("edge_defaults {\n");
688
689 if let Some(ref stroke) = defaults.props.stroke {
690 match &stroke.paint {
691 Paint::Solid(c) => {
692 writeln!(out, " stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
693 }
694 _ => {
695 writeln!(out, " stroke: #000 {}", format_num(stroke.width)).unwrap();
696 }
697 }
698 }
699 if let Some(opacity) = defaults.props.opacity {
700 writeln!(out, " opacity: {}", format_num(opacity)).unwrap();
701 }
702 if let Some(arrow) = defaults.arrow
703 && arrow != ArrowKind::None
704 {
705 let name = match arrow {
706 ArrowKind::None => "none",
707 ArrowKind::Start => "start",
708 ArrowKind::End => "end",
709 ArrowKind::Both => "both",
710 };
711 writeln!(out, " arrow: {name}").unwrap();
712 }
713 if let Some(curve) = defaults.curve
714 && curve != CurveKind::Straight
715 {
716 let name = match curve {
717 CurveKind::Straight => "straight",
718 CurveKind::Smooth => "smooth",
719 CurveKind::Step => "step",
720 };
721 writeln!(out, " curve: {name}").unwrap();
722 }
723
724 out.push_str("}\n");
725}
726
727fn emit_edge(out: &mut String, edge: &Edge, graph: &SceneGraph, defaults: Option<&EdgeDefaults>) {
728 writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
729
730 emit_spec(out, &edge.spec, 1);
732
733 if let Some(text_id) = edge.text_child
735 && let Some(node) = graph.get_by_id(text_id)
736 && let NodeKind::Text { content, .. } = &node.kind
737 {
738 writeln!(out, " text @{} \"{}\" {{}}", text_id.as_str(), content).unwrap();
739 }
740
741 match &edge.from {
743 EdgeAnchor::Node(id) => writeln!(out, " from: @{}", id.as_str()).unwrap(),
744 EdgeAnchor::Point(x, y) => {
745 writeln!(out, " from: {} {}", format_num(*x), format_num(*y)).unwrap()
746 }
747 }
748 match &edge.to {
749 EdgeAnchor::Node(id) => writeln!(out, " to: @{}", id.as_str()).unwrap(),
750 EdgeAnchor::Point(x, y) => {
751 writeln!(out, " to: {} {}", format_num(*x), format_num(*y)).unwrap()
752 }
753 }
754
755 for style_ref in &edge.use_styles {
757 writeln!(out, " use: {}", style_ref.as_str()).unwrap();
758 }
759
760 let stroke_matches_default = defaults
762 .and_then(|d| d.props.stroke.as_ref())
763 .is_some_and(|ds| {
764 edge.props
765 .stroke
766 .as_ref()
767 .is_some_and(|es| stroke_eq(es, ds))
768 });
769 if !stroke_matches_default && let Some(ref stroke) = edge.props.stroke {
770 match &stroke.paint {
771 Paint::Solid(c) => {
772 writeln!(out, " stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
773 }
774 _ => {
775 writeln!(out, " stroke: #000 {}", format_num(stroke.width)).unwrap();
776 }
777 }
778 }
779
780 let opacity_matches_default = defaults.and_then(|d| d.props.opacity).is_some_and(|do_| {
782 edge.props
783 .opacity
784 .is_some_and(|eo| (eo - do_).abs() < 0.001)
785 });
786 if !opacity_matches_default && let Some(opacity) = edge.props.opacity {
787 writeln!(out, " opacity: {}", format_num(opacity)).unwrap();
788 }
789
790 let arrow_matches_default = defaults
792 .and_then(|d| d.arrow)
793 .is_some_and(|da| edge.arrow == da);
794 if !arrow_matches_default && edge.arrow != ArrowKind::None {
795 let name = match edge.arrow {
796 ArrowKind::None => "none",
797 ArrowKind::Start => "start",
798 ArrowKind::End => "end",
799 ArrowKind::Both => "both",
800 };
801 writeln!(out, " arrow: {name}").unwrap();
802 }
803
804 let curve_matches_default = defaults
806 .and_then(|d| d.curve)
807 .is_some_and(|dc| edge.curve == dc);
808 if !curve_matches_default && edge.curve != CurveKind::Straight {
809 let name = match edge.curve {
810 CurveKind::Straight => "straight",
811 CurveKind::Smooth => "smooth",
812 CurveKind::Step => "step",
813 };
814 writeln!(out, " curve: {name}").unwrap();
815 }
816
817 if let Some(ref flow) = edge.flow {
819 let kind = match flow.kind {
820 FlowKind::Pulse => "pulse",
821 FlowKind::Dash => "dash",
822 };
823 writeln!(out, " flow: {} {}ms", kind, flow.duration_ms).unwrap();
824 }
825
826 if let Some((ox, oy)) = edge.label_offset {
828 writeln!(out, " label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
829 }
830
831 for anim in &edge.animations {
833 emit_anim(out, anim, 1);
834 }
835
836 out.push_str("}\n");
837}
838
839pub fn emit_node_standalone(
844 out: &mut String,
845 graph: &SceneGraph,
846 idx: NodeIndex,
847 _bounds: &std::collections::HashMap<NodeIndex, ResolvedBounds>,
848) {
849 emit_node(out, graph, idx, 0);
850}
851
852pub fn emit_edge_standalone(
856 out: &mut String,
857 edge: &Edge,
858 graph: &SceneGraph,
859 defaults: Option<&EdgeDefaults>,
860) {
861 emit_edge(out, edge, graph, defaults);
862}
863
864fn generate_auto_comment(node: &SceneNode, graph: &SceneGraph, idx: NodeIndex) -> Option<String> {
868 match &node.kind {
869 NodeKind::Root => None,
870 NodeKind::Text { .. } => None,
872 NodeKind::Group => {
873 let count = graph.children(idx).len();
874 if count > 0 {
875 Some(format!("container ({count} children)"))
876 } else {
877 None
878 }
879 }
880 NodeKind::Frame { layout, .. } => {
881 let count = graph.children(idx).len();
882 let layout_str = match layout {
883 LayoutMode::Free { .. } => "free",
884 LayoutMode::Column { .. } => "column",
885 LayoutMode::Row { .. } => "row",
886 LayoutMode::Grid { .. } => "grid",
887 };
888 Some(format!("{layout_str} container ({count} children)"))
889 }
890 _ => {
891 if let Some(first_style) = node.use_styles.first() {
893 Some(format!("styled: {}", first_style.as_str()))
894 } else {
895 let edge_target_ids: Vec<NodeId> = graph
897 .edges
898 .iter()
899 .filter(|e| e.from.node_id() == Some(node.id))
900 .filter_map(|e| e.to.node_id())
901 .collect();
902 if !edge_target_ids.is_empty() {
903 let names: Vec<&str> = edge_target_ids.iter().map(|id| id.as_str()).collect();
904 Some(format!("connects to {}", names.join(", ")))
905 } else {
906 None
907 }
908 }
909 }
910 }
911}
912
913fn stroke_eq(a: &Stroke, b: &Stroke) -> bool {
915 (a.width - b.width).abs() < 0.001 && a.paint == b.paint
916}
917
918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
925pub enum ReadMode {
926 Full,
928 Structure,
930 Layout,
932 Design,
934 Spec,
936 Notes,
938 Visual,
940 When,
942 Edges,
944 Diff,
947}
948
949#[must_use]
960pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
961 if mode == ReadMode::Full {
962 return emit_document(graph);
963 }
964 let mode = if mode == ReadMode::Notes {
966 ReadMode::Spec
967 } else {
968 mode
969 };
970 if mode == ReadMode::Diff {
971 return String::from("# Use emit_diff(graph, &snapshot) for Diff mode\n");
973 }
974
975 let mut out = String::with_capacity(1024);
976
977 let children = graph.children(graph.root);
978 let include_styles = matches!(mode, ReadMode::Design | ReadMode::Visual);
979 let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
980 let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
981
982 if include_styles && !graph.styles.is_empty() {
984 let mut styles: Vec<_> = graph.styles.iter().collect();
985 styles.sort_by_key(|(id, _)| id.as_str().to_string());
986 for (name, style) in &styles {
987 emit_style_block(&mut out, name, style, 0);
988 out.push('\n');
989 }
990 }
991
992 for child_idx in &children {
994 emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
995 out.push('\n');
996 }
997
998 if include_constraints {
1000 for idx in graph.graph.node_indices() {
1001 let node = &graph.graph[idx];
1002 for constraint in &node.constraints {
1003 if matches!(constraint, Constraint::Position { .. }) {
1004 continue;
1005 }
1006 emit_constraint(&mut out, &node.id, constraint);
1007 }
1008 }
1009 }
1010
1011 if include_edges {
1013 for edge in &graph.edges {
1014 emit_edge(&mut out, edge, graph, graph.edge_defaults.as_ref());
1015 out.push('\n');
1016 }
1017 }
1018
1019 out
1020}
1021
1022fn emit_node_filtered(
1024 out: &mut String,
1025 graph: &SceneGraph,
1026 idx: NodeIndex,
1027 depth: usize,
1028 mode: ReadMode,
1029) {
1030 let node = &graph.graph[idx];
1031
1032 if matches!(node.kind, NodeKind::Root) {
1033 return;
1034 }
1035
1036 indent(out, depth);
1037
1038 match &node.kind {
1040 NodeKind::Root => return,
1041 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
1042 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
1043 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
1044 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
1045 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
1046 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
1047 NodeKind::Image { .. } => write!(out, "image @{}", node.id.as_str()).unwrap(),
1048 NodeKind::Text { content, .. } => {
1049 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
1050 }
1051 }
1052
1053 out.push_str(" {\n");
1054
1055 if mode == ReadMode::Spec {
1057 emit_spec(out, &node.spec, depth + 1);
1058 }
1059
1060 let children = graph.children(idx);
1062 for child_idx in &children {
1063 emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
1064 }
1065
1066 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1068 emit_layout_mode_filtered(out, &node.kind, depth + 1);
1069 }
1070
1071 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1073 emit_dimensions_filtered(out, &node.kind, depth + 1);
1074 }
1075
1076 if matches!(mode, ReadMode::Design | ReadMode::Visual) {
1078 for style_ref in &node.use_styles {
1079 indent(out, depth + 1);
1080 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
1081 }
1082 if let Some(ref fill) = node.props.fill {
1083 emit_paint_prop(out, "fill", fill, depth + 1);
1084 }
1085 if let Some(ref stroke) = node.props.stroke {
1086 indent(out, depth + 1);
1087 match &stroke.paint {
1088 Paint::Solid(c) => {
1089 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
1090 }
1091 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
1092 }
1093 }
1094 if let Some(radius) = node.props.corner_radius {
1095 indent(out, depth + 1);
1096 writeln!(out, "corner: {}", format_num(radius)).unwrap();
1097 }
1098 if let Some(ref font) = node.props.font {
1099 emit_font_prop(out, font, depth + 1);
1100 }
1101 if let Some(opacity) = node.props.opacity {
1102 indent(out, depth + 1);
1103 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
1104 }
1105 }
1106
1107 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
1109 for constraint in &node.constraints {
1110 if let Constraint::Position { x, y } = constraint {
1111 if *x != 0.0 {
1112 indent(out, depth + 1);
1113 writeln!(out, "x: {}", format_num(*x)).unwrap();
1114 }
1115 if *y != 0.0 {
1116 indent(out, depth + 1);
1117 writeln!(out, "y: {}", format_num(*y)).unwrap();
1118 }
1119 }
1120 }
1121 }
1122
1123 if matches!(mode, ReadMode::When | ReadMode::Visual) {
1125 for anim in &node.animations {
1126 emit_anim(out, anim, depth + 1);
1127 }
1128 }
1129
1130 indent(out, depth);
1131 out.push_str("}\n");
1132}
1133
1134fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1136 let layout = match kind {
1137 NodeKind::Frame { layout, .. } => layout,
1138 _ => return, };
1140 match layout {
1141 LayoutMode::Free { pad } => {
1142 if *pad > 0.0 {
1143 indent(out, depth);
1144 writeln!(out, "padding: {}", format_num(*pad)).unwrap();
1145 }
1146 }
1147 LayoutMode::Column { gap, pad } => {
1148 indent(out, depth);
1149 writeln!(
1150 out,
1151 "layout: column gap={} pad={}",
1152 format_num(*gap),
1153 format_num(*pad)
1154 )
1155 .unwrap();
1156 }
1157 LayoutMode::Row { gap, pad } => {
1158 indent(out, depth);
1159 writeln!(
1160 out,
1161 "layout: row gap={} pad={}",
1162 format_num(*gap),
1163 format_num(*pad)
1164 )
1165 .unwrap();
1166 }
1167 LayoutMode::Grid { cols, gap, pad } => {
1168 indent(out, depth);
1169 writeln!(
1170 out,
1171 "layout: grid cols={cols} gap={} pad={}",
1172 format_num(*gap),
1173 format_num(*pad)
1174 )
1175 .unwrap();
1176 }
1177 }
1178}
1179
1180fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
1182 match kind {
1183 NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
1184 indent(out, depth);
1185 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1186 }
1187 NodeKind::Ellipse { rx, ry } => {
1188 indent(out, depth);
1189 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
1190 }
1191 NodeKind::Image { width, height, .. } => {
1192 indent(out, depth);
1193 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
1194 }
1195 _ => {}
1196 }
1197}
1198
1199#[must_use]
1204pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
1205 let mut out = String::with_capacity(512);
1206 writeln!(out, "# Spec: {title}\n").unwrap();
1207
1208 let children = graph.children(graph.root);
1210 for child_idx in &children {
1211 emit_spec_node(&mut out, graph, *child_idx, 2);
1212 }
1213
1214 if !graph.edges.is_empty() {
1216 out.push_str("\n---\n\n## Flows\n\n");
1217 for edge in &graph.edges {
1218 let from_str = match &edge.from {
1219 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1220 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1221 };
1222 let to_str = match &edge.to {
1223 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
1224 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
1225 };
1226 write!(out, "- **{}** → **{}**", from_str, to_str).unwrap();
1227 if let Some(text_id) = edge.text_child
1228 && let Some(node) = graph.get_by_id(text_id)
1229 && let NodeKind::Text { content, .. } = &node.kind
1230 {
1231 write!(out, " — {content}").unwrap();
1232 }
1233 out.push('\n');
1234 if let Some(spec) = &edge.spec {
1236 for line in spec.lines() {
1237 writeln!(out, " {line}").unwrap();
1238 }
1239 out.push('\n');
1240 }
1241 }
1242 }
1243
1244 out
1245}
1246
1247fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
1248 let node = &graph.graph[idx];
1249
1250 let has_spec = node.spec.is_some();
1252 let children = graph.children(idx);
1253 let has_spec_children = children.iter().any(|c| has_spec_recursive(graph, *c));
1254
1255 if !has_spec && !has_spec_children {
1256 return;
1257 }
1258
1259 let hashes = "#".repeat(heading_level.min(6));
1261 let kind_label = match &node.kind {
1262 NodeKind::Root => return,
1263 NodeKind::Generic => "spec",
1264 NodeKind::Group => "group",
1265 NodeKind::Frame { .. } => "frame",
1266 NodeKind::Rect { .. } => "rect",
1267 NodeKind::Ellipse { .. } => "ellipse",
1268 NodeKind::Path { .. } => "path",
1269 NodeKind::Image { .. } => "image",
1270 NodeKind::Text { .. } => "text",
1271 };
1272 writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
1273
1274 if let Some(spec) = &node.spec {
1276 out.push_str(spec);
1277 out.push_str("\n\n");
1278 }
1279
1280 for child_idx in &children {
1282 emit_spec_node(out, graph, *child_idx, heading_level + 1);
1283 }
1284}
1285
1286fn has_spec_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
1287 let node = &graph.graph[idx];
1288 if node.spec.is_some() {
1289 return true;
1290 }
1291 graph
1292 .children(idx)
1293 .iter()
1294 .any(|c| has_spec_recursive(graph, *c))
1295}
1296
1297fn has_inline_styles(style: &Properties) -> bool {
1299 style.fill.is_some()
1300 || style.stroke.is_some()
1301 || style.font.is_some()
1302 || style.corner_radius.is_some()
1303 || style.opacity.is_some()
1304 || style.shadow.is_some()
1305 || style.text_align.is_some()
1306 || style.text_valign.is_some()
1307 || style.scale.is_some()
1308}
1309
1310pub(crate) fn format_num(n: f32) -> String {
1312 if n == n.floor() {
1313 format!("{}", n as i32)
1314 } else {
1315 format!("{n:.1}")
1316 .trim_end_matches('0')
1317 .trim_end_matches('.')
1318 .to_string()
1319 }
1320}
1321
1322use std::collections::hash_map::DefaultHasher;
1325use std::hash::{Hash, Hasher};
1326
1327#[must_use]
1333pub fn snapshot_graph(graph: &SceneGraph) -> GraphSnapshot {
1334 let mut snapshot = GraphSnapshot::default();
1335
1336 for idx in graph.graph.node_indices() {
1338 let node = &graph.graph[idx];
1339 if matches!(node.kind, NodeKind::Root) {
1340 continue;
1341 }
1342 let mut buf = String::new();
1343 emit_node(&mut buf, graph, idx, 0);
1344 let mut hasher = DefaultHasher::new();
1345 buf.hash(&mut hasher);
1346 snapshot.node_hashes.insert(node.id, hasher.finish());
1347 }
1348
1349 for edge in &graph.edges {
1351 let mut buf = String::new();
1352 emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1353 let mut hasher = DefaultHasher::new();
1354 buf.hash(&mut hasher);
1355 snapshot.edge_hashes.insert(edge.id, hasher.finish());
1356 }
1357
1358 snapshot
1359}
1360
1361#[must_use]
1368pub fn emit_diff(graph: &SceneGraph, prev: &GraphSnapshot) -> String {
1369 let mut out = String::with_capacity(512);
1370
1371 for idx in graph.graph.node_indices() {
1373 let node = &graph.graph[idx];
1374 if matches!(node.kind, NodeKind::Root) {
1375 continue;
1376 }
1377
1378 let mut buf = String::new();
1379 emit_node(&mut buf, graph, idx, 0);
1380 let mut hasher = DefaultHasher::new();
1381 buf.hash(&mut hasher);
1382 let current_hash = hasher.finish();
1383
1384 match prev.node_hashes.get(&node.id) {
1385 None => {
1386 out.push_str("+ ");
1388 out.push_str(&buf);
1389 out.push('\n');
1390 }
1391 Some(&prev_hash) if prev_hash != current_hash => {
1392 out.push_str("~ ");
1394 out.push_str(&buf);
1395 out.push('\n');
1396 }
1397 _ => {} }
1399 }
1400
1401 for edge in &graph.edges {
1403 let mut buf = String::new();
1404 emit_edge(&mut buf, edge, graph, graph.edge_defaults.as_ref());
1405 let mut hasher = DefaultHasher::new();
1406 buf.hash(&mut hasher);
1407 let current_hash = hasher.finish();
1408
1409 match prev.edge_hashes.get(&edge.id) {
1410 None => {
1411 out.push_str("+ ");
1412 out.push_str(&buf);
1413 out.push('\n');
1414 }
1415 Some(&prev_hash) if prev_hash != current_hash => {
1416 out.push_str("~ ");
1417 out.push_str(&buf);
1418 out.push('\n');
1419 }
1420 _ => {}
1421 }
1422 }
1423
1424 for id in prev.node_hashes.keys() {
1426 if graph.get_by_id(*id).is_none() {
1427 writeln!(out, "- @{}", id.as_str()).unwrap();
1428 }
1429 }
1430
1431 let current_edge_ids: std::collections::HashSet<NodeId> =
1433 graph.edges.iter().map(|e| e.id).collect();
1434 for id in prev.edge_hashes.keys() {
1435 if !current_edge_ids.contains(id) {
1436 writeln!(out, "- edge @{}", id.as_str()).unwrap();
1437 }
1438 }
1439
1440 if out.is_empty() {
1441 out.push_str("# No changes\n");
1442 }
1443
1444 out
1445}
1446
1447#[must_use]
1449pub fn emit_notes_markdown(graph: &SceneGraph, title: &str) -> String {
1450 emit_spec_markdown(graph, title)
1451}
1452
1453#[cfg(test)]
1454#[path = "emitter_tests.rs"]
1455mod tests;