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