Skip to main content

dotstate/
styles.rs

1//! Theme and style system for `DotState`
2//!
3//! Provides consistent styling across the application with support for
4//! light and dark themes.
5
6use ratatui::style::{Color, Modifier, Style};
7use ratatui::widgets::BorderType;
8use std::str::FromStr;
9use std::sync::RwLock;
10
11/// List selection indicator shown next to the selected item
12pub const LIST_HIGHLIGHT_SYMBOL: &str = "ยป ";
13
14/// Global theme instance (supports runtime updates)
15static THEME: RwLock<Theme> = RwLock::new(Theme {
16    theme_type: ThemeType::Dark,
17    primary: Color::Cyan,
18    secondary: Color::Magenta,
19    tertiary: Color::Blue,
20    success: Color::Green,
21    warning: Color::Yellow,
22    error: Color::Red,
23    text: Color::White,
24    text_muted: Color::DarkGray,
25    text_dimmed: Color::Cyan,
26    text_emphasis: Color::Yellow,
27    border: Color::DarkGray,
28    border_focused: Color::Cyan,
29    highlight_bg: Color::DarkGray,
30    background: Color::Reset,
31    dim_bg: Color::Black,
32    border_type: BorderType::Plain,
33    border_focused_type: BorderType::Thick,
34    dialog_border_type: BorderType::Double,
35});
36
37/// Initialize the global theme (call once at startup, or to update at runtime)
38pub fn init_theme(theme_type: ThemeType) {
39    // Recover from poison - if a thread panicked while holding the lock,
40    // we still want to update the theme rather than propagate the panic
41    let mut theme = THEME
42        .write()
43        .unwrap_or_else(std::sync::PoisonError::into_inner);
44    *theme = Theme::new(theme_type);
45}
46
47/// Get the current theme
48pub fn theme() -> Theme {
49    // Recover from poison - theme should always be accessible
50    THEME
51        .read()
52        .unwrap_or_else(std::sync::PoisonError::into_inner)
53        .clone()
54}
55
56/// Theme type selector
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum ThemeType {
59    #[default]
60    Dark,
61    Light,
62    /// Disable all UI colors (equivalent to `NO_COLOR=1` / `--no-colors`)
63    NoColor,
64    /// Midnight colors regardless of Terminal color presets, RGB values only
65    Midnight,
66    /// Solarized Dark theme
67    SolarizedDark,
68    /// Solarized Light theme
69    SolarizedLight,
70}
71
72impl ThemeType {
73    /// Get the display name of this theme
74    #[must_use]
75    pub fn name(&self) -> &'static str {
76        match self {
77            ThemeType::Dark => "Dark",
78            ThemeType::Light => "Light",
79            ThemeType::NoColor => "No Color",
80            ThemeType::Midnight => "Midnight",
81            ThemeType::SolarizedDark => "Solarized Dark",
82            ThemeType::SolarizedLight => "Solarized Light",
83        }
84    }
85
86    /// Get the config string value for this theme
87    #[must_use]
88    pub fn to_config_string(&self) -> &'static str {
89        match self {
90            ThemeType::Dark => "dark",
91            ThemeType::Light => "light",
92            ThemeType::NoColor => "nocolor",
93            ThemeType::Midnight => "midnight",
94            ThemeType::SolarizedDark => "solarized-dark",
95            ThemeType::SolarizedLight => "solarized-light",
96        }
97    }
98
99    /// Get all available themes
100    #[must_use]
101    pub fn all() -> &'static [ThemeType] {
102        &[
103            ThemeType::Dark,
104            ThemeType::Light,
105            ThemeType::Midnight,
106            ThemeType::SolarizedDark,
107            ThemeType::SolarizedLight,
108            ThemeType::NoColor,
109        ]
110    }
111}
112
113impl FromStr for ThemeType {
114    type Err = ();
115
116    fn from_str(s: &str) -> Result<Self, Self::Err> {
117        Ok(match s.to_lowercase().as_str() {
118            "light" => ThemeType::Light,
119            "midnight" => ThemeType::Midnight,
120            "solarized-dark" | "solarized_dark" | "solarized" => ThemeType::SolarizedDark,
121            "solarized-light" | "solarized_light" => ThemeType::SolarizedLight,
122            "nocolor" | "no-color" | "no_color" => ThemeType::NoColor,
123            _ => ThemeType::Dark,
124        })
125    }
126}
127
128/// Color palette for the application
129#[derive(Debug, Clone)]
130pub struct Theme {
131    /// Theme type
132    pub theme_type: ThemeType,
133
134    // === Primary Colors ===
135    /// Main accent color (borders, titles, key UI elements)
136    pub primary: Color,
137    /// Secondary accent (profiles, categories)
138    pub secondary: Color,
139    /// Tertiary accent (additional variety)
140    pub tertiary: Color,
141
142    // === Semantic Colors ===
143    /// Success states (installed, synced, active)
144    pub success: Color,
145    /// Warning states (needs attention, pending)
146    pub warning: Color,
147    /// Error states (not installed, failed)
148    pub error: Color,
149
150    // === Text Colors ===
151    /// Main text color
152    pub text: Color,
153    /// Muted/secondary text
154    pub text_muted: Color,
155    // Dimmed/less prominent text
156    pub text_dimmed: Color,
157    /// Emphasized text (commands, code, highlights)
158    pub text_emphasis: Color,
159
160    // === UI Colors ===
161    /// Default border color
162    pub border: Color,
163    /// Focused/active border color
164    pub border_focused: Color,
165    /// Selection highlight background
166    pub highlight_bg: Color,
167    /// Background color (use Reset for terminal default)
168    pub background: Color,
169    /// Dimmed background color for modals
170    pub dim_bg: Color,
171
172    // === Border Types ===
173    /// Default border type (unfocused)
174    pub border_type: BorderType,
175    /// Focused border type
176    pub border_focused_type: BorderType,
177    /// Dialog border type
178    pub dialog_border_type: BorderType,
179}
180
181impl Theme {
182    #[must_use]
183    pub fn new(theme_type: ThemeType) -> Self {
184        match theme_type {
185            ThemeType::Dark => Self::dark(),
186            ThemeType::Light => Self::light(),
187            ThemeType::NoColor => Self::no_color(),
188            ThemeType::Midnight => Self::midnight(),
189            ThemeType::SolarizedDark => Self::solarized_dark(),
190            ThemeType::SolarizedLight => Self::solarized_light(),
191        }
192    }
193
194    /// Midnight theme - unified color palette
195    #[must_use]
196    pub fn midnight() -> Self {
197        Self {
198            theme_type: ThemeType::Midnight,
199
200            // Primary colors - balanced mid-tones for visibility on light/dark
201            primary: Color::Rgb(0, 150, 200),    // Cerulean Blue
202            secondary: Color::Rgb(170, 50, 170), // Medium Orchid
203            tertiary: Color::Rgb(60, 100, 200),  // Steel Blue
204
205            // Semantic colors
206            success: Color::Rgb(40, 160, 60), // Jungle Green
207            warning: Color::Rgb(200, 130, 0), // Dark Orange
208            error: Color::Rgb(200, 40, 40),   // Fire Brick
209
210            // Text colors
211            text: Color::Rgb(200, 200, 200),       // Light gray
212            text_muted: Color::Rgb(128, 128, 128), // Gray works on both
213            text_dimmed: Color::Rgb(100, 100, 100),
214            text_emphasis: Color::Rgb(220, 140, 0), // Match warning/orange
215
216            // UI colors
217            border: Color::Rgb(100, 100, 100),       // Dark Gray
218            border_focused: Color::Rgb(0, 150, 200), // Match primary
219            highlight_bg: Color::Rgb(60, 60, 60), // Dark gray for selection (assuming text becomes white-ish or readable)
220            background: Color::Rgb(20, 20, 20),
221            dim_bg: Color::Rgb(40, 40, 40),
222
223            border_type: BorderType::Rounded,
224            border_focused_type: BorderType::Thick,
225            dialog_border_type: BorderType::Double,
226        }
227    }
228
229    /// Solarized Dark theme
230    #[must_use]
231    pub fn solarized_dark() -> Self {
232        Self {
233            theme_type: ThemeType::SolarizedDark,
234
235            // Solarized Palette (Dark)
236            // Base03  #002b36 (Background)
237            // Base02  #073642 (Background Highlights)
238            // Base01  #586e75 (Content Emphasis)
239            // Base00  #657b83 (Content)
240            // Base0   #839496 (Content)
241            // Base1   #93a1a1 (Content Emphasis)
242            // Base2   #eee8d5 (Background Highlights - unused in dark)
243            // Base3   #fdf6e3 (Background - unused in dark)
244            // Yellow  #b58900
245            // Orange  #cb4b16
246            // Red     #dc322f
247            // Magenta #d33682
248            // Violet  #6c71c4
249            // Blue    #268bd2
250            // Cyan    #2aa198
251            // Green   #859900
252
253            // Primary colors
254            primary: Color::Rgb(38, 139, 210),    // Blue
255            secondary: Color::Rgb(108, 113, 196), // Violet
256            tertiary: Color::Rgb(42, 161, 152),   // Cyan
257
258            // Semantic colors
259            success: Color::Rgb(133, 153, 0), // Green
260            warning: Color::Rgb(181, 137, 0), // Yellow
261            error: Color::Rgb(220, 50, 47),   // Red
262
263            // Text colors
264            text: Color::Rgb(131, 148, 150),          // Base0
265            text_muted: Color::Rgb(88, 110, 117),     // Base01
266            text_dimmed: Color::Rgb(7, 54, 66),       // Base02
267            text_emphasis: Color::Rgb(147, 161, 161), // Base1
268
269            // UI colors
270            border: Color::Rgb(88, 110, 117),         // Base01
271            border_focused: Color::Rgb(38, 139, 210), // Blue
272            highlight_bg: Color::Rgb(7, 54, 66),      // Base02
273            background: Color::Rgb(0, 43, 54),        // Base03
274            dim_bg: Color::Rgb(7, 54, 66),            // Base02
275
276            border_type: BorderType::Rounded,
277            border_focused_type: BorderType::Thick,
278            dialog_border_type: BorderType::Double,
279        }
280    }
281
282    /// Solarized Light theme
283    #[must_use]
284    pub fn solarized_light() -> Self {
285        Self {
286            theme_type: ThemeType::SolarizedLight,
287
288            // Solarized Palette (Light)
289            // Base3   #fdf6e3 (Background)
290            // Base2   #eee8d5 (Background Highlights)
291            // Base1   #93a1a1 (Content Emphasis)
292            // Base0   #839496 (Content)
293            // Base00  #657b83 (Content)
294            // Base01  #586e75 (Content Emphasis)
295            // Base02  #073642 (Background Highlights - unused in light)
296            // Base03  #002b36 (Background - unused in light)
297
298            // Primary colors
299            primary: Color::Rgb(38, 139, 210),    // Blue
300            secondary: Color::Rgb(108, 113, 196), // Violet
301            tertiary: Color::Rgb(42, 161, 152),   // Cyan
302
303            // Semantic colors
304            success: Color::Rgb(133, 153, 0), // Green
305            warning: Color::Rgb(203, 75, 22), // Orange (better visibility on light)
306            error: Color::Rgb(220, 50, 47),   // Red
307
308            // Text colors
309            text: Color::Rgb(101, 123, 131),         // Base00
310            text_muted: Color::Rgb(147, 161, 161),   // Base1
311            text_dimmed: Color::Rgb(238, 232, 213),  // Base2
312            text_emphasis: Color::Rgb(88, 110, 117), // Base01
313
314            // UI colors
315            border: Color::Rgb(147, 161, 161),        // Base1
316            border_focused: Color::Rgb(38, 139, 210), // Blue
317            highlight_bg: Color::Rgb(238, 232, 213),  // Base2
318            background: Color::Rgb(253, 246, 227),    // Base3
319            dim_bg: Color::Rgb(238, 232, 213),        // Base2
320
321            border_type: BorderType::Rounded,
322            border_focused_type: BorderType::Thick,
323            dialog_border_type: BorderType::Double,
324        }
325    }
326
327    /// Dark theme - for dark terminal backgrounds
328    #[must_use]
329    pub fn dark() -> Self {
330        Self {
331            theme_type: ThemeType::Dark,
332
333            // Primary colors - cyan family for main accents
334            primary: Color::Cyan,
335            secondary: Color::Magenta,
336            tertiary: Color::Blue,
337
338            // Semantic colors
339            success: Color::Green,
340            warning: Color::Yellow,
341            error: Color::Red,
342
343            // Text colors
344            text: Color::Reset,
345            text_muted: Color::DarkGray,
346            text_dimmed: Color::Cyan,
347            text_emphasis: Color::Yellow,
348
349            // UI colors
350            border: Color::Cyan,
351            border_focused: Color::LightBlue,
352            highlight_bg: Color::DarkGray,
353            background: Color::Reset,
354            dim_bg: Color::Reset,
355
356            border_type: BorderType::Plain,
357            border_focused_type: BorderType::Thick,
358            dialog_border_type: BorderType::Double,
359        }
360    }
361
362    /// Light theme - for light terminal backgrounds
363    #[must_use]
364    pub fn light() -> Self {
365        Self {
366            theme_type: ThemeType::Light,
367
368            // Primary colors - darker variants for light backgrounds
369            primary: Color::Blue,
370            secondary: Color::Magenta,
371            tertiary: Color::Cyan,
372
373            // Semantic colors - darker/more saturated for visibility
374            success: Color::Green,
375            warning: Color::Rgb(180, 120, 0), // Darker yellow/orange
376            error: Color::Red,
377
378            // Text colors - dark text on light background
379            text: Color::Reset,
380            text_muted: Color::DarkGray,
381            text_dimmed: Color::Cyan,
382            text_emphasis: Color::Blue,
383
384            // UI colors
385            border: Color::DarkGray,
386            border_focused: Color::Blue,
387            highlight_bg: Color::Gray,
388            background: Color::Reset,
389            dim_bg: Color::Reset,
390
391            border_type: BorderType::Plain,
392            border_focused_type: BorderType::Thick,
393            dialog_border_type: BorderType::Double,
394        }
395    }
396
397    /// No-color theme - for terminals where colors should be disabled
398    ///
399    /// Note: In this mode, style helpers below intentionally avoid setting fg/bg
400    /// so the UI uses the terminal defaults without emitting color codes.
401    #[must_use]
402    pub fn no_color() -> Self {
403        Self {
404            theme_type: ThemeType::NoColor,
405
406            // These palette values are effectively unused by the style helpers in NoColor mode.
407            primary: Color::Reset,
408            secondary: Color::Reset,
409            tertiary: Color::Reset,
410
411            success: Color::Reset,
412            warning: Color::Reset,
413            error: Color::Reset,
414
415            text: Color::Reset,
416            text_muted: Color::Reset,
417            text_dimmed: Color::Reset,
418            text_emphasis: Color::Reset,
419
420            border: Color::Reset,
421            border_focused: Color::Reset,
422            highlight_bg: Color::Reset,
423            background: Color::Reset,
424            dim_bg: Color::Reset,
425
426            border_type: BorderType::Rounded,
427            border_focused_type: BorderType::Thick,
428            dialog_border_type: BorderType::Double,
429        }
430    }
431
432    // === Style Helpers ===
433
434    /// Style for primary/title text
435    #[must_use]
436    pub fn title_style(&self) -> Style {
437        if self.theme_type == ThemeType::NoColor {
438            return Style::default().add_modifier(Modifier::BOLD);
439        }
440        Style::default()
441            .fg(self.primary)
442            .add_modifier(Modifier::BOLD)
443    }
444
445    /// Style for regular text
446    #[must_use]
447    pub fn text_style(&self) -> Style {
448        if self.theme_type == ThemeType::NoColor {
449            return Style::default();
450        }
451        Style::default().fg(self.text)
452    }
453
454    /// Style for muted/secondary text
455    #[must_use]
456    pub fn muted_style(&self) -> Style {
457        if self.theme_type == ThemeType::NoColor {
458            return Style::default().add_modifier(Modifier::DIM);
459        }
460        Style::default().fg(self.text_muted)
461    }
462
463    /// Style for emphasized text (commands, code)
464    #[must_use]
465    pub fn emphasis_style(&self) -> Style {
466        if self.theme_type == ThemeType::NoColor {
467            return Style::default().add_modifier(Modifier::BOLD);
468        }
469        Style::default().fg(self.text_emphasis)
470    }
471
472    /// Style for success states
473    #[must_use]
474    pub fn success_style(&self) -> Style {
475        if self.theme_type == ThemeType::NoColor {
476            return Style::default().add_modifier(Modifier::BOLD);
477        }
478        Style::default().fg(self.success)
479    }
480
481    /// Style for warning states
482    #[allow(dead_code)]
483    #[must_use]
484    pub fn warning_style(&self) -> Style {
485        Style::default().fg(self.warning)
486    }
487
488    /// Style for error states
489    #[allow(dead_code)]
490    #[must_use]
491    pub fn error_style(&self) -> Style {
492        Style::default().fg(self.error)
493    }
494
495    /// Style for list item highlight (selected row)
496    #[must_use]
497    pub fn highlight_style(&self) -> Style {
498        if self.theme_type == ThemeType::NoColor {
499            return Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED);
500        }
501        Style::default()
502            .fg(self.text_emphasis)
503            .bg(self.highlight_bg)
504            .add_modifier(Modifier::BOLD)
505    }
506
507    /// Get the border style based on focus
508    #[must_use]
509    pub fn border_style(&self, focused: bool) -> Style {
510        if focused {
511            self.border_focused_style()
512        } else {
513            self.unfocused_border_style()
514        }
515    }
516
517    /// Get the border type based on focus
518    #[must_use]
519    pub fn border_type(&self, focused: bool) -> BorderType {
520        if focused {
521            self.border_focused_type
522        } else {
523            self.border_type
524        }
525    }
526
527    /// Style for focused borders
528    #[must_use]
529    pub fn border_focused_style(&self) -> Style {
530        if self.theme_type == ThemeType::NoColor {
531            return Style::default().add_modifier(Modifier::BOLD);
532        }
533        Style::default().fg(self.border_focused)
534    }
535
536    /// Style for unfocused borders
537    #[must_use]
538    pub fn unfocused_border_style(&self) -> Style {
539        if self.theme_type == ThemeType::NoColor {
540            return Style::default();
541        }
542        Style::default().fg(self.border)
543    }
544
545    /// Style for disabled items
546    #[must_use]
547    pub fn disabled_style(&self) -> Style {
548        if self.theme_type == ThemeType::NoColor {
549            return Style::default().add_modifier(Modifier::DIM);
550        }
551        Style::default().fg(self.text_muted)
552    }
553
554    /// Background style
555    #[must_use]
556    pub fn background_style(&self) -> Style {
557        if self.theme_type == ThemeType::NoColor {
558            return Style::default();
559        }
560        Style::default().bg(self.background)
561    }
562
563    /// Dimmed background style for modals
564    #[must_use]
565    pub fn dim_style(&self) -> Style {
566        if self.theme_type == ThemeType::NoColor {
567            return Style::default();
568        }
569        Style::default().bg(self.dim_bg).fg(self.text_muted)
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_theme_type_from_str() {
579        assert_eq!("dark".parse::<ThemeType>().unwrap(), ThemeType::Dark);
580        assert_eq!("light".parse::<ThemeType>().unwrap(), ThemeType::Light);
581        assert_eq!("nocolor".parse::<ThemeType>().unwrap(), ThemeType::NoColor);
582        assert_eq!("no-color".parse::<ThemeType>().unwrap(), ThemeType::NoColor);
583        assert_eq!("no_color".parse::<ThemeType>().unwrap(), ThemeType::NoColor);
584        assert_eq!(
585            "solarized-dark".parse::<ThemeType>().unwrap(),
586            ThemeType::SolarizedDark
587        );
588        assert_eq!(
589            "solarized_dark".parse::<ThemeType>().unwrap(),
590            ThemeType::SolarizedDark
591        );
592        assert_eq!(
593            "solarized".parse::<ThemeType>().unwrap(),
594            ThemeType::SolarizedDark
595        );
596        assert_eq!(
597            "solarized-light".parse::<ThemeType>().unwrap(),
598            ThemeType::SolarizedLight
599        );
600        assert_eq!(
601            "solarized_light".parse::<ThemeType>().unwrap(),
602            ThemeType::SolarizedLight
603        );
604    }
605
606    #[test]
607    fn test_no_color_theme_styles_do_not_set_colors() {
608        let t = Theme::new(ThemeType::NoColor);
609        let s = t.highlight_style();
610        // In no-color mode we rely on modifiers only, not fg/bg.
611        assert!(s.fg.is_none());
612        assert!(s.bg.is_none());
613    }
614}