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/// Compile-time lookup table for fast hex character parsing.
28/// Maps ASCII bytes to their 0-15 hex value, or 255 if invalid.
29/// This avoids branching (match statement) in the hot parsing path.
30const HEX_LUT: [u8; 256] = {
31    let mut lut = [255; 256];
32    let mut i = 0;
33    while i < 10 {
34        lut[(b'0' + i) as usize] = i;
35        i += 1;
36    }
37    let mut i = 0;
38    while i < 6 {
39        lut[(b'a' + i) as usize] = i + 10;
40        lut[(b'A' + i) as usize] = i + 10;
41        i += 1;
42    }
43    lut
44};
45
46/// Helper to parse a single hex digit.
47#[inline(always)]
48pub fn hex_val(c: u8) -> Option<u8> {
49    let val = HEX_LUT[c as usize];
50    if val != 255 { Some(val) } else { None }
51}
52
53impl Color {
54    /// Create a new color from RGBA components.
55    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
56        Self { r, g, b, a }
57    }
58
59    /// Parse a hex color string: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`.
60    /// The string may optionally start with `#`.
61    pub fn from_hex(hex: &str) -> Option<Self> {
62        let hex = hex.strip_prefix('#').unwrap_or(hex);
63        let bytes = hex.as_bytes();
64
65        match bytes.len() {
66            3 => {
67                let r = hex_val(bytes[0])?;
68                let g = hex_val(bytes[1])?;
69                let b = hex_val(bytes[2])?;
70                Some(Self::rgba(
71                    (r * 17) as f32 / 255.0,
72                    (g * 17) as f32 / 255.0,
73                    (b * 17) as f32 / 255.0,
74                    1.0,
75                ))
76            }
77            4 => {
78                let r = hex_val(bytes[0])?;
79                let g = hex_val(bytes[1])?;
80                let b = hex_val(bytes[2])?;
81                let a = hex_val(bytes[3])?;
82                Some(Self::rgba(
83                    (r * 17) as f32 / 255.0,
84                    (g * 17) as f32 / 255.0,
85                    (b * 17) as f32 / 255.0,
86                    (a * 17) as f32 / 255.0,
87                ))
88            }
89            6 => {
90                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
91                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
92                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
93                Some(Self::rgba(
94                    r as f32 / 255.0,
95                    g as f32 / 255.0,
96                    b as f32 / 255.0,
97                    1.0,
98                ))
99            }
100            8 => {
101                let r = hex_val(bytes[0])? << 4 | hex_val(bytes[1])?;
102                let g = hex_val(bytes[2])? << 4 | hex_val(bytes[3])?;
103                let b = hex_val(bytes[4])? << 4 | hex_val(bytes[5])?;
104                let a = hex_val(bytes[6])? << 4 | hex_val(bytes[7])?;
105                Some(Self::rgba(
106                    r as f32 / 255.0,
107                    g as f32 / 255.0,
108                    b as f32 / 255.0,
109                    a as f32 / 255.0,
110                ))
111            }
112            _ => None,
113        }
114    }
115
116    /// Emit as shortest valid hex string.
117    pub fn to_hex(&self) -> String {
118        let r = (self.r * 255.0).round() as u8;
119        let g = (self.g * 255.0).round() as u8;
120        let b = (self.b * 255.0).round() as u8;
121        let a = (self.a * 255.0).round() as u8;
122        if a == 255 {
123            format!("#{r:02X}{g:02X}{b:02X}")
124        } else {
125            format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
126        }
127    }
128}
129
130/// A gradient stop.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct GradientStop {
133    pub offset: f32, // 0.0 .. 1.0
134    pub color: Color,
135}
136
137/// Fill or stroke paint.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub enum Paint {
140    Solid(Color),
141    LinearGradient {
142        angle: f32, // degrees
143        stops: Vec<GradientStop>,
144    },
145    RadialGradient {
146        stops: Vec<GradientStop>,
147    },
148}
149
150// ─── Stroke ──────────────────────────────────────────────────────────────
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Stroke {
154    pub paint: Paint,
155    pub width: f32,
156    pub cap: StrokeCap,
157    pub join: StrokeJoin,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161pub enum StrokeCap {
162    Butt,
163    Round,
164    Square,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168pub enum StrokeJoin {
169    Miter,
170    Round,
171    Bevel,
172}
173
174impl Default for Stroke {
175    fn default() -> Self {
176        Self {
177            paint: Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0)),
178            width: 1.0,
179            cap: StrokeCap::Butt,
180            join: StrokeJoin::Miter,
181        }
182    }
183}
184
185// ─── Font / Text ─────────────────────────────────────────────────────────
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct FontSpec {
189    pub family: String,
190    pub weight: u16, // 100..900
191    pub size: f32,
192}
193
194impl Default for FontSpec {
195    fn default() -> Self {
196        Self {
197            family: "Inter".into(),
198            weight: 400,
199            size: 14.0,
200        }
201    }
202}
203
204// ─── Path data ───────────────────────────────────────────────────────────
205
206/// A single path command (SVG-like but simplified).
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub enum PathCmd {
209    MoveTo(f32, f32),
210    LineTo(f32, f32),
211    QuadTo(f32, f32, f32, f32),            // control, end
212    CubicTo(f32, f32, f32, f32, f32, f32), // c1, c2, end
213    Close,
214}
215
216// ─── Image data ──────────────────────────────────────────────────────────
217
218/// Source for an embedded image.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub enum ImageSource {
221    /// Relative file path: `src: "assets/hero.png"`.
222    File(String),
223}
224
225/// How an image fits within its declared dimensions.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
227pub enum ImageFit {
228    /// Scale to cover bounds, crop overflow.
229    #[default]
230    Cover,
231    /// Scale to fit within bounds, letterbox.
232    Contain,
233    /// Stretch to exact dimensions.
234    Fill,
235    /// Natural size, no scaling.
236    None,
237}
238
239// ─── Shadow ──────────────────────────────────────────────────────────────
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct Shadow {
243    pub offset_x: f32,
244    pub offset_y: f32,
245    pub blur: f32,
246    pub color: Color,
247}
248
249// ─── Styling ─────────────────────────────────────────────────────────────
250
251/// Horizontal text alignment.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
253pub enum TextAlign {
254    Left,
255    #[default]
256    Center,
257    Right,
258}
259
260/// Vertical text alignment.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
262pub enum TextVAlign {
263    Top,
264    #[default]
265    Middle,
266    Bottom,
267}
268
269/// Horizontal placement of a child within its parent.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
271pub enum HPlace {
272    Left,
273    #[default]
274    Center,
275    Right,
276}
277
278/// Vertical placement of a child within its parent.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
280pub enum VPlace {
281    Top,
282    #[default]
283    Middle,
284    Bottom,
285}
286
287/// A reusable style set that nodes can reference via `use: style_name`.
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct Properties {
290    pub fill: Option<Paint>,
291    pub stroke: Option<Stroke>,
292    pub font: Option<FontSpec>,
293    pub corner_radius: Option<f32>,
294    pub opacity: Option<f32>,
295    pub shadow: Option<Shadow>,
296
297    /// Horizontal text alignment (default: Center).
298    pub text_align: Option<TextAlign>,
299    /// Vertical text alignment (default: Middle).
300    pub text_valign: Option<TextVAlign>,
301
302    /// Scale factor applied during rendering (from animations).
303    pub scale: Option<f32>,
304}
305
306// ─── Animation ───────────────────────────────────────────────────────────
307
308/// The trigger for an animation.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub enum AnimTrigger {
311    Hover,
312    Press,
313    Enter, // viewport enter
314    Custom(String),
315}
316
317/// Easing function.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub enum Easing {
320    Linear,
321    EaseIn,
322    EaseOut,
323    EaseInOut,
324    Spring,
325    CubicBezier(f32, f32, f32, f32),
326}
327
328/// A property animation keyframe.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct AnimKeyframe {
331    pub trigger: AnimTrigger,
332    pub duration_ms: u32,
333    pub easing: Easing,
334    pub properties: AnimProperties,
335    /// Optional post-revert cooldown (ms) before re-triggerable.
336    /// `None` = no cooldown (default).
337    pub delay_ms: Option<u32>,
338}
339
340/// Animatable property overrides.
341#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct AnimProperties {
343    pub fill: Option<Paint>,
344    pub opacity: Option<f32>,
345    pub scale: Option<f32>,
346    pub rotate: Option<f32>, // degrees
347    pub translate: Option<(f32, f32)>,
348}
349
350// ─── Imports ─────────────────────────────────────────────────────────────
351
352/// A file import declaration: `import "path.fd" as namespace`.
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct Import {
355    /// Relative file path, e.g. "components/buttons.fd".
356    pub path: String,
357    /// Namespace alias, e.g. "buttons".
358    pub namespace: String,
359}
360
361// ─── Layout Constraints ──────────────────────────────────────────────────
362
363/// Constraint-based layout — no absolute coordinates in the format.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub enum Constraint {
366    /// Center this node within a target (e.g. `canvas` or another node).
367    CenterIn(NodeId),
368    /// Position relative: dx, dy from a reference node.
369    Offset { from: NodeId, dx: f32, dy: f32 },
370    /// Fill the parent with optional padding.
371    FillParent { pad: f32 },
372    /// Parent-relative position (used for drag-placed or pinned nodes).
373    /// Resolved as `parent.x + x`, `parent.y + y` by the layout solver.
374    Position { x: f32, y: f32 },
375}
376
377// ─── Edges (connections between nodes) ───────────────────────────────────
378
379/// Arrow head placement on an edge.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
381pub enum ArrowKind {
382    #[default]
383    None,
384    Start,
385    End,
386    Both,
387}
388
389/// How the edge path is drawn between two nodes.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
391pub enum CurveKind {
392    #[default]
393    Straight,
394    Smooth,
395    Step,
396}
397
398/// An edge endpoint — either connected to a node or a free point in scene-space.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub enum EdgeAnchor {
401    /// Connected to a node center.
402    Node(NodeId),
403    /// Fixed position in scene-space (standalone arrow).
404    Point(f32, f32),
405}
406
407impl EdgeAnchor {
408    /// Return the NodeId if this is a Node anchor.
409    pub fn node_id(&self) -> Option<NodeId> {
410        match self {
411            Self::Node(id) => Some(*id),
412            Self::Point(_, _) => None,
413        }
414    }
415}
416
417/// Document-level default styles for edges.
418///
419/// When an `edge_defaults` block is present, individual edges omit
420/// properties that match the defaults — saving tokens for documents
421/// with many similarly styled edges.
422#[derive(Debug, Clone, Default, Serialize, Deserialize)]
423pub struct EdgeDefaults {
424    pub props: Properties,
425    pub arrow: Option<ArrowKind>,
426    pub curve: Option<CurveKind>,
427}
428
429/// A visual connection between two endpoints.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct Edge {
432    pub id: NodeId,
433    pub from: EdgeAnchor,
434    pub to: EdgeAnchor,
435    /// Optional text child node (max 1). The node lives in the SceneGraph.
436    pub text_child: Option<NodeId>,
437    pub props: Properties,
438    pub use_styles: SmallVec<[NodeId; 2]>,
439    pub arrow: ArrowKind,
440    pub curve: CurveKind,
441    pub spec: Option<String>,
442    pub animations: SmallVec<[AnimKeyframe; 2]>,
443    pub flow: Option<FlowAnim>,
444    /// Offset of the edge text from the midpoint, set when label is dragged.
445    pub label_offset: Option<(f32, f32)>,
446}
447
448/// Flow animation kind — continuous motion along the edge path.
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450pub enum FlowKind {
451    /// A glowing dot traveling from → to on a loop.
452    Pulse,
453    /// Marching dashes along the edge (stroke-dashoffset animation).
454    Dash,
455}
456
457/// A flow animation attached to an edge.
458#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
459pub struct FlowAnim {
460    pub kind: FlowKind,
461    pub duration_ms: u32,
462}
463
464/// Group layout mode (for children arrangement).
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub enum LayoutMode {
467    /// Free / absolute positioning of children.
468    /// Optional padding insets the content area (default 0).
469    Free { pad: f32 },
470    /// Column (vertical stack).
471    Column { gap: f32, pad: f32 },
472    /// Row (horizontal stack).
473    Row { gap: f32, pad: f32 },
474    /// Grid layout.
475    Grid { cols: u32, gap: f32, pad: f32 },
476}
477
478impl Default for LayoutMode {
479    fn default() -> Self {
480        LayoutMode::Free { pad: 0.0 }
481    }
482}
483
484// ─── Scene Graph Nodes ───────────────────────────────────────────────────
485
486/// The node kinds in the scene DAG.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub enum NodeKind {
489    /// Root of the document.
490    Root,
491
492    /// Generic placeholder — no visual shape assigned yet.
493    /// Used for spec-only nodes: `@login_btn { spec "CTA" }`
494    Generic,
495
496    /// Organizational container (like Figma Group).
497    /// Auto-sizes to children, no own styles or layout modes.
498    Group,
499
500    /// Frame — visible container with explicit size and optional clipping.
501    /// Like a Figma frame: has fill/stroke, declared dimensions, clips overflow.
502    Frame {
503        width: f32,
504        height: f32,
505        clip: bool,
506        layout: LayoutMode,
507    },
508
509    /// Rectangle.
510    Rect { width: f32, height: f32 },
511
512    /// Ellipse / circle.
513    Ellipse { rx: f32, ry: f32 },
514
515    /// Freeform path (pen tool output).
516    Path { commands: Vec<PathCmd> },
517
518    /// Embedded image (R3.32).
519    Image {
520        source: ImageSource,
521        width: f32,
522        height: f32,
523        fit: ImageFit,
524    },
525
526    /// Text label. Optional `max_width` constrains horizontal extent
527    /// for word wrapping (set via resize handle drag).
528    Text {
529        content: String,
530        max_width: Option<f32>,
531    },
532}
533
534impl NodeKind {
535    /// Return the FD format keyword for this node kind.
536    pub fn kind_name(&self) -> &'static str {
537        match self {
538            Self::Root => "root",
539            Self::Generic => "generic",
540            Self::Group => "group",
541            Self::Frame { .. } => "frame",
542            Self::Rect { .. } => "rect",
543            Self::Ellipse { .. } => "ellipse",
544            Self::Path { .. } => "path",
545            Self::Image { .. } => "image",
546            Self::Text { .. } => "text",
547        }
548    }
549}
550
551/// A single node in the scene graph.
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct SceneNode {
554    /// The node's ID (e.g. `@login_form`). Anonymous nodes get auto-IDs.
555    pub id: NodeId,
556
557    /// What kind of element this is.
558    pub kind: NodeKind,
559
560    /// Inline style overrides on this node.
561    pub props: Properties,
562
563    /// Named style references (`use: base_text`).
564    pub use_styles: SmallVec<[NodeId; 2]>,
565
566    /// Constraint-based positioning.
567    pub constraints: SmallVec<[Constraint; 2]>,
568
569    /// Animations attached to this node.
570    pub animations: SmallVec<[AnimKeyframe; 2]>,
571
572    /// Markdown spec content (`spec { ... }` block, also accepts legacy `note`).
573    pub spec: Option<String>,
574
575    /// Line comments (`# text`) that appeared before this node in the source.
576    /// Preserved across parse/emit round-trips so format passes don't delete them.
577    pub comments: Vec<String>,
578
579    /// 9-position placement of this child within its parent.
580    /// `None` = default positioning (auto-center for text, origin for others).
581    pub place: Option<(HPlace, VPlace)>,
582
583    /// Whether this node is locked (prevents move, resize, delete on canvas).
584    /// Parsed from `locked: true` in the FD format.
585    pub locked: bool,
586}
587
588impl SceneNode {
589    /// Create a new SceneNode with a given ID and kind.
590    pub fn new(id: NodeId, kind: NodeKind) -> Self {
591        Self {
592            id,
593            kind,
594            props: Properties::default(),
595            use_styles: SmallVec::new(),
596            constraints: SmallVec::new(),
597            animations: SmallVec::new(),
598            spec: None,
599            comments: Vec::new(),
600            place: None,
601            locked: false,
602        }
603    }
604}
605
606// ─── Graph Snapshot (for ReadMode::Diff) ─────────────────────────────────
607
608/// A lightweight snapshot of graph state for diff computation.
609///
610/// Stores per-node and per-edge hashes so that changes can be detected
611/// by comparing hashes rather than storing full copies of the graph.
612#[derive(Debug, Clone, Default)]
613pub struct GraphSnapshot {
614    /// Hash of each node's emitted text, keyed by NodeId.
615    pub node_hashes: HashMap<NodeId, u64>,
616    /// Hash of each edge's emitted text, keyed by edge id.
617    pub edge_hashes: HashMap<NodeId, u64>,
618}
619
620// ─── Scene Graph ─────────────────────────────────────────────────────────
621
622/// The complete FD document — a DAG of `SceneNode` values.
623///
624/// Edges go from parent → child. Style definitions are stored separately
625/// in a hashmap for lookup by name.
626#[derive(Debug, Clone)]
627pub struct SceneGraph {
628    /// The underlying directed graph.
629    pub graph: StableDiGraph<SceneNode, ()>,
630
631    /// The root node index.
632    pub root: NodeIndex,
633
634    /// Named style definitions (`style base_text { ... }`).
635    pub styles: HashMap<NodeId, Properties>,
636
637    /// Index from NodeId → NodeIndex for fast lookup.
638    pub id_index: HashMap<NodeId, NodeIndex>,
639
640    /// Visual edges (connections between nodes).
641    pub edges: Vec<Edge>,
642
643    /// File imports with namespace aliases.
644    pub imports: Vec<Import>,
645
646    /// Explicit child ordering set by `sort_nodes`.
647    /// When present for a parent, `children()` returns this order
648    /// instead of the default `NodeIndex` sort.
649    pub sorted_child_order: HashMap<NodeIndex, Vec<NodeIndex>>,
650
651    /// Document-level default styles for edges.
652    /// When present, individual edge properties matching the defaults are omitted.
653    pub edge_defaults: Option<EdgeDefaults>,
654}
655
656impl SceneGraph {
657    /// Create a new empty scene graph with a root node.
658    #[must_use]
659    pub fn new() -> Self {
660        let mut graph = StableDiGraph::new();
661        let root_node = SceneNode::new(NodeId::intern("root"), NodeKind::Root);
662        let root = graph.add_node(root_node);
663
664        let mut id_index = HashMap::new();
665        id_index.insert(NodeId::intern("root"), root);
666
667        Self {
668            graph,
669            root,
670            styles: HashMap::new(),
671            id_index,
672            edges: Vec::new(),
673            imports: Vec::new(),
674            sorted_child_order: HashMap::new(),
675            edge_defaults: None,
676        }
677    }
678
679    /// Add a node as a child of `parent`. Returns the new node's index.
680    pub fn add_node(&mut self, parent: NodeIndex, node: SceneNode) -> NodeIndex {
681        let id = node.id;
682        let idx = self.graph.add_node(node);
683        self.graph.add_edge(parent, idx, ());
684        self.id_index.insert(id, idx);
685        idx
686    }
687
688    /// Remove a node safely, keeping the `id_index` synchronized.
689    pub fn remove_node(&mut self, idx: NodeIndex) -> Option<SceneNode> {
690        let removed = self.graph.remove_node(idx);
691        if let Some(removed_node) = &removed {
692            self.id_index.remove(&removed_node.id);
693        }
694        removed
695    }
696
697    /// Look up a node by its `@id`.
698    pub fn get_by_id(&self, id: NodeId) -> Option<&SceneNode> {
699        self.id_index.get(&id).map(|idx| &self.graph[*idx])
700    }
701
702    /// Look up a node mutably by its `@id`.
703    pub fn get_by_id_mut(&mut self, id: NodeId) -> Option<&mut SceneNode> {
704        self.id_index
705            .get(&id)
706            .copied()
707            .map(|idx| &mut self.graph[idx])
708    }
709
710    /// Get the index for a NodeId.
711    pub fn index_of(&self, id: NodeId) -> Option<NodeIndex> {
712        self.id_index.get(&id).copied()
713    }
714
715    /// Get the parent index of a node.
716    pub fn parent(&self, idx: NodeIndex) -> Option<NodeIndex> {
717        self.graph
718            .neighbors_directed(idx, petgraph::Direction::Incoming)
719            .next()
720    }
721
722    /// Reparent a node to a new parent.
723    pub fn reparent_node(&mut self, child: NodeIndex, new_parent: NodeIndex) {
724        if let Some(old_parent) = self.parent(child)
725            && let Some(edge) = self.graph.find_edge(old_parent, child)
726        {
727            self.graph.remove_edge(edge);
728            // Clean up stale sorted_child_order for the old parent
729            // to prevent ghost children in the emitter.
730            if let Some(order) = self.sorted_child_order.get_mut(&old_parent) {
731                order.retain(|&idx| idx != child);
732            }
733        }
734        self.graph.add_edge(new_parent, child, ());
735    }
736
737    /// Get children of a node in document (insertion) order.
738    ///
739    /// Sorts by `NodeIndex` so the result is deterministic regardless of
740    /// how `petgraph` iterates its adjacency list on different targets
741    /// (native vs WASM).
742    pub fn children(&self, idx: NodeIndex) -> Vec<NodeIndex> {
743        // If an explicit sort order was set (by sort_nodes), use it.
744        // Filter out stale entries whose edge was removed (e.g. by reparent_node)
745        // as a defensive guard against sorted_child_order desync.
746        if let Some(order) = self.sorted_child_order.get(&idx) {
747            return order
748                .iter()
749                .copied()
750                .filter(|&c| self.graph.find_edge(idx, c).is_some())
751                .collect();
752        }
753
754        let mut children: Vec<NodeIndex> = self
755            .graph
756            .neighbors_directed(idx, petgraph::Direction::Outgoing)
757            .collect();
758        children.sort();
759        children
760    }
761
762    /// Move a child one step backward in z-order (swap with previous sibling).
763    /// Returns true if the z-order changed.
764    pub fn send_backward(&mut self, child: NodeIndex) -> bool {
765        let parent = match self.parent(child) {
766            Some(p) => p,
767            None => return false,
768        };
769        let siblings = self.children(parent);
770        let pos = match siblings.iter().position(|&s| s == child) {
771            Some(p) => p,
772            None => return false,
773        };
774        if pos == 0 {
775            return false; // already at back
776        }
777        // Rebuild edges in swapped order
778        self.rebuild_child_order(parent, &siblings, pos, pos - 1)
779    }
780
781    /// Move a child one step forward in z-order (swap with next sibling).
782    /// Returns true if the z-order changed.
783    pub fn bring_forward(&mut self, child: NodeIndex) -> bool {
784        let parent = match self.parent(child) {
785            Some(p) => p,
786            None => return false,
787        };
788        let siblings = self.children(parent);
789        let pos = match siblings.iter().position(|&s| s == child) {
790            Some(p) => p,
791            None => return false,
792        };
793        if pos >= siblings.len() - 1 {
794            return false; // already at front
795        }
796        self.rebuild_child_order(parent, &siblings, pos, pos + 1)
797    }
798
799    /// Move a child to the back of z-order (first child).
800    pub fn send_to_back(&mut self, child: NodeIndex) -> bool {
801        let parent = match self.parent(child) {
802            Some(p) => p,
803            None => return false,
804        };
805        let siblings = self.children(parent);
806        let pos = match siblings.iter().position(|&s| s == child) {
807            Some(p) => p,
808            None => return false,
809        };
810        if pos == 0 {
811            return false;
812        }
813        self.rebuild_child_order(parent, &siblings, pos, 0)
814    }
815
816    /// Move a child to the front of z-order (last child).
817    pub fn bring_to_front(&mut self, child: NodeIndex) -> bool {
818        let parent = match self.parent(child) {
819            Some(p) => p,
820            None => return false,
821        };
822        let siblings = self.children(parent);
823        let pos = match siblings.iter().position(|&s| s == child) {
824            Some(p) => p,
825            None => return false,
826        };
827        let last = siblings.len() - 1;
828        if pos == last {
829            return false;
830        }
831        self.rebuild_child_order(parent, &siblings, pos, last)
832    }
833
834    /// Move a child to a specific index within its parent's children.
835    /// Clamps `target_index` to `[0, sibling_count - 1]`.
836    /// Returns true if the order changed.
837    pub fn move_child_to_index(&mut self, child: NodeIndex, target_index: usize) -> bool {
838        let parent = match self.parent(child) {
839            Some(p) => p,
840            None => return false,
841        };
842        let siblings = self.children(parent);
843        let from = match siblings.iter().position(|&s| s == child) {
844            Some(p) => p,
845            None => return false,
846        };
847        let to = target_index.min(siblings.len().saturating_sub(1));
848        if from == to {
849            return false;
850        }
851        self.rebuild_child_order(parent, &siblings, from, to)
852    }
853
854    /// Rebuild child edges, moving child at `from` to `to` position.
855    fn rebuild_child_order(
856        &mut self,
857        parent: NodeIndex,
858        siblings: &[NodeIndex],
859        from: usize,
860        to: usize,
861    ) -> bool {
862        // Remove all edges from parent to children
863        for &sib in siblings {
864            if let Some(edge) = self.graph.find_edge(parent, sib) {
865                self.graph.remove_edge(edge);
866            }
867        }
868        // Build new order
869        let mut new_order: Vec<NodeIndex> = siblings.to_vec();
870        let child = new_order.remove(from);
871        new_order.insert(to, child);
872        // Re-add edges in new order
873        for &sib in &new_order {
874            self.graph.add_edge(parent, sib, ());
875        }
876        // Store explicit child order so children() returns z-order, not NodeIndex order
877        self.sorted_child_order.insert(parent, new_order);
878        true
879    }
880
881    /// Define a named style.
882    pub fn define_style(&mut self, name: NodeId, style: Properties) {
883        self.styles.insert(name, style);
884    }
885
886    /// Resolve a node's effective style (merging `use` references + inline overrides + active animations).
887    pub fn resolve_style(&self, node: &SceneNode, active_triggers: &[AnimTrigger]) -> Properties {
888        let mut resolved = Properties::default();
889
890        // Apply referenced styles in order
891        for style_id in &node.use_styles {
892            if let Some(base) = self.styles.get(style_id) {
893                merge_style(&mut resolved, base);
894            }
895        }
896
897        // Apply inline overrides (take precedence)
898        merge_style(&mut resolved, &node.props);
899
900        // Apply active animation state overrides
901        for anim in &node.animations {
902            if active_triggers.contains(&anim.trigger) {
903                if anim.properties.fill.is_some() {
904                    resolved.fill = anim.properties.fill.clone();
905                }
906                if anim.properties.opacity.is_some() {
907                    resolved.opacity = anim.properties.opacity;
908                }
909                if anim.properties.scale.is_some() {
910                    resolved.scale = anim.properties.scale;
911                }
912            }
913        }
914
915        resolved
916    }
917
918    /// Rebuild the `id_index` (needed after deserialization).
919    pub fn rebuild_index(&mut self) {
920        self.id_index.clear();
921        for idx in self.graph.node_indices() {
922            let id = self.graph[idx].id;
923            self.id_index.insert(id, idx);
924        }
925    }
926
927    /// Resolve an edge's effective style (merging `use` references + inline overrides + active animations).
928    pub fn resolve_style_for_edge(
929        &self,
930        edge: &Edge,
931        active_triggers: &[AnimTrigger],
932    ) -> Properties {
933        let mut resolved = Properties::default();
934        for style_id in &edge.use_styles {
935            if let Some(base) = self.styles.get(style_id) {
936                merge_style(&mut resolved, base);
937            }
938        }
939        merge_style(&mut resolved, &edge.props);
940
941        for anim in &edge.animations {
942            if active_triggers.contains(&anim.trigger) {
943                if anim.properties.fill.is_some() {
944                    resolved.fill = anim.properties.fill.clone();
945                }
946                if anim.properties.opacity.is_some() {
947                    resolved.opacity = anim.properties.opacity;
948                }
949                if anim.properties.scale.is_some() {
950                    resolved.scale = anim.properties.scale;
951                }
952            }
953        }
954
955        resolved
956    }
957
958    /// Resolve the effective click target for a leaf node.
959    ///
960    /// Figma-style group selection with progressive drill-down:
961    /// - **First click** → selects the topmost group ancestor (below root).
962    /// - **Click again** (topmost group already selected) → next-level group.
963    /// - **Click again** (all group ancestors selected) → the leaf itself.
964    pub fn effective_target(&self, leaf_id: NodeId, selected: &[NodeId]) -> NodeId {
965        let leaf_idx = match self.index_of(leaf_id) {
966            Some(idx) => idx,
967            None => return leaf_id,
968        };
969
970        // Walk up from the leaf, collecting group ancestors below root
971        // in bottom-up order.
972        let mut groups_bottom_up: Vec<NodeId> = Vec::new();
973        let mut cursor = self.parent(leaf_idx);
974        while let Some(parent_idx) = cursor {
975            if parent_idx == self.root {
976                break;
977            }
978            if matches!(self.graph[parent_idx].kind, NodeKind::Group) {
979                groups_bottom_up.push(self.graph[parent_idx].id);
980            }
981            cursor = self.parent(parent_idx);
982        }
983
984        // Reverse to get top-down order (topmost group first).
985        groups_bottom_up.reverse();
986
987        // Find the deepest selected group in the ancestor chain.
988        // If a selected node is in the chain, advance to the next level down.
989        let deepest_selected_pos = groups_bottom_up
990            .iter()
991            .rposition(|gid| selected.contains(gid));
992
993        match deepest_selected_pos {
994            None => {
995                // Nothing in the chain is selected → return topmost group
996                if let Some(top) = groups_bottom_up.first() {
997                    return *top;
998                }
999            }
1000            Some(pos) if pos + 1 < groups_bottom_up.len() => {
1001                // Selected group is not the deepest → advance one level
1002                return groups_bottom_up[pos + 1];
1003            }
1004            Some(_) => {
1005                // Deepest group is already selected → drill to leaf
1006            }
1007        }
1008
1009        leaf_id
1010    }
1011
1012    /// Check if `ancestor_id` is a parent/grandparent/etc. of `descendant_id`.
1013    pub fn is_ancestor_of(&self, ancestor_id: NodeId, descendant_id: NodeId) -> bool {
1014        if ancestor_id == descendant_id {
1015            return false;
1016        }
1017        let mut current_idx = match self.index_of(descendant_id) {
1018            Some(idx) => idx,
1019            None => return false,
1020        };
1021        while let Some(parent_idx) = self.parent(current_idx) {
1022            if self.graph[parent_idx].id == ancestor_id {
1023                return true;
1024            }
1025            if matches!(self.graph[parent_idx].kind, NodeKind::Root) {
1026                break;
1027            }
1028            current_idx = parent_idx;
1029        }
1030        false
1031    }
1032}
1033
1034impl Default for SceneGraph {
1035    fn default() -> Self {
1036        Self::new()
1037    }
1038}
1039
1040/// Merge `src` style into `dst`, overwriting only `Some` fields.
1041fn merge_style(dst: &mut Properties, src: &Properties) {
1042    if src.fill.is_some() {
1043        dst.fill = src.fill.clone();
1044    }
1045    if src.stroke.is_some() {
1046        dst.stroke = src.stroke.clone();
1047    }
1048    if src.font.is_some() {
1049        dst.font = src.font.clone();
1050    }
1051    if src.corner_radius.is_some() {
1052        dst.corner_radius = src.corner_radius;
1053    }
1054    if src.opacity.is_some() {
1055        dst.opacity = src.opacity;
1056    }
1057    if src.shadow.is_some() {
1058        dst.shadow = src.shadow.clone();
1059    }
1060
1061    if src.text_align.is_some() {
1062        dst.text_align = src.text_align;
1063    }
1064    if src.text_valign.is_some() {
1065        dst.text_valign = src.text_valign;
1066    }
1067    if src.scale.is_some() {
1068        dst.scale = src.scale;
1069    }
1070}
1071
1072// ─── Resolved positions (output of layout solver) ────────────────────────
1073
1074/// Resolved absolute bounding box after constraint solving.
1075#[derive(Debug, Clone, Copy, Default, PartialEq)]
1076pub struct ResolvedBounds {
1077    pub x: f32,
1078    pub y: f32,
1079    pub width: f32,
1080    pub height: f32,
1081}
1082
1083impl ResolvedBounds {
1084    /// Check if a point (px, py) is inside these bounds.
1085    pub fn contains(&self, px: f32, py: f32) -> bool {
1086        px >= self.x && px <= self.x + self.width && py >= self.y && py <= self.y + self.height
1087    }
1088
1089    /// Return the center point of these bounds.
1090    pub fn center(&self) -> (f32, f32) {
1091        (self.x + self.width / 2.0, self.y + self.height / 2.0)
1092    }
1093
1094    /// Check if this bounds intersects with a rectangle (AABB overlap).
1095    pub fn intersects_rect(&self, rx: f32, ry: f32, rw: f32, rh: f32) -> bool {
1096        self.x < rx + rw
1097            && self.x + self.width > rx
1098            && self.y < ry + rh
1099            && self.y + self.height > ry
1100    }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106
1107    #[test]
1108    fn scene_graph_basics() {
1109        let mut sg = SceneGraph::new();
1110        let rect = SceneNode::new(
1111            NodeId::intern("box1"),
1112            NodeKind::Rect {
1113                width: 100.0,
1114                height: 50.0,
1115            },
1116        );
1117        let idx = sg.add_node(sg.root, rect);
1118
1119        assert!(sg.get_by_id(NodeId::intern("box1")).is_some());
1120        assert_eq!(sg.children(sg.root).len(), 1);
1121        assert_eq!(sg.children(sg.root)[0], idx);
1122    }
1123
1124    #[test]
1125    fn color_hex_roundtrip() {
1126        let c = Color::from_hex("#6C5CE7").unwrap();
1127        assert_eq!(c.to_hex(), "#6C5CE7");
1128
1129        let c2 = Color::from_hex("#FF000080").unwrap();
1130        assert!((c2.a - 128.0 / 255.0).abs() < 0.01);
1131        assert!(c2.to_hex().len() == 9); // #RRGGBBAA
1132    }
1133
1134    #[test]
1135    fn style_merging() {
1136        let mut sg = SceneGraph::new();
1137        sg.define_style(
1138            NodeId::intern("base"),
1139            Properties {
1140                fill: Some(Paint::Solid(Color::rgba(0.0, 0.0, 0.0, 1.0))),
1141                font: Some(FontSpec {
1142                    family: "Inter".into(),
1143                    weight: 400,
1144                    size: 14.0,
1145                }),
1146                ..Default::default()
1147            },
1148        );
1149
1150        let mut node = SceneNode::new(
1151            NodeId::intern("txt"),
1152            NodeKind::Text {
1153                content: "hi".into(),
1154                max_width: None,
1155            },
1156        );
1157        node.use_styles.push(NodeId::intern("base"));
1158        node.props.font = Some(FontSpec {
1159            family: "Inter".into(),
1160            weight: 700,
1161            size: 24.0,
1162        });
1163
1164        let resolved = sg.resolve_style(&node, &[]);
1165        // Fill comes from base style
1166        assert!(resolved.fill.is_some());
1167        // Font comes from inline override
1168        let f = resolved.font.unwrap();
1169        assert_eq!(f.weight, 700);
1170        assert_eq!(f.size, 24.0);
1171    }
1172
1173    #[test]
1174    fn style_merging_align() {
1175        let mut sg = SceneGraph::new();
1176        sg.define_style(
1177            NodeId::intern("centered"),
1178            Properties {
1179                text_align: Some(TextAlign::Center),
1180                text_valign: Some(TextVAlign::Middle),
1181                ..Default::default()
1182            },
1183        );
1184
1185        // Node with use: centered + inline override of text_align to Right
1186        let mut node = SceneNode::new(
1187            NodeId::intern("overridden"),
1188            NodeKind::Text {
1189                content: "hello".into(),
1190                max_width: None,
1191            },
1192        );
1193        node.use_styles.push(NodeId::intern("centered"));
1194        node.props.text_align = Some(TextAlign::Right);
1195
1196        let resolved = sg.resolve_style(&node, &[]);
1197        // Horizontal should be overridden to Right
1198        assert_eq!(resolved.text_align, Some(TextAlign::Right));
1199        // Vertical should come from base style (Middle)
1200        assert_eq!(resolved.text_valign, Some(TextVAlign::Middle));
1201    }
1202
1203    #[test]
1204    fn test_effective_target_group_selects_group_first() {
1205        let mut sg = SceneGraph::new();
1206
1207        // Root -> Group -> Rect
1208        let group_id = NodeId::intern("my_group");
1209        let rect_id = NodeId::intern("my_rect");
1210
1211        let group = SceneNode::new(group_id, NodeKind::Group);
1212        let rect = SceneNode::new(
1213            rect_id,
1214            NodeKind::Rect {
1215                width: 10.0,
1216                height: 10.0,
1217            },
1218        );
1219
1220        let group_idx = sg.add_node(sg.root, group);
1221        sg.add_node(group_idx, rect);
1222
1223        // Single click (nothing selected): should select the group
1224        assert_eq!(sg.effective_target(rect_id, &[]), group_id);
1225        // Double click (group already selected): drill down to leaf
1226        assert_eq!(sg.effective_target(rect_id, &[group_id]), rect_id);
1227        // Group itself → returns group (it IS the leaf in this call)
1228        assert_eq!(sg.effective_target(group_id, &[]), group_id);
1229    }
1230
1231    #[test]
1232    fn test_effective_target_nested_groups_selects_topmost() {
1233        let mut sg = SceneGraph::new();
1234
1235        // Root -> group_outer -> group_inner -> rect_leaf
1236        let outer_id = NodeId::intern("group_outer");
1237        let inner_id = NodeId::intern("group_inner");
1238        let leaf_id = NodeId::intern("rect_leaf");
1239
1240        let outer = SceneNode::new(outer_id, NodeKind::Group);
1241        let inner = SceneNode::new(inner_id, NodeKind::Group);
1242        let leaf = SceneNode::new(
1243            leaf_id,
1244            NodeKind::Rect {
1245                width: 50.0,
1246                height: 50.0,
1247            },
1248        );
1249
1250        let outer_idx = sg.add_node(sg.root, outer);
1251        let inner_idx = sg.add_node(outer_idx, inner);
1252        sg.add_node(inner_idx, leaf);
1253
1254        // Single click (nothing selected): topmost group
1255        assert_eq!(sg.effective_target(leaf_id, &[]), outer_id);
1256        // Outer selected → drill to inner group
1257        assert_eq!(sg.effective_target(leaf_id, &[outer_id]), inner_id);
1258        // Both outer+inner selected → drill to leaf
1259        assert_eq!(sg.effective_target(leaf_id, &[outer_id, inner_id]), leaf_id);
1260        // Non-cumulative: only inner selected (SelectTool replaces, not accumulates)
1261        // Must drill to leaf — NOT loop back to outer
1262        assert_eq!(sg.effective_target(leaf_id, &[inner_id]), leaf_id);
1263    }
1264
1265    #[test]
1266    fn test_effective_target_nested_drill_down_three_levels() {
1267        let mut sg = SceneGraph::new();
1268
1269        // Root -> group_a -> group_b -> group_c -> rect_leaf
1270        let a_id = NodeId::intern("group_a");
1271        let b_id = NodeId::intern("group_b");
1272        let c_id = NodeId::intern("group_c");
1273        let leaf_id = NodeId::intern("deep_leaf");
1274
1275        let a = SceneNode::new(a_id, NodeKind::Group);
1276        let b = SceneNode::new(b_id, NodeKind::Group);
1277        let c = SceneNode::new(c_id, NodeKind::Group);
1278        let leaf = SceneNode::new(
1279            leaf_id,
1280            NodeKind::Rect {
1281                width: 10.0,
1282                height: 10.0,
1283            },
1284        );
1285
1286        let a_idx = sg.add_node(sg.root, a);
1287        let b_idx = sg.add_node(a_idx, b);
1288        let c_idx = sg.add_node(b_idx, c);
1289        sg.add_node(c_idx, leaf);
1290
1291        // Progressive drill-down (non-cumulative — SelectTool replaces selection)
1292        assert_eq!(sg.effective_target(leaf_id, &[]), a_id);
1293        assert_eq!(sg.effective_target(leaf_id, &[a_id]), b_id);
1294        assert_eq!(sg.effective_target(leaf_id, &[b_id]), c_id);
1295        assert_eq!(sg.effective_target(leaf_id, &[c_id]), leaf_id);
1296    }
1297
1298    #[test]
1299    fn test_visual_highlight_differs_from_selected() {
1300        // Visual highlight contract: when effective_target returns a group,
1301        // the UI should highlight the raw hit (leaf) not the group.
1302        let mut sg = SceneGraph::new();
1303
1304        let group_id = NodeId::intern("card");
1305        let child_id = NodeId::intern("card_title");
1306
1307        let group = SceneNode::new(group_id, NodeKind::Group);
1308        let child = SceneNode::new(
1309            child_id,
1310            NodeKind::Text {
1311                content: "Title".into(),
1312                max_width: None,
1313            },
1314        );
1315
1316        let group_idx = sg.add_node(sg.root, group);
1317        sg.add_node(group_idx, child);
1318
1319        // Raw hit = child_id, nothing selected
1320        let logical_target = sg.effective_target(child_id, &[]);
1321        // Logical selection should be the group
1322        assert_eq!(logical_target, group_id);
1323        // Visual highlight should be the child (raw hit != logical_target)
1324        assert_ne!(child_id, logical_target);
1325        // After drilling (group selected), both converge
1326        let drilled = sg.effective_target(child_id, &[group_id]);
1327        assert_eq!(drilled, child_id);
1328    }
1329
1330    #[test]
1331    fn test_effective_target_no_group() {
1332        let mut sg = SceneGraph::new();
1333
1334        // Root -> Rect (no group)
1335        let rect_id = NodeId::intern("standalone_rect");
1336        let rect = SceneNode::new(
1337            rect_id,
1338            NodeKind::Rect {
1339                width: 10.0,
1340                height: 10.0,
1341            },
1342        );
1343        sg.add_node(sg.root, rect);
1344
1345        // No group parent → returns leaf directly
1346        assert_eq!(sg.effective_target(rect_id, &[]), rect_id);
1347    }
1348
1349    #[test]
1350    fn test_is_ancestor_of() {
1351        let mut sg = SceneGraph::new();
1352
1353        // Root -> Group -> Rect
1354        let group_id = NodeId::intern("grp");
1355        let rect_id = NodeId::intern("r1");
1356        let other_id = NodeId::intern("other");
1357
1358        let group = SceneNode::new(group_id, NodeKind::Group);
1359        let rect = SceneNode::new(
1360            rect_id,
1361            NodeKind::Rect {
1362                width: 10.0,
1363                height: 10.0,
1364            },
1365        );
1366        let other = SceneNode::new(
1367            other_id,
1368            NodeKind::Rect {
1369                width: 5.0,
1370                height: 5.0,
1371            },
1372        );
1373
1374        let group_idx = sg.add_node(sg.root, group);
1375        sg.add_node(group_idx, rect);
1376        sg.add_node(sg.root, other);
1377
1378        // Group is ancestor of rect
1379        assert!(sg.is_ancestor_of(group_id, rect_id));
1380        // Root is ancestor of rect (grandparent)
1381        assert!(sg.is_ancestor_of(NodeId::intern("root"), rect_id));
1382        // Rect is NOT ancestor of group
1383        assert!(!sg.is_ancestor_of(rect_id, group_id));
1384        // Self is NOT ancestor of self
1385        assert!(!sg.is_ancestor_of(group_id, group_id));
1386        // Other is not ancestor of rect (sibling)
1387        assert!(!sg.is_ancestor_of(other_id, rect_id));
1388    }
1389
1390    #[test]
1391    fn test_resolve_style_scale_animation() {
1392        let sg = SceneGraph::new();
1393
1394        let mut node = SceneNode::new(
1395            NodeId::intern("btn"),
1396            NodeKind::Rect {
1397                width: 100.0,
1398                height: 40.0,
1399            },
1400        );
1401        node.props.fill = Some(Paint::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
1402        node.animations.push(AnimKeyframe {
1403            trigger: AnimTrigger::Press,
1404            duration_ms: 100,
1405            easing: Easing::EaseOut,
1406            properties: AnimProperties {
1407                scale: Some(0.97),
1408                ..Default::default()
1409            },
1410            delay_ms: None,
1411        });
1412
1413        // Without press trigger: scale should be None
1414        let resolved = sg.resolve_style(&node, &[]);
1415        assert!(resolved.scale.is_none());
1416
1417        // With press trigger: scale should be 0.97
1418        let resolved = sg.resolve_style(&node, &[AnimTrigger::Press]);
1419        assert_eq!(resolved.scale, Some(0.97));
1420        // Fill should still be present
1421        assert!(resolved.fill.is_some());
1422    }
1423
1424    #[test]
1425    fn z_order_bring_forward() {
1426        let mut sg = SceneGraph::new();
1427        let a = sg.add_node(
1428            sg.root,
1429            SceneNode::new(
1430                NodeId::intern("a"),
1431                NodeKind::Rect {
1432                    width: 50.0,
1433                    height: 50.0,
1434                },
1435            ),
1436        );
1437        let _b = sg.add_node(
1438            sg.root,
1439            SceneNode::new(
1440                NodeId::intern("b"),
1441                NodeKind::Rect {
1442                    width: 50.0,
1443                    height: 50.0,
1444                },
1445            ),
1446        );
1447        let _c = sg.add_node(
1448            sg.root,
1449            SceneNode::new(
1450                NodeId::intern("c"),
1451                NodeKind::Rect {
1452                    width: 50.0,
1453                    height: 50.0,
1454                },
1455            ),
1456        );
1457
1458        // Initial: [a, b, c]
1459        let ids: Vec<&str> = sg
1460            .children(sg.root)
1461            .iter()
1462            .map(|&i| sg.graph[i].id.as_str())
1463            .collect();
1464        assert_eq!(ids, vec!["a", "b", "c"]);
1465
1466        // Bring @a forward → should swap a and b → [b, a, c]
1467        let changed = sg.bring_forward(a);
1468        assert!(changed);
1469        let ids: Vec<&str> = sg
1470            .children(sg.root)
1471            .iter()
1472            .map(|&i| sg.graph[i].id.as_str())
1473            .collect();
1474        assert_eq!(ids, vec!["b", "a", "c"]);
1475    }
1476
1477    #[test]
1478    fn z_order_send_backward() {
1479        let mut sg = SceneGraph::new();
1480        let _a = sg.add_node(
1481            sg.root,
1482            SceneNode::new(
1483                NodeId::intern("a"),
1484                NodeKind::Rect {
1485                    width: 50.0,
1486                    height: 50.0,
1487                },
1488            ),
1489        );
1490        let _b = sg.add_node(
1491            sg.root,
1492            SceneNode::new(
1493                NodeId::intern("b"),
1494                NodeKind::Rect {
1495                    width: 50.0,
1496                    height: 50.0,
1497                },
1498            ),
1499        );
1500        let c = sg.add_node(
1501            sg.root,
1502            SceneNode::new(
1503                NodeId::intern("c"),
1504                NodeKind::Rect {
1505                    width: 50.0,
1506                    height: 50.0,
1507                },
1508            ),
1509        );
1510
1511        // Send @c backward → should swap c and b → [a, c, b]
1512        let changed = sg.send_backward(c);
1513        assert!(changed);
1514        let ids: Vec<&str> = sg
1515            .children(sg.root)
1516            .iter()
1517            .map(|&i| sg.graph[i].id.as_str())
1518            .collect();
1519        assert_eq!(ids, vec!["a", "c", "b"]);
1520    }
1521
1522    #[test]
1523    fn z_order_bring_to_front() {
1524        let mut sg = SceneGraph::new();
1525        let a = sg.add_node(
1526            sg.root,
1527            SceneNode::new(
1528                NodeId::intern("a"),
1529                NodeKind::Rect {
1530                    width: 50.0,
1531                    height: 50.0,
1532                },
1533            ),
1534        );
1535        let _b = sg.add_node(
1536            sg.root,
1537            SceneNode::new(
1538                NodeId::intern("b"),
1539                NodeKind::Rect {
1540                    width: 50.0,
1541                    height: 50.0,
1542                },
1543            ),
1544        );
1545        let _c = sg.add_node(
1546            sg.root,
1547            SceneNode::new(
1548                NodeId::intern("c"),
1549                NodeKind::Rect {
1550                    width: 50.0,
1551                    height: 50.0,
1552                },
1553            ),
1554        );
1555
1556        // Bring @a to front → [b, c, a]
1557        let changed = sg.bring_to_front(a);
1558        assert!(changed);
1559        let ids: Vec<&str> = sg
1560            .children(sg.root)
1561            .iter()
1562            .map(|&i| sg.graph[i].id.as_str())
1563            .collect();
1564        assert_eq!(ids, vec!["b", "c", "a"]);
1565    }
1566
1567    #[test]
1568    fn z_order_send_to_back() {
1569        let mut sg = SceneGraph::new();
1570        let _a = sg.add_node(
1571            sg.root,
1572            SceneNode::new(
1573                NodeId::intern("a"),
1574                NodeKind::Rect {
1575                    width: 50.0,
1576                    height: 50.0,
1577                },
1578            ),
1579        );
1580        let _b = sg.add_node(
1581            sg.root,
1582            SceneNode::new(
1583                NodeId::intern("b"),
1584                NodeKind::Rect {
1585                    width: 50.0,
1586                    height: 50.0,
1587                },
1588            ),
1589        );
1590        let c = sg.add_node(
1591            sg.root,
1592            SceneNode::new(
1593                NodeId::intern("c"),
1594                NodeKind::Rect {
1595                    width: 50.0,
1596                    height: 50.0,
1597                },
1598            ),
1599        );
1600
1601        // Send @c to back → [c, a, b]
1602        let changed = sg.send_to_back(c);
1603        assert!(changed);
1604        let ids: Vec<&str> = sg
1605            .children(sg.root)
1606            .iter()
1607            .map(|&i| sg.graph[i].id.as_str())
1608            .collect();
1609        assert_eq!(ids, vec!["c", "a", "b"]);
1610    }
1611
1612    #[test]
1613    fn z_order_emitter_roundtrip() {
1614        use crate::emitter::emit_document;
1615        use crate::parser::parse_document;
1616
1617        let mut sg = SceneGraph::new();
1618        let a = sg.add_node(
1619            sg.root,
1620            SceneNode::new(
1621                NodeId::intern("a"),
1622                NodeKind::Rect {
1623                    width: 50.0,
1624                    height: 50.0,
1625                },
1626            ),
1627        );
1628        let _b = sg.add_node(
1629            sg.root,
1630            SceneNode::new(
1631                NodeId::intern("b"),
1632                NodeKind::Rect {
1633                    width: 50.0,
1634                    height: 50.0,
1635                },
1636            ),
1637        );
1638        let _c = sg.add_node(
1639            sg.root,
1640            SceneNode::new(
1641                NodeId::intern("c"),
1642                NodeKind::Rect {
1643                    width: 50.0,
1644                    height: 50.0,
1645                },
1646            ),
1647        );
1648
1649        // Bring @a to front → [b, c, a]
1650        sg.bring_to_front(a);
1651
1652        // Emit and re-parse
1653        let text = emit_document(&sg);
1654        let reparsed = parse_document(&text).unwrap();
1655        let ids: Vec<&str> = reparsed
1656            .children(reparsed.root)
1657            .iter()
1658            .map(|&i| reparsed.graph[i].id.as_str())
1659            .collect();
1660        assert_eq!(
1661            ids,
1662            vec!["b", "c", "a"],
1663            "Z-order should survive emit→parse roundtrip. Emitted:\n{}",
1664            text
1665        );
1666    }
1667
1668    #[test]
1669    fn move_child_to_index_basic() {
1670        let mut sg = SceneGraph::new();
1671        let a = sg.add_node(
1672            sg.root,
1673            SceneNode::new(
1674                NodeId::intern("a"),
1675                NodeKind::Rect {
1676                    width: 10.0,
1677                    height: 10.0,
1678                },
1679            ),
1680        );
1681        let _b = sg.add_node(
1682            sg.root,
1683            SceneNode::new(
1684                NodeId::intern("b"),
1685                NodeKind::Rect {
1686                    width: 10.0,
1687                    height: 10.0,
1688                },
1689            ),
1690        );
1691        let _c = sg.add_node(
1692            sg.root,
1693            SceneNode::new(
1694                NodeId::intern("c"),
1695                NodeKind::Rect {
1696                    width: 10.0,
1697                    height: 10.0,
1698                },
1699            ),
1700        );
1701        // Move "a" from index 0 to index 2 (last)
1702        assert!(sg.move_child_to_index(a, 2));
1703        let ids: Vec<&str> = sg
1704            .children(sg.root)
1705            .iter()
1706            .map(|&i| sg.graph[i].id.as_str())
1707            .collect();
1708        assert_eq!(ids, vec!["b", "c", "a"]);
1709    }
1710
1711    #[test]
1712    fn move_child_to_index_out_of_bounds() {
1713        let mut sg = SceneGraph::new();
1714        let a = sg.add_node(
1715            sg.root,
1716            SceneNode::new(
1717                NodeId::intern("a"),
1718                NodeKind::Rect {
1719                    width: 10.0,
1720                    height: 10.0,
1721                },
1722            ),
1723        );
1724        let _b = sg.add_node(
1725            sg.root,
1726            SceneNode::new(
1727                NodeId::intern("b"),
1728                NodeKind::Rect {
1729                    width: 10.0,
1730                    height: 10.0,
1731                },
1732            ),
1733        );
1734        // Index 999 should clamp to last position (1)
1735        assert!(sg.move_child_to_index(a, 999));
1736        let ids: Vec<&str> = sg
1737            .children(sg.root)
1738            .iter()
1739            .map(|&i| sg.graph[i].id.as_str())
1740            .collect();
1741        assert_eq!(ids, vec!["b", "a"]);
1742    }
1743
1744    #[test]
1745    fn move_child_to_index_noop() {
1746        let mut sg = SceneGraph::new();
1747        let a = sg.add_node(
1748            sg.root,
1749            SceneNode::new(
1750                NodeId::intern("a"),
1751                NodeKind::Rect {
1752                    width: 10.0,
1753                    height: 10.0,
1754                },
1755            ),
1756        );
1757        // Move to same index → no change
1758        assert!(!sg.move_child_to_index(a, 0));
1759    }
1760
1761    #[test]
1762    fn reparent_then_children_correct() {
1763        let mut sg = SceneGraph::new();
1764        let group = sg.add_node(
1765            sg.root,
1766            SceneNode::new(NodeId::intern("g"), NodeKind::Group),
1767        );
1768        let rect = sg.add_node(
1769            sg.root,
1770            SceneNode::new(
1771                NodeId::intern("r"),
1772                NodeKind::Rect {
1773                    width: 10.0,
1774                    height: 10.0,
1775                },
1776            ),
1777        );
1778        // rect is child of root initially
1779        assert_eq!(sg.children(sg.root).len(), 2);
1780        // Reparent rect into group
1781        sg.reparent_node(rect, group);
1782        assert_eq!(sg.children(sg.root).len(), 1);
1783        assert_eq!(sg.children(group).len(), 1);
1784        assert_eq!(sg.children(group)[0], rect);
1785        // Reparent back to root
1786        sg.reparent_node(rect, sg.root);
1787        assert_eq!(sg.children(sg.root).len(), 2);
1788        assert_eq!(sg.children(group).len(), 0);
1789    }
1790
1791    /// Regression test: reorder creates sorted_child_order, then reparent
1792    /// must not leave ghost entries in the old parent's children().
1793    #[test]
1794    fn reparent_after_reorder_no_ghost_children() {
1795        let mut sg = SceneGraph::new();
1796        let parent = sg.add_node(
1797            sg.root,
1798            SceneNode::new(
1799                NodeId::intern("parent"),
1800                NodeKind::Rect {
1801                    width: 200.0,
1802                    height: 200.0,
1803                },
1804            ),
1805        );
1806        let child_a = sg.add_node(
1807            parent,
1808            SceneNode::new(
1809                NodeId::intern("a"),
1810                NodeKind::Rect {
1811                    width: 50.0,
1812                    height: 50.0,
1813                },
1814            ),
1815        );
1816        let child_b = sg.add_node(
1817            parent,
1818            SceneNode::new(
1819                NodeId::intern("b"),
1820                NodeKind::Rect {
1821                    width: 50.0,
1822                    height: 50.0,
1823                },
1824            ),
1825        );
1826
1827        // Reorder children to create a sorted_child_order entry
1828        assert!(sg.move_child_to_index(child_b, 0));
1829        assert!(sg.sorted_child_order.contains_key(&parent));
1830        assert_eq!(sg.children(parent), vec![child_b, child_a]);
1831
1832        // Now reparent child_b out to root (simulates drag-above-parent)
1833        sg.reparent_node(child_b, sg.root);
1834
1835        // Old parent must NOT have ghost children
1836        let parent_children = sg.children(parent);
1837        assert_eq!(
1838            parent_children.len(),
1839            1,
1840            "parent should have exactly 1 child after reparent, got {:?}",
1841            parent_children
1842        );
1843        assert_eq!(parent_children[0], child_a);
1844
1845        // New parent (root) should now contain both parent and child_b
1846        let root_children = sg.children(sg.root);
1847        assert_eq!(root_children.len(), 2);
1848        assert!(root_children.contains(&parent));
1849        assert!(root_children.contains(&child_b));
1850
1851        // Emit document should not duplicate child_b
1852        let emitted = crate::emitter::emit_document(&sg);
1853        let count = emitted.matches("@b").count();
1854        assert_eq!(
1855            count, 1,
1856            "@b should appear exactly once in emitted text, found {count} times:\n{emitted}"
1857        );
1858    }
1859}