Skip to main content

imp_tui/
theme.rs

1use imp_llm::ThinkingLevel;
2use ratatui::style::{Color, Modifier, Style};
3use serde::Deserialize;
4
5/// Color theme for the TUI.
6#[derive(Debug, Clone)]
7pub struct Theme {
8    pub fg: Color,
9    pub bg: Color,
10    pub accent: Color,
11    pub error: Color,
12    pub warning: Color,
13    pub success: Color,
14    pub muted: Color,
15    pub border: Color,
16    pub user_prefix: Color,
17    pub tool_name: Color,
18    pub code_bg: Color,
19    pub header_fg: Color,
20    pub selection_bg: Color,
21    pub selection_fg: Color,
22}
23
24impl Default for Theme {
25    /// Dungeon stone — charcoal, muted bronze, forge ember, moss.
26    fn default() -> Self {
27        Self {
28            bg: Color::Rgb(0x14, 0x12, 0x10),           // charcoal stone
29            fg: Color::Rgb(0xb8, 0xa8, 0x98),           // weathered limestone
30            accent: Color::Rgb(0xc0, 0xa1, 0x70),       // muted bronze
31            error: Color::Rgb(0xce, 0x5b, 0x47),        // forge ember
32            warning: Color::Rgb(0xcb, 0x97, 0x73),      // muted coral-orange
33            success: Color::Rgb(0x8a, 0x9a, 0x6b),      // dungeon moss
34            muted: Color::Rgb(0x83, 0x7e, 0x78),        // worn stone
35            border: Color::Rgb(0x2a, 0x26, 0x22),       // mortar
36            user_prefix: Color::Rgb(0xc0, 0xa1, 0x70),  // bronze
37            tool_name: Color::Rgb(0xb9, 0x9c, 0x72),    // darker bronze
38            code_bg: Color::Rgb(0x1a, 0x18, 0x16),      // dark alcove
39            header_fg: Color::Rgb(0xb8, 0xa8, 0x98),    // limestone
40            selection_bg: Color::Rgb(0x2a, 0x26, 0x22), // torchlit stone
41            selection_fg: Color::Rgb(0xb8, 0xa8, 0x98), // limestone
42        }
43    }
44}
45
46impl Theme {
47    /// Load a named built-in theme.
48    pub fn named(name: &str) -> Self {
49        match name {
50            "light" => Self::light(),
51            _ => Self::default(),
52        }
53    }
54
55    /// Light theme — sandstone in daylight.
56    pub fn light() -> Self {
57        Self {
58            bg: Color::Rgb(0xf5, 0xf0, 0xe8),           // sunlit sandstone
59            fg: Color::Rgb(0x2a, 0x26, 0x22),           // charcoal ink
60            accent: Color::Rgb(0x8a, 0x70, 0x48),       // dark bronze
61            error: Color::Rgb(0xa0, 0x38, 0x28),        // brick red
62            warning: Color::Rgb(0x9a, 0x6a, 0x40),      // aged copper
63            success: Color::Rgb(0x50, 0x6a, 0x3a),      // deep moss
64            muted: Color::Rgb(0x8a, 0x84, 0x7e),        // grey stone
65            border: Color::Rgb(0xd0, 0xc8, 0xbc),       // pale mortar
66            user_prefix: Color::Rgb(0x8a, 0x70, 0x48),  // dark bronze
67            tool_name: Color::Rgb(0x7a, 0x68, 0x50),    // worn brass
68            code_bg: Color::Rgb(0xec, 0xe6, 0xdc),      // parchment shadow
69            header_fg: Color::Rgb(0x2a, 0x26, 0x22),    // charcoal
70            selection_bg: Color::Rgb(0xd8, 0xd0, 0xc0), // highlighted stone
71            selection_fg: Color::Rgb(0x2a, 0x26, 0x22), // charcoal
72        }
73    }
74
75    /// Apply overrides from a TOML config section.
76    pub fn apply_overrides(&mut self, overrides: &ThemeOverrides) {
77        if let Some(ref c) = overrides.fg {
78            if let Some(c) = parse_hex(c) {
79                self.fg = c;
80            }
81        }
82        if let Some(ref c) = overrides.bg {
83            if let Some(c) = parse_hex(c) {
84                self.bg = c;
85            }
86        }
87        if let Some(ref c) = overrides.accent {
88            if let Some(c) = parse_hex(c) {
89                self.accent = c;
90            }
91        }
92        if let Some(ref c) = overrides.error {
93            if let Some(c) = parse_hex(c) {
94                self.error = c;
95            }
96        }
97        if let Some(ref c) = overrides.warning {
98            if let Some(c) = parse_hex(c) {
99                self.warning = c;
100            }
101        }
102        if let Some(ref c) = overrides.success {
103            if let Some(c) = parse_hex(c) {
104                self.success = c;
105            }
106        }
107        if let Some(ref c) = overrides.muted {
108            if let Some(c) = parse_hex(c) {
109                self.muted = c;
110            }
111        }
112        if let Some(ref c) = overrides.border {
113            if let Some(c) = parse_hex(c) {
114                self.border = c;
115            }
116        }
117        if let Some(ref c) = overrides.user_prefix {
118            if let Some(c) = parse_hex(c) {
119                self.user_prefix = c;
120            }
121        }
122        if let Some(ref c) = overrides.tool_name {
123            if let Some(c) = parse_hex(c) {
124                self.tool_name = c;
125            }
126        }
127        if let Some(ref c) = overrides.code_bg {
128            if let Some(c) = parse_hex(c) {
129                self.code_bg = c;
130            }
131        }
132    }
133
134    pub fn style(&self) -> Style {
135        Style::default().fg(self.fg).bg(self.bg)
136    }
137
138    pub fn accent_style(&self) -> Style {
139        Style::default().fg(self.accent)
140    }
141
142    pub fn error_style(&self) -> Style {
143        Style::default().fg(self.error)
144    }
145
146    pub fn warning_style(&self) -> Style {
147        Style::default().fg(self.warning)
148    }
149
150    pub fn success_style(&self) -> Style {
151        Style::default().fg(self.success)
152    }
153
154    pub fn muted_style(&self) -> Style {
155        Style::default().fg(self.muted)
156    }
157
158    pub fn border_style(&self) -> Style {
159        Style::default().fg(self.border)
160    }
161
162    pub fn bold_style(&self) -> Style {
163        Style::default().add_modifier(Modifier::BOLD)
164    }
165
166    pub fn italic_style(&self) -> Style {
167        Style::default().add_modifier(Modifier::ITALIC)
168    }
169
170    pub fn code_inline_style(&self) -> Style {
171        Style::default().fg(self.warning).bg(self.code_bg)
172    }
173
174    pub fn header_style(&self) -> Style {
175        Style::default()
176            .fg(self.header_fg)
177            .add_modifier(Modifier::BOLD)
178    }
179
180    pub fn selected_style(&self) -> Style {
181        Style::default().fg(self.selection_fg).bg(self.selection_bg)
182    }
183
184    /// Border color progresses like a forge heating up.
185    pub fn thinking_border_color(&self, level: ThinkingLevel) -> Color {
186        match level {
187            ThinkingLevel::Off => self.border, // cold mortar
188            ThinkingLevel::Minimal => Color::Rgb(0x83, 0x7e, 0x78), // warming stone
189            ThinkingLevel::Low => Color::Rgb(0xb9, 0x9c, 0x72), // bronze glow
190            ThinkingLevel::Medium => self.accent, // muted bronze
191            ThinkingLevel::High => Color::Rgb(0xce, 0x5b, 0x47), // forge ember
192            ThinkingLevel::XHigh => Color::Rgb(0xcb, 0x97, 0x73), // hot coral
193        }
194    }
195}
196
197/// Config-driven theme overrides. All fields optional — only set ones override the base theme.
198#[derive(Debug, Clone, Default, Deserialize)]
199pub struct ThemeOverrides {
200    pub fg: Option<String>,
201    pub bg: Option<String>,
202    pub accent: Option<String>,
203    pub error: Option<String>,
204    pub warning: Option<String>,
205    pub success: Option<String>,
206    pub muted: Option<String>,
207    pub border: Option<String>,
208    pub user_prefix: Option<String>,
209    pub tool_name: Option<String>,
210    pub code_bg: Option<String>,
211}
212
213/// Parse a "#rrggbb" hex string into a ratatui Color.
214fn parse_hex(s: &str) -> Option<Color> {
215    let s = s.strip_prefix('#').unwrap_or(s);
216    if s.len() != 6 {
217        return None;
218    }
219    let r = u8::from_str_radix(&s[0..2], 16).ok()?;
220    let g = u8::from_str_radix(&s[2..4], 16).ok()?;
221    let b = u8::from_str_radix(&s[4..6], 16).ok()?;
222    Some(Color::Rgb(r, g, b))
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn parse_hex_valid() {
231        assert_eq!(parse_hex("#ff0000"), Some(Color::Rgb(255, 0, 0)));
232        assert_eq!(parse_hex("00ff00"), Some(Color::Rgb(0, 255, 0)));
233        assert_eq!(parse_hex("#151820"), Some(Color::Rgb(0x15, 0x18, 0x20)));
234    }
235
236    #[test]
237    fn parse_hex_invalid() {
238        assert_eq!(parse_hex("nope"), None);
239        assert_eq!(parse_hex("#fff"), None);
240        assert_eq!(parse_hex(""), None);
241    }
242
243    #[test]
244    fn default_theme_is_dungeon() {
245        let t = Theme::default();
246        // Muted bronze accent
247        assert_eq!(t.accent, Color::Rgb(0xc0, 0xa1, 0x70));
248        // Charcoal stone background
249        assert_eq!(t.bg, Color::Rgb(0x14, 0x12, 0x10));
250        // Forge ember error
251        assert_eq!(t.error, Color::Rgb(0xce, 0x5b, 0x47));
252    }
253
254    #[test]
255    fn overrides_apply() {
256        let mut t = Theme::default();
257        let overrides = ThemeOverrides {
258            accent: Some("#ff0000".into()),
259            ..Default::default()
260        };
261        t.apply_overrides(&overrides);
262        assert_eq!(t.accent, Color::Rgb(255, 0, 0));
263        // Other fields unchanged
264        assert_eq!(t.user_prefix, Color::Rgb(0xc0, 0xa1, 0x70));
265    }
266
267    #[test]
268    fn named_themes() {
269        let default = Theme::named("default");
270        assert_eq!(default.accent, Color::Rgb(0xc0, 0xa1, 0x70));
271
272        let light = Theme::named("light");
273        assert_eq!(light.bg, Color::Rgb(0xf5, 0xf0, 0xe8));
274
275        // Unknown falls back to default
276        let unknown = Theme::named("nonexistent");
277        assert_eq!(unknown.accent, Color::Rgb(0xc0, 0xa1, 0x70));
278    }
279}