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