Skip to main content

fd_core/
layout.rs

1//! Constraint-based layout solver.
2//!
3//! Converts relative constraints (center_in, offset, fill_parent) into
4//! absolute `ResolvedBounds` for each node. Also handles Column/Row/Grid
5//! layout modes for groups.
6
7use crate::model::*;
8use petgraph::graph::NodeIndex;
9use std::collections::HashMap;
10
11/// The canvas (viewport) dimensions.
12#[derive(Debug, Clone, Copy)]
13pub struct Viewport {
14    pub width: f32,
15    pub height: f32,
16}
17
18impl Default for Viewport {
19    fn default() -> Self {
20        Self {
21            width: 800.0,
22            height: 600.0,
23        }
24    }
25}
26
27/// Resolve all node positions in the scene graph.
28///
29/// Returns a map from `NodeIndex` → `ResolvedBounds` with absolute positions.
30pub fn resolve_layout(
31    graph: &SceneGraph,
32    viewport: Viewport,
33) -> HashMap<NodeIndex, ResolvedBounds> {
34    let mut bounds: HashMap<NodeIndex, ResolvedBounds> = HashMap::new();
35
36    // Root fills the viewport
37    bounds.insert(
38        graph.root,
39        ResolvedBounds {
40            x: 0.0,
41            y: 0.0,
42            width: viewport.width,
43            height: viewport.height,
44        },
45    );
46
47    // Resolve recursively from root
48    resolve_children(graph, graph.root, &mut bounds, viewport);
49
50    // Apply top-level constraints (may override layout-computed positions)
51    // We do this by traversing top-down to ensure parent constraints are resolved before children.
52    resolve_constraints_top_down(graph, graph.root, &mut bounds, viewport);
53
54    // Final pass: re-compute group auto-sizes after constraints shifted children.
55    // This ensures free-layout groups correctly contain children with Position constraints.
56    recompute_group_auto_sizes(graph, graph.root, &mut bounds);
57
58    bounds
59}
60
61fn resolve_constraints_top_down(
62    graph: &SceneGraph,
63    node_idx: NodeIndex,
64    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
65    viewport: Viewport,
66) {
67    let node = &graph.graph[node_idx];
68    let parent_managed = is_parent_managed(graph, node_idx);
69    for constraint in &node.constraints {
70        // Skip Position constraints for children inside managed layouts —
71        // the Column/Row/Grid layout mode owns child positioning.
72        if parent_managed && matches!(constraint, Constraint::Position { .. }) {
73            continue;
74        }
75        apply_constraint(graph, node_idx, constraint, bounds, viewport);
76    }
77
78    for child_idx in graph.children(node_idx) {
79        resolve_constraints_top_down(graph, child_idx, bounds, viewport);
80    }
81}
82
83/// Check whether a node's parent uses a managed layout (Column/Row/Grid).
84fn is_parent_managed(graph: &SceneGraph, node_idx: NodeIndex) -> bool {
85    let parent_idx = match graph.parent(node_idx) {
86        Some(p) => p,
87        None => return false,
88    };
89    let parent_node = &graph.graph[parent_idx];
90    match &parent_node.kind {
91        NodeKind::Group { layout } | NodeKind::Frame { layout, .. } => {
92            !matches!(layout, LayoutMode::Free)
93        }
94        _ => false,
95    }
96}
97
98/// Bottom-up re-computation of group auto-sizes after all constraints are applied.
99fn recompute_group_auto_sizes(
100    graph: &SceneGraph,
101    node_idx: NodeIndex,
102    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
103) {
104    // Recurse into children first (bottom-up)
105    for child_idx in graph.children(node_idx) {
106        recompute_group_auto_sizes(graph, child_idx, bounds);
107    }
108
109    let node = &graph.graph[node_idx];
110    let pad = match &node.kind {
111        NodeKind::Group {
112            layout: LayoutMode::Column { pad, .. },
113        }
114        | NodeKind::Group {
115            layout: LayoutMode::Row { pad, .. },
116        }
117        | NodeKind::Group {
118            layout: LayoutMode::Grid { pad, .. },
119        } => *pad,
120        NodeKind::Group {
121            layout: LayoutMode::Free,
122        } => 0.0,
123        _ => return, // not a group
124    };
125
126    let children = graph.children(node_idx);
127    if children.is_empty() {
128        return;
129    }
130
131    let mut min_x = f32::MAX;
132    let mut min_y = f32::MAX;
133    let mut max_x = f32::MIN;
134    let mut max_y = f32::MIN;
135
136    for &child_idx in &children {
137        if let Some(cb) = bounds.get(&child_idx) {
138            min_x = min_x.min(cb.x);
139            min_y = min_y.min(cb.y);
140            max_x = max_x.max(cb.x + cb.width);
141            max_y = max_y.max(cb.y + cb.height);
142        }
143    }
144
145    if min_x < f32::MAX {
146        bounds.insert(
147            node_idx,
148            ResolvedBounds {
149                x: min_x - pad,
150                y: min_y - pad,
151                width: (max_x - min_x) + 2.0 * pad,
152                height: (max_y - min_y) + 2.0 * pad,
153            },
154        );
155    }
156}
157
158#[allow(clippy::only_used_in_recursion)]
159fn resolve_children(
160    graph: &SceneGraph,
161    parent_idx: NodeIndex,
162    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
163    viewport: Viewport,
164) {
165    let parent_bounds = bounds[&parent_idx];
166    let parent_node = &graph.graph[parent_idx];
167
168    let children: Vec<NodeIndex> = graph.children(parent_idx);
169    if children.is_empty() {
170        return;
171    }
172
173    // Determine layout mode
174    let layout = match &parent_node.kind {
175        NodeKind::Group { layout } => layout.clone(),
176        NodeKind::Frame { layout, .. } => layout.clone(),
177        _ => LayoutMode::Free,
178    };
179
180    match layout {
181        LayoutMode::Column { gap, pad } => {
182            let content_width = parent_bounds.width - 2.0 * pad;
183            // Pass 1: initialize children at parent origin + pad, recurse to resolve nested groups
184            for &child_idx in &children {
185                let child_node = &graph.graph[child_idx];
186                let child_size = intrinsic_size(child_node);
187                // Stretch text nodes to fill column width (like CSS align-items: stretch)
188                let w = if matches!(child_node.kind, NodeKind::Text { .. }) {
189                    content_width.max(child_size.0)
190                } else {
191                    child_size.0
192                };
193                bounds.insert(
194                    child_idx,
195                    ResolvedBounds {
196                        x: parent_bounds.x + pad,
197                        y: parent_bounds.y + pad,
198                        width: w,
199                        height: child_size.1,
200                    },
201                );
202                resolve_children(graph, child_idx, bounds, viewport);
203            }
204            // Pass 2: reposition using resolved sizes, shifting entire subtrees
205            let mut y = parent_bounds.y + pad;
206            for &child_idx in &children {
207                let resolved = bounds[&child_idx];
208                let dx = (parent_bounds.x + pad) - resolved.x;
209                let dy = y - resolved.y;
210                if dx.abs() > 0.001 || dy.abs() > 0.001 {
211                    shift_subtree(graph, child_idx, dx, dy, bounds);
212                }
213                y += bounds[&child_idx].height + gap;
214            }
215        }
216        LayoutMode::Row { gap, pad } => {
217            // Pass 1: initialize and recurse
218            for &child_idx in &children {
219                let child_size = intrinsic_size(&graph.graph[child_idx]);
220                bounds.insert(
221                    child_idx,
222                    ResolvedBounds {
223                        x: parent_bounds.x + pad,
224                        y: parent_bounds.y + pad,
225                        width: child_size.0,
226                        height: child_size.1,
227                    },
228                );
229                resolve_children(graph, child_idx, bounds, viewport);
230            }
231            // Pass 2: reposition using resolved widths, shifting subtrees
232            let mut x = parent_bounds.x + pad;
233            for &child_idx in &children {
234                let resolved = bounds[&child_idx];
235                let dx = x - resolved.x;
236                let dy = (parent_bounds.y + pad) - resolved.y;
237                if dx.abs() > 0.001 || dy.abs() > 0.001 {
238                    shift_subtree(graph, child_idx, dx, dy, bounds);
239                }
240                x += bounds[&child_idx].width + gap;
241            }
242        }
243        LayoutMode::Grid { cols, gap, pad } => {
244            // Pass 1: initialize and recurse
245            for &child_idx in &children {
246                let child_size = intrinsic_size(&graph.graph[child_idx]);
247                bounds.insert(
248                    child_idx,
249                    ResolvedBounds {
250                        x: parent_bounds.x + pad,
251                        y: parent_bounds.y + pad,
252                        width: child_size.0,
253                        height: child_size.1,
254                    },
255                );
256                resolve_children(graph, child_idx, bounds, viewport);
257            }
258            // Pass 2: reposition using resolved sizes, shifting subtrees
259            let mut x = parent_bounds.x + pad;
260            let mut y = parent_bounds.y + pad;
261            let mut col = 0u32;
262            let mut row_height = 0.0f32;
263
264            for &child_idx in &children {
265                let resolved = bounds[&child_idx];
266                let dx = x - resolved.x;
267                let dy = y - resolved.y;
268                if dx.abs() > 0.001 || dy.abs() > 0.001 {
269                    shift_subtree(graph, child_idx, dx, dy, bounds);
270                }
271
272                let resolved = bounds[&child_idx];
273                row_height = row_height.max(resolved.height);
274                col += 1;
275                if col >= cols {
276                    col = 0;
277                    x = parent_bounds.x + pad;
278                    y += row_height + gap;
279                    row_height = 0.0;
280                } else {
281                    x += resolved.width + gap;
282                }
283            }
284        }
285        LayoutMode::Free => {
286            // Each child positioned at parent origin by default
287            for &child_idx in &children {
288                let child_size = intrinsic_size(&graph.graph[child_idx]);
289                bounds.insert(
290                    child_idx,
291                    ResolvedBounds {
292                        x: parent_bounds.x,
293                        y: parent_bounds.y,
294                        width: child_size.0,
295                        height: child_size.1,
296                    },
297                );
298            }
299
300            // Auto-center: if parent is a shape with a single text child (no
301            // explicit position), expand text bounds to fill parent so the
302            // renderer's center/middle alignment visually centers the label.
303            let parent_is_shape = matches!(
304                parent_node.kind,
305                NodeKind::Rect { .. } | NodeKind::Ellipse { .. } | NodeKind::Frame { .. }
306            );
307            if parent_is_shape && children.len() == 1 {
308                let child_idx = children[0];
309                let child_node = &graph.graph[child_idx];
310                let has_position = child_node
311                    .constraints
312                    .iter()
313                    .any(|c| matches!(c, Constraint::Position { .. }));
314                if matches!(child_node.kind, NodeKind::Text { .. }) && !has_position {
315                    bounds.insert(child_idx, parent_bounds);
316                }
317            }
318        }
319    }
320
321    // Recurse into children (only for Free mode — Column/Row/Grid already recursed in pass 1)
322    if matches!(layout, LayoutMode::Free) {
323        for &child_idx in &children {
324            resolve_children(graph, child_idx, bounds, viewport);
325        }
326    }
327
328    // Auto-size groups to the union bounding box of their children + padding
329    if matches!(parent_node.kind, NodeKind::Group { .. }) && !children.is_empty() {
330        let pad = match &layout {
331            LayoutMode::Column { pad, .. }
332            | LayoutMode::Row { pad, .. }
333            | LayoutMode::Grid { pad, .. } => *pad,
334            LayoutMode::Free => 0.0,
335        };
336
337        let mut min_x = f32::MAX;
338        let mut min_y = f32::MAX;
339        let mut max_x = f32::MIN;
340        let mut max_y = f32::MIN;
341
342        for &child_idx in &children {
343            if let Some(cb) = bounds.get(&child_idx) {
344                // Use resolved absolute positions directly — by this point,
345                // bounds already hold correct positions from the layout pass.
346                min_x = min_x.min(cb.x);
347                min_y = min_y.min(cb.y);
348                max_x = max_x.max(cb.x + cb.width);
349                max_y = max_y.max(cb.y + cb.height);
350            }
351        }
352
353        if min_x < f32::MAX {
354            // Include padding on all sides: origin moves back by pad,
355            // size grows by pad on the trailing edge
356            bounds.insert(
357                parent_idx,
358                ResolvedBounds {
359                    x: min_x - pad,
360                    y: min_y - pad,
361                    width: (max_x - min_x) + 2.0 * pad,
362                    height: (max_y - min_y) + 2.0 * pad,
363                },
364            );
365        }
366    }
367}
368
369/// Recursively shift a node and all its descendants by (dx, dy).
370/// Used after pass 2 repositioning to keep subtree positions consistent.
371fn shift_subtree(
372    graph: &SceneGraph,
373    node_idx: NodeIndex,
374    dx: f32,
375    dy: f32,
376    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
377) {
378    if let Some(b) = bounds.get(&node_idx).copied() {
379        bounds.insert(
380            node_idx,
381            ResolvedBounds {
382                x: b.x + dx,
383                y: b.y + dy,
384                ..b
385            },
386        );
387    }
388    for child_idx in graph.children(node_idx) {
389        shift_subtree(graph, child_idx, dx, dy, bounds);
390    }
391}
392
393/// Get the intrinsic (declared) size of a node.
394fn intrinsic_size(node: &SceneNode) -> (f32, f32) {
395    match &node.kind {
396        NodeKind::Rect { width, height } => (*width, *height),
397        NodeKind::Ellipse { rx, ry } => (*rx * 2.0, *ry * 2.0),
398        NodeKind::Text { content } => {
399            let font_size = node.style.font.as_ref().map_or(14.0, |f| f.size);
400            let char_width = font_size * 0.6;
401            (content.len() as f32 * char_width, font_size)
402        }
403        NodeKind::Group { .. } => (0.0, 0.0), // Auto-sized: computed after children resolve
404        NodeKind::Frame { width, height, .. } => (*width, *height),
405        NodeKind::Path { .. } => (100.0, 100.0), // Computed from path bounds
406        NodeKind::Generic => (120.0, 40.0),      // Placeholder label box
407        NodeKind::Root => (0.0, 0.0),
408    }
409}
410
411fn apply_constraint(
412    graph: &SceneGraph,
413    node_idx: NodeIndex,
414    constraint: &Constraint,
415    bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
416    viewport: Viewport,
417) {
418    let node_bounds = match bounds.get(&node_idx) {
419        Some(b) => *b,
420        None => return,
421    };
422
423    match constraint {
424        Constraint::CenterIn(target_id) => {
425            let container = if target_id.as_str() == "canvas" {
426                ResolvedBounds {
427                    x: 0.0,
428                    y: 0.0,
429                    width: viewport.width,
430                    height: viewport.height,
431                }
432            } else {
433                match graph.index_of(*target_id).and_then(|i| bounds.get(&i)) {
434                    Some(b) => *b,
435                    None => return,
436                }
437            };
438
439            let cx = container.x + (container.width - node_bounds.width) / 2.0;
440            let cy = container.y + (container.height - node_bounds.height) / 2.0;
441            let dx = cx - node_bounds.x;
442            let dy = cy - node_bounds.y;
443
444            shift_subtree(graph, node_idx, dx, dy, bounds);
445        }
446        Constraint::Offset { from, dx, dy } => {
447            let from_bounds = match graph.index_of(*from).and_then(|i| bounds.get(&i)) {
448                Some(b) => *b,
449                None => return,
450            };
451            let target_x = from_bounds.x + dx;
452            let target_y = from_bounds.y + dy;
453            let sdx = target_x - node_bounds.x;
454            let sdy = target_y - node_bounds.y;
455
456            shift_subtree(graph, node_idx, sdx, sdy, bounds);
457        }
458        Constraint::FillParent { pad } => {
459            // Find parent in graph
460            let parent_idx = graph
461                .graph
462                .neighbors_directed(node_idx, petgraph::Direction::Incoming)
463                .next();
464
465            if let Some(parent) = parent_idx.and_then(|p| bounds.get(&p).copied()) {
466                let target_x = parent.x + pad;
467                let target_y = parent.y + pad;
468                let new_w = parent.width - 2.0 * pad;
469                let new_h = parent.height - 2.0 * pad;
470                let dx = target_x - node_bounds.x;
471                let dy = target_y - node_bounds.y;
472
473                // Move children with the position shift
474                shift_subtree(graph, node_idx, dx, dy, bounds);
475
476                // Apply the resize to the node itself (children keep their sizes)
477                if let Some(nb) = bounds.get_mut(&node_idx) {
478                    nb.width = new_w;
479                    nb.height = new_h;
480                }
481            }
482        }
483        Constraint::Position { x, y } => {
484            let (px, py) = match graph.parent(node_idx).and_then(|p| bounds.get(&p)) {
485                Some(p_bounds) => (p_bounds.x, p_bounds.y),
486                None => (0.0, 0.0),
487            };
488            let target_x = px + *x;
489            let target_y = py + *y;
490            let dx = target_x - node_bounds.x;
491            let dy = target_y - node_bounds.y;
492
493            shift_subtree(graph, node_idx, dx, dy, bounds);
494        }
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::id::NodeId;
502    use crate::parser::parse_document;
503
504    #[test]
505    fn layout_column() {
506        let input = r#"
507group @form {
508  layout: column gap=10 pad=20
509
510  rect @a { w: 100 h: 40 }
511  rect @b { w: 100 h: 30 }
512}
513"#;
514        let graph = parse_document(input).unwrap();
515        let viewport = Viewport {
516            width: 800.0,
517            height: 600.0,
518        };
519        let bounds = resolve_layout(&graph, viewport);
520
521        let a_idx = graph.index_of(NodeId::intern("a")).unwrap();
522        let b_idx = graph.index_of(NodeId::intern("b")).unwrap();
523
524        let a = bounds[&a_idx];
525        let b = bounds[&b_idx];
526
527        // Both should be at x = pad (20)
528        assert!(
529            (a.x - 20.0).abs() < 0.01,
530            "a.x should be 20 (pad), got {}",
531            a.x
532        );
533        assert!(
534            (b.x - 20.0).abs() < 0.01,
535            "b.x should be 20 (pad), got {}",
536            b.x
537        );
538
539        // The two children should be exactly (height_of_first + gap) apart
540        let gap_plus_height = (b.y - a.y).abs();
541        // Either a is first (gap = 40 + 10 = 50) or b is first (gap = 30 + 10 = 40)
542        assert!(
543            (gap_plus_height - 50.0).abs() < 0.01 || (gap_plus_height - 40.0).abs() < 0.01,
544            "children should be height+gap apart, got diff = {gap_plus_height}"
545        );
546    }
547
548    #[test]
549    fn layout_center_in_canvas() {
550        let input = r#"
551rect @box {
552  w: 200
553  h: 100
554}
555
556@box -> center_in: canvas
557"#;
558        let graph = parse_document(input).unwrap();
559        let viewport = Viewport {
560            width: 800.0,
561            height: 600.0,
562        };
563        let bounds = resolve_layout(&graph, viewport);
564
565        let idx = graph.index_of(NodeId::intern("box")).unwrap();
566        let b = bounds[&idx];
567
568        assert!((b.x - 300.0).abs() < 0.01); // (800 - 200) / 2
569        assert!((b.y - 250.0).abs() < 0.01); // (600 - 100) / 2
570    }
571
572    #[test]
573    fn layout_group_auto_bounds() {
574        let input = r#"
575group @container {
576  layout: column gap=10 pad=0
577
578  rect @a { w: 100 h: 40 }
579  rect @b { w: 80 h: 30 }
580}
581"#;
582        let graph = parse_document(input).unwrap();
583        let viewport = Viewport {
584            width: 800.0,
585            height: 600.0,
586        };
587        let bounds = resolve_layout(&graph, viewport);
588
589        let container_idx = graph.index_of(NodeId::intern("container")).unwrap();
590        let cb = &bounds[&container_idx];
591
592        // Group should auto-size to cover both children (not hardcoded 200x200)
593        assert!(cb.width > 0.0, "group width should be positive");
594        assert!(cb.height > 0.0, "group height should be positive");
595        // Width should be at least the wider child (100px)
596        assert!(
597            cb.width >= 100.0,
598            "group width ({}) should be >= 100",
599            cb.width
600        );
601        // Height should cover both children + gap (40 + 10 + 30 = 80)
602        assert!(
603            cb.height >= 80.0,
604            "group height ({}) should be >= 80 (children + gap)",
605            cb.height
606        );
607    }
608
609    #[test]
610    fn layout_frame_declared_size() {
611        let input = r#"
612frame @card {
613  w: 480 h: 320
614}
615"#;
616        let graph = parse_document(input).unwrap();
617        let viewport = Viewport {
618            width: 800.0,
619            height: 600.0,
620        };
621        let bounds = resolve_layout(&graph, viewport);
622
623        let idx = graph.index_of(NodeId::intern("card")).unwrap();
624        let b = &bounds[&idx];
625
626        assert_eq!(b.width, 480.0, "frame should use declared width");
627        assert_eq!(b.height, 320.0, "frame should use declared height");
628    }
629
630    #[test]
631    fn layout_nested_group_auto_size() {
632        let input = r#"
633group @outer {
634  layout: column gap=10 pad=0
635
636  group @inner {
637    layout: column gap=5 pad=0
638
639    rect @a { w: 100 h: 40 }
640    rect @b { w: 80 h: 30 }
641  }
642  rect @c { w: 120 h: 50 }
643}
644"#;
645        let graph = parse_document(input).unwrap();
646        let viewport = Viewport {
647            width: 800.0,
648            height: 600.0,
649        };
650        let bounds = resolve_layout(&graph, viewport);
651
652        let inner_idx = graph.index_of(NodeId::intern("inner")).unwrap();
653        let outer_idx = graph.index_of(NodeId::intern("outer")).unwrap();
654        let c_idx = graph.index_of(NodeId::intern("c")).unwrap();
655
656        let inner = bounds[&inner_idx];
657        let outer = bounds[&outer_idx];
658        let c = bounds[&c_idx];
659
660        // Inner group: height = 40 + 5 + 30 = 75
661        assert!(
662            inner.height >= 75.0,
663            "inner group height ({}) should be >= 75 (children + gap)",
664            inner.height
665        );
666
667        // @c and @inner should NOT overlap (regardless of order)
668        let c_range = c.y..(c.y + c.height);
669        let inner_range = inner.y..(inner.y + inner.height);
670        assert!(
671            c_range.end <= inner_range.start || inner_range.end <= c_range.start,
672            "@c [{}, {}] and @inner [{}, {}] should not overlap",
673            c.y,
674            c.y + c.height,
675            inner.y,
676            inner.y + inner.height
677        );
678
679        // Gap between siblings should be 10
680        let gap = if c.y < inner.y {
681            inner.y - (c.y + c.height)
682        } else {
683            c.y - (inner.y + inner.height)
684        };
685        assert!(
686            (gap - 10.0).abs() < 0.01,
687            "gap between siblings should be 10, got {gap}"
688        );
689
690        // Outer group should contain both @inner and @c
691        let outer_bottom = outer.y + outer.height;
692        let c_bottom = c.y + c.height;
693        let inner_bottom = inner.y + inner.height;
694        assert!(
695            outer_bottom >= c_bottom && outer_bottom >= inner_bottom,
696            "outer bottom ({outer_bottom}) should contain @c ({c_bottom}) and @inner ({inner_bottom})"
697        );
698    }
699
700    #[test]
701    fn layout_group_child_inside_column_parent() {
702        let input = r#"
703group @wizard {
704  layout: column gap=0 pad=0
705
706  rect @card {
707    w: 480 h: 520
708
709    group @content {
710      layout: column gap=24 pad=40
711
712      rect @illustration { w: 400 h: 240 }
713      rect @title { w: 400 h: 20 }
714      rect @desc { w: 400 h: 20 }
715    }
716  }
717}
718"#;
719        let graph = parse_document(input).unwrap();
720        let viewport = Viewport {
721            width: 800.0,
722            height: 600.0,
723        };
724        let bounds = resolve_layout(&graph, viewport);
725
726        let content_idx = graph.index_of(NodeId::intern("content")).unwrap();
727        let wizard_idx = graph.index_of(NodeId::intern("wizard")).unwrap();
728
729        let content = bounds[&content_idx];
730        let wizard = bounds[&wizard_idx];
731
732        // Content group should auto-size to fit: pad(40) + 240 + gap(24) + 20 + gap(24) + 20 + pad(40) = 408
733        assert!(
734            content.height >= 280.0,
735            "content group height ({}) should be >= 280 (children + gaps)",
736            content.height
737        );
738
739        // Wizard should contain the content
740        let wizard_bottom = wizard.y + wizard.height;
741        let content_bottom = content.y + content.height;
742        assert!(
743            wizard_bottom >= content_bottom,
744            "wizard ({wizard_bottom}) should contain content ({content_bottom})"
745        );
746    }
747
748    #[test]
749    fn layout_column_preserves_document_order() {
750        let input = r#"
751group @card {
752  layout: column gap=12 pad=24
753
754  text @heading "Monthly Revenue" {
755    font: "Inter" 600 18
756  }
757  text @amount "$48,250" {
758    font: "Inter" 700 36
759  }
760  rect @button { w: 320 h: 44 }
761}
762"#;
763        let graph = parse_document(input).unwrap();
764        let viewport = Viewport {
765            width: 800.0,
766            height: 600.0,
767        };
768        let bounds = resolve_layout(&graph, viewport);
769
770        let heading = bounds[&graph.index_of(NodeId::intern("heading")).unwrap()];
771        let amount = bounds[&graph.index_of(NodeId::intern("amount")).unwrap()];
772        let button = bounds[&graph.index_of(NodeId::intern("button")).unwrap()];
773
774        assert!(
775            heading.y < amount.y,
776            "heading (y={}) must be above amount (y={})",
777            heading.y,
778            amount.y
779        );
780        assert!(
781            amount.y < button.y,
782            "amount (y={}) must be above button (y={})",
783            amount.y,
784            button.y
785        );
786        // Heading height should use font size (18), not hardcoded 20
787        assert!(
788            (heading.height - 18.0).abs() < 0.01,
789            "heading height should be 18 (font size), got {}",
790            heading.height
791        );
792        // Amount height should use font size (36)
793        assert!(
794            (amount.height - 36.0).abs() < 0.01,
795            "amount height should be 36 (font size), got {}",
796            amount.height
797        );
798    }
799
800    #[test]
801    fn layout_dashboard_card_with_center_in() {
802        let input = r#"
803group @card {
804  layout: column gap=12 pad=24
805  text @heading "Monthly Revenue" { font: "Inter" 600 18 }
806  text @amount "$48,250" { font: "Inter" 700 36 }
807  text @change "+12.5% from last month" { font: "Inter" 400 14 }
808  rect @chart { w: 320 h: 160 }
809  rect @button { w: 320 h: 44 }
810}
811@card -> center_in: canvas
812"#;
813        let graph = parse_document(input).unwrap();
814        let card_idx = graph.index_of(NodeId::intern("card")).unwrap();
815
816        // graph.children() must return document order regardless of platform
817        let children: Vec<_> = graph
818            .children(card_idx)
819            .iter()
820            .map(|idx| graph.graph[*idx].id.as_str().to_string())
821            .collect();
822        assert_eq!(children[0], "heading", "First child must be heading");
823        assert_eq!(children[4], "button", "Last child must be button");
824
825        let viewport = Viewport {
826            width: 800.0,
827            height: 600.0,
828        };
829        let bounds = resolve_layout(&graph, viewport);
830
831        let heading = bounds[&graph.index_of(NodeId::intern("heading")).unwrap()];
832        let amount = bounds[&graph.index_of(NodeId::intern("amount")).unwrap()];
833        let change = bounds[&graph.index_of(NodeId::intern("change")).unwrap()];
834        let chart = bounds[&graph.index_of(NodeId::intern("chart")).unwrap()];
835        let button = bounds[&graph.index_of(NodeId::intern("button")).unwrap()];
836        let card = bounds[&graph.index_of(NodeId::intern("card")).unwrap()];
837
838        // All children must be INSIDE the card
839        assert!(
840            heading.y >= card.y,
841            "heading.y({}) must be >= card.y({})",
842            heading.y,
843            card.y
844        );
845        assert!(
846            button.y + button.height <= card.y + card.height + 0.1,
847            "button bottom({}) must be <= card bottom({})",
848            button.y + button.height,
849            card.y + card.height
850        );
851
852        // Document order preserved after center_in shift
853        assert!(
854            heading.y < amount.y,
855            "heading.y({}) < amount.y({})",
856            heading.y,
857            amount.y
858        );
859        assert!(
860            amount.y < change.y,
861            "amount.y({}) < change.y({})",
862            amount.y,
863            change.y
864        );
865        assert!(
866            change.y < chart.y,
867            "change.y({}) < chart.y({})",
868            change.y,
869            chart.y
870        );
871        assert!(
872            chart.y < button.y,
873            "chart.y({}) < button.y({})",
874            chart.y,
875            button.y
876        );
877    }
878
879    #[test]
880    fn layout_column_ignores_position_constraint() {
881        // Children with stale Position constraints inside a column layout
882        // should be positioned by the column, not by their Position.
883        let input = r#"
884group @card {
885  layout: column gap=10 pad=20
886
887  rect @a { w: 100 h: 40 }
888  rect @b {
889    w: 100 h: 30
890    x: 500 y: 500
891  }
892}
893"#;
894        let graph = parse_document(input).unwrap();
895        let viewport = Viewport {
896            width: 800.0,
897            height: 600.0,
898        };
899        let bounds = resolve_layout(&graph, viewport);
900
901        let a_idx = graph.index_of(NodeId::intern("a")).unwrap();
902        let b_idx = graph.index_of(NodeId::intern("b")).unwrap();
903        let card_idx = graph.index_of(NodeId::intern("card")).unwrap();
904
905        let a = bounds[&a_idx];
906        let b = bounds[&b_idx];
907        let card = bounds[&card_idx];
908
909        // Both children should be at column x = pad (20), NOT at x=500
910        assert!(
911            (a.x - b.x).abs() < 0.01,
912            "a.x ({}) and b.x ({}) should be equal (column aligns them)",
913            a.x,
914            b.x
915        );
916        // b should be below a by height + gap (40 + 10 = 50)
917        assert!(
918            (b.y - a.y - 50.0).abs() < 0.01,
919            "b.y ({}) should be a.y + 50, got diff = {}",
920            b.y,
921            b.y - a.y
922        );
923        // Both children should be inside the card
924        assert!(
925            b.y + b.height <= card.y + card.height + 0.1,
926            "b bottom ({}) must be inside card bottom ({})",
927            b.y + b.height,
928            card.y + card.height
929        );
930    }
931
932    #[test]
933    fn layout_group_auto_size_contains_all_children() {
934        // A free-layout group should auto-size to contain all children,
935        // even those with Position constraints that extend beyond others.
936        let input = r#"
937group @panel {
938  rect @a { w: 100 h: 40 }
939  rect @b {
940    w: 80 h: 30
941    x: 200 y: 150
942  }
943}
944"#;
945        let graph = parse_document(input).unwrap();
946        let viewport = Viewport {
947            width: 800.0,
948            height: 600.0,
949        };
950        let bounds = resolve_layout(&graph, viewport);
951
952        let panel_idx = graph.index_of(NodeId::intern("panel")).unwrap();
953        let b_idx = graph.index_of(NodeId::intern("b")).unwrap();
954
955        let panel = bounds[&panel_idx];
956        let b = bounds[&b_idx];
957
958        // Panel must contain @b entirely
959        assert!(
960            panel.x + panel.width >= b.x + b.width,
961            "panel right ({}) must contain b right ({})",
962            panel.x + panel.width,
963            b.x + b.width
964        );
965        assert!(
966            panel.y + panel.height >= b.y + b.height,
967            "panel bottom ({}) must contain b bottom ({})",
968            panel.y + panel.height,
969            b.y + b.height
970        );
971    }
972
973    #[test]
974    fn layout_text_centered_in_rect() {
975        let input = r#"
976rect @btn {
977  w: 320 h: 44
978  text @label "View Details" {
979    font: "Inter" 600 14
980  }
981}
982"#;
983        let graph = parse_document(input).unwrap();
984        let viewport = Viewport {
985            width: 800.0,
986            height: 600.0,
987        };
988        let bounds = resolve_layout(&graph, viewport);
989
990        let btn = bounds[&graph.index_of(NodeId::intern("btn")).unwrap()];
991        let label = bounds[&graph.index_of(NodeId::intern("label")).unwrap()];
992
993        // Text bounds should match parent rect (renderer handles visual centering)
994        assert!(
995            (label.x - btn.x).abs() < 0.01,
996            "text x ({}) should match parent ({})",
997            label.x,
998            btn.x
999        );
1000        assert!(
1001            (label.y - btn.y).abs() < 0.01,
1002            "text y ({}) should match parent ({})",
1003            label.y,
1004            btn.y
1005        );
1006        assert!(
1007            (label.width - btn.width).abs() < 0.01,
1008            "text width ({}) should match parent ({})",
1009            label.width,
1010            btn.width
1011        );
1012        assert!(
1013            (label.height - btn.height).abs() < 0.01,
1014            "text height ({}) should match parent ({})",
1015            label.height,
1016            btn.height
1017        );
1018    }
1019
1020    #[test]
1021    fn layout_text_in_ellipse_centered() {
1022        let input = r#"
1023ellipse @badge {
1024  rx: 60 ry: 30
1025  text @count "42" {
1026    font: "Inter" 700 20
1027  }
1028}
1029"#;
1030        let graph = parse_document(input).unwrap();
1031        let viewport = Viewport {
1032            width: 800.0,
1033            height: 600.0,
1034        };
1035        let bounds = resolve_layout(&graph, viewport);
1036
1037        let badge = bounds[&graph.index_of(NodeId::intern("badge")).unwrap()];
1038        let count = bounds[&graph.index_of(NodeId::intern("count")).unwrap()];
1039
1040        // Text bounds should fill the ellipse bounding box
1041        assert!(
1042            (count.width - badge.width).abs() < 0.01,
1043            "text width ({}) should match ellipse ({})",
1044            count.width,
1045            badge.width
1046        );
1047        assert!(
1048            (count.height - badge.height).abs() < 0.01,
1049            "text height ({}) should match ellipse ({})",
1050            count.height,
1051            badge.height
1052        );
1053    }
1054
1055    #[test]
1056    fn layout_text_explicit_position_not_expanded() {
1057        let input = r#"
1058rect @btn {
1059  w: 320 h: 44
1060  text @label "OK" {
1061    font: "Inter" 600 14
1062    x: 10 y: 5
1063  }
1064}
1065"#;
1066        let graph = parse_document(input).unwrap();
1067        let viewport = Viewport {
1068            width: 800.0,
1069            height: 600.0,
1070        };
1071        let bounds = resolve_layout(&graph, viewport);
1072
1073        let btn = bounds[&graph.index_of(NodeId::intern("btn")).unwrap()];
1074        let label = bounds[&graph.index_of(NodeId::intern("label")).unwrap()];
1075
1076        // Text with explicit position should NOT be expanded to parent
1077        assert!(
1078            label.width < btn.width,
1079            "text width ({}) should be < parent ({}) when explicit position is set",
1080            label.width,
1081            btn.width
1082        );
1083    }
1084
1085    #[test]
1086    fn layout_text_multiple_children_not_expanded() {
1087        let input = r#"
1088rect @card {
1089  w: 200 h: 100
1090  text @title "Title" {
1091    font: "Inter" 600 16
1092  }
1093  text @subtitle "Sub" {
1094    font: "Inter" 400 12
1095  }
1096}
1097"#;
1098        let graph = parse_document(input).unwrap();
1099        let viewport = Viewport {
1100            width: 800.0,
1101            height: 600.0,
1102        };
1103        let bounds = resolve_layout(&graph, viewport);
1104
1105        let card = bounds[&graph.index_of(NodeId::intern("card")).unwrap()];
1106        let title = bounds[&graph.index_of(NodeId::intern("title")).unwrap()];
1107
1108        // Multiple children: text should NOT be expanded to parent
1109        assert!(
1110            title.width < card.width,
1111            "text width ({}) should be < parent ({}) with multiple children",
1112            title.width,
1113            card.width
1114        );
1115    }
1116
1117    #[test]
1118    fn layout_text_centered_in_rect_inside_column() {
1119        // Reproduces demo.fd: text inside rect inside column group
1120        let input = r#"
1121group @form {
1122  layout: column gap=16 pad=32
1123
1124  rect @email_field {
1125    w: 280 h: 44
1126    text @email_hint "Email" { }
1127  }
1128
1129  rect @login_btn {
1130    w: 280 h: 48
1131    text @btn_label "Sign In" { }
1132  }
1133}
1134"#;
1135        let graph = parse_document(input).unwrap();
1136        let viewport = Viewport {
1137            width: 800.0,
1138            height: 600.0,
1139        };
1140        let bounds = resolve_layout(&graph, viewport);
1141
1142        let email_field = bounds[&graph.index_of(NodeId::intern("email_field")).unwrap()];
1143        let email_hint = bounds[&graph.index_of(NodeId::intern("email_hint")).unwrap()];
1144        let login_btn = bounds[&graph.index_of(NodeId::intern("login_btn")).unwrap()];
1145        let btn_label = bounds[&graph.index_of(NodeId::intern("btn_label")).unwrap()];
1146
1147        // Text bounds must match parent rect for centering to work
1148        eprintln!(
1149            "email_field: x={:.1} y={:.1} w={:.1} h={:.1}",
1150            email_field.x, email_field.y, email_field.width, email_field.height
1151        );
1152        eprintln!(
1153            "email_hint:  x={:.1} y={:.1} w={:.1} h={:.1}",
1154            email_hint.x, email_hint.y, email_hint.width, email_hint.height
1155        );
1156        eprintln!(
1157            "login_btn:   x={:.1} y={:.1} w={:.1} h={:.1}",
1158            login_btn.x, login_btn.y, login_btn.width, login_btn.height
1159        );
1160        eprintln!(
1161            "btn_label:   x={:.1} y={:.1} w={:.1} h={:.1}",
1162            btn_label.x, btn_label.y, btn_label.width, btn_label.height
1163        );
1164
1165        assert!(
1166            (email_hint.x - email_field.x).abs() < 0.01,
1167            "email_hint x ({}) should match email_field x ({})",
1168            email_hint.x,
1169            email_field.x
1170        );
1171        assert!(
1172            (email_hint.y - email_field.y).abs() < 0.01,
1173            "email_hint y ({}) should match email_field y ({})",
1174            email_hint.y,
1175            email_field.y
1176        );
1177        assert!(
1178            (email_hint.width - email_field.width).abs() < 0.01,
1179            "email_hint width ({}) should match email_field width ({})",
1180            email_hint.width,
1181            email_field.width
1182        );
1183        assert!(
1184            (email_hint.height - email_field.height).abs() < 0.01,
1185            "email_hint height ({}) should match email_field height ({})",
1186            email_hint.height,
1187            email_field.height
1188        );
1189
1190        assert!(
1191            (btn_label.x - login_btn.x).abs() < 0.01,
1192            "btn_label x ({}) should match login_btn x ({})",
1193            btn_label.x,
1194            login_btn.x
1195        );
1196        assert!(
1197            (btn_label.y - login_btn.y).abs() < 0.01,
1198            "btn_label y ({}) should match login_btn y ({})",
1199            btn_label.y,
1200            login_btn.y
1201        );
1202        assert!(
1203            (btn_label.width - login_btn.width).abs() < 0.01,
1204            "btn_label width ({}) should match login_btn width ({})",
1205            btn_label.width,
1206            login_btn.width
1207        );
1208        assert!(
1209            (btn_label.height - login_btn.height).abs() < 0.01,
1210            "btn_label height ({}) should match login_btn height ({})",
1211            btn_label.height,
1212            login_btn.height
1213        );
1214    }
1215}