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