Skip to main content

oracle_lib/ui/theme/
mod.rs

1//! Theme system for Oracle TUI
2
3use ratatui::style::{Color, Modifier, Style};
4
5/// Theme preset identifier (for config and cycling)
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ThemeKind {
8    DefaultDark,
9    Nord,
10    CatppuccinMocha,
11    Dracula,
12}
13
14impl ThemeKind {
15    pub const ALL: &'static [ThemeKind] = &[
16        ThemeKind::DefaultDark,
17        ThemeKind::Nord,
18        ThemeKind::CatppuccinMocha,
19        ThemeKind::Dracula,
20    ];
21
22    pub fn name(&self) -> &'static str {
23        match self {
24            ThemeKind::DefaultDark => "default_dark",
25            ThemeKind::Nord => "nord",
26            ThemeKind::CatppuccinMocha => "catppuccin_mocha",
27            ThemeKind::Dracula => "dracula",
28        }
29    }
30
31    pub fn display_name(&self) -> &'static str {
32        match self {
33            ThemeKind::DefaultDark => "Default Dark",
34            ThemeKind::Nord => "Nord",
35            ThemeKind::CatppuccinMocha => "Catppuccin Mocha",
36            ThemeKind::Dracula => "Dracula",
37        }
38    }
39
40    pub fn from_name(name: &str) -> Self {
41        let s = name.to_lowercase();
42        let s = s.trim();
43        match s {
44            "nord" => ThemeKind::Nord,
45            "catppuccin_mocha" | "catppuccin" | "mocha" | "catppuccin mocha" => {
46                ThemeKind::CatppuccinMocha
47            }
48            "dracula" => ThemeKind::Dracula,
49            "default_dark" | "default" | "default dark" => ThemeKind::DefaultDark,
50            _ => ThemeKind::DefaultDark,
51        }
52    }
53
54    pub fn next(self) -> Self {
55        let i = Self::ALL.iter().position(|&k| k == self).unwrap_or(0);
56        let next = (i + 1) % Self::ALL.len();
57        Self::ALL[next]
58    }
59}
60
61/// Color palette for the UI
62#[derive(Debug, Clone)]
63pub struct Theme {
64    pub name: String,
65    pub accent: Color,
66    pub accent_dim: Color,
67    pub bg: Color,
68    pub bg_highlight: Color,
69    pub bg_panel: Color,
70    pub fg: Color,
71    pub fg_dim: Color,
72    pub fg_muted: Color,
73    pub border: Color,
74    pub border_focused: Color,
75    /// Active tab background (darker tone so white text is always visible)
76    pub tab_active_bg: Color,
77    /// Active tab text (white for consistent contrast across themes)
78    pub tab_active_fg: Color,
79    pub error: Color,
80    pub warning: Color,
81    pub success: Color,
82    pub info: Color,
83    // Syntax colors
84    pub keyword: Color,
85    pub function: Color,
86    pub type_: Color,
87    pub string: Color,
88    pub number: Color,
89    pub comment: Color,
90}
91
92impl Theme {
93    pub fn default_dark() -> Self {
94        Self {
95            name: "Default Dark".into(),
96            accent: Color::Rgb(78, 191, 113), // #4EBF71 - Green
97            accent_dim: Color::Rgb(45, 110, 65),
98            bg: Color::Rgb(24, 24, 24),
99            bg_highlight: Color::Rgb(45, 45, 45),
100            bg_panel: Color::Rgb(32, 32, 32),
101            fg: Color::Rgb(230, 230, 230),
102            fg_dim: Color::Rgb(195, 195, 200),
103            fg_muted: Color::Rgb(140, 140, 145),
104            border: Color::Rgb(60, 60, 60),
105            border_focused: Color::Rgb(78, 191, 113),
106            tab_active_bg: Color::Rgb(88, 66, 134), // Darker purple: high contrast for white text
107            tab_active_fg: Color::Rgb(255, 255, 255), // White: consistent across themes
108            error: Color::Rgb(244, 67, 54),
109            warning: Color::Rgb(255, 152, 0),
110            success: Color::Rgb(76, 175, 80),
111            info: Color::Rgb(33, 150, 243),
112            keyword: Color::Rgb(198, 120, 221), // Purple
113            function: Color::Rgb(97, 175, 239), // Blue
114            type_: Color::Rgb(229, 192, 123),   // Yellow
115            string: Color::Rgb(152, 195, 121),  // Green
116            number: Color::Rgb(209, 154, 102),  // Orange
117            comment: Color::Rgb(92, 99, 112),   // Gray
118        }
119    }
120
121    pub fn nord() -> Self {
122        Self {
123            name: "Nord".into(),
124            accent: Color::Rgb(136, 192, 208),    // Nord8
125            accent_dim: Color::Rgb(94, 129, 172), // Nord10
126            bg: Color::Rgb(46, 52, 64),           // Nord0
127            bg_highlight: Color::Rgb(59, 66, 82), // Nord1
128            bg_panel: Color::Rgb(67, 76, 94),     // Nord2
129            fg: Color::Rgb(236, 239, 244),        // Nord6
130            fg_dim: Color::Rgb(229, 233, 240),    // Nord5 brighter
131            fg_muted: Color::Rgb(136, 142, 156),  // Nord3 brighter
132            border: Color::Rgb(76, 86, 106),      // Nord3
133            border_focused: Color::Rgb(136, 192, 208),
134            tab_active_bg: Color::Rgb(65, 89, 122), // Darker Nord blue: high contrast for white text
135            tab_active_fg: Color::Rgb(255, 255, 255), // White: consistent across themes
136            error: Color::Rgb(191, 97, 106),        // Nord11
137            warning: Color::Rgb(235, 203, 139),     // Nord13
138            success: Color::Rgb(163, 190, 140),     // Nord14
139            info: Color::Rgb(129, 161, 193),        // Nord9
140            keyword: Color::Rgb(180, 142, 173),     // Nord15
141            function: Color::Rgb(136, 192, 208),    // Nord8
142            type_: Color::Rgb(235, 203, 139),       // Nord13
143            string: Color::Rgb(163, 190, 140),      // Nord14
144            number: Color::Rgb(208, 135, 112),      // Nord12
145            comment: Color::Rgb(76, 86, 106),       // Nord3
146        }
147    }
148
149    /// Catppuccin Mocha theme
150    pub fn catppuccin_mocha() -> Self {
151        Self {
152            name: "Catppuccin Mocha".into(),
153            accent: Color::Rgb(166, 227, 161),     // Green
154            accent_dim: Color::Rgb(116, 199, 236), // Sapphire
155            bg: Color::Rgb(30, 30, 46),            // Base
156            bg_highlight: Color::Rgb(49, 50, 68),  // Surface0
157            bg_panel: Color::Rgb(36, 39, 58),      // Mantle
158            fg: Color::Rgb(205, 214, 244),         // Text
159            fg_dim: Color::Rgb(205, 214, 244),     // Text (readable)
160            fg_muted: Color::Rgb(147, 153, 178),   // Overlay0 brighter
161            border: Color::Rgb(69, 71, 90),        // Surface1
162            border_focused: Color::Rgb(166, 227, 161),
163            tab_active_bg: Color::Rgb(126, 87, 194), // Darker mauve: high contrast for white text
164            tab_active_fg: Color::Rgb(255, 255, 255), // White: consistent across themes
165            error: Color::Rgb(243, 139, 168),        // Red
166            warning: Color::Rgb(249, 226, 175),      // Yellow
167            success: Color::Rgb(166, 227, 161),      // Green
168            info: Color::Rgb(137, 180, 250),         // Blue
169            keyword: Color::Rgb(203, 166, 247),      // Mauve
170            function: Color::Rgb(137, 180, 250),     // Blue
171            type_: Color::Rgb(249, 226, 175),        // Yellow
172            string: Color::Rgb(166, 227, 161),       // Green
173            number: Color::Rgb(250, 179, 135),       // Peach
174            comment: Color::Rgb(108, 112, 134),      // Overlay0
175        }
176    }
177
178    /// Dracula theme
179    pub fn dracula() -> Self {
180        Self {
181            name: "Dracula".into(),
182            accent: Color::Rgb(80, 250, 123),      // Green
183            accent_dim: Color::Rgb(139, 233, 253), // Cyan
184            bg: Color::Rgb(40, 42, 54),            // Background
185            bg_highlight: Color::Rgb(68, 71, 90),  // Current Line
186            bg_panel: Color::Rgb(33, 34, 44),      // Darker bg
187            fg: Color::Rgb(248, 248, 242),         // Foreground
188            fg_dim: Color::Rgb(230, 230, 235),
189            fg_muted: Color::Rgb(139, 153, 195),
190            border: Color::Rgb(68, 71, 90), // Current Line
191            border_focused: Color::Rgb(80, 250, 123),
192            tab_active_bg: Color::Rgb(118, 92, 168), // Darker purple: high contrast for white text
193            tab_active_fg: Color::Rgb(255, 255, 255), // White: consistent across themes
194            error: Color::Rgb(255, 85, 85),          // Red
195            warning: Color::Rgb(255, 184, 108),      // Orange
196            success: Color::Rgb(80, 250, 123),       // Green
197            info: Color::Rgb(139, 233, 253),         // Cyan
198            keyword: Color::Rgb(255, 121, 198),      // Pink
199            function: Color::Rgb(80, 250, 123),      // Green
200            type_: Color::Rgb(139, 233, 253),        // Cyan
201            string: Color::Rgb(241, 250, 140),       // Yellow
202            number: Color::Rgb(189, 147, 249),       // Purple
203            comment: Color::Rgb(98, 114, 164),       // Comment
204        }
205    }
206
207    pub fn from_kind(kind: ThemeKind) -> Self {
208        match kind {
209            ThemeKind::DefaultDark => Self::default_dark(),
210            ThemeKind::Nord => Self::nord(),
211            ThemeKind::CatppuccinMocha => Self::catppuccin_mocha(),
212            ThemeKind::Dracula => Self::dracula(),
213        }
214    }
215
216    pub fn from_name(name: &str) -> Self {
217        Self::from_kind(ThemeKind::from_name(name))
218    }
219
220    pub fn kind(&self) -> ThemeKind {
221        ThemeKind::from_name(&self.name)
222    }
223
224    // Style builders
225    pub fn style_accent(&self) -> Style {
226        Style::default().fg(self.accent)
227    }
228
229    pub fn style_accent_bold(&self) -> Style {
230        Style::default()
231            .fg(self.accent)
232            .add_modifier(Modifier::BOLD)
233    }
234
235    pub fn style_normal(&self) -> Style {
236        Style::default().fg(self.fg)
237    }
238
239    pub fn style_dim(&self) -> Style {
240        Style::default().fg(self.fg_dim)
241    }
242
243    pub fn style_muted(&self) -> Style {
244        Style::default().fg(self.fg_muted)
245    }
246
247    pub fn style_highlight(&self) -> Style {
248        Style::default().bg(self.bg_highlight)
249    }
250
251    /// Style for selected list rows. Uses explicit fg so text stays readable on the highlight background.
252    pub fn style_selected(&self) -> Style {
253        Style::default()
254            .fg(self.fg)
255            .bg(self.bg_highlight)
256            .add_modifier(Modifier::BOLD)
257    }
258
259    pub fn style_border(&self) -> Style {
260        Style::default().fg(self.border)
261    }
262
263    /// Active tab: button-style highlight (e.g. lavender bg, light text).
264    pub fn style_tab_active(&self) -> Style {
265        Style::default()
266            .fg(self.tab_active_fg)
267            .bg(self.tab_active_bg)
268            .add_modifier(Modifier::BOLD)
269    }
270
271    /// Subtle accent-tinted border for the outer frame (soft glow effect).
272    pub fn style_border_glow(&self) -> Style {
273        Style::default().fg(self.accent).add_modifier(Modifier::DIM)
274    }
275
276    pub fn style_border_focused(&self) -> Style {
277        Style::default().fg(self.border_focused)
278    }
279
280    pub fn style_error(&self) -> Style {
281        Style::default().fg(self.error)
282    }
283
284    pub fn style_warning(&self) -> Style {
285        Style::default().fg(self.warning)
286    }
287
288    pub fn style_success(&self) -> Style {
289        Style::default().fg(self.success)
290    }
291
292    pub fn style_info(&self) -> Style {
293        Style::default().fg(self.info)
294    }
295
296    pub fn style_keyword(&self) -> Style {
297        Style::default().fg(self.keyword)
298    }
299
300    pub fn style_function(&self) -> Style {
301        Style::default().fg(self.function)
302    }
303
304    pub fn style_type(&self) -> Style {
305        Style::default().fg(self.type_)
306    }
307
308    pub fn style_string(&self) -> Style {
309        Style::default().fg(self.string)
310    }
311
312    pub fn style_number(&self) -> Style {
313        Style::default().fg(self.number)
314    }
315
316    pub fn style_comment(&self) -> Style {
317        Style::default().fg(self.comment)
318    }
319}
320
321impl Default for Theme {
322    fn default() -> Self {
323        Self::default_dark()
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_theme_kind_from_name() {
333        assert_eq!(ThemeKind::from_name("default"), ThemeKind::DefaultDark);
334        assert_eq!(ThemeKind::from_name("default_dark"), ThemeKind::DefaultDark);
335        assert_eq!(ThemeKind::from_name("nord"), ThemeKind::Nord);
336        assert_eq!(ThemeKind::from_name("Nord"), ThemeKind::Nord);
337        assert_eq!(
338            ThemeKind::from_name("catppuccin_mocha"),
339            ThemeKind::CatppuccinMocha
340        );
341        assert_eq!(ThemeKind::from_name("mocha"), ThemeKind::CatppuccinMocha);
342        assert_eq!(ThemeKind::from_name("dracula"), ThemeKind::Dracula);
343        assert_eq!(ThemeKind::from_name("unknown"), ThemeKind::DefaultDark);
344    }
345
346    #[test]
347    fn test_theme_kind_next_cycles() {
348        let first = ThemeKind::DefaultDark;
349        let second = first.next();
350        assert_ne!(first, second);
351        let mut k = ThemeKind::DefaultDark;
352        for _ in 0..ThemeKind::ALL.len() {
353            k = k.next();
354        }
355        assert_eq!(k, ThemeKind::DefaultDark);
356    }
357
358    #[test]
359    fn test_theme_from_kind() {
360        let t = Theme::from_kind(ThemeKind::Nord);
361        assert_eq!(t.name, "Nord");
362        let t2 = Theme::from_name("dracula");
363        assert_eq!(t2.name, "Dracula");
364    }
365}