Skip to main content

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}