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, 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 = REGISTRY
1246 .get(DEFAULT_THEME_ID)
1247 .expect("default theme must exist");
1248 let styles = default.palette.build_styles();
1249 RwLock::new(ActiveTheme {
1250 id: default.id.to_string(),
1251 label: default.label.to_string(),
1252 palette: default.palette.clone(),
1253 styles,
1254 })
1255});
1256
1257pub fn set_active_theme(theme_id: &str) -> Result<()> {
1259 let id_lc = theme_id.trim().to_lowercase();
1260 let theme = REGISTRY
1261 .get(id_lc.as_str())
1262 .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
1263
1264 let styles = theme.palette.build_styles();
1265 let mut guard = ACTIVE.write();
1266 guard.id = theme.id.to_string();
1267 guard.label = theme.label.to_string();
1268 guard.palette = theme.palette.clone();
1269 guard.styles = styles;
1270 Ok(())
1271}
1272
1273pub fn active_theme_id() -> String {
1275 ACTIVE.read().id.clone()
1276}
1277
1278pub fn active_theme_label() -> String {
1280 ACTIVE.read().label.clone()
1281}
1282
1283pub fn active_styles() -> ThemeStyles {
1285 ACTIVE.read().styles.clone()
1286}
1287
1288pub fn banner_color() -> RgbColor {
1290 let guard = ACTIVE.read();
1291 let accent = guard.palette.logo_accent;
1292 let secondary = guard.palette.secondary_accent;
1293 let background = guard.palette.background;
1294 drop(guard);
1295
1296 let min_contrast = get_minimum_contrast();
1297 let candidate = lighten(accent, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO);
1298 ensure_contrast(
1299 candidate,
1300 background,
1301 min_contrast,
1302 &[
1303 lighten(accent, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
1304 lighten(
1305 secondary,
1306 ui::THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO,
1307 ),
1308 accent,
1309 ],
1310 )
1311}
1312
1313pub fn banner_style() -> Style {
1315 let accent = banner_color();
1316 Style::new().fg_color(Some(Color::Rgb(accent))).bold()
1317}
1318
1319pub fn logo_accent_color() -> RgbColor {
1321 ACTIVE.read().palette.logo_accent
1322}
1323
1324pub fn available_themes() -> Vec<&'static str> {
1326 let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
1327 keys.sort();
1328 keys
1329}
1330
1331pub fn theme_label(theme_id: &str) -> Option<&'static str> {
1333 REGISTRY.get(theme_id).map(|definition| definition.label)
1334}
1335
1336fn suite_id_for_theme(theme_id: &str) -> Option<&'static str> {
1337 if theme_id.starts_with("catppuccin-") {
1338 Some("catppuccin")
1339 } else if theme_id.starts_with("vitesse-") {
1340 Some("vitesse")
1341 } else if theme_id.starts_with("ciapre-") {
1342 Some("ciapre")
1343 } else if theme_id == "mono" {
1344 Some("mono")
1345 } else {
1346 None
1347 }
1348}
1349
1350fn suite_label(suite_id: &str) -> Option<&'static str> {
1351 match suite_id {
1352 "catppuccin" => Some("Catppuccin"),
1353 "vitesse" => Some("Vitesse"),
1354 "ciapre" => Some("Ciapre"),
1355 "mono" => Some("Mono"),
1356 _ => None,
1357 }
1358}
1359
1360pub fn theme_suite_id(theme_id: &str) -> Option<&'static str> {
1362 suite_id_for_theme(theme_id)
1363}
1364
1365pub fn theme_suite_label(theme_id: &str) -> Option<&'static str> {
1367 suite_id_for_theme(theme_id).and_then(suite_label)
1368}
1369
1370pub fn available_theme_suites() -> Vec<ThemeSuite> {
1372 const ORDER: &[&str] = &["ciapre", "vitesse", "catppuccin", "mono"];
1373
1374 ORDER
1375 .iter()
1376 .filter_map(|suite_id| {
1377 let mut theme_ids: Vec<&'static str> = available_themes()
1378 .into_iter()
1379 .filter(|theme_id| suite_id_for_theme(theme_id) == Some(*suite_id))
1380 .collect();
1381 if theme_ids.is_empty() {
1382 return None;
1383 }
1384 theme_ids.sort_unstable();
1385 Some(ThemeSuite {
1386 id: suite_id,
1387 label: suite_label(suite_id).expect("known suite id must have label"),
1388 theme_ids,
1389 })
1390 })
1391 .collect()
1392}
1393
1394fn relative_luminance(color: RgbColor) -> f64 {
1395 fn channel(value: u8) -> f64 {
1396 let c = (value as f64) / 255.0;
1397 if c <= ui::THEME_RELATIVE_LUMINANCE_CUTOFF {
1398 c / ui::THEME_RELATIVE_LUMINANCE_LOW_FACTOR
1399 } else {
1400 ((c + ui::THEME_RELATIVE_LUMINANCE_OFFSET)
1401 / (1.0 + ui::THEME_RELATIVE_LUMINANCE_OFFSET))
1402 .powf(ui::THEME_RELATIVE_LUMINANCE_EXPONENT)
1403 }
1404 }
1405 let r = channel(color.0);
1406 let g = channel(color.1);
1407 let b = channel(color.2);
1408 ui::THEME_RED_LUMINANCE_COEFFICIENT * r
1409 + ui::THEME_GREEN_LUMINANCE_COEFFICIENT * g
1410 + ui::THEME_BLUE_LUMINANCE_COEFFICIENT * b
1411}
1412
1413fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
1414 let fg = relative_luminance(foreground);
1415 let bg = relative_luminance(background);
1416 let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
1417 (lighter + ui::THEME_CONTRAST_RATIO_OFFSET) / (darker + ui::THEME_CONTRAST_RATIO_OFFSET)
1418}
1419
1420fn darken(color: RgbColor, ratio: f64) -> RgbColor {
1421 mix(color, RgbColor(0, 0, 0), ratio)
1422}
1423
1424fn adjust_luminance_to_target(color: RgbColor, target: f64) -> RgbColor {
1425 let current = relative_luminance(color);
1426 if (current - target).abs() < 1e-3 {
1427 return color;
1428 }
1429
1430 if current < target {
1431 let denom = (1.0 - current).max(1e-6);
1433 let ratio = ((target - current) / denom).clamp(0.0, 1.0);
1434 lighten(color, ratio)
1435 } else {
1436 let denom = current.max(1e-6);
1438 let ratio = ((current - target) / denom).clamp(0.0, 1.0);
1439 darken(color, ratio)
1440 }
1441}
1442
1443fn balance_text_luminance(color: RgbColor, background: RgbColor, min_contrast: f64) -> RgbColor {
1444 let bg_luminance = relative_luminance(background);
1445 let mut candidate = color;
1446 let current = relative_luminance(candidate);
1447 if bg_luminance < 0.5 {
1448 if current < MIN_DARK_BG_TEXT_LUMINANCE {
1449 candidate = adjust_luminance_to_target(candidate, MIN_DARK_BG_TEXT_LUMINANCE);
1450 } else if current > MAX_DARK_BG_TEXT_LUMINANCE {
1451 candidate = adjust_luminance_to_target(candidate, MAX_DARK_BG_TEXT_LUMINANCE);
1452 }
1453 } else if current > MAX_LIGHT_BG_TEXT_LUMINANCE {
1454 candidate = adjust_luminance_to_target(candidate, MAX_LIGHT_BG_TEXT_LUMINANCE);
1455 }
1456
1457 ensure_contrast(candidate, background, min_contrast, &[color])
1458}
1459
1460fn ensure_contrast(
1461 candidate: RgbColor,
1462 background: RgbColor,
1463 min_ratio: f64,
1464 fallbacks: &[RgbColor],
1465) -> RgbColor {
1466 if contrast_ratio(candidate, background) >= min_ratio {
1467 return candidate;
1468 }
1469 for &fallback in fallbacks {
1470 if contrast_ratio(fallback, background) >= min_ratio {
1471 return fallback;
1472 }
1473 }
1474
1475 let black = RgbColor(0, 0, 0);
1477 let white = RgbColor(255, 255, 255);
1478 if contrast_ratio(black, background) >= contrast_ratio(white, background) {
1479 black
1480 } else {
1481 white
1482 }
1483}
1484
1485pub(crate) fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
1486 let ratio = ratio.clamp(ui::THEME_MIX_RATIO_MIN, ui::THEME_MIX_RATIO_MAX);
1487 let blend = |c: u8, t: u8| -> u8 {
1488 let c = c as f64;
1489 let t = t as f64;
1490 ((c + (t - c) * ratio).round()).clamp(ui::THEME_BLEND_CLAMP_MIN, ui::THEME_BLEND_CLAMP_MAX)
1491 as u8
1492 };
1493 RgbColor(
1494 blend(color.0, target.0),
1495 blend(color.1, target.1),
1496 blend(color.2, target.2),
1497 )
1498}
1499
1500fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
1501 mix(
1502 color,
1503 RgbColor(
1504 ui::THEME_COLOR_WHITE_RED,
1505 ui::THEME_COLOR_WHITE_GREEN,
1506 ui::THEME_COLOR_WHITE_BLUE,
1507 ),
1508 ratio,
1509 )
1510}
1511
1512pub fn resolve_theme(preferred: Option<String>) -> String {
1514 preferred
1515 .and_then(|candidate| {
1516 let trimmed = candidate.trim().to_lowercase();
1517 if trimmed.is_empty() {
1518 None
1519 } else if REGISTRY.contains_key(trimmed.as_str()) {
1520 Some(trimmed)
1521 } else {
1522 None
1523 }
1524 })
1525 .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
1526}
1527
1528pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
1530 REGISTRY
1531 .get(theme_id)
1532 .map(|definition| definition.label)
1533 .context("Theme not found")
1534}
1535
1536pub fn rebuild_active_styles() {
1539 let mut guard = ACTIVE.write();
1540 guard.styles = guard.palette.build_styles();
1541}
1542
1543#[derive(Debug, Clone)]
1545pub struct ThemeValidationResult {
1546 pub is_valid: bool,
1548 pub warnings: Vec<String>,
1550 pub errors: Vec<String>,
1552}
1553
1554pub fn validate_theme_contrast(theme_id: &str) -> ThemeValidationResult {
1557 let mut result = ThemeValidationResult {
1558 is_valid: true,
1559 warnings: Vec::new(),
1560 errors: Vec::new(),
1561 };
1562
1563 let theme = match REGISTRY.get(theme_id) {
1564 Some(t) => t,
1565 None => {
1566 result.is_valid = false;
1567 result.errors.push(format!("Unknown theme: {}", theme_id));
1568 return result;
1569 }
1570 };
1571
1572 let palette = &theme.palette;
1573 let bg = palette.background;
1574 let min_contrast = get_minimum_contrast();
1575
1576 let checks = [
1578 ("foreground", palette.foreground),
1579 ("primary_accent", palette.primary_accent),
1580 ("secondary_accent", palette.secondary_accent),
1581 ("alert", palette.alert),
1582 ("logo_accent", palette.logo_accent),
1583 ];
1584
1585 for (name, color) in checks {
1586 let ratio = contrast_ratio(color, bg);
1587 if ratio < min_contrast {
1588 result.warnings.push(format!(
1589 "{} ({:02X}{:02X}{:02X}) has contrast ratio {:.2} < {:.1} against background",
1590 name, color.0, color.1, color.2, ratio, min_contrast
1591 ));
1592 }
1593 }
1594
1595 result
1596}
1597
1598pub fn theme_matches_terminal_scheme(theme_id: &str) -> bool {
1601 use crate::utils::ansi_capabilities::ColorScheme;
1602 use crate::utils::ansi_capabilities::detect_color_scheme;
1603
1604 let scheme = detect_color_scheme();
1605 let theme_is_light = is_light_theme(theme_id);
1606
1607 match scheme {
1608 ColorScheme::Light => theme_is_light,
1609 ColorScheme::Dark | ColorScheme::Unknown => !theme_is_light,
1610 }
1611}
1612
1613pub fn is_light_theme(theme_id: &str) -> bool {
1615 REGISTRY
1616 .get(theme_id)
1617 .map(|theme| {
1618 let bg = theme.palette.background;
1619 let luminance = relative_luminance(bg);
1620 luminance > 0.5
1622 })
1623 .unwrap_or(false)
1624}
1625
1626pub fn suggest_theme_for_terminal() -> &'static str {
1629 use crate::utils::ansi_capabilities::ColorScheme;
1630 use crate::utils::ansi_capabilities::detect_color_scheme;
1631
1632 match detect_color_scheme() {
1633 ColorScheme::Light => "vitesse-light",
1634 ColorScheme::Dark | ColorScheme::Unknown => DEFAULT_THEME_ID,
1635 }
1636}
1637
1638pub fn get_syntax_theme_for_ui_theme(ui_theme: &str) -> &'static str {
1656 match ui_theme.to_lowercase().as_str() {
1657 "ayu" => "ayu-dark",
1659 "ayu-mirage" => "ayu-mirage",
1660
1661 "catppuccin-latte" => "catppuccin-latte",
1663 "catppuccin-frappe" => "catppuccin-frappe",
1664 "catppuccin-macchiato" => "catppuccin-macchiato",
1665 "catppuccin-mocha" => "catppuccin-mocha",
1666
1667 "solarized-dark" | "solarized-dark-hc" => "Solarized (dark)",
1669 "solarized-light" => "Solarized (light)",
1670
1671 "gruvbox-dark" | "gruvbox-dark-hard" => "gruvbox-dark",
1673 "gruvbox-light" | "gruvbox-light-hard" => "gruvbox-light",
1674 "gruvbox-material" | "gruvbox-material-dark" => "gruvbox-dark",
1675 "gruvbox-material-light" => "gruvbox-light",
1676
1677 "tomorrow" => "Tomorrow",
1679 "tomorrow-night" => "Tomorrow Night",
1680 "tomorrow-night-blue" => "Tomorrow Night Blue",
1681 "tomorrow-night-bright" => "Tomorrow Night Bright",
1682 "tomorrow-night-eighties" => "Tomorrow Night Eighties",
1683 "tomorrow-night-burns" => "Tomorrow Night",
1684
1685 "github-dark" => "GitHub Dark",
1687 "github" => "GitHub",
1688
1689 "atom-one-dark" => "OneDark",
1691 "atom-one-light" => "OneLight",
1692 "atom" => "base16-ocean.dark",
1693
1694 "spacegray" | "spacegray-bright" | "spacegray-eighties" | "spacegray-eighties-dull" => {
1696 "base16-ocean.dark"
1697 }
1698
1699 "material-ocean" | "material-dark" | "material" => "Material Dark",
1701
1702 "dracula" => "Dracula",
1704 "monokai-classic" => "monokai-classic",
1705 "night-owl" => "Night Owl",
1706 "zenburn" => "Zenburn",
1707
1708 "jetbrains-darcula" => "base16-ocean.dark",
1710 "man-page" => "base16-ocean.dark",
1711 "homebrew" => "base16-ocean.dark",
1712 "framer" => "base16-ocean.dark",
1713 "espresso" => "base16-ocean.dark",
1714 "adventure-time" => "base16-ocean.dark",
1715 "afterglow" => "base16-ocean.dark",
1716 "apple-classic" => "base16-ocean.dark",
1717 "apple-system-colors" => "base16-ocean.dark",
1718
1719 "apple-system-colors-light" => "base16-ocean.light",
1721 "vitesse-light" | "vitesse-light-soft" => "base16-ocean.light",
1722
1723 "ciapre" | "ciapre-dark" | "ciapre-blue" => "base16-ocean.dark",
1725 "vitesse-black" | "vitesse-dark" | "vitesse-dark-soft" => "base16-ocean.dark",
1726 "mono" => "base16-ocean.dark",
1727 "ansi-classic" => "base16-ocean.dark",
1728
1729 _ => "base16-ocean.dark",
1731 }
1732}
1733
1734pub fn get_active_syntax_theme() -> &'static str {
1737 get_syntax_theme_for_ui_theme(&active_theme_id())
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742 use super::*;
1743
1744 #[test]
1745 fn test_mono_theme_exists() {
1746 let result = ensure_theme("mono");
1747 assert!(result.is_ok(), "Mono theme should be registered");
1748 assert_eq!(result.unwrap(), "Mono");
1749 }
1750
1751 #[test]
1752 fn test_mono_theme_contrast() {
1753 let result = validate_theme_contrast("mono");
1754 assert!(result.errors.is_empty(), "Mono theme should have no errors");
1756 assert!(result.is_valid);
1758 }
1759
1760 #[test]
1761 fn test_all_themes_resolvable() {
1762 for id in available_themes() {
1763 assert!(
1764 ensure_theme(id).is_ok(),
1765 "Theme {} should be resolvable",
1766 id
1767 );
1768 }
1769 }
1770
1771 #[test]
1772 fn test_available_theme_suites_contains_expected_groups() {
1773 let suites = available_theme_suites();
1774 let suite_ids: Vec<&str> = suites.iter().map(|suite| suite.id).collect();
1775 assert!(suite_ids.contains(&"ciapre"));
1776 assert!(suite_ids.contains(&"vitesse"));
1777 assert!(suite_ids.contains(&"catppuccin"));
1778 assert!(suite_ids.contains(&"mono"));
1779 }
1780
1781 #[test]
1782 fn test_theme_suite_resolution() {
1783 assert_eq!(theme_suite_id("catppuccin-mocha"), Some("catppuccin"));
1784 assert_eq!(theme_suite_id("vitesse-light"), Some("vitesse"));
1785 assert_eq!(theme_suite_id("ciapre-dark"), Some("ciapre"));
1786 assert_eq!(theme_suite_id("mono"), Some("mono"));
1787 assert_eq!(theme_suite_id("unknown-theme"), None);
1788 }
1789
1790 #[test]
1791 fn test_all_themes_have_readable_foreground_and_accents() {
1792 let min_contrast = get_minimum_contrast();
1793 for definition in REGISTRY.values() {
1794 let styles = definition.palette.build_styles_with_contrast(min_contrast);
1795 let bg = definition.palette.background;
1796
1797 for (name, color) in [
1798 ("foreground", style_rgb(styles.output)),
1799 ("primary", style_rgb(styles.primary)),
1800 ("secondary", style_rgb(styles.secondary)),
1801 ("user", style_rgb(styles.user)),
1802 ("response", style_rgb(styles.response)),
1803 ] {
1804 let color = color
1805 .unwrap_or_else(|| panic!("{} missing fg color for {}", name, definition.id));
1806 let ratio = contrast_ratio(color, bg);
1807 assert!(
1808 ratio >= min_contrast,
1809 "theme={} style={} contrast {:.2} < {:.1}",
1810 definition.id,
1811 name,
1812 ratio,
1813 min_contrast
1814 );
1815
1816 let luminance = relative_luminance(color);
1817 if relative_luminance(bg) < 0.5 {
1818 assert!(
1819 (MIN_DARK_BG_TEXT_LUMINANCE..=MAX_DARK_BG_TEXT_LUMINANCE)
1820 .contains(&luminance),
1821 "theme={} style={} luminance {:.3} outside dark-theme readability bounds",
1822 definition.id,
1823 name,
1824 luminance
1825 );
1826 } else {
1827 assert!(
1828 luminance <= MAX_LIGHT_BG_TEXT_LUMINANCE,
1829 "theme={} style={} luminance {:.3} too bright for light theme",
1830 definition.id,
1831 name,
1832 luminance
1833 );
1834 }
1835 }
1836 }
1837 }
1838
1839 fn style_rgb(style: Style) -> Option<RgbColor> {
1840 match style.get_fg_color() {
1841 Some(Color::Rgb(rgb)) => Some(rgb),
1842 _ => None,
1843 }
1844 }
1845}