1use crate::model::*;
8use petgraph::graph::NodeIndex;
9use std::collections::HashMap;
10
11#[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
27pub fn resolve_layout(
31 graph: &SceneGraph,
32 viewport: Viewport,
33) -> HashMap<NodeIndex, ResolvedBounds> {
34 let mut bounds: HashMap<NodeIndex, ResolvedBounds> = HashMap::new();
35
36 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_children(graph, graph.root, &mut bounds, viewport);
49
50 resolve_constraints_top_down(graph, graph.root, &mut bounds, viewport);
53
54 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 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
83fn 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
96fn recompute_group_auto_sizes(
98 graph: &SceneGraph,
99 node_idx: NodeIndex,
100 bounds: &mut HashMap<NodeIndex, ResolvedBounds>,
101) {
102 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 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 let layout = match &parent_node.kind {
162 NodeKind::Group => LayoutMode::Free, 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 for &child_idx in &children {
172 let child_node = &graph.graph[child_idx];
173 let child_size = intrinsic_size(child_node);
174 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 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 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 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 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 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 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 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 if matches!(layout, LayoutMode::Free) {
324 for &child_idx in &children {
325 resolve_children(graph, child_idx, bounds, viewport);
326 }
327 }
328
329 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
359fn 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
383fn 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), NodeKind::Frame { width, height, .. } => (*width, *height),
395 NodeKind::Path { .. } => (100.0, 100.0), NodeKind::Generic => (120.0, 40.0), 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 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 shift_subtree(graph, node_idx, dx, dy, bounds);
465
466 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 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 let gap_plus_height = (b.y - a.y).abs();
532 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); assert!((b.y - 250.0).abs() < 0.01); }
562
563 #[test]
564 fn layout_group_auto_bounds() {
565 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 assert!(cb.width > 0.0, "group width should be positive");
584 assert!(cb.height > 0.0, "group height should be positive");
585 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 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 assert!(
641 inner.height >= 70.0,
642 "inner group height ({}) should be >= 70 (children bbox)",
643 inner.height
644 );
645
646 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
996 count.width < badge.width,
997 "text width ({}) should be < ellipse ({})",
998 count.width,
999 badge.width
1000 );
1001
1002 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 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 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 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 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 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 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}