waterui_layout/containers/
aspect_ratio.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
14pub enum ContentMode {
15 #[default]
17 Fit,
18 Fill,
20}
21
22#[derive(Debug, Clone)]
24pub struct AspectRatioLayout {
25 ratio: Computed<f32>,
26 mode: ContentMode,
27}
28
29impl AspectRatioLayout {
30 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 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 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 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#[derive(Debug)]
117pub struct AspectRatio {
118 layout: AspectRatioLayout,
119 content: AnyView,
120}
121
122impl AspectRatio {
123 #[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 fn stretch_axis(&self) -> StretchAxis {
148 self.layout.stretch_axis(&[self.content.stretch_axis()])
149 }
150}
151
152pub 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 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}