From ba57c6cdd113c6ac7943398084982fea8cfc1568 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Mon, 6 Apr 2026 20:03:18 +0100 Subject: [PATCH 01/19] Add Direction (RTL) support to layout engine --- ecs/src/implementations.rs | 4 ++++ ecs/src/store.rs | 5 +++- ecs/src/world.rs | 7 +++++- src/layout.rs | 23 ++++++++----------- src/node.rs | 17 ++++++++++++-- src/types.rs | 19 +++++++++++++++ tests/direction.rs | 47 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 tests/direction.rs diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index 8aac9cac..649303a5 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -35,6 +35,10 @@ impl Node for Entity { store.position_type.get(*self).copied() } + fn direction(&self, store: &Store) -> Option { + store.direction.get(*self).copied() + } + fn alignment(&self, store: &Store) -> Option { store.alignment.get(*self).copied() } diff --git a/ecs/src/store.rs b/ecs/src/store.rs index 28d5f5cd..23fe7535 100644 --- a/ecs/src/store.rs +++ b/ecs/src/store.rs @@ -1,7 +1,7 @@ // Part of a very simple ECS for demonstration purposes only. use crate::{entity::Entity, TextWrap}; -use morphorm::{LayoutType, PositionType, Units, Alignment}; +use morphorm::{Alignment, Direction, LayoutType, PositionType, Units}; use slotmap::SecondaryMap; type ContentSizeType = Box, Option) -> (f32, f32)>; @@ -13,6 +13,7 @@ pub struct Store { pub layout_type: SecondaryMap, pub position_type: SecondaryMap, + pub direction: SecondaryMap, pub alignment: SecondaryMap, pub grid_columns: SecondaryMap>, @@ -72,6 +73,7 @@ impl Store { self.visible.remove(entity); self.layout_type.remove(entity); self.position_type.remove(entity); + self.direction.remove(entity); self.left.remove(entity); self.right.remove(entity); self.top.remove(entity); @@ -110,6 +112,7 @@ impl Store { self.visible.clear(); self.layout_type.clear(); self.position_type.clear(); + self.direction.clear(); self.left.clear(); self.right.clear(); self.top.clear(); diff --git a/ecs/src/world.rs b/ecs/src/world.rs index e2c68c65..0fafa363 100644 --- a/ecs/src/world.rs +++ b/ecs/src/world.rs @@ -1,6 +1,6 @@ // Part of a very simple ECS for demonstration purposes only. -use morphorm::{LayoutType, PositionType, Units, Alignment}; +use morphorm::{Alignment, Direction, LayoutType, PositionType, Units}; use crate::entity::{Entity, EntityManager}; use crate::implementations::NodeCache; @@ -68,6 +68,11 @@ impl World { self.store.position_type.insert(entity, value); } + /// Set the inline direction of row content for the given entity. + pub fn set_direction(&mut self, entity: Entity, value: Direction) { + self.store.direction.insert(entity, value); + } + pub fn set_alignment(&mut self, entity: Entity, value: Alignment) { self.store.alignment.insert(entity, value); } diff --git a/src/layout.rs b/src/layout.rs index c91b0288..3e51254c 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,6 +1,6 @@ use smallvec::SmallVec; -use crate::{Alignment, Cache, CacheExt, LayoutType, Node, NodeExt, PositionType, Size, Units::*}; +use crate::{Alignment, Cache, CacheExt, Direction, LayoutType, Node, NodeExt, PositionType, Size, Units::*}; const DEFAULT_MIN: f32 = -f32::MAX; const DEFAULT_MAX: f32 = f32::MAX; @@ -451,25 +451,22 @@ where parent_main = parent_main - padding_main_before - padding_main_after - border_main_before - border_main_after; parent_cross = parent_cross - padding_cross_before - padding_cross_after - border_cross_before - border_cross_after; - // Determine index of first and last relative child nodes. - let mut iter = node + let is_row_rtl = layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft; + + let mut relative_children = node .children(tree) .filter(|child| child.visible(store)) .filter(|child| child.position_type(store).unwrap_or_default() == PositionType::Relative) - .enumerate(); + .collect::>(); - let first = iter.next().map(|(index, _)| index); - let last = iter.last().map_or(first, |(index, _)| Some(index)); + if is_row_rtl { + relative_children.reverse(); + } - let mut node_children = node - .children(tree) - .filter(|child| child.visible(store)) - .filter(|child| child.position_type(store).unwrap_or_default() == PositionType::Relative) - .enumerate() - .peekable(); + let last = relative_children.len().checked_sub(1); // Compute space and size of non-flexible relative children. - while let Some((index, child)) = node_children.next() { + for (index, child) in relative_children.into_iter().enumerate() { let child_main = child.main(store, layout_type); let child_cross = child.cross(store, layout_type); diff --git a/src/node.rs b/src/node.rs index 1a6b8349..0a8047ab 100644 --- a/src/node.rs +++ b/src/node.rs @@ -69,6 +69,11 @@ pub trait Node: Sized { /// Returns the position type of the node. fn position_type(&self, store: &Self::Store) -> Option; + /// Returns the inline direction of row content. + fn direction(&self, _store: &Self::Store) -> Option { + None + } + /// Returns the alignment of the node. fn alignment(&self, store: &Self::Store) -> Option; @@ -252,11 +257,19 @@ pub(crate) trait NodeExt: Node { } fn padding_main_before(&self, store: &Self::Store, parent_layout_type: LayoutType) -> Units { - parent_layout_type.select_unwrap(store, |store| self.padding_left(store), |store| self.padding_top(store)) + if parent_layout_type == LayoutType::Row && self.direction(store).unwrap_or_default() == Direction::RightToLeft { + self.padding_right(store).unwrap_or_default() + } else { + parent_layout_type.select_unwrap(store, |store| self.padding_left(store), |store| self.padding_top(store)) + } } fn padding_main_after(&self, store: &Self::Store, parent_layout_type: LayoutType) -> Units { - parent_layout_type.select_unwrap(store, |store| self.padding_right(store), |store| self.padding_bottom(store)) + if parent_layout_type == LayoutType::Row && self.direction(store).unwrap_or_default() == Direction::RightToLeft { + self.padding_left(store).unwrap_or_default() + } else { + parent_layout_type.select_unwrap(store, |store| self.padding_right(store), |store| self.padding_bottom(store)) + } } fn padding_cross_before(&self, store: &Self::Store, parent_layout_type: LayoutType) -> Units { diff --git a/src/types.rs b/src/types.rs index 74cac2e6..9d706e5a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -81,6 +81,25 @@ impl std::fmt::Display for PositionType { } } +/// The inline direction of row content. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// Lay out row children from left to right. + #[default] + LeftToRight, + /// Lay out row children from right to left. + RightToLeft, +} + +impl std::fmt::Display for Direction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Direction::LeftToRight => write!(f, "ltr"), + Direction::RightToLeft => write!(f, "rtl"), + } + } +} + /// Units which describe spacing and size. #[derive(Default, Debug, Clone, Copy, PartialEq)] pub enum Units { diff --git a/tests/direction.rs b/tests/direction.rs new file mode 100644 index 00000000..2e722929 --- /dev/null +++ b/tests/direction.rs @@ -0,0 +1,47 @@ +use morphorm::*; +use morphorm_ecs::*; + +#[test] +fn rtl_reverses_row_order() { + let mut world = World::default(); + + let root = world.add(None); + world.set_layout_type(root, LayoutType::Row); + world.set_direction(root, Direction::RightToLeft); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(100.0)); + + let first = world.add(Some(root)); + world.set_width(first, Units::Pixels(50.0)); + world.set_height(first, Units::Pixels(50.0)); + + let second = world.add(Some(root)); + world.set_width(second, Units::Pixels(50.0)); + world.set_height(second, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + assert_eq!(world.cache.bounds(first), Some(&Rect { posx: 50.0, posy: 0.0, width: 50.0, height: 50.0 })); + assert_eq!(world.cache.bounds(second), Some(&Rect { posx: 0.0, posy: 0.0, width: 50.0, height: 50.0 })); +} + +#[test] +fn rtl_swaps_row_padding_left_and_right() { + let mut world = World::default(); + + let root = world.add(None); + world.set_layout_type(root, LayoutType::Row); + world.set_direction(root, Direction::RightToLeft); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(100.0)); + world.set_padding_left(root, Units::Pixels(20.0)); + world.set_padding_right(root, Units::Pixels(40.0)); + + let child = world.add(Some(root)); + world.set_width(child, Units::Pixels(50.0)); + world.set_height(child, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + assert_eq!(world.cache.bounds(child), Some(&Rect { posx: 40.0, posy: 0.0, width: 50.0, height: 50.0 })); +} From c21de08bfeda0a7591852a414e0df454bb3e7e67 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Mon, 6 Apr 2026 23:32:16 +0100 Subject: [PATCH 02/19] Flip horizontal alignment for RTL direction --- src/layout.rs | 24 ++++++++++++++++++++++-- tests/direction.rs | 26 +++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 3e51254c..42d0a179 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -51,6 +51,18 @@ struct ChildNode<'a, N: Node> { main_after: f32, } +fn flip_alignment_horizontal(alignment: Alignment) -> Alignment { + match alignment { + Alignment::TopLeft => Alignment::TopRight, + Alignment::TopRight => Alignment::TopLeft, + Alignment::Left => Alignment::Right, + Alignment::Right => Alignment::Left, + Alignment::BottomLeft => Alignment::BottomRight, + Alignment::BottomRight => Alignment::BottomLeft, + alignment => alignment, + } +} + #[allow(clippy::too_many_arguments)] pub(crate) fn layout_grid( node: &N, @@ -242,7 +254,11 @@ where // println!("{:?} {:?}", computed_grid_cols, computed_grid_rows); - let alignment = node.alignment(store).unwrap_or_default(); + let mut alignment = node.alignment(store).unwrap_or_default(); + + if parent_layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft { + alignment = flip_alignment_horizontal(alignment); + } let (mut child_posx, mut child_posy) = match alignment { Alignment::TopLeft => (0.0, 0.0), @@ -851,7 +867,11 @@ where }); } - let alignment = node.alignment(store).unwrap_or_default(); + let mut alignment = node.alignment(store).unwrap_or_default(); + + if layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft { + alignment = flip_alignment_horizontal(alignment); + } // Set size and position of children in the cache. let mut main_pos = padding_main_before + border_main_before; diff --git a/tests/direction.rs b/tests/direction.rs index 2e722929..22774c1f 100644 --- a/tests/direction.rs +++ b/tests/direction.rs @@ -21,8 +21,8 @@ fn rtl_reverses_row_order() { root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); - assert_eq!(world.cache.bounds(first), Some(&Rect { posx: 50.0, posy: 0.0, width: 50.0, height: 50.0 })); - assert_eq!(world.cache.bounds(second), Some(&Rect { posx: 0.0, posy: 0.0, width: 50.0, height: 50.0 })); + assert_eq!(world.cache.bounds(first), Some(&Rect { posx: 250.0, posy: 0.0, width: 50.0, height: 50.0 })); + assert_eq!(world.cache.bounds(second), Some(&Rect { posx: 200.0, posy: 0.0, width: 50.0, height: 50.0 })); } #[test] @@ -43,5 +43,25 @@ fn rtl_swaps_row_padding_left_and_right() { root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); - assert_eq!(world.cache.bounds(child), Some(&Rect { posx: 40.0, posy: 0.0, width: 50.0, height: 50.0 })); + assert_eq!(world.cache.bounds(child), Some(&Rect { posx: 230.0, posy: 0.0, width: 50.0, height: 50.0 })); +} + +#[test] +fn rtl_reverses_row_alignment_horizontally() { + let mut world = World::default(); + + let root = world.add(None); + world.set_layout_type(root, LayoutType::Row); + world.set_direction(root, Direction::RightToLeft); + world.set_alignment(root, Alignment::TopRight); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(100.0)); + + let child = world.add(Some(root)); + world.set_width(child, Units::Pixels(50.0)); + world.set_height(child, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + assert_eq!(world.cache.bounds(child), Some(&Rect { posx: 0.0, posy: 0.0, width: 50.0, height: 50.0 })); } From a8df5483f297cb828950a272ab26ac1809aa15b0 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Wed, 8 Apr 2026 15:34:36 +0100 Subject: [PATCH 03/19] Add wrapping support --- ecs/src/implementations.rs | 4 + ecs/src/store.rs | 4 +- ecs/src/world.rs | 7 +- examples/wrap.rs | 70 ++++++ src/layout.rs | 427 +++++++++++++++++++++++++++++++++++- src/node.rs | 20 +- src/types.rs | 23 ++ tests/wrap.rs | 432 +++++++++++++++++++++++++++++++++++++ 8 files changed, 980 insertions(+), 7 deletions(-) create mode 100644 examples/wrap.rs create mode 100644 tests/wrap.rs diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index 649303a5..6e9a4d17 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -39,6 +39,10 @@ impl Node for Entity { store.direction.get(*self).copied() } + fn wrap(&self, store: &Store) -> Option { + store.wrap.get(*self).copied() + } + fn alignment(&self, store: &Store) -> Option { store.alignment.get(*self).copied() } diff --git a/ecs/src/store.rs b/ecs/src/store.rs index 23fe7535..4e1da093 100644 --- a/ecs/src/store.rs +++ b/ecs/src/store.rs @@ -1,7 +1,7 @@ // Part of a very simple ECS for demonstration purposes only. use crate::{entity::Entity, TextWrap}; -use morphorm::{Alignment, Direction, LayoutType, PositionType, Units}; +use morphorm::{Alignment, Direction, LayoutType, LayoutWrap, PositionType, Units}; use slotmap::SecondaryMap; type ContentSizeType = Box, Option) -> (f32, f32)>; @@ -15,6 +15,7 @@ pub struct Store { pub position_type: SecondaryMap, pub direction: SecondaryMap, pub alignment: SecondaryMap, + pub wrap: SecondaryMap, pub grid_columns: SecondaryMap>, pub grid_rows: SecondaryMap>, @@ -74,6 +75,7 @@ impl Store { self.layout_type.remove(entity); self.position_type.remove(entity); self.direction.remove(entity); + self.wrap.remove(entity); self.left.remove(entity); self.right.remove(entity); self.top.remove(entity); diff --git a/ecs/src/world.rs b/ecs/src/world.rs index 0fafa363..30cb8146 100644 --- a/ecs/src/world.rs +++ b/ecs/src/world.rs @@ -1,6 +1,6 @@ // Part of a very simple ECS for demonstration purposes only. -use morphorm::{Alignment, Direction, LayoutType, PositionType, Units}; +use morphorm::{Alignment, Direction, LayoutType, LayoutWrap, PositionType, Units}; use crate::entity::{Entity, EntityManager}; use crate::implementations::NodeCache; @@ -73,6 +73,11 @@ impl World { self.store.direction.insert(entity, value); } + /// Set the wrap mode for children of the given entity. + pub fn set_wrap(&mut self, entity: Entity, value: LayoutWrap) { + self.store.wrap.insert(entity, value); + } + pub fn set_alignment(&mut self, entity: Entity, value: Alignment) { self.store.alignment.insert(entity, value); } diff --git a/examples/wrap.rs b/examples/wrap.rs new file mode 100644 index 00000000..ab9cfae3 --- /dev/null +++ b/examples/wrap.rs @@ -0,0 +1,70 @@ +mod common; +use common::*; + +fn main() { + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(600.0)); + world.set_height(root, Units::Pixels(600.0)); + world.set_padding(root, Units::Pixels(20.0)); + world.set_layout_type(root, LayoutType::Column); + world.set_vertical_gap(root, Units::Pixels(20.0)); + world.set_alignment(root, Alignment::TopLeft); + + // Row wrapping example + let row_wrap_container = world.add(Some(root)); + world.set_width(row_wrap_container, Units::Stretch(1.0)); + world.set_height(row_wrap_container, Units::Auto); + world.set_layout_type(row_wrap_container, LayoutType::Row); + world.set_wrap(row_wrap_container, LayoutWrap::Wrap); + world.set_horizontal_gap(row_wrap_container, Units::Pixels(10.0)); + world.set_vertical_gap(row_wrap_container, Units::Pixels(10.0)); + world.set_alignment(row_wrap_container, Alignment::TopLeft); + + // Add items to row wrap container + for _i in 0..6 { + let item = world.add(Some(row_wrap_container)); + world.set_width(item, Units::Pixels(80.0)); + world.set_height(item, Units::Pixels(60.0)); + } + + // Column wrapping example + let col_wrap_container = world.add(Some(root)); + world.set_width(col_wrap_container, Units::Pixels(200.0)); + world.set_height(col_wrap_container, Units::Pixels(250.0)); + world.set_layout_type(col_wrap_container, LayoutType::Column); + world.set_wrap(col_wrap_container, LayoutWrap::Wrap); + world.set_horizontal_gap(col_wrap_container, Units::Pixels(10.0)); + world.set_vertical_gap(col_wrap_container, Units::Pixels(10.0)); + world.set_alignment(col_wrap_container, Alignment::TopLeft); + + // Add items to column wrap container + for _i in 0..6 { + let item = world.add(Some(col_wrap_container)); + world.set_width(item, Units::Pixels(60.0)); + world.set_height(item, Units::Pixels(50.0)); + } + + // RTL wrapping example + let rtl_wrap_container = world.add(Some(root)); + world.set_width(rtl_wrap_container, Units::Pixels(300.0)); + world.set_height(rtl_wrap_container, Units::Auto); + world.set_layout_type(rtl_wrap_container, LayoutType::Row); + world.set_direction(rtl_wrap_container, Direction::RightToLeft); + world.set_wrap(rtl_wrap_container, LayoutWrap::Wrap); + world.set_horizontal_gap(rtl_wrap_container, Units::Pixels(10.0)); + world.set_vertical_gap(rtl_wrap_container, Units::Pixels(10.0)); + world.set_alignment(rtl_wrap_container, Alignment::TopLeft); + + // Add items to RTL wrap container + for _i in 0..4 { + let item = world.add(Some(rtl_wrap_container)); + world.set_width(item, Units::Pixels(100.0)); + world.set_height(item, Units::Pixels(50.0)); + } + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + render(world, root); +} diff --git a/src/layout.rs b/src/layout.rs index 42d0a179..22983cf8 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,6 +1,6 @@ use smallvec::SmallVec; -use crate::{Alignment, Cache, CacheExt, Direction, LayoutType, Node, NodeExt, PositionType, Size, Units::*}; +use crate::{Alignment, Cache, CacheExt, Direction, LayoutType, LayoutWrap, Node, NodeExt, PositionType, Size, Units::*}; const DEFAULT_MIN: f32 = -f32::MAX; const DEFAULT_MAX: f32 = f32::MAX; @@ -313,6 +313,427 @@ where Size { main: computed_main, cross: computed_cross } } +/// Performs wrapped layout on the given node, arranging children into multiple lines +/// along the main axis when they overflow the available space. +/// +/// Called from [`layout`] when a node has [`LayoutWrap::Wrap`] set. +#[allow(clippy::too_many_arguments)] +pub(crate) fn layout_wrap( + node: &N, + parent_layout_type: LayoutType, + parent_main: f32, + parent_cross: f32, + cache: &mut C, + tree: &::Tree, + store: &::Store, + sublayout: &mut ::SubLayout<'_>, +) -> Size +where + N: Node, + C: Cache, +{ + let layout_type = node.layout_type(store).unwrap_or_default(); + + // Convert parent-provided main/cross (which are in parent layout axes) + // into this node's layout axes. + let (parent_main, parent_cross) = if parent_layout_type == layout_type { + (parent_main, parent_cross) + } else { + (parent_cross, parent_main) + }; + + let border_main_before = + node.border_main_before(store, layout_type).to_px(parent_main, DEFAULT_BORDER_WIDTH); + let border_main_after = + node.border_main_after(store, layout_type).to_px(parent_main, DEFAULT_BORDER_WIDTH); + let border_cross_before = + node.border_cross_before(store, layout_type).to_px(parent_cross, DEFAULT_BORDER_WIDTH); + let border_cross_after = + node.border_cross_after(store, layout_type).to_px(parent_cross, DEFAULT_BORDER_WIDTH); + + let padding_main_before = node.padding_main_before(store, layout_type).to_px(parent_main, 0.0); + let padding_main_after = node.padding_main_after(store, layout_type).to_px(parent_main, 0.0); + let padding_cross_before = node.padding_cross_before(store, layout_type).to_px(parent_cross, 0.0); + let padding_cross_after = node.padding_cross_after(store, layout_type).to_px(parent_cross, 0.0); + + // Available space for children after subtracting padding and border. + let avail_main = + parent_main - padding_main_before - padding_main_after - border_main_before - border_main_after; + let avail_cross = + parent_cross - padding_cross_before - padding_cross_after - border_cross_before - border_cross_after; + + // Gap between items within a line (on the main axis). + let min_main_between = node.min_main_between(store, layout_type); + let max_main_between = node.max_main_between(store, layout_type); + let item_gap_px = node + .main_between(store, layout_type) + .to_px_clamped(avail_main, 0.0, min_main_between, max_main_between); + + // Gap between lines (on the cross axis). + let line_gap_px = node.cross_between(store, layout_type).to_px(avail_cross, 0.0); + + let is_row_rtl = + layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft; + + let relative_children = node + .children(tree) + .filter(|c| c.visible(store)) + .filter(|c| c.position_type(store).unwrap_or_default() == PositionType::Relative) + .collect::>(); + + let num_rel = relative_children.len(); + + // Per-item data used during layout. + struct WrapItem { + main: f32, + cross: f32, + /// Non-zero when this item has Stretch units on the main axis. + stretch_main_factor: f32, + min_main: f32, + max_main: f32, + min_cross: f32, + max_cross: f32, + } + + // Phase 1: Compute sizes for all relative children. + // Stretch-main items are deferred; their sizes are resolved per-line in phase 3. + let mut items: SmallVec<[WrapItem; 32]> = SmallVec::with_capacity(num_rel); + for child in relative_children.iter() { + let child_main_units = child.main(store, layout_type); + let child_min_main = child.min_main(store, layout_type); + let child_max_main = child.max_main(store, layout_type); + let child_min_cross = child.min_cross(store, layout_type); + let child_max_cross = child.max_cross(store, layout_type); + + let min_main_px = child_min_main.to_px(avail_main, DEFAULT_MIN); + let max_main_px = child_max_main.to_px(avail_main, DEFAULT_MAX); + let min_cross_px = child_min_cross.to_px(avail_cross, DEFAULT_MIN); + let max_cross_px = child_max_cross.to_px(avail_cross, DEFAULT_MAX); + + if let Stretch(factor) = child_main_units { + // Use min size as base for line-break decisions; actual size resolved later. + let base = min_main_px.max(0.0); + items.push(WrapItem { + main: base, + cross: 0.0, + stretch_main_factor: factor, + min_main: min_main_px, + max_main: max_main_px, + min_cross: min_cross_px, + max_cross: max_cross_px, + }); + } else { + let size = layout(*child, layout_type, avail_main, avail_cross, cache, tree, store, sublayout); + items.push(WrapItem { + main: size.main, + cross: size.cross, + stretch_main_factor: 0.0, + min_main: min_main_px, + max_main: max_main_px, + min_cross: min_cross_px, + max_cross: max_cross_px, + }); + } + } + + // Phase 2: Assign children to lines. + // A new line begins when adding the next fixed-size child would exceed avail_main. + // Stretch-main children are treated as zero-width for break decisions. + // If avail_main <= 0 (auto-width container), no breaks occur. + let mut lines: SmallVec<[std::ops::Range; 8]> = SmallVec::new(); + if num_rel > 0 { + let mut line_start = 0usize; + let mut line_main_used = 0.0f32; + let mut items_in_line = 0usize; + + for i in 0..num_rel { + // Stretch items contribute their min size to the line-break decision (0 if no min set). + let size_contribution = items[i].main; + let gap_before = if items_in_line > 0 { item_gap_px } else { 0.0 }; + let projected = line_main_used + gap_before + size_contribution; + + if avail_main > 0.0 + && items_in_line > 0 + && projected > avail_main + && items[i].stretch_main_factor == 0.0 + { + // Finish current line and start a new one. + lines.push(line_start..i); + line_start = i; + line_main_used = size_contribution; + items_in_line = 1; + } else { + line_main_used = projected; + items_in_line += 1; + } + } + lines.push(line_start..num_rel); + } + + // Phase 3: Per-line flex resolution for stretch-main items. + for line in lines.iter() { + let count = line.len(); + if count == 0 { + continue; + } + + let stretch_sum: f32 = + line.clone().filter(|&i| items[i].stretch_main_factor > 0.0).map(|i| items[i].stretch_main_factor).sum(); + + if stretch_sum > 0.0 { + let fixed_sum: f32 = + line.clone().filter(|&i| items[i].stretch_main_factor == 0.0).map(|i| items[i].main).sum(); + let gap_total = (count - 1) as f32 * item_gap_px; + let free_main = (avail_main - fixed_sum - gap_total).max(0.0); + + for i in line.clone() { + let factor = items[i].stretch_main_factor; + if factor > 0.0 { + let allocated = (factor / stretch_sum * free_main).round(); + let clamped = allocated.clamp(items[i].min_main, items[i].max_main); + let size = + layout(relative_children[i], layout_type, clamped, avail_cross, cache, tree, store, sublayout); + items[i].main = size.main; + items[i].cross = size.cross; + } + } + } + } + + // Phase 4: Compute the cross extent of each line from non-cross-stretch children. + let mut line_cross: SmallVec<[f32; 8]> = SmallVec::with_capacity(lines.len()); + for line in lines.iter() { + let max_cross = line + .clone() + .filter(|&i| !relative_children[i].cross(store, layout_type).is_stretch()) + .map(|i| items[i].cross) + .fold(0.0f32, f32::max); + line_cross.push(max_cross); + } + + // Phase 5: Resolve cross-stretch children to fill their line's cross extent. + for (line_idx, line) in lines.iter().enumerate() { + let lc = line_cross[line_idx]; + for i in line.clone() { + let child = relative_children[i]; + if child.cross(store, layout_type).is_stretch() { + let clamped_cross = lc.clamp(items[i].min_cross, items[i].max_cross); + let size = + layout(child, layout_type, items[i].main, clamped_cross, cache, tree, store, sublayout); + items[i].main = size.main; + items[i].cross = size.cross; + } + } + // Re-compute line cross to include cross-stretch items in case they changed. + line_cross[line_idx] = line.clone().map(|i| items[i].cross).fold(0.0f32, f32::max); + } + + // Phase 6: Determine the final cross size of the container. + let num_lines = lines.len(); + let total_content_cross = if num_lines > 0 { + line_cross.iter().sum::() + (num_lines.saturating_sub(1)) as f32 * line_gap_px + } else { + 0.0 + }; + + let cross_units = node.cross(store, layout_type); + let final_cross = if cross_units.is_auto() || parent_cross == 0.0 { + let raw = + total_content_cross + padding_cross_before + padding_cross_after + border_cross_before + border_cross_after; + let min_c = node.min_cross(store, layout_type).to_px(0.0, DEFAULT_MIN); + let max_c = node.max_cross(store, layout_type).to_px(0.0, DEFAULT_MAX); + raw.max(min_c).min(max_c) + } else { + parent_cross + }; + + // Recompute auto main size (for containers with Auto main axis). + let main_units = node.main(store, layout_type); + let final_main = if main_units.is_auto() || parent_main == 0.0 { + let raw = if !lines.is_empty() { + lines[0].clone().map(|i| items[i].main).sum::() + + (lines[0].len().saturating_sub(1)) as f32 * item_gap_px + } else { + 0.0 + }; + let raw = raw + padding_main_before + padding_main_after + border_main_before + border_main_after; + let min_m = node.min_main(store, layout_type).to_px(0.0, DEFAULT_MIN); + let max_m = node.max_main(store, layout_type).to_px(0.0, DEFAULT_MAX); + raw.max(min_m).min(max_m) + } else { + parent_main + }; + + // Phase 7: Lay out absolute children against the container bounds. + let abs_avail_main = final_main - padding_main_before - padding_main_after - border_main_before - border_main_after; + let abs_avail_cross = + final_cross - padding_cross_before - padding_cross_after - border_cross_before - border_cross_after; + + let abs_children = node + .children(tree) + .filter(|c| c.position_type(store).unwrap_or_default() == PositionType::Absolute) + .filter(|c| c.visible(store)); + + let mut abs_items: SmallVec<[ChildNode; 8]> = SmallVec::new(); + for child in abs_children { + let main = if child.main(store, layout_type).is_stretch() { + let child_min_main = child.min_main(store, layout_type).to_px(abs_avail_main, DEFAULT_MIN); + let child_max_main = child.max_main(store, layout_type).to_px(abs_avail_main, DEFAULT_MAX); + let main_before = child.main_before(store, layout_type).to_px(abs_avail_main, 0.0); + let main_after = child.main_after(store, layout_type).to_px(abs_avail_main, 0.0); + abs_avail_main.clamp(child_min_main, child_max_main) - main_before - main_after + } else { + abs_avail_main + }; + + let cross = if child.cross(store, layout_type).is_stretch() { + let child_min_cross = child.min_cross(store, layout_type).to_px(abs_avail_cross, DEFAULT_MIN); + let child_max_cross = child.max_cross(store, layout_type).to_px(abs_avail_cross, DEFAULT_MAX); + let cross_before = child.cross_before(store, layout_type).to_px(abs_avail_cross, 0.0); + let cross_after = child.cross_after(store, layout_type).to_px(abs_avail_cross, 0.0); + abs_avail_cross.clamp(child_min_cross, child_max_cross) - cross_before - cross_after + } else { + abs_avail_cross + }; + + let size = layout(child, layout_type, main, cross, cache, tree, store, sublayout); + abs_items.push(ChildNode { node: child, main: size.main, cross: size.cross, main_after: 0.0 }); + } + + // Phase 8: Position all children. + // Decompose alignment into (main-fraction, cross-fraction) where the + // main-fraction offsets the whole group and cross-fraction aligns each item + // within its line's cross extent. The swap mirrors what the non-wrap layout + // does so that `TopLeft` means the same visual position regardless of + // layout_type. + let mut alignment = node.alignment(store).unwrap_or_default(); + + // For RTL row layouts, flip alignment horizontally so TopLeft becomes TopRight + if is_row_rtl { + alignment = flip_alignment_horizontal(alignment); + } + + let (mut main_align_frac, mut cross_align_frac) = match alignment { + Alignment::TopLeft => (0.0f32, 0.0f32), + Alignment::TopCenter => (0.0, 0.5), + Alignment::TopRight => (0.0, 1.0), + Alignment::Left => (0.5, 0.0), + Alignment::Center => (0.5, 0.5), + Alignment::Right => (0.5, 1.0), + Alignment::BottomLeft => (1.0, 0.0), + Alignment::BottomCenter => (1.0, 0.5), + Alignment::BottomRight => (1.0, 1.0), + }; + if layout_type == LayoutType::Row { + std::mem::swap(&mut main_align_frac, &mut cross_align_frac); + } + + let mut cross_cursor = padding_cross_before + border_cross_before; + + for (line_idx, line) in lines.iter().enumerate() { + let lc = line_cross[line_idx]; + let count = line.len(); + let gap_total = (count.saturating_sub(1)) as f32 * item_gap_px; + let line_main_sum: f32 = line.clone().map(|i| items[i].main).sum(); + let free_main = (avail_main - line_main_sum - gap_total).max(0.0); + + if is_row_rtl { + // RTL positioning: place items in reverse order within each wrapped line. + // Alignment is flipped above so TopLeft maps to TopRight semantics. + let mut main_cursor = + padding_main_before + border_main_before + main_align_frac * free_main; + + for (item_idx, i) in line.clone().rev().enumerate() { + let item = &items[i]; + let child = relative_children[i]; + let item_cross_offset = cross_align_frac * (lc - item.cross); + + cache.set_rect(child, layout_type, main_cursor, cross_cursor + item_cross_offset, item.main, item.cross); + + main_cursor += item.main; + if item_idx + 1 < count { + main_cursor += item_gap_px; + } + } + } else { + // LTR positioning: items are positioned left-to-right within the line + let mut main_cursor = + padding_main_before + border_main_before + main_align_frac * free_main; + + for (item_pos, i) in line.clone().enumerate() { + let item = &items[i]; + let child = relative_children[i]; + + let item_cross_offset = cross_align_frac * (lc - item.cross); + + cache.set_rect(child, layout_type, main_cursor, cross_cursor + item_cross_offset, item.main, item.cross); + + main_cursor += item.main; + if item_pos + 1 < count { + main_cursor += item_gap_px; + } + } + } + + cross_cursor += lc; + if line_idx + 1 < num_lines { + cross_cursor += line_gap_px; + } + } + + // Position absolute children. + for abs_child in &abs_items { + let child_main_before = abs_child.node.main_before(store, layout_type); + let child_main_after = abs_child.node.main_after(store, layout_type); + let child_cross_before = abs_child.node.cross_before(store, layout_type); + let child_cross_after = abs_child.node.cross_after(store, layout_type); + + let pma = abs_avail_main + padding_main_before + padding_main_after; + let pca = abs_avail_cross + padding_cross_before + padding_cross_after; + + let child_main_pos = match (child_main_before, child_main_after) { + (Pixels(val), _) => val, + (Percentage(val), _) => val * 0.01 * pma, + (_, Pixels(val)) => pma - val - abs_child.main, + (_, Percentage(val)) => pma - abs_child.main - val * 0.01 * pma, + (Stretch(b), Stretch(a)) => { + if b == a { (pma - abs_child.main) * 0.5 } else { (pma - abs_child.main) * (b / (b + a)) } + } + (Stretch(_), Auto) => pma - abs_child.main, + (Auto, Stretch(_)) => 0.0, + (Auto, Auto) => 0.0, + }; + + let child_cross_pos = match (child_cross_before, child_cross_after) { + (Pixels(val), _) => val, + (Percentage(val), _) => val * 0.01 * pca, + (_, Pixels(val)) => pca - val - abs_child.cross, + (_, Percentage(val)) => pca - abs_child.cross - val * 0.01 * pca, + (Stretch(b), Stretch(a)) => { + if b == a { (pca - abs_child.cross) * 0.5 } else { (pca - abs_child.cross) * (b / (b + a)) } + } + (Stretch(_), Auto) => pca - abs_child.cross, + (Auto, Stretch(_)) => 0.0, + (Auto, Auto) => 0.0, + }; + + cache.set_rect( + abs_child.node, + layout_type, + child_main_pos + border_main_before, + child_cross_pos + border_cross_before, + abs_child.main, + abs_child.cross, + ); + } + + if parent_layout_type == layout_type { + Size { main: final_main, cross: final_cross } + } else { + Size { main: final_cross, cross: final_main } + } +} + /// Performs layout on the given node returning its computed size. /// /// The algorithm recurses down the tree, in depth-first order, and performs @@ -437,6 +858,10 @@ where return layout_grid(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); } + if node.wrap(store).unwrap_or_default() == LayoutWrap::Wrap { + return layout_wrap(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); + } + // Determine the parent_main/cross size to pass to the children based on the layout type of the parent and the node. // i.e. if the parent layout type and the node layout type are different, swap the main and the cross axes. let (mut parent_main, mut parent_cross) = if parent_layout_type == layout_type { diff --git a/src/node.rs b/src/node.rs index 0a8047ab..69c4d3fe 100644 --- a/src/node.rs +++ b/src/node.rs @@ -1,4 +1,4 @@ -use crate::{layout, types::*, Cache}; +use crate::{layout, types::*, Cache, LayoutWrap}; /// A `Node` represents a layout element which can be sized and positioned based on /// a number of layout properties. @@ -51,7 +51,14 @@ pub trait Node: Sized { cache.set_bounds(self, cache.posx(self), cache.posy(self), width, height); - layout(self, LayoutType::Column, height, width, cache, tree, store, sublayout) + // Use the node's layout type instead of hardcoding Column + let layout_type = self.layout_type(store).unwrap_or_default(); + let (parent_main, parent_cross) = match layout_type { + LayoutType::Row | LayoutType::Grid => (width, height), // Row: main=width, cross=height + LayoutType::Column => (height, width), // Column: main=height, cross=width + }; + + layout(self, layout_type, parent_main, parent_cross, cache, tree, store, sublayout) } /// Returns a key which can be used to set/get computed layout data from the [`cache`](crate::Cache). @@ -74,6 +81,13 @@ pub trait Node: Sized { None } + /// Returns whether children wrap to a new line when they overflow the main axis. + /// + /// Defaults to `None` which is treated as [`LayoutWrap::NoWrap`]. + fn wrap(&self, _store: &Self::Store) -> Option { + None + } + /// Returns the alignment of the node. fn alignment(&self, store: &Self::Store) -> Option; @@ -300,8 +314,6 @@ pub(crate) trait NodeExt: Node { ) } - // Currently unused until wrapping is implemented - #[allow(dead_code)] fn cross_between(&self, store: &Self::Store, parent_layout_type: LayoutType) -> Units { parent_layout_type.select_unwrap(store, |store| self.vertical_gap(store), |store| self.horizontal_gap(store)) } diff --git a/src/types.rs b/src/types.rs index 9d706e5a..d6553bf4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -225,6 +225,29 @@ impl std::fmt::Display for Alignment { } } } +/// Determines whether children wrap to a new line when they overflow the main axis. +/// +/// Wrapping is only applicable for [`LayoutType::Row`] and [`LayoutType::Column`] containers. +/// The cross-axis gap ([`vertical_gap`](crate::Node::vertical_gap) for Row, [`horizontal_gap`](crate::Node::horizontal_gap) for Column) +/// controls the spacing between lines. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayoutWrap { + /// Children do not wrap; they overflow the main axis (default). + #[default] + NoWrap, + /// Children wrap to a new line when they would overflow the main axis. + Wrap, +} + +impl std::fmt::Display for LayoutWrap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LayoutWrap::NoWrap => write!(f, "no-wrap"), + LayoutWrap::Wrap => write!(f, "wrap"), + } + } +} + /// A type which represents the computed size of a node after [`layout`](crate::Node::layout). #[derive(Default, Debug, Copy, Clone, PartialEq)] pub struct Size { diff --git a/tests/wrap.rs b/tests/wrap.rs new file mode 100644 index 00000000..ea33ed7e --- /dev/null +++ b/tests/wrap.rs @@ -0,0 +1,432 @@ +use morphorm::*; +use morphorm_ecs::*; + +#[test] +fn wrap_row_basic() { + // Test basic row wrapping - when wrap is enabled, items should stay on one line + // if they fit. Wrapping only occurs when items exceed available space. + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(100.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(100.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(100.0)); + world.set_height(node3, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // All items fit on one line (300px available, 300px used) + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 100.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 200.0, posy: 0.0, width: 100.0, height: 50.0 })); +} + +#[test] +fn wrap_row_with_gap() { + // Test row wrapping with horizontal gap between items and vertical gap between lines + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + world.set_horizontal_gap(root, Units::Pixels(20.0)); + world.set_vertical_gap(root, Units::Pixels(10.0)); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(80.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(80.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(80.0)); + world.set_height(node3, Units::Pixels(50.0)); + + let node4 = world.add(Some(root)); + world.set_width(node4, Units::Pixels(80.0)); + world.set_height(node4, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // First line: nodes 1, 2, 3 (80 + 20 + 80 + 20 + 80 = 280px fits in 300px) + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 80.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 100.0, posy: 0.0, width: 80.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 200.0, posy: 0.0, width: 80.0, height: 50.0 })); + + // Second line: node 4 (80px, offset by line_gap + line cross size) + assert_eq!(world.cache.bounds(node4), Some(&Rect { posx: 0.0, posy: 60.0, width: 80.0, height: 50.0 })); +} + +#[test] +fn wrap_column_basic() { + // Test basic column wrapping with fixed-size items that exceed container height + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(200.0)); + world.set_height(root, Units::Pixels(250.0)); + world.set_layout_type(root, LayoutType::Column); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(80.0)); + world.set_height(node1, Units::Pixels(100.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(80.0)); + world.set_height(node2, Units::Pixels(100.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(80.0)); + world.set_height(node3, Units::Pixels(100.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // First column: nodes 1 and 2 (200px height fits in 250px) + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 80.0, height: 100.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 0.0, posy: 100.0, width: 80.0, height: 100.0 })); + + // Second column: node 3 + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 80.0, posy: 0.0, width: 80.0, height: 100.0 })); +} + +#[test] +fn wrap_row_with_stretch() { + // Test row wrapping with stretch items filling available space on each line + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Stretch(1.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Stretch(1.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(100.0)); + world.set_height(node3, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // With wrapping, stretch items treat min size (0) for line-break decision + // node1 and node2 both stretch to fill (no size contribution to line break) + // All items fit on one line + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 100.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 200.0, posy: 0.0, width: 100.0, height: 50.0 })); +} + +#[test] +fn wrap_row_no_wrap_mode() { + // Test that NoWrap mode (default) doesn't wrap items + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::NoWrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(100.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(100.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(100.0)); + world.set_height(node3, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // All items on one line (no wrapping, even though they sum to 300px) + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 100.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 200.0, posy: 0.0, width: 100.0, height: 50.0 })); +} + +#[test] +fn wrap_row_single_item_per_line() { + // Test wrapping where items are so large that only one fits per line + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(250.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(200.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(200.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(200.0)); + world.set_height(node3, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // Each item on its own line + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 200.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 0.0, posy: 50.0, width: 200.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 0.0, posy: 100.0, width: 200.0, height: 50.0 })); +} + +#[test] +fn wrap_with_alignment() { + // Test wrapping with center alignment + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::Center); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(100.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(100.0)); + world.set_height(node2, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // Items centered on first line: (300 - 200) / 2 = 50px offset on main axis + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 50.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 150.0, posy: 0.0, width: 100.0, height: 50.0 })); +} + +#[test] +fn wrap_auto_container() { + // Test wrap with fixed-size container and fixed-size children + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(250.0)); + world.set_height(root, Units::Pixels(250.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(100.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(100.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(100.0)); + world.set_height(node3, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // First line: nodes 1 and 2 (200px fits in 250px) + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 100.0, posy: 0.0, width: 100.0, height: 50.0 })); + + // Second line: node 3 wraps (adding it would be 300px > 250px) + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 0.0, posy: 50.0, width: 100.0, height: 50.0 })); +} + +// TODO: wrap_with_different_line_heights test - currently fails because the line-wrapping +// logic in layout_wrap doesn't properly wrap items when they exceed available space. +// This is a known issue with the Phase 2 line assignment algorithm that needs fixing. + +#[test] +fn wrap_with_different_line_heights() { + // Test wrapping where items have different heights + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(250.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(100.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(100.0)); + world.set_height(node2, Units::Pixels(80.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(100.0)); + world.set_height(node3, Units::Pixels(60.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // First line: nodes 1 and 2 (200px fits in 250px, line height is max = 80px) + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 0.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 100.0, posy: 0.0, width: 100.0, height: 80.0 })); + + // Second line: node 3 (line height is 60px) + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 0.0, posy: 80.0, width: 100.0, height: 60.0 })); +} + +#[test] +fn wrap_row_with_padding() { + // Test wrapping with padding on the container + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(320.0)); + world.set_height(root, Units::Pixels(320.0)); + world.set_padding_left(root, Units::Pixels(10.0)); + world.set_padding_right(root, Units::Pixels(10.0)); + world.set_padding_top(root, Units::Pixels(10.0)); + world.set_padding_bottom(root, Units::Pixels(10.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(100.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(100.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(100.0)); + world.set_height(node3, Units::Pixels(50.0)); + + let node4 = world.add(Some(root)); + world.set_width(node4, Units::Pixels(100.0)); + world.set_height(node4, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // Available width is 320 - 10 - 10 = 300px + // First line: nodes 1, 2, 3 (300px fills the available space) + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 10.0, posy: 10.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 110.0, posy: 10.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 210.0, posy: 10.0, width: 100.0, height: 50.0 })); + + // Second line: node 4 wraps (adding it would exceed 300px) + assert_eq!(world.cache.bounds(node4), Some(&Rect { posx: 10.0, posy: 60.0, width: 100.0, height: 50.0 })); +} + +#[test] +fn wrap_row_rtl() { + // Test row wrapping with right-to-left direction + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(250.0)); + world.set_height(root, Units::Pixels(300.0)); + world.set_layout_type(root, LayoutType::Row); + world.set_direction(root, Direction::RightToLeft); + world.set_wrap(root, LayoutWrap::Wrap); + world.set_alignment(root, Alignment::TopLeft); + + let node1 = world.add(Some(root)); + world.set_width(node1, Units::Pixels(100.0)); + world.set_height(node1, Units::Pixels(50.0)); + + let node2 = world.add(Some(root)); + world.set_width(node2, Units::Pixels(100.0)); + world.set_height(node2, Units::Pixels(50.0)); + + let node3 = world.add(Some(root)); + world.set_width(node3, Units::Pixels(100.0)); + world.set_height(node3, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // RTL: wrapped line composition stays the same, but placement order per line is reversed. + // With TopLeft alignment flipped to TopRight in RTL: + // - Line 1: node1 (100px) + node2 (100px) = 200px fits in 250px + // Free space on left (50px), reversed placement: node2 at 50-150, node1 at 150-250 + // - Line 2: node3 (100px) alone, with free space on left (150px) + // node3 on right: node3 at 150-250 + assert_eq!(world.cache.bounds(node1), Some(&Rect { posx: 150.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 50.0, posy: 0.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 150.0, posy: 50.0, width: 100.0, height: 50.0 })); +} + +#[test] +fn wrap_row_auto_height_includes_lines_gap_and_padding() { + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(400.0)); + world.set_height(root, Units::Pixels(400.0)); + world.set_layout_type(root, LayoutType::Column); + world.set_alignment(root, Alignment::TopLeft); + + let wrap = world.add(Some(root)); + world.set_width(wrap, Units::Pixels(250.0)); + world.set_height(wrap, Units::Auto); + world.set_layout_type(wrap, LayoutType::Row); + world.set_wrap(wrap, LayoutWrap::Wrap); + world.set_alignment(wrap, Alignment::TopLeft); + world.set_padding_top(wrap, Units::Pixels(5.0)); + world.set_padding_bottom(wrap, Units::Pixels(5.0)); + world.set_vertical_gap(wrap, Units::Pixels(10.0)); + + let a = world.add(Some(wrap)); + world.set_width(a, Units::Pixels(100.0)); + world.set_height(a, Units::Pixels(50.0)); + + let b = world.add(Some(wrap)); + world.set_width(b, Units::Pixels(100.0)); + world.set_height(b, Units::Pixels(50.0)); + + let c = world.add(Some(wrap)); + world.set_width(c, Units::Pixels(100.0)); + world.set_height(c, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + // Two lines: [a, b] then [c]. + // Height = 50 + 10 + 50 + 5 + 5 = 120. + assert_eq!(world.cache.bounds(wrap), Some(&Rect { posx: 0.0, posy: 0.0, width: 250.0, height: 120.0 })); + assert_eq!(world.cache.bounds(a), Some(&Rect { posx: 0.0, posy: 5.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(b), Some(&Rect { posx: 100.0, posy: 5.0, width: 100.0, height: 50.0 })); + assert_eq!(world.cache.bounds(c), Some(&Rect { posx: 0.0, posy: 65.0, width: 100.0, height: 50.0 })); +} + From 9b0ca5a1609221fcda8af74a190881f830bbd6e3 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 21:37:17 +0100 Subject: [PATCH 04/19] Re-run relative children with final constraints Compute child layout for Size items to update cross axis, and after resolving child min/max and flex clamping, re-run relative-positioned children using their final resolved main/cross constraints so descendant layouts use the same cached dimensions. Adds a test (auto_min_width_propagates_to_nested_children_after_flex_clamp) to verify min-width propagation to nested children after flex clamp. --- src/layout.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ tests/auto.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/layout.rs b/src/layout.rs index 22983cf8..1247a024 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1160,6 +1160,22 @@ where match item.item_type { ItemType::Size => { + let child_size = layout( + child.node, + layout_type, + item.computed, + if child.node.cross(store, layout_type).is_stretch() { + child.cross + } else { + parent_cross + }, + cache, + tree, + store, + sublayout, + ); + + child.cross = child_size.cross; child.main = item.computed; } @@ -1246,6 +1262,33 @@ where child.cross = parent_cross.clamp(child_min_cross, child_max_cross); } + // Re-run relative children with their final resolved constraints so descendant + // layout uses the same dimensions that are ultimately cached for each child. + for child in children + .iter_mut() + .filter(|child| child.node.position_type(store).unwrap_or_default() == PositionType::Relative) + { + let child_main_is_stretch = child.node.main(store, layout_type).is_stretch(); + let child_cross_is_stretch = child.node.cross(store, layout_type).is_stretch(); + + let child_size = layout( + child.node, + layout_type, + if child_main_is_stretch { child.main } else { parent_main }, + if child_cross_is_stretch { child.cross } else { parent_cross }, + cache, + tree, + store, + sublayout, + ); + + if !child_main_is_stretch { + child.main = child_size.main; + } + + child.cross = child_size.cross; + } + // Absolute Children let node_children = node diff --git a/tests/auto.rs b/tests/auto.rs index ec7082c0..2521e381 100644 --- a/tests/auto.rs +++ b/tests/auto.rs @@ -210,6 +210,40 @@ fn auto_min_width4() { assert_eq!(world.cache.bounds(child2), Some(&Rect { posx: 50.0, posy: 0.0, width: 50.0, height: 80.0 })); } +#[test] +fn auto_min_width_propagates_to_nested_children_after_flex_clamp() { + let mut world = World::default(); + + let root = world.add(None); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(100.0)); + world.set_alignment(root, Alignment::TopLeft); + world.set_layout_type(root, LayoutType::Row); + + let child1 = world.add(Some(root)); + world.set_width(child1, Units::Stretch(1.0)); + world.set_min_width(child1, Units::Pixels(200.0)); + world.set_height(child1, Units::Stretch(1.0)); + world.set_layout_type(child1, LayoutType::Row); + + let grandchild1 = world.add(Some(child1)); + world.set_width(grandchild1, Units::Stretch(1.0)); + world.set_height(grandchild1, Units::Stretch(1.0)); + world.set_content_size(grandchild1, |_, width, height| (width.unwrap(), height.unwrap())); + + let child2 = world.add(Some(root)); + world.set_width(child2, Units::Stretch(1.0)); + world.set_min_width(child2, Units::Auto); + world.set_height(child2, Units::Stretch(1.0)); + world.set_content_size(child2, |_, _, height| (80.0, height.unwrap())); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + assert_eq!(world.cache.bounds(child1), Some(&Rect { posx: 0.0, posy: 0.0, width: 200.0, height: 100.0 })); + assert_eq!(world.cache.bounds(grandchild1), Some(&Rect { posx: 0.0, posy: 0.0, width: 200.0, height: 100.0 })); + assert_eq!(world.cache.bounds(child2), Some(&Rect { posx: 200.0, posy: 0.0, width: 100.0, height: 100.0 })); +} + // #[test] // fn percentage_left_pixels_width() { // let mut world = World::default(); From bd28508a2fd4c7ce6e5c9165b17419a6ef1b2830 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 21:56:52 +0100 Subject: [PATCH 05/19] Update layout.rs --- src/layout.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/layout.rs b/src/layout.rs index 1247a024..625ed02f 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1264,9 +1264,11 @@ where // Re-run relative children with their final resolved constraints so descendant // layout uses the same dimensions that are ultimately cached for each child. + // Only rerun children that have descendants (skip leaf nodes). for child in children .iter_mut() .filter(|child| child.node.position_type(store).unwrap_or_default() == PositionType::Relative) + .filter(|child| child.node.children(tree).next().is_some()) { let child_main_is_stretch = child.node.main(store, layout_type).is_stretch(); let child_cross_is_stretch = child.node.cross(store, layout_type).is_stretch(); From 09609e56bdc31d59019d6c8fe83d4910165d995b Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 22:50:07 +0100 Subject: [PATCH 06/19] Optimize layout computations and child traversal Avoid repeated tree traversals and full-sum recomputations in layout. Compute and store prior values (for grid cols/rows and child main extents) and update width_sum/height_sum/main_sum incrementally instead of calling .iter().sum(). Classify visible children once into relative and absolute SmallVecs, use their lengths for child counts, reverse relative order for RTL when needed, and iterate absolute_children directly. These changes are intended as performance optimizations without changing layout semantics. --- src/layout.rs | 42 +++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 625ed02f..c9a9d303 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -190,9 +190,9 @@ where // If the item is frozen, adjust the used_space and sum of cross stretch factors. if item.frozen { col_flex_sum -= item.factor; + let prev = computed_grid_cols[item.index]; computed_grid_cols[item.index] = item.computed; - - width_sum = computed_grid_cols.iter().sum(); + width_sum += item.computed - prev; } } } @@ -230,9 +230,9 @@ where // If the item is frozen, adjust the used_space and sum of cross stretch factors. if item.frozen { row_flex_sum -= item.factor; + let prev = computed_grid_rows[item.index]; computed_grid_rows[item.index] = item.computed; - - height_sum = computed_grid_rows.iter().sum(); + height_sum += item.computed - prev; } } } @@ -818,15 +818,21 @@ where let border_cross_after = node.border_cross_after(store, parent_layout_type).to_px(computed_cross, DEFAULT_BORDER_WIDTH); + // Classify visible children once to avoid repeated tree traversals. + let mut relative_children = SmallVec::<[&N; 32]>::new(); + let mut absolute_children = SmallVec::<[&N; 8]>::new(); + for child in node.children(tree).filter(|child| child.visible(store)) { + match child.position_type(store).unwrap_or_default() { + PositionType::Relative => relative_children.push(child), + PositionType::Absolute => absolute_children.push(child), + } + } + // Get the total number of children of the node. - let num_children = node.children(tree).filter(|child| child.visible(store)).count(); + let num_children = relative_children.len() + absolute_children.len(); // Get the total number of relative children of the node. - let num_parent_directed_children = node - .children(tree) - .filter(|child| child.position_type(store).unwrap_or_default() == PositionType::Relative) - .filter(|child| child.visible(store)) - .count(); + let num_parent_directed_children = relative_children.len(); // Apply content sizing. if (node.min_main(store, parent_layout_type).is_auto() || node.min_cross(store, parent_layout_type).is_auto()) @@ -894,12 +900,6 @@ where let is_row_rtl = layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft; - let mut relative_children = node - .children(tree) - .filter(|child| child.visible(store)) - .filter(|child| child.position_type(store).unwrap_or_default() == PositionType::Relative) - .collect::>(); - if is_row_rtl { relative_children.reverse(); } @@ -1157,6 +1157,7 @@ where // If the item is frozen, adjust the used_space and sum of cross stretch factors. if item.frozen { main_flex_sum -= item.factor; + let previous_total = child.main + child.main_after; match item.item_type { ItemType::Size => { @@ -1184,7 +1185,7 @@ where } } - main_sum = children.iter().map(|child| child.main + child.main_after).sum(); + main_sum += (child.main + child.main_after) - previous_total; } } } @@ -1293,13 +1294,8 @@ where // Absolute Children - let node_children = node - .children(tree) - .filter(|child| child.position_type(store).unwrap_or_default() == PositionType::Absolute) - .filter(|child| child.visible(store)); - // Compute space and size of non-flexible absolute children. - for child in node_children { + for child in absolute_children.into_iter() { let main = if child.main(store, layout_type).is_stretch() { let child_min_main = child.min_main(store, layout_type).to_px(parent_cross, DEFAULT_MIN); let child_max_main = child.max_main(store, layout_type).to_px(parent_cross, DEFAULT_MAX); From a572e6dfd6f507a379dc9437bea2cccfa75cf253 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 22:52:06 +0100 Subject: [PATCH 07/19] Record measured size and avoid redundant layout Add a `measured` field to StretchItem and initialize it in `new`. Set `item.measured` when computing the clamped/actual main size during the flex iteration. In the child layout phase, skip relayout for Size items when `item.computed` equals `item.measured` (comparison uses `f32::EPSILON`), avoiding redundant child layout calls when the size hasn't actually changed. --- src/layout.rs | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index c9a9d303..209c0cce 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -25,6 +25,8 @@ struct StretchItem { violation: f32, // The computed size of the stretch item. computed: f32, + // The measured size from the current flex iteration. + measured: f32, // Whether or not the stretch item is frozen. frozen: bool, // The minimum size of the stretch item. @@ -35,7 +37,7 @@ struct StretchItem { impl StretchItem { pub fn new(index: usize, factor: f32, item_type: ItemType, min: f32, max: f32) -> Self { - Self { index, factor, item_type, violation: 0.0, computed: 0.0, frozen: false, min, max } + Self { index, factor, item_type, violation: 0.0, computed: 0.0, measured: 0.0, frozen: false, min, max } } } @@ -176,6 +178,7 @@ where let clamped = actual_main.min(item.max).max(item.min); item.violation = clamped - actual_main; total_violation += item.violation; + item.measured = actual_main; item.computed = clamped; } @@ -216,6 +219,7 @@ where let clamped = actual_main.min(item.max).max(item.min); item.violation = clamped - actual_main; total_violation += item.violation; + item.measured = actual_main; item.computed = clamped; } @@ -1161,22 +1165,25 @@ where match item.item_type { ItemType::Size => { - let child_size = layout( - child.node, - layout_type, - item.computed, - if child.node.cross(store, layout_type).is_stretch() { - child.cross - } else { - parent_cross - }, - cache, - tree, - store, - sublayout, - ); - - child.cross = child_size.cross; + if (item.computed - item.measured).abs() > f32::EPSILON { + let child_size = layout( + child.node, + layout_type, + item.computed, + if child.node.cross(store, layout_type).is_stretch() { + child.cross + } else { + parent_cross + }, + cache, + tree, + store, + sublayout, + ); + + child.cross = child_size.cross; + } + child.main = item.computed; } From 827446a99575f2934a90ac8af807893906bef0b4 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 22:54:54 +0100 Subject: [PATCH 08/19] Cache child layout constraints to avoid relayout Add per-child caching of the last parent constraints to skip redundant layout calls. Introduce fields on ChildNode (last_layout_main, last_layout_cross, has_layout_constraints) and a same_f32 helper for bitwise float comparison. Update layout logic in multiple phases to set and check these cached constraint values (initialization, stretch handling, size recomputation, final child layout) so repeated layout(...) calls are avoided when parent constraints haven't changed. This reduces unnecessary work and improves layout performance. --- src/layout.rs | 155 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 43 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 209c0cce..6ddb6888 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -51,6 +51,10 @@ struct ChildNode<'a, N: Node> { main: f32, main_after: f32, + // Last parent constraints used to lay out this child. + last_layout_main: f32, + last_layout_cross: f32, + has_layout_constraints: bool, } fn flip_alignment_horizontal(alignment: Alignment) -> Alignment { @@ -65,6 +69,11 @@ fn flip_alignment_horizontal(alignment: Alignment) -> Alignment { } } +#[inline] +fn same_f32(a: f32, b: f32) -> bool { + a.to_bits() == b.to_bits() +} + #[allow(clippy::too_many_arguments)] pub(crate) fn layout_grid( node: &N, @@ -601,7 +610,15 @@ where }; let size = layout(child, layout_type, main, cross, cache, tree, store, sublayout); - abs_items.push(ChildNode { node: child, main: size.main, cross: size.cross, main_after: 0.0 }); + abs_items.push(ChildNode { + node: child, + main: size.main, + cross: size.cross, + main_after: 0.0, + last_layout_main: 0.0, + last_layout_cross: 0.0, + has_layout_constraints: false, + }); } // Phase 8: Position all children. @@ -961,11 +978,18 @@ where let mut computed_child_cross = child_cross.to_px_clamped(parent_cross, 0.0, child_min_cross, child_max_cross); // Compute fixed-size child main and cross. + let mut has_layout_constraints = false; + let mut last_layout_main = 0.0; + let mut last_layout_cross = 0.0; + if !child_main.is_stretch() && (!child_cross.is_stretch() || child_min_cross.is_auto()) { let child_size = layout(child, layout_type, parent_main, parent_cross, cache, tree, store, sublayout); computed_child_main = child_size.main; computed_child_cross = child_size.cross; + has_layout_constraints = true; + last_layout_main = parent_main; + last_layout_cross = parent_cross; } children.push(ChildNode { @@ -973,6 +997,9 @@ where cross: computed_child_cross, main: computed_child_main, main_after: computed_child_main_after, + last_layout_main, + last_layout_cross, + has_layout_constraints, }); } @@ -1042,9 +1069,17 @@ where .filter(|child| child.node.cross(store, layout_type).is_stretch()) { if !child.node.main(store, layout_type).is_stretch() { - let child_size = layout(child.node, layout_type, parent_main, parent_cross, cache, tree, store, sublayout); - child.main = child_size.main; - child.cross = child_size.cross; + if !child.has_layout_constraints + || !same_f32(child.last_layout_main, parent_main) + || !same_f32(child.last_layout_cross, parent_cross) + { + let child_size = layout(child.node, layout_type, parent_main, parent_cross, cache, tree, store, sublayout); + child.main = child_size.main; + child.cross = child_size.cross; + child.last_layout_main = parent_main; + child.last_layout_cross = parent_cross; + child.has_layout_constraints = true; + } } else { let child_min_cross = if child.node.min_cross(store, layout_type).is_auto() { child.cross @@ -1124,21 +1159,37 @@ where let child = &mut children[item.index]; if item.item_type == ItemType::Size { - let child_size = layout( - child.node, - layout_type, - actual_main, - if child.node.cross(store, layout_type).is_stretch() { child.cross } else { parent_cross }, - cache, - tree, - store, - sublayout, - ); - child.cross = child_size.cross; - actual_main = child_size.main; + let target_cross = if child.node.cross(store, layout_type).is_stretch() { + child.cross + } else { + parent_cross + }; + + if !child.has_layout_constraints + || !same_f32(child.last_layout_main, actual_main) + || !same_f32(child.last_layout_cross, target_cross) + { + let child_size = layout( + child.node, + layout_type, + actual_main, + target_cross, + cache, + tree, + store, + sublayout, + ); + child.cross = child_size.cross; + actual_main = child_size.main; + child.last_layout_main = actual_main; + child.last_layout_cross = target_cross; + child.has_layout_constraints = true; + } else { + actual_main = child.main; + } if child.node.min_main(store, layout_type).is_auto() { - item.min = child_size.main; + item.min = child.main; } } @@ -1166,22 +1217,32 @@ where match item.item_type { ItemType::Size => { if (item.computed - item.measured).abs() > f32::EPSILON { - let child_size = layout( - child.node, - layout_type, - item.computed, - if child.node.cross(store, layout_type).is_stretch() { - child.cross - } else { - parent_cross - }, - cache, - tree, - store, - sublayout, - ); - - child.cross = child_size.cross; + let target_cross = if child.node.cross(store, layout_type).is_stretch() { + child.cross + } else { + parent_cross + }; + + if !child.has_layout_constraints + || !same_f32(child.last_layout_main, item.computed) + || !same_f32(child.last_layout_cross, target_cross) + { + let child_size = layout( + child.node, + layout_type, + item.computed, + target_cross, + cache, + tree, + store, + sublayout, + ); + + child.cross = child_size.cross; + child.last_layout_main = item.computed; + child.last_layout_cross = target_cross; + child.has_layout_constraints = true; + } } child.main = item.computed; @@ -1281,16 +1342,21 @@ where let child_main_is_stretch = child.node.main(store, layout_type).is_stretch(); let child_cross_is_stretch = child.node.cross(store, layout_type).is_stretch(); - let child_size = layout( - child.node, - layout_type, - if child_main_is_stretch { child.main } else { parent_main }, - if child_cross_is_stretch { child.cross } else { parent_cross }, - cache, - tree, - store, - sublayout, - ); + let target_main = if child_main_is_stretch { child.main } else { parent_main }; + let target_cross = if child_cross_is_stretch { child.cross } else { parent_cross }; + + if child.has_layout_constraints + && same_f32(child.last_layout_main, target_main) + && same_f32(child.last_layout_cross, target_cross) + { + continue; + } + + let child_size = layout(child.node, layout_type, target_main, target_cross, cache, tree, store, sublayout); + + child.last_layout_main = target_main; + child.last_layout_cross = target_cross; + child.has_layout_constraints = true; if !child_main_is_stretch { child.main = child_size.main; @@ -1337,6 +1403,9 @@ where cross: computed_child_cross, main: computed_child_main, main_after: 0.0, + last_layout_main: 0.0, + last_layout_cross: 0.0, + has_layout_constraints: false, }); } From 6173bd9962c1b143f1570ad9cb52f410a8ae853a Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 22:59:32 +0100 Subject: [PATCH 09/19] Optimize layout iteration and cross-stretch Introduce a cross_is_stretch flag on item records and use index-range loops instead of cloning iterator views to avoid repeated method calls and iterator allocations. Compute stretch_sum and fixed_sum with simple loops, compute per-line max/main sums via ranges, and resolve cross-stretch children using the cached flag. Also simplify recomputation of line cross extents and adjust RTL/LTR positioning loops to avoid clone().enumerate(). These changes improve performance and reduce allocations in the flex layout algorithm. --- src/layout.rs | 77 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 6ddb6888..819cd30a 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -402,6 +402,7 @@ where cross: f32, /// Non-zero when this item has Stretch units on the main axis. stretch_main_factor: f32, + cross_is_stretch: bool, min_main: f32, max_main: f32, min_cross: f32, @@ -417,6 +418,7 @@ where let child_max_main = child.max_main(store, layout_type); let child_min_cross = child.min_cross(store, layout_type); let child_max_cross = child.max_cross(store, layout_type); + let child_cross_is_stretch = child.cross(store, layout_type).is_stretch(); let min_main_px = child_min_main.to_px(avail_main, DEFAULT_MIN); let max_main_px = child_max_main.to_px(avail_main, DEFAULT_MAX); @@ -430,6 +432,7 @@ where main: base, cross: 0.0, stretch_main_factor: factor, + cross_is_stretch: child_cross_is_stretch, min_main: min_main_px, max_main: max_main_px, min_cross: min_cross_px, @@ -441,6 +444,7 @@ where main: size.main, cross: size.cross, stretch_main_factor: 0.0, + cross_is_stretch: child_cross_is_stretch, min_main: min_main_px, max_main: max_main_px, min_cross: min_cross_px, @@ -485,21 +489,28 @@ where // Phase 3: Per-line flex resolution for stretch-main items. for line in lines.iter() { + let start = line.start; + let end = line.end; let count = line.len(); if count == 0 { continue; } - let stretch_sum: f32 = - line.clone().filter(|&i| items[i].stretch_main_factor > 0.0).map(|i| items[i].stretch_main_factor).sum(); + let mut stretch_sum = 0.0f32; + let mut fixed_sum = 0.0f32; + for i in start..end { + if items[i].stretch_main_factor > 0.0 { + stretch_sum += items[i].stretch_main_factor; + } else { + fixed_sum += items[i].main; + } + } if stretch_sum > 0.0 { - let fixed_sum: f32 = - line.clone().filter(|&i| items[i].stretch_main_factor == 0.0).map(|i| items[i].main).sum(); let gap_total = (count - 1) as f32 * item_gap_px; let free_main = (avail_main - fixed_sum - gap_total).max(0.0); - for i in line.clone() { + for i in start..end { let factor = items[i].stretch_main_factor; if factor > 0.0 { let allocated = (factor / stretch_sum * free_main).round(); @@ -516,20 +527,25 @@ where // Phase 4: Compute the cross extent of each line from non-cross-stretch children. let mut line_cross: SmallVec<[f32; 8]> = SmallVec::with_capacity(lines.len()); for line in lines.iter() { - let max_cross = line - .clone() - .filter(|&i| !relative_children[i].cross(store, layout_type).is_stretch()) - .map(|i| items[i].cross) - .fold(0.0f32, f32::max); + let start = line.start; + let end = line.end; + let mut max_cross = 0.0f32; + for i in start..end { + if !items[i].cross_is_stretch { + max_cross = max_cross.max(items[i].cross); + } + } line_cross.push(max_cross); } // Phase 5: Resolve cross-stretch children to fill their line's cross extent. for (line_idx, line) in lines.iter().enumerate() { + let start = line.start; + let end = line.end; let lc = line_cross[line_idx]; - for i in line.clone() { - let child = relative_children[i]; - if child.cross(store, layout_type).is_stretch() { + for i in start..end { + if items[i].cross_is_stretch { + let child = relative_children[i]; let clamped_cross = lc.clamp(items[i].min_cross, items[i].max_cross); let size = layout(child, layout_type, items[i].main, clamped_cross, cache, tree, store, sublayout); @@ -538,7 +554,11 @@ where } } // Re-compute line cross to include cross-stretch items in case they changed. - line_cross[line_idx] = line.clone().map(|i| items[i].cross).fold(0.0f32, f32::max); + let mut max_cross = 0.0f32; + for i in start..end { + max_cross = max_cross.max(items[i].cross); + } + line_cross[line_idx] = max_cross; } // Phase 6: Determine the final cross size of the container. @@ -564,8 +584,12 @@ where let main_units = node.main(store, layout_type); let final_main = if main_units.is_auto() || parent_main == 0.0 { let raw = if !lines.is_empty() { - lines[0].clone().map(|i| items[i].main).sum::() - + (lines[0].len().saturating_sub(1)) as f32 * item_gap_px + let first = &lines[0]; + let mut sum = 0.0f32; + for i in first.start..first.end { + sum += items[i].main; + } + sum + (first.len().saturating_sub(1)) as f32 * item_gap_px } else { 0.0 }; @@ -652,10 +676,15 @@ where let mut cross_cursor = padding_cross_before + border_cross_before; for (line_idx, line) in lines.iter().enumerate() { + let start = line.start; + let end = line.end; let lc = line_cross[line_idx]; let count = line.len(); let gap_total = (count.saturating_sub(1)) as f32 * item_gap_px; - let line_main_sum: f32 = line.clone().map(|i| items[i].main).sum(); + let mut line_main_sum = 0.0f32; + for i in start..end { + line_main_sum += items[i].main; + } let free_main = (avail_main - line_main_sum - gap_total).max(0.0); if is_row_rtl { @@ -663,25 +692,27 @@ where // Alignment is flipped above so TopLeft maps to TopRight semantics. let mut main_cursor = padding_main_before + border_main_before + main_align_frac * free_main; - - for (item_idx, i) in line.clone().rev().enumerate() { + + let mut item_idx = 0usize; + for i in (start..end).rev() { let item = &items[i]; let child = relative_children[i]; let item_cross_offset = cross_align_frac * (lc - item.cross); - + cache.set_rect(child, layout_type, main_cursor, cross_cursor + item_cross_offset, item.main, item.cross); - + main_cursor += item.main; if item_idx + 1 < count { main_cursor += item_gap_px; } + item_idx += 1; } } else { // LTR positioning: items are positioned left-to-right within the line let mut main_cursor = padding_main_before + border_main_before + main_align_frac * free_main; - for (item_pos, i) in line.clone().enumerate() { + for i in start..end { let item = &items[i]; let child = relative_children[i]; @@ -690,7 +721,7 @@ where cache.set_rect(child, layout_type, main_cursor, cross_cursor + item_cross_offset, item.main, item.cross); main_cursor += item.main; - if item_pos + 1 < count { + if i + 1 < end { main_cursor += item_gap_px; } } From 8e5380cb722fda71d48b0a04e65ddc5542668dfb Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 23:05:29 +0100 Subject: [PATCH 10/19] Add layout memoization and steady-state bench Introduce pass-scoped layout memoization to avoid recomputing node sizes when parent constraints haven't changed. Added LayoutMemo and a layout_memo SecondaryMap plus a layout_pass counter to NodeCache, along with same_f32 for exact f32 comparison. Extended the Cache trait with begin_layout_pass, get_layout_result and set_layout_result (default no-ops) and implemented them in NodeCache. Updated layout logic to consult and store memoized results, and ensure begin_layout_pass is called at the start of node layout. Also added a "Steady State Tree" benchmark that builds a tree and repeatedly runs layout to measure the effect. --- benches/stack.rs | 24 ++++++++++++++ ecs/src/implementations.rs | 66 ++++++++++++++++++++++++++++++++++++++ src/cache.rs | 34 +++++++++++++++++++- src/layout.rs | 19 +++++++++-- src/node.rs | 2 ++ 5 files changed, 141 insertions(+), 4 deletions(-) diff --git a/benches/stack.rs b/benches/stack.rs index 8471b716..d5422c11 100644 --- a/benches/stack.rs +++ b/benches/stack.rs @@ -124,6 +124,30 @@ fn morphorm_benchmarks(c: &mut Criterion) { }); group.finish(); + + let mut group = c.benchmark_group("Steady State Tree"); + group.sample_size(20); + + let children_per_node = 10; + let depth = 6usize; + let benchmark_id = BenchmarkId::new( + format!( + "Steady state bench. {children_per_node} children per node, depth: {depth}. Total nodes: {}.", + compute_node_count(children_per_node, depth, &mut 0) + ), + depth, + ); + + let mut world = World::default(); + let root = build_tree(&mut world, None, children_per_node, depth); + + group.bench_function(benchmark_id, |b| { + b.iter(|| { + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + }) + }); + + group.finish(); } criterion_group!(benches, morphorm_benchmarks); diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index 6e9a4d17..bb518aa8 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -198,19 +198,41 @@ pub struct Rect { pub struct NodeCache { // Computed size and position of nodes. pub rect: SecondaryMap, + // Pass-scoped memoized layout sizes. + layout_memo: SecondaryMap, + layout_pass: u64, +} + +#[derive(Default, Debug, Clone, Copy)] +struct LayoutMemo { + parent_layout_type: LayoutType, + parent_main: f32, + parent_cross: f32, + size: Size, + pass: u64, + valid: bool, +} + +#[inline] +fn same_f32(a: f32, b: f32) -> bool { + a.to_bits() == b.to_bits() } impl NodeCache { pub fn add(&mut self, entity: Entity) { self.rect.insert(entity, Default::default()); + self.layout_memo.insert(entity, Default::default()); } pub fn remove(&mut self, entity: Entity) { self.rect.remove(entity); + self.layout_memo.remove(entity); } pub fn clear(&mut self) { self.rect.clear(); + self.layout_memo.clear(); + self.layout_pass = 0; } pub fn bounds(&self, entity: Entity) -> Option<&Rect> { @@ -221,6 +243,50 @@ impl NodeCache { impl Cache for NodeCache { type Node = Entity; + fn begin_layout_pass(&mut self) { + self.layout_pass = self.layout_pass.wrapping_add(1); + } + + fn get_layout_result( + &self, + node: &Self::Node, + parent_layout_type: LayoutType, + parent_main: f32, + parent_cross: f32, + ) -> Option { + let memo = self.layout_memo.get(*node)?; + if !memo.valid || memo.pass != self.layout_pass { + return None; + } + + if memo.parent_layout_type == parent_layout_type + && same_f32(memo.parent_main, parent_main) + && same_f32(memo.parent_cross, parent_cross) + { + Some(memo.size) + } else { + None + } + } + + fn set_layout_result( + &mut self, + node: &Self::Node, + parent_layout_type: LayoutType, + parent_main: f32, + parent_cross: f32, + size: Size, + ) { + if let Some(memo) = self.layout_memo.get_mut(*node) { + memo.parent_layout_type = parent_layout_type; + memo.parent_main = parent_main; + memo.parent_cross = parent_cross; + memo.size = size; + memo.pass = self.layout_pass; + memo.valid = true; + } + } + fn set_bounds(&mut self, node: &Self::Node, posx: f32, posy: f32, width: f32, height: f32) { if let Some(rect) = self.rect.get_mut(*node) { rect.posx = posx; diff --git a/src/cache.rs b/src/cache.rs index e942e3eb..d3862016 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,4 +1,4 @@ -use crate::{LayoutType, Node}; +use crate::{LayoutType, Node, Size}; /// The `Cache` is a store which contains the computed size and position of nodes /// after a layout calculation. @@ -20,6 +20,38 @@ pub trait Cache { /// Sets the cached position and size of the given node. fn set_bounds(&mut self, node: &Self::Node, posx: f32, posy: f32, width: f32, height: f32); + + /// Starts a new layout pass. + /// + /// Caches can use this to invalidate pass-scoped memoized results. + /// Default implementation is a no-op for backward compatibility. + fn begin_layout_pass(&mut self) {} + + /// Returns a memoized layout size for a node under the given parent constraints. + /// + /// Default implementation disables memoization. + fn get_layout_result( + &self, + _node: &Self::Node, + _parent_layout_type: LayoutType, + _parent_main: f32, + _parent_cross: f32, + ) -> Option { + None + } + + /// Stores a memoized layout size for a node under the given parent constraints. + /// + /// Default implementation is a no-op. + fn set_layout_result( + &mut self, + _node: &Self::Node, + _parent_layout_type: LayoutType, + _parent_main: f32, + _parent_cross: f32, + _size: Size, + ) { + } } /// Helper trait for getting/setting node position/size in a direction agnostic way. diff --git a/src/layout.rs b/src/layout.rs index 819cd30a..ceb10ba8 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -822,6 +822,13 @@ where N: Node, C: Cache, { + let requested_main = parent_main; + let requested_cross = parent_cross; + + if let Some(cached) = cache.get_layout_result(node, parent_layout_type, requested_main, requested_cross) { + return cached; + } + // The layout type of the node. Determines the main and cross axes of the children. let layout_type = node.layout_type(store).unwrap_or_default(); @@ -913,11 +920,15 @@ where computed_cross = computed_cross.max(min_cross).min(max_cross); if layout_type == LayoutType::Grid { - return layout_grid(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); + let size = layout_grid(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); + cache.set_layout_result(node, parent_layout_type, requested_main, requested_cross, size); + return size; } if node.wrap(store).unwrap_or_default() == LayoutWrap::Wrap { - return layout_wrap(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); + let size = layout_wrap(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); + cache.set_layout_result(node, parent_layout_type, requested_main, requested_cross, size); + return size; } // Determine the parent_main/cross size to pass to the children based on the layout type of the parent and the node. @@ -1547,5 +1558,7 @@ where } // Return the computed size, propagating it back up the tree. - Size { main: computed_main, cross: computed_cross } + let size = Size { main: computed_main, cross: computed_cross }; + cache.set_layout_result(node, parent_layout_type, requested_main, requested_cross, size); + size } diff --git a/src/node.rs b/src/node.rs index 69c4d3fe..f2d7b4d1 100644 --- a/src/node.rs +++ b/src/node.rs @@ -46,6 +46,8 @@ pub trait Node: Sized { store: &Self::Store, sublayout: &mut Self::SubLayout<'_>, ) -> Size { + cache.begin_layout_pass(); + let width = self.width(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); let height = self.height(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); From 22a250a7add32273c8bf1e5c2f18f465c6fcfebe Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 23:19:37 +0100 Subject: [PATCH 11/19] Add cross-pass layout memoization with revisioning Introduce cross-pass memoization for layout caches by adding revision tracking and control APIs. NodeCache gains cross_pass_memo_enabled and layout_revision fields and LayoutMemo now stores a revision; cache lookups accept entries that match either the current pass or the layout revision when cross-pass memoization is enabled. New NodeCache methods (enable_cross_pass_memoization, set_layout_revision, bump_layout_revision) are provided and layout entries record the revision when stored. World.bump_layout_revision is added and invoked on structural changes and on most property setters (add/remove/clear and mutators) to ensure cached layouts are invalidated when relevant state changes. Benchmarks updated to compare a baseline world vs a cached world with cross-pass memoization enabled. --- benches/stack.rs | 28 ++++++++++++++--------- ecs/src/implementations.rs | 25 ++++++++++++++++++++- ecs/src/world.rs | 46 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/benches/stack.rs b/benches/stack.rs index d5422c11..f5509e68 100644 --- a/benches/stack.rs +++ b/benches/stack.rs @@ -130,20 +130,28 @@ fn morphorm_benchmarks(c: &mut Criterion) { let children_per_node = 10; let depth = 6usize; - let benchmark_id = BenchmarkId::new( - format!( - "Steady state bench. {children_per_node} children per node, depth: {depth}. Total nodes: {}.", - compute_node_count(children_per_node, depth, &mut 0) - ), - depth, + let mut world_baseline = World::default(); + let root_baseline = build_tree(&mut world_baseline, None, children_per_node, depth); + + let mut world_cached = World::default(); + let root_cached = build_tree(&mut world_cached, None, children_per_node, depth); + world_cached.cache.enable_cross_pass_memoization(true); + world_cached.cache.set_layout_revision(1); + + let benchmark_label = format!( + "Steady state bench. {children_per_node} children per node, depth: {depth}. Total nodes: {}.", + compute_node_count(children_per_node, depth, &mut 0) ); - let mut world = World::default(); - let root = build_tree(&mut world, None, children_per_node, depth); + group.bench_function(format!("{} [baseline]", benchmark_label), |b| { + b.iter(|| { + root_baseline.layout(&mut world_baseline.cache, &world_baseline.tree, &world_baseline.store, &mut ()); + }) + }); - group.bench_function(benchmark_id, |b| { + group.bench_function(format!("{} [cross-pass memo]", benchmark_label), |b| { b.iter(|| { - root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + root_cached.layout(&mut world_cached.cache, &world_cached.tree, &world_cached.store, &mut ()); }) }); diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index bb518aa8..9b43d1c0 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -201,6 +201,8 @@ pub struct NodeCache { // Pass-scoped memoized layout sizes. layout_memo: SecondaryMap, layout_pass: u64, + cross_pass_memo_enabled: bool, + layout_revision: u64, } #[derive(Default, Debug, Clone, Copy)] @@ -210,6 +212,7 @@ struct LayoutMemo { parent_cross: f32, size: Size, pass: u64, + revision: u64, valid: bool, } @@ -219,6 +222,18 @@ fn same_f32(a: f32, b: f32) -> bool { } impl NodeCache { + pub fn enable_cross_pass_memoization(&mut self, enabled: bool) { + self.cross_pass_memo_enabled = enabled; + } + + pub fn set_layout_revision(&mut self, revision: u64) { + self.layout_revision = revision; + } + + pub fn bump_layout_revision(&mut self) { + self.layout_revision = self.layout_revision.wrapping_add(1); + } + pub fn add(&mut self, entity: Entity) { self.rect.insert(entity, Default::default()); self.layout_memo.insert(entity, Default::default()); @@ -233,6 +248,7 @@ impl NodeCache { self.rect.clear(); self.layout_memo.clear(); self.layout_pass = 0; + self.layout_revision = 0; } pub fn bounds(&self, entity: Entity) -> Option<&Rect> { @@ -255,7 +271,13 @@ impl Cache for NodeCache { parent_cross: f32, ) -> Option { let memo = self.layout_memo.get(*node)?; - if !memo.valid || memo.pass != self.layout_pass { + if !memo.valid { + return None; + } + + let pass_match = memo.pass == self.layout_pass; + let revision_match = self.cross_pass_memo_enabled && memo.revision == self.layout_revision; + if !pass_match && !revision_match { return None; } @@ -283,6 +305,7 @@ impl Cache for NodeCache { memo.parent_cross = parent_cross; memo.size = size; memo.pass = self.layout_pass; + memo.revision = self.layout_revision; memo.valid = true; } } diff --git a/ecs/src/world.rs b/ecs/src/world.rs index 30cb8146..a34f851e 100644 --- a/ecs/src/world.rs +++ b/ecs/src/world.rs @@ -28,6 +28,10 @@ pub struct World { } impl World { + fn bump_layout_revision(&mut self) { + self.cache.bump_layout_revision(); + } + /// Add a node to the world with a specified parent node. pub fn add(&mut self, parent: Option) -> Entity { let entity = self.entity_manager.create(); @@ -41,6 +45,7 @@ impl World { self.store.red.insert(entity, random_red); self.store.green.insert(entity, random_green); self.store.blue.insert(entity, random_blue); + self.bump_layout_revision(); entity } @@ -49,6 +54,7 @@ impl World { self.store.remove(entity); self.cache.remove(entity); self.tree.remove(&entity); + self.bump_layout_revision(); } pub fn clear(&mut self) { @@ -56,79 +62,95 @@ impl World { self.store.clear(); self.cache.clear(); self.tree.clear(); + self.bump_layout_revision(); } /// Set the desired layout type of the given entity. pub fn set_layout_type(&mut self, entity: Entity, value: LayoutType) { self.store.layout_type.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired position type of the given entity. pub fn set_position_type(&mut self, entity: Entity, value: PositionType) { self.store.position_type.insert(entity, value); + self.bump_layout_revision(); } /// Set the inline direction of row content for the given entity. pub fn set_direction(&mut self, entity: Entity, value: Direction) { self.store.direction.insert(entity, value); + self.bump_layout_revision(); } /// Set the wrap mode for children of the given entity. pub fn set_wrap(&mut self, entity: Entity, value: LayoutWrap) { self.store.wrap.insert(entity, value); + self.bump_layout_revision(); } pub fn set_alignment(&mut self, entity: Entity, value: Alignment) { self.store.alignment.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired width of the given entity. pub fn set_width(&mut self, entity: Entity, value: Units) { self.store.width.insert(entity, value); + self.bump_layout_revision(); } /// Set the minimum width of the given entity. pub fn set_min_width(&mut self, entity: Entity, value: Units) { self.store.min_width.insert(entity, value); + self.bump_layout_revision(); } /// Set the maximum width of the given entity. pub fn set_max_width(&mut self, entity: Entity, value: Units) { self.store.max_width.insert(entity, value); + self.bump_layout_revision(); } /// Set the minimum height of the given entity. pub fn set_min_height(&mut self, entity: Entity, value: Units) { self.store.min_height.insert(entity, value); + self.bump_layout_revision(); } /// Set the maximum height of the given entity. pub fn set_max_height(&mut self, entity: Entity, value: Units) { self.store.max_height.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired height of the given entity. pub fn set_height(&mut self, entity: Entity, value: Units) { self.store.height.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired left space of the given entity. pub fn set_left(&mut self, entity: Entity, value: Units) { self.store.left.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired right space of the given entity. pub fn set_right(&mut self, entity: Entity, value: Units) { self.store.right.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired top space of the given entity. pub fn set_top(&mut self, entity: Entity, value: Units) { self.store.top.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired bottom space of the given entity. pub fn set_bottom(&mut self, entity: Entity, value: Units) { self.store.bottom.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired padding of the given entity. @@ -137,92 +159,111 @@ impl World { self.store.padding_right.insert(entity, value); self.store.padding_top.insert(entity, value); self.store.padding_bottom.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired padding_left space of the given entity. pub fn set_padding_left(&mut self, entity: Entity, value: Units) { self.store.padding_left.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired padding_right space of the given entity. pub fn set_padding_right(&mut self, entity: Entity, value: Units) { self.store.padding_right.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired padding_top space of the given entity. pub fn set_padding_top(&mut self, entity: Entity, value: Units) { self.store.padding_top.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired padding_bottom space of the given entity. pub fn set_padding_bottom(&mut self, entity: Entity, value: Units) { self.store.padding_bottom.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired vertical (row) space between children of the given entity. pub fn set_vertical_gap(&mut self, entity: Entity, value: Units) { self.store.vertical_gap.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired horizontal (column) space between children of the given entity. pub fn set_horizontal_gap(&mut self, entity: Entity, value: Units) { self.store.horizontal_gap.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired minimum vertical (row) space between children of the given entity. pub fn set_min_vertical_gap(&mut self, entity: Entity, value: Units) { self.store.min_vertical_gap.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired minimum horizontal (column) space between children of the given entity. pub fn set_min_horizontal_gap(&mut self, entity: Entity, value: Units) { self.store.min_horizontal_gap.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired maximum vertical (row) space between children of the given entity. pub fn set_max_vertical_gap(&mut self, entity: Entity, value: Units) { self.store.max_vertical_gap.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired maximum horizontal (column) space between children of the given entity. pub fn set_max_horizontal_gap(&mut self, entity: Entity, value: Units) { self.store.max_horizontal_gap.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired vertical scroll offset. pub fn set_vertical_scroll(&mut self, entity: Entity, value: f32) { self.store.vertical_scroll.insert(entity, value); + self.bump_layout_revision(); } /// Set the desired horizontal scroll offset. pub fn set_horizontal_scroll(&mut self, entity: Entity, value: f32) { self.store.horizontal_scroll.insert(entity, value); + self.bump_layout_revision(); } pub fn set_grid_columns(&mut self, entity: Entity, value: Vec) { self.store.grid_columns.insert(entity, value); + self.bump_layout_revision(); } pub fn set_grid_rows(&mut self, entity: Entity, value: Vec) { self.store.grid_rows.insert(entity, value); + self.bump_layout_revision(); } pub fn set_column_start(&mut self, entity: Entity, value: usize) { self.store.column_start.insert(entity, value); + self.bump_layout_revision(); } pub fn set_row_start(&mut self, entity: Entity, value: usize) { self.store.row_start.insert(entity, value); + self.bump_layout_revision(); } pub fn set_column_span(&mut self, entity: Entity, value: usize) { self.store.column_span.insert(entity, value); + self.bump_layout_revision(); } pub fn set_row_span(&mut self, entity: Entity, value: usize) { self.store.row_span.insert(entity, value); + self.bump_layout_revision(); } /// Set the content size function for the given entity. @@ -232,20 +273,24 @@ impl World { content: impl Fn(&Store, Option, Option) -> (f32, f32) + 'static, ) { self.store.content_size.insert(entity, Box::new(content)); + self.bump_layout_revision(); } pub fn set_visibility(&mut self, entity: Entity, visible: bool) { self.store.visible.insert(entity, visible); + self.bump_layout_revision(); } /// Set the text to be displayed on the given entity. pub fn set_text(&mut self, entity: Entity, text: &str) { self.store.text.insert(entity, String::from(text)); + self.bump_layout_revision(); } /// Set whether the text should wrap for the given entity. pub fn set_text_wrap(&mut self, entity: Entity, text_wrap: TextWrap) { self.store.text_wrap.insert(entity, text_wrap); + self.bump_layout_revision(); } /// Set all space and size properties of the given node to stretch. @@ -263,5 +308,6 @@ impl World { self.store.border_right.insert(entity, width); self.store.border_top.insert(entity, width); self.store.border_bottom.insert(entity, width); + self.bump_layout_revision(); } } From 1761f3cce48d7d33cb0cc19439fb044a7980826d Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Fri, 10 Apr 2026 23:30:01 +0100 Subject: [PATCH 12/19] Use generation-based layout memoization Replace the previous pass/revision cross-pass memoization with a single generation-based invalidation scheme. Renamed fields (layout_revision -> layout_generation, cross_pass_memo_enabled -> memoization_enabled) and LayoutMemo.pass/revision -> generation; added enable_layout_memoization and a Default impl for NodeCache. Simplified enable_cross_pass_memoization to delegate to the new toggle, removed per-layout-pass tracking and begin_layout_pass, and updated cache get/set logic to validate against the current generation. Also removed the begin_layout_pass call in node layout code. --- ecs/src/implementations.rs | 54 ++++++++++++++++++++++---------------- src/node.rs | 2 -- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index 9b43d1c0..e43f30da 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -194,15 +194,13 @@ pub struct Rect { pub height: f32, } -#[derive(Default, Debug)] pub struct NodeCache { // Computed size and position of nodes. pub rect: SecondaryMap, - // Pass-scoped memoized layout sizes. + // Memoized layout sizes for the current invalidation generation. layout_memo: SecondaryMap, - layout_pass: u64, - cross_pass_memo_enabled: bool, - layout_revision: u64, + layout_generation: u64, + memoization_enabled: bool, } #[derive(Default, Debug, Clone, Copy)] @@ -211,8 +209,7 @@ struct LayoutMemo { parent_main: f32, parent_cross: f32, size: Size, - pass: u64, - revision: u64, + generation: u64, valid: bool, } @@ -222,16 +219,20 @@ fn same_f32(a: f32, b: f32) -> bool { } impl NodeCache { + pub fn enable_layout_memoization(&mut self, enabled: bool) { + self.memoization_enabled = enabled; + } + pub fn enable_cross_pass_memoization(&mut self, enabled: bool) { - self.cross_pass_memo_enabled = enabled; + self.enable_layout_memoization(enabled); } pub fn set_layout_revision(&mut self, revision: u64) { - self.layout_revision = revision; + self.layout_generation = revision; } pub fn bump_layout_revision(&mut self) { - self.layout_revision = self.layout_revision.wrapping_add(1); + self.layout_generation = self.layout_generation.wrapping_add(1); } pub fn add(&mut self, entity: Entity) { @@ -247,8 +248,7 @@ impl NodeCache { pub fn clear(&mut self) { self.rect.clear(); self.layout_memo.clear(); - self.layout_pass = 0; - self.layout_revision = 0; + self.layout_generation = 0; } pub fn bounds(&self, entity: Entity) -> Option<&Rect> { @@ -259,10 +259,6 @@ impl NodeCache { impl Cache for NodeCache { type Node = Entity; - fn begin_layout_pass(&mut self) { - self.layout_pass = self.layout_pass.wrapping_add(1); - } - fn get_layout_result( &self, node: &Self::Node, @@ -270,14 +266,12 @@ impl Cache for NodeCache { parent_main: f32, parent_cross: f32, ) -> Option { - let memo = self.layout_memo.get(*node)?; - if !memo.valid { + if !self.memoization_enabled { return None; } - let pass_match = memo.pass == self.layout_pass; - let revision_match = self.cross_pass_memo_enabled && memo.revision == self.layout_revision; - if !pass_match && !revision_match { + let memo = self.layout_memo.get(*node)?; + if !memo.valid || memo.generation != self.layout_generation { return None; } @@ -299,13 +293,16 @@ impl Cache for NodeCache { parent_cross: f32, size: Size, ) { + if !self.memoization_enabled { + return; + } + if let Some(memo) = self.layout_memo.get_mut(*node) { memo.parent_layout_type = parent_layout_type; memo.parent_main = parent_main; memo.parent_cross = parent_cross; memo.size = size; - memo.pass = self.layout_pass; - memo.revision = self.layout_revision; + memo.generation = self.layout_generation; memo.valid = true; } } @@ -351,3 +348,14 @@ impl Cache for NodeCache { 0.0 } } + +impl Default for NodeCache { + fn default() -> Self { + Self { + rect: SecondaryMap::new(), + layout_memo: SecondaryMap::new(), + layout_generation: 0, + memoization_enabled: true, + } + } +} diff --git a/src/node.rs b/src/node.rs index f2d7b4d1..69c4d3fe 100644 --- a/src/node.rs +++ b/src/node.rs @@ -46,8 +46,6 @@ pub trait Node: Sized { store: &Self::Store, sublayout: &mut Self::SubLayout<'_>, ) -> Size { - cache.begin_layout_pass(); - let width = self.width(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); let height = self.height(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); From 05a33394995918075ca4e99380a9a68851291170 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Mon, 13 Apr 2026 22:20:16 +0100 Subject: [PATCH 13/19] Handle RTL for inline layouts Treat both Row and Column as inline layouts when applying RTL behavior: replace equality checks with matches!(..., Row | Column), rename is_row_rtl to is_inline_rtl, and flip horizontal alignment/positioning accordingly. This fixes RTL handling for column-based inline layouts. Added a unit test (rtl_reverses_column_alignment_horizontally) to verify column alignment is flipped in RTL. --- src/layout.rs | 18 +++++++++++------- tests/direction.rs | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index ceb10ba8..5d8f63a8 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -269,7 +269,9 @@ where let mut alignment = node.alignment(store).unwrap_or_default(); - if parent_layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft { + if matches!(parent_layout_type, LayoutType::Row | LayoutType::Column) + && node.direction(store).unwrap_or_default() == Direction::RightToLeft + { alignment = flip_alignment_horizontal(alignment); } @@ -385,8 +387,8 @@ where // Gap between lines (on the cross axis). let line_gap_px = node.cross_between(store, layout_type).to_px(avail_cross, 0.0); - let is_row_rtl = - layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft; + let is_inline_rtl = matches!(layout_type, LayoutType::Row | LayoutType::Column) + && node.direction(store).unwrap_or_default() == Direction::RightToLeft; let relative_children = node .children(tree) @@ -653,8 +655,8 @@ where // layout_type. let mut alignment = node.alignment(store).unwrap_or_default(); - // For RTL row layouts, flip alignment horizontally so TopLeft becomes TopRight - if is_row_rtl { + // For RTL inline layouts, flip horizontal alignment so TopLeft becomes TopRight. + if is_inline_rtl { alignment = flip_alignment_horizontal(alignment); } @@ -687,7 +689,7 @@ where } let free_main = (avail_main - line_main_sum - gap_total).max(0.0); - if is_row_rtl { + if is_inline_rtl { // RTL positioning: place items in reverse order within each wrapped line. // Alignment is flipped above so TopLeft maps to TopRight semantics. let mut main_cursor = @@ -1453,7 +1455,9 @@ where let mut alignment = node.alignment(store).unwrap_or_default(); - if layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft { + if matches!(layout_type, LayoutType::Row | LayoutType::Column) + && node.direction(store).unwrap_or_default() == Direction::RightToLeft + { alignment = flip_alignment_horizontal(alignment); } diff --git a/tests/direction.rs b/tests/direction.rs index 22774c1f..80ad85b4 100644 --- a/tests/direction.rs +++ b/tests/direction.rs @@ -65,3 +65,23 @@ fn rtl_reverses_row_alignment_horizontally() { assert_eq!(world.cache.bounds(child), Some(&Rect { posx: 0.0, posy: 0.0, width: 50.0, height: 50.0 })); } + +#[test] +fn rtl_reverses_column_alignment_horizontally() { + let mut world = World::default(); + + let root = world.add(None); + world.set_layout_type(root, LayoutType::Column); + world.set_direction(root, Direction::RightToLeft); + world.set_alignment(root, Alignment::TopRight); + world.set_width(root, Units::Pixels(300.0)); + world.set_height(root, Units::Pixels(100.0)); + + let child = world.add(Some(root)); + world.set_width(child, Units::Pixels(50.0)); + world.set_height(child, Units::Pixels(50.0)); + + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); + + assert_eq!(world.cache.bounds(child), Some(&Rect { posx: 0.0, posy: 0.0, width: 50.0, height: 50.0 })); +} From b34cb57704641115104daaa187675d6652a5e94b Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Tue, 14 Apr 2026 18:54:13 +0100 Subject: [PATCH 14/19] Don't apply parent padding to absolute children Treat absolute children as sized without parent padding. Adjust abs_avail calculations to subtract only borders, use abs_avail for positioning, and compute absolute child sizing/constraints against abs_size_main/abs_size_cross. Update related tests to reflect the corrected bounding box expectations. --- src/layout.rs | 38 +++++++++++++++++++++----------------- tests/size_constraints.rs | 6 +++--- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 5d8f63a8..e0be7f4d 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -604,9 +604,9 @@ where }; // Phase 7: Lay out absolute children against the container bounds. - let abs_avail_main = final_main - padding_main_before - padding_main_after - border_main_before - border_main_after; - let abs_avail_cross = - final_cross - padding_cross_before - padding_cross_after - border_cross_before - border_cross_after; + // Absolute children are sized without parent padding influence. + let abs_avail_main = final_main - border_main_before - border_main_after; + let abs_avail_cross = final_cross - border_cross_before - border_cross_after; let abs_children = node .children(tree) @@ -742,8 +742,8 @@ where let child_cross_before = abs_child.node.cross_before(store, layout_type); let child_cross_after = abs_child.node.cross_after(store, layout_type); - let pma = abs_avail_main + padding_main_before + padding_main_after; - let pca = abs_avail_cross + padding_cross_before + padding_cross_after; + let pma = abs_avail_main; + let pca = abs_avail_cross; let child_main_pos = match (child_main_before, child_main_after) { (Pixels(val), _) => val, @@ -1411,30 +1411,34 @@ where // Absolute Children + // Absolute children are sized without parent padding influence. + let abs_size_main = parent_main + padding_main_before + padding_main_after; + let abs_size_cross = parent_cross + padding_cross_before + padding_cross_after; + // Compute space and size of non-flexible absolute children. for child in absolute_children.into_iter() { let main = if child.main(store, layout_type).is_stretch() { - let child_min_main = child.min_main(store, layout_type).to_px(parent_cross, DEFAULT_MIN); - let child_max_main = child.max_main(store, layout_type).to_px(parent_cross, DEFAULT_MAX); + let child_min_main = child.min_main(store, layout_type).to_px(abs_size_cross, DEFAULT_MIN); + let child_max_main = child.max_main(store, layout_type).to_px(abs_size_cross, DEFAULT_MAX); - let child_main_before = child.main_before(store, layout_type).to_px(parent_main, 0.0); - let child_main_after = child.main_after(store, layout_type).to_px(parent_main, 0.0); + let child_main_before = child.main_before(store, layout_type).to_px(abs_size_main, 0.0); + let child_main_after = child.main_after(store, layout_type).to_px(abs_size_main, 0.0); - parent_main.clamp(child_min_main, child_max_main) - child_main_before - child_main_after + abs_size_main.clamp(child_min_main, child_max_main) - child_main_before - child_main_after } else { - parent_main + abs_size_main }; let cross = if child.cross(store, layout_type).is_stretch() { - let child_min_cross = child.min_cross(store, layout_type).to_px(parent_cross, DEFAULT_MIN); - let child_max_cross = child.max_cross(store, layout_type).to_px(parent_cross, DEFAULT_MAX); + let child_min_cross = child.min_cross(store, layout_type).to_px(abs_size_cross, DEFAULT_MIN); + let child_max_cross = child.max_cross(store, layout_type).to_px(abs_size_cross, DEFAULT_MAX); - let child_cross_before = child.cross_before(store, layout_type).to_px(parent_main, 0.0); - let child_cross_after = child.cross_after(store, layout_type).to_px(parent_main, 0.0); + let child_cross_before = child.cross_before(store, layout_type).to_px(abs_size_cross, 0.0); + let child_cross_after = child.cross_after(store, layout_type).to_px(abs_size_cross, 0.0); - parent_cross.clamp(child_min_cross, child_max_cross) - child_cross_before - child_cross_after + abs_size_cross.clamp(child_min_cross, child_max_cross) - child_cross_before - child_cross_after } else { - parent_cross + abs_size_cross }; let child_size = layout(child, layout_type, main, cross, cache, tree, store, sublayout); diff --git a/tests/size_constraints.rs b/tests/size_constraints.rs index 3f8d1804..09e37ba9 100644 --- a/tests/size_constraints.rs +++ b/tests/size_constraints.rs @@ -286,7 +286,7 @@ fn min_width_auto_absolute() { world.set_width(node2, Units::Pixels(300.0)); world.set_height(node2, Units::Pixels(300.0)); root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); - assert_eq!(world.cache.bounds(node), Some(&Rect { posx: 0.0, posy: 0.0, width: 300.0, height: 200.0 })); + assert_eq!(world.cache.bounds(node), Some(&Rect { posx: 0.0, posy: 0.0, width: 600.0, height: 600.0 })); assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 0.0, posy: 0.0, width: 300.0, height: 300.0 })); } @@ -311,7 +311,7 @@ fn min_height_auto_absolute() { world.set_height(node2, Units::Pixels(300.0)); root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); - assert_eq!(world.cache.bounds(node), Some(&Rect { posx: 0.0, posy: 0.0, width: 200.0, height: 300.0 })); + assert_eq!(world.cache.bounds(node), Some(&Rect { posx: 0.0, posy: 0.0, width: 600.0, height: 600.0 })); assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 0.0, posy: 0.0, width: 300.0, height: 300.0 })); } @@ -337,7 +337,7 @@ fn min_size_auto_absolute() { world.set_height(node2, Units::Pixels(300.0)); root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); - assert_eq!(world.cache.bounds(node), Some(&Rect { posx: 0.0, posy: 0.0, width: 300.0, height: 300.0 })); + assert_eq!(world.cache.bounds(node), Some(&Rect { posx: 0.0, posy: 0.0, width: 600.0, height: 600.0 })); assert_eq!(world.cache.bounds(node2), Some(&Rect { posx: 0.0, posy: 0.0, width: 300.0, height: 300.0 })); } From 0954fea10cdb35450008cc0d8e10cc9df57cdc98 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Mon, 20 Apr 2026 15:18:26 +0100 Subject: [PATCH 15/19] Improve layout logic, API updates and cleanups Several fixes and refactors across the layout, ECS, examples and tests: - Disable NodeCache memoization by default and add explicit begin_layout_pass() to Node layout entry. Benchmarks now explicitly toggle cross-pass memoization. - Fix wrapping/line-assignment and auto-size computation (use per-line sizing, treat stretch contributions correctly) and correct absolute-child min/max main computation. - Iterate and mutate child/row/col vectors using idiomatic iterators, simplify computations, and clean up formatting for readability. - Update Store::clear to reset wrap, and adjust world/node/type docstrings to clarify horizontal/inline semantics. - Update example code to match changed canvas and drawing APIs (set_size/clear_rect/fill_path/fill_text signatures), adjust draw_node signature, and small test comment improvements. These changes both correct layout behavior and align examples with updated APIs while improving code clarity. --- benches/stack.rs | 3 +- ecs/src/implementations.rs | 2 +- ecs/src/store.rs | 1 + ecs/src/world.rs | 2 +- examples/advanced.rs | 22 +++--- examples/common/mod.rs | 30 +++---- examples/textwrap.rs | 2 +- src/layout.rs | 157 ++++++++++++++++++------------------- src/node.rs | 20 +++-- src/types.rs | 6 +- tests/wrap.rs | 8 +- 11 files changed, 128 insertions(+), 125 deletions(-) diff --git a/benches/stack.rs b/benches/stack.rs index f5509e68..dd159cf0 100644 --- a/benches/stack.rs +++ b/benches/stack.rs @@ -36,7 +36,7 @@ fn compute_node_count(children_per_node: usize, depth: usize, node_count: &mut u } } - return *node_count; + *node_count } fn morphorm_benchmarks(c: &mut Criterion) { @@ -132,6 +132,7 @@ fn morphorm_benchmarks(c: &mut Criterion) { let depth = 6usize; let mut world_baseline = World::default(); let root_baseline = build_tree(&mut world_baseline, None, children_per_node, depth); + world_baseline.cache.enable_cross_pass_memoization(false); let mut world_cached = World::default(); let root_cached = build_tree(&mut world_cached, None, children_per_node, depth); diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index e43f30da..33ec1f53 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -355,7 +355,7 @@ impl Default for NodeCache { rect: SecondaryMap::new(), layout_memo: SecondaryMap::new(), layout_generation: 0, - memoization_enabled: true, + memoization_enabled: false, } } } diff --git a/ecs/src/store.rs b/ecs/src/store.rs index 4e1da093..c92a6fc9 100644 --- a/ecs/src/store.rs +++ b/ecs/src/store.rs @@ -115,6 +115,7 @@ impl Store { self.layout_type.clear(); self.position_type.clear(); self.direction.clear(); + self.wrap.clear(); self.left.clear(); self.right.clear(); self.top.clear(); diff --git a/ecs/src/world.rs b/ecs/src/world.rs index a34f851e..0fbb6944 100644 --- a/ecs/src/world.rs +++ b/ecs/src/world.rs @@ -77,7 +77,7 @@ impl World { self.bump_layout_revision(); } - /// Set the inline direction of row content for the given entity. + /// Set the inline direction used for horizontal positioning semantics for the given entity. pub fn set_direction(&mut self, entity: Entity, value: Direction) { self.store.direction.insert(entity, value); self.bump_layout_revision(); diff --git a/examples/advanced.rs b/examples/advanced.rs index d5d08b23..618d420b 100644 --- a/examples/advanced.rs +++ b/examples/advanced.rs @@ -374,7 +374,7 @@ pub fn render(mut cache: LayoutCache, mut root: Widget) { *control_flow = ControlFlow::Wait; match event { - Event::LoopDestroyed => return, + Event::LoopDestroyed => (), Event::WindowEvent { ref event, .. } => match event { WindowEvent::Resized(size) => { surface.resize(&context, size.width.try_into().unwrap(), size.height.try_into().unwrap()); @@ -386,10 +386,10 @@ pub fn render(mut cache: LayoutCache, mut root: Widget) { } WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit, - WindowEvent::KeyboardInput { device_id: _, input, is_synthetic: _ } => { - if input.virtual_keycode == Some(VirtualKeyCode::H) && input.state == ElementState::Pressed { - print_node(&root, &cache, &(), true, false, String::new()); - } + WindowEvent::KeyboardInput { device_id: _, input, is_synthetic: _ } + if input.virtual_keycode == Some(VirtualKeyCode::H) && input.state == ElementState::Pressed => + { + print_node(&root, &cache, &(), true, false, String::new()); } _ => (), }, @@ -397,8 +397,8 @@ pub fn render(mut cache: LayoutCache, mut root: Widget) { let dpi_factor = window.scale_factor(); let size = window.inner_size(); - canvas.set_size(size.width as u32, size.height as u32, dpi_factor as f32); - canvas.clear_rect(0, 0, size.width as u32, size.height as u32, Color::rgbf(0.3, 0.3, 0.32)); + canvas.set_size(size.width, size.height, dpi_factor as f32); + canvas.clear_rect(0, 0, size.width, size.height, Color::rgbf(0.3, 0.3, 0.32)); draw_node(&root, &cache, &mut canvas, font); @@ -422,16 +422,16 @@ fn draw_node(node: &Widget, cache: &LayoutCache, canvas: &mut Canvas, fo let mut path = Path::new(); path.rect(posx, posy, width, height); let paint = Paint::color(color); - canvas.fill_path(&mut path, &paint); + canvas.fill_path(&path, &paint); let mut paint = Paint::color(Color::black()); paint.set_font_size(24.0); paint.set_text_align(Align::Center); paint.set_text_baseline(Baseline::Middle); - paint.set_font(&vec![font]); - let _ = canvas.fill_text(posx + width / 2.0, posy + height / 2.0, &node.key().to_string(), &paint); + paint.set_font(&[font]); + let _ = canvas.fill_text(posx + width / 2.0, posy + height / 2.0, node.key().to_string(), &paint); - for child in (&node).children(&()) { + for child in node.children(&()) { draw_node(child, cache, canvas, font); } } diff --git a/examples/common/mod.rs b/examples/common/mod.rs index efc6e457..45452637 100644 --- a/examples/common/mod.rs +++ b/examples/common/mod.rs @@ -102,7 +102,7 @@ pub fn render(mut world: World, root: Entity) { *control_flow = ControlFlow::Wait; match event { - Event::LoopDestroyed => return, + Event::LoopDestroyed => (), Event::WindowEvent { ref event, .. } => match event { WindowEvent::Resized(size) => { surface.resize(&context, size.width.try_into().unwrap(), size.height.try_into().unwrap()); @@ -126,20 +126,20 @@ pub fn render(mut world: World, root: Entity) { } WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit, - WindowEvent::KeyboardInput { device_id: _, input, is_synthetic: _ } => { - if input.virtual_keycode == Some(VirtualKeyCode::H) && input.state == ElementState::Pressed { - print_node(&root, &world.cache, &world.tree, true, false, String::new()); - } + WindowEvent::KeyboardInput { device_id: _, input, is_synthetic: _ } + if input.virtual_keycode == Some(VirtualKeyCode::H) && input.state == ElementState::Pressed => + { + print_node(&root, &world.cache, &world.tree, true, false, String::new()); } _ => (), }, Event::RedrawRequested(_) => { let size = window.inner_size(); - canvas.set_size(size.width as u32, size.height as u32, 1.0); - canvas.clear_rect(0, 0, size.width as u32, size.height as u32, Color::rgbf(0.3, 0.3, 0.32)); + canvas.set_size(size.width, size.height, 1.0); + canvas.clear_rect(0, 0, size.width, size.height, Color::rgbf(0.3, 0.3, 0.32)); - draw_node(&root, &world.tree, &world.cache, &world.store, 0.0, 0.0, font, &mut canvas); + draw_node(&root, &world.tree, &world.cache, &world.store, (0.0, 0.0), font, &mut canvas); canvas.flush(); surface.swap_buffers(&context).unwrap(); @@ -157,11 +157,11 @@ fn draw_node>( tree: &N::Tree, cache: &impl Cache, store: &Store, - parent_posx: f32, - parent_posy: f32, + parent_pos: (f32, f32), font: FontId, canvas: &mut Canvas, ) { + let (parent_posx, parent_posy) = parent_pos; let posx = cache.posx(node); let posy = cache.posy(node); let width = cache.width(node); @@ -174,14 +174,14 @@ fn draw_node>( let mut path = Path::new(); path.rect(parent_posx + posx, parent_posy + posy, width, height); let paint = Paint::color(Color::rgb(*red, *green, *blue)); - canvas.fill_path(&mut path, &paint); + canvas.fill_path(&path, &paint); if let Some(text) = store.text.get(node.key()) { let mut paint = Paint::color(Color::black()); paint.set_font_size(48.0); paint.set_text_align(Align::Left); paint.set_text_baseline(Baseline::Top); - paint.set_font(&vec![font]); + paint.set_font(&[font]); let font_metrics = canvas.measure_font(&paint).expect("Error measuring font"); @@ -199,17 +199,17 @@ fn draw_node>( paint.set_font_size(48.0); paint.set_text_align(Align::Center); paint.set_text_baseline(Baseline::Middle); - paint.set_font(&vec![font]); + paint.set_font(&[font]); let _ = canvas.fill_text( parent_posx + posx + width / 2.0, parent_posy + posy + height / 2.0, - &node.key().0.to_string(), + node.key().0.to_string(), &paint, ); } for child in node.children(tree) { - draw_node(child, tree, cache, store, posx + parent_posx, posy + parent_posy, font, canvas); + draw_node(child, tree, cache, store, (posx + parent_posx, posy + parent_posy), font, canvas); } } diff --git a/examples/textwrap.rs b/examples/textwrap.rs index 8ebfd67d..bba0a58b 100644 --- a/examples/textwrap.rs +++ b/examples/textwrap.rs @@ -91,7 +91,7 @@ fn content_size(node: Entity, store: &Store, width: Option, height: Option< paint.set_font_size(48.0); paint.set_text_align(femtovg::Align::Left); paint.set_text_baseline(femtovg::Baseline::Top); - paint.set_font(&vec![store.font_id.unwrap()]); + paint.set_font(&[store.font_id.unwrap()]); // let should_wrap = store.text_wrap.get(&node).copied().unwrap_or_default(); let text_wrap = store.text_wrap.get(node).copied().unwrap_or_default(); diff --git a/src/layout.rs b/src/layout.rs index e0be7f4d..50d53cf8 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,6 +1,8 @@ use smallvec::SmallVec; -use crate::{Alignment, Cache, CacheExt, Direction, LayoutType, LayoutWrap, Node, NodeExt, PositionType, Size, Units::*}; +use crate::{ + Alignment, Cache, CacheExt, Direction, LayoutType, LayoutWrap, Node, NodeExt, PositionType, Size, Units::*, +}; const DEFAULT_MIN: f32 = -f32::MAX; const DEFAULT_MAX: f32 = f32::MAX; @@ -254,15 +256,15 @@ where // println!("{:?} {:?}", computed_grid_cols, computed_grid_rows); let mut current_col_pos = 0.0; - for i in 0..computed_grid_cols.len() { - current_col_pos += computed_grid_cols[i]; - computed_grid_cols[i] = current_col_pos; + for col in &mut computed_grid_cols { + current_col_pos += *col; + *col = current_col_pos; } let mut current_row_pos = 0.0; - for i in 0..computed_grid_rows.len() { - current_row_pos += computed_grid_rows[i]; - computed_grid_rows[i] = current_row_pos; + for row in &mut computed_grid_rows { + current_row_pos += *row; + *row = current_row_pos; } // println!("{:?} {:?}", computed_grid_cols, computed_grid_rows); @@ -290,15 +292,13 @@ where child_posx *= parent_width - width_sum; child_posy *= parent_height - height_sum; - let mut node_children = node + let node_children = node .children(tree) .filter(|child| child.visible(store)) - .filter(|child| child.position_type(store).unwrap_or_default() == PositionType::Relative) - .enumerate() - .peekable(); + .filter(|child| child.position_type(store).unwrap_or_default() == PositionType::Relative); // Compute space and size of non-flexible relative children. - while let Some((_, child)) = node_children.next() { + for child in node_children { let column_start = 2 * child.column_start(store).unwrap_or_default(); let column_span = 2 * child.column_span(store).unwrap_or(1) - 1; let column_end = column_start + column_span; @@ -351,20 +351,13 @@ where // Convert parent-provided main/cross (which are in parent layout axes) // into this node's layout axes. - let (parent_main, parent_cross) = if parent_layout_type == layout_type { - (parent_main, parent_cross) - } else { - (parent_cross, parent_main) - }; + let (parent_main, parent_cross) = + if parent_layout_type == layout_type { (parent_main, parent_cross) } else { (parent_cross, parent_main) }; - let border_main_before = - node.border_main_before(store, layout_type).to_px(parent_main, DEFAULT_BORDER_WIDTH); - let border_main_after = - node.border_main_after(store, layout_type).to_px(parent_main, DEFAULT_BORDER_WIDTH); - let border_cross_before = - node.border_cross_before(store, layout_type).to_px(parent_cross, DEFAULT_BORDER_WIDTH); - let border_cross_after = - node.border_cross_after(store, layout_type).to_px(parent_cross, DEFAULT_BORDER_WIDTH); + let border_main_before = node.border_main_before(store, layout_type).to_px(parent_main, DEFAULT_BORDER_WIDTH); + let border_main_after = node.border_main_after(store, layout_type).to_px(parent_main, DEFAULT_BORDER_WIDTH); + let border_cross_before = node.border_cross_before(store, layout_type).to_px(parent_cross, DEFAULT_BORDER_WIDTH); + let border_cross_after = node.border_cross_after(store, layout_type).to_px(parent_cross, DEFAULT_BORDER_WIDTH); let padding_main_before = node.padding_main_before(store, layout_type).to_px(parent_main, 0.0); let padding_main_after = node.padding_main_after(store, layout_type).to_px(parent_main, 0.0); @@ -372,17 +365,15 @@ where let padding_cross_after = node.padding_cross_after(store, layout_type).to_px(parent_cross, 0.0); // Available space for children after subtracting padding and border. - let avail_main = - parent_main - padding_main_before - padding_main_after - border_main_before - border_main_after; + let avail_main = parent_main - padding_main_before - padding_main_after - border_main_before - border_main_after; let avail_cross = parent_cross - padding_cross_before - padding_cross_after - border_cross_before - border_cross_after; // Gap between items within a line (on the main axis). let min_main_between = node.min_main_between(store, layout_type); let max_main_between = node.max_main_between(store, layout_type); - let item_gap_px = node - .main_between(store, layout_type) - .to_px_clamped(avail_main, 0.0, min_main_between, max_main_between); + let item_gap_px = + node.main_between(store, layout_type).to_px_clamped(avail_main, 0.0, min_main_between, max_main_between); // Gap between lines (on the cross axis). let line_gap_px = node.cross_between(store, layout_type).to_px(avail_cross, 0.0); @@ -456,8 +447,8 @@ where } // Phase 2: Assign children to lines. - // A new line begins when adding the next fixed-size child would exceed avail_main. - // Stretch-main children are treated as zero-width for break decisions. + // A new line begins when adding the next child would exceed avail_main. + // Stretch-main children use their min contribution for break decisions. // If avail_main <= 0 (auto-width container), no breaks occur. let mut lines: SmallVec<[std::ops::Range; 8]> = SmallVec::new(); if num_rel > 0 { @@ -471,11 +462,7 @@ where let gap_before = if items_in_line > 0 { item_gap_px } else { 0.0 }; let projected = line_main_used + gap_before + size_contribution; - if avail_main > 0.0 - && items_in_line > 0 - && projected > avail_main - && items[i].stretch_main_factor == 0.0 - { + if avail_main > 0.0 && items_in_line > 0 && projected > avail_main { // Finish current line and start a new one. lines.push(line_start..i); line_start = i; @@ -549,8 +536,7 @@ where if items[i].cross_is_stretch { let child = relative_children[i]; let clamped_cross = lc.clamp(items[i].min_cross, items[i].max_cross); - let size = - layout(child, layout_type, items[i].main, clamped_cross, cache, tree, store, sublayout); + let size = layout(child, layout_type, items[i].main, clamped_cross, cache, tree, store, sublayout); items[i].main = size.main; items[i].cross = size.cross; } @@ -585,16 +571,16 @@ where // Recompute auto main size (for containers with Auto main axis). let main_units = node.main(store, layout_type); let final_main = if main_units.is_auto() || parent_main == 0.0 { - let raw = if !lines.is_empty() { - let first = &lines[0]; - let mut sum = 0.0f32; - for i in first.start..first.end { - sum += items[i].main; - } - sum + (first.len().saturating_sub(1)) as f32 * item_gap_px - } else { - 0.0 - }; + let raw = lines + .iter() + .map(|line| { + let mut sum = 0.0f32; + for i in line.start..line.end { + sum += items[i].main; + } + sum + (line.len().saturating_sub(1)) as f32 * item_gap_px + }) + .fold(0.0f32, f32::max); let raw = raw + padding_main_before + padding_main_after + border_main_before + border_main_after; let min_m = node.min_main(store, layout_type).to_px(0.0, DEFAULT_MIN); let max_m = node.max_main(store, layout_type).to_px(0.0, DEFAULT_MAX); @@ -654,12 +640,12 @@ where // does so that `TopLeft` means the same visual position regardless of // layout_type. let mut alignment = node.alignment(store).unwrap_or_default(); - + // For RTL inline layouts, flip horizontal alignment so TopLeft becomes TopRight. if is_inline_rtl { alignment = flip_alignment_horizontal(alignment); } - + let (mut main_align_frac, mut cross_align_frac) = match alignment { Alignment::TopLeft => (0.0f32, 0.0f32), Alignment::TopCenter => (0.0, 0.5), @@ -692,27 +678,30 @@ where if is_inline_rtl { // RTL positioning: place items in reverse order within each wrapped line. // Alignment is flipped above so TopLeft maps to TopRight semantics. - let mut main_cursor = - padding_main_before + border_main_before + main_align_frac * free_main; + let mut main_cursor = padding_main_before + border_main_before + main_align_frac * free_main; - let mut item_idx = 0usize; - for i in (start..end).rev() { + for (item_idx, i) in (start..end).rev().enumerate() { let item = &items[i]; let child = relative_children[i]; let item_cross_offset = cross_align_frac * (lc - item.cross); - cache.set_rect(child, layout_type, main_cursor, cross_cursor + item_cross_offset, item.main, item.cross); + cache.set_rect( + child, + layout_type, + main_cursor, + cross_cursor + item_cross_offset, + item.main, + item.cross, + ); main_cursor += item.main; if item_idx + 1 < count { main_cursor += item_gap_px; } - item_idx += 1; } } else { // LTR positioning: items are positioned left-to-right within the line - let mut main_cursor = - padding_main_before + border_main_before + main_align_frac * free_main; + let mut main_cursor = padding_main_before + border_main_before + main_align_frac * free_main; for i in start..end { let item = &items[i]; @@ -720,7 +709,14 @@ where let item_cross_offset = cross_align_frac * (lc - item.cross); - cache.set_rect(child, layout_type, main_cursor, cross_cursor + item_cross_offset, item.main, item.cross); + cache.set_rect( + child, + layout_type, + main_cursor, + cross_cursor + item_cross_offset, + item.main, + item.cross, + ); main_cursor += item.main; if i + 1 < end { @@ -751,7 +747,11 @@ where (_, Pixels(val)) => pma - val - abs_child.main, (_, Percentage(val)) => pma - abs_child.main - val * 0.01 * pma, (Stretch(b), Stretch(a)) => { - if b == a { (pma - abs_child.main) * 0.5 } else { (pma - abs_child.main) * (b / (b + a)) } + if b == a { + (pma - abs_child.main) * 0.5 + } else { + (pma - abs_child.main) * (b / (b + a)) + } } (Stretch(_), Auto) => pma - abs_child.main, (Auto, Stretch(_)) => 0.0, @@ -764,7 +764,11 @@ where (_, Pixels(val)) => pca - val - abs_child.cross, (_, Percentage(val)) => pca - abs_child.cross - val * 0.01 * pca, (Stretch(b), Stretch(a)) => { - if b == a { (pca - abs_child.cross) * 0.5 } else { (pca - abs_child.cross) * (b / (b + a)) } + if b == a { + (pca - abs_child.cross) * 0.5 + } else { + (pca - abs_child.cross) * (b / (b + a)) + } } (Stretch(_), Auto) => pca - abs_child.cross, (Auto, Stretch(_)) => 0.0, @@ -963,7 +967,8 @@ where parent_main = parent_main - padding_main_before - padding_main_after - border_main_before - border_main_after; parent_cross = parent_cross - padding_cross_before - padding_cross_after - border_cross_before - border_cross_after; - let is_row_rtl = layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft; + let is_row_rtl = + layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft; if is_row_rtl { relative_children.reverse(); @@ -1117,7 +1122,8 @@ where || !same_f32(child.last_layout_main, parent_main) || !same_f32(child.last_layout_cross, parent_cross) { - let child_size = layout(child.node, layout_type, parent_main, parent_cross, cache, tree, store, sublayout); + let child_size = + layout(child.node, layout_type, parent_main, parent_cross, cache, tree, store, sublayout); child.main = child_size.main; child.cross = child_size.cross; child.last_layout_main = parent_main; @@ -1203,26 +1209,15 @@ where let child = &mut children[item.index]; if item.item_type == ItemType::Size { - let target_cross = if child.node.cross(store, layout_type).is_stretch() { - child.cross - } else { - parent_cross - }; + let target_cross = + if child.node.cross(store, layout_type).is_stretch() { child.cross } else { parent_cross }; if !child.has_layout_constraints || !same_f32(child.last_layout_main, actual_main) || !same_f32(child.last_layout_cross, target_cross) { - let child_size = layout( - child.node, - layout_type, - actual_main, - target_cross, - cache, - tree, - store, - sublayout, - ); + let child_size = + layout(child.node, layout_type, actual_main, target_cross, cache, tree, store, sublayout); child.cross = child_size.cross; actual_main = child_size.main; child.last_layout_main = actual_main; @@ -1418,8 +1413,8 @@ where // Compute space and size of non-flexible absolute children. for child in absolute_children.into_iter() { let main = if child.main(store, layout_type).is_stretch() { - let child_min_main = child.min_main(store, layout_type).to_px(abs_size_cross, DEFAULT_MIN); - let child_max_main = child.max_main(store, layout_type).to_px(abs_size_cross, DEFAULT_MAX); + let child_min_main = child.min_main(store, layout_type).to_px(abs_size_main, DEFAULT_MIN); + let child_max_main = child.max_main(store, layout_type).to_px(abs_size_main, DEFAULT_MAX); let child_main_before = child.main_before(store, layout_type).to_px(abs_size_main, 0.0); let child_main_after = child.main_after(store, layout_type).to_px(abs_size_main, 0.0); diff --git a/src/node.rs b/src/node.rs index 69c4d3fe..34b583d0 100644 --- a/src/node.rs +++ b/src/node.rs @@ -46,6 +46,8 @@ pub trait Node: Sized { store: &Self::Store, sublayout: &mut Self::SubLayout<'_>, ) -> Size { + cache.begin_layout_pass(); + let width = self.width(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); let height = self.height(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); @@ -54,8 +56,8 @@ pub trait Node: Sized { // Use the node's layout type instead of hardcoding Column let layout_type = self.layout_type(store).unwrap_or_default(); let (parent_main, parent_cross) = match layout_type { - LayoutType::Row | LayoutType::Grid => (width, height), // Row: main=width, cross=height - LayoutType::Column => (height, width), // Column: main=height, cross=width + LayoutType::Row | LayoutType::Grid => (width, height), // Row: main=width, cross=height + LayoutType::Column => (height, width), // Column: main=height, cross=width }; layout(self, layout_type, parent_main, parent_cross, cache, tree, store, sublayout) @@ -76,7 +78,7 @@ pub trait Node: Sized { /// Returns the position type of the node. fn position_type(&self, store: &Self::Store) -> Option; - /// Returns the inline direction of row content. + /// Returns the inline direction used for horizontal positioning semantics. fn direction(&self, _store: &Self::Store) -> Option { None } @@ -271,7 +273,8 @@ pub(crate) trait NodeExt: Node { } fn padding_main_before(&self, store: &Self::Store, parent_layout_type: LayoutType) -> Units { - if parent_layout_type == LayoutType::Row && self.direction(store).unwrap_or_default() == Direction::RightToLeft { + if parent_layout_type == LayoutType::Row && self.direction(store).unwrap_or_default() == Direction::RightToLeft + { self.padding_right(store).unwrap_or_default() } else { parent_layout_type.select_unwrap(store, |store| self.padding_left(store), |store| self.padding_top(store)) @@ -279,10 +282,15 @@ pub(crate) trait NodeExt: Node { } fn padding_main_after(&self, store: &Self::Store, parent_layout_type: LayoutType) -> Units { - if parent_layout_type == LayoutType::Row && self.direction(store).unwrap_or_default() == Direction::RightToLeft { + if parent_layout_type == LayoutType::Row && self.direction(store).unwrap_or_default() == Direction::RightToLeft + { self.padding_left(store).unwrap_or_default() } else { - parent_layout_type.select_unwrap(store, |store| self.padding_right(store), |store| self.padding_bottom(store)) + parent_layout_type.select_unwrap( + store, + |store| self.padding_right(store), + |store| self.padding_bottom(store), + ) } } diff --git a/src/types.rs b/src/types.rs index d6553bf4..dc2a5e26 100644 --- a/src/types.rs +++ b/src/types.rs @@ -81,13 +81,13 @@ impl std::fmt::Display for PositionType { } } -/// The inline direction of row content. +/// The inline direction used for horizontal positioning semantics. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { - /// Lay out row children from left to right. + /// Lay out inline horizontal semantics from left to right. #[default] LeftToRight, - /// Lay out row children from right to left. + /// Lay out inline horizontal semantics from right to left. RightToLeft, } diff --git a/tests/wrap.rs b/tests/wrap.rs index ea33ed7e..2285d852 100644 --- a/tests/wrap.rs +++ b/tests/wrap.rs @@ -268,9 +268,8 @@ fn wrap_auto_container() { assert_eq!(world.cache.bounds(node3), Some(&Rect { posx: 0.0, posy: 50.0, width: 100.0, height: 50.0 })); } -// TODO: wrap_with_different_line_heights test - currently fails because the line-wrapping -// logic in layout_wrap doesn't properly wrap items when they exceed available space. -// This is a known issue with the Phase 2 line assignment algorithm that needs fixing. +// Regression test for wrapping when items on different lines have different heights. +// Verifies line assignment and vertical positioning use each line's maximum height. #[test] fn wrap_with_different_line_heights() { @@ -352,7 +351,7 @@ fn wrap_row_with_padding() { #[test] fn wrap_row_rtl() { - // Test row wrapping with right-to-left direction + // Test row wrapping with right-to-left direction let mut world = World::default(); let root = world.add(None); @@ -429,4 +428,3 @@ fn wrap_row_auto_height_includes_lines_gap_and_padding() { assert_eq!(world.cache.bounds(b), Some(&Rect { posx: 100.0, posy: 5.0, width: 100.0, height: 50.0 })); assert_eq!(world.cache.bounds(c), Some(&Rect { posx: 0.0, posy: 65.0, width: 100.0, height: 50.0 })); } - From 531ed7ae428fcfcc32ff2a89c3f74b98dc224482 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Mon, 20 Apr 2026 16:28:49 +0100 Subject: [PATCH 16/19] Remove layout-result memoization hooks and relayout caching - Remove get_layout_result() and set_layout_result() from Cache trait - Remove memoization logic from layout computation - Remove LayoutMemo struct and memoization fields from NodeCache - Remove enable_layout_memoization() and related methods - Remove bump_layout_revision() calls from World API - Simplify benchmark to remove cross-pass memo comparison - All tests passing --- benches/stack.rs | 20 ++------- ecs/src/implementations.rs | 90 -------------------------------------- ecs/src/world.rs | 45 ------------------- src/cache.rs | 34 +------------- src/layout.rs | 17 ++----- src/node.rs | 2 - 6 files changed, 8 insertions(+), 200 deletions(-) diff --git a/benches/stack.rs b/benches/stack.rs index dd159cf0..58c33df1 100644 --- a/benches/stack.rs +++ b/benches/stack.rs @@ -130,29 +130,17 @@ fn morphorm_benchmarks(c: &mut Criterion) { let children_per_node = 10; let depth = 6usize; - let mut world_baseline = World::default(); - let root_baseline = build_tree(&mut world_baseline, None, children_per_node, depth); - world_baseline.cache.enable_cross_pass_memoization(false); - - let mut world_cached = World::default(); - let root_cached = build_tree(&mut world_cached, None, children_per_node, depth); - world_cached.cache.enable_cross_pass_memoization(true); - world_cached.cache.set_layout_revision(1); + let mut world = World::default(); + let root = build_tree(&mut world, None, children_per_node, depth); let benchmark_label = format!( "Steady state bench. {children_per_node} children per node, depth: {depth}. Total nodes: {}.", compute_node_count(children_per_node, depth, &mut 0) ); - group.bench_function(format!("{} [baseline]", benchmark_label), |b| { - b.iter(|| { - root_baseline.layout(&mut world_baseline.cache, &world_baseline.tree, &world_baseline.store, &mut ()); - }) - }); - - group.bench_function(format!("{} [cross-pass memo]", benchmark_label), |b| { + group.bench_function(benchmark_label, |b| { b.iter(|| { - root_cached.layout(&mut world_cached.cache, &world_cached.tree, &world_cached.store, &mut ()); + root.layout(&mut world.cache, &world.tree, &world.store, &mut ()); }) }); diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index 33ec1f53..b0059a3a 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -197,58 +197,19 @@ pub struct Rect { pub struct NodeCache { // Computed size and position of nodes. pub rect: SecondaryMap, - // Memoized layout sizes for the current invalidation generation. - layout_memo: SecondaryMap, - layout_generation: u64, - memoization_enabled: bool, -} - -#[derive(Default, Debug, Clone, Copy)] -struct LayoutMemo { - parent_layout_type: LayoutType, - parent_main: f32, - parent_cross: f32, - size: Size, - generation: u64, - valid: bool, -} - -#[inline] -fn same_f32(a: f32, b: f32) -> bool { - a.to_bits() == b.to_bits() } impl NodeCache { - pub fn enable_layout_memoization(&mut self, enabled: bool) { - self.memoization_enabled = enabled; - } - - pub fn enable_cross_pass_memoization(&mut self, enabled: bool) { - self.enable_layout_memoization(enabled); - } - - pub fn set_layout_revision(&mut self, revision: u64) { - self.layout_generation = revision; - } - - pub fn bump_layout_revision(&mut self) { - self.layout_generation = self.layout_generation.wrapping_add(1); - } - pub fn add(&mut self, entity: Entity) { self.rect.insert(entity, Default::default()); - self.layout_memo.insert(entity, Default::default()); } pub fn remove(&mut self, entity: Entity) { self.rect.remove(entity); - self.layout_memo.remove(entity); } pub fn clear(&mut self) { self.rect.clear(); - self.layout_memo.clear(); - self.layout_generation = 0; } pub fn bounds(&self, entity: Entity) -> Option<&Rect> { @@ -259,54 +220,6 @@ impl NodeCache { impl Cache for NodeCache { type Node = Entity; - fn get_layout_result( - &self, - node: &Self::Node, - parent_layout_type: LayoutType, - parent_main: f32, - parent_cross: f32, - ) -> Option { - if !self.memoization_enabled { - return None; - } - - let memo = self.layout_memo.get(*node)?; - if !memo.valid || memo.generation != self.layout_generation { - return None; - } - - if memo.parent_layout_type == parent_layout_type - && same_f32(memo.parent_main, parent_main) - && same_f32(memo.parent_cross, parent_cross) - { - Some(memo.size) - } else { - None - } - } - - fn set_layout_result( - &mut self, - node: &Self::Node, - parent_layout_type: LayoutType, - parent_main: f32, - parent_cross: f32, - size: Size, - ) { - if !self.memoization_enabled { - return; - } - - if let Some(memo) = self.layout_memo.get_mut(*node) { - memo.parent_layout_type = parent_layout_type; - memo.parent_main = parent_main; - memo.parent_cross = parent_cross; - memo.size = size; - memo.generation = self.layout_generation; - memo.valid = true; - } - } - fn set_bounds(&mut self, node: &Self::Node, posx: f32, posy: f32, width: f32, height: f32) { if let Some(rect) = self.rect.get_mut(*node) { rect.posx = posx; @@ -353,9 +266,6 @@ impl Default for NodeCache { fn default() -> Self { Self { rect: SecondaryMap::new(), - layout_memo: SecondaryMap::new(), - layout_generation: 0, - memoization_enabled: false, } } } diff --git a/ecs/src/world.rs b/ecs/src/world.rs index 0fbb6944..b5878d29 100644 --- a/ecs/src/world.rs +++ b/ecs/src/world.rs @@ -28,9 +28,6 @@ pub struct World { } impl World { - fn bump_layout_revision(&mut self) { - self.cache.bump_layout_revision(); - } /// Add a node to the world with a specified parent node. pub fn add(&mut self, parent: Option) -> Entity { @@ -45,7 +42,6 @@ impl World { self.store.red.insert(entity, random_red); self.store.green.insert(entity, random_green); self.store.blue.insert(entity, random_blue); - self.bump_layout_revision(); entity } @@ -54,7 +50,6 @@ impl World { self.store.remove(entity); self.cache.remove(entity); self.tree.remove(&entity); - self.bump_layout_revision(); } pub fn clear(&mut self) { @@ -62,95 +57,79 @@ impl World { self.store.clear(); self.cache.clear(); self.tree.clear(); - self.bump_layout_revision(); } /// Set the desired layout type of the given entity. pub fn set_layout_type(&mut self, entity: Entity, value: LayoutType) { self.store.layout_type.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired position type of the given entity. pub fn set_position_type(&mut self, entity: Entity, value: PositionType) { self.store.position_type.insert(entity, value); - self.bump_layout_revision(); } /// Set the inline direction used for horizontal positioning semantics for the given entity. pub fn set_direction(&mut self, entity: Entity, value: Direction) { self.store.direction.insert(entity, value); - self.bump_layout_revision(); } /// Set the wrap mode for children of the given entity. pub fn set_wrap(&mut self, entity: Entity, value: LayoutWrap) { self.store.wrap.insert(entity, value); - self.bump_layout_revision(); } pub fn set_alignment(&mut self, entity: Entity, value: Alignment) { self.store.alignment.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired width of the given entity. pub fn set_width(&mut self, entity: Entity, value: Units) { self.store.width.insert(entity, value); - self.bump_layout_revision(); } /// Set the minimum width of the given entity. pub fn set_min_width(&mut self, entity: Entity, value: Units) { self.store.min_width.insert(entity, value); - self.bump_layout_revision(); } /// Set the maximum width of the given entity. pub fn set_max_width(&mut self, entity: Entity, value: Units) { self.store.max_width.insert(entity, value); - self.bump_layout_revision(); } /// Set the minimum height of the given entity. pub fn set_min_height(&mut self, entity: Entity, value: Units) { self.store.min_height.insert(entity, value); - self.bump_layout_revision(); } /// Set the maximum height of the given entity. pub fn set_max_height(&mut self, entity: Entity, value: Units) { self.store.max_height.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired height of the given entity. pub fn set_height(&mut self, entity: Entity, value: Units) { self.store.height.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired left space of the given entity. pub fn set_left(&mut self, entity: Entity, value: Units) { self.store.left.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired right space of the given entity. pub fn set_right(&mut self, entity: Entity, value: Units) { self.store.right.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired top space of the given entity. pub fn set_top(&mut self, entity: Entity, value: Units) { self.store.top.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired bottom space of the given entity. pub fn set_bottom(&mut self, entity: Entity, value: Units) { self.store.bottom.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired padding of the given entity. @@ -159,111 +138,92 @@ impl World { self.store.padding_right.insert(entity, value); self.store.padding_top.insert(entity, value); self.store.padding_bottom.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired padding_left space of the given entity. pub fn set_padding_left(&mut self, entity: Entity, value: Units) { self.store.padding_left.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired padding_right space of the given entity. pub fn set_padding_right(&mut self, entity: Entity, value: Units) { self.store.padding_right.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired padding_top space of the given entity. pub fn set_padding_top(&mut self, entity: Entity, value: Units) { self.store.padding_top.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired padding_bottom space of the given entity. pub fn set_padding_bottom(&mut self, entity: Entity, value: Units) { self.store.padding_bottom.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired vertical (row) space between children of the given entity. pub fn set_vertical_gap(&mut self, entity: Entity, value: Units) { self.store.vertical_gap.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired horizontal (column) space between children of the given entity. pub fn set_horizontal_gap(&mut self, entity: Entity, value: Units) { self.store.horizontal_gap.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired minimum vertical (row) space between children of the given entity. pub fn set_min_vertical_gap(&mut self, entity: Entity, value: Units) { self.store.min_vertical_gap.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired minimum horizontal (column) space between children of the given entity. pub fn set_min_horizontal_gap(&mut self, entity: Entity, value: Units) { self.store.min_horizontal_gap.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired maximum vertical (row) space between children of the given entity. pub fn set_max_vertical_gap(&mut self, entity: Entity, value: Units) { self.store.max_vertical_gap.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired maximum horizontal (column) space between children of the given entity. pub fn set_max_horizontal_gap(&mut self, entity: Entity, value: Units) { self.store.max_horizontal_gap.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired vertical scroll offset. pub fn set_vertical_scroll(&mut self, entity: Entity, value: f32) { self.store.vertical_scroll.insert(entity, value); - self.bump_layout_revision(); } /// Set the desired horizontal scroll offset. pub fn set_horizontal_scroll(&mut self, entity: Entity, value: f32) { self.store.horizontal_scroll.insert(entity, value); - self.bump_layout_revision(); } pub fn set_grid_columns(&mut self, entity: Entity, value: Vec) { self.store.grid_columns.insert(entity, value); - self.bump_layout_revision(); } pub fn set_grid_rows(&mut self, entity: Entity, value: Vec) { self.store.grid_rows.insert(entity, value); - self.bump_layout_revision(); } pub fn set_column_start(&mut self, entity: Entity, value: usize) { self.store.column_start.insert(entity, value); - self.bump_layout_revision(); } pub fn set_row_start(&mut self, entity: Entity, value: usize) { self.store.row_start.insert(entity, value); - self.bump_layout_revision(); } pub fn set_column_span(&mut self, entity: Entity, value: usize) { self.store.column_span.insert(entity, value); - self.bump_layout_revision(); } pub fn set_row_span(&mut self, entity: Entity, value: usize) { self.store.row_span.insert(entity, value); - self.bump_layout_revision(); } /// Set the content size function for the given entity. @@ -273,24 +233,20 @@ impl World { content: impl Fn(&Store, Option, Option) -> (f32, f32) + 'static, ) { self.store.content_size.insert(entity, Box::new(content)); - self.bump_layout_revision(); } pub fn set_visibility(&mut self, entity: Entity, visible: bool) { self.store.visible.insert(entity, visible); - self.bump_layout_revision(); } /// Set the text to be displayed on the given entity. pub fn set_text(&mut self, entity: Entity, text: &str) { self.store.text.insert(entity, String::from(text)); - self.bump_layout_revision(); } /// Set whether the text should wrap for the given entity. pub fn set_text_wrap(&mut self, entity: Entity, text_wrap: TextWrap) { self.store.text_wrap.insert(entity, text_wrap); - self.bump_layout_revision(); } /// Set all space and size properties of the given node to stretch. @@ -308,6 +264,5 @@ impl World { self.store.border_right.insert(entity, width); self.store.border_top.insert(entity, width); self.store.border_bottom.insert(entity, width); - self.bump_layout_revision(); } } diff --git a/src/cache.rs b/src/cache.rs index d3862016..e942e3eb 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,4 +1,4 @@ -use crate::{LayoutType, Node, Size}; +use crate::{LayoutType, Node}; /// The `Cache` is a store which contains the computed size and position of nodes /// after a layout calculation. @@ -20,38 +20,6 @@ pub trait Cache { /// Sets the cached position and size of the given node. fn set_bounds(&mut self, node: &Self::Node, posx: f32, posy: f32, width: f32, height: f32); - - /// Starts a new layout pass. - /// - /// Caches can use this to invalidate pass-scoped memoized results. - /// Default implementation is a no-op for backward compatibility. - fn begin_layout_pass(&mut self) {} - - /// Returns a memoized layout size for a node under the given parent constraints. - /// - /// Default implementation disables memoization. - fn get_layout_result( - &self, - _node: &Self::Node, - _parent_layout_type: LayoutType, - _parent_main: f32, - _parent_cross: f32, - ) -> Option { - None - } - - /// Stores a memoized layout size for a node under the given parent constraints. - /// - /// Default implementation is a no-op. - fn set_layout_result( - &mut self, - _node: &Self::Node, - _parent_layout_type: LayoutType, - _parent_main: f32, - _parent_cross: f32, - _size: Size, - ) { - } } /// Helper trait for getting/setting node position/size in a direction agnostic way. diff --git a/src/layout.rs b/src/layout.rs index 50d53cf8..fe77674e 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -828,12 +828,7 @@ where N: Node, C: Cache, { - let requested_main = parent_main; - let requested_cross = parent_cross; - if let Some(cached) = cache.get_layout_result(node, parent_layout_type, requested_main, requested_cross) { - return cached; - } // The layout type of the node. Determines the main and cross axes of the children. let layout_type = node.layout_type(store).unwrap_or_default(); @@ -926,15 +921,11 @@ where computed_cross = computed_cross.max(min_cross).min(max_cross); if layout_type == LayoutType::Grid { - let size = layout_grid(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); - cache.set_layout_result(node, parent_layout_type, requested_main, requested_cross, size); - return size; + return layout_grid(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); } if node.wrap(store).unwrap_or_default() == LayoutWrap::Wrap { - let size = layout_wrap(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); - cache.set_layout_result(node, parent_layout_type, requested_main, requested_cross, size); - return size; + return layout_wrap(node, parent_layout_type, computed_main, computed_cross, cache, tree, store, sublayout); } // Determine the parent_main/cross size to pass to the children based on the layout type of the parent and the node. @@ -1561,7 +1552,5 @@ where } // Return the computed size, propagating it back up the tree. - let size = Size { main: computed_main, cross: computed_cross }; - cache.set_layout_result(node, parent_layout_type, requested_main, requested_cross, size); - size + Size { main: computed_main, cross: computed_cross } } diff --git a/src/node.rs b/src/node.rs index 34b583d0..4be70a06 100644 --- a/src/node.rs +++ b/src/node.rs @@ -46,8 +46,6 @@ pub trait Node: Sized { store: &Self::Store, sublayout: &mut Self::SubLayout<'_>, ) -> Size { - cache.begin_layout_pass(); - let width = self.width(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); let height = self.height(store).unwrap_or(Units::Pixels(0.0)).to_px(0.0, 0.0); From 55d274980a4e0c056325df2be207e839af6c6a89 Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Mon, 20 Apr 2026 16:37:03 +0100 Subject: [PATCH 17/19] fmt --- ecs/src/implementations.rs | 4 +--- ecs/src/world.rs | 11 ++++------- src/layout.rs | 2 -- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/ecs/src/implementations.rs b/ecs/src/implementations.rs index b0059a3a..2691d37e 100644 --- a/ecs/src/implementations.rs +++ b/ecs/src/implementations.rs @@ -264,8 +264,6 @@ impl Cache for NodeCache { impl Default for NodeCache { fn default() -> Self { - Self { - rect: SecondaryMap::new(), - } + Self { rect: SecondaryMap::new() } } } diff --git a/ecs/src/world.rs b/ecs/src/world.rs index b5878d29..c0ab609c 100644 --- a/ecs/src/world.rs +++ b/ecs/src/world.rs @@ -28,7 +28,6 @@ pub struct World { } impl World { - /// Add a node to the world with a specified parent node. pub fn add(&mut self, parent: Option) -> Entity { let entity = self.entity_manager.create(); @@ -194,18 +193,16 @@ impl World { pub fn set_vertical_scroll(&mut self, entity: Entity, value: f32) { self.store.vertical_scroll.insert(entity, value); } - + /// Set the desired horizontal scroll offset. pub fn set_horizontal_scroll(&mut self, entity: Entity, value: f32) { self.store.horizontal_scroll.insert(entity, value); } - pub fn set_grid_columns(&mut self, entity: Entity, value: Vec) { self.store.grid_columns.insert(entity, value); } - - + pub fn set_grid_rows(&mut self, entity: Entity, value: Vec) { self.store.grid_rows.insert(entity, value); } @@ -213,7 +210,7 @@ impl World { pub fn set_column_start(&mut self, entity: Entity, value: usize) { self.store.column_start.insert(entity, value); } - + pub fn set_row_start(&mut self, entity: Entity, value: usize) { self.store.row_start.insert(entity, value); } @@ -221,7 +218,7 @@ impl World { pub fn set_column_span(&mut self, entity: Entity, value: usize) { self.store.column_span.insert(entity, value); } - + pub fn set_row_span(&mut self, entity: Entity, value: usize) { self.store.row_span.insert(entity, value); } diff --git a/src/layout.rs b/src/layout.rs index fe77674e..71f82e2a 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -828,8 +828,6 @@ where N: Node, C: Cache, { - - // The layout type of the node. Determines the main and cross axes of the children. let layout_type = node.layout_type(store).unwrap_or_default(); From a42258e032c6a736900716852e6cd02f1a65e3dc Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Mon, 20 Apr 2026 17:51:56 +0100 Subject: [PATCH 18/19] Use padding-box for absolute children; fix RTL Apply horizontal alignment flip whenever a node is RTL (remove parent-type guard), and restrict RTL wrap reversal to row layouts. Change absolute-child sizing to use the padding box (content + padding, excluding border) rather than ignoring parent padding. Refactor flex item sizing: separate the rounded allocated size (input/computed) from the actual measured main size, store measured values on items, and use the input allocation for cache comparisons. These changes fix RTL alignment edge-cases, ensure absolute children are sized relative to the padding box, and stabilize flex layout/caching against rounding-induced relayouts. --- src/layout.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 71f82e2a..3fa512e9 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -271,9 +271,7 @@ where let mut alignment = node.alignment(store).unwrap_or_default(); - if matches!(parent_layout_type, LayoutType::Row | LayoutType::Column) - && node.direction(store).unwrap_or_default() == Direction::RightToLeft - { + if node.direction(store).unwrap_or_default() == Direction::RightToLeft { alignment = flip_alignment_horizontal(alignment); } @@ -590,7 +588,7 @@ where }; // Phase 7: Lay out absolute children against the container bounds. - // Absolute children are sized without parent padding influence. + // Absolute children are sized against the padding box (content box + padding, excluding border). let abs_avail_main = final_main - border_main_before - border_main_after; let abs_avail_cross = final_cross - border_cross_before - border_cross_after; @@ -675,7 +673,7 @@ where } let free_main = (avail_main - line_main_sum - gap_total).max(0.0); - if is_inline_rtl { + if layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft { // RTL positioning: place items in reverse order within each wrapped line. // Alignment is flipped above so TopLeft maps to TopRight semantics. let mut main_cursor = padding_main_before + border_main_before + main_align_frac * free_main; @@ -1193,7 +1191,8 @@ where let mut total_violation = 0.0; for item in main_axis.iter_mut().filter(|item| !item.frozen) { - let mut actual_main = (item.factor * free_main_space / main_flex_sum).round(); + let input_main = (item.factor * free_main_space / main_flex_sum).round(); + let mut actual_main = input_main; let child = &mut children[item.index]; @@ -1202,18 +1201,19 @@ where if child.node.cross(store, layout_type).is_stretch() { child.cross } else { parent_cross }; if !child.has_layout_constraints - || !same_f32(child.last_layout_main, actual_main) + || !same_f32(child.last_layout_main, input_main) || !same_f32(child.last_layout_cross, target_cross) { let child_size = - layout(child.node, layout_type, actual_main, target_cross, cache, tree, store, sublayout); + layout(child.node, layout_type, input_main, target_cross, cache, tree, store, sublayout); child.cross = child_size.cross; actual_main = child_size.main; - child.last_layout_main = actual_main; + item.measured = actual_main; + child.last_layout_main = input_main; child.last_layout_cross = target_cross; child.has_layout_constraints = true; } else { - actual_main = child.main; + actual_main = item.measured; } if child.node.min_main(store, layout_type).is_auto() { @@ -1267,6 +1267,7 @@ where ); child.cross = child_size.cross; + item.measured = child_size.main; child.last_layout_main = item.computed; child.last_layout_cross = target_cross; child.has_layout_constraints = true; @@ -1395,7 +1396,7 @@ where // Absolute Children - // Absolute children are sized without parent padding influence. + // Absolute children are sized against the padding box (content box + padding, excluding border). let abs_size_main = parent_main + padding_main_before + padding_main_after; let abs_size_cross = parent_cross + padding_cross_before + padding_cross_after; From bab98473d928fa80133fa82febe810843bda155e Mon Sep 17 00:00:00 2001 From: George Atkinson Date: Wed, 22 Apr 2026 19:57:46 +0100 Subject: [PATCH 19/19] Swap absolute child space for RTL layouts Fix positioning for right-to-left layouts by swapping main_before/main_after when layout direction is RTL. Introduces an is_rtl flag and uses it to invert main-axis offsets for absolute and flow children (and preserves cross-axis lookups). This ensures correct child placement in RTL row/column layouts rather than relying only on reversing child order. --- src/layout.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 3fa512e9..c89f813c 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -731,8 +731,11 @@ where // Position absolute children. for abs_child in &abs_items { - let child_main_before = abs_child.node.main_before(store, layout_type); - let child_main_after = abs_child.node.main_after(store, layout_type); + let (child_main_before, child_main_after) = if is_inline_rtl { + (abs_child.node.main_after(store, layout_type), abs_child.node.main_before(store, layout_type)) + } else { + (abs_child.node.main_before(store, layout_type), abs_child.node.main_after(store, layout_type)) + }; let child_cross_before = abs_child.node.cross_before(store, layout_type); let child_cross_after = abs_child.node.cross_after(store, layout_type); @@ -957,6 +960,9 @@ where let is_row_rtl = layout_type == LayoutType::Row && node.direction(store).unwrap_or_default() == Direction::RightToLeft; + let is_rtl = matches!(layout_type, LayoutType::Row | LayoutType::Column) + && node.direction(store).unwrap_or_default() == Direction::RightToLeft; + if is_row_rtl { relative_children.reverse(); } @@ -1457,8 +1463,11 @@ where match child_position { PositionType::Absolute => { - let child_main_before = child.node.main_before(store, layout_type); - let child_main_after = child.node.main_after(store, layout_type); + let (child_main_before, child_main_after) = if is_rtl { + (child.node.main_after(store, layout_type), child.node.main_before(store, layout_type)) + } else { + (child.node.main_before(store, layout_type), child.node.main_after(store, layout_type)) + }; let child_cross_before = child.node.cross_before(store, layout_type); let child_cross_after = child.node.cross_after(store, layout_type);