gpui_component/theme/
registry.rs

1use crate::{highlighter::HighlightTheme, Theme, ThemeColor, ThemeConfig, ThemeMode, ThemeSet};
2use anyhow::Result;
3use gpui::{App, Global, SharedString};
4use notify::Watcher as _;
5use std::{
6    collections::HashMap,
7    fs,
8    path::PathBuf,
9    rc::Rc,
10    sync::{Arc, LazyLock},
11};
12
13const DEFAULT_THEME: &str = include_str!("./default-theme.json");
14pub(crate) const DEFAULT_THEME_COLORS: LazyLock<
15    HashMap<ThemeMode, (Arc<ThemeColor>, Arc<HighlightTheme>)>,
16> = LazyLock::new(|| {
17    let mut colors = HashMap::new();
18
19    let themes: Vec<ThemeConfig> = serde_json::from_str::<ThemeSet>(DEFAULT_THEME)
20        .expect("Failed to parse themes/default.json")
21        .themes;
22
23    for theme in themes {
24        let mut theme_color = ThemeColor::default();
25        theme_color.apply_config(&theme, &ThemeColor::default());
26
27        let highlight_theme = HighlightTheme {
28            name: theme.name.to_string(),
29            appearance: theme.mode,
30            style: theme.highlight.unwrap_or_default(),
31        };
32
33        colors.insert(
34            theme.mode,
35            (Arc::new(theme_color), Arc::new(highlight_theme)),
36        );
37    }
38
39    colors
40});
41
42pub(super) fn init(cx: &mut App) {
43    cx.set_global(ThemeRegistry::default());
44    ThemeRegistry::global_mut(cx).init_default_themes();
45
46    // Observe changes to the theme registry to apply changes to the active theme
47    cx.observe_global::<ThemeRegistry>(|cx| {
48        let mode = Theme::global(cx).mode;
49        let light_theme = Theme::global(cx).light_theme.name.clone();
50        let dark_theme = Theme::global(cx).dark_theme.name.clone();
51
52        if let Some(theme) = ThemeRegistry::global(cx)
53            .themes()
54            .get(&light_theme)
55            .cloned()
56        {
57            Theme::global_mut(cx).light_theme = theme;
58        }
59        if let Some(theme) = ThemeRegistry::global(cx).themes().get(&dark_theme).cloned() {
60            Theme::global_mut(cx).dark_theme = theme;
61        }
62
63        let theme_name = if mode.is_dark() {
64            dark_theme
65        } else {
66            light_theme
67        };
68
69        tracing::info!("Reload active theme: {:?}...", theme_name);
70        Theme::change(mode, None, cx);
71        cx.refresh_windows();
72    })
73    .detach();
74}
75
76#[derive(Default, Debug)]
77pub struct ThemeRegistry {
78    themes_dir: PathBuf,
79    default_themes: HashMap<ThemeMode, Rc<ThemeConfig>>,
80    themes: HashMap<SharedString, Rc<ThemeConfig>>,
81    has_custom_themes: bool,
82}
83
84impl Global for ThemeRegistry {}
85
86impl ThemeRegistry {
87    pub fn global(cx: &App) -> &Self {
88        cx.global::<Self>()
89    }
90
91    pub fn global_mut(cx: &mut App) -> &mut Self {
92        cx.global_mut::<Self>()
93    }
94
95    /// Watch themes directory.
96    ///
97    /// And reload themes to trigger the `on_load` callback.
98    pub fn watch_dir<F>(themes_dir: PathBuf, cx: &mut App, on_load: F) -> Result<()>
99    where
100        F: Fn(&mut App) + 'static,
101    {
102        Self::global_mut(cx).themes_dir = themes_dir.clone();
103
104        // Load theme in the background.
105        cx.spawn(async move |cx| {
106            _ = cx.update(|cx| {
107                if let Err(err) = Self::_watch_themes_dir(themes_dir, cx) {
108                    tracing::error!("Failed to watch themes directory: {}", err);
109                }
110
111                Self::reload_themes(cx);
112                on_load(cx);
113            });
114        })
115        .detach();
116
117        Ok(())
118    }
119
120    /// Returns a reference to the map of themes (including default themes).
121    pub fn themes(&self) -> &HashMap<SharedString, Rc<ThemeConfig>> {
122        &self.themes
123    }
124
125    /// Returns a sorted list of themes.
126    pub fn sorted_themes(&self) -> Vec<&Rc<ThemeConfig>> {
127        let mut themes = self.themes.values().collect::<Vec<_>>();
128        // sort by is_default true first, then light first dark later, then by name case-insensitive
129        themes.sort_by(|a, b| {
130            b.is_default
131                .cmp(&a.is_default)
132                .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
133        });
134        themes
135    }
136
137    /// Returns a reference to the map of default themes.
138    pub fn default_themes(&self) -> &HashMap<ThemeMode, Rc<ThemeConfig>> {
139        &self.default_themes
140    }
141
142    pub fn default_light_theme(&self) -> &Rc<ThemeConfig> {
143        &self.default_themes[&ThemeMode::Light]
144    }
145
146    pub fn default_dark_theme(&self) -> &Rc<ThemeConfig> {
147        &self.default_themes[&ThemeMode::Dark]
148    }
149
150    fn init_default_themes(&mut self) {
151        let default_themes: Vec<ThemeConfig> = serde_json::from_str::<ThemeSet>(DEFAULT_THEME)
152            .expect("failed to parse default theme.")
153            .themes;
154        for theme in default_themes.into_iter() {
155            if theme.mode.is_dark() {
156                self.default_themes.insert(ThemeMode::Dark, Rc::new(theme));
157            } else {
158                self.default_themes.insert(ThemeMode::Light, Rc::new(theme));
159            }
160        }
161        self.themes = self
162            .default_themes
163            .values()
164            .map(|theme| {
165                let name = theme.name.clone();
166                (name, Rc::clone(theme))
167            })
168            .collect();
169    }
170
171    fn _watch_themes_dir(themes_dir: PathBuf, cx: &mut App) -> anyhow::Result<()> {
172        if !themes_dir.exists() {
173            fs::create_dir_all(&themes_dir)?;
174        }
175
176        let (tx, rx) = smol::channel::bounded(100);
177        let mut watcher =
178            notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
179                if let Ok(event) = &res {
180                    match event.kind {
181                        notify::EventKind::Create(_)
182                        | notify::EventKind::Modify(_)
183                        | notify::EventKind::Remove(_) => {
184                            if let Err(err) = tx.send_blocking(res) {
185                                tracing::error!("Failed to send theme event: {:?}", err);
186                            }
187                        }
188                        _ => {}
189                    }
190                }
191            })?;
192
193        cx.spawn(async move |cx| {
194            if let Err(err) = watcher.watch(&themes_dir, notify::RecursiveMode::Recursive) {
195                tracing::error!("Failed to watch themes directory: {:?}", err);
196            }
197
198            while (rx.recv().await).is_ok() {
199                tracing::info!("Reloading themes...");
200                _ = cx.update(Self::reload_themes);
201            }
202        })
203        .detach();
204
205        Ok(())
206    }
207
208    fn reload_themes(cx: &mut App) {
209        let registry = Self::global_mut(cx);
210        match registry.reload() {
211            Ok(_) => {
212                tracing::info!("Themes reloaded successfully.");
213            }
214            Err(e) => tracing::error!("Failed to reload themes: {:?}", e),
215        }
216    }
217
218    /// Reload themes from the `themes_dir`.
219    fn reload(&mut self) -> Result<()> {
220        let mut themes = vec![];
221
222        if self.themes_dir.exists() {
223            for entry in fs::read_dir(&self.themes_dir)? {
224                let entry = entry?;
225                let path = entry.path();
226                if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
227                    let file_content = fs::read_to_string(path.clone())?;
228
229                    match serde_json::from_str::<ThemeSet>(&file_content) {
230                        Ok(theme_set) => {
231                            themes.extend(theme_set.themes);
232                        }
233                        Err(e) => {
234                            tracing::error!(
235                                "ignored invalid theme file: {}, {}",
236                                path.display(),
237                                e
238                            );
239                        }
240                    }
241                }
242            }
243        }
244
245        self.themes.clear();
246        for theme in self.default_themes.values() {
247            self.themes
248                .insert(theme.name.clone(), Rc::new((**theme).clone()));
249        }
250
251        for theme in themes.iter() {
252            if self.themes.contains_key(&theme.name) {
253                continue;
254            }
255
256            if theme.is_default {
257                self.default_themes
258                    .insert(theme.mode, Rc::new(theme.clone()));
259            }
260
261            self.has_custom_themes = true;
262            self.themes
263                .insert(theme.name.clone(), Rc::new(theme.clone()));
264        }
265
266        Ok(())
267    }
268}