Skip to main content

waterui_layout/stack/
vstack.rs

1//! Vertical 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    HorizontalAlignment, Layout, LazyContainer, PlacedSubview, Point, ProposalSize, Rect, Size,
12    StretchAxis, SubView, ViewDimensions,
13    container::FixedContainer,
14    stack::{
15        Axis,
16        distribute::{Extent, compress_to_fit, exceeds},
17        stack_stretch_axis,
18    },
19};
20
21/// Layout engine shared by the public [`VStack`] view.
22#[derive(Debug, Clone)]
23pub struct VStackLayout {
24    /// The horizontal alignment of children within the stack.
25    pub alignment: HorizontalAlignment,
26    /// The spacing between children in the stack.
27    pub spacing: Computed<f32>,
28}
29
30impl Default for VStackLayout {
31    fn default() -> Self {
32        Self {
33            alignment: HorizontalAlignment::default(),
34            spacing: Computed::constant(10.0),
35        }
36    }
37}
38
39/// Cached measurement for a child during layout
40struct ChildMeasurement {
41    dimensions: ViewDimensions,
42    stretch_axis: StretchAxis,
43}
44
45impl ChildMeasurement {
46    const fn size(&self) -> Size {
47        self.dimensions.size
48    }
49
50    fn horizontal_guide(&self, alignment: HorizontalAlignment) -> f32 {
51        self.dimensions.horizontal(alignment)
52    }
53
54    /// Returns true if this child stretches vertically (for `VStack` height distribution).
55    /// In `VStack` context:
56    /// - `MainAxis` means vertical (`VStack`'s main axis)
57    /// - `CrossAxis` means horizontal (`VStack`'s cross axis)
58    const fn stretches_main_axis(&self) -> bool {
59        matches!(
60            self.stretch_axis,
61            StretchAxis::Vertical | StretchAxis::Both | StretchAxis::MainAxis
62        )
63    }
64
65    /// Returns true if this child stretches horizontally (for `VStack` width expansion).
66    /// In `VStack` context:
67    /// - `CrossAxis` means horizontal (`VStack`'s cross axis)
68    const fn stretches_cross_axis(&self) -> bool {
69        matches!(
70            self.stretch_axis,
71            StretchAxis::Horizontal | StretchAxis::Both | StretchAxis::CrossAxis
72        )
73    }
74}
75
76/// The widest child either side of the alignment guide.
77///
78/// Every child that reports a width counts, the ones that fill the cross axis
79/// included. Filling means "at least what I measure, and more if you have it",
80/// not "nothing": leaving a filler out of this makes a column of nothing-but-
81/// fillers report zero width, and its parent then hands it zero width to fill.
82/// A child that answers an unbounded width is answering "as much as you have"
83/// rather than naming a size, so it sets no floor here — the fill pass in
84/// [`Layout::place`] is what gives it the column's width.
85fn vstack_intrinsic_cross_metrics(
86    measurements: &[ChildMeasurement],
87    alignment: HorizontalAlignment,
88) -> (f32, f32) {
89    let mut max_leading = 0.0_f32;
90    let mut max_trailing = 0.0_f32;
91
92    for measurement in measurements
93        .iter()
94        .filter(|measurement| measurement.size().width.is_finite())
95    {
96        let size = measurement.size();
97        let guide = measurement
98            .horizontal_guide(alignment)
99            .clamp(0.0, size.width);
100        max_leading = max_leading.max(guide);
101        max_trailing = max_trailing.max((size.width - guide).max(0.0));
102    }
103
104    (max_leading, max_trailing)
105}
106
107/// Compresses the children that do not stretch vertically into `available`,
108/// taking height from the lowest layout priorities first and never pushing a
109/// child below the height it reports when proposed zero.
110///
111/// `HStack` has always done this on its axis; `VStack` used to just sum heights,
112/// so a column taller than its bounds overflowed even when its content could
113/// have wrapped or truncated into the space.
114fn compress_children(
115    measurements: &mut [ChildMeasurement],
116    children: &[&dyn SubView],
117    compress_indices: &[usize],
118    available: f32,
119    width_proposal: Option<f32>,
120) {
121    if compress_indices.is_empty() {
122        return;
123    }
124
125    // Probing minimums only pays once the column is known not to fit.
126    let ideal_total: f32 = compress_indices
127        .iter()
128        .map(|&index| measurements[index].size().height)
129        .sum();
130    if !exceeds(ideal_total, available, compress_indices.len()) {
131        return;
132    }
133
134    let extents: Vec<Extent> = compress_indices
135        .iter()
136        .map(|&index| Extent {
137            ideal: measurements[index].size().height,
138            min: children[index]
139                .measure(ProposalSize::new(width_proposal, Some(0.0)))
140                .size
141                .height,
142            priority: children[index].priority(),
143        })
144        .collect();
145
146    for (&index, resolved) in compress_indices
147        .iter()
148        .zip(compress_to_fit(&extents, available))
149    {
150        if resolved >= measurements[index].size().height {
151            continue;
152        }
153        let constrained = ProposalSize::new(width_proposal, Some(resolved));
154        measurements[index].dimensions = children[index].measure(constrained);
155        measurements[index].dimensions.size.height =
156            measurements[index].size().height.min(resolved);
157    }
158}
159
160fn usize_to_f32(value: usize) -> f32 {
161    use num_traits::ToPrimitive;
162    value
163        .to_f32()
164        .expect("VStackLayout: child count must be representable as f32")
165}
166
167impl Layout for VStackLayout {
168    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
169        if children.is_empty() {
170            return Size::zero();
171        }
172
173        // Measure each child with parent's width (for text wrapping) and unspecified height
174        let child_proposal = ProposalSize::new(proposal.width, None);
175
176        let measurements: Vec<ChildMeasurement> = children
177            .iter()
178            .map(|child| ChildMeasurement {
179                dimensions: child.measure(child_proposal),
180                stretch_axis: child.stretch_axis(),
181            })
182            .collect();
183
184        // VStack checks for main-axis (vertical) stretching
185        let has_main_axis_stretch = measurements
186            .iter()
187            .any(ChildMeasurement::stretches_main_axis);
188
189        // Height: every child's measured height plus spacing. Children that
190        // stretch are included: measured under this same proposal a stretcher
191        // reports what it actually needs — nothing for a spacer, its content
192        // for a list or a scroll view. Leaving them out makes a column that
193        // holds, say, a header above a list report only the header's height,
194        // and the list is then handed nothing to draw in.
195        let content_height: f32 = measurements.iter().map(|m| m.size().height).sum();
196
197        let spacing = self.spacing.get();
198        let total_spacing = if children.len() > 1 {
199            usize_to_f32(children.len() - 1) * spacing
200        } else {
201            0.0
202        };
203
204        let intrinsic_height = content_height + total_spacing;
205        // Only a column that has something able to grow accepts an offer larger
206        // than its content; a content-sized column keeps its own height.
207        let final_height = if has_main_axis_stretch {
208            proposal.height.unwrap_or(intrinsic_height)
209        } else {
210            intrinsic_height
211        };
212
213        // Width: every child's width counts, so the column can never report less
214        // than the widest thing in it.
215        let is_min_size_query = proposal.width == Some(0.0);
216        let (max_leading, max_trailing) =
217            vstack_intrinsic_cross_metrics(&measurements, self.alignment);
218        let max_width = max_leading + max_trailing;
219
220        // VStack stretches horizontally (cross-axis), so use proposed width when available
221        // (unless it's a min-size query where we want the minimum required width)
222        let final_width = if is_min_size_query {
223            max_width
224        } else {
225            proposal.width.unwrap_or(max_width)
226        };
227
228        Size::new(final_width, final_height)
229    }
230
231    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
232        if children.is_empty() {
233            return vec![];
234        }
235
236        // Measure children again (will be cached by SubView implementation)
237        let child_proposal = ProposalSize::new(Some(bounds.width()), None);
238
239        let mut measurements: Vec<ChildMeasurement> = children
240            .iter()
241            .map(|child| ChildMeasurement {
242                dimensions: child.measure(child_proposal),
243                stretch_axis: child.stretch_axis(),
244            })
245            .collect();
246
247        let spacing = self.spacing.get();
248        let total_spacing = if children.len() > 1 {
249            usize_to_f32(children.len() - 1) * spacing
250        } else {
251            0.0
252        };
253
254        // Fit the non-stretching children into the height on offer before any of
255        // it is handed to the stretchers.
256        let compress_indices: Vec<usize> = measurements
257            .iter()
258            .enumerate()
259            .filter(|(_, m)| !m.stretches_main_axis())
260            .map(|(index, _)| index)
261            .collect();
262        compress_children(
263            &mut measurements,
264            children,
265            &compress_indices,
266            (bounds.height() - total_spacing).max(0.0),
267            Some(bounds.width()),
268        );
269
270        // Calculate stretch child height - only for main-axis (vertically) stretching children
271        let main_axis_stretch_count = measurements
272            .iter()
273            .filter(|m| m.stretches_main_axis())
274            .count();
275        let non_stretch_height: f32 = measurements
276            .iter()
277            .filter(|m| !m.stretches_main_axis())
278            .map(|m| m.size().height)
279            .sum();
280
281        let remaining_height = bounds.height() - non_stretch_height - total_spacing;
282        let stretch_height = if main_axis_stretch_count > 0 {
283            (remaining_height / usize_to_f32(main_axis_stretch_count)).max(0.0)
284        } else {
285            0.0
286        };
287
288        let has_explicit_alignment_guides = measurements.iter().any(|measurement| {
289            measurement
290                .dimensions
291                .explicit_horizontal(self.alignment)
292                .is_some()
293        });
294        let guide_line = has_explicit_alignment_guides.then(|| {
295            let (intrinsic_leading, _intrinsic_trailing) =
296                vstack_intrinsic_cross_metrics(&measurements, self.alignment);
297            bounds.x() + intrinsic_leading
298        });
299
300        // Place children
301        let mut rects = Vec::with_capacity(children.len());
302        let mut current_y = bounds.y();
303
304        for (i, measurement) in measurements.iter().enumerate() {
305            if i > 0 {
306                current_y += spacing;
307            }
308
309            // Handle cross-axis (horizontal) stretching and infinite width
310            let child_width = if measurement.stretches_cross_axis() {
311                // CrossAxis in VStack means expand horizontally to full bounds width
312                bounds.width()
313            } else if measurement.size().width.is_infinite() {
314                bounds.width()
315            } else {
316                // Clamp child width to bounds - child can't be wider than container
317                measurement.size().width.min(bounds.width())
318            };
319
320            let child_height = if measurement.stretches_main_axis() {
321                stretch_height
322            } else {
323                measurement.size().height
324            };
325
326            let mut adjusted_dimensions = measurement.dimensions.clone();
327            adjusted_dimensions.size = Size::new(child_width, child_height);
328
329            let x = if measurement.stretches_cross_axis() {
330                bounds.x()
331            } else if let Some(guide_line) = guide_line {
332                let guide = adjusted_dimensions
333                    .horizontal(self.alignment)
334                    .clamp(0.0, child_width);
335                guide_line - guide
336            } else if self.alignment == HorizontalAlignment::Leading {
337                bounds.x()
338            } else if self.alignment == HorizontalAlignment::Trailing {
339                bounds.x() + bounds.width() - child_width
340            } else {
341                let guide = adjusted_dimensions
342                    .horizontal(self.alignment)
343                    .clamp(0.0, child_width);
344                bounds.x() + bounds.width() * 0.5 - guide
345            };
346
347            rects.push(Rect::new(
348                Point::new(x, current_y),
349                Size::new(child_width, child_height),
350            ));
351
352            current_y += child_height;
353        }
354
355        rects
356    }
357
358    fn explicit_horizontal(
359        &self,
360        alignment: HorizontalAlignment,
361        _bounds: Rect,
362        children: &[PlacedSubview<'_>],
363    ) -> Option<f32> {
364        if alignment == self.alignment {
365            return children
366                .iter()
367                .filter_map(|child| child.explicit_horizontal(alignment))
368                .min_by(f32::total_cmp);
369        }
370
371        None
372    }
373
374    /// A `VStack` claims nothing of its own — a column of labels is
375    /// content-sized, like `SwiftUI`'s, and a parent placing an undersized
376    /// column centers it per its alignment. What it does claim is whatever its
377    /// children claim: filling comes from children that ask for it (`Spacer`,
378    /// greedy frames, `Color`), and the ask has to survive the trip up through
379    /// every container between that child and whoever owns the space.
380    fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
381        stack_stretch_axis(Axis::Vertical, children)
382    }
383
384    fn watch_invalidation(
385        &self,
386        invalidate: LayoutInvalidationCallback,
387    ) -> Vec<nami::watcher::BoxWatcherGuard> {
388        vec![self.spacing.watch(move |_| invalidate())]
389    }
390}
391
392/// A view that arranges its children in a vertical line.
393///
394/// Use a `VStack` to arrange views top-to-bottom. The stack sizes itself to fit
395/// its contents, distributing available space among its children.
396///
397/// ```rust
398/// # use waterui::prelude::*;
399/// # fn heading() -> impl View {
400/// vstack((
401///     text("Title"),
402///     text("Subtitle"),
403/// ))
404/// # }
405/// ```
406///
407/// You can customize the spacing between children and their horizontal alignment:
408///
409/// ```rust
410/// # use waterui::prelude::*;
411/// # fn spaced() -> impl View {
412/// VStack::new(HorizontalAlignment::Leading, 8.0, (
413///     text("First"),
414///     text("Second"),
415/// ))
416/// # }
417/// ```
418///
419/// Use [`spacer()`](crate::spacer()) to push content to the top and bottom:
420///
421/// ```rust
422/// # use waterui::prelude::*;
423/// # fn page() -> impl View {
424/// vstack((
425///     text("Header"),
426///     spacer(),
427///     text("Footer"),
428/// ))
429/// # }
430/// ```
431#[derive(Debug, Clone)]
432pub struct VStack<C> {
433    layout: VStackLayout,
434    contents: C,
435}
436
437impl<C: TupleViews> VStack<(C,)> {
438    /// Creates a vertical stack with the provided alignment, spacing, and
439    /// children.
440    pub fn new(alignment: HorizontalAlignment, spacing: f32, contents: C) -> Self {
441        Self {
442            layout: VStackLayout {
443                alignment,
444                spacing: Computed::constant(spacing),
445            },
446            contents: (contents,),
447        }
448    }
449}
450
451crate::stack::impl_stack_for_each!(VStack, VStackLayout);
452
453impl<C> VStack<C> {
454    /// Sets the horizontal alignment for children in the stack.
455    #[must_use]
456    pub const fn alignment(mut self, alignment: HorizontalAlignment) -> Self {
457        self.layout.alignment = alignment;
458        self
459    }
460
461    /// Sets the spacing between children in the stack.
462    ///
463    /// Accepts any numeric literal or signal of `f32`. Signal changes invalidate
464    /// only this stack's layout.
465    #[must_use]
466    pub fn spacing(mut self, spacing: impl IntoSignalF32 + 'static) -> Self {
467        self.layout.spacing = spacing.into_signal_f32().computed();
468        self
469    }
470}
471
472impl<V> FromIterator<V> for VStack<(Vec<AnyView>,)>
473where
474    V: View,
475{
476    fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
477        let contents = iter.into_iter().map(AnyView::new).collect::<Vec<_>>();
478        Self::new(HorizontalAlignment::default(), 10.0, contents)
479    }
480}
481
482/// Convenience constructor that centres children and uses the default spacing.
483pub fn vstack<C: TupleViews>(contents: C) -> VStack<(C,)> {
484    VStack::new(HorizontalAlignment::Center, 10.0, contents)
485}
486
487impl<C, F, V> View for VStack<ForEach<C, F, V>>
488where
489    C: Collection + Clone,
490    C::Item: Identifiable,
491    F: 'static + Fn(C::Item) -> V,
492    V: View,
493{
494    fn body(self, _env: &waterui_core::Environment) -> impl View {
495        // Inject the vertical axis into the container
496        with(
497            LazyContainer::new(self.layout, self.contents),
498            Axis::Vertical,
499        )
500    }
501}
502
503impl<C: TupleViews + 'static> View for VStack<(C,)> {
504    fn body(self, _env: &waterui_core::Environment) -> impl View {
505        // Inject the vertical axis into the container
506        with(
507            FixedContainer::new(self.layout, self.contents.0),
508            Axis::Vertical,
509        )
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    struct MockSubView {
518        size: Size,
519        stretch_axis: StretchAxis,
520    }
521
522    impl SubView for MockSubView {
523        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
524            ViewDimensions::new(self.size)
525        }
526        fn stretch_axis(&self) -> StretchAxis {
527            self.stretch_axis
528        }
529        fn priority(&self) -> i32 {
530            0
531        }
532    }
533
534    #[test]
535    fn test_vstack_size_two_children() {
536        let layout = VStackLayout {
537            alignment: HorizontalAlignment::Center,
538            spacing: Computed::constant(10.0),
539        };
540
541        let mut child1 = MockSubView {
542            size: Size::new(100.0, 30.0),
543            stretch_axis: StretchAxis::None,
544        };
545        let mut child2 = MockSubView {
546            size: Size::new(80.0, 40.0),
547            stretch_axis: StretchAxis::None,
548        };
549
550        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
551
552        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
553
554        assert!((size.width - 100.0).abs() < f32::EPSILON); // max width
555        assert!((size.height - 80.0).abs() < f32::EPSILON); // 30 + 10 + 40
556    }
557
558    /// A column holding a header above something that grows — a list, a scroll
559    /// view — must report the height of both. Reporting only the header's makes
560    /// an unconstrained parent hand the column that much and the list draws
561    /// nothing at all.
562    #[test]
563    fn a_column_counts_a_growing_child_in_its_intrinsic_height() {
564        let layout = VStackLayout {
565            alignment: HorizontalAlignment::Leading,
566            spacing: Computed::constant(0.0),
567        };
568
569        let mut header = MockSubView {
570            size: Size::new(100.0, 30.0),
571            stretch_axis: StretchAxis::None,
572        };
573        // A list measures its content and also accepts more room if offered.
574        let mut list = MockSubView {
575            size: Size::new(100.0, 200.0),
576            stretch_axis: StretchAxis::Both,
577        };
578        let children: Vec<&dyn SubView> = vec![&mut header, &mut list];
579
580        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
581
582        assert!(
583            (size.height - 230.0).abs() < f32::EPSILON,
584            "the column dropped its growing child from its own height: got {}, want 230",
585            size.height
586        );
587    }
588
589    /// A spacer contributes nothing, so counting growing children must not
590    /// inflate a column that merely holds one.
591    #[test]
592    fn a_spacer_adds_nothing_to_the_intrinsic_height() {
593        let layout = VStackLayout {
594            alignment: HorizontalAlignment::Leading,
595            spacing: Computed::constant(0.0),
596        };
597
598        let mut row = MockSubView {
599            size: Size::new(100.0, 30.0),
600            stretch_axis: StretchAxis::None,
601        };
602        let mut spacer = MockSubView {
603            size: Size::zero(),
604            stretch_axis: StretchAxis::Both,
605        };
606        let children: Vec<&dyn SubView> = vec![&mut row, &mut spacer];
607
608        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
609
610        assert!(
611            (size.height - 30.0).abs() < f32::EPSILON,
612            "a spacer changed the column's intrinsic height: got {}, want 30",
613            size.height
614        );
615    }
616
617    #[test]
618    fn test_vstack_with_spacer() {
619        let layout = VStackLayout {
620            alignment: HorizontalAlignment::Center,
621            spacing: Computed::constant(0.0),
622        };
623
624        let mut child1 = MockSubView {
625            size: Size::new(100.0, 30.0),
626            stretch_axis: StretchAxis::None,
627        };
628        let mut spacer = MockSubView {
629            size: Size::zero(),
630            stretch_axis: StretchAxis::Both, // Spacer stretches in both directions
631        };
632        let mut child2 = MockSubView {
633            size: Size::new(100.0, 30.0),
634            stretch_axis: StretchAxis::None,
635        };
636
637        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
638
639        // With specified height, spacer should expand
640        let size = layout.size_that_fits(ProposalSize::new(None, Some(200.0)), &children);
641
642        assert!((size.height - 200.0).abs() < f32::EPSILON);
643
644        // Place should distribute remaining space to spacer
645        let bounds = Rect::new(Point::zero(), Size::new(100.0, 200.0));
646
647        // Need fresh references
648        let mut child1 = MockSubView {
649            size: Size::new(100.0, 30.0),
650            stretch_axis: StretchAxis::None,
651        };
652        let mut spacer = MockSubView {
653            size: Size::zero(),
654            stretch_axis: StretchAxis::Both,
655        };
656        let mut child2 = MockSubView {
657            size: Size::new(100.0, 30.0),
658            stretch_axis: StretchAxis::None,
659        };
660        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
661
662        let rects = layout.place(bounds, &children);
663
664        assert!((rects[0].height() - 30.0).abs() < f32::EPSILON);
665        assert!((rects[1].height() - 140.0).abs() < f32::EPSILON); // 200 - 30 - 30
666        assert!((rects[2].height() - 30.0).abs() < f32::EPSILON);
667        assert!((rects[2].y() - 170.0).abs() < f32::EPSILON); // 30 + 140
668    }
669
670    /// A child that fills the cross axis still contributes the width it
671    /// measures. Filling means "at least this, and more if you have it": a
672    /// column that reported less than its widest child would be handed less,
673    /// and the child would fill a box too small for it.
674    #[test]
675    fn a_cross_filling_child_still_sets_the_column_width() {
676        // TextField-like component: stretches horizontally but has fixed height
677        let layout = VStackLayout {
678            alignment: HorizontalAlignment::Center,
679            spacing: Computed::constant(10.0),
680        };
681
682        let mut label = MockSubView {
683            size: Size::new(50.0, 20.0),
684            stretch_axis: StretchAxis::None,
685        };
686        let mut text_field = MockSubView {
687            size: Size::new(100.0, 40.0), // reports minimum width, intrinsic height
688            stretch_axis: StretchAxis::Horizontal, // stretches width only
689        };
690        let mut button = MockSubView {
691            size: Size::new(80.0, 44.0),
692            stretch_axis: StretchAxis::None,
693        };
694
695        let children: Vec<&dyn SubView> = vec![&mut label, &mut text_field, &mut button];
696
697        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
698
699        // Width: max of every child = max(50, 100, 80) = 100
700        assert!(
701            (size.width - 100.0).abs() < f32::EPSILON,
702            "the text field's own width has to reach the column, got {}",
703            size.width
704        );
705        // Height: all children contribute (text_field doesn't stretch vertically)
706        // = 20 + 10 + 40 + 10 + 44 = 124
707        assert!((size.height - 124.0).abs() < f32::EPSILON);
708    }
709
710    /// A column never reports less than its widest child, whichever way it is
711    /// asked. The minimum a window may shrink to and the size the column wants
712    /// are the same question about the same children.
713    #[test]
714    fn a_column_reports_its_widest_child_however_it_is_asked() {
715        let layout = VStackLayout {
716            alignment: HorizontalAlignment::Center,
717            spacing: Computed::constant(10.0),
718        };
719
720        let mut label = MockSubView {
721            size: Size::new(50.0, 20.0),
722            stretch_axis: StretchAxis::None,
723        };
724        let mut toggle = MockSubView {
725            size: Size::new(200.0, 40.0), // Toggle with label has larger min width
726            stretch_axis: StretchAxis::Horizontal, // but it stretches horizontally
727        };
728        let mut button = MockSubView {
729            size: Size::new(80.0, 44.0),
730            stretch_axis: StretchAxis::None,
731        };
732
733        let children: Vec<&dyn SubView> = vec![&mut label, &mut toggle, &mut button];
734
735        // Width: max of every child = max(50, 200, 80) = 200
736        let min_size = layout.size_that_fits(ProposalSize::ZERO, &children);
737        assert!(
738            (min_size.width - 200.0).abs() < f32::EPSILON,
739            "a window cannot shrink below the toggle, got {}",
740            min_size.width
741        );
742
743        let children2: Vec<&dyn SubView> = vec![&mut label, &mut toggle, &mut button];
744        let intrinsic_size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children2);
745        assert!(
746            (intrinsic_size.width - 200.0).abs() < f32::EPSILON,
747            "and it wants the same width when nothing constrains it, got {}",
748            intrinsic_size.width
749        );
750    }
751
752    #[test]
753    fn compressible_children_shrink_to_fit_the_column() {
754        // Three 60pt rows in 120pt: a column used to just overflow, because
755        // `VStack` summed heights and never asked anyone to give way. Content
756        // that can shrink now does, evenly.
757        let layout = VStackLayout {
758            alignment: HorizontalAlignment::Center,
759            spacing: Computed::constant(0.0),
760        };
761
762        let mut rows: Vec<CompressibleHeightView> = (0..3)
763            .map(|_| CompressibleHeightView {
764                ideal: Size::new(50.0, 60.0),
765                floor: 0.0,
766            })
767            .collect();
768        let children: Vec<&dyn SubView> = rows.iter_mut().map(|row| row as &dyn SubView).collect();
769
770        let bounds = Rect::new(Point::zero(), Size::new(50.0, 120.0));
771        let rects = layout.place(bounds, &children);
772
773        for rect in &rects {
774            assert!(
775                (rect.height() - 40.0).abs() < 0.01,
776                "expected each row to give up 20pt, got {}",
777                rect.height()
778            );
779        }
780    }
781
782    #[test]
783    fn a_row_never_shrinks_below_the_height_it_reports() {
784        // The middle row will not go below 50pt, so the others absorb what they
785        // can and the column overflows by the remainder rather than crushing it.
786        let layout = VStackLayout {
787            alignment: HorizontalAlignment::Center,
788            spacing: Computed::constant(0.0),
789        };
790
791        let mut top = CompressibleHeightView {
792            ideal: Size::new(50.0, 60.0),
793            floor: 0.0,
794        };
795        let mut middle = CompressibleHeightView {
796            ideal: Size::new(50.0, 60.0),
797            floor: 50.0,
798        };
799        let mut bottom = CompressibleHeightView {
800            ideal: Size::new(50.0, 60.0),
801            floor: 0.0,
802        };
803        let children: Vec<&dyn SubView> = vec![&mut top, &mut middle, &mut bottom];
804
805        let bounds = Rect::new(Point::zero(), Size::new(50.0, 90.0));
806        let rects = layout.place(bounds, &children);
807
808        assert!(
809            rects[1].height() >= 50.0 - 0.01,
810            "the middle row reported a 50pt floor, got {}",
811            rects[1].height()
812        );
813    }
814
815    /// A row that shrinks to whatever height it is proposed, down to `floor`.
816    struct CompressibleHeightView {
817        ideal: Size,
818        floor: f32,
819    }
820
821    impl SubView for CompressibleHeightView {
822        fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
823            let height = proposal.height.map_or(self.ideal.height, |proposed| {
824                proposed.clamp(self.floor, self.ideal.height)
825            });
826            ViewDimensions::new(Size::new(self.ideal.width, height))
827        }
828        fn stretch_axis(&self) -> StretchAxis {
829            StretchAxis::None
830        }
831        fn priority(&self) -> i32 {
832            0
833        }
834    }
835}