herogpui_theme/
theme_registry.rs1use std::fmt;
23use std::path::{Path, PathBuf};
24
25use gpui::{App, SharedString};
26
27use crate::{ThemeDocument, ThemeDocumentError, ThemeProvider};
28
29pub const THEME_SCHEMA: &str = include_str!("../theme.schema.json");
31
32#[derive(Debug)]
34pub enum ThemeLoadError {
35 Io {
37 path: PathBuf,
38 source: std::io::Error,
39 },
40 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
69pub 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
80pub 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
114pub mod presets {
118 use super::*;
119
120 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 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 #[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}