git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/scene/layout.rs (31.9K)

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