Skip to main content

herogpui_theme/
theme_registry.rs

1//! Loading [`ThemeDocument`] files into the [`ThemeProvider`], plus the
2//! built-in preset themes (HeroGPUI extension; `serde` feature).
3//!
4//! A theme file is one sparse [`ThemeDocument`] in JSON. The checked-in
5//! [`THEME_SCHEMA`] (`crates/herogpui-theme/theme.schema.json`) describes the
6//! format for editors and validators; point a `"$schema"`-aware editor at it
7//! through its settings (a document itself may not carry a `$schema` key,
8//! because documents reject unknown keys).
9//!
10//! Loading only *registers* themes; switch with [`use_theme`](crate::use_theme),
11//! which repaints every window.
12//!
13//! ```
14//! use herogpui_theme::{presets, ThemeDocument};
15//!
16//! for (id, json) in presets::PRESETS {
17//!     let theme = ThemeDocument::theme_from_json(json).unwrap();
18//!     assert_eq!(theme.id.as_ref(), *id);
19//! }
20//! ```
21
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25use gpui::{App, SharedString};
26
27use crate::{ThemeDocument, ThemeDocumentError, ThemeProvider};
28
29/// The JSON Schema (draft 2020-12) for a [`ThemeDocument`] file.
30pub const THEME_SCHEMA: &str = include_str!("../theme.schema.json");
31
32/// Why [`load_themes_dir`] or [`register_theme_json`] failed.
33#[derive(Debug)]
34pub enum ThemeLoadError {
35    /// A directory or file could not be read.
36    Io {
37        /// The path that could not be read.
38        path: PathBuf,
39        /// The underlying I/O error.
40        source: std::io::Error,
41    },
42    /// A file was read but is not a valid [`ThemeDocument`].
43    Document {
44        /// The offending file, or `None` when the JSON came from a string.
45        path: Option<PathBuf>,
46        /// The underlying document error.
47        source: ThemeDocumentError,
48    },
49}
50
51impl fmt::Display for ThemeLoadError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
55            Self::Document {
56                path: Some(path),
57                source,
58            } => write!(f, "{}: {source}", path.display()),
59            Self::Document { path: None, source } => source.fmt(f),
60        }
61    }
62}
63
64impl std::error::Error for ThemeLoadError {
65    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
66        match self {
67            Self::Io { source, .. } => Some(source),
68            Self::Document { source, .. } => Some(source),
69        }
70    }
71}
72
73/// Parses one JSON [`ThemeDocument`] and registers it without activating
74/// it. Returns the theme id. A theme with the same id is replaced.
75pub fn register_theme_json(json: &str, cx: &mut App) -> Result<SharedString, ThemeLoadError> {
76    let theme = ThemeDocument::theme_from_json(json)
77        .map_err(|source| ThemeLoadError::Document { path: None, source })?;
78    let id = theme.id.clone();
79    cx.global_mut::<ThemeProvider>().insert(theme);
80    cx.refresh_windows();
81    Ok(id)
82}
83
84/// Registers every `*.json` file in `dir` (not recursive) as a theme,
85/// without activating any. Files load in name order, so the result is
86/// deterministic; the first invalid file aborts the load with its path, and
87/// the files before it stay registered. Returns the registered ids.
88pub fn load_themes_dir(
89    dir: impl AsRef<Path>,
90    cx: &mut App,
91) -> Result<Vec<SharedString>, ThemeLoadError> {
92    let dir = dir.as_ref();
93    let io = |path: &Path| {
94        let path = path.to_path_buf();
95        move |source| ThemeLoadError::Io { path, source }
96    };
97    let files = theme_files(dir).map_err(io(dir))?;
98    let mut ids = Vec::with_capacity(files.len());
99    for path in files {
100        let json = std::fs::read_to_string(&path).map_err(io(&path))?;
101        ids.push(register_theme_file(&path, &json, cx)?);
102    }
103    Ok(ids)
104}
105
106/// The theme files of `dir` (`*.json`, not recursive), sorted by name.
107pub(crate) fn theme_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
108    let mut files: Vec<PathBuf> = std::fs::read_dir(dir)?
109        .filter_map(|entry| entry.ok().map(|e| e.path()))
110        .filter(|path| path.is_file() && path.extension().is_some_and(|e| e == "json"))
111        .collect();
112    files.sort();
113    Ok(files)
114}
115
116/// [`register_theme_json`] for the contents of `path`, naming the file in
117/// the error.
118pub(crate) fn register_theme_file(
119    path: &Path,
120    json: &str,
121    cx: &mut App,
122) -> Result<SharedString, ThemeLoadError> {
123    register_theme_json(json, cx).map_err(|err| match err {
124        ThemeLoadError::Document { source, .. } => ThemeLoadError::Document {
125            path: Some(path.to_path_buf()),
126            source,
127        },
128        other => other,
129    })
130}
131
132/// Built-in preset themes, shipped as [`ThemeDocument`] JSON under
133/// `crates/herogpui-theme/themes/`. They are HeroGPUI's own palettes over
134/// v3's light and dark bases, not HeroUI themes.
135pub mod presets {
136    use super::*;
137
138    /// `(id, json)` for every preset, in display order.
139    pub const PRESETS: &[(&str, &str)] = &[
140        ("ocean", include_str!("../themes/ocean.json")),
141        ("forest", include_str!("../themes/forest.json")),
142        ("midnight", include_str!("../themes/midnight.json")),
143        ("rose", include_str!("../themes/rose.json")),
144    ];
145
146    /// Registers every preset without activating one; returns their ids.
147    pub fn register_presets(cx: &mut App) -> Vec<SharedString> {
148        PRESETS
149            .iter()
150            .map(|(_, json)| {
151                register_theme_json(json, cx).expect("built-in presets are tested to parse")
152            })
153            .collect()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::{use_theme, ActiveTheme, Appearance};
161    use gpui::TestAppContext;
162
163    #[test]
164    fn every_preset_parses_and_keeps_its_id() {
165        for (id, json) in presets::PRESETS {
166            let theme = ThemeDocument::theme_from_json(json).unwrap();
167            assert_eq!(theme.id.as_ref(), *id);
168        }
169    }
170
171    /// The checked-in schema names exactly the document's keys, requires
172    /// `id` and `base`, and rejects unknown keys, as the struct does.
173    #[test]
174    fn schema_matches_the_document_struct() {
175        let schema: serde_json::Value = serde_json::from_str(THEME_SCHEMA).unwrap();
176        let mut keys: Vec<&str> = schema["properties"]
177            .as_object()
178            .unwrap()
179            .keys()
180            .map(String::as_str)
181            .collect();
182        keys.sort_unstable();
183        let source = include_str!("theme_document.rs");
184        let body = &source[source.find("pub struct ThemeDocument {").unwrap()..];
185        let body = &body[body.find('\n').unwrap()..body.find("\n}").unwrap()];
186        let mut fields: Vec<&str> = body
187            .lines()
188            .filter_map(|line| line.trim().strip_prefix("pub "))
189            .filter_map(|rest| rest.split(':').next())
190            .collect();
191        fields.sort_unstable();
192        assert_eq!(keys, fields);
193        assert_eq!(schema["required"], serde_json::json!(["id", "base"]));
194        assert_eq!(schema["additionalProperties"], serde_json::json!(false));
195    }
196
197    #[gpui::test]
198    fn presets_register_without_activating_then_switch(cx: &mut TestAppContext) {
199        cx.update(|cx| {
200            ThemeProvider::init(cx);
201            let ids = presets::register_presets(cx);
202            assert_eq!(ids, ["ocean", "forest", "midnight", "rose"]);
203            assert_eq!(ThemeProvider::get(cx).active_id().as_ref(), "light");
204            use_theme("midnight", cx).unwrap();
205            assert_eq!(cx.theme().appearance, Appearance::Dark);
206            assert!(ThemeProvider::get(cx)
207                .theme_ids()
208                .iter()
209                .any(|id| id == "rose"));
210        });
211    }
212
213    #[gpui::test]
214    fn load_dir_registers_json_files_in_name_order(cx: &mut TestAppContext) {
215        let dir = std::env::temp_dir().join(format!("herogpui-themes-{}", std::process::id()));
216        std::fs::create_dir_all(&dir).unwrap();
217        std::fs::write(dir.join("b.json"), r#"{"id":"beta","base":"dark"}"#).unwrap();
218        std::fs::write(dir.join("a.json"), r#"{"id":"alpha","base":"light"}"#).unwrap();
219        std::fs::write(dir.join("notes.txt"), "ignored").unwrap();
220        cx.update(|cx| {
221            ThemeProvider::init(cx);
222            let ids = load_themes_dir(&dir, cx).unwrap();
223            assert_eq!(ids, ["alpha", "beta"]);
224            std::fs::write(
225                dir.join("c.json"),
226                r#"{"id":"gamma","base":"light","nope":1}"#,
227            )
228            .unwrap();
229            let err = load_themes_dir(&dir, cx).unwrap_err();
230            assert!(err.to_string().contains("c.json"), "{err}");
231        });
232        std::fs::remove_dir_all(&dir).unwrap();
233    }
234}