theme_registry.rs

  1use crate::{resolution::resolve_references, Theme};
  2use anyhow::{Context, Result};
  3use gpui::{fonts, AssetSource, FontCache};
  4use parking_lot::Mutex;
  5use serde_json::{Map, Value};
  6use std::{collections::HashMap, sync::Arc};
  7
  8pub struct ThemeRegistry {
  9    assets: Box<dyn AssetSource>,
 10    themes: Mutex<HashMap<String, Arc<Theme>>>,
 11    theme_data: Mutex<HashMap<String, Arc<Value>>>,
 12    font_cache: Arc<FontCache>,
 13}
 14
 15impl ThemeRegistry {
 16    pub fn new(source: impl AssetSource, font_cache: Arc<FontCache>) -> Arc<Self> {
 17        Arc::new(Self {
 18            assets: Box::new(source),
 19            themes: Default::default(),
 20            theme_data: Default::default(),
 21            font_cache,
 22        })
 23    }
 24
 25    pub fn list(&self) -> impl Iterator<Item = String> {
 26        self.assets.list("themes/").into_iter().filter_map(|path| {
 27            let filename = path.strip_prefix("themes/")?;
 28            let theme_name = filename.strip_suffix(".toml")?;
 29            if theme_name.starts_with('_') {
 30                None
 31            } else {
 32                Some(theme_name.to_string())
 33            }
 34        })
 35    }
 36
 37    pub fn clear(&self) {
 38        self.theme_data.lock().clear();
 39        self.themes.lock().clear();
 40    }
 41
 42    pub fn get(&self, name: &str) -> Result<Arc<Theme>> {
 43        if let Some(theme) = self.themes.lock().get(name) {
 44            return Ok(theme.clone());
 45        }
 46
 47        let theme_data = self.load(name, true)?;
 48        let mut theme: Theme = fonts::with_font_cache(self.font_cache.clone(), || {
 49            serde_path_to_error::deserialize(theme_data.as_ref())
 50        })?;
 51
 52        theme.name = name.into();
 53        let theme = Arc::new(theme);
 54        self.themes.lock().insert(name.to_string(), theme.clone());
 55        Ok(theme)
 56    }
 57
 58    fn load(&self, name: &str, evaluate_references: bool) -> Result<Arc<Value>> {
 59        if let Some(data) = self.theme_data.lock().get(name) {
 60            return Ok(data.clone());
 61        }
 62
 63        let asset_path = format!("themes/{}.toml", name);
 64        let source_code = self
 65            .assets
 66            .load(&asset_path)
 67            .with_context(|| format!("failed to load theme file {}", asset_path))?;
 68
 69        let mut theme_data: Map<String, Value> = toml::from_slice(source_code.as_ref())
 70            .with_context(|| format!("failed to parse {}.toml", name))?;
 71
 72        // If this theme extends another base theme, deeply merge it into the base theme's data
 73        if let Some(base_name) = theme_data
 74            .get("extends")
 75            .and_then(|name| name.as_str())
 76            .map(str::to_string)
 77        {
 78            let base_theme_data = self
 79                .load(&base_name, false)
 80                .with_context(|| format!("failed to load base theme {}", base_name))?
 81                .as_ref()
 82                .clone();
 83            if let Value::Object(mut base_theme_object) = base_theme_data {
 84                deep_merge_json(&mut base_theme_object, theme_data);
 85                theme_data = base_theme_object;
 86            }
 87        }
 88
 89        let mut theme_data = Value::Object(theme_data);
 90
 91        // Find all of the key path references in the object, and then sort them according
 92        // to their dependencies.
 93        if evaluate_references {
 94            theme_data = resolve_references(theme_data)?;
 95        }
 96
 97        let result = Arc::new(theme_data);
 98        self.theme_data
 99            .lock()
100            .insert(name.to_string(), result.clone());
101
102        Ok(result)
103    }
104}
105
106fn deep_merge_json(base: &mut Map<String, Value>, extension: Map<String, Value>) {
107    for (key, extension_value) in extension {
108        if let Value::Object(extension_object) = extension_value {
109            if let Some(base_object) = base.get_mut(&key).and_then(|value| value.as_object_mut()) {
110                deep_merge_json(base_object, extension_object);
111            } else {
112                base.insert(key, Value::Object(extension_object));
113            }
114        } else {
115            base.insert(key, extension_value);
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use anyhow::anyhow;
124    use gpui::MutableAppContext;
125
126    #[gpui::test]
127    fn test_theme_extension(cx: &mut MutableAppContext) {
128        let assets = TestAssets(&[
129            (
130                "themes/_base.toml",
131                r##"
132                [ui.active_tab]
133                extends = "$ui.tab"
134                border.color = "#666666"
135                text = "$text_colors.bright"
136
137                [ui.tab]
138                extends = "$ui.element"
139                text = "$text_colors.dull"
140
141                [ui.element]
142                background = "#111111"
143                border = {width = 2.0, color = "#00000000"}
144
145                [editor]
146                background = "#222222"
147                default_text = "$text_colors.regular"
148                "##,
149            ),
150            (
151                "themes/light.toml",
152                r##"
153                extends = "_base"
154
155                [text_colors]
156                bright = "#ffffff"
157                regular = "#eeeeee"
158                dull = "#dddddd"
159
160                [editor]
161                background = "#232323"
162                "##,
163            ),
164        ]);
165
166        let registry = ThemeRegistry::new(assets, cx.font_cache().clone());
167        let theme_data = registry.load("light", true).unwrap();
168
169        assert_eq!(
170            theme_data.as_ref(),
171            &serde_json::json!({
172              "ui": {
173                "active_tab": {
174                  "background": "#111111",
175                  "border": {
176                    "width": 2.0,
177                    "color": "#666666"
178                  },
179                  "extends": "$ui.tab",
180                  "text": "#ffffff"
181                },
182                "tab": {
183                  "background": "#111111",
184                  "border": {
185                    "width": 2.0,
186                    "color": "#00000000"
187                  },
188                  "extends": "$ui.element",
189                  "text": "#dddddd"
190                },
191                "element": {
192                  "background": "#111111",
193                  "border": {
194                    "width": 2.0,
195                    "color": "#00000000"
196                  }
197                }
198              },
199              "editor": {
200                "background": "#232323",
201                "default_text": "#eeeeee"
202              },
203              "extends": "_base",
204              "text_colors": {
205                "bright": "#ffffff",
206                "regular": "#eeeeee",
207                "dull": "#dddddd"
208              }
209            })
210        );
211    }
212
213    #[gpui::test]
214    fn test_nested_extension(cx: &mut MutableAppContext) {
215        let assets = TestAssets(&[(
216            "themes/theme.toml",
217            r##"
218                [a]
219                text = { extends = "$text.0" }
220
221                [b]
222                extends = "$a"
223                text = { extends = "$text.1" }
224
225                [text]
226                0 = { color = "red" }
227                1 = { color = "blue" }
228            "##,
229        )]);
230
231        let registry = ThemeRegistry::new(assets, cx.font_cache().clone());
232        let theme_data = registry.load("theme", true).unwrap();
233        assert_eq!(
234            theme_data
235                .get("b")
236                .unwrap()
237                .get("text")
238                .unwrap()
239                .get("color")
240                .unwrap(),
241            "blue"
242        );
243    }
244
245    struct TestAssets(&'static [(&'static str, &'static str)]);
246
247    impl AssetSource for TestAssets {
248        fn load(&self, path: &str) -> Result<std::borrow::Cow<[u8]>> {
249            if let Some(row) = self.0.iter().find(|e| e.0 == path) {
250                Ok(row.1.as_bytes().into())
251            } else {
252                Err(anyhow!("no such path {}", path))
253            }
254        }
255
256        fn list(&self, prefix: &str) -> Vec<std::borrow::Cow<'static, str>> {
257            self.0
258                .iter()
259                .copied()
260                .filter_map(|(path, _)| {
261                    if path.starts_with(prefix) {
262                        Some(path.into())
263                    } else {
264                        None
265                    }
266                })
267                .collect()
268        }
269    }
270}