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