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    /// Changes to fields the Base layer mirrors — the radius, the colors, the
205    /// fonts — reach the scrollbar and resize handles only once
206    /// [`Theme::sync_base`] runs.
207    #[inline(always)]
208    pub fn global_mut(cx: &mut App) -> &mut Theme {
209        cx.global_mut::<Theme>()
210    }
211
212    /// Returns true if the theme is dark.
213    #[inline(always)]
214    pub fn is_dark(&self) -> bool {
215        self.mode.is_dark()
216    }
217
218    /// Returns the current theme name.
219    pub fn theme_name(&self) -> &SharedString {
220        if self.is_dark() {
221            &self.dark_theme.name
222        } else {
223            &self.light_theme.name
224        }
225    }
226
227    /// Sync the theme with the system appearance
228    pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
229        // Better use window.appearance() for avoid error on Linux.
230        // https://github.com/longbridge/gpui-kit/issues/104
231        let appearance = window
232            .as_ref()
233            .map(|window| window.appearance())
234            .unwrap_or_else(|| cx.window_appearance());
235
236        Self::change(appearance, window, cx);
237    }
238
239    /// Sync the Scrollbar showing behavior with the system
240    pub fn sync_scrollbar_appearance(cx: &mut App) {
241        let mode = if cx.should_auto_hide_scrollbars() {
242            ScrollbarMode::Scrolling
243        } else {
244            ScrollbarMode::Hover
245        };
246        Self::set_scrollbar_mode(mode, cx);
247    }
248
249    /// Changes the scrollbar display mode and synchronizes the Base projection.
250    pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
251        Theme::global_mut(cx).scrollbar_mode = mode;
252        let base_theme = gpui_base::Theme::global_mut(cx);
253        base_theme.scrollbar = base_theme
254            .scrollbar
255            .clone()
256            .with_mode(mode)
257            .with_motion(scrollbar_motion(mode));
258    }
259
260    /// Change the theme mode.
261    pub fn change(mode: impl Into<ThemeMode>, window: Option<&mut Window>, cx: &mut App) {
262        let mode = mode.into();
263        if !cx.has_global::<Theme>() {
264            let mut theme = Theme::default();
265            theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
266            theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
267            cx.set_global(theme);
268        }
269
270        {
271            let theme = cx.global_mut::<Theme>();
272            theme.mode = mode;
273            if mode.is_dark() {
274                theme.apply_config(&theme.dark_theme.clone());
275            } else {
276                theme.apply_config(&theme.light_theme.clone());
277            }
278        }
279        system_font::resolve_default_font(cx);
280        mono_font::resolve_default_mono_font(cx);
281        let theme = cx.global::<Theme>().clone();
282
283        let base_theme = theme.base_theme();
284        cx.set_global(base_theme);
285        crate::text::install_text_view_defaults(&theme, cx);
286
287        if let Some(window) = window {
288            window.refresh();
289        }
290    }
291
292    /// This theme projected onto the Base layer, which owns the scrollbar and
293    /// resize handles and reads the semantic tokens.
294    fn base_theme(&self) -> gpui_base::Theme {
295        gpui_base::Theme {
296            appearance: if self.mode.is_dark() {
297                gpui_base::ThemeAppearance::Dark
298            } else {
299                gpui_base::ThemeAppearance::Light
300            },
301            tokens: self.semantic_tokens(),
302            scrollbar: gpui_base::ScrollbarTheme::new()
303                .with_mode(self.scrollbar_mode)
304                .with_motion(scrollbar_motion(self.scrollbar_mode))
305                .with_styles(
306                    gpui_base::ScrollbarStyles::default()
307                        .track(|style| style.bg(self.scrollbar))
308                        .track_hover(|style| style.bg(self.scrollbar))
309                        .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
310                        .thumb(|style| {
311                            style
312                                .bg(self.tokens.scrollbar_thumb)
313                                .radius(self.radius)
314                                .when(gpui_base::is_mobile(), |style| {
315                                    style
316                                        .width(MOBILE_SCROLLBAR_THUMB_WIDTH)
317                                        .inset(MOBILE_SCROLLBAR_THUMB_INSET)
318                                        .radius(RADIUS_FULL)
319                                })
320                        })
321                        .thumb_hover(|style| {
322                            style
323                                .bg(self.tokens.scrollbar_thumb_hover)
324                                .radius(self.radius)
325                                .when(gpui_base::is_mobile(), |style| {
326                                    style
327                                        .width(SCROLLBAR_THUMB_HOVER_WIDTH)
328                                        .inset(SCROLLBAR_THUMB_INSET)
329                                })
330                        })
331                        .thumb_active(|style| {
332                            style
333                                .bg(self.tokens.scrollbar_thumb_hover)
334                                .radius(self.radius)
335                                .when(gpui_base::is_mobile(), |style| {
336                                    style
337                                        .width(SCROLLBAR_THUMB_ACTIVE_WIDTH)
338                                        .inset(SCROLLBAR_THUMB_INSET)
339                                })
340                        }),
341                ),
342            resizable: gpui_base::ResizableTheme {
343                handle: Some(self.border),
344                active_handle: Some(self.drag_border),
345            },
346        }
347    }
348
349    /// Push the current theme down to the Base layer.
350    ///
351    /// The Base layer holds its own copy of the theme — the semantic tokens
352    /// plus the scrollbar and resize-handle styles — because it paints those
353    /// without going through `gpui-component`. [`Theme::change`] refreshes that
354    /// copy, but writing to the theme's public fields directly does not, so a
355    /// scrollbar keeps painting with the radius and colors it was last given.
356    ///
357    /// Call this after mutating the theme through [`Theme::global_mut`]:
358    ///
359    /// ```ignore
360    /// Theme::global_mut(cx).radius = px(0.);
361    /// Theme::sync_base(cx);
362    /// ```
363    ///
364    /// It rebuilds the Base theme from scratch, so any style written straight
365    /// onto the Base global is replaced — the same thing [`Theme::change`]
366    /// does.
367    pub fn sync_base(cx: &mut App) {
368        let theme = Theme::global(cx).clone();
369        let base_theme = theme.base_theme();
370        cx.set_global(base_theme);
371        crate::text::install_text_view_defaults(&theme, cx);
372    }
373
374    /// Get the input background color.
375    ///
376    /// For dark, use a transparent color mixed with the input border: `cx.theme().input`,
377    /// otherwise use the `cx.theme().background` color.
378    #[inline]
379    pub fn input_background(&self) -> Hsla {
380        if self.is_dark() {
381            self.input.mix_oklab(self.transparent, 0.3)
382        } else {
383            self.background
384        }
385    }
386
387    /// Get the editor background color, if not set, use the input background color.
388    #[inline]
389    pub(crate) fn editor_background(&self) -> Hsla {
390        self.highlight_theme
391            .style
392            .editor_background
393            .unwrap_or_else(|| self.input_background())
394    }
395
396    /// Returns a snapshot of the semantic design tokens represented by this
397    /// theme. The snapshot is computed from the legacy public fields so direct
398    /// mutations of those fields are reflected immediately.
399    pub fn semantic_tokens(&self) -> SemanticThemeTokens {
400        SemanticThemeTokens {
401            colors: self.color_tokens(),
402            radius: self.radius_tokens(),
403            spacing: self.spacing_tokens(),
404            typography: self.typography_tokens(),
405            shadow: self.shadow_tokens(),
406        }
407    }
408
409    /// Returns the styled layer's semantic motion policy.
410    pub fn motion_tokens(&self) -> &MotionTokens {
411        &self.motion
412    }
413
414    pub fn color_tokens(&self) -> ColorTokens {
415        ColorTokens {
416            background: self.background,
417            foreground: self.foreground,
418            surface: self.popover,
419            surface_foreground: self.popover_foreground,
420            primary: self.primary,
421            primary_foreground: self.primary_foreground,
422            secondary: self.secondary,
423            secondary_foreground: self.secondary_foreground,
424            muted: self.muted,
425            muted_foreground: self.muted_foreground,
426            accent: self.accent,
427            accent_foreground: self.accent_foreground,
428            destructive: self.danger,
429            destructive_foreground: self.danger_foreground,
430            border: self.border,
431            input: self.input,
432            ring: self.ring,
433            selection: self.selection,
434        }
435    }
436
437    /// The radius of a shape that reads as a circle or a pill — an avatar, a
438    /// slider thumb, a badge dot, a pill tab.
439    ///
440    /// A theme whose [`Theme::radius`] is zero squares these off too, so one
441    /// setting governs the whole UI instead of leaving a handful of
442    /// permanently round elements behind. Use it in place of
443    /// [`gpui::Styled::rounded_full`], or reach for
444    /// [`crate::ThemeStyled::rounded_full_style`] when styling an element.
445    pub fn radius_full(&self) -> Pixels {
446        if self.radius.is_zero() {
447            px(0.)
448        } else {
449            RADIUS_FULL
450        }
451    }
452
453    /// Returns the next surface radius above the existing `xl` theme tier.
454    ///
455    /// Larger surface tiers derive from the same application-controlled base
456    /// radius, so adjusting or squaring the theme updates every tier together.
457    pub fn radius_2xl(&self) -> Pixels {
458        self.radius * 2.5
459    }
460
461    /// Returns the surface radius above [`Self::radius_2xl`].
462    pub fn radius_3xl(&self) -> Pixels {
463        self.radius * 3.
464    }
465
466    /// Returns the surface radius above [`Self::radius_3xl`].
467    pub fn radius_4xl(&self) -> Pixels {
468        self.radius * 3.5
469    }
470
471    pub fn radius_tokens(&self) -> RadiusTokens {
472        RadiusTokens {
473            none: px(0.),
474            sm: self.radius / 2.,
475            md: self.radius,
476            lg: self.radius_lg,
477            xl: self.radius * 2.,
478            full: self.radius_full(),
479        }
480    }
481
482    pub fn spacing_tokens(&self) -> SpacingTokens {
483        SpacingTokens::default()
484    }
485
486    pub fn typography_tokens(&self) -> TypographyTokens {
487        let mut tokens = TypographyTokens::default();
488        tokens.sans = self.font_family.clone();
489        tokens.mono = self.mono_font_family.clone();
490        tokens.md.size = self.font_size;
491        tokens.mono_md.size = self.mono_font_size;
492        tokens
493    }
494
495    pub fn shadow_tokens(&self) -> ShadowTokens {
496        if self.shadow {
497            ShadowTokens::elevations(self.transparent.alpha(0.18))
498        } else {
499            ShadowTokens::default()
500        }
501    }
502
503    /// Applies the subset of semantic tokens representable by the legacy
504    /// theme. Scale-only spacing and elevation details have no legacy storage;
505    /// legacy components therefore keep their existing behavior.
506    pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
507        let colors = tokens.colors;
508        self.background = colors.background;
509        self.foreground = colors.foreground;
510        self.popover = colors.surface;
511        self.popover_foreground = colors.surface_foreground;
512        self.primary = colors.primary;
513        self.primary_foreground = colors.primary_foreground;
514        self.secondary = colors.secondary;
515        self.secondary_foreground = colors.secondary_foreground;
516        self.muted = colors.muted;
517        self.muted_foreground = colors.muted_foreground;
518        self.accent = colors.accent;
519        self.accent_foreground = colors.accent_foreground;
520        self.danger = colors.destructive;
521        self.danger_foreground = colors.destructive_foreground;
522        self.border = colors.border;
523        self.input = colors.input;
524        self.ring = colors.ring;
525
526        self.tokens.background = colors.background.into();
527        self.tokens.popover = colors.surface.into();
528        self.tokens.primary = colors.primary.into();
529        self.tokens.secondary = colors.secondary.into();
530        self.tokens.muted = colors.muted.into();
531        self.tokens.accent = colors.accent.into();
532        self.tokens.danger = colors.destructive.into();
533
534        self.radius = tokens.radius.md;
535        self.radius_lg = tokens.radius.lg;
536        self.font_family = tokens.typography.sans.clone();
537        self.mono_font_family = tokens.typography.mono.clone();
538        self.font_size = tokens.typography.md.size;
539        self.mono_font_size = tokens.typography.mono_md.size;
540        self.shadow = !tokens.shadow.sm.is_empty()
541            || !tokens.shadow.md.is_empty()
542            || !tokens.shadow.lg.is_empty();
543    }
544
545    /// Resolves a standalone semantic configuration over the current legacy
546    /// theme without mutating either value.
547    pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
548        let mut tokens = self.semantic_tokens();
549        config.apply_to(&mut tokens);
550        tokens
551    }
552
553    /// Applies the legacy-representable part of a standalone semantic config
554    /// and returns the complete resolved snapshot for application-owned UI.
555    pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
556        let tokens = self.resolve_semantic_config(config);
557        self.apply_semantic_tokens(&tokens);
558        tokens
559    }
560
561    /// Parses and applies a standalone `{ "tokens": ... }` semantic theme file.
562    pub fn apply_semantic_config_str(
563        &mut self,
564        content: &str,
565    ) -> anyhow::Result<SemanticThemeTokens> {
566        let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
567        Ok(self.apply_semantic_config(&config.tokens))
568    }
569}
570
571#[cfg(test)]
572mod semantic_token_tests {
573    use gpui::{Hsla, IsZero as _, px};
574
575    use super::{RADIUS_FULL, Theme};
576
577    #[test]
578    fn semantic_colors_are_a_live_projection_of_legacy_fields() {
579        let mut theme = Theme::default();
580        let primary = Hsla::default().alpha(0.42);
581        theme.primary = primary;
582
583        assert_eq!(theme.color_tokens().primary, primary);
584        assert_eq!(theme.semantic_tokens().colors.primary, primary);
585    }
586
587    #[test]
588    fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
589        let mut theme = Theme::default();
590        let component_color = theme.button_primary;
591        let mut tokens = theme.semantic_tokens();
592        tokens.colors.primary = Hsla::default().alpha(0.25);
593        tokens.colors.destructive = Hsla::default().alpha(0.75);
594        tokens.radius.md = px(10.);
595
596        theme.apply_semantic_tokens(&tokens);
597
598        assert_eq!(theme.primary, tokens.colors.primary);
599        assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
600        assert_eq!(theme.danger, tokens.colors.destructive);
601        assert_eq!(theme.radius, px(10.));
602        assert_eq!(theme.button_primary, component_color);
603    }
604
605    #[test]
606    fn square_themes_square_off_pills_and_circles() {
607        let mut theme = Theme::default();
608        assert_eq!(theme.radius_full(), RADIUS_FULL);
609        assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
610
611        // An application asking for square corners gets them everywhere, not
612        // just on the elements whose radius happens to come from `radius`.
613        theme.radius = px(0.);
614        assert_eq!(theme.radius_full(), px(0.));
615        assert_eq!(theme.radius_tokens().full, px(0.));
616    }
617
618    #[test]
619    fn larger_surface_radii_follow_the_theme_radius() {
620        let mut theme = Theme::default();
621        assert!(theme.radius_tokens().xl < theme.radius_2xl());
622        assert!(theme.radius_2xl() < theme.radius_3xl());
623        assert!(theme.radius_3xl() < theme.radius_4xl());
624
625        theme.radius = px(10.);
626        assert_eq!(theme.radius_2xl(), px(25.));
627        assert_eq!(theme.radius_3xl(), px(30.));
628        assert_eq!(theme.radius_4xl(), px(35.));
629
630        theme.radius = px(0.);
631        assert_eq!(theme.radius_2xl(), px(0.));
632        assert_eq!(theme.radius_3xl(), px(0.));
633        assert_eq!(theme.radius_4xl(), px(0.));
634    }
635
636    #[test]
637    fn base_projection_carries_a_square_radius_to_the_scrollbar() {
638        let mut theme = Theme::default();
639        assert!(!theme.base_theme().tokens.radius.md.is_zero());
640
641        // The scrollbar paints from the Base layer's copy of the theme, so a
642        // square theme has to reach it or the thumb stays a pill.
643        theme.radius = px(0.);
644        assert!(theme.base_theme().tokens.radius.md.is_zero());
645    }
646
647    #[test]
648    fn disabled_legacy_shadows_project_to_empty_elevations() {
649        let mut theme = Theme::default();
650        theme.shadow = false;
651
652        let shadows = theme.shadow_tokens();
653        assert!(shadows.sm.is_empty());
654        assert!(shadows.md.is_empty());
655        assert!(shadows.lg.is_empty());
656    }
657}
658
659impl From<&ThemeColor> for Theme {
660    fn from(colors: &ThemeColor) -> Self {
661        Theme {
662            mode: ThemeMode::default(),
663            transparent: Hsla::transparent_black(),
664            font_family: ".SystemUIFont".into(),
665            font_size: px(16.),
666            mono_font_family: mono_font::default_mono_font_family(),
667            mono_font_size: px(13.),
668            radius: px(6.),
669            radius_lg: px(8.),
670            shadow: true,
671            focus_ring: true,
672            scrollbar_mode: ScrollbarMode::default(),
673            notification: NotificationSettings::default(),
674            list: ListSettings::default(),
675            colors: *colors,
676            tokens: ThemeTokens::from(colors),
677            light_theme: Rc::new(ThemeConfig::default()),
678            dark_theme: Rc::new(ThemeConfig::default()),
679            highlight_theme: HighlightTheme::default_light(),
680            sheet: SheetSettings::default(),
681            motion: MotionTokens::default(),
682        }
683    }
684}
685
686#[derive(
687    Debug,
688    Clone,
689    Copy,
690    Default,
691    PartialEq,
692    PartialOrd,
693    Eq,
694    Ord,
695    Hash,
696    Serialize,
697    Deserialize,
698    JsonSchema,
699)]
700#[serde(rename_all = "snake_case")]
701pub enum ThemeMode {
702    #[default]
703    Light,
704    Dark,
705}
706
707impl ThemeMode {
708    #[inline(always)]
709    pub fn is_dark(&self) -> bool {
710        matches!(self, Self::Dark)
711    }
712
713    /// Return lower_case theme name: `light`, `dark`.
714    pub fn name(&self) -> &'static str {
715        match self {
716            ThemeMode::Light => "light",
717            ThemeMode::Dark => "dark",
718        }
719    }
720}
721
722impl From<WindowAppearance> for ThemeMode {
723    fn from(appearance: WindowAppearance) -> Self {
724        match appearance {
725            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
726            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
727        }
728    }
729}
730
731#[cfg(test)]
732mod base_theme_projection_tests {
733    use super::*;
734    use gpui::TestAppContext;
735
736    #[gpui::test]
737    fn base_theme_tracks_initialization_and_mode_changes(cx: &mut TestAppContext) {
738        cx.update(|cx| {
739            init(cx);
740            assert_styled_projection(cx);
741
742            Theme::change(ThemeMode::Dark, None, cx);
743            assert_styled_projection(cx);
744
745            Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
746            assert_eq!(Theme::global(cx).scrollbar_mode, ScrollbarMode::Always);
747            assert_eq!(
748                gpui_base::Theme::global(cx).scrollbar.mode(),
749                gpui_base::ScrollbarMode::Always
750            );
751            assert_styled_projection(cx);
752        });
753    }
754
755    #[gpui::test]
756    fn scrollbar_motion_is_owned_here_and_projected_onto_base(cx: &mut TestAppContext) {
757        cx.update(|cx| {
758            init(cx);
759
760            // Base itself ships none of this timing.
761            let bare = gpui_base::ScrollbarMotion::default();
762            assert_eq!(bare.enter(), Duration::ZERO);
763            assert_eq!(bare.exit(), Duration::ZERO);
764            assert_eq!(bare.expand(), Duration::ZERO);
765
766            Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx);
767            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
768            assert_eq!(motion.idle(), SCROLLBAR_IDLE);
769            assert_eq!(motion.enter(), SCROLLBAR_ENTER);
770            assert_eq!(motion.exit(), SCROLLBAR_EXIT);
771            assert_eq!(motion.expand(), SCROLLBAR_EXPAND);
772            assert_eq!(
773                motion.entrance(),
774                gpui_base::ScrollbarEntrance::Fade,
775                "scroll-revealed scrollbars fade in without sliding"
776            );
777
778            Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);
779            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
780            assert_eq!(motion.entrance(), gpui_base::ScrollbarEntrance::Fade);
781            assert_eq!(
782                motion.thumb_hover_entrance(),
783                gpui_base::ScrollbarEntrance::SlideAndFade
784            );
785        });
786    }
787
788    #[test]
789    fn default_motion_tokens_form_a_coherent_semantic_scale() {
790        let theme = Theme::default();
791        let motion = theme.motion_tokens();
792
793        assert_eq!(motion.duration_instant, Duration::ZERO);
794        assert!(motion.duration_fast < motion.duration_normal);
795        assert!(motion.duration_normal < motion.duration_slow);
796        assert!(motion.distance_short.0 < motion.distance_medium.0);
797        assert_eq!(motion.easing_enter.sample(0.0), 0.0);
798        assert_eq!(motion.easing_enter.sample(1.0), 1.0);
799    }
800
801    fn assert_styled_projection(cx: &App) {
802        let theme = Theme::global(cx);
803        let base = gpui_base::Theme::global(cx);
804
805        assert_eq!(base.tokens, theme.semantic_tokens());
806        assert_eq!(base.scrollbar.mode(), theme.scrollbar_mode);
807        assert_eq!(
808            base.scrollbar.motion(),
809            scrollbar_motion(theme.scrollbar_mode)
810        );
811        assert_eq!(base.resizable.handle, Some(theme.border));
812        assert_eq!(base.resizable.active_handle, Some(theme.drag_border));
813    }
814
815    #[gpui::test]
816    fn default_component_palettes_match_base_light_and_dark_tokens(cx: &mut gpui::TestAppContext) {
817        fn assert_close(left: ColorTokens, right: ColorTokens) {
818            macro_rules! color {
819                ($field:ident) => {
820                    assert!(
821                        (left.$field.h - right.$field.h).abs() < 1e-6
822                            && (left.$field.s - right.$field.s).abs() < 1e-6
823                            && (left.$field.l - right.$field.l).abs() < 1e-6
824                            && (left.$field.a - right.$field.a).abs() < 1e-6,
825                        "{} differs: {:?} != {:?}",
826                        stringify!($field),
827                        left.$field,
828                        right.$field
829                    );
830                };
831            }
832            color!(background);
833            color!(foreground);
834            color!(surface);
835            color!(surface_foreground);
836            color!(primary);
837            color!(primary_foreground);
838            color!(secondary);
839            color!(secondary_foreground);
840            color!(muted);
841            color!(muted_foreground);
842            color!(accent);
843            color!(accent_foreground);
844            color!(destructive);
845            color!(destructive_foreground);
846            color!(border);
847            color!(input);
848            color!(ring);
849            color!(selection);
850        }
851
852        cx.update(crate::init);
853        cx.update(|cx| {
854            assert_close(Theme::global(cx).color_tokens(), ColorTokens::light());
855        });
856
857        cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx));
858        cx.update(|cx| {
859            assert_close(Theme::global(cx).color_tokens(), ColorTokens::dark());
860        });
861    }
862}