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, 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}