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
6pub const DEFAULT_THEME_ID: &str = defaults::DEFAULT_THEME;
8
9const DEFAULT_MIN_CONTRAST: f32 = ui::THEME_MIN_CONTRAST_RATIO;
10
11#[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#[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#[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 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 fn guaranteed_accent_color(&self, candidate: RgbColor, fallbacks: &[RgbColor]) -> RgbColor {
75 ensure_contrast(candidate, self.background, self.min_contrast, fallbacks)
76 }
77
78 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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#[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#[derive(Debug, Clone)]
334pub struct ThemeValidationResult {
335 pub(crate) is_valid: bool,
336 pub warnings: Vec<String>,
337 pub(crate) errors: Vec<String>,
338}