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}