//! Which theme magicmirror renders in, and where it loads from. //! //! No hex value is written anywhere in this crate. There is deliberately no //! built-in fallback palette: a missing or malformed theme is an error the //! operator sees, not something papered over by rendering in colors that exist //! nowhere in the theme files. //! //! The shared app convention is wiki `makeover-app-convention`. magicmirror is //! one of the store-less apps that note describes, with one difference worth //! knowing: it already owns a config file, so `magicmirror.toml` *is* the //! file-backed store and the convention's unprefixed `theme` key lives there. //! There is no picker and nothing writes the key back, which is why this //! module has no `remember`: magicmirror displays, and choosing a theme is an //! edit to the same file that says what to display. use std::path::PathBuf; use anyhow::{Context, Result}; use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant}; use makeover_tui::{Fidelity, Theme}; /// magicmirror's own light theme: the platform's titular skin. const DEFAULT_LIGHT: &str = "makenotwork"; /// magicmirror's own dark theme. /// /// MNW authors no dark skin of its own, so this is the nearest thing in the /// bundled set: near-neutral greys on the same axis as `makenotwork`'s /// parchment, rather than a theme with a hue of its own to bring. It is a /// fallback and not a pin — `ThemeSelection::resolve` reaches any installed /// dark theme when this one is missing, and the operator names the theme they /// want in `magicmirror.toml`. const DEFAULT_DARK: &str = "carbonfox"; /// Theme search path: the operator's own themes win, then makeover's bundled /// set. /// /// Two tiers and not three. There is no packaged `/usr/share` tier because /// nothing packages magicmirror — it is an operator binary run out of a build, /// so a system tier would be a directory that never exists. fn search_path() -> Vec<(PathBuf, bool)> { ThemeDirs::new() .bundled(makeover::bundled_themes_dir()) .custom(config_home().map(|config| config.join("magicmirror").join("themes"))) .build() } /// `$XDG_CONFIG_HOME`, else `~/.config`, matching where magicmirror already /// looks for `magicmirror.toml`. fn config_home() -> Option { if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { return Some(PathBuf::from(xdg)); } std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")) } /// The themes magicmirror falls back to when nothing has been chosen. fn defaults() -> ThemeDefaults { ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK) } /// Every theme magicmirror can render, from the same search path it loads from. /// /// makeover's own scan rather than a second one: a list here that disagreed /// with [`load`] would resolve to an id that then failed to load. fn available() -> Vec { makeover::list_themes_from_dirs(&search_path()) } /// What magicmirror is being drawn on, as makeover's vocabulary. /// /// The terminal's answer to a `prefers-color-scheme` media query, and the only /// one available without writing an OSC query and waiting for a reply before /// the first frame. `COLORFGBG` carries the background as a color index; 0-6 /// and 8 are the dark ones. A terminal that says nothing reads as light, which /// is the documented default. fn ambient() -> Variant { terminal_background().unwrap_or(Variant::Light) } fn terminal_background() -> Option { let value = std::env::var("COLORFGBG").ok()?; let bg = value.rsplit(';').next()?.trim().parse::().ok()?; Some(if bg <= 6 || bg == 8 { Variant::Dark } else { Variant::Light }) } /// Load the theme a selection resolves to, as this terminal can draw it. /// /// Resolved by makeover against the ambient mode and what is actually /// installed, so following the terminal reaches any dark theme the operator /// dropped in rather than only the one this crate names, and a theme that has /// since been deleted falls back instead of failing to load. /// /// Quantized through [`Theme::for_terminal`] rather than handed over as /// 24-bit. Left alone, a terminal below truecolor approximates the colors /// itself and its approximation collapses tones the theme keeps apart — which /// on this surface means a `degraded` and a `failed` source that no longer /// look different, on the one screen whose whole job is that difference. pub(crate) fn load(selection: &ThemeSelection) -> Result { let dirs = search_path(); let id = selection.resolve(ambient(), &defaults(), &available()); let colors = makeover::load_theme(&dirs, &id) .map_err(anyhow::Error::msg) .with_context(|| { let searched: Vec = dirs .iter() .map(|(path, _)| path.display().to_string()) .collect(); format!("loading theme `{id}` (searched: {})", searched.join(", ")) })?; Theme::from_theme(&colors) .map(|theme| theme.for_terminal(Fidelity::detect())) .map_err(|e| anyhow::anyhow!("{e}")) .with_context(|| format!("theme `{id}` is incomplete")) } #[cfg(test)] pub(crate) mod tests { use super::*; /// A fixed theme for the render tests. /// /// Parsed from makeover's embedded copy rather than loaded off the search /// path, so the snapshot tests stay pure: they do not depend on which /// directories exist on the machine running them, on `COLORFGBG`, or on /// what the operator installed. pub(crate) fn fixed() -> Theme { let (_, source) = makeover::embedded_themes() .find(|(id, _)| *id == DEFAULT_LIGHT) .expect("makeover embeds magicmirror's default theme"); let colors = makeover::parse_theme_str(DEFAULT_LIGHT, source, false) .expect("the embedded default theme parses"); Theme::from_theme(&colors).expect("the embedded default theme is complete") } // The operator's own themes must outrank the bundled ones. Asserted from // this side because `ThemeDirs` is what makes the order not this file's to // get backwards, and a hand-built vector here could still reverse it. #[test] fn the_operators_own_themes_outrank_the_bundled_ones() { let dirs = search_path(); assert!( dirs.iter().filter(|(_, is_custom)| *is_custom).count() <= 1, "exactly one tier is the operator's: {dirs:?}", ); if let (Some(custom), Some(bundled)) = ( dirs.iter().position(|(_, is_custom)| *is_custom), dirs.iter().position(|(_, is_custom)| !*is_custom), ) { assert!( custom > bundled, "the operator's themes must come last so they win: {dirs:?}", ); } } // Nothing in `magicmirror.toml` is Follow, not a pin on whatever the first run // guessed. #[test] fn an_unset_key_follows_the_terminal() { assert_eq!(ThemeSelection::parse(None), ThemeSelection::Follow); } // `COLORFGBG` carries the background as a color index; 0-6 and 8 are dark. #[test] fn the_terminal_background_reads_as_a_variant() { for (raw, expect) in [ ("15;0", Variant::Dark), ("0;15", Variant::Light), ("15;8", Variant::Dark), ("15;7", Variant::Light), ] { let dark = raw .rsplit(';') .next() .and_then(|bg| bg.trim().parse::().ok()) .is_some_and(|bg| bg <= 6 || bg == 8); let got = if dark { Variant::Dark } else { Variant::Light }; assert_eq!(got, expect, "COLORFGBG={raw}"); } } // Following resolves to the theme matching the terminal rather than to a // fixed default, and both ids magicmirror names are ones makeover ships. #[test] fn following_resolves_to_the_theme_matching_the_terminal() { let available = available(); if available.is_empty() { return; // no theme directory on this machine; nothing to resolve against } assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available), DEFAULT_DARK, ); assert_eq!( ThemeSelection::Follow.resolve(Variant::Light, &defaults(), &available), DEFAULT_LIGHT, ); } // A pin wins over the ambient mode; that is the difference between a // selection and a rendered id. #[test] fn a_pinned_theme_ignores_the_terminal() { let available = available(); if available.is_empty() { return; } let pinned = ThemeSelection::parse(Some("nord")); assert_eq!( pinned.resolve(Variant::Light, &defaults(), &available), "nord" ); } // Both ids this crate ships against exist in makeover's set. A rename over // there should fail here rather than at an operator's first launch. #[test] fn the_shipped_defaults_load_and_are_complete() { if available().is_empty() { return; } for id in [DEFAULT_LIGHT, DEFAULT_DARK] { let colors = makeover::load_theme(&search_path(), id) .unwrap_or_else(|e| panic!("default theme `{id}` failed to load: {e}")); assert!( Theme::from_theme(&colors).is_ok(), "default theme `{id}` is incomplete", ); } } #[test] fn a_missing_theme_is_an_error_not_a_fallback() { // A `Fixed` id that is not installed falls back through `resolve`, so // the error path is the one where the resolved theme itself is // unreadable. Asserted through `load_theme` directly, since `resolve` // by design never hands back an id it did not find. assert!(makeover::load_theme(&search_path(), "no-such-theme").is_err()); } }