implement flexbox main axis justification

This commit is contained in:
Will Temple
2026-05-22 23:46:05 -04:00
parent e5ae067bc2
commit 1be4a249e8
8 changed files with 457 additions and 11 deletions

View File

@@ -8,7 +8,8 @@ use crate::scene::{
};
use crate::text::TextSystem;
use crate::tree::{
AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style,
AlignItems, CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, JustifyContent,
Style,
};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
@@ -971,7 +972,13 @@ fn layout_container_children(
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
let free_space = if flex_total > 0.0 { 0.0 } else { remaining_main };
let justify = justify_offsets(
element.style.justify_content,
free_space,
element.children.len(),
);
let mut cursor = main_axis_origin(content, element.style.direction) + justify.leading;
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
@@ -1019,7 +1026,7 @@ fn layout_container_children(
layout_cache,
clip_rect,
));
cursor += child_main.max(0.0) + element.style.gap;
cursor += child_main.max(0.0) + element.style.gap + justify.between;
}
children
}
@@ -1226,7 +1233,13 @@ fn layout_interaction_skeleton_children(
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
let free_space = if flex_total > 0.0 { 0.0 } else { remaining_main };
let justify = justify_offsets(
element.style.justify_content,
free_space,
element.children.len(),
);
let mut cursor = main_axis_origin(content, element.style.direction) + justify.leading;
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
@@ -1269,7 +1282,7 @@ fn layout_interaction_skeleton_children(
perf_stats,
layout_cache,
));
cursor += child_main.max(0.0) + element.style.gap;
cursor += child_main.max(0.0) + element.style.gap + justify.between;
}
children
}
@@ -2246,15 +2259,197 @@ fn child_rect(
}
}
/// Adjustment applied by `justify_content` to the leading offset of the first
/// child and the extra space inserted between adjacent children (in addition
/// to `gap`).
#[derive(Clone, Copy, Debug)]
struct JustifyOffsets {
leading: f32,
between: f32,
}
fn justify_offsets(
justify: JustifyContent,
free_space: f32,
child_count: usize,
) -> JustifyOffsets {
if child_count == 0 {
return JustifyOffsets {
leading: 0.0,
between: 0.0,
};
}
let free = free_space.max(0.0);
match justify {
JustifyContent::Start => JustifyOffsets {
leading: 0.0,
between: 0.0,
},
JustifyContent::End => JustifyOffsets {
leading: free,
between: 0.0,
},
JustifyContent::Center => JustifyOffsets {
leading: free * 0.5,
between: 0.0,
},
JustifyContent::SpaceBetween => {
if child_count < 2 {
JustifyOffsets {
leading: 0.0,
between: 0.0,
}
} else {
JustifyOffsets {
leading: 0.0,
between: free / (child_count - 1) as f32,
}
}
}
JustifyContent::SpaceAround => {
let segment = free / child_count as f32;
JustifyOffsets {
leading: segment * 0.5,
between: segment,
}
}
JustifyContent::SpaceEvenly => {
let segment = free / (child_count + 1) as f32;
JustifyOffsets {
leading: segment,
between: segment,
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
LayoutCache, LayoutSnapshot, layout_scene, layout_snapshot, layout_snapshot_with_cache,
LayoutCache, LayoutSnapshot, justify_offsets, layout_scene, layout_snapshot,
layout_snapshot_with_cache,
};
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::TextSystem;
use crate::text::{TextStyle, TextWrap};
use crate::tree::{Edges, Element, ElementId, FlexDirection};
use crate::tree::{Edges, Element, ElementId, FlexDirection, JustifyContent};
#[test]
fn justify_offsets_match_each_mode() {
// 3 children, 60 units of free space.
let cases = [
(JustifyContent::Start, (0.0, 0.0)),
(JustifyContent::End, (60.0, 0.0)),
(JustifyContent::Center, (30.0, 0.0)),
(JustifyContent::SpaceBetween, (0.0, 30.0)),
(JustifyContent::SpaceAround, (10.0, 20.0)),
(JustifyContent::SpaceEvenly, (15.0, 15.0)),
];
for (mode, (leading, between)) in cases {
let off = justify_offsets(mode, 60.0, 3);
assert_eq!(
(off.leading, off.between),
(leading, between),
"mode {mode:?}",
);
}
}
#[test]
fn justify_offsets_handle_degenerate_inputs() {
// No children: every mode collapses to zero offsets.
for mode in [
JustifyContent::Start,
JustifyContent::End,
JustifyContent::Center,
JustifyContent::SpaceBetween,
JustifyContent::SpaceAround,
JustifyContent::SpaceEvenly,
] {
let off = justify_offsets(mode, 100.0, 0);
assert_eq!((off.leading, off.between), (0.0, 0.0));
}
// Single child: SpaceBetween pins to start, SpaceAround/SpaceEvenly center.
let one_between = justify_offsets(JustifyContent::SpaceBetween, 60.0, 1);
assert_eq!((one_between.leading, one_between.between), (0.0, 0.0));
let one_around = justify_offsets(JustifyContent::SpaceAround, 60.0, 1);
assert_eq!(one_around.leading, 30.0);
let one_evenly = justify_offsets(JustifyContent::SpaceEvenly, 60.0, 1);
assert_eq!(one_evenly.leading, 30.0);
// Negative free space is clamped to zero.
let off = justify_offsets(JustifyContent::Center, -10.0, 3);
assert_eq!((off.leading, off.between), (0.0, 0.0));
}
/// End-to-end check that `justify_content` actually shifts child rectangles
/// for every mode by laying out three 50-unit boxes in a 300-unit row with
/// no padding or gap (so 150 units of free space).
#[test]
fn justify_content_positions_children_for_each_mode() {
fn child_xs(mode: JustifyContent, child_count: usize) -> Vec<f32> {
let children: Vec<Element> = (0..child_count)
.map(|_| Element::new().width(50.0).background(Color::rgb(1, 0, 0)))
.collect();
let root = Element::row().justify_content(mode).children(children);
let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(q) => Some(q.rect.origin.x),
_ => None,
})
.collect()
}
// 3 children, free = 300 - 3*50 = 150.
assert_eq!(child_xs(JustifyContent::Start, 3), vec![0.0, 50.0, 100.0]);
assert_eq!(child_xs(JustifyContent::End, 3), vec![150.0, 200.0, 250.0]);
assert_eq!(
child_xs(JustifyContent::Center, 3),
vec![75.0, 125.0, 175.0],
);
// SpaceBetween: between = 150/2 = 75.
assert_eq!(
child_xs(JustifyContent::SpaceBetween, 3),
vec![0.0, 125.0, 250.0],
);
// SpaceAround: segment = 150/3 = 50, leading = 25.
assert_eq!(
child_xs(JustifyContent::SpaceAround, 3),
vec![25.0, 125.0, 225.0],
);
// SpaceEvenly with 4 children to avoid sub-pixel positions:
// free = 300 - 4*50 = 100, segment = 100/5 = 20.
assert_eq!(
child_xs(JustifyContent::SpaceEvenly, 4),
vec![20.0, 90.0, 160.0, 230.0],
);
}
#[test]
fn justify_content_no_op_when_flex_children_consume_remainder() {
// A flex child eats all remaining space, so SpaceBetween should not
// shift the fixed sibling away from its normal cursor position.
let root = Element::row()
.justify_content(JustifyContent::SpaceBetween)
.children([
Element::new().width(50.0).background(Color::rgb(1, 0, 0)),
Element::new().flex(1.0).background(Color::rgb(0, 1, 0)),
]);
let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
let xs: Vec<f32> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(q) => Some(q.rect.origin.x),
_ => None,
})
.collect();
assert_eq!(xs, vec![0.0, 50.0]);
}
#[test]
fn row_layout_apportions_fixed_and_flex_children() {

View File

@@ -53,7 +53,7 @@ pub use text::{
};
pub use tree::{
AlignItems, Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element,
ElementId, FlexDirection, ScrollbarStyle, Style,
ElementId, FlexDirection, JustifyContent, ScrollbarStyle, Style,
};
pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,

View File

@@ -32,6 +32,36 @@ pub enum AlignItems {
End,
}
/// Controls how children are positioned and how leftover space is distributed
/// along the main axis (horizontal for `FlexDirection::Row`, vertical for
/// `FlexDirection::Column`).
///
/// When a container has flex children (`flex_grow > 0`) all remaining space is
/// consumed by them, so `justify_content` has no visible effect on the main
/// axis distribution in that case. The container's `gap` is always honored as
/// a minimum between adjacent children — `SpaceBetween`, `SpaceAround`, and
/// `SpaceEvenly` distribute leftover space *in addition to* `gap`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum JustifyContent {
/// Pack children at the start of the main axis. This is the default and
/// matches the behavior before `justify_content` existed.
#[default]
Start,
/// Pack children at the end of the main axis.
End,
/// Pack children as a group, centered along the main axis.
Center,
/// Distribute children with the first child flush with the start, the last
/// child flush with the end, and equal extra space between adjacent pairs.
SpaceBetween,
/// Distribute children with equal extra space around each child, so the
/// space at the ends is half of the space between adjacent children.
SpaceAround,
/// Distribute children so the space between adjacent children and between
/// the children and both edges of the container is identical.
SpaceEvenly,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum CursorIcon {
Default,
@@ -192,6 +222,7 @@ pub struct Style {
pub align_items: AlignItems,
/// Per-element override of the parent container's `align_items`. `None` means inherit.
pub align_self: Option<AlignItems>,
pub justify_content: JustifyContent,
pub width: Option<f32>,
pub height: Option<f32>,
pub min_width: Option<f32>,
@@ -214,6 +245,7 @@ impl Default for Style {
direction: FlexDirection::Column,
align_items: AlignItems::Stretch,
align_self: None,
justify_content: JustifyContent::Start,
width: None,
height: None,
min_width: None,
@@ -427,6 +459,11 @@ impl Element {
self
}
pub fn justify_content(mut self, justify: JustifyContent) -> Self {
self.style.justify_content = justify;
self
}
pub fn cursor(mut self, cursor: CursorIcon) -> Self {
self.style.cursor = Some(cursor);
self
@@ -578,11 +615,18 @@ impl Hash for AlignItems {
}
}
impl Hash for JustifyContent {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
impl Hash for Style {
fn hash<H: Hasher>(&self, state: &mut H) {
self.direction.hash(state);
self.align_items.hash(state);
self.align_self.hash(state);
self.justify_content.hash(state);
self.width.map(f32::to_bits).hash(state);
self.height.map(f32::to_bits).hash(state);
self.min_width.map(f32::to_bits).hash(state);