1use anstyle::{Color, Effects, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use once_cell::sync::Lazy;
4use parking_lot::RwLock;
5use std::collections::HashMap;
6
7pub const DEFAULT_THEME_ID: &str = "ciapre-dark";
9
10const MIN_CONTRAST: f64 = 4.5;
11
12#[derive(Clone, Debug)]
14pub struct ThemePalette {
15 pub primary_accent: RgbColor,
16 pub background: RgbColor,
17 pub foreground: RgbColor,
18 pub secondary_accent: RgbColor,
19 pub alert: RgbColor,
20 pub logo_accent: RgbColor,
21}
22
23impl ThemePalette {
24 fn style_from(color: RgbColor, bold: bool) -> Style {
25 let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
26 if bold {
27 style = style.bold();
28 }
29 style
30 }
31
32 fn build_styles(&self) -> ThemeStyles {
33 let primary = self.primary_accent;
34 let background = self.background;
35 let secondary = self.secondary_accent;
36
37 let fallback_light = RgbColor(0xFF, 0xFF, 0xFF);
38
39 let text_color = ensure_contrast(
40 self.foreground,
41 background,
42 MIN_CONTRAST,
43 &[
44 lighten(self.foreground, 0.25),
45 lighten(secondary, 0.2),
46 fallback_light,
47 ],
48 );
49 let info_color = ensure_contrast(
50 secondary,
51 background,
52 MIN_CONTRAST,
53 &[lighten(secondary, 0.2), text_color, fallback_light],
54 );
55 let tool_candidate = lighten(secondary, 0.3);
56 let tool_color = ensure_contrast(
57 tool_candidate,
58 background,
59 MIN_CONTRAST,
60 &[
61 lighten(secondary, 0.45),
62 lighten(primary, 0.35),
63 fallback_light,
64 ],
65 );
66 let response_color = ensure_contrast(
67 text_color,
68 background,
69 MIN_CONTRAST,
70 &[lighten(text_color, 0.15), fallback_light],
71 );
72 let reasoning_color = ensure_contrast(
73 lighten(secondary, 0.3),
74 background,
75 MIN_CONTRAST,
76 &[lighten(secondary, 0.15), text_color, fallback_light],
77 );
78 let reasoning_style = Self::style_from(reasoning_color, false).effects(Effects::ITALIC);
79 let user_color = ensure_contrast(
80 lighten(primary, 0.25),
81 background,
82 MIN_CONTRAST,
83 &[lighten(secondary, 0.15), info_color, text_color],
84 );
85 let alert_color = ensure_contrast(
86 self.alert,
87 background,
88 MIN_CONTRAST,
89 &[lighten(self.alert, 0.2), fallback_light, text_color],
90 );
91
92 ThemeStyles {
93 info: Self::style_from(info_color, true),
94 error: Self::style_from(alert_color, true),
95 output: Self::style_from(text_color, false),
96 response: Self::style_from(response_color, false),
97 reasoning: reasoning_style,
98 tool: Style::new().fg_color(Some(Color::Rgb(tool_color))).bold(),
99 user: Self::style_from(user_color, false),
100 primary: Self::style_from(primary, false),
101 secondary: Self::style_from(secondary, false),
102 background: Color::Rgb(background),
103 foreground: Color::Rgb(text_color),
104 }
105 }
106}
107
108#[derive(Clone, Debug)]
110pub struct ThemeStyles {
111 pub info: Style,
112 pub error: Style,
113 pub output: Style,
114 pub response: Style,
115 pub reasoning: Style,
116 pub tool: Style,
117 pub user: Style,
118 pub primary: Style,
119 pub secondary: Style,
120 pub background: Color,
121 pub foreground: Color,
122}
123
124#[derive(Clone, Debug)]
125pub struct ThemeDefinition {
126 pub id: &'static str,
127 pub label: &'static str,
128 pub palette: ThemePalette,
129}
130
131#[derive(Clone, Debug)]
132struct ActiveTheme {
133 id: String,
134 label: String,
135 palette: ThemePalette,
136 styles: ThemeStyles,
137}
138
139static REGISTRY: Lazy<HashMap<&'static str, ThemeDefinition>> = Lazy::new(|| {
140 let mut map = HashMap::new();
141 map.insert(
142 "ciapre-dark",
143 ThemeDefinition {
144 id: "ciapre-dark",
145 label: "Ciapre Dark",
146 palette: ThemePalette {
147 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
148 background: RgbColor(0x26, 0x26, 0x26),
149 foreground: RgbColor(0xBF, 0xB3, 0x8F),
150 secondary_accent: RgbColor(0xD9, 0x9A, 0x4E),
151 alert: RgbColor(0xFF, 0x8A, 0x8A),
152 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
153 },
154 },
155 );
156 map.insert(
157 "ciapre-blue",
158 ThemeDefinition {
159 id: "ciapre-blue",
160 label: "Ciapre Blue",
161 palette: ThemePalette {
162 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
163 background: RgbColor(0x17, 0x1C, 0x26),
164 foreground: RgbColor(0xBF, 0xB3, 0x8F),
165 secondary_accent: RgbColor(0xBF, 0xB3, 0x8F),
166 alert: RgbColor(0xFF, 0x8A, 0x8A),
167 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
168 },
169 },
170 );
171 map
172});
173
174static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
175 let default = REGISTRY
176 .get(DEFAULT_THEME_ID)
177 .expect("default theme must exist");
178 let styles = default.palette.build_styles();
179 RwLock::new(ActiveTheme {
180 id: default.id.to_string(),
181 label: default.label.to_string(),
182 palette: default.palette.clone(),
183 styles,
184 })
185});
186
187pub fn set_active_theme(theme_id: &str) -> Result<()> {
189 let id_lc = theme_id.trim().to_lowercase();
190 let theme = REGISTRY
191 .get(id_lc.as_str())
192 .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
193
194 let styles = theme.palette.build_styles();
195 let mut guard = ACTIVE.write();
196 guard.id = theme.id.to_string();
197 guard.label = theme.label.to_string();
198 guard.palette = theme.palette.clone();
199 guard.styles = styles;
200 Ok(())
201}
202
203pub fn active_theme_id() -> String {
205 ACTIVE.read().id.clone()
206}
207
208pub fn active_theme_label() -> String {
210 ACTIVE.read().label.clone()
211}
212
213pub fn active_styles() -> ThemeStyles {
215 ACTIVE.read().styles.clone()
216}
217
218pub fn banner_color() -> RgbColor {
220 let guard = ACTIVE.read();
221 let accent = guard.palette.logo_accent;
222 let secondary = guard.palette.secondary_accent;
223 let background = guard.palette.background;
224 drop(guard);
225
226 let candidate = lighten(accent, 0.35);
227 ensure_contrast(
228 candidate,
229 background,
230 MIN_CONTRAST,
231 &[lighten(accent, 0.5), lighten(secondary, 0.25), accent],
232 )
233}
234
235pub fn banner_style() -> Style {
237 let accent = banner_color();
238 Style::new().fg_color(Some(Color::Rgb(accent))).bold()
239}
240
241pub fn logo_accent_color() -> RgbColor {
243 ACTIVE.read().palette.logo_accent
244}
245
246pub fn available_themes() -> Vec<&'static str> {
248 let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
249 keys.sort();
250 keys
251}
252
253pub fn theme_label(theme_id: &str) -> Option<&'static str> {
255 REGISTRY.get(theme_id).map(|definition| definition.label)
256}
257
258fn relative_luminance(color: RgbColor) -> f64 {
259 fn channel(value: u8) -> f64 {
260 let c = (value as f64) / 255.0;
261 if c <= 0.03928 {
262 c / 12.92
263 } else {
264 ((c + 0.055) / 1.055).powf(2.4)
265 }
266 }
267 let r = channel(color.0);
268 let g = channel(color.1);
269 let b = channel(color.2);
270 0.2126 * r + 0.7152 * g + 0.0722 * b
271}
272
273fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
274 let fg = relative_luminance(foreground);
275 let bg = relative_luminance(background);
276 let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
277 (lighter + 0.05) / (darker + 0.05)
278}
279
280fn ensure_contrast(
281 candidate: RgbColor,
282 background: RgbColor,
283 min_ratio: f64,
284 fallbacks: &[RgbColor],
285) -> RgbColor {
286 if contrast_ratio(candidate, background) >= min_ratio {
287 return candidate;
288 }
289 for &fallback in fallbacks {
290 if contrast_ratio(fallback, background) >= min_ratio {
291 return fallback;
292 }
293 }
294 candidate
295}
296
297fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
298 let ratio = ratio.clamp(0.0, 1.0);
299 let blend = |c: u8, t: u8| -> u8 {
300 let c = c as f64;
301 let t = t as f64;
302 ((c + (t - c) * ratio).round()).clamp(0.0, 255.0) as u8
303 };
304 RgbColor(
305 blend(color.0, target.0),
306 blend(color.1, target.1),
307 blend(color.2, target.2),
308 )
309}
310
311fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
312 mix(color, RgbColor(0xFF, 0xFF, 0xFF), ratio)
313}
314
315pub fn resolve_theme(preferred: Option<String>) -> String {
317 preferred
318 .and_then(|candidate| {
319 let trimmed = candidate.trim().to_lowercase();
320 if trimmed.is_empty() {
321 None
322 } else if REGISTRY.contains_key(trimmed.as_str()) {
323 Some(trimmed)
324 } else {
325 None
326 }
327 })
328 .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
329}
330
331pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
333 REGISTRY
334 .get(theme_id)
335 .map(|definition| definition.label)
336 .context("Theme not found")
337}