Skip to main content

waterui_layout/modifiers/
padding.rs

1//! Padding layouts that inset a child by fixed edge distances.
2
3use alloc::{vec, vec::Vec};
4use nami::{Computed, Signal, signal::IntoComputed, watcher::BoxWatcherGuard};
5use waterui_core::{AnyView, View, layout::LayoutInvalidationCallback};
6
7use crate::{
8    HorizontalAlignment, Layout, PlacedSubview, Point, ProposalSize, Rect, Size, SubView,
9    VerticalAlignment, container::FixedContainer,
10};
11
12/// Layout that insets its single child by the configured edge values.
13///
14/// The insets are reactive, so a window that republishes its safe area on
15/// rotation (see [`SafeAreaInsets`](super::safe_area::SafeAreaInsets)) moves the
16/// padded content without the subtree being rebuilt.
17#[derive(Debug, Clone)]
18pub struct PaddingLayout {
19    edges: Computed<EdgeInsets>,
20}
21
22impl Layout for PaddingLayout {
23    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
24        let edges = self.edges.get();
25        // The horizontal and vertical space consumed by padding.
26        let horizontal_padding = edges.leading + edges.trailing;
27        let vertical_padding = edges.top + edges.bottom;
28
29        // Reduce the proposed size for the child by the padding amount.
30        let child_proposal = ProposalSize {
31            width: proposal.width.map(|w| (w - horizontal_padding).max(0.0)),
32            height: proposal.height.map(|h| (h - vertical_padding).max(0.0)),
33        };
34
35        // Measure the child
36        let child_size = children
37            .first()
38            .map_or(Size::zero(), |c| c.measure(child_proposal).size);
39
40        // A greedy child reports an infinite extent, which cannot be added to the
41        // insets, so resolve it against the offer instead. With nothing offered
42        // there is nothing to fill, and the inset alone is the answer — never a
43        // negative extent, which is what subtracting the insets from a missing
44        // proposal used to produce.
45        let child_width = if child_size.width.is_infinite() {
46            (proposal.width.unwrap_or(0.0) - horizontal_padding).max(0.0)
47        } else {
48            child_size.width
49        };
50
51        let child_height = if child_size.height.is_infinite() {
52            (proposal.height.unwrap_or(0.0) - vertical_padding).max(0.0)
53        } else {
54            child_size.height
55        };
56
57        // The final size is the child's size plus the padding.
58        Size::new(
59            child_width + horizontal_padding,
60            child_height + vertical_padding,
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        let edges = self.edges.get();
70        // Create the child's frame by insetting the parent's bound by the padding amount.
71        let child_origin = Point::new(bounds.x() + edges.leading, bounds.y() + edges.top);
72
73        let horizontal_padding = edges.leading + edges.trailing;
74        let vertical_padding = edges.top + edges.bottom;
75
76        let child_size = Size::new(
77            (bounds.width() - horizontal_padding).max(0.0),
78            (bounds.height() - vertical_padding).max(0.0),
79        );
80
81        vec![Rect::new(child_origin, child_size)]
82    }
83
84    fn explicit_horizontal(
85        &self,
86        alignment: HorizontalAlignment,
87        _bounds: Rect,
88        children: &[PlacedSubview<'_>],
89    ) -> Option<f32> {
90        children
91            .first()
92            .and_then(|child| child.explicit_horizontal(alignment))
93    }
94
95    fn explicit_vertical(
96        &self,
97        alignment: VerticalAlignment,
98        _bounds: Rect,
99        children: &[PlacedSubview<'_>],
100    ) -> Option<f32> {
101        children
102            .first()
103            .and_then(|child| child.explicit_vertical(alignment))
104    }
105
106    fn watch_invalidation(&self, invalidate: LayoutInvalidationCallback) -> Vec<BoxWatcherGuard> {
107        vec![self.edges.watch(move |_| invalidate())]
108    }
109}
110
111/// Insets applied to the four edges of a rectangle.
112#[derive(Debug, Clone, PartialEq)]
113pub struct EdgeInsets {
114    top: f32,
115    bottom: f32,
116    leading: f32,
117    trailing: f32,
118}
119
120nami::impl_constant!(EdgeInsets);
121
122#[allow(clippy::cast_possible_truncation)]
123impl<T: Into<f64>> From<T> for EdgeInsets {
124    fn from(value: T) -> Self {
125        let v = value.into() as f32;
126        Self::all(v)
127    }
128}
129
130impl core::ops::Add for EdgeInsets {
131    type Output = Self;
132
133    /// Stacks two sets of insets, edge by edge.
134    fn add(self, rhs: Self) -> Self {
135        Self {
136            top: self.top + rhs.top,
137            bottom: self.bottom + rhs.bottom,
138            leading: self.leading + rhs.leading,
139            trailing: self.trailing + rhs.trailing,
140        }
141    }
142}
143
144impl Default for EdgeInsets {
145    fn default() -> Self {
146        Self::all(0.0)
147    }
148}
149
150impl EdgeInsets {
151    /// Creates an [`EdgeInsets`] value with explicit edges.
152    #[must_use]
153    pub const fn new(top: f32, bottom: f32, leading: f32, trailing: f32) -> Self {
154        Self {
155            top,
156            bottom,
157            leading,
158            trailing,
159        }
160    }
161
162    /// Returns equal insets on every edge.
163    #[must_use]
164    pub const fn all(value: f32) -> Self {
165        Self {
166            top: value,
167            bottom: value,
168            leading: value,
169            trailing: value,
170        }
171    }
172
173    /// Returns symmetric vertical and horizontal insets.
174    #[must_use]
175    pub const fn symmetric(vertical: f32, horizontal: f32) -> Self {
176        Self {
177            top: vertical,
178            bottom: vertical,
179            leading: horizontal,
180            trailing: horizontal,
181        }
182    }
183
184    /// Returns the top inset.
185    #[must_use]
186    pub const fn top(&self) -> f32 {
187        self.top
188    }
189
190    /// Returns the bottom inset.
191    #[must_use]
192    pub const fn bottom(&self) -> f32 {
193        self.bottom
194    }
195
196    /// Returns the leading (left in LTR) inset.
197    #[must_use]
198    pub const fn leading(&self) -> f32 {
199        self.leading
200    }
201
202    /// Returns the trailing (right in LTR) inset.
203    #[must_use]
204    pub const fn trailing(&self) -> f32 {
205        self.trailing
206    }
207}
208
209/// View wrapper that applies [`PaddingLayout`] to a single child.
210#[derive(Debug)]
211pub struct Padding {
212    layout: PaddingLayout,
213    content: AnyView,
214}
215
216impl Padding {
217    /// Wraps a view with custom `edges`.
218    pub fn new(edges: impl IntoComputed<EdgeInsets>, content: impl View + 'static) -> Self {
219        Self {
220            layout: PaddingLayout {
221                edges: edges.into_computed(),
222            },
223            content: AnyView::new(content),
224        }
225    }
226
227    /// Consumes the padding and returns the edge insets and content.
228    pub fn into_inner(self) -> (Computed<EdgeInsets>, AnyView) {
229        (self.layout.edges, self.content)
230    }
231}
232
233impl View for Padding {
234    fn body(self, _env: &waterui_core::Environment) -> impl View {
235        FixedContainer::new(self.layout, vec![self.content])
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::StretchAxis;
243    use crate::ViewDimensions;
244    use crate::measure_layout;
245    use alloc::rc::Rc;
246    use core::cell::Cell;
247    use nami::binding;
248
249    struct MockSubView {
250        size: Size,
251    }
252
253    impl SubView for MockSubView {
254        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
255            ViewDimensions::new(self.size)
256        }
257        fn stretch_axis(&self) -> StretchAxis {
258            StretchAxis::None
259        }
260        fn priority(&self) -> i32 {
261            0
262        }
263    }
264
265    struct GuidedSubview {
266        size: Size,
267        horizontal_guide: f32,
268        vertical_guide: f32,
269    }
270
271    impl SubView for GuidedSubview {
272        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
273            ViewDimensions::new(self.size)
274                .with_horizontal(HorizontalAlignment::Leading, self.horizontal_guide)
275                .with_vertical(VerticalAlignment::Top, self.vertical_guide)
276        }
277
278        fn stretch_axis(&self) -> StretchAxis {
279            StretchAxis::None
280        }
281
282        fn priority(&self) -> i32 {
283            0
284        }
285    }
286
287    #[test]
288    fn test_padding_size() {
289        let layout = PaddingLayout {
290            edges: EdgeInsets::all(10.0).into_computed(),
291        };
292
293        let mut child = MockSubView {
294            size: Size::new(50.0, 30.0),
295        };
296        let children: Vec<&dyn SubView> = vec![&mut child];
297
298        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
299
300        // Size = child size + padding on all sides
301        assert!((size.width - 70.0).abs() < f32::EPSILON); // 50 + 10 + 10
302        assert!((size.height - 50.0).abs() < f32::EPSILON); // 30 + 10 + 10
303    }
304
305    #[test]
306    fn test_padding_placement() {
307        let layout = PaddingLayout {
308            edges: EdgeInsets::new(10.0, 20.0, 15.0, 25.0).into_computed(),
309        };
310
311        let mut child = MockSubView {
312            size: Size::new(50.0, 30.0),
313        };
314        let children: Vec<&dyn SubView> = vec![&mut child];
315
316        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
317        let rects = layout.place(bounds, &children);
318
319        // Child origin is offset by leading and top
320        assert!((rects[0].x() - 15.0).abs() < f32::EPSILON);
321        assert!((rects[0].y() - 10.0).abs() < f32::EPSILON);
322
323        // Child size is bounds minus padding
324        assert!((rects[0].width() - 60.0).abs() < f32::EPSILON); // 100 - 15 - 25
325        assert!((rects[0].height() - 70.0).abs() < f32::EPSILON); // 100 - 10 - 20
326    }
327
328    #[test]
329    fn reactive_insets_invalidate_and_change_measurement() {
330        let inset = binding(EdgeInsets::all(10.0));
331        let layout = PaddingLayout {
332            edges: inset.clone().into_computed(),
333        };
334
335        let invalidations = Rc::new(Cell::new(0));
336        let counted = Rc::clone(&invalidations);
337        let _guards = layout.watch_invalidation(Rc::new(move || {
338            counted.set(counted.get() + 1);
339        }));
340
341        let child = MockSubView {
342            size: Size::new(50.0, 30.0),
343        };
344        let children: Vec<&dyn SubView> = vec![&child];
345        assert_eq!(
346            layout
347                .size_that_fits(ProposalSize::UNSPECIFIED, &children)
348                .width,
349            70.0
350        );
351
352        inset.set(EdgeInsets::all(20.0));
353
354        assert_eq!(invalidations.get(), 1);
355        assert_eq!(
356            layout
357                .size_that_fits(ProposalSize::UNSPECIFIED, &children)
358                .width,
359            90.0
360        );
361    }
362
363    #[test]
364    fn test_padding_offsets_explicit_guides() {
365        let layout = PaddingLayout {
366            edges: EdgeInsets::new(10.0, 0.0, 15.0, 0.0).into_computed(),
367        };
368        let child = GuidedSubview {
369            size: Size::new(50.0, 30.0),
370            horizontal_guide: 8.0,
371            vertical_guide: 6.0,
372        };
373        let children: Vec<&dyn SubView> = vec![&child];
374
375        let dimensions = measure_layout(&layout, ProposalSize::UNSPECIFIED, &children);
376
377        assert_eq!(
378            dimensions.explicit_horizontal(HorizontalAlignment::Leading),
379            Some(23.0)
380        );
381        assert_eq!(
382            dimensions.explicit_vertical(VerticalAlignment::Top),
383            Some(16.0)
384        );
385    }
386}