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

@@ -52,6 +52,10 @@ path = "example/04_composition_and_context.rs"
name = "05_async_runtime_io"
path = "example/05_async_runtime_io.rs"
[[example]]
name = "06_justify_content"
path = "example/06_justify_content.rs"
[[example]]
name = "07_children_and_slots"
path = "example/07_children_and_slots.rs"

View File

@@ -0,0 +1,194 @@
//! Visual demonstration of every supported `justify_content` mode.
//!
//! The window arranges one labeled cell per mode in a two-column grid. Each
//! cell is a flex container of the same fixed width with four
//! identically-sized children, so the only visible difference between cells is
//! how `justify_content` distributes the leftover space along the main axis.
use ruin_app::prelude::*;
const TRACK_WIDTH: f32 = 360.0;
const ITEM_SIZE: f32 = 44.0;
#[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> {
App::new()
.window(
Window::new()
.title("RUIN justify_content")
.app_id("dev.ruin.justify-content")
.size(960.0, 620.0),
)
.mount(view! {
JustifyContentGallery() {}
})
.run()
.await
}
#[component]
fn JustifyContentGallery() -> impl IntoView {
let canvas = Color::rgb(0x10, 0x16, 0x22);
let panel = Color::rgb(0x1A, 0x23, 0x33);
let accent = Color::rgb(0x71, 0xA7, 0xF7);
let text_color = Color::rgb(0xF5, 0xF7, 0xFB);
let muted = Color::rgb(0x9A, 0xA9, 0xC2);
view! {
column(gap = 18.0, padding = 24.0, background = canvas) {
text(role = TextRole::Heading(1), size = 28.0, weight = FontWeight::Semibold, color = text_color) {
"justify_content"
}
text(color = muted, wrap = TextWrap::Word) {
"Each cell below is the same width and contains the same four boxes. The only \
thing that changes between cells is `justify_content`, which controls how the \
leftover space along the main axis is distributed."
}
row(gap = 16.0, align_items = AlignItems::Start) {
column(gap = 14.0, flex = 1.0) {
JustifyRow(
label = "Start",
description = "Pack children at the start of the main axis (default).",
justify = JustifyContent::Start,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "End",
description = "Pack children at the end of the main axis.",
justify = JustifyContent::End,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "Center",
description = "Pack children as a group, centered on the main axis.",
justify = JustifyContent::Center,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
}
column(gap = 14.0, flex = 1.0) {
JustifyRow(
label = "SpaceBetween",
description = "First child flush with the start, last with the end, equal gaps between.",
justify = JustifyContent::SpaceBetween,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "SpaceAround",
description = "Equal space around each child; end gaps are half the gap between children.",
justify = JustifyContent::SpaceAround,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
JustifyRow(
label = "SpaceEvenly",
description = "Equal space between children and between children and both edges.",
justify = JustifyContent::SpaceEvenly,
panel = panel,
accent = accent,
text_color = text_color,
muted = muted,
) {}
}
}
}
}
}
#[component]
fn JustifyRow(
label: &'static str,
description: &'static str,
justify: JustifyContent,
panel: Color,
accent: Color,
text_color: Color,
muted: Color,
) -> impl IntoView {
view! {
column(
gap = 6.0,
padding = 12.0,
background = panel,
border_radius = 10.0,
border = (1.0, accent),
) {
row(gap = 10.0, align_items = AlignItems::Center) {
text(size = 16.0, weight = FontWeight::Semibold, color = text_color) { label }
text(color = muted) { description }
}
row(
width = TRACK_WIDTH,
height = ITEM_SIZE + 16.0,
padding = 8.0,
gap = 8.0,
background = Color::rgba(0xFF, 0xFF, 0xFF, 0x10),
border_radius = 6.0,
align_items = AlignItems::Center,
justify_content = justify,
) {
JustifyItem(label = "1", accent = accent, text_color = text_color) {}
JustifyItem(label = "2", accent = accent, text_color = text_color) {}
JustifyItem(label = "3", accent = accent, text_color = text_color) {}
JustifyItem(label = "4", accent = accent, text_color = text_color) {}
}
}
}
}
#[component]
fn JustifyItem(label: &'static str, accent: Color, text_color: Color) -> impl IntoView {
view! {
block(
width = ITEM_SIZE,
height = ITEM_SIZE,
background = accent,
border_radius = 6.0,
align_items = AlignItems::Center,
) {
row(
flex = 1.0,
align_items = AlignItems::Center,
justify_content = JustifyContent::Center,
) {
text(size = 18.0, weight = FontWeight::Semibold, color = text_color) { label }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gallery_renders_all_labels() {
let root = JustifyContentGallery::builder().children(());
let view = ruin_app::__render_mountable_for_test(&root);
let snapshot = ruin_ui::layout_snapshot(1, UiSize::new(960.0, 620.0), view.element());
for label in ["Start", "End", "Center", "SpaceBetween", "SpaceAround", "SpaceEvenly"] {
assert!(
snapshot.scene.items.iter().any(|item| matches!(
item,
ruin_ui::DisplayItem::Text(text) if text.text.contains(label)
)),
"expected `{label}` label to appear in the rendered scene",
);
}
}
}

View File

@@ -71,8 +71,8 @@ pub mod prelude {
};
pub use ruin_ui::{
AlignItems, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree,
PointerButton, PointerEventKind, RoutedPointerEvent, RoutedPointerEventKind,
ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
JustifyContent, PointerButton, PointerEventKind, RoutedPointerEvent,
RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextStyle, TextWrap, UiSize,
};
}

View File

@@ -1,4 +1,4 @@
use ruin_ui::{AlignItems, Color, Element, ElementId};
use ruin_ui::{AlignItems, Color, Element, ElementId, JustifyContent};
use crate::context::allocate_element_id;
use crate::converters::{IntoBorder, IntoEdges, IntoShadow};
@@ -92,6 +92,11 @@ impl ContainerProps {
self
}
pub fn justify_content(mut self, justify: JustifyContent) -> Self {
self.element = self.element.justify_content(justify);
self
}
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.element = self.element.shadow(shadow.into_shadow());
self

View File

@@ -53,6 +53,10 @@ macro_rules! impl_props_methods {
self.props = self.props.align_self(align);
self
}
pub fn justify_content(mut self, justify: ruin_ui::JustifyContent) -> Self {
self.props = self.props.justify_content(justify);
self
}
pub fn widget_ref<T>(mut self, widget_ref: $crate::hooks::WidgetRef<T>) -> Self {
self.props = self.props.widget_ref(widget_ref);
self

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);