1use ratatui::style::Color;
2use serde::Deserialize;
3
4fn rgb(hex: u32) -> Color {
5 Color::Rgb(
6 ((hex >> 16) & 0xFF) as u8,
7 ((hex >> 8) & 0xFF) as u8,
8 (hex & 0xFF) as u8,
9 )
10}
11
12fn darken(hex: u32, factor: u8) -> Color {
13 let r = ((hex >> 16) & 0xFF) as u16;
14 let g = ((hex >> 8) & 0xFF) as u16;
15 let b = (hex & 0xFF) as u16;
16 let f = factor as u16;
17 Color::Rgb(
18 (r * f / 100) as u8,
19 (g * f / 100) as u8,
20 (b * f / 100) as u8,
21 )
22}
23
24fn muted(neutral: u32, ink: u32) -> Color {
25 let nr = (neutral >> 16) & 0xFF;
26 let ng = (neutral >> 8) & 0xFF;
27 let nb = neutral & 0xFF;
28 let ir = (ink >> 16) & 0xFF;
29 let ig = (ink >> 8) & 0xFF;
30 let ib = ink & 0xFF;
31 let r = ((nr as u16 * 60 + ir as u16 * 40) / 100) as u8;
32 let g = ((ng as u16 * 60 + ig as u16 * 40) / 100) as u8;
33 let b = ((nb as u16 * 60 + ib as u16 * 40) / 100) as u8;
34 Color::Rgb(r, g, b)
35}
36
37#[derive(Clone, Debug, PartialEq)]
38pub struct Theme {
39 pub accent: Color,
40 pub highlight: Color,
41 pub logo: Color,
42 pub text: Color,
43 pub text_muted: Color,
44 pub background: Color,
45 pub background_panel: Color,
46 pub background_overlay: Color,
47 pub border: Color,
48 pub success: Color,
49 pub error: Color,
50 pub inverted_text: Color,
51}
52
53struct ThemeDef {
54 name: &'static str,
55 neutral: u32,
56 ink: u32,
57 primary: u32,
58 accent: u32,
59 success: u32,
60 error: u32,
61}
62
63#[derive(Deserialize)]
65struct ThemeDefRaw {
66 neutral: String,
67 ink: String,
68 primary: String,
69 accent: String,
70 success: String,
71 error: String,
72}
73
74pub fn load_user_themes(config_dir: &std::path::Path) -> Vec<(String, Theme)> {
78 let themes_dir = config_dir.join("themes");
79 if !themes_dir.is_dir() {
80 return Vec::new();
81 }
82 let mut themes = Vec::new();
83 let Ok(entries) = std::fs::read_dir(&themes_dir) else {
84 return themes;
85 };
86 for entry in entries.flatten() {
87 let path = entry.path();
88 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
89 continue;
90 }
91 let name = match path.file_stem().and_then(|s| s.to_str()) {
92 Some(n) => n.to_string(),
93 None => continue,
94 };
95 let data = match std::fs::read_to_string(&path) {
96 Ok(d) => d,
97 Err(_) => continue,
98 };
99 let raw: ThemeDefRaw = match toml::from_str(&data) {
100 Ok(r) => r,
101 Err(_) => continue,
102 };
103 let parse = |s: &str| -> Option<u32> {
104 let s = s.trim_start_matches('#');
105 u32::from_str_radix(s, 16).ok()
106 };
107 let neutral = match parse(&raw.neutral) {
108 Some(v) => v,
109 None => continue,
110 };
111 let ink = match parse(&raw.ink) {
112 Some(v) => v,
113 None => continue,
114 };
115 let primary = match parse(&raw.primary) {
116 Some(v) => v,
117 None => continue,
118 };
119 let accent = match parse(&raw.accent) {
120 Some(v) => v,
121 None => continue,
122 };
123 let success = match parse(&raw.success) {
124 Some(v) => v,
125 None => continue,
126 };
127 let error = match parse(&raw.error) {
128 Some(v) => v,
129 None => continue,
130 };
131 themes.push((
132 name,
133 Theme {
134 accent: rgb(accent),
135 highlight: rgb(primary),
136 logo: rgb(primary),
137 text: rgb(ink),
138 text_muted: muted(neutral, ink),
139 background: Color::Reset,
140 background_panel: rgb(neutral),
141 background_overlay: darken(neutral, 40),
142 border: rgb(primary),
143 success: rgb(success),
144 error: rgb(error),
145 inverted_text: rgb(neutral),
146 },
147 ));
148 }
149 themes
150}
151
152const THEMES: &[ThemeDef] = &[
153 ThemeDef {
154 name: "OpenCode",
155 neutral: 0x0a0a0a,
156 ink: 0xeeeeee,
157 primary: 0xfab283,
158 accent: 0x9d7cd8,
159 success: 0x7fd88f,
160 error: 0xe06c75,
161 },
162 ThemeDef {
163 name: "Santui",
164 neutral: 0x141414,
165 ink: 0xffffff,
166 primary: 0xffb900,
167 accent: 0x9d7cd8,
168 success: 0x7fd88f,
169 error: 0xe06c75,
170 },
171 ThemeDef {
172 name: "AMOLED",
173 neutral: 0x000000,
174 ink: 0xffffff,
175 primary: 0xb388ff,
176 accent: 0xff4081,
177 success: 0x00ff88,
178 error: 0xff1744,
179 },
180 ThemeDef {
181 name: "Aura",
182 neutral: 0x15141b,
183 ink: 0xedecee,
184 primary: 0xa277ff,
185 accent: 0xff6767,
186 success: 0x61ffca,
187 error: 0xff6767,
188 },
189 ThemeDef {
190 name: "Ayu",
191 neutral: 0x0f1419,
192 ink: 0xd6dae0,
193 primary: 0x3fb7e3,
194 accent: 0xf2856f,
195 success: 0x78d05c,
196 error: 0xf58572,
197 },
198 ThemeDef {
199 name: "Carbonfox",
200 neutral: 0x393939,
201 ink: 0xf2f4f8,
202 primary: 0x33b1ff,
203 accent: 0xff8389,
204 success: 0x42be65,
205 error: 0xff8389,
206 },
207 ThemeDef {
208 name: "Catppuccin Frappe",
209 neutral: 0x303446,
210 ink: 0xc6d0f5,
211 primary: 0x8da4e2,
212 accent: 0xf4b8e4,
213 success: 0xa6d189,
214 error: 0xe78284,
215 },
216 ThemeDef {
217 name: "Catppuccin Macchiato",
218 neutral: 0x24273a,
219 ink: 0xcad3f5,
220 primary: 0x8aadf4,
221 accent: 0xf5bde6,
222 success: 0xa6da95,
223 error: 0xed8796,
224 },
225 ThemeDef {
226 name: "Catppuccin",
227 neutral: 0x1e1e2e,
228 ink: 0xcdd6f4,
229 primary: 0xb4befe,
230 accent: 0xf38ba8,
231 success: 0xa6d189,
232 error: 0xf38ba8,
233 },
234 ThemeDef {
235 name: "Cobalt2",
236 neutral: 0x193549,
237 ink: 0xffffff,
238 primary: 0x0088ff,
239 accent: 0x2affdf,
240 success: 0x9eff80,
241 error: 0xff0088,
242 },
243 ThemeDef {
244 name: "Cursor",
245 neutral: 0x181818,
246 ink: 0xe4e4e4,
247 primary: 0x88c0d0,
248 accent: 0x88c0d0,
249 success: 0x3fa266,
250 error: 0xe34671,
251 },
252 ThemeDef {
253 name: "Dracula",
254 neutral: 0x1d1e28,
255 ink: 0xf8f8f2,
256 primary: 0xbd93f9,
257 accent: 0xff79c6,
258 success: 0x50fa7b,
259 error: 0xff5555,
260 },
261 ThemeDef {
262 name: "Everforest",
263 neutral: 0x2d353b,
264 ink: 0xd3c6aa,
265 primary: 0xa7c080,
266 accent: 0xd699b6,
267 success: 0xa7c080,
268 error: 0xe67e80,
269 },
270 ThemeDef {
271 name: "Flexoki",
272 neutral: 0x100f0f,
273 ink: 0xcecdc3,
274 primary: 0xda702c,
275 accent: 0x8b7ec8,
276 success: 0x879a39,
277 error: 0xd14d41,
278 },
279 ThemeDef {
280 name: "GitHub",
281 neutral: 0x0d1117,
282 ink: 0xc9d1d9,
283 primary: 0x58a6ff,
284 accent: 0x39c5cf,
285 success: 0x3fb950,
286 error: 0xf85149,
287 },
288 ThemeDef {
289 name: "Gruvbox",
290 neutral: 0x282828,
291 ink: 0xebdbb2,
292 primary: 0x83a598,
293 accent: 0xfb4934,
294 success: 0xb8bb26,
295 error: 0xfb4934,
296 },
297 ThemeDef {
298 name: "Kanagawa",
299 neutral: 0x1f1f28,
300 ink: 0xdcd7ba,
301 primary: 0x7e9cd8,
302 accent: 0xd27e99,
303 success: 0x98bb6c,
304 error: 0xe82424,
305 },
306 ThemeDef {
307 name: "Lucent Orng",
308 neutral: 0x2a1a15,
309 ink: 0xeeeeee,
310 primary: 0xec5b2b,
311 accent: 0xfff7f1,
312 success: 0x6ba1e6,
313 error: 0xe06c75,
314 },
315 ThemeDef {
316 name: "Material",
317 neutral: 0x263238,
318 ink: 0xeeffff,
319 primary: 0x82aaff,
320 accent: 0x89ddff,
321 success: 0xc3e88d,
322 error: 0xf07178,
323 },
324 ThemeDef {
325 name: "Matrix",
326 neutral: 0x0a0e0a,
327 ink: 0x62ff94,
328 primary: 0x2eff6a,
329 accent: 0xc770ff,
330 success: 0x62ff94,
331 error: 0xff4b4b,
332 },
333 ThemeDef {
334 name: "Mercury",
335 neutral: 0x171721,
336 ink: 0xdddde5,
337 primary: 0x8da4f5,
338 accent: 0x8da4f5,
339 success: 0x77c599,
340 error: 0xfc92b4,
341 },
342 ThemeDef {
343 name: "Monokai",
344 neutral: 0x272822,
345 ink: 0xf8f8f2,
346 primary: 0xae81ff,
347 accent: 0xf92672,
348 success: 0xa6e22e,
349 error: 0xf92672,
350 },
351 ThemeDef {
352 name: "Night Owl",
353 neutral: 0x011627,
354 ink: 0xd6deeb,
355 primary: 0x82aaff,
356 accent: 0xf78c6c,
357 success: 0xc5e478,
358 error: 0xef5350,
359 },
360 ThemeDef {
361 name: "Nord",
362 neutral: 0x2e3440,
363 ink: 0xe5e9f0,
364 primary: 0x88c0d0,
365 accent: 0xd57780,
366 success: 0xa3be8c,
367 error: 0xbf616a,
368 },
369 ThemeDef {
370 name: "OC-2",
371 neutral: 0x1f1f1f,
372 ink: 0xf1ece8,
373 primary: 0xfab283,
374 accent: 0xfab283,
375 success: 0x12c905,
376 error: 0xfc533a,
377 },
378 ThemeDef {
379 name: "One Dark",
380 neutral: 0x282c34,
381 ink: 0xabb2bf,
382 primary: 0x61afef,
383 accent: 0x56b6c2,
384 success: 0x98c379,
385 error: 0xe06c75,
386 },
387 ThemeDef {
388 name: "One Dark Pro",
389 neutral: 0x1e222a,
390 ink: 0xabb2bf,
391 primary: 0x61afef,
392 accent: 0xe06c75,
393 success: 0x98c379,
394 error: 0xe06c75,
395 },
396 ThemeDef {
397 name: "Orng",
398 neutral: 0x0a0a0a,
399 ink: 0xeeeeee,
400 primary: 0xec5b2b,
401 accent: 0xfff7f1,
402 success: 0x6ba1e6,
403 error: 0xe06c75,
404 },
405 ThemeDef {
406 name: "Osaka Jade",
407 neutral: 0x111c18,
408 ink: 0xc1c497,
409 primary: 0x2dd5b7,
410 accent: 0x549e6a,
411 success: 0x549e6a,
412 error: 0xff5345,
413 },
414 ThemeDef {
415 name: "Palenight",
416 neutral: 0x292d3e,
417 ink: 0xa6accd,
418 primary: 0x82aaff,
419 accent: 0x89ddff,
420 success: 0xc3e88d,
421 error: 0xf07178,
422 },
423 ThemeDef {
424 name: "Rose Pine",
425 neutral: 0x191724,
426 ink: 0xe0def4,
427 primary: 0x9ccfd8,
428 accent: 0xebbcba,
429 success: 0x31748f,
430 error: 0xeb6f92,
431 },
432 ThemeDef {
433 name: "Shades of Purple",
434 neutral: 0x1a102b,
435 ink: 0xf5f0ff,
436 primary: 0xc792ff,
437 accent: 0xff7ac6,
438 success: 0x7be0b0,
439 error: 0xff7ac6,
440 },
441 ThemeDef {
442 name: "Solarized",
443 neutral: 0x002b36,
444 ink: 0x93a1a1,
445 primary: 0x6c71c4,
446 accent: 0xd33682,
447 success: 0x859900,
448 error: 0xdc322f,
449 },
450 ThemeDef {
451 name: "Synthwave '84",
452 neutral: 0x262335,
453 ink: 0xffffff,
454 primary: 0x36f9f6,
455 accent: 0xb084eb,
456 success: 0x72f1b8,
457 error: 0xfe4450,
458 },
459 ThemeDef {
460 name: "Tokyonight",
461 neutral: 0x1a1b26,
462 ink: 0xc0caf5,
463 primary: 0x7aa2f7,
464 accent: 0xff9e64,
465 success: 0x9ece6a,
466 error: 0xf7768e,
467 },
468 ThemeDef {
469 name: "Vercel",
470 neutral: 0x000000,
471 ink: 0xededed,
472 primary: 0x0070f3,
473 accent: 0x8e4ec6,
474 success: 0x46a758,
475 error: 0xe5484d,
476 },
477 ThemeDef {
478 name: "Vesper",
479 neutral: 0x101010,
480 ink: 0xffffff,
481 primary: 0xffc799,
482 accent: 0xff8080,
483 success: 0x99ffe4,
484 error: 0xff8080,
485 },
486 ThemeDef {
487 name: "Zenburn",
488 neutral: 0x3f3f3f,
489 ink: 0xdcdccc,
490 primary: 0x8cd0d3,
491 accent: 0x93e0e3,
492 success: 0x7f9f7f,
493 error: 0xcc9393,
494 },
495];
496
497impl Theme {
498 pub fn all() -> Vec<(&'static str, Self)> {
499 THEMES
500 .iter()
501 .map(|d| {
502 (
503 d.name,
504 Self {
505 accent: rgb(d.accent),
506 highlight: rgb(d.primary),
507 logo: rgb(d.primary),
508 text: rgb(d.ink),
509 text_muted: muted(d.neutral, d.ink),
510 background: Color::Reset,
511 background_panel: rgb(d.neutral),
512 background_overlay: darken(d.neutral, 40),
513 border: rgb(d.primary),
514 success: rgb(d.success),
515 error: rgb(d.error),
516 inverted_text: rgb(d.neutral),
517 },
518 )
519 })
520 .collect()
521 }
522}
523
524impl Default for Theme {
525 fn default() -> Self {
526 let d = &THEMES[1];
527 Self {
528 accent: rgb(d.accent),
529 highlight: rgb(d.primary),
530 logo: rgb(d.primary),
531 text: rgb(d.ink),
532 text_muted: muted(d.neutral, d.ink),
533 background: Color::Reset,
534 background_panel: rgb(d.neutral),
535 background_overlay: darken(d.neutral, 40),
536 border: rgb(d.primary),
537 success: rgb(d.success),
538 error: rgb(d.error),
539 inverted_text: rgb(d.neutral),
540 }
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn rgb_creates_correct_color() {
550 let c = rgb(0xff8800);
551 assert_eq!(c, Color::Rgb(255, 136, 0));
552 }
553
554 #[test]
555 fn rgb_black() {
556 let c = rgb(0x000000);
557 assert_eq!(c, Color::Rgb(0, 0, 0));
558 }
559
560 #[test]
561 fn rgb_white() {
562 let c = rgb(0xffffff);
563 assert_eq!(c, Color::Rgb(255, 255, 255));
564 }
565
566 #[test]
567 fn darken_reduces_brightness() {
568 let c = darken(0xffffff, 50);
569 assert_eq!(c, Color::Rgb(127, 127, 127));
570 }
571
572 #[test]
573 fn darken_full_brightness() {
574 let c = darken(0xffffff, 100);
575 assert_eq!(c, Color::Rgb(255, 255, 255));
576 }
577
578 #[test]
579 fn darken_minimum() {
580 let c = darken(0xffffff, 0);
581 assert_eq!(c, Color::Rgb(0, 0, 0));
582 }
583
584 #[test]
585 fn muted_creates_mixed_color() {
586 let c = muted(0x000000, 0xffffff);
587 assert_eq!(c, Color::Rgb(102, 102, 102));
588 }
589
590 #[test]
591 fn theme_all_returns_all_themes() {
592 let themes = Theme::all();
593 assert_eq!(themes.len(), THEMES.len());
594 for (i, (name, _)) in themes.iter().enumerate() {
595 assert_eq!(*name, THEMES[i].name);
596 }
597 }
598
599 #[test]
600 fn theme_default_is_santui() {
601 let default = Theme::default();
602 let themes = Theme::all();
603 let santui = &themes[1].1;
604 assert_eq!(default.accent, santui.accent);
605 assert_eq!(default.highlight, santui.highlight);
606 assert_eq!(default.text, santui.text);
607 }
608
609 #[test]
610 fn theme_has_background_reset() {
611 let default = Theme::default();
612 assert_eq!(default.background, Color::Reset);
613 }
614}