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        path: PathBuf,
38        source: std::io::Error,
39    },
40    /// A file was read but is not a valid [`ThemeDocument`].
41    Document {
42        path: Option<PathBuf>,
43        source: ThemeDocumentError,
44    },
45}
46
47impl fmt::Display for ThemeLoadError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
51            Self::Document {
52                path: Some(path),
53                source,
54            } => write!(f, "{}: {source}", path.display()),
55            Self::Document { path: None, source } => source.fmt(f),
56        }
57    }
58}
59
60impl std::error::Error for ThemeLoadError {
61    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
62        match self {
63            Self::Io { source, .. } => Some(source),
64            Self::Document { source, .. } => Some(source),
65        }
66    }
67}
68
69/// Parses one JSON [`ThemeDocument`] and registers it without activating
70/// it. Returns the theme id. A theme with the same id is replaced.
71pub fn register_theme_json(json: &str, cx: &mut App) -> Result<SharedString, ThemeLoadError> {
72    let theme = ThemeDocument::theme_from_json(json)
73        .map_err(|source| ThemeLoadError::Document { path: None, source })?;
74    let id = theme.id.clone();
75    cx.global_mut::<ThemeProvider>().insert(theme);
76    cx.refresh_windows();
77    Ok(id)
78}
79
80/// Registers every `*.json` file in `dir` (not recursive) as a theme,
81/// without activating any. Files load in name order, so the result is
82/// deterministic; the first invalid file aborts the load with its path, and
83/// the files before it stay registered. Returns the registered ids.
84pub fn load_themes_dir(
85    dir: impl AsRef<Path>,
86    cx: &mut App,
87) -> Result<Vec<SharedString>, ThemeLoadError> {
88    let dir = dir.as_ref();
89    let io = |path: &Path| {
90        let path = path.to_path_buf();
91        move |source| ThemeLoadError::Io { path, source }
92    };
93    let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
94        .map_err(io(dir))?
95        .filter_map(|entry| entry.ok().map(|e| e.path()))
96        .filter(|path| path.is_file() && path.extension().is_some_and(|e| e == "json"))
97        .collect();
98    files.sort();
99    let mut ids = Vec::with_capacity(files.len());
100    for path in files {
101        let json = std::fs::read_to_string(&path).map_err(io(&path))?;
102        let id = register_theme_json(&json, cx).map_err(|err| match err {
103            ThemeLoadError::Document { source, .. } => ThemeLoadError::Document {
104                path: Some(path.clone()),
105                source,
106            },
107            other => other,
108        })?;
109        ids.push(id);
110    }
111    Ok(ids)
112}
113
114/// Built-in preset themes, shipped as [`ThemeDocument`] JSON under
115/// `crates/herogpui-theme/themes/`. They are HeroGPUI's own palettes over
116/// v3's light and dark bases, not HeroUI themes.
117pub mod presets {
118    use super::*;
119
120    /// `(id, json)` for every preset, in display order.
121    pub const PRESETS: &[(&str, &str)] = &[
122        ("ocean", include_str!("../themes/ocean.json")),
123        ("forest", include_str!("../themes/forest.json")),
124        ("midnight", include_str!("../themes/midnight.json")),
125        ("rose", include_str!("../themes/rose.json")),
126    ];
127
128    /// Registers every preset without activating one; returns their ids.
129    pub fn register_presets(cx: &mut App) -> Vec<SharedString> {
130        PRESETS
131            .iter()
132            .map(|(_, json)| {
133                register_theme_json(json, cx).expect("built-in presets are tested to parse")
134            })
135            .collect()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::{use_theme, ActiveTheme, Appearance};
143    use gpui::TestAppContext;
144
145    #[test]
146    fn every_preset_parses_and_keeps_its_id() {
147        for (id, json) in presets::PRESETS {
148            let theme = ThemeDocument::theme_from_json(json).unwrap();
149            assert_eq!(theme.id.as_ref(), *id);
150        }
151    }
152
153    /// The checked-in schema names exactly the document's keys, requires
154    /// `id` and `base`, and rejects unknown keys, as the struct does.
155    #[test]
156    fn schema_matches_the_document_struct() {
157        let schema: serde_json::Value = serde_json::from_str(THEME_SCHEMA).unwrap();
158        let mut keys: Vec<&str> = schema["properties"]
159            .as_object()
160            .unwrap()
161            .keys()
162            .map(String::as_str)
163            .collect();
164        keys.sort_unstable();
165        let source = include_str!("theme_document.rs");
166        let body = &source[source.find("pub struct ThemeDocument {").unwrap()..];
167        let body = &body[body.find('\n').unwrap()..body.find("\n}").unwrap()];
168        let mut fields: Vec<&str> = body
169            .lines()
170            .filter_map(|line| line.trim().strip_prefix("pub "))
171            .filter_map(|rest| rest.split(':').next())
172            .collect();
173        fields.sort_unstable();
174        assert_eq!(keys, fields);
175        assert_eq!(schema["required"], serde_json::json!(["id", "base"]));
176        assert_eq!(schema["additionalProperties"], serde_json::json!(false));
177    }
178
179    #[gpui::test]
180    fn presets_register_without_activating_then_switch(cx: &mut TestAppContext) {
181        cx.update(|cx| {
182            ThemeProvider::init(cx);
183            let ids = presets::register_presets(cx);
184            assert_eq!(ids, ["ocean", "forest", "midnight", "rose"]);
185            assert_eq!(ThemeProvider::get(cx).active_id().as_ref(), "light");
186            use_theme("midnight", cx).unwrap();
187            assert_eq!(cx.theme().appearance, Appearance::Dark);
188            assert!(ThemeProvider::get(cx)
189                .theme_ids()
190                .iter()
191                .any(|id| id == "rose"));
192        });
193    }
194
195    #[gpui::test]
196    fn load_dir_registers_json_files_in_name_order(cx: &mut TestAppContext) {
197        let dir = std::env::temp_dir().join(format!("herogpui-themes-{}", std::process::id()));
198        std::fs::create_dir_all(&dir).unwrap();
199        std::fs::write(dir.join("b.json"), r#"{"id":"beta","base":"dark"}"#).unwrap();
200        std::fs::write(dir.join("a.json"), r#"{"id":"alpha","base":"light"}"#).unwrap();
201        std::fs::write(dir.join("notes.txt"), "ignored").unwrap();
202        cx.update(|cx| {
203            ThemeProvider::init(cx);
204            let ids = load_themes_dir(&dir, cx).unwrap();
205            assert_eq!(ids, ["alpha", "beta"]);
206            std::fs::write(
207                dir.join("c.json"),
208                r#"{"id":"gamma","base":"light","nope":1}"#,
209            )
210            .unwrap();
211            let err = load_themes_dir(&dir, cx).unwrap_err();
212            assert!(err.to_string().contains("c.json"), "{err}");
213        });
214        std::fs::remove_dir_all(&dir).unwrap();
215    }
216}