Skip to main content

herogpui_theme/
layout.rs

1//! Layout tokens — a faithful port of HeroUI v3's non-color custom properties
2//! from `packages/styles/themes/default/variables.css`.
3//!
4//! v3 replaced v2's size-named tokens (`radius-small`, `box-shadow-medium`)
5//! with a single `--radius` base plus calculated steps, and with
6//! component-semantic shadows (`--surface-shadow`, `--overlay-shadow`,
7//! `--field-shadow`).
8
9use gpui::{point, px, BoxShadow, Pixels};
10
11/// How a [`Skeleton`](../herogpui_components/struct.Skeleton.html) animates by
12/// default (`--skeleton-animation`).
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum SkeletonAnimation {
15    #[default]
16    /// Shimmer sweep (the default).
17    Shimmer,
18    /// Opacity pulse.
19    Pulse,
20    /// No animation.
21    None,
22}
23
24/// Spacing, radius, border and shadow tokens shared by all components.
25#[derive(Clone, Debug)]
26#[non_exhaustive]
27pub struct LayoutTheme {
28    /// `--spacing: 0.25rem`
29    pub spacing: Pixels,
30
31    /// `--radius: 0.5rem` — the base every other radius is calculated from.
32    pub radius: Pixels,
33    /// `--field-radius: calc(var(--radius) * 1.5)`
34    pub field_radius: Pixels,
35
36    /// `--border-width: 1px`
37    pub border_width: Pixels,
38    /// `--field-border-width: 0px`
39    pub field_border_width: Pixels,
40
41    /// `--disabled-opacity: 0.5`
42    pub disabled_opacity: f32,
43    /// `--ring-offset-width: 2px`
44    pub ring_offset_width: Pixels,
45
46    /// `--surface-shadow` — cards, accordions and other inline containers.
47    pub surface_shadow: Vec<BoxShadow>,
48    /// `--overlay-shadow` — tooltips, popovers, modals and menus.
49    pub overlay_shadow: Vec<BoxShadow>,
50    /// `--field-shadow` — inputs and other form controls.
51    pub field_shadow: Vec<BoxShadow>,
52
53    /// `--skeleton-animation`
54    pub skeleton_animation: SkeletonAnimation,
55    /// `--tooltip-delay: 1500ms`
56    pub tooltip_delay_ms: u64,
57    /// `--tooltip-close-delay: 500ms`
58    pub tooltip_close_delay_ms: u64,
59    /// The hairline a floating panel draws instead of a border.
60    ///
61    /// v3 gives its panels no border at all: light mode separates them with
62    /// `--overlay-shadow`, and dark mode adds `0 0 1px 0 rgba(255,255,255,.3)
63    /// **inset**` -- a one-pixel highlight just inside the edge. gpui has no
64    /// inset shadow, so the closest reproduction is a one-pixel border in that
65    /// colour, and in light mode there is none.
66    pub overlay_hairline: Option<gpui::Hsla>,
67
68    /// The cursor an interactive control shows while the pointer is over it.
69    ///
70    /// v3 gives every clickable control `cursor: pointer`, so the default is
71    /// [`gpui::CursorStyle::PointingHand`] — the variant GPUI's own
72    /// `Styled::cursor_pointer()` sets, which keeps stock rendering identical.
73    /// A theme that wants the platform arrow everywhere sets
74    /// [`gpui::CursorStyle::Arrow`] once here instead of restyling components.
75    pub cursor_interactive: gpui::CursorStyle,
76
77    /// The opacity a hovered `Tabs` item drops to.
78    ///
79    /// v3 hardcodes `opacity: 0.7` on an unselected `.tabs__tab:hover`; this
80    /// port names it so a theme can soften or disable the dim. Not a v3 CSS
81    /// variable, and not consumed by `Link`: its root hover draws an underline.
82    pub tabs_hover_opacity: f32,
83
84    /// The warm window after the pointer leaves a tooltip during which the
85    /// next tip opens without its delay.
86    ///
87    /// The cooldown starts when the tooltip is dismissed (hover exit) and
88    /// lasts `max(this, close_delay)`, so a per-tooltip close delay extends
89    /// the window. React Aria uses the same 500 ms.
90    pub tooltip_cooldown_ms: u64,
91
92    /// How long a `DropdownTrigger::LongPress` waits before it opens.
93    ///
94    /// React Aria uses 500 ms.
95    pub long_press_ms: u64,
96
97    /// The background fade duration `anim::hover_fade` eases between two
98    /// colors. The default is the button's own `100ms` (`anim::TRANSITION_MS`);
99    /// zero resolves immediately, like reduced motion.
100    pub hover_fade_ms: u64,
101}
102
103impl Default for LayoutTheme {
104    fn default() -> Self {
105        Self::light()
106    }
107}
108
109impl LayoutTheme {
110    /// The light-mode layout tokens, including the three-layer surface, overlay and field shadows.
111    pub fn light() -> Self {
112        Self {
113            // `0 2px 4px 0 rgba(0,0,0,.04), 0 1px 2px 0 rgba(0,0,0,.06),
114            //  0 0 1px 0 rgba(0,0,0,.06)`
115            surface_shadow: vec![
116                shadow(0., 2., 4., 0.04),
117                shadow(0., 1., 2., 0.06),
118                shadow(0., 0., 1., 0.06),
119            ],
120            // `0 2px 8px 0 rgba(0,0,0,.06), 0 -6px 12px 0 rgba(0,0,0,.03),
121            //  0 14px 28px 0 rgba(0,0,0,.08)` -- three, and the middle one
122            // throws its blur *upward*, which is what keeps a panel from
123            // looking pasted onto the page.
124            overlay_shadow: vec![
125                shadow(0., 2., 8., 0.06),
126                shadow(0., -6., 12., 0.03),
127                shadow(0., 14., 28., 0.08),
128            ],
129            field_shadow: vec![
130                shadow(0., 2., 4., 0.04),
131                shadow(0., 1., 2., 0.06),
132                shadow(0., 0., 1., 0.06),
133            ],
134            ..Self::common()
135        }
136    }
137
138    /// The dark-mode layout tokens; v3 drops the surface, overlay and field drop shadows in dark mode.
139    pub fn dark() -> Self {
140        // Dark mode drops all three shadows in v3.
141        Self {
142            surface_shadow: Vec::new(),
143            overlay_shadow: Vec::new(),
144            field_shadow: Vec::new(),
145            // `--overlay-shadow: 0 0 1px 0 rgba(255,255,255,.3) inset` is the
146            // only shadow dark mode keeps, and it is what separates a panel from
147            // the page now that both are the same colour.
148            overlay_hairline: Some(gpui::hsla(0., 0., 1., 0.3)),
149            ..Self::common()
150        }
151    }
152
153    fn common() -> Self {
154        let radius = px(8.0);
155        Self {
156            spacing: px(4.0),
157            radius,
158            field_radius: radius * 1.5,
159            border_width: px(1.0),
160            field_border_width: px(0.0),
161            disabled_opacity: 0.5,
162            ring_offset_width: px(2.0),
163            surface_shadow: Vec::new(),
164            overlay_shadow: Vec::new(),
165            field_shadow: Vec::new(),
166            skeleton_animation: SkeletonAnimation::Shimmer,
167            tooltip_delay_ms: 1500,
168            tooltip_close_delay_ms: 500,
169            overlay_hairline: None,
170            cursor_interactive: gpui::CursorStyle::PointingHand,
171            tabs_hover_opacity: 0.7,
172            tooltip_cooldown_ms: 500,
173            long_press_ms: 500,
174            hover_fade_ms: 100,
175        }
176    }
177
178    /// `--radius-xs: calc(var(--radius) * 0.25)`
179    pub fn radius_xs(&self) -> Pixels {
180        self.radius * 0.25
181    }
182    /// `--radius-sm: calc(var(--radius) * 0.5)`
183    pub fn radius_sm(&self) -> Pixels {
184        self.radius * 0.5
185    }
186    /// `--radius-md: calc(var(--radius) * 0.75)`
187    pub fn radius_md(&self) -> Pixels {
188        self.radius * 0.75
189    }
190    /// `--radius-lg: calc(var(--radius) * 1)`
191    pub fn radius_lg(&self) -> Pixels {
192        self.radius
193    }
194    /// `--radius-xl: calc(var(--radius) * 1.5)`
195    pub fn radius_xl(&self) -> Pixels {
196        self.radius * 1.5
197    }
198    /// `--radius-2xl: calc(var(--radius) * 2)`
199    pub fn radius_2xl(&self) -> Pixels {
200        self.radius * 2.0
201    }
202    /// `--radius-3xl: calc(var(--radius) * 3)`
203    pub fn radius_3xl(&self) -> Pixels {
204        self.radius * 3.0
205    }
206    /// `--radius-4xl: calc(var(--radius) * 4)`
207    pub fn radius_4xl(&self) -> Pixels {
208        self.radius * 4.0
209    }
210
211    /// A radius capped the way v3 caps its own: `min(32px, ..)`.
212    ///
213    /// v3 wraps every `rounded-*` and `rounded-full` in `min()` so a theme with
214    /// an oversized `--radius` cannot distort a component — the corner stops
215    /// growing before it swallows the box.
216    pub fn capped(&self, radius: Pixels) -> Pixels {
217        radius.min(px(32.0))
218    }
219}
220
221fn shadow(x: f32, y: f32, blur: f32, alpha: f32) -> BoxShadow {
222    BoxShadow {
223        inset: false,
224        color: gpui::hsla(0.0, 0.0, 0.0, alpha),
225        offset: point(px(x), px(y)),
226        blur_radius: px(blur),
227        spread_radius: px(0.),
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use gpui::{div, Styled};
235
236    /// The default must be *the same cursor GPUI's own `cursor_pointer()` sets*,
237    /// not merely a hand-shaped variant: every component now reads this token
238    /// instead of calling that method, so any divergence silently changes stock
239    /// rendering. Comparing against the method's own output keeps the invariant
240    /// true even if GPUI renames or repoints the variant.
241    #[test]
242    fn the_default_interactive_cursor_is_gpuis_own_pointer() {
243        let mut probe = div().cursor_pointer();
244        assert_eq!(
245            Some(LayoutTheme::light().cursor_interactive),
246            probe.style().mouse_cursor,
247        );
248        assert_eq!(
249            LayoutTheme::dark().cursor_interactive,
250            LayoutTheme::light().cursor_interactive,
251            "light and dark share the token; only a custom theme changes it"
252        );
253    }
254
255    /// Every new token starts on the literal it replaced, so a consumer that
256    /// does not set one renders the stock pixels.
257    #[test]
258    fn customisation_tokens_default_to_the_literals_they_replace() {
259        for layout in [LayoutTheme::light(), LayoutTheme::dark()] {
260            assert!((layout.tabs_hover_opacity - 0.7).abs() < f32::EPSILON);
261            assert_eq!(layout.tooltip_cooldown_ms, 500);
262            assert_eq!(layout.long_press_ms, 500);
263            assert_eq!(layout.hover_fade_ms, 100);
264        }
265    }
266}