Skip to main content

gpui_component/theme/
mod.rs

1use crate::{
2    highlighter::HighlightTheme, list::ListSettings, notification::NotificationSettings,
3    scroll::ScrollbarMode, sheet::SheetSettings,
4};
5use gpui::{
6    App, Global, Hsla, IsZero as _, Pixels, SharedString, Window, WindowAppearance,
7    prelude::FluentBuilder as _, px,
8};
9pub use gpui_base::{
10    ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens, TextStyleToken,
11    TypographyTokens,
12};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use std::{
16    ops::{Deref, DerefMut},
17    rc::Rc,
18    sync::Arc,
19    time::Duration,
20};
21
22mod color;
23mod mono_font;
24mod motion;
25mod registry;
26mod schema;
27mod system_font;
28mod theme_color;
29
30pub use color::*;
31pub use motion::*;
32pub use registry::*;
33pub use schema::*;
34pub use theme_color::*;
35
36pub fn init(cx: &mut App) {
37    registry::init(cx);
38
39    // Ensure theme is loaded directly on startup for WASM compatibility
40    Theme::change(ThemeMode::Light, None, cx);
41    Theme::sync_scrollbar_appearance(cx);
42}
43
44pub trait ActiveTheme {
45    fn theme(&self) -> &Theme;
46}
47
48impl ActiveTheme for App {
49    #[inline(always)]
50    fn theme(&self) -> &Theme {
51        Theme::global(self)
52    }
53}
54
55fn default_true() -> bool {
56    true
57}
58
59/// The radius that rounds a shape as far as its own size allows, giving a
60/// circle or a pill. Any value past half the shorter side is clamped by the
61/// renderer, so this is simply "as round as it goes".
62const RADIUS_FULL: Pixels = px(9999.);
63
64/// How long the scrollbar stays visible after the last scroll, drag, or hover.
65const SCROLLBAR_IDLE: Duration = Duration::from_secs(2);
66/// How long the scrollbar takes to appear.
67const SCROLLBAR_ENTER: Duration = Duration::from_millis(300);
68/// How long the scrollbar takes to fade away once the idle hold expires.
69const SCROLLBAR_EXIT: Duration = Duration::from_millis(500);
70/// How long the thumb takes to reach its hovered or resting width.
71const SCROLLBAR_EXPAND: Duration = Duration::from_millis(300);
72
73/// The resting thumb width on iOS and Android, matching the 3pt indicator
74/// those platforms draw. Hover and drag keep Base's desktop widths, so a
75/// grabbed thumb still grows under the finger.
76const MOBILE_SCROLLBAR_THUMB_WIDTH: Pixels = px(3.);
77/// How far the resting thumb sits from the edge on iOS and Android. Base's
78/// desktop inset leaves a 3px thumb floating too far from the edge.
79const MOBILE_SCROLLBAR_THUMB_INSET: Pixels = px(2.);
80/// Base's resting thumb width, restated so the hovered thumb keeps it when
81/// the mobile resting width would otherwise cascade into it.
82const SCROLLBAR_THUMB_HOVER_WIDTH: Pixels = px(6.);
83/// Base's dragged thumb width, restated for the same reason.
84const SCROLLBAR_THUMB_ACTIVE_WIDTH: Pixels = px(8.);
85/// Base's hovered and dragged thumb inset, restated for the same reason.
86const SCROLLBAR_THUMB_INSET: Pixels = px(4.);
87
88/// The scrollbar motion this design system projects onto Base.
89///
90/// Scrolling and track hover reveal a scrollbar by fading it in place. In hover
91/// mode, pointing at the thumb slides it in from the nearest edge as it fades.
92fn scrollbar_motion(mode: ScrollbarMode) -> gpui_base::ScrollbarMotion {
93    gpui_base::ScrollbarMotion::default()
94        .with_idle(SCROLLBAR_IDLE)
95        .with_enter(SCROLLBAR_ENTER)
96        .with_exit(SCROLLBAR_EXIT)
97        .with_expand(SCROLLBAR_EXPAND)
98        .with_entrance(gpui_base::ScrollbarEntrance::Fade)
99        .with_thumb_hover_entrance(match mode {
100            ScrollbarMode::Scrolling | ScrollbarMode::Always => gpui_base::ScrollbarEntrance::Fade,
101            ScrollbarMode::Hover => gpui_base::ScrollbarEntrance::SlideAndFade,
102        })
103}
104
105/// The global theme configuration.
106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
107pub struct Theme {
108    pub colors: ThemeColor,
109    /// Component-specific resolved tokens retained for legacy compatibility.
110    ///
111    /// New application-owned presentation should use [`Self::semantic_tokens`]
112    /// rather than extending this legacy surface.
113    #[serde(default)]
114    pub tokens: ThemeTokens,
115    pub highlight_theme: Arc<HighlightTheme>,
116    pub light_theme: Rc<ThemeConfig>,
117    pub dark_theme: Rc<ThemeConfig>,
118
119    pub mode: ThemeMode,
120    /// The font family for the application, default is `.SystemUIFont`.
121    ///
122    /// When the system font resolves to an installed fallback family instead
123    /// of itself (Linux desktops without the family GPUI maps it to),
124    /// [`Theme::change`] names that family here, so every text lookup hits
125    /// the font cache. A family set explicitly is used as-is.
126    pub font_family: SharedString,
127    /// The base font size for the application, default is 16px.
128    pub font_size: Pixels,
129    /// The monospace font family for the application.
130    ///
131    /// Defaults to:
132    ///
133    /// - macOS: `Menlo`
134    /// - Windows: `Consolas`
135    /// - Linux: `DejaVu Sans Mono`
136    ///
137    /// When that default is not installed, [`Theme::change`] swaps it for the
138    /// first installed alternative (`Monaco`, `Cascadia Mono`, `Noto Sans Mono`
139    /// and the like) and finally `.SystemUIFont`, so a missing font cannot
140    /// crash text layout. A family set explicitly is used as-is.
141    pub mono_font_family: SharedString,
142    /// The monospace font size for the application, default is 13px.
143    pub mono_font_size: Pixels,
144    /// Radius for the general elements.
145    pub radius: Pixels,
146    /// Radius for the large elements, e.g.: Dialog, Notification border radius.
147    pub radius_lg: Pixels,
148    pub shadow: bool,
149    /// Whether focused controls draw a ring outside their border, default true.
150    ///
151    /// The ring is painted outside the element, so any ancestor that clips its
152    /// content will cut it off. An application whose layout clips heavily can
153    /// turn it off here: focused controls then show only their tinted border,
154    /// which costs no space and cannot be clipped.
155    #[serde(default = "default_true")]
156    pub focus_ring: bool,
157    pub transparent: Hsla,
158    /// Show the scrollbar mode, default: Scrolling
159    #[serde(alias = "scrollbar_show")]
160    pub scrollbar_mode: ScrollbarMode,
161    /// The notification setting.
162    #[serde(skip)]
163    pub notification: NotificationSettings,
164    /// The list settings.
165    pub list: ListSettings,
166    /// The sheet settings.
167    pub sheet: SheetSettings,
168    /// Semantic motion policy for styled components.
169    #[serde(skip)]
170    pub motion: MotionTokens,
171}
172
173impl Default for Theme {
174    fn default() -> Self {
175        Self::from(&ThemeColor::default())
176    }
177}
178
179impl Deref for Theme {
180    type Target = ThemeColor;
181
182    fn deref(&self) -> &Self::Target {
183        &self.colors
184    }
185}
186
187impl DerefMut for Theme {
188    fn deref_mut(&mut self) -> &mut Self::Target {
189        &mut self.colors
190    }
191}
192
193impl Global for Theme {}
194
195impl Theme {
196    /// Returns the global theme reference
197    #[inline(always)]
198    pub fn global(cx: &App) -> &Theme {
199        cx.global::<Theme>()
200    }
201
202    /// Returns the global theme mutable reference.
203    ///
204    /// An edit made through this reference reaches nothing but the field it
205    /// touches: [`Theme::tokens`] keeps the colors it had, and so does the
206    /// Base projection until [`Theme::sync_base`] rebuilds it. Prefer
207    /// [`Theme::update`], which does both after the edit and refreshes every
208    /// window. Keep this for an edit that must not trigger any of that.
209    #[inline(always)]
210    pub fn global_mut(cx: &mut App) -> &mut Theme {
211        cx.global_mut::<Theme>()
212    }
213
214    /// Edits the global theme and keeps every copy of it in step.
215    ///
216    /// The theme holds the same colors twice — [`Theme::colors`] as solid
217    /// colors and [`Theme::tokens`] as renderable backgrounds that may carry a
218    /// gradient — and the Base layer keeps a projection of its own for the
219    /// scrollbar and resize handles. Editing one of them through
220    /// [`Theme::global_mut`] leaves the others where they were, so a sidebar
221    /// can paint its text from the new colors and its background from the old
222    /// tokens. This is the write path that cannot drift:
223    ///
224    /// ```ignore
225    /// Theme::update(cx, |theme| {
226    ///     theme.colors = my_colors;
227    ///     theme.radius = px(8.);
228    /// });
229    /// ```
230    ///
231    /// After the closure returns, a color edited on `colors` replaces its
232    /// token (dropping any gradient — the edit asked for that solid color), a
233    /// token edited on its own writes its solid color back to `colors`, an
234    /// untouched field keeps the gradient a theme file gave it, the Base
235    /// projection is rebuilt, and every window is refreshed.
236    ///
237    /// A field the closure sets to the value it already had counts as
238    /// untouched: assigning a whole palette keeps the gradient of any field
239    /// whose color did not change. Edit the token to replace one.
240    ///
241    /// Setting [`Theme::mode`] loads that mode's registered theme, the same
242    /// as [`Theme::change`]; that load replaces the colors, so edit colors in
243    /// a second `update` after switching mode rather than in the same closure.
244    /// [`Theme::apply_config`] installs a theme file and switches to its mode
245    /// in one step, and the closure may go on editing after it — nothing is
246    /// loaded over its edits.
247    pub fn update<R>(cx: &mut App, edit: impl FnOnce(&mut Theme) -> R) -> R {
248        Self::edit(cx, false, edit)
249    }
250
251    /// The write path behind [`Theme::update`] and [`Theme::change`].
252    ///
253    /// `reload_mode` loads the current mode's registered theme even when the
254    /// mode did not change, which is what `change` promises: a caller that
255    /// swapped [`Theme::light_theme`] or [`Theme::dark_theme`] and then asks
256    /// for that mode gets the new theme applied.
257    fn edit<R>(cx: &mut App, reload_mode: bool, edit: impl FnOnce(&mut Theme) -> R) -> R {
258        let theme = Theme::global_mut(cx);
259        let colors_before = theme.colors;
260        let tokens_before = theme.tokens;
261        let mode_before = theme.mode;
262        let light_before = theme.light_theme.clone();
263        let dark_before = theme.dark_theme.clone();
264        let fonts_before = (theme.font_family.clone(), theme.mono_font_family.clone());
265
266        let result = edit(theme);
267
268        theme
269            .tokens
270            .reconcile(&mut theme.colors, &colors_before, &tokens_before);
271        let mode_changed = theme.mode != mode_before;
272        let (config, config_before) = if theme.mode.is_dark() {
273            (&theme.dark_theme, &dark_before)
274        } else {
275            (&theme.light_theme, &light_before)
276        };
277        // `apply_config` registers the file it applies and switches to its
278        // mode, so a mode change that arrives with a newly registered config
279        // has already loaded it. Loading it again would put the file's radius,
280        // fonts and colors back over whatever the closure edited after it.
281        let installed_by_edit = mode_changed && !Rc::ptr_eq(config, config_before);
282        if (mode_changed || reload_mode) && !installed_by_edit {
283            let config = config.clone();
284            theme.apply_config(&config);
285        }
286        let fonts_changed =
287            (&theme.font_family, &theme.mono_font_family) != (&fonts_before.0, &fonts_before.1);
288        if mode_changed || reload_mode || fonts_changed {
289            system_font::resolve_default_font(cx);
290            mono_font::resolve_default_mono_font(cx);
291        }
292        Self::sync_base(cx);
293        cx.refresh_windows();
294        result
295    }
296
297    /// Returns true if the theme is dark.
298    #[inline(always)]
299    pub fn is_dark(&self) -> bool {
300        self.mode.is_dark()
301    }
302
303    /// Returns the current theme name.
304    pub fn theme_name(&self) -> &SharedString {
305        if self.is_dark() {
306            &self.dark_theme.name
307        } else {
308            &self.light_theme.name
309        }
310    }
311
312    /// Sync the theme with the system appearance
313    pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
314        // Better use window.appearance() for avoid error on Linux.
315        // https://github.com/longbridge/gpui-kit/issues/104
316        let appearance = window
317            .as_ref()
318            .map(|window| window.appearance())
319            .unwrap_or_else(|| cx.window_appearance());
320
321        Self::change(appearance, window, cx);
322    }
323
324    /// Sync the Scrollbar showing behavior with the system
325    pub fn sync_scrollbar_appearance(cx: &mut App) {
326        let mode = if cx.should_auto_hide_scrollbars() {
327            ScrollbarMode::Scrolling
328        } else {
329            ScrollbarMode::Hover
330        };
331        Self::set_scrollbar_mode(mode, cx);
332    }
333
334    /// Changes the scrollbar display mode through [`Theme::update`], which
335    /// projects it onto the Base scrollbar and refreshes every window.
336    pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
337        Self::update(cx, |theme| theme.scrollbar_mode = mode);
338    }
339
340    /// Change the theme mode.
341    ///
342    /// Loads the registered theme for `mode` — even when `mode` is already
343    /// current, so a caller that swapped [`Theme::light_theme`] or
344    /// [`Theme::dark_theme`] sees the new theme — through [`Theme::update`],
345    /// which keeps every copy of the theme in step and refreshes every
346    /// window. `window` is accepted for compatibility; every window is
347    /// refreshed either way, so it is not read.
348    pub fn change(mode: impl Into<ThemeMode>, _window: Option<&mut Window>, cx: &mut App) {
349        let mode = mode.into();
350        if !cx.has_global::<Theme>() {
351            let mut theme = Theme::default();
352            theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
353            theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
354            cx.set_global(theme);
355        }
356
357        Self::edit(cx, true, |theme| theme.mode = mode);
358    }
359
360    /// This theme projected onto the Base layer, which owns the scrollbar and
361    /// resize handles and reads the semantic tokens.
362    fn base_theme(&self) -> gpui_base::Theme {
363        gpui_base::Theme {
364            appearance: if self.mode.is_dark() {
365                gpui_base::ThemeAppearance::Dark
366            } else {
367                gpui_base::ThemeAppearance::Light
368            },
369            tokens: self.semantic_tokens(),
370            scrollbar: gpui_base::ScrollbarTheme::new()
371                .with_mode(self.scrollbar_mode)
372                .with_motion(scrollbar_motion(self.scrollbar_mode))
373                .with_styles(
374                    gpui_base::ScrollbarStyles::default()
375                        .track(|style| style.bg(self.scrollbar))
376                        .track_hover(|style| style.bg(self.scrollbar))
377                        .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
378                        .thumb(|style| {
379                            style
380                                .bg(self.tokens.scrollbar_thumb)
381                                .radius(self.radius)
382                                .when(gpui_base::is_mobile(), |style| {
383                                    style
384                                        .width(MOBILE_SCROLLBAR_THUMB_WIDTH)
385                                        .inset(MOBILE_SCROLLBAR_THUMB_INSET)
386                                        .radius(RADIUS_FULL)
387                                })
388                        })
389                        .thumb_hover(|style| {
390                            style
391                                .bg(self.tokens.scrollbar_thumb_hover)
392                                .radius(self.radius)
393                                .when(gpui_base::is_mobile(), |style| {
394                                    style
395                                        .width(SCROLLBAR_THUMB_HOVER_WIDTH)
396                                        .inset(SCROLLBAR_THUMB_INSET)
397                                })
398                        })
399                        .thumb_active(|style| {
400                            style
401                                .bg(self.tokens.scrollbar_thumb_hover)
402                                .radius(self.radius)
403                                .when(gpui_base::is_mobile(), |style| {
404                                    style
405                                        .width(SCROLLBAR_THUMB_ACTIVE_WIDTH)
406                                        .inset(SCROLLBAR_THUMB_INSET)
407                                })
408                        }),
409                ),
410            resizable: gpui_base::ResizableTheme {
411                handle: Some(self.border),
412                active_handle: Some(self.drag_border),
413            },
414        }
415    }
416
417    /// Push the current theme down to the Base layer.
418    ///
419    /// The Base layer holds its own copy of the theme — the semantic tokens
420    /// plus the scrollbar and resize-handle styles — because it paints those
421    /// without going through `gpui-component`. [`Theme::change`] refreshes that
422    /// copy, but writing to the theme's public fields directly does not, so a
423    /// scrollbar keeps painting with the radius and colors it was last given.
424    ///
425    /// [`Theme::update`] and [`Theme::change`] call this after their edits.
426    /// After editing through [`Theme::global_mut`], call it yourself, then
427    /// refresh the windows.
428    ///
429    /// It rebuilds the Base theme from scratch, so any style written straight
430    /// onto the Base global is replaced. It does not touch [`Theme::tokens`].
431    pub fn sync_base(cx: &mut App) {
432        let theme = Theme::global(cx).clone();
433        let base_theme = theme.base_theme();
434        cx.set_global(base_theme);
435        crate::text::install_text_view_defaults(&theme, cx);
436    }
437
438    /// Get the input background color.
439    ///
440    /// For dark, use a transparent color mixed with the input border: `cx.theme().input`,
441    /// otherwise use the `cx.theme().background` color.
442    #[inline]
443    pub fn input_background(&self) -> Hsla {
444        if self.is_dark() {
445            self.input.mix_oklab(self.transparent, 0.3)
446        } else {
447            self.background
448        }
449    }
450
451    /// Get the editor background color, if not set, use the input background color.
452    #[inline]
453    pub(crate) fn editor_background(&self) -> Hsla {
454        self.highlight_theme
455            .style
456            .editor_background
457            .unwrap_or_else(|| self.input_background())
458    }
459
460    /// Returns a snapshot of the semantic design tokens represented by this
461    /// theme. The snapshot is computed from the legacy public fields so direct
462    /// mutations of those fields are reflected immediately.
463    pub fn semantic_tokens(&self) -> SemanticThemeTokens {
464        SemanticThemeTokens {
465            colors: self.color_tokens(),
466            radius: self.radius_tokens(),
467            spacing: self.spacing_tokens(),
468            typography: self.typography_tokens(),
469            shadow: self.shadow_tokens(),
470        }
471    }
472
473    /// Returns the styled layer's semantic motion policy.
474    pub fn motion_tokens(&self) -> &MotionTokens {
475        &self.motion
476    }
477
478    pub fn color_tokens(&self) -> ColorTokens {
479        ColorTokens {
480            background: self.background,
481            foreground: self.foreground,
482            surface: self.popover,
483            surface_foreground: self.popover_foreground,
484            primary: self.primary,
485            primary_foreground: self.primary_foreground,
486            secondary: self.secondary,
487            secondary_foreground: self.secondary_foreground,
488            muted: self.muted,
489            muted_foreground: self.muted_foreground,
490            accent: self.accent,
491            accent_foreground: self.accent_foreground,
492            destructive: self.danger,
493            destructive_foreground: self.danger_foreground,
494            border: self.border,
495            input: self.input,
496            ring: self.ring,
497            selection: self.selection,
498        }
499    }
500
501    /// The radius of a shape that reads as a circle or a pill — an avatar, a
502    /// slider thumb, a badge dot, a pill tab.
503    ///
504    /// A theme whose [`Theme::radius`] is zero squares these off too, so one
505    /// setting governs the whole UI instead of leaving a handful of
506    /// permanently round elements behind. Use it in place of
507    /// [`gpui::Styled::rounded_full`], or reach for
508    /// [`crate::ThemeStyled::rounded_full_style`] when styling an element.
509    pub fn radius_full(&self) -> Pixels {
510        if self.radius.is_zero() {
511            px(0.)
512        } else {
513            RADIUS_FULL
514        }
515    }
516
517    /// Returns the next surface radius above the existing `xl` theme tier.
518    ///
519    /// Larger surface tiers derive from the same application-controlled base
520    /// radius, so adjusting or squaring the theme updates every tier together.
521    pub fn radius_2xl(&self) -> Pixels {
522        self.radius * 2.5
523    }
524
525    /// Returns the surface radius above [`Self::radius_2xl`].
526    pub fn radius_3xl(&self) -> Pixels {
527        self.radius * 3.
528    }
529
530    /// Returns the surface radius above [`Self::radius_3xl`].
531    pub fn radius_4xl(&self) -> Pixels {
532        self.radius * 3.5
533    }
534
535    pub fn radius_tokens(&self) -> RadiusTokens {
536        RadiusTokens {
537            none: px(0.),
538            sm: self.radius / 2.,
539            md: self.radius,
540            lg: self.radius_lg,
541            xl: self.radius * 2.,
542            full: self.radius_full(),
543        }
544    }
545
546    pub fn spacing_tokens(&self) -> SpacingTokens {
547        SpacingTokens::default()
548    }
549
550    pub fn typography_tokens(&self) -> TypographyTokens {
551        let mut tokens = TypographyTokens::default();
552        tokens.sans = self.font_family.clone();
553        tokens.mono = self.mono_font_family.clone();
554        tokens.md.size = self.font_size;
555        tokens.mono_md.size = self.mono_font_size;
556        tokens
557    }
558
559    pub fn shadow_tokens(&self) -> ShadowTokens {
560        if self.shadow {
561            ShadowTokens::elevations(self.transparent.alpha(0.18))
562        } else {
563            ShadowTokens::default()
564        }
565    }
566
567    /// Applies the subset of semantic tokens representable by the legacy
568    /// theme. Scale-only spacing and elevation details have no legacy storage;
569    /// legacy components therefore keep their existing behavior.
570    pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
571        let colors = tokens.colors;
572        self.background = colors.background;
573        self.foreground = colors.foreground;
574        self.popover = colors.surface;
575        self.popover_foreground = colors.surface_foreground;
576        self.primary = colors.primary;
577        self.primary_foreground = colors.primary_foreground;
578        self.secondary = colors.secondary;
579        self.secondary_foreground = colors.secondary_foreground;
580        self.muted = colors.muted;
581        self.muted_foreground = colors.muted_foreground;
582        self.accent = colors.accent;
583        self.accent_foreground = colors.accent_foreground;
584        self.danger = colors.destructive;
585        self.danger_foreground = colors.destructive_foreground;
586        self.border = colors.border;
587        self.input = colors.input;
588        self.ring = colors.ring;
589
590        self.tokens.background = colors.background.into();
591        self.tokens.popover = colors.surface.into();
592        self.tokens.primary = colors.primary.into();
593        self.tokens.secondary = colors.secondary.into();
594        self.tokens.muted = colors.muted.into();
595        self.tokens.accent = colors.accent.into();
596        self.tokens.danger = colors.destructive.into();
597
598        self.radius = tokens.radius.md;
599        self.radius_lg = tokens.radius.lg;
600        self.font_family = tokens.typography.sans.clone();
601        self.mono_font_family = tokens.typography.mono.clone();
602        self.font_size = tokens.typography.md.size;
603        self.mono_font_size = tokens.typography.mono_md.size;
604        self.shadow = !tokens.shadow.sm.is_empty()
605            || !tokens.shadow.md.is_empty()
606            || !tokens.shadow.lg.is_empty();
607    }
608
609    /// Resolves a standalone semantic configuration over the current legacy
610    /// theme without mutating either value.
611    pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
612        let mut tokens = self.semantic_tokens();
613        config.apply_to(&mut tokens);
614        tokens
615    }
616
617    /// Applies the legacy-representable part of a standalone semantic config
618    /// and returns the complete resolved snapshot for application-owned UI.
619    pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
620        let tokens = self.resolve_semantic_config(config);
621        self.apply_semantic_tokens(&tokens);
622        tokens
623    }
624
625    /// Parses and applies a standalone `{ "tokens": ... }` semantic theme file.
626    pub fn apply_semantic_config_str(
627        &mut self,
628        content: &str,
629    ) -> anyhow::Result<SemanticThemeTokens> {
630        let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
631        Ok(self.apply_semantic_config(&config.tokens))
632    }
633}
634
635#[cfg(test)]
636mod semantic_token_tests {
637    use gpui::{Hsla, IsZero as _, px};
638
639    use super::{RADIUS_FULL, Theme};
640
641    #[test]
642    fn semantic_colors_are_a_live_projection_of_legacy_fields() {
643        let mut theme = Theme::default();
644        let primary = Hsla::default().alpha(0.42);
645        theme.primary = primary;
646
647        assert_eq!(theme.color_tokens().primary, primary);
648        assert_eq!(theme.semantic_tokens().colors.primary, primary);
649    }
650
651    #[test]
652    fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
653        let mut theme = Theme::default();
654        let component_color = theme.button_primary;
655        let mut tokens = theme.semantic_tokens();
656        tokens.colors.primary = Hsla::default().alpha(0.25);
657        tokens.colors.destructive = Hsla::default().alpha(0.75);
658        tokens.radius.md = px(10.);
659
660        theme.apply_semantic_tokens(&tokens);
661
662        assert_eq!(theme.primary, tokens.colors.primary);
663        assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
664        assert_eq!(theme.danger, tokens.colors.destructive);
665        assert_eq!(theme.radius, px(10.));
666        assert_eq!(theme.button_primary, component_color);
667    }
668
669    #[test]
670    fn square_themes_square_off_pills_and_circles() {
671        let mut theme = Theme::default();
672        assert_eq!(theme.radius_full(), RADIUS_FULL);
673        assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
674
675        // An application asking for square corners gets them everywhere, not
676        // just on the elements whose radius happens to come from `radius`.
677        theme.radius = px(0.);
678        assert_eq!(theme.radius_full(), px(0.));
679        assert_eq!(theme.radius_tokens().full, px(0.));
680    }
681
682    #[test]
683    fn larger_surface_radii_follow_the_theme_radius() {
684        let mut theme = Theme::default();
685        assert!(theme.radius_tokens().xl < theme.radius_2xl());
686        assert!(theme.radius_2xl() < theme.radius_3xl());
687        assert!(theme.radius_3xl() < theme.radius_4xl());
688
689        theme.radius = px(10.);
690        assert_eq!(theme.radius_2xl(), px(25.));
691        assert_eq!(theme.radius_3xl(), px(30.));
692        assert_eq!(theme.radius_4xl(), px(35.));
693
694        theme.radius = px(0.);
695        assert_eq!(theme.radius_2xl(), px(0.));
696        assert_eq!(theme.radius_3xl(), px(0.));
697        assert_eq!(theme.radius_4xl(), px(0.));
698    }
699
700    #[test]
701    fn base_projection_carries_a_square_radius_to_the_scrollbar() {
702        let mut theme = Theme::default();
703        assert!(!theme.base_theme().tokens.radius.md.is_zero());
704
705        // The scrollbar paints from the Base layer's copy of the theme, so a
706        // square theme has to reach it or the thumb stays a pill.
707        theme.radius = px(0.);
708        assert!(theme.base_theme().tokens.radius.md.is_zero());
709    }
710
711    #[test]
712    fn disabled_legacy_shadows_project_to_empty_elevations() {
713        let mut theme = Theme::default();
714        theme.shadow = false;
715
716        let shadows = theme.shadow_tokens();
717        assert!(shadows.sm.is_empty());
718        assert!(shadows.md.is_empty());
719        assert!(shadows.lg.is_empty());
720    }
721}
722
723impl From<&ThemeColor> for Theme {
724    fn from(colors: &ThemeColor) -> Self {
725        Theme {
726            mode: ThemeMode::default(),
727            transparent: Hsla::transparent_black(),
728            font_family: ".SystemUIFont".into(),
729            font_size: px(16.),
730            mono_font_family: mono_font::default_mono_font_family(),
731            mono_font_size: px(13.),
732            radius: px(6.),
733            radius_lg: px(8.),
734            shadow: true,
735            focus_ring: true,
736            scrollbar_mode: ScrollbarMode::default(),
737            notification: NotificationSettings::default(),
738            list: ListSettings::default(),
739            colors: *colors,
740            tokens: ThemeTokens::from(colors),
741            light_theme: Rc::new(ThemeConfig::default()),
742            dark_theme: Rc::new(ThemeConfig::default()),
743            highlight_theme: HighlightTheme::default_light(),
744            sheet: SheetSettings::default(),
745            motion: MotionTokens::default(),
746        }
747    }
748}
749
750#[derive(
751    Debug,
752    Clone,
753    Copy,
754    Default,
755    PartialEq,
756    PartialOrd,
757    Eq,
758    Ord,
759    Hash,
760    Serialize,
761    Deserialize,
762    JsonSchema,
763)]
764#[serde(rename_all = "snake_case")]
765pub enum ThemeMode {
766    #[default]
767    Light,
768    Dark,
769}
770
771impl ThemeMode {
772    #[inline(always)]
773    pub fn is_dark(&self) -> bool {
774        matches!(self, Self::Dark)
775    }
776
777    /// Return lower_case theme name: `light`, `dark`.
778    pub fn name(&self) -> &'static str {
779        match self {
780            ThemeMode::Light => "light",
781            ThemeMode::Dark => "dark",
782        }
783    }
784}
785
786impl From<WindowAppearance> for ThemeMode {
787    fn from(appearance: WindowAppearance) -> Self {
788        match appearance {
789            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
790            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
791        }
792    }
793}
794
795#[cfg(test)]
796mod update_tests {
797    use super::*;
798    use gpui::{TestAppContext, linear_color_stop, linear_gradient};
799
800    fn gradient(from: Hsla, to: Hsla) -> ThemeToken {
801        ThemeToken::new(
802            from,
803            linear_gradient(135., linear_color_stop(from, 0.), linear_color_stop(to, 1.)),
804        )
805    }
806
807    /// A color edited on `colors` reaches the token the components paint
808    /// with, and the Base projection the scrollbar paints with.
809    #[gpui::test]
810    fn editing_colors_updates_the_tokens_and_the_base_projection(cx: &mut TestAppContext) {
811        cx.update(|cx| {
812            init(cx);
813            let sidebar = gpui::rgb(0x123456).into();
814            let primary = gpui::rgb(0xabcdef).into();
815
816            Theme::update(cx, |theme| {
817                theme.sidebar = sidebar;
818                theme.colors.primary = primary;
819                theme.radius = px(0.);
820            });
821
822            let theme = Theme::global(cx);
823            assert_eq!(theme.tokens.sidebar.color, sidebar);
824            assert_eq!(theme.tokens.sidebar.background, sidebar.into());
825            assert_eq!(theme.tokens.primary.color, primary);
826            assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, primary);
827            assert!(gpui_base::Theme::global(cx).tokens.radius.md.is_zero());
828        });
829    }
830
831    /// Replacing the whole `colors` struct, as an application installing its
832    /// own palette does, rewrites every token.
833    #[gpui::test]
834    fn replacing_the_palette_rewrites_every_token(cx: &mut TestAppContext) {
835        cx.update(|cx| {
836            init(cx);
837            let palette = *ThemeColor::dark();
838
839            Theme::update(cx, |theme| theme.colors = palette);
840
841            assert_eq!(Theme::global(cx).tokens, ThemeTokens::from(palette));
842        });
843    }
844
845    /// A gradient a theme file gave a token survives edits to other fields;
846    /// editing that field's color replaces the gradient with the solid color.
847    #[gpui::test]
848    fn a_gradient_survives_until_its_own_color_is_edited(cx: &mut TestAppContext) {
849        cx.update(|cx| {
850            init(cx);
851            let from = gpui::rgb(0x4f46e5).into();
852            let to = gpui::rgb(0x06b6d4).into();
853            let token = gradient(from, to);
854            Theme::update(cx, |theme| theme.tokens.primary = token);
855            // The token's solid color is written back, so text painted with
856            // `theme.primary` matches the gradient's representative color.
857            assert_eq!(Theme::global(cx).primary, from);
858
859            Theme::update(cx, |theme| theme.secondary = gpui::rgb(0x222222).into());
860            assert_eq!(Theme::global(cx).tokens.primary, token);
861
862            let solid = gpui::rgb(0x999999).into();
863            Theme::update(cx, |theme| theme.primary = solid);
864            assert_eq!(Theme::global(cx).tokens.primary, solid.into());
865        });
866    }
867
868    /// Setting `mode` through `update` loads that mode's theme, the same as
869    /// `change`, and the Base projection follows.
870    #[gpui::test]
871    fn setting_the_mode_loads_that_modes_theme(cx: &mut TestAppContext) {
872        cx.update(|cx| {
873            init(cx);
874            let light_background = Theme::global(cx).background;
875
876            Theme::update(cx, |theme| theme.mode = ThemeMode::Dark);
877
878            let theme = Theme::global(cx);
879            assert!(theme.is_dark());
880            assert_ne!(theme.background, light_background);
881            assert_eq!(theme.tokens.background.color, theme.background);
882            assert_eq!(
883                gpui_base::Theme::global(cx).appearance,
884                gpui_base::ThemeAppearance::Dark
885            );
886            assert_eq!(
887                gpui_base::Theme::global(cx).tokens.colors.background,
888                theme.background
889            );
890        });
891    }
892
893    /// Applying a theme file through `update` keeps the gradients it
894    /// declares: the config sets `colors` and `tokens` to one color, which is
895    /// not a conflict to resolve.
896    #[gpui::test]
897    fn applying_a_config_keeps_its_gradients(cx: &mut TestAppContext) {
898        cx.update(|cx| {
899            init(cx);
900            let config: ThemeConfig = serde_json::from_value(serde_json::json!({
901                "name": "Gradient",
902                "mode": "light",
903                "colors": {
904                    "primary": "#4F46E5",
905                    "primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)"
906                }
907            }))
908            .unwrap();
909            let config = Rc::new(config);
910
911            Theme::update(cx, |theme| theme.apply_config(&config));
912
913            let theme = Theme::global(cx);
914            assert_eq!(theme.tokens.primary.color, theme.primary);
915            assert_ne!(
916                theme.tokens.primary.background,
917                theme.primary.into(),
918                "the gradient must survive the reconcile"
919            );
920        });
921    }
922
923    /// `apply_config` switches to the file's mode itself, so `edit` must not
924    /// load that mode's theme a second time over what the closure went on to
925    /// set — the same closure has to land the same result from either mode.
926    #[gpui::test]
927    fn edits_after_applying_a_config_of_the_other_mode_survive(cx: &mut TestAppContext) {
928        cx.update(|cx| {
929            init(cx);
930            let config: ThemeConfig = serde_json::from_value(serde_json::json!({
931                "name": "Rounded Dark",
932                "mode": "dark",
933                "radius": 12,
934                "colors": { "primary": "#4F46E5" }
935            }))
936            .unwrap();
937            let config = Rc::new(config);
938            assert!(!Theme::global(cx).is_dark());
939
940            let red = gpui::red();
941            Theme::update(cx, |theme| {
942                theme.apply_config(&config);
943                theme.radius = px(0.);
944                theme.colors.primary = red;
945            });
946
947            let theme = Theme::global(cx);
948            assert!(theme.is_dark());
949            assert!(Rc::ptr_eq(&theme.dark_theme, &config));
950            assert_eq!(theme.radius, px(0.), "the file's radius must not reload");
951            assert_eq!(theme.primary, red);
952            assert_eq!(theme.tokens.primary, red.into());
953            assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, red);
954        });
955    }
956
957    #[gpui::test]
958    fn update_returns_the_closure_result(cx: &mut TestAppContext) {
959        cx.update(|cx| {
960            init(cx);
961            let radius = Theme::update(cx, |theme| {
962                theme.radius = px(6.);
963                theme.radius
964            });
965            assert_eq!(radius, px(6.));
966        });
967    }
968}
969
970#[cfg(test)]
971mod base_theme_projection_tests {
972    use super::*;
973    use gpui::TestAppContext;
974
975    #[gpui::test]
976    fn base_theme_tracks_initialization_and_mode_changes(cx: &mut TestAppContext) {
977        cx.update(|cx| {
978            init(cx);
979            assert_styled_projection(cx);
980
981            Theme::change(ThemeMode::Dark, None, cx);
982            assert_styled_projection(cx);
983
984            Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
985            assert_eq!(Theme::global(cx).scrollbar_mode, ScrollbarMode::Always);
986            assert_eq!(
987                gpui_base::Theme::global(cx).scrollbar.mode(),
988                gpui_base::ScrollbarMode::Always
989            );
990            assert_styled_projection(cx);
991        });
992    }
993
994    #[gpui::test]
995    fn scrollbar_motion_is_owned_here_and_projected_onto_base(cx: &mut TestAppContext) {
996        cx.update(|cx| {
997            init(cx);
998
999            // Base itself ships none of this timing.
1000            let bare = gpui_base::ScrollbarMotion::default();
1001            assert_eq!(bare.enter(), Duration::ZERO);
1002            assert_eq!(bare.exit(), Duration::ZERO);
1003            assert_eq!(bare.expand(), Duration::ZERO);
1004
1005            Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx);
1006            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1007            assert_eq!(motion.idle(), SCROLLBAR_IDLE);
1008            assert_eq!(motion.enter(), SCROLLBAR_ENTER);
1009            assert_eq!(motion.exit(), SCROLLBAR_EXIT);
1010            assert_eq!(motion.expand(), SCROLLBAR_EXPAND);
1011            assert_eq!(
1012                motion.entrance(),
1013                gpui_base::ScrollbarEntrance::Fade,
1014                "scroll-revealed scrollbars fade in without sliding"
1015            );
1016
1017            Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);
1018            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1019            assert_eq!(motion.entrance(), gpui_base::ScrollbarEntrance::Fade);
1020            assert_eq!(
1021                motion.thumb_hover_entrance(),
1022                gpui_base::ScrollbarEntrance::SlideAndFade
1023            );
1024        });
1025    }
1026
1027    #[test]
1028    fn default_motion_tokens_form_a_coherent_semantic_scale() {
1029        let theme = Theme::default();
1030        let motion = theme.motion_tokens();
1031
1032        assert_eq!(motion.duration_instant, Duration::ZERO);
1033        assert!(motion.duration_fast < motion.duration_normal);
1034        assert!(motion.duration_normal < motion.duration_slow);
1035        assert!(motion.distance_short.0 < motion.distance_medium.0);
1036        assert_eq!(motion.easing_enter.sample(0.0), 0.0);
1037        assert_eq!(motion.easing_enter.sample(1.0), 1.0);
1038    }
1039
1040    fn assert_styled_projection(cx: &App) {
1041        let theme = Theme::global(cx);
1042        let base = gpui_base::Theme::global(cx);
1043
1044        assert_eq!(base.tokens, theme.semantic_tokens());
1045        assert_eq!(base.scrollbar.mode(), theme.scrollbar_mode);
1046        assert_eq!(
1047            base.scrollbar.motion(),
1048            scrollbar_motion(theme.scrollbar_mode)
1049        );
1050        assert_eq!(base.resizable.handle, Some(theme.border));
1051        assert_eq!(base.resizable.active_handle, Some(theme.drag_border));
1052    }
1053
1054    #[gpui::test]
1055    fn default_component_palettes_match_base_light_and_dark_tokens(cx: &mut gpui::TestAppContext) {
1056        fn assert_close(left: ColorTokens, right: ColorTokens) {
1057            macro_rules! color {
1058                ($field:ident) => {
1059                    assert!(
1060                        (left.$field.h - right.$field.h).abs() < 1e-6
1061                            && (left.$field.s - right.$field.s).abs() < 1e-6
1062                            && (left.$field.l - right.$field.l).abs() < 1e-6
1063                            && (left.$field.a - right.$field.a).abs() < 1e-6,
1064                        "{} differs: {:?} != {:?}",
1065                        stringify!($field),
1066                        left.$field,
1067                        right.$field
1068                    );
1069                };
1070            }
1071            color!(background);
1072            color!(foreground);
1073            color!(surface);
1074            color!(surface_foreground);
1075            color!(primary);
1076            color!(primary_foreground);
1077            color!(secondary);
1078            color!(secondary_foreground);
1079            color!(muted);
1080            color!(muted_foreground);
1081            color!(accent);
1082            color!(accent_foreground);
1083            color!(destructive);
1084            color!(destructive_foreground);
1085            color!(border);
1086            color!(input);
1087            color!(ring);
1088            color!(selection);
1089        }
1090
1091        cx.update(crate::init);
1092        cx.update(|cx| {
1093            assert_close(Theme::global(cx).color_tokens(), ColorTokens::light());
1094        });
1095
1096        cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx));
1097        cx.update(|cx| {
1098            assert_close(Theme::global(cx).color_tokens(), ColorTokens::dark());
1099        });
1100    }
1101}