workspace: Rework background image to VS Code-style surface tint

Replaces the whole-UI opacity hack with a theme-level surface tint:

- background.opacity now scales the alpha of editor/terminal/panel
  surface colors only, so the image shows through the code area while
  text, borders, tabs and chrome stay fully opaque and readable
- the image element renders at full opacity behind the workspace
- theme reloads automatically when background settings change
This commit is contained in:
yenru0 committed 2026-09-03 15:45:48 +09:00
1 parent ada1148914
commit ef0ddca6ce
7 files changed
+66 -58

No files matched your search

+4 -5
View File
@@ -604,11 +604,10 @@
"path": null,
// How the image is fitted into the workspace bounds: "cover", "fill" or "contain".
"fit": "cover",
// The opacity of the background image, between 0.0 (fully transparent) and 1.0 (fully opaque).
"opacity": 0.6,
// The opacity of the UI drawn on top of the image, between 0.0 (UI fully
// transparent, image fully visible) and 1.0 (opaque UI).
"ui_opacity": 1.0,
// The opacity of the editor and terminal surfaces drawn on top of the image,
// between 0.0 (surfaces fully transparent, image fully visible) and 1.0
// (surfaces fully opaque, image hidden).
"opacity": 0.15,
},
// Scrollbar related settings
"scrollbar": {
@@ -34,17 +34,11 @@ pub struct BackgroundImageSettingsContent {
/// Default: cover
pub fit: Option<BackgroundImageFit>,
/// The opacity of the background image, between 0.0 (fully transparent)
/// and 1.0 (fully opaque).
/// The opacity of the editor and terminal surfaces drawn on top of the
/// image, between 0.0 (surfaces fully transparent, image fully visible)
/// and 1.0 (surfaces fully opaque, image hidden). Lower values let the
/// image show through the code area; text and chrome stay unaffected.
///
/// Default: 0.6
/// Default: 0.15
pub opacity: Option<f32>,
/// The opacity applied to the UI elements drawn on top of the image,
/// between 0.0 (fully transparent UI, image fully visible) and 1.0
/// (opaque UI, the default). Lower this to make the image show through
/// panels and editors.
///
/// Default: 1.0
pub ui_opacity: Option<f32>,
}
+2 -21
View File
@@ -1530,7 +1530,7 @@ fn appearance_page() -> SettingsPage {
]
}
fn background_image_section() -> [SettingsPageItem; 5] {
fn background_image_section() -> [SettingsPageItem; 4] {
[
SettingsPageItem::SectionHeader("Background Image"),
SettingsPageItem::SettingItem(SettingItem {
@@ -1573,7 +1573,7 @@ fn appearance_page() -> SettingsPage {
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Opacity",
description: "Opacity of the background image, between 0.0 (fully transparent) and 1.0 (fully opaque).",
description: "Opacity of the editor and terminal surfaces, between 0.0 (image fully visible) and 1.0 (image hidden).",
field: Box::new(SettingField {
organization_override: None,
json_path: Some("background.opacity"),
@@ -1590,25 +1590,6 @@ fn appearance_page() -> SettingsPage {
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "UI Opacity",
description: "Opacity of the UI drawn on top of the image, between 0.0 (image fully visible) and 1.0 (opaque UI).",
field: Box::new(SettingField {
organization_override: None,
json_path: Some("background.ui_opacity"),
pick: |settings_content| {
settings_content.background.as_ref()?.ui_opacity.as_ref()
},
write: |settings_content, value, _| {
settings_content
.background
.get_or_insert_default()
.ui_opacity = value;
},
}),
metadata: None,
files: USER,
}),
]
}
-1
View File
@@ -183,7 +183,6 @@ impl ThemeSelection {
},
}
}
/// Returns the [ThemeMode] for the [ThemeSelection].
pub fn mode(&self) -> Option<ThemeAppearanceMode> {
match self {
+48 -2
View File
@@ -100,6 +100,7 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) {
settings.experimental_theme_overrides.clone(),
settings.theme_overrides.clone(),
);
let mut prev_background = background_tint_input(cx);
cx.observe_global::<SettingsStore>(move |cx| {
let settings = ThemeSettings::get_global(cx);
@@ -116,6 +117,7 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) {
settings.experimental_theme_overrides.clone(),
settings.theme_overrides.clone(),
);
let background = background_tint_input(cx);
if buffer_font_size_settings != prev_buffer_font_size_settings {
prev_buffer_font_size_settings = buffer_font_size_settings;
@@ -147,9 +149,13 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) {
reset_markdown_preview_font_size(cx);
}
if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides {
if theme_name != prev_theme_name
|| theme_overrides != prev_theme_overrides
|| background != prev_background
{
prev_theme_name = theme_name;
prev_theme_overrides = theme_overrides;
prev_background = background;
reload_theme(cx);
}
@@ -161,6 +167,15 @@ pub fn init(themes_to_load: LoadThemes, cx: &mut App) {
.detach();
}
/// The parts of the background image settings that affect the theme tint.
fn background_tint_input(cx: &App) -> Option<(Option<String>, Option<f32>)> {
let background = cx
.try_global::<SettingsStore>()
.and_then(|store| store.merged_settings().background.clone())?;
Some((background.path, background.opacity))
}
fn configured_theme(cx: &mut App) -> Arc<Theme> {
let themes = ThemeRegistry::default_global(cx);
let theme_settings = ThemeSettings::get_global(cx);
@@ -179,7 +194,38 @@ fn configured_theme(cx: &mut App) -> Arc<Theme> {
.unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap())
}
};
theme_settings.apply_theme_overrides(theme)
let theme = theme_settings.apply_theme_overrides(theme);
tint_for_background_image(theme, cx)
}
/// Tints the editor and terminal surface colors to let the background image
/// show through. Only applies while a background image path is configured.
/// Text and chrome colors are left untouched so readability is preserved.
fn tint_for_background_image(theme: Arc<Theme>, cx: &App) -> Arc<Theme> {
let Some(background) = cx
.try_global::<SettingsStore>()
.and_then(|store| store.merged_settings().background.clone())
else {
return theme;
};
if background.path.is_none() {
return theme;
}
let opacity = background.opacity.unwrap_or(0.15).clamp(0.0, 1.0);
if opacity >= 1.0 {
return theme;
}
let mut theme = (*theme).clone();
let colors = &mut theme.styles.colors;
colors.background.a *= opacity;
colors.editor_background.a *= opacity;
colors.editor_gutter_background.a *= opacity;
colors.editor_subheader_background.a *= opacity;
colors.terminal_background.a *= opacity;
colors.panel_background.a *= opacity;
Arc::new(theme)
}
fn configured_icon_theme(cx: &mut App) -> Arc<theme::IconTheme> {
+7 -13
View File
@@ -14,8 +14,9 @@ use image::Frame;
use settings::{BackgroundImageFit, RegisterSetting, Settings, SettingsStore};
use smallvec::SmallVec;
/// Default opacity of the workspace background image.
pub const DEFAULT_BACKGROUND_IMAGE_OPACITY: f32 = 0.6;
/// Default opacity of the editor and terminal surfaces drawn on top of
/// the background image. Lower values let the image show through more.
pub const DEFAULT_BACKGROUND_IMAGE_OPACITY: f32 = 0.15;
/// Maximum time to wait for the image file to become readable before falling
/// back to the local cache. Bounds how long an unreachable path (e.g. a dead
/// network mount) can delay the background image; the workspace itself never
@@ -29,10 +30,10 @@ pub struct BackgroundImageSettings {
pub path: Option<Arc<Path>>,
/// How the image is fitted into the workspace bounds.
pub fit: BackgroundImageFit,
/// The opacity of the image, between 0.0 and 1.0.
/// The opacity of the editor and terminal surfaces drawn on top of the
/// image, between 0.0 (surfaces fully transparent) and 1.0 (surfaces
/// fully opaque, image hidden).
pub opacity: f32,
/// The opacity of the UI drawn on top of the image, between 0.0 and 1.0.
pub ui_opacity: f32,
}
impl Default for BackgroundImageSettings {
@@ -41,7 +42,6 @@ impl Default for BackgroundImageSettings {
path: None,
fit: BackgroundImageFit::Cover,
opacity: DEFAULT_BACKGROUND_IMAGE_OPACITY,
ui_opacity: 1.0,
}
}
}
@@ -62,10 +62,6 @@ impl Settings for BackgroundImageSettings {
.opacity
.map(|opacity| opacity.clamp(0.0, 1.0))
.unwrap_or(DEFAULT_BACKGROUND_IMAGE_OPACITY),
ui_opacity: background
.ui_opacity
.map(|opacity| opacity.clamp(0.0, 1.0))
.unwrap_or(1.0),
}
}
}
@@ -169,8 +165,7 @@ impl Render for BackgroundImage {
.size_full()
.child(
img(ImageSource::Render(image))
.object_fit(object_fit)
.opacity(self.settings.opacity),
.object_fit(object_fit),
)
.into_any_element()
}
@@ -351,7 +346,6 @@ mod tests {
path: Some("~/background.png".to_string()),
fit: Some(BackgroundImageFit::Contain),
opacity: Some(0.25),
ui_opacity: None,
});
});
});
-5
View File
@@ -9323,10 +9323,6 @@ impl Render for Workspace {
workspace: &self.weak_self,
};
let background_settings =
background_image::BackgroundImageSettings::get_global(cx);
let ui_opacity = background_settings.ui_opacity;
let ui = div()
.size_full()
.flex()
@@ -9337,7 +9333,6 @@ impl Render for Workspace {
.items_start()
.text_color(colors.text)
.overflow_hidden()
.when(ui_opacity < 1.0, |div| div.opacity(ui_opacity))
// Expose the title bar as an ARIA toolbar so region navigation
// (FocusNextPart) can reach the top bar's controls and assistive
// technology announces it as a toolbar. The contained controls form