Add support for specifying both light and dark themes in `settings.json` (#7404)

Marshall Bowers created

This PR adds support for configuring both a light and dark theme in
`settings.json`.

In addition to accepting just a theme name, the `theme` field now also
accepts an object in the following form:

```jsonc
{
  "theme": {
    "mode": "system",
    "light": "One Light",
    "dark": "One Dark"
  }
}
```

Both `light` and `dark` are required, and indicate which theme should be
used when the system is in light mode and dark mode, respectively.

The `mode` field is optional and indicates which theme should be used:
- `"system"` - Use the theme that corresponds to the system's
appearance.
- `"light"` - Use the theme indicated by the `light` field.
- `"dark"` - Use the theme indicated by the `dark` field.

Thank you to @Yesterday17 for taking a first stab at this in #6881!

Release Notes:

- Added support for configuring both a light and dark theme and
switching between them based on system preference.

Change summary

crates/collab_ui/src/notifications/incoming_call_notification.rs  |   4 
crates/collab_ui/src/notifications/project_shared_notification.rs |   4 
crates/theme/src/settings.rs                                      | 112 
crates/theme/src/theme.rs                                         |  11 
crates/theme_selector/src/theme_selector.rs                       |  24 
crates/workspace/src/workspace.rs                                 |  19 
crates/zed/src/main.rs                                            |  17 
7 files changed, 167 insertions(+), 24 deletions(-)

Detailed changes

crates/collab_ui/src/notifications/incoming_call_notification.rs 🔗

@@ -5,7 +5,7 @@ use futures::StreamExt;
 use gpui::{prelude::*, AppContext, WindowHandle};
 use settings::Settings;
 use std::sync::{Arc, Weak};
-use theme::ThemeSettings;
+use theme::{SystemAppearance, ThemeSettings};
 use ui::{prelude::*, Button, Label};
 use util::ResultExt;
 use workspace::AppState;
@@ -35,6 +35,8 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
                     let options = notification_window_options(screen, window_size);
                     let window = cx
                         .open_window(options, |cx| {
+                            SystemAppearance::init_for_window(cx);
+
                             cx.new_view(|_| {
                                 IncomingCallNotification::new(
                                     incoming_call.clone(),

crates/collab_ui/src/notifications/project_shared_notification.rs 🔗

@@ -6,7 +6,7 @@ use collections::HashMap;
 use gpui::{AppContext, Size};
 use settings::Settings;
 use std::sync::{Arc, Weak};
-use theme::ThemeSettings;
+use theme::{SystemAppearance, ThemeSettings};
 use ui::{prelude::*, Button, Label};
 use workspace::AppState;
 
@@ -28,6 +28,8 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
             for screen in cx.displays() {
                 let options = notification_window_options(screen, window_size);
                 let window = cx.open_window(options, |cx| {
+                    SystemAppearance::init_for_window(cx);
+
                     cx.new_view(|_| {
                         ProjectSharedNotification::new(
                             owner.clone(),

crates/theme/src/settings.rs 🔗

@@ -1,9 +1,10 @@
 use crate::one_themes::one_dark;
-use crate::{SyntaxTheme, Theme, ThemeRegistry, ThemeStyleContent};
+use crate::{Appearance, SyntaxTheme, Theme, ThemeRegistry, ThemeStyleContent};
 use anyhow::Result;
+use derive_more::{Deref, DerefMut};
 use gpui::{
     px, AppContext, Font, FontFeatures, FontStyle, FontWeight, Global, Pixels, Subscription,
-    ViewContext,
+    ViewContext, WindowContext,
 };
 use refineable::Refineable;
 use schemars::{
@@ -27,16 +28,104 @@ pub struct ThemeSettings {
     pub buffer_font: Font,
     pub buffer_font_size: Pixels,
     pub buffer_line_height: BufferLineHeight,
-    pub requested_theme: Option<String>,
+    pub theme_selection: Option<ThemeSelection>,
     pub active_theme: Arc<Theme>,
     pub theme_overrides: Option<ThemeStyleContent>,
 }
 
+/// The appearance of the system.
+#[derive(Debug, Clone, Copy, Deref)]
+pub struct SystemAppearance(pub Appearance);
+
+impl Default for SystemAppearance {
+    fn default() -> Self {
+        Self(Appearance::Dark)
+    }
+}
+
+#[derive(Deref, DerefMut, Default)]
+struct GlobalSystemAppearance(SystemAppearance);
+
+impl Global for GlobalSystemAppearance {}
+
+impl SystemAppearance {
+    /// Returns the global [`SystemAppearance`].
+    ///
+    /// Inserts a default [`SystemAppearance`] if one does not yet exist.
+    pub(crate) fn default_global(cx: &mut AppContext) -> Self {
+        cx.default_global::<GlobalSystemAppearance>().0
+    }
+
+    /// Initializes the [`SystemAppearance`] for the current window.
+    pub fn init_for_window(cx: &mut WindowContext) {
+        *cx.default_global::<GlobalSystemAppearance>() =
+            GlobalSystemAppearance(SystemAppearance(cx.appearance().into()));
+    }
+
+    /// Returns the global [`SystemAppearance`].
+    pub fn global(cx: &AppContext) -> Self {
+        cx.global::<GlobalSystemAppearance>().0
+    }
+
+    /// Returns a mutable reference to the global [`SystemAppearance`].
+    pub fn global_mut(cx: &mut AppContext) -> &mut Self {
+        cx.global_mut::<GlobalSystemAppearance>()
+    }
+}
+
 #[derive(Default)]
 pub(crate) struct AdjustedBufferFontSize(Pixels);
 
 impl Global for AdjustedBufferFontSize {}
 
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(untagged)]
+pub enum ThemeSelection {
+    Static(#[schemars(schema_with = "theme_name_ref")] String),
+    Dynamic {
+        #[serde(default)]
+        mode: ThemeMode,
+        #[schemars(schema_with = "theme_name_ref")]
+        light: String,
+        #[schemars(schema_with = "theme_name_ref")]
+        dark: String,
+    },
+}
+
+fn theme_name_ref(_: &mut SchemaGenerator) -> Schema {
+    Schema::new_ref("#/definitions/ThemeName".into())
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ThemeMode {
+    /// Use the specified `light` theme.
+    Light,
+
+    /// Use the specified `dark` theme.
+    Dark,
+
+    /// Use the theme based on the system's appearance.
+    #[default]
+    System,
+}
+
+impl ThemeSelection {
+    pub fn theme(&self, system_appearance: Appearance) -> &str {
+        match self {
+            Self::Static(theme) => theme,
+            Self::Dynamic { mode, light, dark } => match mode {
+                ThemeMode::Light => light,
+                ThemeMode::Dark => dark,
+                ThemeMode::System => match system_appearance {
+                    Appearance::Light => light,
+                    Appearance::Dark => dark,
+                },
+            },
+        }
+    }
+}
+
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct ThemeSettingsContent {
     #[serde(default)]
@@ -54,7 +143,7 @@ pub struct ThemeSettingsContent {
     #[serde(default)]
     pub buffer_font_features: Option<FontFeatures>,
     #[serde(default)]
-    pub theme: Option<String>,
+    pub theme: Option<ThemeSelection>,
 
     /// EXPERIMENTAL: Overrides for the current theme.
     ///
@@ -188,6 +277,7 @@ impl settings::Settings for ThemeSettings {
         cx: &mut AppContext,
     ) -> Result<Self> {
         let themes = ThemeRegistry::default_global(cx);
+        let system_appearance = SystemAppearance::default_global(cx);
 
         let mut this = Self {
             ui_font_size: defaults.ui_font_size.unwrap().into(),
@@ -205,9 +295,9 @@ impl settings::Settings for ThemeSettings {
             },
             buffer_font_size: defaults.buffer_font_size.unwrap().into(),
             buffer_line_height: defaults.buffer_line_height.unwrap(),
-            requested_theme: defaults.theme.clone(),
+            theme_selection: defaults.theme.clone(),
             active_theme: themes
-                .get(defaults.theme.as_ref().unwrap())
+                .get(defaults.theme.as_ref().unwrap().theme(*system_appearance))
                 .or(themes.get(&one_dark().name))
                 .unwrap(),
             theme_overrides: None,
@@ -229,9 +319,11 @@ impl settings::Settings for ThemeSettings {
             }
 
             if let Some(value) = &value.theme {
-                this.requested_theme = Some(value.clone());
+                this.theme_selection = Some(value.clone());
+
+                let theme_name = value.theme(*system_appearance);
 
-                if let Some(theme) = themes.get(value).log_err() {
+                if let Some(theme) = themes.get(theme_name).log_err() {
                     this.active_theme = theme;
                 }
             }
@@ -291,10 +383,6 @@ impl settings::Settings for ThemeSettings {
             .unwrap()
             .properties
             .extend([
-                (
-                    "theme".to_owned(),
-                    Schema::new_ref("#/definitions/ThemeName".into()),
-                ),
                 (
                     "buffer_font_family".to_owned(),
                     Schema::new_ref("#/definitions/FontFamilies".into()),

crates/theme/src/theme.rs 🔗

@@ -27,7 +27,7 @@ pub use schema::*;
 pub use settings::*;
 pub use styles::*;
 
-use gpui::{AppContext, AssetSource, Hsla, SharedString};
+use gpui::{AppContext, AssetSource, Hsla, SharedString, WindowAppearance};
 use serde::Deserialize;
 
 #[derive(Debug, PartialEq, Clone, Copy, Deserialize)]
@@ -45,6 +45,15 @@ impl Appearance {
     }
 }
 
+impl From<WindowAppearance> for Appearance {
+    fn from(value: WindowAppearance) -> Self {
+        match value {
+            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
+            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
+        }
+    }
+}
+
 pub enum LoadThemes {
     /// Only load the base theme.
     ///

crates/theme_selector/src/theme_selector.rs 🔗

@@ -9,7 +9,9 @@ use gpui::{
 use picker::{Picker, PickerDelegate};
 use settings::{update_settings_file, SettingsStore};
 use std::sync::Arc;
-use theme::{Theme, ThemeMeta, ThemeRegistry, ThemeSettings};
+use theme::{
+    Appearance, Theme, ThemeMeta, ThemeMode, ThemeRegistry, ThemeSelection, ThemeSettings,
+};
 use ui::{prelude::*, v_flex, ListItem, ListItemSpacing};
 use util::ResultExt;
 use workspace::{ui::HighlightedLabel, ModalView, Workspace};
@@ -167,8 +169,26 @@ impl PickerDelegate for ThemeSelectorDelegate {
         self.telemetry
             .report_setting_event("theme", theme_name.to_string());
 
+        let appearance = Appearance::from(cx.appearance());
+
         update_settings_file::<ThemeSettings>(self.fs.clone(), cx, move |settings| {
-            settings.theme = Some(theme_name.to_string());
+            if let Some(selection) = settings.theme.as_mut() {
+                let theme_to_update = match selection {
+                    ThemeSelection::Static(theme) => theme,
+                    ThemeSelection::Dynamic { mode, light, dark } => match mode {
+                        ThemeMode::Light => light,
+                        ThemeMode::Dark => dark,
+                        ThemeMode::System => match appearance {
+                            Appearance::Light => light,
+                            Appearance::Dark => dark,
+                        },
+                    },
+                };
+
+                *theme_to_update = theme_name.to_string();
+            } else {
+                settings.theme = Some(ThemeSelection::Static(theme_name.to_string()));
+            }
         });
 
         self.view

crates/workspace/src/workspace.rs 🔗

@@ -64,7 +64,7 @@ use std::{
     sync::{atomic::AtomicUsize, Arc},
     time::Duration,
 };
-use theme::{ActiveTheme, ThemeSettings};
+use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
 pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
 pub use ui;
 use ui::Label;
@@ -682,6 +682,21 @@ impl Workspace {
                 }
                 cx.notify();
             }),
+            cx.observe_window_appearance(|_, cx| {
+                let window_appearance = cx.appearance();
+
+                *SystemAppearance::global_mut(cx) = SystemAppearance(window_appearance.into());
+
+                let mut theme_settings = ThemeSettings::get_global(cx).clone();
+
+                if let Some(theme_selection) = theme_settings.theme_selection.clone() {
+                    let theme_name = theme_selection.theme(window_appearance.into());
+
+                    if let Some(_theme) = theme_settings.switch_theme(&theme_name, cx) {
+                        ThemeSettings::override_global(theme_settings, cx);
+                    }
+                }
+            }),
             cx.observe(&left_dock, |this, _, cx| {
                 this.serialize_workspace(cx);
                 cx.notify();
@@ -840,6 +855,8 @@ impl Workspace {
                     let workspace_id = workspace_id.clone();
                     let project_handle = project_handle.clone();
                     move |cx| {
+                        SystemAppearance::init_for_window(cx);
+
                         cx.new_view(|cx| {
                             Workspace::new(workspace_id, project_handle, app_state, cx)
                         })

crates/zed/src/main.rs 🔗

@@ -43,7 +43,7 @@ use std::{
     thread,
     time::Duration,
 };
-use theme::{ActiveTheme, ThemeRegistry, ThemeSettings};
+use theme::{ActiveTheme, SystemAppearance, ThemeRegistry, ThemeSettings};
 use util::{
     async_maybe,
     http::{self, HttpClient, ZedHttpClient},
@@ -912,8 +912,10 @@ fn load_user_themes_in_background(fs: Arc<dyn fs::Fs>, cx: &mut AppContext) {
                 theme_registry.load_user_themes(themes_dir, fs).await?;
                 cx.update(|cx| {
                     let mut theme_settings = ThemeSettings::get_global(cx).clone();
-                    if let Some(requested_theme) = theme_settings.requested_theme.clone() {
-                        if let Some(_theme) = theme_settings.switch_theme(&requested_theme, cx) {
+                    if let Some(theme_selection) = theme_settings.theme_selection.clone() {
+                        let theme_name = theme_selection.theme(*SystemAppearance::global(cx));
+
+                        if let Some(_theme) = theme_settings.switch_theme(&theme_name, cx) {
                             ThemeSettings::override_global(theme_settings, cx);
                         }
                     }
@@ -949,11 +951,14 @@ fn watch_themes(fs: Arc<dyn fs::Fs>, cx: &mut AppContext) {
                             cx.update(|cx| {
                                 let mut theme_settings = ThemeSettings::get_global(cx).clone();
 
-                                if let Some(requested_theme) =
-                                    theme_settings.requested_theme.clone()
+                                if let Some(theme_selection) =
+                                    theme_settings.theme_selection.clone()
                                 {
+                                    let theme_name =
+                                        theme_selection.theme(*SystemAppearance::global(cx));
+
                                     if let Some(_theme) =
-                                        theme_settings.switch_theme(&requested_theme, cx)
+                                        theme_settings.switch_theme(&theme_name, cx)
                                     {
                                         ThemeSettings::override_global(theme_settings, cx);
                                     }