1use anstyle::{Color, Effects, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use catppuccin::PALETTE;
4use hashbrown::HashMap;
5use once_cell::sync::Lazy;
6use parking_lot::RwLock;
7
8use crate::config::constants::{defaults, ui};
9
10pub const DEFAULT_THEME_ID: &str = defaults::DEFAULT_THEME;
12
13const DEFAULT_MIN_CONTRAST: f64 = ui::THEME_MIN_CONTRAST_RATIO;
15const MAX_DARK_BG_TEXT_LUMINANCE: f64 = 0.92;
16const MIN_DARK_BG_TEXT_LUMINANCE: f64 = 0.20;
17const MAX_LIGHT_BG_TEXT_LUMINANCE: f64 = 0.68;
18
19static COLOR_CONFIG: Lazy<RwLock<ColorAccessibilityConfig>> =
22 Lazy::new(|| RwLock::new(ColorAccessibilityConfig::default()));
23
24#[derive(Clone, Debug)]
26pub struct ColorAccessibilityConfig {
27 pub minimum_contrast: f64,
29 pub bold_is_bright: bool,
31 pub safe_colors_only: bool,
33}
34
35impl Default for ColorAccessibilityConfig {
36 fn default() -> Self {
37 Self {
38 minimum_contrast: DEFAULT_MIN_CONTRAST,
39 bold_is_bright: false,
40 safe_colors_only: false,
41 }
42 }
43}
44
45pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
48 *COLOR_CONFIG.write() = config;
49}
50
51pub fn get_minimum_contrast() -> f64 {
53 COLOR_CONFIG.read().minimum_contrast
54}
55
56pub fn is_bold_bright_mode() -> bool {
58 COLOR_CONFIG.read().bold_is_bright
59}
60
61pub fn is_safe_colors_only() -> bool {
63 COLOR_CONFIG.read().safe_colors_only
64}
65
66#[derive(Clone, Debug)]
68pub struct ThemePalette {
69 pub primary_accent: RgbColor,
70 pub background: RgbColor,
71 pub foreground: RgbColor,
72 pub secondary_accent: RgbColor,
73 pub alert: RgbColor,
74 pub logo_accent: RgbColor,
75}
76
77impl ThemePalette {
78 fn style_from(color: RgbColor, bold: bool) -> Style {
82 let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
83 if bold && !is_bold_bright_mode() {
85 style = style.bold();
86 }
87 style
88 }
89
90 fn build_styles(&self) -> ThemeStyles {
91 self.build_styles_with_contrast(get_minimum_contrast())
92 }
93
94 fn build_styles_with_contrast(&self, min_contrast: f64) -> ThemeStyles {
97 let primary = self.primary_accent;
98 let background = self.background;
99 let secondary = self.secondary_accent;
100 let logo_accent = self.logo_accent;
101
102 let fallback_light = RgbColor(
103 ui::THEME_COLOR_WHITE_RED,
104 ui::THEME_COLOR_WHITE_GREEN,
105 ui::THEME_COLOR_WHITE_BLUE,
106 );
107
108 let text_color = ensure_contrast(
109 self.foreground,
110 background,
111 min_contrast,
112 &[
113 lighten(self.foreground, ui::THEME_FOREGROUND_LIGHTEN_RATIO),
114 lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
115 fallback_light,
116 ],
117 );
118 let text_color = balance_text_luminance(text_color, background, min_contrast);
119
120 let info_color = ensure_contrast(
121 secondary,
122 background,
123 min_contrast,
124 &[
125 lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
126 text_color,
127 fallback_light,
128 ],
129 );
130 let info_color = balance_text_luminance(info_color, background, min_contrast);
131
132 let light_tool_color = lighten(text_color, ui::THEME_MIX_RATIO); let tool_color = ensure_contrast(
135 light_tool_color,
136 background,
137 min_contrast,
138 &[
139 lighten(light_tool_color, ui::THEME_TOOL_BODY_LIGHTEN_RATIO),
140 info_color,
141 text_color,
142 ],
143 );
144 let tool_body_candidate = mix(light_tool_color, text_color, ui::THEME_TOOL_BODY_MIX_RATIO);
145 let tool_body_color = ensure_contrast(
146 tool_body_candidate,
147 background,
148 min_contrast,
149 &[
150 lighten(light_tool_color, ui::THEME_TOOL_BODY_LIGHTEN_RATIO),
151 text_color,
152 fallback_light,
153 ],
154 );
155 let tool_style = Style::new().fg_color(Some(Color::Rgb(tool_color)));
156 let tool_detail_style = Style::new().fg_color(Some(Color::Rgb(tool_body_color)));
157 let response_color = ensure_contrast(
158 text_color,
159 background,
160 min_contrast,
161 &[
162 lighten(text_color, ui::THEME_RESPONSE_COLOR_LIGHTEN_RATIO),
163 fallback_light,
164 ],
165 );
166 let response_color = balance_text_luminance(response_color, background, min_contrast);
167
168 let reasoning_color = ensure_contrast(
170 lighten(text_color, 0.25), background,
172 min_contrast,
173 &[lighten(text_color, 0.15), text_color, fallback_light],
174 );
175 let reasoning_color = balance_text_luminance(reasoning_color, background, min_contrast);
176 let reasoning_style =
178 Self::style_from(reasoning_color, false).effects(Effects::DIMMED | Effects::ITALIC);
179 let user_color = ensure_contrast(
182 lighten(secondary, ui::THEME_USER_COLOR_LIGHTEN_RATIO),
183 background,
184 min_contrast,
185 &[
186 lighten(secondary, ui::THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO),
187 info_color,
188 text_color,
189 ],
190 );
191 let user_color = balance_text_luminance(user_color, background, min_contrast);
192
193 let alert_color = ensure_contrast(
194 self.alert,
195 background,
196 min_contrast,
197 &[
198 lighten(self.alert, ui::THEME_LUMINANCE_LIGHTEN_RATIO),
199 fallback_light,
200 text_color,
201 ],
202 );
203
204 let alert_color = balance_text_luminance(alert_color, background, min_contrast);
205
206 let tool_output_style = Style::new();
208
209 let pty_output_candidate = lighten(tool_body_color, ui::THEME_PTY_OUTPUT_LIGHTEN_RATIO);
213 let pty_output_color = ensure_contrast(
214 pty_output_candidate,
215 background,
216 min_contrast,
217 &[
218 lighten(text_color, ui::THEME_PTY_OUTPUT_LIGHTEN_RATIO),
219 tool_body_color,
220 text_color,
221 ],
222 );
223 let pty_output_style = Style::new().fg_color(Some(Color::Rgb(pty_output_color)));
224
225 let primary_style_color = balance_text_luminance(
226 ensure_contrast(primary, background, min_contrast, &[text_color]),
227 background,
228 min_contrast,
229 );
230 let secondary_style_color = balance_text_luminance(
231 ensure_contrast(
232 secondary,
233 background,
234 min_contrast,
235 &[info_color, text_color],
236 ),
237 background,
238 min_contrast,
239 );
240 let logo_style_color = balance_text_luminance(
241 ensure_contrast(
242 logo_accent,
243 background,
244 min_contrast,
245 &[secondary_style_color, text_color],
246 ),
247 background,
248 min_contrast,
249 );
250
251 ThemeStyles {
252 info: Self::style_from(info_color, true),
253 error: Self::style_from(alert_color, true),
254 output: Self::style_from(text_color, false),
255 response: Self::style_from(response_color, false),
256 reasoning: reasoning_style,
257 tool: tool_style,
258 tool_detail: tool_detail_style,
259 tool_output: tool_output_style,
260 pty_output: pty_output_style,
261 status: Self::style_from(
262 ensure_contrast(
263 lighten(primary_style_color, ui::THEME_PRIMARY_STATUS_LIGHTEN_RATIO),
264 background,
265 min_contrast,
266 &[
267 lighten(
268 primary_style_color,
269 ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO,
270 ),
271 info_color,
272 text_color,
273 ],
274 ),
275 true,
276 ),
277 mcp: Self::style_from(
278 ensure_contrast(
279 lighten(logo_style_color, ui::THEME_SECONDARY_LIGHTEN_RATIO),
280 background,
281 min_contrast,
282 &[
283 lighten(logo_style_color, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO),
284 info_color,
285 fallback_light,
286 ],
287 ),
288 true,
289 ),
290 user: Self::style_from(user_color, false),
291 primary: Self::style_from(primary_style_color, false),
292 secondary: Self::style_from(secondary_style_color, false),
293 background: Color::Rgb(background),
294 foreground: Color::Rgb(text_color),
295 }
296 }
297}
298
299#[derive(Clone, Debug)]
301pub struct ThemeStyles {
302 pub info: Style,
303 pub error: Style,
304 pub output: Style,
305 pub response: Style,
306 pub reasoning: Style,
307 pub tool: Style,
308 pub tool_detail: Style,
309 pub tool_output: Style,
310 pub pty_output: Style,
311 pub status: Style,
312 pub mcp: Style,
313 pub user: Style,
314 pub primary: Style,
315 pub secondary: Style,
316 pub background: Color,
317 pub foreground: Color,
318}
319
320#[derive(Clone, Debug)]
321pub struct ThemeDefinition {
322 pub id: &'static str,
323 pub label: &'static str,
324 pub palette: ThemePalette,
325}
326
327#[derive(Clone, Debug, PartialEq, Eq)]
329pub struct ThemeSuite {
330 pub id: &'static str,
331 pub label: &'static str,
332 pub theme_ids: Vec<&'static str>,
333}
334
335#[derive(Clone, Debug)]
336struct ActiveTheme {
337 id: String,
338 label: String,
339 palette: ThemePalette,
340 styles: ThemeStyles,
341}
342
343#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
344enum CatppuccinFlavorKind {
345 Latte,
346 Frappe,
347 Macchiato,
348 Mocha,
349}
350
351impl CatppuccinFlavorKind {
352 const fn id(self) -> &'static str {
353 match self {
354 CatppuccinFlavorKind::Latte => "catppuccin-latte",
355 CatppuccinFlavorKind::Frappe => "catppuccin-frappe",
356 CatppuccinFlavorKind::Macchiato => "catppuccin-macchiato",
357 CatppuccinFlavorKind::Mocha => "catppuccin-mocha",
358 }
359 }
360
361 const fn label(self) -> &'static str {
362 match self {
363 CatppuccinFlavorKind::Latte => "Catppuccin Latte",
364 CatppuccinFlavorKind::Frappe => "Catppuccin Frappé",
365 CatppuccinFlavorKind::Macchiato => "Catppuccin Macchiato",
366 CatppuccinFlavorKind::Mocha => "Catppuccin Mocha",
367 }
368 }
369
370 fn flavor(self) -> catppuccin::Flavor {
371 match self {
372 CatppuccinFlavorKind::Latte => PALETTE.latte,
373 CatppuccinFlavorKind::Frappe => PALETTE.frappe,
374 CatppuccinFlavorKind::Macchiato => PALETTE.macchiato,
375 CatppuccinFlavorKind::Mocha => PALETTE.mocha,
376 }
377 }
378}
379
380static CATPPUCCIN_FLAVORS: &[CatppuccinFlavorKind] = &[
381 CatppuccinFlavorKind::Latte,
382 CatppuccinFlavorKind::Frappe,
383 CatppuccinFlavorKind::Macchiato,
384 CatppuccinFlavorKind::Mocha,
385];
386
387static REGISTRY: Lazy<HashMap<&'static str, ThemeDefinition>> = Lazy::new(|| {
388 let mut map = HashMap::new();
389 map.insert(
390 "ciapre-dark",
391 ThemeDefinition {
392 id: "ciapre-dark",
393 label: "Ciapre Dark",
394 palette: ThemePalette {
395 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
396 background: RgbColor(0x26, 0x26, 0x26),
397 foreground: RgbColor(0xBF, 0xB3, 0x8F),
398 secondary_accent: RgbColor(0xD9, 0x9A, 0x4E),
399 alert: RgbColor(0xFF, 0x8A, 0x8A),
400 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
401 },
402 },
403 );
404 map.insert(
405 "ciapre-blue",
406 ThemeDefinition {
407 id: "ciapre-blue",
408 label: "Ciapre Blue",
409 palette: ThemePalette {
410 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
411 background: RgbColor(0x17, 0x1C, 0x26),
412 foreground: RgbColor(0xBF, 0xB3, 0x8F),
413 secondary_accent: RgbColor(0xBF, 0xB3, 0x8F),
414 alert: RgbColor(0xFF, 0x8A, 0x8A),
415 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
416 },
417 },
418 );
419 map.insert(
420 "ciapre",
421 ThemeDefinition {
422 id: "ciapre",
423 label: "Ciapre",
424 palette: ThemePalette {
425 primary_accent: RgbColor(0xAE, 0xA4, 0x7F), background: RgbColor(0x18, 0x18, 0x18), foreground: RgbColor(0xAE, 0xA4, 0x7F), secondary_accent: RgbColor(0xCC, 0x8A, 0x3E), alert: RgbColor(0xAC, 0x38, 0x35), logo_accent: RgbColor(0xCC, 0x8A, 0x3E), },
432 },
433 );
434 map.insert(
435 "solarized-dark",
436 ThemeDefinition {
437 id: "solarized-dark",
438 label: "Solarized Dark",
439 palette: ThemePalette {
440 primary_accent: RgbColor(0x83, 0x94, 0x96), background: RgbColor(0x00, 0x2B, 0x36), foreground: RgbColor(0x83, 0x94, 0x96), secondary_accent: RgbColor(0x26, 0x8B, 0xD2), alert: RgbColor(0xDC, 0x32, 0x2F), logo_accent: RgbColor(0xB5, 0x89, 0x00), },
447 },
448 );
449 map.insert(
450 "solarized-light",
451 ThemeDefinition {
452 id: "solarized-light",
453 label: "Solarized Light",
454 palette: ThemePalette {
455 primary_accent: RgbColor(0x58, 0x6E, 0x75), background: RgbColor(0xFD, 0xF6, 0xE3), foreground: RgbColor(0x58, 0x6E, 0x75), secondary_accent: RgbColor(0x26, 0x8B, 0xD2), alert: RgbColor(0xDC, 0x32, 0x2F), logo_accent: RgbColor(0xB5, 0x89, 0x00), },
462 },
463 );
464 map.insert(
465 "solarized-dark-hc",
466 ThemeDefinition {
467 id: "solarized-dark-hc",
468 label: "Solarized Dark Higher Contrast",
469 palette: ThemePalette {
470 primary_accent: RgbColor(0x83, 0x94, 0x96), background: RgbColor(0x00, 0x28, 0x31), foreground: RgbColor(0xE9, 0xE3, 0xCC), secondary_accent: RgbColor(0x20, 0x76, 0xC7), alert: RgbColor(0xD1, 0x1C, 0x24), logo_accent: RgbColor(0xA5, 0x77, 0x06), },
477 },
478 );
479
480 map.insert(
482 "gruvbox-dark",
483 ThemeDefinition {
484 id: "gruvbox-dark",
485 label: "Gruvbox Dark",
486 palette: ThemePalette {
487 primary_accent: RgbColor(0xA8, 0x99, 0x84), background: RgbColor(0x28, 0x28, 0x28), foreground: RgbColor(0xA8, 0x99, 0x84), secondary_accent: RgbColor(0x45, 0x85, 0x88), alert: RgbColor(0xCC, 0x24, 0x1D), logo_accent: RgbColor(0xD7, 0x99, 0x21), },
494 },
495 );
496 map.insert(
497 "gruvbox-dark-hard",
498 ThemeDefinition {
499 id: "gruvbox-dark-hard",
500 label: "Gruvbox Dark Hard",
501 palette: ThemePalette {
502 primary_accent: RgbColor(0xA8, 0x99, 0x84), background: RgbColor(0x1D, 0x20, 0x21), foreground: RgbColor(0xA8, 0x99, 0x84), secondary_accent: RgbColor(0x45, 0x85, 0x88), alert: RgbColor(0xCC, 0x24, 0x1D), logo_accent: RgbColor(0xD7, 0x99, 0x21), },
509 },
510 );
511 map.insert(
512 "gruvbox-light",
513 ThemeDefinition {
514 id: "gruvbox-light",
515 label: "Gruvbox Light",
516 palette: ThemePalette {
517 primary_accent: RgbColor(0x7C, 0x6F, 0x64), background: RgbColor(0xFB, 0xF4, 0xE8), foreground: RgbColor(0x7C, 0x6F, 0x64), secondary_accent: RgbColor(0x45, 0x85, 0x88), alert: RgbColor(0xCC, 0x24, 0x1D), logo_accent: RgbColor(0xD7, 0x99, 0x21), },
524 },
525 );
526 map.insert(
527 "gruvbox-light-hard",
528 ThemeDefinition {
529 id: "gruvbox-light-hard",
530 label: "Gruvbox Light Hard",
531 palette: ThemePalette {
532 primary_accent: RgbColor(0x7C, 0x6F, 0x64), background: RgbColor(0xF9, 0xF5, 0xD7), foreground: RgbColor(0x7C, 0x6F, 0x64), secondary_accent: RgbColor(0x45, 0x85, 0x88), alert: RgbColor(0xCC, 0x24, 0x1D), logo_accent: RgbColor(0xD7, 0x99, 0x21), },
539 },
540 );
541 map.insert(
542 "gruvbox-material",
543 ThemeDefinition {
544 id: "gruvbox-material",
545 label: "Gruvbox Material",
546 palette: ThemePalette {
547 primary_accent: RgbColor(0xFF, 0xFF, 0xFF), background: RgbColor(0x14, 0x16, 0x17), foreground: RgbColor(0xFF, 0xFF, 0xFF), secondary_accent: RgbColor(0x6D, 0xA3, 0xED), alert: RgbColor(0xEA, 0x69, 0x26), logo_accent: RgbColor(0xEE, 0xCE, 0x5B), },
554 },
555 );
556 map.insert(
557 "gruvbox-material-dark",
558 ThemeDefinition {
559 id: "gruvbox-material-dark",
560 label: "Gruvbox Material Dark",
561 palette: ThemePalette {
562 primary_accent: RgbColor(0xD4, 0xBE, 0x98), background: RgbColor(0x28, 0x28, 0x28), foreground: RgbColor(0xD4, 0xBE, 0x98), secondary_accent: RgbColor(0x7D, 0xAE, 0xA3), alert: RgbColor(0xEA, 0x69, 0x62), logo_accent: RgbColor(0xD8, 0xA6, 0x57), },
569 },
570 );
571 map.insert(
572 "gruvbox-material-light",
573 ThemeDefinition {
574 id: "gruvbox-material-light",
575 label: "Gruvbox Material Light",
576 palette: ThemePalette {
577 primary_accent: RgbColor(0x65, 0x47, 0x35), background: RgbColor(0xFB, 0xF1, 0xC7), foreground: RgbColor(0x65, 0x47, 0x35), secondary_accent: RgbColor(0x45, 0x70, 0x7A), alert: RgbColor(0xC1, 0x4A, 0x4A), logo_accent: RgbColor(0xB4, 0x71, 0x09), },
584 },
585 );
586
587 map.insert(
589 "zenburn",
590 ThemeDefinition {
591 id: "zenburn",
592 label: "Zenburn",
593 palette: ThemePalette {
594 primary_accent: RgbColor(0xDC, 0xDC, 0xCC), background: RgbColor(0x4D, 0x4D, 0x4D), foreground: RgbColor(0xDC, 0xDC, 0xCC), secondary_accent: RgbColor(0x8C, 0xD0, 0xD3), alert: RgbColor(0x70, 0x50, 0x50), logo_accent: RgbColor(0xF0, 0xDF, 0xAF), },
601 },
602 );
603
604 map.insert(
606 "tomorrow",
607 ThemeDefinition {
608 id: "tomorrow",
609 label: "Tomorrow",
610 palette: ThemePalette {
611 primary_accent: RgbColor(0x4D, 0x4D, 0x4D), background: RgbColor(0xFF, 0xFF, 0xFF), foreground: RgbColor(0x4D, 0x4D, 0x4D), secondary_accent: RgbColor(0x42, 0x71, 0xAE), alert: RgbColor(0xC8, 0x28, 0x29), logo_accent: RgbColor(0xEA, 0xB7, 0x00), },
618 },
619 );
620 map.insert(
621 "tomorrow-night",
622 ThemeDefinition {
623 id: "tomorrow-night",
624 label: "Tomorrow Night",
625 palette: ThemePalette {
626 primary_accent: RgbColor(0xDE, 0xDE, 0xDE), background: RgbColor(0x1D, 0x1F, 0x21), foreground: RgbColor(0xDE, 0xDE, 0xDE), secondary_accent: RgbColor(0x81, 0xA2, 0xBE), alert: RgbColor(0xCC, 0x66, 0x66), logo_accent: RgbColor(0xF0, 0xC6, 0x74), },
633 },
634 );
635 map.insert(
636 "tomorrow-night-blue",
637 ThemeDefinition {
638 id: "tomorrow-night-blue",
639 label: "Tomorrow Night Blue",
640 palette: ThemePalette {
641 primary_accent: RgbColor(0xFF, 0xFF, 0xFF), background: RgbColor(0x00, 0x24, 0x51), foreground: RgbColor(0xFF, 0xFF, 0xFF), secondary_accent: RgbColor(0xBB, 0xDA, 0xFF), alert: RgbColor(0xFF, 0x9D, 0xA4), logo_accent: RgbColor(0xFF, 0xEE, 0xAD), },
648 },
649 );
650 map.insert(
651 "tomorrow-night-bright",
652 ThemeDefinition {
653 id: "tomorrow-night-bright",
654 label: "Tomorrow Night Bright",
655 palette: ThemePalette {
656 primary_accent: RgbColor(0xE0, 0xE0, 0xE0), background: RgbColor(0x00, 0x00, 0x00), foreground: RgbColor(0xE0, 0xE0, 0xE0), secondary_accent: RgbColor(0x7A, 0xA6, 0xDA), alert: RgbColor(0xD5, 0x4E, 0x53), logo_accent: RgbColor(0xE7, 0xC5, 0x47), },
663 },
664 );
665 map.insert(
666 "tomorrow-night-burns",
667 ThemeDefinition {
668 id: "tomorrow-night-burns",
669 label: "Tomorrow Night Burns",
670 palette: ThemePalette {
671 primary_accent: RgbColor(0xF5, 0xF5, 0xF5), background: RgbColor(0x25, 0x25, 0x25), foreground: RgbColor(0xF5, 0xF5, 0xF5), secondary_accent: RgbColor(0xFC, 0x59, 0x5F), alert: RgbColor(0xFC, 0x59, 0x5F), logo_accent: RgbColor(0xE0, 0x93, 0x95), },
678 },
679 );
680 map.insert(
681 "tomorrow-night-eighties",
682 ThemeDefinition {
683 id: "tomorrow-night-eighties",
684 label: "Tomorrow Night Eighties",
685 palette: ThemePalette {
686 primary_accent: RgbColor(0xCC, 0xCC, 0xCC), background: RgbColor(0x2D, 0x2D, 0x2D), foreground: RgbColor(0xCC, 0xCC, 0xCC), secondary_accent: RgbColor(0x66, 0x99, 0xCC), alert: RgbColor(0xF2, 0x77, 0x7A), logo_accent: RgbColor(0xFF, 0xCC, 0x66), },
693 },
694 );
695
696 map.insert(
698 "ayu",
699 ThemeDefinition {
700 id: "ayu",
701 label: "Ayu",
702 palette: ThemePalette {
703 primary_accent: RgbColor(0xC7, 0xC7, 0xC7), background: RgbColor(0x11, 0x15, 0x1C), foreground: RgbColor(0xC7, 0xC7, 0xC7), secondary_accent: RgbColor(0x53, 0xBD, 0xFA), alert: RgbColor(0xEA, 0x6C, 0x73), logo_accent: RgbColor(0xF9, 0xAF, 0x4F), },
710 },
711 );
712 map.insert(
713 "ayu-mirage",
714 ThemeDefinition {
715 id: "ayu-mirage",
716 label: "Ayu Mirage",
717 palette: ThemePalette {
718 primary_accent: RgbColor(0xC7, 0xC7, 0xC7), background: RgbColor(0x17, 0x1B, 0x24), foreground: RgbColor(0xC7, 0xC7, 0xC7), secondary_accent: RgbColor(0x6D, 0xCB, 0xFA), alert: RgbColor(0xED, 0x82, 0x74), logo_accent: RgbColor(0xFA, 0xCC, 0x6E), },
725 },
726 );
727
728 map.insert(
730 "material-ocean",
731 ThemeDefinition {
732 id: "material-ocean",
733 label: "Material Ocean",
734 palette: ThemePalette {
735 primary_accent: RgbColor(0xFF, 0xFF, 0xFF), background: RgbColor(0x0F, 0x11, 0x1A), foreground: RgbColor(0xFF, 0xFF, 0xFF), secondary_accent: RgbColor(0x82, 0xAA, 0xFF), alert: RgbColor(0xFF, 0x53, 0x70), logo_accent: RgbColor(0xFF, 0xCB, 0x6B), },
742 },
743 );
744 map.insert(
745 "material-dark",
746 ThemeDefinition {
747 id: "material-dark",
748 label: "Material Dark",
749 palette: ThemePalette {
750 primary_accent: RgbColor(0xEF, 0xEF, 0xEF), background: RgbColor(0x21, 0x21, 0x21), foreground: RgbColor(0xEF, 0xEF, 0xEF), secondary_accent: RgbColor(0x13, 0x4E, 0xB2), alert: RgbColor(0xB7, 0x14, 0x1F), logo_accent: RgbColor(0xF6, 0x98, 0x1E), },
757 },
758 );
759 map.insert(
760 "material",
761 ThemeDefinition {
762 id: "material",
763 label: "Material",
764 palette: ThemePalette {
765 primary_accent: RgbColor(0xEF, 0xEF, 0xEF), background: RgbColor(0x21, 0x21, 0x21), foreground: RgbColor(0xEF, 0xEF, 0xEF), secondary_accent: RgbColor(0x14, 0x4E, 0xB2), alert: RgbColor(0xB7, 0x14, 0x1F), logo_accent: RgbColor(0xF6, 0x98, 0x1E), },
772 },
773 );
774
775 map.insert(
777 "github-dark",
778 ThemeDefinition {
779 id: "github-dark",
780 label: "GitHub Dark",
781 palette: ThemePalette {
782 primary_accent: RgbColor(0xFF, 0xFF, 0xFF), background: RgbColor(0x0D, 0x11, 0x17), foreground: RgbColor(0xFF, 0xFF, 0xFF), secondary_accent: RgbColor(0x6C, 0xA4, 0xF8), alert: RgbColor(0xF7, 0x81, 0x66), logo_accent: RgbColor(0xE3, 0xB3, 0x41), },
789 },
790 );
791 map.insert(
792 "github",
793 ThemeDefinition {
794 id: "github",
795 label: "GitHub",
796 palette: ThemePalette {
797 primary_accent: RgbColor(0x3E, 0x3E, 0x3E), background: RgbColor(0xFF, 0xFF, 0xFF), foreground: RgbColor(0x3E, 0x3E, 0x3E), secondary_accent: RgbColor(0x00, 0x3E, 0x8A), alert: RgbColor(0x97, 0x0B, 0x16), logo_accent: RgbColor(0xF8, 0xEE, 0xC7), },
804 },
805 );
806
807 map.insert(
809 "dracula",
810 ThemeDefinition {
811 id: "dracula",
812 label: "Dracula",
813 palette: ThemePalette {
814 primary_accent: RgbColor(0xF8, 0xF8, 0xF2), background: RgbColor(0x21, 0x22, 0x2C), foreground: RgbColor(0xF8, 0xF8, 0xF2), secondary_accent: RgbColor(0xBD, 0x93, 0xF9), alert: RgbColor(0xFF, 0x55, 0x55), logo_accent: RgbColor(0xF1, 0xFA, 0x8C), },
821 },
822 );
823
824 map.insert(
826 "monokai-classic",
827 ThemeDefinition {
828 id: "monokai-classic",
829 label: "Monokai Classic",
830 palette: ThemePalette {
831 primary_accent: RgbColor(0xF8, 0xF8, 0xF2), background: RgbColor(0x27, 0x28, 0x22), foreground: RgbColor(0xF8, 0xF8, 0xF2), secondary_accent: RgbColor(0x66, 0xD9, 0xEF), alert: RgbColor(0xF9, 0x26, 0x72), logo_accent: RgbColor(0xE6, 0xDB, 0x74), },
838 },
839 );
840
841 map.insert(
843 "night-owl",
844 ThemeDefinition {
845 id: "night-owl",
846 label: "Night Owl",
847 palette: ThemePalette {
848 primary_accent: RgbColor(0xFF, 0xFF, 0xFF), background: RgbColor(0x00, 0x16, 0x26), foreground: RgbColor(0xFF, 0xFF, 0xFF), secondary_accent: RgbColor(0x82, 0xAA, 0xFF), alert: RgbColor(0xEF, 0x53, 0x50), logo_accent: RgbColor(0xAD, 0xDB, 0x89), },
855 },
856 );
857
858 map.insert(
860 "spacegray",
861 ThemeDefinition {
862 id: "spacegray",
863 label: "Spacegray",
864 palette: ThemePalette {
865 primary_accent: RgbColor(0xB3, 0xB8, 0xC3), background: RgbColor(0x00, 0x00, 0x00), foreground: RgbColor(0xB3, 0xB8, 0xC3), secondary_accent: RgbColor(0x7D, 0x8F, 0xA4), alert: RgbColor(0xB0, 0x4B, 0x57), logo_accent: RgbColor(0xE5, 0xC1, 0x79), },
872 },
873 );
874 map.insert(
875 "spacegray-bright",
876 ThemeDefinition {
877 id: "spacegray-bright",
878 label: "Spacegray Bright",
879 palette: ThemePalette {
880 primary_accent: RgbColor(0xD8, 0xD8, 0xD8), background: RgbColor(0x08, 0x08, 0x08), foreground: RgbColor(0xD8, 0xD8, 0xD8), secondary_accent: RgbColor(0x7B, 0xAE, 0xBC), alert: RgbColor(0xBD, 0x55, 0x53), logo_accent: RgbColor(0xF6, 0xC9, 0x73), },
887 },
888 );
889 map.insert(
890 "spacegray-eighties",
891 ThemeDefinition {
892 id: "spacegray-eighties",
893 label: "Spacegray Eighties",
894 palette: ThemePalette {
895 primary_accent: RgbColor(0xEF, 0xEC, 0xE7), background: RgbColor(0x15, 0x17, 0x1D), foreground: RgbColor(0xEF, 0xEC, 0xE7), secondary_accent: RgbColor(0x54, 0x86, 0xC0), alert: RgbColor(0xEC, 0x5F, 0x67), logo_accent: RgbColor(0xFE, 0xC2, 0x54), },
902 },
903 );
904 map.insert(
905 "spacegray-eighties-dull",
906 ThemeDefinition {
907 id: "spacegray-eighties-dull",
908 label: "Spacegray Eighties Dull",
909 palette: ThemePalette {
910 primary_accent: RgbColor(0xB3, 0xB8, 0xBC), background: RgbColor(0x15, 0x17, 0x1C), foreground: RgbColor(0xB3, 0xB8, 0xBC), secondary_accent: RgbColor(0x7C, 0x8F, 0x9E), alert: RgbColor(0xB2, 0x4A, 0x56), logo_accent: RgbColor(0xC6, 0x73, 0x44), },
917 },
918 );
919
920 map.insert(
922 "atom",
923 ThemeDefinition {
924 id: "atom",
925 label: "Atom",
926 palette: ThemePalette {
927 primary_accent: RgbColor(0xE0, 0xE0, 0xE0), background: RgbColor(0x00, 0x00, 0x00), foreground: RgbColor(0xE0, 0xE0, 0xE0), secondary_accent: RgbColor(0x85, 0xBE, 0xFE), alert: RgbColor(0xFD, 0x5F, 0xF1), logo_accent: RgbColor(0xFF, 0xD7, 0xB1), },
934 },
935 );
936 map.insert(
937 "atom-one-dark",
938 ThemeDefinition {
939 id: "atom-one-dark",
940 label: "Atom One Dark",
941 palette: ThemePalette {
942 primary_accent: RgbColor(0xAB, 0xB2, 0xBF), background: RgbColor(0x21, 0x25, 0x2B), foreground: RgbColor(0xAB, 0xB2, 0xBF), secondary_accent: RgbColor(0x61, 0xAF, 0xEF), alert: RgbColor(0xE0, 0x6C, 0x75), logo_accent: RgbColor(0xE5, 0xC0, 0x7B), },
949 },
950 );
951 map.insert(
952 "atom-one-light",
953 ThemeDefinition {
954 id: "atom-one-light",
955 label: "Atom One Light",
956 palette: ThemePalette {
957 primary_accent: RgbColor(0x3E, 0x3E, 0x3E), background: RgbColor(0xFF, 0xFF, 0xFF), foreground: RgbColor(0x3E, 0x3E, 0x3E), secondary_accent: RgbColor(0x2F, 0x5A, 0xF3), alert: RgbColor(0xDE, 0x3E, 0x35), logo_accent: RgbColor(0xD2, 0xB6, 0x7C), },
964 },
965 );
966
967 map.insert(
969 "man-page",
970 ThemeDefinition {
971 id: "man-page",
972 label: "Man Page",
973 palette: ThemePalette {
974 primary_accent: RgbColor(0xCC, 0xCC, 0xCC), background: RgbColor(0xFF, 0xFF, 0xFF), foreground: RgbColor(0xCC, 0xCC, 0xCC), secondary_accent: RgbColor(0x00, 0x00, 0xB2), alert: RgbColor(0xCC, 0x00, 0x00), logo_accent: RgbColor(0x99, 0x99, 0x00), },
981 },
982 );
983 map.insert(
984 "jetbrains-darcula",
985 ThemeDefinition {
986 id: "jetbrains-darcula",
987 label: "JetBrains Darcula",
988 palette: ThemePalette {
989 primary_accent: RgbColor(0xAD, 0xAD, 0xAD), background: RgbColor(0x1E, 0x1E, 0x1E), foreground: RgbColor(0xAD, 0xAD, 0xAD), secondary_accent: RgbColor(0x45, 0x82, 0xEB), alert: RgbColor(0xFB, 0x54, 0x54), logo_accent: RgbColor(0xC2, 0xC2, 0x00), },
996 },
997 );
998 map.insert(
999 "homebrew",
1000 ThemeDefinition {
1001 id: "homebrew",
1002 label: "Homebrew",
1003 palette: ThemePalette {
1004 primary_accent: RgbColor(0xBF, 0xBF, 0xBF), background: RgbColor(0x00, 0x00, 0x00), foreground: RgbColor(0xBF, 0xBF, 0xBF), secondary_accent: RgbColor(0x00, 0x00, 0xB2), alert: RgbColor(0x99, 0x00, 0x00), logo_accent: RgbColor(0x99, 0x99, 0x00), },
1011 },
1012 );
1013 map.insert(
1014 "framer",
1015 ThemeDefinition {
1016 id: "framer",
1017 label: "Framer",
1018 palette: ThemePalette {
1019 primary_accent: RgbColor(0xCC, 0xCC, 0xCC), background: RgbColor(0x14, 0x14, 0x14), foreground: RgbColor(0xCC, 0xCC, 0xCC), secondary_accent: RgbColor(0x00, 0xAA, 0xFF), alert: RgbColor(0xFF, 0x55, 0x55), logo_accent: RgbColor(0xFF, 0xCC, 0x33), },
1026 },
1027 );
1028 map.insert(
1029 "espresso",
1030 ThemeDefinition {
1031 id: "espresso",
1032 label: "Espresso",
1033 palette: ThemePalette {
1034 primary_accent: RgbColor(0xEE, 0xEE, 0xEF), background: RgbColor(0x35, 0x35, 0x35), foreground: RgbColor(0xEE, 0xEE, 0xEF), secondary_accent: RgbColor(0x6C, 0x99, 0xBB), alert: RgbColor(0xD2, 0x52, 0x52), logo_accent: RgbColor(0xFF, 0xC6, 0x6D), },
1041 },
1042 );
1043 map.insert(
1044 "adventure-time",
1045 ThemeDefinition {
1046 id: "adventure-time",
1047 label: "Adventure Time",
1048 palette: ThemePalette {
1049 primary_accent: RgbColor(0xF8, 0xDC, 0xC0), background: RgbColor(0x05, 0x04, 0x04), foreground: RgbColor(0xF8, 0xDC, 0xC0), secondary_accent: RgbColor(0x0E, 0x49, 0xC6), alert: RgbColor(0xBD, 0x00, 0x13), logo_accent: RgbColor(0xE8, 0x74, 0x1D), },
1056 },
1057 );
1058 map.insert(
1059 "afterglow",
1060 ThemeDefinition {
1061 id: "afterglow",
1062 label: "Afterglow",
1063 palette: ThemePalette {
1064 primary_accent: RgbColor(0xD0, 0xD0, 0xD0), background: RgbColor(0x15, 0x15, 0x15), foreground: RgbColor(0xD0, 0xD0, 0xD0), secondary_accent: RgbColor(0x6C, 0x99, 0xBB), alert: RgbColor(0xAC, 0x41, 0x42), logo_accent: RgbColor(0xE5, 0xB5, 0x67), },
1071 },
1072 );
1073 map.insert(
1074 "apple-classic",
1075 ThemeDefinition {
1076 id: "apple-classic",
1077 label: "Apple Classic",
1078 palette: ThemePalette {
1079 primary_accent: RgbColor(0xC7, 0xC7, 0xC7), background: RgbColor(0x00, 0x00, 0x00), foreground: RgbColor(0xC7, 0xC7, 0xC7), secondary_accent: RgbColor(0x01, 0x25, 0xC8), alert: RgbColor(0xCA, 0x1B, 0x11), logo_accent: RgbColor(0xC7, 0xC5, 0x00), },
1086 },
1087 );
1088 map.insert(
1089 "apple-system-colors",
1090 ThemeDefinition {
1091 id: "apple-system-colors",
1092 label: "Apple System Colors",
1093 palette: ThemePalette {
1094 primary_accent: RgbColor(0x98, 0x98, 0x9D), background: RgbColor(0x1A, 0x1A, 0x1A), foreground: RgbColor(0x98, 0x98, 0x9D), secondary_accent: RgbColor(0x08, 0x69, 0xC9), alert: RgbColor(0xCC, 0x37, 0x2E), logo_accent: RgbColor(0xCD, 0xAB, 0x1E), },
1101 },
1102 );
1103 map.insert(
1104 "apple-system-colors-light",
1105 ThemeDefinition {
1106 id: "apple-system-colors-light",
1107 label: "Apple System Colors Light",
1108 palette: ThemePalette {
1109 primary_accent: RgbColor(0x1A, 0x1A, 0x1A), background: RgbColor(0xFF, 0xFF, 0xFF), foreground: RgbColor(0x1A, 0x1A, 0x1A), secondary_accent: RgbColor(0x2E, 0x68, 0xC5), alert: RgbColor(0xBC, 0x44, 0x37), logo_accent: RgbColor(0xC8, 0xAD, 0x3A), },
1116 },
1117 );
1118
1119 map.insert(
1121 "vitesse-black",
1122 ThemeDefinition {
1123 id: "vitesse-black",
1124 label: "Vitesse Black",
1125 palette: ThemePalette {
1126 primary_accent: RgbColor(0xDB, 0xD7, 0xCA), background: RgbColor(0x00, 0x00, 0x00), foreground: RgbColor(0xDB, 0xD7, 0xCA), secondary_accent: RgbColor(0x4D, 0x93, 0x75), alert: RgbColor(0xCB, 0x76, 0x76), logo_accent: RgbColor(0xDB, 0xD7, 0xCA), },
1133 },
1134 );
1135 map.insert(
1136 "vitesse-dark",
1137 ThemeDefinition {
1138 id: "vitesse-dark",
1139 label: "Vitesse Dark",
1140 palette: ThemePalette {
1141 primary_accent: RgbColor(0xDB, 0xD7, 0xCA), background: RgbColor(0x12, 0x12, 0x12), foreground: RgbColor(0xDB, 0xD7, 0xCA), secondary_accent: RgbColor(0x4D, 0x93, 0x75), alert: RgbColor(0xCB, 0x76, 0x76), logo_accent: RgbColor(0xDB, 0xD7, 0xCA), },
1148 },
1149 );
1150 map.insert(
1151 "vitesse-dark-soft",
1152 ThemeDefinition {
1153 id: "vitesse-dark-soft",
1154 label: "Vitesse Dark Soft",
1155 palette: ThemePalette {
1156 primary_accent: RgbColor(0xDB, 0xD7, 0xCA), background: RgbColor(0x22, 0x22, 0x22), foreground: RgbColor(0xDB, 0xD7, 0xCA), secondary_accent: RgbColor(0x4D, 0x93, 0x75), alert: RgbColor(0xCB, 0x76, 0x76), logo_accent: RgbColor(0xDB, 0xD7, 0xCA), },
1163 },
1164 );
1165 map.insert(
1166 "vitesse-light",
1167 ThemeDefinition {
1168 id: "vitesse-light",
1169 label: "Vitesse Light",
1170 palette: ThemePalette {
1171 primary_accent: RgbColor(0x39, 0x3A, 0x34), background: RgbColor(0xFF, 0xFF, 0xFF), foreground: RgbColor(0x39, 0x3A, 0x34), secondary_accent: RgbColor(0x1C, 0x6B, 0x48), alert: RgbColor(0xAB, 0x59, 0x59), logo_accent: RgbColor(0x39, 0x3A, 0x34), },
1178 },
1179 );
1180 map.insert(
1181 "vitesse-light-soft",
1182 ThemeDefinition {
1183 id: "vitesse-light-soft",
1184 label: "Vitesse Light Soft",
1185 palette: ThemePalette {
1186 primary_accent: RgbColor(0x39, 0x3A, 0x34), background: RgbColor(0xF1, 0xF0, 0xE9), foreground: RgbColor(0x39, 0x3A, 0x34), secondary_accent: RgbColor(0x1C, 0x6B, 0x48), alert: RgbColor(0xAB, 0x59, 0x59), logo_accent: RgbColor(0x39, 0x3A, 0x34), },
1193 },
1194 );
1195
1196 map.insert(
1197 "mono",
1198 ThemeDefinition {
1199 id: "mono",
1200 label: "Mono",
1201 palette: ThemePalette {
1202 primary_accent: RgbColor(0xFF, 0xFF, 0xFF), background: RgbColor(0x00, 0x00, 0x00), foreground: RgbColor(0xDB, 0xD7, 0xCA), secondary_accent: RgbColor(0xBB, 0xBB, 0xBB), alert: RgbColor(0xFF, 0xFF, 0xFF), logo_accent: RgbColor(0xFF, 0xFF, 0xFF), },
1209 },
1210 );
1211
1212 register_catppuccin_themes(&mut map);
1213 map
1214});
1215
1216fn register_catppuccin_themes(map: &mut HashMap<&'static str, ThemeDefinition>) {
1217 for &flavor_kind in CATPPUCCIN_FLAVORS {
1218 let flavor = flavor_kind.flavor();
1219 let theme_definition = ThemeDefinition {
1220 id: flavor_kind.id(),
1221 label: flavor_kind.label(),
1222 palette: catppuccin_palette(flavor),
1223 };
1224 map.insert(flavor_kind.id(), theme_definition);
1225 }
1226}
1227
1228fn catppuccin_palette(flavor: catppuccin::Flavor) -> ThemePalette {
1229 let colors = flavor.colors;
1230 ThemePalette {
1231 primary_accent: catppuccin_rgb(colors.lavender),
1232 background: catppuccin_rgb(colors.base),
1233 foreground: catppuccin_rgb(colors.text),
1234 secondary_accent: catppuccin_rgb(colors.sapphire),
1235 alert: catppuccin_rgb(colors.red),
1236 logo_accent: catppuccin_rgb(colors.peach),
1237 }
1238}
1239
1240fn catppuccin_rgb(color: catppuccin::Color) -> RgbColor {
1241 RgbColor(color.rgb.r, color.rgb.g, color.rgb.b)
1242}
1243
1244static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
1245 let default = match REGISTRY.get(DEFAULT_THEME_ID) {
1246 Some(theme) => theme,
1247 None => panic!("default theme must exist"),
1248 };
1249 let styles = default.palette.build_styles();
1250 RwLock::new(ActiveTheme {
1251 id: default.id.to_string(),
1252 label: default.label.to_string(),
1253 palette: default.palette.clone(),
1254 styles,
1255 })
1256});
1257
1258pub fn set_active_theme(theme_id: &str) -> Result<()> {
1260 let id_lc = theme_id.trim().to_lowercase();
1261 let theme = REGISTRY
1262 .get(id_lc.as_str())
1263 .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
1264
1265 let styles = theme.palette.build_styles();
1266 let mut guard = ACTIVE.write();
1267 guard.id = theme.id.to_string();
1268 guard.label = theme.label.to_string();
1269 guard.palette = theme.palette.clone();
1270 guard.styles = styles;
1271 Ok(())
1272}
1273
1274pub fn active_theme_id() -> String {
1276 ACTIVE.read().id.clone()
1277}
1278
1279pub fn active_theme_label() -> String {
1281 ACTIVE.read().label.clone()
1282}
1283
1284pub fn active_styles() -> ThemeStyles {
1286 ACTIVE.read().styles.clone()
1287}
1288
1289pub fn banner_color() -> RgbColor {
1291 let guard = ACTIVE.read();
1292 let accent = guard.palette.logo_accent;
1293 let secondary = guard.palette.secondary_accent;
1294 let background = guard.palette.background;
1295 drop(guard);
1296
1297 let min_contrast = get_minimum_contrast();
1298 let candidate = lighten(accent, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO);
1299 ensure_contrast(
1300 candidate,
1301 background,
1302 min_contrast,
1303 &[
1304 lighten(accent, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
1305 lighten(
1306 secondary,
1307 ui::THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO,
1308 ),
1309 accent,
1310 ],
1311 )
1312}
1313
1314pub fn banner_style() -> Style {
1316 let accent = banner_color();
1317 Style::new().fg_color(Some(Color::Rgb(accent))).bold()
1318}
1319
1320pub fn logo_accent_color() -> RgbColor {
1322 ACTIVE.read().palette.logo_accent
1323}
1324
1325pub fn available_themes() -> Vec<&'static str> {
1327 let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
1328 keys.sort();
1329 keys
1330}
1331
1332pub fn theme_label(theme_id: &str) -> Option<&'static str> {
1334 REGISTRY.get(theme_id).map(|definition| definition.label)
1335}
1336
1337fn suite_id_for_theme(theme_id: &str) -> Option<&'static str> {
1338 if theme_id.starts_with("catppuccin-") {
1339 Some("catppuccin")
1340 } else if theme_id.starts_with("vitesse-") {
1341 Some("vitesse")
1342 } else if theme_id.starts_with("ciapre-") {
1343 Some("ciapre")
1344 } else if theme_id == "mono" {
1345 Some("mono")
1346 } else {
1347 None
1348 }
1349}
1350
1351fn suite_label(suite_id: &str) -> Option<&'static str> {
1352 match suite_id {
1353 "catppuccin" => Some("Catppuccin"),
1354 "vitesse" => Some("Vitesse"),
1355 "ciapre" => Some("Ciapre"),
1356 "mono" => Some("Mono"),
1357 _ => None,
1358 }
1359}
1360
1361pub fn theme_suite_id(theme_id: &str) -> Option<&'static str> {
1363 suite_id_for_theme(theme_id)
1364}
1365
1366pub fn theme_suite_label(theme_id: &str) -> Option<&'static str> {
1368 suite_id_for_theme(theme_id).and_then(suite_label)
1369}
1370
1371pub fn available_theme_suites() -> Vec<ThemeSuite> {
1373 const ORDER: &[&str] = &["ciapre", "vitesse", "catppuccin", "mono"];
1374
1375 ORDER
1376 .iter()
1377 .filter_map(|suite_id| {
1378 let mut theme_ids: Vec<&'static str> = available_themes()
1379 .into_iter()
1380 .filter(|theme_id| suite_id_for_theme(theme_id) == Some(*suite_id))
1381 .collect();
1382 if theme_ids.is_empty() {
1383 return None;
1384 }
1385 theme_ids.sort_unstable();
1386 let label = suite_label(suite_id)?;
1387 Some(ThemeSuite {
1388 id: suite_id,
1389 label,
1390 theme_ids,
1391 })
1392 })
1393 .collect()
1394}
1395
1396fn relative_luminance(color: RgbColor) -> f64 {
1397 fn channel(value: u8) -> f64 {
1398 let c = (value as f64) / 255.0;
1399 if c <= ui::THEME_RELATIVE_LUMINANCE_CUTOFF {
1400 c / ui::THEME_RELATIVE_LUMINANCE_LOW_FACTOR
1401 } else {
1402 ((c + ui::THEME_RELATIVE_LUMINANCE_OFFSET)
1403 / (1.0 + ui::THEME_RELATIVE_LUMINANCE_OFFSET))
1404 .powf(ui::THEME_RELATIVE_LUMINANCE_EXPONENT)
1405 }
1406 }
1407 let r = channel(color.0);
1408 let g = channel(color.1);
1409 let b = channel(color.2);
1410 ui::THEME_RED_LUMINANCE_COEFFICIENT * r
1411 + ui::THEME_GREEN_LUMINANCE_COEFFICIENT * g
1412 + ui::THEME_BLUE_LUMINANCE_COEFFICIENT * b
1413}
1414
1415fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
1416 let fg = relative_luminance(foreground);
1417 let bg = relative_luminance(background);
1418 let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
1419 (lighter + ui::THEME_CONTRAST_RATIO_OFFSET) / (darker + ui::THEME_CONTRAST_RATIO_OFFSET)
1420}
1421
1422fn darken(color: RgbColor, ratio: f64) -> RgbColor {
1423 mix(color, RgbColor(0, 0, 0), ratio)
1424}
1425
1426fn adjust_luminance_to_target(color: RgbColor, target: f64) -> RgbColor {
1427 let current = relative_luminance(color);
1428 if (current - target).abs() < 1e-3 {
1429 return color;
1430 }
1431
1432 if current < target {
1433 let denom = (1.0 - current).max(1e-6);
1435 let ratio = ((target - current) / denom).clamp(0.0, 1.0);
1436 lighten(color, ratio)
1437 } else {
1438 let denom = current.max(1e-6);
1440 let ratio = ((current - target) / denom).clamp(0.0, 1.0);
1441 darken(color, ratio)
1442 }
1443}
1444
1445fn balance_text_luminance(color: RgbColor, background: RgbColor, min_contrast: f64) -> RgbColor {
1446 let bg_luminance = relative_luminance(background);
1447 let mut candidate = color;
1448 let current = relative_luminance(candidate);
1449 if bg_luminance < 0.5 {
1450 if current < MIN_DARK_BG_TEXT_LUMINANCE {
1451 candidate = adjust_luminance_to_target(candidate, MIN_DARK_BG_TEXT_LUMINANCE);
1452 } else if current > MAX_DARK_BG_TEXT_LUMINANCE {
1453 candidate = adjust_luminance_to_target(candidate, MAX_DARK_BG_TEXT_LUMINANCE);
1454 }
1455 } else if current > MAX_LIGHT_BG_TEXT_LUMINANCE {
1456 candidate = adjust_luminance_to_target(candidate, MAX_LIGHT_BG_TEXT_LUMINANCE);
1457 }
1458
1459 ensure_contrast(candidate, background, min_contrast, &[color])
1460}
1461
1462fn ensure_contrast(
1463 candidate: RgbColor,
1464 background: RgbColor,
1465 min_ratio: f64,
1466 fallbacks: &[RgbColor],
1467) -> RgbColor {
1468 if contrast_ratio(candidate, background) >= min_ratio {
1469 return candidate;
1470 }
1471 for &fallback in fallbacks {
1472 if contrast_ratio(fallback, background) >= min_ratio {
1473 return fallback;
1474 }
1475 }
1476
1477 let black = RgbColor(0, 0, 0);
1479 let white = RgbColor(255, 255, 255);
1480 if contrast_ratio(black, background) >= contrast_ratio(white, background) {
1481 black
1482 } else {
1483 white
1484 }
1485}
1486
1487pub(crate) fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
1488 let ratio = ratio.clamp(ui::THEME_MIX_RATIO_MIN, ui::THEME_MIX_RATIO_MAX);
1489 let blend = |c: u8, t: u8| -> u8 {
1490 let c = c as f64;
1491 let t = t as f64;
1492 ((c + (t - c) * ratio).round()).clamp(ui::THEME_BLEND_CLAMP_MIN, ui::THEME_BLEND_CLAMP_MAX)
1493 as u8
1494 };
1495 RgbColor(
1496 blend(color.0, target.0),
1497 blend(color.1, target.1),
1498 blend(color.2, target.2),
1499 )
1500}
1501
1502fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
1503 mix(
1504 color,
1505 RgbColor(
1506 ui::THEME_COLOR_WHITE_RED,
1507 ui::THEME_COLOR_WHITE_GREEN,
1508 ui::THEME_COLOR_WHITE_BLUE,
1509 ),
1510 ratio,
1511 )
1512}
1513
1514pub fn resolve_theme(preferred: Option<String>) -> String {
1516 preferred
1517 .and_then(|candidate| {
1518 let trimmed = candidate.trim().to_lowercase();
1519 if trimmed.is_empty() {
1520 None
1521 } else if REGISTRY.contains_key(trimmed.as_str()) {
1522 Some(trimmed)
1523 } else {
1524 None
1525 }
1526 })
1527 .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
1528}
1529
1530pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
1532 REGISTRY
1533 .get(theme_id)
1534 .map(|definition| definition.label)
1535 .context("Theme not found")
1536}
1537
1538pub fn rebuild_active_styles() {
1541 let mut guard = ACTIVE.write();
1542 guard.styles = guard.palette.build_styles();
1543}
1544
1545#[derive(Debug, Clone)]
1547pub struct ThemeValidationResult {
1548 pub is_valid: bool,
1550 pub warnings: Vec<String>,
1552 pub errors: Vec<String>,
1554}
1555
1556pub fn validate_theme_contrast(theme_id: &str) -> ThemeValidationResult {
1559 let mut result = ThemeValidationResult {
1560 is_valid: true,
1561 warnings: Vec::new(),
1562 errors: Vec::new(),
1563 };
1564
1565 let theme = match REGISTRY.get(theme_id) {
1566 Some(t) => t,
1567 None => {
1568 result.is_valid = false;
1569 result.errors.push(format!("Unknown theme: {}", theme_id));
1570 return result;
1571 }
1572 };
1573
1574 let palette = &theme.palette;
1575 let bg = palette.background;
1576 let min_contrast = get_minimum_contrast();
1577
1578 let checks = [
1580 ("foreground", palette.foreground),
1581 ("primary_accent", palette.primary_accent),
1582 ("secondary_accent", palette.secondary_accent),
1583 ("alert", palette.alert),
1584 ("logo_accent", palette.logo_accent),
1585 ];
1586
1587 for (name, color) in checks {
1588 let ratio = contrast_ratio(color, bg);
1589 if ratio < min_contrast {
1590 result.warnings.push(format!(
1591 "{} ({:02X}{:02X}{:02X}) has contrast ratio {:.2} < {:.1} against background",
1592 name, color.0, color.1, color.2, ratio, min_contrast
1593 ));
1594 }
1595 }
1596
1597 result
1598}
1599
1600pub fn theme_matches_terminal_scheme(theme_id: &str) -> bool {
1603 use crate::utils::ansi_capabilities::ColorScheme;
1604 use crate::utils::ansi_capabilities::detect_color_scheme;
1605
1606 let scheme = detect_color_scheme();
1607 let theme_is_light = is_light_theme(theme_id);
1608
1609 match scheme {
1610 ColorScheme::Light => theme_is_light,
1611 ColorScheme::Dark | ColorScheme::Unknown => !theme_is_light,
1612 }
1613}
1614
1615pub fn is_light_theme(theme_id: &str) -> bool {
1617 REGISTRY
1618 .get(theme_id)
1619 .map(|theme| {
1620 let bg = theme.palette.background;
1621 let luminance = relative_luminance(bg);
1622 luminance > 0.5
1624 })
1625 .unwrap_or(false)
1626}
1627
1628pub fn suggest_theme_for_terminal() -> &'static str {
1631 use crate::utils::ansi_capabilities::ColorScheme;
1632 use crate::utils::ansi_capabilities::detect_color_scheme;
1633
1634 match detect_color_scheme() {
1635 ColorScheme::Light => "vitesse-light",
1636 ColorScheme::Dark | ColorScheme::Unknown => DEFAULT_THEME_ID,
1637 }
1638}
1639
1640pub fn get_syntax_theme_for_ui_theme(ui_theme: &str) -> &'static str {
1658 match ui_theme.to_lowercase().as_str() {
1659 "ayu" => "ayu-dark",
1661 "ayu-mirage" => "ayu-mirage",
1662
1663 "catppuccin-latte" => "catppuccin-latte",
1665 "catppuccin-frappe" => "catppuccin-frappe",
1666 "catppuccin-macchiato" => "catppuccin-macchiato",
1667 "catppuccin-mocha" => "catppuccin-mocha",
1668
1669 "solarized-dark" | "solarized-dark-hc" => "Solarized (dark)",
1671 "solarized-light" => "Solarized (light)",
1672
1673 "gruvbox-dark" | "gruvbox-dark-hard" => "gruvbox-dark",
1675 "gruvbox-light" | "gruvbox-light-hard" => "gruvbox-light",
1676 "gruvbox-material" | "gruvbox-material-dark" => "gruvbox-dark",
1677 "gruvbox-material-light" => "gruvbox-light",
1678
1679 "tomorrow" => "Tomorrow",
1681 "tomorrow-night" => "Tomorrow Night",
1682 "tomorrow-night-blue" => "Tomorrow Night Blue",
1683 "tomorrow-night-bright" => "Tomorrow Night Bright",
1684 "tomorrow-night-eighties" => "Tomorrow Night Eighties",
1685 "tomorrow-night-burns" => "Tomorrow Night",
1686
1687 "github-dark" => "GitHub Dark",
1689 "github" => "GitHub",
1690
1691 "atom-one-dark" => "OneDark",
1693 "atom-one-light" => "OneLight",
1694 "atom" => "base16-ocean.dark",
1695
1696 "spacegray" | "spacegray-bright" | "spacegray-eighties" | "spacegray-eighties-dull" => {
1698 "base16-ocean.dark"
1699 }
1700
1701 "material-ocean" | "material-dark" | "material" => "Material Dark",
1703
1704 "dracula" => "Dracula",
1706 "monokai-classic" => "monokai-classic",
1707 "night-owl" => "Night Owl",
1708 "zenburn" => "Zenburn",
1709
1710 "jetbrains-darcula" => "base16-ocean.dark",
1712 "man-page" => "base16-ocean.dark",
1713 "homebrew" => "base16-ocean.dark",
1714 "framer" => "base16-ocean.dark",
1715 "espresso" => "base16-ocean.dark",
1716 "adventure-time" => "base16-ocean.dark",
1717 "afterglow" => "base16-ocean.dark",
1718 "apple-classic" => "base16-ocean.dark",
1719 "apple-system-colors" => "base16-ocean.dark",
1720
1721 "apple-system-colors-light" => "base16-ocean.light",
1723 "vitesse-light" | "vitesse-light-soft" => "base16-ocean.light",
1724
1725 "ciapre" | "ciapre-dark" | "ciapre-blue" => "base16-ocean.dark",
1727 "vitesse-black" | "vitesse-dark" | "vitesse-dark-soft" => "base16-ocean.dark",
1728 "mono" => "base16-ocean.dark",
1729 "ansi-classic" => "base16-ocean.dark",
1730
1731 _ => "base16-ocean.dark",
1733 }
1734}
1735
1736pub fn get_active_syntax_theme() -> &'static str {
1739 get_syntax_theme_for_ui_theme(&active_theme_id())
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744 use super::*;
1745
1746 #[test]
1747 fn test_mono_theme_exists() {
1748 let result = ensure_theme("mono");
1749 assert!(result.is_ok(), "Mono theme should be registered");
1750 assert_eq!(result.unwrap(), "Mono");
1751 }
1752
1753 #[test]
1754 fn test_mono_theme_contrast() {
1755 let result = validate_theme_contrast("mono");
1756 assert!(result.errors.is_empty(), "Mono theme should have no errors");
1758 assert!(result.is_valid);
1760 }
1761
1762 #[test]
1763 fn test_all_themes_resolvable() {
1764 for id in available_themes() {
1765 assert!(
1766 ensure_theme(id).is_ok(),
1767 "Theme {} should be resolvable",
1768 id
1769 );
1770 }
1771 }
1772
1773 #[test]
1774 fn test_available_theme_suites_contains_expected_groups() {
1775 let suites = available_theme_suites();
1776 let suite_ids: Vec<&str> = suites.iter().map(|suite| suite.id).collect();
1777 assert!(suite_ids.contains(&"ciapre"));
1778 assert!(suite_ids.contains(&"vitesse"));
1779 assert!(suite_ids.contains(&"catppuccin"));
1780 assert!(suite_ids.contains(&"mono"));
1781 }
1782
1783 #[test]
1784 fn test_theme_suite_resolution() {
1785 assert_eq!(theme_suite_id("catppuccin-mocha"), Some("catppuccin"));
1786 assert_eq!(theme_suite_id("vitesse-light"), Some("vitesse"));
1787 assert_eq!(theme_suite_id("ciapre-dark"), Some("ciapre"));
1788 assert_eq!(theme_suite_id("mono"), Some("mono"));
1789 assert_eq!(theme_suite_id("unknown-theme"), None);
1790 }
1791
1792 #[test]
1793 fn test_all_themes_have_readable_foreground_and_accents() {
1794 let min_contrast = get_minimum_contrast();
1795 for definition in REGISTRY.values() {
1796 let styles = definition.palette.build_styles_with_contrast(min_contrast);
1797 let bg = definition.palette.background;
1798
1799 for (name, color) in [
1800 ("foreground", style_rgb(styles.output)),
1801 ("primary", style_rgb(styles.primary)),
1802 ("secondary", style_rgb(styles.secondary)),
1803 ("user", style_rgb(styles.user)),
1804 ("response", style_rgb(styles.response)),
1805 ] {
1806 let color = color
1807 .unwrap_or_else(|| panic!("{} missing fg color for {}", name, definition.id));
1808 let ratio = contrast_ratio(color, bg);
1809 assert!(
1810 ratio >= min_contrast,
1811 "theme={} style={} contrast {:.2} < {:.1}",
1812 definition.id,
1813 name,
1814 ratio,
1815 min_contrast
1816 );
1817
1818 let luminance = relative_luminance(color);
1819 if relative_luminance(bg) < 0.5 {
1820 assert!(
1821 (MIN_DARK_BG_TEXT_LUMINANCE..=MAX_DARK_BG_TEXT_LUMINANCE)
1822 .contains(&luminance),
1823 "theme={} style={} luminance {:.3} outside dark-theme readability bounds",
1824 definition.id,
1825 name,
1826 luminance
1827 );
1828 } else {
1829 assert!(
1830 luminance <= MAX_LIGHT_BG_TEXT_LUMINANCE,
1831 "theme={} style={} luminance {:.3} too bright for light theme",
1832 definition.id,
1833 name,
1834 luminance
1835 );
1836 }
1837 }
1838 }
1839 }
1840
1841 fn style_rgb(style: Style) -> Option<RgbColor> {
1842 match style.get_fg_color() {
1843 Some(Color::Rgb(rgb)) => Some(rgb),
1844 _ => None,
1845 }
1846 }
1847}