Skip to main content

vtcode_theme/
runtime.rs

1use anstyle::{Color, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use once_cell::sync::Lazy;
4use parking_lot::RwLock;
5use vtcode_config::constants::ui;
6
7use crate::color_math::{contrast_ratio, ensure_contrast, lighten};
8use crate::registry::theme_definition;
9use crate::types::{
10    ColorAccessibilityConfig, DEFAULT_THEME_ID, ThemeDefinition, ThemeStyles, ThemeValidationResult,
11};
12
13#[derive(Clone, Debug)]
14struct ActiveTheme {
15    definition: &'static ThemeDefinition,
16    styles: ThemeStyles,
17}
18
19static COLOR_CONFIG: Lazy<RwLock<ColorAccessibilityConfig>> =
20    Lazy::new(|| RwLock::new(ColorAccessibilityConfig::default()));
21
22fn current_color_config() -> impl std::ops::Deref<Target = ColorAccessibilityConfig> {
23    COLOR_CONFIG.read()
24}
25
26static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
27    let default = theme_definition(DEFAULT_THEME_ID).expect("default theme must exist");
28    let styles = default
29        .palette
30        .build_styles_with_accessibility(&current_color_config());
31    RwLock::new(ActiveTheme {
32        definition: default,
33        styles,
34    })
35});
36
37/// Update the runtime color accessibility configuration.
38pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
39    *COLOR_CONFIG.write() = config;
40}
41
42/// Return the currently configured minimum contrast ratio.
43pub fn get_minimum_contrast() -> f32 {
44    COLOR_CONFIG.read().minimum_contrast
45}
46
47/// Report whether bold text should avoid terminal bright-color behavior.
48pub fn is_bold_bright_mode() -> bool {
49    COLOR_CONFIG.read().bold_is_bright
50}
51
52/// Report whether the UI should restrict itself to safe ANSI colors.
53pub fn is_safe_colors_only() -> bool {
54    COLOR_CONFIG.read().safe_colors_only
55}
56
57/// Activate a built-in theme by identifier.
58pub fn set_active_theme(theme_id: &str) -> Result<()> {
59    let id_lc = theme_id.trim().to_lowercase();
60    let theme =
61        theme_definition(id_lc.as_str()).ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
62
63    let styles = theme
64        .palette
65        .build_styles_with_accessibility(&current_color_config());
66    let mut guard = ACTIVE.write();
67    guard.definition = theme;
68    guard.styles = styles;
69    Ok(())
70}
71
72/// Return the active theme identifier.
73pub fn active_theme_id() -> String {
74    ACTIVE.read().definition.id.to_string()
75}
76
77/// Return the active theme label.
78pub fn active_theme_label() -> String {
79    ACTIVE.read().definition.label.to_string()
80}
81
82/// Return a clone of the active style set.
83pub fn active_styles() -> ThemeStyles {
84    ACTIVE.read().styles.clone()
85}
86
87/// Return a readable accent color for banner-like copy.
88pub fn banner_color() -> RgbColor {
89    let guard = ACTIVE.read();
90    let accent = guard.definition.palette.logo_accent;
91    let secondary = guard.definition.palette.secondary_accent;
92    let background = guard.definition.palette.background;
93    drop(guard);
94
95    let min_contrast = get_minimum_contrast();
96    let candidate = lighten(accent, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO);
97    ensure_contrast(
98        candidate,
99        background,
100        min_contrast,
101        &[
102            lighten(accent, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
103            lighten(
104                secondary,
105                ui::THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO,
106            ),
107            accent,
108        ],
109    )
110}
111
112/// Return a bold banner style derived from the active theme.
113pub fn banner_style() -> Style {
114    let accent = banner_color();
115    Style::new().fg_color(Some(Color::Rgb(accent))).bold()
116}
117
118/// Return the raw logo accent color from the active theme.
119pub fn logo_accent_color() -> RgbColor {
120    ACTIVE.read().definition.palette.logo_accent
121}
122
123/// Resolve a requested theme to a valid built-in identifier or the default.
124pub fn resolve_theme(preferred: Option<String>) -> String {
125    preferred
126        .and_then(|candidate| {
127            let trimmed = candidate.trim().to_lowercase();
128            if trimmed.is_empty() {
129                None
130            } else if theme_definition(trimmed.as_str()).is_some() {
131                Some(trimmed)
132            } else {
133                None
134            }
135        })
136        .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
137}
138
139/// Validate that a theme exists and return its label.
140pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
141    theme_definition(theme_id)
142        .map(|definition| definition.label)
143        .context("Theme not found")
144}
145
146/// Rebuild the active styles after accessibility settings change.
147pub fn rebuild_active_styles() {
148    let mut guard = ACTIVE.write();
149    guard.styles = guard
150        .definition
151        .palette
152        .build_styles_with_accessibility(&current_color_config());
153}
154
155/// Validate a theme's base palette contrast ratios.
156pub fn validate_theme_contrast(theme_id: &str) -> ThemeValidationResult {
157    let mut result = ThemeValidationResult {
158        is_valid: true,
159        warnings: Vec::new(),
160        errors: Vec::new(),
161    };
162
163    let theme = match theme_definition(theme_id) {
164        Some(theme) => theme,
165        None => {
166            result.is_valid = false;
167            result.errors.push(format!("Unknown theme: {}", theme_id));
168            return result;
169        }
170    };
171
172    let palette = &theme.palette;
173    let bg = palette.background;
174    let min_contrast = get_minimum_contrast();
175
176    for (name, color) in [
177        ("foreground", palette.foreground),
178        ("primary_accent", palette.primary_accent),
179        ("secondary_accent", palette.secondary_accent),
180        ("alert", palette.alert),
181        ("logo_accent", palette.logo_accent),
182    ] {
183        let ratio = contrast_ratio(color, bg);
184        if ratio < min_contrast {
185            result.warnings.push(format!(
186                "{} ({:02X}{:02X}{:02X}) has contrast ratio {:.2} < {:.1} against background",
187                name, color.0, color.1, color.2, ratio, min_contrast
188            ));
189        }
190    }
191
192    result
193}