Skip to main content

rmut_front/
theme.rs

1//! mutt's look, from the config: the slot colors, the quote
2//! palette, the status bar. In front-end terms; each front end maps
3//! them onto its own styles.
4
5use rmut_core::config::Config;
6
7use crate::style::{Color, Style, parse_color};
8
9#[derive(Clone)]
10pub struct Theme {
11    pub bar_fg: Color,
12    pub bar_bg: Color,
13    bar_reversed: bool,
14    pub deleted: Color,
15    pub flagged: Color,
16    pub tagged: Color,
17    pub header: Color,
18    /// Quote-depth palette (mutt's `color quoted`, `quoted1`…): depth
19    /// d takes entry (d-1) mod len; empty = quotes stay untinted.
20    pub quoted: Vec<Color>,
21    /// Search-hit highlight in the pager (mutt's `color search`).
22    pub search: Style,
23    /// Error statuses on the bottom line (mutt's `color error`,
24    /// bold bright red by default).
25    pub error: Style,
26}
27
28impl Theme {
29    fn preset(name: &str) -> Theme {
30        match name {
31            "mono" => Theme {
32                bar_fg: Color::Reset,
33                bar_bg: Color::Reset,
34                bar_reversed: true,
35                deleted: Color::Reset,
36                flagged: Color::Reset,
37                tagged: Color::Reset,
38                header: Color::Reset,
39                quoted: Vec::new(),
40                search: Style::new().reversed(),
41                error: Style::new().bold().reversed(),
42            },
43            // mutt's default look
44            _ => Theme {
45                bar_fg: Color::Black,
46                bar_bg: Color::Cyan,
47                bar_reversed: false,
48                deleted: Color::Red,
49                flagged: Color::Yellow,
50                tagged: Color::Cyan,
51                header: Color::Green,
52                quoted: vec![Color::Cyan],
53                search: Style::new().reversed(),
54                error: Style::new().fg(Color::LightRed).bold(),
55            },
56        }
57    }
58
59    pub fn from_config(cfg: &Config) -> (Theme, Vec<String>) {
60        let mut theme = Theme::preset(cfg.ui.theme.as_deref().unwrap_or("default"));
61        let mut warnings = Vec::new();
62        // `quoted`, `quoted1`… build the depth palette in N order
63        // (HashMap iteration is unordered, so collect first).
64        let mut quoted: std::collections::BTreeMap<usize, Color> =
65            std::collections::BTreeMap::new();
66        let (mut search_fg, mut search_bg) = (None, None);
67        for (key, value) in &cfg.colors {
68            let Some(color) = parse_color(value) else {
69                warnings.push(format!("unknown color {value:?}"));
70                continue;
71            };
72            if let Some(n) = key.strip_prefix("quoted")
73                && let Ok(depth) = if n.is_empty() { Ok(0) } else { n.parse() }
74            {
75                quoted.insert(depth, color);
76                continue;
77            }
78            match key.as_str() {
79                "status_fg" => {
80                    theme.bar_fg = color;
81                    theme.bar_reversed = false;
82                }
83                "status_bg" => {
84                    theme.bar_bg = color;
85                    theme.bar_reversed = false;
86                }
87                "deleted" => theme.deleted = color,
88                "flagged" => theme.flagged = color,
89                "tagged" => theme.tagged = color,
90                "header" => theme.header = color,
91                "search_fg" => search_fg = Some(color),
92                "search_bg" => search_bg = Some(color),
93                "error" => {
94                    theme.error = Style::new().fg(color).bold();
95                }
96                other => warnings.push(format!("unknown color key {other:?}")),
97            }
98        }
99        if !quoted.is_empty() {
100            theme.quoted = quoted.into_values().collect();
101        }
102        if search_fg.is_some() || search_bg.is_some() {
103            let mut style = Style::new();
104            if let Some(fg) = search_fg {
105                style = style.fg(fg);
106            }
107            if let Some(bg) = search_bg {
108                style = style.bg(bg);
109            }
110            theme.search = style;
111        }
112        (theme, warnings)
113    }
114
115    pub fn bar_style(&self) -> Style {
116        if self.bar_reversed {
117            Style::new().reversed()
118        } else {
119            Style::new().fg(self.bar_fg).bg(self.bar_bg)
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn overrides_apply_and_warn() {
130        let cfg: Config =
131            toml::from_str("[colors]\ndeleted = \"blue\"\nbogus = \"red\"\nflagged = \"nope\"\n")
132                .unwrap();
133        let (theme, warnings) = Theme::from_config(&cfg);
134        assert_eq!(theme.deleted, Color::Blue);
135        assert_eq!(theme.flagged, Color::Yellow); // bad value kept default
136        assert_eq!(warnings.len(), 2);
137    }
138
139    #[test]
140    fn quoted_palette_and_search_override() {
141        let cfg: Config = toml::from_str(
142            "[colors]\nquoted = \"red\"\nquoted2 = \"blue\"\nsearch_bg = \"yellow\"\n",
143        )
144        .unwrap();
145        let (theme, warnings) = Theme::from_config(&cfg);
146        assert!(warnings.is_empty());
147        assert_eq!(theme.quoted, vec![Color::Red, Color::Blue]);
148        assert_eq!(theme.search, Style::new().bg(Color::Yellow));
149        // Untouched, the default theme tints quotes cyan and reverses
150        // search hits.
151        let (plain, _) = Theme::from_config(&Config::default());
152        assert_eq!(plain.quoted, vec![Color::Cyan]);
153        assert_eq!(plain.search, Style::new().reversed());
154    }
155
156    #[test]
157    fn mono_preset_reverses_bar() {
158        let cfg: Config = toml::from_str("[ui]\ntheme = \"mono\"\n").unwrap();
159        let (theme, warnings) = Theme::from_config(&cfg);
160        assert!(warnings.is_empty());
161        assert_eq!(theme.bar_style(), Style::new().reversed());
162    }
163}