1use std::fmt;
42use std::path::{Path, PathBuf};
43use std::str::FromStr;
44
45use gpui::{App, Global, SharedString, Subscription, Window, WindowAppearance};
46
47use super::{ColorScheme, Theme, ThemeJsonError, PRESET_NAMES};
48
49#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ThemeSource {
53 Builtin,
55 Custom,
57 File(PathBuf),
59}
60
61#[derive(Debug, Clone)]
64pub struct ThemeEntry {
65 pub id: SharedString,
68 pub name: SharedString,
70 pub source: ThemeSource,
71 pub theme: Theme,
72}
73
74impl ThemeEntry {
75 pub fn new(id: impl Into<SharedString>, theme: Theme) -> Self {
77 let id = id.into();
78 let name = title_case(&id).into();
79 ThemeEntry {
80 id,
81 name,
82 source: ThemeSource::Custom,
83 theme,
84 }
85 }
86
87 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
88 self.name = name.into();
89 self
90 }
91
92 pub fn source(mut self, source: ThemeSource) -> Self {
93 self.source = source;
94 self
95 }
96
97 pub fn scheme(&self) -> ColorScheme {
100 self.theme.scheme
101 }
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub enum ThemeChoice {
111 #[default]
113 System,
114 Fixed(SharedString),
116}
117
118impl fmt::Display for ThemeChoice {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 ThemeChoice::System => f.write_str("system"),
122 ThemeChoice::Fixed(id) => write!(f, "theme:{id}"),
123 }
124 }
125}
126
127impl FromStr for ThemeChoice {
128 type Err = std::convert::Infallible;
129
130 fn from_str(s: &str) -> Result<Self, Self::Err> {
131 let s = s.trim();
132 Ok(match s {
133 "" | "system" | "auto" => ThemeChoice::System,
134 _ => ThemeChoice::Fixed(s.strip_prefix("theme:").unwrap_or(s).to_string().into()),
135 })
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum ThemeLoadError {
144 Io(PathBuf, String),
146 Json(PathBuf, ThemeJsonError),
148}
149
150impl ThemeLoadError {
151 pub fn path(&self) -> &Path {
152 match self {
153 ThemeLoadError::Io(path, _) | ThemeLoadError::Json(path, _) => path,
154 }
155 }
156}
157
158impl fmt::Display for ThemeLoadError {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 match self {
161 ThemeLoadError::Io(path, why) => write!(f, "{}: {why}", path.display()),
162 ThemeLoadError::Json(path, why) => write!(f, "{}: {why}", path.display()),
163 }
164 }
165}
166
167impl std::error::Error for ThemeLoadError {}
168
169pub struct ThemeManager {
172 entries: Vec<ThemeEntry>,
173 choice: ThemeChoice,
174 light: SharedString,
175 dark: SharedString,
176 system: ColorScheme,
177 watches: Vec<Subscription>,
178}
179
180impl Global for ThemeManager {}
181
182impl fmt::Debug for ThemeManager {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 f.debug_struct("ThemeManager")
185 .field("entries", &self.entries.len())
186 .field("choice", &self.choice)
187 .field("light", &self.light)
188 .field("dark", &self.dark)
189 .field("system", &self.system)
190 .finish()
191 }
192}
193
194impl Default for ThemeManager {
195 fn default() -> Self {
196 ThemeManager::new()
197 }
198}
199
200impl ThemeManager {
201 pub fn new() -> Self {
208 ThemeManager {
209 entries: vec![
210 ThemeEntry::new("light", Theme::light())
211 .name("Light")
212 .source(ThemeSource::Builtin),
213 ThemeEntry::new("dark", Theme::dark())
214 .name("Dark")
215 .source(ThemeSource::Builtin),
216 ],
217 choice: ThemeChoice::System,
218 light: "light".into(),
219 dark: "dark".into(),
220 system: ColorScheme::Light,
221 watches: Vec::new(),
222 }
223 }
224
225 pub fn with_presets(mut self) -> Self {
227 for id in PRESET_NAMES {
228 if let Some(theme) = Theme::preset(id) {
229 self.register(
230 ThemeEntry::new(id, theme)
231 .name(preset_name(id))
232 .source(ThemeSource::Builtin),
233 );
234 }
235 }
236 self
237 }
238
239 pub fn with(mut self, entry: ThemeEntry) -> Self {
241 self.register(entry);
242 self
243 }
244
245 pub fn with_dir(mut self, dir: impl AsRef<Path>) -> Self {
248 self.load_dir(dir);
249 self
250 }
251
252 pub fn choice(mut self, choice: ThemeChoice) -> Self {
254 self.choice = choice;
255 self
256 }
257
258 pub fn pair(mut self, light: impl Into<SharedString>, dark: impl Into<SharedString>) -> Self {
260 self.light = light.into();
261 self.dark = dark.into();
262 self
263 }
264
265 pub fn register(&mut self, entry: ThemeEntry) {
269 match self.entries.iter_mut().find(|e| e.id == entry.id) {
270 Some(existing) => *existing = entry,
271 None => self.entries.push(entry),
272 }
273 }
274
275 pub fn register_json(
278 &mut self,
279 id: impl Into<SharedString>,
280 source: &str,
281 ) -> Result<(), ThemeJsonError> {
282 let theme = Theme::from_json(source)?;
283 let id = id.into();
284 let name = super::json::json_name(source).unwrap_or_else(|| title_case(&id));
285 self.register(ThemeEntry::new(id, theme).name(name));
286 Ok(())
287 }
288
289 pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> Vec<ThemeLoadError> {
293 let mut failures = Vec::new();
294 let Ok(read) = std::fs::read_dir(dir.as_ref()) else {
295 return failures;
296 };
297
298 let mut paths: Vec<PathBuf> = read
301 .filter_map(|entry| entry.ok())
302 .map(|entry| entry.path())
303 .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
304 .collect();
305 paths.sort();
306
307 for path in paths {
308 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
309 continue;
310 };
311 let source = match std::fs::read_to_string(&path) {
312 Ok(source) => source,
313 Err(e) => {
314 failures.push(ThemeLoadError::Io(path.clone(), e.to_string()));
315 continue;
316 }
317 };
318 match Theme::from_json(&source) {
319 Ok(theme) => {
320 let id: SharedString = stem.to_lowercase().into();
321 let name = super::json::json_name(&source).unwrap_or_else(|| title_case(stem));
322 self.register(
323 ThemeEntry::new(id, theme)
324 .name(name)
325 .source(ThemeSource::File(path.clone())),
326 );
327 }
328 Err(e) => failures.push(ThemeLoadError::Json(path.clone(), e)),
329 }
330 }
331 failures
332 }
333
334 pub fn entries(&self) -> &[ThemeEntry] {
336 &self.entries
337 }
338
339 pub fn entry(&self, id: &str) -> Option<&ThemeEntry> {
340 self.entries.iter().find(|e| e.id == id)
341 }
342
343 pub fn selection(&self) -> &ThemeChoice {
347 &self.choice
348 }
349
350 pub fn system_scheme(&self) -> ColorScheme {
352 self.system
353 }
354
355 pub fn resolved(&self) -> &ThemeEntry {
359 let id = match &self.choice {
360 ThemeChoice::Fixed(id) => id.clone(),
361 ThemeChoice::System => self.pair_id(self.system),
362 };
363 self
364 .entry(&id)
365 .or_else(|| self.entry(&self.pair_id(self.system)))
366 .unwrap_or_else(|| {
367 self
368 .entries
369 .first()
370 .expect("a ThemeManager always holds at least one theme")
371 })
372 }
373
374 pub fn resolved_id(&self) -> &SharedString {
376 &self.resolved().id
377 }
378
379 fn pair_id(&self, scheme: ColorScheme) -> SharedString {
380 match scheme {
381 ColorScheme::Light => self.light.clone(),
382 ColorScheme::Dark => self.dark.clone(),
383 }
384 }
385
386 pub fn install(self, cx: &mut App) {
390 cx.set_global(self);
391 ThemeManager::apply(cx);
392 }
393
394 pub fn apply(cx: &mut App) {
398 let theme = match cx.try_global::<ThemeManager>() {
399 Some(manager) => manager.resolved().theme.clone(),
400 None => return,
401 };
402 cx.set_global(theme);
403 cx.refresh_windows();
404 }
405
406 pub fn select(cx: &mut App, id: impl Into<SharedString>) -> bool {
409 let id = id.into();
410 let known = cx
411 .try_global::<ThemeManager>()
412 .is_some_and(|m| m.entry(&id).is_some());
413 if known {
414 ThemeManager::set_choice(cx, ThemeChoice::Fixed(id));
415 }
416 known
417 }
418
419 pub fn follow_system(cx: &mut App) {
421 ThemeManager::set_choice(cx, ThemeChoice::System);
422 }
423
424 pub fn set_choice(cx: &mut App, choice: ThemeChoice) {
426 if cx.has_global::<ThemeManager>() {
427 cx.global_mut::<ThemeManager>().choice = choice;
428 ThemeManager::apply(cx);
429 }
430 }
431
432 pub fn toggle(cx: &mut App) {
436 let Some(manager) = cx.try_global::<ThemeManager>() else {
437 return;
438 };
439 let next = manager.pair_id(manager.resolved().scheme().toggled());
440 ThemeManager::set_choice(cx, ThemeChoice::Fixed(next));
441 }
442
443 pub fn set_system_scheme(cx: &mut App, scheme: ColorScheme) {
447 let Some(manager) = cx.try_global::<ThemeManager>() else {
448 return;
449 };
450 if manager.system == scheme {
451 return;
452 }
453 cx.global_mut::<ThemeManager>().system = scheme;
454 if cx.global::<ThemeManager>().choice == ThemeChoice::System {
455 ThemeManager::apply(cx);
456 }
457 }
458
459 pub fn watch(window: &mut Window, cx: &mut App) {
463 if !cx.has_global::<ThemeManager>() {
464 return;
465 }
466 ThemeManager::set_system_scheme(cx, scheme_of(window.appearance()));
467 let watch = window.observe_window_appearance(|window, cx| {
468 ThemeManager::set_system_scheme(cx, scheme_of(window.appearance()));
469 });
470 cx.global_mut::<ThemeManager>().watches.push(watch);
471 }
472}
473
474pub fn scheme_of(appearance: WindowAppearance) -> ColorScheme {
477 match appearance {
478 WindowAppearance::Dark | WindowAppearance::VibrantDark => ColorScheme::Dark,
479 WindowAppearance::Light | WindowAppearance::VibrantLight => ColorScheme::Light,
480 }
481}
482
483pub fn manager(cx: &App) -> Option<&ThemeManager> {
487 cx.try_global::<ThemeManager>()
488}
489
490fn title_case(id: &str) -> String {
492 let mut out = String::with_capacity(id.len());
493 for word in id.split(['-', '_', ' ']).filter(|w| !w.is_empty()) {
494 if !out.is_empty() {
495 out.push(' ');
496 }
497 let mut chars = word.chars();
498 if let Some(first) = chars.next() {
499 out.extend(first.to_uppercase());
500 out.push_str(chars.as_str());
501 }
502 }
503 if out.is_empty() {
504 id.to_string()
505 } else {
506 out
507 }
508}
509
510fn preset_name(id: &str) -> &'static str {
512 match id {
513 "catppuccin" => "Catppuccin",
514 "nord" => "Nord",
515 "tokyonight" => "Tokyo Night",
516 "gruvbox" => "Gruvbox",
517 "dracula" => "Dracula",
518 "solarizedlight" => "Solarized Light",
519 _ => "Theme",
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn scratch(name: &str) -> PathBuf {
528 let dir = std::env::temp_dir().join(format!("guise-theme-{name}-{}", std::process::id()));
529 let _ = std::fs::remove_dir_all(&dir);
530 std::fs::create_dir_all(&dir).unwrap();
531 dir
532 }
533
534 #[test]
535 fn new_seeds_the_light_dark_pair() {
536 let manager = ThemeManager::new();
537 assert_eq!(manager.entries().len(), 2);
538 assert_eq!(manager.entry("dark").unwrap().name, "Dark");
539 assert_eq!(manager.resolved_id().as_ref(), "light");
541 }
542
543 #[test]
544 fn presets_land_with_readable_names() {
545 let manager = ThemeManager::new().with_presets();
546 assert_eq!(manager.entries().len(), 2 + PRESET_NAMES.len());
547 assert_eq!(manager.entry("tokyonight").unwrap().name, "Tokyo Night");
548 assert_eq!(manager.entry("nord").unwrap().source, ThemeSource::Builtin);
549 }
550
551 #[test]
552 fn registering_a_known_id_replaces_it() {
553 let mut manager = ThemeManager::new();
554 manager.register(ThemeEntry::new("dark", Theme::dracula()).name("Midnight"));
555 assert_eq!(manager.entries().len(), 2);
556 assert_eq!(manager.entry("dark").unwrap().name, "Midnight");
557 manager.system = ColorScheme::Dark;
559 assert!(manager.resolved().theme.overrides.primary.is_some());
560 }
561
562 #[test]
563 fn system_choice_follows_the_scheme_and_a_fixed_one_does_not() {
564 let mut manager = ThemeManager::new().with_presets();
565 manager.system = ColorScheme::Dark;
566 assert_eq!(manager.resolved_id().as_ref(), "dark");
567 manager.system = ColorScheme::Light;
568 assert_eq!(manager.resolved_id().as_ref(), "light");
569
570 manager.choice = ThemeChoice::Fixed("dracula".into());
571 manager.system = ColorScheme::Dark;
572 assert_eq!(manager.resolved_id().as_ref(), "dracula");
573 }
574
575 #[test]
576 fn a_custom_pair_redirects_the_system_choice() {
577 let manager = ThemeManager::new()
578 .with_presets()
579 .pair("solarizedlight", "nord");
580 assert_eq!(manager.resolved_id().as_ref(), "solarizedlight");
581 }
582
583 #[test]
584 fn a_stale_id_falls_back_instead_of_panicking() {
585 let mut manager = ThemeManager::new();
586 manager.choice = ThemeChoice::Fixed("deleted-by-the-user".into());
587 manager.system = ColorScheme::Dark;
588 assert_eq!(manager.resolved_id().as_ref(), "dark");
589 }
590
591 #[test]
592 fn choice_round_trips_through_a_string() {
593 let cases = [
594 (ThemeChoice::System, "system"),
595 (ThemeChoice::Fixed("dracula".into()), "theme:dracula"),
596 ];
597 for (choice, text) in cases {
598 assert_eq!(choice.to_string(), text);
599 assert_eq!(text.parse::<ThemeChoice>().unwrap(), choice);
600 }
601 assert_eq!(
603 "dracula".parse::<ThemeChoice>().unwrap(),
604 ThemeChoice::Fixed("dracula".into())
605 );
606 assert_eq!("".parse::<ThemeChoice>().unwrap(), ThemeChoice::System);
607 assert_eq!(
608 " auto ".parse::<ThemeChoice>().unwrap(),
609 ThemeChoice::System
610 );
611 }
612
613 #[test]
614 fn load_dir_keys_by_stem_and_reports_only_the_bad_files() {
615 let dir = scratch("load");
616 std::fs::write(
617 dir.join("Midnight.json"),
618 r##"{"name": "Midnight Blue", "scheme": "dark", "primary": "#7aa2f7"}"##,
619 )
620 .unwrap();
621 std::fs::write(dir.join("plain-jane.json"), r#"{"scheme": "light"}"#).unwrap();
622 std::fs::write(dir.join("broken.json"), r##"{"primry": "#fff"}"##).unwrap();
623 std::fs::write(dir.join("notes.txt"), "not a theme").unwrap();
624
625 let mut manager = ThemeManager::new();
626 let failures = manager.load_dir(&dir);
627
628 assert_eq!(failures.len(), 1);
629 assert_eq!(failures[0].path(), dir.join("broken.json"));
630 assert!(matches!(failures[0], ThemeLoadError::Json(_, _)));
631
632 assert_eq!(manager.entries().len(), 4);
633 let midnight = manager.entry("midnight").unwrap();
634 assert_eq!(midnight.name, "Midnight Blue");
635 assert_eq!(midnight.scheme(), ColorScheme::Dark);
636 assert_eq!(
637 midnight.source,
638 ThemeSource::File(dir.join("Midnight.json"))
639 );
640 assert_eq!(manager.entry("plain-jane").unwrap().name, "Plain Jane");
642
643 let _ = std::fs::remove_dir_all(&dir);
644 }
645
646 #[test]
647 fn a_missing_themes_dir_is_not_an_error() {
648 let manager = ThemeManager::new().with_dir("/nonexistent/guise/themes");
649 assert_eq!(manager.entries().len(), 2);
650 }
651
652 #[test]
653 fn register_json_names_from_the_file_then_the_id() {
654 let mut manager = ThemeManager::new();
655 manager
656 .register_json(
657 "midnight",
658 r##"{"name": "Midnight", "primary": "#7aa2f7"}"##,
659 )
660 .unwrap();
661 manager
662 .register_json("plain-jane", r#"{"scheme": "light"}"#)
663 .unwrap();
664 assert_eq!(manager.entry("midnight").unwrap().name, "Midnight");
665 assert_eq!(manager.entry("plain-jane").unwrap().name, "Plain Jane");
666 assert!(manager.register_json("bad", r#"{"nope": "x"}"#).is_err());
667 }
668
669 #[test]
670 fn appearances_collapse_to_two_schemes() {
671 assert_eq!(scheme_of(WindowAppearance::VibrantDark), ColorScheme::Dark);
672 assert_eq!(
673 scheme_of(WindowAppearance::VibrantLight),
674 ColorScheme::Light
675 );
676 }
677}