Skip to main content

waterui_layout/modifiers/
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, PlacedSubview, Point, ProposalSize, Rect, Size, StretchAxis, SubView, ViewDimensions,
15    container::FixedContainer,
16    stack::{Alignment, HorizontalAlignment, VerticalAlignment},
17};
18
19/// Cached measurement for a child during layout
20struct ChildMeasurement {
21    dimensions: ViewDimensions,
22}
23
24/// Layout used by [`Overlay`] to keep the base child's size authoritative while
25/// still allowing aligned overlay content.
26#[derive(Debug, Clone, Default)]
27pub struct OverlayLayout {
28    alignment: Alignment,
29}
30
31impl OverlayLayout {
32    /// Sets the [`Alignment`] used to position overlay layers relative to the base.
33    #[must_use]
34    pub const fn alignment(mut self, alignment: Alignment) -> Self {
35        self.alignment = alignment;
36        self
37    }
38
39    /// Returns the current alignment.
40    #[must_use]
41    pub const fn alignment_ref(&self) -> Alignment {
42        self.alignment
43    }
44}
45
46impl Layout for OverlayLayout {
47    /// An overlay is sized by its base, `children[0]`; the layer on top never
48    /// changes what the pair claims.
49    fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
50        children.first().copied().unwrap_or_default()
51    }
52
53    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
54        let base_size = children
55            .first()
56            .map_or(Size::zero(), |c| c.measure(proposal).size);
57
58        Size::new(
59            overlay_axis_size(base_size.width, proposal.width),
60            overlay_axis_size(base_size.height, proposal.height),
61        )
62    }
63
64    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
65        if children.is_empty() {
66            return vec![];
67        }
68
69        // Measure all children
70        let child_proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
71
72        let measurements: Vec<ChildMeasurement> = children
73            .iter()
74            .map(|child| ChildMeasurement {
75                dimensions: child.measure(child_proposal),
76            })
77            .collect();
78
79        let mut placements = Vec::with_capacity(children.len());
80
81        if let Some(base) = measurements.first() {
82            let base_width = overlay_placement_axis(base.dimensions.size.width, bounds.width());
83            let base_height = overlay_placement_axis(base.dimensions.size.height, bounds.height());
84            placements.push(Rect::new(
85                bounds.origin(),
86                Size::new(base_width, base_height),
87            ));
88        }
89
90        // Overlay children are aligned within the bounds
91        for measurement in measurements.iter().skip(1) {
92            let child_dimensions = &measurement.dimensions;
93            let width = if child_dimensions.size.width.is_infinite() {
94                bounds.width()
95            } else {
96                child_dimensions.size.width.min(bounds.width()).max(0.0)
97            };
98            let height = if child_dimensions.size.height.is_infinite() {
99                bounds.height()
100            } else {
101                child_dimensions.size.height.min(bounds.height()).max(0.0)
102            };
103            let size = Size::new(width, height);
104            let mut adjusted_dimensions = child_dimensions.clone();
105            adjusted_dimensions.size = size;
106            let horizontal = self.alignment.horizontal();
107            let vertical = self.alignment.vertical();
108            let target_x = if horizontal == HorizontalAlignment::Leading {
109                0.0
110            } else if horizontal == HorizontalAlignment::Trailing {
111                bounds.width()
112            } else if horizontal == HorizontalAlignment::Center {
113                bounds.width() * 0.5
114            } else {
115                adjusted_dimensions
116                    .horizontal(horizontal)
117                    .clamp(0.0, size.width)
118            };
119            let target_y = if vertical == VerticalAlignment::Top {
120                0.0
121            } else if vertical == VerticalAlignment::Bottom {
122                bounds.height()
123            } else if vertical == VerticalAlignment::Center {
124                bounds.height() * 0.5
125            } else {
126                adjusted_dimensions
127                    .vertical(vertical)
128                    .clamp(0.0, size.height)
129            };
130            let origin = Point::new(
131                bounds.x() + target_x
132                    - adjusted_dimensions
133                        .horizontal(horizontal)
134                        .clamp(0.0, size.width),
135                bounds.y() + target_y
136                    - adjusted_dimensions
137                        .vertical(vertical)
138                        .clamp(0.0, size.height),
139            );
140            placements.push(Rect::new(origin, size));
141        }
142
143        placements
144    }
145
146    fn explicit_horizontal(
147        &self,
148        alignment: HorizontalAlignment,
149        _bounds: Rect,
150        children: &[PlacedSubview<'_>],
151    ) -> Option<f32> {
152        children
153            .first()
154            .and_then(|child| child.explicit_horizontal(alignment))
155    }
156
157    fn explicit_vertical(
158        &self,
159        alignment: VerticalAlignment,
160        _bounds: Rect,
161        children: &[PlacedSubview<'_>],
162    ) -> Option<f32> {
163        children
164            .first()
165            .and_then(|child| child.explicit_vertical(alignment))
166    }
167}
168
169fn overlay_axis_size(measured: f32, proposal: Option<f32>) -> f32 {
170    if measured.is_finite() {
171        measured.max(0.0)
172    } else {
173        proposal.unwrap_or(0.0).max(0.0)
174    }
175}
176
177const fn overlay_placement_axis(measured: f32, bounds_axis: f32) -> f32 {
178    if measured.is_infinite() {
179        bounds_axis.max(0.0)
180    } else {
181        measured.min(bounds_axis).max(0.0)
182    }
183}
184
185/// A view that layers `overlay` content on top of a `base` view without
186/// allowing the overlay to influence layout sizing.
187pub struct Overlay<Base, Layer> {
188    layout: OverlayLayout,
189    base: Base,
190    layer: Layer,
191}
192
193impl<Base, Layer> Overlay<Base, Layer> {
194    /// Creates a new overlay using the provided base view and overlay layer.
195    #[must_use]
196    pub const fn new(base: Base, layer: Layer) -> Self {
197        Self {
198            layout: OverlayLayout {
199                alignment: Alignment::Center,
200            },
201            base,
202            layer,
203        }
204    }
205
206    /// Sets how the overlay layer should be aligned inside the base bounds.
207    #[must_use]
208    pub const fn alignment(mut self, alignment: Alignment) -> Self {
209        self.layout.alignment = alignment;
210        self
211    }
212}
213
214impl<Base, Layer> fmt::Debug for Overlay<Base, Layer> {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.debug_struct("Overlay")
217            .field("layout", &self.layout)
218            .finish_non_exhaustive()
219    }
220}
221
222impl<Base, Layer> View for Overlay<Base, Layer>
223where
224    Base: View + 'static,
225    Layer: View + 'static,
226{
227    fn body(self, _env: &waterui_core::Environment) -> impl View {
228        let Self {
229            layout,
230            base,
231            layer,
232        } = self;
233        FixedContainer::new(layout, (base, layer))
234    }
235}
236
237/// Convenience constructor for creating an [`Overlay`] with the default alignment.
238#[must_use]
239pub const fn overlay<Base, Layer>(base: Base, layer: Layer) -> Overlay<Base, Layer> {
240    Overlay::new(base, layer)
241}
242
243#[cfg(test)]
244#[allow(clippy::float_cmp)]
245mod tests {
246    use super::*;
247    use crate::StretchAxis;
248    use crate::container::FixedContainer;
249    use waterui_core::{AnyView, Environment, View};
250
251    struct MockSubView {
252        size: Size,
253    }
254
255    impl SubView for MockSubView {
256        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
257            ViewDimensions::new(self.size)
258        }
259        fn stretch_axis(&self) -> StretchAxis {
260            StretchAxis::None
261        }
262        fn priority(&self) -> i32 {
263            0
264        }
265    }
266
267    struct StretchingBase;
268
269    impl View for StretchingBase {
270        fn body(self, _env: &Environment) -> impl View {}
271
272        fn stretch_axis(&self) -> StretchAxis {
273            StretchAxis::Horizontal
274        }
275    }
276
277    #[test]
278    fn test_overlay_size_from_base() {
279        let layout = OverlayLayout::default();
280
281        let mut base = MockSubView {
282            size: Size::new(100.0, 50.0),
283        };
284        let mut overlay_child = MockSubView {
285            size: Size::new(20.0, 20.0),
286        };
287
288        let children: Vec<&dyn SubView> = vec![&mut base, &mut overlay_child];
289
290        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
291
292        // Size comes from base child
293        assert_eq!(size.width, 100.0);
294        assert_eq!(size.height, 50.0);
295    }
296
297    #[test]
298    fn test_overlay_placement_center() {
299        let layout = OverlayLayout {
300            alignment: Alignment::Center,
301        };
302
303        let mut base = MockSubView {
304            size: Size::new(100.0, 100.0),
305        };
306        let mut overlay_child = MockSubView {
307            size: Size::new(20.0, 20.0),
308        };
309
310        let children: Vec<&dyn SubView> = vec![&mut base, &mut overlay_child];
311
312        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
313        let rects = layout.place(bounds, &children);
314
315        // Base fills bounds
316        assert_eq!(rects[0].width(), 100.0);
317        assert_eq!(rects[0].height(), 100.0);
318
319        // Overlay child centered
320        assert_eq!(rects[1].x(), 40.0); // (100 - 20) / 2
321        assert_eq!(rects[1].y(), 40.0); // (100 - 20) / 2
322    }
323
324    #[test]
325    fn test_overlay_preserves_intrinsic_base_size_under_parent_proposal() {
326        let layout = OverlayLayout::default();
327
328        let base = MockSubView {
329            size: Size::new(40.0, 30.0),
330        };
331        let overlay_child = MockSubView {
332            size: Size::new(10.0, 10.0),
333        };
334
335        let children: Vec<&dyn SubView> = vec![&base, &overlay_child];
336        let size = layout.size_that_fits(ProposalSize::new(Some(200.0), Some(100.0)), &children);
337
338        assert_eq!(size.width, 40.0);
339        assert_eq!(size.height, 30.0);
340    }
341
342    #[test]
343    fn test_overlay_body_preserves_base_stretch_axis() {
344        let env = Environment::default();
345        let body = overlay(StretchingBase, ()).body(&env);
346        let container = AnyView::new(body)
347            .downcast::<FixedContainer>()
348            .expect("overlay body should be a FixedContainer");
349        let (layout, children) = container.as_parts();
350
351        assert_eq!(children.len(), 2);
352        let child_axes: Vec<StretchAxis> = children.iter().map(View::stretch_axis).collect();
353        assert_eq!(layout.stretch_axis(&child_axes), StretchAxis::Horizontal);
354    }
355}