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
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        let term = std::env::var("TERM").unwrap_or_default().to_lowercase();
22        if term.contains("light") {
23            Self::Light
24        } else {
25            Self::Dark
26        }
27    }
28
29    pub fn is_light(self) -> bool {
30        self == Self::Light
31    }
32}
33
34/// Terminal color capability level for palette selection.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum DiffColorLevel {
37    TrueColor,
38    Ansi256,
39    Ansi16,
40}
41
42impl DiffColorLevel {
43    /// Detect color level from terminal capabilities.
44    pub fn detect() -> Self {
45        let colorterm = std::env::var("COLORTERM").unwrap_or_default();
46        let term = std::env::var("TERM").unwrap_or_default();
47
48        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
49            Self::TrueColor
50        } else if term.contains("256") {
51            Self::Ansi256
52        } else {
53            Self::Ansi16
54        }
55    }
56}
57
58// ── Truecolor palette (WCAG AA compliant) ──────────────────────────────────
59
60// Dark theme: darker backgrounds with higher contrast for better readability
61// Green kept dark, red softened with lower saturation
62const DARK_TC_ADD_LINE_BG: (u8, u8, u8) = (25, 45, 35); // #192D23 - Dark teal green
63const DARK_TC_DEL_LINE_BG: (u8, u8, u8) = (90, 40, 40); // #5A2828 - Muted dark red (lower alpha feel)
64
65// Light theme: light backgrounds with sufficient contrast for dark text
66// Red background made more pastel/muted
67const LIGHT_TC_ADD_LINE_BG: (u8, u8, u8) = (215, 240, 215); // #D7F0D7 - Light green
68const LIGHT_TC_DEL_LINE_BG: (u8, u8, u8) = (255, 235, 235); // #FFEBEB - Soft pastel red (muted)
69const LIGHT_TC_ADD_NUM_BG: (u8, u8, u8) = (175, 225, 175); // #AFE1AF - Gutter green
70const LIGHT_TC_DEL_NUM_BG: (u8, u8, u8) = (250, 210, 210); // #FAD2D2 - Muted gutter red
71const LIGHT_TC_GUTTER_FG: (u8, u8, u8) = (25, 25, 25); // #191919 - Near-black for contrast
72
73// ── 256-color palette ──────────────────────────────────────────────────────
74
75const DARK_256_ADD_LINE_BG: u8 = 22; // DarkGreen
76const DARK_256_DEL_LINE_BG: u8 = 52; // DarkRed
77
78const LIGHT_256_ADD_LINE_BG: u8 = 194; // LightGreen
79const LIGHT_256_DEL_LINE_BG: u8 = 224; // LightRed/Pink
80const LIGHT_256_ADD_NUM_BG: u8 = 157; // SeaGreen
81const LIGHT_256_DEL_NUM_BG: u8 = 217; // LightPink
82const LIGHT_256_GUTTER_FG: u8 = 236; // DarkGray
83
84// ── Helper functions ───────────────────────────────────────────────────────
85
86fn rgb(t: (u8, u8, u8)) -> Color {
87    Color::Rgb(RgbColor(t.0, t.1, t.2))
88}
89
90fn indexed(i: u8) -> Color {
91    Color::Ansi256(Ansi256Color(i))
92}
93
94/// Get background color for addition lines based on theme and color level.
95pub fn diff_add_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
96    match (theme, level) {
97        (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_ADD_LINE_BG),
98        (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_ADD_LINE_BG),
99        (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::Green),
100        (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_ADD_LINE_BG),
101        (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_ADD_LINE_BG),
102        (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::BrightGreen),
103    }
104}
105
106/// Get background color for deletion lines based on theme and color level.
107pub fn diff_del_bg(theme: DiffTheme, level: DiffColorLevel) -> Color {
108    match (theme, level) {
109        (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_DEL_LINE_BG),
110        (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_DEL_LINE_BG),
111        (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::Red),
112        (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_DEL_LINE_BG),
113        (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_DEL_LINE_BG),
114        (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::Ansi(AnsiColor::BrightRed),
115    }
116}
117
118/// Get gutter foreground color for light theme (dark theme uses dimmed default).
119pub fn diff_gutter_fg_light(level: DiffColorLevel) -> Color {
120    match level {
121        DiffColorLevel::TrueColor => rgb(LIGHT_TC_GUTTER_FG),
122        DiffColorLevel::Ansi256 => indexed(LIGHT_256_GUTTER_FG),
123        DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::Black),
124    }
125}
126
127/// Get gutter background color for addition lines in light theme.
128pub fn diff_gutter_bg_add_light(level: DiffColorLevel) -> Color {
129    match level {
130        DiffColorLevel::TrueColor => rgb(LIGHT_TC_ADD_NUM_BG),
131        DiffColorLevel::Ansi256 => indexed(LIGHT_256_ADD_NUM_BG),
132        DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::BrightGreen),
133    }
134}
135
136/// Get gutter background color for deletion lines in light theme.
137pub fn diff_gutter_bg_del_light(level: DiffColorLevel) -> Color {
138    match level {
139        DiffColorLevel::TrueColor => rgb(LIGHT_TC_DEL_NUM_BG),
140        DiffColorLevel::Ansi256 => indexed(LIGHT_256_DEL_NUM_BG),
141        DiffColorLevel::Ansi16 => Color::Ansi(AnsiColor::BrightRed),
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn dark_truecolor_add_bg_is_rgb() {
151        let bg = diff_add_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
152        assert!(matches!(bg, Color::Rgb(RgbColor(25, 45, 35))));
153    }
154
155    #[test]
156    fn dark_truecolor_del_bg_is_rgb() {
157        let bg = diff_del_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
158        assert!(matches!(bg, Color::Rgb(RgbColor(90, 40, 40))));
159    }
160
161    #[test]
162    fn light_truecolor_add_bg_is_accessible() {
163        let bg = diff_add_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
164        assert!(matches!(bg, Color::Rgb(RgbColor(215, 240, 215))));
165    }
166
167    #[test]
168    fn light_truecolor_del_bg_is_accessible() {
169        let bg = diff_del_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
170        assert!(matches!(bg, Color::Rgb(RgbColor(255, 235, 235))));
171    }
172
173    #[test]
174    fn dark_256_uses_indexed_colors() {
175        let add = diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
176        let del = diff_del_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
177        assert!(matches!(add, Color::Ansi256(Ansi256Color(22))));
178        assert!(matches!(del, Color::Ansi256(Ansi256Color(52))));
179    }
180
181    #[test]
182    fn dark_ansi16_uses_named_colors() {
183        let add = diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
184        let del = diff_del_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
185        assert_eq!(add, Color::Ansi(AnsiColor::Green));
186        assert_eq!(del, Color::Ansi(AnsiColor::Red));
187    }
188}