Skip to main content

vtcode_tui/utils/
diff_styles.rs

1//! Unified message styles and their logical mappings
2
3use anstyle::{AnsiColor, Color, Style};
4
5/// Standard color palette with semantic names
6#[derive(Debug, Clone, Copy)]
7pub struct ColorPalette {
8    pub success: Color, // Green
9    pub error: Color,   // Red
10    pub warning: Color, // Red
11    pub info: Color,    // Cyan
12    pub accent: Color,  // Magenta
13    pub primary: Color, // Cyan
14    pub muted: Color,   // Gray/Dim
15}
16
17impl Default for ColorPalette {
18    fn default() -> Self {
19        Self {
20            success: Color::Ansi(AnsiColor::Green),
21            error: Color::Ansi(AnsiColor::Red),
22            warning: Color::Ansi(AnsiColor::Red),
23            info: Color::Ansi(AnsiColor::Cyan),
24            accent: Color::Ansi(AnsiColor::Magenta),
25            primary: Color::Ansi(AnsiColor::Cyan),
26            muted: Color::Ansi(AnsiColor::BrightBlack),
27        }
28    }
29}
30
31/// Render text with a single color and optional effects
32pub fn render_styled(text: &str, color: Color, effects: Option<String>) -> String {
33    let mut style = Style::new().fg_color(Some(color));
34
35    if let Some(effects_str) = effects {
36        let mut ansi_effects = anstyle::Effects::new();
37
38        for effect in effects_str.split(',') {
39            let effect = effect.trim().to_lowercase();
40            match effect.as_str() {
41                "bold" => ansi_effects |= anstyle::Effects::BOLD,
42                "dim" | "dimmed" => ansi_effects |= anstyle::Effects::DIMMED,
43                "italic" => ansi_effects |= anstyle::Effects::ITALIC,
44                "underline" => ansi_effects |= anstyle::Effects::UNDERLINE,
45                "blink" => ansi_effects |= anstyle::Effects::BLINK,
46                "invert" | "reversed" => ansi_effects |= anstyle::Effects::INVERT,
47                "hidden" => ansi_effects |= anstyle::Effects::HIDDEN,
48                "strikethrough" => ansi_effects |= anstyle::Effects::STRIKETHROUGH,
49                _ => {}
50            }
51        }
52
53        style = style.effects(ansi_effects);
54    }
55
56    // Use static reset code
57    format!("{}{}{}", style, text, "\x1b[0m")
58}
59
60/// Build style from CSS/terminal color name
61pub fn style_from_color_name(name: &str) -> Style {
62    let (color_name, dimmed) = if let Some(idx) = name.find(':') {
63        let (color, modifier) = name.split_at(idx);
64        (color, modifier.strip_prefix(':').unwrap_or(""))
65    } else {
66        (name, "")
67    };
68
69    let color = match color_name.to_lowercase().as_str() {
70        "red" => Color::Ansi(AnsiColor::Red),
71        "green" => Color::Ansi(AnsiColor::Green),
72        "blue" => Color::Ansi(AnsiColor::Blue),
73        "yellow" => Color::Ansi(AnsiColor::Yellow),
74        "cyan" => Color::Ansi(AnsiColor::Cyan),
75        "magenta" | "purple" => Color::Ansi(AnsiColor::Magenta),
76        "white" => Color::Ansi(AnsiColor::White),
77        "black" => Color::Ansi(AnsiColor::Black),
78        _ => return Style::new(),
79    };
80
81    let mut style = Style::new().fg_color(Some(color));
82    if dimmed.eq_ignore_ascii_case("dimmed") {
83        style = style.dimmed();
84    }
85    style
86}
87
88/// Create a bold colored style from AnsiColor
89pub fn bold_color(color: AnsiColor) -> Style {
90    Style::new().bold().fg_color(Some(Color::Ansi(color)))
91}
92
93/// Create a dimmed colored style from AnsiColor
94pub fn dimmed_color(color: AnsiColor) -> Style {
95    Style::new().dimmed().fg_color(Some(Color::Ansi(color)))
96}
97
98/// Diff color palette for consistent git diff styling
99#[derive(Debug, Clone, Copy)]
100pub struct DiffColorPalette {
101    pub added_fg: Color,
102    pub added_bg: Color,
103    pub removed_fg: Color,
104    pub removed_bg: Color,
105    pub header_fg: Color,
106    pub header_bg: Color,
107}
108
109impl Default for DiffColorPalette {
110    fn default() -> Self {
111        Self {
112            added_fg: Color::Ansi(AnsiColor::Green),
113            added_bg: Color::Rgb(anstyle::RgbColor(10, 24, 10)),
114            removed_fg: Color::Ansi(AnsiColor::Red),
115            removed_bg: Color::Rgb(anstyle::RgbColor(24, 10, 10)),
116            header_fg: Color::Ansi(AnsiColor::Cyan),
117            header_bg: Color::Rgb(anstyle::RgbColor(10, 16, 20)),
118        }
119    }
120}
121
122impl DiffColorPalette {
123    pub fn added_style(&self) -> Style {
124        Style::new().fg_color(Some(self.added_fg))
125    }
126
127    pub fn removed_style(&self) -> Style {
128        Style::new().fg_color(Some(self.removed_fg))
129    }
130
131    pub fn header_style(&self) -> Style {
132        Style::new().fg_color(Some(self.header_fg))
133    }
134}
135
136// ── Theme-aware diff rendering ─────────────────────────────────────────────
137//
138// Extends the base DiffColorPalette with terminal-adaptive styling that
139// adjusts background tints and gutter styling based on:
140//   1. DiffTheme (Dark/Light) — detected from terminal background
141//   2. DiffColorLevel (TrueColor/Ansi256/Ansi16) — from terminal capability
142//
143// Inspired by github.com/openai/codex PR #12581.
144
145use ratatui::style::{Color as RatatuiColor, Modifier, Style as RatatuiStyle};
146
147use super::ansi_capabilities::{CAPABILITIES, ColorDepth, ColorScheme, detect_color_scheme};
148
149/// Terminal background theme for diff rendering.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum DiffTheme {
152    Dark,
153    Light,
154}
155
156impl DiffTheme {
157    /// Detect theme from the terminal environment.
158    pub fn detect() -> Self {
159        match detect_color_scheme() {
160            ColorScheme::Light => Self::Light,
161            ColorScheme::Dark | ColorScheme::Unknown => Self::Dark,
162        }
163    }
164
165    pub fn is_light(self) -> bool {
166        self == Self::Light
167    }
168}
169
170/// Terminal color capability level for palette selection.
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub enum DiffColorLevel {
173    TrueColor,
174    Ansi256,
175    Ansi16,
176}
177
178impl DiffColorLevel {
179    /// Detect color level from terminal capabilities.
180    pub fn detect() -> Self {
181        match CAPABILITIES.color_depth {
182            ColorDepth::TrueColor => Self::TrueColor,
183            ColorDepth::Color256 => Self::Ansi256,
184            ColorDepth::Basic16 | ColorDepth::None => Self::Ansi16,
185        }
186    }
187}
188
189// ── Truecolor palette ──────────────────────────────────────────────────────
190
191const DARK_TC_ADD_LINE_BG: (u8, u8, u8) = (33, 58, 43); // #213A2B
192const DARK_TC_DEL_LINE_BG: (u8, u8, u8) = (74, 34, 29); // #4A221D
193
194const LIGHT_TC_ADD_LINE_BG: (u8, u8, u8) = (218, 251, 225); // #DAFBE1 (GitHub-style)
195const LIGHT_TC_DEL_LINE_BG: (u8, u8, u8) = (255, 235, 233); // #FFEBE9 (GitHub-style)
196const LIGHT_TC_ADD_NUM_BG: (u8, u8, u8) = (172, 238, 187); // #ACEEBB (gutter, saturated)
197const LIGHT_TC_DEL_NUM_BG: (u8, u8, u8) = (255, 206, 203); // #FFCECB (gutter, saturated)
198const LIGHT_TC_GUTTER_FG: (u8, u8, u8) = (31, 35, 40); // #1F2328 (near-black)
199
200// ── 256-color palette ──────────────────────────────────────────────────────
201
202const DARK_256_ADD_LINE_BG: u8 = 22; // DarkGreen
203const DARK_256_DEL_LINE_BG: u8 = 52; // DarkRed
204
205const LIGHT_256_ADD_LINE_BG: u8 = 194; // Honeydew2
206const LIGHT_256_DEL_LINE_BG: u8 = 224; // MistyRose1
207const LIGHT_256_ADD_NUM_BG: u8 = 157; // DarkSeaGreen2
208const LIGHT_256_DEL_NUM_BG: u8 = 217; // LightPink1
209const LIGHT_256_GUTTER_FG: u8 = 236; // Grey19
210
211// ── Background color selectors ─────────────────────────────────────────────
212
213fn rgb(t: (u8, u8, u8)) -> RatatuiColor {
214    RatatuiColor::Rgb(t.0, t.1, t.2)
215}
216
217fn indexed(i: u8) -> RatatuiColor {
218    RatatuiColor::Indexed(i)
219}
220
221/// Background color for addition lines.
222pub fn add_line_bg(theme: DiffTheme, level: DiffColorLevel) -> RatatuiColor {
223    match (theme, level) {
224        (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_ADD_LINE_BG),
225        (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_ADD_LINE_BG),
226        (DiffTheme::Dark, DiffColorLevel::Ansi16) => RatatuiColor::Green,
227        (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_ADD_LINE_BG),
228        (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_ADD_LINE_BG),
229        (DiffTheme::Light, DiffColorLevel::Ansi16) => RatatuiColor::LightGreen,
230    }
231}
232
233/// Background color for deletion lines.
234pub fn del_line_bg(theme: DiffTheme, level: DiffColorLevel) -> RatatuiColor {
235    match (theme, level) {
236        (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb(DARK_TC_DEL_LINE_BG),
237        (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed(DARK_256_DEL_LINE_BG),
238        (DiffTheme::Dark, DiffColorLevel::Ansi16) => RatatuiColor::Red,
239        (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb(LIGHT_TC_DEL_LINE_BG),
240        (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed(LIGHT_256_DEL_LINE_BG),
241        (DiffTheme::Light, DiffColorLevel::Ansi16) => RatatuiColor::LightRed,
242    }
243}
244
245// ── Gutter helpers (light theme) ───────────────────────────────────────────
246
247fn light_gutter_fg(level: DiffColorLevel) -> RatatuiColor {
248    match level {
249        DiffColorLevel::TrueColor => rgb(LIGHT_TC_GUTTER_FG),
250        DiffColorLevel::Ansi256 => indexed(LIGHT_256_GUTTER_FG),
251        DiffColorLevel::Ansi16 => RatatuiColor::Black,
252    }
253}
254
255fn light_add_num_bg(level: DiffColorLevel) -> RatatuiColor {
256    match level {
257        DiffColorLevel::TrueColor => rgb(LIGHT_TC_ADD_NUM_BG),
258        DiffColorLevel::Ansi256 => indexed(LIGHT_256_ADD_NUM_BG),
259        DiffColorLevel::Ansi16 => RatatuiColor::Green,
260    }
261}
262
263fn light_del_num_bg(level: DiffColorLevel) -> RatatuiColor {
264    match level {
265        DiffColorLevel::TrueColor => rgb(LIGHT_TC_DEL_NUM_BG),
266        DiffColorLevel::Ansi256 => indexed(LIGHT_256_DEL_NUM_BG),
267        DiffColorLevel::Ansi16 => RatatuiColor::Red,
268    }
269}
270
271// ── Composed style builders ────────────────────────────────────────────────
272
273/// Diff line type for style selection.
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub enum DiffLineType {
276    Insert,
277    Delete,
278    Context,
279}
280
281/// Full-width line background style. Context lines use terminal default.
282pub fn style_line_bg(kind: DiffLineType, theme: DiffTheme, level: DiffColorLevel) -> RatatuiStyle {
283    match kind {
284        DiffLineType::Insert => RatatuiStyle::default().bg(add_line_bg(theme, level)),
285        DiffLineType::Delete => RatatuiStyle::default().bg(del_line_bg(theme, level)),
286        DiffLineType::Context => RatatuiStyle::default(),
287    }
288}
289
290/// Gutter (line number) style.
291///
292/// Light: opaque tinted bg + near-black fg for readability.
293/// Dark: simple DIM modifier.
294pub fn style_gutter(kind: DiffLineType, theme: DiffTheme, level: DiffColorLevel) -> RatatuiStyle {
295    match (theme, kind) {
296        (DiffTheme::Light, DiffLineType::Insert) => RatatuiStyle::default()
297            .fg(light_gutter_fg(level))
298            .bg(light_add_num_bg(level)),
299        (DiffTheme::Light, DiffLineType::Delete) => RatatuiStyle::default()
300            .fg(light_gutter_fg(level))
301            .bg(light_del_num_bg(level)),
302        _ => RatatuiStyle::default().add_modifier(Modifier::DIM),
303    }
304}
305
306/// Sign character (`+`/`-`) style.
307pub fn style_sign(kind: DiffLineType, theme: DiffTheme, level: DiffColorLevel) -> RatatuiStyle {
308    match kind {
309        DiffLineType::Insert => match theme {
310            DiffTheme::Light => RatatuiStyle::default().fg(RatatuiColor::Green),
311            DiffTheme::Dark => style_content(kind, theme, level),
312        },
313        DiffLineType::Delete => match theme {
314            DiffTheme::Light => RatatuiStyle::default().fg(RatatuiColor::Red),
315            DiffTheme::Dark => style_content(kind, theme, level),
316        },
317        DiffLineType::Context => RatatuiStyle::default(),
318    }
319}
320
321/// Content style for plain (non-syntax-highlighted) diff lines.
322pub fn style_content(kind: DiffLineType, theme: DiffTheme, level: DiffColorLevel) -> RatatuiStyle {
323    match (kind, theme, level) {
324        // Dark + ANSI16: force Black fg on colored bg for contrast
325        (DiffLineType::Insert, DiffTheme::Dark, DiffColorLevel::Ansi16) => RatatuiStyle::default()
326            .fg(RatatuiColor::Black)
327            .bg(add_line_bg(theme, level)),
328        (DiffLineType::Delete, DiffTheme::Dark, DiffColorLevel::Ansi16) => RatatuiStyle::default()
329            .fg(RatatuiColor::Black)
330            .bg(del_line_bg(theme, level)),
331        // Light: bg only, no fg override
332        (DiffLineType::Insert, DiffTheme::Light, _) => {
333            RatatuiStyle::default().bg(add_line_bg(theme, level))
334        }
335        (DiffLineType::Delete, DiffTheme::Light, _) => {
336            RatatuiStyle::default().bg(del_line_bg(theme, level))
337        }
338        // Dark + TrueColor/256: colored fg + tinted bg
339        (DiffLineType::Insert, DiffTheme::Dark, _) => RatatuiStyle::default()
340            .fg(RatatuiColor::Green)
341            .bg(add_line_bg(theme, level)),
342        (DiffLineType::Delete, DiffTheme::Dark, _) => RatatuiStyle::default()
343            .fg(RatatuiColor::Red)
344            .bg(del_line_bg(theme, level)),
345        // Context: terminal default
346        (DiffLineType::Context, _, _) => RatatuiStyle::default(),
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn dark_truecolor_add_bg_is_rgb() {
356        let bg = add_line_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
357        assert!(matches!(bg, RatatuiColor::Rgb(33, 58, 43)));
358    }
359
360    #[test]
361    fn dark_truecolor_del_bg_is_rgb() {
362        let bg = del_line_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
363        assert!(matches!(bg, RatatuiColor::Rgb(74, 34, 29)));
364    }
365
366    #[test]
367    fn light_truecolor_add_bg_is_github_style() {
368        let bg = add_line_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
369        assert!(matches!(bg, RatatuiColor::Rgb(218, 251, 225)));
370    }
371
372    #[test]
373    fn light_truecolor_del_bg_is_github_style() {
374        let bg = del_line_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
375        assert!(matches!(bg, RatatuiColor::Rgb(255, 235, 233)));
376    }
377
378    #[test]
379    fn dark_256_uses_indexed_colors() {
380        let add = add_line_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
381        let del = del_line_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
382        assert!(matches!(add, RatatuiColor::Indexed(22)));
383        assert!(matches!(del, RatatuiColor::Indexed(52)));
384    }
385
386    #[test]
387    fn dark_ansi16_uses_named_colors() {
388        let add = add_line_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
389        let del = del_line_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
390        assert_eq!(add, RatatuiColor::Green);
391        assert_eq!(del, RatatuiColor::Red);
392    }
393
394    #[test]
395    fn context_line_bg_is_default() {
396        let style = style_line_bg(
397            DiffLineType::Context,
398            DiffTheme::Dark,
399            DiffColorLevel::TrueColor,
400        );
401        assert_eq!(style, RatatuiStyle::default());
402    }
403
404    #[test]
405    fn dark_gutter_is_dim() {
406        let style = style_gutter(
407            DiffLineType::Context,
408            DiffTheme::Dark,
409            DiffColorLevel::TrueColor,
410        );
411        assert!(style.add_modifier.contains(Modifier::DIM));
412    }
413
414    #[test]
415    fn light_gutter_has_opaque_bg() {
416        let style = style_gutter(
417            DiffLineType::Insert,
418            DiffTheme::Light,
419            DiffColorLevel::TrueColor,
420        );
421        assert!(style.bg.is_some());
422        assert!(style.fg.is_some());
423    }
424
425    #[test]
426    fn dark_ansi16_content_forces_black_fg() {
427        let style = style_content(
428            DiffLineType::Insert,
429            DiffTheme::Dark,
430            DiffColorLevel::Ansi16,
431        );
432        assert_eq!(style.fg, Some(RatatuiColor::Black));
433    }
434}