1use alloc::{vec, vec::Vec};
4use nami::{Computed, Signal, SignalExt, collection::Collection};
5use waterui_core::{
6 AnyView, IntoSignalF32, View, env::with, id::Identifiable, layout::LayoutInvalidationCallback,
7 view::TupleViews, views::ForEach,
8};
9
10use crate::{
11 Layout, LazyContainer, PlacedSubview, Point, ProposalSize, Rect, Size, StretchAxis, SubView,
12 SubviewPlacement,
13 container::FixedContainer,
14 stack::{
15 Axis, VerticalAlignment,
16 distribute::{
17 ChildMeasurement, LineAnchor, container_line, cross_envelope, measure_stack,
18 place_cross_extent, stack_spacing,
19 },
20 stack_stretch_axis,
21 },
22};
23
24#[derive(Debug, Clone)]
64pub struct HStack<C> {
65 layout: HStackLayout,
66 contents: C,
67}
68
69#[derive(Debug, Clone)]
71pub struct HStackLayout {
72 pub alignment: VerticalAlignment,
74 pub spacing: Computed<f32>,
76}
77
78impl Default for HStackLayout {
79 fn default() -> Self {
80 Self {
81 alignment: VerticalAlignment::Center,
82 spacing: Computed::constant(10.0),
83 }
84 }
85}
86
87fn hstack_intrinsic_cross_metrics(
98 measurements: &[ChildMeasurement],
99 alignment: VerticalAlignment,
100) -> (f32, f32) {
101 cross_envelope(measurements.iter().map(|measurement| {
102 (
103 measurement.size().height,
104 measurement.vertical_guide(alignment),
105 )
106 }))
107}
108
109impl Layout for HStackLayout {
110 fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
117 stack_stretch_axis(Axis::Horizontal, children)
118 }
119
120 fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
121 if children.is_empty() {
122 return Size::zero();
123 }
124
125 let spacing = self.spacing.get();
126 let measurements = measure_stack(Axis::Horizontal, proposal, spacing, children);
127 let width = measurements.iter().map(|m| m.size().width).sum::<f32>()
128 + stack_spacing(spacing, children.len());
129 let (above, below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
130 let height = if measurements.iter().any(|m| m.size().height.is_infinite()) {
131 f32::INFINITY
132 } else {
133 above + below
134 };
135 Size::new(width, height)
136 }
137
138 fn place(
139 &self,
140 bounds: Rect,
141 _proposal: ProposalSize,
142 children: &[&dyn SubView],
143 ) -> Vec<SubviewPlacement> {
144 if children.is_empty() {
145 return vec![];
146 }
147
148 let spacing = self.spacing.get();
149 let placement = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
155 let measurements = measure_stack(Axis::Horizontal, placement, spacing, children);
156
157 let (above, below) = hstack_intrinsic_cross_metrics(&measurements, self.alignment);
158 let guide_line = bounds.y()
159 + container_line(
160 bounds.height(),
161 LineAnchor::vertical(self.alignment),
162 above,
163 below,
164 );
165
166 let mut placements = Vec::with_capacity(children.len());
168 let mut current_x = bounds.x();
169
170 for (i, measurement) in measurements.iter().enumerate() {
171 if i > 0 {
172 current_x += spacing;
173 }
174
175 let child_height = place_cross_extent(
176 measurement.size().height,
177 bounds.height(),
178 measurement.stretches_cross_axis(),
179 );
180
181 let child_width = measurement.size().width;
182
183 let mut adjusted_dimensions = measurement.dimensions.clone();
184 adjusted_dimensions.size = Size::new(child_width, child_height);
185
186 let y = if measurement.stretches_cross_axis() {
190 bounds.y()
191 } else {
192 guide_line - adjusted_dimensions.vertical(self.alignment)
193 };
194
195 let rect = Rect::new(
196 Point::new(current_x, y),
197 Size::new(child_width, child_height),
198 );
199 placements.push(SubviewPlacement::new(rect, measurement.proposal));
200
201 current_x += child_width;
202 }
203
204 placements
205 }
206
207 fn explicit_vertical(
208 &self,
209 alignment: VerticalAlignment,
210 _bounds: Rect,
211 children: &[PlacedSubview<'_>],
212 ) -> Option<f32> {
213 if alignment == VerticalAlignment::LastBaseline {
214 return children
215 .iter()
216 .filter_map(|child| child.explicit_vertical(alignment))
217 .max_by(f32::total_cmp);
218 }
219
220 if alignment == VerticalAlignment::FirstBaseline || alignment == self.alignment {
221 return children
222 .iter()
223 .filter_map(|child| child.explicit_vertical(alignment))
224 .min_by(f32::total_cmp);
225 }
226
227 None
228 }
229
230 fn watch_invalidation(
231 &self,
232 invalidate: LayoutInvalidationCallback,
233 ) -> Vec<nami::watcher::BoxWatcherGuard> {
234 vec![self.spacing.watch(move |_| invalidate())]
235 }
236}
237
238impl<C: TupleViews> HStack<(C,)> {
239 pub fn new(alignment: VerticalAlignment, spacing: f32, contents: C) -> Self {
242 Self {
243 layout: HStackLayout {
244 alignment,
245 spacing: Computed::constant(spacing),
246 },
247 contents: (contents,),
248 }
249 }
250}
251
252impl<C> HStack<C> {
253 #[must_use]
263 pub fn alignment(mut self, alignment: impl Into<VerticalAlignment>) -> Self {
264 self.layout.alignment = alignment.into();
265 self
266 }
267
268 crate::alignment::vertical_alignment_methods!();
269
270 #[must_use]
275 pub fn spacing(mut self, spacing: impl IntoSignalF32 + 'static) -> Self {
276 self.layout.spacing = spacing.into_signal_f32().computed();
277 self
278 }
279}
280
281crate::stack::impl_stack_for_each!(HStack, HStackLayout);
282
283impl<V> FromIterator<V> for HStack<(Vec<AnyView>,)>
284where
285 V: View,
286{
287 fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self {
288 let contents = iter.into_iter().map(AnyView::new).collect();
289 Self::new(VerticalAlignment::default(), 10.0, contents)
290 }
291}
292
293pub fn hstack<C: TupleViews>(contents: C) -> HStack<(C,)> {
295 HStack::new(VerticalAlignment::Center, 10.0, contents)
296}
297
298impl<C, F, V> View for HStack<ForEach<C, F, V>>
299where
300 C: Collection + Clone,
301 C::Item: Identifiable,
302 F: 'static + Fn(C::Item) -> V,
303 V: View,
304{
305 fn body(self, _env: &waterui_core::Environment) -> impl View {
306 with(
308 LazyContainer::new(self.layout, self.contents),
309 Axis::Horizontal,
310 )
311 }
312
313 fn stretch_axis(&self) -> StretchAxis {
317 self.layout.stretch_axis(&[])
318 }
319}
320
321impl<C: TupleViews + 'static> View for HStack<(C,)> {
322 fn body(self, _env: &waterui_core::Environment) -> impl View {
323 with(
325 FixedContainer::new(self.layout, self.contents.0),
326 Axis::Horizontal,
327 )
328 }
329
330 fn stretch_axis(&self) -> StretchAxis {
334 self.layout.stretch_axis(&self.contents.0.stretch_axes())
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::ViewDimensions;
342 use crate::tests::CompressibleView;
343
344 struct MockSubView {
345 size: Size,
346 stretch_axis: StretchAxis,
347 }
348
349 impl SubView for MockSubView {
350 fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
351 ViewDimensions::new(self.size)
352 }
353 fn stretch_axis(&self) -> StretchAxis {
354 self.stretch_axis
355 }
356 fn priority(&self) -> i32 {
357 0
358 }
359 }
360
361 struct ResponsiveSubView {
362 intrinsic: Size,
363 wrapped_height: f32,
364 wrap_at_or_below: f32,
365 stretch_axis: StretchAxis,
366 }
367
368 impl SubView for ResponsiveSubView {
369 fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
370 ViewDimensions::new(match proposal.width {
371 Some(width) if width <= self.wrap_at_or_below => {
372 Size::new(width, self.wrapped_height)
373 }
374 Some(width) => Size::new(width, self.intrinsic.height),
375 None => self.intrinsic,
376 })
377 }
378
379 fn stretch_axis(&self) -> StretchAxis {
380 self.stretch_axis
381 }
382
383 fn priority(&self) -> i32 {
384 0
385 }
386 }
387
388 #[test]
389 fn test_hstack_size_two_children() {
390 let layout = HStackLayout {
391 alignment: VerticalAlignment::Center,
392 spacing: Computed::constant(10.0),
393 };
394
395 let mut child1 = MockSubView {
396 size: Size::new(50.0, 30.0),
397 stretch_axis: StretchAxis::None,
398 };
399 let mut child2 = MockSubView {
400 size: Size::new(60.0, 40.0),
401 stretch_axis: StretchAxis::None,
402 };
403
404 let children: Vec<&dyn SubView> = vec![&mut child1, &mut child2];
405
406 let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
407
408 assert!((size.width - 120.0).abs() < f32::EPSILON); assert!((size.height - 40.0).abs() < f32::EPSILON); }
411
412 #[test]
413 fn test_hstack_with_spacer() {
414 let layout = HStackLayout {
415 alignment: VerticalAlignment::Center,
416 spacing: Computed::constant(0.0),
417 };
418
419 let mut child1 = MockSubView {
420 size: Size::new(30.0, 40.0),
421 stretch_axis: StretchAxis::None,
422 };
423 let mut spacer = MockSubView {
424 size: Size::zero(),
425 stretch_axis: StretchAxis::Both, };
427 let mut child2 = MockSubView {
428 size: Size::new(30.0, 40.0),
429 stretch_axis: StretchAxis::None,
430 };
431
432 let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
433
434 let size = layout.size_that_fits(ProposalSize::new(Some(200.0), None), &children);
436
437 assert!((size.width - 200.0).abs() < f32::EPSILON);
438
439 let bounds = Rect::new(Point::zero(), Size::new(200.0, 40.0));
441
442 let mut child1 = MockSubView {
443 size: Size::new(30.0, 40.0),
444 stretch_axis: StretchAxis::None,
445 };
446 let mut spacer = MockSubView {
447 size: Size::zero(),
448 stretch_axis: StretchAxis::Both,
449 };
450 let mut child2 = MockSubView {
451 size: Size::new(30.0, 40.0),
452 stretch_axis: StretchAxis::None,
453 };
454 let children: Vec<&dyn SubView> = vec![&mut child1, &mut spacer, &mut child2];
455
456 let placements = layout.place(bounds, ProposalSize::new(Some(200.0), None), &children);
457
458 assert!((placements[0].frame.width() - 30.0).abs() < f32::EPSILON);
459 assert!((placements[1].frame.width() - 140.0).abs() < f32::EPSILON); assert!((placements[2].frame.width() - 30.0).abs() < f32::EPSILON);
461 assert!((placements[2].frame.x() - 170.0).abs() < f32::EPSILON); }
463
464 #[test]
469 fn a_cross_filling_child_still_sets_the_row_height() {
470 let layout = HStackLayout {
473 alignment: VerticalAlignment::Center,
474 spacing: Computed::constant(10.0),
475 };
476
477 let mut label = MockSubView {
478 size: Size::new(50.0, 20.0),
479 stretch_axis: StretchAxis::None,
480 };
481 let mut vertical_stretch = MockSubView {
483 size: Size::new(40.0, 100.0), stretch_axis: StretchAxis::Vertical, };
486 let mut button = MockSubView {
487 size: Size::new(80.0, 44.0),
488 stretch_axis: StretchAxis::None,
489 };
490
491 let children: Vec<&dyn SubView> = vec![&mut label, &mut vertical_stretch, &mut button];
492
493 let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
494
495 assert!((size.width - 190.0).abs() < f32::EPSILON);
498 assert!(
500 (size.height - 100.0).abs() < f32::EPSILON,
501 "the stretching child's own height has to reach the row, got {}",
502 size.height
503 );
504 }
505
506 #[test]
507 fn test_hstack_compresses_equal_children_evenly_bounded_proposal() {
508 let layout = HStackLayout {
513 alignment: VerticalAlignment::Center,
514 spacing: Computed::constant(8.0),
515 };
516
517 let mut columns: Vec<CompressibleView> = (0..7)
518 .map(|_| CompressibleView {
519 ideal: Size::new(40.0, 40.0),
520 floor: 0.0,
521 })
522 .collect();
523 let children: Vec<&dyn SubView> = columns
524 .iter_mut()
525 .map(|child| child as &dyn SubView)
526 .collect();
527
528 let bounds = Rect::new(Point::zero(), Size::new(272.0, 40.0));
529 let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
530 let placements = layout.place(bounds, proposal, &children);
531
532 for placement in &placements {
533 assert!(
534 (placement.frame.width() - 32.0).abs() < 0.001,
535 "expected even 32pt columns, got {}",
536 placement.frame.width()
537 );
538 }
539 for pair in placements.windows(2) {
540 assert!(
541 ((pair[1].frame.x() - pair[0].frame.x()) - 40.0).abs() < 0.001,
542 "expected uniform 40pt pitch, got {}",
543 pair[1].frame.x() - pair[0].frame.x()
544 );
545 }
546 }
547
548 #[test]
549 fn test_hstack_compression_keeps_small_children_intrinsic_bounded_proposal() {
550 let layout = HStackLayout {
554 alignment: VerticalAlignment::Center,
555 spacing: Computed::constant(10.0),
556 };
557
558 let mut label = MockSubView {
559 size: Size::new(50.0, 20.0),
560 stretch_axis: StretchAxis::None,
561 };
562 let mut long_text = ResponsiveSubView {
563 intrinsic: Size::new(200.0, 20.0),
564 wrapped_height: 40.0,
565 wrap_at_or_below: 60.0,
566 stretch_axis: StretchAxis::None,
567 };
568 let children: Vec<&dyn SubView> = vec![&mut label, &mut long_text];
569
570 let bounds = Rect::new(Point::zero(), Size::new(150.0, 40.0));
571 let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
572 let placements = layout.place(bounds, proposal, &children);
573
574 assert!((placements[0].frame.width() - 50.0).abs() < 0.001);
575 assert!((placements[1].frame.width() - 90.0).abs() < 0.001);
576 }
577
578 #[test]
579 fn test_hstack_compression_never_goes_below_a_reported_minimum_bounded_proposal() {
580 let layout = HStackLayout {
584 alignment: VerticalAlignment::Center,
585 spacing: Computed::constant(0.0),
586 };
587
588 let mut first = CompressibleView {
589 ideal: Size::new(60.0, 20.0),
590 floor: 20.0,
591 };
592 let mut second = CompressibleView {
593 ideal: Size::new(60.0, 20.0),
594 floor: 20.0,
595 };
596 let children: Vec<&dyn SubView> = vec![&mut first, &mut second];
597
598 let bounds = Rect::new(Point::zero(), Size::new(10.0, 20.0));
599 let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
600 let placements = layout.place(bounds, proposal, &children);
601
602 assert!((placements[0].frame.width() - 20.0).abs() < 0.001);
603 assert!((placements[1].frame.width() - 20.0).abs() < 0.001);
604 }
605
606 #[test]
607 fn test_hstack_measures_stretch_child_with_allocated_width_for_height() {
608 let layout = HStackLayout {
609 alignment: VerticalAlignment::Center,
610 spacing: Computed::constant(0.0),
611 };
612
613 let mut fixed = MockSubView {
614 size: Size::new(4.0, 10.0),
615 stretch_axis: StretchAxis::None,
616 };
617
618 let mut stretch = ResponsiveSubView {
620 intrinsic: Size::new(100.0, 20.0),
621 wrapped_height: 40.0,
622 wrap_at_or_below: 60.0,
623 stretch_axis: StretchAxis::Horizontal,
624 };
625
626 let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
627
628 let size = layout.size_that_fits(ProposalSize::new(Some(40.0), None), &children);
629
630 assert!((size.width - 40.0).abs() < f32::EPSILON);
631 assert!((size.height - 40.0).abs() < f32::EPSILON);
632 }
633
634 #[test]
635 fn test_hstack_place_uses_stretch_child_wrapped_height_bounded_proposal() {
636 let layout = HStackLayout {
637 alignment: VerticalAlignment::Center,
638 spacing: Computed::constant(0.0),
639 };
640
641 let bounds = Rect::new(Point::zero(), Size::new(40.0, 40.0));
642
643 let mut fixed = MockSubView {
644 size: Size::new(4.0, 10.0),
645 stretch_axis: StretchAxis::None,
646 };
647
648 let mut stretch = ResponsiveSubView {
649 intrinsic: Size::new(100.0, 20.0),
650 wrapped_height: 40.0,
651 wrap_at_or_below: 60.0,
652 stretch_axis: StretchAxis::Horizontal,
653 };
654
655 let children: Vec<&dyn SubView> = vec![&mut fixed, &mut stretch];
656
657 let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
658 let placements = layout.place(bounds, proposal, &children);
659
660 assert!((placements[0].frame.width() - 4.0).abs() < f32::EPSILON);
661 assert!((placements[1].frame.width() - 36.0).abs() < f32::EPSILON);
662 assert!((placements[1].frame.height() - 40.0).abs() < f32::EPSILON);
663 }
664
665 #[test]
666 fn test_hstack_min_size_query_correctly_sums_children() {
667 let layout = HStackLayout {
671 alignment: VerticalAlignment::Center,
672 spacing: Computed::constant(10.0),
673 };
674
675 let mut label = MockSubView {
676 size: Size::new(50.0, 20.0),
677 stretch_axis: StretchAxis::None,
678 };
679 let mut spacer = MockSubView {
680 size: Size::zero(),
681 stretch_axis: StretchAxis::Both,
682 };
683 let mut button = MockSubView {
684 size: Size::new(80.0, 44.0),
685 stretch_axis: StretchAxis::None,
686 };
687
688 let children: Vec<&dyn SubView> = vec![&mut label, &mut spacer, &mut button];
689
690 let min_size = layout.size_that_fits(ProposalSize::ZERO, &children);
692
693 assert!(
695 (min_size.width - 150.0).abs() < f32::EPSILON,
696 "Min size query should return sum of child min widths (150.0), got {}",
697 min_size.width
698 );
699
700 let intrinsic_size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
702 assert!((intrinsic_size.width - 150.0).abs() < f32::EPSILON);
704 }
705}