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, container::FixedContainer,
9};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13pub enum ContentMode {
14 #[default]
16 Fit,
17 Fill,
19}
20
21#[derive(Debug, Clone)]
23pub struct AspectRatioLayout {
24 ratio: Computed<f32>,
25 mode: ContentMode,
26}
27
28impl AspectRatioLayout {
29 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 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 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 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#[derive(Debug)]
108pub struct AspectRatio {
109 layout: AspectRatioLayout,
110 content: AnyView,
111}
112
113impl AspectRatio {
114 #[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
137pub 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 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}