waterui_layout/stack/
hstack.rs

1//! Horizontal 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, VerticalAlignment},
11};
12
13/// A view that arranges its children in a horizontal line.
14///
15/// Use an `HStack` to arrange views side-by-side. The stack sizes itself to fit
16/// its contents, distributing available space among its children.
17///
18/// ```ignore
19/// hstack((
20///     text("Hello"),
21///     text("World"),
22/// ))
23/// ```
24///
25/// You can customize the spacing between children and their vertical alignment:
26///
27/// ```ignore
28/// HStack::new(VerticalAlignment::Top, 20.0, (
29///     text("First"),
30///     text("Second"),
31/// ))
32/// ```
33///
34/// Use [`spacer()`] to push content to the sides:
35///
36/// ```ignore
37/// hstack((
38///     text("Leading"),
39///     spacer(),
40///     text("Trailing"),
41/// ))
42/// ```
43#[derive(Debug, Clone)]
44pub struct HStack<C> {
45    layout: HStackLayout,
46    contents: C,
47}
48
49/// Layout engine shared by the public [`HStack`] view.
50#[derive(Debug, Clone)]
51pub struct HStackLayout {
52    /// The vertical alignment of children within the stack.
53    pub alignment: VerticalAlignment,
54    /// The spacing between children in the stack.
55    pub spacing: f32,
56}
57
58impl Default for HStackLayout {
59    fn default() -> Self {
60        Self {
61            alignment: VerticalAlignment::Center,
62            spacing: 10.0,
63        }
64    }
65}
66
67/// Cached measurement for a child during layout
68struct ChildMeasurement {
69    size: Size,
70    stretch_axis: StretchAxis,
71}
72
73impl ChildMeasurement {
74    /// Returns true if this child stretches horizontally (for `HStack` width distribution).
75    /// In `HStack` context:
76    /// - `MainAxis` means horizontal (`HStack`'s main axis)
77    /// - `CrossAxis` means vertical (`HStack`'s cross axis)
78    const fn stretches_main_axis(&self) -> bool {
79        matches!(
80            self.stretch_axis,
81            StretchAxis::Horizontal | StretchAxis::Both | StretchAxis::MainAxis
82        )
83    }
84
85    /// Returns true if this child stretches vertically (for `HStack` height expansion).
86    /// In `HStack` context:
87    /// - `CrossAxis` means vertical (`HStack`'s cross axis)
88    const fn stretches_cross_axis(&self) -> bool {
89        matches!(
90            self.stretch_axis,
91            StretchAxis::Vertical | StretchAxis::Both | StretchAxis::CrossAxis
92        )
93    }
94}
95
96#[allow(clippy::cast_precision_loss)]
97#[allow(clippy::too_many_lines)]
98impl Layout for HStackLayout {
99    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
100        if children.is_empty() {
101            return Size::zero();
102        }
103
104        let total_spacing = if children.len() > 1 {
105            (children.len() - 1) as f32 * self.spacing
106        } else {
107            0.0
108        };
109
110        // First pass: measure children with unspecified width to get intrinsic sizes
111        let intrinsic_proposal = ProposalSize::new(None, proposal.height);
112        let mut measurements: Vec<ChildMeasurement> = children
113            .iter()
114            .map(|child| ChildMeasurement {
115                size: child.size_that_fits(intrinsic_proposal),
116                stretch_axis: child.stretch_axis(),
117            })
118            .collect();
119
120        // HStack checks for main-axis (horizontal) stretching
121        let has_main_axis_stretch = measurements
122            .iter()
123            .any(ChildMeasurement::stretches_main_axis);
124        let main_axis_stretch_indices: Vec<usize> = measurements
125            .iter()
126            .enumerate()
127            .filter(|(_, m)| m.stretches_main_axis())
128            .map(|(idx, _)| idx)
129            .collect();
130        let main_axis_stretch_count = main_axis_stretch_indices.len();
131
132        let intrinsic_width_all: f32 =
133            measurements.iter().map(|m| m.size.width).sum::<f32>() + total_spacing;
134
135        // Intrinsic width used when the parent doesn't constrain width.
136        // In unconstrained context, even "stretching" children should be measured at their
137        // intrinsic widths (otherwise content-bearing views could collapse to 0 width).
138        let intrinsic_width = intrinsic_width_all;
139
140        // Determine final width
141        let final_width = proposal.width.map_or(intrinsic_width, |proposed| {
142            if has_main_axis_stretch {
143                proposed
144            } else {
145                intrinsic_width.min(proposed)
146            }
147        });
148
149        // If width is constrained, we need to distribute space properly
150        // Key insight: small children (labels) keep intrinsic width, large children (text) compress
151        let available_for_children = (final_width - total_spacing).max(0.0);
152
153        let fixed_indices: Vec<usize> = if main_axis_stretch_count > 0 && proposal.width.is_some() {
154            measurements
155                .iter()
156                .enumerate()
157                .filter(|(_, m)| !m.stretches_main_axis())
158                .map(|(idx, _)| idx)
159                .collect()
160        } else {
161            (0..measurements.len()).collect()
162        };
163
164        let fixed_width: f32 = fixed_indices
165            .iter()
166            .map(|&idx| measurements[idx].size.width)
167            .sum();
168
169        if proposal.width.is_some()
170            && !fixed_indices.is_empty()
171            && fixed_width > available_for_children
172        {
173            // Need to compress - find the largest child and give it remaining space
174            // Small children keep their intrinsic width
175            let overflow = fixed_width - available_for_children;
176
177            // Find indices of non-main-axis-stretching children sorted by width (largest first)
178            let mut compress_indices = fixed_indices;
179            compress_indices.sort_by(|&a, &b| {
180                measurements[b]
181                    .size
182                    .width
183                    .partial_cmp(&measurements[a].size.width)
184                    .unwrap()
185            });
186
187            // Compress largest children first until we fit
188            let mut remaining_overflow = overflow;
189            for &idx in &compress_indices {
190                if remaining_overflow <= 0.0 {
191                    break;
192                }
193
194                let current_width = measurements[idx].size.width;
195                // Don't compress below a minimum (e.g., 20px for very small labels)
196                let min_width = 20.0_f32.min(current_width);
197                let max_reduction = current_width - min_width;
198                let reduction = remaining_overflow.min(max_reduction);
199
200                if reduction > 0.0 {
201                    let new_width = current_width - reduction;
202                    let constrained_proposal = ProposalSize::new(Some(new_width), proposal.height);
203                    measurements[idx].size = children[idx].size_that_fits(constrained_proposal);
204                    remaining_overflow -= reduction;
205                }
206            }
207        }
208
209        // If there are main-axis stretching children and width is constrained, measure them with
210        // their allocated widths so height reflects wrapped content.
211        if proposal.width.is_some() && main_axis_stretch_count > 0 {
212            let fixed_width: f32 = measurements
213                .iter()
214                .enumerate()
215                .filter(|(_, m)| !m.stretches_main_axis())
216                .map(|(_, m)| m.size.width)
217                .sum();
218
219            let remaining_width = (available_for_children - fixed_width).max(0.0);
220            let stretch_width = remaining_width / main_axis_stretch_count as f32;
221
222            for idx in main_axis_stretch_indices {
223                let constrained_proposal = ProposalSize::new(Some(stretch_width), proposal.height);
224                measurements[idx].size = children[idx].size_that_fits(constrained_proposal);
225                measurements[idx].size.width = measurements[idx].size.width.min(stretch_width);
226            }
227        }
228
229        // Height: max of all children (after re-measurement for proper wrapped height)
230        // Important: Do NOT cap height to proposal - if text wraps, we need the full height
231        // Note: cross-axis-stretching children don't contribute to intrinsic height
232        let max_height = measurements
233            .iter()
234            .filter(|m| !m.stretches_cross_axis())
235            .map(|m| m.size.height)
236            .max_by(f32::total_cmp)
237            .unwrap_or(0.0);
238
239        Size::new(final_width, max_height)
240    }
241
242    #[allow(clippy::too_many_lines)]
243    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
244        if children.is_empty() {
245            return vec![];
246        }
247
248        let total_spacing = if children.len() > 1 {
249            (children.len() - 1) as f32 * self.spacing
250        } else {
251            0.0
252        };
253
254        let available_width = bounds.width() - total_spacing;
255
256        // First pass: measure all children with None to get intrinsic sizes
257        let intrinsic_proposal = ProposalSize::new(None, Some(bounds.height()));
258        let mut measurements: Vec<ChildMeasurement> = children
259            .iter()
260            .map(|child| ChildMeasurement {
261                size: child.size_that_fits(intrinsic_proposal),
262                stretch_axis: child.stretch_axis(),
263            })
264            .collect();
265
266        // Calculate totals - HStack cares about main-axis (horizontal) stretching
267        let main_axis_stretch_indices: Vec<usize> = measurements
268            .iter()
269            .enumerate()
270            .filter(|(_, m)| m.stretches_main_axis())
271            .map(|(idx, _)| idx)
272            .collect();
273        let main_axis_stretch_count = main_axis_stretch_indices.len();
274
275        let fixed_indices: Vec<usize> = if main_axis_stretch_count > 0 {
276            measurements
277                .iter()
278                .enumerate()
279                .filter(|(_, m)| !m.stretches_main_axis())
280                .map(|(idx, _)| idx)
281                .collect()
282        } else {
283            (0..measurements.len()).collect()
284        };
285
286        let fixed_width: f32 = fixed_indices
287            .iter()
288            .map(|&idx| measurements[idx].size.width)
289            .sum();
290
291        // Check if we need to compress children (when fixed children don't fit)
292        let needs_compression = !fixed_indices.is_empty() && fixed_width > available_width;
293
294        if needs_compression {
295            // Compress largest children first, keeping small labels at intrinsic width
296            let overflow = fixed_width - available_width;
297
298            // Find indices of non-main-axis-stretching children sorted by width (largest first)
299            let mut compress_indices = fixed_indices;
300            compress_indices.sort_by(|&a, &b| {
301                measurements[b]
302                    .size
303                    .width
304                    .partial_cmp(&measurements[a].size.width)
305                    .unwrap()
306            });
307
308            // Compress largest children first until we fit
309            let mut remaining_overflow = overflow;
310            for &idx in &compress_indices {
311                if remaining_overflow <= 0.0 {
312                    break;
313                }
314
315                let current_width = measurements[idx].size.width;
316                // Don't compress below a minimum (keep small labels readable)
317                let min_width = 20.0_f32.min(current_width);
318                let max_reduction = current_width - min_width;
319                let reduction = remaining_overflow.min(max_reduction);
320
321                if reduction > 0.0 {
322                    let new_width = current_width - reduction;
323                    let constrained_proposal =
324                        ProposalSize::new(Some(new_width), Some(bounds.height()));
325                    measurements[idx].size = children[idx].size_that_fits(constrained_proposal);
326                    measurements[idx].size.width = measurements[idx].size.width.min(new_width);
327                    remaining_overflow -= reduction;
328                }
329            }
330        }
331
332        // Calculate stretch child width from remaining space
333        let actual_fixed_width: f32 = measurements
334            .iter()
335            .enumerate()
336            .filter(|(_, m)| !m.stretches_main_axis())
337            .map(|(_, m)| m.size.width)
338            .sum();
339
340        let remaining_width = (available_width - actual_fixed_width).max(0.0);
341        let stretch_width = if main_axis_stretch_count > 0 {
342            remaining_width / main_axis_stretch_count as f32
343        } else {
344            0.0
345        };
346
347        // Measure stretching children with their allocated width so cross-axis sizing is accurate.
348        if main_axis_stretch_count > 0 {
349            for idx in &main_axis_stretch_indices {
350                let constrained_proposal =
351                    ProposalSize::new(Some(stretch_width), Some(bounds.height()));
352                measurements[*idx].size = children[*idx].size_that_fits(constrained_proposal);
353                measurements[*idx].size.width = measurements[*idx].size.width.min(stretch_width);
354            }
355        }
356
357        // Place children
358        let mut rects = Vec::with_capacity(children.len());
359        let mut current_x = bounds.x();
360
361        for (i, measurement) in measurements.iter().enumerate() {
362            if i > 0 {
363                current_x += self.spacing;
364            }
365
366            // Handle cross-axis (vertical) stretching and infinite height
367            let child_height = if measurement.stretches_cross_axis() {
368                // CrossAxis in HStack means expand vertically to full bounds height
369                bounds.height()
370            } else if measurement.size.height.is_infinite() {
371                bounds.height()
372            } else {
373                measurement.size.height.min(bounds.height())
374            };
375
376            let child_width = if measurement.stretches_main_axis() {
377                stretch_width
378            } else {
379                measurement.size.width
380            };
381
382            let y = match self.alignment {
383                VerticalAlignment::Top => bounds.y(),
384                VerticalAlignment::Center => bounds.y() + (bounds.height() - child_height) / 2.0,
385                VerticalAlignment::Bottom => bounds.y() + bounds.height() - child_height,
386            };
387
388            let rect = Rect::new(
389                Point::new(current_x, y),
390                Size::new(child_width, child_height),
391            );
392            rects.push(rect);
393
394            current_x += child_width;
395        }
396
397        rects
398    }
399}
400
401impl<C> HStack<(C,)> {
402    /// Creates a horizontal stack with the provided alignment, spacing, and
403    /// children.
404    pub const fn new(alignment: VerticalAlignment, spacing: f32, contents: C) -> Self {
405        Self {
406            layout: HStackLayout { alignment, spacing },
407            contents: (contents,),
408        }
409    }
410}
411
412impl<C> HStack<C> {
413    /// Sets the vertical alignment for children in the stack.
414    #[must_use]
415    pub const fn alignment(mut self, alignment: VerticalAlignment) -> Self {
416        self.layout.alignment = alignment;
417        self
418    }
419
420    /// Sets the spacing between children in the stack.
421    #[must_use]
422    pub const fn spacing(mut self, spacing: f32) -> Self {
423        self.layout.spacing = spacing;
424        self
425    }
426}
427
428impl<V> FromIterator<V> for HStack<(Vec<AnyView>,)>
429where
430    V: View,
431{
432    fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
433        let contents = iter.into_iter().map(AnyView::new).collect();
434        Self::new(VerticalAlignment::default(), 10.0, contents)
435    }
436}
437
438/// Convenience constructor that centres children and uses the default spacing.
439pub const fn hstack<C>(contents: C) -> HStack<(C,)> {
440    HStack::new(VerticalAlignment::Center, 10.0, contents)
441}
442
443impl<C, F, V> View for HStack<ForEach<C, F, V>>
444where
445    C: Collection,
446    C::Item: Identifiable,
447    F: 'static + Fn(C::Item) -> V,
448    V: View,
449{
450    fn body(self, _env: &waterui_core::Environment) -> impl View {
451        // Inject the horizontal axis into the container
452        with(
453            LazyContainer::new(self.layout, self.contents),
454            Axis::Horizontal,
455        )
456    }
457}
458
459impl<C: TupleViews + 'static> View for HStack<(C,)> {
460    fn body(self, _env: &waterui_core::Environment) -> impl View {
461        // Inject the horizontal axis into the container
462        with(
463            FixedContainer::new(self.layout, self.contents.0),
464            Axis::Horizontal,
465        )
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    struct MockSubView {
474        size: Size,
475        stretch_axis: StretchAxis,
476    }
477
478    impl SubView for MockSubView {
479        fn size_that_fits(&self, _proposal: ProposalSize) -> Size {
480            self.size
481        }
482        fn stretch_axis(&self) -> StretchAxis {
483            self.stretch_axis
484        }
485        fn priority(&self) -> i32 {
486            0
487        }
488    }
489
490    struct ResponsiveSubView {
491        intrinsic: Size,
492        wrapped_height: f32,
493        wrap_at_or_below: f32,
494        stretch_axis: StretchAxis,
495    }
496
497    impl SubView for ResponsiveSubView {
498        fn size_that_fits(&self, proposal: ProposalSize) -> Size {
499            match proposal.width {
500                Some(width) if width <= self.wrap_at_or_below => {
501                    Size::new(width, self.wrapped_height)
502                }
503                Some(width) => Size::new(width, self.intrinsic.height),
504                None => self.intrinsic,
505            }
506        }
507
508        fn stretch_axis(&self) -> StretchAxis {
509            self.stretch_axis
510        }
511
512        fn priority(&self) -> i32 {
513            0
514        }
515    }
516
517    #[test]
518    fn test_hstack_size_two_children() {
519        let layout = HStackLayout {
520            alignment: VerticalAlignment::Center,
521            spacing: 10.0,
522        };
523
524        let mut child1 = MockSubView {
525            size: Size::new(50.0, 30.0),
526            stretch_axis: StretchAxis::None,
527        };
528        let mut child2 = MockSubView {
529            size: Size::new(60.0, 40.0),
530            stretch_axis: StretchAxis::None,
531        };
532
533        let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
534
535        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
536
537        assert!((size.width - 120.0).abs() < f32::EPSILON); // 50 + 10 + 60
538        assert!((size.height - 40.0).abs() < f32::EPSILON); // max height
539    }
540
541    #[test]
542    fn test_hstack_with_spacer() {
543        let layout = HStackLayout {
544            alignment: VerticalAlignment::Center,
545            spacing: 0.0,
546        };
547
548        let mut child1 = MockSubView {
549            size: Size::new(30.0, 40.0),
550            stretch_axis: StretchAxis::None,
551        };
552        let mut spacer = MockSubView {
553            size: Size::zero(),
554            stretch_axis: StretchAxis::Both, // Spacer stretches in both directions
555        };
556        let mut child2 = MockSubView {
557            size: Size::new(30.0, 40.0),
558            stretch_axis: StretchAxis::None,
559        };
560
561        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
562
563        // With specified width, spacer should expand
564        let size = layout.size_that_fits(ProposalSize::new(Some(200.0), None), &children);
565
566        assert!((size.width - 200.0).abs() < f32::EPSILON);
567
568        // Place should distribute remaining space to spacer
569        let bounds = Rect::new(Point::zero(), Size::new(200.0, 40.0));
570
571        let mut child1 = MockSubView {
572            size: Size::new(30.0, 40.0),
573            stretch_axis: StretchAxis::None,
574        };
575        let mut spacer = MockSubView {
576            size: Size::zero(),
577            stretch_axis: StretchAxis::Both,
578        };
579        let mut child2 = MockSubView {
580            size: Size::new(30.0, 40.0),
581            stretch_axis: StretchAxis::None,
582        };
583        let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
584
585        let rects = layout.place(bounds, &children);
586
587        assert!((rects[0].width() - 30.0).abs() < f32::EPSILON);
588        assert!((rects[1].width() - 140.0).abs() < f32::EPSILON); // 200 - 30 - 30
589        assert!((rects[2].width() - 30.0).abs() < f32::EPSILON);
590        assert!((rects[2].x() - 170.0).abs() < f32::EPSILON); // 30 + 140
591    }
592
593    #[test]
594    fn test_hstack_with_vertical_stretch() {
595        // Vertical stretch component in HStack: stretches HEIGHT but has fixed WIDTH
596        // This is like a Slider/TextField rotated for use in HStack context
597        let layout = HStackLayout {
598            alignment: VerticalAlignment::Center,
599            spacing: 10.0,
600        };
601
602        let mut label = MockSubView {
603            size: Size::new(50.0, 20.0),
604            stretch_axis: StretchAxis::None,
605        };
606        // A vertically-stretching component: fixed width, wants to fill height
607        let mut vertical_stretch = MockSubView {
608            size: Size::new(40.0, 100.0), // reports minimum width, tall height
609            stretch_axis: StretchAxis::Vertical, // stretches height only
610        };
611        let mut button = MockSubView {
612            size: Size::new(80.0, 44.0),
613            stretch_axis: StretchAxis::None,
614        };
615
616        let children: Vec<&dyn SubView> = vec![&mut label, &mut vertical_stretch, &mut button];
617
618        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
619
620        // Width: all children contribute (vertical_stretch doesn't stretch horizontally)
621        // = 50 + 10 + 40 + 10 + 80 = 190
622        assert!((size.width - 190.0).abs() < f32::EPSILON);
623        // Height: max of non-vertically-stretching children = max(20, 44) = 44
624        // Note: vertical_stretch stretches vertically so its height doesn't contribute
625        assert!((size.height - 44.0).abs() < f32::EPSILON);
626    }
627
628    #[test]
629    fn test_hstack_measures_stretch_child_with_allocated_width_for_height() {
630        let layout = HStackLayout {
631            alignment: VerticalAlignment::Center,
632            spacing: 0.0,
633        };
634
635        let mut fixed = MockSubView {
636            size: Size::new(4.0, 10.0),
637            stretch_axis: StretchAxis::None,
638        };
639
640        // Simulate a content-bearing stretch child whose height increases when width is constrained.
641        let mut stretch = ResponsiveSubView {
642            intrinsic: Size::new(100.0, 20.0),
643            wrapped_height: 40.0,
644            wrap_at_or_below: 60.0,
645            stretch_axis: StretchAxis::Horizontal,
646        };
647
648        let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
649
650        let size = layout.size_that_fits(ProposalSize::new(Some(40.0), None), &children);
651
652        assert!((size.width - 40.0).abs() < f32::EPSILON);
653        assert!((size.height - 40.0).abs() < f32::EPSILON);
654    }
655
656    #[test]
657    fn test_hstack_place_uses_stretch_child_wrapped_height() {
658        let layout = HStackLayout {
659            alignment: VerticalAlignment::Center,
660            spacing: 0.0,
661        };
662
663        let bounds = Rect::new(Point::zero(), Size::new(40.0, 40.0));
664
665        let mut fixed = MockSubView {
666            size: Size::new(4.0, 10.0),
667            stretch_axis: StretchAxis::None,
668        };
669
670        let mut stretch = ResponsiveSubView {
671            intrinsic: Size::new(100.0, 20.0),
672            wrapped_height: 40.0,
673            wrap_at_or_below: 60.0,
674            stretch_axis: StretchAxis::Horizontal,
675        };
676
677        let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
678
679        let rects = layout.place(bounds, &children);
680
681        assert!((rects[0].width() - 4.0).abs() < f32::EPSILON);
682        assert!((rects[1].width() - 36.0).abs() < f32::EPSILON);
683        assert!((rects[1].height() - 40.0).abs() < f32::EPSILON);
684    }
685}