1use 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,
39 source: std::io::Error,
41 },
42 Document {
44 path: Option<PathBuf>,
46 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
73pub 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
84pub 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
106pub(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
116pub(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
132pub mod presets {
136 use super::*;
137
138 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 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 #[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}