Skip to main content

max / alloy_tui

3.9 KB · 111 lines History Blame Raw
1 //! Resolving which theme the console renders in, and where it loads from.
2 //!
3 //! docs/TOKENS.md: no hex values are hard-coded in Rust. There is deliberately
4 //! no built-in fallback palette here — a missing or malformed theme is an
5 //! error the user sees, not something the console papers over by rendering in
6 //! colors that exist nowhere in the theme files.
7
8 use std::path::PathBuf;
9
10 use alloy_tui::Theme;
11 use anyhow::{Context, Result};
12
13 /// Default light theme (docs/TOKENS.md).
14 pub const DEFAULT_LIGHT: &str = "akari-dawn";
15
16 /// Default dark theme (docs/TOKENS.md).
17 pub const DEFAULT_DARK: &str = "akari-night";
18
19 /// Theme search path, highest precedence first: the user's own themes, then
20 /// the ones the image ships, then the in-repo checkout when running from a dev
21 /// tree. The `bool` is makeover's is-custom flag.
22 fn search_path() -> Vec<(PathBuf, bool)> {
23 let mut dirs = Vec::new();
24
25 if let Some(config) = dirs_config_home() {
26 dirs.push((config.join("alloy").join("themes"), true));
27 }
28 dirs.push((PathBuf::from("/usr/share/alloy/themes"), false));
29
30 // Build-from-source fallback: the themes makeover ships. Lowest
31 // precedence, so a packaged /usr/share/alloy/themes always wins on an
32 // installed system, but `cargo run` in a fresh clone still comes up
33 // themed rather than erroring out.
34 if let Some(bundled) = makeover::bundled_themes_dir() {
35 dirs.push((bundled, false));
36 }
37
38 dirs
39 }
40
41 fn dirs_config_home() -> Option<PathBuf> {
42 // XDG_CONFIG_HOME wins when set and absolute; the spec says a relative
43 // value is invalid and must be ignored rather than resolved against cwd.
44 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
45 let path = PathBuf::from(xdg);
46 if path.is_absolute() {
47 return Some(path);
48 }
49 }
50 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
51 }
52
53 /// Load a theme by id, or the mode-appropriate default when `id` is `None`.
54 pub fn load(id: Option<&str>) -> Result<Theme> {
55 let id = id.map(str::to_string).unwrap_or_else(default_theme_id);
56 let dirs = search_path();
57
58 let colors = makeover::load_theme(&dirs, &id)
59 .map_err(anyhow::Error::msg)
60 .with_context(|| {
61 let searched: Vec<String> = dirs
62 .iter()
63 .map(|(path, _)| path.display().to_string())
64 .collect();
65 format!("loading theme `{id}` (searched: {})", searched.join(", "))
66 })?;
67
68 Theme::from_theme(&colors).with_context(|| format!("theme `{id}` is incomplete"))
69 }
70
71 /// Guess whether the terminal is dark, and pick the matching Akari default.
72 ///
73 /// `COLORFGBG` is the only signal available without writing an OSC query to
74 /// the terminal and waiting on a reply, which is not worth doing before the
75 /// first frame. Its background field is a color index: 0-6 and 8 are the dark
76 /// ones. When the variable is absent or unparseable, light is the documented
77 /// default.
78 fn default_theme_id() -> String {
79 let dark = std::env::var("COLORFGBG")
80 .ok()
81 .and_then(|value| {
82 value
83 .rsplit(';')
84 .next()
85 .and_then(|bg| bg.trim().parse::<u8>().ok())
86 })
87 .is_some_and(|bg| bg <= 6 || bg == 8);
88
89 if dark { DEFAULT_DARK.into() } else { DEFAULT_LIGHT.into() }
90 }
91
92 #[cfg(test)]
93 mod tests {
94 use super::*;
95
96 // The console ships against these two ids; a rename in makeover's themes
97 // that misses this crate should fail here rather than at first launch.
98 #[test]
99 fn shipped_defaults_load_and_resolve() {
100 for id in [DEFAULT_LIGHT, DEFAULT_DARK] {
101 let theme = load(Some(id));
102 assert!(theme.is_ok(), "default theme `{id}` failed to load: {theme:?}");
103 }
104 }
105
106 #[test]
107 fn a_missing_theme_is_an_error_not_a_fallback() {
108 assert!(load(Some("no-such-theme")).is_err());
109 }
110 }
111