Skip to main content

fd_core/
emitter.rs

1//! Emitter: SceneGraph → FD text format.
2//!
3//! Produces minimal, token-efficient output that round-trips through the parser.
4
5use crate::id::NodeId;
6use crate::model::*;
7use petgraph::graph::NodeIndex;
8use std::fmt::Write;
9
10/// Emit a `SceneGraph` as an FD text document.
11#[must_use]
12pub fn emit_document(graph: &SceneGraph) -> String {
13    let mut out = String::with_capacity(1024);
14
15    // Count sections to decide if separators add value
16    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    // Emit imports
31    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    // Emit style definitions
39    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    // Emit root's children (node tree)
50    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    // Emit top-level constraints (skip Position — emitted inline as x:/y:)
59    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; // emitted inline inside node block
67            }
68            emit_constraint(&mut out, &node.id, constraint);
69        }
70    }
71
72    // Emit edges
73    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    // Text alignment
123    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    // Skip empty container nodes (childless Group/Frame) on format.
146    // Shapes (Rect/Ellipse/Text) are always meaningful on their own.
147    // Preserve containers that still carry annotations, styles, animations,
148    // or non-default inline styles (fill, stroke, etc.).
149    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    // Emit preserved `# comment` lines before the node declaration
160    for comment in &node.comments {
161        indent(out, depth);
162        writeln!(out, "# {comment}").unwrap();
163    }
164
165    indent(out, depth);
166
167    // Node kind keyword + optional @id + optional inline text
168    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    // Annotations (spec block)
184    emit_annotations(out, &node.annotations, depth + 1);
185
186    // Children — emitted right after spec so the structural skeleton
187    // is visible first. Visual styling comes at the tail for clean folding.
188    let children = graph.children(idx);
189    for child_idx in &children {
190        emit_node(out, graph, *child_idx, depth + 1);
191    }
192
193    // Group is purely organizational — no layout mode emission
194
195    // Layout mode (for frames)
196    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    // Dimensions
233    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    // Clip property (for frames only)
256    if let NodeKind::Frame { clip: true, .. } = &node.kind {
257        indent(out, depth + 1);
258        writeln!(out, "clip: true").unwrap();
259    }
260
261    // Style references
262    for style_ref in &node.use_styles {
263        indent(out, depth + 1);
264        writeln!(out, "use: {}", style_ref.as_str()).unwrap();
265    }
266
267    // Inline style properties
268    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    // Text alignment
305    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    // Child placement within parent
321    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    // Inline position (x: / y:) — emitted here for token efficiency
338    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    // Animations (when blocks)
352    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    // Single description → inline shorthand: `spec "desc"`
366    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    // Multiple annotations → block form: `spec { ... }`
375    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
436/// Map numeric font weight to human-readable name.
437fn 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", // fallback
449    }
450}
451
452/// Classify a hex color into a human-readable hue name.
453fn 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    // Achromatic check
478    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    // Hue classification
490    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            // Emitted inline as x: / y: inside node block — skip here
592        }
593    }
594}
595
596fn emit_edge(out: &mut String, edge: &Edge, graph: &SceneGraph) {
597    writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
598
599    // Annotations
600    emit_annotations(out, &edge.annotations, 1);
601
602    // Nested text child
603    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    // from / to
611    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    // Style references
625    for style_ref in &edge.use_styles {
626        writeln!(out, "  use: {}", style_ref.as_str()).unwrap();
627    }
628
629    // Stroke
630    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    // Opacity
642    if let Some(opacity) = edge.style.opacity {
643        writeln!(out, "  opacity: {}", format_num(opacity)).unwrap();
644    }
645
646    // Arrow
647    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    // Curve
658    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    // Flow animation
668    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    // Label offset (dragged position)
677    if let Some((ox, oy)) = edge.label_offset {
678        writeln!(out, "  label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
679    }
680
681    // Trigger animations
682    for anim in &edge.animations {
683        emit_anim(out, anim, 1);
684    }
685
686    out.push_str("}\n");
687}
688
689// ─── Read Modes (filtered emit for AI agents) ────────────────────────────
690
691/// What an AI agent wants to read from the document.
692///
693/// Each mode selectively emits only the properties relevant to a specific
694/// concern, saving 50-80% tokens while preserving structural understanding.
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum ReadMode {
697    /// Full file — no filtering (identical to `emit_document`).
698    Full,
699    /// Node types, `@id`s, parent-child nesting only.
700    Structure,
701    /// Structure + dimensions (`w:`/`h:`) + `layout:` directives + constraints.
702    Layout,
703    /// Structure + themes/styles + `fill:`/`stroke:`/`font:`/`corner:`/`use:` refs.
704    Design,
705    /// Structure + `spec {}` blocks + annotations.
706    Spec,
707    /// Layout + Design + When combined — the full visual story.
708    Visual,
709    /// Structure + `when :trigger { ... }` animation blocks only.
710    When,
711    /// Structure + `edge @id { ... }` blocks.
712    Edges,
713}
714
715/// Emit a `SceneGraph` filtered to show only the properties relevant to `mode`.
716///
717/// - `Full`: identical to `emit_document`.
718/// - `Structure`: node kind + `@id` + children. No styles, dims, anims, specs.
719/// - `Layout`: structure + `w:`/`h:` + `layout:` + constraints (`->`).
720/// - `Design`: structure + themes + `fill:`/`stroke:`/`font:`/`corner:`/`use:`.
721/// - `Spec`: structure + `spec {}` blocks.
722/// - `Visual`: layout + design + when combined.
723/// - `When`: structure + `when :trigger { ... }` blocks.
724/// - `Edges`: structure + `edge @id { ... }` blocks.
725#[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    // Themes (Design and Visual modes)
739    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    // Node tree (always emitted, but with per-mode filtering)
749    for child_idx in &children {
750        emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
751        out.push('\n');
752    }
753
754    // Constraints (Layout and Visual modes)
755    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    // Edges (Edges and Visual modes)
768    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
778/// Emit a single node with mode-based property filtering.
779fn 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    // Node kind + @id (always emitted)
795    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    // Spec annotations (Spec mode only)
811    if mode == ReadMode::Spec {
812        emit_annotations(out, &node.annotations, depth + 1);
813    }
814
815    // Children (always recurse)
816    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    // Layout directives (Layout and Visual modes)
822    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
823        emit_layout_mode_filtered(out, &node.kind, depth + 1);
824    }
825
826    // Dimensions (Layout and Visual modes)
827    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
828        emit_dimensions_filtered(out, &node.kind, depth + 1);
829    }
830
831    // Style properties (Design and Visual modes)
832    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    // Inline position (Layout and Visual modes)
863    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    // Animations / when blocks (When and Visual modes)
879    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
889/// Emit layout mode directive for groups and frames (filtered path).
890fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
891    let layout = match kind {
892        NodeKind::Frame { layout, .. } => layout,
893        _ => return, // Group is always Free — no layout emission
894    };
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
930/// Emit dimension properties (w/h) for sized nodes (filtered path).
931fn 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// ─── Spec Markdown Export ─────────────────────────────────────────────────
946
947/// Emit a `SceneGraph` as a markdown spec document.
948///
949/// Extracts only `@id` names, `spec { ... }` annotations, hierarchy, and edges —
950/// all visual properties (fill, stroke, dimensions, animations) are omitted.
951/// Intended for PM-facing spec reports.
952#[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    // Emit root's children
958    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    // Emit edges as flow descriptions
964    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    // Skip nodes with no annotations and no annotated children
994    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    // Heading: ## @node_id (kind)
1005    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    // Annotation details
1019    emit_spec_annotations(out, &node.annotations, "");
1020
1021    // Children (recurse with deeper heading level)
1022    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
1053/// Check if a `Style` has any non-default properties set.
1054fn 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
1066/// Format a float without trailing zeros for compact output.
1067fn 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;