mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
fix(tui): theme-aware diff backgrounds with fallback behavior (#13037)
## Problem The TUI diff renderer uses hardcoded background palettes for insert/delete lines that don't respect the user's chosen syntax theme. When a theme defines `markup.inserted` / `markup.deleted` scope backgrounds (the convention used by GitHub, Solarized, Monokai, and most VS Code themes), those colors are ignored — the diff always renders with the same green/red tints regardless of theme selection. Separately, ANSI-16 terminals (and Windows Terminal sessions misreported as ANSI-16) rendered diff backgrounds as full-saturation blocks that obliterated syntax token colors, making highlighted diffs unreadable. ## Mental model Diff backgrounds are resolved in three layers: 1. **Color level detection** — `diff_color_level_for_terminal()` maps the raw `supports-color` probe + Windows Terminal heuristics to a `DiffColorLevel` (TrueColor / Ansi256 / Ansi16). Windows Terminal gets promoted from Ansi16 to TrueColor when `WT_SESSION` is present. 2. **Background resolution** — `resolve_diff_backgrounds()` queries the active syntax theme for `markup.inserted`/`markup.deleted` (falling back to `diff.inserted`/`diff.deleted`), then overlays those on top of the hardcoded palette. For ANSI-256, theme RGB values are quantized to the nearest xterm-256 index. For ANSI-16, backgrounds are `None` (foreground-only). 3. **Style composition** — The resolved `ResolvedDiffBackgrounds` is threaded through every call to `style_add`, `style_del`, `style_sign_*`, and `style_line_bg_for`, which decide how to compose foreground+background for each line kind and theme variant. A new `RichDiffColorLevel` type (a subset of `DiffColorLevel` without Ansi16) encodes the invariant "we have enough depth for tinted backgrounds" at the type level, so background-producing functions have exhaustive matches without unreachable arms. ## Non-goals - No change to gutter (line number column) styling — gutter backgrounds still use the hardcoded palette. - No per-token scope background resolution — this is line-level background only; syntax token colors come from the existing `highlight_code_to_styled_spans` path. - No dark/light theme auto-switching from scope backgrounds — `DiffTheme` is still determined by querying the terminal's background color. ## Tradeoffs - **Theme trust vs. visual safety:** When a theme defines scope backgrounds, we trust them unconditionally for rich color levels. A badly authored theme could produce illegible combinations. The fallback for `None` backgrounds (foreground-only) is intentionally conservative. - **Quantization quality:** ANSI-256 quantization uses perceptual distance across indices 16–255, skipping system colors. The result is approximate — a subtle theme tint may land on a noticeably different xterm index. - **Single-query caching:** `resolve_diff_backgrounds` is called once per `render_change` invocation (i.e., once per file in a diff). If the theme changes mid-render (live preview), the next file picks up the new backgrounds. ## Architecture Files changed: | File | Role | |---|---| | `tui/src/render/highlight.rs` | New: `DiffScopeBackgroundRgbs`, `diff_scope_background_rgbs()`, scope extraction helpers | | `tui/src/diff_render.rs` | New: `RichDiffColorLevel`, `ResolvedDiffBackgrounds`, `resolve_diff_backgrounds*`, `quantize_rgb_to_ansi256`, Windows Terminal promotion; modified: all style helpers to accept/thread `ResolvedDiffBackgrounds` | The scope-extraction code lives in `highlight.rs` because it uses `syntect::highlighting::Highlighter` and the theme singleton. The resolution and quantization logic lives in `diff_render.rs` because it depends on diff-specific types (`DiffTheme`, `DiffColorLevel`, ratatui `Color`). ## Observability No runtime logging was added. The most useful debugging aid is the `diff_color_level_for_terminal` function, which is pure and fully unit-tested — to diagnose a color-depth mismatch, log its four inputs (`StdoutColorLevel`, `TerminalName`, `WT_SESSION` presence, `FORCE_COLOR` presence). Scope resolution can be tested by loading a custom `.tmTheme` with known `markup.inserted` / `markup.deleted` backgrounds and checking the diff output in a truecolor terminal. ## Tests - **Windows Terminal promotion:** 7 unit tests cover every branch of `diff_color_level_for_terminal` (ANSI-16 promotion, `WT_SESSION` unconditional promotion, `FORCE_COLOR` suppression, conservative `Unknown` level). - **ANSI-16 foreground-only:** Tests verify that `style_add`, `style_del`, `style_sign_*`, `style_line_bg_for`, and `style_gutter_for` all return `None` backgrounds on ANSI-16. - **Scope resolution:** Tests verify `markup.*` preference over `diff.*`, `None` when no scope matches, bundled theme resolution, and custom `.tmTheme` round-trip. - **Quantization:** Test verifies ANSI-256 quantization of a known RGB triple. - **Insta snapshots:** 2 new snapshot tests (`ansi16_insert_delete_no_background`, `theme_scope_background_resolution`) lock visual output.
This commit is contained in:
@@ -14,6 +14,12 @@
|
||||
//! palettes for truecolor / 256-color / 16-color terminals so add/delete lines
|
||||
//! remain visually distinct even when quantizing to limited palettes.
|
||||
//!
|
||||
//! **Syntax-theme scope backgrounds:** when the active syntax theme defines
|
||||
//! background colors for `markup.inserted` / `markup.deleted` (or fallback
|
||||
//! `diff.inserted` / `diff.deleted`) scopes, those colors override the
|
||||
//! hardcoded palette for rich color levels. ANSI-16 mode always uses
|
||||
//! foreground-only styling regardless of theme scope backgrounds.
|
||||
//!
|
||||
//! **Highlighting strategy for `Update` diffs:** the renderer highlights each
|
||||
//! hunk as a single concatenated block rather than line-by-line. This
|
||||
//! preserves syntect's parser state across consecutive lines within a hunk
|
||||
@@ -69,8 +75,11 @@ const LIGHT_256_DEL_NUM_BG_IDX: u8 = 217;
|
||||
const LIGHT_256_GUTTER_FG_IDX: u8 = 236;
|
||||
|
||||
use crate::color::is_light;
|
||||
use crate::color::perceptual_distance;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::render::Insets;
|
||||
use crate::render::highlight::DiffScopeBackgroundRgbs;
|
||||
use crate::render::highlight::diff_scope_background_rgbs;
|
||||
use crate::render::highlight::exceeds_highlight_limits;
|
||||
use crate::render::highlight::highlight_code_to_styled_spans;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
@@ -78,6 +87,7 @@ use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::terminal_palette::StdoutColorLevel;
|
||||
use crate::terminal_palette::XTERM_COLORS;
|
||||
use crate::terminal_palette::default_bg;
|
||||
use crate::terminal_palette::indexed_color;
|
||||
use crate::terminal_palette::rgb_color;
|
||||
@@ -106,7 +116,7 @@ pub(crate) enum DiffLineType {
|
||||
/// the terminal's queried background color. When the background cannot be
|
||||
/// determined (common in CI or piped output), `Dark` is used as the safe
|
||||
/// default.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum DiffTheme {
|
||||
Dark,
|
||||
Light,
|
||||
@@ -127,6 +137,17 @@ enum DiffColorLevel {
|
||||
Ansi16,
|
||||
}
|
||||
|
||||
/// Subset of [`DiffColorLevel`] that supports tinted backgrounds.
|
||||
///
|
||||
/// ANSI-16 terminals render backgrounds with bold, saturated palette entries
|
||||
/// that overpower syntax tokens. This type encodes the invariant "we have
|
||||
/// enough color depth for pastel tints" so that background-producing helpers
|
||||
/// (`add_line_bg`, `del_line_bg`, `light_add_num_bg`, `light_del_num_bg`)
|
||||
/// never need an unreachable ANSI-16 arm.
|
||||
///
|
||||
/// Construct via [`RichDiffColorLevel::from_diff_color_level`], which returns
|
||||
/// `None` for ANSI-16 — callers branch on the `Option` and skip backgrounds
|
||||
/// entirely when `None`.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RichDiffColorLevel {
|
||||
TrueColor,
|
||||
@@ -134,6 +155,7 @@ enum RichDiffColorLevel {
|
||||
}
|
||||
|
||||
impl RichDiffColorLevel {
|
||||
/// Extract a rich level, returning `None` for ANSI-16.
|
||||
fn from_diff_color_level(level: DiffColorLevel) -> Option<Self> {
|
||||
match level {
|
||||
DiffColorLevel::TrueColor => Some(Self::TrueColor),
|
||||
@@ -143,6 +165,133 @@ impl RichDiffColorLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-resolved background colors for insert and delete diff lines.
|
||||
///
|
||||
/// Computed once per `render_change` call from the active syntax theme's
|
||||
/// scope backgrounds (via [`resolve_diff_backgrounds`]) and then threaded
|
||||
/// through every style helper so individual lines never re-query the theme.
|
||||
///
|
||||
/// Both fields are `None` when the color level is ANSI-16 — callers fall
|
||||
/// back to foreground-only styling in that case.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
struct ResolvedDiffBackgrounds {
|
||||
add: Option<Color>,
|
||||
del: Option<Color>,
|
||||
}
|
||||
|
||||
/// Precomputed render state for diff line styling.
|
||||
///
|
||||
/// This bundles the terminal-derived theme and color depth plus theme-resolved
|
||||
/// diff backgrounds so callers rendering many lines can compute once per render
|
||||
/// pass and reuse it across all line calls.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct DiffRenderStyleContext {
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
diff_backgrounds: ResolvedDiffBackgrounds,
|
||||
}
|
||||
|
||||
/// Resolve diff backgrounds for production rendering.
|
||||
///
|
||||
/// Queries the active syntax theme for `markup.inserted` / `markup.deleted`
|
||||
/// (and `diff.*` fallbacks), then delegates to [`resolve_diff_backgrounds_for`].
|
||||
fn resolve_diff_backgrounds(
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
) -> ResolvedDiffBackgrounds {
|
||||
resolve_diff_backgrounds_for(theme, color_level, diff_scope_background_rgbs())
|
||||
}
|
||||
|
||||
/// Snapshot the current terminal environment into a reusable style context.
|
||||
///
|
||||
/// Queries `diff_theme`, `diff_color_level`, and the active syntax theme's
|
||||
/// scope backgrounds once, bundling them into a [`DiffRenderStyleContext`]
|
||||
/// that callers thread through every line-rendering call in a single pass.
|
||||
///
|
||||
/// Call this at the top of each render frame — not per line — so the diff
|
||||
/// palette stays consistent within a frame even if the user swaps themes
|
||||
/// mid-render (theme picker live preview).
|
||||
pub(crate) fn current_diff_render_style_context() -> DiffRenderStyleContext {
|
||||
let theme = diff_theme();
|
||||
let color_level = diff_color_level();
|
||||
let diff_backgrounds = resolve_diff_backgrounds(theme, color_level);
|
||||
DiffRenderStyleContext {
|
||||
theme,
|
||||
color_level,
|
||||
diff_backgrounds,
|
||||
}
|
||||
}
|
||||
|
||||
/// Core background-resolution logic, kept pure for testability.
|
||||
///
|
||||
/// Starts from the hardcoded fallback palette and then overrides with theme
|
||||
/// scope backgrounds when both (a) the color level is rich enough and (b) the
|
||||
/// theme defines a matching scope. This means the fallback palette is always
|
||||
/// the baseline and theme scopes are strictly additive.
|
||||
fn resolve_diff_backgrounds_for(
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
scope_backgrounds: DiffScopeBackgroundRgbs,
|
||||
) -> ResolvedDiffBackgrounds {
|
||||
let mut resolved = fallback_diff_backgrounds(theme, color_level);
|
||||
let Some(level) = RichDiffColorLevel::from_diff_color_level(color_level) else {
|
||||
return resolved;
|
||||
};
|
||||
|
||||
if let Some(rgb) = scope_backgrounds.inserted {
|
||||
resolved.add = Some(color_from_rgb_for_level(rgb, level));
|
||||
}
|
||||
if let Some(rgb) = scope_backgrounds.deleted {
|
||||
resolved.del = Some(color_from_rgb_for_level(rgb, level));
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
/// Hardcoded palette backgrounds, used when the syntax theme provides no
|
||||
/// diff-specific scope backgrounds. Returns empty backgrounds for ANSI-16.
|
||||
fn fallback_diff_backgrounds(
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
) -> ResolvedDiffBackgrounds {
|
||||
match RichDiffColorLevel::from_diff_color_level(color_level) {
|
||||
Some(level) => ResolvedDiffBackgrounds {
|
||||
add: Some(add_line_bg(theme, level)),
|
||||
del: Some(del_line_bg(theme, level)),
|
||||
},
|
||||
None => ResolvedDiffBackgrounds::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an RGB triple to the appropriate ratatui `Color` for the given
|
||||
/// rich color level — passthrough for truecolor, quantized for ANSI-256.
|
||||
fn color_from_rgb_for_level(rgb: (u8, u8, u8), color_level: RichDiffColorLevel) -> Color {
|
||||
match color_level {
|
||||
RichDiffColorLevel::TrueColor => rgb_color(rgb),
|
||||
RichDiffColorLevel::Ansi256 => quantize_rgb_to_ansi256(rgb),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the closest ANSI-256 color (indices 16–255) to `target` using
|
||||
/// perceptual distance.
|
||||
///
|
||||
/// Skips the first 16 entries (system colors) because their actual RGB
|
||||
/// values depend on the user's terminal configuration and are unreliable
|
||||
/// for distance calculations.
|
||||
fn quantize_rgb_to_ansi256(target: (u8, u8, u8)) -> Color {
|
||||
let best_index = XTERM_COLORS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(16)
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
perceptual_distance(**a, target).total_cmp(&perceptual_distance(**b, target))
|
||||
})
|
||||
.map(|(index, _)| index as u8);
|
||||
match best_index {
|
||||
Some(index) => indexed_color(index),
|
||||
None => indexed_color(DARK_256_ADD_LINE_BG_IDX),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiffSummary {
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: PathBuf,
|
||||
@@ -326,8 +475,7 @@ fn render_change(
|
||||
width: usize,
|
||||
lang: Option<&str>,
|
||||
) {
|
||||
let theme = diff_theme();
|
||||
let color_level = diff_color_level();
|
||||
let style_context = current_diff_render_style_context();
|
||||
match change {
|
||||
FileChange::Add { content } => {
|
||||
// Pre-highlight the entire file content as a whole.
|
||||
@@ -343,8 +491,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
Some(spans),
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
));
|
||||
} else {
|
||||
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
@@ -354,8 +503,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
None,
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -373,8 +523,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
Some(spans),
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
));
|
||||
} else {
|
||||
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
@@ -384,8 +535,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
None,
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -440,7 +592,11 @@ fn render_change(
|
||||
let spacer = format!("{:width$} ", "", width = line_number_width.max(1));
|
||||
let spacer_span = RtSpan::styled(
|
||||
spacer,
|
||||
style_gutter_for(DiffLineType::Context, theme, color_level),
|
||||
style_gutter_for(
|
||||
DiffLineType::Context,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
),
|
||||
);
|
||||
out.push(RtLine::from(vec![spacer_span, "⋮".dim()]));
|
||||
}
|
||||
@@ -480,8 +636,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
Some(syn),
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -493,8 +650,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
None,
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -511,8 +669,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
Some(syn),
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -524,8 +683,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
None,
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -542,8 +702,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
Some(syn),
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -555,8 +716,9 @@ fn render_change(
|
||||
width,
|
||||
line_number_width,
|
||||
None,
|
||||
theme,
|
||||
color_level,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -614,74 +776,19 @@ pub(crate) fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single diff line (no syntax highlighting) as one or more wrapped
|
||||
/// ratatui `Line`s. The first output line carries the gutter sign; continuation
|
||||
/// lines are indented under the line number column.
|
||||
pub(crate) fn push_wrapped_diff_line(
|
||||
line_number: usize,
|
||||
kind: DiffLineType,
|
||||
text: &str,
|
||||
width: usize,
|
||||
line_number_width: usize,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
push_wrapped_diff_line_inner(line_number, kind, text, width, line_number_width, None)
|
||||
}
|
||||
|
||||
/// Render a single diff line with pre-computed syntax spans. The sign character
|
||||
/// uses the diff color; content gets syntax colors with a dim overlay for delete
|
||||
/// lines.
|
||||
pub(crate) fn push_wrapped_diff_line_with_syntax(
|
||||
line_number: usize,
|
||||
kind: DiffLineType,
|
||||
text: &str,
|
||||
width: usize,
|
||||
line_number_width: usize,
|
||||
syntax_spans: &[RtSpan<'static>],
|
||||
) -> Vec<RtLine<'static>> {
|
||||
push_wrapped_diff_line_inner(
|
||||
line_number,
|
||||
kind,
|
||||
text,
|
||||
width,
|
||||
line_number_width,
|
||||
Some(syntax_spans),
|
||||
)
|
||||
}
|
||||
|
||||
fn push_wrapped_diff_line_inner(
|
||||
line_number: usize,
|
||||
kind: DiffLineType,
|
||||
text: &str,
|
||||
width: usize,
|
||||
line_number_width: usize,
|
||||
syntax_spans: Option<&[RtSpan<'static>]>,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
push_wrapped_diff_line_inner_with_theme(
|
||||
line_number,
|
||||
kind,
|
||||
text,
|
||||
width,
|
||||
line_number_width,
|
||||
syntax_spans,
|
||||
diff_theme(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Core line renderer: builds one or more wrapped `RtLine`s for a single diff
|
||||
/// line, applying gutter, sign, content, and full-width background styles
|
||||
/// according to the given `theme`.
|
||||
/// Render a single plain-text (non-syntax-highlighted) diff line, wrapped to
|
||||
/// `width` columns, using a pre-computed [`DiffRenderStyleContext`].
|
||||
///
|
||||
/// Split out from `push_wrapped_diff_line_inner` so that tests can exercise
|
||||
/// specific `DiffTheme` variants without depending on the terminal's queried
|
||||
/// background.
|
||||
fn push_wrapped_diff_line_inner_with_theme(
|
||||
/// This is the convenience entry point used by the theme picker preview and
|
||||
/// any caller that does not have syntax spans. Delegates to the inner
|
||||
/// rendering core with `syntax_spans = None`.
|
||||
pub(crate) fn push_wrapped_diff_line_with_style_context(
|
||||
line_number: usize,
|
||||
kind: DiffLineType,
|
||||
text: &str,
|
||||
width: usize,
|
||||
line_number_width: usize,
|
||||
syntax_spans: Option<&[RtSpan<'static>]>,
|
||||
theme: DiffTheme,
|
||||
style_context: DiffRenderStyleContext,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
line_number,
|
||||
@@ -689,9 +796,39 @@ fn push_wrapped_diff_line_inner_with_theme(
|
||||
text,
|
||||
width,
|
||||
line_number_width,
|
||||
syntax_spans,
|
||||
theme,
|
||||
diff_color_level(),
|
||||
None,
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render a syntax-highlighted diff line, wrapped to `width` columns, using
|
||||
/// a pre-computed [`DiffRenderStyleContext`].
|
||||
///
|
||||
/// Like [`push_wrapped_diff_line_with_style_context`] but overlays
|
||||
/// `syntax_spans` (from [`highlight_code_to_styled_spans`]) onto the diff
|
||||
/// coloring. Delete lines receive a `DIM` modifier so syntax colors do not
|
||||
/// overpower the removal cue.
|
||||
pub(crate) fn push_wrapped_diff_line_with_syntax_and_style_context(
|
||||
line_number: usize,
|
||||
kind: DiffLineType,
|
||||
text: &str,
|
||||
width: usize,
|
||||
line_number_width: usize,
|
||||
syntax_spans: &[RtSpan<'static>],
|
||||
style_context: DiffRenderStyleContext,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
line_number,
|
||||
kind,
|
||||
text,
|
||||
width,
|
||||
line_number_width,
|
||||
Some(syntax_spans),
|
||||
style_context.theme,
|
||||
style_context.color_level,
|
||||
style_context.diff_backgrounds,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -705,6 +842,7 @@ fn push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
syntax_spans: Option<&[RtSpan<'static>]>,
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
diff_backgrounds: ResolvedDiffBackgrounds,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
let ln_str = line_number.to_string();
|
||||
|
||||
@@ -716,18 +854,18 @@ fn push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
let (sign_char, sign_style, content_style) = match kind {
|
||||
DiffLineType::Insert => (
|
||||
'+',
|
||||
style_sign_add(theme, color_level),
|
||||
style_add(theme, color_level),
|
||||
style_sign_add(theme, color_level, diff_backgrounds),
|
||||
style_add(theme, color_level, diff_backgrounds),
|
||||
),
|
||||
DiffLineType::Delete => (
|
||||
'-',
|
||||
style_sign_del(theme, color_level),
|
||||
style_del(theme, color_level),
|
||||
style_sign_del(theme, color_level, diff_backgrounds),
|
||||
style_del(theme, color_level, diff_backgrounds),
|
||||
),
|
||||
DiffLineType::Context => (' ', style_context(), style_context()),
|
||||
};
|
||||
|
||||
let line_bg = style_line_bg_for(kind, theme, color_level);
|
||||
let line_bg = style_line_bg_for(kind, diff_backgrounds);
|
||||
let gutter_style = style_gutter_for(kind, theme, color_level);
|
||||
|
||||
// When we have syntax spans, compose them with the diff style for a richer
|
||||
@@ -997,12 +1135,15 @@ fn diff_color_level_for_terminal(
|
||||
/// Full-width background applied to the `RtLine` itself (not individual spans).
|
||||
/// Context lines intentionally leave the background unset so the terminal
|
||||
/// default shows through.
|
||||
fn style_line_bg_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
match (kind, RichDiffColorLevel::from_diff_color_level(color_level)) {
|
||||
(_, None) => Style::default(),
|
||||
(DiffLineType::Insert, Some(level)) => Style::default().bg(add_line_bg(theme, level)),
|
||||
(DiffLineType::Delete, Some(level)) => Style::default().bg(del_line_bg(theme, level)),
|
||||
(DiffLineType::Context, _) => Style::default(),
|
||||
fn style_line_bg_for(kind: DiffLineType, diff_backgrounds: ResolvedDiffBackgrounds) -> Style {
|
||||
match kind {
|
||||
DiffLineType::Insert => diff_backgrounds
|
||||
.add
|
||||
.map_or_else(Style::default, |bg| Style::default().bg(bg)),
|
||||
DiffLineType::Delete => diff_backgrounds
|
||||
.del
|
||||
.map_or_else(Style::default, |bg| Style::default().bg(bg)),
|
||||
DiffLineType::Context => Style::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,56 +1219,81 @@ fn style_gutter_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColor
|
||||
/// Sign character (`+`) for insert lines. On dark terminals it inherits the
|
||||
/// full content style (green fg + tinted bg). On light terminals it uses only
|
||||
/// a green foreground and lets the line-level bg show through.
|
||||
fn style_sign_add(theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
fn style_sign_add(
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
diff_backgrounds: ResolvedDiffBackgrounds,
|
||||
) -> Style {
|
||||
match theme {
|
||||
DiffTheme::Light => Style::default().fg(Color::Green),
|
||||
DiffTheme::Dark => style_add(theme, color_level),
|
||||
DiffTheme::Dark => style_add(theme, color_level, diff_backgrounds),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign character (`-`) for delete lines. Mirror of [`style_sign_add`].
|
||||
fn style_sign_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
fn style_sign_del(
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
diff_backgrounds: ResolvedDiffBackgrounds,
|
||||
) -> Style {
|
||||
match theme {
|
||||
DiffTheme::Light => Style::default().fg(Color::Red),
|
||||
DiffTheme::Dark => style_del(theme, color_level),
|
||||
DiffTheme::Dark => style_del(theme, color_level, diff_backgrounds),
|
||||
}
|
||||
}
|
||||
|
||||
/// Content style for insert lines (plain, non-syntax-highlighted text).
|
||||
fn style_add(theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
match (theme, color_level) {
|
||||
(_, DiffColorLevel::Ansi16) => Style::default().fg(Color::Green),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor) => {
|
||||
Style::default().bg(add_line_bg(theme, RichDiffColorLevel::TrueColor))
|
||||
///
|
||||
/// Foreground-only on ANSI-16. On rich levels, uses the pre-resolved
|
||||
/// background from `diff_backgrounds` — which is the theme scope color when
|
||||
/// available, or the hardcoded palette otherwise. Dark themes add an
|
||||
/// explicit green foreground for readability over the tinted background;
|
||||
/// light themes rely on the default (dark) foreground against the pastel.
|
||||
///
|
||||
/// When no background is resolved (e.g. a theme that defines no diff
|
||||
/// scopes and the fallback palette is somehow empty), the style degrades
|
||||
/// to foreground-only so the line is still legible.
|
||||
fn style_add(
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
diff_backgrounds: ResolvedDiffBackgrounds,
|
||||
) -> Style {
|
||||
match (theme, color_level, diff_backgrounds.add) {
|
||||
(_, DiffColorLevel::Ansi16, _) => Style::default().fg(Color::Green),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor, Some(bg))
|
||||
| (DiffTheme::Light, DiffColorLevel::Ansi256, Some(bg)) => Style::default().bg(bg),
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor, Some(bg))
|
||||
| (DiffTheme::Dark, DiffColorLevel::Ansi256, Some(bg)) => {
|
||||
Style::default().fg(Color::Green).bg(bg)
|
||||
}
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi256) => {
|
||||
Style::default().bg(add_line_bg(theme, RichDiffColorLevel::Ansi256))
|
||||
}
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor) => Style::default()
|
||||
.fg(Color::Green)
|
||||
.bg(add_line_bg(theme, RichDiffColorLevel::TrueColor)),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi256) => Style::default()
|
||||
.fg(Color::Green)
|
||||
.bg(add_line_bg(theme, RichDiffColorLevel::Ansi256)),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor, None)
|
||||
| (DiffTheme::Light, DiffColorLevel::Ansi256, None) => Style::default(),
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor, None)
|
||||
| (DiffTheme::Dark, DiffColorLevel::Ansi256, None) => Style::default().fg(Color::Green),
|
||||
}
|
||||
}
|
||||
|
||||
/// Content style for delete lines (plain, non-syntax-highlighted text).
|
||||
fn style_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
match (theme, color_level) {
|
||||
(_, DiffColorLevel::Ansi16) => Style::default().fg(Color::Red),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor) => {
|
||||
Style::default().bg(del_line_bg(theme, RichDiffColorLevel::TrueColor))
|
||||
///
|
||||
/// Mirror of [`style_add`] with red foreground and the delete-side
|
||||
/// resolved background.
|
||||
fn style_del(
|
||||
theme: DiffTheme,
|
||||
color_level: DiffColorLevel,
|
||||
diff_backgrounds: ResolvedDiffBackgrounds,
|
||||
) -> Style {
|
||||
match (theme, color_level, diff_backgrounds.del) {
|
||||
(_, DiffColorLevel::Ansi16, _) => Style::default().fg(Color::Red),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor, Some(bg))
|
||||
| (DiffTheme::Light, DiffColorLevel::Ansi256, Some(bg)) => Style::default().bg(bg),
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor, Some(bg))
|
||||
| (DiffTheme::Dark, DiffColorLevel::Ansi256, Some(bg)) => {
|
||||
Style::default().fg(Color::Red).bg(bg)
|
||||
}
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi256) => {
|
||||
Style::default().bg(del_line_bg(theme, RichDiffColorLevel::Ansi256))
|
||||
}
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor) => Style::default()
|
||||
.fg(Color::Red)
|
||||
.bg(del_line_bg(theme, RichDiffColorLevel::TrueColor)),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi256) => Style::default()
|
||||
.fg(Color::Red)
|
||||
.bg(del_line_bg(theme, RichDiffColorLevel::Ansi256)),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor, None)
|
||||
| (DiffTheme::Light, DiffColorLevel::Ansi256, None) => Style::default(),
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor, None)
|
||||
| (DiffTheme::Dark, DiffColorLevel::Ansi256, None) => Style::default().fg(Color::Red),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1144,33 +1310,49 @@ mod tests {
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
#[test]
|
||||
fn ansi16_add_style_uses_foreground_only() {
|
||||
let style = style_add(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
let style = style_add(
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
|
||||
);
|
||||
assert_eq!(style.fg, Some(Color::Green));
|
||||
assert_eq!(style.bg, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi16_del_style_uses_foreground_only() {
|
||||
let style = style_del(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
let style = style_del(
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
|
||||
);
|
||||
assert_eq!(style.fg, Some(Color::Red));
|
||||
assert_eq!(style.bg, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi16_sign_styles_use_foreground_only() {
|
||||
let add_sign = style_sign_add(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
let add_sign = style_sign_add(
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
|
||||
);
|
||||
assert_eq!(add_sign.fg, Some(Color::Green));
|
||||
assert_eq!(add_sign.bg, None);
|
||||
|
||||
let del_sign = style_sign_del(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
let del_sign = style_sign_del(
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
|
||||
);
|
||||
assert_eq!(del_sign.fg, Some(Color::Red));
|
||||
assert_eq!(del_sign.bg, None);
|
||||
}
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
fn diff_summary_for_tests(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
|
||||
create_diff_summary(changes, &PathBuf::from("/"), 80)
|
||||
}
|
||||
@@ -1308,8 +1490,14 @@ mod tests {
|
||||
let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
|
||||
|
||||
// Call the wrapping function directly so we can precisely control the width
|
||||
let lines =
|
||||
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1));
|
||||
let lines = push_wrapped_diff_line_with_style_context(
|
||||
1,
|
||||
DiffLineType::Insert,
|
||||
long_line,
|
||||
80,
|
||||
line_number_width(1),
|
||||
current_diff_render_style_context(),
|
||||
);
|
||||
|
||||
// Render into a small terminal to capture the visual layout
|
||||
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
|
||||
@@ -1516,13 +1704,14 @@ mod tests {
|
||||
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
|
||||
let spans = &syntax_spans[0];
|
||||
|
||||
let lines = push_wrapped_diff_line_with_syntax(
|
||||
let lines = push_wrapped_diff_line_with_syntax_and_style_context(
|
||||
1,
|
||||
DiffLineType::Insert,
|
||||
long_rust,
|
||||
80,
|
||||
line_number_width(1),
|
||||
spans,
|
||||
current_diff_render_style_context(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -1542,13 +1731,14 @@ mod tests {
|
||||
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
|
||||
let spans = &syntax_spans[0];
|
||||
|
||||
let lines = push_wrapped_diff_line_with_syntax(
|
||||
let lines = push_wrapped_diff_line_with_syntax_and_style_context(
|
||||
1,
|
||||
DiffLineType::Insert,
|
||||
long_rust,
|
||||
80,
|
||||
line_number_width(1),
|
||||
spans,
|
||||
current_diff_render_style_context(),
|
||||
);
|
||||
|
||||
snapshot_lines_text("syntax_highlighted_insert_wraps_text", &lines);
|
||||
@@ -1580,6 +1770,7 @@ mod tests {
|
||||
None,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
|
||||
);
|
||||
lines.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
2,
|
||||
@@ -1590,6 +1781,7 @@ mod tests {
|
||||
None,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
|
||||
));
|
||||
|
||||
snapshot_lines("ansi16_insert_delete_no_background", lines, 40, 4);
|
||||
@@ -1600,16 +1792,14 @@ mod tests {
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Insert,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::TrueColor
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::TrueColor)
|
||||
),
|
||||
Style::default().bg(rgb_color(DARK_TC_ADD_LINE_BG_RGB))
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Delete,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::TrueColor
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::TrueColor)
|
||||
),
|
||||
Style::default().bg(rgb_color(DARK_TC_DEL_LINE_BG_RGB))
|
||||
);
|
||||
@@ -1636,49 +1826,101 @@ mod tests {
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Insert,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi256
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
|
||||
),
|
||||
Style::default().bg(indexed_color(DARK_256_ADD_LINE_BG_IDX))
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Delete,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi256
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
|
||||
),
|
||||
Style::default().bg(indexed_color(DARK_256_DEL_LINE_BG_IDX))
|
||||
);
|
||||
assert_ne!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Insert,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi256
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
|
||||
),
|
||||
style_line_bg_for(
|
||||
DiffLineType::Delete,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi256
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi256)
|
||||
),
|
||||
"256-color mode should keep add/delete backgrounds distinct"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_scope_backgrounds_override_truecolor_fallback_when_available() {
|
||||
let backgrounds = resolve_diff_backgrounds_for(
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::TrueColor,
|
||||
DiffScopeBackgroundRgbs {
|
||||
inserted: Some((1, 2, 3)),
|
||||
deleted: Some((4, 5, 6)),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(DiffLineType::Insert, backgrounds),
|
||||
Style::default().bg(rgb_color((1, 2, 3)))
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(DiffLineType::Delete, backgrounds),
|
||||
Style::default().bg(rgb_color((4, 5, 6)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_scope_backgrounds_quantize_to_ansi256() {
|
||||
let backgrounds = resolve_diff_backgrounds_for(
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi256,
|
||||
DiffScopeBackgroundRgbs {
|
||||
inserted: Some((0, 95, 0)),
|
||||
deleted: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(DiffLineType::Insert, backgrounds),
|
||||
Style::default().bg(indexed_color(22))
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(DiffLineType::Delete, backgrounds),
|
||||
Style::default().bg(indexed_color(DARK_256_DEL_LINE_BG_IDX))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_snapshot_theme_scope_background_resolution() {
|
||||
let backgrounds = resolve_diff_backgrounds_for(
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::TrueColor,
|
||||
DiffScopeBackgroundRgbs {
|
||||
inserted: Some((12, 34, 56)),
|
||||
deleted: None,
|
||||
},
|
||||
);
|
||||
let snapshot = format!(
|
||||
"insert={:?}\ndelete={:?}",
|
||||
style_line_bg_for(DiffLineType::Insert, backgrounds).bg,
|
||||
style_line_bg_for(DiffLineType::Delete, backgrounds).bg,
|
||||
);
|
||||
assert_snapshot!("theme_scope_background_resolution", snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi16_disables_line_and_gutter_backgrounds() {
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Insert,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16
|
||||
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16)
|
||||
),
|
||||
Style::default()
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Delete,
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::Ansi16
|
||||
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::Ansi16)
|
||||
),
|
||||
Style::default()
|
||||
);
|
||||
@@ -1698,6 +1940,22 @@ mod tests {
|
||||
),
|
||||
Style::default().fg(Color::Black)
|
||||
);
|
||||
let themed_backgrounds = resolve_diff_backgrounds_for(
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::Ansi16,
|
||||
DiffScopeBackgroundRgbs {
|
||||
inserted: Some((8, 9, 10)),
|
||||
deleted: Some((11, 12, 13)),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(DiffLineType::Insert, themed_backgrounds),
|
||||
Style::default()
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(DiffLineType::Delete, themed_backgrounds),
|
||||
Style::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1705,16 +1963,14 @@ mod tests {
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Insert,
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::TrueColor
|
||||
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::TrueColor)
|
||||
),
|
||||
Style::default().bg(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB))
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Delete,
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::TrueColor
|
||||
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::TrueColor)
|
||||
),
|
||||
Style::default().bg(rgb_color(LIGHT_TC_DEL_LINE_BG_RGB))
|
||||
);
|
||||
@@ -1751,6 +2007,7 @@ mod tests {
|
||||
None,
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::TrueColor,
|
||||
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::TrueColor),
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -2025,12 +2282,13 @@ mod tests {
|
||||
#[test]
|
||||
fn fallback_wrapping_uses_display_width_for_tabs_and_wide_chars() {
|
||||
let width = 8;
|
||||
let lines = push_wrapped_diff_line(
|
||||
let lines = push_wrapped_diff_line_with_style_context(
|
||||
1,
|
||||
DiffLineType::Insert,
|
||||
"abcd\t界🙂",
|
||||
width,
|
||||
line_number_width(1),
|
||||
current_diff_render_style_context(),
|
||||
);
|
||||
|
||||
assert!(lines.len() >= 2, "expected wrapped output, got {lines:?}");
|
||||
|
||||
@@ -32,9 +32,11 @@ use std::sync::OnceLock;
|
||||
use std::sync::RwLock;
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::FontStyle;
|
||||
use syntect::highlighting::Highlighter;
|
||||
use syntect::highlighting::Style as SyntectStyle;
|
||||
use syntect::highlighting::Theme;
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::parsing::Scope;
|
||||
use syntect::parsing::SyntaxReference;
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
@@ -241,6 +243,50 @@ pub(crate) fn current_syntax_theme() -> Theme {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw RGB background colors extracted from syntax theme diff/markup scopes.
|
||||
///
|
||||
/// These are theme-provided colors, not yet adapted for any particular color
|
||||
/// depth. [`diff_render`](crate::diff_render) converts them to ratatui
|
||||
/// `Color` values via `color_from_rgb_for_level` after deciding whether to
|
||||
/// emit truecolor or quantized ANSI-256.
|
||||
///
|
||||
/// Both fields are `None` when the active theme defines no relevant scope
|
||||
/// backgrounds, in which case the diff renderer falls back to its hardcoded
|
||||
/// palette.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) struct DiffScopeBackgroundRgbs {
|
||||
pub inserted: Option<(u8, u8, u8)>,
|
||||
pub deleted: Option<(u8, u8, u8)>,
|
||||
}
|
||||
|
||||
/// Query the active syntax theme for diff-scope background colors.
|
||||
///
|
||||
/// Prefers `markup.inserted` / `markup.deleted` (the TextMate convention used
|
||||
/// by most VS Code themes) and falls back to `diff.inserted` / `diff.deleted`
|
||||
/// (used by some older `.tmTheme` files).
|
||||
pub(crate) fn diff_scope_background_rgbs() -> DiffScopeBackgroundRgbs {
|
||||
let theme = current_syntax_theme();
|
||||
diff_scope_background_rgbs_for_theme(&theme)
|
||||
}
|
||||
|
||||
/// Pure extraction helper, separated from the global theme singleton so tests
|
||||
/// can pass arbitrary themes.
|
||||
fn diff_scope_background_rgbs_for_theme(theme: &Theme) -> DiffScopeBackgroundRgbs {
|
||||
let highlighter = Highlighter::new(theme);
|
||||
let inserted = scope_background_rgb(&highlighter, "markup.inserted")
|
||||
.or_else(|| scope_background_rgb(&highlighter, "diff.inserted"));
|
||||
let deleted = scope_background_rgb(&highlighter, "markup.deleted")
|
||||
.or_else(|| scope_background_rgb(&highlighter, "diff.deleted"));
|
||||
DiffScopeBackgroundRgbs { inserted, deleted }
|
||||
}
|
||||
|
||||
/// Extract the background color for a single TextMate scope, if defined.
|
||||
fn scope_background_rgb(highlighter: &Highlighter<'_>, scope_name: &str) -> Option<(u8, u8, u8)> {
|
||||
let scope = Scope::new(scope_name).ok()?;
|
||||
let bg = highlighter.style_mod_for_stack(&[scope]).background?;
|
||||
Some((bg.r, bg.g, bg.b))
|
||||
}
|
||||
|
||||
/// Return the configured kebab-case theme name when it resolves; otherwise
|
||||
/// return the adaptive auto-detected default theme name.
|
||||
///
|
||||
@@ -551,6 +597,12 @@ pub(crate) fn highlight_code_to_styled_spans(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::str::FromStr;
|
||||
use syntect::highlighting::Color as SyntectColor;
|
||||
use syntect::highlighting::ScopeSelectors;
|
||||
use syntect::highlighting::StyleModifier;
|
||||
use syntect::highlighting::ThemeItem;
|
||||
use syntect::highlighting::ThemeSettings;
|
||||
|
||||
fn write_minimal_tmtheme(path: &Path) {
|
||||
// Minimal valid .tmTheme plist (enough for syntect to parse).
|
||||
@@ -570,6 +622,43 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_tmtheme_with_diff_backgrounds(
|
||||
path: &Path,
|
||||
inserted_scope: &str,
|
||||
inserted_background: &str,
|
||||
deleted_scope: &str,
|
||||
deleted_background: &str,
|
||||
) {
|
||||
let contents = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>name</key><string>Custom Diff Theme</string>
|
||||
<key>settings</key><array>
|
||||
<dict>
|
||||
<key>settings</key><dict>
|
||||
<key>foreground</key><string>#FFFFFF</string>
|
||||
<key>background</key><string>#000000</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>scope</key><string>{inserted_scope}</string>
|
||||
<key>settings</key><dict>
|
||||
<key>background</key><string>{inserted_background}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>scope</key><string>{deleted_scope}</string>
|
||||
<key>settings</key><dict>
|
||||
<key>background</key><string>{deleted_background}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict></plist>"#
|
||||
);
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
/// Reconstruct plain text from highlighted Lines.
|
||||
fn reconstructed(lines: &[Line<'static>]) -> String {
|
||||
lines
|
||||
@@ -584,6 +673,16 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn theme_item(scope: &str, background: Option<(u8, u8, u8)>) -> ThemeItem {
|
||||
ThemeItem {
|
||||
scope: ScopeSelectors::from_str(scope).expect("scope selector should parse"),
|
||||
style: StyleModifier {
|
||||
background: background.map(|(r, g, b)| SyntectColor { r, g, b, a: 255 }),
|
||||
..StyleModifier::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_rust_has_keyword_style() {
|
||||
let code = "fn main() {}";
|
||||
@@ -848,6 +947,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_scope_backgrounds_prefer_markup_scope_then_diff_fallback() {
|
||||
let theme = Theme {
|
||||
settings: ThemeSettings::default(),
|
||||
scopes: vec![
|
||||
theme_item("markup.inserted", Some((10, 20, 30))),
|
||||
theme_item("diff.deleted", Some((40, 50, 60))),
|
||||
],
|
||||
..Theme::default()
|
||||
};
|
||||
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
|
||||
assert_eq!(
|
||||
rgbs,
|
||||
DiffScopeBackgroundRgbs {
|
||||
inserted: Some((10, 20, 30)),
|
||||
deleted: Some((40, 50, 60)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_scope_backgrounds_return_none_when_no_background_scope_matches() {
|
||||
let theme = Theme {
|
||||
settings: ThemeSettings::default(),
|
||||
scopes: vec![theme_item("constant.numeric", Some((1, 2, 3)))],
|
||||
..Theme::default()
|
||||
};
|
||||
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
|
||||
assert_eq!(
|
||||
rgbs,
|
||||
DiffScopeBackgroundRgbs {
|
||||
inserted: None,
|
||||
deleted: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_theme_can_provide_diff_scope_backgrounds() {
|
||||
let theme =
|
||||
resolve_theme_by_name("github", None).expect("expected built-in GitHub theme to load");
|
||||
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
|
||||
assert!(
|
||||
rgbs.inserted.is_some() && rgbs.deleted.is_some(),
|
||||
"expected built-in theme to provide insert/delete backgrounds, got {rgbs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tmtheme_diff_scope_backgrounds_are_resolved() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let themes_dir = dir.path().join("themes");
|
||||
std::fs::create_dir(&themes_dir).unwrap();
|
||||
write_tmtheme_with_diff_backgrounds(
|
||||
&themes_dir.join("custom-diff.tmTheme"),
|
||||
"diff.inserted",
|
||||
"#102030",
|
||||
"markup.deleted",
|
||||
"#405060",
|
||||
);
|
||||
|
||||
let theme = resolve_theme_by_name("custom-diff", Some(dir.path()))
|
||||
.expect("expected custom theme to resolve");
|
||||
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
|
||||
assert_eq!(
|
||||
rgbs,
|
||||
DiffScopeBackgroundRgbs {
|
||||
inserted: Some((16, 32, 48)),
|
||||
deleted: Some((64, 80, 96)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_theme_name_covers_all_variants() {
|
||||
let known = [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: snapshot
|
||||
---
|
||||
insert=Some(Rgb(12, 34, 56))
|
||||
delete=Some(Rgb(74, 34, 29))
|
||||
@@ -29,9 +29,10 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::bottom_pane::popup_content_width;
|
||||
use crate::bottom_pane::side_by_side_layout_widths;
|
||||
use crate::diff_render::DiffLineType;
|
||||
use crate::diff_render::current_diff_render_style_context;
|
||||
use crate::diff_render::line_number_width;
|
||||
use crate::diff_render::push_wrapped_diff_line;
|
||||
use crate::diff_render::push_wrapped_diff_line_with_syntax;
|
||||
use crate::diff_render::push_wrapped_diff_line_with_style_context;
|
||||
use crate::diff_render::push_wrapped_diff_line_with_syntax_and_style_context;
|
||||
use crate::render::highlight;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::status::format_directory_display;
|
||||
@@ -200,27 +201,30 @@ fn render_preview(
|
||||
|
||||
let mut y = area.y.saturating_add(top_pad);
|
||||
let render_width = area.width.saturating_sub(left_pad);
|
||||
let style_context = current_diff_render_style_context();
|
||||
for (idx, row) in preview_rows.iter().enumerate() {
|
||||
if y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
let diff_type = preview_diff_line_type(row.kind);
|
||||
let wrapped = if let Some(syn) = syntax_lines.as_ref().and_then(|sl| sl.get(idx)) {
|
||||
push_wrapped_diff_line_with_syntax(
|
||||
push_wrapped_diff_line_with_syntax_and_style_context(
|
||||
row.line_no,
|
||||
diff_type,
|
||||
row.code,
|
||||
render_width as usize,
|
||||
ln_width,
|
||||
syn,
|
||||
style_context,
|
||||
)
|
||||
} else {
|
||||
push_wrapped_diff_line(
|
||||
push_wrapped_diff_line_with_style_context(
|
||||
row.line_no,
|
||||
diff_type,
|
||||
row.code,
|
||||
render_width as usize,
|
||||
ln_width,
|
||||
style_context,
|
||||
)
|
||||
};
|
||||
let first_line = wrapped.into_iter().next().unwrap_or_else(|| Line::from(""));
|
||||
|
||||
Reference in New Issue
Block a user