Skip to main content

rhythm_gpui/
frame.rs

1//! `rhythm_frame`: fit fluid-width media into whole rhythm rows.
2
3use 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/// A container that fits fluid-width media — images, video, embeds, any
12/// content whose height follows its width instead of the rhythm — into a
13/// whole number of rhythm rows, so everything after it stays on the grid at
14/// any width.
15///
16/// The frame fills the parent's width; its height is the content's natural
17/// height (`width / ratio`) snapped to whole rhythm rows, recomputed in the
18/// layout pass whenever the width changes. `ratio` is width over height,
19/// matching gpui `img`'s aspect-ratio convention. Content lives in a box at
20/// its natural height: pinned to the frame's top edge when padding, and
21/// centered behind the frame's clipping mask when cropping. Style the child
22/// to fill it (`.size_full()`, plus `.object_fit(ObjectFit::Cover)` when the
23/// image itself has a different ratio). The box reserves its height even
24/// before an image loads — no layout shift.
25///
26/// By default the height rounds **up** and the leftover space — always under
27/// one rhythm unit — is left below the content (pad). Choose the mode with
28/// [`RhythmFrame::fit`], or [`RhythmFrame::crop`] for the shorthand.
29/// With a known width the frame is unnecessary — size the block directly
30/// with [`RhythmGrid::snap_up`].
31///
32/// The frame needs a parent that offers a **definite width**: a height that
33/// follows the width is unknowable under an intrinsic (min-/max-content)
34/// measurement, so the frame reports no size in that pass and contributes
35/// nothing to a shrink-to-fit parent. Give the frame's container a resolved
36/// width — a full-width column or a sized panel — rather than asking a
37/// shrink-to-fit wrapper to derive its width from the frame.
38///
39/// # Examples
40///
41/// ```no_run
42/// use std::path::PathBuf;
43///
44/// use gpui::{img, prelude::*, px, ObjectFit};
45/// use rhythm_gpui::{rhythm_frame, RhythmFit, RhythmGrid};
46///
47/// fn figure(grid: RhythmGrid, crop: bool) -> impl IntoElement {
48///     // Pad (default) keeps the whole image and leaves the sub-unit
49///     // remainder below it; crop splits the clipped remainder between the
50///     // top and bottom edges.
51///     rhythm_frame(grid, 16. / 9.)
52///         .fit(if crop { RhythmFit::Crop } else { RhythmFit::Pad })
53///         .child(
54///             img(PathBuf::from("photo.jpg"))
55///                 .size_full()
56///                 .object_fit(ObjectFit::Cover),
57///         )
58/// }
59/// ```
60#[derive(IntoElement)]
61pub struct RhythmFrame {
62    grid: RhythmGrid,
63    ratio: f32,
64    fit: RhythmFit,
65    children: Vec<AnyElement>,
66}
67
68/// How a [`RhythmFrame`] reconciles its content's natural height with whole
69/// rhythm rows.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum RhythmFit {
72    /// Round **up** and leave the remainder — always under one rhythm unit —
73    /// as space below the content. Keeps the whole image, which suits
74    /// diagrams and screenshots.
75    #[default]
76    Pad,
77    /// Round **down** and clip the natural-height content evenly between the
78    /// top and bottom edges, never scaling it to the snapped height. Suits
79    /// full-bleed photography; frames narrower than one row's worth of
80    /// content floor to zero height.
81    Crop,
82}
83
84/// Create a [`RhythmFrame`] for content with a `width / height` ratio of
85/// `ratio`; add the content with `.child()`.
86///
87/// # Panics
88///
89/// Panics when `ratio` is zero, negative, or non-finite.
90pub 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    /// Choose how the height snaps to whole rhythm rows. Takes the mode as a
105    /// value, so a runtime choice needs no rebinding:
106    /// `.fit(if crop { RhythmFit::Crop } else { RhythmFit::Pad })`.
107    #[must_use]
108    pub fn fit(mut self, fit: RhythmFit) -> Self {
109        self.fit = fit;
110        self
111    }
112
113    /// Shorthand for [`fit(RhythmFit::Crop)`](Self::fit): round the height
114    /// down instead of up, so the natural-height content overfills the frame
115    /// by less than one rhythm unit and is clipped evenly between the top and
116    /// bottom edges instead of padded.
117    #[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        // The sizer is the only in-flow child, so the frame's height is the
132        // snapped height. The overlaid mask contains a natural-height content
133        // box (gpui's `img` cannot derive its height from a fractional width,
134        // so the box sizes the content, not the reverse). Pad leaves that box
135        // at the top; crop centers the inflexible box so overflow is split
136        // between the top and bottom instead of delegated to `ObjectFit`.
137        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
158/// The invisible in-flow leaf that gives the frame its height: a measured
159/// Taffy node returning the snapped height for the width it is offered.
160struct FrameSizer {
161    grid: RhythmGrid,
162    ratio: f32,
163    fit: RhythmFit,
164}
165
166/// The frame height at `width`: the natural `width / ratio` snapped to whole
167/// rhythm rows — up for pad, down for crop.
168fn 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        // Taffy may probe with min-/max-content available space; without a
209        // definite width the height is unknowable, so report zero and let
210        // the definite pass size the frame.
211        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        // 800px wide at 16:9 → 450px natural height.
256        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        // Exact multiples pass through untouched in both modes.
265        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        // The shorthand and the explicit mode are the same frame.
285        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}