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 PLOT_APPEAR: Duration = Duration::from_millis(1000);
70
71fn plot_appear_easing(t: f32) -> f32 {
78 1. - (1. - t).powi(4)
79}
80
81const SCROLLBAR_IDLE: Duration = Duration::from_secs(2);
83const SCROLLBAR_ENTER: Duration = Duration::from_millis(300);
85const SCROLLBAR_EXIT: Duration = Duration::from_millis(500);
87const SCROLLBAR_EXPAND: Duration = Duration::from_millis(300);
89
90const MOBILE_SCROLLBAR_THUMB_WIDTH: Pixels = px(3.);
94const MOBILE_SCROLLBAR_THUMB_INSET: Pixels = px(2.);
97const SCROLLBAR_THUMB_HOVER_WIDTH: Pixels = px(6.);
100const SCROLLBAR_THUMB_ACTIVE_WIDTH: Pixels = px(8.);
102const SCROLLBAR_THUMB_INSET: Pixels = px(4.);
104
105fn plot_motion(motion: &MotionTokens) -> gpui_base::PlotMotion {
115 gpui_base::PlotMotion::default()
116 .with_pointer(gpui_base::Spring::new(motion.duration_fast).with_epsilon(0.1))
117 .with_enter(
118 gpui_base::motion::Transition::new(motion.duration_fast)
119 .easing(motion.easing_enter.clone()),
120 )
121 .with_exit(
122 gpui_base::motion::Transition::new(motion.duration_fast)
123 .easing(motion.easing_exit.clone()),
124 )
125 .with_appear(gpui_base::motion::Transition::new(PLOT_APPEAR).ease(plot_appear_easing))
126}
127
128fn scrollbar_motion(mode: ScrollbarMode) -> gpui_base::ScrollbarMotion {
133 gpui_base::ScrollbarMotion::default()
134 .with_idle(SCROLLBAR_IDLE)
135 .with_enter(SCROLLBAR_ENTER)
136 .with_exit(SCROLLBAR_EXIT)
137 .with_expand(SCROLLBAR_EXPAND)
138 .with_entrance(gpui_base::ScrollbarEntrance::Fade)
139 .with_thumb_hover_entrance(match mode {
140 ScrollbarMode::Scrolling | ScrollbarMode::Always => gpui_base::ScrollbarEntrance::Fade,
141 ScrollbarMode::Hover => gpui_base::ScrollbarEntrance::SlideAndFade,
142 })
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
147pub struct Theme {
148 pub colors: ThemeColor,
149 #[serde(default)]
154 pub tokens: ThemeTokens,
155 pub highlight_theme: Arc<HighlightTheme>,
156 pub light_theme: Rc<ThemeConfig>,
157 pub dark_theme: Rc<ThemeConfig>,
158
159 pub mode: ThemeMode,
160 pub font_family: SharedString,
167 pub font_size: Pixels,
169 pub mono_font_family: SharedString,
182 pub mono_font_size: Pixels,
184 pub radius: Pixels,
186 pub radius_lg: Pixels,
188 pub shadow: bool,
189 #[serde(default = "default_true")]
197 pub focus_ring: bool,
198 pub transparent: Hsla,
199 #[serde(alias = "scrollbar_show")]
201 pub scrollbar_mode: ScrollbarMode,
202 #[serde(skip)]
204 pub notification: NotificationSettings,
205 pub list: ListSettings,
207 pub sheet: SheetSettings,
209 #[serde(skip)]
211 pub motion: MotionTokens,
212}
213
214impl Default for Theme {
215 fn default() -> Self {
216 Self::from(&ThemeColor::default())
217 }
218}
219
220impl Deref for Theme {
221 type Target = ThemeColor;
222
223 fn deref(&self) -> &Self::Target {
224 &self.colors
225 }
226}
227
228impl DerefMut for Theme {
229 fn deref_mut(&mut self) -> &mut Self::Target {
230 &mut self.colors
231 }
232}
233
234impl Global for Theme {}
235
236impl Theme {
237 #[inline(always)]
239 pub fn global(cx: &App) -> &Theme {
240 cx.global::<Theme>()
241 }
242
243 #[inline(always)]
251 pub fn global_mut(cx: &mut App) -> &mut Theme {
252 cx.global_mut::<Theme>()
253 }
254
255 pub fn update<R>(cx: &mut App, edit: impl FnOnce(&mut Theme) -> R) -> R {
289 Self::edit(cx, false, edit)
290 }
291
292 fn edit<R>(cx: &mut App, reload_mode: bool, edit: impl FnOnce(&mut Theme) -> R) -> R {
299 let theme = Theme::global_mut(cx);
300 let colors_before = theme.colors;
301 let tokens_before = theme.tokens;
302 let mode_before = theme.mode;
303 let light_before = theme.light_theme.clone();
304 let dark_before = theme.dark_theme.clone();
305 let fonts_before = (theme.font_family.clone(), theme.mono_font_family.clone());
306
307 let result = edit(theme);
308
309 theme
310 .tokens
311 .reconcile(&mut theme.colors, &colors_before, &tokens_before);
312 let mode_changed = theme.mode != mode_before;
313 let (config, config_before) = if theme.mode.is_dark() {
314 (&theme.dark_theme, &dark_before)
315 } else {
316 (&theme.light_theme, &light_before)
317 };
318 let installed_by_edit = mode_changed && !Rc::ptr_eq(config, config_before);
323 if (mode_changed || reload_mode) && !installed_by_edit {
324 let config = config.clone();
325 theme.apply_config(&config);
326 }
327 let fonts_changed =
328 (&theme.font_family, &theme.mono_font_family) != (&fonts_before.0, &fonts_before.1);
329 if mode_changed || reload_mode || fonts_changed {
330 system_font::resolve_default_font(cx);
331 mono_font::resolve_default_mono_font(cx);
332 }
333 Self::sync_base(cx);
334 cx.refresh_windows();
335 result
336 }
337
338 #[inline(always)]
340 pub fn is_dark(&self) -> bool {
341 self.mode.is_dark()
342 }
343
344 pub fn theme_name(&self) -> &SharedString {
346 if self.is_dark() {
347 &self.dark_theme.name
348 } else {
349 &self.light_theme.name
350 }
351 }
352
353 pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
355 let appearance = window
358 .as_ref()
359 .map(|window| window.appearance())
360 .unwrap_or_else(|| cx.window_appearance());
361
362 Self::change(appearance, window, cx);
363 }
364
365 pub fn sync_scrollbar_appearance(cx: &mut App) {
367 let mode = if cx.should_auto_hide_scrollbars() {
368 ScrollbarMode::Scrolling
369 } else {
370 ScrollbarMode::Hover
371 };
372 Self::set_scrollbar_mode(mode, cx);
373 }
374
375 pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
378 Self::update(cx, |theme| theme.scrollbar_mode = mode);
379 }
380
381 pub fn change(mode: impl Into<ThemeMode>, _window: Option<&mut Window>, cx: &mut App) {
390 let mode = mode.into();
391 if !cx.has_global::<Theme>() {
392 let mut theme = Theme::default();
393 theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
394 theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
395 cx.set_global(theme);
396 }
397
398 Self::edit(cx, true, |theme| theme.mode = mode);
399 }
400
401 fn base_theme(&self) -> gpui_base::Theme {
404 gpui_base::Theme {
405 appearance: if self.mode.is_dark() {
406 gpui_base::ThemeAppearance::Dark
407 } else {
408 gpui_base::ThemeAppearance::Light
409 },
410 tokens: self.semantic_tokens(),
411 scrollbar: gpui_base::ScrollbarTheme::new()
412 .with_mode(self.scrollbar_mode)
413 .with_motion(scrollbar_motion(self.scrollbar_mode))
414 .with_styles(
415 gpui_base::ScrollbarStyles::default()
416 .track(|style| style.bg(self.scrollbar))
417 .track_hover(|style| style.bg(self.scrollbar))
418 .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
419 .thumb(|style| {
420 style
421 .bg(self.tokens.scrollbar_thumb)
422 .radius(self.radius)
423 .when(gpui_base::is_mobile(), |style| {
424 style
425 .width(MOBILE_SCROLLBAR_THUMB_WIDTH)
426 .inset(MOBILE_SCROLLBAR_THUMB_INSET)
427 .radius(RADIUS_FULL)
428 })
429 })
430 .thumb_hover(|style| {
431 style
432 .bg(self.tokens.scrollbar_thumb_hover)
433 .radius(self.radius)
434 .when(gpui_base::is_mobile(), |style| {
435 style
436 .width(SCROLLBAR_THUMB_HOVER_WIDTH)
437 .inset(SCROLLBAR_THUMB_INSET)
438 })
439 })
440 .thumb_active(|style| {
441 style
442 .bg(self.tokens.scrollbar_thumb_hover)
443 .radius(self.radius)
444 .when(gpui_base::is_mobile(), |style| {
445 style
446 .width(SCROLLBAR_THUMB_ACTIVE_WIDTH)
447 .inset(SCROLLBAR_THUMB_INSET)
448 })
449 }),
450 ),
451 resizable: gpui_base::ResizableTheme {
452 handle: Some(self.border),
453 active_handle: Some(self.drag_border),
454 },
455 plot: gpui_base::PlotTheme::new().with_motion(plot_motion(&self.motion)),
456 }
457 }
458
459 pub fn sync_base(cx: &mut App) {
474 let theme = Theme::global(cx).clone();
475 let base_theme = theme.base_theme();
476 cx.set_global(base_theme);
477 crate::text::install_text_view_defaults(&theme, cx);
478 }
479
480 #[inline]
485 pub fn input_background(&self) -> Hsla {
486 if self.is_dark() {
487 self.input.mix_oklab(self.transparent, 0.3)
488 } else {
489 self.background
490 }
491 }
492
493 #[inline]
495 pub(crate) fn editor_background(&self) -> Hsla {
496 self.highlight_theme
497 .style
498 .editor_background
499 .unwrap_or_else(|| self.input_background())
500 }
501
502 pub fn semantic_tokens(&self) -> SemanticThemeTokens {
506 SemanticThemeTokens {
507 colors: self.color_tokens(),
508 radius: self.radius_tokens(),
509 spacing: self.spacing_tokens(),
510 typography: self.typography_tokens(),
511 shadow: self.shadow_tokens(),
512 }
513 }
514
515 pub fn motion_tokens(&self) -> &MotionTokens {
517 &self.motion
518 }
519
520 pub fn color_tokens(&self) -> ColorTokens {
521 ColorTokens {
522 background: self.background,
523 foreground: self.foreground,
524 surface: self.popover,
525 surface_foreground: self.popover_foreground,
526 primary: self.primary,
527 primary_foreground: self.primary_foreground,
528 secondary: self.secondary,
529 secondary_foreground: self.secondary_foreground,
530 muted: self.muted,
531 muted_foreground: self.muted_foreground,
532 accent: self.accent,
533 accent_foreground: self.accent_foreground,
534 destructive: self.danger,
535 destructive_foreground: self.danger_foreground,
536 border: self.border,
537 input: self.input,
538 ring: self.ring,
539 selection: self.selection,
540 }
541 }
542
543 pub fn radius_full(&self) -> Pixels {
552 if self.radius.is_zero() {
553 px(0.)
554 } else {
555 RADIUS_FULL
556 }
557 }
558
559 pub fn radius_2xl(&self) -> Pixels {
564 self.radius * 2.5
565 }
566
567 pub fn radius_3xl(&self) -> Pixels {
569 self.radius * 3.
570 }
571
572 pub fn radius_4xl(&self) -> Pixels {
574 self.radius * 3.5
575 }
576
577 pub fn radius_tokens(&self) -> RadiusTokens {
578 RadiusTokens {
579 none: px(0.),
580 sm: self.radius / 2.,
581 md: self.radius,
582 lg: self.radius_lg,
583 xl: self.radius * 2.,
584 full: self.radius_full(),
585 }
586 }
587
588 pub fn spacing_tokens(&self) -> SpacingTokens {
589 SpacingTokens::default()
590 }
591
592 pub fn typography_tokens(&self) -> TypographyTokens {
593 let mut tokens = TypographyTokens::default();
594 tokens.sans = self.font_family.clone();
595 tokens.mono = self.mono_font_family.clone();
596 tokens.md.size = self.font_size;
597 tokens.mono_md.size = self.mono_font_size;
598 tokens
599 }
600
601 pub fn shadow_tokens(&self) -> ShadowTokens {
602 if self.shadow {
603 ShadowTokens::elevations(self.transparent.alpha(0.18))
604 } else {
605 ShadowTokens::default()
606 }
607 }
608
609 pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
613 let colors = tokens.colors;
614 self.background = colors.background;
615 self.foreground = colors.foreground;
616 self.popover = colors.surface;
617 self.popover_foreground = colors.surface_foreground;
618 self.primary = colors.primary;
619 self.primary_foreground = colors.primary_foreground;
620 self.secondary = colors.secondary;
621 self.secondary_foreground = colors.secondary_foreground;
622 self.muted = colors.muted;
623 self.muted_foreground = colors.muted_foreground;
624 self.accent = colors.accent;
625 self.accent_foreground = colors.accent_foreground;
626 self.danger = colors.destructive;
627 self.danger_foreground = colors.destructive_foreground;
628 self.border = colors.border;
629 self.input = colors.input;
630 self.ring = colors.ring;
631
632 self.tokens.background = colors.background.into();
633 self.tokens.popover = colors.surface.into();
634 self.tokens.primary = colors.primary.into();
635 self.tokens.secondary = colors.secondary.into();
636 self.tokens.muted = colors.muted.into();
637 self.tokens.accent = colors.accent.into();
638 self.tokens.danger = colors.destructive.into();
639
640 self.radius = tokens.radius.md;
641 self.radius_lg = tokens.radius.lg;
642 self.font_family = tokens.typography.sans.clone();
643 self.mono_font_family = tokens.typography.mono.clone();
644 self.font_size = tokens.typography.md.size;
645 self.mono_font_size = tokens.typography.mono_md.size;
646 self.shadow = !tokens.shadow.sm.is_empty()
647 || !tokens.shadow.md.is_empty()
648 || !tokens.shadow.lg.is_empty();
649 }
650
651 pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
654 let mut tokens = self.semantic_tokens();
655 config.apply_to(&mut tokens);
656 tokens
657 }
658
659 pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
662 let tokens = self.resolve_semantic_config(config);
663 self.apply_semantic_tokens(&tokens);
664 tokens
665 }
666
667 pub fn apply_semantic_config_str(
669 &mut self,
670 content: &str,
671 ) -> anyhow::Result<SemanticThemeTokens> {
672 let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
673 Ok(self.apply_semantic_config(&config.tokens))
674 }
675}
676
677#[cfg(test)]
678mod semantic_token_tests {
679 use gpui::{Hsla, IsZero as _, px};
680
681 use super::{RADIUS_FULL, Theme};
682
683 #[test]
684 fn semantic_colors_are_a_live_projection_of_legacy_fields() {
685 let mut theme = Theme::default();
686 let primary = Hsla::default().alpha(0.42);
687 theme.primary = primary;
688
689 assert_eq!(theme.color_tokens().primary, primary);
690 assert_eq!(theme.semantic_tokens().colors.primary, primary);
691 }
692
693 #[test]
694 fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
695 let mut theme = Theme::default();
696 let component_color = theme.button_primary;
697 let mut tokens = theme.semantic_tokens();
698 tokens.colors.primary = Hsla::default().alpha(0.25);
699 tokens.colors.destructive = Hsla::default().alpha(0.75);
700 tokens.radius.md = px(10.);
701
702 theme.apply_semantic_tokens(&tokens);
703
704 assert_eq!(theme.primary, tokens.colors.primary);
705 assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
706 assert_eq!(theme.danger, tokens.colors.destructive);
707 assert_eq!(theme.radius, px(10.));
708 assert_eq!(theme.button_primary, component_color);
709 }
710
711 #[test]
712 fn square_themes_square_off_pills_and_circles() {
713 let mut theme = Theme::default();
714 assert_eq!(theme.radius_full(), RADIUS_FULL);
715 assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
716
717 theme.radius = px(0.);
720 assert_eq!(theme.radius_full(), px(0.));
721 assert_eq!(theme.radius_tokens().full, px(0.));
722 }
723
724 #[test]
725 fn larger_surface_radii_follow_the_theme_radius() {
726 let mut theme = Theme::default();
727 assert!(theme.radius_tokens().xl < theme.radius_2xl());
728 assert!(theme.radius_2xl() < theme.radius_3xl());
729 assert!(theme.radius_3xl() < theme.radius_4xl());
730
731 theme.radius = px(10.);
732 assert_eq!(theme.radius_2xl(), px(25.));
733 assert_eq!(theme.radius_3xl(), px(30.));
734 assert_eq!(theme.radius_4xl(), px(35.));
735
736 theme.radius = px(0.);
737 assert_eq!(theme.radius_2xl(), px(0.));
738 assert_eq!(theme.radius_3xl(), px(0.));
739 assert_eq!(theme.radius_4xl(), px(0.));
740 }
741
742 #[test]
743 fn base_projection_carries_a_square_radius_to_the_scrollbar() {
744 let mut theme = Theme::default();
745 assert!(!theme.base_theme().tokens.radius.md.is_zero());
746
747 theme.radius = px(0.);
750 assert!(theme.base_theme().tokens.radius.md.is_zero());
751 }
752
753 #[test]
754 fn disabled_legacy_shadows_project_to_empty_elevations() {
755 let mut theme = Theme::default();
756 theme.shadow = false;
757
758 let shadows = theme.shadow_tokens();
759 assert!(shadows.sm.is_empty());
760 assert!(shadows.md.is_empty());
761 assert!(shadows.lg.is_empty());
762 }
763}
764
765impl From<&ThemeColor> for Theme {
766 fn from(colors: &ThemeColor) -> Self {
767 Theme {
768 mode: ThemeMode::default(),
769 transparent: Hsla::transparent_black(),
770 font_family: ".SystemUIFont".into(),
771 font_size: px(16.),
772 mono_font_family: mono_font::default_mono_font_family(),
773 mono_font_size: px(13.),
774 radius: px(6.),
775 radius_lg: px(8.),
776 shadow: true,
777 focus_ring: true,
778 scrollbar_mode: ScrollbarMode::default(),
779 notification: NotificationSettings::default(),
780 list: ListSettings::default(),
781 colors: *colors,
782 tokens: ThemeTokens::from(colors),
783 light_theme: Rc::new(ThemeConfig::default()),
784 dark_theme: Rc::new(ThemeConfig::default()),
785 highlight_theme: HighlightTheme::default_light(),
786 sheet: SheetSettings::default(),
787 motion: MotionTokens::default(),
788 }
789 }
790}
791
792#[derive(
793 Debug,
794 Clone,
795 Copy,
796 Default,
797 PartialEq,
798 PartialOrd,
799 Eq,
800 Ord,
801 Hash,
802 Serialize,
803 Deserialize,
804 JsonSchema,
805)]
806#[serde(rename_all = "snake_case")]
807pub enum ThemeMode {
808 #[default]
809 Light,
810 Dark,
811}
812
813impl ThemeMode {
814 #[inline(always)]
815 pub fn is_dark(&self) -> bool {
816 matches!(self, Self::Dark)
817 }
818
819 pub fn name(&self) -> &'static str {
821 match self {
822 ThemeMode::Light => "light",
823 ThemeMode::Dark => "dark",
824 }
825 }
826}
827
828impl From<WindowAppearance> for ThemeMode {
829 fn from(appearance: WindowAppearance) -> Self {
830 match appearance {
831 WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
832 WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
833 }
834 }
835}
836
837#[cfg(test)]
838mod update_tests {
839 use super::*;
840 use gpui::{TestAppContext, linear_color_stop, linear_gradient};
841
842 fn gradient(from: Hsla, to: Hsla) -> ThemeToken {
843 ThemeToken::new(
844 from,
845 linear_gradient(135., linear_color_stop(from, 0.), linear_color_stop(to, 1.)),
846 )
847 }
848
849 #[gpui::test]
852 fn editing_colors_updates_the_tokens_and_the_base_projection(cx: &mut TestAppContext) {
853 cx.update(|cx| {
854 init(cx);
855 let sidebar = gpui::rgb(0x123456).into();
856 let primary = gpui::rgb(0xabcdef).into();
857
858 Theme::update(cx, |theme| {
859 theme.sidebar = sidebar;
860 theme.colors.primary = primary;
861 theme.radius = px(0.);
862 });
863
864 let theme = Theme::global(cx);
865 assert_eq!(theme.tokens.sidebar.color, sidebar);
866 assert_eq!(theme.tokens.sidebar.background, sidebar.into());
867 assert_eq!(theme.tokens.primary.color, primary);
868 assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, primary);
869 assert!(gpui_base::Theme::global(cx).tokens.radius.md.is_zero());
870 });
871 }
872
873 #[gpui::test]
876 fn replacing_the_palette_rewrites_every_token(cx: &mut TestAppContext) {
877 cx.update(|cx| {
878 init(cx);
879 let palette = *ThemeColor::dark();
880
881 Theme::update(cx, |theme| theme.colors = palette);
882
883 assert_eq!(Theme::global(cx).tokens, ThemeTokens::from(palette));
884 });
885 }
886
887 #[gpui::test]
890 fn a_gradient_survives_until_its_own_color_is_edited(cx: &mut TestAppContext) {
891 cx.update(|cx| {
892 init(cx);
893 let from = gpui::rgb(0x4f46e5).into();
894 let to = gpui::rgb(0x06b6d4).into();
895 let token = gradient(from, to);
896 Theme::update(cx, |theme| theme.tokens.primary = token);
897 assert_eq!(Theme::global(cx).primary, from);
900
901 Theme::update(cx, |theme| theme.secondary = gpui::rgb(0x222222).into());
902 assert_eq!(Theme::global(cx).tokens.primary, token);
903
904 let solid = gpui::rgb(0x999999).into();
905 Theme::update(cx, |theme| theme.primary = solid);
906 assert_eq!(Theme::global(cx).tokens.primary, solid.into());
907 });
908 }
909
910 #[gpui::test]
913 fn setting_the_mode_loads_that_modes_theme(cx: &mut TestAppContext) {
914 cx.update(|cx| {
915 init(cx);
916 let light_background = Theme::global(cx).background;
917
918 Theme::update(cx, |theme| theme.mode = ThemeMode::Dark);
919
920 let theme = Theme::global(cx);
921 assert!(theme.is_dark());
922 assert_ne!(theme.background, light_background);
923 assert_eq!(theme.tokens.background.color, theme.background);
924 assert_eq!(
925 gpui_base::Theme::global(cx).appearance,
926 gpui_base::ThemeAppearance::Dark
927 );
928 assert_eq!(
929 gpui_base::Theme::global(cx).tokens.colors.background,
930 theme.background
931 );
932 });
933 }
934
935 #[gpui::test]
939 fn applying_a_config_keeps_its_gradients(cx: &mut TestAppContext) {
940 cx.update(|cx| {
941 init(cx);
942 let config: ThemeConfig = serde_json::from_value(serde_json::json!({
943 "name": "Gradient",
944 "mode": "light",
945 "colors": {
946 "primary": "#4F46E5",
947 "primary.background": "linear-gradient(135deg, #4F46E5, #06B6D4)"
948 }
949 }))
950 .unwrap();
951 let config = Rc::new(config);
952
953 Theme::update(cx, |theme| theme.apply_config(&config));
954
955 let theme = Theme::global(cx);
956 assert_eq!(theme.tokens.primary.color, theme.primary);
957 assert_ne!(
958 theme.tokens.primary.background,
959 theme.primary.into(),
960 "the gradient must survive the reconcile"
961 );
962 });
963 }
964
965 #[gpui::test]
969 fn edits_after_applying_a_config_of_the_other_mode_survive(cx: &mut TestAppContext) {
970 cx.update(|cx| {
971 init(cx);
972 let config: ThemeConfig = serde_json::from_value(serde_json::json!({
973 "name": "Rounded Dark",
974 "mode": "dark",
975 "radius": 12,
976 "colors": { "primary": "#4F46E5" }
977 }))
978 .unwrap();
979 let config = Rc::new(config);
980 assert!(!Theme::global(cx).is_dark());
981
982 let red = gpui::red();
983 Theme::update(cx, |theme| {
984 theme.apply_config(&config);
985 theme.radius = px(0.);
986 theme.colors.primary = red;
987 });
988
989 let theme = Theme::global(cx);
990 assert!(theme.is_dark());
991 assert!(Rc::ptr_eq(&theme.dark_theme, &config));
992 assert_eq!(theme.radius, px(0.), "the file's radius must not reload");
993 assert_eq!(theme.primary, red);
994 assert_eq!(theme.tokens.primary, red.into());
995 assert_eq!(gpui_base::Theme::global(cx).tokens.colors.primary, red);
996 });
997 }
998
999 #[gpui::test]
1000 fn update_returns_the_closure_result(cx: &mut TestAppContext) {
1001 cx.update(|cx| {
1002 init(cx);
1003 let radius = Theme::update(cx, |theme| {
1004 theme.radius = px(6.);
1005 theme.radius
1006 });
1007 assert_eq!(radius, px(6.));
1008 });
1009 }
1010}
1011
1012#[cfg(test)]
1013mod base_theme_projection_tests {
1014 use super::*;
1015 use gpui::TestAppContext;
1016
1017 #[gpui::test]
1018 fn base_theme_tracks_initialization_and_mode_changes(cx: &mut TestAppContext) {
1019 cx.update(|cx| {
1020 init(cx);
1021 assert_styled_projection(cx);
1022
1023 Theme::change(ThemeMode::Dark, None, cx);
1024 assert_styled_projection(cx);
1025
1026 Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
1027 assert_eq!(Theme::global(cx).scrollbar_mode, ScrollbarMode::Always);
1028 assert_eq!(
1029 gpui_base::Theme::global(cx).scrollbar.mode(),
1030 gpui_base::ScrollbarMode::Always
1031 );
1032 assert_styled_projection(cx);
1033 });
1034 }
1035
1036 #[gpui::test]
1037 fn scrollbar_motion_is_owned_here_and_projected_onto_base(cx: &mut TestAppContext) {
1038 cx.update(|cx| {
1039 init(cx);
1040
1041 let bare = gpui_base::ScrollbarMotion::default();
1043 assert_eq!(bare.enter(), Duration::ZERO);
1044 assert_eq!(bare.exit(), Duration::ZERO);
1045 assert_eq!(bare.expand(), Duration::ZERO);
1046
1047 Theme::set_scrollbar_mode(ScrollbarMode::Scrolling, cx);
1048 let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1049 assert_eq!(motion.idle(), SCROLLBAR_IDLE);
1050 assert_eq!(motion.enter(), SCROLLBAR_ENTER);
1051 assert_eq!(motion.exit(), SCROLLBAR_EXIT);
1052 assert_eq!(motion.expand(), SCROLLBAR_EXPAND);
1053 assert_eq!(
1054 motion.entrance(),
1055 gpui_base::ScrollbarEntrance::Fade,
1056 "scroll-revealed scrollbars fade in without sliding"
1057 );
1058
1059 Theme::set_scrollbar_mode(ScrollbarMode::Hover, cx);
1060 let motion = gpui_base::Theme::global(cx).scrollbar.motion();
1061 assert_eq!(motion.entrance(), gpui_base::ScrollbarEntrance::Fade);
1062 assert_eq!(
1063 motion.thumb_hover_entrance(),
1064 gpui_base::ScrollbarEntrance::SlideAndFade
1065 );
1066 });
1067 }
1068
1069 #[test]
1070 fn default_motion_tokens_form_a_coherent_semantic_scale() {
1071 let theme = Theme::default();
1072 let motion = theme.motion_tokens();
1073
1074 assert_eq!(motion.duration_instant, Duration::ZERO);
1075 assert!(motion.duration_fast < motion.duration_normal);
1076 assert!(motion.duration_normal < motion.duration_slow);
1077 assert!(motion.distance_short.0 < motion.distance_medium.0);
1078 assert_eq!(motion.easing_enter.sample(0.0), 0.0);
1079 assert_eq!(motion.easing_enter.sample(1.0), 1.0);
1080 }
1081
1082 fn assert_styled_projection(cx: &App) {
1083 let theme = Theme::global(cx);
1084 let base = gpui_base::Theme::global(cx);
1085
1086 assert_eq!(base.tokens, theme.semantic_tokens());
1087 assert_eq!(base.scrollbar.mode(), theme.scrollbar_mode);
1088 assert_eq!(
1089 base.scrollbar.motion(),
1090 scrollbar_motion(theme.scrollbar_mode)
1091 );
1092 assert_eq!(base.resizable.handle, Some(theme.border));
1093 assert_eq!(base.resizable.active_handle, Some(theme.drag_border));
1094 }
1095
1096 #[gpui::test]
1097 fn default_component_palettes_match_base_light_and_dark_tokens(cx: &mut gpui::TestAppContext) {
1098 fn assert_close(left: ColorTokens, right: ColorTokens) {
1099 macro_rules! color {
1100 ($field:ident) => {
1101 assert!(
1102 (left.$field.h - right.$field.h).abs() < 1e-6
1103 && (left.$field.s - right.$field.s).abs() < 1e-6
1104 && (left.$field.l - right.$field.l).abs() < 1e-6
1105 && (left.$field.a - right.$field.a).abs() < 1e-6,
1106 "{} differs: {:?} != {:?}",
1107 stringify!($field),
1108 left.$field,
1109 right.$field
1110 );
1111 };
1112 }
1113 color!(background);
1114 color!(foreground);
1115 color!(surface);
1116 color!(surface_foreground);
1117 color!(primary);
1118 color!(primary_foreground);
1119 color!(secondary);
1120 color!(secondary_foreground);
1121 color!(muted);
1122 color!(muted_foreground);
1123 color!(accent);
1124 color!(accent_foreground);
1125 color!(destructive);
1126 color!(destructive_foreground);
1127 color!(border);
1128 color!(input);
1129 color!(ring);
1130 color!(selection);
1131 }
1132
1133 cx.update(crate::init);
1134 cx.update(|cx| {
1135 assert_close(Theme::global(cx).color_tokens(), ColorTokens::light());
1136 });
1137
1138 cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx));
1139 cx.update(|cx| {
1140 assert_close(Theme::global(cx).color_tokens(), ColorTokens::dark());
1141 });
1142 }
1143}