Skip to main content

recursive/tui/ui/
theme.rs

1//! Named colour palettes for the TUI.
2//!
3//! All colour constants used across the TUI rendering modules come from a
4//! [`Theme`] struct that lives in [`AppState`].  Switching themes at runtime
5//! requires only updating `app.state.theme` — no re-compile needed.
6//!
7//! Built-in themes: [`DARK`] (default), [`LIGHT`], [`SOLARIZED`].
8
9use ratatui::style::Color;
10
11// ── Theme struct ──────────────────────────────────────────────────────────
12
13/// A named colour palette for the TUI.
14#[derive(Clone, Debug, PartialEq)]
15pub struct Theme {
16    pub name: &'static str,
17    // Status bar
18    pub status_bg: Color,
19    pub status_fg: Color,
20    pub status_mode_fg: Color,
21    pub status_cost_fg: Color,
22    pub status_dim_fg: Color,
23    // Input box
24    pub input_border: Color,
25    pub input_prompt_fg: Color,
26    pub input_bash_fg: Color,
27    pub input_note_fg: Color,
28    pub input_command_fg: Color,
29    pub input_atfile_fg: Color,
30    // Transcript
31    pub user_bar: Color,
32    pub assistant_bar: Color,
33    pub system_bar: Color,
34    pub tool_call_icon: Color,
35    pub tool_ok_fg: Color,
36    pub tool_err_fg: Color,
37    pub code_fg: Color,
38    pub diff_add: Color,
39    pub diff_del: Color,
40}
41
42// ── Built-in palettes ─────────────────────────────────────────────────────
43
44pub const DARK: Theme = Theme {
45    name: "dark",
46    status_bg: Color::DarkGray,
47    status_fg: Color::White,
48    status_mode_fg: Color::Green,
49    status_cost_fg: Color::Cyan,
50    status_dim_fg: Color::DarkGray,
51    input_border: Color::DarkGray,
52    input_prompt_fg: Color::Cyan,
53    input_bash_fg: Color::Yellow,
54    input_note_fg: Color::Green,
55    input_command_fg: Color::Magenta,
56    input_atfile_fg: Color::Cyan,
57    user_bar: Color::Blue,
58    assistant_bar: Color::Green,
59    system_bar: Color::DarkGray,
60    tool_call_icon: Color::Yellow,
61    tool_ok_fg: Color::Green,
62    tool_err_fg: Color::Red,
63    code_fg: Color::Cyan,
64    diff_add: Color::Green,
65    diff_del: Color::Red,
66};
67
68pub const LIGHT: Theme = Theme {
69    name: "light",
70    status_bg: Color::Gray,
71    status_fg: Color::Black,
72    status_mode_fg: Color::Blue,
73    status_cost_fg: Color::DarkGray,
74    status_dim_fg: Color::Gray,
75    input_border: Color::Gray,
76    input_prompt_fg: Color::Blue,
77    input_bash_fg: Color::Rgb(180, 100, 0),
78    input_note_fg: Color::Rgb(0, 120, 0),
79    input_command_fg: Color::Rgb(100, 0, 150),
80    input_atfile_fg: Color::Blue,
81    user_bar: Color::Blue,
82    assistant_bar: Color::Rgb(0, 130, 0),
83    system_bar: Color::Gray,
84    tool_call_icon: Color::Rgb(180, 100, 0),
85    tool_ok_fg: Color::Rgb(0, 130, 0),
86    tool_err_fg: Color::Red,
87    code_fg: Color::Blue,
88    diff_add: Color::Rgb(0, 130, 0),
89    diff_del: Color::Red,
90};
91
92pub const SOLARIZED: Theme = Theme {
93    name: "solarized",
94    status_bg: Color::Rgb(7, 54, 66),
95    status_fg: Color::Rgb(131, 148, 150),
96    status_mode_fg: Color::Rgb(38, 139, 210),
97    status_cost_fg: Color::Rgb(42, 161, 152),
98    status_dim_fg: Color::Rgb(88, 110, 117),
99    input_border: Color::Rgb(88, 110, 117),
100    input_prompt_fg: Color::Rgb(42, 161, 152),
101    input_bash_fg: Color::Rgb(181, 137, 0),
102    input_note_fg: Color::Rgb(133, 153, 0),
103    input_command_fg: Color::Rgb(108, 113, 196),
104    input_atfile_fg: Color::Rgb(42, 161, 152),
105    user_bar: Color::Rgb(38, 139, 210),
106    assistant_bar: Color::Rgb(133, 153, 0),
107    system_bar: Color::Rgb(88, 110, 117),
108    tool_call_icon: Color::Rgb(181, 137, 0),
109    tool_ok_fg: Color::Rgb(133, 153, 0),
110    tool_err_fg: Color::Rgb(220, 50, 47),
111    code_fg: Color::Rgb(42, 161, 152),
112    diff_add: Color::Rgb(133, 153, 0),
113    diff_del: Color::Rgb(220, 50, 47),
114};
115
116pub const ALL_THEMES: &[&Theme] = &[&DARK, &LIGHT, &SOLARIZED];
117
118/// Look up a theme by name; falls back to [`DARK`] for unknown names.
119pub fn find_theme(name: &str) -> &'static Theme {
120    ALL_THEMES
121        .iter()
122        .copied()
123        .find(|t| t.name == name)
124        .unwrap_or(&DARK)
125}
126
127// ── Tests ─────────────────────────────────────────────────────────────────
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn dark_theme_has_expected_name() {
135        assert_eq!(DARK.name, "dark");
136    }
137
138    #[test]
139    fn find_theme_returns_dark_for_unknown() {
140        assert_eq!(find_theme("nope").name, "dark");
141        assert_eq!(find_theme("").name, "dark");
142    }
143
144    #[test]
145    fn all_themes_have_unique_names() {
146        let names: Vec<&str> = ALL_THEMES.iter().map(|t| t.name).collect();
147        let unique: std::collections::HashSet<&&str> = names.iter().collect();
148        assert_eq!(names.len(), unique.len(), "duplicate theme names");
149    }
150
151    #[test]
152    fn find_theme_returns_correct_theme() {
153        assert_eq!(find_theme("light").name, "light");
154        assert_eq!(find_theme("solarized").name, "solarized");
155        assert_eq!(find_theme("dark").name, "dark");
156    }
157}