diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index 38b33bd..79b0f25 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -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" diff --git a/lib/ruin_app/example/06_justify_content.rs b/lib/ruin_app/example/06_justify_content.rs new file mode 100644 index 0000000..c2cb447 --- /dev/null +++ b/lib/ruin_app/example/06_justify_content.rs @@ -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", + ); + } + } +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index 4177c24..d906764 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -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, }; } diff --git a/lib/ruin_app/src/primitives/container.rs b/lib/ruin_app/src/primitives/container.rs index fb6a2bb..5e635e5 100644 --- a/lib/ruin_app/src/primitives/container.rs +++ b/lib/ruin_app/src/primitives/container.rs @@ -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 diff --git a/lib/ruin_app/src/primitives/mod.rs b/lib/ruin_app/src/primitives/mod.rs index 406570a..9bb9f13 100644 --- a/lib/ruin_app/src/primitives/mod.rs +++ b/lib/ruin_app/src/primitives/mod.rs @@ -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(mut self, widget_ref: $crate::hooks::WidgetRef) -> Self { self.props = self.props.widget_ref(widget_ref); self diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index d497251..2c896ae 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -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 { + let children: Vec = (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 = 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() { diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 46ed72d..d697769 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -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, diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index 335ea95..09a1493 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -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, + pub justify_content: JustifyContent, pub width: Option, pub height: Option, pub min_width: Option, @@ -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(&self, state: &mut H) { + (*self as u8).hash(state); + } +} + impl Hash for Style { fn hash(&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);