Skip to main content

waterui_layout/stack/
hstack.rs

1//! Horizontal stack layout.
2
3use alloc::{vec, vec::Vec};
4use nami::{Computed, Signal, SignalExt, collection::Collection};
5use num_traits::ToPrimitive;
6use waterui_core::{
7    AnyView, IntoSignalF32, View, env::with, id::Identifiable, layout::LayoutInvalidationCallback,
8    view::TupleViews, views::ForEach,
9};
10
11use crate::{
12    Layout, LazyContainer, PlacedSubview, Point, ProposalSize, Rect, Size, StretchAxis, SubView,
13    ViewDimensions,
14    container::FixedContainer,
15    stack::{
16        Axis, VerticalAlignment,
17        distribute::{Extent, compress_to_fit, exceeds},
18        stack_stretch_axis,
19    },
20};
21
22/// A view that arranges its children in a horizontal line.
23///
24/// Use an `HStack` to arrange views side-by-side. The stack sizes itself to fit
25/// its contents, distributing available space among its children.
26///
27/// ```rust
28/// # use waterui::prelude::*;
29/// # fn greeting() -> impl View {
30/// hstack((
31///     text("Hello"),
32///     text("World"),
33/// ))
34/// # }
35/// ```
36///
37/// You can customize the spacing between children and their vertical alignment:
38///
39/// ```rust
40/// # use waterui::prelude::*;
41/// # fn spaced() -> impl View {
42/// HStack::new(VerticalAlignment::Top, 20.0, (
43///     text("First"),
44///     text("Second"),
45/// ))
46/// # }
47/// ```
48///
49/// Use [`spacer()`](crate::spacer()) to push content to the sides:
50///
51/// ```rust
52/// # use waterui::prelude::*;
53/// # fn toolbar() -> impl View {
54/// hstack((
55///     text("Leading"),
56///     spacer(),
57///     text("Trailing"),
58/// ))
59/// # }
60/// ```
61#[derive(Debug, Clone)]
62pub struct HStack<C> {
63    layout: HStackLayout,
64    contents: C,
65}
66
67/// Layout engine shared by the public [`HStack`] view.
68#[derive(Debug, Clone)]
69pub struct HStackLayout {
70    /// The vertical alignment of children within the stack.
71    pub alignment: VerticalAlignment,
72    /// The spacing between children in the stack.
73    pub spacing: Computed<f32>,
74}
75
76impl Default for HStackLayout {
77    fn default() -> Self {
78        Self {
79            alignment: VerticalAlignment::Center,
80            spacing: Computed::constant(10.0),
81        }
82    }
83}
84
85/// Cached measurement for a child during layout
86struct ChildMeasurement {
87    dimensions: ViewDimensions,
88    stretch_axis: StretchAxis,
89}
90
91impl ChildMeasurement {
92    const fn size(&self) -> Size {
93        self.dimensions.size
94    }
95
96    fn vertical_guide(&self, alignment: VerticalAlignment) -> f32 {
97        self.dimensions.vertical(alignment)
98    }
99
100    /// Returns true if this child stretches horizontally (for `HStack` width distribution).
101    /// In `HStack` context:
102    /// - `MainAxis` means horizontal (`HStack`'s main axis)
103    /// - `CrossAxis` means vertical (`HStack`'s cross axis)
104    const fn stretches_main_axis(&self) -> bool {
105        matches!(
106            self.stretch_axis,
107            StretchAxis::Horizontal | StretchAxis::Both | StretchAxis::MainAxis
108        )
109    }
110
111    /// Returns true if this child stretches vertically (for `HStack` height expansion).
112    /// In `HStack` context:
113    /// - `CrossAxis` means vertical (`HStack`'s cross axis)
114    const fn stretches_cross_axis(&self) -> bool {
115        matches!(
116            self.stretch_axis,
117            StretchAxis::Vertical | StretchAxis::Both | StretchAxis::CrossAxis
118        )
119    }
120}
121
122/// The tallest child above and below the alignment guide.
123///
124/// Every child that reports a height counts, the ones that fill the cross axis
125/// included. Filling means "at least what I measure, and more if you have it",
126/// not "nothing": leaving a filler out of this makes a row of nothing-but-
127/// fillers report zero height, and its parent then hands it zero height to
128/// fill. A child that answers an unbounded height is answering "as much as you
129/// have" rather than naming a size, so it sets no floor here — the fill pass in
130/// [`Layout::place`] is what gives it the row's height.
131fn hstack_intrinsic_cross_metrics(
132    measurements: &[ChildMeasurement],
133    alignment: VerticalAlignment,
134) -> (f32, f32) {
135    let mut max_above = 0.0_f32;
136    let mut max_below = 0.0_f32;
137
138    for measurement in measurements
139        .iter()
140        .filter(|measurement| measurement.size().height.is_finite())
141    {
142        let size = measurement.size();
143        let guide = measurement
144            .vertical_guide(alignment)
145            .clamp(0.0, size.height);
146        max_above = max_above.max(guide);
147        max_below = max_below.max((size.height - guide).max(0.0));
148    }
149
150    (max_above, max_below)
151}
152
153fn hstack_container_guide_offset(
154    bounds: Rect,
155    alignment: VerticalAlignment,
156    intrinsic_above: f32,
157) -> f32 {
158    if alignment == VerticalAlignment::Top {
159        0.0
160    } else if alignment == VerticalAlignment::Bottom {
161        bounds.height()
162    } else if alignment == VerticalAlignment::Center {
163        bounds.height() * 0.5
164    } else {
165        intrinsic_above
166    }
167}
168
169fn hstack_stretch_allocations(
170    stretch_indices: &[usize],
171    available_for_children: f32,
172    fixed_width: f32,
173) -> Vec<(usize, f32)> {
174    if stretch_indices.is_empty() {
175        return Vec::new();
176    }
177
178    let width =
179        (available_for_children - fixed_width).max(0.0) / usize_to_f32(stretch_indices.len());
180
181    stretch_indices.iter().map(|&idx| (idx, width)).collect()
182}
183
184/// Compresses the non-stretching children into `available`, taking space from the
185/// lowest layout priorities first and never pushing a child below the minimum it
186/// reports when proposed zero width.
187///
188/// Children clamped below their ideal are re-measured at the width they end up
189/// with, so wrapped content reports the height that width actually produces.
190fn compress_children(
191    measurements: &mut [ChildMeasurement],
192    children: &[&dyn SubView],
193    compress_indices: &[usize],
194    available: f32,
195    height_proposal: Option<f32>,
196) {
197    if compress_indices.is_empty() {
198        return;
199    }
200
201    // Probing every child for its minimum only pays when something has to give,
202    // so check the ideals first. That keeps an already-fitting row at one measure
203    // per child — which the embedded backend's per-frame measure budget notices
204    // immediately if it regresses.
205    let ideal_total: f32 = compress_indices
206        .iter()
207        .map(|&index| measurements[index].size().width)
208        .sum();
209    if !exceeds(ideal_total, available, compress_indices.len()) {
210        return;
211    }
212
213    let extents: Vec<Extent> = compress_indices
214        .iter()
215        .map(|&index| Extent {
216            ideal: measurements[index].size().width,
217            min: children[index]
218                .measure(ProposalSize::new(Some(0.0), height_proposal))
219                .size
220                .width,
221            priority: children[index].priority(),
222        })
223        .collect();
224
225    for (&index, resolved) in compress_indices
226        .iter()
227        .zip(compress_to_fit(&extents, available))
228    {
229        if resolved >= measurements[index].size().width {
230            continue;
231        }
232        let constrained = ProposalSize::new(Some(resolved), height_proposal);
233        measurements[index].dimensions = children[index].measure(constrained);
234        measurements[index].dimensions.size.width = measurements[index].size().width.min(resolved);
235    }
236}
237
238#[allow(clippy::too_many_lines)]
239impl Layout for HStackLayout {
240    /// An `HStack` claims nothing of its own — a row of labels is content-sized,
241    /// like `SwiftUI`'s, and a parent placing an undersized row centers it per
242    /// its alignment. What it does claim is whatever its children claim: filling
243    /// comes from children that ask for it (`Spacer`, greedy frames, `Color`),
244    /// and the ask has to survive the trip up through every container between
245    /// that child and whoever owns the space.
246    fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
247        stack_stretch_axis(Axis::Horizontal, children)
248    }
249
250    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
251        if children.is_empty() {
252            return Size::zero();
253        }
254
255        let spacing = self.spacing.get();
256        let total_spacing = if children.len() > 1 {
257            usize_to_f32(children.len() - 1) * spacing
258        } else {
259            0.0
260        };
261
262        // First pass: measure children. If min size query (width=0), use 0 width proposal to get min sizes.
263        let is_min_size_query = proposal.width == Some(0.0);
264        let intrinsic_proposal = if is_min_size_query {
265            ProposalSize::new(Some(0.0), proposal.height)
266        } else {
267            ProposalSize::new(None, proposal.height)
268        };
269        let mut measurements: Vec<ChildMeasurement> = children
270            .iter()
271            .map(|child| ChildMeasurement {
272                dimensions: child.measure(intrinsic_proposal),
273                stretch_axis: child.stretch_axis(),
274            })
275            .collect();
276
277        // HStack checks for main-axis (horizontal) stretching
278        let has_main_axis_stretch = measurements
279            .iter()
280            .any(ChildMeasurement::stretches_main_axis);
281        let main_axis_stretch_indices: Vec<usize> = measurements
282            .iter()
283            .enumerate()
284            .filter(|(_, m)| m.stretches_main_axis())
285            .map(|(idx, _)| idx)
286            .collect();
287        let main_axis_stretch_count = main_axis_stretch_indices.len();
288
289        let intrinsic_width_all: f32 =
290            measurements.iter().map(|m| m.size().width).sum::<f32>() + total_spacing;
291
292        if is_min_size_query {
293            let (max_above, max_below) =
294                hstack_intrinsic_cross_metrics(&measurements, self.alignment);
295            return Size::new(intrinsic_width_all, max_above + max_below);
296        }
297
298        // Intrinsic width used when the parent doesn't constrain width.
299        // In unconstrained context, even "stretching" children should be measured at their
300        // intrinsic widths (otherwise content-bearing views could collapse to 0 width).
301        let intrinsic_width = intrinsic_width_all;
302
303        // Determine final width
304        let final_width = proposal.width.map_or(intrinsic_width, |proposed| {
305            if has_main_axis_stretch {
306                proposed
307            } else {
308                intrinsic_width.min(proposed)
309            }
310        });
311
312        // If width is constrained, we need to distribute space properly
313        // Key insight: small children (labels) keep intrinsic width, large children (text) compress
314        let available_for_children = (final_width - total_spacing).max(0.0);
315
316        let fixed_indices: Vec<usize> = if main_axis_stretch_count > 0 && proposal.width.is_some() {
317            measurements
318                .iter()
319                .enumerate()
320                .filter(|(_, m)| !m.stretches_main_axis())
321                .map(|(idx, _)| idx)
322                .collect()
323        } else {
324            (0..measurements.len()).collect()
325        };
326
327        if proposal.width.is_some() {
328            compress_children(
329                &mut measurements,
330                children,
331                &fixed_indices,
332                available_for_children,
333                proposal.height,
334            );
335        }
336
337        // If there are main-axis stretching children and width is constrained, measure them with
338        // their allocated widths so height reflects wrapped content.
339        if proposal.width.is_some() && main_axis_stretch_count > 0 {
340            let fixed_width: f32 = measurements
341                .iter()
342                .enumerate()
343                .filter(|(_, m)| !m.stretches_main_axis())
344                .map(|(_, m)| m.size().width)
345                .sum();
346
347            let allocations = hstack_stretch_allocations(
348                &main_axis_stretch_indices,
349                available_for_children,
350                fixed_width,
351            );
352
353            for (idx, stretch_width) in allocations {
354                let constrained_proposal = ProposalSize::new(Some(stretch_width), proposal.height);
355                measurements[idx].dimensions = children[idx].measure(constrained_proposal);
356                measurements[idx].dimensions.size.width =
357                    measurements[idx].size().width.max(stretch_width);
358            }
359        }
360
361        // Height: max of all children (after re-measurement for proper wrapped height)
362        // Important: Do NOT cap height to proposal - if text wraps, we need the full height
363        // Note: cross-axis-stretching children don't contribute to intrinsic height
364        let (max_above, max_below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
365        let max_height = max_above + max_below;
366
367        Size::new(final_width, max_height)
368    }
369
370    #[allow(clippy::too_many_lines)]
371    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
372        if children.is_empty() {
373            return vec![];
374        }
375
376        let spacing = self.spacing.get();
377        let total_spacing = if children.len() > 1 {
378            usize_to_f32(children.len() - 1) * spacing
379        } else {
380            0.0
381        };
382
383        let available_width = bounds.width() - total_spacing;
384
385        // First pass: measure all children with None to get intrinsic sizes
386        let intrinsic_proposal = ProposalSize::new(None, Some(bounds.height()));
387        let mut measurements: Vec<ChildMeasurement> = children
388            .iter()
389            .map(|child| ChildMeasurement {
390                dimensions: child.measure(intrinsic_proposal),
391                stretch_axis: child.stretch_axis(),
392            })
393            .collect();
394
395        // Calculate totals - HStack cares about main-axis (horizontal) stretching
396        let main_axis_stretch_indices: Vec<usize> = measurements
397            .iter()
398            .enumerate()
399            .filter(|(_, m)| m.stretches_main_axis())
400            .map(|(idx, _)| idx)
401            .collect();
402        let main_axis_stretch_count = main_axis_stretch_indices.len();
403
404        let fixed_indices: Vec<usize> = if main_axis_stretch_count > 0 {
405            measurements
406                .iter()
407                .enumerate()
408                .filter(|(_, m)| !m.stretches_main_axis())
409                .map(|(idx, _)| idx)
410                .collect()
411        } else {
412            (0..measurements.len()).collect()
413        };
414
415        compress_children(
416            &mut measurements,
417            children,
418            &fixed_indices,
419            available_width,
420            Some(bounds.height()),
421        );
422
423        // Calculate stretch child width from remaining space
424        let actual_fixed_width: f32 = measurements
425            .iter()
426            .enumerate()
427            .filter(|(_, m)| !m.stretches_main_axis())
428            .map(|(_, m)| m.size().width)
429            .sum();
430
431        let stretch_allocations = hstack_stretch_allocations(
432            &main_axis_stretch_indices,
433            available_width,
434            actual_fixed_width,
435        );
436
437        // Measure stretching children with their allocated width so cross-axis sizing is accurate.
438        if main_axis_stretch_count > 0 {
439            for &(idx, stretch_width) in &stretch_allocations {
440                let constrained_proposal =
441                    ProposalSize::new(Some(stretch_width), Some(bounds.height()));
442                measurements[idx].dimensions = children[idx].measure(constrained_proposal);
443                measurements[idx].dimensions.size.width =
444                    measurements[idx].size().width.max(stretch_width);
445            }
446        }
447
448        let (intrinsic_above, _intrinsic_below) =
449            hstack_intrinsic_cross_metrics(&measurements, self.alignment);
450        let guide_line =
451            bounds.y() + hstack_container_guide_offset(bounds, self.alignment, intrinsic_above);
452
453        // Place children
454        let mut rects = Vec::with_capacity(children.len());
455        let mut current_x = bounds.x();
456
457        for (i, measurement) in measurements.iter().enumerate() {
458            if i > 0 {
459                current_x += spacing;
460            }
461
462            // Handle cross-axis (vertical) stretching and infinite height
463            let child_height = if measurement.stretches_cross_axis() {
464                // CrossAxis in HStack means expand vertically to full bounds height
465                bounds.height()
466            } else if measurement.size().height.is_infinite() {
467                bounds.height()
468            } else {
469                measurement.size().height.min(bounds.height())
470            };
471
472            let child_width = measurement.size().width;
473
474            let mut adjusted_dimensions = measurement.dimensions.clone();
475            adjusted_dimensions.size = Size::new(child_width, child_height);
476
477            let y =
478                if measurement.stretches_cross_axis() || self.alignment == VerticalAlignment::Top {
479                    bounds.y()
480                } else if self.alignment == VerticalAlignment::Bottom {
481                    bounds.y() + bounds.height() - child_height
482                } else {
483                    let guide = adjusted_dimensions
484                        .vertical(self.alignment)
485                        .clamp(0.0, child_height);
486                    guide_line - guide
487                };
488
489            let rect = Rect::new(
490                Point::new(current_x, y),
491                Size::new(child_width, child_height),
492            );
493            rects.push(rect);
494
495            current_x += child_width;
496        }
497
498        rects
499    }
500
501    fn explicit_vertical(
502        &self,
503        alignment: VerticalAlignment,
504        _bounds: Rect,
505        children: &[PlacedSubview<'_>],
506    ) -> Option<f32> {
507        if alignment == VerticalAlignment::LastBaseline {
508            return children
509                .iter()
510                .filter_map(|child| child.explicit_vertical(alignment))
511                .max_by(f32::total_cmp);
512        }
513
514        if alignment == VerticalAlignment::FirstBaseline || alignment == self.alignment {
515            return children
516                .iter()
517                .filter_map(|child| child.explicit_vertical(alignment))
518                .min_by(f32::total_cmp);
519        }
520
521        None
522    }
523
524    fn watch_invalidation(
525        &self,
526        invalidate: LayoutInvalidationCallback,
527    ) -> Vec<nami::watcher::BoxWatcherGuard> {
528        vec![self.spacing.watch(move |_| invalidate())]
529    }
530}
531
532fn usize_to_f32(value: usize) -> f32 {
533    value
534        .to_f32()
535        .expect("HStackLayout: child count must be representable as f32")
536}
537
538impl<C> HStack<(C,)> {
539    /// Creates a horizontal stack with the provided alignment, spacing, and
540    /// children.
541    pub fn new(alignment: VerticalAlignment, spacing: f32, contents: C) -> Self {
542        Self {
543            layout: HStackLayout {
544                alignment,
545                spacing: Computed::constant(spacing),
546            },
547            contents: (contents,),
548        }
549    }
550}
551
552impl<C> HStack<C> {
553    /// Sets the vertical alignment for children in the stack.
554    #[must_use]
555    pub const fn alignment(mut self, alignment: VerticalAlignment) -> Self {
556        self.layout.alignment = alignment;
557        self
558    }
559
560    /// Sets the spacing between children in the stack.
561    ///
562    /// Accepts any numeric literal or signal of f32. Signal changes invalidate
563    /// only this stack's native layout.
564    #[must_use]
565    pub fn spacing(mut self, spacing: impl IntoSignalF32 + 'static) -> Self {
566        self.layout.spacing = spacing.into_signal_f32().computed();
567        self
568    }
569}
570
571crate::stack::impl_stack_for_each!(HStack, HStackLayout);
572
573impl<V> FromIterator<V> for HStack<(Vec<AnyView>,)>
574where
575    V: View,
576{
577    fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
578        let contents = iter.into_iter().map(AnyView::new).collect();
579        Self::new(VerticalAlignment::default(), 10.0, contents)
580    }
581}
582
583/// Convenience constructor that centres children and uses the default spacing.
584pub fn hstack<C>(contents: C) -> HStack<(C,)> {
585    HStack::new(VerticalAlignment::Center, 10.0, contents)
586}
587
588impl<C, F, V> View for HStack<ForEach<C, F, V>>
589where
590    C: Collection + Clone,
591    C::Item: Identifiable,
592    F: 'static + Fn(C::Item) -> V,
593    V: View,
594{
595    fn body(self, _env: &waterui_core::Environment) -> impl View {
596        // Inject the horizontal axis into the container
597        with(
598            LazyContainer::new(self.layout, self.contents),
599            Axis::Horizontal,
600        )
601    }
602}
603
604impl<C: TupleViews + 'static> View for HStack<(C,)> {
605    fn body(self, _env: &waterui_core::Environment) -> impl View {
606        // Inject the horizontal axis into the container
607        with(
608            FixedContainer::new(self.layout, self.contents.0),
609            Axis::Horizontal,
610        )
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::tests::CompressibleView;
618
619    struct MockSubView {
620        size: Size,
621        stretch_axis: StretchAxis,
622    }
623
624    impl SubView for MockSubView {
625        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
626            ViewDimensions::new(self.size)
627        }
628        fn stretch_axis(&self) -> StretchAxis {
629            self.stretch_axis
630        }
631        fn priority(&self) -> i32 {
632            0
633        }
634    }
635
636    struct ResponsiveSubView {
637        intrinsic: Size,
638        wrapped_height: f32,
639        wrap_at_or_below: f32,
640        stretch_axis: StretchAxis,
641    }
642
643    impl SubView for ResponsiveSubView {
644        fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
645            ViewDimensions::new(match proposal.width {
646                Some(width) if width <= self.wrap_at_or_below => {
647                    Size::new(width, self.wrapped_height)
648                }
649                Some(width) => Size::new(width, self.intrinsic.height),
650                None => self.intrinsic,
651            })
652        }
653
654        fn stretch_axis(&self) -> StretchAxis {
655            self.stretch_axis
656        }
657
658        fn priority(&self) -> i32 {
659            0
660        }
661    }
662
663    #[test]
664    fn test_hstack_size_two_children() {
665        let layout = HStackLayout {
666            alignment: VerticalAlignment::Center,
667            spacing: Computed::constant(10.0),
668        };
669
670        let mut child1 = MockSubView {
671            size: Size::new(50.0, 30.0),
672            stretch_axis: StretchAxis::None,
673        };
674        let mut child2 = MockSubView {
675            size: Size::new(60.0, 40.0),
676            stretch_axis: StretchAxis::None,
677        };
678
679        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
680
681        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
682
683        assert!((size.width - 120.0).abs() < f32::EPSILON); // 50 + 10 + 60
684        assert!((size.height - 40.0).abs() < f32::EPSILON); // max height
685    }
686
687    #[test]
688    fn test_hstack_with_spacer() {
689        let layout = HStackLayout {
690            alignment: VerticalAlignment::Center,
691            spacing: Computed::constant(0.0),
692        };
693
694        let mut child1 = MockSubView {
695            size: Size::new(30.0, 40.0),
696            stretch_axis: StretchAxis::None,
697        };
698        let mut spacer = MockSubView {
699            size: Size::zero(),
700            stretch_axis: StretchAxis::Both, // Spacer stretches in both directions
701        };
702        let mut child2 = MockSubView {
703            size: Size::new(30.0, 40.0),
704            stretch_axis: StretchAxis::None,
705        };
706
707        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
708
709        // With specified width, spacer should expand
710        let size = layout.size_that_fits(ProposalSize::new(Some(200.0), None), &children);
711
712        assert!((size.width - 200.0).abs() < f32::EPSILON);
713
714        // Place should distribute remaining space to spacer
715        let bounds = Rect::new(Point::zero(), Size::new(200.0, 40.0));
716
717        let mut child1 = MockSubView {
718            size: Size::new(30.0, 40.0),
719            stretch_axis: StretchAxis::None,
720        };
721        let mut spacer = MockSubView {
722            size: Size::zero(),
723            stretch_axis: StretchAxis::Both,
724        };
725        let mut child2 = MockSubView {
726            size: Size::new(30.0, 40.0),
727            stretch_axis: StretchAxis::None,
728        };
729        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
730
731        let rects = layout.place(bounds, &children);
732
733        assert!((rects[0].width() - 30.0).abs() < f32::EPSILON);
734        assert!((rects[1].width() - 140.0).abs() < f32::EPSILON); // 200 - 30 - 30
735        assert!((rects[2].width() - 30.0).abs() < f32::EPSILON);
736        assert!((rects[2].x() - 170.0).abs() < f32::EPSILON); // 30 + 140
737    }
738
739    /// A child that fills the cross axis still contributes the height it
740    /// measures. Filling means "at least this, and more if you have it": a row
741    /// that reported less than its tallest child would be handed less, and the
742    /// child would fill a box too small for it.
743    #[test]
744    fn a_cross_filling_child_still_sets_the_row_height() {
745        // Vertical stretch component in HStack: stretches HEIGHT but has fixed WIDTH
746        // This is like a Slider/TextField rotated for use in HStack context
747        let layout = HStackLayout {
748            alignment: VerticalAlignment::Center,
749            spacing: Computed::constant(10.0),
750        };
751
752        let mut label = MockSubView {
753            size: Size::new(50.0, 20.0),
754            stretch_axis: StretchAxis::None,
755        };
756        // A vertically-stretching component: fixed width, wants to fill height
757        let mut vertical_stretch = MockSubView {
758            size: Size::new(40.0, 100.0), // reports minimum width, tall height
759            stretch_axis: StretchAxis::Vertical, // stretches height only
760        };
761        let mut button = MockSubView {
762            size: Size::new(80.0, 44.0),
763            stretch_axis: StretchAxis::None,
764        };
765
766        let children: Vec<&dyn SubView> = vec![&mut label, &mut vertical_stretch, &mut button];
767
768        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
769
770        // Width: all children contribute (vertical_stretch doesn't stretch horizontally)
771        // = 50 + 10 + 40 + 10 + 80 = 190
772        assert!((size.width - 190.0).abs() < f32::EPSILON);
773        // Height: max of every child = max(20, 100, 44) = 100
774        assert!(
775            (size.height - 100.0).abs() < f32::EPSILON,
776            "the stretching child's own height has to reach the row, got {}",
777            size.height
778        );
779    }
780
781    #[test]
782    fn test_hstack_compresses_equal_children_evenly() {
783        // Seven equal 40pt columns with 8pt spacing (a calendar's day row) in
784        // a 272pt bound: the 56pt deficit must be shared equally — every
785        // column shrinks to 32 at a uniform 40pt pitch — never taken
786        // order-dependently from the leading children.
787        let layout = HStackLayout {
788            alignment: VerticalAlignment::Center,
789            spacing: Computed::constant(8.0),
790        };
791
792        let mut columns: Vec<CompressibleView> = (0..7)
793            .map(|_| CompressibleView {
794                ideal: Size::new(40.0, 40.0),
795                floor: 0.0,
796            })
797            .collect();
798        let children: Vec<&dyn SubView> = columns
799            .iter_mut()
800            .map(|child| child as &dyn SubView)
801            .collect();
802
803        let bounds = Rect::new(Point::zero(), Size::new(272.0, 40.0));
804        let rects = layout.place(bounds, &children);
805
806        for rect in &rects {
807            assert!(
808                (rect.width() - 32.0).abs() < 0.001,
809                "expected even 32pt columns, got {}",
810                rect.width()
811            );
812        }
813        for pair in rects.windows(2) {
814            assert!(
815                ((pair[1].x() - pair[0].x()) - 40.0).abs() < 0.001,
816                "expected uniform 40pt pitch, got {}",
817                pair[1].x() - pair[0].x()
818            );
819        }
820    }
821
822    #[test]
823    fn test_hstack_compression_keeps_small_children_intrinsic() {
824        // A 50pt label next to a 200pt text in 140pt of space: the text alone
825        // absorbs the deficit (clamped to 90) while the label keeps its
826        // intrinsic width.
827        let layout = HStackLayout {
828            alignment: VerticalAlignment::Center,
829            spacing: Computed::constant(10.0),
830        };
831
832        let mut label = MockSubView {
833            size: Size::new(50.0, 20.0),
834            stretch_axis: StretchAxis::None,
835        };
836        let mut long_text = ResponsiveSubView {
837            intrinsic: Size::new(200.0, 20.0),
838            wrapped_height: 40.0,
839            wrap_at_or_below: 60.0,
840            stretch_axis: StretchAxis::None,
841        };
842        let children: Vec<&dyn SubView> = vec![&mut label, &mut long_text];
843
844        let bounds = Rect::new(Point::zero(), Size::new(150.0, 40.0));
845        let rects = layout.place(bounds, &children);
846
847        assert!((rects[0].width() - 50.0).abs() < 0.001);
848        assert!((rects[1].width() - 90.0).abs() < 0.001);
849    }
850
851    #[test]
852    fn test_hstack_compression_never_goes_below_a_reported_minimum() {
853        // Two children that will not go below 20pt, in 10pt of space: the row
854        // overflows rather than squeezing them past what they said they need.
855        // The floor is each child's own answer, not a constant the stack picked.
856        let layout = HStackLayout {
857            alignment: VerticalAlignment::Center,
858            spacing: Computed::constant(0.0),
859        };
860
861        let mut first = CompressibleView {
862            ideal: Size::new(60.0, 20.0),
863            floor: 20.0,
864        };
865        let mut second = CompressibleView {
866            ideal: Size::new(60.0, 20.0),
867            floor: 20.0,
868        };
869        let children: Vec<&dyn SubView> = vec![&mut first, &mut second];
870
871        let bounds = Rect::new(Point::zero(), Size::new(10.0, 20.0));
872        let rects = layout.place(bounds, &children);
873
874        assert!((rects[0].width() - 20.0).abs() < 0.001);
875        assert!((rects[1].width() - 20.0).abs() < 0.001);
876    }
877
878    #[test]
879    fn test_hstack_measures_stretch_child_with_allocated_width_for_height() {
880        let layout = HStackLayout {
881            alignment: VerticalAlignment::Center,
882            spacing: Computed::constant(0.0),
883        };
884
885        let mut fixed = MockSubView {
886            size: Size::new(4.0, 10.0),
887            stretch_axis: StretchAxis::None,
888        };
889
890        // Simulate a content-bearing stretch child whose height increases when width is constrained.
891        let mut stretch = ResponsiveSubView {
892            intrinsic: Size::new(100.0, 20.0),
893            wrapped_height: 40.0,
894            wrap_at_or_below: 60.0,
895            stretch_axis: StretchAxis::Horizontal,
896        };
897
898        let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
899
900        let size = layout.size_that_fits(ProposalSize::new(Some(40.0), None), &children);
901
902        assert!((size.width - 40.0).abs() < f32::EPSILON);
903        assert!((size.height - 40.0).abs() < f32::EPSILON);
904    }
905
906    #[test]
907    fn test_hstack_place_uses_stretch_child_wrapped_height() {
908        let layout = HStackLayout {
909            alignment: VerticalAlignment::Center,
910            spacing: Computed::constant(0.0),
911        };
912
913        let bounds = Rect::new(Point::zero(), Size::new(40.0, 40.0));
914
915        let mut fixed = MockSubView {
916            size: Size::new(4.0, 10.0),
917            stretch_axis: StretchAxis::None,
918        };
919
920        let mut stretch = ResponsiveSubView {
921            intrinsic: Size::new(100.0, 20.0),
922            wrapped_height: 40.0,
923            wrap_at_or_below: 60.0,
924            stretch_axis: StretchAxis::Horizontal,
925        };
926
927        let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
928
929        let rects = layout.place(bounds, &children);
930
931        assert!((rects[0].width() - 4.0).abs() < f32::EPSILON);
932        assert!((rects[1].width() - 36.0).abs() < f32::EPSILON);
933        assert!((rects[1].height() - 40.0).abs() < f32::EPSILON);
934    }
935
936    #[test]
937    fn test_hstack_min_size_query_correctly_sums_children() {
938        // When ProposalSize::ZERO is used (min size query), ALL children's widths
939        // should be summed to ensure window minimum width calculation is correct.
940        // Previous bug: if spacer (stretch) existed, it would return 0 width for min size query.
941        let layout = HStackLayout {
942            alignment: VerticalAlignment::Center,
943            spacing: Computed::constant(10.0),
944        };
945
946        let mut label = MockSubView {
947            size: Size::new(50.0, 20.0),
948            stretch_axis: StretchAxis::None,
949        };
950        let mut spacer = MockSubView {
951            size: Size::zero(),
952            stretch_axis: StretchAxis::Both,
953        };
954        let mut button = MockSubView {
955            size: Size::new(80.0, 44.0),
956            stretch_axis: StretchAxis::None,
957        };
958
959        let children: Vec<&dyn SubView> = vec![&mut label, &mut spacer, &mut button];
960
961        // With ZERO proposal (min size query)
962        let min_size = layout.size_that_fits(ProposalSize::ZERO, &children);
963
964        // Width: 50 + 10 + 0 + 10 + 80 = 150
965        assert!(
966            (min_size.width - 150.0).abs() < f32::EPSILON,
967            "Min size query should return sum of child min widths (150.0), got {}",
968            min_size.width
969        );
970
971        // Verify regular behavior (Unspecified) still works
972        let intrinsic_size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
973        // Should also be 150 because spacer has 0 intrinsic width
974        assert!((intrinsic_size.width - 150.0).abs() < f32::EPSILON);
975    }
976}