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