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 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
59const RADIUS_FULL: Pixels = px(9999.);
63
64const SCROLLBAR_IDLE: Duration = Duration::from_secs(2);
66const SCROLLBAR_ENTER: Duration = Duration::from_millis(300);
68const SCROLLBAR_EXIT: Duration = Duration::from_millis(500);
70const SCROLLBAR_EXPAND: Duration = Duration::from_millis(300);
72
73const MOBILE_SCROLLBAR_THUMB_WIDTH: Pixels = px(3.);
77const MOBILE_SCROLLBAR_THUMB_INSET: Pixels = px(2.);
80const SCROLLBAR_THUMB_HOVER_WIDTH: Pixels = px(6.);
83const SCROLLBAR_THUMB_ACTIVE_WIDTH: Pixels = px(8.);
85const SCROLLBAR_THUMB_INSET: Pixels = px(4.);
87
88fn 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
107pub struct Theme {
108 pub colors: ThemeColor,
109 #[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 pub font_family: SharedString,
127 pub font_size: Pixels,
129 pub mono_font_family: SharedString,
142 pub mono_font_size: Pixels,
144 pub radius: Pixels,
146 pub radius_lg: Pixels,
148 pub shadow: bool,
149 #[serde(default = "default_true")]
156 pub focus_ring: bool,
157 pub transparent: Hsla,
158 #[serde(alias = "scrollbar_show")]
160 pub scrollbar_mode: ScrollbarMode,
161 #[serde(skip)]
163 pub notification: NotificationSettings,
164 pub list: ListSettings,
166 pub sheet: SheetSettings,
168 #[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 #[inline(always)]
198 pub fn global(cx: &App) -> &Theme {
199 cx.global::<Theme>()
200 }
201
202 #[inline(always)]
210 pub fn global_mut(cx: &mut App) -> &mut Theme {
211 cx.global_mut::<Theme>()
212 }
213
214 pub fn update<R>(cx: &mut App, edit: impl FnOnce(&mut Theme) -> R) -> R {
248 Self::edit(cx, false, edit)
249 }
250
251 fn edit<R>(cx: &mut App, reload_mode: bool, edit: impl FnOnce(&mut Theme) -> R) -> R {
258 let theme = Theme::global_mut(cx);
259 let colors_before = theme.colors;
260 let tokens_before = theme.tokens;
261 let mode_before = theme.mode;
262 let light_before = theme.light_theme.clone();
263 let dark_before = theme.dark_theme.clone();
264 let fonts_before = (theme.font_family.clone(), theme.mono_font_family.clone());
265
266 let result = edit(theme);
267
268 theme
269 .tokens
270 .reconcile(&mut theme.colors, &colors_before, &tokens_before);
271 let mode_changed = theme.mode != mode_before;
272 let (config, config_before) = if theme.mode.is_dark() {
273 (&theme.dark_theme, &dark_before)
274 } else {
275 (&theme.light_theme, &light_before)
276 };
277 let installed_by_edit = mode_changed && !Rc::ptr_eq(config, config_before);
282 if (mode_changed || reload_mode) && !installed_by_edit {
283 let config = config.clone();
284 theme.apply_config(&config);
285 }
286 let fonts_changed =
287 (&theme.font_family, &theme.mono_font_family) != (&fonts_before.0, &fonts_before.1);
288 if mode_changed || reload_mode || fonts_changed {
289 system_font::resolve_default_font(cx);
290 mono_font::resolve_default_mono_font(cx);
291 }
292 Self::sync_base(cx);
293 cx.refresh_windows();
294 result
295 }
296
297 #[inline(always)]
299 pub fn is_dark(&self) -> bool {
300 self.mode.is_dark()
301 }
302
303 pub fn theme_name(&self) -> &SharedString {
305 if self.is_dark() {
306 &self.dark_theme.name
307 } else {
308 &self.light_theme.name
309 }
310 }
311
312 pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
314 let appearance = window
317 .as_ref()
318 .map(|window| window.appearance())
319 .unwrap_or_else(|| cx.window_appearance());
320
321 Self::change(appearance, window, cx);
322 }
323
324 pub fn sync_scrollbar_appearance(cx: &mut App) {
326 let mode = if cx.should_auto_hide_scrollbars() {
327 ScrollbarMode::Scrolling
328 } else {
329 ScrollbarMode::Hover
330 };
331 Self::set_scrollbar_mode(mode, cx);
332 }
333
334 pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
337 Self::update(cx, |theme| theme.scrollbar_mode = mode);
338 }
339
340 pub fn change(mode: impl Into<ThemeMode>, _window: Option<&mut Window>, cx: &mut App) {
349 let mode = mode.into();
350 if !cx.has_global::<Theme>() {
351 let mut theme = Theme::default();
352 theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
353 theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
354 cx.set_global(theme);
355 }
356
357 Self::edit(cx, true, |theme| theme.mode = mode);
358 }
359
360 fn base_theme(&self) -> gpui_base::Theme {
363 gpui_base::Theme {
364 appearance: if self.mode.is_dark() {
365 gpui_base::ThemeAppearance::Dark
366 } else {
367 gpui_base::ThemeAppearance::Light
368 },
369 tokens: self.semantic_tokens(),
370 scrollbar: gpui_base::ScrollbarTheme::new()
371 .with_mode(self.scrollbar_mode)
372 .with_motion(scrollbar_motion(self.scrollbar_mode))
373 .with_styles(
374 gpui_base::ScrollbarStyles::default()
375 .track(|style| style.bg(self.scrollbar))
376 .track_hover(|style| style.bg(self.scrollbar))
377 .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
378 .thumb(|style| {
379 style
380 .bg(self.tokens.scrollbar_thumb)
381 .radius(self.radius)
382 .when(gpui_base::is_mobile(), |style| {
383 style
384 .width(MOBILE_SCROLLBAR_THUMB_WIDTH)
385 .inset(MOBILE_SCROLLBAR_THUMB_INSET)
386 .radius(RADIUS_FULL)
387 })
388 })
389 .thumb_hover(|style| {
390 style
391 .bg(self.tokens.scrollbar_thumb_hover)
392 .radius(self.radius)
393 .when(gpui_base::is_mobile(), |style| {
394 style
395 .width(SCROLLBAR_THUMB_HOVER_WIDTH)
396 .inset(SCROLLBAR_THUMB_INSET)
397 })
398 })
399 .thumb_active(|style| {
400 style
401 .bg(self.tokens.scrollbar_thumb_hover)
402 .radius(self.radius)
403 .when(gpui_base::is_mobile(), |style| {
404 style
405 .width(SCROLLBAR_THUMB_ACTIVE_WIDTH)
406 .inset(SCROLLBAR_THUMB_INSET)
407 })
408 }),
409 ),
410 resizable: gpui_base::ResizableTheme {
411 handle: Some(self.border),
412 active_handle: Some(self.drag_border),
413 },
414 }
415 }
416
417 pub fn sync_base(cx: &mut App) {
432 let theme = Theme::global(cx).clone();
433 let base_theme = theme.base_theme();
434 cx.set_global(base_theme);
435 crate::text::install_text_view_defaults(&theme, cx);
436 }
437
438 #[inline]
443 pub fn input_background(&self) -> Hsla {
444 if self.is_dark() {
445 self.input.mix_oklab(self.transparent, 0.3)
446 } else {
447 self.background
448 }
449 }
450
451 #[inline]
453 pub(crate) fn editor_background(&self) -> Hsla {
454 self.highlight_theme
455 .style
456 .editor_background
457 .unwrap_or_else(|| self.input_background())
458 }
459
460 pub fn semantic_tokens(&self) -> SemanticThemeTokens {
464 SemanticThemeTokens {
465 colors: self.color_tokens(),
466 radius: self.radius_tokens(),
467 spacing: self.spacing_tokens(),
468 typography: self.typography_tokens(),
469 shadow: self.shadow_tokens(),
470 }
471 }
472
473 pub fn motion_tokens(&self) -> &MotionTokens {
475 &self.motion
476 }
477
478 pub fn color_tokens(&self) -> ColorTokens {
479 ColorTokens {
480 background: self.background,
481 foreground: self.foreground,
482 surface: self.popover,
483 surface_foreground: self.popover_foreground,
484 primary: self.primary,
485 primary_foreground: self.primary_foreground,
486 secondary: self.secondary,
487 secondary_foreground: self.secondary_foreground,
488 muted: self.muted,
489 muted_foreground: self.muted_foreground,
490 accent: self.accent,
491 accent_foreground: self.accent_foreground,
492 destructive: self.danger,
493 destructive_foreground: self.danger_foreground,
494 border: self.border,
495 input: self.input,
496 ring: self.ring,
497 selection: self.selection,
498 }
499 }
500
501 pub fn radius_full(&self) -> Pixels {
510 if self.radius.is_zero() {
511 px(0.)
512 } else {
513 RADIUS_FULL
514 }
515 }
516
517 pub fn radius_2xl(&self) -> Pixels {
522 self.radius * 2.5
523 }
524
525 pub fn radius_3xl(&self) -> Pixels {
527 self.radius * 3.
528 }
529
530 pub fn radius_4xl(&self) -> Pixels {
532 self.radius * 3.5
533 }
534
535 pub fn radius_tokens(&self) -> RadiusTokens {
536 RadiusTokens {
537 none: px(0.),
538 sm: self.radius / 2.,
539 md: self.radius,
540 lg: self.radius_lg,
541 xl: self.radius * 2.,
542 full: self.radius_full(),
543 }
544 }
545
546 pub fn spacing_tokens(&self) -> SpacingTokens {
547 SpacingTokens::default()
548 }
549
550 pub fn typography_tokens(&self) -> TypographyTokens {
551 let mut tokens = TypographyTokens::default();
552 tokens.sans = self.font_family.clone();
553 tokens.mono = self.mono_font_family.clone();
554 tokens.md.size = self.font_size;
555 tokens.mono_md.size = self.mono_font_size;
556 tokens
557 }
558
559 pub fn shadow_tokens(&self) -> ShadowTokens {
560 if self.shadow {
561 ShadowTokens::elevations(self.transparent.alpha(0.18))
562 } else {
563 ShadowTokens::default()
564 }
565 }
566
567 pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
571 let colors = tokens.colors;
572 self.background = colors.background;
573 self.foreground = colors.foreground;
574 self.popover = colors.surface;
575 self.popover_foreground = colors.surface_foreground;
576 self.primary = colors.primary;
577 self.primary_foreground = colors.primary_foreground;
578 self.secondary = colors.secondary;
579 self.secondary_foreground = colors.secondary_foreground;
580 self.muted = colors.muted;
581 self.muted_foreground = colors.muted_foreground;
582 self.accent = colors.accent;
583 self.accent_foreground = colors.accent_foreground;
584 self.danger = colors.destructive;
585 self.danger_foreground = colors.destructive_foreground;
586 self.border = colors.border;
587 self.input = colors.input;
588 self.ring = colors.ring;
589
590 self.tokens.background = colors.background.into();
591 self.tokens.popover = colors.surface.into();
592 self.tokens.primary = colors.primary.into();
593 self.tokens.secondary = colors.secondary.into();
594 self.tokens.muted = colors.muted.into();
595 self.tokens.accent = colors.accent.into();
596 self.tokens.danger = colors.destructive.into();
597
598 self.radius = tokens.radius.md;
599 self.radius_lg = tokens.radius.lg;
600 self.font_family = tokens.typography.sans.clone();
601 self.mono_font_family = tokens.typography.mono.clone();
602 self.font_size = tokens.typography.md.size;
603 self.mono_font_size = tokens.typography.mono_md.size;
604 self.shadow = !tokens.shadow.sm.is_empty()
605 || !tokens.shadow.md.is_empty()
606 || !tokens.shadow.lg.is_empty();
607 }
608
609 pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
612 let mut tokens = self.semantic_tokens();
613 config.apply_to(&mut tokens);
614 tokens
615 }
616
617 pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
620 let tokens = self.resolve_semantic_config(config);
621 self.apply_semantic_tokens(&tokens);
622 tokens
623 }
624
625 pub fn apply_semantic_config_str(
627 &mut self,
628 content: &str,
629 ) -> anyhow::Result<SemanticThemeTokens> {
630 let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
631 Ok(self.apply_semantic_config(&config.tokens))
632 }
633}
634
635#[cfg(test)]
636mod semantic_token_tests {
637 use gpui::{Hsla, IsZero as _, px};
638
639 use super::{RADIUS_FULL, Theme};
640
641 #[test]
642 fn semantic_colors_are_a_live_projection_of_legacy_fields() {
643 let mut theme = Theme::default();
644 let primary = Hsla::default().alpha(0.42);
645 theme.primary = primary;
646
647 assert_eq!(theme.color_tokens().primary, primary);
648 assert_eq!(theme.semantic_tokens().colors.primary, primary);
649 }
650
651 #[test]
652 fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
653 let mut theme = Theme::default();
654 let component_color = theme.button_primary;
655 let mut tokens = theme.semantic_tokens();
656 tokens.colors.primary = Hsla::default().alpha(0.25);
657 tokens.colors.destructive = Hsla::default().alpha(0.75);
658 tokens.radius.md = px(10.);
659
660 theme.apply_semantic_tokens(&tokens);
661
662 assert_eq!(theme.primary, tokens.colors.primary);
663 assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
664 assert_eq!(theme.danger, tokens.colors.destructive);
665 assert_eq!(theme.radius, px(10.));
666 assert_eq!(theme.button_primary, component_color);
667 }
668
669 #[test]
670 fn square_themes_square_off_pills_and_circles() {
671 let mut theme = Theme::default();
672 assert_eq!(theme.radius_full(), RADIUS_FULL);
673 assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
674
675 theme.radius = px(0.);
678 assert_eq!(theme.radius_full(), px(0.));
679 assert_eq!(theme.radius_tokens().full, px(0.));
680 }
681
682 #[test]
683 fn larger_surface_radii_follow_the_theme_radius() {
684 let mut theme = Theme::default();
685 assert!(theme.radius_tokens().xl < theme.radius_2xl());
686 assert!(theme.radius_2xl() < theme.radius_3xl());
687 assert!(theme.radius_3xl() < theme.radius_4xl());
688
689 theme.radius = px(10.);
690 assert_eq!(theme.radius_2xl(), px(25.));
691 assert_eq!(theme.radius_3xl(), px(30.));
692 assert_eq!(theme.radius_4xl(), px(35.));
693
694 theme.radius = px(0.);
695 assert_eq!(theme.radius_2xl(), px(0.));
696 assert_eq!(theme.radius_3xl(), px(0.));
697 assert_eq!(theme.radius_4xl(), px(0.));
698 }
699
700 #[test]
701 fn base_projection_carries_a_square_radius_to_the_scrollbar() {
702 let mut theme = Theme::default();
703 assert!(!theme.base_theme().tokens.radius.md.is_zero());
704
705 theme.radius = px(0.);
708 assert!(theme.base_theme().tokens.radius.md.is_zero());
709 }
710
711 #[test]
712 fn disabled_legacy_shadows_project_to_empty_elevations() {
713 let mut theme = Theme::default();
714 theme.shadow = false;
715
716 let shadows = theme.shadow_tokens();
717 assert!(shadows.sm.is_empty());
718 assert!(shadows.md.is_empty());
719 assert!(shadows.lg.is_empty());
720 }
721}
722
723impl From<&ThemeColor> for Theme {
724 fn from(colors: &ThemeColor) -> Self {
725 Theme {
726 mode: ThemeMode::default(),
727 transparent: Hsla::transparent_black(),
728 font_family: ".SystemUIFont".into(),
729 font_size: px(16.),
730 mono_font_family: mono_font::default_mono_font_family(),
731 mono_font_size: px(13.),
732 radius: px(6.),
733 radius_lg: px(8.),
734 shadow: true,
735 focus_ring: true,
736 scrollbar_mode: ScrollbarMode::default(),
737 notification: NotificationSettings::default(),
738 list: ListSettings::default(),
739 colors: *colors,
740 tokens: ThemeTokens::from(colors),
741 light_theme: Rc::new(ThemeConfig::default()),
742 dark_theme: Rc::new(ThemeConfig::default()),
743 highlight_theme: HighlightTheme::default_light(),
744 sheet: SheetSettings::default(),
745 motion: MotionTokens::default(),
746 }
747 }
748}
749
750#[derive(
751 Debug,
752 Clone,
753 Copy,
754 Default,
755 PartialEq,
756 PartialOrd,
757 Eq,
758 Ord,
759 Hash,
760 Serialize,
761 Deserialize,
762 JsonSchema,
763)]
764#[serde(rename_all = "snake_case")]
765pub enum ThemeMode {
766 #[default]
767 Light,
768 Dark,
769}
770
771impl ThemeMode {
772 #[inline(always)]
773 pub fn is_dark(&self) -> bool {
774 matches!(self, Self::Dark)
775 }
776
777 pub fn name(&self) -> &'static str {
779 match self {
780 ThemeMode::Light => "light",
781 ThemeMode::Dark => "dark",
782 }
783 }
784}
785
786impl From<WindowAppearance> for ThemeMode {
787 fn from(appearance: WindowAppearance) -> Self {
788 match appearance {
789 WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
790 WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
791 }
792 }
793}
794
795#[cfg(test)]
796mod update_tests {
797 use super::*;
798 use gpui::{TestAppContext, linear_color_stop, linear_gradient};
799
800 fn gradient(from: Hsla, to: Hsla) -> ThemeToken {
801 ThemeToken::new(
802 from,
803 linear_gradient(135., linear_color_stop(from, 0.), linear_color_stop(to, 1.)),
804 )
805 }
806
807 #[gpui::test]
810 fn editing_colors_updates_the_tokens_and_the_base_projection(cx: &mut TestAppContext) {
811 cx.update(|cx| {
812 init(cx);
813 let sidebar = gpui::rgb(0x123456).into();
814 let primary = gpui::rgb(0xabcdef).into();
815
816 Theme::update(cx, |theme| {
817 theme.sidebar = sidebar;
818 theme.colors.primary = primary;
819 theme.radius = px(0.);
820 });
821
822 let theme = Theme::global(cx);
823 assert_eq!(theme.tokens.sidebar.color, sidebar);
824 assert_eq!(theme.tokens.sidebar.background, sidebar.into());
825 assert_eq!(theme.tokens.primary.color, primary);
826 assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, primary);
827 assert!(gpui_base::Theme::global(cx).tokens.radius.md.is_zero());
828 });
829 }
830
831 #[gpui::test]
834 fn replacing_the_palette_rewrites_every_token(cx: &mut TestAppContext) {
835 cx.update(|cx| {
836 init(cx);
837 let palette = *ThemeColor::dark();
838
839 Theme::update(cx, |theme| theme.colors = palette);
840
841 assert_eq!(Theme::global(cx).tokens, ThemeTokens::from(palette));
842 });
843 }
844
845 #[gpui::test]
848 fn a_gradient_survives_until_its_own_color_is_edited(cx: &mut TestAppContext) {
849 cx.update(|cx| {
850 init(cx);
851 let from = gpui::rgb(0x4f46e5).into();
852 let to = gpui::rgb(0x06b6d4).into();
853 let token = gradient(from, to);
854 Theme::update(cx, |theme| theme.tokens.primary = token);
855 assert_eq!(Theme::global(cx).primary, from);
858
859 Theme::update(cx, |theme| theme.secondary = gpui::rgb(0x222222).into());
860 assert_eq!(Theme::global(cx).tokens.primary, token);
861
862 let solid = gpui::rgb(0x999999).into();
863 Theme::update(cx, |theme| theme.primary = solid);
864 assert_eq!(Theme::global(cx).tokens.primary, solid.into());
865 });
866 }
867
868 #[gpui::test]
871 fn setting_the_mode_loads_that_modes_theme(cx: &mut TestAppContext) {
872 cx.update(|cx| {
873 init(cx);
874 let light_background = Theme::global(cx).background;
875
876 Theme::update(cx, |theme| theme.mode = ThemeMode::Dark);
877
878 let theme = Theme::global(cx);
879 assert!(theme.is_dark());
880 assert_ne!(theme.background, light_background);
881 assert_eq!(theme.tokens.background.color, theme.background);
882 assert_eq!(
883 gpui_base::Theme::global(cx).appearance,
884 gpui_base::ThemeAppearance::Dark
885 );
886 assert_eq!(
887 gpui_base::Theme::global(cx).tokens.colors.background,
888 theme.background
889 );
890 });
891 }
892
893 #[gpui::test]
897 fn applying_a_config_keeps_its_gradients(cx: &mut TestAppContext) {
898 cx.update(|cx| {
899 init(cx);
900 let config: ThemeConfig = serde_json::from_value(serde_json::json!({
901 "name": "Gradient",
902 "mode": "light",
903 "colors": {
904 "primary": "#4F46E5",
905 "primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)"
906 }
907 }))
908 .unwrap();
909 let config = Rc::new(config);
910
911 Theme::update(cx, |theme| theme.apply_config(&config));
912
913 let theme = Theme::global(cx);
914 assert_eq!(theme.tokens.primary.color, theme.primary);
915 assert_ne!(
916 theme.tokens.primary.background,
917 theme.primary.into(),
918 "the gradient must survive the reconcile"
919 );
920 });
921 }
922
923 #[gpui::test]
927 fn edits_after_applying_a_config_of_the_other_mode_survive(cx: &mut TestAppContext) {
928 cx.update(|cx| {
929 init(cx);
930 let config: ThemeConfig = serde_json::from_value(serde_json::json!({
931 "name": "Rounded Dark",
932 "mode": "dark",
933 "radius": 12,
934 "colors": { "primary": "#4F46E5" }
935 }))
936 .unwrap();
937 let config = Rc::new(config);
938 assert!(!Theme::global(cx).is_dark());
939
940 let red = gpui::red();
941 Theme::update(cx, |theme| {
942 theme.apply_config(&config);
943 theme.radius = px(0.);
944 theme.colors.primary = red;
945 });
946
947 let theme = Theme::global(cx);
948 assert!(theme.is_dark());
949 assert!(Rc::ptr_eq(&theme.dark_theme, &config));
950 assert_eq!(theme.radius, px(0.), "the file's radius must not reload");
951 assert_eq!(theme.primary, red);
952 assert_eq!(theme.tokens.primary, red.into());
953 assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, red);
954 });
955 }
956
957 #[gpui::test]
958 fn update_returns_the_closure_result(cx: &mut TestAppContext) {
959 cx.update(|cx| {
960 init(cx);
961 let radius = Theme::update(cx, |theme| {
962 theme.radius = px(6.);
963 theme.radius
964 });
965 assert_eq!(radius, px(6.));
966 });
967 }
968}
969
970#[cfg(test)]
971mod base_theme_projection_tests {
972 use super::*;
973 use gpui::TestAppContext;
974
975 #[gpui::test]
976 fn base_theme_tracks_initialization_and_mode_changes(cx: &mut TestAppContext) {
977 cx.update(|cx| {
978 init(cx);
979 assert_styled_projection(cx);
980
981 Theme::change(ThemeMode::Dark, None, cx);
982 assert_styled_projection(cx);
983
984 Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
985 assert_eq!(Theme::global(cx).scrollbar_mode, ScrollbarMode::Always);
986 assert_eq!(
987 gpui_base::Theme::global(cx).scrollbar.mode(),
988 gpui_base::ScrollbarMode::Always
989 );
990 assert_styled_projection(cx);
991 });
992 }
993
994 #[gpui::test]
995 fn scrollbar_motion_is_owned_here_and_projected_onto_base(cx: &mut TestAppContext) {
996 cx.update(|cx| {
997 init(cx);
998
999 let bare = gpui_base::ScrollbarMotion::default();
1001 assert_eq!(bare.enter(), Duration::ZERO);
1002 assert_eq!(bare.exit(), Duration::ZERO);
1003 assert_eq!(bare.expand(), Duration::ZERO);
1004
1005 Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx);
1006 let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1007 assert_eq!(motion.idle(), SCROLLBAR_IDLE);
1008 assert_eq!(motion.enter(), SCROLLBAR_ENTER);
1009 assert_eq!(motion.exit(), SCROLLBAR_EXIT);
1010 assert_eq!(motion.expand(), SCROLLBAR_EXPAND);
1011 assert_eq!(
1012 motion.entrance(),
1013 gpui_base::ScrollbarEntrance::Fade,
1014 "scroll-revealed scrollbars fade in without sliding"
1015 );
1016
1017 Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);
1018 let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1019 assert_eq!(motion.entrance(), gpui_base::ScrollbarEntrance::Fade);
1020 assert_eq!(
1021 motion.thumb_hover_entrance(),
1022 gpui_base::ScrollbarEntrance::SlideAndFade
1023 );
1024 });
1025 }
1026
1027 #[test]
1028 fn default_motion_tokens_form_a_coherent_semantic_scale() {
1029 let theme = Theme::default();
1030 let motion = theme.motion_tokens();
1031
1032 assert_eq!(motion.duration_instant, Duration::ZERO);
1033 assert!(motion.duration_fast < motion.duration_normal);
1034 assert!(motion.duration_normal < motion.duration_slow);
1035 assert!(motion.distance_short.0 < motion.distance_medium.0);
1036 assert_eq!(motion.easing_enter.sample(0.0), 0.0);
1037 assert_eq!(motion.easing_enter.sample(1.0), 1.0);
1038 }
1039
1040 fn assert_styled_projection(cx: &App) {
1041 let theme = Theme::global(cx);
1042 let base = gpui_base::Theme::global(cx);
1043
1044 assert_eq!(base.tokens, theme.semantic_tokens());
1045 assert_eq!(base.scrollbar.mode(), theme.scrollbar_mode);
1046 assert_eq!(
1047 base.scrollbar.motion(),
1048 scrollbar_motion(theme.scrollbar_mode)
1049 );
1050 assert_eq!(base.resizable.handle, Some(theme.border));
1051 assert_eq!(base.resizable.active_handle, Some(theme.drag_border));
1052 }
1053
1054 #[gpui::test]
1055 fn default_component_palettes_match_base_light_and_dark_tokens(cx: &mut gpui::TestAppContext) {
1056 fn assert_close(left: ColorTokens, right: ColorTokens) {
1057 macro_rules! color {
1058 ($field:ident) => {
1059 assert!(
1060 (left.$field.h - right.$field.h).abs() < 1e-6
1061 && (left.$field.s - right.$field.s).abs() < 1e-6
1062 && (left.$field.l - right.$field.l).abs() < 1e-6
1063 && (left.$field.a - right.$field.a).abs() < 1e-6,
1064 "{} differs: {:?} != {:?}",
1065 stringify!($field),
1066 left.$field,
1067 right.$field
1068 );
1069 };
1070 }
1071 color!(background);
1072 color!(foreground);
1073 color!(surface);
1074 color!(surface_foreground);
1075 color!(primary);
1076 color!(primary_foreground);
1077 color!(secondary);
1078 color!(secondary_foreground);
1079 color!(muted);
1080 color!(muted_foreground);
1081 color!(accent);
1082 color!(accent_foreground);
1083 color!(destructive);
1084 color!(destructive_foreground);
1085 color!(border);
1086 color!(input);
1087 color!(ring);
1088 color!(selection);
1089 }
1090
1091 cx.update(crate::init);
1092 cx.update(|cx| {
1093 assert_close(Theme::global(cx).color_tokens(), ColorTokens::light());
1094 });
1095
1096 cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx));
1097 cx.update(|cx| {
1098 assert_close(Theme::global(cx).color_tokens(), ColorTokens::dark());
1099 });
1100 }
1101}