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`.
This commit is contained in:
Marshall Bowers 2024-01-29 21:32:45 -05:00 committed by GitHub
parent 5f4dd36a1a
commit 9cb5a84b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 131 additions and 38 deletions

1
Cargo.lock generated
View File

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

View File

@ -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"] }

View File

@ -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>()
pub fn global(cx: &AppContext) -> Arc<Self> {
cx.global::<GlobalThemeRegistry>().0.clone()
}
/// Returns a mutable reference to the global [`ThemeRegistry`].
pub fn global_mut(cx: &mut AppContext) -> &mut Self {
cx.global_mut::<GlobalThemeRegistry>()
}
/// 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 {

View File

@ -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();

View File

@ -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);

View File

@ -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()

View File

@ -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");

View File

@ -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);

View File

@ -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 {