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. The active Omarchy theme's `colors.toml`
6//!   3. One Dark fallback
7//!
8//! Omarchy applies a theme into `~/.local/state/omarchy/current/theme`; the
9//! `~/.config/omarchy/current/theme` location this port originally read is the
10//! older layout and stays as a fallback. The Omarchy file format is
11//! `key = "value"` lines (claudebar:115-131 uses a regex that tolerates
12//! comments + blank lines + extra whitespace). We use `toml::from_str` for the
13//! same effect - Omarchy themes are valid TOML.
14
15use std::path::{Path, PathBuf};
16
17use serde::Deserialize;
18
19/// One Dark defaults, byte-identical to claudebar:152-159.
20const ONE_DARK_GREEN: &str = "#98c379";
21const ONE_DARK_YELLOW: &str = "#e5c07b";
22const ONE_DARK_ORANGE: &str = "#d19a66";
23const ONE_DARK_RED: &str = "#e06c75";
24const ONE_DARK_BLUE: &str = "#61afef";
25const ONE_DARK_DIM: &str = "#5c6370";
26const ONE_DARK_FG: &str = "#abb2bf";
27const ONE_DARK_BAR_EMPTY: &str = "#3e4451";
28const ONE_DARK_MARKER: &str = "#d19a66";
29
30/// Full resolved palette used by the widget tooltip and the TUI.
31///
32/// All fields are stored as `#RRGGBB` strings ready to drop into Pango
33/// `<span foreground='…'>` markup. The TUI converts them to `ratatui::Color`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Theme {
36    pub green: String,
37    pub yellow: String,
38    pub orange: String,
39    pub red: String,
40    pub blue: String,
41    pub dim: String,
42    pub fg: String,
43    pub bar_empty: String,
44    pub marker: String,
45}
46
47impl Default for Theme {
48    fn default() -> Self {
49        Self {
50            green: ONE_DARK_GREEN.into(),
51            yellow: ONE_DARK_YELLOW.into(),
52            orange: ONE_DARK_ORANGE.into(),
53            red: ONE_DARK_RED.into(),
54            blue: ONE_DARK_BLUE.into(),
55            dim: ONE_DARK_DIM.into(),
56            fg: ONE_DARK_FG.into(),
57            bar_empty: ONE_DARK_BAR_EMPTY.into(),
58            marker: ONE_DARK_MARKER.into(),
59        }
60    }
61}
62
63/// Subset of an Omarchy theme file we care about. Unknown keys are ignored,
64/// missing keys fall back to One Dark. Themes name these colors as of Omarchy
65/// 4 (`red`/`green`/`yellow`); `color1`-`color3` are the older aliases and
66/// still resolve, so a theme carrying either layout works.
67#[derive(Debug, Default, Deserialize)]
68struct OmarchyTheme {
69    accent: Option<String>,
70    foreground: Option<String>,
71    background: Option<String>,
72    red: Option<String>,
73    green: Option<String>,
74    yellow: Option<String>,
75    color1: Option<String>,
76    color2: Option<String>,
77    color3: Option<String>,
78}
79
80impl Theme {
81    /// Apply CLI `--color-*` overrides. Each `Some(_)` wins over the underlying
82    /// theme; `None` preserves the resolved color.
83    pub fn with_overrides(
84        mut self,
85        low: Option<String>,
86        mid: Option<String>,
87        high: Option<String>,
88        critical: Option<String>,
89    ) -> Self {
90        if let Some(v) = low {
91            self.green = v;
92        }
93        if let Some(v) = mid {
94            self.yellow = v;
95        }
96        if let Some(v) = high {
97            self.orange = v;
98        }
99        if let Some(v) = critical {
100            self.red = v;
101        }
102        self
103    }
104
105    /// Try to load the Omarchy theme and fold it on top of `self`. Returns
106    /// the result unmodified if the file is missing or unreadable — matching
107    /// claudebar's silent fallback (the script never errors on missing themes).
108    pub fn merged_with_omarchy(self) -> Self {
109        let Some(path) = omarchy_theme_path() else {
110            return self;
111        };
112        self.merged_with_omarchy_file(&path)
113    }
114
115    /// Same as `merged_with_omarchy` but with an explicit path (for tests).
116    pub fn merged_with_omarchy_file(mut self, path: &Path) -> Self {
117        let Ok(contents) = std::fs::read_to_string(path) else {
118            return self;
119        };
120        let Ok(parsed) = toml::from_str::<OmarchyTheme>(&contents) else {
121            return self;
122        };
123
124        // claudebar mapping (claudebar:133-148), with the named keys Omarchy
125        // writes today winning over the older color1-3 aliases:
126        //   accent       → blue
127        //   foreground   → fg
128        //   red/color1   → red AND orange
129        //   green/color2 → green
130        //   yellow/color3 → yellow
131        //   foreground+background → dim = midpoint
132        //   background → bar_empty = midpoint(background, dim)
133        if let Some(v) = parsed.accent {
134            self.blue = v;
135        }
136        if let Some(v) = parsed.foreground.clone() {
137            self.fg = v;
138        }
139        if let Some(v) = parsed.red.or(parsed.color1) {
140            self.red = v.clone();
141            self.orange = v;
142        }
143        if let Some(v) = parsed.green.or(parsed.color2) {
144            self.green = v;
145        }
146        if let Some(v) = parsed.yellow.or(parsed.color3) {
147            self.yellow = v;
148        }
149        if let (Some(fg), Some(bg)) = (&parsed.foreground, &parsed.background)
150            && let Some(dim) = hex_blend(fg, bg)
151        {
152            self.dim = dim;
153            self.marker = self.dim.clone();
154            if let Some(bar_empty) = hex_blend(bg, &self.dim) {
155                self.bar_empty = bar_empty;
156            }
157        }
158        self
159    }
160}
161
162/// Resolve the path to the active Omarchy theme. `None` on non-Omarchy systems
163/// or when `$HOME` isn't set.
164fn omarchy_theme_path() -> Option<PathBuf> {
165    let home = std::env::var_os("HOME")?;
166    omarchy_theme_path_in(Path::new(&home))
167}
168
169/// Same as `omarchy_theme_path` but with an explicit home (for tests).
170fn omarchy_theme_path_in(home: &Path) -> Option<PathBuf> {
171    [
172        // Where `omarchy-theme-set` applies the active theme.
173        home.join(".local/state/omarchy/current/theme/colors.toml"),
174        // The layout this port and claudebar originally read.
175        home.join(".config/omarchy/current/theme/colors.toml"),
176    ]
177    .into_iter()
178    .find(|path| path.is_file())
179}
180
181/// Average two `#RRGGBB` strings into a midpoint color. Returns `None` if
182/// either input isn't parseable. Mirrors claudebar's `hex_blend()`
183/// (claudebar:105-110).
184pub fn hex_blend(a: &str, b: &str) -> Option<String> {
185    let (ar, ag, ab) = parse_hex_rgb(a)?;
186    let (br, bg, bb) = parse_hex_rgb(b)?;
187    Some(format!(
188        "#{:02x}{:02x}{:02x}",
189        (u16::from(ar) + u16::from(br)) / 2,
190        (u16::from(ag) + u16::from(bg)) / 2,
191        (u16::from(ab) + u16::from(bb)) / 2,
192    ))
193}
194
195pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
196    let s = s.strip_prefix('#').unwrap_or(s);
197    let [r1, r2, g1, g2, b1, b2] = s.as_bytes() else {
198        return None;
199    };
200    Some((
201        hex_pair(*r1, *r2)?,
202        hex_pair(*g1, *g2)?,
203        hex_pair(*b1, *b2)?,
204    ))
205}
206
207fn hex_pair(hi: u8, lo: u8) -> Option<u8> {
208    Some((hex_nibble(hi)? << 4) | hex_nibble(lo)?)
209}
210
211fn hex_nibble(b: u8) -> Option<u8> {
212    match b {
213        b'0'..=b'9' => Some(b - b'0'),
214        b'a'..=b'f' => Some(b - b'a' + 10),
215        b'A'..=b'F' => Some(b - b'A' + 10),
216        _ => None,
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::io::Write;
224    use tempfile::NamedTempFile;
225
226    #[test]
227    fn one_dark_is_default() {
228        let t = Theme::default();
229        assert_eq!(t.green, ONE_DARK_GREEN);
230        assert_eq!(t.red, ONE_DARK_RED);
231        assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
232    }
233
234    #[test]
235    fn hex_blend_averages() {
236        // black + white = mid grey (rounded down per integer division).
237        assert_eq!(hex_blend("#000000", "#ffffff"), Some("#7f7f7f".into()));
238        // red + blue = magenta-ish.
239        assert_eq!(hex_blend("#ff0000", "#0000ff"), Some("#7f007f".into()));
240    }
241
242    #[test]
243    fn hex_blend_rejects_garbage() {
244        assert_eq!(hex_blend("not-hex", "#000000"), None);
245        assert_eq!(hex_blend("#fff", "#000000"), None); // 3-digit not supported (claudebar parity)
246        assert_eq!(hex_blend("#xxxxxx", "#000000"), None);
247        assert_eq!(hex_blend("aéabc", "#000000"), None);
248    }
249
250    #[test]
251    fn hex_blend_strips_optional_hash() {
252        assert_eq!(hex_blend("000000", "ffffff"), Some("#7f7f7f".into()));
253    }
254
255    #[test]
256    fn cli_overrides_win_over_defaults() {
257        let t = Theme::default().with_overrides(
258            Some("#111111".into()),
259            None,
260            Some("#222222".into()),
261            None,
262        );
263        assert_eq!(t.green, "#111111");
264        assert_eq!(t.orange, "#222222");
265        assert_eq!(t.red, ONE_DARK_RED);
266        assert_eq!(t.yellow, ONE_DARK_YELLOW);
267    }
268
269    #[test]
270    fn missing_omarchy_file_is_silent() {
271        let t = Theme::default();
272        let merged = t
273            .clone()
274            .merged_with_omarchy_file(Path::new("/nonexistent/path.toml"));
275        assert_eq!(t, merged);
276    }
277
278    #[test]
279    fn omarchy_named_keys_override_palette() {
280        // Omarchy 4 themes name their colors. Same slots as the color1-3
281        // aliases above, so a theme may carry either layout.
282        let mut f = NamedTempFile::new().unwrap();
283        writeln!(
284            f,
285            r##"
286            accent = "#aabbcc"
287            foreground = "#ffffff"
288            background = "#000000"
289            red = "#ff0000"
290            green = "#00ff00"
291            yellow = "#ffff00"
292            "##
293        )
294        .unwrap();
295
296        let t = Theme::default().merged_with_omarchy_file(f.path());
297        assert_eq!(t.blue, "#aabbcc");
298        assert_eq!(t.fg, "#ffffff");
299        assert_eq!(t.red, "#ff0000");
300        assert_eq!(t.orange, "#ff0000"); // red maps to both
301        assert_eq!(t.green, "#00ff00");
302        assert_eq!(t.yellow, "#ffff00");
303    }
304
305    #[test]
306    fn omarchy_named_keys_win_over_aliases() {
307        // A theme that kept the old aliases next to the named keys keeps the
308        // named value, which is what Omarchy itself reads.
309        let mut f = NamedTempFile::new().unwrap();
310        writeln!(
311            f,
312            r##"
313            red = "#111111"
314            green = "#222222"
315            yellow = "#333333"
316            color1 = "#aaaaaa"
317            color2 = "#bbbbbb"
318            color3 = "#cccccc"
319            "##
320        )
321        .unwrap();
322
323        let t = Theme::default().merged_with_omarchy_file(f.path());
324        assert_eq!(t.red, "#111111");
325        assert_eq!(t.orange, "#111111");
326        assert_eq!(t.green, "#222222");
327        assert_eq!(t.yellow, "#333333");
328    }
329
330    #[test]
331    fn omarchy_theme_path_follows_where_the_theme_is_applied() {
332        let home = tempfile::tempdir().unwrap();
333        assert_eq!(omarchy_theme_path_in(home.path()), None);
334
335        // The pre-Omarchy-4 layout is still read, and only while the applied
336        // theme is missing.
337        let legacy = home
338            .path()
339            .join(".config/omarchy/current/theme/colors.toml");
340        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
341        std::fs::write(&legacy, "accent = \"#123456\"\n").unwrap();
342        assert_eq!(omarchy_theme_path_in(home.path()), Some(legacy.clone()));
343
344        let applied = home
345            .path()
346            .join(".local/state/omarchy/current/theme/colors.toml");
347        std::fs::create_dir_all(applied.parent().unwrap()).unwrap();
348        std::fs::write(&applied, "accent = \"#654321\"\n").unwrap();
349        assert_eq!(omarchy_theme_path_in(home.path()), Some(applied));
350    }
351
352    #[test]
353    fn omarchy_overrides_palette() {
354        let mut f = NamedTempFile::new().unwrap();
355        writeln!(
356            f,
357            r##"
358            accent = "#aabbcc"
359            foreground = "#ffffff"
360            background = "#000000"
361            color1 = "#ff0000"
362            color2 = "#00ff00"
363            color3 = "#ffff00"
364            "##
365        )
366        .unwrap();
367
368        let t = Theme::default().merged_with_omarchy_file(f.path());
369        assert_eq!(t.blue, "#aabbcc");
370        assert_eq!(t.fg, "#ffffff");
371        assert_eq!(t.red, "#ff0000");
372        assert_eq!(t.orange, "#ff0000"); // color1 maps to both
373        assert_eq!(t.green, "#00ff00");
374        assert_eq!(t.yellow, "#ffff00");
375        // dim = blend(fg, bg) = blend(#fff, #000) = #7f7f7f
376        assert_eq!(t.dim, "#7f7f7f");
377        assert_eq!(t.marker, "#7f7f7f");
378        // bar_empty = blend(bg, dim) = blend(#000, #7f7f7f) = #3f3f3f
379        assert_eq!(t.bar_empty, "#3f3f3f");
380    }
381
382    #[test]
383    fn omarchy_partial_keys_keep_defaults_for_unset() {
384        let mut f = NamedTempFile::new().unwrap();
385        writeln!(f, r##"accent = "#123456""##).unwrap();
386        let t = Theme::default().merged_with_omarchy_file(f.path());
387        assert_eq!(t.blue, "#123456");
388        // Untouched fields remain One Dark.
389        assert_eq!(t.green, ONE_DARK_GREEN);
390        assert_eq!(t.red, ONE_DARK_RED);
391        assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
392    }
393
394    #[test]
395    fn omarchy_with_only_bg_keeps_default_dim() {
396        // claudebar only computes dim when BOTH fg and bg are present.
397        let mut f = NamedTempFile::new().unwrap();
398        writeln!(f, r##"background = "#000000""##).unwrap();
399        let t = Theme::default().merged_with_omarchy_file(f.path());
400        assert_eq!(t.dim, ONE_DARK_DIM);
401        assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
402    }
403
404    #[test]
405    fn omarchy_garbage_is_silent() {
406        let mut f = NamedTempFile::new().unwrap();
407        writeln!(f, "this is not toml = = =").unwrap();
408        let before = Theme::default();
409        let after = before.clone().merged_with_omarchy_file(f.path());
410        assert_eq!(before, after);
411    }
412}