Skip to main content

vtcode_tui/utils/
diff_styles.rs

1//! Unified diff styles for TUI rendering
2//!
3//! Re-exports diff theme from vtcode-commons and provides
4//! ratatui-specific style helpers for diff rendering.
5
6// Re-export diff theme from vtcode-commons
7pub use vtcode_commons::diff_theme::{
8    DiffColorLevel, DiffTheme, diff_add_bg, diff_del_bg, diff_gutter_bg_add_light,
9    diff_gutter_bg_del_light, diff_gutter_fg_light,
10};
11pub use vtcode_commons::styling::DiffColorPalette;
12
13use crate::ui::syntax_highlight::{DiffScopeBackgroundRgbs, diff_scope_background_rgbs};
14use ratatui::style::{Color as RatatuiColor, Modifier, Style as RatatuiStyle};
15use vtcode_commons::color256_theme::rgb_to_ansi256_for_theme;
16
17// ── Conversion helpers ─────────────────────────────────────────────────────
18
19/// Convert anstyle Color to ratatui Color
20fn ratatui_color_from_anstyle(color: anstyle::Color) -> RatatuiColor {
21    match color {
22        anstyle::Color::Ansi(c) => match c {
23            anstyle::AnsiColor::Black => RatatuiColor::Black,
24            anstyle::AnsiColor::Red => RatatuiColor::Red,
25            anstyle::AnsiColor::Green => RatatuiColor::Green,
26            anstyle::AnsiColor::Yellow => RatatuiColor::Yellow,
27            anstyle::AnsiColor::Blue => RatatuiColor::Blue,
28            anstyle::AnsiColor::Magenta => RatatuiColor::Magenta,
29            anstyle::AnsiColor::Cyan => RatatuiColor::Cyan,
30            anstyle::AnsiColor::White => RatatuiColor::White,
31            anstyle::AnsiColor::BrightBlack => RatatuiColor::DarkGray,
32            anstyle::AnsiColor::BrightRed => RatatuiColor::LightRed,
33            anstyle::AnsiColor::BrightGreen => RatatuiColor::LightGreen,
34            anstyle::AnsiColor::BrightYellow => RatatuiColor::LightYellow,
35            anstyle::AnsiColor::BrightBlue => RatatuiColor::LightBlue,
36            anstyle::AnsiColor::BrightMagenta => RatatuiColor::LightMagenta,
37            anstyle::AnsiColor::BrightCyan => RatatuiColor::LightCyan,
38            anstyle::AnsiColor::BrightWhite => RatatuiColor::White,
39        },
40        anstyle::Color::Ansi256(c) => RatatuiColor::Indexed(c.0),
41        anstyle::Color::Rgb(c) => RatatuiColor::Rgb(c.0, c.1, c.2),
42    }
43}
44
45// ── TUI-specific diff line styling ─────────────────────────────────────────
46
47/// Diff line type for style selection.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum DiffLineType {
50    Insert,
51    Delete,
52    Context,
53}
54
55#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56struct ResolvedDiffBackgrounds {
57    add: Option<RatatuiColor>,
58    del: Option<RatatuiColor>,
59}
60
61/// Snapshot of diff styling inputs that can be reused while rendering.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub struct DiffRenderStyleContext {
64    theme: DiffTheme,
65    level: DiffColorLevel,
66    backgrounds: ResolvedDiffBackgrounds,
67}
68
69/// Resolve the current terminal and syntax-theme styling into one context.
70pub fn current_diff_render_style_context() -> DiffRenderStyleContext {
71    let theme = DiffTheme::detect();
72    let level = DiffColorLevel::detect();
73    diff_render_style_context_for(theme, level, scope_backgrounds_for_level(level))
74}
75
76fn diff_render_style_context_for(
77    theme: DiffTheme,
78    level: DiffColorLevel,
79    scope_backgrounds: DiffScopeBackgroundRgbs,
80) -> DiffRenderStyleContext {
81    DiffRenderStyleContext {
82        theme,
83        level,
84        backgrounds: resolve_diff_backgrounds_for(theme, level, scope_backgrounds),
85    }
86}
87
88fn resolve_diff_backgrounds_for(
89    theme: DiffTheme,
90    level: DiffColorLevel,
91    scope_backgrounds: DiffScopeBackgroundRgbs,
92) -> ResolvedDiffBackgrounds {
93    let mut resolved = fallback_diff_backgrounds(theme, level);
94    if level == DiffColorLevel::Ansi16 {
95        return resolved;
96    }
97
98    if let Some(rgb) = scope_backgrounds.inserted
99        && let Some(color) = color_from_rgb_for_level(rgb, theme, level)
100    {
101        resolved.add = Some(color);
102    }
103
104    if let Some(rgb) = scope_backgrounds.deleted
105        && let Some(color) = color_from_rgb_for_level(rgb, theme, level)
106    {
107        resolved.del = Some(color);
108    }
109
110    resolved
111}
112
113fn fallback_diff_backgrounds(theme: DiffTheme, level: DiffColorLevel) -> ResolvedDiffBackgrounds {
114    match level {
115        DiffColorLevel::Ansi16 => ResolvedDiffBackgrounds::default(),
116        DiffColorLevel::TrueColor | DiffColorLevel::Ansi256 => ResolvedDiffBackgrounds {
117            add: Some(ratatui_color_from_anstyle(diff_add_bg(theme, level))),
118            del: Some(ratatui_color_from_anstyle(diff_del_bg(theme, level))),
119        },
120    }
121}
122
123fn color_from_rgb_for_level(
124    rgb: (u8, u8, u8),
125    theme: DiffTheme,
126    level: DiffColorLevel,
127) -> Option<RatatuiColor> {
128    match level {
129        DiffColorLevel::TrueColor => Some(RatatuiColor::Rgb(rgb.0, rgb.1, rgb.2)),
130        DiffColorLevel::Ansi256 => Some(RatatuiColor::Indexed(rgb_to_ansi256_for_theme(
131            rgb.0,
132            rgb.1,
133            rgb.2,
134            theme.is_light(),
135        ))),
136        DiffColorLevel::Ansi16 => None,
137    }
138}
139
140pub fn content_background(
141    kind: DiffLineType,
142    style_context: DiffRenderStyleContext,
143) -> Option<RatatuiColor> {
144    match kind {
145        DiffLineType::Insert => style_context.backgrounds.add,
146        DiffLineType::Delete => style_context.backgrounds.del,
147        DiffLineType::Context => None,
148    }
149}
150
151/// Full-width line background style. Context lines use terminal default.
152pub fn style_line_bg(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
153    match kind {
154        DiffLineType::Insert => style_context
155            .backgrounds
156            .add
157            .map_or_else(RatatuiStyle::default, |bg| RatatuiStyle::default().bg(bg)),
158        DiffLineType::Delete => style_context
159            .backgrounds
160            .del
161            .map_or_else(RatatuiStyle::default, |bg| RatatuiStyle::default().bg(bg)),
162        DiffLineType::Context => RatatuiStyle::default(),
163    }
164}
165
166fn scope_backgrounds_for_level(level: DiffColorLevel) -> DiffScopeBackgroundRgbs {
167    match level {
168        DiffColorLevel::Ansi16 => DiffScopeBackgroundRgbs::default(),
169        DiffColorLevel::TrueColor | DiffColorLevel::Ansi256 => diff_scope_background_rgbs(),
170    }
171}
172
173/// Gutter (line number) style.
174///
175/// Keep gutter signs/numbers dim and on standard ANSI red/green without bold.
176pub fn style_gutter(kind: DiffLineType) -> RatatuiStyle {
177    match kind {
178        DiffLineType::Insert => RatatuiStyle::default()
179            .fg(RatatuiColor::Green)
180            .add_modifier(Modifier::DIM)
181            .remove_modifier(Modifier::BOLD),
182        DiffLineType::Delete => RatatuiStyle::default()
183            .fg(RatatuiColor::Red)
184            .add_modifier(Modifier::DIM)
185            .remove_modifier(Modifier::BOLD),
186        DiffLineType::Context => RatatuiStyle::default().add_modifier(Modifier::DIM),
187    }
188}
189
190/// Sign character (`+`/`-`) style.
191/// Uses standard ANSI red/green without bold for consistency.
192pub fn style_sign(kind: DiffLineType) -> RatatuiStyle {
193    match kind {
194        DiffLineType::Insert => RatatuiStyle::default()
195            .fg(RatatuiColor::Green)
196            .add_modifier(Modifier::DIM)
197            .remove_modifier(Modifier::BOLD),
198        DiffLineType::Delete => RatatuiStyle::default()
199            .fg(RatatuiColor::Red)
200            .add_modifier(Modifier::DIM)
201            .remove_modifier(Modifier::BOLD),
202        DiffLineType::Context => RatatuiStyle::default(),
203    }
204}
205
206/// Content style for plain (non-syntax-highlighted) diff lines.
207///
208/// ANSI16: foreground-only styling.
209/// Light: bg only, no fg override.
210/// Dark + TrueColor/256: colored fg + tinted bg.
211pub fn style_content(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
212    let bg = content_background(kind, style_context);
213    match (kind, style_context.theme, style_context.level, bg) {
214        (DiffLineType::Context, _, _, _) => RatatuiStyle::default(),
215        (DiffLineType::Insert, _, DiffColorLevel::Ansi16, _) => {
216            RatatuiStyle::default().fg(RatatuiColor::Green)
217        }
218        (DiffLineType::Delete, _, DiffColorLevel::Ansi16, _) => {
219            RatatuiStyle::default().fg(RatatuiColor::Red)
220        }
221        (DiffLineType::Insert, DiffTheme::Light, _, Some(bg)) => RatatuiStyle::default().bg(bg),
222        (DiffLineType::Delete, DiffTheme::Light, _, Some(bg)) => RatatuiStyle::default().bg(bg),
223        (DiffLineType::Insert, DiffTheme::Dark, _, Some(bg)) => {
224            RatatuiStyle::default().fg(RatatuiColor::Green).bg(bg)
225        }
226        (DiffLineType::Delete, DiffTheme::Dark, _, Some(bg)) => {
227            RatatuiStyle::default().fg(RatatuiColor::Red).bg(bg)
228        }
229        (DiffLineType::Insert, DiffTheme::Light, _, None)
230        | (DiffLineType::Delete, DiffTheme::Light, _, None) => RatatuiStyle::default(),
231        (DiffLineType::Insert, DiffTheme::Dark, _, None) => {
232            RatatuiStyle::default().fg(RatatuiColor::Green)
233        }
234        (DiffLineType::Delete, DiffTheme::Dark, _, None) => {
235            RatatuiStyle::default().fg(RatatuiColor::Red)
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn test_style_context(theme: DiffTheme, level: DiffColorLevel) -> DiffRenderStyleContext {
245        diff_render_style_context_for(theme, level, scope_backgrounds_for_level(level))
246    }
247
248    #[test]
249    fn dark_truecolor_add_bg_is_rgb() {
250        let bg = diff_add_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
251        assert!(matches!(
252            bg,
253            anstyle::Color::Rgb(anstyle::RgbColor(25, 45, 35))
254        ));
255    }
256
257    #[test]
258    fn dark_truecolor_del_bg_is_rgb() {
259        let bg = diff_del_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
260        assert!(matches!(
261            bg,
262            anstyle::Color::Rgb(anstyle::RgbColor(90, 40, 40))
263        ));
264    }
265
266    #[test]
267    fn light_truecolor_add_bg_is_accessible() {
268        let bg = diff_add_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
269        assert!(matches!(
270            bg,
271            anstyle::Color::Rgb(anstyle::RgbColor(215, 240, 215))
272        ));
273    }
274
275    #[test]
276    fn light_truecolor_del_bg_is_accessible() {
277        let bg = diff_del_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
278        assert!(matches!(
279            bg,
280            anstyle::Color::Rgb(anstyle::RgbColor(255, 235, 235))
281        ));
282    }
283
284    #[test]
285    fn dark_256_uses_indexed_colors() {
286        let add = diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
287        let del = diff_del_bg(DiffTheme::Dark, DiffColorLevel::Ansi256);
288        assert!(matches!(
289            add,
290            anstyle::Color::Ansi256(anstyle::Ansi256Color(22))
291        ));
292        assert!(matches!(
293            del,
294            anstyle::Color::Ansi256(anstyle::Ansi256Color(52))
295        ));
296    }
297
298    #[test]
299    fn dark_ansi16_uses_named_colors() {
300        let add = diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
301        let del = diff_del_bg(DiffTheme::Dark, DiffColorLevel::Ansi16);
302        assert_eq!(add, anstyle::Color::Ansi(anstyle::AnsiColor::Green));
303        assert_eq!(del, anstyle::Color::Ansi(anstyle::AnsiColor::Red));
304    }
305
306    #[test]
307    fn context_line_bg_is_default() {
308        let style = style_line_bg(
309            DiffLineType::Context,
310            test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor),
311        );
312        assert_eq!(style, RatatuiStyle::default());
313    }
314
315    #[test]
316    fn dark_gutter_is_dim() {
317        let style = style_gutter(DiffLineType::Context);
318        assert!(style.add_modifier.contains(Modifier::DIM));
319    }
320
321    #[test]
322    fn insert_gutter_is_dim_standard_green_no_bold() {
323        let style = style_gutter(DiffLineType::Insert);
324        assert_eq!(style.fg, Some(RatatuiColor::Green));
325        assert!(style.add_modifier.contains(Modifier::DIM));
326        assert!(style.sub_modifier.contains(Modifier::BOLD));
327    }
328
329    #[test]
330    fn dark_ansi16_content_uses_foreground_only() {
331        let style = style_content(
332            DiffLineType::Insert,
333            test_style_context(DiffTheme::Dark, DiffColorLevel::Ansi16),
334        );
335        assert_eq!(style.fg, Some(RatatuiColor::Green));
336        assert_eq!(style.bg, None);
337    }
338
339    #[test]
340    fn sign_style_always_uses_standard_colors() {
341        let add_sign = style_sign(DiffLineType::Insert);
342        let del_sign = style_sign(DiffLineType::Delete);
343        assert_eq!(add_sign.fg, Some(RatatuiColor::Green));
344        assert_eq!(del_sign.fg, Some(RatatuiColor::Red));
345        assert!(add_sign.add_modifier.contains(Modifier::DIM));
346        assert!(del_sign.add_modifier.contains(Modifier::DIM));
347        assert!(add_sign.sub_modifier.contains(Modifier::BOLD));
348        assert!(del_sign.sub_modifier.contains(Modifier::BOLD));
349    }
350
351    #[test]
352    fn theme_scope_backgrounds_override_truecolor_fallback_when_available() {
353        let style_context = diff_render_style_context_for(
354            DiffTheme::Dark,
355            DiffColorLevel::TrueColor,
356            DiffScopeBackgroundRgbs {
357                inserted: Some((1, 2, 3)),
358                deleted: Some((4, 5, 6)),
359            },
360        );
361
362        assert_eq!(
363            style_line_bg(DiffLineType::Insert, style_context),
364            RatatuiStyle::default().bg(RatatuiColor::Rgb(1, 2, 3))
365        );
366        assert_eq!(
367            style_line_bg(DiffLineType::Delete, style_context),
368            RatatuiStyle::default().bg(RatatuiColor::Rgb(4, 5, 6))
369        );
370    }
371
372    #[test]
373    fn theme_scope_backgrounds_quantize_to_ansi256() {
374        let style_context = diff_render_style_context_for(
375            DiffTheme::Dark,
376            DiffColorLevel::Ansi256,
377            DiffScopeBackgroundRgbs {
378                inserted: Some((0, 95, 0)),
379                deleted: None,
380            },
381        );
382        assert_eq!(
383            style_line_bg(DiffLineType::Insert, style_context),
384            RatatuiStyle::default().bg(RatatuiColor::Indexed(22))
385        );
386        assert_eq!(
387            style_line_bg(DiffLineType::Delete, style_context),
388            RatatuiStyle::default().bg(RatatuiColor::Indexed(52))
389        );
390    }
391
392    #[test]
393    fn ansi16_disables_line_backgrounds_even_with_scope_colors() {
394        let style_context = diff_render_style_context_for(
395            DiffTheme::Dark,
396            DiffColorLevel::Ansi16,
397            DiffScopeBackgroundRgbs {
398                inserted: Some((8, 9, 10)),
399                deleted: Some((11, 12, 13)),
400            },
401        );
402        assert_eq!(
403            style_line_bg(DiffLineType::Insert, style_context),
404            RatatuiStyle::default()
405        );
406        assert_eq!(
407            style_line_bg(DiffLineType::Delete, style_context),
408            RatatuiStyle::default()
409        );
410    }
411
412    #[test]
413    fn ansi16_content_has_no_background() {
414        let style_context = diff_render_style_context_for(
415            DiffTheme::Dark,
416            DiffColorLevel::Ansi16,
417            DiffScopeBackgroundRgbs::default(),
418        );
419        let add = style_content(DiffLineType::Insert, style_context);
420        let del = style_content(DiffLineType::Delete, style_context);
421        assert_eq!(add.fg, Some(RatatuiColor::Green));
422        assert_eq!(add.bg, None);
423        assert_eq!(del.fg, Some(RatatuiColor::Red));
424        assert_eq!(del.bg, None);
425    }
426
427    #[test]
428    fn partial_scope_override_keeps_missing_side_fallback() {
429        let style_context = diff_render_style_context_for(
430            DiffTheme::Dark,
431            DiffColorLevel::TrueColor,
432            DiffScopeBackgroundRgbs {
433                inserted: Some((12, 34, 56)),
434                deleted: None,
435            },
436        );
437        assert_eq!(
438            content_background(DiffLineType::Insert, style_context),
439            Some(RatatuiColor::Rgb(12, 34, 56))
440        );
441        assert_eq!(
442            content_background(DiffLineType::Delete, style_context),
443            Some(RatatuiColor::Rgb(90, 40, 40))
444        );
445    }
446}