Skip to main content

mxr_tui/
theme.rs

1use ratatui::style::{Color, Modifier, Style};
2use serde::Deserialize;
3
4/// Centralized color palette for the TUI.
5/// All UI code should use theme methods instead of hardcoded colors.
6#[derive(Debug, Clone)]
7pub struct Theme {
8    // Borders
9    pub border_focused: Color,
10    pub border_unfocused: Color,
11
12    // Text
13    pub text_primary: Color,
14    pub text_secondary: Color,
15    pub text_muted: Color,
16
17    // Accent / highlight
18    pub accent: Color,
19    pub accent_dim: Color,
20
21    // Selection
22    pub selection_bg: Color,
23    pub selection_fg: Color,
24
25    // Semantic
26    pub error: Color,
27    pub warning: Color,
28    pub success: Color,
29
30    // Specific UI
31    pub unread_fg: Color,
32    pub label_bg: Color,
33    pub modal_bg: Color,
34    pub hint_bar_bg: Color,
35    pub quote_fg: Color,
36    pub signature_fg: Color,
37    pub line_number_fg: Color,
38    pub link_fg: Color,
39}
40
41impl Theme {
42    pub fn from_spec(spec: &str) -> Self {
43        let normalized = spec.trim();
44        if normalized.is_empty() {
45            return Self::default();
46        }
47
48        match normalized.to_ascii_lowercase().as_str() {
49            "default" | "dark" => Self::dark(),
50            "minimal" => Self::minimal(),
51            "light" => Self::light(),
52            "catppuccin" => Self::catppuccin(),
53            _ => Self::from_path(normalized).unwrap_or_default(),
54        }
55    }
56
57    /// Dark theme using colors that match the terminal-native aesthetic.
58    pub fn dark() -> Self {
59        Self {
60            border_focused: Color::Cyan,
61            border_unfocused: Color::DarkGray,
62            text_primary: Color::White,
63            text_secondary: Color::Gray,
64            text_muted: Color::DarkGray,
65            accent: Color::Cyan,
66            accent_dim: Color::Blue,
67            selection_bg: Color::Rgb(50, 50, 60),
68            selection_fg: Color::White,
69            error: Color::Red,
70            warning: Color::Yellow,
71            success: Color::Green,
72            unread_fg: Color::White,
73            label_bg: Color::Rgb(40, 40, 50),
74            modal_bg: Color::Rgb(18, 18, 26),
75            hint_bar_bg: Color::Rgb(30, 30, 40),
76            quote_fg: Color::DarkGray,
77            signature_fg: Color::DarkGray,
78            line_number_fg: Color::Rgb(80, 80, 80),
79            link_fg: Color::Rgb(96, 165, 250), // blue, underlined in render
80        }
81    }
82
83    pub fn minimal() -> Self {
84        Self {
85            border_focused: Color::White,
86            border_unfocused: Color::DarkGray,
87            text_primary: Color::White,
88            text_secondary: Color::Gray,
89            text_muted: Color::DarkGray,
90            accent: Color::White,
91            accent_dim: Color::Gray,
92            selection_bg: Color::Black,
93            selection_fg: Color::White,
94            error: Color::Red,
95            warning: Color::Yellow,
96            success: Color::Green,
97            unread_fg: Color::White,
98            label_bg: Color::Black,
99            modal_bg: Color::Black,
100            hint_bar_bg: Color::Black,
101            quote_fg: Color::Gray,
102            signature_fg: Color::DarkGray,
103            line_number_fg: Color::DarkGray,
104            link_fg: Color::Cyan,
105        }
106    }
107
108    pub fn light() -> Self {
109        Self {
110            border_focused: Color::Blue,
111            border_unfocused: Color::Gray,
112            text_primary: Color::Black,
113            text_secondary: Color::DarkGray,
114            text_muted: Color::Gray,
115            accent: Color::Blue,
116            accent_dim: Color::Cyan,
117            selection_bg: Color::Rgb(225, 235, 255),
118            selection_fg: Color::Black,
119            error: Color::Red,
120            warning: Color::Rgb(180, 120, 0),
121            success: Color::Green,
122            unread_fg: Color::Black,
123            label_bg: Color::Rgb(236, 242, 255),
124            modal_bg: Color::Rgb(248, 249, 252),
125            hint_bar_bg: Color::Rgb(240, 244, 248),
126            quote_fg: Color::Gray,
127            signature_fg: Color::Gray,
128            line_number_fg: Color::Gray,
129            link_fg: Color::Blue,
130        }
131    }
132
133    pub fn catppuccin() -> Self {
134        Self {
135            border_focused: Color::Rgb(137, 180, 250),
136            border_unfocused: Color::Rgb(88, 91, 112),
137            text_primary: Color::Rgb(205, 214, 244),
138            text_secondary: Color::Rgb(186, 194, 222),
139            text_muted: Color::Rgb(127, 132, 156),
140            accent: Color::Rgb(203, 166, 247),
141            accent_dim: Color::Rgb(137, 180, 250),
142            selection_bg: Color::Rgb(49, 50, 68),
143            selection_fg: Color::Rgb(205, 214, 244),
144            error: Color::Rgb(243, 139, 168),
145            warning: Color::Rgb(249, 226, 175),
146            success: Color::Rgb(166, 227, 161),
147            unread_fg: Color::Rgb(205, 214, 244),
148            label_bg: Color::Rgb(69, 71, 90),
149            modal_bg: Color::Rgb(30, 30, 46),
150            hint_bar_bg: Color::Rgb(49, 50, 68),
151            quote_fg: Color::Rgb(108, 112, 134),
152            signature_fg: Color::Rgb(127, 132, 156),
153            line_number_fg: Color::Rgb(88, 91, 112),
154            link_fg: Color::Rgb(137, 180, 250),
155        }
156    }
157
158    // Helper style methods
159    pub fn border_style(&self, focused: bool) -> Style {
160        if focused {
161            Style::default().fg(self.border_focused)
162        } else {
163            Style::default().fg(self.border_unfocused)
164        }
165    }
166
167    pub fn highlight_style(&self) -> Style {
168        Style::default()
169            .bg(self.selection_bg)
170            .fg(self.selection_fg)
171            .add_modifier(Modifier::BOLD)
172    }
173
174    pub fn accent_style(&self) -> Style {
175        Style::default().fg(self.accent)
176    }
177
178    pub fn muted_style(&self) -> Style {
179        Style::default().fg(self.text_muted)
180    }
181
182    pub fn primary_style(&self) -> Style {
183        Style::default().fg(self.text_primary)
184    }
185
186    pub fn secondary_style(&self) -> Style {
187        Style::default().fg(self.text_secondary)
188    }
189
190    pub fn error_style(&self) -> Style {
191        Style::default().fg(self.error)
192    }
193
194    pub fn warning_style(&self) -> Style {
195        Style::default().fg(self.warning)
196    }
197
198    pub fn success_style(&self) -> Style {
199        Style::default().fg(self.success)
200    }
201
202    pub fn unread_style(&self) -> Style {
203        Style::default()
204            .fg(self.unread_fg)
205            .add_modifier(Modifier::BOLD)
206    }
207
208    pub fn modal_block_style(&self) -> Style {
209        Style::default().bg(self.modal_bg)
210    }
211
212    /// Returns a color for a label based on its name.
213    /// System labels get fixed colors, user labels use a hash-based palette.
214    pub fn label_color(label_name: &str) -> Color {
215        match label_name.to_uppercase().as_str() {
216            "INBOX" => Color::Blue,
217            "STARRED" => Color::Yellow,
218            "SENT" => Color::Gray,
219            "DRAFT" => Color::Magenta,
220            "TRASH" => Color::Red,
221            "SPAM" => Color::Rgb(255, 140, 0),
222            "ARCHIVE" => Color::DarkGray,
223            "IMPORTANT" => Color::Yellow,
224            _ => {
225                // Hash-based color for user labels
226                let hash: u8 = label_name.bytes().fold(0u8, |acc, b| acc.wrapping_add(b));
227                let colors = [
228                    Color::Rgb(96, 165, 250),  // blue
229                    Color::Rgb(52, 211, 153),  // emerald
230                    Color::Rgb(251, 146, 60),  // orange
231                    Color::Rgb(167, 139, 250), // violet
232                    Color::Rgb(251, 113, 133), // rose
233                    Color::Rgb(56, 189, 248),  // sky
234                    Color::Rgb(253, 186, 116), // amber
235                    Color::Rgb(134, 239, 172), // green
236                ];
237                colors[(hash % colors.len() as u8) as usize]
238            }
239        }
240    }
241
242    fn from_path(path: &str) -> Option<Self> {
243        let content = std::fs::read_to_string(path).ok()?;
244        let overrides = toml::from_str::<ThemeOverrides>(&content).ok()?;
245        Some(overrides.apply(Self::dark()))
246    }
247}
248
249impl Default for Theme {
250    fn default() -> Self {
251        Self::dark()
252    }
253}
254
255#[derive(Debug, Clone, Deserialize)]
256struct ThemeOverrides {
257    border_focused: Option<ColorValue>,
258    border_unfocused: Option<ColorValue>,
259    text_primary: Option<ColorValue>,
260    text_secondary: Option<ColorValue>,
261    text_muted: Option<ColorValue>,
262    accent: Option<ColorValue>,
263    accent_dim: Option<ColorValue>,
264    selection_bg: Option<ColorValue>,
265    selection_fg: Option<ColorValue>,
266    error: Option<ColorValue>,
267    warning: Option<ColorValue>,
268    success: Option<ColorValue>,
269    unread_fg: Option<ColorValue>,
270    label_bg: Option<ColorValue>,
271    modal_bg: Option<ColorValue>,
272    hint_bar_bg: Option<ColorValue>,
273    quote_fg: Option<ColorValue>,
274    signature_fg: Option<ColorValue>,
275    line_number_fg: Option<ColorValue>,
276    link_fg: Option<ColorValue>,
277}
278
279impl ThemeOverrides {
280    fn apply(self, mut theme: Theme) -> Theme {
281        macro_rules! override_color {
282            ($field:ident) => {
283                if let Some(value) = self.$field.and_then(ColorValue::into_color) {
284                    theme.$field = value;
285                }
286            };
287        }
288
289        override_color!(border_focused);
290        override_color!(border_unfocused);
291        override_color!(text_primary);
292        override_color!(text_secondary);
293        override_color!(text_muted);
294        override_color!(accent);
295        override_color!(accent_dim);
296        override_color!(selection_bg);
297        override_color!(selection_fg);
298        override_color!(error);
299        override_color!(warning);
300        override_color!(success);
301        override_color!(unread_fg);
302        override_color!(label_bg);
303        override_color!(modal_bg);
304        override_color!(hint_bar_bg);
305        override_color!(quote_fg);
306        override_color!(signature_fg);
307        override_color!(line_number_fg);
308        override_color!(link_fg);
309        theme
310    }
311}
312
313#[derive(Debug, Clone, Deserialize)]
314#[serde(untagged)]
315enum ColorValue {
316    Named(String),
317    Rgb([u8; 3]),
318}
319
320impl ColorValue {
321    fn into_color(self) -> Option<Color> {
322        match self {
323            Self::Rgb([r, g, b]) => Some(Color::Rgb(r, g, b)),
324            Self::Named(name) => parse_named_color(&name),
325        }
326    }
327}
328
329fn parse_named_color(value: &str) -> Option<Color> {
330    let normalized = value.trim().to_ascii_lowercase();
331    match normalized.as_str() {
332        "black" => Some(Color::Black),
333        "red" => Some(Color::Red),
334        "green" => Some(Color::Green),
335        "yellow" => Some(Color::Yellow),
336        "blue" => Some(Color::Blue),
337        "magenta" => Some(Color::Magenta),
338        "cyan" => Some(Color::Cyan),
339        "gray" | "grey" => Some(Color::Gray),
340        "darkgray" | "dark_gray" | "dark-grey" | "darkgrey" => Some(Color::DarkGray),
341        "white" => Some(Color::White),
342        _ if normalized.starts_with('#') && normalized.len() == 7 => {
343            let r = u8::from_str_radix(&normalized[1..3], 16).ok()?;
344            let g = u8::from_str_radix(&normalized[3..5], 16).ok()?;
345            let b = u8::from_str_radix(&normalized[5..7], 16).ok()?;
346            Some(Color::Rgb(r, g, b))
347        }
348        _ => None,
349    }
350}