1use anstyle::{Color, Effects, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use catppuccin::PALETTE;
4use once_cell::sync::Lazy;
5use parking_lot::RwLock;
6use std::collections::HashMap;
7
8use crate::config::constants::defaults;
9
10pub const DEFAULT_THEME_ID: &str = defaults::DEFAULT_THEME;
12
13const MIN_CONTRAST: f64 = 4.5;
14
15#[derive(Clone, Debug)]
17pub struct ThemePalette {
18 pub primary_accent: RgbColor,
19 pub background: RgbColor,
20 pub foreground: RgbColor,
21 pub secondary_accent: RgbColor,
22 pub alert: RgbColor,
23 pub logo_accent: RgbColor,
24}
25
26impl ThemePalette {
27 fn style_from(color: RgbColor, bold: bool) -> Style {
28 let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
29 if bold {
30 style = style.bold();
31 }
32 style
33 }
34
35 fn build_styles(&self) -> ThemeStyles {
36 let primary = self.primary_accent;
37 let background = self.background;
38 let secondary = self.secondary_accent;
39
40 let fallback_light = RgbColor(0xFF, 0xFF, 0xFF);
41
42 let text_color = ensure_contrast(
43 self.foreground,
44 background,
45 MIN_CONTRAST,
46 &[
47 lighten(self.foreground, 0.25),
48 lighten(secondary, 0.2),
49 fallback_light,
50 ],
51 );
52 let info_color = ensure_contrast(
53 secondary,
54 background,
55 MIN_CONTRAST,
56 &[lighten(secondary, 0.2), text_color, fallback_light],
57 );
58 let tool_candidate = mix(self.alert, background, 0.35);
59 let tool_color = ensure_contrast(
60 tool_candidate,
61 background,
62 MIN_CONTRAST,
63 &[self.alert, mix(self.alert, secondary, 0.25), fallback_light],
64 );
65 let tool_body_candidate = mix(tool_color, text_color, 0.35);
66 let tool_body_color = ensure_contrast(
67 tool_body_candidate,
68 background,
69 MIN_CONTRAST,
70 &[lighten(tool_color, 0.2), text_color, fallback_light],
71 );
72 let tool_style = Style::new().fg_color(Some(Color::Rgb(tool_color))).bold();
73 let tool_detail_style = Style::new()
74 .fg_color(Some(Color::Rgb(tool_body_color)))
75 .effects(Effects::ITALIC);
76 let response_color = ensure_contrast(
77 text_color,
78 background,
79 MIN_CONTRAST,
80 &[lighten(text_color, 0.15), fallback_light],
81 );
82 let reasoning_color = ensure_contrast(
83 lighten(secondary, 0.3),
84 background,
85 MIN_CONTRAST,
86 &[lighten(secondary, 0.15), text_color, fallback_light],
87 );
88 let reasoning_style = Self::style_from(reasoning_color, false).effects(Effects::ITALIC);
89 let user_color = ensure_contrast(
90 lighten(primary, 0.25),
91 background,
92 MIN_CONTRAST,
93 &[lighten(secondary, 0.15), info_color, text_color],
94 );
95 let alert_color = ensure_contrast(
96 self.alert,
97 background,
98 MIN_CONTRAST,
99 &[lighten(self.alert, 0.2), fallback_light, text_color],
100 );
101
102 ThemeStyles {
103 info: Self::style_from(info_color, true),
104 error: Self::style_from(alert_color, true),
105 output: Self::style_from(text_color, false),
106 response: Self::style_from(response_color, false),
107 reasoning: reasoning_style,
108 tool: tool_style,
109 tool_detail: tool_detail_style,
110 status: Self::style_from(
111 ensure_contrast(
112 lighten(primary, 0.35),
113 background,
114 MIN_CONTRAST,
115 &[lighten(primary, 0.5), info_color, text_color],
116 ),
117 true,
118 ),
119 mcp: Self::style_from(
120 ensure_contrast(
121 lighten(self.logo_accent, 0.2),
122 background,
123 MIN_CONTRAST,
124 &[lighten(self.logo_accent, 0.35), info_color, fallback_light],
125 ),
126 true,
127 ),
128 user: Self::style_from(user_color, false),
129 primary: Self::style_from(primary, false),
130 secondary: Self::style_from(secondary, false),
131 background: Color::Rgb(background),
132 foreground: Color::Rgb(text_color),
133 }
134 }
135}
136
137#[derive(Clone, Debug)]
139pub struct ThemeStyles {
140 pub info: Style,
141 pub error: Style,
142 pub output: Style,
143 pub response: Style,
144 pub reasoning: Style,
145 pub tool: Style,
146 pub tool_detail: Style,
147 pub status: Style,
148 pub mcp: Style,
149 pub user: Style,
150 pub primary: Style,
151 pub secondary: Style,
152 pub background: Color,
153 pub foreground: Color,
154}
155
156#[derive(Clone, Debug)]
157pub struct ThemeDefinition {
158 pub id: &'static str,
159 pub label: &'static str,
160 pub palette: ThemePalette,
161}
162
163#[derive(Clone, Debug)]
164struct ActiveTheme {
165 id: String,
166 label: String,
167 palette: ThemePalette,
168 styles: ThemeStyles,
169}
170
171#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
172enum CatppuccinFlavorKind {
173 Latte,
174 Frappe,
175 Macchiato,
176 Mocha,
177}
178
179impl CatppuccinFlavorKind {
180 const fn id(self) -> &'static str {
181 match self {
182 CatppuccinFlavorKind::Latte => "catppuccin-latte",
183 CatppuccinFlavorKind::Frappe => "catppuccin-frappe",
184 CatppuccinFlavorKind::Macchiato => "catppuccin-macchiato",
185 CatppuccinFlavorKind::Mocha => "catppuccin-mocha",
186 }
187 }
188
189 const fn label(self) -> &'static str {
190 match self {
191 CatppuccinFlavorKind::Latte => "Catppuccin Latte",
192 CatppuccinFlavorKind::Frappe => "Catppuccin Frappé",
193 CatppuccinFlavorKind::Macchiato => "Catppuccin Macchiato",
194 CatppuccinFlavorKind::Mocha => "Catppuccin Mocha",
195 }
196 }
197
198 fn flavor(self) -> catppuccin::Flavor {
199 match self {
200 CatppuccinFlavorKind::Latte => PALETTE.latte,
201 CatppuccinFlavorKind::Frappe => PALETTE.frappe,
202 CatppuccinFlavorKind::Macchiato => PALETTE.macchiato,
203 CatppuccinFlavorKind::Mocha => PALETTE.mocha,
204 }
205 }
206}
207
208static CATPPUCCIN_FLAVORS: &[CatppuccinFlavorKind] = &[
209 CatppuccinFlavorKind::Latte,
210 CatppuccinFlavorKind::Frappe,
211 CatppuccinFlavorKind::Macchiato,
212 CatppuccinFlavorKind::Mocha,
213];
214
215static REGISTRY: Lazy<HashMap<&'static str, ThemeDefinition>> = Lazy::new(|| {
216 let mut map = HashMap::new();
217 map.insert(
218 "ciapre-dark",
219 ThemeDefinition {
220 id: "ciapre-dark",
221 label: "Ciapre Dark",
222 palette: ThemePalette {
223 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
224 background: RgbColor(0x26, 0x26, 0x26),
225 foreground: RgbColor(0xBF, 0xB3, 0x8F),
226 secondary_accent: RgbColor(0xD9, 0x9A, 0x4E),
227 alert: RgbColor(0xFF, 0x8A, 0x8A),
228 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
229 },
230 },
231 );
232 map.insert(
233 "ciapre-blue",
234 ThemeDefinition {
235 id: "ciapre-blue",
236 label: "Ciapre Blue",
237 palette: ThemePalette {
238 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
239 background: RgbColor(0x17, 0x1C, 0x26),
240 foreground: RgbColor(0xBF, 0xB3, 0x8F),
241 secondary_accent: RgbColor(0xBF, 0xB3, 0x8F),
242 alert: RgbColor(0xFF, 0x8A, 0x8A),
243 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
244 },
245 },
246 );
247 register_catppuccin_themes(&mut map);
248 map
249});
250
251fn register_catppuccin_themes(map: &mut HashMap<&'static str, ThemeDefinition>) {
252 for &flavor_kind in CATPPUCCIN_FLAVORS {
253 let flavor = flavor_kind.flavor();
254 let theme_definition = ThemeDefinition {
255 id: flavor_kind.id(),
256 label: flavor_kind.label(),
257 palette: catppuccin_palette(flavor),
258 };
259 map.insert(flavor_kind.id(), theme_definition);
260 }
261}
262
263fn catppuccin_palette(flavor: catppuccin::Flavor) -> ThemePalette {
264 let colors = flavor.colors;
265 ThemePalette {
266 primary_accent: catppuccin_rgb(colors.lavender),
267 background: catppuccin_rgb(colors.base),
268 foreground: catppuccin_rgb(colors.text),
269 secondary_accent: catppuccin_rgb(colors.sapphire),
270 alert: catppuccin_rgb(colors.red),
271 logo_accent: catppuccin_rgb(colors.peach),
272 }
273}
274
275fn catppuccin_rgb(color: catppuccin::Color) -> RgbColor {
276 RgbColor(color.rgb.r, color.rgb.g, color.rgb.b)
277}
278
279static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
280 let default = REGISTRY
281 .get(DEFAULT_THEME_ID)
282 .expect("default theme must exist");
283 let styles = default.palette.build_styles();
284 RwLock::new(ActiveTheme {
285 id: default.id.to_string(),
286 label: default.label.to_string(),
287 palette: default.palette.clone(),
288 styles,
289 })
290});
291
292pub fn set_active_theme(theme_id: &str) -> Result<()> {
294 let id_lc = theme_id.trim().to_lowercase();
295 let theme = REGISTRY
296 .get(id_lc.as_str())
297 .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
298
299 let styles = theme.palette.build_styles();
300 let mut guard = ACTIVE.write();
301 guard.id = theme.id.to_string();
302 guard.label = theme.label.to_string();
303 guard.palette = theme.palette.clone();
304 guard.styles = styles;
305 Ok(())
306}
307
308pub fn active_theme_id() -> String {
310 ACTIVE.read().id.clone()
311}
312
313pub fn active_theme_label() -> String {
315 ACTIVE.read().label.clone()
316}
317
318pub fn active_styles() -> ThemeStyles {
320 ACTIVE.read().styles.clone()
321}
322
323pub fn banner_color() -> RgbColor {
325 let guard = ACTIVE.read();
326 let accent = guard.palette.logo_accent;
327 let secondary = guard.palette.secondary_accent;
328 let background = guard.palette.background;
329 drop(guard);
330
331 let candidate = lighten(accent, 0.35);
332 ensure_contrast(
333 candidate,
334 background,
335 MIN_CONTRAST,
336 &[lighten(accent, 0.5), lighten(secondary, 0.25), accent],
337 )
338}
339
340pub fn banner_style() -> Style {
342 let accent = banner_color();
343 Style::new().fg_color(Some(Color::Rgb(accent))).bold()
344}
345
346pub fn logo_accent_color() -> RgbColor {
348 ACTIVE.read().palette.logo_accent
349}
350
351pub fn available_themes() -> Vec<&'static str> {
353 let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
354 keys.sort();
355 keys
356}
357
358pub fn theme_label(theme_id: &str) -> Option<&'static str> {
360 REGISTRY.get(theme_id).map(|definition| definition.label)
361}
362
363fn relative_luminance(color: RgbColor) -> f64 {
364 fn channel(value: u8) -> f64 {
365 let c = (value as f64) / 255.0;
366 if c <= 0.03928 {
367 c / 12.92
368 } else {
369 ((c + 0.055) / 1.055).powf(2.4)
370 }
371 }
372 let r = channel(color.0);
373 let g = channel(color.1);
374 let b = channel(color.2);
375 0.2126 * r + 0.7152 * g + 0.0722 * b
376}
377
378fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
379 let fg = relative_luminance(foreground);
380 let bg = relative_luminance(background);
381 let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
382 (lighter + 0.05) / (darker + 0.05)
383}
384
385fn ensure_contrast(
386 candidate: RgbColor,
387 background: RgbColor,
388 min_ratio: f64,
389 fallbacks: &[RgbColor],
390) -> RgbColor {
391 if contrast_ratio(candidate, background) >= min_ratio {
392 return candidate;
393 }
394 for &fallback in fallbacks {
395 if contrast_ratio(fallback, background) >= min_ratio {
396 return fallback;
397 }
398 }
399 candidate
400}
401
402fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
403 let ratio = ratio.clamp(0.0, 1.0);
404 let blend = |c: u8, t: u8| -> u8 {
405 let c = c as f64;
406 let t = t as f64;
407 ((c + (t - c) * ratio).round()).clamp(0.0, 255.0) as u8
408 };
409 RgbColor(
410 blend(color.0, target.0),
411 blend(color.1, target.1),
412 blend(color.2, target.2),
413 )
414}
415
416fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
417 mix(color, RgbColor(0xFF, 0xFF, 0xFF), ratio)
418}
419
420pub fn resolve_theme(preferred: Option<String>) -> String {
422 preferred
423 .and_then(|candidate| {
424 let trimmed = candidate.trim().to_lowercase();
425 if trimmed.is_empty() {
426 None
427 } else if REGISTRY.contains_key(trimmed.as_str()) {
428 Some(trimmed)
429 } else {
430 None
431 }
432 })
433 .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
434}
435
436pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
438 REGISTRY
439 .get(theme_id)
440 .map(|definition| definition.label)
441 .context("Theme not found")
442}