Skip to main content

ratatui_interact/
theme.rs

1//! Color theme system for consistent styling across all widgets.
2//!
3//! The theme system provides a centralized [`ColorPalette`] with semantic color roles
4//! that every widget style can be derived from. Existing APIs are untouched --
5//! `*Style::default()` still works identically.
6//!
7//! # Quick Start
8//!
9//! ```rust
10//! use ratatui_interact::theme::Theme;
11//! use ratatui_interact::components::ButtonStyle;
12//!
13//! // Use the dark theme (matches existing defaults)
14//! let theme = Theme::dark();
15//! let button_style: ButtonStyle = theme.style();
16//!
17//! // Or use the light theme
18//! let light = Theme::light();
19//! let button_style: ButtonStyle = light.style();
20//! ```
21//!
22//! # Applying to Widgets
23//!
24//! Every widget with a `.style()` method also has a `.theme()` convenience method:
25//!
26//! ```rust,ignore
27//! let button = Button::new("OK", &state).theme(&theme);
28//! let input = Input::new(&input_state).theme(&theme);
29//! ```
30
31use ratatui::style::Color;
32
33/// A semantic color palette with ~30 color roles.
34///
35/// Each color has a specific semantic purpose (e.g., `primary` for focus/selection,
36/// `error` for error states) rather than a visual description.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[cfg_attr(feature = "theme-serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct ColorPalette {
40    // Primary interaction
41    /// Focus/selection accent color.
42    pub primary: Color,
43    /// Secondary accent color.
44    pub secondary: Color,
45
46    // Text
47    /// Main text color.
48    pub text: Color,
49    /// Secondary text, line numbers.
50    pub text_dim: Color,
51    /// Disabled/inactive text.
52    pub text_disabled: Color,
53    /// Placeholder text.
54    pub text_placeholder: Color,
55    /// Shortcuts/hints.
56    pub text_muted: Color,
57
58    // Backgrounds
59    /// Main background.
60    pub bg: Color,
61    /// Elevated surface - menus/popups.
62    pub surface: Color,
63    /// Bar/header background.
64    pub surface_raised: Color,
65
66    // Borders
67    /// Focused border.
68    pub border_focused: Color,
69    /// Default border.
70    pub border: Color,
71    /// Disabled border.
72    pub border_disabled: Color,
73    /// Accent border - panels.
74    pub border_accent: Color,
75    /// Dividers/separators.
76    pub separator: Color,
77
78    // Selection/highlight
79    /// Highlighted foreground.
80    pub highlight_fg: Color,
81    /// Highlighted background.
82    pub highlight_bg: Color,
83    /// Menu highlight foreground.
84    pub menu_highlight_fg: Color,
85    /// Menu highlight background.
86    pub menu_highlight_bg: Color,
87    /// Pressed foreground.
88    pub pressed_fg: Color,
89    /// Pressed background.
90    pub pressed_bg: Color,
91
92    // Semantic status
93    /// Success color.
94    pub success: Color,
95    /// Warning color.
96    pub warning: Color,
97    /// Error: Color,
98    pub error: Color,
99    /// Info color.
100    pub info: Color,
101
102    // Diff
103    /// Diff addition foreground.
104    pub diff_add_fg: Color,
105    /// Diff addition background.
106    pub diff_add_bg: Color,
107    /// Diff deletion foreground.
108    pub diff_del_fg: Color,
109    /// Diff deletion background.
110    pub diff_del_bg: Color,
111}
112
113/// A named theme with a [`ColorPalette`].
114#[derive(Debug, Clone, PartialEq, Eq)]
115#[cfg_attr(feature = "theme-serde", derive(serde::Serialize, serde::Deserialize))]
116pub struct Theme {
117    /// Display name for this theme.
118    pub name: String,
119    /// The color palette.
120    pub palette: ColorPalette,
121}
122
123impl Default for Theme {
124    fn default() -> Self {
125        Self::dark()
126    }
127}
128
129impl Theme {
130    /// Create the dark theme, matching the existing hardcoded defaults.
131    pub fn dark() -> Self {
132        Self {
133            name: "Dark".to_string(),
134            palette: ColorPalette {
135                primary: Color::Yellow,
136                secondary: Color::Cyan,
137
138                text: Color::White,
139                text_dim: Color::Gray,
140                text_disabled: Color::DarkGray,
141                text_placeholder: Color::DarkGray,
142                text_muted: Color::Rgb(140, 140, 140),
143
144                bg: Color::Reset,
145                surface: Color::Rgb(40, 40, 40),
146                surface_raised: Color::Rgb(50, 50, 50),
147
148                border_focused: Color::Yellow,
149                border: Color::Gray,
150                border_disabled: Color::DarkGray,
151                border_accent: Color::Cyan,
152                separator: Color::Rgb(80, 80, 80),
153
154                highlight_fg: Color::Black,
155                highlight_bg: Color::Yellow,
156                menu_highlight_fg: Color::White,
157                menu_highlight_bg: Color::Rgb(60, 100, 180),
158                pressed_fg: Color::Black,
159                pressed_bg: Color::White,
160
161                success: Color::Green,
162                warning: Color::Yellow,
163                error: Color::Red,
164                info: Color::Cyan,
165
166                diff_add_fg: Color::Green,
167                diff_add_bg: Color::Rgb(0, 40, 0),
168                diff_del_fg: Color::Red,
169                diff_del_bg: Color::Rgb(40, 0, 0),
170            },
171        }
172    }
173
174    /// Create a light theme suitable for light terminal backgrounds.
175    pub fn light() -> Self {
176        Self {
177            name: "Light".to_string(),
178            palette: ColorPalette {
179                primary: Color::Blue,
180                secondary: Color::Rgb(0, 128, 128),
181
182                text: Color::Rgb(30, 30, 30),
183                text_dim: Color::Rgb(100, 100, 100),
184                text_disabled: Color::Rgb(160, 160, 160),
185                text_placeholder: Color::Rgb(160, 160, 160),
186                text_muted: Color::Rgb(100, 100, 100),
187
188                bg: Color::Reset,
189                surface: Color::Rgb(250, 250, 250),
190                surface_raised: Color::Rgb(240, 240, 240),
191
192                border_focused: Color::Blue,
193                border: Color::Rgb(180, 180, 180),
194                border_disabled: Color::Rgb(200, 200, 200),
195                border_accent: Color::Rgb(0, 128, 128),
196                separator: Color::Rgb(200, 200, 200),
197
198                highlight_fg: Color::White,
199                highlight_bg: Color::Blue,
200                menu_highlight_fg: Color::White,
201                menu_highlight_bg: Color::Rgb(0, 120, 215),
202                pressed_fg: Color::White,
203                pressed_bg: Color::Rgb(30, 30, 30),
204
205                success: Color::Rgb(0, 128, 0),
206                warning: Color::Rgb(200, 150, 0),
207                error: Color::Rgb(200, 0, 0),
208                info: Color::Rgb(0, 128, 128),
209
210                diff_add_fg: Color::Rgb(0, 128, 0),
211                diff_add_bg: Color::Rgb(220, 255, 220),
212                diff_del_fg: Color::Rgb(200, 0, 0),
213                diff_del_bg: Color::Rgb(255, 220, 220),
214            },
215        }
216    }
217
218    /// Convenience method to derive any widget style from this theme.
219    ///
220    /// # Example
221    ///
222    /// ```rust
223    /// use ratatui_interact::theme::Theme;
224    /// use ratatui_interact::components::ButtonStyle;
225    ///
226    /// let theme = Theme::dark();
227    /// let style: ButtonStyle = theme.style();
228    /// ```
229    pub fn style<S: for<'a> From<&'a Theme>>(&self) -> S {
230        S::from(self)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::components::{ButtonStyle, CheckBoxStyle, InputStyle};
238
239    #[test]
240    fn test_dark_theme_matches_button_default() {
241        let theme = Theme::dark();
242        let themed: ButtonStyle = theme.style();
243        let default = ButtonStyle::default();
244
245        assert_eq!(themed.focused_fg, default.focused_fg);
246        assert_eq!(themed.focused_bg, default.focused_bg);
247        assert_eq!(themed.unfocused_fg, default.unfocused_fg);
248        assert_eq!(themed.unfocused_bg, default.unfocused_bg);
249        assert_eq!(themed.disabled_fg, default.disabled_fg);
250        assert_eq!(themed.pressed_fg, default.pressed_fg);
251        assert_eq!(themed.pressed_bg, default.pressed_bg);
252        assert_eq!(themed.toggled_fg, default.toggled_fg);
253        assert_eq!(themed.toggled_bg, default.toggled_bg);
254    }
255
256    #[test]
257    fn test_dark_theme_matches_input_default() {
258        let theme = Theme::dark();
259        let themed: InputStyle = theme.style();
260        let default = InputStyle::default();
261
262        assert_eq!(themed.focused_border, default.focused_border);
263        assert_eq!(themed.unfocused_border, default.unfocused_border);
264        assert_eq!(themed.disabled_border, default.disabled_border);
265        assert_eq!(themed.text_fg, default.text_fg);
266        assert_eq!(themed.cursor_fg, default.cursor_fg);
267        assert_eq!(themed.placeholder_fg, default.placeholder_fg);
268    }
269
270    #[test]
271    fn test_dark_theme_matches_checkbox_default() {
272        let theme = Theme::dark();
273        let themed: CheckBoxStyle = theme.style();
274        let default = CheckBoxStyle::default();
275
276        assert_eq!(themed.focused_fg, default.focused_fg);
277        assert_eq!(themed.unfocused_fg, default.unfocused_fg);
278        assert_eq!(themed.disabled_fg, default.disabled_fg);
279        assert_eq!(themed.checked_fg, default.checked_fg);
280    }
281
282    #[test]
283    fn test_light_theme_differs_from_dark() {
284        let dark = Theme::dark();
285        let light = Theme::light();
286
287        assert_ne!(dark.palette.text, light.palette.text);
288        assert_ne!(dark.palette.primary, light.palette.primary);
289        assert_ne!(dark.palette.surface, light.palette.surface);
290    }
291
292    #[test]
293    fn test_theme_default_is_dark() {
294        let default = Theme::default();
295        let dark = Theme::dark();
296        assert_eq!(default.palette, dark.palette);
297    }
298
299    #[test]
300    fn test_theme_clone_and_eq() {
301        let theme = Theme::dark();
302        let cloned = theme.clone();
303        assert_eq!(theme, cloned);
304    }
305
306    #[test]
307    fn test_color_palette_clone_and_eq() {
308        let palette = Theme::dark().palette;
309        let cloned = palette.clone();
310        assert_eq!(palette, cloned);
311    }
312
313    #[test]
314    fn test_style_generic_method() {
315        let theme = Theme::dark();
316        let _: ButtonStyle = theme.style();
317        let _: InputStyle = theme.style();
318        let _: CheckBoxStyle = theme.style();
319    }
320
321    #[test]
322    fn test_light_theme_produces_valid_styles() {
323        let theme = Theme::light();
324        let btn: ButtonStyle = theme.style();
325        let input: InputStyle = theme.style();
326        let cb: CheckBoxStyle = theme.style();
327
328        // Light theme should produce different colors than defaults
329        let default_btn = ButtonStyle::default();
330        assert_ne!(btn.focused_bg, default_btn.focused_bg);
331
332        // But should still have sensible values (not Reset for fg colors)
333        assert_ne!(input.text_fg, Color::Reset);
334        assert_ne!(cb.focused_fg, Color::Reset);
335    }
336}