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;
15
16static COLOR_CONFIG: Lazy<RwLock<ColorAccessibilityConfig>> =
19 Lazy::new(|| RwLock::new(ColorAccessibilityConfig::default()));
20
21#[derive(Clone, Debug)]
23pub struct ColorAccessibilityConfig {
24 pub minimum_contrast: f64,
26 pub bold_is_bright: bool,
28 pub safe_colors_only: bool,
30}
31
32impl Default for ColorAccessibilityConfig {
33 fn default() -> Self {
34 Self {
35 minimum_contrast: DEFAULT_MIN_CONTRAST,
36 bold_is_bright: false,
37 safe_colors_only: false,
38 }
39 }
40}
41
42pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
45 *COLOR_CONFIG.write() = config;
46}
47
48pub fn get_minimum_contrast() -> f64 {
50 COLOR_CONFIG.read().minimum_contrast
51}
52
53pub fn is_bold_bright_mode() -> bool {
55 COLOR_CONFIG.read().bold_is_bright
56}
57
58pub fn is_safe_colors_only() -> bool {
60 COLOR_CONFIG.read().safe_colors_only
61}
62
63#[derive(Clone, Debug)]
65pub struct ThemePalette {
66 pub primary_accent: RgbColor,
67 pub background: RgbColor,
68 pub foreground: RgbColor,
69 pub secondary_accent: RgbColor,
70 pub alert: RgbColor,
71 pub logo_accent: RgbColor,
72}
73
74impl ThemePalette {
75 fn style_from(color: RgbColor, bold: bool) -> Style {
79 let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
80 if bold && !is_bold_bright_mode() {
82 style = style.bold();
83 }
84 style
85 }
86
87 fn build_styles(&self) -> ThemeStyles {
88 self.build_styles_with_contrast(get_minimum_contrast())
89 }
90
91 fn build_styles_with_contrast(&self, min_contrast: f64) -> ThemeStyles {
94 let primary = self.primary_accent;
95 let background = self.background;
96 let secondary = self.secondary_accent;
97
98 let fallback_light = RgbColor(
99 ui::THEME_COLOR_WHITE_RED,
100 ui::THEME_COLOR_WHITE_GREEN,
101 ui::THEME_COLOR_WHITE_BLUE,
102 );
103
104 let text_color = ensure_contrast(
105 self.foreground,
106 background,
107 min_contrast,
108 &[
109 lighten(self.foreground, ui::THEME_FOREGROUND_LIGHTEN_RATIO),
110 lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
111 fallback_light,
112 ],
113 );
114 let info_color = ensure_contrast(
115 secondary,
116 background,
117 min_contrast,
118 &[
119 lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
120 text_color,
121 fallback_light,
122 ],
123 );
124 let light_tool_color = lighten(text_color, ui::THEME_MIX_RATIO); let tool_color = ensure_contrast(
127 light_tool_color,
128 background,
129 min_contrast,
130 &[
131 lighten(light_tool_color, ui::THEME_TOOL_BODY_LIGHTEN_RATIO),
132 info_color,
133 text_color,
134 ],
135 );
136 let tool_body_candidate = mix(light_tool_color, text_color, ui::THEME_TOOL_BODY_MIX_RATIO);
137 let tool_body_color = ensure_contrast(
138 tool_body_candidate,
139 background,
140 min_contrast,
141 &[
142 lighten(light_tool_color, ui::THEME_TOOL_BODY_LIGHTEN_RATIO),
143 text_color,
144 fallback_light,
145 ],
146 );
147 let tool_style = Style::new().fg_color(Some(Color::Rgb(tool_color)));
148 let tool_detail_style = Style::new().fg_color(Some(Color::Rgb(tool_body_color)));
149 let response_color = ensure_contrast(
150 text_color,
151 background,
152 min_contrast,
153 &[
154 lighten(text_color, ui::THEME_RESPONSE_COLOR_LIGHTEN_RATIO),
155 fallback_light,
156 ],
157 );
158 let reasoning_color = ensure_contrast(
160 lighten(text_color, 0.25), background,
162 min_contrast,
163 &[lighten(text_color, 0.15), text_color, fallback_light],
164 );
165 let reasoning_style =
167 Self::style_from(reasoning_color, false).effects(Effects::DIMMED | Effects::ITALIC);
168 let user_color = ensure_contrast(
170 lighten(secondary, ui::THEME_USER_COLOR_LIGHTEN_RATIO),
171 background,
172 min_contrast,
173 &[
174 lighten(secondary, ui::THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO),
175 info_color,
176 text_color,
177 ],
178 );
179 let alert_color = ensure_contrast(
180 self.alert,
181 background,
182 min_contrast,
183 &[
184 lighten(self.alert, ui::THEME_LUMINANCE_LIGHTEN_RATIO),
185 fallback_light,
186 text_color,
187 ],
188 );
189
190 let tool_output_style = Style::new();
192
193 let pty_output_candidate = lighten(tool_body_color, ui::THEME_PTY_OUTPUT_LIGHTEN_RATIO);
197 let pty_output_color = ensure_contrast(
198 pty_output_candidate,
199 background,
200 min_contrast,
201 &[
202 lighten(text_color, ui::THEME_PTY_OUTPUT_LIGHTEN_RATIO),
203 tool_body_color,
204 text_color,
205 ],
206 );
207 let pty_output_style = Style::new().fg_color(Some(Color::Rgb(pty_output_color)));
208
209 ThemeStyles {
210 info: Self::style_from(info_color, true),
211 error: Self::style_from(alert_color, true),
212 output: Self::style_from(text_color, false),
213 response: Self::style_from(response_color, false),
214 reasoning: reasoning_style,
215 tool: tool_style,
216 tool_detail: tool_detail_style,
217 tool_output: tool_output_style,
218 pty_output: pty_output_style,
219 status: Self::style_from(
220 ensure_contrast(
221 lighten(primary, ui::THEME_PRIMARY_STATUS_LIGHTEN_RATIO),
222 background,
223 min_contrast,
224 &[
225 lighten(primary, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
226 info_color,
227 text_color,
228 ],
229 ),
230 true,
231 ),
232 mcp: Self::style_from(
233 ensure_contrast(
234 lighten(self.logo_accent, ui::THEME_SECONDARY_LIGHTEN_RATIO),
235 background,
236 min_contrast,
237 &[
238 lighten(self.logo_accent, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO),
239 info_color,
240 fallback_light,
241 ],
242 ),
243 true,
244 ),
245 user: Self::style_from(user_color, false),
246 primary: Self::style_from(primary, false),
247 secondary: Self::style_from(secondary, false),
248 background: Color::Rgb(background),
249 foreground: Color::Rgb(text_color),
250 }
251 }
252}
253
254#[derive(Clone, Debug)]
256pub struct ThemeStyles {
257 pub info: Style,
258 pub error: Style,
259 pub output: Style,
260 pub response: Style,
261 pub reasoning: Style,
262 pub tool: Style,
263 pub tool_detail: Style,
264 pub tool_output: Style,
265 pub pty_output: Style,
266 pub status: Style,
267 pub mcp: Style,
268 pub user: Style,
269 pub primary: Style,
270 pub secondary: Style,
271 pub background: Color,
272 pub foreground: Color,
273}
274
275#[derive(Clone, Debug)]
276pub struct ThemeDefinition {
277 pub id: &'static str,
278 pub label: &'static str,
279 pub palette: ThemePalette,
280}
281
282#[derive(Clone, Debug, PartialEq, Eq)]
284pub struct ThemeSuite {
285 pub id: &'static str,
286 pub label: &'static str,
287 pub theme_ids: Vec<&'static str>,
288}
289
290#[derive(Clone, Debug)]
291struct ActiveTheme {
292 id: String,
293 label: String,
294 palette: ThemePalette,
295 styles: ThemeStyles,
296}
297
298#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
299enum CatppuccinFlavorKind {
300 Latte,
301 Frappe,
302 Macchiato,
303 Mocha,
304}
305
306impl CatppuccinFlavorKind {
307 const fn id(self) -> &'static str {
308 match self {
309 CatppuccinFlavorKind::Latte => "catppuccin-latte",
310 CatppuccinFlavorKind::Frappe => "catppuccin-frappe",
311 CatppuccinFlavorKind::Macchiato => "catppuccin-macchiato",
312 CatppuccinFlavorKind::Mocha => "catppuccin-mocha",
313 }
314 }
315
316 const fn label(self) -> &'static str {
317 match self {
318 CatppuccinFlavorKind::Latte => "Catppuccin Latte",
319 CatppuccinFlavorKind::Frappe => "Catppuccin Frappé",
320 CatppuccinFlavorKind::Macchiato => "Catppuccin Macchiato",
321 CatppuccinFlavorKind::Mocha => "Catppuccin Mocha",
322 }
323 }
324
325 fn flavor(self) -> catppuccin::Flavor {
326 match self {
327 CatppuccinFlavorKind::Latte => PALETTE.latte,
328 CatppuccinFlavorKind::Frappe => PALETTE.frappe,
329 CatppuccinFlavorKind::Macchiato => PALETTE.macchiato,
330 CatppuccinFlavorKind::Mocha => PALETTE.mocha,
331 }
332 }
333}
334
335static CATPPUCCIN_FLAVORS: &[CatppuccinFlavorKind] = &[
336 CatppuccinFlavorKind::Latte,
337 CatppuccinFlavorKind::Frappe,
338 CatppuccinFlavorKind::Macchiato,
339 CatppuccinFlavorKind::Mocha,
340];
341
342static REGISTRY: Lazy<HashMap<&'static str, ThemeDefinition>> = Lazy::new(|| {
343 let mut map = HashMap::new();
344 map.insert(
345 "ciapre-dark",
346 ThemeDefinition {
347 id: "ciapre-dark",
348 label: "Ciapre Dark",
349 palette: ThemePalette {
350 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
351 background: RgbColor(0x26, 0x26, 0x26),
352 foreground: RgbColor(0xBF, 0xB3, 0x8F),
353 secondary_accent: RgbColor(0xD9, 0x9A, 0x4E),
354 alert: RgbColor(0xFF, 0x8A, 0x8A),
355 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
356 },
357 },
358 );
359 map.insert(
360 "ciapre-blue",
361 ThemeDefinition {
362 id: "ciapre-blue",
363 label: "Ciapre Blue",
364 palette: ThemePalette {
365 primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
366 background: RgbColor(0x17, 0x1C, 0x26),
367 foreground: RgbColor(0xBF, 0xB3, 0x8F),
368 secondary_accent: RgbColor(0xBF, 0xB3, 0x8F),
369 alert: RgbColor(0xFF, 0x8A, 0x8A),
370 logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
371 },
372 },
373 );
374
375 map.insert(
377 "vitesse-black",
378 ThemeDefinition {
379 id: "vitesse-black",
380 label: "Vitesse Black",
381 palette: ThemePalette {
382 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), },
389 },
390 );
391 map.insert(
392 "vitesse-dark",
393 ThemeDefinition {
394 id: "vitesse-dark",
395 label: "Vitesse Dark",
396 palette: ThemePalette {
397 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), },
404 },
405 );
406 map.insert(
407 "vitesse-dark-soft",
408 ThemeDefinition {
409 id: "vitesse-dark-soft",
410 label: "Vitesse Dark Soft",
411 palette: ThemePalette {
412 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), },
419 },
420 );
421 map.insert(
422 "vitesse-light",
423 ThemeDefinition {
424 id: "vitesse-light",
425 label: "Vitesse Light",
426 palette: ThemePalette {
427 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), },
434 },
435 );
436 map.insert(
437 "vitesse-light-soft",
438 ThemeDefinition {
439 id: "vitesse-light-soft",
440 label: "Vitesse Light Soft",
441 palette: ThemePalette {
442 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), },
449 },
450 );
451
452 map.insert(
453 "mono",
454 ThemeDefinition {
455 id: "mono",
456 label: "Mono",
457 palette: ThemePalette {
458 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), },
465 },
466 );
467
468 register_catppuccin_themes(&mut map);
469 map
470});
471
472fn register_catppuccin_themes(map: &mut HashMap<&'static str, ThemeDefinition>) {
473 for &flavor_kind in CATPPUCCIN_FLAVORS {
474 let flavor = flavor_kind.flavor();
475 let theme_definition = ThemeDefinition {
476 id: flavor_kind.id(),
477 label: flavor_kind.label(),
478 palette: catppuccin_palette(flavor),
479 };
480 map.insert(flavor_kind.id(), theme_definition);
481 }
482}
483
484fn catppuccin_palette(flavor: catppuccin::Flavor) -> ThemePalette {
485 let colors = flavor.colors;
486 ThemePalette {
487 primary_accent: catppuccin_rgb(colors.lavender),
488 background: catppuccin_rgb(colors.base),
489 foreground: catppuccin_rgb(colors.text),
490 secondary_accent: catppuccin_rgb(colors.sapphire),
491 alert: catppuccin_rgb(colors.red),
492 logo_accent: catppuccin_rgb(colors.peach),
493 }
494}
495
496fn catppuccin_rgb(color: catppuccin::Color) -> RgbColor {
497 RgbColor(color.rgb.r, color.rgb.g, color.rgb.b)
498}
499
500static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
501 let default = REGISTRY
502 .get(DEFAULT_THEME_ID)
503 .expect("default theme must exist");
504 let styles = default.palette.build_styles();
505 RwLock::new(ActiveTheme {
506 id: default.id.to_string(),
507 label: default.label.to_string(),
508 palette: default.palette.clone(),
509 styles,
510 })
511});
512
513pub fn set_active_theme(theme_id: &str) -> Result<()> {
515 let id_lc = theme_id.trim().to_lowercase();
516 let theme = REGISTRY
517 .get(id_lc.as_str())
518 .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
519
520 let styles = theme.palette.build_styles();
521 let mut guard = ACTIVE.write();
522 guard.id = theme.id.to_string();
523 guard.label = theme.label.to_string();
524 guard.palette = theme.palette.clone();
525 guard.styles = styles;
526 Ok(())
527}
528
529pub fn active_theme_id() -> String {
531 ACTIVE.read().id.clone()
532}
533
534pub fn active_theme_label() -> String {
536 ACTIVE.read().label.clone()
537}
538
539pub fn active_styles() -> ThemeStyles {
541 ACTIVE.read().styles.clone()
542}
543
544pub fn banner_color() -> RgbColor {
546 let guard = ACTIVE.read();
547 let accent = guard.palette.logo_accent;
548 let secondary = guard.palette.secondary_accent;
549 let background = guard.palette.background;
550 drop(guard);
551
552 let min_contrast = get_minimum_contrast();
553 let candidate = lighten(accent, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO);
554 ensure_contrast(
555 candidate,
556 background,
557 min_contrast,
558 &[
559 lighten(accent, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
560 lighten(
561 secondary,
562 ui::THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO,
563 ),
564 accent,
565 ],
566 )
567}
568
569pub fn banner_style() -> Style {
571 let accent = banner_color();
572 Style::new().fg_color(Some(Color::Rgb(accent))).bold()
573}
574
575pub fn logo_accent_color() -> RgbColor {
577 ACTIVE.read().palette.logo_accent
578}
579
580pub fn available_themes() -> Vec<&'static str> {
582 let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
583 keys.sort();
584 keys
585}
586
587pub fn theme_label(theme_id: &str) -> Option<&'static str> {
589 REGISTRY.get(theme_id).map(|definition| definition.label)
590}
591
592fn suite_id_for_theme(theme_id: &str) -> Option<&'static str> {
593 if theme_id.starts_with("catppuccin-") {
594 Some("catppuccin")
595 } else if theme_id.starts_with("vitesse-") {
596 Some("vitesse")
597 } else if theme_id.starts_with("ciapre-") {
598 Some("ciapre")
599 } else if theme_id == "mono" {
600 Some("mono")
601 } else {
602 None
603 }
604}
605
606fn suite_label(suite_id: &str) -> Option<&'static str> {
607 match suite_id {
608 "catppuccin" => Some("Catppuccin"),
609 "vitesse" => Some("Vitesse"),
610 "ciapre" => Some("Ciapre"),
611 "mono" => Some("Mono"),
612 _ => None,
613 }
614}
615
616pub fn theme_suite_id(theme_id: &str) -> Option<&'static str> {
618 suite_id_for_theme(theme_id)
619}
620
621pub fn theme_suite_label(theme_id: &str) -> Option<&'static str> {
623 suite_id_for_theme(theme_id).and_then(suite_label)
624}
625
626pub fn available_theme_suites() -> Vec<ThemeSuite> {
628 const ORDER: &[&str] = &["ciapre", "vitesse", "catppuccin", "mono"];
629
630 ORDER
631 .iter()
632 .filter_map(|suite_id| {
633 let mut theme_ids: Vec<&'static str> = available_themes()
634 .into_iter()
635 .filter(|theme_id| suite_id_for_theme(theme_id) == Some(*suite_id))
636 .collect();
637 if theme_ids.is_empty() {
638 return None;
639 }
640 theme_ids.sort_unstable();
641 Some(ThemeSuite {
642 id: suite_id,
643 label: suite_label(suite_id).expect("known suite id must have label"),
644 theme_ids,
645 })
646 })
647 .collect()
648}
649
650fn relative_luminance(color: RgbColor) -> f64 {
651 fn channel(value: u8) -> f64 {
652 let c = (value as f64) / 255.0;
653 if c <= ui::THEME_RELATIVE_LUMINANCE_CUTOFF {
654 c / ui::THEME_RELATIVE_LUMINANCE_LOW_FACTOR
655 } else {
656 ((c + ui::THEME_RELATIVE_LUMINANCE_OFFSET)
657 / (1.0 + ui::THEME_RELATIVE_LUMINANCE_OFFSET))
658 .powf(ui::THEME_RELATIVE_LUMINANCE_EXPONENT)
659 }
660 }
661 let r = channel(color.0);
662 let g = channel(color.1);
663 let b = channel(color.2);
664 ui::THEME_RED_LUMINANCE_COEFFICIENT * r
665 + ui::THEME_GREEN_LUMINANCE_COEFFICIENT * g
666 + ui::THEME_BLUE_LUMINANCE_COEFFICIENT * b
667}
668
669fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
670 let fg = relative_luminance(foreground);
671 let bg = relative_luminance(background);
672 let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
673 (lighter + ui::THEME_CONTRAST_RATIO_OFFSET) / (darker + ui::THEME_CONTRAST_RATIO_OFFSET)
674}
675
676fn ensure_contrast(
677 candidate: RgbColor,
678 background: RgbColor,
679 min_ratio: f64,
680 fallbacks: &[RgbColor],
681) -> RgbColor {
682 if contrast_ratio(candidate, background) >= min_ratio {
683 return candidate;
684 }
685 for &fallback in fallbacks {
686 if contrast_ratio(fallback, background) >= min_ratio {
687 return fallback;
688 }
689 }
690 candidate
691}
692
693pub(crate) fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
694 let ratio = ratio.clamp(ui::THEME_MIX_RATIO_MIN, ui::THEME_MIX_RATIO_MAX);
695 let blend = |c: u8, t: u8| -> u8 {
696 let c = c as f64;
697 let t = t as f64;
698 ((c + (t - c) * ratio).round()).clamp(ui::THEME_BLEND_CLAMP_MIN, ui::THEME_BLEND_CLAMP_MAX)
699 as u8
700 };
701 RgbColor(
702 blend(color.0, target.0),
703 blend(color.1, target.1),
704 blend(color.2, target.2),
705 )
706}
707
708fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
709 mix(
710 color,
711 RgbColor(
712 ui::THEME_COLOR_WHITE_RED,
713 ui::THEME_COLOR_WHITE_GREEN,
714 ui::THEME_COLOR_WHITE_BLUE,
715 ),
716 ratio,
717 )
718}
719
720pub fn resolve_theme(preferred: Option<String>) -> String {
722 preferred
723 .and_then(|candidate| {
724 let trimmed = candidate.trim().to_lowercase();
725 if trimmed.is_empty() {
726 None
727 } else if REGISTRY.contains_key(trimmed.as_str()) {
728 Some(trimmed)
729 } else {
730 None
731 }
732 })
733 .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
734}
735
736pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
738 REGISTRY
739 .get(theme_id)
740 .map(|definition| definition.label)
741 .context("Theme not found")
742}
743
744pub fn rebuild_active_styles() {
747 let mut guard = ACTIVE.write();
748 guard.styles = guard.palette.build_styles();
749}
750
751#[derive(Debug, Clone)]
753pub struct ThemeValidationResult {
754 pub is_valid: bool,
756 pub warnings: Vec<String>,
758 pub errors: Vec<String>,
760}
761
762pub fn validate_theme_contrast(theme_id: &str) -> ThemeValidationResult {
765 let mut result = ThemeValidationResult {
766 is_valid: true,
767 warnings: Vec::new(),
768 errors: Vec::new(),
769 };
770
771 let theme = match REGISTRY.get(theme_id) {
772 Some(t) => t,
773 None => {
774 result.is_valid = false;
775 result.errors.push(format!("Unknown theme: {}", theme_id));
776 return result;
777 }
778 };
779
780 let palette = &theme.palette;
781 let bg = palette.background;
782 let min_contrast = get_minimum_contrast();
783
784 let checks = [
786 ("foreground", palette.foreground),
787 ("primary_accent", palette.primary_accent),
788 ("secondary_accent", palette.secondary_accent),
789 ("alert", palette.alert),
790 ("logo_accent", palette.logo_accent),
791 ];
792
793 for (name, color) in checks {
794 let ratio = contrast_ratio(color, bg);
795 if ratio < min_contrast {
796 result.warnings.push(format!(
797 "{} ({:02X}{:02X}{:02X}) has contrast ratio {:.2} < {:.1} against background",
798 name, color.0, color.1, color.2, ratio, min_contrast
799 ));
800 }
801 }
802
803 result
804}
805
806pub fn theme_matches_terminal_scheme(theme_id: &str) -> bool {
809 use crate::utils::ansi_capabilities::ColorScheme;
810 use crate::utils::ansi_capabilities::detect_color_scheme;
811
812 let scheme = detect_color_scheme();
813 let theme_is_light = is_light_theme(theme_id);
814
815 match scheme {
816 ColorScheme::Light => theme_is_light,
817 ColorScheme::Dark | ColorScheme::Unknown => !theme_is_light,
818 }
819}
820
821pub fn is_light_theme(theme_id: &str) -> bool {
823 REGISTRY
824 .get(theme_id)
825 .map(|theme| {
826 let bg = theme.palette.background;
827 let luminance = relative_luminance(bg);
828 luminance > 0.5
830 })
831 .unwrap_or(false)
832}
833
834pub fn suggest_theme_for_terminal() -> &'static str {
837 use crate::utils::ansi_capabilities::ColorScheme;
838 use crate::utils::ansi_capabilities::detect_color_scheme;
839
840 match detect_color_scheme() {
841 ColorScheme::Light => "vitesse-light",
842 ColorScheme::Dark | ColorScheme::Unknown => DEFAULT_THEME_ID,
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849
850 #[test]
851 fn test_mono_theme_exists() {
852 let result = ensure_theme("mono");
853 assert!(result.is_ok(), "Mono theme should be registered");
854 assert_eq!(result.unwrap(), "Mono");
855 }
856
857 #[test]
858 fn test_mono_theme_contrast() {
859 let result = validate_theme_contrast("mono");
860 assert!(result.errors.is_empty(), "Mono theme should have no errors");
862 assert!(result.is_valid);
864 }
865
866 #[test]
867 fn test_all_themes_resolvable() {
868 for id in available_themes() {
869 assert!(
870 ensure_theme(id).is_ok(),
871 "Theme {} should be resolvable",
872 id
873 );
874 }
875 }
876
877 #[test]
878 fn test_available_theme_suites_contains_expected_groups() {
879 let suites = available_theme_suites();
880 let suite_ids: Vec<&str> = suites.iter().map(|suite| suite.id).collect();
881 assert!(suite_ids.contains(&"ciapre"));
882 assert!(suite_ids.contains(&"vitesse"));
883 assert!(suite_ids.contains(&"catppuccin"));
884 assert!(suite_ids.contains(&"mono"));
885 }
886
887 #[test]
888 fn test_theme_suite_resolution() {
889 assert_eq!(theme_suite_id("catppuccin-mocha"), Some("catppuccin"));
890 assert_eq!(theme_suite_id("vitesse-light"), Some("vitesse"));
891 assert_eq!(theme_suite_id("ciapre-dark"), Some("ciapre"));
892 assert_eq!(theme_suite_id("mono"), Some("mono"));
893 assert_eq!(theme_suite_id("unknown-theme"), None);
894 }
895}