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