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, StretchAxis,
9    SubView, SubviewPlacement, 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 change to them moves the padded content
15/// without the subtree being rebuilt.
16#[derive(Debug, Clone)]
17pub struct PaddingLayout {
18    edges: Computed<EdgeInsets>,
19}
20
21impl Layout for PaddingLayout {
22    /// Padding is transparent to its content: it insets the child within
23    /// whatever bounds it is given, so the child's axis is the answer.
24    fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
25        children.first().copied().unwrap_or_default()
26    }
27
28    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
29        let edges = self.edges.get();
30        // The horizontal and vertical space consumed by padding.
31        let horizontal_padding = edges.leading + edges.trailing;
32        let vertical_padding = edges.top + edges.bottom;
33
34        // Reduce the proposed size for the child by the padding amount.
35        let child_proposal = ProposalSize {
36            width: proposal.width.map(|w| (w - horizontal_padding).max(0.0)),
37            height: proposal.height.map(|h| (h - vertical_padding).max(0.0)),
38        };
39
40        // Measure the child
41        let child_size = children
42            .first()
43            .map_or(Size::zero(), |c| c.measure(child_proposal).size);
44
45        // A greedy child reports an infinite extent, which cannot be added to the
46        // insets, so resolve it against the offer instead. With nothing offered
47        // there is nothing to fill, and the inset alone is the answer — never a
48        // negative extent, which is what subtracting the insets from a missing
49        // proposal used to produce.
50        let child_width = if child_size.width.is_infinite() {
51            (proposal.width.unwrap_or(0.0) - horizontal_padding).max(0.0)
52        } else {
53            child_size.width
54        };
55
56        let child_height = if child_size.height.is_infinite() {
57            (proposal.height.unwrap_or(0.0) - vertical_padding).max(0.0)
58        } else {
59            child_size.height
60        };
61
62        // The final size is the child's size plus the padding.
63        Size::new(
64            child_width + horizontal_padding,
65            child_height + vertical_padding,
66        )
67    }
68
69    fn place(
70        &self,
71        bounds: Rect,
72        _proposal: ProposalSize,
73        children: &[&dyn SubView],
74    ) -> Vec<SubviewPlacement> {
75        if children.is_empty() {
76            return vec![];
77        }
78
79        let edges = self.edges.get();
80        // Create the child's frame by insetting the parent's bound by the padding amount.
81        let child_origin = Point::new(bounds.x() + edges.leading, bounds.y() + edges.top);
82
83        let horizontal_padding = edges.leading + edges.trailing;
84        let vertical_padding = edges.top + edges.bottom;
85
86        let child_size = Size::new(
87            (bounds.width() - horizontal_padding).max(0.0),
88            (bounds.height() - vertical_padding).max(0.0),
89        );
90
91        // Placement proposes the region the child is placed in: the bounds
92        // less the insets, so a padded child stretched by its container lays
93        // out across the room it actually has.
94        let child_proposal = ProposalSize::new(Some(child_size.width), Some(child_size.height));
95
96        vec![SubviewPlacement::new(
97            Rect::new(child_origin, child_size),
98            child_proposal,
99        )]
100    }
101
102    fn explicit_horizontal(
103        &self,
104        alignment: HorizontalAlignment,
105        _bounds: Rect,
106        children: &[PlacedSubview<'_>],
107    ) -> Option<f32> {
108        children
109            .first()
110            .and_then(|child| child.explicit_horizontal(alignment))
111    }
112
113    fn explicit_vertical(
114        &self,
115        alignment: VerticalAlignment,
116        _bounds: Rect,
117        children: &[PlacedSubview<'_>],
118    ) -> Option<f32> {
119        children
120            .first()
121            .and_then(|child| child.explicit_vertical(alignment))
122    }
123
124    fn watch_invalidation(&self, invalidate: LayoutInvalidationCallback) -> Vec<BoxWatcherGuard> {
125        vec![self.edges.watch(move |_| invalidate())]
126    }
127}
128
129/// Insets applied to the four edges of a rectangle.
130#[derive(Debug, Clone, PartialEq)]
131pub struct EdgeInsets {
132    top: f32,
133    bottom: f32,
134    leading: f32,
135    trailing: f32,
136}
137
138nami::impl_constant!(EdgeInsets);
139
140/// Equal insets on every edge from one number — `.padding_with(16.0)` —
141/// for every numeric type `f64` converts from, which is the set the former
142/// `From<T: Into<f64>>` blanket covered. The blanket had to go so the tuple
143/// and array conversions below could exist beside it (coherence forbids a
144/// concrete `From<(f32, f32)>` next to a blanket over `Into<f64>`).
145macro_rules! from_scalar {
146    (lossless: $($ty:ty),+ $(,)?) => {
147        $(
148            impl From<$ty> for EdgeInsets {
149                fn from(value: $ty) -> Self {
150                    Self::all(f32::from(value))
151                }
152            }
153        )+
154    };
155    (lossy: $($ty:ty),+ $(,)?) => {
156        $(
157            #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
158            impl From<$ty> for EdgeInsets {
159                fn from(value: $ty) -> Self {
160                    Self::all(value as f32)
161                }
162            }
163        )+
164    };
165}
166
167from_scalar!(lossless: f32, i8, i16, u8, u16);
168from_scalar!(lossy: f64, i32, u32);
169
170/// `(vertical, horizontal)` — the [`EdgeInsets::symmetric`] order:
171/// `.padding_with((8.0, 16.0))`. The `f64` form is what an unsuffixed
172/// literal pair infers to as a constant signal.
173macro_rules! from_pair {
174    ($($ty:ty),+ $(,)?) => {
175        $(
176            #[allow(clippy::cast_possible_truncation)]
177            impl From<($ty, $ty)> for EdgeInsets {
178                fn from((vertical, horizontal): ($ty, $ty)) -> Self {
179                    Self::symmetric(vertical as f32, horizontal as f32)
180                }
181            }
182        )+
183    };
184}
185
186from_pair!(f32, f64);
187
188/// `[top, bottom, leading, trailing]` — the [`EdgeInsets::new`] order:
189/// `.padding_with([4.0, 12.0, 16.0, 16.0])`.
190macro_rules! from_quad {
191    ($($ty:ty),+ $(,)?) => {
192        $(
193            #[allow(clippy::cast_possible_truncation)]
194            impl From<[$ty; 4]> for EdgeInsets {
195                fn from([top, bottom, leading, trailing]: [$ty; 4]) -> Self {
196                    Self::new(top as f32, bottom as f32, leading as f32, trailing as f32)
197                }
198            }
199        )+
200    };
201}
202
203from_quad!(f32, f64);
204
205impl core::ops::Add for EdgeInsets {
206    type Output = Self;
207
208    /// Stacks two sets of insets, edge by edge.
209    fn add(self, rhs: Self) -> Self {
210        Self {
211            top: self.top + rhs.top,
212            bottom: self.bottom + rhs.bottom,
213            leading: self.leading + rhs.leading,
214            trailing: self.trailing + rhs.trailing,
215        }
216    }
217}
218
219impl Default for EdgeInsets {
220    fn default() -> Self {
221        Self::all(0.0)
222    }
223}
224
225impl EdgeInsets {
226    /// Creates an [`EdgeInsets`] value with explicit edges.
227    #[must_use]
228    pub const fn new(top: f32, bottom: f32, leading: f32, trailing: f32) -> Self {
229        Self {
230            top,
231            bottom,
232            leading,
233            trailing,
234        }
235    }
236
237    /// Returns equal insets on every edge.
238    #[must_use]
239    pub const fn all(value: f32) -> Self {
240        Self {
241            top: value,
242            bottom: value,
243            leading: value,
244            trailing: value,
245        }
246    }
247
248    /// Returns symmetric vertical and horizontal insets.
249    #[must_use]
250    pub const fn symmetric(vertical: f32, horizontal: f32) -> Self {
251        Self {
252            top: vertical,
253            bottom: vertical,
254            leading: horizontal,
255            trailing: horizontal,
256        }
257    }
258
259    /// Returns the top inset.
260    #[must_use]
261    pub const fn top(&self) -> f32 {
262        self.top
263    }
264
265    /// Returns the bottom inset.
266    #[must_use]
267    pub const fn bottom(&self) -> f32 {
268        self.bottom
269    }
270
271    /// Returns the leading (left in LTR) inset.
272    #[must_use]
273    pub const fn leading(&self) -> f32 {
274        self.leading
275    }
276
277    /// Returns the trailing (right in LTR) inset.
278    #[must_use]
279    pub const fn trailing(&self) -> f32 {
280        self.trailing
281    }
282}
283
284/// View wrapper that applies [`PaddingLayout`] to a single child.
285#[derive(Debug)]
286pub struct Padding {
287    layout: PaddingLayout,
288    content: AnyView,
289}
290
291impl Padding {
292    /// Wraps a view with custom `edges`.
293    pub fn new(edges: impl IntoComputed<EdgeInsets>, content: impl View + 'static) -> Self {
294        Self {
295            layout: PaddingLayout {
296                edges: edges.into_computed(),
297            },
298            content: AnyView::new(content),
299        }
300    }
301
302    /// Consumes the padding and returns the edge insets and content.
303    pub fn into_inner(self) -> (Computed<EdgeInsets>, AnyView) {
304        (self.layout.edges, self.content)
305    }
306}
307
308impl View for Padding {
309    fn body(self, _env: &waterui_core::Environment) -> impl View {
310        FixedContainer::new(self.layout, vec![self.content])
311    }
312
313    /// Resolves to `FixedContainer` over the same layout and single child;
314    /// reports what that container would.
315    fn stretch_axis(&self) -> StretchAxis {
316        self.layout.stretch_axis(&[self.content.stretch_axis()])
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::StretchAxis;
324    use crate::ViewDimensions;
325    use crate::measure_layout;
326    use alloc::rc::Rc;
327    use core::cell::Cell;
328    use nami::binding;
329
330    struct MockSubView {
331        size: Size,
332    }
333
334    impl SubView for MockSubView {
335        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
336            ViewDimensions::new(self.size)
337        }
338        fn stretch_axis(&self) -> StretchAxis {
339            StretchAxis::None
340        }
341        fn priority(&self) -> i32 {
342            0
343        }
344    }
345
346    struct GuidedSubview {
347        size: Size,
348        horizontal_guide: f32,
349        vertical_guide: f32,
350    }
351
352    impl SubView for GuidedSubview {
353        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
354            ViewDimensions::new(self.size)
355                .with_horizontal(HorizontalAlignment::Leading, self.horizontal_guide)
356                .with_vertical(VerticalAlignment::Top, self.vertical_guide)
357        }
358
359        fn stretch_axis(&self) -> StretchAxis {
360            StretchAxis::None
361        }
362
363        fn priority(&self) -> i32 {
364            0
365        }
366    }
367
368    #[test]
369    fn test_padding_size() {
370        let layout = PaddingLayout {
371            edges: EdgeInsets::all(10.0).into_computed(),
372        };
373
374        let mut child = MockSubView {
375            size: Size::new(50.0, 30.0),
376        };
377        let children: Vec<&dyn SubView> = vec![&mut child];
378
379        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &children);
380
381        // Size = child size + padding on all sides
382        assert!((size.width - 70.0).abs() < f32::EPSILON); // 50 + 10 + 10
383        assert!((size.height - 50.0).abs() < f32::EPSILON); // 30 + 10 + 10
384    }
385
386    #[test]
387    fn test_padding_placement_bounded_proposal() {
388        let layout = PaddingLayout {
389            edges: EdgeInsets::new(10.0, 20.0, 15.0, 25.0).into_computed(),
390        };
391
392        let mut child = MockSubView {
393            size: Size::new(50.0, 30.0),
394        };
395        let children: Vec<&dyn SubView> = vec![&mut child];
396
397        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
398        let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
399        let placements = layout.place(bounds, proposal, &children);
400
401        // Child origin is offset by leading and top
402        assert!((placements[0].frame.x() - 15.0).abs() < f32::EPSILON);
403        assert!((placements[0].frame.y() - 10.0).abs() < f32::EPSILON);
404
405        // Child size is bounds minus padding
406        assert!((placements[0].frame.width() - 60.0).abs() < f32::EPSILON); // 100 - 15 - 25
407        assert!((placements[0].frame.height() - 70.0).abs() < f32::EPSILON); // 100 - 10 - 20
408    }
409
410    #[test]
411    fn reactive_insets_invalidate_and_change_measurement() {
412        let inset = binding(EdgeInsets::all(10.0));
413        let layout = PaddingLayout {
414            edges: inset.clone().into_computed(),
415        };
416
417        let invalidations = Rc::new(Cell::new(0));
418        let counted = Rc::clone(&invalidations);
419        let _guards = layout.watch_invalidation(Rc::new(move || {
420            counted.set(counted.get() + 1);
421        }));
422
423        let child = MockSubView {
424            size: Size::new(50.0, 30.0),
425        };
426        let children: Vec<&dyn SubView> = vec![&child];
427        assert_eq!(
428            layout
429                .size_that_fits(ProposalSize::UNSPECIFIED, &children)
430                .width,
431            70.0
432        );
433
434        inset.set(EdgeInsets::all(20.0));
435
436        assert_eq!(invalidations.get(), 1);
437        assert_eq!(
438            layout
439                .size_that_fits(ProposalSize::UNSPECIFIED, &children)
440                .width,
441            90.0
442        );
443    }
444
445    #[test]
446    fn test_padding_offsets_explicit_guides() {
447        let layout = PaddingLayout {
448            edges: EdgeInsets::new(10.0, 0.0, 15.0, 0.0).into_computed(),
449        };
450        let child = GuidedSubview {
451            size: Size::new(50.0, 30.0),
452            horizontal_guide: 8.0,
453            vertical_guide: 6.0,
454        };
455        let children: Vec<&dyn SubView> = vec![&child];
456
457        let dimensions = measure_layout(&layout, ProposalSize::UNSPECIFIED, &children);
458
459        assert_eq!(
460            dimensions.explicit_horizontal(HorizontalAlignment::Leading),
461            Some(23.0)
462        );
463        assert_eq!(
464            dimensions.explicit_vertical(VerticalAlignment::Top),
465            Some(16.0)
466        );
467    }
468}