Skip to main content

herogpui_theme/
theme.rs

1//! The `Theme` type plus a builder for custom themes.
2//!
3//! Mirrors how v3 themes are authored: override a handful of base CSS variables
4//! and let every hover / soft / surface-level value derive from them.
5
6use gpui::{Hsla, Pixels, SharedString, WindowAppearance};
7
8use crate::layout::LayoutTheme;
9use crate::semantic::{SurfaceColor, ThemeColors};
10
11/// Visual appearance of a theme (`color-scheme`).
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
15pub enum Appearance {
16    Light,
17    Dark,
18}
19
20/// Collapses the OS appearance onto the two `color-scheme` values v3 has.
21///
22/// The vibrant variants are macOS `NSAppearanceNameVibrant{Light,Dark}`: they
23/// are still light and dark, and forgetting them is the classic bug here
24/// because they only appear on a vibrancy-enabled window, never in a test.
25/// The match is deliberately exhaustive rather than `_ => Light`, so a future
26/// GPUI variant fails the build instead of silently painting light tokens over
27/// a dark desktop.
28impl From<WindowAppearance> for Appearance {
29    fn from(appearance: WindowAppearance) -> Self {
30        match appearance {
31            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
32            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
33        }
34    }
35}
36
37/// A complete HeroUI v3 theme: semantic colors plus layout tokens.
38#[derive(Clone, Debug)]
39#[non_exhaustive]
40pub struct Theme {
41    pub id: SharedString,
42    pub appearance: Appearance,
43    pub colors: ThemeColors,
44    pub layout: LayoutTheme,
45    pub components: crate::ComponentThemes,
46}
47
48impl Theme {
49    /// The default light theme.
50    pub fn light() -> Self {
51        Self {
52            id: "light".into(),
53            appearance: Appearance::Light,
54            colors: ThemeColors::light(),
55            layout: LayoutTheme::light(),
56            components: crate::ComponentThemes::default(),
57        }
58    }
59
60    /// The default dark theme.
61    pub fn dark() -> Self {
62        Self {
63            id: "dark".into(),
64            appearance: Appearance::Dark,
65            colors: ThemeColors::dark(),
66            layout: LayoutTheme::dark(),
67            components: crate::ComponentThemes::default(),
68        }
69    }
70
71    /// Starts a custom theme extending `base` — the equivalent of overriding
72    /// CSS variables under a `[data-theme]` selector.
73    ///
74    /// A JSON document of the same sparse overrides lives behind this crate's
75    /// `serde` feature (`ThemeDocument`): it applies through this builder so
76    /// derived hover / soft mixes stay live.
77    pub fn builder(id: impl Into<SharedString>, base: Theme) -> ThemeBuilder {
78        ThemeBuilder { theme: base }.id(id)
79    }
80
81    pub fn is_dark(&self) -> bool {
82        self.appearance == Appearance::Dark
83    }
84}
85
86/// Builder for custom themes.
87pub struct ThemeBuilder {
88    theme: Theme,
89}
90
91impl ThemeBuilder {
92    /// Component defaults and named recipes, resolved by renderers on every frame.
93    pub fn components(mut self, components: crate::ComponentThemes) -> Self {
94        self.theme.components = components;
95        self
96    }
97
98    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
99        self.theme.id = id.into();
100        self
101    }
102
103    pub fn appearance(mut self, appearance: Appearance) -> Self {
104        self.theme.appearance = appearance;
105        self
106    }
107
108    // -- layout -------------------------------------------------------------
109
110    /// Sets `--radius`; `--field-radius` follows as `radius * 1.5` unless it is
111    /// overridden afterwards with [`field_radius`](Self::field_radius).
112    pub fn radius(mut self, radius: Pixels) -> Self {
113        self.theme.layout.radius = radius;
114        self.theme.layout.field_radius = radius * 1.5;
115        self
116    }
117
118    pub fn field_radius(mut self, radius: Pixels) -> Self {
119        self.theme.layout.field_radius = radius;
120        self
121    }
122
123    pub fn border_width(mut self, width: Pixels) -> Self {
124        self.theme.layout.border_width = width;
125        self
126    }
127
128    pub fn disabled_opacity(mut self, v: f32) -> Self {
129        self.theme.layout.disabled_opacity = v;
130        self
131    }
132
133    /// Sets the cursor every interactive control shows on hover. Defaults to
134    /// [`gpui::CursorStyle::PointingHand`], v3's `cursor: pointer`.
135    pub fn cursor_interactive(mut self, cursor: gpui::CursorStyle) -> Self {
136        self.theme.layout.cursor_interactive = cursor;
137        self
138    }
139
140    /// Sets the opacity a hovered `Tabs` item drops to. Finite values are
141    /// clamped to `0..=1`; a non-finite value keeps the default `0.7`.
142    pub fn tabs_hover_opacity(mut self, v: f32) -> Self {
143        self.theme.layout.tabs_hover_opacity = if v.is_finite() {
144            v.clamp(0.0, 1.0)
145        } else {
146            0.7
147        };
148        self
149    }
150
151    /// Sets the warm window after the pointer leaves a tooltip during which
152    /// the next tip opens without its delay. A per-tooltip close delay
153    /// extends the window via `max()`.
154    pub fn tooltip_cooldown_ms(mut self, ms: u64) -> Self {
155        self.theme.layout.tooltip_cooldown_ms = ms;
156        self
157    }
158
159    /// Sets how long a `DropdownTrigger::LongPress` waits before it opens.
160    pub fn long_press_ms(mut self, ms: u64) -> Self {
161        self.theme.layout.long_press_ms = ms;
162        self
163    }
164
165    /// Sets the background fade duration of `anim::hover_fade`. Zero resolves
166    /// immediately, like reduced motion.
167    pub fn hover_fade_ms(mut self, ms: u64) -> Self {
168        self.theme.layout.hover_fade_ms = ms;
169        self
170    }
171
172    /// `--tooltip-delay`: how long a hover waits before the tip opens.
173    pub fn tooltip_delay_ms(mut self, ms: u64) -> Self {
174        self.theme.layout.tooltip_delay_ms = ms;
175        self
176    }
177
178    /// `--tooltip-close-delay`: the per-tooltip close delay default.
179    pub fn tooltip_close_delay_ms(mut self, ms: u64) -> Self {
180        self.theme.layout.tooltip_close_delay_ms = ms;
181        self
182    }
183
184    // -- base colors --------------------------------------------------------
185
186    pub fn background(mut self, c: Hsla) -> Self {
187        self.theme.colors.background = c;
188        self
189    }
190
191    pub fn foreground(mut self, c: Hsla) -> Self {
192        self.theme.colors.foreground = c;
193        self.theme.colors.scrollbar = herogpui_core::with_alpha(c, 0.15);
194        self
195    }
196
197    pub fn muted(mut self, c: Hsla) -> Self {
198        self.theme.colors.muted = c;
199        self
200    }
201
202    pub fn border(mut self, c: Hsla) -> Self {
203        self.theme.colors.border = c;
204        self
205    }
206
207    /// Sets `--separator`. Defaults to the same value as `--border`.
208    pub fn separator(mut self, c: Hsla) -> Self {
209        self.theme.colors.separator = c;
210        self
211    }
212
213    pub fn focus(mut self, c: Hsla) -> Self {
214        self.theme.colors.focus = c;
215        self
216    }
217
218    pub fn link(mut self, c: Hsla) -> Self {
219        self.theme.colors.link = c;
220        self
221    }
222
223    pub fn backdrop(mut self, c: Hsla) -> Self {
224        self.theme.colors.backdrop = c;
225        self
226    }
227
228    // -- containers ---------------------------------------------------------
229
230    pub fn surface(mut self, background: Hsla, foreground: Hsla) -> Self {
231        self.theme.colors.surface = SurfaceColor {
232            background,
233            foreground,
234        };
235        self
236    }
237
238    pub fn surface_levels(mut self, secondary: Hsla, tertiary: Hsla) -> Self {
239        self.theme.colors.surface_secondary = secondary;
240        self.theme.colors.surface_tertiary = tertiary;
241        self
242    }
243
244    pub fn overlay(mut self, background: Hsla, foreground: Hsla) -> Self {
245        self.theme.colors.overlay = SurfaceColor {
246            background,
247            foreground,
248        };
249        self
250    }
251
252    pub fn segment(mut self, background: Hsla, foreground: Hsla) -> Self {
253        self.theme.colors.segment = SurfaceColor {
254            background,
255            foreground,
256        };
257        self
258    }
259
260    // -- roles --------------------------------------------------------------
261
262    /// Sets a role's base value and foreground. Like overriding a CSS
263    /// variable, the role's hover and soft mix weights carry over — only the
264    /// inputs change. `--focus` tracks `--accent` unless it is overridden
265    /// afterwards.
266    pub fn role(mut self, name: &str, color: Hsla, foreground: Hsla) -> Self {
267        match name {
268            "default" => {
269                self.theme.colors.default.color = color;
270                self.theme.colors.default.foreground = foreground;
271                self.theme.colors.field.background = color;
272            }
273            "success" => {
274                self.theme.colors.success.color = color;
275                self.theme.colors.success.foreground = foreground;
276            }
277            "warning" => {
278                self.theme.colors.warning.color = color;
279                self.theme.colors.warning.foreground = foreground;
280            }
281            "danger" => {
282                self.theme.colors.danger.color = color;
283                self.theme.colors.danger.foreground = foreground;
284            }
285            _ => {
286                self.theme.colors.accent.color = color;
287                self.theme.colors.accent.foreground = foreground;
288                self.theme.colors.focus = color;
289            }
290        }
291        self
292    }
293
294    /// HeroUI's opt-in `[data-vibrant-palette="true"]` block
295    /// (variables.css:317-330): the accent, success, warning and danger
296    /// `*-soft-foreground` mixes become `92%` role over `8%` page foreground in
297    /// both appearances, for more saturated soft text with less contrast.
298    /// `--default-soft-foreground` is not in that block and is left alone.
299    /// Off by default, like the attribute.
300    pub fn vibrant_palette(mut self, vibrant: bool) -> Self {
301        self.theme.colors.set_vibrant_palette(vibrant);
302        self
303    }
304
305    /// Sets `--accent` and `--accent-foreground`, deriving the foreground for
306    /// readability when it is not supplied.
307    pub fn accent(self, color: Hsla) -> Self {
308        let fg = herogpui_core::readable_color(color);
309        self.role("accent", color, fg)
310    }
311
312    /// Names a role's `*-hover` shade outright, in place of the derived mix.
313    ///
314    /// Upstream mixes a role toward its own foreground for hover, so a design
315    /// system whose hover moves the other way has no weight that expresses it.
316    /// Naming it here reaches every component that hovers the role, so
317    /// `Variant::Primary` is correct by construction rather than by converting
318    /// call sites to a named recipe carrying a
319    /// [`crate::ComponentColor::Literal`]. `*-soft-hover` is unaffected.
320    ///
321    /// The name matches [`ThemeBuilder::role`]: `default`, `success`,
322    /// `warning`, `danger`, and anything else is `accent`.
323    pub fn role_hover(mut self, name: &str, hover: Hsla) -> Self {
324        let role = match name {
325            "default" => &mut self.theme.colors.default,
326            "success" => &mut self.theme.colors.success,
327            "warning" => &mut self.theme.colors.warning,
328            "danger" => &mut self.theme.colors.danger,
329            _ => &mut self.theme.colors.accent,
330        };
331        *role = role.with_hover(hover);
332        self
333    }
334
335    /// [`ThemeBuilder::role_hover`] for `accent`, the role `Variant::Primary`
336    /// and the focus ring resolve.
337    pub fn accent_hover(self, hover: Hsla) -> Self {
338        self.role_hover("accent", hover)
339    }
340
341    // -- fields -------------------------------------------------------------
342
343    pub fn field(mut self, background: Hsla, foreground: Hsla) -> Self {
344        self.theme.colors.field.background = background;
345        self.theme.colors.field.foreground = foreground;
346        self
347    }
348
349    pub fn field_placeholder(mut self, c: Hsla) -> Self {
350        self.theme.colors.field.placeholder = c;
351        self
352    }
353
354    pub fn field_border(mut self, c: Hsla) -> Self {
355        self.theme.colors.field.border = c;
356        self
357    }
358
359    pub fn build(self) -> Theme {
360        self.theme
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use herogpui_core::{mix_oklab, oklch, with_alpha};
368
369    #[test]
370    fn the_builder_overrides_the_interactive_cursor_and_nothing_else() {
371        let base = Theme::light();
372        assert_eq!(
373            base.layout.cursor_interactive,
374            gpui::CursorStyle::PointingHand
375        );
376
377        let theme = Theme::builder("arrow", base.clone())
378            .cursor_interactive(gpui::CursorStyle::Arrow)
379            .build();
380
381        assert_eq!(theme.layout.cursor_interactive, gpui::CursorStyle::Arrow);
382        assert_eq!(theme.layout.radius, base.layout.radius);
383        assert!(
384            (theme.layout.disabled_opacity - base.layout.disabled_opacity).abs() < f32::EPSILON
385        );
386        assert_eq!(theme.colors.background, base.colors.background);
387    }
388
389    #[test]
390    fn customisation_tokens_flow_through_the_builder_and_clamp() {
391        let theme = Theme::builder("custom", Theme::light())
392            .tabs_hover_opacity(2.0)
393            .tooltip_cooldown_ms(250)
394            .long_press_ms(350)
395            .hover_fade_ms(0)
396            .tooltip_delay_ms(50)
397            .tooltip_close_delay_ms(75)
398            .build();
399        assert!((theme.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
400        assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
401        assert_eq!(theme.layout.long_press_ms, 350);
402        assert_eq!(theme.layout.hover_fade_ms, 0);
403        assert_eq!(theme.layout.tooltip_delay_ms, 50);
404        assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
405
406        let nan = Theme::builder("nan", Theme::light())
407            .tabs_hover_opacity(f32::NAN)
408            .build();
409        assert!((nan.layout.tabs_hover_opacity - 0.7).abs() < f32::EPSILON);
410    }
411
412    #[test]
413    fn overriding_foreground_recomputes_scrollbar_without_changing_other_tokens() {
414        let base = Theme::light();
415        let foreground = oklch(0.30, 0.05, 120.0);
416        let background = oklch(0.90, 0.01, 286.0);
417        let unchanged = (
418            base.colors.muted,
419            base.colors.border,
420            base.colors.separator,
421            base.colors.focus,
422            base.layout.radius,
423            base.layout.disabled_opacity,
424        );
425        let theme = Theme::builder("brand", base)
426            .background(background)
427            .foreground(foreground)
428            .build();
429
430        assert_eq!(
431            (
432                theme.colors.scrollbar,
433                theme.colors.background,
434                theme.colors.muted,
435                theme.colors.border,
436                theme.colors.separator,
437                theme.colors.focus,
438                theme.layout.radius,
439                theme.layout.disabled_opacity,
440            ),
441            (
442                with_alpha(foreground, 0.15),
443                background,
444                unchanged.0,
445                unchanged.1,
446                unchanged.2,
447                unchanged.3,
448                unchanged.4,
449                unchanged.5,
450            )
451        );
452    }
453
454    #[test]
455    fn overriding_a_role_keeps_its_soft_semantics() {
456        // v3's soft variables are `color-mix`es of the role variables: an
457        // override replaces the input, never the weights.
458        let theme = Theme::builder("brand", Theme::light())
459            .role("success", oklch(0.55, 0.18, 145.0), oklch(1.0, 0.0, 0.0))
460            .build();
461        assert!((theme.colors.success.soft().a - 0.15).abs() < 1e-4);
462        assert!((theme.colors.success.soft_hover().a - 0.20).abs() < 1e-4);
463        assert_eq!(
464            theme
465                .colors
466                .success
467                .soft_foreground(theme.colors.foreground),
468            mix_oklab(
469                theme.colors.success.color,
470                theme.colors.foreground,
471                60.0 / 140.0
472            )
473        );
474    }
475
476    #[test]
477    fn a_default_role_override_keeps_the_half_strength_soft() {
478        let theme = Theme::builder("brand", Theme::light())
479            .role(
480                "default",
481                oklch(0.90, 0.01, 286.0),
482                oklch(0.20, 0.01, 286.0),
483            )
484            .build();
485        assert!((theme.colors.default.soft().a - 0.50).abs() < 1e-4);
486        assert!((theme.colors.default.soft_hover().a - 0.60).abs() < 1e-4);
487        assert_eq!(
488            theme
489                .colors
490                .default
491                .soft_foreground(theme.colors.foreground),
492            theme.colors.default.foreground
493        );
494    }
495
496    #[test]
497    fn the_vibrant_palette_reweights_only_the_soft_foregrounds() {
498        // variables.css:317-330 lists accent, danger, warning and success in
499        // both the light and the dark block, at 92% role / 8% foreground.
500        for base in [Theme::light(), Theme::dark()] {
501            let plain = Theme::builder("plain", base.clone()).build();
502            let vibrant = Theme::builder("vibrant", base.clone())
503                .vibrant_palette(true)
504                .build();
505            assert!(!plain.colors.vibrant_palette());
506            assert!(vibrant.colors.vibrant_palette());
507
508            for role in ["accent", "success", "warning", "danger"] {
509                let r = vibrant.colors.role(role);
510                assert_eq!(
511                    r.soft_foreground(vibrant.colors.foreground),
512                    mix_oklab(r.color, vibrant.colors.foreground, 0.08),
513                    "{role} soft-foreground is not the 92/8 vibrant mix"
514                );
515                assert_ne!(
516                    r.soft_foreground(vibrant.colors.foreground),
517                    plain
518                        .colors
519                        .role(role)
520                        .soft_foreground(plain.colors.foreground),
521                    "{role} soft-foreground did not move"
522                );
523            }
524
525            // `default` is absent from both vibrant blocks: it still resolves
526            // to `--default-foreground`.
527            assert_eq!(
528                vibrant
529                    .colors
530                    .default
531                    .soft_foreground(vibrant.colors.foreground),
532                plain.colors.default.foreground
533            );
534
535            // Nothing else derived moves.
536            for role in ["default", "accent", "success", "warning", "danger"] {
537                let (a, b) = (plain.colors.role(role), vibrant.colors.role(role));
538                assert_eq!(a.color, b.color, "{role} base moved");
539                assert_eq!(a.foreground, b.foreground, "{role} foreground moved");
540                assert_eq!(a.hover(), b.hover(), "{role} hover moved");
541                assert_eq!(a.soft(), b.soft(), "{role} soft moved");
542                assert_eq!(a.soft_hover(), b.soft_hover(), "{role} soft-hover moved");
543            }
544            assert_eq!(plain.colors.foreground, vibrant.colors.foreground);
545            assert_eq!(plain.colors.background, vibrant.colors.background);
546            assert_eq!(plain.colors.focus, vibrant.colors.focus);
547            assert_eq!(plain.colors.surface.hover(), vibrant.colors.surface.hover());
548            assert_eq!(plain.colors.field.hover(), vibrant.colors.field.hover());
549
550            // The flag is an opt-in that can be turned back off.
551            let off = Theme::builder("off", base)
552                .vibrant_palette(true)
553                .vibrant_palette(false)
554                .build();
555            assert!(!off.colors.vibrant_palette());
556            assert_eq!(
557                off.colors.accent.soft_foreground(off.colors.foreground),
558                plain.colors.accent.soft_foreground(plain.colors.foreground)
559            );
560        }
561    }
562}