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}