From fb3218e01e22d5dcc2791fd6b94d22cf37d8e42f Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Wed, 6 May 2026 23:18:29 +0200 Subject: [PATCH] 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: CleanShot 2026-03-17 at 20 26
41@2x This version: CleanShot 2026-03-17 at 20 23
09@2x 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 --- crates/editor/src/element.rs | 51 +++-- crates/settings_content/src/theme.rs | 24 ++ crates/theme/src/default_colors.rs | 12 + crates/theme/src/fallback_themes.rs | 6 + crates/theme/src/styles/colors.rs | 12 + crates/theme_settings/src/schema.rs | 241 ++++++++++++++++++-- crates/theme_settings/src/settings.rs | 6 +- crates/theme_settings/src/theme_settings.rs | 7 +- 8 files changed, 318 insertions(+), 41 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index c872500a4672acd03da21edbec10afb6554e4b0b..fe3ec5cb4623bd7e06b54237e183098b626877f1 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -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, }; diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index 8597dbe0616c5d21e3ff599ee6d44335e9e4175b..43cf3b36e98d866c2e1dd72ba031643344f974e9 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -854,6 +854,30 @@ pub struct ThemeColorsContent { #[serde(rename = "editor.document_highlight.bracket_background")] pub editor_document_highlight_bracket_background: Option, + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + /// Terminal background color. #[serde(rename = "terminal.background")] pub terminal_background: Option, diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 14e1df388415e95f50f00c4fd3d886d2e2b03d31..b52e855f2b4909e922a50753748258311e41a7c4 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -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(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 22a2c737048f3dd4010bce30f686e1e3464d7d30..2716eae0b23fd653a36682cc21899927f8f720b4 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -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") diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 63ccdacca7a8c9f3ffdd7506dc6ce4928d883164..f9ebd441aafd83241d9026dd22a58a9781cece4e 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -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 diff --git a/crates/theme_settings/src/schema.rs b/crates/theme_settings/src/schema.rs index 76c2e2a8b24d8ad459b6d5577168ae9f8d4c8360..3f4a20adbfbb3c021bac5e256b8e8e5b7e39333e 100644 --- a/crates/theme_settings/src/schema.rs +++ b/crates/theme_settings/src/schema.rs @@ -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 { #[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!( diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index 727f9425ca6b52901b7a9aa67124722d6810b529..86432cf7b5e759af9a2f97531e7ab531cc87d759 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -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); diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs index 9be00af4755d394c2d67292e9e183c1792fd6a5b..b5bf1a6028300b8266cb7f0ba0cf5f4561f2dfed 100644 --- a/crates/theme_settings/src/theme_settings.rs +++ b/crates/theme_settings/src/theme_settings.rs @@ -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);