Skip to main content

vtcode_commons/
diff_theme.rs

1//! Diff theme configuration and color palettes
2//!
3//! Uses subtle red/green tints for diff line backgrounds.
4
5use anstyle::{AnsiColor, Color};
6
7use crate::ansi_capabilities::{ColorScheme, detect_color_scheme};
8use crate::color_policy::color_output_enabled;
9use crate::color256_theme::rgb_to_ansi256_for_theme;
10
11/// Terminal background theme for diff rendering.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum DiffTheme {
14    Dark,
15    Light,
16}
17
18impl DiffTheme {
19    /// Detect theme from the terminal environment.
20    pub fn detect() -> Self {
21        match detect_color_scheme() {
22            ColorScheme::Light => Self::Light,
23            ColorScheme::Dark | ColorScheme::Unknown => Self::Dark,
24        }
25    }
26
27    pub fn is_light(self) -> bool {
28        self == Self::Light
29    }
30}
31
32/// Terminal color capability level for palette selection.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum DiffColorLevel {
35    TrueColor,
36    Ansi256,
37    Ansi16,
38}
39
40impl DiffColorLevel {
41    /// Detect color level from terminal capabilities.
42    pub fn detect() -> Self {
43        // Keep diff backgrounds foreground-only when the process has opted
44        // out of ANSI color. Mapping no-color to ANSI16 also keeps all
45        // renderer-specific style resolvers on the same fallback path.
46        if !color_output_enabled() {
47            return Self::Ansi16;
48        }
49        let colorterm = std::env::var("COLORTERM").unwrap_or_default();
50        let term = std::env::var("TERM").unwrap_or_default();
51        let term_program = std::env::var("TERM_PROGRAM").ok();
52        let has_wt_session = std::env::var_os("WT_SESSION").is_some();
53        let has_force_color_override = std::env::var_os("FORCE_COLOR").is_some();
54
55        diff_color_level_for_terminal(
56            base_diff_color_level(&colorterm, &term),
57            term_program.as_deref(),
58            has_wt_session,
59            has_force_color_override,
60        )
61    }
62}
63
64fn base_diff_color_level(colorterm: &str, term: &str) -> DiffColorLevel {
65    let colorterm = colorterm.to_ascii_lowercase();
66    let term = term.to_ascii_lowercase();
67
68    if colorterm.contains("truecolor") || colorterm.contains("24bit") {
69        DiffColorLevel::TrueColor
70    } else if term.contains("256") {
71        DiffColorLevel::Ansi256
72    } else {
73        DiffColorLevel::Ansi16
74    }
75}
76
77fn diff_color_level_for_terminal(
78    base_level: DiffColorLevel,
79    term_program: Option<&str>,
80    has_wt_session: bool,
81    has_force_color_override: bool,
82) -> DiffColorLevel {
83    if has_force_color_override {
84        return base_level;
85    }
86
87    if has_wt_session || (base_level == DiffColorLevel::Ansi16 && is_windows_terminal(term_program)) {
88        return DiffColorLevel::TrueColor;
89    }
90
91    base_level
92}
93
94fn is_windows_terminal(term_program: Option<&str>) -> bool {
95    let Some(program) = term_program else {
96        return false;
97    };
98
99    let normalized = program.trim().to_ascii_lowercase();
100    normalized.contains("windows_terminal") || normalized.contains("windows terminal")
101}
102
103// ── Theme-aware red/green palette ──────────────────────────────────────────
104
105fn capability_color(rgb: (u8, u8, u8), theme: DiffTheme, level: DiffColorLevel) -> Color {
106    match level {
107        DiffColorLevel::TrueColor => Color::Rgb(anstyle::RgbColor(rgb.0, rgb.1, rgb.2)),
108        DiffColorLevel::Ansi256 => {
109            Color::Ansi256(anstyle::Ansi256Color(rgb_to_ansi256_for_theme(rgb.0, rgb.1, rgb.2, theme.is_light())))
110        }
111        // ANSI16 callers use these colors only as a foreground fallback. The
112        // renderer still suppresses all backgrounds at this capability level.
113        DiffColorLevel::Ansi16 => Color::Rgb(anstyle::RgbColor(rgb.0, rgb.1, rgb.2)),
114    }
115}
116
117/// Foreground color for an addition marker or fallback body.
118pub fn diff_add_fg(theme: DiffTheme, level: DiffColorLevel) -> Color {
119    match level {
120        DiffColorLevel::Ansi16 => Color::Ansi(if theme.is_light() {
121            AnsiColor::Green
122        } else {
123            AnsiColor::BrightGreen
124        }),
125        DiffColorLevel::TrueColor | DiffColorLevel::Ansi256 => {
126            capability_color(if theme.is_light() { (0, 92, 43) } else { (85, 255, 85) }, theme, level)
127        }
128    }
129}
130
131/// Foreground color for a deletion marker or fallback body.
132pub fn diff_del_fg(theme: DiffTheme, level: DiffColorLevel) -> Color {
133    match level {
134        DiffColorLevel::Ansi16 => Color::Ansi(if theme.is_light() {
135            AnsiColor::Red
136        } else {
137            AnsiColor::BrightRed
138        }),
139        DiffColorLevel::TrueColor | DiffColorLevel::Ansi256 => capability_color(
140            if theme.is_light() {
141                (140, 20, 25)
142            } else {
143                (255, 180, 180)
144            },
145            theme,
146            level,
147        ),
148    }
149}
150
151/// Foreground color for line-number gutters and separators.
152pub fn diff_gutter_fg(theme: DiffTheme, level: DiffColorLevel) -> Color {
153    match level {
154        DiffColorLevel::Ansi16 => Color::Ansi(if theme.is_light() {
155            AnsiColor::Black
156        } else {
157            AnsiColor::BrightWhite
158        }),
159        DiffColorLevel::TrueColor | DiffColorLevel::Ansi256 => capability_color(
160            if theme.is_light() {
161                (70, 80, 75)
162            } else {
163                // Keep the gutter quieter than code text while retaining
164                // WCAG AA contrast against both dark row backgrounds.
165                (165, 175, 170)
166            },
167            theme,
168            level,
169        ),
170    }
171}
172
173// ── Soft diff backgrounds ─────────────────────────────────────────────────
174
175/// Get background color for addition lines based on theme and color level.
176pub fn diff_add_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
177    match theme {
178        DiffTheme::Dark => capability_color((20, 58, 45), theme, level),
179        DiffTheme::Light => capability_color((218, 246, 225), theme, level),
180    }
181}
182
183/// Get background color for deletion lines based on theme and color level.
184pub fn diff_del_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
185    match theme {
186        DiffTheme::Dark => capability_color((70, 38, 42), theme, level),
187        DiffTheme::Light => capability_color((255, 224, 224), theme, level),
188    }
189}
190
191/// Stronger addition-chip background for word-level (intra-line) highlights.
192///
193/// Sits on top of the subtle full-width add tint so only the tokens that
194/// actually changed pop (IntelliJ / GitHub two-level background).
195pub fn diff_add_word_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
196    match theme {
197        DiffTheme::Dark => capability_color((36, 100, 70), theme, level),
198        DiffTheme::Light => capability_color((168, 230, 190), theme, level),
199    }
200}
201
202/// Stronger deletion-chip background for word-level (intra-line) highlights.
203pub fn diff_del_word_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
204    match theme {
205        DiffTheme::Dark => capability_color((140, 52, 58), theme, level),
206        DiffTheme::Light => capability_color((255, 186, 186), theme, level),
207    }
208}
209
210/// Get background color for hunk header (`@@ -old +new @@`) lines.
211///
212/// Neutral blue-grey tint (not red/green) so hunk separators stay visually
213/// distinct from add/del content while still painting full-width.
214pub fn diff_hunk_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
215    match theme {
216        DiffTheme::Dark => capability_color((30, 45, 62), theme, level),
217        DiffTheme::Light => capability_color((221, 235, 244), theme, level),
218    }
219}
220
221/// Legacy light-theme gutter foreground retained for compatibility.
222pub fn diff_gutter_fg_light(_level: DiffColorLevel) -> Color {
223    Color::Ansi(AnsiColor::Black)
224}
225
226/// Get gutter background color for addition lines in light theme.
227pub fn diff_gutter_bg_add_light(_level: DiffColorLevel) -> Color {
228    Color::Ansi(AnsiColor::BrightGreen)
229}
230
231/// Get gutter background color for deletion lines in light theme.
232pub fn diff_gutter_bg_del_light(_level: DiffColorLevel) -> Color {
233    Color::Ansi(AnsiColor::BrightRed)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn dark_add_bg_is_subtle_green_tint() {
242        let bg = diff_add_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
243        assert_eq!(bg, Color::Rgb(anstyle::RgbColor(20, 58, 45)));
244    }
245
246    #[test]
247    fn dark_del_bg_is_subtle_red_tint() {
248        let bg = diff_del_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
249        assert_eq!(bg, Color::Rgb(anstyle::RgbColor(70, 38, 42)));
250    }
251
252    #[test]
253    fn light_add_bg_is_subtle_green_tint() {
254        let bg = diff_add_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
255        assert_eq!(bg, Color::Rgb(anstyle::RgbColor(218, 246, 225)));
256    }
257
258    #[test]
259    fn light_del_bg_is_subtle_red_tint() {
260        let bg = diff_del_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
261        assert_eq!(bg, Color::Rgb(anstyle::RgbColor(255, 224, 224)));
262    }
263
264    #[test]
265    fn backgrounds_follow_terminal_capability() {
266        assert_eq!(diff_add_bg(DiffTheme::Dark, DiffColorLevel::TrueColor), Color::Rgb(anstyle::RgbColor(20, 58, 45)));
267        assert_eq!(
268            diff_del_bg(DiffTheme::Light, DiffColorLevel::TrueColor),
269            Color::Rgb(anstyle::RgbColor(255, 224, 224))
270        );
271        assert!(matches!(diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi256), Color::Ansi256(_)));
272        assert!(matches!(diff_add_word_bg(DiffTheme::Light, DiffColorLevel::Ansi256), Color::Ansi256(_)));
273        assert!(matches!(diff_gutter_fg(DiffTheme::Dark, DiffColorLevel::Ansi256), Color::Ansi256(_)));
274    }
275
276    #[test]
277    fn wt_session_promotes_ansi16_to_truecolor() {
278        assert_eq!(diff_color_level_for_terminal(DiffColorLevel::Ansi16, None, true, false), DiffColorLevel::TrueColor);
279    }
280
281    #[test]
282    fn windows_terminal_term_program_promotes_ansi16_to_truecolor() {
283        assert_eq!(
284            diff_color_level_for_terminal(DiffColorLevel::Ansi16, Some("Windows_Terminal"), false, false),
285            DiffColorLevel::TrueColor
286        );
287    }
288
289    #[test]
290    fn non_windows_terminal_keeps_ansi16() {
291        assert_eq!(
292            diff_color_level_for_terminal(DiffColorLevel::Ansi16, Some("WezTerm"), false, false),
293            DiffColorLevel::Ansi16
294        );
295    }
296
297    #[test]
298    fn force_color_keeps_ansi16_when_wt_session_exists() {
299        assert_eq!(diff_color_level_for_terminal(DiffColorLevel::Ansi16, None, true, true), DiffColorLevel::Ansi16);
300    }
301
302    #[test]
303    fn force_color_keeps_ansi256_when_wt_session_exists() {
304        assert_eq!(diff_color_level_for_terminal(DiffColorLevel::Ansi256, None, true, true), DiffColorLevel::Ansi256);
305    }
306
307    #[test]
308    fn base_level_detects_truecolor_from_colorterm() {
309        assert_eq!(base_diff_color_level("truecolor", "xterm-256color"), DiffColorLevel::TrueColor);
310    }
311
312    #[test]
313    fn base_level_detects_ansi256_from_term() {
314        assert_eq!(base_diff_color_level("", "xterm-256color"), DiffColorLevel::Ansi256);
315    }
316
317    #[test]
318    fn base_level_falls_back_to_ansi16() {
319        assert_eq!(base_diff_color_level("", "xterm"), DiffColorLevel::Ansi16);
320    }
321}