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