Skip to main content

oxicode_vtui/theme/
types.rs

1use anstyle::{Color, Effects, RgbColor, Style};
2use oxicode_vtui_compat::constants::{defaults, ui};
3
4use crate::theme::color_math::{balance_text_luminance, ensure_contrast, lighten, mix};
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 * 255.0) as u8,
59                (ui::THEME_COLOR_WHITE_GREEN * 255.0) as u8,
60                (ui::THEME_COLOR_WHITE_BLUE * 255.0) as u8,
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(
108                    lighten(text_color, ui::THEME_MIX_RATIO),
109                    ui::THEME_TOOL_BODY_LIGHTEN_RATIO,
110                ),
111                text_color,
112                self.fallback_light,
113            ],
114        )
115    }
116
117    /// 4. Tool body text color (subdued variant of tool accent).
118    fn compute_tool_body_color(&self, text_color: RgbColor) -> RgbColor {
119        let candidate = mix(
120            lighten(text_color, ui::THEME_MIX_RATIO),
121            text_color,
122            ui::THEME_TOOL_BODY_MIX_RATIO,
123        );
124        self.guaranteed_accent_color(
125            candidate,
126            &[
127                lighten(
128                    lighten(text_color, ui::THEME_MIX_RATIO),
129                    ui::THEME_TOOL_BODY_LIGHTEN_RATIO,
130                ),
131                text_color,
132                self.fallback_light,
133            ],
134        )
135    }
136
137    /// 5. PTY/shell output color — dimmed by blending tool_body toward the
138    ///    background, then balanced for readability.
139    fn compute_pty_output_color(
140        &self,
141        tool_body_color: RgbColor,
142        text_color: RgbColor,
143    ) -> RgbColor {
144        let candidate = mix(
145            tool_body_color,
146            self.background,
147            ui::THEME_PTY_OUTPUT_MIX_RATIO,
148        );
149        self.guaranteed_text_color(candidate, &[tool_body_color, text_color])
150    }
151
152    /// 6. Response/assistant text color.
153    fn compute_response_color(&self, text_color: RgbColor) -> RgbColor {
154        self.guaranteed_text_color(
155            text_color,
156            &[
157                lighten(text_color, ui::THEME_RESPONSE_COLOR_LIGHTEN_RATIO),
158                self.fallback_light,
159            ],
160        )
161    }
162
163    /// 7. Reasoning text color (lightened text, DIMMED+ITALIC applied separately).
164    fn compute_reasoning_color(&self, text_color: RgbColor) -> RgbColor {
165        self.guaranteed_text_color(
166            lighten(text_color, 0.25),
167            &[lighten(text_color, 0.15), text_color, self.fallback_light],
168        )
169    }
170
171    /// 8. User input text color.
172    fn compute_user_color(
173        &self,
174        secondary: RgbColor,
175        info_color: RgbColor,
176        text_color: RgbColor,
177    ) -> RgbColor {
178        self.guaranteed_text_color(
179            lighten(secondary, ui::THEME_USER_COLOR_LIGHTEN_RATIO),
180            &[
181                lighten(secondary, ui::THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO),
182                info_color,
183                text_color,
184            ],
185        )
186    }
187
188    /// 9. Alert/error color.
189    fn compute_alert_color(&self, alert: RgbColor, text_color: RgbColor) -> RgbColor {
190        self.guaranteed_text_color(
191            alert,
192            &[
193                lighten(alert, ui::THEME_LUMINANCE_LIGHTEN_RATIO),
194                self.fallback_light,
195                text_color,
196            ],
197        )
198    }
199
200    /// 10. Primary accent (for UI chrome, not body text).
201    fn compute_primary_color(&self, primary: RgbColor, text_color: RgbColor) -> RgbColor {
202        self.guaranteed_text_color(
203            ensure_contrast(primary, self.background, self.min_contrast, &[text_color]),
204            &[text_color],
205        )
206    }
207
208    /// 11. Secondary accent (for UI chrome).
209    fn compute_secondary_color(
210        &self,
211        secondary: RgbColor,
212        info_color: RgbColor,
213        text_color: RgbColor,
214    ) -> RgbColor {
215        self.guaranteed_text_color(
216            ensure_contrast(
217                secondary,
218                self.background,
219                self.min_contrast,
220                &[info_color, text_color],
221            ),
222            &[info_color, text_color],
223        )
224    }
225
226    /// 12. Logo accent color.
227    fn compute_logo_color(
228        &self,
229        logo_accent: RgbColor,
230        secondary_color: RgbColor,
231        text_color: RgbColor,
232    ) -> RgbColor {
233        self.guaranteed_text_color(
234            ensure_contrast(
235                logo_accent,
236                self.background,
237                self.min_contrast,
238                &[secondary_color, text_color],
239            ),
240            &[secondary_color, text_color],
241        )
242    }
243
244    /// 13. Status banner color (lightened primary).
245    fn compute_status_color(
246        &self,
247        primary_color: RgbColor,
248        info_color: RgbColor,
249        text_color: RgbColor,
250    ) -> RgbColor {
251        self.guaranteed_accent_color(
252            lighten(primary_color, ui::THEME_PRIMARY_STATUS_LIGHTEN_RATIO),
253            &[
254                lighten(
255                    primary_color,
256                    ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO,
257                ),
258                info_color,
259                text_color,
260            ],
261        )
262    }
263
264    /// 14. MCP badge color (lightened logo accent).
265    fn compute_mcp_color(&self, logo_color: RgbColor, info_color: RgbColor) -> RgbColor {
266        self.guaranteed_accent_color(
267            lighten(logo_color, ui::THEME_SECONDARY_LIGHTEN_RATIO),
268            &[
269                lighten(logo_color, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO),
270                info_color,
271                self.fallback_light,
272            ],
273        )
274    }
275}
276
277impl ThemePalette {
278    fn style_from(color: RgbColor, bold: bool, bold_is_bright: bool) -> Style {
279        let mut style = Style::new().fg_color(Some(Color::Rgb(color)));
280        if bold && !bold_is_bright {
281            style = style.bold();
282        }
283        style
284    }
285
286    pub(crate) fn build_styles_with_accessibility(
287        &self,
288        accessibility: &ColorAccessibilityConfig,
289    ) -> ThemeStyles {
290        let ctx = ColorContext::new(self.background, accessibility.minimum_contrast);
291        let bold_is_bright = accessibility.bold_is_bright;
292
293        let text = ctx.compute_text_color(self.foreground, self.secondary_accent);
294        let info = ctx.compute_info_color(self.secondary_accent, text);
295        let tool_body = ctx.compute_tool_body_color(text);
296        let pty = ctx.compute_pty_output_color(tool_body, text);
297        let primary = ctx.compute_primary_color(self.primary_accent, text);
298        let secondary = ctx.compute_secondary_color(self.secondary_accent, info, text);
299        let logo = ctx.compute_logo_color(self.logo_accent, secondary, text);
300
301        ThemeStyles {
302            info: Self::style_from(info, true, bold_is_bright),
303            error: Self::style_from(
304                ctx.compute_alert_color(self.alert, text),
305                true,
306                bold_is_bright,
307            ),
308            output: Self::style_from(text, false, bold_is_bright),
309            response: Self::style_from(ctx.compute_response_color(text), false, bold_is_bright),
310            reasoning: Self::style_from(ctx.compute_reasoning_color(text), false, bold_is_bright)
311                .effects(Effects::DIMMED | Effects::ITALIC),
312            tool: Style::new().fg_color(Some(Color::Rgb(ctx.compute_tool_color(text)))),
313            tool_detail: Style::new().fg_color(Some(Color::Rgb(tool_body))),
314            tool_output: Style::new(),
315            pty_output: Style::new().fg_color(Some(Color::Rgb(pty))),
316            status: Self::style_from(
317                ctx.compute_status_color(primary, info, text),
318                true,
319                bold_is_bright,
320            ),
321            mcp: Self::style_from(ctx.compute_mcp_color(logo, info), true, bold_is_bright),
322            user: Self::style_from(
323                ctx.compute_user_color(self.secondary_accent, info, text),
324                false,
325                bold_is_bright,
326            ),
327            primary: Self::style_from(primary, false, bold_is_bright),
328            secondary: Self::style_from(secondary, false, bold_is_bright),
329            background: Color::Rgb(self.background),
330            foreground: Color::Rgb(text),
331        }
332    }
333}
334
335/// Styles computed from palette colors.
336#[derive(Clone, Debug)]
337pub struct ThemeStyles {
338    pub info: Style,
339    pub error: Style,
340    pub output: Style,
341    pub response: Style,
342    pub reasoning: Style,
343    pub tool: Style,
344    pub tool_detail: Style,
345    pub tool_output: Style,
346    pub pty_output: Style,
347    pub status: Style,
348    pub mcp: Style,
349    pub user: Style,
350    pub primary: Style,
351    pub secondary: Style,
352    pub background: Color,
353    pub foreground: Color,
354}
355
356#[derive(Clone, Debug)]
357pub struct ThemeDefinition {
358    pub(crate) id: &'static str,
359    pub(crate) label: &'static str,
360    pub(crate) palette: ThemePalette,
361}
362
363/// Logical grouping of built-in themes.
364#[derive(Clone, Debug, PartialEq, Eq)]
365pub struct ThemeSuite {
366    pub(crate) id: &'static str,
367    pub(crate) label: &'static str,
368    pub(crate) theme_ids: Vec<&'static str>,
369}
370
371/// Theme validation result.
372#[derive(Debug, Clone)]
373pub struct ThemeValidationResult {
374    pub(crate) is_valid: bool,
375    pub warnings: Vec<String>,
376    pub(crate) errors: Vec<String>,
377}