From 9cb5a84b8d37062bdd0f0afe7aeae64f714d1568 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 29 Jan 2024 21:32:45 -0500 Subject: [PATCH] Add support for loading user themes (#7027) 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`. --- 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(-) diff --git a/Cargo.lock b/Cargo.lock index 1562c4a7b3..3817ba633c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7922,6 +7922,7 @@ dependencies = [ "color", "derive_more", "fs", + "futures 0.3.28", "gpui", "indexmap 1.9.3", "itertools 0.11.0", diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index a4b177fb9b..b7ffb1cb13 100644 --- a/crates/theme/Cargo.toml +++ b/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"] } diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index c44e7c11dd..afd2983f04 100644 --- a/crates/theme/src/registry.rs +++ b/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); /// Initializes the theme registry. pub fn init(assets: Box, cx: &mut AppContext) { - cx.set_global(GlobalThemeRegistry(ThemeRegistry::new(assets))); + cx.set_global(GlobalThemeRegistry(Arc::new(ThemeRegistry::new(assets)))); +} + +struct ThemeRegistryState { + themes: HashMap>, } pub struct ThemeRegistry { + state: RwLock, assets: Box, - themes: HashMap>, } impl ThemeRegistry { /// Returns the global [`ThemeRegistry`]. - pub fn global(cx: &AppContext) -> &Self { - cx.global::() + pub fn global(cx: &AppContext) -> Arc { + cx.global::().0.clone() } - /// Returns a mutable reference to the global [`ThemeRegistry`]. - pub fn global_mut(cx: &mut AppContext) -> &mut Self { - cx.global_mut::() - } - - /// 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::() + pub fn default_global(cx: &mut AppContext) -> Arc { + cx.default_global::().0.clone() } pub fn new(assets: Box) -> 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) { + fn insert_theme_families(&self, families: impl IntoIterator) { for family in families.into_iter() { self.insert_themes(family.themes); } } - fn insert_themes(&mut self, themes: impl IntoIterator) { + fn insert_themes(&self, themes: impl IntoIterator) { + 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, - ) { + fn insert_user_theme_families(&self, families: impl IntoIterator) { for family in families.into_iter() { self.insert_user_themes(family.themes); } } - #[allow(unused)] - fn insert_user_themes(&mut self, themes: impl IntoIterator) { + pub fn insert_user_themes(&self, themes: impl IntoIterator) { 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 + '_ { - self.themes.keys().cloned() + pub fn list_names(&self, _staff: bool) -> Vec { + self.state.read().themes.keys().cloned().collect() } - pub fn list(&self, _staff: bool) -> impl Iterator + '_ { - self.themes.values().map(|theme| ThemeMeta { - name: theme.name.clone(), - appearance: theme.appearance(), - }) + pub fn list(&self, _staff: bool) -> Vec { + self.state + .read() + .themes + .values() + .map(|theme| ThemeMeta { + name: theme.name.clone(), + appearance: theme.appearance(), + }) + .collect() } pub fn get(&self, name: &str) -> Result> { - 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) -> 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 { diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index 828a2f5de5..67c0814dfa 100644 --- a/crates/theme/src/settings.rs +++ b/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, pub active_theme: Arc, pub theme_overrides: Option, } @@ -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> { + 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::(); let theme_names = ThemeRegistry::global(cx) .list_names(params.staff_mode) + .into_iter() .map(|theme_name| Value::String(theme_name.to_string())) .collect(); diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index bb25133210..ba0203d999 100644 --- a/crates/theme/src/theme.rs +++ b/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); diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index d67ff9ba71..1e87785bc2 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/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::>(); + let mut themes = registry.list(staff_mode); themes.sort_unstable_by(|a, b| { a.appearance .is_light() diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 2df28def4c..cfab7d0a40 100644 --- a/crates/util/src/paths.rs +++ b/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"); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 3e546ca547..5955d79a59 100644 --- a/crates/zed/src/main.rs +++ b/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); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f045e62f83..95d3d751b0 100644 --- a/crates/zed/src/zed.rs +++ b/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 {