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 waterui_core::{
6    AnyView, IntoSignalF32, View, env::with, id::Identifiable, layout::LayoutInvalidationCallback,
7    view::TupleViews, views::ForEach,
8};
9
10use crate::{
11    Layout, LazyContainer, PlacedSubview, Point, ProposalSize, Rect, Size, StretchAxis, SubView,
12    SubviewPlacement,
13    container::FixedContainer,
14    stack::{
15        Axis, VerticalAlignment,
16        distribute::{
17            ChildMeasurement, LineAnchor, container_line, cross_envelope, measure_stack,
18            place_cross_extent, stack_spacing,
19        },
20        stack_stretch_axis,
21    },
22};
23
24/// A view that arranges its children in a horizontal line.
25///
26/// Use an `HStack` to arrange views side-by-side. The stack sizes itself to fit
27/// its contents, distributing available space among its children.
28///
29/// ```rust
30/// # use waterui::prelude::*;
31/// # fn greeting() -> impl View {
32/// hstack((
33///     text("Hello"),
34///     text("World"),
35/// ))
36/// # }
37/// ```
38///
39/// You can customize the spacing between children and their vertical alignment:
40///
41/// ```rust
42/// # use waterui::prelude::*;
43/// # fn spaced() -> impl View {
44/// HStack::new(VerticalAlignment::Top, 20.0, (
45///     text("First"),
46///     text("Second"),
47/// ))
48/// # }
49/// ```
50///
51/// Use [`spacer()`](crate::spacer()) to push content to the sides:
52///
53/// ```rust
54/// # use waterui::prelude::*;
55/// # fn toolbar() -> impl View {
56/// hstack((
57///     text("Leading"),
58///     spacer(),
59///     text("Trailing"),
60/// ))
61/// # }
62/// ```
63#[derive(Debug, Clone)]
64pub struct HStack<C> {
65    layout: HStackLayout,
66    contents: C,
67}
68
69/// Layout engine shared by the public [`HStack`] view.
70#[derive(Debug, Clone)]
71pub struct HStackLayout {
72    /// The vertical alignment of children within the stack.
73    pub alignment: VerticalAlignment,
74    /// The spacing between children in the stack.
75    pub spacing: Computed<f32>,
76}
77
78impl Default for HStackLayout {
79    fn default() -> Self {
80        Self {
81            alignment: VerticalAlignment::Center,
82            spacing: Computed::constant(10.0),
83        }
84    }
85}
86
87/// The tallest child above and below the alignment guide.
88///
89/// Every child that reports a height counts, the ones that fill the cross axis
90/// included. Filling means "at least what I measure, and more if you have it",
91/// not "nothing": leaving a filler out of this makes a row of nothing-but-
92/// fillers report zero height, and its parent then hands it zero height to
93/// fill. A child that answers an unbounded height is answering "as much as you
94/// have" rather than naming a size, so it sets no floor here — the fill pass in
95/// [`Layout::place`] is what gives it the row's height.
96/// The vertical envelope of the row's children lined up on `alignment`.
97fn hstack_intrinsic_cross_metrics(
98    measurements: &[ChildMeasurement],
99    alignment: VerticalAlignment,
100) -> (f32, f32) {
101    cross_envelope(measurements.iter().map(|measurement| {
102        (
103            measurement.size().height,
104            measurement.vertical_guide(alignment),
105        )
106    }))
107}
108
109impl Layout for HStackLayout {
110    /// An `HStack` claims nothing of its own — a row of labels is content-sized,
111    /// like `SwiftUI`'s, and a parent placing an undersized row centers it per
112    /// its alignment. What it does claim is whatever its children claim: filling
113    /// comes from children that ask for it (`Spacer`, greedy frames, `Color`),
114    /// and the ask has to survive the trip up through every container between
115    /// that child and whoever owns the space.
116    fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
117        stack_stretch_axis(Axis::Horizontal, children)
118    }
119
120    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
121        if children.is_empty() {
122            return Size::zero();
123        }
124
125        let spacing = self.spacing.get();
126        let measurements = measure_stack(Axis::Horizontal, proposal, spacing, children);
127        let width = measurements.iter().map(|m| m.size().width).sum::<f32>()
128            + stack_spacing(spacing, children.len());
129        let (above, below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
130        let height = if measurements.iter().any(|m| m.size().height.is_infinite()) {
131            f32::INFINITY
132        } else {
133            above + below
134        };
135        Size::new(width, height)
136    }
137
138    fn place(
139        &self,
140        bounds: Rect,
141        _proposal: ProposalSize,
142        children: &[&dyn SubView],
143    ) -> Vec<SubviewPlacement> {
144        if children.is_empty() {
145            return vec![];
146        }
147
148        let spacing = self.spacing.get();
149        // Placement is a fresh negotiation against the bounds the row was
150        // placed in: every child is allocated from the row's resolved width
151        // and proposed the row's resolved height, so what a child lays out
152        // under is the extent it actually has, never the offer the row was
153        // measured with.
154        let placement = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
155        let measurements = measure_stack(Axis::Horizontal, placement, spacing, children);
156
157        let (above, below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
158        let guide_line = bounds.y()
159            + container_line(
160                bounds.height(),
161                LineAnchor::vertical(self.alignment),
162                above,
163                below,
164            );
165
166        // Place children
167        let mut placements = Vec::with_capacity(children.len());
168        let mut current_x = bounds.x();
169
170        for (i, measurement) in measurements.iter().enumerate() {
171            if i > 0 {
172                current_x += spacing;
173            }
174
175            let child_height = place_cross_extent(
176                measurement.size().height,
177                bounds.height(),
178                measurement.stretches_cross_axis(),
179            );
180
181            let child_width = measurement.size().width;
182
183            let mut adjusted_dimensions = measurement.dimensions.clone();
184            adjusted_dimensions.size = Size::new(child_width, child_height);
185
186            // A child that fills the cross axis spans the bounds; every other
187            // child sits with its guide on the row's line, wherever that
188            // guide lies.
189            let y = if measurement.stretches_cross_axis() {
190                bounds.y()
191            } else {
192                guide_line - adjusted_dimensions.vertical(self.alignment)
193            };
194
195            let rect = Rect::new(
196                Point::new(current_x, y),
197                Size::new(child_width, child_height),
198            );
199            placements.push(SubviewPlacement::new(rect, measurement.proposal));
200
201            current_x += child_width;
202        }
203
204        placements
205    }
206
207    fn explicit_vertical(
208        &self,
209        alignment: VerticalAlignment,
210        _bounds: Rect,
211        children: &[PlacedSubview<'_>],
212    ) -> Option<f32> {
213        if alignment == VerticalAlignment::LastBaseline {
214            return children
215                .iter()
216                .filter_map(|child| child.explicit_vertical(alignment))
217                .max_by(f32::total_cmp);
218        }
219
220        if alignment == VerticalAlignment::FirstBaseline || alignment == self.alignment {
221            return children
222                .iter()
223                .filter_map(|child| child.explicit_vertical(alignment))
224                .min_by(f32::total_cmp);
225        }
226
227        None
228    }
229
230    fn watch_invalidation(
231        &self,
232        invalidate: LayoutInvalidationCallback,
233    ) -> Vec<nami::watcher::BoxWatcherGuard> {
234        vec![self.spacing.watch(move |_| invalidate())]
235    }
236}
237
238impl<C: TupleViews> HStack<(C,)> {
239    /// Creates a horizontal stack with the provided alignment, spacing, and
240    /// children.
241    pub fn new(alignment: VerticalAlignment, spacing: f32, contents: C) -> Self {
242        Self {
243            layout: HStackLayout {
244                alignment,
245                spacing: Computed::constant(spacing),
246            },
247            contents: (contents,),
248        }
249    }
250}
251
252impl<C> HStack<C> {
253    /// Sets the vertical alignment for children in the stack — a
254    /// [`VerticalAlignment`] or one of the tokens [`Top`], [`Center`],
255    /// [`Bottom`], [`FirstBaseline`], [`LastBaseline`].
256    ///
257    /// [`Top`]: crate::Top
258    /// [`Center`]: crate::Center
259    /// [`Bottom`]: crate::Bottom
260    /// [`FirstBaseline`]: crate::FirstBaseline
261    /// [`LastBaseline`]: crate::LastBaseline
262    #[must_use]
263    pub fn alignment(mut self, alignment: impl Into<VerticalAlignment>) -> Self {
264        self.layout.alignment = alignment.into();
265        self
266    }
267
268    crate::alignment::vertical_alignment_methods!();
269
270    /// Sets the spacing between children in the stack.
271    ///
272    /// Accepts any numeric literal or signal of f32. Signal changes invalidate
273    /// only this stack's native layout.
274    #[must_use]
275    pub fn spacing(mut self, spacing: impl IntoSignalF32 + 'static) -> Self {
276        self.layout.spacing = spacing.into_signal_f32().computed();
277        self
278    }
279}
280
281crate::stack::impl_stack_for_each!(HStack, HStackLayout);
282
283impl<V> FromIterator<V> for HStack<(Vec<AnyView>,)>
284where
285    V: View,
286{
287    fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
288        let contents = iter.into_iter().map(AnyView::new).collect();
289        Self::new(VerticalAlignment::default(), 10.0, contents)
290    }
291}
292
293/// Convenience constructor that centres children and uses the default spacing.
294pub fn hstack<C: TupleViews>(contents: C) -> HStack<(C,)> {
295    HStack::new(VerticalAlignment::Center, 10.0, contents)
296}
297
298impl<C, F, V> View for HStack<ForEach<C, F, V>>
299where
300    C: Collection + Clone,
301    C::Item: Identifiable,
302    F: 'static + Fn(C::Item) -> V,
303    V: View,
304{
305    fn body(self, _env: &waterui_core::Environment) -> impl View {
306        // Inject the horizontal axis into the container
307        with(
308            LazyContainer::new(self.layout, self.contents),
309            Axis::Horizontal,
310        )
311    }
312
313    /// Resolves to `LazyContainer`, which cannot enumerate children without
314    /// materializing them and answers its layout's axis over an empty child
315    /// set — matching `LazyContainer::stretch_axis`.
316    fn stretch_axis(&self) -> StretchAxis {
317        self.layout.stretch_axis(&[])
318    }
319}
320
321impl<C: TupleViews + 'static> View for HStack<(C,)> {
322    fn body(self, _env: &waterui_core::Environment) -> impl View {
323        // Inject the horizontal axis into the container
324        with(
325            FixedContainer::new(self.layout, self.contents.0),
326            Axis::Horizontal,
327        )
328    }
329
330    /// Resolves to `FixedContainer` over the same layout and children;
331    /// reports what that container would — matching `FixedContainer`'s
332    /// `View::stretch_axis`.
333    fn stretch_axis(&self) -> StretchAxis {
334        self.layout.stretch_axis(&self.contents.0.stretch_axes())
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::ViewDimensions;
342    use crate::tests::CompressibleView;
343
344    struct MockSubView {
345        size: Size,
346        stretch_axis: StretchAxis,
347    }
348
349    impl SubView for MockSubView {
350        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
351            ViewDimensions::new(self.size)
352        }
353        fn stretch_axis(&self) -> StretchAxis {
354            self.stretch_axis
355        }
356        fn priority(&self) -> i32 {
357            0
358        }
359    }
360
361    struct ResponsiveSubView {
362        intrinsic: Size,
363        wrapped_height: f32,
364        wrap_at_or_below: f32,
365        stretch_axis: StretchAxis,
366    }
367
368    impl SubView for ResponsiveSubView {
369        fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
370            ViewDimensions::new(match proposal.width {
371                Some(width) if width <= self.wrap_at_or_below => {
372                    Size::new(width, self.wrapped_height)
373                }
374                Some(width) => Size::new(width, self.intrinsic.height),
375                None => self.intrinsic,
376            })
377        }
378
379        fn stretch_axis(&self) -> StretchAxis {
380            self.stretch_axis
381        }
382
383        fn priority(&self) -> i32 {
384            0
385        }
386    }
387
388    #[test]
389    fn test_hstack_size_two_children() {
390        let layout = HStackLayout {
391            alignment: VerticalAlignment::Center,
392            spacing: Computed::constant(10.0),
393        };
394
395        let mut child1 = MockSubView {
396            size: Size::new(50.0, 30.0),
397            stretch_axis: StretchAxis::None,
398        };
399        let mut child2 = MockSubView {
400            size: Size::new(60.0, 40.0),
401            stretch_axis: StretchAxis::None,
402        };
403
404        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
405
406        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
407
408        assert!((size.width - 120.0).abs() < f32::EPSILON); // 50 + 10 + 60
409        assert!((size.height - 40.0).abs() < f32::EPSILON); // max height
410    }
411
412    #[test]
413    fn test_hstack_with_spacer() {
414        let layout = HStackLayout {
415            alignment: VerticalAlignment::Center,
416            spacing: Computed::constant(0.0),
417        };
418
419        let mut child1 = MockSubView {
420            size: Size::new(30.0, 40.0),
421            stretch_axis: StretchAxis::None,
422        };
423        let mut spacer = MockSubView {
424            size: Size::zero(),
425            stretch_axis: StretchAxis::Both, // Spacer stretches in both directions
426        };
427        let mut child2 = MockSubView {
428            size: Size::new(30.0, 40.0),
429            stretch_axis: StretchAxis::None,
430        };
431
432        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
433
434        // With specified width, spacer should expand
435        let size = layout.size_that_fits(ProposalSize::new(Some(200.0), None), &children);
436
437        assert!((size.width - 200.0).abs() < f32::EPSILON);
438
439        // Place should distribute remaining space to spacer
440        let bounds = Rect::new(Point::zero(), Size::new(200.0, 40.0));
441
442        let mut child1 = MockSubView {
443            size: Size::new(30.0, 40.0),
444            stretch_axis: StretchAxis::None,
445        };
446        let mut spacer = MockSubView {
447            size: Size::zero(),
448            stretch_axis: StretchAxis::Both,
449        };
450        let mut child2 = MockSubView {
451            size: Size::new(30.0, 40.0),
452            stretch_axis: StretchAxis::None,
453        };
454        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
455
456        let placements = layout.place(bounds, ProposalSize::new(Some(200.0), None), &children);
457
458        assert!((placements[0].frame.width() - 30.0).abs() < f32::EPSILON);
459        assert!((placements[1].frame.width() - 140.0).abs() < f32::EPSILON); // 200 - 30 - 30
460        assert!((placements[2].frame.width() - 30.0).abs() < f32::EPSILON);
461        assert!((placements[2].frame.x() - 170.0).abs() < f32::EPSILON); // 30 + 140
462    }
463
464    /// A child that fills the cross axis still contributes the height it
465    /// measures. Filling means "at least this, and more if you have it": a row
466    /// that reported less than its tallest child would be handed less, and the
467    /// child would fill a box too small for it.
468    #[test]
469    fn a_cross_filling_child_still_sets_the_row_height() {
470        // Vertical stretch component in HStack: stretches HEIGHT but has fixed WIDTH
471        // This is like a Slider/TextField rotated for use in HStack context
472        let layout = HStackLayout {
473            alignment: VerticalAlignment::Center,
474            spacing: Computed::constant(10.0),
475        };
476
477        let mut label = MockSubView {
478            size: Size::new(50.0, 20.0),
479            stretch_axis: StretchAxis::None,
480        };
481        // A vertically-stretching component: fixed width, wants to fill height
482        let mut vertical_stretch = MockSubView {
483            size: Size::new(40.0, 100.0), // reports minimum width, tall height
484            stretch_axis: StretchAxis::Vertical, // stretches height only
485        };
486        let mut button = MockSubView {
487            size: Size::new(80.0, 44.0),
488            stretch_axis: StretchAxis::None,
489        };
490
491        let children: Vec<&dyn SubView> = vec![&mut label, &mut vertical_stretch, &mut button];
492
493        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
494
495        // Width: all children contribute (vertical_stretch doesn't stretch horizontally)
496        // = 50 + 10 + 40 + 10 + 80 = 190
497        assert!((size.width - 190.0).abs() < f32::EPSILON);
498        // Height: max of every child = max(20, 100, 44) = 100
499        assert!(
500            (size.height - 100.0).abs() < f32::EPSILON,
501            "the stretching child's own height has to reach the row, got {}",
502            size.height
503        );
504    }
505
506    #[test]
507    fn test_hstack_compresses_equal_children_evenly_bounded_proposal() {
508        // Seven equal 40pt columns with 8pt spacing (a calendar's day row) in
509        // a 272pt bound: the 56pt deficit must be shared equally — every
510        // column shrinks to 32 at a uniform 40pt pitch — never taken
511        // order-dependently from the leading children.
512        let layout = HStackLayout {
513            alignment: VerticalAlignment::Center,
514            spacing: Computed::constant(8.0),
515        };
516
517        let mut columns: Vec<CompressibleView> = (0..7)
518            .map(|_| CompressibleView {
519                ideal: Size::new(40.0, 40.0),
520                floor: 0.0,
521            })
522            .collect();
523        let children: Vec<&dyn SubView> = columns
524            .iter_mut()
525            .map(|child| child as &dyn SubView)
526            .collect();
527
528        let bounds = Rect::new(Point::zero(), Size::new(272.0, 40.0));
529        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
530        let placements = layout.place(bounds, proposal, &children);
531
532        for placement in &placements {
533            assert!(
534                (placement.frame.width() - 32.0).abs() < 0.001,
535                "expected even 32pt columns, got {}",
536                placement.frame.width()
537            );
538        }
539        for pair in placements.windows(2) {
540            assert!(
541                ((pair[1].frame.x() - pair[0].frame.x()) - 40.0).abs() < 0.001,
542                "expected uniform 40pt pitch, got {}",
543                pair[1].frame.x() - pair[0].frame.x()
544            );
545        }
546    }
547
548    #[test]
549    fn test_hstack_compression_keeps_small_children_intrinsic_bounded_proposal() {
550        // A 50pt label next to a 200pt text in 140pt of space: the text alone
551        // absorbs the deficit (clamped to 90) while the label keeps its
552        // intrinsic width.
553        let layout = HStackLayout {
554            alignment: VerticalAlignment::Center,
555            spacing: Computed::constant(10.0),
556        };
557
558        let mut label = MockSubView {
559            size: Size::new(50.0, 20.0),
560            stretch_axis: StretchAxis::None,
561        };
562        let mut long_text = ResponsiveSubView {
563            intrinsic: Size::new(200.0, 20.0),
564            wrapped_height: 40.0,
565            wrap_at_or_below: 60.0,
566            stretch_axis: StretchAxis::None,
567        };
568        let children: Vec<&dyn SubView> = vec![&mut label, &mut long_text];
569
570        let bounds = Rect::new(Point::zero(), Size::new(150.0, 40.0));
571        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
572        let placements = layout.place(bounds, proposal, &children);
573
574        assert!((placements[0].frame.width() - 50.0).abs() < 0.001);
575        assert!((placements[1].frame.width() - 90.0).abs() < 0.001);
576    }
577
578    #[test]
579    fn test_hstack_compression_never_goes_below_a_reported_minimum_bounded_proposal() {
580        // Two children that will not go below 20pt, in 10pt of space: the row
581        // overflows rather than squeezing them past what they said they need.
582        // The floor is each child's own answer, not a constant the stack picked.
583        let layout = HStackLayout {
584            alignment: VerticalAlignment::Center,
585            spacing: Computed::constant(0.0),
586        };
587
588        let mut first = CompressibleView {
589            ideal: Size::new(60.0, 20.0),
590            floor: 20.0,
591        };
592        let mut second = CompressibleView {
593            ideal: Size::new(60.0, 20.0),
594            floor: 20.0,
595        };
596        let children: Vec<&dyn SubView> = vec![&mut first, &mut second];
597
598        let bounds = Rect::new(Point::zero(), Size::new(10.0, 20.0));
599        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
600        let placements = layout.place(bounds, proposal, &children);
601
602        assert!((placements[0].frame.width() - 20.0).abs() < 0.001);
603        assert!((placements[1].frame.width() - 20.0).abs() < 0.001);
604    }
605
606    #[test]
607    fn test_hstack_measures_stretch_child_with_allocated_width_for_height() {
608        let layout = HStackLayout {
609            alignment: VerticalAlignment::Center,
610            spacing: Computed::constant(0.0),
611        };
612
613        let mut fixed = MockSubView {
614            size: Size::new(4.0, 10.0),
615            stretch_axis: StretchAxis::None,
616        };
617
618        // Simulate a content-bearing stretch child whose height increases when width is constrained.
619        let mut stretch = ResponsiveSubView {
620            intrinsic: Size::new(100.0, 20.0),
621            wrapped_height: 40.0,
622            wrap_at_or_below: 60.0,
623            stretch_axis: StretchAxis::Horizontal,
624        };
625
626        let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
627
628        let size = layout.size_that_fits(ProposalSize::new(Some(40.0), None), &children);
629
630        assert!((size.width - 40.0).abs() < f32::EPSILON);
631        assert!((size.height - 40.0).abs() < f32::EPSILON);
632    }
633
634    #[test]
635    fn test_hstack_place_uses_stretch_child_wrapped_height_bounded_proposal() {
636        let layout = HStackLayout {
637            alignment: VerticalAlignment::Center,
638            spacing: Computed::constant(0.0),
639        };
640
641        let bounds = Rect::new(Point::zero(), Size::new(40.0, 40.0));
642
643        let mut fixed = MockSubView {
644            size: Size::new(4.0, 10.0),
645            stretch_axis: StretchAxis::None,
646        };
647
648        let mut stretch = ResponsiveSubView {
649            intrinsic: Size::new(100.0, 20.0),
650            wrapped_height: 40.0,
651            wrap_at_or_below: 60.0,
652            stretch_axis: StretchAxis::Horizontal,
653        };
654
655        let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
656
657        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
658        let placements = layout.place(bounds, proposal, &children);
659
660        assert!((placements[0].frame.width() - 4.0).abs() < f32::EPSILON);
661        assert!((placements[1].frame.width() - 36.0).abs() < f32::EPSILON);
662        assert!((placements[1].frame.height() - 40.0).abs() < f32::EPSILON);
663    }
664
665    #[test]
666    fn test_hstack_min_size_query_correctly_sums_children() {
667        // When ProposalSize::ZERO is used (min size query), ALL children's widths
668        // should be summed to ensure window minimum width calculation is correct.
669        // Previous bug: if spacer (stretch) existed, it would return 0 width for min size query.
670        let layout = HStackLayout {
671            alignment: VerticalAlignment::Center,
672            spacing: Computed::constant(10.0),
673        };
674
675        let mut label = MockSubView {
676            size: Size::new(50.0, 20.0),
677            stretch_axis: StretchAxis::None,
678        };
679        let mut spacer = MockSubView {
680            size: Size::zero(),
681            stretch_axis: StretchAxis::Both,
682        };
683        let mut button = MockSubView {
684            size: Size::new(80.0, 44.0),
685            stretch_axis: StretchAxis::None,
686        };
687
688        let children: Vec<&dyn SubView> = vec![&mut label, &mut spacer, &mut button];
689
690        // With ZERO proposal (min size query)
691        let min_size = layout.size_that_fits(ProposalSize::ZERO, &children);
692
693        // Width: 50 + 10 + 0 + 10 + 80 = 150
694        assert!(
695            (min_size.width - 150.0).abs() < f32::EPSILON,
696            "Min size query should return sum of child min widths (150.0), got {}",
697            min_size.width
698        );
699
700        // Verify regular behavior (Unspecified) still works
701        let intrinsic_size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
702        // Should also be 150 because spacer has 0 intrinsic width
703        assert!((intrinsic_size.width - 150.0).abs() < f32::EPSILON);
704    }
705}