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}