Skip to main content

vtcode_tui/ui/
theme.rs

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
10/// Identifier for the default theme.
11pub const DEFAULT_THEME_ID: &str = defaults::DEFAULT_THEME;
12
13/// Default minimum contrast ratio (WCAG AA standard)
14const DEFAULT_MIN_CONTRAST: f64 = ui::THEME_MIN_CONTRAST_RATIO;
15
16/// Runtime configuration for color accessibility settings.
17/// These can be updated from vtcode.toml [ui] section.
18static COLOR_CONFIG: Lazy<RwLock<ColorAccessibilityConfig>> =
19    Lazy::new(|| RwLock::new(ColorAccessibilityConfig::default()));
20
21/// Color accessibility configuration loaded from vtcode.toml
22#[derive(Clone, Debug)]
23pub struct ColorAccessibilityConfig {
24    /// Minimum contrast ratio for text (WCAG standard)
25    pub minimum_contrast: f64,
26    /// Whether to treat bold as bright (legacy terminal compat)
27    pub bold_is_bright: bool,
28    /// Whether to restrict to safe ANSI colors only
29    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
42/// Update the global color accessibility configuration.
43/// Call this after loading vtcode.toml to apply user preferences.
44pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
45    *COLOR_CONFIG.write() = config;
46}
47
48/// Get the current minimum contrast ratio setting.
49pub fn get_minimum_contrast() -> f64 {
50    COLOR_CONFIG.read().minimum_contrast
51}
52
53/// Check if bold-is-bright compatibility mode is enabled.
54pub fn is_bold_bright_mode() -> bool {
55    COLOR_CONFIG.read().bold_is_bright
56}
57
58/// Check if safe colors only mode is enabled.
59pub fn is_safe_colors_only() -> bool {
60    COLOR_CONFIG.read().safe_colors_only
61}
62
63/// Palette describing UI colors for the terminal experience.
64#[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    /// Create a style with foreground color, respecting bold_is_bright setting.
76    /// When bold_is_bright is enabled and bold is requested, we skip bold
77    /// to prevent unintended bright color mapping in legacy terminals.
78    fn style_from(color: RgbColor, bold: bool) -> Style {
79        let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
80        // Only apply bold if not in bold_is_bright compatibility mode
81        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    /// Build styles with a specific minimum contrast ratio.
92    /// This allows runtime configuration of contrast requirements.
93    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        // Light gray for tool output derived from theme colors
125        let light_tool_color = lighten(text_color, ui::THEME_MIX_RATIO); // Lighter version of the text color
126        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        // Reasoning color: Use text color with dimmed effect for placeholder-like appearance
159        let reasoning_color = ensure_contrast(
160            lighten(text_color, 0.25), // Lighter for placeholder-like appearance
161            background,
162            min_contrast,
163            &[lighten(text_color, 0.15), text_color, fallback_light],
164        );
165        // Reasoning style: Dimmed and italic for placeholder-like thinking output
166        let reasoning_style =
167            Self::style_from(reasoning_color, false).effects(Effects::DIMMED | Effects::ITALIC);
168        // Make user messages more distinct using secondary accent color
169        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        // Tool output style: use default terminal styling (no color/bold/dim effects)
191        let tool_output_style = Style::new();
192
193        // PTY output style: subdued foreground for terminal output that's readable
194        // but visually distinct from agent/user text — avoids terminal DIM modifier
195        // which can be too faint on many terminals
196        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/// Styles computed from palette colors.
255#[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/// Logical grouping of built-in themes.
283#[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    // Vitesse themes
376    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), // Light gray foreground
383                background: RgbColor(0x00, 0x00, 0x00),     // Black
384                foreground: RgbColor(0xDB, 0xD7, 0xCA),     // Light gray
385                secondary_accent: RgbColor(0x4D, 0x93, 0x75), // Green (selection color)
386                alert: RgbColor(0xCB, 0x76, 0x76),          // Red for errors
387                logo_accent: RgbColor(0xDB, 0xD7, 0xCA),    // Light gray for logo accent
388            },
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), // Light gray foreground
398                background: RgbColor(0x12, 0x12, 0x12),     // Very dark gray
399                foreground: RgbColor(0xDB, 0xD7, 0xCA),     // Light gray
400                secondary_accent: RgbColor(0x4D, 0x93, 0x75), // Green (selection color)
401                alert: RgbColor(0xCB, 0x76, 0x76),          // Red for errors
402                logo_accent: RgbColor(0xDB, 0xD7, 0xCA),    // Light gray for logo accent
403            },
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), // Light gray foreground
413                background: RgbColor(0x22, 0x22, 0x22),     // Very dark gray (soft)
414                foreground: RgbColor(0xDB, 0xD7, 0xCA),     // Light gray
415                secondary_accent: RgbColor(0x4D, 0x93, 0x75), // Green (selection color)
416                alert: RgbColor(0xCB, 0x76, 0x76),          // Red for errors
417                logo_accent: RgbColor(0xDB, 0xD7, 0xCA),    // Light gray for logo accent
418            },
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), // Dark gray foreground
428                background: RgbColor(0xFF, 0xFF, 0xFF),     // White
429                foreground: RgbColor(0x39, 0x3A, 0x34),     // Dark gray
430                secondary_accent: RgbColor(0x1C, 0x6B, 0x48), // Green (selection color)
431                alert: RgbColor(0xAB, 0x59, 0x59),          // Red for errors
432                logo_accent: RgbColor(0x39, 0x3A, 0x34),    // Dark gray for logo accent
433            },
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), // Dark gray foreground
443                background: RgbColor(0xF1, 0xF0, 0xE9),     // Soft cream
444                foreground: RgbColor(0x39, 0x3A, 0x34),     // Dark gray
445                secondary_accent: RgbColor(0x1C, 0x6B, 0x48), // Green (selection color)
446                alert: RgbColor(0xAB, 0x59, 0x59),          // Red for errors
447                logo_accent: RgbColor(0x39, 0x3A, 0x34),    // Dark gray for logo accent
448            },
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),   // Pure white
459                background: RgbColor(0x00, 0x00, 0x00),       // Black
460                foreground: RgbColor(0xDB, 0xD7, 0xCA), // Soft light gray (borrowed from vitesse)
461                secondary_accent: RgbColor(0xBB, 0xBB, 0xBB), // Medium gray
462                alert: RgbColor(0xFF, 0xFF, 0xFF),      // High contrast white for alerts
463                logo_accent: RgbColor(0xFF, 0xFF, 0xFF), // White for logo
464            },
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
513/// Set the active theme by identifier.
514pub 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
529/// Get the identifier of the active theme.
530pub fn active_theme_id() -> String {
531    ACTIVE.read().id.clone()
532}
533
534/// Get the human-readable label of the active theme.
535pub fn active_theme_label() -> String {
536    ACTIVE.read().label.clone()
537}
538
539/// Get the current styles cloned from the active theme.
540pub fn active_styles() -> ThemeStyles {
541    ACTIVE.read().styles.clone()
542}
543
544/// Slightly adjusted accent color for banner-like copy.
545pub 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
569/// Slightly darkened accent style for banner-like copy.
570pub fn banner_style() -> Style {
571    let accent = banner_color();
572    Style::new().fg_color(Some(Color::Rgb(accent))).bold()
573}
574
575/// Accent color for the startup banner logo.
576pub fn logo_accent_color() -> RgbColor {
577    ACTIVE.read().palette.logo_accent
578}
579
580/// Enumerate available theme identifiers.
581pub fn available_themes() -> Vec<&'static str> {
582    let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
583    keys.sort();
584    keys
585}
586
587/// Look up a theme label for display.
588pub 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
616/// Resolve the suite identifier for a theme id.
617pub fn theme_suite_id(theme_id: &str) -> Option<&'static str> {
618    suite_id_for_theme(theme_id)
619}
620
621/// Resolve the suite label for a theme id.
622pub fn theme_suite_label(theme_id: &str) -> Option<&'static str> {
623    suite_id_for_theme(theme_id).and_then(suite_label)
624}
625
626/// Enumerate built-in theme suites and their member theme ids.
627pub 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
720/// Resolve a theme identifier from configuration or CLI input.
721pub 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
736/// Validate a theme and return its label for messaging.
737pub 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
744/// Rebuild the active theme's styles with current accessibility settings.
745/// Call this after updating color accessibility configuration.
746pub fn rebuild_active_styles() {
747    let mut guard = ACTIVE.write();
748    guard.styles = guard.palette.build_styles();
749}
750
751/// Theme validation result
752#[derive(Debug, Clone)]
753pub struct ThemeValidationResult {
754    /// Whether the theme passed validation
755    pub is_valid: bool,
756    /// List of warnings (non-fatal issues)
757    pub warnings: Vec<String>,
758    /// List of errors (fatal issues)
759    pub errors: Vec<String>,
760}
761
762/// Validate a theme's color contrast ratios.
763/// Returns warnings for colors that don't meet WCAG AA standards.
764pub 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    // Check main text colors
785    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
806/// Check if a theme is suitable for the detected terminal color scheme.
807/// Returns true if the theme matches (light theme for light terminal, dark for dark).
808pub 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
821/// Determine if a theme is a light theme based on its background luminance.
822pub 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            // If background luminance > 0.5, it's a light theme
829            luminance > 0.5
830        })
831        .unwrap_or(false)
832}
833
834/// Get a suggested theme based on terminal color scheme detection.
835/// Returns a light or dark theme depending on detected terminal background.
836pub 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        // We expect it to be valid, but we check if there are any major contrast issues
861        assert!(result.errors.is_empty(), "Mono theme should have no errors");
862        // Mono themes might have some warnings if grays are close, but pure black/white should be fine.
863        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}