Skip to main content

vtcode_ui/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_add_fg, diff_add_word_bg, diff_del_bg, diff_del_fg, diff_del_word_bg,
9    diff_gutter_bg_add_light, diff_gutter_bg_del_light, diff_gutter_fg, diff_gutter_fg_light, diff_hunk_bg,
10};
11pub use vtcode_commons::styling::DiffColorPalette;
12
13use crate::tui::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
17use anstyle::{Color as AnstyleColor, Style as AnstyleStyle};
18
19// ── Conversion helpers ─────────────────────────────────────────────────────
20
21/// Convert anstyle Color to ratatui Color.
22fn ratatui_color_from_anstyle(color: anstyle::Color) -> RatatuiColor {
23    crate::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
50impl DiffRenderStyleContext {
51    pub fn theme(self) -> DiffTheme {
52        self.theme
53    }
54
55    pub fn level(self) -> DiffColorLevel {
56        self.level
57    }
58
59    /// Stronger addition background for changed intraline spans.
60    pub fn add_word_bg(self) -> Option<AnstyleColor> {
61        (self.level != DiffColorLevel::Ansi16).then(|| diff_add_word_bg(self.theme, self.level))
62    }
63
64    /// Stronger deletion background for changed intraline spans.
65    pub fn del_word_bg(self) -> Option<AnstyleColor> {
66        (self.level != DiffColorLevel::Ansi16).then(|| diff_del_word_bg(self.theme, self.level))
67    }
68}
69
70/// Resolve the current terminal and syntax-theme styling into one context.
71pub(crate) fn current_diff_render_style_context() -> DiffRenderStyleContext {
72    let theme = DiffTheme::detect();
73    let level = DiffColorLevel::detect();
74    diff_render_style_context_for(theme, level, scope_backgrounds_for_level(level))
75}
76
77pub(crate) fn diff_render_style_context_for(
78    theme: DiffTheme,
79    level: DiffColorLevel,
80    scope_backgrounds: DiffScopeBackgroundRgbs,
81) -> DiffRenderStyleContext {
82    DiffRenderStyleContext {
83        theme,
84        level,
85        backgrounds: resolve_diff_backgrounds_for(theme, level, scope_backgrounds),
86    }
87}
88
89fn resolve_diff_backgrounds_for(
90    theme: DiffTheme,
91    level: DiffColorLevel,
92    scope_backgrounds: DiffScopeBackgroundRgbs,
93) -> ResolvedDiffBackgrounds {
94    let mut resolved = fallback_diff_backgrounds(theme, level);
95    if level == DiffColorLevel::Ansi16 {
96        return resolved;
97    }
98
99    if let Some(rgb) = scope_backgrounds.inserted
100        && let Some(color) = color_from_rgb_for_level(rgb, theme, level)
101    {
102        resolved.add = Some(color);
103    }
104
105    if let Some(rgb) = scope_backgrounds.deleted
106        && let Some(color) = color_from_rgb_for_level(rgb, theme, level)
107    {
108        resolved.del = Some(color);
109    }
110
111    resolved
112}
113
114fn fallback_diff_backgrounds(theme: DiffTheme, level: DiffColorLevel) -> ResolvedDiffBackgrounds {
115    match level {
116        DiffColorLevel::Ansi16 => ResolvedDiffBackgrounds::default(),
117        DiffColorLevel::TrueColor | DiffColorLevel::Ansi256 => ResolvedDiffBackgrounds {
118            add: Some(ratatui_color_from_anstyle(diff_add_bg(theme, level))),
119            del: Some(ratatui_color_from_anstyle(diff_del_bg(theme, level))),
120        },
121    }
122}
123
124fn color_from_rgb_for_level(rgb: (u8, u8, u8), theme: DiffTheme, level: DiffColorLevel) -> Option<RatatuiColor> {
125    match level {
126        DiffColorLevel::TrueColor => Some(RatatuiColor::Rgb(rgb.0, rgb.1, rgb.2)),
127        DiffColorLevel::Ansi256 => {
128            Some(RatatuiColor::Indexed(rgb_to_ansi256_for_theme(rgb.0, rgb.1, rgb.2, theme.is_light())))
129        }
130        DiffColorLevel::Ansi16 => None,
131    }
132}
133
134fn content_background(kind: DiffLineType, style_context: DiffRenderStyleContext) -> Option<RatatuiColor> {
135    match kind {
136        DiffLineType::Insert => style_context.backgrounds.add,
137        DiffLineType::Delete => style_context.backgrounds.del,
138        DiffLineType::Context => None,
139    }
140}
141
142fn word_background(kind: DiffLineType, style_context: DiffRenderStyleContext) -> Option<RatatuiColor> {
143    let color = match kind {
144        DiffLineType::Insert => style_context.add_word_bg(),
145        DiffLineType::Delete => style_context.del_word_bg(),
146        DiffLineType::Context => None,
147    }?;
148    Some(ratatui_color_from_anstyle(color))
149}
150
151/// Full-width line background style. Context rows and ANSI16 terminals use
152/// the terminal default background.
153pub(crate) fn style_line_bg(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
154    content_background(kind, style_context).map_or_else(RatatuiStyle::default, |bg| RatatuiStyle::default().bg(bg))
155}
156
157/// Stronger background for a changed intraline span.
158pub(crate) fn style_word_bg(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
159    word_background(kind, style_context).map_or_else(RatatuiStyle::default, |bg| RatatuiStyle::default().bg(bg))
160}
161
162fn scope_backgrounds_for_level(level: DiffColorLevel) -> DiffScopeBackgroundRgbs {
163    match level {
164        DiffColorLevel::Ansi16 => DiffScopeBackgroundRgbs::default(),
165        DiffColorLevel::TrueColor | DiffColorLevel::Ansi256 => diff_scope_background_rgbs(),
166    }
167}
168
169// ── Private colour helpers ──────────────────────────────────────────────────
170
171/// Resolve the foreground colour for a diff indicator (gutter/sign).
172fn indicator_fg(kind: DiffLineType, style_context: DiffRenderStyleContext) -> Option<RatatuiColor> {
173    let color = match kind {
174        DiffLineType::Insert => diff_add_fg(style_context.theme, style_context.level),
175        DiffLineType::Delete => diff_del_fg(style_context.theme, style_context.level),
176        DiffLineType::Context => return None,
177    };
178    Some(ratatui_color_from_anstyle(color))
179}
180
181/// Theme-aware gutter foreground. It is intentionally explicit rather than
182/// DIMMED so line numbers remain readable on both diff background levels.
183fn gutter_fg(style_context: DiffRenderStyleContext) -> RatatuiColor {
184    ratatui_color_from_anstyle(diff_gutter_fg(style_context.theme, style_context.level))
185}
186
187// ── Public style API ────────────────────────────────────────────────────────
188
189/// Gutter (line number + `│`) style: muted, contrast-safe foreground on the
190/// row tint. The explicit theme colors recede without applying DIM, whose
191/// terminal-dependent attenuation can make tinted gutters unreadable.
192pub(crate) fn style_gutter(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
193    let mut style = RatatuiStyle::default().fg(gutter_fg(style_context));
194    if let Some(bg) = content_background(kind, style_context) {
195        style = style.bg(bg);
196    }
197    style
198}
199
200/// Sign character (`+`/`-`) style: bright bold foreground on the row tint.
201pub(crate) fn style_sign(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
202    let mut style = RatatuiStyle::default().add_modifier(Modifier::BOLD);
203    if let Some(color) = indicator_fg(kind, style_context) {
204        style = style.fg(color);
205    }
206    if let Some(bg) = content_background(kind, style_context) {
207        style = style.bg(bg);
208    }
209    style
210}
211
212/// Hunk header (`@@ -old +new @@`): bold cyan metadata with no row tint.
213pub(crate) fn style_hunk_header(style_context: DiffRenderStyleContext) -> RatatuiStyle {
214    let _ = style_context;
215    RatatuiStyle::default().fg(RatatuiColor::Cyan).add_modifier(Modifier::BOLD)
216}
217
218/// File header for `--- a/path` (red) and `+++ b/path` (green): bold
219/// foreground-only metadata. File headers are section labels, not diff rows.
220fn style_file_header(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
221    let mut style = RatatuiStyle::default().add_modifier(Modifier::BOLD);
222    if let Some(color) = indicator_fg(kind, style_context) {
223        style = style.fg(color);
224    }
225    style
226}
227
228/// `--- a/path` header: bold red on the deletion tint.
229pub(crate) fn style_file_header_old(style_context: DiffRenderStyleContext) -> RatatuiStyle {
230    style_file_header(DiffLineType::Delete, style_context)
231}
232
233/// `+++ b/path` header: bold green on the addition tint.
234pub(crate) fn style_file_header_new(style_context: DiffRenderStyleContext) -> RatatuiStyle {
235    style_file_header(DiffLineType::Insert, style_context)
236}
237
238/// Content style for plain (non-syntax-highlighted) diff lines.
239///
240/// Color-capable terminals use the row tint with the terminal's normal
241/// foreground. ANSI16 retains the bright foreground fallback because it
242/// cannot represent the tint.
243pub(crate) fn style_content(kind: DiffLineType, style_context: DiffRenderStyleContext) -> RatatuiStyle {
244    let bg = content_background(kind, style_context);
245    let fg = indicator_fg(kind, style_context);
246    match (kind, style_context.level, bg) {
247        (DiffLineType::Context, _, _) => RatatuiStyle::default(),
248        (_, DiffColorLevel::Ansi16, _) => fg.map(|c| RatatuiStyle::default().fg(c)).unwrap_or_default(),
249        (_, _, Some(bg)) => RatatuiStyle::default().bg(bg),
250        (_, _, None) => RatatuiStyle::default(),
251    }
252}
253
254/// Markdown rendering uses `anstyle`; return the plain diff style there.
255pub(crate) fn style_content_ansi(kind: DiffLineType, style_context: DiffRenderStyleContext) -> AnstyleStyle {
256    let background = content_background(kind, style_context).map(ratatui_color_to_anstyle);
257    let foreground = indicator_fg(kind, style_context).map(ratatui_color_to_anstyle);
258    match (kind, style_context.level, background) {
259        (DiffLineType::Context, _, _) => AnstyleStyle::new(),
260        (_, DiffColorLevel::Ansi16, _) => AnstyleStyle::new().fg_color(foreground),
261        (_, _, background) => AnstyleStyle::new().bg_color(background),
262    }
263}
264
265/// Markdown rendering uses `anstyle`; return the diff marker style there.
266///
267/// Bright bold foreground on the row tint — never dimmed so `+`/`-` stay
268/// scannable against the muted gutter.
269pub(crate) fn style_sign_ansi(kind: DiffLineType, style_context: DiffRenderStyleContext) -> AnstyleStyle {
270    let background = content_background(kind, style_context).map(ratatui_color_to_anstyle);
271    let foreground = indicator_fg(kind, style_context).map(ratatui_color_to_anstyle);
272    let mut style = AnstyleStyle::new().fg_color(foreground).bg_color(background);
273    if !matches!(kind, DiffLineType::Context) {
274        style = style.effects(anstyle::Effects::BOLD);
275    }
276    style
277}
278
279/// Contrast-safe gutter style for `anstyle` rendering (line numbers + `│`) on
280/// the row tint. Avoid DIM because its result depends on the terminal palette.
281pub(crate) fn style_gutter_ansi(kind: DiffLineType, style_context: DiffRenderStyleContext) -> AnstyleStyle {
282    let background = content_background(kind, style_context).map(ratatui_color_to_anstyle);
283    let foreground = Some(ratatui_color_to_anstyle(gutter_fg(style_context)));
284    AnstyleStyle::new().fg_color(foreground).bg_color(background)
285}
286
287/// Hunk header for `anstyle` rendering: bold cyan metadata without a
288/// background.
289pub(crate) fn style_hunk_header_ansi(style_context: DiffRenderStyleContext) -> AnstyleStyle {
290    let _ = style_context;
291    AnstyleStyle::new()
292        .fg_color(Some(AnstyleColor::Ansi(anstyle::AnsiColor::Cyan)))
293        .effects(anstyle::Effects::BOLD)
294}
295
296/// File header for `anstyle` rendering (`---` red, `+++` green): bold
297/// foreground-only metadata.
298fn style_file_header_ansi(kind: DiffLineType, style_context: DiffRenderStyleContext) -> AnstyleStyle {
299    let foreground = indicator_fg(kind, style_context).map(ratatui_color_to_anstyle);
300    AnstyleStyle::new().fg_color(foreground).effects(anstyle::Effects::BOLD)
301}
302
303/// `--- a/path` header for `anstyle` rendering.
304pub(crate) fn style_file_header_old_ansi(style_context: DiffRenderStyleContext) -> AnstyleStyle {
305    style_file_header_ansi(DiffLineType::Delete, style_context)
306}
307
308/// `+++ b/path` header for `anstyle` rendering.
309pub(crate) fn style_file_header_new_ansi(style_context: DiffRenderStyleContext) -> AnstyleStyle {
310    style_file_header_ansi(DiffLineType::Insert, style_context)
311}
312
313fn ratatui_color_to_anstyle(color: RatatuiColor) -> AnstyleColor {
314    match color {
315        RatatuiColor::Reset => AnstyleColor::Ansi(anstyle::AnsiColor::Black),
316        RatatuiColor::Black => AnstyleColor::Ansi(anstyle::AnsiColor::Black),
317        RatatuiColor::Red => AnstyleColor::Ansi(anstyle::AnsiColor::Red),
318        RatatuiColor::Green => AnstyleColor::Ansi(anstyle::AnsiColor::Green),
319        RatatuiColor::Yellow => AnstyleColor::Ansi(anstyle::AnsiColor::Yellow),
320        RatatuiColor::Blue => AnstyleColor::Ansi(anstyle::AnsiColor::Blue),
321        RatatuiColor::Magenta => AnstyleColor::Ansi(anstyle::AnsiColor::Magenta),
322        RatatuiColor::Cyan => AnstyleColor::Ansi(anstyle::AnsiColor::Cyan),
323        RatatuiColor::Gray => AnstyleColor::Ansi(anstyle::AnsiColor::White),
324        RatatuiColor::DarkGray => AnstyleColor::Ansi(anstyle::AnsiColor::BrightBlack),
325        RatatuiColor::LightRed => AnstyleColor::Ansi(anstyle::AnsiColor::BrightRed),
326        RatatuiColor::LightGreen => AnstyleColor::Ansi(anstyle::AnsiColor::BrightGreen),
327        RatatuiColor::LightYellow => AnstyleColor::Ansi(anstyle::AnsiColor::BrightYellow),
328        RatatuiColor::LightBlue => AnstyleColor::Ansi(anstyle::AnsiColor::BrightBlue),
329        RatatuiColor::LightMagenta => AnstyleColor::Ansi(anstyle::AnsiColor::BrightMagenta),
330        RatatuiColor::LightCyan => AnstyleColor::Ansi(anstyle::AnsiColor::BrightCyan),
331        RatatuiColor::White => AnstyleColor::Ansi(anstyle::AnsiColor::BrightWhite),
332        RatatuiColor::Indexed(index) => AnstyleColor::Ansi256(anstyle::Ansi256Color(index)),
333        RatatuiColor::Rgb(red, green, blue) => AnstyleColor::Rgb(anstyle::RgbColor(red, green, blue)),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn test_style_context(theme: DiffTheme, level: DiffColorLevel) -> DiffRenderStyleContext {
342        diff_render_style_context_for(theme, level, DiffScopeBackgroundRgbs::default())
343    }
344
345    #[test]
346    fn dark_add_bg_is_subtle_green_tint() {
347        let bg = diff_add_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
348        assert_eq!(bg, anstyle::Color::Rgb(anstyle::RgbColor(20, 58, 45)));
349    }
350
351    #[test]
352    fn dark_del_bg_is_subtle_red_tint() {
353        let bg = diff_del_bg(DiffTheme::Dark, DiffColorLevel::TrueColor);
354        assert_eq!(bg, anstyle::Color::Rgb(anstyle::RgbColor(70, 38, 42)));
355    }
356
357    #[test]
358    fn light_add_bg_is_subtle_green_tint() {
359        let bg = diff_add_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
360        assert_eq!(bg, anstyle::Color::Rgb(anstyle::RgbColor(218, 246, 225)));
361    }
362
363    #[test]
364    fn light_del_bg_is_subtle_red_tint() {
365        let bg = diff_del_bg(DiffTheme::Light, DiffColorLevel::TrueColor);
366        assert_eq!(bg, anstyle::Color::Rgb(anstyle::RgbColor(255, 224, 224)));
367    }
368
369    #[test]
370    fn backgrounds_follow_terminal_capability() {
371        assert_eq!(
372            diff_add_bg(DiffTheme::Dark, DiffColorLevel::TrueColor),
373            anstyle::Color::Rgb(anstyle::RgbColor(20, 58, 45))
374        );
375        assert_eq!(
376            diff_del_bg(DiffTheme::Dark, DiffColorLevel::TrueColor),
377            anstyle::Color::Rgb(anstyle::RgbColor(70, 38, 42))
378        );
379        assert!(matches!(diff_add_bg(DiffTheme::Dark, DiffColorLevel::Ansi256), anstyle::Color::Ansi256(_)));
380        assert!(matches!(diff_del_word_bg(DiffTheme::Light, DiffColorLevel::Ansi256), anstyle::Color::Ansi256(_)));
381    }
382
383    #[test]
384    fn context_line_bg_is_default() {
385        let style =
386            style_line_bg(DiffLineType::Context, test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor));
387        assert_eq!(style, RatatuiStyle::default());
388    }
389
390    #[test]
391    fn dark_gutter_context_is_contrast_safe_without_bg() {
392        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
393        let style = style_gutter(DiffLineType::Context, ctx);
394        assert_eq!(style.fg, Some(RatatuiColor::Rgb(165, 175, 170)));
395        assert!(!style.add_modifier.contains(Modifier::DIM));
396        assert_eq!(style.bg, None);
397    }
398
399    #[test]
400    fn insert_gutter_is_contrast_safe_on_add_bg() {
401        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
402        let style = style_gutter(DiffLineType::Insert, ctx);
403        assert_eq!(style.fg, Some(RatatuiColor::Rgb(165, 175, 170)));
404        assert!(!style.add_modifier.contains(Modifier::DIM));
405        assert_eq!(style.bg, Some(RatatuiColor::Rgb(20, 58, 45)));
406    }
407
408    #[test]
409    fn delete_gutter_is_contrast_safe_on_delete_bg() {
410        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
411        let style = style_gutter(DiffLineType::Delete, ctx);
412        assert_eq!(style.fg, Some(RatatuiColor::Rgb(165, 175, 170)));
413        assert!(!style.add_modifier.contains(Modifier::DIM));
414        assert_eq!(style.bg, Some(RatatuiColor::Rgb(70, 38, 42)));
415    }
416
417    #[test]
418    fn dark_ansi16_content_uses_foreground_only() {
419        let style = style_content(DiffLineType::Insert, test_style_context(DiffTheme::Dark, DiffColorLevel::Ansi16));
420        assert_eq!(style.fg, Some(RatatuiColor::LightGreen));
421        assert_eq!(style.bg, None);
422    }
423
424    #[test]
425    fn truecolor_content_uses_row_tint_and_keeps_default_foreground() {
426        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
427        let add = style_content(DiffLineType::Insert, ctx);
428        let del = style_content(DiffLineType::Delete, ctx);
429        assert_eq!(add.fg, None);
430        assert_eq!(del.fg, None);
431        assert_eq!(add.bg, Some(RatatuiColor::Rgb(20, 58, 45)));
432        assert_eq!(del.bg, Some(RatatuiColor::Rgb(70, 38, 42)));
433        let sign = style_sign(DiffLineType::Insert, ctx);
434        assert_eq!(sign.fg, Some(RatatuiColor::Rgb(85, 255, 85)));
435        assert_eq!(sign.bg, add.bg);
436    }
437
438    #[test]
439    fn sign_style_dark_uses_light_green_and_custom_red_with_bold_on_row_bg() {
440        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
441        let add_sign = style_sign(DiffLineType::Insert, ctx);
442        let del_sign = style_sign(DiffLineType::Delete, ctx);
443        assert_eq!(add_sign.fg, Some(RatatuiColor::Rgb(85, 255, 85)));
444        assert_eq!(del_sign.fg, Some(RatatuiColor::Rgb(255, 180, 180)));
445        assert!(add_sign.add_modifier.contains(Modifier::BOLD));
446        assert!(del_sign.add_modifier.contains(Modifier::BOLD));
447        assert_eq!(add_sign.bg, Some(RatatuiColor::Rgb(20, 58, 45)));
448        assert_eq!(del_sign.bg, Some(RatatuiColor::Rgb(70, 38, 42)));
449    }
450
451    #[test]
452    fn sign_style_light_stays_bright_bold_on_row_bg() {
453        let ctx = test_style_context(DiffTheme::Light, DiffColorLevel::TrueColor);
454        let add_sign = style_sign(DiffLineType::Insert, ctx);
455        let del_sign = style_sign(DiffLineType::Delete, ctx);
456        assert_eq!(add_sign.fg, Some(RatatuiColor::Rgb(0, 92, 43)));
457        assert_eq!(del_sign.fg, Some(RatatuiColor::Rgb(140, 20, 25)));
458        assert!(add_sign.add_modifier.contains(Modifier::BOLD));
459        assert!(!add_sign.add_modifier.contains(Modifier::DIM));
460        assert!(!del_sign.add_modifier.contains(Modifier::DIM));
461        assert_eq!(add_sign.bg, Some(RatatuiColor::Rgb(218, 246, 225)));
462        assert_eq!(del_sign.bg, Some(RatatuiColor::Rgb(255, 224, 224)));
463    }
464
465    #[test]
466    fn hunk_header_uses_cyan_bold_without_background() {
467        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
468        let style = style_hunk_header(ctx);
469        assert_eq!(style.fg, Some(RatatuiColor::Cyan));
470        assert!(style.add_modifier.contains(Modifier::BOLD));
471        assert_eq!(style.bg, None);
472    }
473
474    #[test]
475    fn file_headers_use_red_green_bold_without_background() {
476        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
477        let old = style_file_header_old(ctx);
478        let new = style_file_header_new(ctx);
479        assert!(old.add_modifier.contains(Modifier::BOLD));
480        assert!(new.add_modifier.contains(Modifier::BOLD));
481        assert_eq!(old.bg, None);
482        assert_eq!(new.bg, None);
483        assert_ne!(old.fg, new.fg);
484    }
485
486    #[test]
487    fn line_backgrounds_use_scope_colors_when_available() {
488        let style_context = diff_render_style_context_for(
489            DiffTheme::Dark,
490            DiffColorLevel::TrueColor,
491            DiffScopeBackgroundRgbs {
492                inserted: Some((1, 2, 3)),
493                deleted: Some((4, 5, 6)),
494            },
495        );
496
497        assert_eq!(style_line_bg(DiffLineType::Insert, style_context).bg, Some(RatatuiColor::Rgb(1, 2, 3)));
498        assert_eq!(style_line_bg(DiffLineType::Delete, style_context).bg, Some(RatatuiColor::Rgb(4, 5, 6)));
499    }
500
501    #[test]
502    fn ansi256_maps_row_and_word_backgrounds() {
503        let style_context = diff_render_style_context_for(
504            DiffTheme::Dark,
505            DiffColorLevel::Ansi256,
506            DiffScopeBackgroundRgbs { inserted: Some((0, 95, 0)), deleted: None },
507        );
508        assert!(style_line_bg(DiffLineType::Insert, style_context).bg.is_some());
509        assert!(style_line_bg(DiffLineType::Delete, style_context).bg.is_some());
510        assert!(style_word_bg(DiffLineType::Insert, style_context).bg.is_some());
511    }
512
513    #[test]
514    fn ansi16_disables_line_backgrounds_even_with_scope_colors() {
515        let style_context = diff_render_style_context_for(
516            DiffTheme::Dark,
517            DiffColorLevel::Ansi16,
518            DiffScopeBackgroundRgbs {
519                inserted: Some((8, 9, 10)),
520                deleted: Some((11, 12, 13)),
521            },
522        );
523        assert_eq!(style_line_bg(DiffLineType::Insert, style_context), RatatuiStyle::default());
524        assert_eq!(style_line_bg(DiffLineType::Delete, style_context), RatatuiStyle::default());
525    }
526
527    #[test]
528    fn ansi16_content_has_no_background() {
529        let style_context =
530            diff_render_style_context_for(DiffTheme::Dark, DiffColorLevel::Ansi16, DiffScopeBackgroundRgbs::default());
531        let add = style_content(DiffLineType::Insert, style_context);
532        let del = style_content(DiffLineType::Delete, style_context);
533        assert_eq!(add.fg, Some(RatatuiColor::LightGreen));
534        assert_eq!(add.bg, None);
535        assert_eq!(del.fg, Some(RatatuiColor::LightRed));
536        assert_eq!(del.bg, None);
537    }
538
539    #[test]
540    fn partial_scope_override_keeps_palette_for_missing_side() {
541        let style_context = diff_render_style_context_for(
542            DiffTheme::Dark,
543            DiffColorLevel::TrueColor,
544            DiffScopeBackgroundRgbs { inserted: Some((12, 34, 56)), deleted: None },
545        );
546        assert_eq!(style_line_bg(DiffLineType::Insert, style_context).bg, Some(RatatuiColor::Rgb(12, 34, 56)));
547        assert_eq!(style_content(DiffLineType::Insert, style_context).bg, Some(RatatuiColor::Rgb(12, 34, 56)));
548        assert_eq!(style_content(DiffLineType::Delete, style_context).bg, Some(RatatuiColor::Rgb(70, 38, 42)));
549    }
550
551    #[test]
552    fn word_background_is_stronger_and_ansi16_stays_foreground_only() {
553        let ctx = test_style_context(DiffTheme::Dark, DiffColorLevel::TrueColor);
554        assert_eq!(style_word_bg(DiffLineType::Insert, ctx).bg, Some(RatatuiColor::Rgb(36, 100, 70)));
555        assert_eq!(style_word_bg(DiffLineType::Delete, ctx).bg, Some(RatatuiColor::Rgb(140, 52, 58)));
556
557        let ansi16 = test_style_context(DiffTheme::Dark, DiffColorLevel::Ansi16);
558        assert_eq!(style_line_bg(DiffLineType::Insert, ansi16).bg, None);
559        assert_eq!(style_word_bg(DiffLineType::Insert, ansi16).bg, None);
560        assert_eq!(style_content_ansi(DiffLineType::Insert, ansi16).get_bg_color(), None);
561        assert_eq!(style_sign_ansi(DiffLineType::Insert, ansi16).get_bg_color(), None);
562    }
563
564    fn relative_luminance(color: (u8, u8, u8)) -> f32 {
565        let linear = |component: u8| {
566            let value = f32::from(component) / 255.0;
567            if value <= 0.04045 {
568                value / 12.92
569            } else {
570                ((value + 0.055) / 1.055).powf(2.4)
571            }
572        };
573        let [red, green, blue] = [linear(color.0), linear(color.1), linear(color.2)];
574        0.2126 * red + 0.7152 * green + 0.0722 * blue
575    }
576
577    fn contrast_ratio(foreground: (u8, u8, u8), background: (u8, u8, u8)) -> f32 {
578        let foreground = relative_luminance(foreground);
579        let background = relative_luminance(background);
580        (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05)
581    }
582
583    fn test_color_rgb(color: RatatuiColor) -> (u8, u8, u8) {
584        match color {
585            RatatuiColor::Rgb(red, green, blue) => (red, green, blue),
586            RatatuiColor::LightGreen => (85, 255, 85),
587            other => panic!("test only covers RGB and bright-green marker colors, got {other:?}"),
588        }
589    }
590
591    #[test]
592    fn marker_foregrounds_meet_wcag_against_both_diff_background_levels() {
593        for theme in [DiffTheme::Dark, DiffTheme::Light] {
594            let context = test_style_context(theme, DiffColorLevel::TrueColor);
595            for kind in [DiffLineType::Insert, DiffLineType::Delete] {
596                let foreground = test_color_rgb(style_sign(kind, context).fg.expect("marker foreground"));
597                let row = test_color_rgb(style_line_bg(kind, context).bg.expect("row background"));
598                let word = test_color_rgb(style_word_bg(kind, context).bg.expect("word background"));
599                let gutter = test_color_rgb(style_gutter(kind, context).fg.expect("gutter foreground"));
600                assert!(
601                    contrast_ratio(foreground, row) >= 4.5,
602                    "{theme:?} {kind:?} marker fails row contrast: {foreground:?} on {row:?}"
603                );
604                assert!(
605                    contrast_ratio(foreground, word) >= 4.5,
606                    "{theme:?} {kind:?} marker fails word contrast: {foreground:?} on {word:?}"
607                );
608                assert!(
609                    contrast_ratio(gutter, row) >= 4.5,
610                    "{theme:?} {kind:?} gutter fails row contrast: {gutter:?} on {row:?}"
611                );
612            }
613        }
614    }
615}