vtcode_core/ui/
theme.rs

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