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