Skip to main content

vtcode_commons/
diff_theme.rs

1//! Diff theme configuration and color palettes
2//!
3//! Provides terminal-adaptive styling that adjusts background tints based on:
4//!   1. [`DiffTheme`] (Dark/Light) — detected from terminal background
5//!   2. [`DiffColorLevel`] (TrueColor/Ansi256/Ansi16) — from terminal capability
6//!
7//! Colors are selected for WCAG AA accessibility contrast ratios (4.5:1 minimum).
8
9use anstyle::{Ansi256Color, AnsiColor, Color, RgbColor};
10
11use crate::ansi_capabilities::{ColorScheme, detect_color_scheme};
12use crate::color256_theme::adjust_index_for_theme;
13
14/// Terminal background theme for diff rendering.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum DiffTheme {
17    Dark,
18    Light,
19}
20
21impl DiffTheme {
22    /// Detect theme from the terminal environment.
23    pub fn detect() -> Self {
24        match detect_color_scheme() {
25            ColorScheme::Light => Self::Light,
26            ColorScheme::Dark | ColorScheme::Unknown => Self::Dark,
27        }
28    }
29
30    pub fn is_light(self) -> bool {
31        self == Self::Light
32    }
33}
34
35/// Terminal color capability level for palette selection.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum DiffColorLevel {
38    TrueColor,
39    Ansi256,
40    Ansi16,
41}
42
43impl DiffColorLevel {
44    /// Detect color level from terminal capabilities.
45    pub fn detect() -> Self {
46        let colorterm = std::env::var("COLORTERM").unwrap_or_default();
47        let term = std::env::var("TERM").unwrap_or_default();
48        let term_program = std::env::var("TERM_PROGRAM").ok();
49        let has_wt_session = std::env::var_os("WT_SESSION").is_some();
50        let has_force_color_override = std::env::var_os("FORCE_COLOR").is_some();
51
52        diff_color_level_for_terminal(
53            base_diff_color_level(&colorterm, &term),
54            term_program.as_deref(),
55            has_wt_session,
56            has_force_color_override,
57        )
58    }
59}
60
61fn base_diff_color_level(colorterm: &str, term: &str) -> DiffColorLevel {
62    let colorterm = colorterm.to_ascii_lowercase();
63    let term = term.to_ascii_lowercase();
64
65    if colorterm.contains("truecolor") || colorterm.contains("24bit") {
66        DiffColorLevel::TrueColor
67    } else if term.contains("256") {
68        DiffColorLevel::Ansi256
69    } else {
70        DiffColorLevel::Ansi16
71    }
72}
73
74fn diff_color_level_for_terminal(
75    base_level: DiffColorLevel,
76    term_program: Option<&str>,
77    has_wt_session: bool,
78    has_force_color_override: bool,
79) -> DiffColorLevel {
80    if has_force_color_override {
81        return base_level;
82    }
83
84    if has_wt_session || (base_level == DiffColorLevel::Ansi16 && is_windows_terminal(term_program))
85    {
86        return DiffColorLevel::TrueColor;
87    }
88
89    base_level
90}
91
92fn is_windows_terminal(term_program: Option<&str>) -> bool {
93    let Some(program) = term_program else {
94        return false;
95    };
96
97    let normalized = program.trim().to_ascii_lowercase();
98    normalized.contains("windows_terminal") || normalized.contains("windows terminal")
99}
100
101// ── Truecolor palette (WCAG AA compliant) ──────────────────────────────────
102
103// Dark theme: darker backgrounds with higher contrast for better readability
104// Green kept dark, red softened with lower saturation
105const DARK_TC_ADD_LINE_BG: (u8, u8, u8) = (25, 45, 35); // #192D23 - Dark teal green
106const DARK_TC_DEL_LINE_BG: (u8, u8, u8) = (90, 40, 40); // #5A2828 - Muted dark red (lower alpha feel)
107
108// Light theme: light backgrounds with sufficient contrast for dark text
109// Red background made more pastel/muted
110const LIGHT_TC_ADD_LINE_BG: (u8, u8, u8) = (215, 240, 215); // #D7F0D7 - Light green
111const LIGHT_TC_DEL_LINE_BG: (u8, u8, u8) = (255, 235, 235); // #FFEBEB - Soft pastel red (muted)
112const LIGHT_TC_ADD_NUM_BG: (u8, u8, u8) = (175, 225, 175); // #AFE1AF - Gutter green
113const LIGHT_TC_DEL_NUM_BG: (u8, u8, u8) = (250, 210, 210); // #FAD2D2 - Muted gutter red
114const LIGHT_TC_GUTTER_FG: (u8, u8, u8) = (25, 25, 25); // #191919 - Near-black for contrast
115
116// ── 256-color palette ──────────────────────────────────────────────────────
117
118const DARK_256_ADD_LINE_BG: u8 = 22; // DarkGreen
119const DARK_256_DEL_LINE_BG: u8 = 52; // DarkRed
120
121const LIGHT_256_ADD_LINE_BG: u8 = 194; // LightGreen
122const LIGHT_256_DEL_LINE_BG: u8 = 224; // LightRed/Pink
123const LIGHT_256_ADD_NUM_BG: u8 = 157; // SeaGreen
124const LIGHT_256_DEL_NUM_BG: u8 = 217; // LightPink
125const LIGHT_256_GUTTER_FG: u8 = 236; // DarkGray
126
127// ── Helper functions ───────────────────────────────────────────────────────
128
129fn rgb(t: (u8, u8, u8)) -> Color {
130    Color::Rgb(RgbColor(t.0, t.1, t.2))
131}
132
133fn indexed(i: u8, theme: DiffTheme) -> Color {
134    let adjusted = adjust_index_for_theme(i, theme.is_light());
135    Color::Ansi256(Ansi256Color(adjusted))
136}
137
138/// Get background color for addition lines based on theme and color level.
139pub fn diff_add_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
140    match (theme, level) {
141        (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_ADD_LINE_BG),
142        (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_ADD_LINE_BG, theme),
143        (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::Green),
144        (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_ADD_LINE_BG),
145        (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_ADD_LINE_BG, theme),
146        (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::BrightGreen),
147    }
148}
149
150/// Get background color for deletion lines based on theme and color level.
151pub fn diff_del_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
152    match (theme, level) {
153        (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_DEL_LINE_BG),
154        (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_DEL_LINE_BG, theme),
155        (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::Red),
156        (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_DEL_LINE_BG),
157        (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_DEL_LINE_BG, theme),
158        (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::BrightRed),
159    }
160}
161
162/// Get gutter foreground color for light theme (dark theme uses dimmed default).
163pub fn diff_gutter_fg_light(level: DiffColorLevel) -> Color {
164    match level {
165        DiffColorLevel::TrueColor => rgb(LIGHT_TC_GUTTER_FG),
166        DiffColorLevel::Ansi256 => indexed(LIGHT_256_GUTTER_FG, DiffTheme::Light),
167        DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::Black),
168    }
169}
170
171/// Get gutter background color for addition lines in light theme.
172pub fn diff_gutter_bg_add_light(level: DiffColorLevel) -> Color {
173    match level {
174        DiffColorLevel::TrueColor => rgb(LIGHT_TC_ADD_NUM_BG),
175        DiffColorLevel::Ansi256 => indexed(LIGHT_256_ADD_NUM_BG, DiffTheme::Light),
176        DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::BrightGreen),
177    }
178}
179
180/// Get gutter background color for deletion lines in light theme.
181pub fn diff_gutter_bg_del_light(level: DiffColorLevel) -> Color {
182    match level {
183        DiffColorLevel::TrueColor => rgb(LIGHT_TC_DEL_NUM_BG),
184        DiffColorLevel::Ansi256 => indexed(LIGHT_256_DEL_NUM_BG, DiffTheme::Light),
185        DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::BrightRed),
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn dark_truecolor_add_bg_is_rgb() {
195        let bg = diff_add_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
196        assert!(matches!(bg, Color::Rgb(RgbColor(25, 45, 35))));
197    }
198
199    #[test]
200    fn dark_truecolor_del_bg_is_rgb() {
201        let bg = diff_del_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
202        assert!(matches!(bg, Color::Rgb(RgbColor(90, 40, 40))));
203    }
204
205    #[test]
206    fn light_truecolor_add_bg_is_accessible() {
207        let bg = diff_add_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
208        assert!(matches!(bg, Color::Rgb(RgbColor(215, 240, 215))));
209    }
210
211    #[test]
212    fn light_truecolor_del_bg_is_accessible() {
213        let bg = diff_del_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
214        assert!(matches!(bg, Color::Rgb(RgbColor(255, 235, 235))));
215    }
216
217    #[test]
218    fn dark_256_uses_indexed_colors() {
219        let add = diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
220        let del = diff_del_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
221        assert!(matches!(add, Color::Ansi256(Ansi256Color(22))));
222        assert!(matches!(del, Color::Ansi256(Ansi256Color(52))));
223    }
224
225    #[test]
226    fn light_256_defaults_to_non_harmonious_adjustment() {
227        let add = diff_add_bg(DiffTheme::Light, DiffColorLevel::Ansi256);
228        let del = diff_del_bg(DiffTheme::Light, DiffColorLevel::Ansi256);
229        assert!(matches!(add, Color::Ansi256(Ansi256Color(22))));
230        assert!(matches!(del, Color::Ansi256(Ansi256Color(52))));
231    }
232
233    #[test]
234    fn dark_ansi16_uses_named_colors() {
235        let add = diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
236        let del = diff_del_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
237        assert_eq!(add, Color::Ansi(AnsiColor::Green));
238        assert_eq!(del, Color::Ansi(AnsiColor::Red));
239    }
240
241    #[test]
242    fn wt_session_promotes_ansi16_to_truecolor() {
243        assert_eq!(
244            diff_color_level_for_terminal(DiffColorLevel::Ansi16, None, true, false),
245            DiffColorLevel::TrueColor
246        );
247    }
248
249    #[test]
250    fn windows_terminal_term_program_promotes_ansi16_to_truecolor() {
251        assert_eq!(
252            diff_color_level_for_terminal(
253                DiffColorLevel::Ansi16,
254                Some("Windows_Terminal"),
255                false,
256                false
257            ),
258            DiffColorLevel::TrueColor
259        );
260    }
261
262    #[test]
263    fn non_windows_terminal_keeps_ansi16() {
264        assert_eq!(
265            diff_color_level_for_terminal(DiffColorLevel::Ansi16, Some("WezTerm"), false, false),
266            DiffColorLevel::Ansi16
267        );
268    }
269
270    #[test]
271    fn force_color_keeps_ansi16_when_wt_session_exists() {
272        assert_eq!(
273            diff_color_level_for_terminal(DiffColorLevel::Ansi16, None, true, true),
274            DiffColorLevel::Ansi16
275        );
276    }
277
278    #[test]
279    fn force_color_keeps_ansi256_when_wt_session_exists() {
280        assert_eq!(
281            diff_color_level_for_terminal(DiffColorLevel::Ansi256, None, true, true),
282            DiffColorLevel::Ansi256
283        );
284    }
285
286    #[test]
287    fn base_level_detects_truecolor_from_colorterm() {
288        assert_eq!(
289            base_diff_color_level("truecolor", "xterm-256color"),
290            DiffColorLevel::TrueColor
291        );
292    }
293
294    #[test]
295    fn base_level_detects_ansi256_from_term() {
296        assert_eq!(
297            base_diff_color_level("", "xterm-256color"),
298            DiffColorLevel::Ansi256
299        );
300    }
301
302    #[test]
303    fn base_level_falls_back_to_ansi16() {
304        assert_eq!(base_diff_color_level("", "xterm"), DiffColorLevel::Ansi16);
305    }
306}