Skip to main content

fd_core/
model.rs

1//! Core scene-graph data model for FD documents.
2//!
3//! The document is a DAG (Directed Acyclic Graph) where nodes represent
4//! visual elements (shapes, text, groups) and edges represent parent→child
5//! containment. Styles and animations are attached to nodes. Layout is
6//! constraint-based — relationships are preferred over raw positions.
7//! `Position { x, y }` is the escape hatch for drag-placed or pinned nodes.
8
9use crate::id::NodeId;
10use petgraph::graph::NodeIndex;
11use petgraph::stable_graph::StableDiGraph;
12use serde::{Deserialize, Serialize};
13use smallvec::SmallVec;
14use std::collections::HashMap;
15
16// ─── Colors & Paint ──────────────────────────────────────────────────────
17
18/// RGBA color. Stored as 4 × f32 [0.0, 1.0].
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20pub struct Color {
21    pub r: f32,
22    pub g: f32,
23    pub b: f32,
24    pub a: f32,
25}
26
27impl Color {
28    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
29        Self { r, g, b, a }
30    }
31
32    /// Parse a hex color string: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`.
33    pub fn from_hex(hex: &str) -> Option<Self> {
34        let hex = hex.strip_prefix('#')?;
35        let bytes = hex.as_bytes();
36
37        fn hex_val(c: u8) -> Option<u8> {
38            match c {
39                b'0'..=b'9' => Some(c - b'0'),
40                b'a'..=b'f' => Some(c - b'a' + 10),
41                b'A'..=b'F' => Some(c - b'A' + 10),
42                _ => None,
43            }
44        }
45
46        match bytes.len() {
47            3 => {
48                let r = hex_val(bytes[0])?;
49                let g = hex_val(bytes[1])?;
50                let b = hex_val(bytes[2])?;
51                Some(Self::rgba(
52                    (r * 17) as f32 / 255.0,
53                    (g * 17) as f32 / 255.0,
54                    (b * 17) as f32 / 255.0,
55                    1.0,
56                ))
57            }
58            4 => {
59                let r = hex_val(bytes[0])?;
60                let g = hex_val(bytes[1])?;
61                let b = hex_val(bytes[2])?;
62                let a = hex_val(bytes[3])?;
63                Some(Self::rgba(
64                    (r * 17) as f32 / 255.0,
65                    (g * 17) as f32 / 255.0,
66                    (b * 17) as f32 / 255.0,
67                    (a * 17) as f32 / 255.0,
68                ))
69            }
70            6 => {
71                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
72                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
73                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
74                Some(Self::rgba(
75                    r as f32 / 255.0,
76                    g as f32 / 255.0,
77                    b as f32 / 255.0,
78                    1.0,
79                ))
80            }
81            8 => {
82                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
83                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
84                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
85                let a = hex_val(bytes[6])? << 4 | hex_val(bytes[7])?;
86                Some(Self::rgba(
87                    r as f32 / 255.0,
88                    g as f32 / 255.0,
89                    b as f32 / 255.0,
90                    a as f32 / 255.0,
91                ))
92            }
93            _ => None,
94        }
95    }
96
97    /// Emit as shortest valid hex string.
98    pub fn to_hex(&self) -> String {
99        let r = (self.r * 255.0).round() as u8;
100        let g = (self.g * 255.0).round() as u8;
101        let b = (self.b * 255.0).round() as u8;
102        let a = (self.a * 255.0).round() as u8;
103        if a == 255 {
104            format!("#{r:02X}{g:02X}{b:02X}")
105        } else {
106            format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
107        }
108    }
109}
110
111/// A gradient stop.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct GradientStop {
114    pub offset: f32, // 0.0 .. 1.0
115    pub color: Color,
116}
117
118/// Fill or stroke paint.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub enum Paint {
121    Solid(Color),
122    LinearGradient {
123        angle: f32, // degrees
124        stops: Vec<GradientStop>,
125    },
126    RadialGradient {
127        stops: Vec<GradientStop>,
128    },
129}
130
131// ─── Stroke ──────────────────────────────────────────────────────────────
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Stroke {
135    pub paint: Paint,
136    pub width: f32,
137    pub cap: StrokeCap,
138    pub join: StrokeJoin,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142pub enum StrokeCap {
143    Butt,
144    Round,
145    Square,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub enum StrokeJoin {
150    Miter,
151    Round,
152    Bevel,
153}
154
155impl Default for Stroke {
156    fn default() -> Self {
157        Self {
158            paint: Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0)),
159            width: 1.0,
160            cap: StrokeCap::Butt,
161            join: StrokeJoin::Miter,
162        }
163    }
164}
165
166// ─── Font / Text ─────────────────────────────────────────────────────────
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct FontSpec {
170    pub family: String,
171    pub weight: u16, // 100..900
172    pub size: f32,
173}
174
175impl Default for FontSpec {
176    fn default() -> Self {
177        Self {
178            family: "Inter".into(),
179            weight: 400,
180            size: 14.0,
181        }
182    }
183}
184
185// ─── Path data ───────────────────────────────────────────────────────────
186
187/// A single path command (SVG-like but simplified).
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub enum PathCmd {
190    MoveTo(f32, f32),
191    LineTo(f32, f32),
192    QuadTo(f32, f32, f32, f32),            // control, end
193    CubicTo(f32, f32, f32, f32, f32, f32), // c1, c2, end
194    Close,
195}
196
197// ─── Shadow ──────────────────────────────────────────────────────────────
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Shadow {
201    pub offset_x: f32,
202    pub offset_y: f32,
203    pub blur: f32,
204    pub color: Color,
205}
206
207// ─── Styling ─────────────────────────────────────────────────────────────
208
209/// Horizontal text alignment.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
211pub enum TextAlign {
212    Left,
213    #[default]
214    Center,
215    Right,
216}
217
218/// Vertical text alignment.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
220pub enum TextVAlign {
221    Top,
222    #[default]
223    Middle,
224    Bottom,
225}
226
227/// A reusable theme set that nodes can reference via `use: theme_name`.
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct Style {
230    pub fill: Option<Paint>,
231    pub stroke: Option<Stroke>,
232    pub font: Option<FontSpec>,
233    pub corner_radius: Option<f32>,
234    pub opacity: Option<f32>,
235    pub shadow: Option<Shadow>,
236
237    /// Horizontal text alignment (default: Center).
238    pub text_align: Option<TextAlign>,
239    /// Vertical text alignment (default: Middle).
240    pub text_valign: Option<TextVAlign>,
241
242    /// Scale factor applied during rendering (from animations).
243    pub scale: Option<f32>,
244}
245
246// ─── Animation ───────────────────────────────────────────────────────────
247
248/// The trigger for an animation.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub enum AnimTrigger {
251    Hover,
252    Press,
253    Enter, // viewport enter
254    Custom(String),
255}
256
257/// Easing function.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub enum Easing {
260    Linear,
261    EaseIn,
262    EaseOut,
263    EaseInOut,
264    Spring,
265    CubicBezier(f32, f32, f32, f32),
266}
267
268/// A property animation keyframe.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct AnimKeyframe {
271    pub trigger: AnimTrigger,
272    pub duration_ms: u32,
273    pub easing: Easing,
274    pub properties: AnimProperties,
275}
276
277/// Animatable property overrides.
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279pub struct AnimProperties {
280    pub fill: Option<Paint>,
281    pub opacity: Option<f32>,
282    pub scale: Option<f32>,
283    pub rotate: Option<f32>, // degrees
284    pub translate: Option<(f32, f32)>,
285}
286
287// ─── Annotations ─────────────────────────────────────────────────────────
288
289/// Structured annotation attached to a scene node.
290/// Parsed from `spec { ... }` blocks in the FD format.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub enum Annotation {
293    /// Freeform description: `spec { "User auth entry point" }`
294    Description(String),
295    /// Acceptance criterion: `spec { accept: "validates email on blur" }`
296    Accept(String),
297    /// Status: `spec { status: todo }` (values: todo, doing, done, blocked)
298    Status(String),
299    /// Priority: `spec { priority: high }`
300    Priority(String),
301    /// Tag: `spec { tag: auth }`
302    Tag(String),
303}
304
305// ─── Imports ─────────────────────────────────────────────────────────────
306
307/// A file import declaration: `import "path.fd" as namespace`.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct Import {
310    /// Relative file path, e.g. "components/buttons.fd".
311    pub path: String,
312    /// Namespace alias, e.g. "buttons".
313    pub namespace: String,
314}
315
316// ─── Layout Constraints ──────────────────────────────────────────────────
317
318/// Constraint-based layout — no absolute coordinates in the format.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub enum Constraint {
321    /// Center this node within a target (e.g. `canvas` or another node).
322    CenterIn(NodeId),
323    /// Position relative: dx, dy from a reference node.
324    Offset { from: NodeId, dx: f32, dy: f32 },
325    /// Fill the parent with optional padding.
326    FillParent { pad: f32 },
327    /// Parent-relative position (used for drag-placed or pinned nodes).
328    /// Resolved as `parent.x + x`, `parent.y + y` by the layout solver.
329    Position { x: f32, y: f32 },
330}
331
332// ─── Edges (connections between nodes) ───────────────────────────────────
333
334/// Arrow head placement on an edge.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
336pub enum ArrowKind {
337    #[default]
338    None,
339    Start,
340    End,
341    Both,
342}
343
344/// How the edge path is drawn between two nodes.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
346pub enum CurveKind {
347    #[default]
348    Straight,
349    Smooth,
350    Step,
351}
352
353/// A visual connection between two nodes.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct Edge {
356    pub id: NodeId,
357    pub from: NodeId,
358    pub to: NodeId,
359    pub label: Option<String>,
360    pub style: Style,
361    pub use_styles: SmallVec<[NodeId; 2]>,
362    pub arrow: ArrowKind,
363    pub curve: CurveKind,
364    pub annotations: Vec<Annotation>,
365    pub animations: SmallVec<[AnimKeyframe; 2]>,
366    pub flow: Option<FlowAnim>,
367}
368
369/// Flow animation kind — continuous motion along the edge path.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371pub enum FlowKind {
372    /// A glowing dot traveling from → to on a loop.
373    Pulse,
374    /// Marching dashes along the edge (stroke-dashoffset animation).
375    Dash,
376}
377
378/// A flow animation attached to an edge.
379#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
380pub struct FlowAnim {
381    pub kind: FlowKind,
382    pub duration_ms: u32,
383}
384
385/// Group layout mode (for children arrangement).
386#[derive(Debug, Clone, Default, Serialize, Deserialize)]
387pub enum LayoutMode {
388    /// Free / absolute positioning of children.
389    #[default]
390    Free,
391    /// Column (vertical stack).
392    Column { gap: f32, pad: f32 },
393    /// Row (horizontal stack).
394    Row { gap: f32, pad: f32 },
395    /// Grid layout.
396    Grid { cols: u32, gap: f32, pad: f32 },
397}
398
399// ─── Scene Graph Nodes ───────────────────────────────────────────────────
400
401/// The node kinds in the scene DAG.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub enum NodeKind {
404    /// Root of the document.
405    Root,
406
407    /// Generic placeholder — no visual shape assigned yet.
408    /// Used for spec-only nodes: `@login_btn { spec "CTA" }`
409    Generic,
410
411    /// Group / frame — contains children.
412    Group { layout: LayoutMode },
413
414    /// Frame — visible container with explicit size and optional clipping.
415    /// Like a Figma frame: has fill/stroke, declared dimensions, clips overflow.
416    Frame {
417        width: f32,
418        height: f32,
419        clip: bool,
420        layout: LayoutMode,
421    },
422
423    /// Rectangle.
424    Rect { width: f32, height: f32 },
425
426    /// Ellipse / circle.
427    Ellipse { rx: f32, ry: f32 },
428
429    /// Freeform path (pen tool output).
430    Path { commands: Vec<PathCmd> },
431
432    /// Text label.
433    Text { content: String },
434}
435
436/// A single node in the scene graph.
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct SceneNode {
439    /// The node's ID (e.g. `@login_form`). Anonymous nodes get auto-IDs.
440    pub id: NodeId,
441
442    /// What kind of element this is.
443    pub kind: NodeKind,
444
445    /// Inline style overrides on this node.
446    pub style: Style,
447
448    /// Named theme references (`use: base_text`).
449    pub use_styles: SmallVec<[NodeId; 2]>,
450
451    /// Constraint-based positioning.
452    pub constraints: SmallVec<[Constraint; 2]>,
453
454    /// Animations attached to this node.
455    pub animations: SmallVec<[AnimKeyframe; 2]>,
456
457    /// Structured annotations (`spec { ... }` block).
458    pub annotations: Vec<Annotation>,
459
460    /// Line comments (`# text`) that appeared before this node in the source.
461    /// Preserved across parse/emit round-trips so format passes don't delete them.
462    pub comments: Vec<String>,
463}
464
465impl SceneNode {
466    pub fn new(id: NodeId, kind: NodeKind) -> Self {
467        Self {
468            id,
469            kind,
470            style: Style::default(),
471            use_styles: SmallVec::new(),
472            constraints: SmallVec::new(),
473            animations: SmallVec::new(),
474            annotations: Vec::new(),
475            comments: Vec::new(),
476        }
477    }
478}
479
480// ─── Scene Graph ─────────────────────────────────────────────────────────
481
482/// The complete FD document — a DAG of `SceneNode` values.
483///
484/// Edges go from parent → child. Style definitions are stored separately
485/// in a hashmap for lookup by name.
486#[derive(Debug, Clone)]
487pub struct SceneGraph {
488    /// The underlying directed graph.
489    pub graph: StableDiGraph<SceneNode, ()>,
490
491    /// The root node index.
492    pub root: NodeIndex,
493
494    /// Named theme definitions (`theme base_text { ... }`).
495    pub styles: HashMap<NodeId, Style>,
496
497    /// Index from NodeId → NodeIndex for fast lookup.
498    pub id_index: HashMap<NodeId, NodeIndex>,
499
500    /// Visual edges (connections between nodes).
501    pub edges: Vec<Edge>,
502
503    /// File imports with namespace aliases.
504    pub imports: Vec<Import>,
505
506    /// Explicit child ordering set by `sort_nodes`.
507    /// When present for a parent, `children()` returns this order
508    /// instead of the default `NodeIndex` sort.
509    pub sorted_child_order: HashMap<NodeIndex, Vec<NodeIndex>>,
510}
511
512impl SceneGraph {
513    /// Create a new empty scene graph with a root node.
514    #[must_use]
515    pub fn new() -> Self {
516        let mut graph = StableDiGraph::new();
517        let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
518        let root = graph.add_node(root_node);
519
520        let mut id_index = HashMap::new();
521        id_index.insert(NodeId::intern("root"), root);
522
523        Self {
524            graph,
525            root,
526            styles: HashMap::new(),
527            id_index,
528            edges: Vec::new(),
529            imports: Vec::new(),
530            sorted_child_order: HashMap::new(),
531        }
532    }
533
534    /// Add a node as a child of `parent`. Returns the new node's index.
535    pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
536        let id = node.id;
537        let idx = self.graph.add_node(node);
538        self.graph.add_edge(parent, idx, ());
539        self.id_index.insert(id, idx);
540        idx
541    }
542
543    /// Remove a node safely, keeping the `id_index` synchronized.
544    pub fn remove_node(&mut self, idx: NodeIndex) -> Option<SceneNode> {
545        let removed = self.graph.remove_node(idx);
546        if let Some(removed_node) = &removed {
547            self.id_index.remove(&removed_node.id);
548        }
549        removed
550    }
551
552    /// Look up a node by its `@id`.
553    pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
554        self.id_index.get(&id).map(|idx| &self.graph[*idx])
555    }
556
557    /// Look up a node mutably by its `@id`.
558    pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
559        self.id_index
560            .get(&id)
561            .copied()
562            .map(|idx| &mut self.graph[idx])
563    }
564
565    /// Get the index for a NodeId.
566    pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
567        self.id_index.get(&id).copied()
568    }
569
570    /// Get the parent index of a node.
571    pub fn parent(&self, idx: NodeIndex) -> Option<NodeIndex> {
572        self.graph
573            .neighbors_directed(idx, petgraph::Direction::Incoming)
574            .next()
575    }
576
577    /// Reparent a node to a new parent.
578    pub fn reparent_node(&mut self, child: NodeIndex, new_parent: NodeIndex) {
579        if let Some(old_parent) = self.parent(child)
580            && let Some(edge) = self.graph.find_edge(old_parent, child)
581        {
582            self.graph.remove_edge(edge);
583        }
584        self.graph.add_edge(new_parent, child, ());
585    }
586
587    /// Get children of a node in document (insertion) order.
588    ///
589    /// Sorts by `NodeIndex` so the result is deterministic regardless of
590    /// how `petgraph` iterates its adjacency list on different targets
591    /// (native vs WASM).
592    pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
593        // If an explicit sort order was set (by sort_nodes), use it
594        if let Some(order) = self.sorted_child_order.get(&idx) {
595            return order.clone();
596        }
597
598        let mut children: Vec<NodeIndex> = self
599            .graph
600            .neighbors_directed(idx, petgraph::Direction::Outgoing)
601            .collect();
602        children.sort();
603        children
604    }
605
606    /// Move a child one step backward in z-order (swap with previous sibling).
607    /// Returns true if the z-order changed.
608    pub fn send_backward(&mut self, child: NodeIndex) -> bool {
609        let parent = match self.parent(child) {
610            Some(p) => p,
611            None => return false,
612        };
613        let siblings = self.children(parent);
614        let pos = match siblings.iter().position(|&s| s == child) {
615            Some(p) => p,
616            None => return false,
617        };
618        if pos == 0 {
619            return false; // already at back
620        }
621        // Rebuild edges in swapped order
622        self.rebuild_child_order(parent, &siblings, pos, pos - 1)
623    }
624
625    /// Move a child one step forward in z-order (swap with next sibling).
626    /// Returns true if the z-order changed.
627    pub fn bring_forward(&mut self, child: NodeIndex) -> bool {
628        let parent = match self.parent(child) {
629            Some(p) => p,
630            None => return false,
631        };
632        let siblings = self.children(parent);
633        let pos = match siblings.iter().position(|&s| s == child) {
634            Some(p) => p,
635            None => return false,
636        };
637        if pos >= siblings.len() - 1 {
638            return false; // already at front
639        }
640        self.rebuild_child_order(parent, &siblings, pos, pos + 1)
641    }
642
643    /// Move a child to the back of z-order (first child).
644    pub fn send_to_back(&mut self, child: NodeIndex) -> bool {
645        let parent = match self.parent(child) {
646            Some(p) => p,
647            None => return false,
648        };
649        let siblings = self.children(parent);
650        let pos = match siblings.iter().position(|&s| s == child) {
651            Some(p) => p,
652            None => return false,
653        };
654        if pos == 0 {
655            return false;
656        }
657        self.rebuild_child_order(parent, &siblings, pos, 0)
658    }
659
660    /// Move a child to the front of z-order (last child).
661    pub fn bring_to_front(&mut self, child: NodeIndex) -> bool {
662        let parent = match self.parent(child) {
663            Some(p) => p,
664            None => return false,
665        };
666        let siblings = self.children(parent);
667        let pos = match siblings.iter().position(|&s| s == child) {
668            Some(p) => p,
669            None => return false,
670        };
671        let last = siblings.len() - 1;
672        if pos == last {
673            return false;
674        }
675        self.rebuild_child_order(parent, &siblings, pos, last)
676    }
677
678    /// Rebuild child edges, moving child at `from` to `to` position.
679    fn rebuild_child_order(
680        &mut self,
681        parent: NodeIndex,
682        siblings: &[NodeIndex],
683        from: usize,
684        to: usize,
685    ) -> bool {
686        // Remove all edges from parent to children
687        for &sib in siblings {
688            if let Some(edge) = self.graph.find_edge(parent, sib) {
689                self.graph.remove_edge(edge);
690            }
691        }
692        // Build new order
693        let mut new_order: Vec<NodeIndex> = siblings.to_vec();
694        let child = new_order.remove(from);
695        new_order.insert(to, child);
696        // Re-add edges in new order
697        for &sib in &new_order {
698            self.graph.add_edge(parent, sib, ());
699        }
700        true
701    }
702
703    /// Define a named style.
704    pub fn define_style(&mut self, name: NodeId, style: Style) {
705        self.styles.insert(name, style);
706    }
707
708    /// Resolve a node's effective style (merging `use` references + inline overrides + active animations).
709    pub fn resolve_style(&self, node: &SceneNode, active_triggers: &[AnimTrigger]) -> Style {
710        let mut resolved = Style::default();
711
712        // Apply referenced styles in order
713        for style_id in &node.use_styles {
714            if let Some(base) = self.styles.get(style_id) {
715                merge_style(&mut resolved, base);
716            }
717        }
718
719        // Apply inline overrides (take precedence)
720        merge_style(&mut resolved, &node.style);
721
722        // Apply active animation state overrides
723        for anim in &node.animations {
724            if active_triggers.contains(&anim.trigger) {
725                if anim.properties.fill.is_some() {
726                    resolved.fill = anim.properties.fill.clone();
727                }
728                if anim.properties.opacity.is_some() {
729                    resolved.opacity = anim.properties.opacity;
730                }
731                if anim.properties.scale.is_some() {
732                    resolved.scale = anim.properties.scale;
733                }
734            }
735        }
736
737        resolved
738    }
739
740    /// Rebuild the `id_index` (needed after deserialization).
741    pub fn rebuild_index(&mut self) {
742        self.id_index.clear();
743        for idx in self.graph.node_indices() {
744            let id = self.graph[idx].id;
745            self.id_index.insert(id, idx);
746        }
747    }
748
749    /// Resolve an edge's effective style (merging `use` references + inline overrides + active animations).
750    pub fn resolve_style_for_edge(&self, edge: &Edge, active_triggers: &[AnimTrigger]) -> Style {
751        let mut resolved = Style::default();
752        for style_id in &edge.use_styles {
753            if let Some(base) = self.styles.get(style_id) {
754                merge_style(&mut resolved, base);
755            }
756        }
757        merge_style(&mut resolved, &edge.style);
758
759        for anim in &edge.animations {
760            if active_triggers.contains(&anim.trigger) {
761                if anim.properties.fill.is_some() {
762                    resolved.fill = anim.properties.fill.clone();
763                }
764                if anim.properties.opacity.is_some() {
765                    resolved.opacity = anim.properties.opacity;
766                }
767                if anim.properties.scale.is_some() {
768                    resolved.scale = anim.properties.scale;
769                }
770            }
771        }
772
773        resolved
774    }
775
776    /// Return the leaf node directly — children are always selectable first.
777    /// Groups can still be selected by clicking their own area (not covered by children)
778    /// or via marquee selection.
779    pub fn effective_target(&self, leaf_id: NodeId, _selected: &[NodeId]) -> NodeId {
780        leaf_id
781    }
782
783    /// Check if `ancestor_id` is a parent/grandparent/etc. of `descendant_id`.
784    pub fn is_ancestor_of(&self, ancestor_id: NodeId, descendant_id: NodeId) -> bool {
785        if ancestor_id == descendant_id {
786            return false;
787        }
788        let mut current_idx = match self.index_of(descendant_id) {
789            Some(idx) => idx,
790            None => return false,
791        };
792        while let Some(parent_idx) = self.parent(current_idx) {
793            if self.graph[parent_idx].id == ancestor_id {
794                return true;
795            }
796            if matches!(self.graph[parent_idx].kind, NodeKind::Root) {
797                break;
798            }
799            current_idx = parent_idx;
800        }
801        false
802    }
803}
804
805impl Default for SceneGraph {
806    fn default() -> Self {
807        Self::new()
808    }
809}
810
811/// Merge `src` style into `dst`, overwriting only `Some` fields.
812fn merge_style(dst: &mut Style, src: &Style) {
813    if src.fill.is_some() {
814        dst.fill = src.fill.clone();
815    }
816    if src.stroke.is_some() {
817        dst.stroke = src.stroke.clone();
818    }
819    if src.font.is_some() {
820        dst.font = src.font.clone();
821    }
822    if src.corner_radius.is_some() {
823        dst.corner_radius = src.corner_radius;
824    }
825    if src.opacity.is_some() {
826        dst.opacity = src.opacity;
827    }
828    if src.shadow.is_some() {
829        dst.shadow = src.shadow.clone();
830    }
831
832    if src.text_align.is_some() {
833        dst.text_align = src.text_align;
834    }
835    if src.text_valign.is_some() {
836        dst.text_valign = src.text_valign;
837    }
838    if src.scale.is_some() {
839        dst.scale = src.scale;
840    }
841}
842
843// ─── Resolved positions (output of layout solver) ────────────────────────
844
845/// Resolved absolute bounding box after constraint solving.
846#[derive(Debug, Clone, Copy, Default)]
847pub struct ResolvedBounds {
848    pub x: f32,
849    pub y: f32,
850    pub width: f32,
851    pub height: f32,
852}
853
854impl ResolvedBounds {
855    pub fn contains(&self, px: f32, py: f32) -> bool {
856        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
857    }
858
859    pub fn center(&self) -> (f32, f32) {
860        (self.x + self.width / 2.0, self.y + self.height / 2.0)
861    }
862
863    /// Check if this bounds intersects with a rectangle (AABB overlap).
864    pub fn intersects_rect(&self, rx: f32, ry: f32, rw: f32, rh: f32) -> bool {
865        self.x < rx + rw
866            && self.x + self.width > rx
867            && self.y < ry + rh
868            && self.y + self.height > ry
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    #[test]
877    fn scene_graph_basics() {
878        let mut sg = SceneGraph::new();
879        let rect = SceneNode::new(
880            NodeId::intern("box1"),
881            NodeKind::Rect {
882                width: 100.0,
883                height: 50.0,
884            },
885        );
886        let idx = sg.add_node(sg.root, rect);
887
888        assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
889        assert_eq!(sg.children(sg.root).len(), 1);
890        assert_eq!(sg.children(sg.root)[0], idx);
891    }
892
893    #[test]
894    fn color_hex_roundtrip() {
895        let c = Color::from_hex("#6C5CE7").unwrap();
896        assert_eq!(c.to_hex(), "#6C5CE7");
897
898        let c2 = Color::from_hex("#FF000080").unwrap();
899        assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
900        assert!(c2.to_hex().len() == 9); // #RRGGBBAA
901    }
902
903    #[test]
904    fn style_merging() {
905        let mut sg = SceneGraph::new();
906        sg.define_style(
907            NodeId::intern("base"),
908            Style {
909                fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
910                font: Some(FontSpec {
911                    family: "Inter".into(),
912                    weight: 400,
913                    size: 14.0,
914                }),
915                ..Default::default()
916            },
917        );
918
919        let mut node = SceneNode::new(
920            NodeId::intern("txt"),
921            NodeKind::Text {
922                content: "hi".into(),
923            },
924        );
925        node.use_styles.push(NodeId::intern("base"));
926        node.style.font = Some(FontSpec {
927            family: "Inter".into(),
928            weight: 700,
929            size: 24.0,
930        });
931
932        let resolved = sg.resolve_style(&node, &[]);
933        // Fill comes from base style
934        assert!(resolved.fill.is_some());
935        // Font comes from inline override
936        let f = resolved.font.unwrap();
937        assert_eq!(f.weight, 700);
938        assert_eq!(f.size, 24.0);
939    }
940
941    #[test]
942    fn style_merging_align() {
943        let mut sg = SceneGraph::new();
944        sg.define_style(
945            NodeId::intern("centered"),
946            Style {
947                text_align: Some(TextAlign::Center),
948                text_valign: Some(TextVAlign::Middle),
949                ..Default::default()
950            },
951        );
952
953        // Node with use: centered + inline override of text_align to Right
954        let mut node = SceneNode::new(
955            NodeId::intern("overridden"),
956            NodeKind::Text {
957                content: "hello".into(),
958            },
959        );
960        node.use_styles.push(NodeId::intern("centered"));
961        node.style.text_align = Some(TextAlign::Right);
962
963        let resolved = sg.resolve_style(&node, &[]);
964        // Horizontal should be overridden to Right
965        assert_eq!(resolved.text_align, Some(TextAlign::Right));
966        // Vertical should come from base style (Middle)
967        assert_eq!(resolved.text_valign, Some(TextVAlign::Middle));
968    }
969
970    #[test]
971    fn test_effective_target_returns_leaf() {
972        let mut sg = SceneGraph::new();
973
974        // Root -> Group -> Rect
975        let group_id = NodeId::intern("my_group");
976        let rect_id = NodeId::intern("my_rect");
977
978        let group = SceneNode::new(
979            group_id,
980            NodeKind::Group {
981                layout: LayoutMode::Free,
982            },
983        );
984        let rect = SceneNode::new(
985            rect_id,
986            NodeKind::Rect {
987                width: 10.0,
988                height: 10.0,
989            },
990        );
991
992        let group_idx = sg.add_node(sg.root, group);
993        sg.add_node(group_idx, rect);
994
995        // Always returns leaf directly, regardless of selection state
996        assert_eq!(sg.effective_target(rect_id, &[]), rect_id);
997        assert_eq!(sg.effective_target(rect_id, &[group_id]), rect_id);
998        assert_eq!(sg.effective_target(rect_id, &[rect_id]), rect_id);
999    }
1000
1001    #[test]
1002    fn test_effective_target_nested_returns_leaf() {
1003        let mut sg = SceneGraph::new();
1004
1005        // Root -> group_outer -> group_inner -> rect_leaf
1006        let outer_id = NodeId::intern("group_outer");
1007        let inner_id = NodeId::intern("group_inner");
1008        let leaf_id = NodeId::intern("rect_leaf");
1009
1010        let outer = SceneNode::new(
1011            outer_id,
1012            NodeKind::Group {
1013                layout: LayoutMode::Free,
1014            },
1015        );
1016        let inner = SceneNode::new(
1017            inner_id,
1018            NodeKind::Group {
1019                layout: LayoutMode::Free,
1020            },
1021        );
1022        let leaf = SceneNode::new(
1023            leaf_id,
1024            NodeKind::Rect {
1025                width: 50.0,
1026                height: 50.0,
1027            },
1028        );
1029
1030        let outer_idx = sg.add_node(sg.root, outer);
1031        let inner_idx = sg.add_node(outer_idx, inner);
1032        sg.add_node(inner_idx, leaf);
1033
1034        // Always returns leaf directly, regardless of selection state
1035        assert_eq!(sg.effective_target(leaf_id, &[]), leaf_id);
1036        assert_eq!(sg.effective_target(leaf_id, &[outer_id]), leaf_id);
1037        assert_eq!(sg.effective_target(leaf_id, &[outer_id, inner_id]), leaf_id);
1038    }
1039
1040    #[test]
1041    fn test_is_ancestor_of() {
1042        let mut sg = SceneGraph::new();
1043
1044        // Root -> Group -> Rect
1045        let group_id = NodeId::intern("grp");
1046        let rect_id = NodeId::intern("r1");
1047        let other_id = NodeId::intern("other");
1048
1049        let group = SceneNode::new(
1050            group_id,
1051            NodeKind::Group {
1052                layout: LayoutMode::Free,
1053            },
1054        );
1055        let rect = SceneNode::new(
1056            rect_id,
1057            NodeKind::Rect {
1058                width: 10.0,
1059                height: 10.0,
1060            },
1061        );
1062        let other = SceneNode::new(
1063            other_id,
1064            NodeKind::Rect {
1065                width: 5.0,
1066                height: 5.0,
1067            },
1068        );
1069
1070        let group_idx = sg.add_node(sg.root, group);
1071        sg.add_node(group_idx, rect);
1072        sg.add_node(sg.root, other);
1073
1074        // Group is ancestor of rect
1075        assert!(sg.is_ancestor_of(group_id, rect_id));
1076        // Root is ancestor of rect (grandparent)
1077        assert!(sg.is_ancestor_of(NodeId::intern("root"), rect_id));
1078        // Rect is NOT ancestor of group
1079        assert!(!sg.is_ancestor_of(rect_id, group_id));
1080        // Self is NOT ancestor of self
1081        assert!(!sg.is_ancestor_of(group_id, group_id));
1082        // Other is not ancestor of rect (sibling)
1083        assert!(!sg.is_ancestor_of(other_id, rect_id));
1084    }
1085
1086    #[test]
1087    fn test_resolve_style_scale_animation() {
1088        let sg = SceneGraph::new();
1089
1090        let mut node = SceneNode::new(
1091            NodeId::intern("btn"),
1092            NodeKind::Rect {
1093                width: 100.0,
1094                height: 40.0,
1095            },
1096        );
1097        node.style.fill = Some(Paint::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
1098        node.animations.push(AnimKeyframe {
1099            trigger: AnimTrigger::Press,
1100            duration_ms: 100,
1101            easing: Easing::EaseOut,
1102            properties: AnimProperties {
1103                scale: Some(0.97),
1104                ..Default::default()
1105            },
1106        });
1107
1108        // Without press trigger: scale should be None
1109        let resolved = sg.resolve_style(&node, &[]);
1110        assert!(resolved.scale.is_none());
1111
1112        // With press trigger: scale should be 0.97
1113        let resolved = sg.resolve_style(&node, &[AnimTrigger::Press]);
1114        assert_eq!(resolved.scale, Some(0.97));
1115        // Fill should still be present
1116        assert!(resolved.fill.is_some());
1117    }
1118}