1use gpui::{
4 div, px, relative, size, AnyElement, App, AvailableSpace, Bounds, Element, ElementId,
5 GlobalElementId, InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, RenderOnce,
6 Size, Style, Styled, Window,
7};
8
9use crate::RhythmGrid;
10
11#[derive(IntoElement)]
61pub struct RhythmFrame {
62 grid: RhythmGrid,
63 ratio: f32,
64 fit: RhythmFit,
65 children: Vec<AnyElement>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum RhythmFit {
72 #[default]
76 Pad,
77 Crop,
82}
83
84pub fn rhythm_frame(grid: RhythmGrid, ratio: f32) -> RhythmFrame {
91 assert!(
92 ratio.is_finite() && ratio > 0.0,
93 "aspect ratio must be finite and greater than zero"
94 );
95 RhythmFrame {
96 grid,
97 ratio,
98 fit: RhythmFit::Pad,
99 children: Vec::new(),
100 }
101}
102
103impl RhythmFrame {
104 #[must_use]
108 pub fn fit(mut self, fit: RhythmFit) -> Self {
109 self.fit = fit;
110 self
111 }
112
113 #[must_use]
118 pub fn crop(self) -> Self {
119 self.fit(RhythmFit::Crop)
120 }
121}
122
123impl ParentElement for RhythmFrame {
124 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
125 self.children.extend(elements)
126 }
127}
128
129impl RenderOnce for RhythmFrame {
130 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
131 let mut natural = div().w_full().flex_none();
138 natural.style().aspect_ratio = Some(self.ratio);
139 let natural = natural.children(self.children);
140
141 let mut mask = div().absolute().inset_0().overflow_hidden();
142 if self.fit == RhythmFit::Crop {
143 mask = mask.flex().flex_row().items_center();
144 }
145
146 div()
147 .relative()
148 .w_full()
149 .child(FrameSizer {
150 grid: self.grid,
151 ratio: self.ratio,
152 fit: self.fit,
153 })
154 .child(mask.child(natural))
155 }
156}
157
158struct FrameSizer {
161 grid: RhythmGrid,
162 ratio: f32,
163 fit: RhythmFit,
164}
165
166fn snapped_height(grid: RhythmGrid, ratio: f32, fit: RhythmFit, width: Pixels) -> Pixels {
169 let natural = px(f32::from(width) / ratio);
170 match fit {
171 RhythmFit::Pad => grid.snap_up(natural),
172 RhythmFit::Crop => grid.snap_down(natural),
173 }
174}
175
176impl IntoElement for FrameSizer {
177 type Element = Self;
178
179 fn into_element(self) -> Self::Element {
180 self
181 }
182}
183
184impl Element for FrameSizer {
185 type RequestLayoutState = ();
186 type PrepaintState = ();
187
188 fn id(&self) -> Option<ElementId> {
189 None
190 }
191
192 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
193 None
194 }
195
196 fn request_layout(
197 &mut self,
198 _id: Option<&GlobalElementId>,
199 _inspector_id: Option<&InspectorElementId>,
200 window: &mut Window,
201 _cx: &mut App,
202 ) -> (LayoutId, Self::RequestLayoutState) {
203 let grid = self.grid;
204 let ratio = self.ratio;
205 let fit = self.fit;
206 let mut style = Style::default();
207 style.size.width = relative(1.).into();
208 let layout_id = window.request_measured_layout(style, move |known, available, _, _| {
212 let width = known.width.or(match available.width {
213 AvailableSpace::Definite(width) => Some(width),
214 AvailableSpace::MinContent | AvailableSpace::MaxContent => None,
215 });
216 match width {
217 Some(width) => size(width, snapped_height(grid, ratio, fit, width)),
218 None => Size::default(),
219 }
220 });
221 (layout_id, ())
222 }
223
224 fn prepaint(
225 &mut self,
226 _id: Option<&GlobalElementId>,
227 _inspector_id: Option<&InspectorElementId>,
228 _bounds: Bounds<Pixels>,
229 _request_layout: &mut Self::RequestLayoutState,
230 _window: &mut Window,
231 _cx: &mut App,
232 ) -> Self::PrepaintState {
233 }
234
235 fn paint(
236 &mut self,
237 _id: Option<&GlobalElementId>,
238 _inspector_id: Option<&InspectorElementId>,
239 _bounds: Bounds<Pixels>,
240 _request_layout: &mut Self::RequestLayoutState,
241 _prepaint: &mut Self::PrepaintState,
242 _window: &mut Window,
243 _cx: &mut App,
244 ) {
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn snapped_height_pads_up_and_crops_down() {
254 let grid = RhythmGrid::new(px(8.0));
255 assert_eq!(
257 snapped_height(grid, 16. / 9., RhythmFit::Pad, px(800.0)),
258 px(456.0)
259 );
260 assert_eq!(
261 snapped_height(grid, 16. / 9., RhythmFit::Crop, px(800.0)),
262 px(448.0)
263 );
264 assert_eq!(
266 snapped_height(grid, 2.0, RhythmFit::Pad, px(96.0)),
267 px(48.0)
268 );
269 assert_eq!(
270 snapped_height(grid, 2.0, RhythmFit::Crop, px(96.0)),
271 px(48.0)
272 );
273 }
274
275 #[test]
276 fn frame_collects_children_and_the_fit_mode() {
277 let grid = RhythmGrid::new(px(8.0));
278 let mut frame = rhythm_frame(grid, 16. / 9.);
279 assert_eq!(frame.fit, RhythmFit::Pad);
280 frame.extend([gpui::Empty.into_any_element()]);
281 let frame = frame.crop();
282 assert_eq!(frame.fit, RhythmFit::Crop);
283 assert_eq!(frame.children.len(), 1);
284 assert_eq!(rhythm_frame(grid, 2.0).crop().fit, RhythmFit::Crop);
286 assert_eq!(
287 rhythm_frame(grid, 2.0).fit(RhythmFit::Pad).fit,
288 RhythmFit::Pad
289 );
290 }
291
292 #[test]
293 #[should_panic(expected = "aspect ratio must be finite and greater than zero")]
294 fn frame_rejects_a_non_positive_ratio() {
295 let _ = rhythm_frame(RhythmGrid::new(px(8.0)), 0.0);
296 }
297
298 #[cfg(feature = "test-support")]
299 #[gpui::test]
300 fn frame_layout_pads_at_the_bottom_and_crops_both_edges(cx: &mut gpui::TestAppContext) {
301 use gpui::{point, AvailableSpace, InteractiveElement};
302
303 let cx = cx.add_empty_window();
304 let grid = RhythmGrid::new(px(8.0));
305
306 cx.draw(
307 point(px(0.0), px(0.0)),
308 size(
309 AvailableSpace::Definite(px(800.0)),
310 AvailableSpace::MaxContent,
311 ),
312 |_, _| {
313 div()
314 .w(px(800.0))
315 .flex()
316 .flex_col()
317 .child(
318 div()
319 .flex_none()
320 .debug_selector(|| "pad-frame".into())
321 .child(
322 rhythm_frame(grid, 16. / 9.).child(
323 div().size_full().debug_selector(|| "pad-content".into()),
324 ),
325 ),
326 )
327 .child(
328 div()
329 .flex_none()
330 .debug_selector(|| "crop-frame".into())
331 .child(
332 rhythm_frame(grid, 16. / 9.).crop().child(
333 div().size_full().debug_selector(|| "crop-content".into()),
334 ),
335 ),
336 )
337 },
338 );
339
340 let pad_frame = cx.debug_bounds("pad-frame").expect("pad frame bounds");
341 let pad_content = cx.debug_bounds("pad-content").expect("pad content bounds");
342 assert_eq!(pad_frame.size.height, px(456.0));
343 assert_eq!(pad_content.size.height, px(450.0));
344 assert_eq!(pad_content.origin.y, pad_frame.origin.y);
345
346 let crop_frame = cx.debug_bounds("crop-frame").expect("crop frame bounds");
347 let crop_content = cx
348 .debug_bounds("crop-content")
349 .expect("crop content bounds");
350 assert_eq!(crop_frame.size.height, px(448.0));
351 assert_eq!(crop_content.size.height, px(450.0));
352
353 let top_overflow = crop_frame.origin.y - crop_content.origin.y;
354 let bottom_overflow = (crop_content.origin.y + crop_content.size.height)
355 - (crop_frame.origin.y + crop_frame.size.height);
356 assert_eq!(top_overflow, px(1.0));
357 assert_eq!(bottom_overflow, px(1.0));
358 }
359}