vtcode_core/ui/
theme.rs

1use anstyle::{Color, Effects, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use catppuccin::PALETTE;
4use once_cell::sync::Lazy;
5use parking_lot::RwLock;
6use std::collections::HashMap;
7
8use crate::config::constants::defaults;
9
10/// Identifier for the default theme.
11pub const DEFAULT_THEME_ID: &str = defaults::DEFAULT_THEME;
12
13const MIN_CONTRAST: f64 = 4.5;
14
15/// Palette describing UI colors for the terminal experience.
16#[derive(Clone, Debug)]
17pub struct ThemePalette {
18    pub primary_accent: RgbColor,
19    pub background: RgbColor,
20    pub foreground: RgbColor,
21    pub secondary_accent: RgbColor,
22    pub alert: RgbColor,
23    pub logo_accent: RgbColor,
24}
25
26impl ThemePalette {
27    fn style_from(color: RgbColor, bold: bool) -> Style {
28        let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
29        if bold {
30            style = style.bold();
31        }
32        style
33    }
34
35    fn build_styles(&self) -> ThemeStyles {
36        let primary = self.primary_accent;
37        let background = self.background;
38        let secondary = self.secondary_accent;
39
40        let fallback_light = RgbColor(0xFF, 0xFF, 0xFF);
41
42        let text_color = ensure_contrast(
43            self.foreground,
44            background,
45            MIN_CONTRAST,
46            &[
47                lighten(self.foreground, 0.25),
48                lighten(secondary, 0.2),
49                fallback_light,
50            ],
51        );
52        let info_color = ensure_contrast(
53            secondary,
54            background,
55            MIN_CONTRAST,
56            &[lighten(secondary, 0.2), text_color, fallback_light],
57        );
58        let tool_candidate = mix(self.alert, background, 0.35);
59        let tool_color = ensure_contrast(
60            tool_candidate,
61            background,
62            MIN_CONTRAST,
63            &[self.alert, mix(self.alert, secondary, 0.25), fallback_light],
64        );
65        let tool_body_candidate = mix(tool_color, text_color, 0.35);
66        let tool_body_color = ensure_contrast(
67            tool_body_candidate,
68            background,
69            MIN_CONTRAST,
70            &[lighten(tool_color, 0.2), text_color, fallback_light],
71        );
72        let tool_style = Style::new().fg_color(Some(Color::Rgb(tool_color))).bold();
73        let tool_detail_style = Style::new()
74            .fg_color(Some(Color::Rgb(tool_body_color)))
75            .effects(Effects::ITALIC);
76        let response_color = ensure_contrast(
77            text_color,
78            background,
79            MIN_CONTRAST,
80            &[lighten(text_color, 0.15), fallback_light],
81        );
82        let reasoning_color = ensure_contrast(
83            lighten(secondary, 0.3),
84            background,
85            MIN_CONTRAST,
86            &[lighten(secondary, 0.15), text_color, fallback_light],
87        );
88        let reasoning_style = Self::style_from(reasoning_color, false).effects(Effects::ITALIC);
89        let user_color = ensure_contrast(
90            lighten(primary, 0.25),
91            background,
92            MIN_CONTRAST,
93            &[lighten(secondary, 0.15), info_color, text_color],
94        );
95        let alert_color = ensure_contrast(
96            self.alert,
97            background,
98            MIN_CONTRAST,
99            &[lighten(self.alert, 0.2), fallback_light, text_color],
100        );
101
102        ThemeStyles {
103            info: Self::style_from(info_color, true),
104            error: Self::style_from(alert_color, true),
105            output: Self::style_from(text_color, false),
106            response: Self::style_from(response_color, false),
107            reasoning: reasoning_style,
108            tool: tool_style,
109            tool_detail: tool_detail_style,
110            status: Self::style_from(
111                ensure_contrast(
112                    lighten(primary, 0.35),
113                    background,
114                    MIN_CONTRAST,
115                    &[lighten(primary, 0.5), info_color, text_color],
116                ),
117                true,
118            ),
119            mcp: Self::style_from(
120                ensure_contrast(
121                    lighten(self.logo_accent, 0.2),
122                    background,
123                    MIN_CONTRAST,
124                    &[lighten(self.logo_accent, 0.35), info_color, fallback_light],
125                ),
126                true,
127            ),
128            user: Self::style_from(user_color, false),
129            primary: Self::style_from(primary, false),
130            secondary: Self::style_from(secondary, false),
131            background: Color::Rgb(background),
132            foreground: Color::Rgb(text_color),
133        }
134    }
135}
136
137/// Styles computed from palette colors.
138#[derive(Clone, Debug)]
139pub struct ThemeStyles {
140    pub info: Style,
141    pub error: Style,
142    pub output: Style,
143    pub response: Style,
144    pub reasoning: Style,
145    pub tool: Style,
146    pub tool_detail: Style,
147    pub status: Style,
148    pub mcp: Style,
149    pub user: Style,
150    pub primary: Style,
151    pub secondary: Style,
152    pub background: Color,
153    pub foreground: Color,
154}
155
156#[derive(Clone, Debug)]
157pub struct ThemeDefinition {
158    pub id: &'static str,
159    pub label: &'static str,
160    pub palette: ThemePalette,
161}
162
163#[derive(Clone, Debug)]
164struct ActiveTheme {
165    id: String,
166    label: String,
167    palette: ThemePalette,
168    styles: ThemeStyles,
169}
170
171#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
172enum CatppuccinFlavorKind {
173    Latte,
174    Frappe,
175    Macchiato,
176    Mocha,
177}
178
179impl CatppuccinFlavorKind {
180    const fn id(self) -> &'static str {
181        match self {
182            CatppuccinFlavorKind::Latte => "catppuccin-latte",
183            CatppuccinFlavorKind::Frappe => "catppuccin-frappe",
184            CatppuccinFlavorKind::Macchiato => "catppuccin-macchiato",
185            CatppuccinFlavorKind::Mocha => "catppuccin-mocha",
186        }
187    }
188
189    const fn label(self) -> &'static str {
190        match self {
191            CatppuccinFlavorKind::Latte => "Catppuccin Latte",
192            CatppuccinFlavorKind::Frappe => "Catppuccin Frappé",
193            CatppuccinFlavorKind::Macchiato => "Catppuccin Macchiato",
194            CatppuccinFlavorKind::Mocha => "Catppuccin Mocha",
195        }
196    }
197
198    fn flavor(self) -> catppuccin::Flavor {
199        match self {
200            CatppuccinFlavorKind::Latte => PALETTE.latte,
201            CatppuccinFlavorKind::Frappe => PALETTE.frappe,
202            CatppuccinFlavorKind::Macchiato => PALETTE.macchiato,
203            CatppuccinFlavorKind::Mocha => PALETTE.mocha,
204        }
205    }
206}
207
208static CATPPUCCIN_FLAVORS: &[CatppuccinFlavorKind] = &[
209    CatppuccinFlavorKind::Latte,
210    CatppuccinFlavorKind::Frappe,
211    CatppuccinFlavorKind::Macchiato,
212    CatppuccinFlavorKind::Mocha,
213];
214
215static REGISTRY: Lazy<HashMap<&'static str, ThemeDefinition>> = Lazy::new(|| {
216    let mut map = HashMap::new();
217    map.insert(
218        "ciapre-dark",
219        ThemeDefinition {
220            id: "ciapre-dark",
221            label: "Ciapre Dark",
222            palette: ThemePalette {
223                primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
224                background: RgbColor(0x26, 0x26, 0x26),
225                foreground: RgbColor(0xBF, 0xB3, 0x8F),
226                secondary_accent: RgbColor(0xD9, 0x9A, 0x4E),
227                alert: RgbColor(0xFF, 0x8A, 0x8A),
228                logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
229            },
230        },
231    );
232    map.insert(
233        "ciapre-blue",
234        ThemeDefinition {
235            id: "ciapre-blue",
236            label: "Ciapre Blue",
237            palette: ThemePalette {
238                primary_accent: RgbColor(0xBF, 0xB3, 0x8F),
239                background: RgbColor(0x17, 0x1C, 0x26),
240                foreground: RgbColor(0xBF, 0xB3, 0x8F),
241                secondary_accent: RgbColor(0xBF, 0xB3, 0x8F),
242                alert: RgbColor(0xFF, 0x8A, 0x8A),
243                logo_accent: RgbColor(0xD9, 0x9A, 0x4E),
244            },
245        },
246    );
247    register_catppuccin_themes(&mut map);
248    map
249});
250
251fn register_catppuccin_themes(map: &mut HashMap<&'static str, ThemeDefinition>) {
252    for &flavor_kind in CATPPUCCIN_FLAVORS {
253        let flavor = flavor_kind.flavor();
254        let theme_definition = ThemeDefinition {
255            id: flavor_kind.id(),
256            label: flavor_kind.label(),
257            palette: catppuccin_palette(flavor),
258        };
259        map.insert(flavor_kind.id(), theme_definition);
260    }
261}
262
263fn catppuccin_palette(flavor: catppuccin::Flavor) -> ThemePalette {
264    let colors = flavor.colors;
265    ThemePalette {
266        primary_accent: catppuccin_rgb(colors.lavender),
267        background: catppuccin_rgb(colors.base),
268        foreground: catppuccin_rgb(colors.text),
269        secondary_accent: catppuccin_rgb(colors.sapphire),
270        alert: catppuccin_rgb(colors.red),
271        logo_accent: catppuccin_rgb(colors.peach),
272    }
273}
274
275fn catppuccin_rgb(color: catppuccin::Color) -> RgbColor {
276    RgbColor(color.rgb.r, color.rgb.g, color.rgb.b)
277}
278
279static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
280    let default = REGISTRY
281        .get(DEFAULT_THEME_ID)
282        .expect("default theme must exist");
283    let styles = default.palette.build_styles();
284    RwLock::new(ActiveTheme {
285        id: default.id.to_string(),
286        label: default.label.to_string(),
287        palette: default.palette.clone(),
288        styles,
289    })
290});
291
292/// Set the active theme by identifier.
293pub fn set_active_theme(theme_id: &str) -> Result<()> {
294    let id_lc = theme_id.trim().to_lowercase();
295    let theme = REGISTRY
296        .get(id_lc.as_str())
297        .ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
298
299    let styles = theme.palette.build_styles();
300    let mut guard = ACTIVE.write();
301    guard.id = theme.id.to_string();
302    guard.label = theme.label.to_string();
303    guard.palette = theme.palette.clone();
304    guard.styles = styles;
305    Ok(())
306}
307
308/// Get the identifier of the active theme.
309pub fn active_theme_id() -> String {
310    ACTIVE.read().id.clone()
311}
312
313/// Get the human-readable label of the active theme.
314pub fn active_theme_label() -> String {
315    ACTIVE.read().label.clone()
316}
317
318/// Get the current styles cloned from the active theme.
319pub fn active_styles() -> ThemeStyles {
320    ACTIVE.read().styles.clone()
321}
322
323/// Slightly adjusted accent color for banner-like copy.
324pub fn banner_color() -> RgbColor {
325    let guard = ACTIVE.read();
326    let accent = guard.palette.logo_accent;
327    let secondary = guard.palette.secondary_accent;
328    let background = guard.palette.background;
329    drop(guard);
330
331    let candidate = lighten(accent, 0.35);
332    ensure_contrast(
333        candidate,
334        background,
335        MIN_CONTRAST,
336        &[lighten(accent, 0.5), lighten(secondary, 0.25), accent],
337    )
338}
339
340/// Slightly darkened accent style for banner-like copy.
341pub fn banner_style() -> Style {
342    let accent = banner_color();
343    Style::new().fg_color(Some(Color::Rgb(accent))).bold()
344}
345
346/// Accent color for the startup banner logo.
347pub fn logo_accent_color() -> RgbColor {
348    ACTIVE.read().palette.logo_accent
349}
350
351/// Enumerate available theme identifiers.
352pub fn available_themes() -> Vec<&'static str> {
353    let mut keys: Vec<_> = REGISTRY.keys().copied().collect();
354    keys.sort();
355    keys
356}
357
358/// Look up a theme label for display.
359pub fn theme_label(theme_id: &str) -> Option<&'static str> {
360    REGISTRY.get(theme_id).map(|definition| definition.label)
361}
362
363fn relative_luminance(color: RgbColor) -> f64 {
364    fn channel(value: u8) -> f64 {
365        let c = (value as f64) / 255.0;
366        if c <= 0.03928 {
367            c / 12.92
368        } else {
369            ((c + 0.055) / 1.055).powf(2.4)
370        }
371    }
372    let r = channel(color.0);
373    let g = channel(color.1);
374    let b = channel(color.2);
375    0.2126 * r + 0.7152 * g + 0.0722 * b
376}
377
378fn contrast_ratio(foreground: RgbColor, background: RgbColor) -> f64 {
379    let fg = relative_luminance(foreground);
380    let bg = relative_luminance(background);
381    let (lighter, darker) = if fg > bg { (fg, bg) } else { (bg, fg) };
382    (lighter + 0.05) / (darker + 0.05)
383}
384
385fn ensure_contrast(
386    candidate: RgbColor,
387    background: RgbColor,
388    min_ratio: f64,
389    fallbacks: &[RgbColor],
390) -> RgbColor {
391    if contrast_ratio(candidate, background) >= min_ratio {
392        return candidate;
393    }
394    for &fallback in fallbacks {
395        if contrast_ratio(fallback, background) >= min_ratio {
396            return fallback;
397        }
398    }
399    candidate
400}
401
402fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
403    let ratio = ratio.clamp(0.0, 1.0);
404    let blend = |c: u8, t: u8| -> u8 {
405        let c = c as f64;
406        let t = t as f64;
407        ((c + (t - c) * ratio).round()).clamp(0.0, 255.0) as u8
408    };
409    RgbColor(
410        blend(color.0, target.0),
411        blend(color.1, target.1),
412        blend(color.2, target.2),
413    )
414}
415
416fn lighten(color: RgbColor, ratio: f64) -> RgbColor {
417    mix(color, RgbColor(0xFF, 0xFF, 0xFF), ratio)
418}
419
420/// Resolve a theme identifier from configuration or CLI input.
421pub fn resolve_theme(preferred: Option<String>) -> String {
422    preferred
423        .and_then(|candidate| {
424            let trimmed = candidate.trim().to_lowercase();
425            if trimmed.is_empty() {
426                None
427            } else if REGISTRY.contains_key(trimmed.as_str()) {
428                Some(trimmed)
429            } else {
430                None
431            }
432        })
433        .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
434}
435
436/// Validate a theme and return its label for messaging.
437pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
438    REGISTRY
439        .get(theme_id)
440        .map(|definition| definition.label)
441        .context("Theme not found")
442}