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