Skip to main content

guise/theme/
manager.rs

1//! `ThemeManager` — the registry, the selection, and the OS-appearance follow.
2//!
3//! Switching a theme was always a one-liner: write the [`Theme`] global, ask
4//! for a redraw, and every component restyles because it reads `theme(cx)` at
5//! paint time. That is why guise shipped for so long without a manager. What
6//! the one-liner does not answer is everything *around* the switch — which
7//! themes exist, which one the user picked last time, what "follow the system"
8//! means in an app that ships four dark themes — and every consumer ended up
9//! writing that part again.
10//!
11//! So this owns exactly that, and nothing about how a theme looks:
12//!
13//! - a **registry** of `id -> `[`ThemeEntry`], seeded with `light` and `dark`,
14//!   extended with the prebuilt presets, host themes, or a directory of JSON
15//!   theme files,
16//! - a **choice** ([`ThemeChoice`]) that is either one theme by id or *follow
17//!   the system*, which resolves through the registry's light/dark pair,
18//! - the **window appearance watch** that keeps that resolution honest when the
19//!   OS flips at sundown.
20//!
21//! ```ignore
22//! use guise::prelude::*;
23//!
24//! ThemeManager::new()
25//!     .with_presets()
26//!     .with_dir(config_dir.join("themes"))     // *.json, see `Theme::from_json`
27//!     .choice(saved_choice.parse().unwrap())   // "system" | "theme:dracula"
28//!     .install(cx);
29//!
30//! // once per window, so `ThemeChoice::System` tracks the OS:
31//! ThemeManager::watch(window, cx);
32//! ```
33//!
34//! Persistence stays with the host, the way [`crate::settings`] leaves the
35//! schema with the host: [`ThemeChoice`] is `Display` + `FromStr`, so it round
36//! trips through one string in whatever config file the app already writes.
37//! Reading a *themes directory* is the one thing here that touches the disk —
38//! an app that would rather bring its own bytes (an `include_str!`, a file it
39//! already read) uses [`ThemeManager::register_json`] instead.
40
41use 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/// Where a registered theme came from. Carried for the UI: a picker can group
50/// the app's own themes apart from the ones a user dropped in a folder.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ThemeSource {
53  /// `Theme::light()` / `Theme::dark()` or one of the prebuilt presets.
54  Builtin,
55  /// Registered by the host in code.
56  Custom,
57  /// Parsed from a JSON theme file.
58  File(PathBuf),
59}
60
61/// One theme in the registry: the theme itself plus the identity a picker and
62/// a config file need.
63#[derive(Debug, Clone)]
64pub struct ThemeEntry {
65  /// Stable key — what a saved [`ThemeChoice`] refers to. Lowercase by
66  /// convention; a file's stem becomes its id.
67  pub id: SharedString,
68  /// Display name. Derived from the id when nothing better is given.
69  pub name: SharedString,
70  pub source: ThemeSource,
71  pub theme: Theme,
72}
73
74impl ThemeEntry {
75  /// A host theme, named after its id (`"midnight blue"` from `midnight-blue`).
76  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  /// The scheme this theme is a variation of — what "follow the system" and a
98  /// light/dark toggle sort on.
99  pub fn scheme(&self) -> ColorScheme {
100    self.theme.scheme
101  }
102}
103
104/// What the app is wearing.
105///
106/// `Display`/`FromStr` round trip through one string so a host can persist it
107/// in its own config: `"system"`, or `"theme:<id>"`. A bare id parses too, so a
108/// config that just says `dracula` works.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub enum ThemeChoice {
111  /// Follow the OS appearance through the registry's light/dark pair.
112  #[default]
113  System,
114  /// One theme, by id. Stays put when the OS flips.
115  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/// A theme file the registry could not load. [`ThemeManager::load_dir`] returns
140/// these rather than failing the whole directory — one bad file in a user's
141/// themes folder should not cost them the other nine.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum ThemeLoadError {
144  /// The file could not be read.
145  Io(PathBuf, String),
146  /// The file was read but did not parse.
147  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
169/// The theme registry and the active selection, installed as a gpui global
170/// alongside [`Theme`] itself. See the [module docs](self).
171pub 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  /// A registry holding just `light` and `dark`, following the system.
202  ///
203  /// Those two ids are the light/dark pair `ThemeChoice::System` resolves
204  /// through, so an app with its own two-theme identity can re-register them
205  /// (same ids, its own themes and names) and get the follow behaviour for
206  /// free. To use a *different* pair, see [`ThemeManager::pair`].
207  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  /// Add the six prebuilt presets (see [`PRESET_NAMES`]).
226  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  /// Add one theme (builder form of [`ThemeManager::register`]).
240  pub fn with(mut self, entry: ThemeEntry) -> Self {
241    self.register(entry);
242    self
243  }
244
245  /// Load a directory of `*.json` theme files, ignoring the ones that fail.
246  /// Use [`ThemeManager::load_dir`] when you want to report them.
247  pub fn with_dir(mut self, dir: impl AsRef<Path>) -> Self {
248    self.load_dir(dir);
249    self
250  }
251
252  /// Set the choice (builder form of [`ThemeManager::set_choice`]).
253  pub fn choice(mut self, choice: ThemeChoice) -> Self {
254    self.choice = choice;
255    self
256  }
257
258  /// Point [`ThemeChoice::System`] at a different pair of registered themes.
259  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  // registry
266
267  /// Add a theme, replacing any entry with the same id.
268  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  /// Register a theme from JSON the host already has in hand — an
276  /// `include_str!`, a downloaded file, a string from its own config.
277  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  /// Load every `*.json` file in `dir` as a theme, keyed by file stem, and
290  /// return the files that failed. A missing directory is not an error — apps
291  /// point this at a folder the user may never create.
292  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    // Sorted, so a directory listing's arbitrary order doesn't reorder a picker
299    // between launches.
300    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  /// Every registered theme, in registration order.
335  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  // selection
344
345  /// The active choice. Persist it with `to_string()`.
346  pub fn selection(&self) -> &ThemeChoice {
347    &self.choice
348  }
349
350  /// The last OS appearance a watched window reported.
351  pub fn system_scheme(&self) -> ColorScheme {
352    self.system
353  }
354
355  /// The entry the current choice resolves to. Falls back to the light/dark
356  /// pair, then to the first entry, so a stale id in a config file degrades to
357  /// a sane theme instead of a panic.
358  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  /// The id of the theme currently in use.
375  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  // installed-global operations
387
388  /// Install as the global and apply the resolved theme.
389  pub fn install(self, cx: &mut App) {
390    cx.set_global(self);
391    ThemeManager::apply(cx);
392  }
393
394  /// Write the resolved theme into the [`Theme`] global and redraw. Every
395  /// mutator below ends here; call it directly after editing the manager
396  /// through `cx.global_mut::<ThemeManager>()`.
397  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  /// Wear one registered theme. Unknown ids are ignored (and reported as
407  /// `false`) so a picker can't strand the app on a theme that isn't there.
408  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  /// Follow the OS appearance again.
420  pub fn follow_system(cx: &mut App) {
421    ThemeManager::set_choice(cx, ThemeChoice::System);
422  }
423
424  /// Set the choice and apply it.
425  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  /// Swap to the light/dark counterpart of what is showing — what a
433  /// light/dark toggle button does. Leaves [`ThemeChoice::System`]: an explicit
434  /// toggle is an explicit choice.
435  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  /// Record the OS appearance, restyling if the choice follows it. Usually
444  /// driven by [`ThemeManager::watch`]; call it directly when the host learns
445  /// the appearance some other way.
446  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  /// Track a window's appearance, so [`ThemeChoice::System`] follows the OS.
460  /// Call once per window, from the window's init. The subscription is held by
461  /// the manager global, which outlives every window.
462  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
474/// gpui's four appearances collapse to two: vibrancy is a material, not a
475/// scheme, and guise has no separate palette for it.
476pub 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
483/// The installed manager, if there is one. Unlike [`theme`](super::theme) this
484/// is optional: the manager is opt-in, and a picker rendered without one should
485/// draw nothing rather than panic.
486pub fn manager(cx: &App) -> Option<&ThemeManager> {
487  cx.try_global::<ThemeManager>()
488}
489
490/// `tokyo-night` -> `Tokyo Night`, for entries that carry no name of their own.
491fn 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
510/// Display names for the presets, whose ids are run together.
511fn 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    // Following the system, and the system starts light.
540    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    // ...and the replacement is what "follow the system into dark" resolves to.
558    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    // A hand-written config can be terser than what we emit.
602    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    // No `name` key, so the stem is title-cased.
641    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}