Files
zed/crates/theme_settings/src/theme_settings.rs
T
895f438c1a editor: Fix buffer header context menu line height in multibuffers (#61923)
## Objective

Right-clicking a buffer header in a multibuffer showed a context menu
whose item spacing did not match other context menus. The menu is drawn
via a deferred draw, which inherits the editor's text style stack, so
its line height followed `buffer_line_height` instead of the default UI
line height.

This is the same root cause as #24504, which #25172 fixed for the
editor's mouse context menu and completion popovers. The buffer header
menu was a remaining call site.

## Solution

`ContextMenu` now applies the default (`comfortable`) line height itself
in `render`, next to its existing rem size and font family
normalization, so menus render the same regardless of where they are
opened from. This fixes the all workarounds and the need for them too:
the workaround in `layout_mouse_context_menu` is removed, and the buffer
header ends up needing no changes at all.

The completions and code actions popovers keep their override in
`element.rs`: they are not `ui::ContextMenu`s and compute their sizes
with eager `window.line_height()` reads while being built, so styling on
the elements they return cannot cover them. Migrating them could be a
follow-up.

## Testing

- `cargo check --workspace` passes, `./script/clippy` on the touched
crates passes.
- Manual: set `"buffer_line_height": "standard"` (or any extreme custom
value to see it better), open a multibuffer (e.g. project search),
right-click a buffer header, and compare the menu with another context
menu (e.g. a tab's) — spacing matches. The editor's mouse context menu,
which lost its own override, renders as before, and with default
settings there is no visual change anywhere.
- Tested on macOS, change is platform-independent styling.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior (visual styling fix; menu
text styles have no existing test coverage)
- [x] Performance impact has been considered and is acceptable

## Showcase

Regular context menu:
<img width="425" height="164" alt="A context menu elsewhere in the app,
showing normal item spacing"
src="https://github.com/user-attachments/assets/1fc13981-b394-444f-883a-ce3ef462c386"
/>

Before:
<img width="425" height="164" alt="Buffer header context menu before the
fix, with tighter item spacing following the custom buffer_line_height"
src="https://github.com/user-attachments/assets/9774076c-7e0c-41dd-b54f-53de58b24336"
/>

After:
<img width="425" height="164" alt="Buffer header context menu after the
fix, with item spacing matching other context menus"
src="https://github.com/user-attachments/assets/ae76e37c-8e39-474c-8a5e-e229e69173fb"
/>

---

Release Notes:

- Fixed the file header context menu in multibuffers not matching other
context menus' spacing when a custom `buffer_line_height` is set.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-08-16 11:41:17 +00:00

448 lines
16 KiB
Rust

#![deny(missing_docs)]
//! # Theme Settings
//!
//! This crate provides theme settings integration for Zed,
//! bridging the theme system with the settings infrastructure.
mod schema;
mod settings;
use std::sync::Arc;
use ::settings::{IntoGpui, Settings, SettingsStore};
use anyhow::{Context as _, Result};
use gpui::{App, Font, HighlightStyle, Pixels, Refineable, px};
use gpui_util::ResultExt;
use theme::{
AccentColors, Appearance, AppearanceContent, DEFAULT_DARK_THEME, DEFAULT_ICON_THEME_NAME,
GlobalTheme, LoadThemes, PlayerColor, PlayerColors, StatusColors, SyntaxTheme,
SystemAppearance, SystemColors, Theme, ThemeColors, ThemeFamily, ThemeRegistry,
ThemeSettingsProvider, ThemeStyles, default_color_scales, try_parse_color,
};
pub use crate::schema::{
FontStyleContent, FontWeightContent, HighlightStyleContent, StatusColorsContent,
ThemeColorsContent, ThemeContent, ThemeFamilyContent, ThemeStyleContent,
WindowBackgroundContent, status_colors_refinement, syntax_overrides, theme_colors_refinement,
};
use crate::settings::adjust_buffer_font_size;
pub use crate::settings::{
AgentBufferFontSize, AgentUiFontSize, BufferLineHeight, FontFamilyName,
GitCommitBufferFontSize, IconThemeName, IconThemeSelection, MarkdownPreviewFontSize,
ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, adjust_agent_buffer_font_size,
adjust_agent_ui_font_size, adjust_git_commit_buffer_font_size,
adjust_markdown_preview_font_size, adjust_ui_font_size, adjusted_font_size, appearance_to_mode,
buffer_line_height_from_settings, clamp_font_size, default_theme,
observe_buffer_font_size_adjustment, reset_agent_buffer_font_size, reset_agent_ui_font_size,
reset_buffer_font_size, reset_git_commit_buffer_font_size, reset_markdown_preview_font_size,
reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font,
};
pub use theme::UiDensity;
struct ThemeSettingsProviderImpl;
impl ThemeSettingsProvider for ThemeSettingsProviderImpl {
fn ui_font<'a>(&'a self, cx: &'a App) -> &'a Font {
&ThemeSettings::get_global(cx).ui_font
}
fn buffer_font<'a>(&'a self, cx: &'a App) -> &'a Font {
&ThemeSettings::get_global(cx).buffer_font
}
fn ui_font_size(&self, cx: &App) -> Pixels {
ThemeSettings::get_global(cx).ui_font_size(cx)
}
fn buffer_font_size(&self, cx: &App) -> Pixels {
ThemeSettings::get_global(cx).buffer_font_size(cx)
}
fn ui_density(&self, cx: &App) -> UiDensity {
ThemeSettings::get_global(cx).ui_density
}
}
/// Initialize the theme system with settings integration.
///
/// This is the full initialization for the application. It calls [`theme::init`]
/// and then wires up settings observation for theme/font changes.
pub fn init(themes_to_load: LoadThemes, cx: &mut App) {
let load_user_themes = matches!(&themes_to_load, LoadThemes::All(_));
theme::init(themes_to_load, cx);
theme::set_theme_settings_provider(Box::new(ThemeSettingsProviderImpl), cx);
if load_user_themes {
let registry = ThemeRegistry::global(cx);
load_bundled_themes(&registry);
}
let theme = configured_theme(cx);
let icon_theme = configured_icon_theme(cx);
GlobalTheme::update_theme(cx, theme);
GlobalTheme::update_icon_theme(cx, icon_theme);
let settings = ThemeSettings::get_global(cx);
let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings();
let mut prev_ui_font_size_settings = settings.ui_font_size_settings();
let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings();
let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings();
let mut prev_git_commit_buffer_font_size_settings =
settings.git_commit_buffer_font_size_settings();
let mut prev_markdown_preview_font_size_settings =
settings.markdown_preview_font_size_settings();
let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0);
let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0);
let mut prev_theme_overrides = (
settings.experimental_theme_overrides.clone(),
settings.theme_overrides.clone(),
);
cx.observe_global::<SettingsStore>(move |cx| {
let settings = ThemeSettings::get_global(cx);
let buffer_font_size_settings = settings.buffer_font_size_settings();
let ui_font_size_settings = settings.ui_font_size_settings();
let agent_ui_font_size_settings = settings.agent_ui_font_size_settings();
let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings();
let git_commit_buffer_font_size_settings = settings.git_commit_buffer_font_size_settings();
let markdown_preview_font_size_settings = settings.markdown_preview_font_size_settings();
let theme_name = settings.theme.name(SystemAppearance::global(cx).0);
let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0);
let theme_overrides = (
settings.experimental_theme_overrides.clone(),
settings.theme_overrides.clone(),
);
if buffer_font_size_settings != prev_buffer_font_size_settings {
prev_buffer_font_size_settings = buffer_font_size_settings;
reset_buffer_font_size(cx);
}
if ui_font_size_settings != prev_ui_font_size_settings {
prev_ui_font_size_settings = ui_font_size_settings;
reset_ui_font_size(cx);
}
if agent_ui_font_size_settings != prev_agent_ui_font_size_settings {
prev_agent_ui_font_size_settings = agent_ui_font_size_settings;
reset_agent_ui_font_size(cx);
}
if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings {
prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings;
reset_agent_buffer_font_size(cx);
}
if git_commit_buffer_font_size_settings != prev_git_commit_buffer_font_size_settings {
prev_git_commit_buffer_font_size_settings = git_commit_buffer_font_size_settings;
reset_git_commit_buffer_font_size(cx);
}
if markdown_preview_font_size_settings != prev_markdown_preview_font_size_settings {
prev_markdown_preview_font_size_settings = markdown_preview_font_size_settings;
reset_markdown_preview_font_size(cx);
}
if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides {
prev_theme_name = theme_name;
prev_theme_overrides = theme_overrides;
reload_theme(cx);
}
if icon_theme_name != prev_icon_theme_name {
prev_icon_theme_name = icon_theme_name;
reload_icon_theme(cx);
}
})
.detach();
}
fn configured_theme(cx: &mut App) -> Arc<Theme> {
let themes = ThemeRegistry::default_global(cx);
let theme_settings = ThemeSettings::get_global(cx);
let system_appearance = SystemAppearance::global(cx);
let theme_name = theme_settings.theme.name(*system_appearance);
let theme = match themes.get(&theme_name.0) {
Ok(theme) => theme,
Err(err) => {
if themes.extensions_loaded() {
log::error!("{err}");
}
themes
.get(default_theme(*system_appearance))
.unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap())
}
};
theme_settings.apply_theme_overrides(theme)
}
fn configured_icon_theme(cx: &mut App) -> Arc<theme::IconTheme> {
let themes = ThemeRegistry::default_global(cx);
let theme_settings = ThemeSettings::get_global(cx);
let system_appearance = SystemAppearance::global(cx);
let icon_theme_name = theme_settings.icon_theme.name(*system_appearance);
match themes.get_icon_theme(&icon_theme_name.0) {
Ok(theme) => theme,
Err(err) => {
if themes.extensions_loaded() {
log::error!("{err}");
}
themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap()
}
}
}
/// Reloads the current theme from settings.
pub fn reload_theme(cx: &mut App) {
let theme = configured_theme(cx);
GlobalTheme::update_theme(cx, theme);
cx.refresh_windows();
}
/// Reloads the current icon theme from settings.
pub fn reload_icon_theme(cx: &mut App) {
let icon_theme = configured_icon_theme(cx);
GlobalTheme::update_icon_theme(cx, icon_theme);
cx.refresh_windows();
}
/// Loads the themes bundled with the Zed binary into the registry.
pub fn load_bundled_themes(registry: &ThemeRegistry) {
let theme_paths = registry
.assets()
.list("themes/")
.expect("failed to list theme assets")
.into_iter()
.filter(|path| path.ends_with(".json"));
for path in theme_paths {
let Some(theme) = registry.assets().load(&path).log_err().flatten() else {
continue;
};
let Some(theme_family) = serde_json::from_slice(&theme)
.with_context(|| format!("failed to parse theme at path \"{path}\""))
.log_err()
else {
continue;
};
let refined = refine_theme_family(theme_family);
registry.insert_theme_families([refined]);
}
}
/// Loads a user theme from the given bytes into the registry.
pub fn load_user_theme(registry: &ThemeRegistry, bytes: &[u8]) -> Result<()> {
let theme = deserialize_user_theme(bytes)?;
let refined = refine_theme_family(theme);
registry.insert_theme_families([refined]);
Ok(())
}
/// Deserializes a user theme from the given bytes.
pub fn deserialize_user_theme(bytes: &[u8]) -> Result<ThemeFamilyContent> {
let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(bytes)?;
for theme in &theme_family.themes {
if theme
.style
.colors
.deprecated_scrollbar_thumb_background
.is_some()
{
log::warn!(
r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#,
theme_name = theme.name
)
}
}
Ok(theme_family)
}
/// Refines a [`ThemeFamilyContent`] and its [`ThemeContent`]s into a [`ThemeFamily`].
pub fn refine_theme_family(theme_family_content: ThemeFamilyContent) -> ThemeFamily {
let id = uuid::Uuid::new_v4().to_string();
let name = theme_family_content.name.clone();
let author = theme_family_content.author.clone();
let themes: Vec<Theme> = theme_family_content
.themes
.iter()
.map(|theme_content| refine_theme(theme_content))
.collect();
ThemeFamily {
id,
name: name.into(),
author: author.into(),
themes,
scales: default_color_scales(),
}
}
/// Refines a [`ThemeContent`] into a [`Theme`].
pub fn refine_theme(theme: &ThemeContent) -> Theme {
let appearance = match theme.appearance {
AppearanceContent::Light => Appearance::Light,
AppearanceContent::Dark => Appearance::Dark,
};
let mut refined_status_colors = match theme.appearance {
AppearanceContent::Light => StatusColors::light(),
AppearanceContent::Dark => StatusColors::dark(),
};
let mut status_colors_refinement = status_colors_refinement(&theme.style.status);
theme::apply_status_color_defaults(&mut status_colors_refinement);
refined_status_colors.refine(&status_colors_refinement);
let mut refined_player_colors = match theme.appearance {
AppearanceContent::Light => PlayerColors::light(),
AppearanceContent::Dark => PlayerColors::dark(),
};
merge_player_colors(&mut refined_player_colors, &theme.style.players);
let mut refined_theme_colors = match theme.appearance {
AppearanceContent::Light => ThemeColors::light(),
AppearanceContent::Dark => ThemeColors::dark(),
};
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);
let mut refined_accent_colors = match theme.appearance {
AppearanceContent::Light => AccentColors::light(),
AppearanceContent::Dark => AccentColors::dark(),
};
merge_accent_colors(&mut refined_accent_colors, &theme.style.accents);
let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| {
(
syntax_token.clone(),
HighlightStyle {
color: highlight
.color
.as_ref()
.and_then(|color| try_parse_color(color).ok()),
background_color: highlight
.background_color
.as_ref()
.and_then(|color| try_parse_color(color).ok()),
font_style: highlight.font_style.map(|s| s.into_gpui()),
font_weight: highlight.font_weight.map(|w| w.into_gpui()),
..Default::default()
},
)
});
let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights));
let window_background_appearance = theme
.style
.window_background_appearance
.map(|w| w.into_gpui())
.unwrap_or_default();
Theme {
id: uuid::Uuid::new_v4().to_string(),
name: theme.name.clone().into(),
appearance,
styles: ThemeStyles {
system: SystemColors::default(),
window_background_appearance,
accents: refined_accent_colors,
colors: refined_theme_colors,
status: refined_status_colors,
player: refined_player_colors,
syntax: syntax_theme,
},
}
}
/// Merges player color overrides into the given [`PlayerColors`].
pub fn merge_player_colors(
player_colors: &mut PlayerColors,
user_player_colors: &[::settings::PlayerColorContent],
) {
if user_player_colors.is_empty() {
return;
}
for (idx, player) in user_player_colors.iter().enumerate() {
let cursor = player
.cursor
.as_ref()
.and_then(|color| try_parse_color(color).ok());
let background = player
.background
.as_ref()
.and_then(|color| try_parse_color(color).ok());
let selection = player
.selection
.as_ref()
.and_then(|color| try_parse_color(color).ok());
if let Some(player_color) = player_colors.0.get_mut(idx) {
*player_color = PlayerColor {
cursor: cursor.unwrap_or(player_color.cursor),
background: background.unwrap_or(player_color.background),
selection: selection.unwrap_or(player_color.selection),
};
} else {
player_colors.0.push(PlayerColor {
cursor: cursor.unwrap_or_default(),
background: background.unwrap_or_default(),
selection: selection.unwrap_or_default(),
});
}
}
}
/// Merges accent color overrides into the given [`AccentColors`].
pub fn merge_accent_colors(
accent_colors: &mut AccentColors,
user_accent_colors: &[::settings::AccentContent],
) {
if user_accent_colors.is_empty() {
return;
}
let colors = user_accent_colors
.iter()
.filter_map(|accent_color| {
accent_color
.0
.as_ref()
.and_then(|color| try_parse_color(color).ok())
})
.collect::<Vec<_>>();
if !colors.is_empty() {
accent_colors.0 = Arc::from(colors);
}
}
/// Increases the buffer font size by 1 pixel, without persisting the result in the settings.
/// This will be effective until the app is restarted.
pub fn increase_buffer_font_size(cx: &mut App) {
adjust_buffer_font_size(cx, |size| size + px(1.0));
}
/// Decreases the buffer font size by 1 pixel, without persisting the result in the settings.
/// This will be effective until the app is restarted.
pub fn decrease_buffer_font_size(cx: &mut App) {
adjust_buffer_font_size(cx, |size| size - px(1.0));
}