Skip to main content

waterui_layout/containers/
aspect_ratio.rs

1//! Constrains a view to a width-to-height ratio.
2
3use alloc::{vec, vec::Vec};
4use nami::{Computed, Signal, SignalExt};
5use waterui_core::{AnyView, IntoSignalF32, View, layout::LayoutInvalidationCallback};
6
7use crate::{
8    Layout, Point, ProposalSize, Rect, Size, StretchAxis, SubView, container::FixedContainer,
9};
10
11/// How a view fills a ratio-constrained box.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13pub enum ContentMode {
14    /// Shrink to fit entirely inside the offer, leaving space on the long axis.
15    #[default]
16    Fit,
17    /// Grow to cover the offer entirely, overflowing on the long axis.
18    Fill,
19}
20
21/// Layout that sizes its single child to a fixed width-to-height ratio.
22#[derive(Debug, Clone)]
23pub struct AspectRatioLayout {
24    ratio: Computed<f32>,
25    mode: ContentMode,
26}
27
28impl AspectRatioLayout {
29    /// Resolves the size that honours `ratio` inside `available`.
30    ///
31    /// With both axes offered, `Fit` takes the smaller of the two candidate sizes
32    /// and `Fill` the larger. With one axis offered the other follows from the
33    /// ratio, and with neither the child's own size sets the scale.
34    fn resolve(&self, available: ProposalSize, content: Size) -> Size {
35        let ratio = self.ratio.get();
36        assert!(
37            ratio > 0.0 && ratio.is_finite(),
38            "aspect ratio must be a positive, finite width-to-height ratio, got {ratio}"
39        );
40
41        match (available.width, available.height) {
42            (Some(width), Some(height)) if width.is_finite() && height.is_finite() => {
43                let from_width = Size::new(width, width / ratio);
44                let from_height = Size::new(height * ratio, height);
45                let width_fits = from_width.height <= height;
46                let take_width = match self.mode {
47                    ContentMode::Fit => width_fits,
48                    ContentMode::Fill => !width_fits,
49                };
50                if take_width { from_width } else { from_height }
51            }
52            (Some(width), _) if width.is_finite() => Size::new(width, width / ratio),
53            (_, Some(height)) if height.is_finite() => Size::new(height * ratio, height),
54            _ => {
55                // Unconstrained: keep the child's own scale, on whichever axis it
56                // expressed one.
57                if content.width > 0.0 {
58                    Size::new(content.width, content.width / ratio)
59                } else {
60                    Size::new(content.height * ratio, content.height)
61                }
62            }
63        }
64    }
65}
66
67impl Layout for AspectRatioLayout {
68    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
69        let content = children
70            .first()
71            .map_or(Size::zero(), |child| child.measure(proposal).size);
72        self.resolve(proposal, content)
73    }
74
75    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
76        if children.is_empty() {
77            return vec![];
78        }
79        // The child is handed the ratio-correct box, centred in the bounds so a
80        // `Fit` leaves equal slack on both sides and a `Fill` overflows evenly.
81        let size = self.resolve(
82            ProposalSize::new(Some(bounds.width()), Some(bounds.height())),
83            *bounds.size(),
84        );
85        let origin = Point::new(
86            bounds.x() + (bounds.width() - size.width) * 0.5,
87            bounds.y() + (bounds.height() - size.height) * 0.5,
88        );
89        vec![Rect::new(origin, size)]
90    }
91
92    fn stretch_axis(&self, _children: &[StretchAxis]) -> StretchAxis {
93        // The ratio decides the shape, never how much space is claimed: that is
94        // still the child's to ask for.
95        StretchAxis::None
96    }
97
98    fn watch_invalidation(
99        &self,
100        invalidate: LayoutInvalidationCallback,
101    ) -> Vec<nami::watcher::BoxWatcherGuard> {
102        vec![self.ratio.watch(move |_| invalidate())]
103    }
104}
105
106/// A view constrained to a width-to-height ratio.
107#[derive(Debug)]
108pub struct AspectRatio {
109    layout: AspectRatioLayout,
110    content: AnyView,
111}
112
113impl AspectRatio {
114    /// Constrains `content` to `ratio`, expressed as width divided by height.
115    ///
116    /// # Panics
117    ///
118    /// Measuring panics if the ratio is not positive and finite.
119    #[must_use]
120    pub fn new(content: impl View, ratio: impl IntoSignalF32 + 'static, mode: ContentMode) -> Self {
121        Self {
122            layout: AspectRatioLayout {
123                ratio: ratio.into_signal_f32().computed(),
124                mode,
125            },
126            content: AnyView::new(content),
127        }
128    }
129}
130
131impl View for AspectRatio {
132    fn body(self, _env: &waterui_core::Environment) -> impl View {
133        FixedContainer::new(self.layout, (self.content,))
134    }
135}
136
137/// Constrains `content` to `ratio`, shrinking to fit inside what is offered.
138pub fn aspect_ratio(content: impl View, ratio: impl IntoSignalF32 + 'static) -> AspectRatio {
139    AspectRatio::new(content, ratio, ContentMode::Fit)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::ViewDimensions;
146
147    struct Content(Size);
148
149    impl SubView for Content {
150        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
151            ViewDimensions::new(self.0)
152        }
153        fn stretch_axis(&self) -> StretchAxis {
154            StretchAxis::None
155        }
156        fn priority(&self) -> i32 {
157            0
158        }
159    }
160
161    fn layout(mode: ContentMode) -> AspectRatioLayout {
162        AspectRatioLayout {
163            ratio: Computed::constant(2.0),
164            mode,
165        }
166    }
167
168    #[test]
169    fn fit_stays_inside_a_wider_offer() {
170        // 2:1 inside a 300x100 box fits by height: 200x100, not 300x150.
171        let child = Content(Size::new(10.0, 10.0));
172        let size =
173            layout(ContentMode::Fit).size_that_fits(ProposalSize::new(300.0, 100.0), &[&child]);
174        assert!((size.width - 200.0).abs() < 0.01, "got {size:?}");
175        assert!((size.height - 100.0).abs() < 0.01, "got {size:?}");
176    }
177
178    #[test]
179    fn fill_covers_a_wider_offer() {
180        let child = Content(Size::new(10.0, 10.0));
181        let size =
182            layout(ContentMode::Fill).size_that_fits(ProposalSize::new(300.0, 100.0), &[&child]);
183        assert!((size.width - 300.0).abs() < 0.01, "got {size:?}");
184        assert!((size.height - 150.0).abs() < 0.01, "got {size:?}");
185    }
186
187    #[test]
188    fn one_offered_axis_decides_the_other() {
189        let child = Content(Size::new(10.0, 10.0));
190        let size =
191            layout(ContentMode::Fit).size_that_fits(ProposalSize::new(Some(80.0), None), &[&child]);
192        assert!((size.height - 40.0).abs() < 0.01, "got {size:?}");
193    }
194
195    #[test]
196    fn the_child_sets_the_scale_when_nothing_is_offered() {
197        let child = Content(Size::new(60.0, 999.0));
198        let size = layout(ContentMode::Fit).size_that_fits(ProposalSize::UNSPECIFIED, &[&child]);
199        assert!((size.width - 60.0).abs() < 0.01, "got {size:?}");
200        assert!((size.height - 30.0).abs() < 0.01, "got {size:?}");
201    }
202
203    #[test]
204    fn the_child_is_centred_in_the_bounds_it_does_not_fill() {
205        let child = Content(Size::new(10.0, 10.0));
206        let rects = layout(ContentMode::Fit)
207            .place(Rect::new(Point::zero(), Size::new(300.0, 100.0)), &[&child]);
208        assert!(
209            (rects[0].width() - 200.0).abs() < 0.01,
210            "got {:?}",
211            rects[0]
212        );
213        assert!((rects[0].x() - 50.0).abs() < 0.01, "got {:?}", rects[0]);
214    }
215}