Skip to main content

santui_core/
theme.rs

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/// Raw theme definition as stored in a user's `.toml` file.
64#[derive(Deserialize)]
65struct ThemeDefRaw {
66    neutral: String,
67    ink: String,
68    primary: String,
69    accent: String,
70    success: String,
71    error: String,
72}
73
74/// Load user-defined themes from `config_dir/themes/*.toml`.
75/// Returns `(filename_stem, Theme)` pairs.  The caller is responsible
76/// for merging these into the main theme list.
77pub 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}