GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/scene/layout.rs (33.2K)
1 //! Hand-rolled two-phase layout engine — Phase 2 of the core rebuild.
2 //!
3 //! The legacy toolkit computed layout *inline during paint*, smeared across `LayoutStrategy`, a
4 //! child-driven `allocate` bump-cursor, and hand-written `set_rect` calls with absolute
5 //! coordinates — with `SectionContext` literally rendering twice to measure (both gone since
6 //! 2026-10-08). There is no layout
7 //! pass that is independent of paint, which is why animated/relayout-able UI is hard and why a
8 //! widget can end up sized by two different owners (the breadcrumb bug).
9 //!
10 //! This module replaces that with a real, self-contained solver that runs over the [`Arena`] and
11 //! is independent of paint:
12 //!
13 //! * **measure** (bottom-up): each node reports an intrinsic [`Size`] from its children (or, for
14 //! a leaf, its content size). Written into `LayoutBox::measured`.
15 //! * **arrange** (top-down): each node is given a final [`Rect`] and positions its children
16 //! within it. Written into `LayoutBox::rect`.
17 //!
18 //! Because it operates on `Style` + `Size` and writes plain rects, it is fully unit-testable
19 //! without a GPU or a Wayland surface (see the tests below).
20 //!
21 //! Layout modes ([`LayoutMode`]): **Flex** (row/column with grow/shrink, gap, padding, main/cross
22 //! alignment incl. stretch), **Stack** (Z-overlay with per-axis alignment), and **Grid** (fixed
23 //! column count with uniform column width and per-row heights). Deferred to later increments:
24 //! wrapping, percentage lengths, width-dependent adaptive grids, and the cosmic-text text-measure
25 //! hook for real leaf widgets (that lands with Phase 2b integration).
26
27 use crate::scene::arena::{Arena, NodeId};
28
29 /// A width/height pair in logical pixels.
30 #[derive(Debug, Clone, Copy, PartialEq)]
31 pub struct Size {
32 pub width: f32,
33 pub height: f32,
34 }
35
36 impl Size {
37 pub const ZERO: Size = Size { width: 0.0, height: 0.0 };
38 pub fn new(width: f32, height: f32) -> Self {
39 Size { width, height }
40 }
41 }
42
43 /// A positioned box in logical pixels (absolute coordinates after arrange).
44 #[derive(Debug, Clone, Copy, PartialEq)]
45 pub struct Rect {
46 pub x: f32,
47 pub y: f32,
48 pub width: f32,
49 pub height: f32,
50 }
51
52 impl Rect {
53 pub const ZERO: Rect = Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 };
54
55 /// Whether the point `(x, y)` lies in the rect: the left and top edges
56 /// are inside, the right and bottom edges are not.
57 ///
58 /// Half-open on purpose, so rects that share an edge — a row of tabs, a
59 /// list's rows — partition the space between them: a point on the
60 /// shared edge belongs to exactly one, whichever order they are tested
61 /// in. A rect with no width or height contains nothing.
62 pub fn contains(&self, x: f32, y: f32) -> bool {
63 x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height
64 }
65 }
66
67 /// How an image maps into a bounding box — see [`fit_rect`].
68 #[derive(Debug, Clone, Copy, PartialEq)]
69 pub enum FitMode {
70 /// Aspect-preserving: the image fills the box on its long axis and
71 /// letterboxes on the other, never scaling up past `max_upscale`
72 /// (1.0 = never enlarge; f32::INFINITY = always fill).
73 Contain { max_upscale: f32 },
74 /// The full box, aspect ignored.
75 Stretch,
76 }
77
78 /// The rect an `img_w` × `img_h` image occupies inside `bounds` under `mode`,
79 /// centered on both axes. Zero-sized images yield a zero rect at the box
80 /// center rather than a division blow-up.
81 pub fn fit_rect(img_w: u32, img_h: u32, bounds: Rect, mode: FitMode) -> Rect {
82 match mode {
83 FitMode::Stretch => bounds,
84 FitMode::Contain { max_upscale } => {
85 if img_w == 0 || img_h == 0 {
86 return Rect {
87 x: bounds.x + bounds.width * 0.5,
88 y: bounds.y + bounds.height * 0.5,
89 width: 0.0,
90 height: 0.0,
91 };
92 }
93 let (iw, ih) = (img_w as f32, img_h as f32);
94 let scale = (bounds.width / iw).min(bounds.height / ih).min(max_upscale).max(0.0);
95 let (w, h) = (iw * scale, ih * scale);
96 Rect {
97 x: bounds.x + (bounds.width - w) * 0.5,
98 y: bounds.y + (bounds.height - h) * 0.5,
99 width: w,
100 height: h,
101 }
102 }
103 }
104 }
105
106 /// Per-side spacing (padding).
107 #[derive(Debug, Clone, Copy, PartialEq)]
108 pub struct Edges {
109 pub left: f32,
110 pub right: f32,
111 pub top: f32,
112 pub bottom: f32,
113 }
114
115 impl Edges {
116 pub const ZERO: Edges = Edges { left: 0.0, right: 0.0, top: 0.0, bottom: 0.0 };
117 pub fn all(v: f32) -> Self {
118 Edges { left: v, right: v, top: v, bottom: v }
119 }
120 }
121
122 /// The main-axis direction a flex container lays its children along.
123 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
124 pub enum Axis {
125 Row,
126 Column,
127 }
128
129 /// How a node arranges its children.
130 #[derive(Debug, Clone, Copy, PartialEq)]
131 pub enum LayoutMode {
132 /// Row/column flex (uses `Style::axis`).
133 Flex,
134 /// All children overlaid in the same box (Z-stack), aligned per axis.
135 Stack,
136 /// Fixed-column grid, filling left-to-right then top-to-bottom.
137 Grid(GridSpec),
138 }
139
140 /// A fixed-column grid: uniform column width (max child width), per-row heights.
141 #[derive(Debug, Clone, Copy, PartialEq)]
142 pub struct GridSpec {
143 pub columns: usize,
144 pub col_gap: f32,
145 pub row_gap: f32,
146 }
147
148 /// A length along one axis.
149 #[derive(Debug, Clone, Copy, PartialEq)]
150 pub enum Length {
151 /// Size to content (children/intrinsic), plus padding.
152 Auto,
153 /// Fixed logical pixels, overriding content size.
154 Fixed(f32),
155 }
156
157 /// Distribution of free space along the main axis (when no child grows/shrinks). For [`Stack`]
158 /// this selects horizontal placement.
159 ///
160 /// [`Stack`]: LayoutMode::Stack
161 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
162 pub enum MainAlign {
163 Start,
164 Center,
165 End,
166 SpaceBetween,
167 }
168
169 /// Placement of each child across the cross axis. For [`Stack`] this selects vertical placement.
170 ///
171 /// [`Stack`]: LayoutMode::Stack
172 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
173 pub enum CrossAlign {
174 Start,
175 Center,
176 End,
177 /// Fill the container's cross-axis content extent.
178 Stretch,
179 }
180
181 /// Layout inputs for a node.
182 #[derive(Debug, Clone, Copy, PartialEq)]
183 pub struct Style {
184 pub mode: LayoutMode,
185 pub axis: Axis,
186 pub padding: Edges,
187 pub gap: f32,
188 pub main_align: MainAlign,
189 pub cross_align: CrossAlign,
190 pub width: Length,
191 pub height: Length,
192 /// Flex grow weight: share of leftover main-axis space this node claims.
193 pub grow: f32,
194 /// Flex shrink weight: share of a main-axis overflow this node gives up.
195 pub shrink: f32,
196 pub min_width: f32,
197 pub min_height: f32,
198 pub max_width: f32,
199 pub max_height: f32,
200 }
201
202 impl Default for Style {
203 fn default() -> Self {
204 Style {
205 mode: LayoutMode::Flex,
206 axis: Axis::Column,
207 padding: Edges::ZERO,
208 gap: 0.0,
209 main_align: MainAlign::Start,
210 cross_align: CrossAlign::Start,
211 width: Length::Auto,
212 height: Length::Auto,
213 grow: 0.0,
214 shrink: 0.0,
215 min_width: 0.0,
216 min_height: 0.0,
217 max_width: f32::INFINITY,
218 max_height: f32::INFINITY,
219 }
220 }
221 }
222
223 impl Style {
224 pub fn row() -> Self {
225 Style { mode: LayoutMode::Flex, axis: Axis::Row, ..Default::default() }
226 }
227 pub fn column() -> Self {
228 Style { mode: LayoutMode::Flex, axis: Axis::Column, ..Default::default() }
229 }
230 pub fn stack() -> Self {
231 Style { mode: LayoutMode::Stack, ..Default::default() }
232 }
233 pub fn grid(columns: usize, col_gap: f32, row_gap: f32) -> Self {
234 Style { mode: LayoutMode::Grid(GridSpec { columns: columns.max(1), col_gap, row_gap }), ..Default::default() }
235 }
236 // ── The spacing ladder as presets ─────────────────────────────────
237 // An app on the standard root plate never names a padding or gap: it
238 // picks the rung. Root presets inset by `root_plate_inset` (the plate's
239 // roll plus one padding) and space siblings by `root_plate_gap`; pane
240 // presets by `plate_padding` / `plate_gap`; the controls presets space
241 // a form's controls by `control_gap` with no inset of their own, since
242 // they sit inside a pane or root preset that already has one.
243
244 /// A column of siblings standing on the root plate, stretched across it.
245 pub fn root_column() -> Self {
246 Self::column()
247 .padding(crate::layout::root_plate_inset())
248 .gap(crate::layout::root_plate_gap())
249 .cross_align(CrossAlign::Stretch)
250 }
251 /// A row of siblings standing on the root plate.
252 pub fn root_row() -> Self {
253 Self::row()
254 .padding(crate::layout::root_plate_inset())
255 .gap(crate::layout::root_plate_gap())
256 .cross_align(CrossAlign::Stretch)
257 }
258 /// A column inside a pane plate, inset from its rim.
259 pub fn pane_column() -> Self {
260 Self::column()
261 .padding(crate::layout::plate_padding())
262 .gap(crate::layout::plate_gap())
263 .cross_align(CrossAlign::Stretch)
264 }
265 /// A row inside a pane plate.
266 pub fn pane_row() -> Self {
267 Self::row()
268 .padding(crate::layout::plate_padding())
269 .gap(crate::layout::plate_gap())
270 .cross_align(CrossAlign::Stretch)
271 }
272 /// A column of controls: the control gap between them, no inset.
273 pub fn controls_column() -> Self {
274 Self::column().gap(crate::layout::control_gap())
275 }
276 /// A row of controls: the control gap between them, no inset.
277 pub fn controls_row() -> Self {
278 Self::row().gap(crate::layout::control_gap())
279 }
280
281 pub fn gap(mut self, v: f32) -> Self {
282 self.gap = v;
283 self
284 }
285 pub fn padding(mut self, v: f32) -> Self {
286 self.padding = Edges::all(v);
287 self
288 }
289 pub fn grow(mut self, v: f32) -> Self {
290 self.grow = v;
291 self
292 }
293 pub fn shrink(mut self, v: f32) -> Self {
294 self.shrink = v;
295 self
296 }
297 pub fn main_align(mut self, a: MainAlign) -> Self {
298 self.main_align = a;
299 self
300 }
301 pub fn cross_align(mut self, a: CrossAlign) -> Self {
302 self.cross_align = a;
303 self
304 }
305 pub fn width(mut self, w: Length) -> Self {
306 self.width = w;
307 self
308 }
309 pub fn height(mut self, h: Length) -> Self {
310 self.height = h;
311 self
312 }
313 }
314
315 /// A node's layout state: inputs (`style`, optional `intrinsic` content size for leaves) and the
316 /// two computed outputs (`measured`, then `rect`).
317 #[derive(Debug, Clone, Copy)]
318 pub struct LayoutBox {
319 pub style: Style,
320 /// Content size for a leaf (e.g. measured text). Ignored when the node has children.
321 pub intrinsic: Option<Size>,
322 pub measured: Size,
323 pub rect: Rect,
324 }
325
326 impl LayoutBox {
327 /// A container node laid out from its children.
328 pub fn container(style: Style) -> Self {
329 LayoutBox { style, intrinsic: None, measured: Size::ZERO, rect: Rect::ZERO }
330 }
331
332 /// A leaf node with a fixed content size.
333 pub fn leaf(style: Style, content: Size) -> Self {
334 LayoutBox { style, intrinsic: Some(content), measured: Size::ZERO, rect: Rect::ZERO }
335 }
336 }
337
338 // --- axis helpers: project/unproject a Size onto the (main, cross) frame of an axis ---
339
340 #[inline]
341 fn main_of(axis: Axis, s: Size) -> f32 {
342 match axis {
343 Axis::Row => s.width,
344 Axis::Column => s.height,
345 }
346 }
347
348 #[inline]
349 fn cross_of(axis: Axis, s: Size) -> f32 {
350 match axis {
351 Axis::Row => s.height,
352 Axis::Column => s.width,
353 }
354 }
355
356 #[inline]
357 fn make_size(axis: Axis, main: f32, cross: f32) -> Size {
358 match axis {
359 Axis::Row => Size::new(main, cross),
360 Axis::Column => Size::new(cross, main),
361 }
362 }
363
364 /// Offset that places an item of extent `item` within `container` per a start/center/end rule.
365 #[inline]
366 fn align_offset(start: bool, center: bool, container: f32, item: f32) -> f32 {
367 if center {
368 (container - item) / 2.0
369 } else if start {
370 0.0
371 } else {
372 container - item // end
373 }
374 }
375
376 /// Build a child rect from axis-relative main/cross offsets and extents (offsets are relative to
377 /// the parent's content origin `content_x`/`content_y`).
378 #[inline]
379 fn child_rect(
380 axis: Axis,
381 content_x: f32,
382 content_y: f32,
383 main_pos: f32,
384 cross_pos: f32,
385 main_size: f32,
386 cross_size: f32,
387 ) -> Rect {
388 match axis {
389 Axis::Row => Rect {
390 x: content_x + main_pos,
391 y: content_y + cross_pos,
392 width: main_size,
393 height: cross_size,
394 },
395 Axis::Column => Rect {
396 x: content_x + cross_pos,
397 y: content_y + main_pos,
398 width: cross_size,
399 height: main_size,
400 },
401 }
402 }
403
404 #[inline]
405 fn content_box(rect: Rect, p: Edges) -> (f32, f32, f32, f32) {
406 (
407 rect.x + p.left,
408 rect.y + p.top,
409 (rect.width - p.left - p.right).max(0.0),
410 (rect.height - p.top - p.bottom).max(0.0),
411 )
412 }
413
414 /// Resolve the node's own size from its content box: apply explicit width/height, add padding for
415 /// `Auto`, then clamp to min/max.
416 fn finalize_size(style: &Style, content: Size) -> Size {
417 let padded = Size::new(
418 content.width + style.padding.left + style.padding.right,
419 content.height + style.padding.top + style.padding.bottom,
420 );
421 let mut size = Size {
422 width: match style.width {
423 Length::Fixed(v) => v,
424 Length::Auto => padded.width,
425 },
426 height: match style.height {
427 Length::Fixed(v) => v,
428 Length::Auto => padded.height,
429 },
430 };
431 size.width = size.width.clamp(style.min_width, style.max_width);
432 size.height = size.height.clamp(style.min_height, style.max_height);
433 size
434 }
435
436 /// Run both passes over the subtree rooted at `root`, laying it out into `available` space at the
437 /// origin. Writes `measured` and `rect` into every node.
438 pub fn compute_layout(arena: &mut Arena<LayoutBox>, root: NodeId, available: Size) {
439 measure(arena, root);
440 let root_rect = Rect { x: 0.0, y: 0.0, width: available.width, height: available.height };
441 arrange(arena, root, root_rect);
442 }
443
444 /// Bottom-up intrinsic sizing. Returns and records the node's `measured` size.
445 pub fn measure(arena: &mut Arena<LayoutBox>, id: NodeId) -> Size {
446 let (style, intrinsic) = {
447 let b = arena.value(id).expect("measure: stale node");
448 (b.style, b.intrinsic)
449 };
450 let children = arena.children(id).to_vec();
451
452 let content = if children.is_empty() {
453 intrinsic.unwrap_or(Size::ZERO)
454 } else {
455 match style.mode {
456 LayoutMode::Flex => measure_flex(arena, &style, &children),
457 LayoutMode::Stack => measure_stack(arena, &children),
458 LayoutMode::Grid(spec) => measure_grid(arena, spec, &children),
459 }
460 };
461
462 let size = finalize_size(&style, content);
463 arena.value_mut(id).expect("measure: stale node").measured = size;
464 size
465 }
466
467 fn measure_flex(arena: &mut Arena<LayoutBox>, style: &Style, children: &[NodeId]) -> Size {
468 let mut main = 0.0f32;
469 let mut cross = 0.0f32;
470 for (i, &child) in children.iter().enumerate() {
471 let cs = measure(arena, child);
472 if i > 0 {
473 main += style.gap;
474 }
475 main += main_of(style.axis, cs);
476 cross = cross.max(cross_of(style.axis, cs));
477 }
478 make_size(style.axis, main, cross)
479 }
480
481 fn measure_stack(arena: &mut Arena<LayoutBox>, children: &[NodeId]) -> Size {
482 let mut w = 0.0f32;
483 let mut h = 0.0f32;
484 for &child in children {
485 let cs = measure(arena, child);
486 w = w.max(cs.width);
487 h = h.max(cs.height);
488 }
489 Size::new(w, h)
490 }
491
492 fn measure_grid(arena: &mut Arena<LayoutBox>, spec: GridSpec, children: &[NodeId]) -> Size {
493 let cols = spec.columns.max(1);
494 let sizes: Vec<Size> = children.iter().map(|&c| measure(arena, c)).collect();
495 let cell_w = sizes.iter().fold(0.0f32, |m, s| m.max(s.width));
496 let rows = sizes.len().div_ceil(cols);
497 let mut row_heights = vec![0.0f32; rows];
498 for (i, s) in sizes.iter().enumerate() {
499 let r = i / cols;
500 row_heights[r] = row_heights[r].max(s.height);
501 }
502 let content_w = cols as f32 * cell_w + (cols as f32 - 1.0) * spec.col_gap;
503 let content_h =
504 row_heights.iter().sum::<f32>() + (rows as f32 - 1.0).max(0.0) * spec.row_gap;
505 Size::new(content_w, content_h)
506 }
507
508 /// Top-down placement. Assigns `rect` to `id`, then positions its children within it.
509 pub fn arrange(arena: &mut Arena<LayoutBox>, id: NodeId, rect: Rect) {
510 arena.value_mut(id).expect("arrange: stale node").rect = rect;
511
512 let style = arena.value(id).unwrap().style;
513 let children = arena.children(id).to_vec();
514 if children.is_empty() {
515 return;
516 }
517 let (cx, cy, cw, ch) = content_box(rect, style.padding);
518
519 let placements = match style.mode {
520 LayoutMode::Flex => arrange_flex(arena, &style, &children, cx, cy, cw, ch),
521 LayoutMode::Stack => arrange_stack(arena, &style, &children, cx, cy, cw, ch),
522 LayoutMode::Grid(spec) => arrange_grid(arena, spec, &children, cx, cy),
523 };
524
525 for (child, r) in placements {
526 arrange(arena, child, r);
527 }
528 }
529
530 fn arrange_flex(
531 arena: &Arena<LayoutBox>,
532 style: &Style,
533 children: &[NodeId],
534 cx: f32,
535 cy: f32,
536 cw: f32,
537 ch: f32,
538 ) -> Vec<(NodeId, Rect)> {
539 let axis = style.axis;
540 let content = Size::new(cw, ch);
541 let content_main = main_of(axis, content);
542 let content_cross = cross_of(axis, content);
543
544 let mut child_main = Vec::with_capacity(children.len());
545 let mut child_cross = Vec::with_capacity(children.len());
546 let mut grows = Vec::with_capacity(children.len());
547 let mut shrinks = Vec::with_capacity(children.len());
548 for &child in children {
549 let b = arena.value(child).unwrap();
550 child_main.push(main_of(axis, b.measured));
551 child_cross.push(cross_of(axis, b.measured));
552 grows.push(b.style.grow);
553 shrinks.push(b.style.shrink);
554 }
555
556 let n = children.len();
557 let total_main: f32 = child_main.iter().sum::<f32>() + style.gap * (n as f32 - 1.0);
558 let free = content_main - total_main;
559 let total_grow: f32 = grows.iter().sum();
560 let total_shrink: f32 = shrinks.iter().sum();
561
562 // Resolve each child's main extent: grow to fill, or shrink to fit, else keep measured.
563 let mut sizes = child_main.clone();
564 let distributed = if free > 0.0 && total_grow > 0.0 {
565 for i in 0..n {
566 sizes[i] += grows[i] / total_grow * free;
567 }
568 true
569 } else if free < 0.0 && total_shrink > 0.0 {
570 let deficit = -free;
571 for i in 0..n {
572 sizes[i] = (child_main[i] - shrinks[i] / total_shrink * deficit).max(0.0);
573 }
574 true
575 } else {
576 false
577 };
578
579 // Alignment only distributes leftover space when grow/shrink didn't consume it.
580 let (start_offset, spacing_extra) = if distributed {
581 (0.0, 0.0)
582 } else {
583 match style.main_align {
584 MainAlign::Start => (0.0, 0.0),
585 MainAlign::Center => (free.max(0.0) / 2.0, 0.0),
586 MainAlign::End => (free.max(0.0), 0.0),
587 MainAlign::SpaceBetween => {
588 (0.0, if n > 1 { free.max(0.0) / (n as f32 - 1.0) } else { 0.0 })
589 }
590 }
591 };
592
593 let mut out = Vec::with_capacity(n);
594 let mut main_pos = start_offset;
595 for i in 0..n {
596 let main_size = sizes[i];
597 let cross_size = match style.cross_align {
598 CrossAlign::Stretch => content_cross,
599 _ => child_cross[i],
600 };
601 let cross_pos = match style.cross_align {
602 CrossAlign::Start | CrossAlign::Stretch => 0.0,
603 CrossAlign::Center => (content_cross - cross_size) / 2.0,
604 CrossAlign::End => content_cross - cross_size,
605 };
606 out.push((children[i], child_rect(axis, cx, cy, main_pos, cross_pos, main_size, cross_size)));
607 main_pos += main_size + style.gap + spacing_extra;
608 }
609 out
610 }
611
612 fn arrange_stack(
613 arena: &Arena<LayoutBox>,
614 style: &Style,
615 children: &[NodeId],
616 cx: f32,
617 cy: f32,
618 cw: f32,
619 ch: f32,
620 ) -> Vec<(NodeId, Rect)> {
621 // Stack has no axis: `main_align` places children horizontally, `cross_align` vertically.
622 let (h_start, h_center) =
623 (style.main_align == MainAlign::Start || style.main_align == MainAlign::SpaceBetween,
624 style.main_align == MainAlign::Center);
625 let (v_start, v_center) =
626 (style.cross_align == CrossAlign::Start, style.cross_align == CrossAlign::Center);
627 let stretch_v = style.cross_align == CrossAlign::Stretch;
628
629 let mut out = Vec::with_capacity(children.len());
630 for &child in children {
631 let m = arena.value(child).unwrap().measured;
632 let w = m.width;
633 let h = if stretch_v { ch } else { m.height };
634 let x = cx + align_offset(h_start, h_center, cw, w);
635 let y = cy + if stretch_v { 0.0 } else { align_offset(v_start, v_center, ch, h) };
636 out.push((child, Rect { x, y, width: w, height: h }));
637 }
638 out
639 }
640
641 fn arrange_grid(
642 arena: &Arena<LayoutBox>,
643 spec: GridSpec,
644 children: &[NodeId],
645 cx: f32,
646 cy: f32,
647 ) -> Vec<(NodeId, Rect)> {
648 let cols = spec.columns.max(1);
649 let sizes: Vec<Size> = children.iter().map(|&c| arena.value(c).unwrap().measured).collect();
650 let cell_w = sizes.iter().fold(0.0f32, |m, s| m.max(s.width));
651 let rows = sizes.len().div_ceil(cols);
652
653 let mut row_heights = vec![0.0f32; rows];
654 for (i, s) in sizes.iter().enumerate() {
655 row_heights[i / cols] = row_heights[i / cols].max(s.height);
656 }
657 // y offset of each row's top.
658 let mut row_y = vec![0.0f32; rows];
659 let mut acc = 0.0;
660 for r in 0..rows {
661 row_y[r] = acc;
662 acc += row_heights[r] + spec.row_gap;
663 }
664
665 let mut out = Vec::with_capacity(children.len());
666 for (i, &child) in children.iter().enumerate() {
667 let col = i % cols;
668 let row = i / cols;
669 let x = cx + col as f32 * (cell_w + spec.col_gap);
670 let y = cy + row_y[row];
671 out.push((child, Rect { x, y, width: sizes[i].width, height: sizes[i].height }));
672 }
673 out
674 }
675
676 #[cfg(test)]
677 mod tests {
678 use super::*;
679
680 #[test]
681 fn contains_is_half_open() {
682 let r = Rect { x: 10.0, y: 20.0, width: 100.0, height: 50.0 };
683 assert!(r.contains(10.0, 20.0), "the top-left corner is inside");
684 assert!(r.contains(109.9, 69.9));
685 assert!(!r.contains(110.0, 40.0), "the right edge is outside");
686 assert!(!r.contains(50.0, 70.0), "the bottom edge is outside");
687 assert!(!r.contains(9.9, 40.0) && !r.contains(50.0, 19.9));
688 // Neighbours sharing an edge split it: the point goes to one only.
689 let next = Rect { x: 110.0, ..r };
690 assert!(!r.contains(110.0, 40.0) && next.contains(110.0, 40.0));
691 assert!(!Rect::ZERO.contains(0.0, 0.0), "an empty rect contains nothing");
692 }
693
694 #[test]
695 fn fit_contain_letterboxes_and_centers() {
696 let b = Rect { x: 10.0, y: 20.0, width: 100.0, height: 50.0 };
697 // 200x100 source, scale limited by both axes equally -> 100x50 fill
698 let r = fit_rect(200, 100, b, FitMode::Contain { max_upscale: 4.0 });
699 assert_eq!((r.x, r.y, r.width, r.height), (10.0, 20.0, 100.0, 50.0));
700 // tall source letterboxes horizontally: scale = 50/200 -> 25x50
701 let r = fit_rect(100, 200, b, FitMode::Contain { max_upscale: 4.0 });
702 assert_eq!((r.width, r.height), (25.0, 50.0));
703 assert_eq!(r.x, 10.0 + (100.0 - 25.0) * 0.5);
704 assert_eq!(r.y, 20.0);
705 }
706
707 #[test]
708 fn fit_contain_caps_upscale_but_downscales_freely() {
709 let b = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
710 // small source: would need 8x, capped at 4x, centered
711 let r = fit_rect(50, 50, b, FitMode::Contain { max_upscale: 4.0 });
712 assert_eq!((r.width, r.height), (200.0, 200.0));
713 assert_eq!((r.x, r.y), (100.0, 100.0));
714 // large source downscales with no floor (the old .max(1.0) bug)
715 let r = fit_rect(800, 800, b, FitMode::Contain { max_upscale: 4.0 });
716 assert_eq!((r.width, r.height), (400.0, 400.0));
717 }
718
719 #[test]
720 fn fit_degenerate_inputs() {
721 let b = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
722 let r = fit_rect(0, 50, b, FitMode::Contain { max_upscale: 4.0 });
723 assert_eq!((r.width, r.height), (0.0, 0.0));
724 let r = fit_rect(10, 10, b, FitMode::Stretch);
725 assert_eq!((r.width, r.height), (100.0, 100.0));
726 }
727
728 fn leaf(arena: &mut Arena<LayoutBox>, w: f32, h: f32) -> NodeId {
729 arena.insert(LayoutBox::leaf(Style::default(), Size::new(w, h)))
730 }
731
732 fn rect_of(arena: &Arena<LayoutBox>, id: NodeId) -> Rect {
733 arena.value(id).unwrap().rect
734 }
735
736 #[test]
737 fn row_places_children_left_to_right_with_gap() {
738 let mut arena = Arena::new();
739 let root = arena.insert(LayoutBox::container(Style::row().gap(5.0)));
740 let a = leaf(&mut arena, 10.0, 10.0);
741 let b = leaf(&mut arena, 20.0, 10.0);
742 arena.append_child(root, a);
743 arena.append_child(root, b);
744
745 compute_layout(&mut arena, root, Size::new(100.0, 50.0));
746 assert_eq!(rect_of(&arena, a), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
747 assert_eq!(rect_of(&arena, b), Rect { x: 15.0, y: 0.0, width: 20.0, height: 10.0 });
748 }
749
750 #[test]
751 fn padding_offsets_content() {
752 let mut arena = Arena::new();
753 let mut s = Style::row();
754 s.padding = Edges { left: 5.0, right: 0.0, top: 7.0, bottom: 0.0 };
755 let root = arena.insert(LayoutBox::container(s));
756 let a = leaf(&mut arena, 10.0, 10.0);
757 arena.append_child(root, a);
758
759 compute_layout(&mut arena, root, Size::new(100.0, 50.0));
760 assert_eq!(rect_of(&arena, a), Rect { x: 5.0, y: 7.0, width: 10.0, height: 10.0 });
761 }
762
763 #[test]
764 fn grow_distributes_free_space_by_weight() {
765 let mut arena = Arena::new();
766 let root = arena.insert(LayoutBox::container(Style::row()));
767 let a = arena.insert(LayoutBox::leaf(Style::default().grow(1.0), Size::new(10.0, 10.0)));
768 let b = arena.insert(LayoutBox::leaf(Style::default().grow(3.0), Size::new(10.0, 10.0)));
769 arena.append_child(root, a);
770 arena.append_child(root, b);
771
772 compute_layout(&mut arena, root, Size::new(100.0, 50.0));
773 assert_eq!(rect_of(&arena, a).width, 30.0);
774 assert_eq!(rect_of(&arena, b).width, 70.0);
775 assert_eq!(rect_of(&arena, b).x, 30.0);
776 }
777
778 #[test]
779 fn shrink_absorbs_overflow_by_weight() {
780 // Two 60-wide leaves in 100px, both shrink 1 => 20px deficit split evenly => 50 each.
781 let mut arena = Arena::new();
782 let root = arena.insert(LayoutBox::container(Style::row()));
783 let a = arena.insert(LayoutBox::leaf(Style::default().shrink(1.0), Size::new(60.0, 10.0)));
784 let b = arena.insert(LayoutBox::leaf(Style::default().shrink(1.0), Size::new(60.0, 10.0)));
785 arena.append_child(root, a);
786 arena.append_child(root, b);
787
788 compute_layout(&mut arena, root, Size::new(100.0, 50.0));
789 assert_eq!(rect_of(&arena, a).width, 50.0);
790 assert_eq!(rect_of(&arena, b).width, 50.0);
791 assert_eq!(rect_of(&arena, b).x, 50.0);
792 }
793
794 #[test]
795 fn main_align_center_and_end() {
796 let mut arena = Arena::new();
797 let root_c = arena.insert(LayoutBox::container(Style::row().main_align(MainAlign::Center)));
798 let a = leaf(&mut arena, 20.0, 10.0);
799 arena.append_child(root_c, a);
800 compute_layout(&mut arena, root_c, Size::new(100.0, 50.0));
801 assert_eq!(rect_of(&arena, a).x, 40.0);
802
803 let mut arena2 = Arena::new();
804 let root_e = arena2.insert(LayoutBox::container(Style::row().main_align(MainAlign::End)));
805 let b = arena2.insert(LayoutBox::leaf(Style::default(), Size::new(20.0, 10.0)));
806 arena2.append_child(root_e, b);
807 compute_layout(&mut arena2, root_e, Size::new(100.0, 50.0));
808 assert_eq!(rect_of(&arena2, b).x, 80.0);
809 }
810
811 #[test]
812 fn space_between_pushes_children_to_edges() {
813 let mut arena = Arena::new();
814 let root = arena.insert(LayoutBox::container(Style::row().main_align(MainAlign::SpaceBetween)));
815 let a = leaf(&mut arena, 10.0, 10.0);
816 let b = leaf(&mut arena, 10.0, 10.0);
817 arena.append_child(root, a);
818 arena.append_child(root, b);
819
820 compute_layout(&mut arena, root, Size::new(100.0, 50.0));
821 assert_eq!(rect_of(&arena, a).x, 0.0);
822 assert_eq!(rect_of(&arena, b).x, 90.0);
823 }
824
825 #[test]
826 fn cross_align_center_and_stretch() {
827 let mut arena = Arena::new();
828 let root = arena.insert(LayoutBox::container(Style::row().cross_align(CrossAlign::Center)));
829 let a = leaf(&mut arena, 10.0, 10.0);
830 arena.append_child(root, a);
831 compute_layout(&mut arena, root, Size::new(100.0, 50.0));
832 assert_eq!(rect_of(&arena, a).y, 20.0);
833
834 let mut arena2 = Arena::new();
835 let root2 = arena2.insert(LayoutBox::container(Style::row().cross_align(CrossAlign::Stretch)));
836 let b = arena2.insert(LayoutBox::leaf(Style::default(), Size::new(10.0, 10.0)));
837 arena2.append_child(root2, b);
838 compute_layout(&mut arena2, root2, Size::new(100.0, 50.0));
839 assert_eq!(rect_of(&arena2, b).height, 50.0);
840 assert_eq!(rect_of(&arena2, b).y, 0.0);
841 }
842
843 #[test]
844 fn auto_container_measures_to_content() {
845 let mut arena = Arena::new();
846 let root = arena.insert(LayoutBox::container(Style::column().gap(4.0)));
847 let a = leaf(&mut arena, 10.0, 10.0);
848 let b = leaf(&mut arena, 10.0, 10.0);
849 arena.append_child(root, a);
850 arena.append_child(root, b);
851
852 let m = measure(&mut arena, root);
853 assert_eq!(m, Size::new(10.0, 24.0));
854 }
855
856 #[test]
857 fn fixed_length_overrides_content_and_clamps() {
858 let mut arena = Arena::new();
859 let s = Style::column().width(Length::Fixed(200.0));
860 let root = arena.insert(LayoutBox::container(s));
861 let a = leaf(&mut arena, 10.0, 10.0);
862 arena.append_child(root, a);
863
864 let m = measure(&mut arena, root);
865 assert_eq!(m.width, 200.0);
866 assert_eq!(m.height, 10.0);
867 }
868
869 #[test]
870 fn nested_containers_lay_out_recursively() {
871 let mut arena = Arena::new();
872 let root = arena.insert(LayoutBox::container(Style::row().gap(0.0)));
873 let inner = arena.insert(LayoutBox::container(Style::column().gap(2.0)));
874 let c1 = leaf(&mut arena, 10.0, 10.0);
875 let c2 = leaf(&mut arena, 10.0, 10.0);
876 let sibling = leaf(&mut arena, 5.0, 5.0);
877 arena.append_child(root, inner);
878 arena.append_child(root, sibling);
879 arena.append_child(inner, c1);
880 arena.append_child(inner, c2);
881
882 compute_layout(&mut arena, root, Size::new(100.0, 100.0));
883 assert_eq!(rect_of(&arena, inner), Rect { x: 0.0, y: 0.0, width: 10.0, height: 22.0 });
884 assert_eq!(rect_of(&arena, c1), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
885 assert_eq!(rect_of(&arena, c2), Rect { x: 0.0, y: 12.0, width: 10.0, height: 10.0 });
886 assert_eq!(rect_of(&arena, sibling).x, 10.0);
887 }
888
889 #[test]
890 fn stack_overlays_children_and_aligns_per_axis() {
891 let mut arena = Arena::new();
892 let root = arena.insert(LayoutBox::container(Style::stack()));
893 let a = leaf(&mut arena, 10.0, 10.0);
894 let b = leaf(&mut arena, 30.0, 20.0);
895 arena.append_child(root, a);
896 arena.append_child(root, b);
897 compute_layout(&mut arena, root, Size::new(100.0, 100.0));
898 // Start/Start: both at the content origin, at their own sizes.
899 assert_eq!(rect_of(&arena, a), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
900 assert_eq!(rect_of(&arena, b), Rect { x: 0.0, y: 0.0, width: 30.0, height: 20.0 });
901
902 // Centered on both axes.
903 let mut arena2 = Arena::new();
904 let root2 = arena2.insert(LayoutBox::container(
905 Style::stack().main_align(MainAlign::Center).cross_align(CrossAlign::Center),
906 ));
907 let c = arena2.insert(LayoutBox::leaf(Style::default(), Size::new(10.0, 10.0)));
908 arena2.append_child(root2, c);
909 compute_layout(&mut arena2, root2, Size::new(100.0, 100.0));
910 assert_eq!(rect_of(&arena2, c), Rect { x: 45.0, y: 45.0, width: 10.0, height: 10.0 });
911 }
912
913 #[test]
914 fn grid_flows_children_by_columns() {
915 // 3 leaves (10x10) in a 2-col grid, gaps 5/5.
916 let mut arena = Arena::new();
917 let root = arena.insert(LayoutBox::container(Style::grid(2, 5.0, 5.0)));
918 let a = leaf(&mut arena, 10.0, 10.0);
919 let b = leaf(&mut arena, 10.0, 10.0);
920 let c = leaf(&mut arena, 10.0, 10.0);
921 arena.append_child(root, a);
922 arena.append_child(root, b);
923 arena.append_child(root, c);
924
925 // measured: 2 cols * 10 + 5 = 25 wide; 2 rows * 10 + 5 = 25 tall.
926 let m = measure(&mut arena, root);
927 assert_eq!(m, Size::new(25.0, 25.0));
928
929 compute_layout(&mut arena, root, Size::new(200.0, 200.0));
930 assert_eq!(rect_of(&arena, a), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
931 assert_eq!(rect_of(&arena, b), Rect { x: 15.0, y: 0.0, width: 10.0, height: 10.0 });
932 assert_eq!(rect_of(&arena, c), Rect { x: 0.0, y: 15.0, width: 10.0, height: 10.0 });
933 }
934 }