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