1use anstyle::{Color, 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
12const WELCOME_TOOL_COLOR: RgbColor = RgbColor(0x38, 0x3B, 0x73);
13
14#[derive(Clone, Debug)]
16pub struct ThemePalette {
17 pub primary_accent: RgbColor,
18 pub background: RgbColor,
19 pub foreground: RgbColor,
20 pub secondary_accent: RgbColor,
21 pub alert: 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_color = WELCOME_TOOL_COLOR;
57 let response_color = ensure_contrast(
58 text_color,
59 background,
60 MIN_CONTRAST,
61 &[lighten(text_color, 0.15), fallback_light],
62 );
63 let user_color = ensure_contrast(
64 lighten(primary, 0.25),
65 background,
66 MIN_CONTRAST,
67 &[lighten(secondary, 0.15), info_color, text_color],
68 );
69 let alert_color = ensure_contrast(
70 self.alert,
71 background,
72 MIN_CONTRAST,
73 &[lighten(self.alert, 0.2), fallback_light, text_color],
74 );
75
76 ThemeStyles {
77 info: Self::style_from(info_color, true),
78 error: Self::style_from(alert_color, true),
79 output: Self::style_from(text_color, false),
80 response: Self::style_from(response_color, false),
81 tool: Style::new().fg_color(Some(Color::Rgb(tool_color))).bold(),
82 user: Self::style_from(user_color, false),
83 primary: Self::style_from(primary, false),
84 secondary: Self::style_from(secondary, false),
85 background: Color::Rgb(background),
86 foreground: Color::Rgb(text_color),
87 }
88 }
89}
90
91#[derive(Clone, Debug)]
93pub struct ThemeStyles {
94 pub info: Style,
95 pub error: Style,
96 pub output: Style,
97 pub response: Style,
98 pub tool: Style,
99 pub user: Style,
100 pub primary: Style,
101 pub secondary: Style,
102 pub background: Color,
103 pub foreground: Color,
104}
105
106#[derive(Clone, Debug)]
107pub struct ThemeDefinition {
108 pub id: &'static str,
109 pub label: &'static str,
110 pub palette: ThemePalette,
111}
112
113#[derive(Clone, Debug)]
114struct ActiveTheme {
115 id: String,
116 label: String,
117 palette: ThemePalette,
118 styles: ThemeStyles,
119}
120
121static REGISTRY: Lazy<HashMap<&'static str, ThemeDefinition>> = Lazy::new(|| {
122 let mut map = HashMap::new();
123 map.insert(
124 "ciapre-dark",
125 ThemeDefinition {
126 id: "ciapre-dark",
127 label: "Ciapre Dark",
128 palette: ThemePalette {
129 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
130 background: RgbColor(0x26, 0x26, 0x26),
131 foreground: RgbColor(0xBF, 0xB3, 0x8F),
132 secondary_accent: RgbColor(0xD9, 0x9A, 0x4E),
133 alert: RgbColor(0xFF, 0x8A, 0x8A),
134 },
135 },
136 );
137 map.insert(
138 "ciapre-blue",
139 ThemeDefinition {
140 id: "ciapre-blue",
141 label: "Ciapre Blue",
142 palette: ThemePalette {
143 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
144 background: RgbColor(0x38, 0x3B, 0x73),
145 foreground: RgbColor(0xBF, 0xB3, 0x8F),
146 secondary_accent: RgbColor(0xBF, 0xB3, 0x8F),
147 alert: RgbColor(0xFF, 0x8A, 0x8A),
148 },
149 },
150 );
151 map
152});
153
154static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
155 let default = REGISTRY
156 .get(DEFAULT_THEME_ID)
157 .expect("default theme must exist");
158 let styles = default.palette.build_styles();
159 RwLock::new(ActiveTheme {
160 id: default.id.to_string(),
161 label: default.label.to_string(),
162 palette: default.palette.clone(),
163 styles,
164 })
165});
166
167pub fn set_active_theme(theme_id: &str) -> Result<()> {
169 let id_lc = theme_id.trim().to_lowercase();
170 let theme = REGISTRY
171 .get(id_lc.as_str())
172 .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
173
174 let styles = theme.palette.build_styles();
175 let mut guard = ACTIVE.write();
176 guard.id = theme.id.to_string();
177 guard.label = theme.label.to_string();
178 guard.palette = theme.palette.clone();
179 guard.styles = styles;
180 Ok(())
181}
182
183pub fn active_theme_id() -> String {
185 ACTIVE.read().id.clone()
186}
187
188pub fn active_theme_label() -> String {
190 ACTIVE.read().label.clone()
191}
192
193pub fn active_styles() -> ThemeStyles {
195 ACTIVE.read().styles.clone()
196}
197
198pub fn banner_color() -> RgbColor {
200 WELCOME_TOOL_COLOR
201}
202
203pub fn banner_style() -> Style {
205 Style::new()
206 .fg_color(Some(Color::Rgb(WELCOME_TOOL_COLOR)))
207 .bold()
208}
209
210pub fn available_themes() -> Vec<&'static str> {
212 let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
213 keys.sort();
214 keys
215}
216
217pub fn theme_label(theme_id: &str) -> Option<&'static str> {
219 REGISTRY.get(theme_id).map(|definition| definition.label)
220}
221
222fn relative_luminance(color: RgbColor) -> f64 {
223 fn channel(value: u8) -> f64 {
224 let c = (value as f64) / 255.0;
225 if c <= 0.03928 {
226 c / 12.92
227 } else {
228 ((c + 0.055) / 1.055).powf(2.4)
229 }
230 }
231 let r = channel(color.0);
232 let g = channel(color.1);
233 let b = channel(color.2);
234 0.2126 * r + 0.7152 * g + 0.0722 * b
235}
236
237fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
238 let fg = relative_luminance(foreground);
239 let bg = relative_luminance(background);
240 let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
241 (lighter + 0.05) / (darker + 0.05)
242}
243
244fn ensure_contrast(
245 candidate: RgbColor,
246 background: RgbColor,
247 min_ratio: f64,
248 fallbacks: &[RgbColor],
249) -> RgbColor {
250 if contrast_ratio(candidate, background) >= min_ratio {
251 return candidate;
252 }
253 for &fallback in fallbacks {
254 if contrast_ratio(fallback, background) >= min_ratio {
255 return fallback;
256 }
257 }
258 candidate
259}
260
261fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
262 let ratio = ratio.clamp(0.0, 1.0);
263 let blend = |c: u8, t: u8| -> u8 {
264 let c = c as f64;
265 let t = t as f64;
266 ((c + (t - c) * ratio).round()).clamp(0.0, 255.0) as u8
267 };
268 RgbColor(
269 blend(color.0, target.0),
270 blend(color.1, target.1),
271 blend(color.2, target.2),
272 )
273}
274
275fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
276 mix(color, RgbColor(0xFF, 0xFF, 0xFF), ratio)
277}
278
279pub fn resolve_theme(preferred: Option<String>) -> String {
281 preferred
282 .and_then(|candidate| {
283 let trimmed = candidate.trim().to_lowercase();
284 if trimmed.is_empty() {
285 None
286 } else if REGISTRY.contains_key(trimmed.as_str()) {
287 Some(trimmed)
288 } else {
289 None
290 }
291 })
292 .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
293}
294
295pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
297 REGISTRY
298 .get(theme_id)
299 .map(|definition| definition.label)
300 .context("Theme not found")
301}