1use ratatui::style::{Color, Modifier, Style};
2use serde::Deserialize;
3
4#[derive(Debug, Clone)]
7pub struct Theme {
8 pub border_focused: Color,
10 pub border_unfocused: Color,
11
12 pub text_primary: Color,
14 pub text_secondary: Color,
15 pub text_muted: Color,
16
17 pub accent: Color,
19 pub accent_dim: Color,
20
21 pub selection_bg: Color,
23 pub selection_fg: Color,
24
25 pub error: Color,
27 pub warning: Color,
28 pub success: Color,
29
30 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 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), }
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 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 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 let hash: u8 = label_name.bytes().fold(0u8, |acc, b| acc.wrapping_add(b));
227 let colors = [
228 Color::Rgb(96, 165, 250), Color::Rgb(52, 211, 153), Color::Rgb(251, 146, 60), Color::Rgb(167, 139, 250), Color::Rgb(251, 113, 133), Color::Rgb(56, 189, 248), Color::Rgb(253, 186, 116), Color::Rgb(134, 239, 172), ];
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}