Skip to main content

vtcode_ui/theme/
runtime.rs

1use 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(&current_color_config());
30    ArcSwap::from_pointee(ActiveTheme { definition: default, styles })
31});
32
33/// Preview state: when set, `active_styles()` returns the preview styles
34/// instead of the committed theme styles. This allows theme palette
35/// navigation to show a live preview without committing the selection.
36///
37/// Read-mostly whole-value state (reads on every render, writes only on
38/// theme switch/preview): `ArcSwap` keeps reads lock-free while preserving
39/// atomic replacement. This follows the RwLock-vs-lockfree guidance — a
40/// coarse `RwLock` would work but pays an atomic read-modify-write per
41/// acquisition even without contention; whole-value swap avoids it.
42static PREVIEW: Lazy<ArcSwapOption<ActiveTheme>> = Lazy::new(ArcSwapOption::empty);
43
44/// Update the runtime color accessibility configuration.
45pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
46    COLOR_CONFIG.store(Arc::new(config));
47}
48
49/// Return the currently configured minimum contrast ratio.
50pub fn get_minimum_contrast() -> f32 {
51    COLOR_CONFIG.load_full().minimum_contrast
52}
53
54/// Report whether bold text should avoid terminal bright-color behavior.
55pub fn is_bold_bright_mode() -> bool {
56    COLOR_CONFIG.load_full().bold_is_bright
57}
58
59/// Report whether the UI should restrict itself to safe ANSI colors.
60pub fn is_safe_colors_only() -> bool {
61    COLOR_CONFIG.load_full().safe_colors_only
62}
63
64/// Activate a built-in theme by identifier.
65///
66/// A committed selection supersedes any temporary palette preview. Leaving a
67/// preview in place here would make [`active_styles`] return stale preview
68/// colors after the caller has changed the committed theme.
69pub 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(&current_color_config());
74    PREVIEW.store(None);
75    ACTIVE.store(Arc::new(ActiveTheme { definition: theme, styles }));
76    Ok(())
77}
78
79/// Return the active theme identifier.
80pub fn active_theme_id() -> String {
81    ACTIVE.load_full().definition.id.to_string()
82}
83
84/// Return the active theme label.
85pub fn active_theme_label() -> String {
86    ACTIVE.load_full().definition.label.to_string()
87}
88
89/// Return a clone of the active style set.
90/// When a preview theme is active, returns the preview styles instead.
91pub 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
98/// Set a preview theme by identifier. The preview is returned by
99/// `active_styles()` until `clear_preview_theme()` is called.
100pub 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(&current_color_config());
104    PREVIEW.store(Some(Arc::new(ActiveTheme { definition: theme, styles })));
105    Ok(())
106}
107
108/// Return true when a preview theme is active.
109pub fn has_preview_theme() -> bool {
110    PREVIEW.load_full().is_some()
111}
112
113/// Clear the preview theme, reverting `active_styles()` to the committed theme.
114pub fn clear_preview_theme() {
115    PREVIEW.store(None);
116}
117
118/// Return a readable accent color for banner-like copy.
119pub 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
139/// Return a bold banner style derived from the active theme.
140pub fn banner_style() -> Style {
141    let accent = banner_color();
142    Style::new().fg_color(Some(Color::Rgb(accent))).bold()
143}
144
145/// Return the raw logo accent color from the active theme.
146pub fn logo_accent_color() -> RgbColor {
147    ACTIVE.load_full().definition.palette.logo_accent
148}
149
150/// Contrast ratio of a style's foreground against the active theme background.
151///
152/// Returns `None` when the style carries no RGB foreground (unset, ANSI16, or
153/// ANSI256 are not theme-relative). Consumers that build their own styled
154/// surfaces — for example the CLI exit postamble — use this to prove the
155/// surface meets the configured WCAG minimum (`get_minimum_contrast`, 4.5:1 by
156/// default) instead of shipping hand-picked colors.
157pub 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
165/// Resolve a requested theme to a valid built-in identifier or the default.
166pub 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
181/// Validate that a theme exists and return its label.
182pub 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
188/// Rebuild the active styles after accessibility settings change.
189pub 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(&current_color_config());
196    ACTIVE.store(Arc::new(updated));
197}
198
199/// Validate a theme's base palette contrast ratios.
200pub 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}