gpui_component/styled.rs
1pub use crate::component_traits::{Collapsible, Disableable, Selectable};
2pub use crate::sizing::{Sizable, Size, StyleSized};
3use gpui::{
4 App, BoxShadow, Corners, Edges, Hsla, ParentElement, Pixels, StyleRefinement, Styled, Window,
5 div, hsla, px,
6};
7pub use gpui_base::{FocusableExt, RoleOverride, StyledExt, box_shadow, h_flex, v_flex};
8
9use crate::ActiveTheme as _;
10
11const FOCUS_RING_WIDTH: Pixels = px(3.);
12const FOCUS_RING_OPACITY: f32 = 0.5;
13
14/// Ink every layer of a surface's shadow carries — the `rgb(0 0 0 / 0.1)`
15/// shadcn/ui spends at each elevation.
16const SURFACE_SHADOW_INK: f32 = 0.1;
17
18/// Ink of the hairline ring standing in for a popover's border.
19///
20/// shadcn/ui draws no border on a popup surface at all: its edge is a 1px
21/// `rgb(0 0 0 / 0.1)` ring spent as a shadow layer. Because the ring is
22/// translucent the shadow shows *through* it, which is what makes the edge read
23/// as part of one grounded surface rather than as an outline with a separate
24/// shadow below it. An opaque border cannot reproduce that — a border composites
25/// over the element's own background, not over the shadow.
26const POPOVER_RING_INK: f32 = 0.1;
27
28/// The colour of a popup surface's hairline ring in this theme.
29///
30/// shadcn spends black on it in light mode and white in dark
31/// (`oklch(1 0 0 / 10%)`), so it follows the foreground rather than the border
32/// token: a fixed black ring would all but vanish on a dark surface.
33///
34pub(crate) fn popover_ring(cx: &App) -> Hsla {
35 cx.theme().foreground.alpha(POPOVER_RING_INK)
36}
37
38/// shadcn/ui's popup surface shadow — a hairline `ring` plus `shadow-md` — at
39/// `strength` of its full ink.
40///
41/// Callers animating a surface in pass a rising `strength`; a resting surface
42/// passes `1.0`.
43///
44/// The two blurred layers use Tailwind's radii **halved**, which is the
45/// conversion CSS requires and not a taste adjustment. CSS defines a box
46/// shadow's blur radius as twice the gaussian's standard deviation, while GPUI's
47/// shader takes the field as the deviation itself (`gaussian(y, sigma)`).
48/// Copying Tailwind's `6px` and `4px` across therefore spreads the shadow over
49/// twice the distance, which is why [`Styled::shadow_md`] reads as a wide grey
50/// haze next to a browser's compact one.
51///
52/// Measured against shadcn's own render, this lands within a luminance step of
53/// it the whole way down the falloff.
54///
55/// The ring is taken as a colour rather than read from the theme here so that an
56/// animation can hold it across frames, where no `App` is in hand.
57pub(crate) fn popover_shadow(ring: Hsla, strength: f32) -> Vec<BoxShadow> {
58 let strength = strength.clamp(0., 1.);
59 let ink = hsla(0., 0., 0., SURFACE_SHADOW_INK * strength);
60 vec![
61 // The ring, sitting in the 1px band outside the surface. No blur, so it
62 // takes the shader's crisp path rather than the gaussian one.
63 BoxShadow::new(px(0.), px(0.), ring.alpha(ring.a * strength))
64 .blur_radius(px(0.))
65 .spread_radius(px(1.)),
66 BoxShadow::new(px(0.), px(4.), ink)
67 .blur_radius(px(3.))
68 .spread_radius(px(-1.)),
69 BoxShadow::new(px(0.), px(2.), ink)
70 .blur_radius(px(2.))
71 .spread_radius(px(-2.)),
72 ]
73}
74
75/// shadcn/ui's `shadow-lg`, the elevation it lifts a toast to, at `strength` of
76/// its full ink.
77///
78/// A toast sits higher than a popover and is built differently: shadcn gives it
79/// a real 1px border rather than the translucent ring it puts on a popup, so
80/// there is no ring layer here. Its corner radius is left to the caller.
81///
82/// The radii are Tailwind's halved, for the reason [`popover_shadow`] explains.
83pub(crate) fn toast_shadow(strength: f32) -> Vec<BoxShadow> {
84 let ink = hsla(0., 0., 0., SURFACE_SHADOW_INK * strength.clamp(0., 1.));
85 vec![
86 BoxShadow::new(px(0.), px(10.), ink)
87 .blur_radius(px(7.5))
88 .spread_radius(px(-3.)),
89 BoxShadow::new(px(0.), px(4.), ink)
90 .blur_radius(px(3.))
91 .spread_radius(px(-4.)),
92 ]
93}
94
95/// shadcn/ui's `shadow-sm`, the elevation it spends on a control raised out of
96/// the container it sits in — the active pill of a segmented tab bar — at full
97/// ink.
98///
99/// Unlike a popover or a toast this surface is not floating over the page: it
100/// sits *inside* a trough only a few pixels wider than itself, and that trough
101/// clips. Both are reasons to keep the falloff tight — there is no room for a
102/// wide one, and a wide one would read as grime against the trough wall rather
103/// than as lift.
104///
105/// The radii are Tailwind's halved, for the reason [`popover_shadow`] explains:
106/// CSS defines a box shadow's blur radius as twice the gaussian's standard
107/// deviation, while GPUI's shader takes the field as the deviation itself
108/// (`gaussian(y, sigma)`). Copying Tailwind's `3px` and `2px` across therefore
109/// spreads the shadow over twice the distance, which is why
110/// [`Styled::shadow_sm`] leaves a haze around a 24px pill where shadcn draws a
111/// compact line.
112pub(crate) fn raised_shadow() -> Vec<BoxShadow> {
113 let ink = hsla(0., 0., 0., SURFACE_SHADOW_INK);
114 vec![
115 BoxShadow::new(px(0.), px(1.), ink).blur_radius(px(1.5)),
116 BoxShadow::new(px(0.), px(1.), ink)
117 .blur_radius(px(1.))
118 .spread_radius(px(-1.)),
119 ]
120}
121
122/// Finished styles that read the theme.
123///
124/// Separate from [`StyledExt`], which holds neutral helpers that make no
125/// visual decisions. Everything here does: it reaches into the theme and
126/// produces a specific look, which is why it belongs above the base layer.
127pub trait ThemeStyled: Styled + Sized {
128 /// Give this element the focus appearance the framework's own controls
129 /// use: its border tinted with the focus colour, and the ring outside it.
130 ///
131 /// The ring is dropped when [`crate::Theme::focus_ring`] is off, leaving
132 /// the tinted border — an application whose layout clips its containers can
133 /// turn it off rather than finding room for the ring in each of them.
134 ///
135 /// Calling this turns the ring on; gate it with `when` for the conditions
136 /// that decide whether the control shows one at all — its focus state,
137 /// [`FocusableExt::focus_ring`], appearance, and so on.
138 ///
139 /// The ring sits outside the element's border, so an ancestor that clips
140 /// its content will cut it off — leave it a few pixels of room, or don't
141 /// clip.
142 fn focus_ring_style(self, window: &Window, cx: &App) -> Self
143 where
144 Self: ParentElement;
145
146 /// Give this element the surface, edge, shadow and radius of a popover.
147 ///
148 /// This is the one surface every popup shares — Popover, PopupMenu, Select,
149 /// Combobox, DatePicker and the editor's hover popovers — so they cannot
150 /// drift apart. See [`popover_shadow`] for what the shadow is modelled on.
151 fn popover_style(self, cx: &App) -> Self;
152
153 /// Round this element as far as its size allows — a circle if it is square,
154 /// a pill if it is not — unless the theme squares its corners.
155 ///
156 /// Use this instead of [`gpui::Styled::rounded_full`] on anything the theme
157 /// owns. A hardcoded `rounded_full` survives [`crate::Theme::radius`] being
158 /// set to zero, which leaves avatars, badge dots and slider thumbs round in
159 /// a UI that is square everywhere else. See [`crate::Theme::radius_full`].
160 fn rounded_full_style(self, cx: &App) -> Self {
161 self.rounded(cx.theme().radius_full())
162 }
163}
164
165impl<T: Styled + Sized> ThemeStyled for T {
166 /// Draw the focus ring the framework's own controls use.
167 ///
168 /// Calling this turns the ring on; gate it with `when` for the conditions
169 /// that decide whether the control shows one at all — its focus state,
170 /// [`crate::FocusableExt::focus_ring`], appearance, and so on.
171 ///
172 /// The ring sits outside the element's border, so an ancestor that clips
173 /// its content will cut it off — leave it a few pixels of room, or don't
174 /// clip.
175 fn focus_ring_style(mut self, window: &Window, cx: &App) -> Self
176 where
177 Self: ParentElement,
178 {
179 // The ring is painted outside the border, so a clipping ancestor cuts
180 // it off. An application whose layout clips heavily turns it off in the
181 // theme and keeps the tinted border, which takes no space.
182 if !cx.theme().focus_ring {
183 return self.border_color(cx.theme().ring);
184 }
185
186 let rem_size = window.rem_size();
187 let style = self.style();
188 let border_widths = Edges::<Pixels> {
189 top: style
190 .border_widths
191 .top
192 .map(|v| v.to_pixels(rem_size))
193 .unwrap_or_default(),
194 bottom: style
195 .border_widths
196 .bottom
197 .map(|v| v.to_pixels(rem_size))
198 .unwrap_or_default(),
199 left: style
200 .border_widths
201 .left
202 .map(|v| v.to_pixels(rem_size))
203 .unwrap_or_default(),
204 right: style
205 .border_widths
206 .right
207 .map(|v| v.to_pixels(rem_size))
208 .unwrap_or_default(),
209 };
210 let radius = Corners::<Pixels> {
211 top_left: style
212 .corner_radii
213 .top_left
214 .map(|v| v.to_pixels(rem_size))
215 .unwrap_or_default(),
216 top_right: style
217 .corner_radii
218 .top_right
219 .map(|v| v.to_pixels(rem_size))
220 .unwrap_or_default(),
221 bottom_left: style
222 .corner_radii
223 .bottom_left
224 .map(|v| v.to_pixels(rem_size))
225 .unwrap_or_default(),
226 bottom_right: style
227 .corner_radii
228 .bottom_right
229 .map(|v| v.to_pixels(rem_size))
230 .unwrap_or_default(),
231 }
232 .map(|value| *value + FOCUS_RING_WIDTH);
233 let mut ring_style = StyleRefinement::default();
234 ring_style.corner_radii.top_left = Some(radius.top_left.into());
235 ring_style.corner_radii.top_right = Some(radius.top_right.into());
236 ring_style.corner_radii.bottom_left = Some(radius.bottom_left.into());
237 ring_style.corner_radii.bottom_right = Some(radius.bottom_right.into());
238 let inset = FOCUS_RING_WIDTH;
239
240 self.border_color(cx.theme().ring).child(
241 div()
242 .flex_none()
243 .absolute()
244 .top(-(inset + border_widths.top))
245 .left(-(inset + border_widths.left))
246 .right(-(inset + border_widths.right))
247 .bottom(-(inset + border_widths.bottom))
248 .border(FOCUS_RING_WIDTH)
249 .border_color(cx.theme().ring.alpha(FOCUS_RING_OPACITY))
250 .refine_style(&ring_style),
251 )
252 }
253
254 fn popover_style(self, cx: &App) -> Self {
255 let theme = cx.theme();
256 // No border: the edge is the ring inside `popover_shadow`, which is how
257 // shadcn draws it and the only way the shadow can show through it.
258 self.bg(theme.popover)
259 .text_color(theme.popover_foreground)
260 .shadow(popover_shadow(popover_ring(cx), 1.))
261 .rounded(theme.radius)
262 }
263}