waterui_layout/
overlay.rs

1//! Overlay helpers for layering content on top of a base view.
2//!
3//! `overlay` mirrors the intent of a two-child `ZStack`, but the container's
4//! dimensions are locked to the first (base) child. This makes it ideal for
5//! badges, highlights, and decorators that should not influence the parent
6//! layout's sizing decisions.
7
8use core::fmt;
9
10use alloc::{vec, vec::Vec};
11use waterui_core::View;
12
13use crate::{
14    Layout, Point, ProposalSize, Rect, Size, StretchAxis, SubView, container::FixedContainer,
15    stack::Alignment,
16};
17
18/// Cached measurement for a child during layout
19struct ChildMeasurement {
20    size: Size,
21}
22
23/// Layout used by [`Overlay`] to keep the base child's size authoritative while
24/// still allowing aligned overlay content.
25#[derive(Debug, Clone, Default)]
26pub struct OverlayLayout {
27    alignment: Alignment,
28}
29
30impl OverlayLayout {
31    /// Sets the [`Alignment`] used to position overlay layers relative to the base.
32    #[must_use]
33    pub const fn alignment(mut self, alignment: Alignment) -> Self {
34        self.alignment = alignment;
35        self
36    }
37
38    /// Returns the current alignment.
39    #[must_use]
40    pub const fn alignment_ref(&self) -> Alignment {
41        self.alignment
42    }
43
44    fn aligned_origin(&self, bounds: &Rect, size: Size) -> Point {
45        match self.alignment {
46            Alignment::TopLeading => Point::new(bounds.x(), bounds.y()),
47            Alignment::Top => {
48                Point::new(bounds.x() + (bounds.width() - size.width) / 2.0, bounds.y())
49            }
50            Alignment::TopTrailing => Point::new(bounds.max_x() - size.width, bounds.y()),
51            Alignment::Leading => Point::new(
52                bounds.x(),
53                bounds.y() + (bounds.height() - size.height) / 2.0,
54            ),
55            Alignment::Center => Point::new(
56                bounds.x() + (bounds.width() - size.width) / 2.0,
57                bounds.y() + (bounds.height() - size.height) / 2.0,
58            ),
59            Alignment::Trailing => Point::new(
60                bounds.max_x() - size.width,
61                bounds.y() + (bounds.height() - size.height) / 2.0,
62            ),
63            Alignment::BottomLeading => Point::new(bounds.x(), bounds.max_y() - size.height),
64            Alignment::Bottom => Point::new(
65                bounds.x() + (bounds.width() - size.width) / 2.0,
66                bounds.max_y() - size.height,
67            ),
68            Alignment::BottomTrailing => {
69                Point::new(bounds.max_x() - size.width, bounds.max_y() - size.height)
70            }
71        }
72    }
73}
74
75impl Layout for OverlayLayout {
76    /// Overlay stretches in both directions, allowing the base child to fill available space.
77    /// The actual size is determined by the base child in `size_that_fits`.
78    fn stretch_axis(&self) -> StretchAxis {
79        StretchAxis::Both
80    }
81
82    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
83        // Overlay size is driven entirely by the base child (index 0). If the base
84        // provides no intrinsic size, fall back to the parent's constraints.
85        let base_size = children
86            .first()
87            .map_or(Size::zero(), |c| c.size_that_fits(proposal));
88
89        let base_width = if base_size.width.is_finite() && base_size.width > 0.0 {
90            base_size.width
91        } else {
92            proposal.width.unwrap_or(0.0)
93        };
94
95        let base_height = if base_size.height.is_finite() && base_size.height > 0.0 {
96            base_size.height
97        } else {
98            proposal.height.unwrap_or(0.0)
99        };
100
101        let width = proposal.width.unwrap_or(base_width);
102        let height = proposal.height.unwrap_or(base_height);
103
104        Size::new(width.max(0.0), height.max(0.0))
105    }
106
107    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
108        if children.is_empty() {
109            return vec![];
110        }
111
112        // Measure all children
113        let child_proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
114
115        let measurements: Vec<ChildMeasurement> = children
116            .iter()
117            .map(|child| ChildMeasurement {
118                size: child.size_that_fits(child_proposal),
119            })
120            .collect();
121
122        let mut placements = Vec::with_capacity(children.len());
123
124        // Base child always fills the container's bounds
125        if let Some(base) = measurements.first() {
126            let base_width = if base.size.width.is_infinite() {
127                bounds.width()
128            } else {
129                base.size.width
130            };
131            let base_height = if base.size.height.is_infinite() {
132                bounds.height()
133            } else {
134                base.size.height
135            };
136            placements.push(Rect::new(
137                bounds.origin(),
138                Size::new(base_width, base_height),
139            ));
140        }
141
142        // Overlay children are aligned within the bounds
143        for measurement in measurements.iter().skip(1) {
144            let width = if measurement.size.width.is_infinite() {
145                bounds.width()
146            } else {
147                measurement.size.width.min(bounds.width()).max(0.0)
148            };
149            let height = if measurement.size.height.is_infinite() {
150                bounds.height()
151            } else {
152                measurement.size.height.min(bounds.height()).max(0.0)
153            };
154            let size = Size::new(width, height);
155            let origin = self.aligned_origin(&bounds, size);
156            placements.push(Rect::new(origin, size));
157        }
158
159        placements
160    }
161}
162
163/// A view that layers `overlay` content on top of a `base` view without
164/// allowing the overlay to influence layout sizing.
165pub struct Overlay<Base, Layer> {
166    layout: OverlayLayout,
167    base: Base,
168    layer: Layer,
169}
170
171impl<Base, Layer> Overlay<Base, Layer> {
172    /// Creates a new overlay using the provided base view and overlay layer.
173    #[must_use]
174    pub const fn new(base: Base, layer: Layer) -> Self {
175        Self {
176            layout: OverlayLayout {
177                alignment: Alignment::Center,
178            },
179            base,
180            layer,
181        }
182    }
183
184    /// Sets how the overlay layer should be aligned inside the base bounds.
185    #[must_use]
186    pub const fn alignment(mut self, alignment: Alignment) -> Self {
187        self.layout.alignment = alignment;
188        self
189    }
190}
191
192impl<Base, Layer> fmt::Debug for Overlay<Base, Layer> {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.debug_struct("Overlay")
195            .field("layout", &self.layout)
196            .finish_non_exhaustive()
197    }
198}
199
200impl<Base, Layer> View for Overlay<Base, Layer>
201where
202    Base: View + 'static,
203    Layer: View + 'static,
204{
205    fn body(self, _env: &waterui_core::Environment) -> impl View {
206        FixedContainer::new(self.layout, (self.base, self.layer))
207    }
208}
209
210/// Convenience constructor for creating an [`Overlay`] with the default alignment.
211#[must_use]
212pub const fn overlay<Base, Layer>(base: Base, layer: Layer) -> Overlay<Base, Layer> {
213    Overlay::new(base, layer)
214}
215
216#[cfg(test)]
217#[allow(clippy::float_cmp)]
218mod tests {
219    use super::*;
220    use crate::StretchAxis;
221
222    struct MockSubView {
223        size: Size,
224    }
225
226    impl SubView for MockSubView {
227        fn size_that_fits(&self, _proposal: ProposalSize) -> Size {
228            self.size
229        }
230        fn stretch_axis(&self) -> StretchAxis {
231            StretchAxis::None
232        }
233        fn priority(&self) -> i32 {
234            0
235        }
236    }
237
238    #[test]
239    fn test_overlay_size_from_base() {
240        let layout = OverlayLayout::default();
241
242        let mut base = MockSubView {
243            size: Size::new(100.0, 50.0),
244        };
245        let mut overlay_child = MockSubView {
246            size: Size::new(20.0, 20.0),
247        };
248
249        let children: Vec<&dyn SubView> = vec![&mut base, &mut overlay_child];
250
251        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
252
253        // Size comes from base child
254        assert_eq!(size.width, 100.0);
255        assert_eq!(size.height, 50.0);
256    }
257
258    #[test]
259    fn test_overlay_placement_center() {
260        let layout = OverlayLayout {
261            alignment: Alignment::Center,
262        };
263
264        let mut base = MockSubView {
265            size: Size::new(100.0, 100.0),
266        };
267        let mut overlay_child = MockSubView {
268            size: Size::new(20.0, 20.0),
269        };
270
271        let children: Vec<&dyn SubView> = vec![&mut base, &mut overlay_child];
272
273        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
274        let rects = layout.place(bounds, &children);
275
276        // Base fills bounds
277        assert_eq!(rects[0].width(), 100.0);
278        assert_eq!(rects[0].height(), 100.0);
279
280        // Overlay child centered
281        assert_eq!(rects[1].x(), 40.0); // (100 - 20) / 2
282        assert_eq!(rects[1].y(), 40.0); // (100 - 20) / 2
283    }
284}