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