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

commit25b8bcedccb01f0e48f9e914520274b597ba2a04
parent5920d29826
authorLucas Galante <[email protected]>
date2026-07-07 17:44
feat(scene): stack + grid layout modes and flex shrink

Extend scene::layout with LayoutMode {Flex, Stack, Grid}: Stack overlays children with per-axis alignment; Grid flows children across a fixed column count (uniform column width, per-row heights) with row/col gaps; flex gains shrink to absorb main-axis overflow by weight. 12 layout tests, 105 total. Still headless (not wired into widgets). Deferred: wrap, percent lengths, width-dependent adaptive grid.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

 src/scene/layout.rs | 413 +++++++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 324 insertions(+), 89 deletions(-)

diff --git a/src/scene/layout.rs b/src/scene/layout.rs
index 78346d9..870e1eb 100644
--- a/src/scene/layout.rs
+++ b/src/scene/layout.rs
@@ -12,16 +12,15 @@
 //!   * **measure** (bottom-up): each node reports an intrinsic [`Size`] from its children (or, for
 //!     a leaf, its content size). Written into `LayoutBox::measured`.
 //!   * **arrange** (top-down): each node is given a final [`Rect`] and positions its children
-//!     within it — distributing flex `grow`, honoring `gap`/`padding`, and applying main/cross
-//!     alignment. Written into `LayoutBox::rect`.
+//!     within it. Written into `LayoutBox::rect`.
 //!
 //! Because it operates on `Style` + `Size` and writes plain rects, it is fully unit-testable
 //! without a GPU or a Wayland surface (see the tests below).
 //!
-//! Scope of this first cut: a flexbox-style **row/column** model with padding, gap, grow,
-//! main-axis alignment (start/center/end/space-between), cross-axis alignment
-//! (start/center/end/stretch), fixed/auto sizing, and min/max clamps. Deferred to later
-//! increments: shrink weights, wrapping, grid, percentage lengths, and the glyphon text-measure
+//! Layout modes ([`LayoutMode`]): **Flex** (row/column with grow/shrink, gap, padding, main/cross
+//! alignment incl. stretch), **Stack** (Z-overlay with per-axis alignment), and **Grid** (fixed
+//! column count with uniform column width and per-row heights). Deferred to later increments:
+//! wrapping, percentage lengths, width-dependent adaptive grids, and the glyphon text-measure
 //! hook for real leaf widgets (that lands with Phase 2b integration).
 
 use crate::scene::arena::{Arena, NodeId};
@@ -69,13 +68,32 @@ impl Edges {
     }
 }
 
-/// The main-axis direction a container lays its children along.
+/// The main-axis direction a flex container lays its children along.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Axis {
     Row,
     Column,
 }
 
+/// How a node arranges its children.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum LayoutMode {
+    /// Row/column flex (uses `Style::axis`).
+    Flex,
+    /// All children overlaid in the same box (Z-stack), aligned per axis.
+    Stack,
+    /// Fixed-column grid, filling left-to-right then top-to-bottom.
+    Grid(GridSpec),
+}
+
+/// A fixed-column grid: uniform column width (max child width), per-row heights.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct GridSpec {
+    pub columns: usize,
+    pub col_gap: f32,
+    pub row_gap: f32,
+}
+
 /// A length along one axis.
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub enum Length {
@@ -85,7 +103,10 @@ pub enum Length {
     Fixed(f32),
 }
 
-/// Distribution of free space along the main axis (when no child grows).
+/// Distribution of free space along the main axis (when no child grows/shrinks). For [`Stack`]
+/// this selects horizontal placement.
+///
+/// [`Stack`]: LayoutMode::Stack
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum MainAlign {
     Start,
@@ -94,7 +115,9 @@ pub enum MainAlign {
     SpaceBetween,
 }
 
-/// Placement of each child across the cross axis.
+/// Placement of each child across the cross axis. For [`Stack`] this selects vertical placement.
+///
+/// [`Stack`]: LayoutMode::Stack
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum CrossAlign {
     Start,
@@ -107,6 +130,7 @@ pub enum CrossAlign {
 /// Layout inputs for a node.
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub struct Style {
+    pub mode: LayoutMode,
     pub axis: Axis,
     pub padding: Edges,
     pub gap: f32,
@@ -116,6 +140,8 @@ pub struct Style {
     pub height: Length,
     /// Flex grow weight: share of leftover main-axis space this node claims.
     pub grow: f32,
+    /// Flex shrink weight: share of a main-axis overflow this node gives up.
+    pub shrink: f32,
     pub min_width: f32,
     pub min_height: f32,
     pub max_width: f32,
@@ -125,6 +151,7 @@ pub struct Style {
 impl Default for Style {
     fn default() -> Self {
         Style {
+            mode: LayoutMode::Flex,
             axis: Axis::Column,
             padding: Edges::ZERO,
             gap: 0.0,
@@ -133,6 +160,7 @@ impl Default for Style {
             width: Length::Auto,
             height: Length::Auto,
             grow: 0.0,
+            shrink: 0.0,
             min_width: 0.0,
             min_height: 0.0,
             max_width: f32::INFINITY,
@@ -143,10 +171,16 @@ impl Default for Style {
 
 impl Style {
     pub fn row() -> Self {
-        Style { axis: Axis::Row, ..Default::default() }
+        Style { mode: LayoutMode::Flex, axis: Axis::Row, ..Default::default() }
     }
     pub fn column() -> Self {
-        Style { axis: Axis::Column, ..Default::default() }
+        Style { mode: LayoutMode::Flex, axis: Axis::Column, ..Default::default() }
+    }
+    pub fn stack() -> Self {
+        Style { mode: LayoutMode::Stack, ..Default::default() }
+    }
+    pub fn grid(columns: usize, col_gap: f32, row_gap: f32) -> Self {
+        Style { mode: LayoutMode::Grid(GridSpec { columns: columns.max(1), col_gap, row_gap }), ..Default::default() }
     }
     pub fn gap(mut self, v: f32) -> Self {
         self.gap = v;
@@ -160,6 +194,10 @@ impl Style {
         self.grow = v;
         self
     }
+    pub fn shrink(mut self, v: f32) -> Self {
+        self.shrink = v;
+        self
+    }
     pub fn main_align(mut self, a: MainAlign) -> Self {
         self.main_align = a;
         self
@@ -227,19 +265,15 @@ fn make_size(axis: Axis, main: f32, cross: f32) -> Size {
     }
 }
 
+/// Offset that places an item of extent `item` within `container` per a start/center/end rule.
 #[inline]
-fn pad_main(axis: Axis, p: Edges) -> f32 {
-    match axis {
-        Axis::Row => p.left + p.right,
-        Axis::Column => p.top + p.bottom,
-    }
-}
-
-#[inline]
-fn pad_cross(axis: Axis, p: Edges) -> f32 {
-    match axis {
-        Axis::Row => p.top + p.bottom,
-        Axis::Column => p.left + p.right,
+fn align_offset(start: bool, center: bool, container: f32, item: f32) -> f32 {
+    if center {
+        (container - item) / 2.0
+    } else if start {
+        0.0
+    } else {
+        container - item // end
     }
 }
 
@@ -271,6 +305,38 @@ fn child_rect(
     }
 }
 
+#[inline]
+fn content_box(rect: Rect, p: Edges) -> (f32, f32, f32, f32) {
+    (
+        rect.x + p.left,
+        rect.y + p.top,
+        (rect.width - p.left - p.right).max(0.0),
+        (rect.height - p.top - p.bottom).max(0.0),
+    )
+}
+
+/// Resolve the node's own size from its content box: apply explicit width/height, add padding for
+/// `Auto`, then clamp to min/max.
+fn finalize_size(style: &Style, content: Size) -> Size {
+    let padded = Size::new(
+        content.width + style.padding.left + style.padding.right,
+        content.height + style.padding.top + style.padding.bottom,
+    );
+    let mut size = Size {
+        width: match style.width {
+            Length::Fixed(v) => v,
+            Length::Auto => padded.width,
+        },
+        height: match style.height {
+            Length::Fixed(v) => v,
+            Length::Auto => padded.height,
+        },
+    };
+    size.width = size.width.clamp(style.min_width, style.max_width);
+    size.height = size.height.clamp(style.min_height, style.max_height);
+    size
+}
+
 /// Run both passes over the subtree rooted at `root`, laying it out into `available` space at the
 /// origin. Writes `measured` and `rect` into every node.
 pub fn compute_layout(arena: &mut Arena<LayoutBox>, root: NodeId, available: Size) {
@@ -287,46 +353,62 @@ pub fn measure(arena: &mut Arena<LayoutBox>, id: NodeId) -> Size {
     };
     let children = arena.children(id).to_vec();
 
-    let (content_main, content_cross) = if children.is_empty() {
-        let s = intrinsic.unwrap_or(Size::ZERO);
-        (main_of(style.axis, s), cross_of(style.axis, s))
+    let content = if children.is_empty() {
+        intrinsic.unwrap_or(Size::ZERO)
     } else {
-        let mut main = 0.0f32;
-        let mut cross = 0.0f32;
-        for (i, &child) in children.iter().enumerate() {
-            let cs = measure(arena, child);
-            if i > 0 {
-                main += style.gap;
-            }
-            main += main_of(style.axis, cs);
-            cross = cross.max(cross_of(style.axis, cs));
+        match style.mode {
+            LayoutMode::Flex => measure_flex(arena, &style, &children),
+            LayoutMode::Stack => measure_stack(arena, &children),
+            LayoutMode::Grid(spec) => measure_grid(arena, spec, &children),
         }
-        (main, cross)
     };
 
-    let full = make_size(
-        style.axis,
-        content_main + pad_main(style.axis, style.padding),
-        content_cross + pad_cross(style.axis, style.padding),
-    );
-
-    let mut size = Size {
-        width: match style.width {
-            Length::Fixed(v) => v,
-            Length::Auto => full.width,
-        },
-        height: match style.height {
-            Length::Fixed(v) => v,
-            Length::Auto => full.height,
-        },
-    };
-    size.width = size.width.clamp(style.min_width, style.max_width);
-    size.height = size.height.clamp(style.min_height, style.max_height);
-
+    let size = finalize_size(&style, content);
     arena.value_mut(id).expect("measure: stale node").measured = size;
     size
 }
 
+fn measure_flex(arena: &mut Arena<LayoutBox>, style: &Style, children: &[NodeId]) -> Size {
+    let mut main = 0.0f32;
+    let mut cross = 0.0f32;
+    for (i, &child) in children.iter().enumerate() {
+        let cs = measure(arena, child);
+        if i > 0 {
+            main += style.gap;
+        }
+        main += main_of(style.axis, cs);
+        cross = cross.max(cross_of(style.axis, cs));
+    }
+    make_size(style.axis, main, cross)
+}
+
+fn measure_stack(arena: &mut Arena<LayoutBox>, children: &[NodeId]) -> Size {
+    let mut w = 0.0f32;
+    let mut h = 0.0f32;
+    for &child in children {
+        let cs = measure(arena, child);
+        w = w.max(cs.width);
+        h = h.max(cs.height);
+    }
+    Size::new(w, h)
+}
+
+fn measure_grid(arena: &mut Arena<LayoutBox>, spec: GridSpec, children: &[NodeId]) -> Size {
+    let cols = spec.columns.max(1);
+    let sizes: Vec<Size> = children.iter().map(|&c| measure(arena, c)).collect();
+    let cell_w = sizes.iter().fold(0.0f32, |m, s| m.max(s.width));
+    let rows = sizes.len().div_ceil(cols);
+    let mut row_heights = vec![0.0f32; rows];
+    for (i, s) in sizes.iter().enumerate() {
+        let r = i / cols;
+        row_heights[r] = row_heights[r].max(s.height);
+    }
+    let content_w = cols as f32 * cell_w + (cols as f32 - 1.0) * spec.col_gap;
+    let content_h =
+        row_heights.iter().sum::<f32>() + (rows as f32 - 1.0).max(0.0) * spec.row_gap;
+    Size::new(content_w, content_h)
+}
+
 /// Top-down placement. Assigns `rect` to `id`, then positions its children within it.
 pub fn arrange(arena: &mut Arena<LayoutBox>, id: NodeId, rect: Rect) {
     arena.value_mut(id).expect("arrange: stale node").rect = rect;
@@ -336,34 +418,70 @@ pub fn arrange(arena: &mut Arena<LayoutBox>, id: NodeId, rect: Rect) {
     if children.is_empty() {
         return;
     }
+    let (cx, cy, cw, ch) = content_box(rect, style.padding);
+
+    let placements = match style.mode {
+        LayoutMode::Flex => arrange_flex(arena, &style, &children, cx, cy, cw, ch),
+        LayoutMode::Stack => arrange_stack(arena, &style, &children, cx, cy, cw, ch),
+        LayoutMode::Grid(spec) => arrange_grid(arena, spec, &children, cx, cy),
+    };
+
+    for (child, r) in placements {
+        arrange(arena, child, r);
+    }
+}
 
-    let content_x = rect.x + style.padding.left;
-    let content_y = rect.y + style.padding.top;
-    let content_w = (rect.width - style.padding.left - style.padding.right).max(0.0);
-    let content_h = (rect.height - style.padding.top - style.padding.bottom).max(0.0);
-    let content = Size::new(content_w, content_h);
-    let content_main = main_of(style.axis, content);
-    let content_cross = cross_of(style.axis, content);
+fn arrange_flex(
+    arena: &Arena<LayoutBox>,
+    style: &Style,
+    children: &[NodeId],
+    cx: f32,
+    cy: f32,
+    cw: f32,
+    ch: f32,
+) -> Vec<(NodeId, Rect)> {
+    let axis = style.axis;
+    let content = Size::new(cw, ch);
+    let content_main = main_of(axis, content);
+    let content_cross = cross_of(axis, content);
 
-    // Snapshot each child's measured main/cross extent and grow weight.
     let mut child_main = Vec::with_capacity(children.len());
     let mut child_cross = Vec::with_capacity(children.len());
     let mut grows = Vec::with_capacity(children.len());
-    for &child in &children {
+    let mut shrinks = Vec::with_capacity(children.len());
+    for &child in children {
         let b = arena.value(child).unwrap();
-        child_main.push(main_of(style.axis, b.measured));
-        child_cross.push(cross_of(style.axis, b.measured));
+        child_main.push(main_of(axis, b.measured));
+        child_cross.push(cross_of(axis, b.measured));
         grows.push(b.style.grow);
+        shrinks.push(b.style.shrink);
     }
 
     let n = children.len();
     let total_main: f32 = child_main.iter().sum::<f32>() + style.gap * (n as f32 - 1.0);
     let free = content_main - total_main;
     let total_grow: f32 = grows.iter().sum();
-    let extra_per_grow = if total_grow > 0.0 && free > 0.0 { free / total_grow } else { 0.0 };
+    let total_shrink: f32 = shrinks.iter().sum();
 
-    // Alignment only distributes leftover space when nothing grows (grow already consumes it).
-    let (start_offset, spacing_extra) = if total_grow > 0.0 {
+    // Resolve each child's main extent: grow to fill, or shrink to fit, else keep measured.
+    let mut sizes = child_main.clone();
+    let distributed = if free > 0.0 && total_grow > 0.0 {
+        for i in 0..n {
+            sizes[i] += grows[i] / total_grow * free;
+        }
+        true
+    } else if free < 0.0 && total_shrink > 0.0 {
+        let deficit = -free;
+        for i in 0..n {
+            sizes[i] = (child_main[i] - shrinks[i] / total_shrink * deficit).max(0.0);
+        }
+        true
+    } else {
+        false
+    };
+
+    // Alignment only distributes leftover space when grow/shrink didn't consume it.
+    let (start_offset, spacing_extra) = if distributed {
         (0.0, 0.0)
     } else {
         match style.main_align {
@@ -376,10 +494,10 @@ pub fn arrange(arena: &mut Arena<LayoutBox>, id: NodeId, rect: Rect) {
         }
     };
 
-    let mut placements: Vec<(NodeId, Rect)> = Vec::with_capacity(n);
+    let mut out = Vec::with_capacity(n);
     let mut main_pos = start_offset;
     for i in 0..n {
-        let main_size = child_main[i] + grows[i] * extra_per_grow;
+        let main_size = sizes[i];
         let cross_size = match style.cross_align {
             CrossAlign::Stretch => content_cross,
             _ => child_cross[i],
@@ -389,14 +507,74 @@ pub fn arrange(arena: &mut Arena<LayoutBox>, id: NodeId, rect: Rect) {
             CrossAlign::Center => (content_cross - cross_size) / 2.0,
             CrossAlign::End => content_cross - cross_size,
         };
-        let r = child_rect(style.axis, content_x, content_y, main_pos, cross_pos, main_size, cross_size);
-        placements.push((children[i], r));
+        out.push((children[i], child_rect(axis, cx, cy, main_pos, cross_pos, main_size, cross_size)));
         main_pos += main_size + style.gap + spacing_extra;
     }
+    out
+}
 
-    for (child, r) in placements {
-        arrange(arena, child, r);
-    }
+fn arrange_stack(
+    arena: &Arena<LayoutBox>,
+    style: &Style,
+    children: &[NodeId],
+    cx: f32,
+    cy: f32,
+    cw: f32,
+    ch: f32,
+) -> Vec<(NodeId, Rect)> {
+    // Stack has no axis: `main_align` places children horizontally, `cross_align` vertically.
+    let (h_start, h_center) =
+        (style.main_align == MainAlign::Start || style.main_align == MainAlign::SpaceBetween,
+         style.main_align == MainAlign::Center);
+    let (v_start, v_center) =
+        (style.cross_align == CrossAlign::Start, style.cross_align == CrossAlign::Center);
+    let stretch_v = style.cross_align == CrossAlign::Stretch;
+
+    let mut out = Vec::with_capacity(children.len());
+    for &child in children {
+        let m = arena.value(child).unwrap().measured;
+        let w = m.width;
+        let h = if stretch_v { ch } else { m.height };
+        let x = cx + align_offset(h_start, h_center, cw, w);
+        let y = cy + if stretch_v { 0.0 } else { align_offset(v_start, v_center, ch, h) };
+        out.push((child, Rect { x, y, width: w, height: h }));
+    }
+    out
+}
+
+fn arrange_grid(
+    arena: &Arena<LayoutBox>,
+    spec: GridSpec,
+    children: &[NodeId],
+    cx: f32,
+    cy: f32,
+) -> Vec<(NodeId, Rect)> {
+    let cols = spec.columns.max(1);
+    let sizes: Vec<Size> = children.iter().map(|&c| arena.value(c).unwrap().measured).collect();
+    let cell_w = sizes.iter().fold(0.0f32, |m, s| m.max(s.width));
+    let rows = sizes.len().div_ceil(cols);
+
+    let mut row_heights = vec![0.0f32; rows];
+    for (i, s) in sizes.iter().enumerate() {
+        row_heights[i / cols] = row_heights[i / cols].max(s.height);
+    }
+    // y offset of each row's top.
+    let mut row_y = vec![0.0f32; rows];
+    let mut acc = 0.0;
+    for r in 0..rows {
+        row_y[r] = acc;
+        acc += row_heights[r] + spec.row_gap;
+    }
+
+    let mut out = Vec::with_capacity(children.len());
+    for (i, &child) in children.iter().enumerate() {
+        let col = i % cols;
+        let row = i / cols;
+        let x = cx + col as f32 * (cell_w + spec.col_gap);
+        let y = cy + row_y[row];
+        out.push((child, Rect { x, y, width: sizes[i].width, height: sizes[i].height }));
+    }
+    out
 }
 
 #[cfg(test)]
@@ -442,7 +620,6 @@ mod tests {
     fn grow_distributes_free_space_by_weight() {
         let mut arena = Arena::new();
         let root = arena.insert(LayoutBox::container(Style::row()));
-        // Two 10-wide leaves, grow 1 and 3. Free = 100 - 20 = 80, split 1:3 => +20 and +60.
         let a = arena.insert(LayoutBox::leaf(Style::default().grow(1.0), Size::new(10.0, 10.0)));
         let b = arena.insert(LayoutBox::leaf(Style::default().grow(3.0), Size::new(10.0, 10.0)));
         arena.append_child(root, a);
@@ -451,7 +628,23 @@ mod tests {
         compute_layout(&mut arena, root, Size::new(100.0, 50.0));
         assert_eq!(rect_of(&arena, a).width, 30.0);
         assert_eq!(rect_of(&arena, b).width, 70.0);
-        assert_eq!(rect_of(&arena, b).x, 30.0, "b starts after a's grown width");
+        assert_eq!(rect_of(&arena, b).x, 30.0);
+    }
+
+    #[test]
+    fn shrink_absorbs_overflow_by_weight() {
+        // Two 60-wide leaves in 100px, both shrink 1 => 20px deficit split evenly => 50 each.
+        let mut arena = Arena::new();
+        let root = arena.insert(LayoutBox::container(Style::row()));
+        let a = arena.insert(LayoutBox::leaf(Style::default().shrink(1.0), Size::new(60.0, 10.0)));
+        let b = arena.insert(LayoutBox::leaf(Style::default().shrink(1.0), Size::new(60.0, 10.0)));
+        arena.append_child(root, a);
+        arena.append_child(root, b);
+
+        compute_layout(&mut arena, root, Size::new(100.0, 50.0));
+        assert_eq!(rect_of(&arena, a).width, 50.0);
+        assert_eq!(rect_of(&arena, b).width, 50.0);
+        assert_eq!(rect_of(&arena, b).x, 50.0);
     }
 
     #[test]
@@ -461,14 +654,14 @@ mod tests {
         let a = leaf(&mut arena, 20.0, 10.0);
         arena.append_child(root_c, a);
         compute_layout(&mut arena, root_c, Size::new(100.0, 50.0));
-        assert_eq!(rect_of(&arena, a).x, 40.0, "centered: (100-20)/2");
+        assert_eq!(rect_of(&arena, a).x, 40.0);
 
         let mut arena2 = Arena::new();
         let root_e = arena2.insert(LayoutBox::container(Style::row().main_align(MainAlign::End)));
         let b = arena2.insert(LayoutBox::leaf(Style::default(), Size::new(20.0, 10.0)));
         arena2.append_child(root_e, b);
         compute_layout(&mut arena2, root_e, Size::new(100.0, 50.0));
-        assert_eq!(rect_of(&arena2, b).x, 80.0, "end: 100-20");
+        assert_eq!(rect_of(&arena2, b).x, 80.0);
     }
 
     #[test]
@@ -492,20 +685,19 @@ mod tests {
         let a = leaf(&mut arena, 10.0, 10.0);
         arena.append_child(root, a);
         compute_layout(&mut arena, root, Size::new(100.0, 50.0));
-        assert_eq!(rect_of(&arena, a).y, 20.0, "centered on cross axis: (50-10)/2");
+        assert_eq!(rect_of(&arena, a).y, 20.0);
 
         let mut arena2 = Arena::new();
         let root2 = arena2.insert(LayoutBox::container(Style::row().cross_align(CrossAlign::Stretch)));
         let b = arena2.insert(LayoutBox::leaf(Style::default(), Size::new(10.0, 10.0)));
         arena2.append_child(root2, b);
         compute_layout(&mut arena2, root2, Size::new(100.0, 50.0));
-        assert_eq!(rect_of(&arena2, b).height, 50.0, "stretched to cross content extent");
+        assert_eq!(rect_of(&arena2, b).height, 50.0);
         assert_eq!(rect_of(&arena2, b).y, 0.0);
     }
 
     #[test]
     fn auto_container_measures_to_content() {
-        // A column with two 10x10 leaves and gap 4 should measure 10 wide x 24 tall.
         let mut arena = Arena::new();
         let root = arena.insert(LayoutBox::container(Style::column().gap(4.0)));
         let a = leaf(&mut arena, 10.0, 10.0);
@@ -526,13 +718,12 @@ mod tests {
         arena.append_child(root, a);
 
         let m = measure(&mut arena, root);
-        assert_eq!(m.width, 200.0, "fixed width wins over 10px content");
-        assert_eq!(m.height, 10.0, "auto height still follows content");
+        assert_eq!(m.width, 200.0);
+        assert_eq!(m.height, 10.0);
     }
 
     #[test]
     fn nested_containers_lay_out_recursively() {
-        // root(row) -> [ inner(column, gap 2) -> [c1 10x10, c2 10x10], sibling 5x5 ]
         let mut arena = Arena::new();
         let root = arena.insert(LayoutBox::container(Style::row().gap(0.0)));
         let inner = arena.insert(LayoutBox::container(Style::column().gap(2.0)));
@@ -545,11 +736,55 @@ mod tests {
         arena.append_child(inner, c2);
 
         compute_layout(&mut arena, root, Size::new(100.0, 100.0));
-
-        // inner measures 10 wide x 22 tall; sits at origin; sibling to its right.
         assert_eq!(rect_of(&arena, inner), Rect { x: 0.0, y: 0.0, width: 10.0, height: 22.0 });
         assert_eq!(rect_of(&arena, c1), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
         assert_eq!(rect_of(&arena, c2), Rect { x: 0.0, y: 12.0, width: 10.0, height: 10.0 });
-        assert_eq!(rect_of(&arena, sibling).x, 10.0, "sibling follows inner's width");
+        assert_eq!(rect_of(&arena, sibling).x, 10.0);
+    }
+
+    #[test]
+    fn stack_overlays_children_and_aligns_per_axis() {
+        let mut arena = Arena::new();
+        let root = arena.insert(LayoutBox::container(Style::stack()));
+        let a = leaf(&mut arena, 10.0, 10.0);
+        let b = leaf(&mut arena, 30.0, 20.0);
+        arena.append_child(root, a);
+        arena.append_child(root, b);
+        compute_layout(&mut arena, root, Size::new(100.0, 100.0));
+        // Start/Start: both at the content origin, at their own sizes.
+        assert_eq!(rect_of(&arena, a), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
+        assert_eq!(rect_of(&arena, b), Rect { x: 0.0, y: 0.0, width: 30.0, height: 20.0 });
+
+        // Centered on both axes.
+        let mut arena2 = Arena::new();
+        let root2 = arena2.insert(LayoutBox::container(
+            Style::stack().main_align(MainAlign::Center).cross_align(CrossAlign::Center),
+        ));
+        let c = arena2.insert(LayoutBox::leaf(Style::default(), Size::new(10.0, 10.0)));
+        arena2.append_child(root2, c);
+        compute_layout(&mut arena2, root2, Size::new(100.0, 100.0));
+        assert_eq!(rect_of(&arena2, c), Rect { x: 45.0, y: 45.0, width: 10.0, height: 10.0 });
+    }
+
+    #[test]
+    fn grid_flows_children_by_columns() {
+        // 3 leaves (10x10) in a 2-col grid, gaps 5/5.
+        let mut arena = Arena::new();
+        let root = arena.insert(LayoutBox::container(Style::grid(2, 5.0, 5.0)));
+        let a = leaf(&mut arena, 10.0, 10.0);
+        let b = leaf(&mut arena, 10.0, 10.0);
+        let c = leaf(&mut arena, 10.0, 10.0);
+        arena.append_child(root, a);
+        arena.append_child(root, b);
+        arena.append_child(root, c);
+
+        // measured: 2 cols * 10 + 5 = 25 wide; 2 rows * 10 + 5 = 25 tall.
+        let m = measure(&mut arena, root);
+        assert_eq!(m, Size::new(25.0, 25.0));
+
+        compute_layout(&mut arena, root, Size::new(200.0, 200.0));
+        assert_eq!(rect_of(&arena, a), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
+        assert_eq!(rect_of(&arena, b), Rect { x: 15.0, y: 0.0, width: 10.0, height: 10.0 });
+        assert_eq!(rect_of(&arena, c), Rect { x: 0.0, y: 15.0, width: 10.0, height: 10.0 });
     }
 }