Add support for loading user themes (#7027)

Marshall Bowers created

This PR adds support for loading user themes in Zed.

Themes are loaded from the `themes` directory under the Zed config:
`~/.config/zed/themes`. This directory should contain JSON files
containing a `ThemeFamilyContent`.

Here's an example of the general structure of a theme family file:

```jsonc
{
  "name": "Vitesse",
  "author": "Anthony Fu",
  "themes": [
    {
      "name": "Vitesse Dark Soft",
      "appearance": "dark",
      "style": {
        "border": "#252525",
        // ...
      }
    }
  ]
}
```

Themes placed in this directory will be loaded and available in the
theme selector.

Release Notes:

- Added support for loading user themes from `~/.config/zed/themes`.

Change summary

Cargo.lock                                  |   1 
crates/theme/Cargo.toml                     |   1 
crates/theme/src/registry.rs                | 104 +++++++++++++++-------
crates/theme/src/settings.rs                |  24 +++++
crates/theme/src/theme.rs                   |   2 
crates/theme_selector/src/theme_selector.rs |   2 
crates/util/src/paths.rs                    |   1 
crates/zed/src/main.rs                      |  32 ++++++
crates/zed/src/zed.rs                       |   2 
9 files changed, 131 insertions(+), 38 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7922,6 +7922,7 @@ dependencies = [
  "color",
  "derive_more",
  "fs",
+ "futures 0.3.28",
  "gpui",
  "indexmap 1.9.3",
  "itertools 0.11.0",

crates/theme/Cargo.toml 🔗

@@ -23,6 +23,7 @@ doctest = false
 anyhow.workspace = true
 derive_more.workspace = true
 fs = { path = "../fs" }
+futures.workspace = true
 gpui = { path = "../gpui" }
 indexmap = { version = "1.6.2", features = ["serde"] }
 palette = { version = "0.7.3", default-features = false, features = ["std"] }

crates/theme/src/registry.rs 🔗

@@ -1,9 +1,13 @@
 use std::collections::HashMap;
+use std::path::Path;
 use std::sync::Arc;
 
 use anyhow::{anyhow, Context, Result};
 use derive_more::{Deref, DerefMut};
+use fs::Fs;
+use futures::StreamExt;
 use gpui::{AppContext, AssetSource, HighlightStyle, SharedString};
+use parking_lot::RwLock;
 use refineable::Refineable;
 use util::ResultExt;
 
@@ -26,40 +30,41 @@ pub struct ThemeMeta {
 ///
 /// This should not be exposed outside of this module.
 #[derive(Default, Deref, DerefMut)]
-struct GlobalThemeRegistry(ThemeRegistry);
+struct GlobalThemeRegistry(Arc<ThemeRegistry>);
 
 /// Initializes the theme registry.
 pub fn init(assets: Box<dyn AssetSource>, cx: &mut AppContext) {
-    cx.set_global(GlobalThemeRegistry(ThemeRegistry::new(assets)));
+    cx.set_global(GlobalThemeRegistry(Arc::new(ThemeRegistry::new(assets))));
+}
+
+struct ThemeRegistryState {
+    themes: HashMap<SharedString, Arc<Theme>>,
 }
 
 pub struct ThemeRegistry {
+    state: RwLock<ThemeRegistryState>,
     assets: Box<dyn AssetSource>,
-    themes: HashMap<SharedString, Arc<Theme>>,
 }
 
 impl ThemeRegistry {
     /// Returns the global [`ThemeRegistry`].
-    pub fn global(cx: &AppContext) -> &Self {
-        cx.global::<GlobalThemeRegistry>()
-    }
-
-    /// Returns a mutable reference to the global [`ThemeRegistry`].
-    pub fn global_mut(cx: &mut AppContext) -> &mut Self {
-        cx.global_mut::<GlobalThemeRegistry>()
+    pub fn global(cx: &AppContext) -> Arc<Self> {
+        cx.global::<GlobalThemeRegistry>().0.clone()
     }
 
-    /// Returns a mutable reference to the global [`ThemeRegistry`].
+    /// Returns the global [`ThemeRegistry`].
     ///
     /// Inserts a default [`ThemeRegistry`] if one does not yet exist.
-    pub fn default_global(cx: &mut AppContext) -> &mut Self {
-        cx.default_global::<GlobalThemeRegistry>()
+    pub fn default_global(cx: &mut AppContext) -> Arc<Self> {
+        cx.default_global::<GlobalThemeRegistry>().0.clone()
     }
 
     pub fn new(assets: Box<dyn AssetSource>) -> Self {
-        let mut registry = Self {
+        let registry = Self {
+            state: RwLock::new(ThemeRegistryState {
+                themes: HashMap::new(),
+            }),
             assets,
-            themes: HashMap::new(),
         };
 
         // We're loading our new versions of the One themes by default, as
@@ -72,30 +77,27 @@ impl ThemeRegistry {
         registry
     }
 
-    fn insert_theme_families(&mut self, families: impl IntoIterator<Item = ThemeFamily>) {
+    fn insert_theme_families(&self, families: impl IntoIterator<Item = ThemeFamily>) {
         for family in families.into_iter() {
             self.insert_themes(family.themes);
         }
     }
 
-    fn insert_themes(&mut self, themes: impl IntoIterator<Item = Theme>) {
+    fn insert_themes(&self, themes: impl IntoIterator<Item = Theme>) {
+        let mut state = self.state.write();
         for theme in themes.into_iter() {
-            self.themes.insert(theme.name.clone(), Arc::new(theme));
+            state.themes.insert(theme.name.clone(), Arc::new(theme));
         }
     }
 
     #[allow(unused)]
-    fn insert_user_theme_families(
-        &mut self,
-        families: impl IntoIterator<Item = ThemeFamilyContent>,
-    ) {
+    fn insert_user_theme_families(&self, families: impl IntoIterator<Item = ThemeFamilyContent>) {
         for family in families.into_iter() {
             self.insert_user_themes(family.themes);
         }
     }
 
-    #[allow(unused)]
-    fn insert_user_themes(&mut self, themes: impl IntoIterator<Item = ThemeContent>) {
+    pub fn insert_user_themes(&self, themes: impl IntoIterator<Item = ThemeContent>) {
         self.insert_themes(themes.into_iter().map(|user_theme| {
             let mut theme_colors = match user_theme.appearance {
                 AppearanceContent::Light => ThemeColors::light(),
@@ -186,28 +188,36 @@ impl ThemeRegistry {
     }
 
     pub fn clear(&mut self) {
-        self.themes.clear();
+        self.state.write().themes.clear();
     }
 
-    pub fn list_names(&self, _staff: bool) -> impl Iterator<Item = SharedString> + '_ {
-        self.themes.keys().cloned()
+    pub fn list_names(&self, _staff: bool) -> Vec<SharedString> {
+        self.state.read().themes.keys().cloned().collect()
     }
 
-    pub fn list(&self, _staff: bool) -> impl Iterator<Item = ThemeMeta> + '_ {
-        self.themes.values().map(|theme| ThemeMeta {
-            name: theme.name.clone(),
-            appearance: theme.appearance(),
-        })
+    pub fn list(&self, _staff: bool) -> Vec<ThemeMeta> {
+        self.state
+            .read()
+            .themes
+            .values()
+            .map(|theme| ThemeMeta {
+                name: theme.name.clone(),
+                appearance: theme.appearance(),
+            })
+            .collect()
     }
 
     pub fn get(&self, name: &str) -> Result<Arc<Theme>> {
-        self.themes
+        self.state
+            .read()
+            .themes
             .get(name)
             .ok_or_else(|| anyhow!("theme not found: {}", name))
             .cloned()
     }
 
-    pub fn load_user_themes(&mut self) {
+    /// Loads the themes bundled with the Zed binary and adds them to the registry.
+    pub fn load_bundled_themes(&self) {
         let theme_paths = self
             .assets
             .list("themes/")
@@ -230,6 +240,32 @@ impl ThemeRegistry {
             self.insert_user_theme_families([theme_family]);
         }
     }
+
+    /// Loads the user themes from the specified directory and adds them to the registry.
+    pub async fn load_user_themes(&self, themes_path: &Path, fs: Arc<dyn Fs>) -> Result<()> {
+        let mut theme_paths = fs
+            .read_dir(themes_path)
+            .await
+            .with_context(|| format!("reading themes from {themes_path:?}"))?;
+
+        while let Some(theme_path) = theme_paths.next().await {
+            let Some(theme_path) = theme_path.log_err() else {
+                continue;
+            };
+
+            let Some(reader) = fs.open_sync(&theme_path).await.log_err() else {
+                continue;
+            };
+
+            let Some(theme) = serde_json::from_reader(reader).log_err() else {
+                continue;
+            };
+
+            self.insert_user_theme_families([theme]);
+        }
+
+        Ok(())
+    }
 }
 
 impl Default for ThemeRegistry {

crates/theme/src/settings.rs 🔗

@@ -26,6 +26,7 @@ pub struct ThemeSettings {
     pub buffer_font: Font,
     pub buffer_font_size: Pixels,
     pub buffer_line_height: BufferLineHeight,
+    pub requested_theme: Option<String>,
     pub active_theme: Arc<Theme>,
     pub theme_overrides: Option<ThemeStyleContent>,
 }
@@ -89,6 +90,25 @@ impl ThemeSettings {
         f32::max(self.buffer_line_height.value(), MIN_LINE_HEIGHT)
     }
 
+    /// Switches to the theme with the given name, if it exists.
+    ///
+    /// Returns a `Some` containing the new theme if it was successful.
+    /// Returns `None` otherwise.
+    pub fn switch_theme(&mut self, theme: &str, cx: &mut AppContext) -> Option<Arc<Theme>> {
+        let themes = ThemeRegistry::default_global(cx);
+
+        let mut new_theme = None;
+
+        if let Some(theme) = themes.get(&theme).log_err() {
+            self.active_theme = theme.clone();
+            new_theme = Some(theme);
+        }
+
+        self.apply_theme_overrides();
+
+        new_theme
+    }
+
     /// Applies the theme overrides, if there are any, to the current theme.
     pub fn apply_theme_overrides(&mut self) {
         if let Some(theme_overrides) = &self.theme_overrides {
@@ -182,6 +202,7 @@ 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(),
             active_theme: themes
                 .get(defaults.theme.as_ref().unwrap())
                 .or(themes.get(&one_dark().name))
@@ -205,6 +226,8 @@ impl settings::Settings for ThemeSettings {
             }
 
             if let Some(value) = &value.theme {
+                this.requested_theme = Some(value.clone());
+
                 if let Some(theme) = themes.get(value).log_err() {
                     this.active_theme = theme;
                 }
@@ -232,6 +255,7 @@ impl settings::Settings for ThemeSettings {
         let mut root_schema = generator.root_schema_for::<ThemeSettingsContent>();
         let theme_names = ThemeRegistry::global(cx)
             .list_names(params.staff_mode)
+            .into_iter()
             .map(|theme_name| Value::String(theme_name.to_string()))
             .collect();
 

crates/theme/src/theme.rs 🔗

@@ -63,7 +63,7 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut AppContext) {
     registry::init(assets, cx);
 
     if load_user_themes {
-        ThemeRegistry::global_mut(cx).load_user_themes();
+        ThemeRegistry::global(cx).load_bundled_themes();
     }
 
     ThemeSettings::register(cx);

crates/theme_selector/src/theme_selector.rs 🔗

@@ -103,7 +103,7 @@ impl ThemeSelectorDelegate {
 
         let staff_mode = cx.is_staff();
         let registry = ThemeRegistry::global(cx);
-        let mut themes = registry.list(staff_mode).collect::<Vec<_>>();
+        let mut themes = registry.list(staff_mode);
         themes.sort_unstable_by(|a, b| {
             a.appearance
                 .is_light()

crates/util/src/paths.rs 🔗

@@ -8,6 +8,7 @@ lazy_static::lazy_static! {
     pub static ref CONFIG_DIR: PathBuf = HOME.join(".config").join("zed");
     pub static ref CONVERSATIONS_DIR: PathBuf = HOME.join(".config/zed/conversations");
     pub static ref EMBEDDINGS_DIR: PathBuf = HOME.join(".config/zed/embeddings");
+    pub static ref THEMES_DIR: PathBuf = HOME.join(".config/zed/themes");
     pub static ref LOGS_DIR: PathBuf = HOME.join("Library/Logs/Zed");
     pub static ref SUPPORT_DIR: PathBuf = HOME.join("Library/Application Support/Zed");
     pub static ref PLUGINS_DIR: PathBuf = HOME.join("Library/Application Support/Zed/plugins");

crates/zed/src/main.rs 🔗

@@ -38,7 +38,7 @@ use std::{
     },
     thread,
 };
-use theme::ActiveTheme;
+use theme::{ActiveTheme, ThemeRegistry, ThemeSettings};
 use util::{
     async_maybe,
     channel::{parse_zed_link, AppCommitSha, ReleaseChannel, RELEASE_CHANNEL},
@@ -164,6 +164,36 @@ fn main() {
         );
         assistant::init(cx);
 
+        // TODO: Should we be loading the themes in a different spot?
+        cx.spawn({
+            let fs = fs.clone();
+            |cx| async move {
+                if let Some(theme_registry) =
+                    cx.update(|cx| ThemeRegistry::global(cx).clone()).log_err()
+                {
+                    if let Some(()) = theme_registry
+                        .load_user_themes(&paths::THEMES_DIR.clone(), fs)
+                        .await
+                        .log_err()
+                    {
+                        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)
+                                {
+                                    ThemeSettings::override_global(theme_settings, cx);
+                                }
+                            }
+                        })
+                        .log_err();
+                    }
+                }
+            }
+        })
+        .detach();
+
         cx.spawn(|_| watch_languages(fs.clone(), languages.clone()))
             .detach();
         watch_file_types(fs.clone(), cx);

crates/zed/src/zed.rs 🔗

@@ -2690,7 +2690,7 @@ mod tests {
         theme::init(theme::LoadThemes::JustBase, cx);
 
         let mut has_default_theme = false;
-        for theme_name in themes.list(false).map(|meta| meta.name) {
+        for theme_name in themes.list(false).into_iter().map(|meta| meta.name) {
             let theme = themes.get(&theme_name).unwrap();
             assert_eq!(theme.name, theme_name);
             if theme.name == ThemeSettings::get(None, cx).active_theme.name {