Skip to main content

a3s_tui/
theme.rs

1//! Theme system for consistent component styling.
2//!
3//! Themes define a color palette that components use for rendering.
4//! Use built-in themes or create custom ones by implementing [`Theme`].
5
6use crate::style::{Color, Style};
7use std::{error::Error, fmt, str::FromStr};
8
9static BUILTIN_THEMES: [BuiltinTheme; 4] = BuiltinTheme::ALL;
10static THEME_ROLES: [ThemeRole; 12] = ThemeRole::ALL;
11
12/// Built-in theme presets shipped with A3S TUI.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BuiltinTheme {
15    Dark,
16    Light,
17    Catppuccin,
18    TokyoNight,
19}
20
21impl BuiltinTheme {
22    pub const ALL: [Self; 4] = [Self::Dark, Self::Light, Self::Catppuccin, Self::TokyoNight];
23
24    /// Stable machine-readable name for configuration and persistence.
25    pub fn name(self) -> &'static str {
26        match self {
27            Self::Dark => "dark",
28            Self::Light => "light",
29            Self::Catppuccin => "catppuccin",
30            Self::TokyoNight => "tokyo-night",
31        }
32    }
33
34    /// Human-readable label for pickers and help text.
35    pub fn label(self) -> &'static str {
36        match self {
37            Self::Dark => "Dark",
38            Self::Light => "Light",
39            Self::Catppuccin => "Catppuccin",
40            Self::TokyoNight => "Tokyo Night",
41        }
42    }
43
44    /// Build the concrete [`Theme`] for this preset.
45    pub fn theme(self) -> Theme {
46        match self {
47            Self::Dark => Theme::dark(),
48            Self::Light => Theme::light(),
49            Self::Catppuccin => Theme::catppuccin(),
50            Self::TokyoNight => Theme::tokyo_night(),
51        }
52    }
53}
54
55impl FromStr for BuiltinTheme {
56    type Err = ParseBuiltinThemeError;
57
58    fn from_str(value: &str) -> Result<Self, Self::Err> {
59        match normalized_token_name(value).as_str() {
60            "dark" => Ok(Self::Dark),
61            "light" => Ok(Self::Light),
62            "catppuccin" | "catppuccin-mocha" => Ok(Self::Catppuccin),
63            "tokyo-night" | "tokyonight" => Ok(Self::TokyoNight),
64            _ => Err(ParseBuiltinThemeError),
65        }
66    }
67}
68
69/// Error returned when parsing a built-in theme name fails.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ParseBuiltinThemeError;
72
73impl fmt::Display for ParseBuiltinThemeError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str("unknown built-in theme")
76    }
77}
78
79impl Error for ParseBuiltinThemeError {}
80
81/// Semantic color roles available in every [`Theme`].
82///
83/// Roles are stable tokens for application code. Prefer roles over direct field
84/// access when storing user preferences or mapping design-system names.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ThemeRole {
87    Primary,
88    Secondary,
89    Background,
90    Foreground,
91    Muted,
92    Border,
93    Success,
94    Warning,
95    Error,
96    Info,
97    Surface,
98    Highlight,
99}
100
101impl ThemeRole {
102    pub const ALL: [Self; 12] = [
103        Self::Primary,
104        Self::Secondary,
105        Self::Background,
106        Self::Foreground,
107        Self::Muted,
108        Self::Border,
109        Self::Success,
110        Self::Warning,
111        Self::Error,
112        Self::Info,
113        Self::Surface,
114        Self::Highlight,
115    ];
116
117    /// Stable machine-readable role name.
118    pub fn name(self) -> &'static str {
119        match self {
120            Self::Primary => "primary",
121            Self::Secondary => "secondary",
122            Self::Background => "background",
123            Self::Foreground => "foreground",
124            Self::Muted => "muted",
125            Self::Border => "border",
126            Self::Success => "success",
127            Self::Warning => "warning",
128            Self::Error => "error",
129            Self::Info => "info",
130            Self::Surface => "surface",
131            Self::Highlight => "highlight",
132        }
133    }
134
135    /// Human-readable role label.
136    pub fn label(self) -> &'static str {
137        match self {
138            Self::Primary => "Primary",
139            Self::Secondary => "Secondary",
140            Self::Background => "Background",
141            Self::Foreground => "Foreground",
142            Self::Muted => "Muted",
143            Self::Border => "Border",
144            Self::Success => "Success",
145            Self::Warning => "Warning",
146            Self::Error => "Error",
147            Self::Info => "Info",
148            Self::Surface => "Surface",
149            Self::Highlight => "Highlight",
150        }
151    }
152}
153
154impl FromStr for ThemeRole {
155    type Err = ParseThemeRoleError;
156
157    fn from_str(value: &str) -> Result<Self, Self::Err> {
158        match normalized_token_name(value).as_str() {
159            "primary" => Ok(Self::Primary),
160            "secondary" => Ok(Self::Secondary),
161            "background" | "bg" => Ok(Self::Background),
162            "foreground" | "fg" | "text" => Ok(Self::Foreground),
163            "muted" | "dim" => Ok(Self::Muted),
164            "border" => Ok(Self::Border),
165            "success" | "ok" => Ok(Self::Success),
166            "warning" | "warn" => Ok(Self::Warning),
167            "error" | "danger" => Ok(Self::Error),
168            "info" => Ok(Self::Info),
169            "surface" => Ok(Self::Surface),
170            "highlight" | "selection" => Ok(Self::Highlight),
171            _ => Err(ParseThemeRoleError),
172        }
173    }
174}
175
176/// Error returned when parsing a theme role fails.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct ParseThemeRoleError;
179
180impl fmt::Display for ParseThemeRoleError {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        f.write_str("unknown theme role")
183    }
184}
185
186impl Error for ParseThemeRoleError {}
187
188/// A color palette for theming UI components.
189///
190/// Components reference these semantic colors rather than hard-coding values,
191/// allowing the entire UI to be restyled by swapping themes.
192#[derive(Debug, Clone)]
193pub struct Theme {
194    /// Primary accent color (buttons, active elements).
195    pub primary: Color,
196    /// Secondary accent color (less prominent elements).
197    pub secondary: Color,
198    /// Background color for the main content area.
199    pub bg: Color,
200    /// Default foreground/text color.
201    pub fg: Color,
202    /// Muted/dimmed text color.
203    pub muted: Color,
204    /// Border color for containers.
205    pub border: Color,
206    /// Success state color.
207    pub success: Color,
208    /// Warning state color.
209    pub warning: Color,
210    /// Error/danger state color.
211    pub error: Color,
212    /// Informational state color.
213    pub info: Color,
214    /// Surface color (cards, panels, elevated elements).
215    pub surface: Color,
216    /// Highlight/selection background.
217    pub highlight: Color,
218}
219
220impl Theme {
221    /// Return the list of built-in theme presets.
222    pub fn builtins() -> &'static [BuiltinTheme] {
223        &BUILTIN_THEMES
224    }
225
226    /// Build one of the built-in theme presets.
227    pub fn builtin(theme: BuiltinTheme) -> Self {
228        theme.theme()
229    }
230
231    /// Build a built-in theme from a configuration-friendly name.
232    pub fn from_builtin_name(name: &str) -> Option<Self> {
233        name.parse::<BuiltinTheme>().ok().map(Self::builtin)
234    }
235
236    /// Return the list of stable semantic roles supported by every theme.
237    pub fn roles() -> &'static [ThemeRole] {
238        &THEME_ROLES
239    }
240
241    /// Resolve a semantic role to a concrete color.
242    pub fn color(&self, role: ThemeRole) -> Color {
243        match role {
244            ThemeRole::Primary => self.primary,
245            ThemeRole::Secondary => self.secondary,
246            ThemeRole::Background => self.bg,
247            ThemeRole::Foreground => self.fg,
248            ThemeRole::Muted => self.muted,
249            ThemeRole::Border => self.border,
250            ThemeRole::Success => self.success,
251            ThemeRole::Warning => self.warning,
252            ThemeRole::Error => self.error,
253            ThemeRole::Info => self.info,
254            ThemeRole::Surface => self.surface,
255            ThemeRole::Highlight => self.highlight,
256        }
257    }
258
259    /// Create a foreground-only style from a semantic role.
260    pub fn foreground_style(&self, role: ThemeRole) -> Style {
261        Style::new().fg(self.color(role))
262    }
263
264    /// Create a background-only style from a semantic role.
265    pub fn background_style(&self, role: ThemeRole) -> Style {
266        Style::new().bg(self.color(role))
267    }
268
269    /// Create a style from separate foreground and background roles.
270    pub fn pair_style(&self, fg: ThemeRole, bg: ThemeRole) -> Style {
271        Style::new().fg(self.color(fg)).bg(self.color(bg))
272    }
273
274    /// Standard selected-row style for theme-aware widgets.
275    pub fn selection_style(&self) -> Style {
276        self.pair_style(ThemeRole::Foreground, ThemeRole::Highlight)
277            .bold()
278    }
279
280    /// Standard elevated-surface style for panels and overlays.
281    pub fn surface_style(&self) -> Style {
282        self.pair_style(ThemeRole::Foreground, ThemeRole::Surface)
283    }
284
285    /// Dark theme — light text on dark background.
286    pub fn dark() -> Self {
287        Self {
288            primary: Color::Cyan,
289            secondary: Color::Blue,
290            bg: Color::Black,
291            fg: Color::White,
292            muted: Color::BrightBlack,
293            border: Color::BrightBlack,
294            success: Color::Green,
295            warning: Color::Yellow,
296            error: Color::Red,
297            info: Color::Blue,
298            surface: Color::BrightBlack,
299            highlight: Color::BrightBlue,
300        }
301    }
302
303    /// Light theme — dark text on light background.
304    pub fn light() -> Self {
305        Self {
306            primary: Color::Blue,
307            secondary: Color::Cyan,
308            bg: Color::White,
309            fg: Color::Black,
310            muted: Color::BrightBlack,
311            border: Color::BrightBlack,
312            success: Color::Green,
313            warning: Color::Yellow,
314            error: Color::Red,
315            info: Color::Blue,
316            surface: Color::BrightWhite,
317            highlight: Color::BrightCyan,
318        }
319    }
320
321    /// Catppuccin Mocha-inspired theme.
322    pub fn catppuccin() -> Self {
323        Self {
324            primary: Color::Rgb(137, 180, 250),   // blue
325            secondary: Color::Rgb(180, 190, 254), // lavender
326            bg: Color::Rgb(30, 30, 46),           // base
327            fg: Color::Rgb(205, 214, 244),        // text
328            muted: Color::Rgb(127, 132, 156),     // overlay1
329            border: Color::Rgb(88, 91, 112),      // surface2
330            success: Color::Rgb(166, 227, 161),   // green
331            warning: Color::Rgb(249, 226, 175),   // yellow
332            error: Color::Rgb(243, 139, 168),     // red
333            info: Color::Rgb(116, 199, 236),      // sapphire
334            surface: Color::Rgb(49, 50, 68),      // surface0
335            highlight: Color::Rgb(69, 71, 90),    // surface1
336        }
337    }
338
339    /// Tokyo Night-inspired theme.
340    pub fn tokyo_night() -> Self {
341        Self {
342            primary: Color::Rgb(122, 162, 247),   // blue
343            secondary: Color::Rgb(187, 154, 247), // purple
344            bg: Color::Rgb(26, 27, 38),           // bg
345            fg: Color::Rgb(192, 202, 245),        // fg
346            muted: Color::Rgb(86, 95, 137),       // comment
347            border: Color::Rgb(61, 89, 161),      // blue dark
348            success: Color::Rgb(158, 206, 106),   // green
349            warning: Color::Rgb(224, 175, 104),   // orange
350            error: Color::Rgb(247, 118, 142),     // red
351            info: Color::Rgb(125, 207, 255),      // cyan
352            surface: Color::Rgb(36, 40, 59),      // bg_highlight
353            highlight: Color::Rgb(41, 46, 66),    // selection
354        }
355    }
356}
357
358fn normalized_token_name(value: &str) -> String {
359    value
360        .trim()
361        .chars()
362        .map(|ch| match ch {
363            '_' | ' ' => '-',
364            ch => ch.to_ascii_lowercase(),
365        })
366        .collect()
367}
368
369impl Default for Theme {
370    fn default() -> Self {
371        Self::dark()
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn default_is_dark() {
381        let theme = Theme::default();
382        assert_eq!(theme.bg, Color::Black);
383        assert_eq!(theme.fg, Color::White);
384    }
385
386    #[test]
387    fn light_theme_has_white_bg() {
388        let theme = Theme::light();
389        assert_eq!(theme.bg, Color::White);
390        assert_eq!(theme.fg, Color::Black);
391    }
392
393    #[test]
394    fn catppuccin_uses_rgb() {
395        let theme = Theme::catppuccin();
396        assert!(matches!(theme.primary, Color::Rgb(_, _, _)));
397        assert!(matches!(theme.bg, Color::Rgb(_, _, _)));
398    }
399
400    #[test]
401    fn tokyo_night_uses_rgb() {
402        let theme = Theme::tokyo_night();
403        assert!(matches!(theme.primary, Color::Rgb(_, _, _)));
404        assert!(matches!(theme.bg, Color::Rgb(_, _, _)));
405    }
406
407    #[test]
408    fn built_in_themes_parse_friendly_names() {
409        assert_eq!("dark".parse::<BuiltinTheme>(), Ok(BuiltinTheme::Dark));
410        assert_eq!(
411            "Tokyo Night".parse::<BuiltinTheme>(),
412            Ok(BuiltinTheme::TokyoNight)
413        );
414        assert_eq!(
415            "catppuccin_mocha".parse::<BuiltinTheme>(),
416            Ok(BuiltinTheme::Catppuccin)
417        );
418        assert!("missing".parse::<BuiltinTheme>().is_err());
419        assert_eq!(
420            Theme::from_builtin_name("tokyo-night").unwrap().primary,
421            Theme::tokyo_night().primary
422        );
423    }
424
425    #[test]
426    fn theme_roles_map_to_palette_colors() {
427        let theme = Theme::dark();
428
429        assert_eq!(Theme::roles().len(), ThemeRole::ALL.len());
430        assert_eq!(theme.color(ThemeRole::Primary), theme.primary);
431        assert_eq!(theme.color(ThemeRole::Background), theme.bg);
432        assert_eq!(theme.color(ThemeRole::Highlight), theme.highlight);
433        assert_eq!("fg".parse::<ThemeRole>(), Ok(ThemeRole::Foreground));
434        assert_eq!("danger".parse::<ThemeRole>(), Ok(ThemeRole::Error));
435        assert!("unknown".parse::<ThemeRole>().is_err());
436    }
437
438    #[test]
439    fn theme_style_helpers_use_roles() {
440        let theme = Theme::tokyo_night();
441        let primary = theme.foreground_style(ThemeRole::Primary);
442        let selected = theme.selection_style();
443        let surface = theme.surface_style();
444
445        assert_eq!(primary.foreground(), Some(theme.primary));
446        assert_eq!(selected.foreground(), Some(theme.fg));
447        assert_eq!(selected.background(), Some(theme.highlight));
448        assert!(selected.is_bold());
449        assert_eq!(surface.foreground(), Some(theme.fg));
450        assert_eq!(surface.background(), Some(theme.surface));
451    }
452}