Skip to main content

vtcode_ui/theme/
types.rs

1use anstyle::{Color, Effects, RgbColor, Style};
2use vtcode_config::constants::{defaults, ui};
3
4use crate::theme::color_math::{balance_text_luminance, ensure_contrast, lighten, mix, relative_luminance};
5
6/// Identifier for the default theme.
7pub const DEFAULT_THEME_ID: &str = defaults::DEFAULT_THEME;
8
9const DEFAULT_MIN_CONTRAST: f32 = ui::THEME_MIN_CONTRAST_RATIO;
10
11/// Color accessibility configuration loaded from vtcode.toml.
12#[derive(Clone, Debug)]
13pub struct ColorAccessibilityConfig {
14    pub minimum_contrast: f32,
15    pub bold_is_bright: bool,
16    pub safe_colors_only: bool,
17}
18
19impl Default for ColorAccessibilityConfig {
20    fn default() -> Self {
21        Self {
22            minimum_contrast: DEFAULT_MIN_CONTRAST,
23            bold_is_bright: false,
24            safe_colors_only: false,
25        }
26    }
27}
28
29/// Palette describing UI colors for the terminal experience.
30#[derive(Clone, Debug)]
31pub struct ThemePalette {
32    pub(crate) primary_accent: RgbColor,
33    pub(crate) background: RgbColor,
34    pub(crate) foreground: RgbColor,
35    pub(crate) secondary_accent: RgbColor,
36    pub(crate) alert: RgbColor,
37    pub(crate) logo_accent: RgbColor,
38}
39
40/// Shared computation context for theme color derivation.
41///
42/// Holds invariant parameters (background, min_contrast) that every color
43/// computation needs, eliminating repetitive argument passing across the
44/// 14+ color derivations in the theme pipeline.
45#[derive(Clone, Debug)]
46pub(crate) struct ColorContext {
47    pub background: RgbColor,
48    pub min_contrast: f32,
49    pub fallback_light: RgbColor,
50}
51
52impl ColorContext {
53    fn new(background: RgbColor, min_contrast: f32) -> Self {
54        Self {
55            background,
56            min_contrast,
57            fallback_light: RgbColor(
58                ui::THEME_COLOR_WHITE_RED,
59                ui::THEME_COLOR_WHITE_GREEN,
60                ui::THEME_COLOR_WHITE_BLUE,
61            ),
62        }
63    }
64
65    /// Ensure minimum contrast against background, then balance luminance
66    /// into the comfortable reading range. Used for text-content colors.
67    fn guaranteed_text_color(&self, candidate: RgbColor, fallbacks: &[RgbColor]) -> RgbColor {
68        let color = ensure_contrast(candidate, self.background, self.min_contrast, fallbacks);
69        balance_text_luminance(color, self.background, self.min_contrast)
70    }
71
72    /// Ensure minimum contrast against background only. Used for accent/UI
73    /// colors where luminance balancing would override the intended tint.
74    fn guaranteed_accent_color(&self, candidate: RgbColor, fallbacks: &[RgbColor]) -> RgbColor {
75        ensure_contrast(candidate, self.background, self.min_contrast, fallbacks)
76    }
77
78    /// 1. Main foreground text color.
79    fn compute_text_color(&self, foreground: RgbColor, secondary: RgbColor) -> RgbColor {
80        self.guaranteed_text_color(
81            foreground,
82            &[
83                lighten(foreground, ui::THEME_FOREGROUND_LIGHTEN_RATIO),
84                lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
85                self.fallback_light,
86            ],
87        )
88    }
89
90    /// 2. Info/muted text color (secondary accent adapted for readability).
91    fn compute_info_color(&self, secondary: RgbColor, text_color: RgbColor) -> RgbColor {
92        self.guaranteed_text_color(
93            secondary,
94            &[
95                lighten(secondary, ui::THEME_SECONDARY_LIGHTEN_RATIO),
96                text_color,
97                self.fallback_light,
98            ],
99        )
100    }
101
102    /// 3. Tool accent color (text_color lightened and contrast-ensured).
103    fn compute_tool_color(&self, text_color: RgbColor) -> RgbColor {
104        self.guaranteed_accent_color(
105            lighten(text_color, ui::THEME_MIX_RATIO),
106            &[
107                lighten(lighten(text_color, ui::THEME_MIX_RATIO), ui::THEME_TOOL_BODY_LIGHTEN_RATIO),
108                text_color,
109                self.fallback_light,
110            ],
111        )
112    }
113
114    /// 4. Tool body text color (subdued variant of tool accent).
115    fn compute_tool_body_color(&self, text_color: RgbColor) -> RgbColor {
116        let candidate = mix(lighten(text_color, ui::THEME_MIX_RATIO), text_color, ui::THEME_TOOL_BODY_MIX_RATIO);
117        self.guaranteed_accent_color(
118            candidate,
119            &[
120                lighten(lighten(text_color, ui::THEME_MIX_RATIO), ui::THEME_TOOL_BODY_LIGHTEN_RATIO),
121                text_color,
122                self.fallback_light,
123            ],
124        )
125    }
126
127    /// 5. PTY/shell output color — dimmed by blending tool_body toward the
128    ///    background, then balanced for readability.
129    fn compute_pty_output_color(&self, tool_body_color: RgbColor, text_color: RgbColor) -> RgbColor {
130        let candidate = mix(tool_body_color, self.background, ui::THEME_PTY_OUTPUT_MIX_RATIO);
131        self.guaranteed_text_color(candidate, &[tool_body_color, text_color])
132    }
133
134    /// 6. Response/assistant text color.
135    fn compute_response_color(&self, text_color: RgbColor) -> RgbColor {
136        self.guaranteed_text_color(
137            text_color,
138            &[
139                lighten(text_color, ui::THEME_RESPONSE_COLOR_LIGHTEN_RATIO),
140                self.fallback_light,
141            ],
142        )
143    }
144
145    /// 7. Reasoning text color (lightened text, DIMMED+ITALIC applied separately).
146    fn compute_reasoning_color(&self, text_color: RgbColor) -> RgbColor {
147        self.guaranteed_text_color(
148            lighten(text_color, 0.25),
149            &[lighten(text_color, 0.15), text_color, self.fallback_light],
150        )
151    }
152
153    /// 8. User input text color.
154    fn compute_user_color(&self, secondary: RgbColor, info_color: RgbColor, text_color: RgbColor) -> RgbColor {
155        self.guaranteed_text_color(
156            lighten(secondary, ui::THEME_USER_COLOR_LIGHTEN_RATIO),
157            &[
158                lighten(secondary, ui::THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO),
159                info_color,
160                text_color,
161            ],
162        )
163    }
164
165    /// 9. Alert/error color.
166    fn compute_alert_color(&self, alert: RgbColor, text_color: RgbColor) -> RgbColor {
167        self.guaranteed_text_color(
168            alert,
169            &[
170                lighten(alert, ui::THEME_LUMINANCE_LIGHTEN_RATIO),
171                self.fallback_light,
172                text_color,
173            ],
174        )
175    }
176
177    /// 9b. Warning color: dedicated amber semantics (not the per-theme brand
178    /// accent — `logo_accent` is white on `mono` and green on `nord`, both
179    /// wrong hues for a warning). The candidate is picked by background
180    /// scheme (bright amber on dark, dark amber on light) and contrast is
181    /// enforced via the shared guaranteed-text pipeline.
182    fn compute_warning_color(&self, logo_accent: RgbColor, text_color: RgbColor) -> RgbColor {
183        const AMBER: RgbColor = RgbColor(0xD9, 0x9A, 0x4E);
184        const DARK_AMBER: RgbColor = RgbColor(0x98, 0x63, 0x11);
185        let (candidate, alternate) = if relative_luminance(self.background) < 0.5 {
186            (AMBER, DARK_AMBER)
187        } else {
188            (DARK_AMBER, AMBER)
189        };
190        self.guaranteed_text_color(
191            candidate,
192            &[
193                alternate,
194                lighten(candidate, ui::THEME_LUMINANCE_LIGHTEN_RATIO),
195                logo_accent,
196                self.fallback_light,
197                text_color,
198            ],
199        )
200    }
201
202    /// 10. Primary accent (for UI chrome, not body text).
203    fn compute_primary_color(&self, primary: RgbColor, text_color: RgbColor) -> RgbColor {
204        self.guaranteed_text_color(
205            ensure_contrast(primary, self.background, self.min_contrast, &[text_color]),
206            &[text_color],
207        )
208    }
209
210    /// 11. Secondary accent (for UI chrome).
211    fn compute_secondary_color(&self, secondary: RgbColor, info_color: RgbColor, text_color: RgbColor) -> RgbColor {
212        self.guaranteed_text_color(
213            ensure_contrast(secondary, self.background, self.min_contrast, &[info_color, text_color]),
214            &[info_color, text_color],
215        )
216    }
217
218    /// 12. Logo accent color.
219    fn compute_logo_color(&self, logo_accent: RgbColor, secondary_color: RgbColor, text_color: RgbColor) -> RgbColor {
220        self.guaranteed_text_color(
221            ensure_contrast(logo_accent, self.background, self.min_contrast, &[secondary_color, text_color]),
222            &[secondary_color, text_color],
223        )
224    }
225
226    /// 13. Status banner color (lightened primary).
227    fn compute_status_color(&self, primary_color: RgbColor, info_color: RgbColor, text_color: RgbColor) -> RgbColor {
228        self.guaranteed_accent_color(
229            lighten(primary_color, ui::THEME_PRIMARY_STATUS_LIGHTEN_RATIO),
230            &[
231                lighten(primary_color, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
232                info_color,
233                text_color,
234            ],
235        )
236    }
237
238    /// 14. MCP badge color (lightened logo accent).
239    fn compute_mcp_color(&self, logo_color: RgbColor, info_color: RgbColor) -> RgbColor {
240        self.guaranteed_accent_color(
241            lighten(logo_color, ui::THEME_SECONDARY_LIGHTEN_RATIO),
242            &[
243                lighten(logo_color, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO),
244                info_color,
245                self.fallback_light,
246            ],
247        )
248    }
249}
250
251impl ThemePalette {
252    fn style_from(color: RgbColor, bold: bool, bold_is_bright: bool) -> Style {
253        let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
254        if bold && !bold_is_bright {
255            style = style.bold();
256        }
257        style
258    }
259
260    pub(crate) fn build_styles_with_accessibility(&self, accessibility: &ColorAccessibilityConfig) -> ThemeStyles {
261        let ctx = ColorContext::new(self.background, accessibility.minimum_contrast);
262        let bold_is_bright = accessibility.bold_is_bright;
263
264        let text = ctx.compute_text_color(self.foreground, self.secondary_accent);
265        let info = ctx.compute_info_color(self.secondary_accent, text);
266        let tool_body = ctx.compute_tool_body_color(text);
267        let pty = ctx.compute_pty_output_color(tool_body, text);
268        let primary = ctx.compute_primary_color(self.primary_accent, text);
269        let secondary = ctx.compute_secondary_color(self.secondary_accent, info, text);
270        let logo = ctx.compute_logo_color(self.logo_accent, secondary, text);
271
272        ThemeStyles {
273            info: Self::style_from(info, true, bold_is_bright),
274            error: Self::style_from(ctx.compute_alert_color(self.alert, text), true, bold_is_bright),
275            warning: Self::style_from(ctx.compute_warning_color(self.logo_accent, text), true, bold_is_bright),
276            output: Self::style_from(text, false, bold_is_bright),
277            response: Self::style_from(ctx.compute_response_color(text), false, bold_is_bright),
278            reasoning: Self::style_from(ctx.compute_reasoning_color(text), false, bold_is_bright)
279                .effects(Effects::DIMMED | Effects::ITALIC),
280            tool: Style::new().fg_color(Some(Color::Rgb(ctx.compute_tool_color(text)))),
281            tool_detail: Style::new().fg_color(Some(Color::Rgb(tool_body))),
282            tool_output: Style::new(),
283            pty_output: Style::new().fg_color(Some(Color::Rgb(pty))),
284            status: Self::style_from(ctx.compute_status_color(primary, info, text), true, bold_is_bright),
285            mcp: Self::style_from(ctx.compute_mcp_color(logo, info), true, bold_is_bright),
286            user: Self::style_from(ctx.compute_user_color(self.secondary_accent, info, text), false, bold_is_bright),
287            primary: Self::style_from(primary, false, bold_is_bright),
288            secondary: Self::style_from(secondary, false, bold_is_bright),
289            background: Color::Rgb(self.background),
290            foreground: Color::Rgb(text),
291        }
292    }
293}
294
295/// Styles computed from palette colors.
296#[derive(Clone, Debug)]
297pub struct ThemeStyles {
298    pub info: Style,
299    pub error: Style,
300    pub warning: Style,
301    pub output: Style,
302    pub response: Style,
303    pub reasoning: Style,
304    pub tool: Style,
305    pub tool_detail: Style,
306    pub tool_output: Style,
307    pub pty_output: Style,
308    pub status: Style,
309    pub mcp: Style,
310    pub user: Style,
311    pub primary: Style,
312    pub secondary: Style,
313    pub background: Color,
314    pub foreground: Color,
315}
316
317#[derive(Clone, Debug)]
318pub struct ThemeDefinition {
319    pub(crate) id: &'static str,
320    pub(crate) label: &'static str,
321    pub(crate) palette: ThemePalette,
322}
323
324/// Logical grouping of built-in themes.
325#[derive(Clone, Debug, PartialEq, Eq)]
326pub struct ThemeSuite {
327    pub(crate) id: &'static str,
328    pub(crate) label: &'static str,
329    pub(crate) theme_ids: Vec<&'static str>,
330}
331
332/// Theme validation result.
333#[derive(Debug, Clone)]
334pub struct ThemeValidationResult {
335    pub(crate) is_valid: bool,
336    pub warnings: Vec<String>,
337    pub(crate) errors: Vec<String>,
338}