//! Resolving which theme the console renders in, and where it loads from. //! //! docs/TOKENS.md: no hex values are hard-coded in Rust. There is deliberately //! no built-in fallback palette here — a missing or malformed theme is an //! error the user sees, not something the console papers over by rendering in //! colors that exist nowhere in the theme files. use std::path::PathBuf; use alloy_tui::Theme; use anyhow::{Context, Result}; /// Default light theme (docs/TOKENS.md). pub const DEFAULT_LIGHT: &str = "akari-dawn"; /// Default dark theme (docs/TOKENS.md). pub const DEFAULT_DARK: &str = "akari-night"; /// Theme search path, highest precedence first: the user's own themes, then /// the ones the image ships, then the in-repo checkout when running from a dev /// tree. The `bool` is makeover's is-custom flag. fn search_path() -> Vec<(PathBuf, bool)> { let mut dirs = Vec::new(); if let Some(config) = dirs_config_home() { dirs.push((config.join("alloy").join("themes"), true)); } dirs.push((PathBuf::from("/usr/share/alloy/themes"), false)); // Build-from-source fallback: the themes makeover ships. Lowest // precedence, so a packaged /usr/share/alloy/themes always wins on an // installed system, but `cargo run` in a fresh clone still comes up // themed rather than erroring out. if let Some(bundled) = makeover::bundled_themes_dir() { dirs.push((bundled, false)); } dirs } fn dirs_config_home() -> Option { // XDG_CONFIG_HOME wins when set and absolute; the spec says a relative // value is invalid and must be ignored rather than resolved against cwd. if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { let path = PathBuf::from(xdg); if path.is_absolute() { return Some(path); } } std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")) } /// Load a theme by id, or the mode-appropriate default when `id` is `None`. pub fn load(id: Option<&str>) -> Result { let id = id.map(str::to_string).unwrap_or_else(default_theme_id); let dirs = search_path(); 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).with_context(|| format!("theme `{id}` is incomplete")) } /// Guess whether the terminal is dark, and pick the matching Akari default. /// /// `COLORFGBG` is the only signal available without writing an OSC query to /// the terminal and waiting on a reply, which is not worth doing before the /// first frame. Its background field is a color index: 0-6 and 8 are the dark /// ones. When the variable is absent or unparseable, light is the documented /// default. fn default_theme_id() -> String { let dark = std::env::var("COLORFGBG") .ok() .and_then(|value| { value .rsplit(';') .next() .and_then(|bg| bg.trim().parse::().ok()) }) .is_some_and(|bg| bg <= 6 || bg == 8); if dark { DEFAULT_DARK.into() } else { DEFAULT_LIGHT.into() } } #[cfg(test)] mod tests { use super::*; // The console ships against these two ids; a rename in makeover's themes // that misses this crate should fail here rather than at first launch. #[test] fn shipped_defaults_load_and_resolve() { for id in [DEFAULT_LIGHT, DEFAULT_DARK] { let theme = load(Some(id)); assert!(theme.is_ok(), "default theme `{id}` failed to load: {theme:?}"); } } #[test] fn a_missing_theme_is_an_error_not_a_fallback() { assert!(load(Some("no-such-theme")).is_err()); } }