Skip to main content

gpui_kit/layout/
aspect_ratio.rs

1//! A frame that keeps a ratio.
2//!
3//! # Which dimension decides
4//!
5//! A ratio relates two numbers, so exactly one of them has to be the one that
6//! is given, and the component says which rather than guessing. [`AspectFit`]
7//! is that answer:
8//!
9//! - [`AspectFit::Width`] takes the width from the parent and computes the
10//!   height. This is the common case — a thumbnail in a column, a video in an
11//!   article — and it is the default.
12//! - [`AspectFit::Height`] takes the height from the parent and computes the
13//!   width, which is what a strip of previews along a fixed-height row needs.
14//!
15//! **When the parent constrains both**, the frame still does what `fit` says:
16//! it pins the one dimension `fit` names and lets the ratio decide the other,
17//! even where that overflows the parent along the axis it did not take. The
18//! alternative — shrinking to fit inside both — would be a *contain* box,
19//! which is a different component: it leaves empty space on one axis, and
20//! whoever laid the parent out is the only one who can say whether that space
21//! is acceptable. A frame that silently switched between the two would hold
22//! its ratio while quietly disagreeing with the size the caller asked for, so
23//! this one keeps the promise it was given and lets the overflow be visible.
24
25use gpui::{
26    AnyElement, App, IntoElement, ParentElement, RenderOnce, Styled, Window, div, prelude::*,
27};
28use gpui_kit_semantics::{NodeSpec, Role, Semantic};
29
30use crate::foundation::Ident;
31
32/// Which dimension the parent decides, leaving the other to the ratio.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum AspectFit {
35    #[default]
36    Width,
37    Height,
38}
39
40/// A container whose two dimensions stay in a fixed ratio.
41#[derive(IntoElement)]
42pub struct AspectRatio {
43    ident: Ident,
44    /// Width divided by height. `16.0 / 9.0` is wider than tall.
45    ratio: f32,
46    fit: AspectFit,
47    child: Option<AnyElement>,
48}
49
50impl std::fmt::Debug for AspectRatio {
51    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        formatter
53            .debug_struct("AspectRatio")
54            .field("ident", &self.ident)
55            .field("ratio", &self.ratio)
56            .field("fit", &self.fit)
57            .field("has_child", &self.child.is_some())
58            .finish()
59    }
60}
61
62impl AspectRatio {
63    /// `ratio` is width divided by height.
64    pub fn new(ident: impl Into<Ident>, ratio: f32) -> Self {
65        Self {
66            ident: ident.into(),
67            ratio,
68            fit: AspectFit::default(),
69            child: None,
70        }
71    }
72
73    /// The ratio written as the two numbers a caller already has, so
74    /// `sixteen by nine` does not have to be divided at the call site.
75    pub fn of(ident: impl Into<Ident>, width: f32, height: f32) -> Self {
76        Self::new(ident, if height == 0.0 { 1.0 } else { width / height })
77    }
78
79    pub fn fit(mut self, fit: AspectFit) -> Self {
80        self.fit = fit;
81        self
82    }
83
84    /// Takes the width from the parent and computes the height.
85    pub fn width_driven(self) -> Self {
86        self.fit(AspectFit::Width)
87    }
88
89    /// Takes the height from the parent and computes the width.
90    pub fn height_driven(self) -> Self {
91        self.fit(AspectFit::Height)
92    }
93
94    pub fn child(mut self, child: impl IntoElement) -> Self {
95        self.child = Some(child.into_any_element());
96        self
97    }
98
99    pub fn ratio(&self) -> f32 {
100        self.ratio
101    }
102}
103
104impl RenderOnce for AspectRatio {
105    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
106        // A ratio of zero or less is not a ratio; a square is the one answer
107        // that cannot be wrong in a direction.
108        let ratio = if self.ratio.is_finite() && self.ratio > 0.0 {
109            self.ratio
110        } else {
111            1.0
112        };
113
114        div()
115            .flex_none()
116            .overflow_hidden()
117            .aspect_ratio(ratio)
118            .map(|frame| match self.fit {
119                // The dimension `fit` does not name is left auto, which is
120                // what leaves it for the ratio to decide. `self_start` keeps a
121                // flex parent from stretching that free dimension back out
122                // and overriding the ratio with its own cross size.
123                AspectFit::Width => frame.w_full().self_start(),
124                AspectFit::Height => frame.h_full().self_start(),
125            })
126            .children(self.child)
127            .semantic_in(cx, NodeSpec::new(self.ident.semantic_id(), Role::Region))
128    }
129}