Skip to main content

slt/style/
theme.rs

1use super::*;
2
3/// A color theme that flows through all widgets automatically.
4///
5/// Construct with [`Theme::dark()`] or [`Theme::light()`], or build a custom
6/// theme by filling in the fields directly. Pass the theme via [`crate::RunConfig`]
7/// and every widget will pick up the colors without any extra wiring.
8#[derive(Debug, Clone, Copy)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub struct Theme {
11    /// Primary accent color, used for focused borders and highlights.
12    pub primary: Color,
13    /// Secondary accent color, used for less prominent highlights.
14    pub secondary: Color,
15    /// Accent color for decorative elements.
16    pub accent: Color,
17    /// Default foreground text color.
18    pub text: Color,
19    /// Dimmed text color for secondary labels and hints.
20    pub text_dim: Color,
21    /// Border color for unfocused containers.
22    pub border: Color,
23    /// Background color. Typically [`Color::Reset`] to inherit the terminal background.
24    pub bg: Color,
25    /// Color for success states (e.g., toast notifications).
26    pub success: Color,
27    /// Color for warning states.
28    pub warning: Color,
29    /// Color for error states.
30    pub error: Color,
31    /// Background color for selected list/table rows.
32    pub selected_bg: Color,
33    /// Foreground color for selected list/table rows.
34    pub selected_fg: Color,
35    /// Subtle surface color for card backgrounds and elevated containers.
36    pub surface: Color,
37    /// Hover/active surface color, one step brighter than `surface`.
38    ///
39    /// Used for interactive element hover states. Should be visually
40    /// distinguishable from both `surface` and `border`.
41    pub surface_hover: Color,
42    /// Secondary text color guaranteed readable on `surface` backgrounds.
43    ///
44    /// Use this instead of `text_dim` when rendering on `surface`-colored
45    /// containers. `text_dim` is tuned for the main `bg`; on `surface` it
46    /// may lack contrast.
47    pub surface_text: Color,
48    /// Whether this theme is a dark theme. Used to initialize dark mode in Context.
49    pub is_dark: bool,
50}
51
52impl Theme {
53    /// Create a dark theme with cyan primary and white text.
54    pub fn dark() -> Self {
55        Self {
56            primary: Color::Cyan,
57            secondary: Color::Blue,
58            accent: Color::Magenta,
59            text: Color::White,
60            text_dim: Color::Indexed(245),
61            border: Color::Indexed(240),
62            bg: Color::Reset,
63            success: Color::Green,
64            warning: Color::Yellow,
65            error: Color::Red,
66            selected_bg: Color::Cyan,
67            selected_fg: Color::Black,
68            surface: Color::Indexed(236),
69            surface_hover: Color::Indexed(238),
70            surface_text: Color::Indexed(250),
71            is_dark: true,
72        }
73    }
74
75    /// Create a light theme with high-contrast dark text on light backgrounds.
76    pub fn light() -> Self {
77        Self {
78            primary: Color::Rgb(37, 99, 235),
79            secondary: Color::Rgb(14, 116, 144),
80            accent: Color::Rgb(147, 51, 234),
81            text: Color::Rgb(15, 23, 42),
82            text_dim: Color::Rgb(100, 116, 139),
83            border: Color::Rgb(203, 213, 225),
84            bg: Color::Rgb(248, 250, 252),
85            success: Color::Rgb(22, 163, 74),
86            warning: Color::Rgb(202, 138, 4),
87            error: Color::Rgb(220, 38, 38),
88            selected_bg: Color::Rgb(37, 99, 235),
89            selected_fg: Color::White,
90            surface: Color::Rgb(241, 245, 249),
91            surface_hover: Color::Rgb(226, 232, 240),
92            surface_text: Color::Rgb(51, 65, 85),
93            is_dark: false,
94        }
95    }
96
97    /// Create a [`ThemeBuilder`] for configuring a custom theme.
98    ///
99    /// # Example
100    ///
101    /// ```
102    /// use slt::{Color, Theme};
103    ///
104    /// let theme = Theme::builder()
105    ///     .primary(Color::Rgb(255, 107, 107))
106    ///     .accent(Color::Cyan)
107    ///     .build();
108    /// ```
109    pub fn builder() -> ThemeBuilder {
110        ThemeBuilder {
111            primary: None,
112            secondary: None,
113            accent: None,
114            text: None,
115            text_dim: None,
116            border: None,
117            bg: None,
118            success: None,
119            warning: None,
120            error: None,
121            selected_bg: None,
122            selected_fg: None,
123            surface: None,
124            surface_hover: None,
125            surface_text: None,
126            is_dark: None,
127        }
128    }
129
130    /// Dracula theme — purple primary on dark gray.
131    pub fn dracula() -> Self {
132        Self {
133            primary: Color::Rgb(189, 147, 249),
134            secondary: Color::Rgb(139, 233, 253),
135            accent: Color::Rgb(255, 121, 198),
136            text: Color::Rgb(248, 248, 242),
137            text_dim: Color::Rgb(98, 114, 164),
138            border: Color::Rgb(68, 71, 90),
139            bg: Color::Rgb(40, 42, 54),
140            success: Color::Rgb(80, 250, 123),
141            warning: Color::Rgb(241, 250, 140),
142            error: Color::Rgb(255, 85, 85),
143            selected_bg: Color::Rgb(189, 147, 249),
144            selected_fg: Color::Rgb(40, 42, 54),
145            surface: Color::Rgb(68, 71, 90),
146            surface_hover: Color::Rgb(98, 100, 120),
147            surface_text: Color::Rgb(191, 194, 210),
148            is_dark: true,
149        }
150    }
151
152    /// Catppuccin Mocha theme — lavender primary on dark base.
153    pub fn catppuccin() -> Self {
154        Self {
155            primary: Color::Rgb(180, 190, 254),
156            secondary: Color::Rgb(137, 180, 250),
157            accent: Color::Rgb(245, 194, 231),
158            text: Color::Rgb(205, 214, 244),
159            text_dim: Color::Rgb(127, 132, 156),
160            border: Color::Rgb(88, 91, 112),
161            bg: Color::Rgb(30, 30, 46),
162            success: Color::Rgb(166, 227, 161),
163            warning: Color::Rgb(249, 226, 175),
164            error: Color::Rgb(243, 139, 168),
165            selected_bg: Color::Rgb(180, 190, 254),
166            selected_fg: Color::Rgb(30, 30, 46),
167            surface: Color::Rgb(49, 50, 68),
168            surface_hover: Color::Rgb(69, 71, 90),
169            surface_text: Color::Rgb(166, 173, 200),
170            is_dark: true,
171        }
172    }
173
174    /// Nord theme — frost blue primary on polar night.
175    pub fn nord() -> Self {
176        Self {
177            primary: Color::Rgb(136, 192, 208),
178            secondary: Color::Rgb(129, 161, 193),
179            accent: Color::Rgb(180, 142, 173),
180            text: Color::Rgb(236, 239, 244),
181            text_dim: Color::Rgb(76, 86, 106),
182            border: Color::Rgb(76, 86, 106),
183            bg: Color::Rgb(46, 52, 64),
184            success: Color::Rgb(163, 190, 140),
185            warning: Color::Rgb(235, 203, 139),
186            error: Color::Rgb(191, 97, 106),
187            selected_bg: Color::Rgb(136, 192, 208),
188            selected_fg: Color::Rgb(46, 52, 64),
189            surface: Color::Rgb(59, 66, 82),
190            surface_hover: Color::Rgb(67, 76, 94),
191            surface_text: Color::Rgb(216, 222, 233),
192            is_dark: true,
193        }
194    }
195
196    /// Solarized Dark theme — blue primary on dark base.
197    pub fn solarized_dark() -> Self {
198        Self {
199            primary: Color::Rgb(38, 139, 210),
200            secondary: Color::Rgb(42, 161, 152),
201            accent: Color::Rgb(211, 54, 130),
202            text: Color::Rgb(131, 148, 150),
203            text_dim: Color::Rgb(88, 110, 117),
204            border: Color::Rgb(88, 110, 117),
205            bg: Color::Rgb(0, 43, 54),
206            success: Color::Rgb(133, 153, 0),
207            warning: Color::Rgb(181, 137, 0),
208            error: Color::Rgb(220, 50, 47),
209            selected_bg: Color::Rgb(38, 139, 210),
210            selected_fg: Color::Rgb(253, 246, 227),
211            surface: Color::Rgb(7, 54, 66),
212            surface_hover: Color::Rgb(23, 72, 85),
213            surface_text: Color::Rgb(147, 161, 161),
214            is_dark: true,
215        }
216    }
217
218    /// Tokyo Night theme — blue primary on dark storm base.
219    pub fn tokyo_night() -> Self {
220        Self {
221            primary: Color::Rgb(122, 162, 247),
222            secondary: Color::Rgb(125, 207, 255),
223            accent: Color::Rgb(187, 154, 247),
224            text: Color::Rgb(169, 177, 214),
225            text_dim: Color::Rgb(86, 95, 137),
226            border: Color::Rgb(54, 58, 79),
227            bg: Color::Rgb(26, 27, 38),
228            success: Color::Rgb(158, 206, 106),
229            warning: Color::Rgb(224, 175, 104),
230            error: Color::Rgb(247, 118, 142),
231            selected_bg: Color::Rgb(122, 162, 247),
232            selected_fg: Color::Rgb(26, 27, 38),
233            surface: Color::Rgb(36, 40, 59),
234            surface_hover: Color::Rgb(41, 46, 66),
235            surface_text: Color::Rgb(192, 202, 245),
236            is_dark: true,
237        }
238    }
239}
240
241/// Builder for creating custom themes with defaults from `Theme::dark()`.
242pub struct ThemeBuilder {
243    primary: Option<Color>,
244    secondary: Option<Color>,
245    accent: Option<Color>,
246    text: Option<Color>,
247    text_dim: Option<Color>,
248    border: Option<Color>,
249    bg: Option<Color>,
250    success: Option<Color>,
251    warning: Option<Color>,
252    error: Option<Color>,
253    selected_bg: Option<Color>,
254    selected_fg: Option<Color>,
255    surface: Option<Color>,
256    surface_hover: Option<Color>,
257    surface_text: Option<Color>,
258    is_dark: Option<bool>,
259}
260
261impl ThemeBuilder {
262    /// Set the primary color.
263    pub fn primary(mut self, color: Color) -> Self {
264        self.primary = Some(color);
265        self
266    }
267
268    /// Set the secondary color.
269    pub fn secondary(mut self, color: Color) -> Self {
270        self.secondary = Some(color);
271        self
272    }
273
274    /// Set the accent color.
275    pub fn accent(mut self, color: Color) -> Self {
276        self.accent = Some(color);
277        self
278    }
279
280    /// Set the main text color.
281    pub fn text(mut self, color: Color) -> Self {
282        self.text = Some(color);
283        self
284    }
285
286    /// Set the dimmed text color.
287    pub fn text_dim(mut self, color: Color) -> Self {
288        self.text_dim = Some(color);
289        self
290    }
291
292    /// Set the border color.
293    pub fn border(mut self, color: Color) -> Self {
294        self.border = Some(color);
295        self
296    }
297
298    /// Set the background color.
299    pub fn bg(mut self, color: Color) -> Self {
300        self.bg = Some(color);
301        self
302    }
303
304    /// Set the success indicator color.
305    pub fn success(mut self, color: Color) -> Self {
306        self.success = Some(color);
307        self
308    }
309
310    /// Set the warning indicator color.
311    pub fn warning(mut self, color: Color) -> Self {
312        self.warning = Some(color);
313        self
314    }
315
316    /// Set the error indicator color.
317    pub fn error(mut self, color: Color) -> Self {
318        self.error = Some(color);
319        self
320    }
321
322    /// Set the selected item background color.
323    pub fn selected_bg(mut self, color: Color) -> Self {
324        self.selected_bg = Some(color);
325        self
326    }
327
328    /// Set the selected item foreground color.
329    pub fn selected_fg(mut self, color: Color) -> Self {
330        self.selected_fg = Some(color);
331        self
332    }
333
334    /// Set the surface background color.
335    pub fn surface(mut self, color: Color) -> Self {
336        self.surface = Some(color);
337        self
338    }
339
340    /// Set the surface hover color.
341    pub fn surface_hover(mut self, color: Color) -> Self {
342        self.surface_hover = Some(color);
343        self
344    }
345
346    /// Set the surface text color.
347    pub fn surface_text(mut self, color: Color) -> Self {
348        self.surface_text = Some(color);
349        self
350    }
351
352    /// Set the dark mode flag.
353    pub fn is_dark(mut self, is_dark: bool) -> Self {
354        self.is_dark = Some(is_dark);
355        self
356    }
357
358    /// Build the theme. Unfilled fields use [`Theme::dark()`] defaults.
359    pub fn build(self) -> Theme {
360        let defaults = Theme::dark();
361        Theme {
362            primary: self.primary.unwrap_or(defaults.primary),
363            secondary: self.secondary.unwrap_or(defaults.secondary),
364            accent: self.accent.unwrap_or(defaults.accent),
365            text: self.text.unwrap_or(defaults.text),
366            text_dim: self.text_dim.unwrap_or(defaults.text_dim),
367            border: self.border.unwrap_or(defaults.border),
368            bg: self.bg.unwrap_or(defaults.bg),
369            success: self.success.unwrap_or(defaults.success),
370            warning: self.warning.unwrap_or(defaults.warning),
371            error: self.error.unwrap_or(defaults.error),
372            selected_bg: self.selected_bg.unwrap_or(defaults.selected_bg),
373            selected_fg: self.selected_fg.unwrap_or(defaults.selected_fg),
374            surface: self.surface.unwrap_or(defaults.surface),
375            surface_hover: self.surface_hover.unwrap_or(defaults.surface_hover),
376            surface_text: self.surface_text.unwrap_or(defaults.surface_text),
377            is_dark: self.is_dark.unwrap_or(defaults.is_dark),
378        }
379    }
380}
381
382impl Default for Theme {
383    fn default() -> Self {
384        Self::dark()
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn theme_dark_preset_builds() {
394        let t = Theme::dark();
395        assert_eq!(t.primary, Color::Cyan);
396        assert!(t.is_dark);
397    }
398
399    #[test]
400    fn theme_light_preset_builds() {
401        let t = Theme::light();
402        assert_eq!(t.selected_fg, Color::White);
403        assert!(!t.is_dark);
404    }
405
406    #[test]
407    fn theme_dracula_preset_builds() {
408        let t = Theme::dracula();
409        assert_eq!(t.bg, Color::Rgb(40, 42, 54));
410        assert!(t.is_dark);
411    }
412
413    #[test]
414    fn theme_catppuccin_preset_builds() {
415        let t = Theme::catppuccin();
416        assert_eq!(t.bg, Color::Rgb(30, 30, 46));
417        assert!(t.is_dark);
418    }
419
420    #[test]
421    fn theme_nord_preset_builds() {
422        let t = Theme::nord();
423        assert_eq!(t.bg, Color::Rgb(46, 52, 64));
424        assert!(t.is_dark);
425    }
426
427    #[test]
428    fn theme_solarized_dark_preset_builds() {
429        let t = Theme::solarized_dark();
430        assert_eq!(t.bg, Color::Rgb(0, 43, 54));
431        assert!(t.is_dark);
432    }
433
434    #[test]
435    fn theme_tokyo_night_preset_builds() {
436        let t = Theme::tokyo_night();
437        assert_eq!(t.bg, Color::Rgb(26, 27, 38));
438        assert!(t.is_dark);
439    }
440
441    #[test]
442    fn theme_builder_sets_primary_and_accent() {
443        let theme = Theme::builder()
444            .primary(Color::Red)
445            .accent(Color::Yellow)
446            .build();
447
448        assert_eq!(theme.primary, Color::Red);
449        assert_eq!(theme.accent, Color::Yellow);
450    }
451
452    #[test]
453    fn theme_builder_defaults_to_dark_for_unset_fields() {
454        let defaults = Theme::dark();
455        let theme = Theme::builder().primary(Color::Green).build();
456
457        assert_eq!(theme.primary, Color::Green);
458        assert_eq!(theme.secondary, defaults.secondary);
459        assert_eq!(theme.text, defaults.text);
460        assert_eq!(theme.text_dim, defaults.text_dim);
461        assert_eq!(theme.border, defaults.border);
462        assert_eq!(theme.surface_hover, defaults.surface_hover);
463        assert_eq!(theme.is_dark, defaults.is_dark);
464    }
465
466    #[test]
467    fn theme_builder_can_override_is_dark() {
468        let theme = Theme::builder().is_dark(false).build();
469        assert!(!theme.is_dark);
470    }
471
472    #[test]
473    fn theme_default_matches_dark() {
474        let default_theme = Theme::default();
475        let dark = Theme::dark();
476        assert_eq!(default_theme.primary, dark.primary);
477        assert_eq!(default_theme.bg, dark.bg);
478        assert_eq!(default_theme.is_dark, dark.is_dark);
479    }
480}