Min size layout constraints

This commit is contained in:
2026-03-23 19:43:14 -04:00
parent e327516e5e
commit 5f67c06083
6 changed files with 365 additions and 45 deletions

View File

@@ -27,6 +27,11 @@ pub fn layout_scene_with_text_system(
pub struct LayoutSnapshot {
pub scene: SceneSnapshot,
pub interaction_tree: InteractionTree,
/// Minimum size the root element needs in logical units. Accounts for
/// `min_width`/`min_height` constraints and the natural minimum of fixed-
/// size content. Scroll boxes contribute only their own explicit/min size
/// on the scroll axis, not their content.
pub root_min_size: UiSize,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
@@ -242,11 +247,13 @@ pub fn layout_snapshot_with_cache(
"layout snapshot perf"
);
}
let root_min_size = element_min_size(root);
LayoutSnapshot {
scene,
interaction_tree: InteractionTree {
root: interaction_root,
},
root_min_size,
}
}
@@ -817,17 +824,26 @@ fn layout_container_children(
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let cross_inset = cross_axis_padding(child_insets, element.style.direction);
// style.width/height are content-box; add insets to get the outer (allocated) size.
let cross = child_cross_size(child, element.style.direction)
// min-size constraints (content-box) converted to outer.
let min_cross_outer = child_min_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0))
.unwrap_or(available_cross)
.clamp(0.0, available_cross);
.unwrap_or(0.0);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
// style.width/height are content-box; add insets to get the outer (allocated) size.
// min-cross acts as floor; allow exceeding available_cross only when min demands it.
let cross = child_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0).max(min_cross_outer))
.unwrap_or(available_cross.max(min_cross_outer))
.min(available_cross.max(min_cross_outer));
let explicit_main = child_main_size(child, element.style.direction)
.map(|main| (main + main_inset).max(0.0));
.map(|main| (main + main_inset).max(0.0).max(min_main_outer));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
0.0
// Flex items get their share of remaining space later; record min floor now.
min_main_outer
} else {
perf_stats.intrinsic_calls += 1;
if child.text_node().is_some() {
@@ -848,7 +864,8 @@ fn layout_container_children(
if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
}
intrinsic
// Apply min-main floor to intrinsic result.
intrinsic.max(min_main_outer)
}
});
if is_flex {
@@ -868,11 +885,18 @@ fn layout_container_children(
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
if flex_total <= 0.0 {
let allocated = if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
};
// Apply min-main floor to flex allocation.
let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction);
let min_main_outer = child_min_main_size(child, element.style.direction)
.map(|m| (m + main_inset).max(0.0))
.unwrap_or(0.0);
allocated.max(min_main_outer)
} else {
measured.main
};
@@ -1091,6 +1115,17 @@ fn intrinsic_size_inner(
// available_size is the outer size the parent is offering.
// All returned sizes are outer (content + insets).
let min_outer_w = element
.style
.min_width
.map(|mw| mw + horizontal_insets(insets))
.unwrap_or(0.0);
let min_outer_h = element
.style
.min_height
.map(|mh| mh + vertical_insets(insets))
.unwrap_or(0.0);
if let Some(text) = element.text_node() {
// Measure at content dimensions: use explicit if set, else available minus insets.
let content_w = element
@@ -1109,16 +1144,18 @@ fn intrinsic_size_inner(
);
// Return outer: explicit (content) + insets, or measured + insets for auto.
return UiSize::new(
element.style.width.unwrap_or(measured.width) + horizontal_insets(insets),
element.style.height.unwrap_or(measured.height) + vertical_insets(insets),
(element.style.width.unwrap_or(measured.width) + horizontal_insets(insets))
.max(min_outer_w),
(element.style.height.unwrap_or(measured.height) + vertical_insets(insets))
.max(min_outer_h),
);
}
if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new(
resolved.width + horizontal_insets(insets),
resolved.height + vertical_insets(insets),
(resolved.width + horizontal_insets(insets)).max(min_outer_w),
(resolved.height + vertical_insets(insets)).max(min_outer_h),
);
}
@@ -1141,12 +1178,14 @@ fn intrinsic_size_inner(
if element.children.is_empty() {
return UiSize::new(
explicit_width
(explicit_width
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
.unwrap_or(horizontal_insets(insets) + scroll_gutter),
explicit_height
.unwrap_or(horizontal_insets(insets) + scroll_gutter))
.max(min_outer_w),
(explicit_height
.map(|h| h + vertical_insets(insets))
.unwrap_or(vertical_insets(insets)),
.unwrap_or(vertical_insets(insets)))
.max(min_outer_h),
);
}
@@ -1159,12 +1198,159 @@ fn intrinsic_size_inner(
);
UiSize::new(
explicit_width
(explicit_width
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter),
explicit_height
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter))
.max(min_outer_w),
(explicit_height
.map(|h| h + vertical_insets(insets))
.unwrap_or(intrinsic_content.height + vertical_insets(insets)),
.unwrap_or(intrinsic_content.height + vertical_insets(insets)))
.max(min_outer_h),
)
}
/// Computes the minimum logical size that the element's content requires.
///
/// Unlike [`intrinsic_size`], this function is designed for window minimum-size
/// propagation. The key difference is scroll boxes: a vertically-scrolling box
/// does not propagate its content height to the window minimum — only its own
/// explicit `height` or `min_height` contributes. Flex children without an
/// explicit size or `min_*` constraint contribute 0 on the main axis (they
/// can shrink). Text nodes with no explicit size contribute 0 (they wrap).
///
/// The result is in the same coordinate space as [`intrinsic_size`] (outer /
/// including padding and border insets).
pub fn element_min_size(element: &Element) -> UiSize {
let insets = content_insets(&element.style);
let min_outer_w = element
.style
.min_width
.map(|mw| (mw + horizontal_insets(insets)).max(0.0))
.unwrap_or(0.0);
let min_outer_h = element
.style
.min_height
.map(|mh| (mh + vertical_insets(insets)).max(0.0))
.unwrap_or(0.0);
// Text: can wrap to any width; only explicit size or min_* constrain the window.
if element.text_node().is_some() {
let w = element
.style
.width
.map(|w| w + horizontal_insets(insets))
.unwrap_or(0.0)
.max(min_outer_w);
let h = element
.style
.height
.map(|h| h + vertical_insets(insets))
.unwrap_or(0.0)
.max(min_outer_h);
return UiSize::new(w, h);
}
// Image: natural size counts as a minimum.
if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new(
(resolved.width + horizontal_insets(insets)).max(min_outer_w),
(resolved.height + vertical_insets(insets)).max(min_outer_h),
);
}
let explicit_w = element.style.width;
let explicit_h = element.style.height;
// Scroll box: content scrolls on the vertical axis; use only explicit/min height.
// Width still accumulates child widths (no horizontal scroll yet).
if element.scroll_box_node().is_some() {
let scroll_gutter = element
.scroll_box_node()
.map_or(0.0, |sb| sb.scrollbar.gutter_width.max(0.0));
let w = explicit_w
.map(|w| w + horizontal_insets(insets) + scroll_gutter)
.unwrap_or(horizontal_insets(insets) + scroll_gutter)
.max(min_outer_w);
let h = explicit_h
.map(|h| h + vertical_insets(insets))
.unwrap_or(vertical_insets(insets))
.max(min_outer_h);
return UiSize::new(w, h);
}
// Regular container: sum/max children's minimums.
if element.children.is_empty() {
return UiSize::new(
explicit_w
.map(|w| w + horizontal_insets(insets))
.unwrap_or(horizontal_insets(insets))
.max(min_outer_w),
explicit_h
.map(|h| h + vertical_insets(insets))
.unwrap_or(vertical_insets(insets))
.max(min_outer_h),
);
}
let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
let children_min = match element.style.direction {
FlexDirection::Column => {
let mut width: f32 = 0.0;
let mut height = gap_total;
for child in &element.children {
let child_min = element_min_size(child);
// Flex children with no explicit height contribute their min_height only;
// if that is also unset they contribute 0 (they can shrink freely).
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
width = width.max(child_min.width);
if !skip_main {
height += child_min.height;
} else {
// Still count the min_height floor even for flex children.
let child_insets = content_insets(&child.style);
let min_h = child
.style
.min_height
.map(|mh| mh + vertical_insets(child_insets))
.unwrap_or(0.0);
height += min_h;
}
}
UiSize::new(width, height)
}
FlexDirection::Row => {
let mut width = gap_total;
let mut height: f32 = 0.0;
for child in &element.children {
let child_min = element_min_size(child);
let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
if !skip_main {
width += child_min.width;
} else {
let child_insets = content_insets(&child.style);
let min_w = child
.style
.min_width
.map(|mw| mw + horizontal_insets(child_insets))
.unwrap_or(0.0);
width += min_w;
}
height = height.max(child_min.height);
}
UiSize::new(width, height)
}
};
UiSize::new(
explicit_w
.map(|w| w + horizontal_insets(insets))
.unwrap_or(children_min.width + horizontal_insets(insets))
.max(min_outer_w),
explicit_h
.map(|h| h + vertical_insets(insets))
.unwrap_or(children_min.height + vertical_insets(insets))
.max(min_outer_h),
)
}
@@ -1597,6 +1783,20 @@ fn child_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
}
}
fn child_min_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.min_width,
FlexDirection::Column => child.style.min_height,
}
}
fn child_min_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.min_height,
FlexDirection::Column => child.style.min_width,
}
}
fn child_flex_weight(child: &Element) -> f32 {
if child.style.flex_grow > 0.0 {
child.style.flex_grow