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