vtcode_ui/theme/
runtime.rs1use anstyle::{Color, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use arc_swap::{ArcSwap, ArcSwapOption};
4use once_cell::sync::Lazy;
5use std::sync::Arc;
6use vtcode_config::constants::ui;
7
8use crate::theme::color_math::{contrast_ratio, ensure_contrast, lighten};
9use crate::theme::registry::theme_definition;
10use crate::theme::types::{
11 ColorAccessibilityConfig, DEFAULT_THEME_ID, ThemeDefinition, ThemeStyles, ThemeValidationResult,
12};
13
14#[derive(Clone, Debug)]
15struct ActiveTheme {
16 definition: &'static ThemeDefinition,
17 styles: ThemeStyles,
18}
19
20static COLOR_CONFIG: Lazy<ArcSwap<ColorAccessibilityConfig>> =
21 Lazy::new(|| ArcSwap::from_pointee(ColorAccessibilityConfig::default()));
22
23fn current_color_config() -> Arc<ColorAccessibilityConfig> {
24 COLOR_CONFIG.load_full()
25}
26
27static ACTIVE: Lazy<ArcSwap<ActiveTheme>> = Lazy::new(|| {
28 let default = theme_definition(DEFAULT_THEME_ID).expect("default theme must exist");
29 let styles = default.palette.build_styles_with_accessibility(¤t_color_config());
30 ArcSwap::from_pointee(ActiveTheme { definition: default, styles })
31});
32
33static PREVIEW: Lazy<ArcSwapOption<ActiveTheme>> = Lazy::new(ArcSwapOption::empty);
43
44pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
46 COLOR_CONFIG.store(Arc::new(config));
47}
48
49pub fn get_minimum_contrast() -> f32 {
51 COLOR_CONFIG.load_full().minimum_contrast
52}
53
54pub fn is_bold_bright_mode() -> bool {
56 COLOR_CONFIG.load_full().bold_is_bright
57}
58
59pub fn is_safe_colors_only() -> bool {
61 COLOR_CONFIG.load_full().safe_colors_only
62}
63
64pub fn set_active_theme(theme_id: &str) -> Result<()> {
70 let id_lc = theme_id.trim().to_lowercase();
71 let theme = theme_definition(id_lc.as_str()).ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
72
73 let styles = theme.palette.build_styles_with_accessibility(¤t_color_config());
74 PREVIEW.store(None);
75 ACTIVE.store(Arc::new(ActiveTheme { definition: theme, styles }));
76 Ok(())
77}
78
79pub fn active_theme_id() -> String {
81 ACTIVE.load_full().definition.id.to_string()
82}
83
84pub fn active_theme_label() -> String {
86 ACTIVE.load_full().definition.label.to_string()
87}
88
89pub fn active_styles() -> ThemeStyles {
92 if let Some(preview) = PREVIEW.load_full() {
93 return preview.styles.clone();
94 }
95 ACTIVE.load_full().styles.clone()
96}
97
98pub fn set_preview_theme(theme_id: &str) -> Result<()> {
101 let id_lc = theme_id.trim().to_lowercase();
102 let theme = theme_definition(id_lc.as_str()).ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
103 let styles = theme.palette.build_styles_with_accessibility(¤t_color_config());
104 PREVIEW.store(Some(Arc::new(ActiveTheme { definition: theme, styles })));
105 Ok(())
106}
107
108pub fn has_preview_theme() -> bool {
110 PREVIEW.load_full().is_some()
111}
112
113pub fn clear_preview_theme() {
115 PREVIEW.store(None);
116}
117
118pub fn banner_color() -> RgbColor {
120 let active = ACTIVE.load_full();
121 let accent = active.definition.palette.logo_accent;
122 let secondary = active.definition.palette.secondary_accent;
123 let background = active.definition.palette.background;
124
125 let min_contrast = get_minimum_contrast();
126 let candidate = lighten(accent, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO);
127 ensure_contrast(
128 candidate,
129 background,
130 min_contrast,
131 &[
132 lighten(accent, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
133 lighten(secondary, ui::THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO),
134 accent,
135 ],
136 )
137}
138
139pub fn banner_style() -> Style {
141 let accent = banner_color();
142 Style::new().fg_color(Some(Color::Rgb(accent))).bold()
143}
144
145pub fn logo_accent_color() -> RgbColor {
147 ACTIVE.load_full().definition.palette.logo_accent
148}
149
150pub fn style_contrast_ratio(style: &Style) -> Option<f32> {
158 let Color::Rgb(foreground) = style.get_fg_color()? else {
159 return None;
160 };
161 let background = ACTIVE.load_full().definition.palette.background;
162 Some(contrast_ratio(RgbColor(foreground.r(), foreground.g(), foreground.b()), background))
163}
164
165pub fn resolve_theme(preferred: Option<String>) -> String {
167 preferred
168 .and_then(|candidate| {
169 let trimmed = candidate.trim().to_lowercase();
170 if trimmed.is_empty() {
171 None
172 } else if theme_definition(trimmed.as_str()).is_some() {
173 Some(trimmed)
174 } else {
175 None
176 }
177 })
178 .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
179}
180
181pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
183 theme_definition(theme_id)
184 .map(|definition| definition.label)
185 .context("Theme not found")
186}
187
188pub fn rebuild_active_styles() {
190 let current = ACTIVE.load_full();
191 let mut updated = (*current).clone();
192 updated.styles = updated
193 .definition
194 .palette
195 .build_styles_with_accessibility(¤t_color_config());
196 ACTIVE.store(Arc::new(updated));
197}
198
199pub fn validate_theme_contrast(theme_id: &str) -> ThemeValidationResult {
201 let mut result = ThemeValidationResult {
202 is_valid: true,
203 warnings: Vec::new(),
204 errors: Vec::new(),
205 };
206
207 let theme = match theme_definition(theme_id) {
208 Some(theme) => theme,
209 None => {
210 result.is_valid = false;
211 result.errors.push(format!("Unknown theme: {theme_id}"));
212 return result;
213 }
214 };
215
216 let palette = &theme.palette;
217 let bg = palette.background;
218 let min_contrast = get_minimum_contrast();
219
220 for (name, color) in [
221 ("foreground", palette.foreground),
222 ("primary_accent", palette.primary_accent),
223 ("secondary_accent", palette.secondary_accent),
224 ("alert", palette.alert),
225 ("logo_accent", palette.logo_accent),
226 ] {
227 let ratio = contrast_ratio(color, bg);
228 if ratio < min_contrast {
229 result.warnings.push(format!(
230 "{} ({:02X}{:02X}{:02X}) has contrast ratio {:.2} < {:.1} against background",
231 name, color.0, color.1, color.2, ratio, min_contrast
232 ));
233 }
234 }
235
236 result
237}