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