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

@@ -795,6 +795,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let LayoutSnapshot { let LayoutSnapshot {
scene, scene,
interaction_tree: next_interaction_tree, interaction_tree: next_interaction_tree,
..
} = build_snapshot( } = build_snapshot(
viewport, viewport,
version, version,
@@ -1077,6 +1078,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let LayoutSnapshot { let LayoutSnapshot {
scene, scene,
interaction_tree: next_interaction_tree, interaction_tree: next_interaction_tree,
..
} = build_snapshot( } = build_snapshot(
viewport, viewport,
version, version,

View File

@@ -1,4 +1,5 @@
use ruin_app::prelude::*; use ruin_app::prelude::*;
use ruin_ui::{BoxShadow, BoxShadowKind, Point};
#[ruin_runtime::async_main] #[ruin_runtime::async_main]
async fn main() -> ruin_app::Result<()> { async fn main() -> ruin_app::Result<()> {
@@ -9,6 +10,8 @@ async fn main() -> ruin_app::Result<()> {
Window::new() Window::new()
.title(title) .title(title)
.app_id("dev.ruin.counter") .app_id("dev.ruin.counter")
.base_color(Color::rgba(255, 255, 255, 80))
.min_size(320.0, 240.0)
.size(960.0, 640.0), .size(960.0, 640.0),
) )
.mount(view! { .mount(view! {
@@ -33,7 +36,7 @@ fn CounterApp(title: &'static str) -> impl IntoView {
view! { view! {
column(gap = 16.0, padding = 24.0) { column(gap = 16.0, padding = 24.0) {
text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold) { text(role = TextRole::Heading(1), size = 32.0, weight = FontWeight::Semibold, color = Color::rgba(0, 0, 0, 255)) {
title title
} }
@@ -42,12 +45,13 @@ fn CounterApp(title: &'static str) -> impl IntoView {
block( block(
padding = 16.0, padding = 16.0,
gap = 8.0, gap = 8.0,
background = surfaces::raised(), background = Color::rgba(255, 255, 255, 200),
border_radius = 12.0, border_radius = 12.0,
border = Border::new(2.0, Color::rgb(0, 0, 0)) border = Border::new(2.0, Color::rgba(0, 0, 0, 120)),
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 60), BoxShadowKind::Outer),
) { ) {
text(size = 18.0) { "count = "; count.clone() } text(size = 18.0, color = Color::rgb(0, 0, 0)) { "count = "; count.clone() }
text(color = colors::muted()) { "double = "; doubled.clone() } text(color = Color::rgba(0, 0, 0, 200)) { "double = "; doubled.clone() }
} }
} }
} }
@@ -56,8 +60,10 @@ fn CounterApp(title: &'static str) -> impl IntoView {
#[component] #[component]
fn CounterActions(count: Signal<i32>) -> impl IntoView { fn CounterActions(count: Signal<i32>) -> impl IntoView {
view! { view! {
row(gap = 8.0) { row(gap = 16.0) {
button( button(
background = Color::rgba(255, 255, 255, 90),
color = Color::rgba(0, 0, 0, 255),
on_press = { on_press = {
let count = count.clone(); let count = count.clone();
move |_| { move |_| {
@@ -69,6 +75,8 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
} }
button( button(
background = Color::rgba(255, 255, 255, 90),
color = Color::rgba(0, 0, 0, 255),
on_press = { on_press = {
let count = count.clone(); let count = count.clone();
move |_| { move |_| {
@@ -80,6 +88,8 @@ fn CounterActions(count: Signal<i32>) -> impl IntoView {
} }
button( button(
background = Color::rgba(255, 255, 255, 90),
color = Color::rgba(0, 0, 0, 255),
on_press = { on_press = {
let count = count.clone(); let count = count.clone();
move |_| { move |_| {

View File

@@ -11,19 +11,19 @@ use std::collections::HashMap;
use std::error::Error; use std::error::Error;
use std::future::Future; use std::future::Future;
use std::iter; use std::iter;
use std::time::Instant;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::rc::Rc; use std::rc::Rc;
use std::time::Instant;
use ruin_reactivity::effect; use ruin_reactivity::effect;
use ruin_runtime::queue_future; use ruin_runtime::queue_future;
use ruin_ui::{ use ruin_ui::{
Border, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget, InteractionTree, Border, BoxShadow, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, HitTarget,
KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot, PlatformEvent, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutCache, LayoutSnapshot,
PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad, RoutedPointerEvent, PlatformEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, Quad,
RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan, TextSpanWeight, TextStyle, RoutedPointerEvent, RoutedPointerEventKind, ScrollbarStyle, TextFontFamily, TextSpan,
TextSystem, TextWrap, UiSize, WindowController, WindowSpec, WindowUpdate, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, WindowController, WindowSpec,
layout_snapshot_with_cache, WindowUpdate, layout_snapshot_with_cache,
}; };
use ruin_ui_platform_wayland::start_wayland_ui; use ruin_ui_platform_wayland::start_wayland_ui;
@@ -59,6 +59,11 @@ impl Window {
self self
} }
pub fn min_size(mut self, min_width: f32, min_height: f32) -> Self {
self.spec = self.spec.min_inner_size(UiSize::new(min_width, min_height));
self
}
pub fn base_color(mut self, color: Color) -> Self { pub fn base_color(mut self, color: Color) -> Self {
self.spec = self.spec.base_color(color); self.spec = self.spec.base_color(color);
self self
@@ -169,6 +174,8 @@ impl<M: Mountable> MountedApp<M> {
let bindings = Rc::new(RefCell::new(EventBindings::default())); let bindings = Rc::new(RefCell::new(EventBindings::default()));
let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new())); let shortcuts = Rc::new(RefCell::new(Vec::<ShortcutBinding>::new()));
let current_title = Rc::new(RefCell::new(None::<String>)); let current_title = Rc::new(RefCell::new(None::<String>));
let last_min_size = Rc::new(RefCell::new(None::<UiSize>));
let explicit_min_size = app_window.spec.min_inner_size;
let mut input_state = InputState::new(); let mut input_state = InputState::new();
let mut pointer_router = PointerRouter::new(); let mut pointer_router = PointerRouter::new();
@@ -181,6 +188,7 @@ impl<M: Mountable> MountedApp<M> {
let bindings = Rc::clone(&bindings); let bindings = Rc::clone(&bindings);
let shortcuts = Rc::clone(&shortcuts); let shortcuts = Rc::clone(&shortcuts);
let current_title = Rc::clone(&current_title); let current_title = Rc::clone(&current_title);
let last_min_size = Rc::clone(&last_min_size);
let root = Rc::clone(&root); let root = Rc::clone(&root);
let render_state = Rc::clone(&render_state); let render_state = Rc::clone(&render_state);
let text_selection = Rc::clone(&input_state.text_selection); let text_selection = Rc::clone(&input_state.text_selection);
@@ -208,6 +216,7 @@ impl<M: Mountable> MountedApp<M> {
let LayoutSnapshot { let LayoutSnapshot {
mut scene, mut scene,
interaction_tree: next_interaction_tree, interaction_tree: next_interaction_tree,
root_min_size,
} = layout_snapshot_with_cache( } = layout_snapshot_with_cache(
version, version,
viewport, viewport,
@@ -216,6 +225,21 @@ impl<M: Mountable> MountedApp<M> {
&mut layout_cache.borrow_mut(), &mut layout_cache.borrow_mut(),
); );
let layout_us = t_layout.elapsed().as_micros(); let layout_us = t_layout.elapsed().as_micros();
// Compute and send min-size hint: max of layout min and explicit window min.
let desired_min = UiSize::new(
root_min_size
.width
.max(explicit_min_size.map_or(0.0, |s| s.width)),
root_min_size
.height
.max(explicit_min_size.map_or(0.0, |s| s.height)),
);
let current_min = *last_min_size.borrow();
if current_min != Some(desired_min) {
let _ = window.update(WindowUpdate::new().min_inner_size(Some(desired_min)));
*last_min_size.borrow_mut() = Some(desired_min);
}
let effect_us = t_effect.elapsed().as_micros(); let effect_us = t_effect.elapsed().as_micros();
tracing::debug!( tracing::debug!(
@@ -716,6 +740,16 @@ impl IntoBorder for (f32, Color) {
} }
} }
pub trait IntoShadow {
fn into_shadow(self) -> BoxShadow;
}
impl IntoShadow for BoxShadow {
fn into_shadow(self) -> BoxShadow {
self
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextRole { pub enum TextRole {
Body, Body,
@@ -862,6 +896,25 @@ impl ContainerBuilder {
self self
} }
pub fn shadow(mut self, shadow: impl IntoShadow) -> Self {
self.element = self.element.shadow(shadow.into_shadow());
self
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.element = self.element.min_width(min_width);
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.element = self.element.min_height(min_height);
self
}
pub fn min_size(self, min_width: f32, min_height: f32) -> Self {
self.min_width(min_width).min_height(min_height)
}
pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self { pub fn widget_ref<T>(mut self, widget_ref: WidgetRef<T>) -> Self {
self.widget_ref = Some(widget_ref.element_id.clone()); self.widget_ref = Some(widget_ref.element_id.clone());
self self
@@ -921,6 +974,20 @@ impl ScrollBoxBuilder {
self self
} }
pub fn min_width(mut self, min_width: f32) -> Self {
self.element = self.element.min_width(min_width);
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.element = self.element.min_height(min_height);
self
}
pub fn min_size(self, min_width: f32, min_height: f32) -> Self {
self.min_width(min_width).min_height(min_height)
}
pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self { pub fn scrollbar_style(mut self, style: ScrollbarStyle) -> Self {
self.element = self.element.scrollbar_style(style); self.element = self.element.scrollbar_style(style);
self self
@@ -1121,6 +1188,8 @@ impl TextBuilder {
#[derive(Default)] #[derive(Default)]
pub struct ButtonBuilder { pub struct ButtonBuilder {
on_press: Option<PressHandler>, on_press: Option<PressHandler>,
background: Option<Color>,
color: Option<Color>,
} }
impl ButtonBuilder { impl ButtonBuilder {
@@ -1129,26 +1198,43 @@ impl ButtonBuilder {
self self
} }
pub fn background(mut self, color: Color) -> Self {
self.background = Some(color);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn children(self, children: impl TextChildren) -> View { pub fn children(self, children: impl TextChildren) -> View {
let id = allocate_element_id(); let id = allocate_element_id();
let label = children.into_text(); let label = children.into_text();
let view = View::from_element( let view = View::from_element({
Element::column() let elt = Element::column()
.id(id) .id(id)
.padding(Edges::symmetric(14.0, 10.0)) .padding(Edges::symmetric(14.0, 10.0));
.background(surfaces::interactive())
.corner_radius(10.0) let elt = if let Some(background) = self.background {
elt.background(background)
} else {
elt.background(surfaces::interactive())
};
elt.corner_radius(10.0)
.cursor(CursorIcon::Pointer) .cursor(CursorIcon::Pointer)
.focusable(true) .focusable(true)
.child( .child(
Element::spans( Element::spans(
[TextSpan::new(label).weight(TextSpanWeight::Medium)], [TextSpan::new(label).weight(TextSpanWeight::Medium)],
TextStyle::new(18.0, colors::text()).with_line_height(21.6), TextStyle::new(18.0, self.color.unwrap_or(colors::text()))
.with_line_height(21.6),
) )
.pointer_events(false) .pointer_events(false)
.focusable(false), .focusable(false),
), )
); });
match self.on_press { match self.on_press {
Some(handler) => view.with_press_handler(id, handler), Some(handler) => view.with_press_handler(id, handler),
@@ -1988,8 +2074,9 @@ pub mod prelude {
Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget, Pending, Ready, Resource, ResourceState, Result, ScrollBoxBuilder, ScrollBoxWidget,
Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View, Shortcut, ShortcutScope, Signal, TextBuilder, TextChildren, TextRole, TextValue, View,
WidgetRef, Window, block, button, colors, column, component, context_provider, provide, WidgetRef, Window, block, button, colors, column, component, context_provider, provide,
row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource, use_shortcut, row, scroll_box, surfaces, text, use_context, use_effect, use_memo, use_resource,
use_shortcut_with_context, use_signal, use_widget_ref, use_window_title, view, use_shortcut, use_shortcut_with_context, use_signal, use_widget_ref, use_window_title,
view,
}; };
pub use ruin_ui::{ pub use ruin_ui::{
Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton, Border, Color, CursorIcon, Edges, Element, ElementId, InteractionTree, PointerButton,

View File

@@ -27,6 +27,11 @@ pub fn layout_scene_with_text_system(
pub struct LayoutSnapshot { pub struct LayoutSnapshot {
pub scene: SceneSnapshot, pub scene: SceneSnapshot,
pub interaction_tree: InteractionTree, 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)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
@@ -242,11 +247,13 @@ pub fn layout_snapshot_with_cache(
"layout snapshot perf" "layout snapshot perf"
); );
} }
let root_min_size = element_min_size(root);
LayoutSnapshot { LayoutSnapshot {
scene, scene,
interaction_tree: InteractionTree { interaction_tree: InteractionTree {
root: interaction_root, root: interaction_root,
}, },
root_min_size,
} }
} }
@@ -817,17 +824,26 @@ fn layout_container_children(
let child_insets = content_insets(&child.style); let child_insets = content_insets(&child.style);
let main_inset = main_axis_padding(child_insets, element.style.direction); let main_inset = main_axis_padding(child_insets, element.style.direction);
let cross_inset = cross_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. // min-size constraints (content-box) converted to outer.
let cross = child_cross_size(child, element.style.direction) let min_cross_outer = child_min_cross_size(child, element.style.direction)
.map(|c| (c + cross_inset).max(0.0)) .map(|c| (c + cross_inset).max(0.0))
.unwrap_or(available_cross) .unwrap_or(0.0);
.clamp(0.0, available_cross); 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) 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 is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| { let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex { if is_flex {
0.0 // Flex items get their share of remaining space later; record min floor now.
min_main_outer
} else { } else {
perf_stats.intrinsic_calls += 1; perf_stats.intrinsic_calls += 1;
if child.text_node().is_some() { if child.text_node().is_some() {
@@ -848,7 +864,8 @@ fn layout_container_children(
if let Some(intrinsic_started) = intrinsic_started { if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0; 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 { if is_flex {
@@ -868,11 +885,18 @@ fn layout_container_children(
let mut children = Vec::with_capacity(element.children.len()); let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() { for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex { let child_main = if measured.is_flex {
if flex_total <= 0.0 { let allocated = if flex_total <= 0.0 {
0.0 0.0
} else { } else {
remaining_main * (child_flex_weight(child) / flex_total) 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 { } else {
measured.main measured.main
}; };
@@ -1091,6 +1115,17 @@ fn intrinsic_size_inner(
// available_size is the outer size the parent is offering. // available_size is the outer size the parent is offering.
// All returned sizes are outer (content + insets). // 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() { if let Some(text) = element.text_node() {
// Measure at content dimensions: use explicit if set, else available minus insets. // Measure at content dimensions: use explicit if set, else available minus insets.
let content_w = element let content_w = element
@@ -1109,16 +1144,18 @@ fn intrinsic_size_inner(
); );
// Return outer: explicit (content) + insets, or measured + insets for auto. // Return outer: explicit (content) + insets, or measured + insets for auto.
return UiSize::new( return UiSize::new(
element.style.width.unwrap_or(measured.width) + horizontal_insets(insets), (element.style.width.unwrap_or(measured.width) + horizontal_insets(insets))
element.style.height.unwrap_or(measured.height) + vertical_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() { if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size()); let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new( return UiSize::new(
resolved.width + horizontal_insets(insets), (resolved.width + horizontal_insets(insets)).max(min_outer_w),
resolved.height + vertical_insets(insets), (resolved.height + vertical_insets(insets)).max(min_outer_h),
); );
} }
@@ -1141,12 +1178,14 @@ fn intrinsic_size_inner(
if element.children.is_empty() { if element.children.is_empty() {
return UiSize::new( return UiSize::new(
explicit_width (explicit_width
.map(|w| w + horizontal_insets(insets) + scroll_gutter) .map(|w| w + horizontal_insets(insets) + scroll_gutter)
.unwrap_or(horizontal_insets(insets) + scroll_gutter), .unwrap_or(horizontal_insets(insets) + scroll_gutter))
explicit_height .max(min_outer_w),
(explicit_height
.map(|h| h + vertical_insets(insets)) .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( UiSize::new(
explicit_width (explicit_width
.map(|w| w + horizontal_insets(insets) + scroll_gutter) .map(|w| w + horizontal_insets(insets) + scroll_gutter)
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter), .unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter))
explicit_height .max(min_outer_w),
(explicit_height
.map(|h| h + vertical_insets(insets)) .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 { fn child_flex_weight(child: &Element) -> f32 {
if child.style.flex_grow > 0.0 { if child.style.flex_grow > 0.0 {
child.style.flex_grow child.style.flex_grow

View File

@@ -31,7 +31,8 @@ pub use interaction::{
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers}; pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{ pub use layout::{
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics, HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system, TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache,
layout_snapshot_with_text_system,
}; };
pub use layout::{layout_scene, layout_scene_with_text_system}; pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{ pub use platform::{

View File

@@ -182,6 +182,8 @@ pub struct Style {
pub direction: FlexDirection, pub direction: FlexDirection,
pub width: Option<f32>, pub width: Option<f32>,
pub height: Option<f32>, pub height: Option<f32>,
pub min_width: Option<f32>,
pub min_height: Option<f32>,
pub flex_grow: f32, pub flex_grow: f32,
pub gap: f32, pub gap: f32,
pub padding: Edges, pub padding: Edges,
@@ -200,6 +202,8 @@ impl Default for Style {
direction: FlexDirection::Column, direction: FlexDirection::Column,
width: None, width: None,
height: None, height: None,
min_width: None,
min_height: None,
flex_grow: 0.0, flex_grow: 0.0,
gap: 0.0, gap: 0.0,
padding: Edges::ZERO, padding: Edges::ZERO,
@@ -332,6 +336,20 @@ impl Element {
self self
} }
pub fn min_width(mut self, min_width: f32) -> Self {
self.style.min_width = Some(min_width.max(0.0));
self
}
pub fn min_height(mut self, min_height: f32) -> Self {
self.style.min_height = Some(min_height.max(0.0));
self
}
pub fn min_size(self, min_width: f32, min_height: f32) -> Self {
self.min_width(min_width).min_height(min_height)
}
pub fn flex(mut self, flex_grow: f32) -> Self { pub fn flex(mut self, flex_grow: f32) -> Self {
self.style.flex_grow = flex_grow.max(0.0); self.style.flex_grow = flex_grow.max(0.0);
self self
@@ -532,6 +550,8 @@ impl Hash for Style {
self.direction.hash(state); self.direction.hash(state);
self.width.map(f32::to_bits).hash(state); self.width.map(f32::to_bits).hash(state);
self.height.map(f32::to_bits).hash(state); self.height.map(f32::to_bits).hash(state);
self.min_width.map(f32::to_bits).hash(state);
self.min_height.map(f32::to_bits).hash(state);
self.flex_grow.to_bits().hash(state); self.flex_grow.to_bits().hash(state);
self.gap.to_bits().hash(state); self.gap.to_bits().hash(state);
self.padding.hash(state); self.padding.hash(state);