modcli/output/
themes.rs

1use crate::output::colors::*;
2use crossterm::style::{Color, ResetColor, SetBackgroundColor, SetForegroundColor};
3#[cfg(feature = "theme-config")]
4use serde::Deserialize;
5use std::collections::HashMap;
6#[cfg(feature = "theme-config")]
7use std::fs;
8use std::io::{stdout, Write};
9#[cfg(feature = "theme-config")]
10use std::path::Path;
11use std::sync::OnceLock;
12
13#[derive(Clone)]
14pub struct Theme {
15    pub name: String,
16    pub fg: Color,
17    pub bg: Color,
18    pub log_styles: HashMap<&'static str, Color>,
19}
20
21impl Theme {
22    pub fn apply(&self) {
23        let _ = write!(
24            stdout(),
25            "{}{}",
26            SetForegroundColor(self.fg),
27            SetBackgroundColor(self.bg)
28        );
29        let _ = stdout().flush();
30    }
31
32    pub fn reset() {
33        let _ = write!(stdout(), "{ResetColor}");
34        let _ = stdout().flush();
35    }
36
37    pub fn get_log_color(&self, key: &str) -> Color {
38        self.log_styles.get(key).copied().unwrap_or(self.fg)
39    }
40}
41
42static THEME: OnceLock<Theme> = OnceLock::new();
43
44fn log_defaults(base: Color) -> HashMap<&'static str, Color> {
45    let mut map = HashMap::new();
46    map.insert("error", COLOR_ERROR);
47    map.insert("warn", COLOR_WARNING);
48    map.insert("success", COLOR_SUCCESS);
49    map.insert("debug", COLOR_DEBUG);
50    map.insert("info", COLOR_INFO);
51    map.insert("trace", COLOR_TRACE);
52    map.insert("notice", COLOR_NOTICE);
53    map.insert("status", COLOR_STATUS);
54    map.insert("default", base);
55    // Menu theming keys (used by raw paged builders)
56    // Selected background defaults to status color, selected foreground defaults to BLACK for contrast,
57    // stripe foreground defaults to DARK_GREY.
58    map.insert("menu_selected_bg", COLOR_STATUS);
59    map.insert("menu_selected_fg", BLACK);
60    map.insert("menu_stripe_fg", DARK_GREY);
61    map
62}
63
64pub fn apply_theme(name: &str) {
65    let theme = match name.to_lowercase().as_str() {
66        "monochrome" => Theme {
67            name: "monochrome".into(),
68            fg: GREY,
69            bg: BLACK,
70            log_styles: log_defaults(GREY),
71        },
72        "inverted" => Theme {
73            name: "inverted".into(),
74            fg: BLACK,
75            bg: WHITE,
76            log_styles: log_defaults(BLACK),
77        },
78        "blue" => Theme {
79            name: "blue".into(),
80            fg: WHITE,
81            bg: BLUE,
82            log_styles: log_defaults(WHITE),
83        },
84        "green" => Theme {
85            name: "green".into(),
86            fg: BLACK,
87            bg: GREEN,
88            log_styles: log_defaults(BLACK),
89        },
90        _ => Theme {
91            name: "default".into(),
92            fg: WHITE,
93            bg: BLACK,
94            log_styles: log_defaults(WHITE),
95        },
96    };
97
98    let _ = THEME.set(theme.clone()); // only sets once
99    theme.apply();
100}
101
102pub fn current_theme() -> Theme {
103    THEME.get().cloned().unwrap_or_else(|| Theme {
104        name: "default".into(),
105        fg: WHITE,
106        bg: BLACK,
107        log_styles: log_defaults(WHITE),
108    })
109}
110
111/// RAII guard that applies a theme and resets on drop.
112/// Does not mutate global THEME; it only changes terminal colors during the guard's lifetime.
113pub struct ThemeGuard {
114    reset: bool,
115}
116
117impl ThemeGuard {
118    pub fn apply(name: &str) -> Self {
119        apply_theme(name);
120        Self { reset: true }
121    }
122
123    pub fn disable_reset(mut self) -> Self {
124        self.reset = false;
125        self
126    }
127}
128
129impl Drop for ThemeGuard {
130    fn drop(&mut self) {
131        if self.reset {
132            Theme::reset();
133        }
134    }
135}
136
137#[cfg(feature = "theme-config")]
138#[derive(Deserialize)]
139struct ThemeFile {
140    name: Option<String>,
141    fg: Option<String>,
142    bg: Option<String>,
143    log_styles: Option<HashMap<String, String>>,
144}
145
146/// Load a theme from a JSON file (feature: theme-config). Returns a Theme you can apply.
147#[cfg(feature = "theme-config")]
148pub fn load_theme_from_json<P: AsRef<Path>>(path: P) -> Result<Theme, String> {
149    let data = fs::read_to_string(&path).map_err(|e| format!("read failed: {e}"))?;
150    let tf: ThemeFile =
151        serde_json::from_str(&data).map_err(|e| format!("json parse failed: {e}"))?;
152
153    let fg = tf.fg.as_deref().map(get).unwrap_or(WHITE);
154    let bg = tf.bg.as_deref().map(get).unwrap_or(BLACK);
155    let mut log = log_defaults(fg);
156    if let Some(map) = tf.log_styles {
157        for (k, v) in map.into_iter() {
158            log.insert(Box::leak(k.into_boxed_str()), get(&v));
159        }
160    }
161    Ok(Theme {
162        name: tf.name.unwrap_or_else(|| "loaded".into()),
163        fg,
164        bg,
165        log_styles: log,
166    })
167}