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