use alloc::{vec, vec::Vec};
use nami::{Computed, Signal, SignalExt, collection::Collection};
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,
SubviewPlacement,
container::FixedContainer,
stack::{
Axis, VerticalAlignment,
distribute::{
ChildMeasurement, LineAnchor, container_line, cross_envelope, measure_stack,
place_cross_extent, stack_spacing,
},
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),
}
}
}
fn hstack_intrinsic_cross_metrics(
measurements: &[ChildMeasurement],
alignment: VerticalAlignment,
) -> (f32, f32) {
cross_envelope(measurements.iter().map(|measurement| {
(
measurement.size().height,
measurement.vertical_guide(alignment),
)
}))
}
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 measurements = measure_stack(Axis::Horizontal, proposal, spacing, children);
let width = measurements.iter().map(|m| m.size().width).sum::<f32>()
+ stack_spacing(spacing, children.len());
let (above, below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
let height = if measurements.iter().any(|m| m.size().height.is_infinite()) {
f32::INFINITY
} else {
above + below
};
Size::new(width, height)
}
fn place(
&self,
bounds: Rect,
_proposal: ProposalSize,
children: &[&dyn SubView],
) -> Vec<SubviewPlacement> {
if children.is_empty() {
return vec![];
}
let spacing = self.spacing.get();
let placement = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
let measurements = measure_stack(Axis::Horizontal, placement, spacing, children);
let (above, below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
let guide_line = bounds.y()
+ container_line(
bounds.height(),
LineAnchor::vertical(self.alignment),
above,
below,
);
let mut placements = 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 = place_cross_extent(
measurement.size().height,
bounds.height(),
measurement.stretches_cross_axis(),
);
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() {
bounds.y()
} else {
guide_line - adjusted_dimensions.vertical(self.alignment)
};
let rect = Rect::new(
Point::new(current_x, y),
Size::new(child_width, child_height),
);
placements.push(SubviewPlacement::new(rect, measurement.proposal));
current_x += child_width;
}
placements
}
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())]
}
}
impl<C: TupleViews> 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 fn alignment(mut self, alignment: impl Into<VerticalAlignment>) -> Self {
self.layout.alignment = alignment.into();
self
}
crate::alignment::vertical_alignment_methods!();
#[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: TupleViews>(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,
)
}
fn stretch_axis(&self) -> StretchAxis {
self.layout.stretch_axis(&[])
}
}
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,
)
}
fn stretch_axis(&self) -> StretchAxis {
self.layout.stretch_axis(&self.contents.0.stretch_axes())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ViewDimensions;
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 placements = layout.place(bounds, ProposalSize::new(Some(200.0), None), &children);
assert!((placements[0].frame.width() - 30.0).abs() < f32::EPSILON);
assert!((placements[1].frame.width() - 140.0).abs() < f32::EPSILON); assert!((placements[2].frame.width() - 30.0).abs() < f32::EPSILON);
assert!((placements[2].frame.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_bounded_proposal() {
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 proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
let placements = layout.place(bounds, proposal, &children);
for placement in &placements {
assert!(
(placement.frame.width() - 32.0).abs() < 0.001,
"expected even 32pt columns, got {}",
placement.frame.width()
);
}
for pair in placements.windows(2) {
assert!(
((pair[1].frame.x() - pair[0].frame.x()) - 40.0).abs() < 0.001,
"expected uniform 40pt pitch, got {}",
pair[1].frame.x() - pair[0].frame.x()
);
}
}
#[test]
fn test_hstack_compression_keeps_small_children_intrinsic_bounded_proposal() {
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 proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
let placements = layout.place(bounds, proposal, &children);
assert!((placements[0].frame.width() - 50.0).abs() < 0.001);
assert!((placements[1].frame.width() - 90.0).abs() < 0.001);
}
#[test]
fn test_hstack_compression_never_goes_below_a_reported_minimum_bounded_proposal() {
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 proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
let placements = layout.place(bounds, proposal, &children);
assert!((placements[0].frame.width() - 20.0).abs() < 0.001);
assert!((placements[1].frame.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_bounded_proposal() {
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 proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
let placements = layout.place(bounds, proposal, &children);
assert!((placements[0].frame.width() - 4.0).abs() < f32::EPSILON);
assert!((placements[1].frame.width() - 36.0).abs() < f32::EPSILON);
assert!((placements[1].frame.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);
}
}