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