waterui_layout/stack/
vstack.rs

1//! Vertical stack layout.
2
3use alloc::{vec, vec::Vec};
4use nami::collection::Collection;
5use waterui_core::{AnyView, View, env::with, id::Identifiable, view::TupleViews, views::ForEach};
6
7use crate::{
8    Layout, LazyContainer, Point, ProposalSize, Rect, Size, StretchAxis, SubView,
9    container::FixedContainer,
10    stack::{Axis, HorizontalAlignment},
11};
12
13/// Layout engine shared by the public [`VStack`] view.
14#[derive(Debug, Default, Clone)]
15pub struct VStackLayout {
16    /// The horizontal alignment of children within the stack.
17    pub alignment: HorizontalAlignment,
18    /// The spacing between children in the stack.
19    pub spacing: f32,
20}
21
22/// Cached measurement for a child during layout
23struct ChildMeasurement {
24    size: Size,
25    stretch_axis: StretchAxis,
26}
27
28impl ChildMeasurement {
29    /// Returns true if this child stretches vertically (for `VStack` height distribution).
30    /// In `VStack` context:
31    /// - `MainAxis` means vertical (`VStack`'s main axis)
32    /// - `CrossAxis` means horizontal (`VStack`'s cross axis)
33    const fn stretches_main_axis(&self) -> bool {
34        matches!(
35            self.stretch_axis,
36            StretchAxis::Vertical | StretchAxis::Both | StretchAxis::MainAxis
37        )
38    }
39
40    /// Returns true if this child stretches horizontally (for `VStack` width expansion).
41    /// In `VStack` context:
42    /// - `CrossAxis` means horizontal (`VStack`'s cross axis)
43    const fn stretches_cross_axis(&self) -> bool {
44        matches!(
45            self.stretch_axis,
46            StretchAxis::Horizontal | StretchAxis::Both | StretchAxis::CrossAxis
47        )
48    }
49}
50
51#[allow(clippy::cast_precision_loss)]
52impl Layout for VStackLayout {
53    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
54        if children.is_empty() {
55            return Size::zero();
56        }
57
58        // Measure each child with parent's width (for text wrapping) and unspecified height
59        let child_proposal = ProposalSize::new(proposal.width, None);
60
61        let measurements: Vec<ChildMeasurement> = children
62            .iter()
63            .map(|child| ChildMeasurement {
64                size: child.size_that_fits(child_proposal),
65                stretch_axis: child.stretch_axis(),
66            })
67            .collect();
68
69        // VStack checks for main-axis (vertical) stretching
70        let has_main_axis_stretch = measurements
71            .iter()
72            .any(ChildMeasurement::stretches_main_axis);
73
74        // Height: sum of children that don't stretch on main axis (vertically) + spacing
75        // (axis-expanding components like TextField report their intrinsic height here)
76        let non_stretch_height: f32 = measurements
77            .iter()
78            .filter(|m| !m.stretches_main_axis())
79            .map(|m| m.size.height)
80            .sum();
81
82        let total_spacing = if children.len() > 1 {
83            (children.len() - 1) as f32 * self.spacing
84        } else {
85            0.0
86        };
87
88        let intrinsic_height = non_stretch_height + total_spacing;
89        let final_height = if has_main_axis_stretch {
90            proposal.height.unwrap_or(intrinsic_height)
91        } else {
92            intrinsic_height
93        };
94
95        // Width: max of children that don't stretch on cross axis (horizontally)
96        // (cross-axis stretching children don't contribute to intrinsic width)
97        let max_width = measurements
98            .iter()
99            .filter(|m| !m.stretches_cross_axis())
100            .map(|m| m.size.width)
101            .max_by(f32::total_cmp)
102            .unwrap_or(0.0);
103
104        // VStack stretches horizontally (cross-axis), so use proposed width when available
105        // This ensures VStack fills available width for proper child centering
106        let final_width = proposal.width.unwrap_or(max_width);
107
108        Size::new(final_width, final_height)
109    }
110
111    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
112        if children.is_empty() {
113            return vec![];
114        }
115
116        // Measure children again (will be cached by SubView implementation)
117        let child_proposal = ProposalSize::new(Some(bounds.width()), None);
118
119        let measurements: Vec<ChildMeasurement> = children
120            .iter()
121            .map(|child| ChildMeasurement {
122                size: child.size_that_fits(child_proposal),
123                stretch_axis: child.stretch_axis(),
124            })
125            .collect();
126
127        // Calculate stretch child height - only for main-axis (vertically) stretching children
128        let main_axis_stretch_count = measurements
129            .iter()
130            .filter(|m| m.stretches_main_axis())
131            .count();
132        let non_stretch_height: f32 = measurements
133            .iter()
134            .filter(|m| !m.stretches_main_axis())
135            .map(|m| m.size.height)
136            .sum();
137
138        let total_spacing = if children.len() > 1 {
139            (children.len() - 1) as f32 * self.spacing
140        } else {
141            0.0
142        };
143
144        let remaining_height = bounds.height() - non_stretch_height - total_spacing;
145        let stretch_height = if main_axis_stretch_count > 0 {
146            (remaining_height / main_axis_stretch_count as f32).max(0.0)
147        } else {
148            0.0
149        };
150
151        // Place children
152        let mut rects = Vec::with_capacity(children.len());
153        let mut current_y = bounds.y();
154
155        for (i, measurement) in measurements.iter().enumerate() {
156            if i > 0 {
157                current_y += self.spacing;
158            }
159
160            // Handle cross-axis (horizontal) stretching and infinite width
161            let child_width = if measurement.stretches_cross_axis() {
162                // CrossAxis in VStack means expand horizontally to full bounds width
163                bounds.width()
164            } else if measurement.size.width.is_infinite() {
165                bounds.width()
166            } else {
167                // Clamp child width to bounds - child can't be wider than container
168                measurement.size.width.min(bounds.width())
169            };
170
171            let child_height = if measurement.stretches_main_axis() {
172                stretch_height
173            } else {
174                measurement.size.height
175            };
176
177            let x = match self.alignment {
178                HorizontalAlignment::Leading => bounds.x(),
179                HorizontalAlignment::Center => bounds.x() + (bounds.width() - child_width) / 2.0,
180                HorizontalAlignment::Trailing => bounds.x() + bounds.width() - child_width,
181            };
182
183            rects.push(Rect::new(
184                Point::new(x, current_y),
185                Size::new(child_width, child_height),
186            ));
187
188            current_y += child_height;
189        }
190
191        rects
192    }
193
194    /// `VStack` stretches horizontally to fill available width (cross-axis).
195    /// It uses intrinsic height based on children (main-axis).
196    fn stretch_axis(&self) -> StretchAxis {
197        StretchAxis::Horizontal
198    }
199}
200
201/// A view that arranges its children in a vertical line.
202///
203/// Use a `VStack` to arrange views top-to-bottom. The stack sizes itself to fit
204/// its contents, distributing available space among its children.
205///
206/// ```ignore
207/// vstack((
208///     text("Title"),
209///     text("Subtitle"),
210/// ))
211/// ```
212///
213/// You can customize the spacing between children and their horizontal alignment:
214///
215/// ```ignore
216/// VStack::new(HorizontalAlignment::Leading, 8.0, (
217///     text("First"),
218///     text("Second"),
219/// ))
220/// ```
221///
222/// Use [`spacer()`] to push content to the top and bottom:
223///
224/// ```ignore
225/// vstack((
226///     text("Header"),
227///     spacer(),
228///     text("Footer"),
229/// ))
230/// ```
231#[derive(Debug, Clone)]
232pub struct VStack<C> {
233    layout: VStackLayout,
234    contents: C,
235}
236
237impl<C: TupleViews> VStack<(C,)> {
238    /// Creates a vertical stack with the provided alignment, spacing, and
239    /// children.
240    pub const fn new(alignment: HorizontalAlignment, spacing: f32, contents: C) -> Self {
241        Self {
242            layout: VStackLayout { alignment, spacing },
243            contents: (contents,),
244        }
245    }
246}
247
248impl<C, F, V> VStack<ForEach<C, F, V>>
249where
250    C: Collection,
251    C::Item: Identifiable,
252    F: 'static + Fn(C::Item) -> V,
253    V: View,
254{
255    /// Creates a vertical stack by iterating over a collection and generating views.
256    pub fn for_each(collection: C, generator: F) -> Self {
257        Self {
258            layout: VStackLayout::default(),
259            contents: ForEach::new(collection, generator),
260        }
261    }
262}
263
264impl<C> VStack<C> {
265    /// Sets the horizontal alignment for children in the stack.
266    #[must_use]
267    pub const fn alignment(mut self, alignment: HorizontalAlignment) -> Self {
268        self.layout.alignment = alignment;
269        self
270    }
271
272    /// Sets the spacing between children in the stack.
273    #[must_use]
274    pub const fn spacing(mut self, spacing: f32) -> Self {
275        self.layout.spacing = spacing;
276        self
277    }
278}
279
280impl<V> FromIterator<V> for VStack<(Vec<AnyView>,)>
281where
282    V: View,
283{
284    fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
285        let contents = iter.into_iter().map(AnyView::new).collect::<Vec<_>>();
286        Self::new(HorizontalAlignment::default(), 10.0, contents)
287    }
288}
289
290/// Convenience constructor that centres children and uses the default spacing.
291pub const fn vstack<C: TupleViews>(contents: C) -> VStack<(C,)> {
292    VStack::new(HorizontalAlignment::Center, 10.0, contents)
293}
294
295impl<C, F, V> View for VStack<ForEach<C, F, V>>
296where
297    C: Collection,
298    C::Item: Identifiable,
299    F: 'static + Fn(C::Item) -> V,
300    V: View,
301{
302    fn body(self, _env: &waterui_core::Environment) -> impl View {
303        // Inject the vertical axis into the container
304        with(
305            LazyContainer::new(self.layout, self.contents),
306            Axis::Vertical,
307        )
308    }
309}
310
311impl<C: TupleViews + 'static> View for VStack<(C,)> {
312    fn body(self, _env: &waterui_core::Environment) -> impl View {
313        // Inject the vertical axis into the container
314        with(
315            FixedContainer::new(self.layout, self.contents.0),
316            Axis::Vertical,
317        )
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    struct MockSubView {
326        size: Size,
327        stretch_axis: StretchAxis,
328    }
329
330    impl SubView for MockSubView {
331        fn size_that_fits(&self, _proposal: ProposalSize) -> Size {
332            self.size
333        }
334        fn stretch_axis(&self) -> StretchAxis {
335            self.stretch_axis
336        }
337        fn priority(&self) -> i32 {
338            0
339        }
340    }
341
342    #[test]
343    fn test_vstack_size_two_children() {
344        let layout = VStackLayout {
345            alignment: HorizontalAlignment::Center,
346            spacing: 10.0,
347        };
348
349        let mut child1 = MockSubView {
350            size: Size::new(100.0, 30.0),
351            stretch_axis: StretchAxis::None,
352        };
353        let mut child2 = MockSubView {
354            size: Size::new(80.0, 40.0),
355            stretch_axis: StretchAxis::None,
356        };
357
358        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
359
360        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
361
362        assert!((size.width - 100.0).abs() < f32::EPSILON); // max width
363        assert!((size.height - 80.0).abs() < f32::EPSILON); // 30 + 10 + 40
364    }
365
366    #[test]
367    fn test_vstack_with_spacer() {
368        let layout = VStackLayout {
369            alignment: HorizontalAlignment::Center,
370            spacing: 0.0,
371        };
372
373        let mut child1 = MockSubView {
374            size: Size::new(100.0, 30.0),
375            stretch_axis: StretchAxis::None,
376        };
377        let mut spacer = MockSubView {
378            size: Size::zero(),
379            stretch_axis: StretchAxis::Both, // Spacer stretches in both directions
380        };
381        let mut child2 = MockSubView {
382            size: Size::new(100.0, 30.0),
383            stretch_axis: StretchAxis::None,
384        };
385
386        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
387
388        // With specified height, spacer should expand
389        let size = layout.size_that_fits(ProposalSize::new(None, Some(200.0)), &children);
390
391        assert!((size.height - 200.0).abs() < f32::EPSILON);
392
393        // Place should distribute remaining space to spacer
394        let bounds = Rect::new(Point::zero(), Size::new(100.0, 200.0));
395
396        // Need fresh references
397        let mut child1 = MockSubView {
398            size: Size::new(100.0, 30.0),
399            stretch_axis: StretchAxis::None,
400        };
401        let mut spacer = MockSubView {
402            size: Size::zero(),
403            stretch_axis: StretchAxis::Both,
404        };
405        let mut child2 = MockSubView {
406            size: Size::new(100.0, 30.0),
407            stretch_axis: StretchAxis::None,
408        };
409        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
410
411        let rects = layout.place(bounds, &children);
412
413        assert!((rects[0].height() - 30.0).abs() < f32::EPSILON);
414        assert!((rects[1].height() - 140.0).abs() < f32::EPSILON); // 200 - 30 - 30
415        assert!((rects[2].height() - 30.0).abs() < f32::EPSILON);
416        assert!((rects[2].y() - 170.0).abs() < f32::EPSILON); // 30 + 140
417    }
418
419    #[test]
420    fn test_vstack_with_horizontal_stretch() {
421        // TextField-like component: stretches horizontally but has fixed height
422        let layout = VStackLayout {
423            alignment: HorizontalAlignment::Center,
424            spacing: 10.0,
425        };
426
427        let mut label = MockSubView {
428            size: Size::new(50.0, 20.0),
429            stretch_axis: StretchAxis::None,
430        };
431        let mut text_field = MockSubView {
432            size: Size::new(100.0, 40.0), // reports minimum width, intrinsic height
433            stretch_axis: StretchAxis::Horizontal, // stretches width only
434        };
435        let mut button = MockSubView {
436            size: Size::new(80.0, 44.0),
437            stretch_axis: StretchAxis::None,
438        };
439
440        let children: Vec<&dyn SubView> = vec![&mut label, &mut text_field, &mut button];
441
442        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
443
444        // Width: max of non-horizontal-stretching children = max(50, 80) = 80
445        // Note: text_field stretches horizontally so its width doesn't contribute
446        assert!((size.width - 80.0).abs() < f32::EPSILON);
447        // Height: all children contribute (text_field doesn't stretch vertically)
448        // = 20 + 10 + 40 + 10 + 44 = 124
449        assert!((size.height - 124.0).abs() < f32::EPSILON);
450    }
451}