theme: Expose editor diff hunk colors (#51784)

This PR makes editor diff hunk colors configurable from themes, instead
of hardcoded values.

It introduces 6 new optional theme keys:

- `editor.diff_hunk.added.background`
- `editor.diff_hunk.added.hollow_background`
- `editor.diff_hunk.added.hollow_border`
- `editor.diff_hunk.deleted.background`
- `editor.diff_hunk.deleted.hollow_background`
- `editor.diff_hunk.deleted.hollow_border`

When a theme omits these keys, each color falls back to the existing
version-control color with the previous hardcoded opacity values:

- Light defaults:
  - background_opacity = 0.16
  - hollow_background_opacity = 0.08
  - hollow_border_opacity = 0.48
- Dark defaults:
  - background_opacity = 0.12
  - hollow_background_opacity = 0.06
  - hollow_border_opacity = 0.36

There is an existing feature request
https://github.com/zed-industries/zed/discussions/51667

## Screenshots

I used `Modus Themes` (Modus Vivendi) since these themes provide highly
accessible themes.

Original version:

<img width="3248" height="2120" alt="CleanShot 2026-03-17 at 20 26
41@2x"
src="https://github.com/user-attachments/assets/730be802-835d-436e-a1fc-4c60dcb5fce8"
/>

This version:

<img width="3248" height="2120" alt="CleanShot 2026-03-17 at 20 23
09@2x"
src="https://github.com/user-attachments/assets/785c86d3-147e-437f-9624-9576bc201551"
/>

Before you mark this PR as ready for review, make sure that you have:

- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Added theme keys for configuring editor diff hunk colors.

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
This commit is contained in:
Vitaly SlobodinandMrSubidubi authored and GitHub committed 2026-05-06 21:18:29 +00:00
1 parent fead27108f
commit 29af40d1ee
8 files changed
+318 -41

No files matched your search

+27 -24
View File
@@ -10083,8 +10083,6 @@ impl Element for EditorElement {
.editor
.update(cx, |editor, cx| editor.highlighted_display_rows(window, cx));
let is_light = cx.theme().appearance().is_light();
let mut highlighted_ranges = self
.editor_with_selections(cx)
.map(|editor| {
@@ -10124,42 +10122,49 @@ impl Element for EditorElement {
})
.unwrap_or_default();
struct DiffHunkHighlightColors {
filled_background: Hsla,
hollow_background: Hsla,
hollow_border: Hsla,
}
let colors = cx.theme().colors();
let added_diff_hunk_colors = DiffHunkHighlightColors {
filled_background: colors.editor_diff_hunk_added_background,
hollow_background: colors.editor_diff_hunk_added_hollow_background,
hollow_border: colors.editor_diff_hunk_added_hollow_border,
};
let deleted_diff_hunk_colors = DiffHunkHighlightColors {
filled_background: colors.editor_diff_hunk_deleted_background,
hollow_background: colors.editor_diff_hunk_deleted_hollow_background,
hollow_border: colors.editor_diff_hunk_deleted_hollow_border,
};
let drag_highlight_color = colors.editor_active_line_background;
let drag_border_color = colors.border_focused;
for (ix, row_info) in row_infos.iter().enumerate() {
let Some(diff_status) = row_info.diff_status else {
continue;
};
let background_color = match diff_status.kind {
DiffHunkStatusKind::Added => cx.theme().colors().version_control_added,
DiffHunkStatusKind::Deleted => {
cx.theme().colors().version_control_deleted
}
let diff_hunk_colors = match diff_status.kind {
DiffHunkStatusKind::Added => &added_diff_hunk_colors,
DiffHunkStatusKind::Deleted => &deleted_diff_hunk_colors,
DiffHunkStatusKind::Modified => {
debug_panic!("modified diff status for row info");
continue;
}
};
let hunk_opacity = if is_light { 0.16 } else { 0.12 };
let hollow_highlight = LineHighlight {
background: (background_color.opacity(if is_light {
0.08
} else {
0.06
}))
.into(),
border: Some(if is_light {
background_color.opacity(0.48)
} else {
background_color.opacity(0.36)
}),
background: diff_hunk_colors.hollow_background.into(),
border: Some(diff_hunk_colors.hollow_border),
include_gutter: true,
type_id: None,
};
let filled_highlight = LineHighlight {
background: solid_background(background_color.opacity(hunk_opacity)),
background: solid_background(diff_hunk_colors.filled_background),
border: None,
include_gutter: true,
type_id: None,
@@ -10184,11 +10189,9 @@ impl Element for EditorElement {
let range = drag_state.row_range(&snapshot.display_snapshot);
let start_row = range.start().0;
let end_row = range.end().0;
let drag_highlight_color =
cx.theme().colors().editor_active_line_background;
let drag_highlight = LineHighlight {
background: solid_background(drag_highlight_color),
border: Some(cx.theme().colors().border_focused),
border: Some(drag_border_color),
include_gutter: true,
type_id: None,
};
+24
View File
@@ -854,6 +854,30 @@ pub struct ThemeColorsContent {
#[serde(rename = "editor.document_highlight.bracket_background")]
pub editor_document_highlight_bracket_background: Option<String>,
/// Filled background color for added diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.added.background")]
pub editor_diff_hunk_added_background: Option<String>,
/// Hollow background color for added diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.added.hollow_background")]
pub editor_diff_hunk_added_hollow_background: Option<String>,
/// Hollow border color for added diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.added.hollow_border")]
pub editor_diff_hunk_added_hollow_border: Option<String>,
/// Filled background color for deleted diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.deleted.background")]
pub editor_diff_hunk_deleted_background: Option<String>,
/// Hollow background color for deleted diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.deleted.hollow_background")]
pub editor_diff_hunk_deleted_hollow_background: Option<String>,
/// Hollow border color for deleted diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.deleted.hollow_border")]
pub editor_diff_hunk_deleted_hollow_border: Option<String>,
/// Terminal background color.
#[serde(rename = "terminal.background")]
pub terminal_background: Option<String>,
+12
View File
@@ -129,6 +129,12 @@ impl ThemeColors {
editor_document_highlight_read_background: neutral().light_alpha().step_3(),
editor_document_highlight_write_background: neutral().light_alpha().step_4(),
editor_document_highlight_bracket_background: green().light_alpha().step_5(),
editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.16),
editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.08),
editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.48),
editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.16),
editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.08),
editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.48),
terminal_background: neutral().light().step_1(),
terminal_foreground: black().light().step_12(),
terminal_bright_foreground: black().light().step_11(),
@@ -276,6 +282,12 @@ impl ThemeColors {
editor_document_highlight_read_background: neutral().dark_alpha().step_4(),
editor_document_highlight_write_background: neutral().dark_alpha().step_4(),
editor_document_highlight_bracket_background: green().dark_alpha().step_6(),
editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.12),
editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.06),
editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.36),
editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.12),
editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.06),
editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.36),
terminal_background: neutral().dark().step_1(),
terminal_ansi_background: neutral().dark().step_1(),
terminal_foreground: white().dark().step_12(),
+6
View File
@@ -185,6 +185,12 @@ pub(crate) fn zed_default_dark() -> Theme {
),
editor_document_highlight_write_background: gpui::red(),
editor_document_highlight_bracket_background: gpui::green(),
editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.12),
editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.06),
editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.36),
editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.12),
editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.06),
editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.36),
terminal_background: bg,
// todo("Use one colors for terminal")
+12
View File
@@ -241,6 +241,18 @@ pub struct ThemeColors {
///
/// Matching brackets in the cursor scope are highlighted with this background color.
pub editor_document_highlight_bracket_background: Hsla,
/// Filled background color for added diff hunk row highlights in the editor.
pub editor_diff_hunk_added_background: Hsla,
/// Hollow background color for added diff hunk row highlights in the editor.
pub editor_diff_hunk_added_hollow_background: Hsla,
/// Hollow border color for added diff hunk row highlights in the editor.
pub editor_diff_hunk_added_hollow_border: Hsla,
/// Filled background color for deleted diff hunk row highlights in the editor.
pub editor_diff_hunk_deleted_background: Hsla,
/// Hollow background color for deleted diff hunk row highlights in the editor.
pub editor_diff_hunk_deleted_hollow_background: Hsla,
/// Hollow border color for deleted diff hunk row highlights in the editor.
pub editor_diff_hunk_deleted_hollow_border: Hsla,
// ===
// Terminal
+228 -13
View File
@@ -13,6 +13,13 @@ pub use settings::{FontWeightContent, WindowBackgroundContent};
use theme::{StatusColorsRefinement, ThemeColorsRefinement};
const LIGHT_DIFF_HUNK_FILLED_OPACITY: f32 = 0.16;
const LIGHT_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY: f32 = 0.08;
const LIGHT_DIFF_HUNK_HOLLOW_BORDER_OPACITY: f32 = 0.48;
const DARK_DIFF_HUNK_FILLED_OPACITY: f32 = 0.12;
const DARK_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY: f32 = 0.06;
const DARK_DIFF_HUNK_HOLLOW_BORDER_OPACITY: f32 = 0.36;
/// The content of a serialized theme family.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ThemeFamilyContent {
@@ -230,6 +237,7 @@ pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> Statu
pub fn theme_colors_refinement(
this: &settings::ThemeColorsContent,
status_colors: &StatusColorsRefinement,
is_light: bool,
) -> ThemeColorsRefinement {
let border = this
.border
@@ -278,6 +286,29 @@ pub fn theme_colors_refinement(
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(search_match_background);
let version_control_added = this
.version_control_added
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.created);
let version_control_deleted = this
.version_control_deleted
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.deleted);
let (hunk_fill, hunk_hollow_bg, hunk_hollow_border) = if is_light {
(
LIGHT_DIFF_HUNK_FILLED_OPACITY,
LIGHT_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY,
LIGHT_DIFF_HUNK_HOLLOW_BORDER_OPACITY,
)
} else {
(
DARK_DIFF_HUNK_FILLED_OPACITY,
DARK_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY,
DARK_DIFF_HUNK_HOLLOW_BORDER_OPACITY,
)
};
ThemeColorsRefinement {
border,
border_variant: this
@@ -576,6 +607,36 @@ pub fn theme_colors_refinement(
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(editor_document_highlight_read_background),
editor_diff_hunk_added_background: this
.editor_diff_hunk_added_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_added.map(|c| c.opacity(hunk_fill))),
editor_diff_hunk_added_hollow_background: this
.editor_diff_hunk_added_hollow_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_added.map(|c| c.opacity(hunk_hollow_bg))),
editor_diff_hunk_added_hollow_border: this
.editor_diff_hunk_added_hollow_border
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_added.map(|c| c.opacity(hunk_hollow_border))),
editor_diff_hunk_deleted_background: this
.editor_diff_hunk_deleted_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_deleted.map(|c| c.opacity(hunk_fill))),
editor_diff_hunk_deleted_hollow_background: this
.editor_diff_hunk_deleted_hollow_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_deleted.map(|c| c.opacity(hunk_hollow_bg))),
editor_diff_hunk_deleted_hollow_border: this
.editor_diff_hunk_deleted_hollow_border
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_deleted.map(|c| c.opacity(hunk_hollow_border))),
terminal_background: this
.terminal_background
.as_ref()
@@ -696,16 +757,8 @@ pub fn theme_colors_refinement(
.link_text_hover
.as_ref()
.and_then(|color| try_parse_color(color).ok()),
version_control_added: this
.version_control_added
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.created),
version_control_deleted: this
.version_control_deleted
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.deleted),
version_control_added,
version_control_deleted,
version_control_modified: this
.version_control_modified
.as_ref()
@@ -856,7 +909,165 @@ fn try_parse_color(color: &str) -> anyhow::Result<Hsla> {
#[cfg(test)]
mod tests {
use super::*;
use theme::StatusColorsRefinement;
use super::{
StatusColorsContent, ThemeColorsContent, status_colors_refinement, theme_colors_refinement,
try_parse_color,
};
#[test]
fn explicit_diff_hunk_colors_take_precedence_over_fallbacks() {
let mut colors = ThemeColorsContent::default();
colors.editor_diff_hunk_added_background = Some("#112233".to_string());
colors.editor_diff_hunk_added_hollow_background = Some("#223344".to_string());
colors.editor_diff_hunk_added_hollow_border = Some("#334455".to_string());
colors.editor_diff_hunk_deleted_background = Some("#445566".to_string());
colors.editor_diff_hunk_deleted_hollow_background = Some("#556677".to_string());
colors.editor_diff_hunk_deleted_hollow_border = Some("#667788".to_string());
colors.version_control_added = Some("#00ff00".to_string());
colors.version_control_deleted = Some("#ff0000".to_string());
let refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
assert_eq!(
refinement.editor_diff_hunk_added_background,
Some(parse_color("#112233"))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_background,
Some(parse_color("#223344"))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_border,
Some(parse_color("#334455"))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_background,
Some(parse_color("#445566"))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_background,
Some(parse_color("#556677"))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_border,
Some(parse_color("#667788"))
);
}
#[test]
fn diff_hunk_colors_fallback_to_version_control_colors() {
let mut colors = ThemeColorsContent::default();
colors.version_control_added = Some("#00ff00".to_string());
colors.version_control_deleted = Some("#ff0000".to_string());
let refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
let added = parse_color("#00ff00");
let deleted = parse_color("#ff0000");
assert_eq!(
refinement.editor_diff_hunk_added_background,
Some(added.opacity(0.16))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_background,
Some(added.opacity(0.08))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_border,
Some(added.opacity(0.48))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_background,
Some(deleted.opacity(0.16))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_background,
Some(deleted.opacity(0.08))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_border,
Some(deleted.opacity(0.48))
);
}
#[test]
fn diff_hunk_opacity_fallbacks_use_correct_values_for_light_and_dark_themes() {
let mut colors = ThemeColorsContent::default();
colors.version_control_added = Some("#00ff00".to_string());
let light_refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
let dark_refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
false,
);
let added = parse_color("#00ff00");
assert_eq!(
light_refinement.editor_diff_hunk_added_background,
Some(added.opacity(0.16))
);
assert_eq!(
light_refinement.editor_diff_hunk_added_hollow_background,
Some(added.opacity(0.08))
);
assert_eq!(
light_refinement.editor_diff_hunk_added_hollow_border,
Some(added.opacity(0.48))
);
assert_eq!(
dark_refinement.editor_diff_hunk_added_background,
Some(added.opacity(0.12))
);
assert_eq!(
dark_refinement.editor_diff_hunk_added_hollow_background,
Some(added.opacity(0.06))
);
assert_eq!(
dark_refinement.editor_diff_hunk_added_hollow_border,
Some(added.opacity(0.36))
);
}
#[test]
fn diff_hunk_fallbacks_are_absent_when_status_and_version_control_colors_are_missing() {
let refinement = theme_colors_refinement(
&ThemeColorsContent::default(),
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
assert_eq!(refinement.editor_diff_hunk_added_background, None);
assert_eq!(refinement.editor_diff_hunk_added_hollow_background, None);
assert_eq!(refinement.editor_diff_hunk_added_hollow_border, None);
assert_eq!(refinement.editor_diff_hunk_deleted_background, None);
assert_eq!(refinement.editor_diff_hunk_deleted_hollow_background, None);
assert_eq!(refinement.editor_diff_hunk_deleted_hollow_border, None);
}
fn parse_color(color: &str) -> gpui::Hsla {
match try_parse_color(color) {
Ok(color) => color,
Err(error) => panic!("failed to parse color {color}: {error}"),
}
}
#[test]
fn helix_jump_label_color_uses_theme_color_or_status_error() {
@@ -867,8 +1078,11 @@ mod tests {
..Default::default()
};
let fallback_refinement =
theme_colors_refinement(&ThemeColorsContent::default(), &status_colors);
let fallback_refinement = theme_colors_refinement(
&ThemeColorsContent::default(),
&status_colors,
Default::default(),
);
assert_eq!(
fallback_refinement.vim_helix_jump_label_foreground,
@@ -881,6 +1095,7 @@ mod tests {
..Default::default()
},
&status_colors,
Default::default(),
);
assert_eq!(
+4 -2
View File
@@ -476,10 +476,12 @@ impl ThemeSettings {
}
let status_color_refinement = status_colors_refinement(&theme_overrides.status);
base_theme.styles.colors.refine(&theme_colors_refinement(
let theme_color_refinement = theme_colors_refinement(
&theme_overrides.colors,
&status_color_refinement,
));
base_theme.appearance.is_light(),
);
base_theme.styles.colors.refine(&theme_color_refinement);
base_theme.styles.status.refine(&status_color_refinement);
merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players);
merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents);
+5 -2
View File
@@ -296,8 +296,11 @@ pub fn refine_theme(theme: &ThemeContent) -> Theme {
AppearanceContent::Light => ThemeColors::light(),
AppearanceContent::Dark => ThemeColors::dark(),
};
let mut theme_colors_refinement =
theme_colors_refinement(&theme.style.colors, &status_colors_refinement);
let mut theme_colors_refinement = theme_colors_refinement(
&theme.style.colors,
&status_colors_refinement,
theme.appearance == AppearanceContent::Light,
);
theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors);
refined_theme_colors.refine(&theme_colors_refinement);