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::{App, Global, Hsla, IsZero as _, Pixels, SharedString, Window, WindowAppearance, px};
6pub use gpui_base::{
7    ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens, TextStyleToken,
8    TypographyTokens,
9};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::{
13    ops::{Deref, DerefMut},
14    rc::Rc,
15    sync::Arc,
16    time::Duration,
17};
18
19mod color;
20mod mono_font;
21mod motion;
22mod registry;
23mod schema;
24mod theme_color;
25
26pub use color::*;
27pub use motion::*;
28pub use registry::*;
29pub use schema::*;
30pub use theme_color::*;
31
32pub fn init(cx: &mut App) {
33    registry::init(cx);
34
35    // Ensure theme is loaded directly on startup for WASM compatibility
36    Theme::change(ThemeMode::Light, None, cx);
37    Theme::sync_scrollbar_appearance(cx);
38}
39
40pub trait ActiveTheme {
41    fn theme(&self) -> &Theme;
42}
43
44impl ActiveTheme for App {
45    #[inline(always)]
46    fn theme(&self) -> &Theme {
47        Theme::global(self)
48    }
49}
50
51fn default_true() -> bool {
52    true
53}
54
55/// The radius that rounds a shape as far as its own size allows, giving a
56/// circle or a pill. Any value past half the shorter side is clamped by the
57/// renderer, so this is simply "as round as it goes".
58const RADIUS_FULL: Pixels = px(9999.);
59
60/// How long the scrollbar stays visible after the last scroll, drag, or hover.
61const SCROLLBAR_IDLE: Duration = Duration::from_secs(2);
62/// How long the scrollbar takes to appear.
63const SCROLLBAR_ENTER: Duration = Duration::from_millis(300);
64/// How long the scrollbar takes to fade away once the idle hold expires.
65const SCROLLBAR_EXIT: Duration = Duration::from_millis(500);
66/// How long the thumb takes to reach its hovered or resting width.
67const SCROLLBAR_EXPAND: Duration = Duration::from_millis(300);
68
69/// The scrollbar motion this design system projects onto Base.
70///
71/// Scrolling and track hover reveal a scrollbar by fading it in place. In hover
72/// mode, pointing at the thumb slides it in from the nearest edge as it fades.
73fn scrollbar_motion(mode: ScrollbarMode) -> gpui_base::ScrollbarMotion {
74    gpui_base::ScrollbarMotion::default()
75        .with_idle(SCROLLBAR_IDLE)
76        .with_enter(SCROLLBAR_ENTER)
77        .with_exit(SCROLLBAR_EXIT)
78        .with_expand(SCROLLBAR_EXPAND)
79        .with_entrance(gpui_base::ScrollbarEntrance::Fade)
80        .with_thumb_hover_entrance(match mode {
81            ScrollbarMode::Scrolling | ScrollbarMode::Always => gpui_base::ScrollbarEntrance::Fade,
82            ScrollbarMode::Hover => gpui_base::ScrollbarEntrance::SlideAndFade,
83        })
84}
85
86/// The global theme configuration.
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
88pub struct Theme {
89    pub colors: ThemeColor,
90    /// Component-specific resolved tokens retained for legacy compatibility.
91    ///
92    /// New application-owned presentation should use [`Self::semantic_tokens`]
93    /// rather than extending this legacy surface.
94    #[serde(default)]
95    pub tokens: ThemeTokens,
96    pub highlight_theme: Arc<HighlightTheme>,
97    pub light_theme: Rc<ThemeConfig>,
98    pub dark_theme: Rc<ThemeConfig>,
99
100    pub mode: ThemeMode,
101    /// The font family for the application, default is `.SystemUIFont`.
102    pub font_family: SharedString,
103    /// The base font size for the application, default is 16px.
104    pub font_size: Pixels,
105    /// The monospace font family for the application.
106    ///
107    /// Defaults to:
108    ///
109    /// - macOS: `Menlo`
110    /// - Windows: `Consolas`
111    /// - Linux: `DejaVu Sans Mono`
112    ///
113    /// When that default is not installed, [`Theme::change`] swaps it for the
114    /// first installed alternative (`Monaco`, `Cascadia Mono`, `Noto Sans Mono`
115    /// and the like) and finally `.SystemUIFont`, so a missing font cannot
116    /// crash text layout. A family set explicitly is used as-is.
117    pub mono_font_family: SharedString,
118    /// The monospace font size for the application, default is 13px.
119    pub mono_font_size: Pixels,
120    /// Radius for the general elements.
121    pub radius: Pixels,
122    /// Radius for the large elements, e.g.: Dialog, Notification border radius.
123    pub radius_lg: Pixels,
124    pub shadow: bool,
125    /// Whether focused controls draw a ring outside their border, default true.
126    ///
127    /// The ring is painted outside the element, so any ancestor that clips its
128    /// content will cut it off. An application whose layout clips heavily can
129    /// turn it off here: focused controls then show only their tinted border,
130    /// which costs no space and cannot be clipped.
131    #[serde(default = "default_true")]
132    pub focus_ring: bool,
133    pub transparent: Hsla,
134    /// Show the scrollbar mode, default: Scrolling
135    #[serde(alias = "scrollbar_show")]
136    pub scrollbar_mode: ScrollbarMode,
137    /// The notification setting.
138    #[serde(skip)]
139    pub notification: NotificationSettings,
140    /// Tile grid size, default is 4px.
141    pub tile_grid_size: Pixels,
142    /// The shadow of the tile panel.
143    pub tile_shadow: bool,
144    /// The border radius of the tile panel, default is 0px.
145    pub tile_radius: Pixels,
146    /// The list settings.
147    pub list: ListSettings,
148    /// The sheet settings.
149    pub sheet: SheetSettings,
150    /// Semantic motion policy for styled components.
151    #[serde(skip)]
152    pub motion: MotionTokens,
153}
154
155impl Default for Theme {
156    fn default() -> Self {
157        Self::from(&ThemeColor::default())
158    }
159}
160
161impl Deref for Theme {
162    type Target = ThemeColor;
163
164    fn deref(&self) -> &Self::Target {
165        &self.colors
166    }
167}
168
169impl DerefMut for Theme {
170    fn deref_mut(&mut self) -> &mut Self::Target {
171        &mut self.colors
172    }
173}
174
175impl Global for Theme {}
176
177impl Theme {
178    /// Returns the global theme reference
179    #[inline(always)]
180    pub fn global(cx: &App) -> &Theme {
181        cx.global::<Theme>()
182    }
183
184    /// Returns the global theme mutable reference
185    ///
186    /// Changes to fields the Base layer mirrors — the radius, the colors, the
187    /// fonts — reach the scrollbar and resize handles only once
188    /// [`Theme::sync_base`] runs.
189    #[inline(always)]
190    pub fn global_mut(cx: &mut App) -> &mut Theme {
191        cx.global_mut::<Theme>()
192    }
193
194    /// Returns true if the theme is dark.
195    #[inline(always)]
196    pub fn is_dark(&self) -> bool {
197        self.mode.is_dark()
198    }
199
200    /// Returns the current theme name.
201    pub fn theme_name(&self) -> &SharedString {
202        if self.is_dark() {
203            &self.dark_theme.name
204        } else {
205            &self.light_theme.name
206        }
207    }
208
209    /// Sync the theme with the system appearance
210    pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
211        // Better use window.appearance() for avoid error on Linux.
212        // https://github.com/longbridge/gpui-kit/issues/104
213        let appearance = window
214            .as_ref()
215            .map(|window| window.appearance())
216            .unwrap_or_else(|| cx.window_appearance());
217
218        Self::change(appearance, window, cx);
219    }
220
221    /// Sync the Scrollbar showing behavior with the system
222    pub fn sync_scrollbar_appearance(cx: &mut App) {
223        let mode = if cx.should_auto_hide_scrollbars() {
224            ScrollbarMode::Scrolling
225        } else {
226            ScrollbarMode::Hover
227        };
228        Self::set_scrollbar_mode(mode, cx);
229    }
230
231    /// Changes the scrollbar display mode and synchronizes the Base projection.
232    pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
233        Theme::global_mut(cx).scrollbar_mode = mode;
234        let base_theme = gpui_base::Theme::global_mut(cx);
235        base_theme.scrollbar = base_theme
236            .scrollbar
237            .clone()
238            .with_mode(mode)
239            .with_motion(scrollbar_motion(mode));
240    }
241
242    /// Change the theme mode.
243    pub fn change(mode: impl Into<ThemeMode>, window: Option<&mut Window>, cx: &mut App) {
244        let mode = mode.into();
245        if !cx.has_global::<Theme>() {
246            let mut theme = Theme::default();
247            theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
248            theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
249            cx.set_global(theme);
250        }
251
252        {
253            let theme = cx.global_mut::<Theme>();
254            theme.mode = mode;
255            if mode.is_dark() {
256                theme.apply_config(&theme.dark_theme.clone());
257            } else {
258                theme.apply_config(&theme.light_theme.clone());
259            }
260        }
261        mono_font::resolve_default_mono_font(cx);
262        let theme = cx.global::<Theme>().clone();
263
264        let base_theme = theme.base_theme();
265        cx.set_global(base_theme);
266        crate::text::install_text_view_defaults(&theme, cx);
267
268        if let Some(window) = window {
269            window.refresh();
270        }
271    }
272
273    /// This theme projected onto the Base layer, which owns the scrollbar and
274    /// resize handles and reads the semantic tokens.
275    fn base_theme(&self) -> gpui_base::Theme {
276        gpui_base::Theme {
277            appearance: if self.mode.is_dark() {
278                gpui_base::ThemeAppearance::Dark
279            } else {
280                gpui_base::ThemeAppearance::Light
281            },
282            tokens: self.semantic_tokens(),
283            scrollbar: gpui_base::ScrollbarTheme::new()
284                .with_mode(self.scrollbar_mode)
285                .with_motion(scrollbar_motion(self.scrollbar_mode))
286                .with_styles(
287                    gpui_base::ScrollbarStyles::default()
288                        .track(|style| style.bg(self.scrollbar))
289                        .track_hover(|style| style.bg(self.scrollbar))
290                        .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
291                        .thumb(|style| style.bg(self.tokens.scrollbar_thumb).radius(self.radius))
292                        .thumb_hover(|style| {
293                            style
294                                .bg(self.tokens.scrollbar_thumb_hover)
295                                .radius(self.radius)
296                        })
297                        .thumb_active(|style| {
298                            style
299                                .bg(self.tokens.scrollbar_thumb_hover)
300                                .radius(self.radius)
301                        }),
302                ),
303            resizable: gpui_base::ResizableTheme {
304                handle: Some(self.border),
305                active_handle: Some(self.drag_border),
306            },
307        }
308    }
309
310    /// Push the current theme down to the Base layer.
311    ///
312    /// The Base layer holds its own copy of the theme — the semantic tokens
313    /// plus the scrollbar and resize-handle styles — because it paints those
314    /// without going through `gpui-component`. [`Theme::change`] refreshes that
315    /// copy, but writing to the theme's public fields directly does not, so a
316    /// scrollbar keeps painting with the radius and colors it was last given.
317    ///
318    /// Call this after mutating the theme through [`Theme::global_mut`]:
319    ///
320    /// ```ignore
321    /// Theme::global_mut(cx).radius = px(0.);
322    /// Theme::sync_base(cx);
323    /// ```
324    ///
325    /// It rebuilds the Base theme from scratch, so any style written straight
326    /// onto the Base global is replaced — the same thing [`Theme::change`]
327    /// does.
328    pub fn sync_base(cx: &mut App) {
329        let theme = Theme::global(cx).clone();
330        let base_theme = theme.base_theme();
331        cx.set_global(base_theme);
332        crate::text::install_text_view_defaults(&theme, cx);
333    }
334
335    /// Get the input background color.
336    ///
337    /// For dark, use a transparent color mixed with the input border: `cx.theme().input`,
338    /// otherwise use the `cx.theme().background` color.
339    #[inline]
340    pub fn input_background(&self) -> Hsla {
341        if self.is_dark() {
342            self.input.mix_oklab(self.transparent, 0.3)
343        } else {
344            self.background
345        }
346    }
347
348    /// Get the editor background color, if not set, use the input background color.
349    #[inline]
350    pub(crate) fn editor_background(&self) -> Hsla {
351        self.highlight_theme
352            .style
353            .editor_background
354            .unwrap_or_else(|| self.input_background())
355    }
356
357    /// Returns a snapshot of the semantic design tokens represented by this
358    /// theme. The snapshot is computed from the legacy public fields so direct
359    /// mutations of those fields are reflected immediately.
360    pub fn semantic_tokens(&self) -> SemanticThemeTokens {
361        SemanticThemeTokens {
362            colors: self.color_tokens(),
363            radius: self.radius_tokens(),
364            spacing: self.spacing_tokens(),
365            typography: self.typography_tokens(),
366            shadow: self.shadow_tokens(),
367        }
368    }
369
370    /// Returns the styled layer's semantic motion policy.
371    pub fn motion_tokens(&self) -> &MotionTokens {
372        &self.motion
373    }
374
375    pub fn color_tokens(&self) -> ColorTokens {
376        ColorTokens {
377            background: self.background,
378            foreground: self.foreground,
379            surface: self.popover,
380            surface_foreground: self.popover_foreground,
381            primary: self.primary,
382            primary_foreground: self.primary_foreground,
383            secondary: self.secondary,
384            secondary_foreground: self.secondary_foreground,
385            muted: self.muted,
386            muted_foreground: self.muted_foreground,
387            accent: self.accent,
388            accent_foreground: self.accent_foreground,
389            destructive: self.danger,
390            destructive_foreground: self.danger_foreground,
391            border: self.border,
392            input: self.input,
393            ring: self.ring,
394            selection: self.selection,
395        }
396    }
397
398    /// The radius of a shape that reads as a circle or a pill — an avatar, a
399    /// slider thumb, a badge dot, a pill tab.
400    ///
401    /// A theme whose [`Theme::radius`] is zero squares these off too, so one
402    /// setting governs the whole UI instead of leaving a handful of
403    /// permanently round elements behind. Use it in place of
404    /// [`gpui::Styled::rounded_full`], or reach for
405    /// [`crate::ThemeStyled::rounded_full_style`] when styling an element.
406    pub fn radius_full(&self) -> Pixels {
407        if self.radius.is_zero() {
408            px(0.)
409        } else {
410            RADIUS_FULL
411        }
412    }
413
414    /// Returns the next surface radius above the existing `xl` theme tier.
415    ///
416    /// Larger surface tiers derive from the same application-controlled base
417    /// radius, so adjusting or squaring the theme updates every tier together.
418    pub fn radius_2xl(&self) -> Pixels {
419        self.radius * 2.5
420    }
421
422    /// Returns the surface radius above [`Self::radius_2xl`].
423    pub fn radius_3xl(&self) -> Pixels {
424        self.radius * 3.
425    }
426
427    /// Returns the surface radius above [`Self::radius_3xl`].
428    pub fn radius_4xl(&self) -> Pixels {
429        self.radius * 3.5
430    }
431
432    pub fn radius_tokens(&self) -> RadiusTokens {
433        RadiusTokens {
434            none: px(0.),
435            sm: self.radius / 2.,
436            md: self.radius,
437            lg: self.radius_lg,
438            xl: self.radius * 2.,
439            full: self.radius_full(),
440        }
441    }
442
443    pub fn spacing_tokens(&self) -> SpacingTokens {
444        SpacingTokens::default()
445    }
446
447    pub fn typography_tokens(&self) -> TypographyTokens {
448        let mut tokens = TypographyTokens::default();
449        tokens.sans = self.font_family.clone();
450        tokens.mono = self.mono_font_family.clone();
451        tokens.md.size = self.font_size;
452        tokens.mono_md.size = self.mono_font_size;
453        tokens
454    }
455
456    pub fn shadow_tokens(&self) -> ShadowTokens {
457        if self.shadow {
458            ShadowTokens::elevations(self.transparent.alpha(0.18))
459        } else {
460            ShadowTokens::default()
461        }
462    }
463
464    /// Applies the subset of semantic tokens representable by the legacy
465    /// theme. Scale-only spacing and elevation details have no legacy storage;
466    /// legacy components therefore keep their existing behavior.
467    pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
468        let colors = tokens.colors;
469        self.background = colors.background;
470        self.foreground = colors.foreground;
471        self.popover = colors.surface;
472        self.popover_foreground = colors.surface_foreground;
473        self.primary = colors.primary;
474        self.primary_foreground = colors.primary_foreground;
475        self.secondary = colors.secondary;
476        self.secondary_foreground = colors.secondary_foreground;
477        self.muted = colors.muted;
478        self.muted_foreground = colors.muted_foreground;
479        self.accent = colors.accent;
480        self.accent_foreground = colors.accent_foreground;
481        self.danger = colors.destructive;
482        self.danger_foreground = colors.destructive_foreground;
483        self.border = colors.border;
484        self.input = colors.input;
485        self.ring = colors.ring;
486
487        self.tokens.background = colors.background.into();
488        self.tokens.popover = colors.surface.into();
489        self.tokens.primary = colors.primary.into();
490        self.tokens.secondary = colors.secondary.into();
491        self.tokens.muted = colors.muted.into();
492        self.tokens.accent = colors.accent.into();
493        self.tokens.danger = colors.destructive.into();
494
495        self.radius = tokens.radius.md;
496        self.radius_lg = tokens.radius.lg;
497        self.font_family = tokens.typography.sans.clone();
498        self.mono_font_family = tokens.typography.mono.clone();
499        self.font_size = tokens.typography.md.size;
500        self.mono_font_size = tokens.typography.mono_md.size;
501        self.shadow = !tokens.shadow.sm.is_empty()
502            || !tokens.shadow.md.is_empty()
503            || !tokens.shadow.lg.is_empty();
504    }
505
506    /// Resolves a standalone semantic configuration over the current legacy
507    /// theme without mutating either value.
508    pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
509        let mut tokens = self.semantic_tokens();
510        config.apply_to(&mut tokens);
511        tokens
512    }
513
514    /// Applies the legacy-representable part of a standalone semantic config
515    /// and returns the complete resolved snapshot for application-owned UI.
516    pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
517        let tokens = self.resolve_semantic_config(config);
518        self.apply_semantic_tokens(&tokens);
519        tokens
520    }
521
522    /// Parses and applies a standalone `{ "tokens": ... }` semantic theme file.
523    pub fn apply_semantic_config_str(
524        &mut self,
525        content: &str,
526    ) -> anyhow::Result<SemanticThemeTokens> {
527        let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
528        Ok(self.apply_semantic_config(&config.tokens))
529    }
530}
531
532#[cfg(test)]
533mod semantic_token_tests {
534    use gpui::{Hsla, IsZero as _, px};
535
536    use super::{RADIUS_FULL, Theme};
537
538    #[test]
539    fn semantic_colors_are_a_live_projection_of_legacy_fields() {
540        let mut theme = Theme::default();
541        let primary = Hsla::default().alpha(0.42);
542        theme.primary = primary;
543
544        assert_eq!(theme.color_tokens().primary, primary);
545        assert_eq!(theme.semantic_tokens().colors.primary, primary);
546    }
547
548    #[test]
549    fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
550        let mut theme = Theme::default();
551        let component_color = theme.button_primary;
552        let mut tokens = theme.semantic_tokens();
553        tokens.colors.primary = Hsla::default().alpha(0.25);
554        tokens.colors.destructive = Hsla::default().alpha(0.75);
555        tokens.radius.md = px(10.);
556
557        theme.apply_semantic_tokens(&tokens);
558
559        assert_eq!(theme.primary, tokens.colors.primary);
560        assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
561        assert_eq!(theme.danger, tokens.colors.destructive);
562        assert_eq!(theme.radius, px(10.));
563        assert_eq!(theme.button_primary, component_color);
564    }
565
566    #[test]
567    fn square_themes_square_off_pills_and_circles() {
568        let mut theme = Theme::default();
569        assert_eq!(theme.radius_full(), RADIUS_FULL);
570        assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
571
572        // An application asking for square corners gets them everywhere, not
573        // just on the elements whose radius happens to come from `radius`.
574        theme.radius = px(0.);
575        assert_eq!(theme.radius_full(), px(0.));
576        assert_eq!(theme.radius_tokens().full, px(0.));
577    }
578
579    #[test]
580    fn larger_surface_radii_follow_the_theme_radius() {
581        let mut theme = Theme::default();
582        assert!(theme.radius_tokens().xl < theme.radius_2xl());
583        assert!(theme.radius_2xl() < theme.radius_3xl());
584        assert!(theme.radius_3xl() < theme.radius_4xl());
585
586        theme.radius = px(10.);
587        assert_eq!(theme.radius_2xl(), px(25.));
588        assert_eq!(theme.radius_3xl(), px(30.));
589        assert_eq!(theme.radius_4xl(), px(35.));
590
591        theme.radius = px(0.);
592        assert_eq!(theme.radius_2xl(), px(0.));
593        assert_eq!(theme.radius_3xl(), px(0.));
594        assert_eq!(theme.radius_4xl(), px(0.));
595    }
596
597    #[test]
598    fn base_projection_carries_a_square_radius_to_the_scrollbar() {
599        let mut theme = Theme::default();
600        assert!(!theme.base_theme().tokens.radius.md.is_zero());
601
602        // The scrollbar paints from the Base layer's copy of the theme, so a
603        // square theme has to reach it or the thumb stays a pill.
604        theme.radius = px(0.);
605        assert!(theme.base_theme().tokens.radius.md.is_zero());
606    }
607
608    #[test]
609    fn disabled_legacy_shadows_project_to_empty_elevations() {
610        let mut theme = Theme::default();
611        theme.shadow = false;
612
613        let shadows = theme.shadow_tokens();
614        assert!(shadows.sm.is_empty());
615        assert!(shadows.md.is_empty());
616        assert!(shadows.lg.is_empty());
617    }
618}
619
620impl From<&ThemeColor> for Theme {
621    fn from(colors: &ThemeColor) -> Self {
622        Theme {
623            mode: ThemeMode::default(),
624            transparent: Hsla::transparent_black(),
625            font_family: ".SystemUIFont".into(),
626            font_size: px(16.),
627            mono_font_family: mono_font::default_mono_font_family(),
628            mono_font_size: px(13.),
629            radius: px(6.),
630            radius_lg: px(8.),
631            shadow: true,
632            focus_ring: true,
633            scrollbar_mode: ScrollbarMode::default(),
634            notification: NotificationSettings::default(),
635            tile_grid_size: px(8.),
636            tile_shadow: true,
637            tile_radius: px(0.),
638            list: ListSettings::default(),
639            colors: *colors,
640            tokens: ThemeTokens::from(colors),
641            light_theme: Rc::new(ThemeConfig::default()),
642            dark_theme: Rc::new(ThemeConfig::default()),
643            highlight_theme: HighlightTheme::default_light(),
644            sheet: SheetSettings::default(),
645            motion: MotionTokens::default(),
646        }
647    }
648}
649
650#[derive(
651    Debug,
652    Clone,
653    Copy,
654    Default,
655    PartialEq,
656    PartialOrd,
657    Eq,
658    Ord,
659    Hash,
660    Serialize,
661    Deserialize,
662    JsonSchema,
663)]
664#[serde(rename_all = "snake_case")]
665pub enum ThemeMode {
666    #[default]
667    Light,
668    Dark,
669}
670
671impl ThemeMode {
672    #[inline(always)]
673    pub fn is_dark(&self) -> bool {
674        matches!(self, Self::Dark)
675    }
676
677    /// Return lower_case theme name: `light`, `dark`.
678    pub fn name(&self) -> &'static str {
679        match self {
680            ThemeMode::Light => "light",
681            ThemeMode::Dark => "dark",
682        }
683    }
684}
685
686impl From<WindowAppearance> for ThemeMode {
687    fn from(appearance: WindowAppearance) -> Self {
688        match appearance {
689            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
690            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
691        }
692    }
693}
694
695#[cfg(test)]
696mod base_theme_projection_tests {
697    use super::*;
698    use gpui::TestAppContext;
699
700    #[gpui::test]
701    fn base_theme_tracks_initialization_and_mode_changes(cx: &mut TestAppContext) {
702        cx.update(|cx| {
703            init(cx);
704            assert_styled_projection(cx);
705
706            Theme::change(ThemeMode::Dark, None, cx);
707            assert_styled_projection(cx);
708
709            Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
710            assert_eq!(Theme::global(cx).scrollbar_mode, ScrollbarMode::Always);
711            assert_eq!(
712                gpui_base::Theme::global(cx).scrollbar.mode(),
713                gpui_base::ScrollbarMode::Always
714            );
715            assert_styled_projection(cx);
716        });
717    }
718
719    #[gpui::test]
720    fn scrollbar_motion_is_owned_here_and_projected_onto_base(cx: &mut TestAppContext) {
721        cx.update(|cx| {
722            init(cx);
723
724            // Base itself ships none of this timing.
725            let bare = gpui_base::ScrollbarMotion::default();
726            assert_eq!(bare.enter(), Duration::ZERO);
727            assert_eq!(bare.exit(), Duration::ZERO);
728            assert_eq!(bare.expand(), Duration::ZERO);
729
730            Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx);
731            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
732            assert_eq!(motion.idle(), SCROLLBAR_IDLE);
733            assert_eq!(motion.enter(), SCROLLBAR_ENTER);
734            assert_eq!(motion.exit(), SCROLLBAR_EXIT);
735            assert_eq!(motion.expand(), SCROLLBAR_EXPAND);
736            assert_eq!(
737                motion.entrance(),
738                gpui_base::ScrollbarEntrance::Fade,
739                "scroll-revealed scrollbars fade in without sliding"
740            );
741
742            Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);
743            let motion = gpui_base::Theme::global(cx).scrollbar.motion();
744            assert_eq!(motion.entrance(), gpui_base::ScrollbarEntrance::Fade);
745            assert_eq!(
746                motion.thumb_hover_entrance(),
747                gpui_base::ScrollbarEntrance::SlideAndFade
748            );
749        });
750    }
751
752    #[test]
753    fn default_motion_tokens_form_a_coherent_semantic_scale() {
754        let theme = Theme::default();
755        let motion = theme.motion_tokens();
756
757        assert_eq!(motion.duration_instant, Duration::ZERO);
758        assert!(motion.duration_fast < motion.duration_normal);
759        assert!(motion.duration_normal < motion.duration_slow);
760        assert!(motion.distance_short.0 < motion.distance_medium.0);
761        assert_eq!(motion.easing_enter.sample(0.0), 0.0);
762        assert_eq!(motion.easing_enter.sample(1.0), 1.0);
763    }
764
765    fn assert_styled_projection(cx: &App) {
766        let theme = Theme::global(cx);
767        let base = gpui_base::Theme::global(cx);
768
769        assert_eq!(base.tokens, theme.semantic_tokens());
770        assert_eq!(base.scrollbar.mode(), theme.scrollbar_mode);
771        assert_eq!(
772            base.scrollbar.motion(),
773            scrollbar_motion(theme.scrollbar_mode)
774        );
775        assert_eq!(base.resizable.handle, Some(theme.border));
776        assert_eq!(base.resizable.active_handle, Some(theme.drag_border));
777    }
778
779    #[gpui::test]
780    fn default_component_palettes_match_base_light_and_dark_tokens(cx: &mut gpui::TestAppContext) {
781        fn assert_close(left: ColorTokens, right: ColorTokens) {
782            macro_rules! color {
783                ($field:ident) => {
784                    assert!(
785                        (left.$field.h - right.$field.h).abs() < 1e-6
786                            && (left.$field.s - right.$field.s).abs() < 1e-6
787                            && (left.$field.l - right.$field.l).abs() < 1e-6
788                            && (left.$field.a - right.$field.a).abs() < 1e-6,
789                        "{} differs: {:?} != {:?}",
790                        stringify!($field),
791                        left.$field,
792                        right.$field
793                    );
794                };
795            }
796            color!(background);
797            color!(foreground);
798            color!(surface);
799            color!(surface_foreground);
800            color!(primary);
801            color!(primary_foreground);
802            color!(secondary);
803            color!(secondary_foreground);
804            color!(muted);
805            color!(muted_foreground);
806            color!(accent);
807            color!(accent_foreground);
808            color!(destructive);
809            color!(destructive_foreground);
810            color!(border);
811            color!(input);
812            color!(ring);
813            color!(selection);
814        }
815
816        cx.update(crate::init);
817        cx.update(|cx| {
818            assert_close(Theme::global(cx).color_tokens(), ColorTokens::light());
819        });
820
821        cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx));
822        cx.update(|cx| {
823            assert_close(Theme::global(cx).color_tokens(), ColorTokens::dark());
824        });
825    }
826}