vtcode_core/ui/
theme.rs

1use anstyle::{Color, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use once_cell::sync::Lazy;
4use parking_lot::RwLock;
5use std::collections::HashMap;
6
7/// Identifier for the default theme.
8pub const DEFAULT_THEME_ID: &str = "ciapre-dark";
9
10const MIN_CONTRAST: f64 = 4.5;
11
12const WELCOME_TOOL_COLOR: RgbColor = RgbColor(0x38, 0x3B, 0x73);
13
14/// Palette describing UI colors for the terminal experience.
15#[derive(Clone, Debug)]
16pub struct ThemePalette {
17    pub primary_accent: RgbColor,
18    pub background: RgbColor,
19    pub foreground: RgbColor,
20    pub secondary_accent: RgbColor,
21    pub alert: RgbColor,
22}
23
24impl ThemePalette {
25    fn style_from(color: RgbColor, bold: bool) -> Style {
26        let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
27        if bold {
28            style = style.bold();
29        }
30        style
31    }
32
33    fn build_styles(&self) -> ThemeStyles {
34        let primary = self.primary_accent;
35        let background = self.background;
36        let secondary = self.secondary_accent;
37
38        let fallback_light = RgbColor(0xFF, 0xFF, 0xFF);
39
40        let text_color = ensure_contrast(
41            self.foreground,
42            background,
43            MIN_CONTRAST,
44            &[
45                lighten(self.foreground, 0.25),
46                lighten(secondary, 0.2),
47                fallback_light,
48            ],
49        );
50        let info_color = ensure_contrast(
51            secondary,
52            background,
53            MIN_CONTRAST,
54            &[lighten(secondary, 0.2), text_color, fallback_light],
55        );
56        let tool_color = WELCOME_TOOL_COLOR;
57        let response_color = ensure_contrast(
58            text_color,
59            background,
60            MIN_CONTRAST,
61            &[lighten(text_color, 0.15), fallback_light],
62        );
63        let user_color = ensure_contrast(
64            lighten(primary, 0.25),
65            background,
66            MIN_CONTRAST,
67            &[lighten(secondary, 0.15), info_color, text_color],
68        );
69        let alert_color = ensure_contrast(
70            self.alert,
71            background,
72            MIN_CONTRAST,
73            &[lighten(self.alert, 0.2), fallback_light, text_color],
74        );
75
76        ThemeStyles {
77            info: Self::style_from(info_color, true),
78            error: Self::style_from(alert_color, true),
79            output: Self::style_from(text_color, false),
80            response: Self::style_from(response_color, false),
81            tool: Style::new().fg_color(Some(Color::Rgb(tool_color))).bold(),
82            user: Self::style_from(user_color, false),
83            primary: Self::style_from(primary, false),
84            secondary: Self::style_from(secondary, false),
85            background: Color::Rgb(background),
86            foreground: Color::Rgb(text_color),
87        }
88    }
89}
90
91/// Styles computed from palette colors.
92#[derive(Clone, Debug)]
93pub struct ThemeStyles {
94    pub info: Style,
95    pub error: Style,
96    pub output: Style,
97    pub response: Style,
98    pub tool: Style,
99    pub user: Style,
100    pub primary: Style,
101    pub secondary: Style,
102    pub background: Color,
103    pub foreground: Color,
104}
105
106#[derive(Clone, Debug)]
107pub struct ThemeDefinition {
108    pub id: &'static str,
109    pub label: &'static str,
110    pub palette: ThemePalette,
111}
112
113#[derive(Clone, Debug)]
114struct ActiveTheme {
115    id: String,
116    label: String,
117    palette: ThemePalette,
118    styles: ThemeStyles,
119}
120
121static REGISTRY: Lazy<HashMap<&'static str, ThemeDefinition>> = Lazy::new(|| {
122    let mut map = HashMap::new();
123    map.insert(
124        "ciapre-dark",
125        ThemeDefinition {
126            id: "ciapre-dark",
127            label: "Ciapre Dark",
128            palette: ThemePalette {
129                primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
130                background: RgbColor(0x26, 0x26, 0x26),
131                foreground: RgbColor(0xBF, 0xB3, 0x8F),
132                secondary_accent: RgbColor(0xD9, 0x9A, 0x4E),
133                alert: RgbColor(0xFF, 0x8A, 0x8A),
134            },
135        },
136    );
137    map.insert(
138        "ciapre-blue",
139        ThemeDefinition {
140            id: "ciapre-blue",
141            label: "Ciapre Blue",
142            palette: ThemePalette {
143                primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
144                background: RgbColor(0x38, 0x3B, 0x73),
145                foreground: RgbColor(0xBF, 0xB3, 0x8F),
146                secondary_accent: RgbColor(0xBF, 0xB3, 0x8F),
147                alert: RgbColor(0xFF, 0x8A, 0x8A),
148            },
149        },
150    );
151    map
152});
153
154static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
155    let default = REGISTRY
156        .get(DEFAULT_THEME_ID)
157        .expect("default theme must exist");
158    let styles = default.palette.build_styles();
159    RwLock::new(ActiveTheme {
160        id: default.id.to_string(),
161        label: default.label.to_string(),
162        palette: default.palette.clone(),
163        styles,
164    })
165});
166
167/// Set the active theme by identifier.
168pub fn set_active_theme(theme_id: &str) -> Result<()> {
169    let id_lc = theme_id.trim().to_lowercase();
170    let theme = REGISTRY
171        .get(id_lc.as_str())
172        .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
173
174    let styles = theme.palette.build_styles();
175    let mut guard = ACTIVE.write();
176    guard.id = theme.id.to_string();
177    guard.label = theme.label.to_string();
178    guard.palette = theme.palette.clone();
179    guard.styles = styles;
180    Ok(())
181}
182
183/// Get the identifier of the active theme.
184pub fn active_theme_id() -> String {
185    ACTIVE.read().id.clone()
186}
187
188/// Get the human-readable label of the active theme.
189pub fn active_theme_label() -> String {
190    ACTIVE.read().label.clone()
191}
192
193/// Get the current styles cloned from the active theme.
194pub fn active_styles() -> ThemeStyles {
195    ACTIVE.read().styles.clone()
196}
197
198/// Slightly darkened accent color for banner-like copy.
199pub fn banner_color() -> RgbColor {
200    WELCOME_TOOL_COLOR
201}
202
203/// Slightly darkened accent style for banner-like copy.
204pub fn banner_style() -> Style {
205    Style::new()
206        .fg_color(Some(Color::Rgb(WELCOME_TOOL_COLOR)))
207        .bold()
208}
209
210/// Enumerate available theme identifiers.
211pub fn available_themes() -> Vec<&'static str> {
212    let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
213    keys.sort();
214    keys
215}
216
217/// Look up a theme label for display.
218pub fn theme_label(theme_id: &str) -> Option<&'static str> {
219    REGISTRY.get(theme_id).map(|definition| definition.label)
220}
221
222fn relative_luminance(color: RgbColor) -> f64 {
223    fn channel(value: u8) -> f64 {
224        let c = (value as f64) / 255.0;
225        if c <= 0.03928 {
226            c / 12.92
227        } else {
228            ((c + 0.055) / 1.055).powf(2.4)
229        }
230    }
231    let r = channel(color.0);
232    let g = channel(color.1);
233    let b = channel(color.2);
234    0.2126 * r + 0.7152 * g + 0.0722 * b
235}
236
237fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
238    let fg = relative_luminance(foreground);
239    let bg = relative_luminance(background);
240    let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
241    (lighter + 0.05) / (darker + 0.05)
242}
243
244fn ensure_contrast(
245    candidate: RgbColor,
246    background: RgbColor,
247    min_ratio: f64,
248    fallbacks: &[RgbColor],
249) -> RgbColor {
250    if contrast_ratio(candidate, background) >= min_ratio {
251        return candidate;
252    }
253    for &fallback in fallbacks {
254        if contrast_ratio(fallback, background) >= min_ratio {
255            return fallback;
256        }
257    }
258    candidate
259}
260
261fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
262    let ratio = ratio.clamp(0.0, 1.0);
263    let blend = |c: u8, t: u8| -> u8 {
264        let c = c as f64;
265        let t = t as f64;
266        ((c + (t - c) * ratio).round()).clamp(0.0, 255.0) as u8
267    };
268    RgbColor(
269        blend(color.0, target.0),
270        blend(color.1, target.1),
271        blend(color.2, target.2),
272    )
273}
274
275fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
276    mix(color, RgbColor(0xFF, 0xFF, 0xFF), ratio)
277}
278
279/// Resolve a theme identifier from configuration or CLI input.
280pub fn resolve_theme(preferred: Option<String>) -> String {
281    preferred
282        .and_then(|candidate| {
283            let trimmed = candidate.trim().to_lowercase();
284            if trimmed.is_empty() {
285                None
286            } else if REGISTRY.contains_key(trimmed.as_str()) {
287                Some(trimmed)
288            } else {
289                None
290            }
291        })
292        .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
293}
294
295/// Validate a theme and return its label for messaging.
296pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
297    REGISTRY
298        .get(theme_id)
299        .map(|definition| definition.label)
300        .context("Theme not found")
301}