1use crate::{
2 highlighter::HighlightTheme, list::ListSettings, notification::NotificationSettings,
3 scroll::ScrollbarMode, sheet::SheetSettings,
4};
5use gpui::{App, Global, Hsla, IsZero as _, Pixels, SharedString, Window, WindowAppearance, px};
6pub use gpui_base::{
7 ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens, TextStyleToken,
8 TypographyTokens,
9};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::{
13 ops::{Deref, DerefMut},
14 rc::Rc,
15 sync::Arc,
16 time::Duration,
17};
18
19mod color;
20mod mono_font;
21mod motion;
22mod registry;
23mod schema;
24mod theme_color;
25
26pub use color::*;
27pub use motion::*;
28pub use registry::*;
29pub use schema::*;
30pub use theme_color::*;
31
32pub fn init(cx: &mut App) {
33 registry::init(cx);
34
35 Theme::change(ThemeMode::Light, None, cx);
37 Theme::sync_scrollbar_appearance(cx);
38}
39
40pub trait ActiveTheme {
41 fn theme(&self) -> &Theme;
42}
43
44impl ActiveTheme for App {
45 #[inline(always)]
46 fn theme(&self) -> &Theme {
47 Theme::global(self)
48 }
49}
50
51fn default_true() -> bool {
52 true
53}
54
55const RADIUS_FULL: Pixels = px(9999.);
59
60const SCROLLBAR_IDLE: Duration = Duration::from_secs(2);
62const SCROLLBAR_ENTER: Duration = Duration::from_millis(300);
64const SCROLLBAR_EXIT: Duration = Duration::from_millis(500);
66const SCROLLBAR_EXPAND: Duration = Duration::from_millis(300);
68
69fn scrollbar_motion(mode: ScrollbarMode) -> gpui_base::ScrollbarMotion {
74 gpui_base::ScrollbarMotion::default()
75 .with_idle(SCROLLBAR_IDLE)
76 .with_enter(SCROLLBAR_ENTER)
77 .with_exit(SCROLLBAR_EXIT)
78 .with_expand(SCROLLBAR_EXPAND)
79 .with_entrance(gpui_base::ScrollbarEntrance::Fade)
80 .with_thumb_hover_entrance(match mode {
81 ScrollbarMode::Scrolling | ScrollbarMode::Always => gpui_base::ScrollbarEntrance::Fade,
82 ScrollbarMode::Hover => gpui_base::ScrollbarEntrance::SlideAndFade,
83 })
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
88pub struct Theme {
89 pub colors: ThemeColor,
90 #[serde(default)]
95 pub tokens: ThemeTokens,
96 pub highlight_theme: Arc<HighlightTheme>,
97 pub light_theme: Rc<ThemeConfig>,
98 pub dark_theme: Rc<ThemeConfig>,
99
100 pub mode: ThemeMode,
101 pub font_family: SharedString,
103 pub font_size: Pixels,
105 pub mono_font_family: SharedString,
118 pub mono_font_size: Pixels,
120 pub radius: Pixels,
122 pub radius_lg: Pixels,
124 pub shadow: bool,
125 #[serde(default = "default_true")]
132 pub focus_ring: bool,
133 pub transparent: Hsla,
134 #[serde(alias = "scrollbar_show")]
136 pub scrollbar_mode: ScrollbarMode,
137 #[serde(skip)]
139 pub notification: NotificationSettings,
140 pub tile_grid_size: Pixels,
142 pub tile_shadow: bool,
144 pub tile_radius: Pixels,
146 pub list: ListSettings,
148 pub sheet: SheetSettings,
150 #[serde(skip)]
152 pub motion: MotionTokens,
153}
154
155impl Default for Theme {
156 fn default() -> Self {
157 Self::from(&ThemeColor::default())
158 }
159}
160
161impl Deref for Theme {
162 type Target = ThemeColor;
163
164 fn deref(&self) -> &Self::Target {
165 &self.colors
166 }
167}
168
169impl DerefMut for Theme {
170 fn deref_mut(&mut self) -> &mut Self::Target {
171 &mut self.colors
172 }
173}
174
175impl Global for Theme {}
176
177impl Theme {
178 #[inline(always)]
180 pub fn global(cx: &App) -> &Theme {
181 cx.global::<Theme>()
182 }
183
184 #[inline(always)]
190 pub fn global_mut(cx: &mut App) -> &mut Theme {
191 cx.global_mut::<Theme>()
192 }
193
194 #[inline(always)]
196 pub fn is_dark(&self) -> bool {
197 self.mode.is_dark()
198 }
199
200 pub fn theme_name(&self) -> &SharedString {
202 if self.is_dark() {
203 &self.dark_theme.name
204 } else {
205 &self.light_theme.name
206 }
207 }
208
209 pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
211 let appearance = window
214 .as_ref()
215 .map(|window| window.appearance())
216 .unwrap_or_else(|| cx.window_appearance());
217
218 Self::change(appearance, window, cx);
219 }
220
221 pub fn sync_scrollbar_appearance(cx: &mut App) {
223 let mode = if cx.should_auto_hide_scrollbars() {
224 ScrollbarMode::Scrolling
225 } else {
226 ScrollbarMode::Hover
227 };
228 Self::set_scrollbar_mode(mode, cx);
229 }
230
231 pub fn set_scrollbar_mode(mode: ScrollbarMode, cx: &mut App) {
233 Theme::global_mut(cx).scrollbar_mode = mode;
234 let base_theme = gpui_base::Theme::global_mut(cx);
235 base_theme.scrollbar = base_theme
236 .scrollbar
237 .clone()
238 .with_mode(mode)
239 .with_motion(scrollbar_motion(mode));
240 }
241
242 pub fn change(mode: impl Into<ThemeMode>, window: Option<&mut Window>, cx: &mut App) {
244 let mode = mode.into();
245 if !cx.has_global::<Theme>() {
246 let mut theme = Theme::default();
247 theme.light_theme = ThemeRegistry::global(cx).default_light_theme().clone();
248 theme.dark_theme = ThemeRegistry::global(cx).default_dark_theme().clone();
249 cx.set_global(theme);
250 }
251
252 {
253 let theme = cx.global_mut::<Theme>();
254 theme.mode = mode;
255 if mode.is_dark() {
256 theme.apply_config(&theme.dark_theme.clone());
257 } else {
258 theme.apply_config(&theme.light_theme.clone());
259 }
260 }
261 mono_font::resolve_default_mono_font(cx);
262 let theme = cx.global::<Theme>().clone();
263
264 let base_theme = theme.base_theme();
265 cx.set_global(base_theme);
266 crate::text::install_text_view_defaults(&theme, cx);
267
268 if let Some(window) = window {
269 window.refresh();
270 }
271 }
272
273 fn base_theme(&self) -> gpui_base::Theme {
276 gpui_base::Theme {
277 appearance: if self.mode.is_dark() {
278 gpui_base::ThemeAppearance::Dark
279 } else {
280 gpui_base::ThemeAppearance::Light
281 },
282 tokens: self.semantic_tokens(),
283 scrollbar: gpui_base::ScrollbarTheme::new()
284 .with_mode(self.scrollbar_mode)
285 .with_motion(scrollbar_motion(self.scrollbar_mode))
286 .with_styles(
287 gpui_base::ScrollbarStyles::default()
288 .track(|style| style.bg(self.scrollbar))
289 .track_hover(|style| style.bg(self.scrollbar))
290 .track_active(|style| style.bg(self.scrollbar).border_color(self.border))
291 .thumb(|style| style.bg(self.tokens.scrollbar_thumb).radius(self.radius))
292 .thumb_hover(|style| {
293 style
294 .bg(self.tokens.scrollbar_thumb_hover)
295 .radius(self.radius)
296 })
297 .thumb_active(|style| {
298 style
299 .bg(self.tokens.scrollbar_thumb_hover)
300 .radius(self.radius)
301 }),
302 ),
303 resizable: gpui_base::ResizableTheme {
304 handle: Some(self.border),
305 active_handle: Some(self.drag_border),
306 },
307 }
308 }
309
310 pub fn sync_base(cx: &mut App) {
329 let theme = Theme::global(cx).clone();
330 let base_theme = theme.base_theme();
331 cx.set_global(base_theme);
332 crate::text::install_text_view_defaults(&theme, cx);
333 }
334
335 #[inline]
340 pub fn input_background(&self) -> Hsla {
341 if self.is_dark() {
342 self.input.mix_oklab(self.transparent, 0.3)
343 } else {
344 self.background
345 }
346 }
347
348 #[inline]
350 pub(crate) fn editor_background(&self) -> Hsla {
351 self.highlight_theme
352 .style
353 .editor_background
354 .unwrap_or_else(|| self.input_background())
355 }
356
357 pub fn semantic_tokens(&self) -> SemanticThemeTokens {
361 SemanticThemeTokens {
362 colors: self.color_tokens(),
363 radius: self.radius_tokens(),
364 spacing: self.spacing_tokens(),
365 typography: self.typography_tokens(),
366 shadow: self.shadow_tokens(),
367 }
368 }
369
370 pub fn motion_tokens(&self) -> &MotionTokens {
372 &self.motion
373 }
374
375 pub fn color_tokens(&self) -> ColorTokens {
376 ColorTokens {
377 background: self.background,
378 foreground: self.foreground,
379 surface: self.popover,
380 surface_foreground: self.popover_foreground,
381 primary: self.primary,
382 primary_foreground: self.primary_foreground,
383 secondary: self.secondary,
384 secondary_foreground: self.secondary_foreground,
385 muted: self.muted,
386 muted_foreground: self.muted_foreground,
387 accent: self.accent,
388 accent_foreground: self.accent_foreground,
389 destructive: self.danger,
390 destructive_foreground: self.danger_foreground,
391 border: self.border,
392 input: self.input,
393 ring: self.ring,
394 selection: self.selection,
395 }
396 }
397
398 pub fn radius_full(&self) -> Pixels {
407 if self.radius.is_zero() {
408 px(0.)
409 } else {
410 RADIUS_FULL
411 }
412 }
413
414 pub fn radius_2xl(&self) -> Pixels {
419 self.radius * 2.5
420 }
421
422 pub fn radius_3xl(&self) -> Pixels {
424 self.radius * 3.
425 }
426
427 pub fn radius_4xl(&self) -> Pixels {
429 self.radius * 3.5
430 }
431
432 pub fn radius_tokens(&self) -> RadiusTokens {
433 RadiusTokens {
434 none: px(0.),
435 sm: self.radius / 2.,
436 md: self.radius,
437 lg: self.radius_lg,
438 xl: self.radius * 2.,
439 full: self.radius_full(),
440 }
441 }
442
443 pub fn spacing_tokens(&self) -> SpacingTokens {
444 SpacingTokens::default()
445 }
446
447 pub fn typography_tokens(&self) -> TypographyTokens {
448 let mut tokens = TypographyTokens::default();
449 tokens.sans = self.font_family.clone();
450 tokens.mono = self.mono_font_family.clone();
451 tokens.md.size = self.font_size;
452 tokens.mono_md.size = self.mono_font_size;
453 tokens
454 }
455
456 pub fn shadow_tokens(&self) -> ShadowTokens {
457 if self.shadow {
458 ShadowTokens::elevations(self.transparent.alpha(0.18))
459 } else {
460 ShadowTokens::default()
461 }
462 }
463
464 pub fn apply_semantic_tokens(&mut self, tokens: &SemanticThemeTokens) {
468 let colors = tokens.colors;
469 self.background = colors.background;
470 self.foreground = colors.foreground;
471 self.popover = colors.surface;
472 self.popover_foreground = colors.surface_foreground;
473 self.primary = colors.primary;
474 self.primary_foreground = colors.primary_foreground;
475 self.secondary = colors.secondary;
476 self.secondary_foreground = colors.secondary_foreground;
477 self.muted = colors.muted;
478 self.muted_foreground = colors.muted_foreground;
479 self.accent = colors.accent;
480 self.accent_foreground = colors.accent_foreground;
481 self.danger = colors.destructive;
482 self.danger_foreground = colors.destructive_foreground;
483 self.border = colors.border;
484 self.input = colors.input;
485 self.ring = colors.ring;
486
487 self.tokens.background = colors.background.into();
488 self.tokens.popover = colors.surface.into();
489 self.tokens.primary = colors.primary.into();
490 self.tokens.secondary = colors.secondary.into();
491 self.tokens.muted = colors.muted.into();
492 self.tokens.accent = colors.accent.into();
493 self.tokens.danger = colors.destructive.into();
494
495 self.radius = tokens.radius.md;
496 self.radius_lg = tokens.radius.lg;
497 self.font_family = tokens.typography.sans.clone();
498 self.mono_font_family = tokens.typography.mono.clone();
499 self.font_size = tokens.typography.md.size;
500 self.mono_font_size = tokens.typography.mono_md.size;
501 self.shadow = !tokens.shadow.sm.is_empty()
502 || !tokens.shadow.md.is_empty()
503 || !tokens.shadow.lg.is_empty();
504 }
505
506 pub fn resolve_semantic_config(&self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
509 let mut tokens = self.semantic_tokens();
510 config.apply_to(&mut tokens);
511 tokens
512 }
513
514 pub fn apply_semantic_config(&mut self, config: &SemanticThemeConfig) -> SemanticThemeTokens {
517 let tokens = self.resolve_semantic_config(config);
518 self.apply_semantic_tokens(&tokens);
519 tokens
520 }
521
522 pub fn apply_semantic_config_str(
524 &mut self,
525 content: &str,
526 ) -> anyhow::Result<SemanticThemeTokens> {
527 let config = serde_json::from_str::<SemanticThemeConfigFile>(content)?;
528 Ok(self.apply_semantic_config(&config.tokens))
529 }
530}
531
532#[cfg(test)]
533mod semantic_token_tests {
534 use gpui::{Hsla, IsZero as _, px};
535
536 use super::{RADIUS_FULL, Theme};
537
538 #[test]
539 fn semantic_colors_are_a_live_projection_of_legacy_fields() {
540 let mut theme = Theme::default();
541 let primary = Hsla::default().alpha(0.42);
542 theme.primary = primary;
543
544 assert_eq!(theme.color_tokens().primary, primary);
545 assert_eq!(theme.semantic_tokens().colors.primary, primary);
546 }
547
548 #[test]
549 fn applying_semantic_tokens_only_updates_generic_legacy_colors() {
550 let mut theme = Theme::default();
551 let component_color = theme.button_primary;
552 let mut tokens = theme.semantic_tokens();
553 tokens.colors.primary = Hsla::default().alpha(0.25);
554 tokens.colors.destructive = Hsla::default().alpha(0.75);
555 tokens.radius.md = px(10.);
556
557 theme.apply_semantic_tokens(&tokens);
558
559 assert_eq!(theme.primary, tokens.colors.primary);
560 assert_eq!(theme.tokens.primary.color, tokens.colors.primary);
561 assert_eq!(theme.danger, tokens.colors.destructive);
562 assert_eq!(theme.radius, px(10.));
563 assert_eq!(theme.button_primary, component_color);
564 }
565
566 #[test]
567 fn square_themes_square_off_pills_and_circles() {
568 let mut theme = Theme::default();
569 assert_eq!(theme.radius_full(), RADIUS_FULL);
570 assert_eq!(theme.radius_tokens().full, RADIUS_FULL);
571
572 theme.radius = px(0.);
575 assert_eq!(theme.radius_full(), px(0.));
576 assert_eq!(theme.radius_tokens().full, px(0.));
577 }
578
579 #[test]
580 fn larger_surface_radii_follow_the_theme_radius() {
581 let mut theme = Theme::default();
582 assert!(theme.radius_tokens().xl < theme.radius_2xl());
583 assert!(theme.radius_2xl() < theme.radius_3xl());
584 assert!(theme.radius_3xl() < theme.radius_4xl());
585
586 theme.radius = px(10.);
587 assert_eq!(theme.radius_2xl(), px(25.));
588 assert_eq!(theme.radius_3xl(), px(30.));
589 assert_eq!(theme.radius_4xl(), px(35.));
590
591 theme.radius = px(0.);
592 assert_eq!(theme.radius_2xl(), px(0.));
593 assert_eq!(theme.radius_3xl(), px(0.));
594 assert_eq!(theme.radius_4xl(), px(0.));
595 }
596
597 #[test]
598 fn base_projection_carries_a_square_radius_to_the_scrollbar() {
599 let mut theme = Theme::default();
600 assert!(!theme.base_theme().tokens.radius.md.is_zero());
601
602 theme.radius = px(0.);
605 assert!(theme.base_theme().tokens.radius.md.is_zero());
606 }
607
608 #[test]
609 fn disabled_legacy_shadows_project_to_empty_elevations() {
610 let mut theme = Theme::default();
611 theme.shadow = false;
612
613 let shadows = theme.shadow_tokens();
614 assert!(shadows.sm.is_empty());
615 assert!(shadows.md.is_empty());
616 assert!(shadows.lg.is_empty());
617 }
618}
619
620impl From<&ThemeColor> for Theme {
621 fn from(colors: &ThemeColor) -> Self {
622 Theme {
623 mode: ThemeMode::default(),
624 transparent: Hsla::transparent_black(),
625 font_family: ".SystemUIFont".into(),
626 font_size: px(16.),
627 mono_font_family: mono_font::default_mono_font_family(),
628 mono_font_size: px(13.),
629 radius: px(6.),
630 radius_lg: px(8.),
631 shadow: true,
632 focus_ring: true,
633 scrollbar_mode: ScrollbarMode::default(),
634 notification: NotificationSettings::default(),
635 tile_grid_size: px(8.),
636 tile_shadow: true,
637 tile_radius: px(0.),
638 list: ListSettings::default(),
639 colors: *colors,
640 tokens: ThemeTokens::from(colors),
641 light_theme: Rc::new(ThemeConfig::default()),
642 dark_theme: Rc::new(ThemeConfig::default()),
643 highlight_theme: HighlightTheme::default_light(),
644 sheet: SheetSettings::default(),
645 motion: MotionTokens::default(),
646 }
647 }
648}
649
650#[derive(
651 Debug,
652 Clone,
653 Copy,
654 Default,
655 PartialEq,
656 PartialOrd,
657 Eq,
658 Ord,
659 Hash,
660 Serialize,
661 Deserialize,
662 JsonSchema,
663)]
664#[serde(rename_all = "snake_case")]
665pub enum ThemeMode {
666 #[default]
667 Light,
668 Dark,
669}
670
671impl ThemeMode {
672 #[inline(always)]
673 pub fn is_dark(&self) -> bool {
674 matches!(self, Self::Dark)
675 }
676
677 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 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}