use alloc::{vec, vec::Vec};
use nami::{Computed, Signal, SignalExt, collection::Collection};
use num_traits::ToPrimitive;
use waterui_core::{
AnyView, IntoSignalF32, View, env::with, id::Identifiable, layout::LayoutInvalidationCallback,
view::TupleViews, views::ForEach,
};
use crate::{
Layout, LazyContainer, PlacedSubview, Point, ProposalSize, Rect, Size, StretchAxis, SubView,
ViewDimensions,
container::FixedContainer,
stack::{
Axis, VerticalAlignment,
distribute::{Extent, compress_to_fit, exceeds},
stack_stretch_axis,
},
};
#[derive(Debug, Clone)]
pub struct HStack<C> {
layout: HStackLayout,
contents: C,
}
#[derive(Debug, Clone)]
pub struct HStackLayout {
pub alignment: VerticalAlignment,
pub spacing: Computed<f32>,
}
impl Default for HStackLayout {
fn default() -> Self {
Self {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(10.0),
}
}
}
struct ChildMeasurement {
dimensions: ViewDimensions,
stretch_axis: StretchAxis,
}
impl ChildMeasurement {
const fn size(&self) -> Size {
self.dimensions.size
}
fn vertical_guide(&self, alignment: VerticalAlignment) -> f32 {
self.dimensions.vertical(alignment)
}
const fn stretches_main_axis(&self) -> bool {
matches!(
self.stretch_axis,
StretchAxis::Horizontal | StretchAxis::Both | StretchAxis::MainAxis
)
}
const fn stretches_cross_axis(&self) -> bool {
matches!(
self.stretch_axis,
StretchAxis::Vertical | StretchAxis::Both | StretchAxis::CrossAxis
)
}
}
fn hstack_intrinsic_cross_metrics(
measurements: &[ChildMeasurement],
alignment: VerticalAlignment,
) -> (f32, f32) {
let mut max_above = 0.0_f32;
let mut max_below = 0.0_f32;
for measurement in measurements
.iter()
.filter(|measurement| measurement.size().height.is_finite())
{
let size = measurement.size();
let guide = measurement
.vertical_guide(alignment)
.clamp(0.0, size.height);
max_above = max_above.max(guide);
max_below = max_below.max((size.height - guide).max(0.0));
}
(max_above, max_below)
}
fn hstack_container_guide_offset(
bounds: Rect,
alignment: VerticalAlignment,
intrinsic_above: f32,
) -> f32 {
if alignment == VerticalAlignment::Top {
0.0
} else if alignment == VerticalAlignment::Bottom {
bounds.height()
} else if alignment == VerticalAlignment::Center {
bounds.height() * 0.5
} else {
intrinsic_above
}
}
fn hstack_stretch_allocations(
stretch_indices: &[usize],
available_for_children: f32,
fixed_width: f32,
) -> Vec<(usize, f32)> {
if stretch_indices.is_empty() {
return Vec::new();
}
let width =
(available_for_children - fixed_width).max(0.0) / usize_to_f32(stretch_indices.len());
stretch_indices.iter().map(|&idx| (idx, width)).collect()
}
fn compress_children(
measurements: &mut [ChildMeasurement],
children: &[&dyn SubView],
compress_indices: &[usize],
available: f32,
height_proposal: Option<f32>,
) {
if compress_indices.is_empty() {
return;
}
let ideal_total: f32 = compress_indices
.iter()
.map(|&index| measurements[index].size().width)
.sum();
if !exceeds(ideal_total, available, compress_indices.len()) {
return;
}
let extents: Vec<Extent> = compress_indices
.iter()
.map(|&index| Extent {
ideal: measurements[index].size().width,
min: children[index]
.measure(ProposalSize::new(Some(0.0), height_proposal))
.size
.width,
priority: children[index].priority(),
})
.collect();
for (&index, resolved) in compress_indices
.iter()
.zip(compress_to_fit(&extents, available))
{
if resolved >= measurements[index].size().width {
continue;
}
let constrained = ProposalSize::new(Some(resolved), height_proposal);
measurements[index].dimensions = children[index].measure(constrained);
measurements[index].dimensions.size.width = measurements[index].size().width.min(resolved);
}
}
#[allow(clippy::too_many_lines)]
impl Layout for HStackLayout {
fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
stack_stretch_axis(Axis::Horizontal, children)
}
fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
if children.is_empty() {
return Size::zero();
}
let spacing = self.spacing.get();
let total_spacing = if children.len() > 1 {
usize_to_f32(children.len() - 1) * spacing
} else {
0.0
};
let is_min_size_query = proposal.width == Some(0.0);
let intrinsic_proposal = if is_min_size_query {
ProposalSize::new(Some(0.0), proposal.height)
} else {
ProposalSize::new(None, proposal.height)
};
let mut measurements: Vec<ChildMeasurement> = children
.iter()
.map(|child| ChildMeasurement {
dimensions: child.measure(intrinsic_proposal),
stretch_axis: child.stretch_axis(),
})
.collect();
let has_main_axis_stretch = measurements
.iter()
.any(ChildMeasurement::stretches_main_axis);
let main_axis_stretch_indices: Vec<usize> = measurements
.iter()
.enumerate()
.filter(|(_, m)| m.stretches_main_axis())
.map(|(idx, _)| idx)
.collect();
let main_axis_stretch_count = main_axis_stretch_indices.len();
let intrinsic_width_all: f32 =
measurements.iter().map(|m| m.size().width).sum::<f32>() + total_spacing;
if is_min_size_query {
let (max_above, max_below) =
hstack_intrinsic_cross_metrics(&measurements, self.alignment);
return Size::new(intrinsic_width_all, max_above + max_below);
}
let intrinsic_width = intrinsic_width_all;
let final_width = proposal.width.map_or(intrinsic_width, |proposed| {
if has_main_axis_stretch {
proposed
} else {
intrinsic_width.min(proposed)
}
});
let available_for_children = (final_width - total_spacing).max(0.0);
let fixed_indices: Vec<usize> = if main_axis_stretch_count > 0 && proposal.width.is_some() {
measurements
.iter()
.enumerate()
.filter(|(_, m)| !m.stretches_main_axis())
.map(|(idx, _)| idx)
.collect()
} else {
(0..measurements.len()).collect()
};
if proposal.width.is_some() {
compress_children(
&mut measurements,
children,
&fixed_indices,
available_for_children,
proposal.height,
);
}
if proposal.width.is_some() && main_axis_stretch_count > 0 {
let fixed_width: f32 = measurements
.iter()
.enumerate()
.filter(|(_, m)| !m.stretches_main_axis())
.map(|(_, m)| m.size().width)
.sum();
let allocations = hstack_stretch_allocations(
&main_axis_stretch_indices,
available_for_children,
fixed_width,
);
for (idx, stretch_width) in allocations {
let constrained_proposal = ProposalSize::new(Some(stretch_width), proposal.height);
measurements[idx].dimensions = children[idx].measure(constrained_proposal);
measurements[idx].dimensions.size.width =
measurements[idx].size().width.max(stretch_width);
}
}
let (max_above, max_below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
let max_height = max_above + max_below;
Size::new(final_width, max_height)
}
#[allow(clippy::too_many_lines)]
fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
if children.is_empty() {
return vec![];
}
let spacing = self.spacing.get();
let total_spacing = if children.len() > 1 {
usize_to_f32(children.len() - 1) * spacing
} else {
0.0
};
let available_width = bounds.width() - total_spacing;
let intrinsic_proposal = ProposalSize::new(None, Some(bounds.height()));
let mut measurements: Vec<ChildMeasurement> = children
.iter()
.map(|child| ChildMeasurement {
dimensions: child.measure(intrinsic_proposal),
stretch_axis: child.stretch_axis(),
})
.collect();
let main_axis_stretch_indices: Vec<usize> = measurements
.iter()
.enumerate()
.filter(|(_, m)| m.stretches_main_axis())
.map(|(idx, _)| idx)
.collect();
let main_axis_stretch_count = main_axis_stretch_indices.len();
let fixed_indices: Vec<usize> = if main_axis_stretch_count > 0 {
measurements
.iter()
.enumerate()
.filter(|(_, m)| !m.stretches_main_axis())
.map(|(idx, _)| idx)
.collect()
} else {
(0..measurements.len()).collect()
};
compress_children(
&mut measurements,
children,
&fixed_indices,
available_width,
Some(bounds.height()),
);
let actual_fixed_width: f32 = measurements
.iter()
.enumerate()
.filter(|(_, m)| !m.stretches_main_axis())
.map(|(_, m)| m.size().width)
.sum();
let stretch_allocations = hstack_stretch_allocations(
&main_axis_stretch_indices,
available_width,
actual_fixed_width,
);
if main_axis_stretch_count > 0 {
for &(idx, stretch_width) in &stretch_allocations {
let constrained_proposal =
ProposalSize::new(Some(stretch_width), Some(bounds.height()));
measurements[idx].dimensions = children[idx].measure(constrained_proposal);
measurements[idx].dimensions.size.width =
measurements[idx].size().width.max(stretch_width);
}
}
let (intrinsic_above, _intrinsic_below) =
hstack_intrinsic_cross_metrics(&measurements, self.alignment);
let guide_line =
bounds.y() + hstack_container_guide_offset(bounds, self.alignment, intrinsic_above);
let mut rects = Vec::with_capacity(children.len());
let mut current_x = bounds.x();
for (i, measurement) in measurements.iter().enumerate() {
if i > 0 {
current_x += spacing;
}
let child_height = if measurement.stretches_cross_axis() {
bounds.height()
} else if measurement.size().height.is_infinite() {
bounds.height()
} else {
measurement.size().height.min(bounds.height())
};
let child_width = measurement.size().width;
let mut adjusted_dimensions = measurement.dimensions.clone();
adjusted_dimensions.size = Size::new(child_width, child_height);
let y =
if measurement.stretches_cross_axis() || self.alignment == VerticalAlignment::Top {
bounds.y()
} else if self.alignment == VerticalAlignment::Bottom {
bounds.y() + bounds.height() - child_height
} else {
let guide = adjusted_dimensions
.vertical(self.alignment)
.clamp(0.0, child_height);
guide_line - guide
};
let rect = Rect::new(
Point::new(current_x, y),
Size::new(child_width, child_height),
);
rects.push(rect);
current_x += child_width;
}
rects
}
fn explicit_vertical(
&self,
alignment: VerticalAlignment,
_bounds: Rect,
children: &[PlacedSubview<'_>],
) -> Option<f32> {
if alignment == VerticalAlignment::LastBaseline {
return children
.iter()
.filter_map(|child| child.explicit_vertical(alignment))
.max_by(f32::total_cmp);
}
if alignment == VerticalAlignment::FirstBaseline || alignment == self.alignment {
return children
.iter()
.filter_map(|child| child.explicit_vertical(alignment))
.min_by(f32::total_cmp);
}
None
}
fn watch_invalidation(
&self,
invalidate: LayoutInvalidationCallback,
) -> Vec<nami::watcher::BoxWatcherGuard> {
vec![self.spacing.watch(move |_| invalidate())]
}
}
fn usize_to_f32(value: usize) -> f32 {
value
.to_f32()
.expect("HStackLayout: child count must be representable as f32")
}
impl<C> HStack<(C,)> {
pub fn new(alignment: VerticalAlignment, spacing: f32, contents: C) -> Self {
Self {
layout: HStackLayout {
alignment,
spacing: Computed::constant(spacing),
},
contents: (contents,),
}
}
}
impl<C> HStack<C> {
#[must_use]
pub const fn alignment(mut self, alignment: VerticalAlignment) -> Self {
self.layout.alignment = alignment;
self
}
#[must_use]
pub fn spacing(mut self, spacing: impl IntoSignalF32 + 'static) -> Self {
self.layout.spacing = spacing.into_signal_f32().computed();
self
}
}
crate::stack::impl_stack_for_each!(HStack, HStackLayout);
impl<V> FromIterator<V> for HStack<(Vec<AnyView>,)>
where
V: View,
{
fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
let contents = iter.into_iter().map(AnyView::new).collect();
Self::new(VerticalAlignment::default(), 10.0, contents)
}
}
pub fn hstack<C>(contents: C) -> HStack<(C,)> {
HStack::new(VerticalAlignment::Center, 10.0, contents)
}
impl<C, F, V> View for HStack<ForEach<C, F, V>>
where
C: Collection + Clone,
C::Item: Identifiable,
F: 'static + Fn(C::Item) -> V,
V: View,
{
fn body(self, _env: &waterui_core::Environment) -> impl View {
with(
LazyContainer::new(self.layout, self.contents),
Axis::Horizontal,
)
}
}
impl<C: TupleViews + 'static> View for HStack<(C,)> {
fn body(self, _env: &waterui_core::Environment) -> impl View {
with(
FixedContainer::new(self.layout, self.contents.0),
Axis::Horizontal,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::CompressibleView;
struct MockSubView {
size: Size,
stretch_axis: StretchAxis,
}
impl SubView for MockSubView {
fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
ViewDimensions::new(self.size)
}
fn stretch_axis(&self) -> StretchAxis {
self.stretch_axis
}
fn priority(&self) -> i32 {
0
}
}
struct ResponsiveSubView {
intrinsic: Size,
wrapped_height: f32,
wrap_at_or_below: f32,
stretch_axis: StretchAxis,
}
impl SubView for ResponsiveSubView {
fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
ViewDimensions::new(match proposal.width {
Some(width) if width <= self.wrap_at_or_below => {
Size::new(width, self.wrapped_height)
}
Some(width) => Size::new(width, self.intrinsic.height),
None => self.intrinsic,
})
}
fn stretch_axis(&self) -> StretchAxis {
self.stretch_axis
}
fn priority(&self) -> i32 {
0
}
}
#[test]
fn test_hstack_size_two_children() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(10.0),
};
let mut child1 = MockSubView {
size: Size::new(50.0, 30.0),
stretch_axis: StretchAxis::None,
};
let mut child2 = MockSubView {
size: Size::new(60.0, 40.0),
stretch_axis: StretchAxis::None,
};
let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
assert!((size.width - 120.0).abs() < f32::EPSILON); assert!((size.height - 40.0).abs() < f32::EPSILON); }
#[test]
fn test_hstack_with_spacer() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(0.0),
};
let mut child1 = MockSubView {
size: Size::new(30.0, 40.0),
stretch_axis: StretchAxis::None,
};
let mut spacer = MockSubView {
size: Size::zero(),
stretch_axis: StretchAxis::Both, };
let mut child2 = MockSubView {
size: Size::new(30.0, 40.0),
stretch_axis: StretchAxis::None,
};
let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
let size = layout.size_that_fits(ProposalSize::new(Some(200.0), None), &children);
assert!((size.width - 200.0).abs() < f32::EPSILON);
let bounds = Rect::new(Point::zero(), Size::new(200.0, 40.0));
let mut child1 = MockSubView {
size: Size::new(30.0, 40.0),
stretch_axis: StretchAxis::None,
};
let mut spacer = MockSubView {
size: Size::zero(),
stretch_axis: StretchAxis::Both,
};
let mut child2 = MockSubView {
size: Size::new(30.0, 40.0),
stretch_axis: StretchAxis::None,
};
let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
let rects = layout.place(bounds, &children);
assert!((rects[0].width() - 30.0).abs() < f32::EPSILON);
assert!((rects[1].width() - 140.0).abs() < f32::EPSILON); assert!((rects[2].width() - 30.0).abs() < f32::EPSILON);
assert!((rects[2].x() - 170.0).abs() < f32::EPSILON); }
#[test]
fn a_cross_filling_child_still_sets_the_row_height() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(10.0),
};
let mut label = MockSubView {
size: Size::new(50.0, 20.0),
stretch_axis: StretchAxis::None,
};
let mut vertical_stretch = MockSubView {
size: Size::new(40.0, 100.0), stretch_axis: StretchAxis::Vertical, };
let mut button = MockSubView {
size: Size::new(80.0, 44.0),
stretch_axis: StretchAxis::None,
};
let children: Vec<&dyn SubView> = vec![&mut label, &mut vertical_stretch, &mut button];
let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
assert!((size.width - 190.0).abs() < f32::EPSILON);
assert!(
(size.height - 100.0).abs() < f32::EPSILON,
"the stretching child's own height has to reach the row, got {}",
size.height
);
}
#[test]
fn test_hstack_compresses_equal_children_evenly() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(8.0),
};
let mut columns: Vec<CompressibleView> = (0..7)
.map(|_| CompressibleView {
ideal: Size::new(40.0, 40.0),
floor: 0.0,
})
.collect();
let children: Vec<&dyn SubView> = columns
.iter_mut()
.map(|child| child as &dyn SubView)
.collect();
let bounds = Rect::new(Point::zero(), Size::new(272.0, 40.0));
let rects = layout.place(bounds, &children);
for rect in &rects {
assert!(
(rect.width() - 32.0).abs() < 0.001,
"expected even 32pt columns, got {}",
rect.width()
);
}
for pair in rects.windows(2) {
assert!(
((pair[1].x() - pair[0].x()) - 40.0).abs() < 0.001,
"expected uniform 40pt pitch, got {}",
pair[1].x() - pair[0].x()
);
}
}
#[test]
fn test_hstack_compression_keeps_small_children_intrinsic() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(10.0),
};
let mut label = MockSubView {
size: Size::new(50.0, 20.0),
stretch_axis: StretchAxis::None,
};
let mut long_text = ResponsiveSubView {
intrinsic: Size::new(200.0, 20.0),
wrapped_height: 40.0,
wrap_at_or_below: 60.0,
stretch_axis: StretchAxis::None,
};
let children: Vec<&dyn SubView> = vec![&mut label, &mut long_text];
let bounds = Rect::new(Point::zero(), Size::new(150.0, 40.0));
let rects = layout.place(bounds, &children);
assert!((rects[0].width() - 50.0).abs() < 0.001);
assert!((rects[1].width() - 90.0).abs() < 0.001);
}
#[test]
fn test_hstack_compression_never_goes_below_a_reported_minimum() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(0.0),
};
let mut first = CompressibleView {
ideal: Size::new(60.0, 20.0),
floor: 20.0,
};
let mut second = CompressibleView {
ideal: Size::new(60.0, 20.0),
floor: 20.0,
};
let children: Vec<&dyn SubView> = vec![&mut first, &mut second];
let bounds = Rect::new(Point::zero(), Size::new(10.0, 20.0));
let rects = layout.place(bounds, &children);
assert!((rects[0].width() - 20.0).abs() < 0.001);
assert!((rects[1].width() - 20.0).abs() < 0.001);
}
#[test]
fn test_hstack_measures_stretch_child_with_allocated_width_for_height() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(0.0),
};
let mut fixed = MockSubView {
size: Size::new(4.0, 10.0),
stretch_axis: StretchAxis::None,
};
let mut stretch = ResponsiveSubView {
intrinsic: Size::new(100.0, 20.0),
wrapped_height: 40.0,
wrap_at_or_below: 60.0,
stretch_axis: StretchAxis::Horizontal,
};
let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
let size = layout.size_that_fits(ProposalSize::new(Some(40.0), None), &children);
assert!((size.width - 40.0).abs() < f32::EPSILON);
assert!((size.height - 40.0).abs() < f32::EPSILON);
}
#[test]
fn test_hstack_place_uses_stretch_child_wrapped_height() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(0.0),
};
let bounds = Rect::new(Point::zero(), Size::new(40.0, 40.0));
let mut fixed = MockSubView {
size: Size::new(4.0, 10.0),
stretch_axis: StretchAxis::None,
};
let mut stretch = ResponsiveSubView {
intrinsic: Size::new(100.0, 20.0),
wrapped_height: 40.0,
wrap_at_or_below: 60.0,
stretch_axis: StretchAxis::Horizontal,
};
let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
let rects = layout.place(bounds, &children);
assert!((rects[0].width() - 4.0).abs() < f32::EPSILON);
assert!((rects[1].width() - 36.0).abs() < f32::EPSILON);
assert!((rects[1].height() - 40.0).abs() < f32::EPSILON);
}
#[test]
fn test_hstack_min_size_query_correctly_sums_children() {
let layout = HStackLayout {
alignment: VerticalAlignment::Center,
spacing: Computed::constant(10.0),
};
let mut label = MockSubView {
size: Size::new(50.0, 20.0),
stretch_axis: StretchAxis::None,
};
let mut spacer = MockSubView {
size: Size::zero(),
stretch_axis: StretchAxis::Both,
};
let mut button = MockSubView {
size: Size::new(80.0, 44.0),
stretch_axis: StretchAxis::None,
};
let children: Vec<&dyn SubView> = vec![&mut label, &mut spacer, &mut button];
let min_size = layout.size_that_fits(ProposalSize::ZERO, &children);
assert!(
(min_size.width - 150.0).abs() < f32::EPSILON,
"Min size query should return sum of child min widths (150.0), got {}",
min_size.width
);
let intrinsic_size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
assert!((intrinsic_size.width - 150.0).abs() < f32::EPSILON);
}
}