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