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