Skip to main content

ai_usagebar/
theme.rs

1//! Color palette resolution.
2//!
3//! Three-layer precedence (matches claudebar:163-167):
4//!   1. CLI overrides (passed via `Theme::with_overrides`)
5//!   2. Omarchy theme at `~/.config/omarchy/current/theme/colors.toml`
6//!   3. One Dark fallback
7//!
8//! The Omarchy file format is `key = "value"` lines (claudebar:115-131 uses a
9//! regex that tolerates comments + blank lines + extra whitespace). We use
10//! `toml::from_str` for the same effect — Omarchy themes are valid TOML.
11
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16/// One Dark defaults, byte-identical to claudebar:152-159.
17const ONE_DARK_GREEN: &str = "#98c379";
18const ONE_DARK_YELLOW: &str = "#e5c07b";
19const ONE_DARK_ORANGE: &str = "#d19a66";
20const ONE_DARK_RED: &str = "#e06c75";
21const ONE_DARK_BLUE: &str = "#61afef";
22const ONE_DARK_DIM: &str = "#5c6370";
23const ONE_DARK_FG: &str = "#abb2bf";
24const ONE_DARK_BAR_EMPTY: &str = "#3e4451";
25const ONE_DARK_MARKER: &str = "#d19a66";
26
27/// Full resolved palette used by the widget tooltip and the TUI.
28///
29/// All fields are stored as `#RRGGBB` strings ready to drop into Pango
30/// `<span foreground='…'>` markup. The TUI converts them to `ratatui::Color`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Theme {
33    pub green: String,
34    pub yellow: String,
35    pub orange: String,
36    pub red: String,
37    pub blue: String,
38    pub dim: String,
39    pub fg: String,
40    pub bar_empty: String,
41    pub marker: String,
42}
43
44impl Default for Theme {
45    fn default() -> Self {
46        Self {
47            green: ONE_DARK_GREEN.into(),
48            yellow: ONE_DARK_YELLOW.into(),
49            orange: ONE_DARK_ORANGE.into(),
50            red: ONE_DARK_RED.into(),
51            blue: ONE_DARK_BLUE.into(),
52            dim: ONE_DARK_DIM.into(),
53            fg: ONE_DARK_FG.into(),
54            bar_empty: ONE_DARK_BAR_EMPTY.into(),
55            marker: ONE_DARK_MARKER.into(),
56        }
57    }
58}
59
60/// Subset of an Omarchy theme file we care about. Unknown keys are ignored,
61/// missing keys fall back to One Dark.
62#[derive(Debug, Default, Deserialize)]
63struct OmarchyTheme {
64    accent: Option<String>,
65    foreground: Option<String>,
66    background: Option<String>,
67    color1: Option<String>,
68    color2: Option<String>,
69    color3: Option<String>,
70}
71
72impl Theme {
73    /// Apply CLI `--color-*` overrides. Each `Some(_)` wins over the underlying
74    /// theme; `None` preserves the resolved color.
75    pub fn with_overrides(
76        mut self,
77        low: Option<String>,
78        mid: Option<String>,
79        high: Option<String>,
80        critical: Option<String>,
81    ) -> Self {
82        if let Some(v) = low {
83            self.green = v;
84        }
85        if let Some(v) = mid {
86            self.yellow = v;
87        }
88        if let Some(v) = high {
89            self.orange = v;
90        }
91        if let Some(v) = critical {
92            self.red = v;
93        }
94        self
95    }
96
97    /// Try to load the Omarchy theme and fold it on top of `self`. Returns
98    /// the result unmodified if the file is missing or unreadable — matching
99    /// claudebar's silent fallback (the script never errors on missing themes).
100    pub fn merged_with_omarchy(self) -> Self {
101        let Some(path) = omarchy_theme_path() else {
102            return self;
103        };
104        self.merged_with_omarchy_file(&path)
105    }
106
107    /// Same as `merged_with_omarchy` but with an explicit path (for tests).
108    pub fn merged_with_omarchy_file(mut self, path: &Path) -> Self {
109        let Ok(contents) = std::fs::read_to_string(path) else {
110            return self;
111        };
112        let Ok(parsed) = toml::from_str::<OmarchyTheme>(&contents) else {
113            return self;
114        };
115
116        // claudebar mapping (claudebar:133-148):
117        //   accent     → blue
118        //   foreground → fg
119        //   color1     → red AND orange
120        //   color2     → green
121        //   color3     → yellow
122        //   foreground+background → dim = midpoint
123        //   background → bar_empty = midpoint(background, dim)
124        if let Some(v) = parsed.accent {
125            self.blue = v;
126        }
127        if let Some(v) = parsed.foreground.clone() {
128            self.fg = v;
129        }
130        if let Some(v) = parsed.color1 {
131            self.red = v.clone();
132            self.orange = v;
133        }
134        if let Some(v) = parsed.color2 {
135            self.green = v;
136        }
137        if let Some(v) = parsed.color3 {
138            self.yellow = v;
139        }
140        if let (Some(fg), Some(bg)) = (&parsed.foreground, &parsed.background)
141            && let Some(dim) = hex_blend(fg, bg)
142        {
143            self.dim = dim;
144            self.marker = self.dim.clone();
145            if let Some(bar_empty) = hex_blend(bg, &self.dim) {
146                self.bar_empty = bar_empty;
147            }
148        }
149        self
150    }
151}
152
153/// Resolve the path to the active Omarchy theme. `None` on non-Omarchy systems
154/// or when `$HOME` isn't set.
155fn omarchy_theme_path() -> Option<PathBuf> {
156    let home = std::env::var_os("HOME")?;
157    Some(PathBuf::from(home).join(".config/omarchy/current/theme/colors.toml"))
158}
159
160/// Average two `#RRGGBB` strings into a midpoint color. Returns `None` if
161/// either input isn't parseable. Mirrors claudebar's `hex_blend()`
162/// (claudebar:105-110).
163pub fn hex_blend(a: &str, b: &str) -> Option<String> {
164    let (ar, ag, ab) = parse_hex_rgb(a)?;
165    let (br, bg, bb) = parse_hex_rgb(b)?;
166    Some(format!(
167        "#{:02x}{:02x}{:02x}",
168        (u16::from(ar) + u16::from(br)) / 2,
169        (u16::from(ag) + u16::from(bg)) / 2,
170        (u16::from(ab) + u16::from(bb)) / 2,
171    ))
172}
173
174pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
175    let s = s.strip_prefix('#').unwrap_or(s);
176    let [r1, r2, g1, g2, b1, b2] = s.as_bytes() else {
177        return None;
178    };
179    Some((
180        hex_pair(*r1, *r2)?,
181        hex_pair(*g1, *g2)?,
182        hex_pair(*b1, *b2)?,
183    ))
184}
185
186fn hex_pair(hi: u8, lo: u8) -> Option<u8> {
187    Some((hex_nibble(hi)? << 4) | hex_nibble(lo)?)
188}
189
190fn hex_nibble(b: u8) -> Option<u8> {
191    match b {
192        b'0'..=b'9' => Some(b - b'0'),
193        b'a'..=b'f' => Some(b - b'a' + 10),
194        b'A'..=b'F' => Some(b - b'A' + 10),
195        _ => None,
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use std::io::Write;
203    use tempfile::NamedTempFile;
204
205    #[test]
206    fn one_dark_is_default() {
207        let t = Theme::default();
208        assert_eq!(t.green, ONE_DARK_GREEN);
209        assert_eq!(t.red, ONE_DARK_RED);
210        assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
211    }
212
213    #[test]
214    fn hex_blend_averages() {
215        // black + white = mid grey (rounded down per integer division).
216        assert_eq!(hex_blend("#000000", "#ffffff"), Some("#7f7f7f".into()));
217        // red + blue = magenta-ish.
218        assert_eq!(hex_blend("#ff0000", "#0000ff"), Some("#7f007f".into()));
219    }
220
221    #[test]
222    fn hex_blend_rejects_garbage() {
223        assert_eq!(hex_blend("not-hex", "#000000"), None);
224        assert_eq!(hex_blend("#fff", "#000000"), None); // 3-digit not supported (claudebar parity)
225        assert_eq!(hex_blend("#xxxxxx", "#000000"), None);
226        assert_eq!(hex_blend("aéabc", "#000000"), None);
227    }
228
229    #[test]
230    fn hex_blend_strips_optional_hash() {
231        assert_eq!(hex_blend("000000", "ffffff"), Some("#7f7f7f".into()));
232    }
233
234    #[test]
235    fn cli_overrides_win_over_defaults() {
236        let t = Theme::default().with_overrides(
237            Some("#111111".into()),
238            None,
239            Some("#222222".into()),
240            None,
241        );
242        assert_eq!(t.green, "#111111");
243        assert_eq!(t.orange, "#222222");
244        assert_eq!(t.red, ONE_DARK_RED);
245        assert_eq!(t.yellow, ONE_DARK_YELLOW);
246    }
247
248    #[test]
249    fn missing_omarchy_file_is_silent() {
250        let t = Theme::default();
251        let merged = t
252            .clone()
253            .merged_with_omarchy_file(Path::new("/nonexistent/path.toml"));
254        assert_eq!(t, merged);
255    }
256
257    #[test]
258    fn omarchy_overrides_palette() {
259        let mut f = NamedTempFile::new().unwrap();
260        writeln!(
261            f,
262            r##"
263            accent = "#aabbcc"
264            foreground = "#ffffff"
265            background = "#000000"
266            color1 = "#ff0000"
267            color2 = "#00ff00"
268            color3 = "#ffff00"
269            "##
270        )
271        .unwrap();
272
273        let t = Theme::default().merged_with_omarchy_file(f.path());
274        assert_eq!(t.blue, "#aabbcc");
275        assert_eq!(t.fg, "#ffffff");
276        assert_eq!(t.red, "#ff0000");
277        assert_eq!(t.orange, "#ff0000"); // color1 maps to both
278        assert_eq!(t.green, "#00ff00");
279        assert_eq!(t.yellow, "#ffff00");
280        // dim = blend(fg, bg) = blend(#fff, #000) = #7f7f7f
281        assert_eq!(t.dim, "#7f7f7f");
282        assert_eq!(t.marker, "#7f7f7f");
283        // bar_empty = blend(bg, dim) = blend(#000, #7f7f7f) = #3f3f3f
284        assert_eq!(t.bar_empty, "#3f3f3f");
285    }
286
287    #[test]
288    fn omarchy_partial_keys_keep_defaults_for_unset() {
289        let mut f = NamedTempFile::new().unwrap();
290        writeln!(f, r##"accent = "#123456""##).unwrap();
291        let t = Theme::default().merged_with_omarchy_file(f.path());
292        assert_eq!(t.blue, "#123456");
293        // Untouched fields remain One Dark.
294        assert_eq!(t.green, ONE_DARK_GREEN);
295        assert_eq!(t.red, ONE_DARK_RED);
296        assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
297    }
298
299    #[test]
300    fn omarchy_with_only_bg_keeps_default_dim() {
301        // claudebar only computes dim when BOTH fg and bg are present.
302        let mut f = NamedTempFile::new().unwrap();
303        writeln!(f, r##"background = "#000000""##).unwrap();
304        let t = Theme::default().merged_with_omarchy_file(f.path());
305        assert_eq!(t.dim, ONE_DARK_DIM);
306        assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
307    }
308
309    #[test]
310    fn omarchy_garbage_is_silent() {
311        let mut f = NamedTempFile::new().unwrap();
312        writeln!(f, "this is not toml = = =").unwrap();
313        let before = Theme::default();
314        let after = before.clone().merged_with_omarchy_file(f.path());
315        assert_eq!(before, after);
316    }
317}