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