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, SubviewPlacement,
13    container::FixedContainer,
14    stack::{
15        Axis,
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/// Layout engine shared by the public [`VStack`] view.
25#[derive(Debug, Clone)]
26pub struct VStackLayout {
27    /// The horizontal alignment of children within the stack.
28    pub alignment: HorizontalAlignment,
29    /// The spacing between children in the stack.
30    pub spacing: Computed<f32>,
31}
32
33impl Default for VStackLayout {
34    fn default() -> Self {
35        Self {
36            alignment: HorizontalAlignment::default(),
37            spacing: Computed::constant(10.0),
38        }
39    }
40}
41
42/// The widest child either side of the alignment guide.
43///
44/// Every child that reports a width counts, the ones that fill the cross axis
45/// included. Filling means "at least what I measure, and more if you have it",
46/// not "nothing": leaving a filler out of this makes a column of nothing-but-
47/// fillers report zero width, and its parent then hands it zero width to fill.
48/// A child that answers an unbounded width is answering "as much as you have"
49/// rather than naming a size, so it sets no floor here — the fill pass in
50/// [`Layout::place`] is what gives it the column's width.
51/// The horizontal envelope of the column's children lined up on `alignment`.
52fn vstack_intrinsic_cross_metrics(
53    measurements: &[ChildMeasurement],
54    alignment: HorizontalAlignment,
55) -> (f32, f32) {
56    cross_envelope(measurements.iter().map(|measurement| {
57        (
58            measurement.size().width,
59            measurement.horizontal_guide(alignment),
60        )
61    }))
62}
63
64impl Layout for VStackLayout {
65    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
66        if children.is_empty() {
67            return Size::zero();
68        }
69
70        let spacing = self.spacing.get();
71        let measurements = measure_stack(Axis::Vertical, proposal, spacing, children);
72        let final_height = measurements.iter().map(|m| m.size().height).sum::<f32>()
73            + stack_spacing(spacing, children.len());
74
75        let (leading, trailing) = vstack_intrinsic_cross_metrics(&measurements, self.alignment);
76        let width = if measurements.iter().any(|m| m.size().width.is_infinite()) {
77            f32::INFINITY
78        } else {
79            leading + trailing
80        };
81        Size::new(width, final_height)
82    }
83
84    fn place(
85        &self,
86        bounds: Rect,
87        _proposal: ProposalSize,
88        children: &[&dyn SubView],
89    ) -> Vec<SubviewPlacement> {
90        if children.is_empty() {
91            return vec![];
92        }
93
94        let spacing = self.spacing.get();
95        // Placement is a fresh negotiation against the bounds the column was
96        // placed in: every child is allocated from the column's resolved
97        // height and proposed the column's resolved width, so what a child
98        // lays out under is the extent it actually has, never the offer the
99        // column was measured with. A column widened past its proposal by an
100        // unshrinkable row hands that width to every child, so a title lays
101        // out across the column it actually has.
102        let placement = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
103        let measurements = measure_stack(Axis::Vertical, placement, spacing, children);
104
105        let (above, below) = vstack_intrinsic_cross_metrics(&measurements, self.alignment);
106        let guide_line = bounds.x()
107            + container_line(
108                bounds.width(),
109                LineAnchor::horizontal(self.alignment),
110                above,
111                below,
112            );
113
114        // Place children
115        let mut placements = Vec::with_capacity(children.len());
116        let mut current_y = bounds.y();
117
118        for (i, measurement) in measurements.iter().enumerate() {
119            if i > 0 {
120                current_y += spacing;
121            }
122
123            let child_width = place_cross_extent(
124                measurement.size().width,
125                bounds.width(),
126                measurement.stretches_cross_axis(),
127            );
128
129            // Main-axis extent is whatever the last measurement recorded:
130            // intrinsic when the column was unspecified, at least the stretch
131            // allocation when it was specified.
132            let child_height = measurement.size().height;
133
134            let mut adjusted_dimensions = measurement.dimensions.clone();
135            adjusted_dimensions.size = Size::new(child_width, child_height);
136
137            // A child that fills the cross axis spans the bounds; every other
138            // child sits with its guide on the column's line, wherever that
139            // guide lies.
140            let x = if measurement.stretches_cross_axis() {
141                bounds.x()
142            } else {
143                guide_line - adjusted_dimensions.horizontal(self.alignment)
144            };
145
146            placements.push(SubviewPlacement::new(
147                Rect::new(
148                    Point::new(x, current_y),
149                    Size::new(child_width, child_height),
150                ),
151                measurement.proposal,
152            ));
153
154            current_y += child_height;
155        }
156
157        placements
158    }
159
160    fn explicit_horizontal(
161        &self,
162        alignment: HorizontalAlignment,
163        _bounds: Rect,
164        children: &[PlacedSubview<'_>],
165    ) -> Option<f32> {
166        if alignment == self.alignment {
167            return children
168                .iter()
169                .filter_map(|child| child.explicit_horizontal(alignment))
170                .min_by(f32::total_cmp);
171        }
172
173        None
174    }
175
176    /// A `VStack` claims nothing of its own — a column of labels is
177    /// content-sized, like `SwiftUI`'s, and a parent placing an undersized
178    /// column centers it per its alignment. What it does claim is whatever its
179    /// children claim: filling comes from children that ask for it (`Spacer`,
180    /// greedy frames, `Color`), and the ask has to survive the trip up through
181    /// every container between that child and whoever owns the space.
182    fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
183        stack_stretch_axis(Axis::Vertical, children)
184    }
185
186    fn watch_invalidation(
187        &self,
188        invalidate: LayoutInvalidationCallback,
189    ) -> Vec<nami::watcher::BoxWatcherGuard> {
190        vec![self.spacing.watch(move |_| invalidate())]
191    }
192}
193
194/// A view that arranges its children in a vertical line.
195///
196/// Use a `VStack` to arrange views top-to-bottom. The stack sizes itself to fit
197/// its contents, distributing available space among its children.
198///
199/// ```rust
200/// # use waterui::prelude::*;
201/// # fn heading() -> impl View {
202/// vstack((
203///     text("Title"),
204///     text("Subtitle"),
205/// ))
206/// # }
207/// ```
208///
209/// You can customize the spacing between children and their horizontal alignment:
210///
211/// ```rust
212/// # use waterui::prelude::*;
213/// # fn spaced() -> impl View {
214/// VStack::new(HorizontalAlignment::Leading, 8.0, (
215///     text("First"),
216///     text("Second"),
217/// ))
218/// # }
219/// ```
220///
221/// Use [`spacer()`](crate::spacer()) to push content to the top and bottom:
222///
223/// ```rust
224/// # use waterui::prelude::*;
225/// # fn page() -> impl View {
226/// vstack((
227///     text("Header"),
228///     spacer(),
229///     text("Footer"),
230/// ))
231/// # }
232/// ```
233#[derive(Debug, Clone)]
234pub struct VStack<C> {
235    layout: VStackLayout,
236    contents: C,
237}
238
239impl<C: TupleViews> VStack<(C,)> {
240    /// Creates a vertical stack with the provided alignment, spacing, and
241    /// children.
242    pub fn new(alignment: HorizontalAlignment, spacing: f32, contents: C) -> Self {
243        Self {
244            layout: VStackLayout {
245                alignment,
246                spacing: Computed::constant(spacing),
247            },
248            contents: (contents,),
249        }
250    }
251}
252
253crate::stack::impl_stack_for_each!(VStack, VStackLayout);
254
255impl<C> VStack<C> {
256    /// Sets the horizontal alignment for children in the stack — a
257    /// [`HorizontalAlignment`] or one of the tokens [`Leading`],
258    /// [`Center`], [`Trailing`].
259    ///
260    /// [`Leading`]: crate::Leading
261    /// [`Center`]: crate::Center
262    /// [`Trailing`]: crate::Trailing
263    #[must_use]
264    pub fn alignment(mut self, alignment: impl Into<HorizontalAlignment>) -> Self {
265        self.layout.alignment = alignment.into();
266        self
267    }
268
269    crate::alignment::horizontal_alignment_methods!();
270
271    /// Sets the spacing between children in the stack.
272    ///
273    /// Accepts any numeric literal or signal of `f32`. Signal changes invalidate
274    /// only this stack's layout.
275    #[must_use]
276    pub fn spacing(mut self, spacing: impl IntoSignalF32 + 'static) -> Self {
277        self.layout.spacing = spacing.into_signal_f32().computed();
278        self
279    }
280}
281
282impl<V> FromIterator<V> for VStack<(Vec<AnyView>,)>
283where
284    V: View,
285{
286    fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
287        let contents = iter.into_iter().map(AnyView::new).collect::<Vec<_>>();
288        Self::new(HorizontalAlignment::default(), 10.0, contents)
289    }
290}
291
292/// Convenience constructor that centres children and uses the default spacing.
293pub fn vstack<C: TupleViews>(contents: C) -> VStack<(C,)> {
294    VStack::new(HorizontalAlignment::Center, 10.0, contents)
295}
296
297impl<C, F, V> View for VStack<ForEach<C, F, V>>
298where
299    C: Collection + Clone,
300    C::Item: Identifiable,
301    F: 'static + Fn(C::Item) -> V,
302    V: View,
303{
304    fn body(self, _env: &waterui_core::Environment) -> impl View {
305        // Inject the vertical axis into the container
306        with(
307            LazyContainer::new(self.layout, self.contents),
308            Axis::Vertical,
309        )
310    }
311
312    /// Resolves to `LazyContainer`, which cannot enumerate children without
313    /// materializing them and answers its layout's axis over an empty child
314    /// set — matching `LazyContainer::stretch_axis`.
315    fn stretch_axis(&self) -> StretchAxis {
316        self.layout.stretch_axis(&[])
317    }
318}
319
320impl<C: TupleViews + 'static> View for VStack<(C,)> {
321    fn body(self, _env: &waterui_core::Environment) -> impl View {
322        // Inject the vertical axis into the container
323        with(
324            FixedContainer::new(self.layout, self.contents.0),
325            Axis::Vertical,
326        )
327    }
328
329    /// Resolves to `FixedContainer` over the same layout and children;
330    /// reports what that container would — matching `FixedContainer`'s
331    /// `View::stretch_axis`.
332    fn stretch_axis(&self) -> StretchAxis {
333        self.layout.stretch_axis(&self.contents.0.stretch_axes())
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::ViewDimensions;
341    use crate::tests::CompressibleHeightView;
342
343    struct MockSubView {
344        size: Size,
345        stretch_axis: StretchAxis,
346    }
347
348    impl SubView for MockSubView {
349        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
350            ViewDimensions::new(self.size)
351        }
352        fn stretch_axis(&self) -> StretchAxis {
353            self.stretch_axis
354        }
355        fn priority(&self) -> i32 {
356            0
357        }
358    }
359
360    #[test]
361    fn test_vstack_size_two_children() {
362        let layout = VStackLayout {
363            alignment: HorizontalAlignment::Center,
364            spacing: Computed::constant(10.0),
365        };
366
367        let mut child1 = MockSubView {
368            size: Size::new(100.0, 30.0),
369            stretch_axis: StretchAxis::None,
370        };
371        let mut child2 = MockSubView {
372            size: Size::new(80.0, 40.0),
373            stretch_axis: StretchAxis::None,
374        };
375
376        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
377
378        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
379
380        assert!((size.width - 100.0).abs() < f32::EPSILON); // max width
381        assert!((size.height - 80.0).abs() < f32::EPSILON); // 30 + 10 + 40
382    }
383
384    /// A column holding a header above something that grows — a list, a scroll
385    /// view — must report the height of both. Reporting only the header's makes
386    /// an unconstrained parent hand the column that much and the list draws
387    /// nothing at all.
388    #[test]
389    fn a_column_counts_a_growing_child_in_its_intrinsic_height() {
390        let layout = VStackLayout {
391            alignment: HorizontalAlignment::Leading,
392            spacing: Computed::constant(0.0),
393        };
394
395        let mut header = MockSubView {
396            size: Size::new(100.0, 30.0),
397            stretch_axis: StretchAxis::None,
398        };
399        // A list measures its content and also accepts more room if offered.
400        let mut list = MockSubView {
401            size: Size::new(100.0, 200.0),
402            stretch_axis: StretchAxis::Both,
403        };
404        let children: Vec<&dyn SubView> = vec![&mut header, &mut list];
405
406        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
407
408        assert!(
409            (size.height - 230.0).abs() < f32::EPSILON,
410            "the column dropped its growing child from its own height: got {}, want 230",
411            size.height
412        );
413    }
414
415    /// A spacer contributes nothing, so counting growing children must not
416    /// inflate a column that merely holds one.
417    #[test]
418    fn a_spacer_adds_nothing_to_the_intrinsic_height() {
419        let layout = VStackLayout {
420            alignment: HorizontalAlignment::Leading,
421            spacing: Computed::constant(0.0),
422        };
423
424        let mut row = MockSubView {
425            size: Size::new(100.0, 30.0),
426            stretch_axis: StretchAxis::None,
427        };
428        let mut spacer = MockSubView {
429            size: Size::zero(),
430            stretch_axis: StretchAxis::Both,
431        };
432        let children: Vec<&dyn SubView> = vec![&mut row, &mut spacer];
433
434        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
435
436        assert!(
437            (size.height - 30.0).abs() < f32::EPSILON,
438            "a spacer changed the column's intrinsic height: got {}, want 30",
439            size.height
440        );
441    }
442
443    #[test]
444    fn test_vstack_with_spacer() {
445        let layout = VStackLayout {
446            alignment: HorizontalAlignment::Center,
447            spacing: Computed::constant(0.0),
448        };
449
450        let mut child1 = MockSubView {
451            size: Size::new(100.0, 30.0),
452            stretch_axis: StretchAxis::None,
453        };
454        let mut spacer = MockSubView {
455            size: Size::zero(),
456            stretch_axis: StretchAxis::Both, // Spacer stretches in both directions
457        };
458        let mut child2 = MockSubView {
459            size: Size::new(100.0, 30.0),
460            stretch_axis: StretchAxis::None,
461        };
462
463        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
464
465        // With specified height, spacer should expand
466        let size = layout.size_that_fits(ProposalSize::new(None, Some(200.0)), &children);
467
468        assert!((size.height - 200.0).abs() < f32::EPSILON);
469
470        // Place should distribute remaining space to spacer
471        let bounds = Rect::new(Point::zero(), Size::new(100.0, 200.0));
472
473        // Need fresh references
474        let mut child1 = MockSubView {
475            size: Size::new(100.0, 30.0),
476            stretch_axis: StretchAxis::None,
477        };
478        let mut spacer = MockSubView {
479            size: Size::zero(),
480            stretch_axis: StretchAxis::Both,
481        };
482        let mut child2 = MockSubView {
483            size: Size::new(100.0, 30.0),
484            stretch_axis: StretchAxis::None,
485        };
486        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
487
488        let placements = layout.place(bounds, ProposalSize::new(None, Some(200.0)), &children);
489
490        assert!((placements[0].frame.height() - 30.0).abs() < f32::EPSILON);
491        assert!((placements[1].frame.height() - 140.0).abs() < f32::EPSILON); // 200 - 30 - 30
492        assert!((placements[2].frame.height() - 30.0).abs() < f32::EPSILON);
493        assert!((placements[2].frame.y() - 170.0).abs() < f32::EPSILON); // 30 + 140
494    }
495
496    /// A child that fills the cross axis still contributes the width it
497    /// measures. Filling means "at least this, and more if you have it": a
498    /// column that reported less than its widest child would be handed less,
499    /// and the child would fill a box too small for it.
500    #[test]
501    fn a_cross_filling_child_still_sets_the_column_width() {
502        // TextField-like component: stretches horizontally but has fixed height
503        let layout = VStackLayout {
504            alignment: HorizontalAlignment::Center,
505            spacing: Computed::constant(10.0),
506        };
507
508        let mut label = MockSubView {
509            size: Size::new(50.0, 20.0),
510            stretch_axis: StretchAxis::None,
511        };
512        let mut text_field = MockSubView {
513            size: Size::new(100.0, 40.0), // reports minimum width, intrinsic height
514            stretch_axis: StretchAxis::Horizontal, // stretches width only
515        };
516        let mut button = MockSubView {
517            size: Size::new(80.0, 44.0),
518            stretch_axis: StretchAxis::None,
519        };
520
521        let children: Vec<&dyn SubView> = vec![&mut label, &mut text_field, &mut button];
522
523        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
524
525        // Width: max of every child = max(50, 100, 80) = 100
526        assert!(
527            (size.width - 100.0).abs() < f32::EPSILON,
528            "the text field's own width has to reach the column, got {}",
529            size.width
530        );
531        // Height: all children contribute (text_field doesn't stretch vertically)
532        // = 20 + 10 + 40 + 10 + 44 = 124
533        assert!((size.height - 124.0).abs() < f32::EPSILON);
534    }
535
536    /// A column never reports less than its widest child, whichever way it is
537    /// asked. The minimum a window may shrink to and the size the column wants
538    /// are the same question about the same children.
539    #[test]
540    fn a_column_reports_its_widest_child_however_it_is_asked() {
541        let layout = VStackLayout {
542            alignment: HorizontalAlignment::Center,
543            spacing: Computed::constant(10.0),
544        };
545
546        let mut label = MockSubView {
547            size: Size::new(50.0, 20.0),
548            stretch_axis: StretchAxis::None,
549        };
550        let mut toggle = MockSubView {
551            size: Size::new(200.0, 40.0), // Toggle with label has larger min width
552            stretch_axis: StretchAxis::Horizontal, // but it stretches horizontally
553        };
554        let mut button = MockSubView {
555            size: Size::new(80.0, 44.0),
556            stretch_axis: StretchAxis::None,
557        };
558
559        let children: Vec<&dyn SubView> = vec![&mut label, &mut toggle, &mut button];
560
561        // Width: max of every child = max(50, 200, 80) = 200
562        let min_size = layout.size_that_fits(ProposalSize::ZERO, &children);
563        assert!(
564            (min_size.width - 200.0).abs() < f32::EPSILON,
565            "a window cannot shrink below the toggle, got {}",
566            min_size.width
567        );
568
569        let children2: Vec<&dyn SubView> = vec![&mut label, &mut toggle, &mut button];
570        let intrinsic_size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children2);
571        assert!(
572            (intrinsic_size.width - 200.0).abs() < f32::EPSILON,
573            "and it wants the same width when nothing constrains it, got {}",
574            intrinsic_size.width
575        );
576    }
577
578    #[test]
579    fn compressible_children_shrink_to_fit_the_column_bounded_proposal() {
580        // Three 60pt rows in 120pt: a column used to just overflow, because
581        // `VStack` summed heights and never asked anyone to give way. Content
582        // that can shrink now does, evenly.
583        let layout = VStackLayout {
584            alignment: HorizontalAlignment::Center,
585            spacing: Computed::constant(0.0),
586        };
587
588        let mut rows: Vec<CompressibleHeightView> = (0..3)
589            .map(|_| CompressibleHeightView {
590                ideal: Size::new(50.0, 60.0),
591                floor: 0.0,
592            })
593            .collect();
594        let children: Vec<&dyn SubView> = rows.iter_mut().map(|row| row as &dyn SubView).collect();
595
596        let bounds = Rect::new(Point::zero(), Size::new(50.0, 120.0));
597        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
598        let placements = layout.place(bounds, proposal, &children);
599
600        for placement in &placements {
601            assert!(
602                (placement.frame.height() - 40.0).abs() < 0.01,
603                "expected each row to give up 20pt, got {}",
604                placement.frame.height()
605            );
606        }
607    }
608
609    #[test]
610    fn a_row_never_shrinks_below_the_height_it_reports_bounded_proposal() {
611        // The middle row will not go below 50pt, so the others absorb what they
612        // can and the column overflows by the remainder rather than crushing it.
613        let layout = VStackLayout {
614            alignment: HorizontalAlignment::Center,
615            spacing: Computed::constant(0.0),
616        };
617
618        let mut top = CompressibleHeightView {
619            ideal: Size::new(50.0, 60.0),
620            floor: 0.0,
621        };
622        let mut middle = CompressibleHeightView {
623            ideal: Size::new(50.0, 60.0),
624            floor: 50.0,
625        };
626        let mut bottom = CompressibleHeightView {
627            ideal: Size::new(50.0, 60.0),
628            floor: 0.0,
629        };
630        let children: Vec<&dyn SubView> = vec![&mut top, &mut middle, &mut bottom];
631
632        let bounds = Rect::new(Point::zero(), Size::new(50.0, 90.0));
633        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
634        let placements = layout.place(bounds, proposal, &children);
635
636        assert!(
637            placements[1].frame.height() >= 50.0 - 0.01,
638            "the middle row reported a 50pt floor, got {}",
639            placements[1].frame.height()
640        );
641    }
642}