git.lucas.co / cce-ui
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 }