1use crate::id::NodeId;
6use crate::model::*;
7use petgraph::graph::NodeIndex;
8use std::fmt::Write;
9
10#[must_use]
12pub fn emit_document(graph: &SceneGraph) -> String {
13 let mut out = String::with_capacity(1024);
14
15 let has_imports = !graph.imports.is_empty();
17 let has_styles = !graph.styles.is_empty();
18 let children = graph.children(graph.root);
19 let has_constraints = graph.graph.node_indices().any(|idx| {
20 graph.graph[idx]
21 .constraints
22 .iter()
23 .any(|c| !matches!(c, Constraint::Position { .. }))
24 });
25 let has_edges = !graph.edges.is_empty();
26 let section_count =
27 has_imports as u8 + has_styles as u8 + has_constraints as u8 + has_edges as u8;
28 let use_separators = section_count >= 2;
29
30 for import in &graph.imports {
32 let _ = writeln!(out, "import \"{}\" as {}", import.path, import.namespace);
33 }
34 if has_imports {
35 out.push('\n');
36 }
37
38 if use_separators && has_styles {
40 out.push_str("# ─── Themes ───\n\n");
41 }
42 let mut styles: Vec<_> = graph.styles.iter().collect();
43 styles.sort_by_key(|(id, _)| id.as_str().to_string());
44 for (name, style) in &styles {
45 emit_style_block(&mut out, name, style, 0);
46 out.push('\n');
47 }
48
49 if use_separators && !children.is_empty() {
51 out.push_str("# ─── Layout ───\n\n");
52 }
53 for child_idx in &children {
54 emit_node(&mut out, graph, *child_idx, 0);
55 out.push('\n');
56 }
57
58 if use_separators && has_constraints {
60 out.push_str("# ─── Constraints ───\n\n");
61 }
62 for idx in graph.graph.node_indices() {
63 let node = &graph.graph[idx];
64 for constraint in &node.constraints {
65 if matches!(constraint, Constraint::Position { .. }) {
66 continue; }
68 emit_constraint(&mut out, &node.id, constraint);
69 }
70 }
71
72 if use_separators && has_edges {
74 if has_constraints {
75 out.push('\n');
76 }
77 out.push_str("# ─── Flows ───\n\n");
78 }
79 for edge in &graph.edges {
80 emit_edge(&mut out, edge, graph);
81 }
82
83 out
84}
85
86fn indent(out: &mut String, depth: usize) {
87 for _ in 0..depth {
88 out.push_str(" ");
89 }
90}
91
92fn emit_style_block(out: &mut String, name: &NodeId, style: &Style, depth: usize) {
93 indent(out, depth);
94 writeln!(out, "theme {} {{", name.as_str()).unwrap();
95
96 if let Some(ref fill) = style.fill {
97 emit_paint_prop(out, "fill", fill, depth + 1);
98 }
99 if let Some(ref font) = style.font {
100 emit_font_prop(out, font, depth + 1);
101 }
102 if let Some(radius) = style.corner_radius {
103 indent(out, depth + 1);
104 writeln!(out, "corner: {}", format_num(radius)).unwrap();
105 }
106 if let Some(opacity) = style.opacity {
107 indent(out, depth + 1);
108 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
109 }
110 if let Some(ref shadow) = style.shadow {
111 indent(out, depth + 1);
112 writeln!(
113 out,
114 "shadow: ({},{},{},{})",
115 format_num(shadow.offset_x),
116 format_num(shadow.offset_y),
117 format_num(shadow.blur),
118 shadow.color.to_hex()
119 )
120 .unwrap();
121 }
122 if style.text_align.is_some() || style.text_valign.is_some() {
124 let h = match style.text_align {
125 Some(TextAlign::Left) => "left",
126 Some(TextAlign::Right) => "right",
127 _ => "center",
128 };
129 let v = match style.text_valign {
130 Some(TextVAlign::Top) => "top",
131 Some(TextVAlign::Bottom) => "bottom",
132 _ => "middle",
133 };
134 indent(out, depth + 1);
135 writeln!(out, "align: {h} {v}").unwrap();
136 }
137
138 indent(out, depth);
139 out.push_str("}\n");
140}
141
142fn emit_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, depth: usize) {
143 let node = &graph.graph[idx];
144
145 if matches!(node.kind, NodeKind::Group | NodeKind::Frame { .. })
150 && graph.children(idx).is_empty()
151 && node.annotations.is_empty()
152 && node.use_styles.is_empty()
153 && node.animations.is_empty()
154 && !has_inline_styles(&node.style)
155 {
156 return;
157 }
158
159 for comment in &node.comments {
161 indent(out, depth);
162 writeln!(out, "# {comment}").unwrap();
163 }
164
165 indent(out, depth);
166
167 match &node.kind {
169 NodeKind::Root => return,
170 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
171 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
172 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
173 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
174 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
175 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
176 NodeKind::Text { content, .. } => {
177 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
178 }
179 }
180
181 out.push_str(" {\n");
182
183 emit_annotations(out, &node.annotations, depth + 1);
185
186 let children = graph.children(idx);
189 for child_idx in &children {
190 emit_node(out, graph, *child_idx, depth + 1);
191 }
192
193 if let NodeKind::Frame { layout, .. } = &node.kind {
197 match layout {
198 LayoutMode::Free => {}
199 LayoutMode::Column { gap, pad } => {
200 indent(out, depth + 1);
201 writeln!(
202 out,
203 "layout: column gap={} pad={}",
204 format_num(*gap),
205 format_num(*pad)
206 )
207 .unwrap();
208 }
209 LayoutMode::Row { gap, pad } => {
210 indent(out, depth + 1);
211 writeln!(
212 out,
213 "layout: row gap={} pad={}",
214 format_num(*gap),
215 format_num(*pad)
216 )
217 .unwrap();
218 }
219 LayoutMode::Grid { cols, gap, pad } => {
220 indent(out, depth + 1);
221 writeln!(
222 out,
223 "layout: grid cols={cols} gap={} pad={}",
224 format_num(*gap),
225 format_num(*pad)
226 )
227 .unwrap();
228 }
229 }
230 }
231
232 match &node.kind {
234 NodeKind::Rect { width, height } => {
235 indent(out, depth + 1);
236 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
237 }
238 NodeKind::Frame { width, height, .. } => {
239 indent(out, depth + 1);
240 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
241 }
242 NodeKind::Ellipse { rx, ry } => {
243 indent(out, depth + 1);
244 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
245 }
246 NodeKind::Text {
247 max_width: Some(w), ..
248 } => {
249 indent(out, depth + 1);
250 writeln!(out, "w: {}", format_num(*w)).unwrap();
251 }
252 _ => {}
253 }
254
255 if let NodeKind::Frame { clip: true, .. } = &node.kind {
257 indent(out, depth + 1);
258 writeln!(out, "clip: true").unwrap();
259 }
260
261 for style_ref in &node.use_styles {
263 indent(out, depth + 1);
264 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
265 }
266
267 if let Some(ref fill) = node.style.fill {
269 emit_paint_prop(out, "fill", fill, depth + 1);
270 }
271 if let Some(ref stroke) = node.style.stroke {
272 indent(out, depth + 1);
273 match &stroke.paint {
274 Paint::Solid(c) => {
275 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap()
276 }
277 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
278 }
279 }
280 if let Some(radius) = node.style.corner_radius {
281 indent(out, depth + 1);
282 writeln!(out, "corner: {}", format_num(radius)).unwrap();
283 }
284 if let Some(ref font) = node.style.font {
285 emit_font_prop(out, font, depth + 1);
286 }
287 if let Some(opacity) = node.style.opacity {
288 indent(out, depth + 1);
289 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
290 }
291 if let Some(ref shadow) = node.style.shadow {
292 indent(out, depth + 1);
293 writeln!(
294 out,
295 "shadow: ({},{},{},{})",
296 format_num(shadow.offset_x),
297 format_num(shadow.offset_y),
298 format_num(shadow.blur),
299 shadow.color.to_hex()
300 )
301 .unwrap();
302 }
303
304 if node.style.text_align.is_some() || node.style.text_valign.is_some() {
306 let h = match node.style.text_align {
307 Some(TextAlign::Left) => "left",
308 Some(TextAlign::Right) => "right",
309 _ => "center",
310 };
311 let v = match node.style.text_valign {
312 Some(TextVAlign::Top) => "top",
313 Some(TextVAlign::Bottom) => "bottom",
314 _ => "middle",
315 };
316 indent(out, depth + 1);
317 writeln!(out, "align: {h} {v}").unwrap();
318 }
319
320 if let Some((h, v)) = node.place {
322 indent(out, depth + 1);
323 let place_str = match (h, v) {
324 (HPlace::Center, VPlace::Middle) => "center".to_string(),
325 (HPlace::Left, VPlace::Top) => "top-left".to_string(),
326 (HPlace::Center, VPlace::Top) => "top".to_string(),
327 (HPlace::Right, VPlace::Top) => "top-right".to_string(),
328 (HPlace::Left, VPlace::Middle) => "left middle".to_string(),
329 (HPlace::Right, VPlace::Middle) => "right middle".to_string(),
330 (HPlace::Left, VPlace::Bottom) => "bottom-left".to_string(),
331 (HPlace::Center, VPlace::Bottom) => "bottom".to_string(),
332 (HPlace::Right, VPlace::Bottom) => "bottom-right".to_string(),
333 };
334 writeln!(out, "place: {place_str}").unwrap();
335 }
336
337 for constraint in &node.constraints {
339 if let Constraint::Position { x, y } = constraint {
340 if *x != 0.0 {
341 indent(out, depth + 1);
342 writeln!(out, "x: {}", format_num(*x)).unwrap();
343 }
344 if *y != 0.0 {
345 indent(out, depth + 1);
346 writeln!(out, "y: {}", format_num(*y)).unwrap();
347 }
348 }
349 }
350
351 for anim in &node.animations {
353 emit_anim(out, anim, depth + 1);
354 }
355
356 indent(out, depth);
357 out.push_str("}\n");
358}
359
360fn emit_annotations(out: &mut String, annotations: &[Annotation], depth: usize) {
361 if annotations.is_empty() {
362 return;
363 }
364
365 if annotations.len() == 1
367 && let Annotation::Description(s) = &annotations[0]
368 {
369 indent(out, depth);
370 writeln!(out, "spec \"{s}\"").unwrap();
371 return;
372 }
373
374 indent(out, depth);
376 out.push_str("spec {\n");
377 for ann in annotations {
378 indent(out, depth + 1);
379 match ann {
380 Annotation::Description(s) => writeln!(out, "\"{s}\"").unwrap(),
381 Annotation::Accept(s) => writeln!(out, "accept: \"{s}\"").unwrap(),
382 Annotation::Status(s) => writeln!(out, "status: {s}").unwrap(),
383 Annotation::Priority(s) => writeln!(out, "priority: {s}").unwrap(),
384 Annotation::Tag(s) => writeln!(out, "tag: {s}").unwrap(),
385 }
386 }
387 indent(out, depth);
388 out.push_str("}\n");
389}
390
391fn emit_paint_prop(out: &mut String, name: &str, paint: &Paint, depth: usize) {
392 indent(out, depth);
393 match paint {
394 Paint::Solid(c) => {
395 let hex = c.to_hex();
396 let hint = color_hint(&hex);
397 if hint.is_empty() {
398 writeln!(out, "{name}: {hex}").unwrap();
399 } else {
400 writeln!(out, "{name}: {hex} # {hint}").unwrap();
401 }
402 }
403 Paint::LinearGradient { angle, stops } => {
404 write!(out, "{name}: linear({}deg", format_num(*angle)).unwrap();
405 for stop in stops {
406 write!(out, ", {} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
407 }
408 writeln!(out, ")").unwrap();
409 }
410 Paint::RadialGradient { stops } => {
411 write!(out, "{name}: radial(").unwrap();
412 for (i, stop) in stops.iter().enumerate() {
413 if i > 0 {
414 write!(out, ", ").unwrap();
415 }
416 write!(out, "{} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
417 }
418 writeln!(out, ")").unwrap();
419 }
420 }
421}
422
423fn emit_font_prop(out: &mut String, font: &FontSpec, depth: usize) {
424 indent(out, depth);
425 let weight_str = weight_number_to_name(font.weight);
426 writeln!(
427 out,
428 "font: \"{}\" {} {}",
429 font.family,
430 weight_str,
431 format_num(font.size)
432 )
433 .unwrap();
434}
435
436fn weight_number_to_name(weight: u16) -> &'static str {
438 match weight {
439 100 => "thin",
440 200 => "extralight",
441 300 => "light",
442 400 => "regular",
443 500 => "medium",
444 600 => "semibold",
445 700 => "bold",
446 800 => "extrabold",
447 900 => "black",
448 _ => "400", }
450}
451
452fn color_hint(hex: &str) -> &'static str {
454 let hex = hex.trim_start_matches('#');
455 let bytes = hex.as_bytes();
456 let Some((r, g, b)) = (match bytes.len() {
457 3 | 4 => {
458 let r = crate::model::hex_val(bytes[0]).unwrap_or(0) * 17;
459 let g = crate::model::hex_val(bytes[1]).unwrap_or(0) * 17;
460 let b = crate::model::hex_val(bytes[2]).unwrap_or(0) * 17;
461 Some((r, g, b))
462 }
463 6 | 8 => {
464 let r = (crate::model::hex_val(bytes[0]).unwrap_or(0) << 4)
465 | crate::model::hex_val(bytes[1]).unwrap_or(0);
466 let g = (crate::model::hex_val(bytes[2]).unwrap_or(0) << 4)
467 | crate::model::hex_val(bytes[3]).unwrap_or(0);
468 let b = (crate::model::hex_val(bytes[4]).unwrap_or(0) << 4)
469 | crate::model::hex_val(bytes[5]).unwrap_or(0);
470 Some((r, g, b))
471 }
472 _ => None,
473 }) else {
474 return "";
475 };
476
477 let max = r.max(g).max(b);
479 let min = r.min(g).min(b);
480 let diff = max - min;
481 if diff < 15 {
482 return match max {
483 0..=30 => "black",
484 31..=200 => "gray",
485 _ => "white",
486 };
487 }
488
489 let rf = r as f32;
491 let gf = g as f32;
492 let bf = b as f32;
493 let hue = if max == r {
494 60.0 * (((gf - bf) / diff as f32) % 6.0)
495 } else if max == g {
496 60.0 * (((bf - rf) / diff as f32) + 2.0)
497 } else {
498 60.0 * (((rf - gf) / diff as f32) + 4.0)
499 };
500 let hue = if hue < 0.0 { hue + 360.0 } else { hue };
501
502 match hue as u16 {
503 0..=14 | 346..=360 => "red",
504 15..=39 => "orange",
505 40..=64 => "yellow",
506 65..=79 => "lime",
507 80..=159 => "green",
508 160..=179 => "teal",
509 180..=199 => "cyan",
510 200..=259 => "blue",
511 260..=279 => "purple",
512 280..=319 => "pink",
513 320..=345 => "rose",
514 _ => "",
515 }
516}
517
518fn emit_anim(out: &mut String, anim: &AnimKeyframe, depth: usize) {
519 indent(out, depth);
520 let trigger = match &anim.trigger {
521 AnimTrigger::Hover => "hover",
522 AnimTrigger::Press => "press",
523 AnimTrigger::Enter => "enter",
524 AnimTrigger::Custom(s) => s.as_str(),
525 };
526 writeln!(out, "when :{trigger} {{").unwrap();
527
528 if let Some(ref fill) = anim.properties.fill {
529 emit_paint_prop(out, "fill", fill, depth + 1);
530 }
531 if let Some(opacity) = anim.properties.opacity {
532 indent(out, depth + 1);
533 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
534 }
535 if let Some(scale) = anim.properties.scale {
536 indent(out, depth + 1);
537 writeln!(out, "scale: {}", format_num(scale)).unwrap();
538 }
539 if let Some(rotate) = anim.properties.rotate {
540 indent(out, depth + 1);
541 writeln!(out, "rotate: {}", format_num(rotate)).unwrap();
542 }
543
544 let ease_name = match &anim.easing {
545 Easing::Linear => "linear",
546 Easing::EaseIn => "ease_in",
547 Easing::EaseOut => "ease_out",
548 Easing::EaseInOut => "ease_in_out",
549 Easing::Spring => "spring",
550 Easing::CubicBezier(_, _, _, _) => "cubic",
551 };
552 indent(out, depth + 1);
553 writeln!(out, "ease: {ease_name} {}ms", anim.duration_ms).unwrap();
554
555 indent(out, depth);
556 out.push_str("}\n");
557}
558
559fn emit_constraint(out: &mut String, node_id: &NodeId, constraint: &Constraint) {
560 match constraint {
561 Constraint::CenterIn(target) => {
562 writeln!(
563 out,
564 "@{} -> center_in: {}",
565 node_id.as_str(),
566 target.as_str()
567 )
568 .unwrap();
569 }
570 Constraint::Offset { from, dx, dy } => {
571 writeln!(
572 out,
573 "@{} -> offset: @{} {}, {}",
574 node_id.as_str(),
575 from.as_str(),
576 format_num(*dx),
577 format_num(*dy)
578 )
579 .unwrap();
580 }
581 Constraint::FillParent { pad } => {
582 writeln!(
583 out,
584 "@{} -> fill_parent: {}",
585 node_id.as_str(),
586 format_num(*pad)
587 )
588 .unwrap();
589 }
590 Constraint::Position { .. } => {
591 }
593 }
594}
595
596fn emit_edge(out: &mut String, edge: &Edge, graph: &SceneGraph) {
597 writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
598
599 emit_annotations(out, &edge.annotations, 1);
601
602 if let Some(text_id) = edge.text_child
604 && let Some(node) = graph.get_by_id(text_id)
605 && let NodeKind::Text { content, .. } = &node.kind
606 {
607 writeln!(out, " text @{} \"{}\" {{}}", text_id.as_str(), content).unwrap();
608 }
609
610 match &edge.from {
612 EdgeAnchor::Node(id) => writeln!(out, " from: @{}", id.as_str()).unwrap(),
613 EdgeAnchor::Point(x, y) => {
614 writeln!(out, " from: {} {}", format_num(*x), format_num(*y)).unwrap()
615 }
616 }
617 match &edge.to {
618 EdgeAnchor::Node(id) => writeln!(out, " to: @{}", id.as_str()).unwrap(),
619 EdgeAnchor::Point(x, y) => {
620 writeln!(out, " to: {} {}", format_num(*x), format_num(*y)).unwrap()
621 }
622 }
623
624 for style_ref in &edge.use_styles {
626 writeln!(out, " use: {}", style_ref.as_str()).unwrap();
627 }
628
629 if let Some(ref stroke) = edge.style.stroke {
631 match &stroke.paint {
632 Paint::Solid(c) => {
633 writeln!(out, " stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
634 }
635 _ => {
636 writeln!(out, " stroke: #000 {}", format_num(stroke.width)).unwrap();
637 }
638 }
639 }
640
641 if let Some(opacity) = edge.style.opacity {
643 writeln!(out, " opacity: {}", format_num(opacity)).unwrap();
644 }
645
646 if edge.arrow != ArrowKind::None {
648 let name = match edge.arrow {
649 ArrowKind::None => "none",
650 ArrowKind::Start => "start",
651 ArrowKind::End => "end",
652 ArrowKind::Both => "both",
653 };
654 writeln!(out, " arrow: {name}").unwrap();
655 }
656
657 if edge.curve != CurveKind::Straight {
659 let name = match edge.curve {
660 CurveKind::Straight => "straight",
661 CurveKind::Smooth => "smooth",
662 CurveKind::Step => "step",
663 };
664 writeln!(out, " curve: {name}").unwrap();
665 }
666
667 if let Some(ref flow) = edge.flow {
669 let kind = match flow.kind {
670 FlowKind::Pulse => "pulse",
671 FlowKind::Dash => "dash",
672 };
673 writeln!(out, " flow: {} {}ms", kind, flow.duration_ms).unwrap();
674 }
675
676 if let Some((ox, oy)) = edge.label_offset {
678 writeln!(out, " label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
679 }
680
681 for anim in &edge.animations {
683 emit_anim(out, anim, 1);
684 }
685
686 out.push_str("}\n");
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum ReadMode {
697 Full,
699 Structure,
701 Layout,
703 Design,
705 Spec,
707 Visual,
709 When,
711 Edges,
713}
714
715#[must_use]
726pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
727 if mode == ReadMode::Full {
728 return emit_document(graph);
729 }
730
731 let mut out = String::with_capacity(1024);
732
733 let children = graph.children(graph.root);
734 let include_themes = matches!(mode, ReadMode::Design | ReadMode::Visual);
735 let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
736 let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
737
738 if include_themes && !graph.styles.is_empty() {
740 let mut styles: Vec<_> = graph.styles.iter().collect();
741 styles.sort_by_key(|(id, _)| id.as_str().to_string());
742 for (name, style) in &styles {
743 emit_style_block(&mut out, name, style, 0);
744 out.push('\n');
745 }
746 }
747
748 for child_idx in &children {
750 emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
751 out.push('\n');
752 }
753
754 if include_constraints {
756 for idx in graph.graph.node_indices() {
757 let node = &graph.graph[idx];
758 for constraint in &node.constraints {
759 if matches!(constraint, Constraint::Position { .. }) {
760 continue;
761 }
762 emit_constraint(&mut out, &node.id, constraint);
763 }
764 }
765 }
766
767 if include_edges {
769 for edge in &graph.edges {
770 emit_edge(&mut out, edge, graph);
771 out.push('\n');
772 }
773 }
774
775 out
776}
777
778fn emit_node_filtered(
780 out: &mut String,
781 graph: &SceneGraph,
782 idx: NodeIndex,
783 depth: usize,
784 mode: ReadMode,
785) {
786 let node = &graph.graph[idx];
787
788 if matches!(node.kind, NodeKind::Root) {
789 return;
790 }
791
792 indent(out, depth);
793
794 match &node.kind {
796 NodeKind::Root => return,
797 NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
798 NodeKind::Group => write!(out, "group @{}", node.id.as_str()).unwrap(),
799 NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
800 NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
801 NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
802 NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
803 NodeKind::Text { content, .. } => {
804 write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
805 }
806 }
807
808 out.push_str(" {\n");
809
810 if mode == ReadMode::Spec {
812 emit_annotations(out, &node.annotations, depth + 1);
813 }
814
815 let children = graph.children(idx);
817 for child_idx in &children {
818 emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
819 }
820
821 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
823 emit_layout_mode_filtered(out, &node.kind, depth + 1);
824 }
825
826 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
828 emit_dimensions_filtered(out, &node.kind, depth + 1);
829 }
830
831 if matches!(mode, ReadMode::Design | ReadMode::Visual) {
833 for style_ref in &node.use_styles {
834 indent(out, depth + 1);
835 writeln!(out, "use: {}", style_ref.as_str()).unwrap();
836 }
837 if let Some(ref fill) = node.style.fill {
838 emit_paint_prop(out, "fill", fill, depth + 1);
839 }
840 if let Some(ref stroke) = node.style.stroke {
841 indent(out, depth + 1);
842 match &stroke.paint {
843 Paint::Solid(c) => {
844 writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
845 }
846 _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
847 }
848 }
849 if let Some(radius) = node.style.corner_radius {
850 indent(out, depth + 1);
851 writeln!(out, "corner: {}", format_num(radius)).unwrap();
852 }
853 if let Some(ref font) = node.style.font {
854 emit_font_prop(out, font, depth + 1);
855 }
856 if let Some(opacity) = node.style.opacity {
857 indent(out, depth + 1);
858 writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
859 }
860 }
861
862 if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
864 for constraint in &node.constraints {
865 if let Constraint::Position { x, y } = constraint {
866 if *x != 0.0 {
867 indent(out, depth + 1);
868 writeln!(out, "x: {}", format_num(*x)).unwrap();
869 }
870 if *y != 0.0 {
871 indent(out, depth + 1);
872 writeln!(out, "y: {}", format_num(*y)).unwrap();
873 }
874 }
875 }
876 }
877
878 if matches!(mode, ReadMode::When | ReadMode::Visual) {
880 for anim in &node.animations {
881 emit_anim(out, anim, depth + 1);
882 }
883 }
884
885 indent(out, depth);
886 out.push_str("}\n");
887}
888
889fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
891 let layout = match kind {
892 NodeKind::Frame { layout, .. } => layout,
893 _ => return, };
895 match layout {
896 LayoutMode::Free => {}
897 LayoutMode::Column { gap, pad } => {
898 indent(out, depth);
899 writeln!(
900 out,
901 "layout: column gap={} pad={}",
902 format_num(*gap),
903 format_num(*pad)
904 )
905 .unwrap();
906 }
907 LayoutMode::Row { gap, pad } => {
908 indent(out, depth);
909 writeln!(
910 out,
911 "layout: row gap={} pad={}",
912 format_num(*gap),
913 format_num(*pad)
914 )
915 .unwrap();
916 }
917 LayoutMode::Grid { cols, gap, pad } => {
918 indent(out, depth);
919 writeln!(
920 out,
921 "layout: grid cols={cols} gap={} pad={}",
922 format_num(*gap),
923 format_num(*pad)
924 )
925 .unwrap();
926 }
927 }
928}
929
930fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
932 match kind {
933 NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
934 indent(out, depth);
935 writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
936 }
937 NodeKind::Ellipse { rx, ry } => {
938 indent(out, depth);
939 writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
940 }
941 _ => {}
942 }
943}
944
945#[must_use]
953pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
954 let mut out = String::with_capacity(512);
955 writeln!(out, "# Spec: {title}\n").unwrap();
956
957 let children = graph.children(graph.root);
959 for child_idx in &children {
960 emit_spec_node(&mut out, graph, *child_idx, 2);
961 }
962
963 if !graph.edges.is_empty() {
965 out.push_str("\n---\n\n## Flows\n\n");
966 for edge in &graph.edges {
967 let from_str = match &edge.from {
968 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
969 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
970 };
971 let to_str = match &edge.to {
972 EdgeAnchor::Node(id) => format!("@{}", id.as_str()),
973 EdgeAnchor::Point(x, y) => format!("({}, {})", x, y),
974 };
975 write!(out, "- **{}** → **{}**", from_str, to_str).unwrap();
976 if let Some(text_id) = edge.text_child
977 && let Some(node) = graph.get_by_id(text_id)
978 && let NodeKind::Text { content, .. } = &node.kind
979 {
980 write!(out, " — {content}").unwrap();
981 }
982 out.push('\n');
983 emit_spec_annotations(&mut out, &edge.annotations, " ");
984 }
985 }
986
987 out
988}
989
990fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
991 let node = &graph.graph[idx];
992
993 let has_annotations = !node.annotations.is_empty();
995 let children = graph.children(idx);
996 let has_annotated_children = children
997 .iter()
998 .any(|c| has_annotations_recursive(graph, *c));
999
1000 if !has_annotations && !has_annotated_children {
1001 return;
1002 }
1003
1004 let hashes = "#".repeat(heading_level.min(6));
1006 let kind_label = match &node.kind {
1007 NodeKind::Root => return,
1008 NodeKind::Generic => "spec",
1009 NodeKind::Group => "group",
1010 NodeKind::Frame { .. } => "frame",
1011 NodeKind::Rect { .. } => "rect",
1012 NodeKind::Ellipse { .. } => "ellipse",
1013 NodeKind::Path { .. } => "path",
1014 NodeKind::Text { .. } => "text",
1015 };
1016 writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
1017
1018 emit_spec_annotations(out, &node.annotations, "");
1020
1021 for child_idx in &children {
1023 emit_spec_node(out, graph, *child_idx, heading_level + 1);
1024 }
1025}
1026
1027fn has_annotations_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
1028 let node = &graph.graph[idx];
1029 if !node.annotations.is_empty() {
1030 return true;
1031 }
1032 graph
1033 .children(idx)
1034 .iter()
1035 .any(|c| has_annotations_recursive(graph, *c))
1036}
1037
1038fn emit_spec_annotations(out: &mut String, annotations: &[Annotation], prefix: &str) {
1039 for ann in annotations {
1040 match ann {
1041 Annotation::Description(s) => writeln!(out, "{prefix}> {s}").unwrap(),
1042 Annotation::Accept(s) => writeln!(out, "{prefix}- [ ] {s}").unwrap(),
1043 Annotation::Status(s) => writeln!(out, "{prefix}- **Status:** {s}").unwrap(),
1044 Annotation::Priority(s) => writeln!(out, "{prefix}- **Priority:** {s}").unwrap(),
1045 Annotation::Tag(s) => writeln!(out, "{prefix}- **Tag:** {s}").unwrap(),
1046 }
1047 }
1048 if !annotations.is_empty() {
1049 out.push('\n');
1050 }
1051}
1052
1053fn has_inline_styles(style: &Style) -> bool {
1055 style.fill.is_some()
1056 || style.stroke.is_some()
1057 || style.font.is_some()
1058 || style.corner_radius.is_some()
1059 || style.opacity.is_some()
1060 || style.shadow.is_some()
1061 || style.text_align.is_some()
1062 || style.text_valign.is_some()
1063 || style.scale.is_some()
1064}
1065
1066fn format_num(n: f32) -> String {
1068 if n == n.floor() {
1069 format!("{}", n as i32)
1070 } else {
1071 format!("{n:.2}")
1072 .trim_end_matches('0')
1073 .trim_end_matches('.')
1074 .to_string()
1075 }
1076}
1077
1078#[cfg(test)]
1079#[path = "emitter_tests.rs"]
1080mod tests;