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