workspace: Add background image position and none-fit support

Allows anchoring background images via `background.position`:
`center` (default), `top_left`, `top_right`, `bottom_left`,
`bottom_right`, matching CSS `background-position` semantics.

Also adds `none` to `background.fit` to keep the original image size,
making watermark-style bottom-right or corner positioning possible
without stretching or cover cropping.
This commit is contained in:
yenru0 committed 2026-09-03 16:40:06 +09:00
1 parent ef0ddca6ce
commit d9f6214406
5 files changed
+267 -17

No files matched your search

+3 -1
View File
@@ -602,8 +602,10 @@
// The path to the image displayed behind the workspace UI.
// `~` and environment variables are expanded. `null` disables the background image.
"path": null,
// How the image is fitted into the workspace bounds: "cover", "fill" or "contain".
// How the image is fitted into the workspace bounds: "cover", "fill", "contain" or "none" (original size).
"fit": "cover",
// Where the image is anchored: "center", "top_left", "top_right", "bottom_left" or "bottom_right".
"position": "center",
// 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).
@@ -17,6 +17,45 @@ pub enum BackgroundImageFit {
Fill,
/// The image is scaled to fit entirely within the workspace bounds.
Contain,
/// The image keeps its original size; `position` decides which part is
/// visible.
None,
}
/// Determines where the workspace background image is anchored.
///
/// Default: center
#[derive(
Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom,
strum::VariantArray, strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundImagePosition {
/// The image is centered within the workspace bounds.
#[default]
Center,
/// The image is anchored to the top-left corner.
TopLeft,
/// The image is anchored to the top-right corner.
TopRight,
/// The image is anchored to the bottom-left corner.
BottomLeft,
/// The image is anchored to the bottom-right corner.
BottomRight,
}
impl BackgroundImagePosition {
/// Returns the anchor point as (x, y) ratios of the overflow area,
/// matching CSS `background-position` semantics.
pub fn ratio(self) -> (f32, f32) {
match self {
BackgroundImagePosition::Center => (0.5, 0.5),
BackgroundImagePosition::TopLeft => (0.0, 0.0),
BackgroundImagePosition::TopRight => (1.0, 0.0),
BackgroundImagePosition::BottomLeft => (0.0, 1.0),
BackgroundImagePosition::BottomRight => (1.0, 1.0),
}
}
}
/// Settings for the image displayed behind the workspace UI.
@@ -28,12 +67,19 @@ pub struct BackgroundImageSettingsContent {
/// image is displayed.
pub path: Option<String>,
/// How the image is fitted into the workspace bounds: "cover", "fill" or
/// "contain".
/// How the image is fitted into the workspace bounds: "cover", "fill",
/// "contain" or "none" (original size).
///
/// Default: cover
pub fit: Option<BackgroundImageFit>,
/// Where the image is anchored within the workspace bounds: "center",
/// "top_left", "top_right", "bottom_left" or "bottom_right". Controls
/// which part of the image is visible when it overflows the bounds.
///
/// Default: center
pub position: Option<BackgroundImagePosition>,
/// 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
+20 -1
View File
@@ -1530,7 +1530,7 @@ fn appearance_page() -> SettingsPage {
]
}
fn background_image_section() -> [SettingsPageItem; 4] {
fn background_image_section() -> [SettingsPageItem; 5] {
[
SettingsPageItem::SectionHeader("Background Image"),
SettingsPageItem::SettingItem(SettingItem {
@@ -1571,6 +1571,25 @@ fn appearance_page() -> SettingsPage {
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Position",
description: "Where the image is anchored within the workspace bounds.",
field: Box::new(SettingField {
organization_override: None,
json_path: Some("background.position"),
pick: |settings_content| {
settings_content.background.as_ref()?.position.as_ref()
},
write: |settings_content, value, _| {
settings_content
.background
.get_or_insert_default()
.position = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Opacity",
description: "Opacity of the editor and terminal surfaces, between 0.0 (image fully visible) and 1.0 (image hidden).",
+1
View File
@@ -675,6 +675,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::AudioOutputDeviceName>(render_output_audio_device_dropdown)
.add_basic_renderer::<settings::TerminalBell>(render_dropdown)
.add_basic_renderer::<settings::BackgroundImageFit>(render_dropdown)
.add_basic_renderer::<settings::BackgroundImagePosition>(render_dropdown)
// please semicolon stay on next line
;
}
+195 -13
View File
@@ -7,11 +7,15 @@ use std::{
use fs::Fs;
use gpui::{
BackgroundExecutor, Context, Empty, ImageSource, IntoElement, ObjectFit, ParentElement,
Render, RenderImage, Styled, StyledImage, Subscription, Task, Window, img,
point, px, relative, size, BackgroundExecutor, Bounds, Context, Corners, Element,
ElementId, Empty, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
ParentElement, Pixels, Render, RenderImage, Style, Styled, Subscription, Task, Window,
};
use util::ResultExt;
use image::Frame;
use settings::{BackgroundImageFit, RegisterSetting, Settings, SettingsStore};
use settings::{
BackgroundImageFit, BackgroundImagePosition, RegisterSetting, Settings, SettingsStore,
};
use smallvec::SmallVec;
/// Default opacity of the editor and terminal surfaces drawn on top of
@@ -30,6 +34,8 @@ pub struct BackgroundImageSettings {
pub path: Option<Arc<Path>>,
/// How the image is fitted into the workspace bounds.
pub fit: BackgroundImageFit,
/// Where the image is anchored within the workspace bounds.
pub position: BackgroundImagePosition,
/// 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).
@@ -41,6 +47,7 @@ impl Default for BackgroundImageSettings {
Self {
path: None,
fit: BackgroundImageFit::Cover,
position: BackgroundImagePosition::Center,
opacity: DEFAULT_BACKGROUND_IMAGE_OPACITY,
}
}
@@ -58,6 +65,7 @@ impl Settings for BackgroundImageSettings {
Arc::from(Path::new(expanded.as_ref()))
}),
fit: background.fit.unwrap_or_default(),
position: background.position.unwrap_or_default(),
opacity: background
.opacity
.map(|opacity| opacity.clamp(0.0, 1.0))
@@ -153,24 +161,152 @@ impl Render for BackgroundImage {
return Empty.into_any_element();
};
let object_fit = match self.settings.fit {
BackgroundImageFit::Cover => ObjectFit::Cover,
BackgroundImageFit::Fill => ObjectFit::Fill,
BackgroundImageFit::Contain => ObjectFit::Contain,
};
gpui::div()
.absolute()
.inset_0()
.size_full()
.child(
img(ImageSource::Render(image))
.object_fit(object_fit),
)
.child(PositionedBackgroundImage {
image,
fit: self.settings.fit,
position: self.settings.position,
})
.into_any_element()
}
}
struct PositionedBackgroundImage {
image: Arc<RenderImage>,
fit: BackgroundImageFit,
position: BackgroundImagePosition,
}
impl Element for PositionedBackgroundImage {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut gpui::App,
) -> (LayoutId, ()) {
let mut style = Style::default();
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
let layout_id = window.request_layout(style, None, cx);
(layout_id, ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_state: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut gpui::App,
) {
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
_cx: &mut gpui::App,
) {
if self.image.frame_count() == 0 {
return;
}
let image_bounds = compute_image_bounds(
bounds,
self.image.size(0),
window.scale_factor(),
self.fit,
self.position,
);
window
.paint_image(
bounds,
image_bounds,
Corners::default(),
self.image.clone(),
0,
false,
)
.log_err();
}
}
impl IntoElement for PositionedBackgroundImage {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
fn compute_image_bounds(
bounds: Bounds<Pixels>,
image_size: gpui::Size<gpui::DevicePixels>,
scale_factor: f32,
fit: BackgroundImageFit,
position: BackgroundImagePosition,
) -> Bounds<Pixels> {
let img_w = u32::from(image_size.width) as f32 / scale_factor;
let img_h = u32::from(image_size.height) as f32 / scale_factor;
let bounds_w = f32::from(bounds.size.width);
let bounds_h = f32::from(bounds.size.height);
if img_w <= 0.0 || img_h <= 0.0 || bounds_w <= 0.0 || bounds_h <= 0.0 {
return bounds;
}
let img_ratio = img_w / img_h;
let bounds_ratio = bounds_w / bounds_h;
let fitted_size = match fit {
BackgroundImageFit::Fill => bounds.size,
BackgroundImageFit::Cover => {
if bounds_ratio > img_ratio {
size(bounds.size.width, px(bounds_w / img_ratio))
} else {
size(px(bounds_h * img_ratio), bounds.size.height)
}
}
BackgroundImageFit::Contain => {
if bounds_ratio > img_ratio {
size(px(bounds_h * img_ratio), bounds.size.height)
} else {
size(bounds.size.width, px(bounds_w / img_ratio))
}
}
BackgroundImageFit::None => size(px(img_w), px(img_h)),
};
let (pos_x, pos_y) = position.ratio();
let origin = point(
bounds.origin.x + (bounds.size.width - fitted_size.width) * pos_x,
bounds.origin.y + (bounds.size.height - fitted_size.height) * pos_y,
);
Bounds {
origin,
size: fitted_size,
}
}
async fn load_background_image(
fs: &Arc<dyn Fs>,
path: &Arc<Path>,
@@ -331,6 +467,7 @@ mod tests {
let settings = BackgroundImageSettings::get_global(cx);
assert_eq!(settings.path, None);
assert_eq!(settings.fit, BackgroundImageFit::Cover);
assert_eq!(settings.position, BackgroundImagePosition::Center);
assert_eq!(settings.opacity, DEFAULT_BACKGROUND_IMAGE_OPACITY);
});
}
@@ -345,6 +482,7 @@ mod tests {
settings.background = Some(BackgroundImageSettingsContent {
path: Some("~/background.png".to_string()),
fit: Some(BackgroundImageFit::Contain),
position: Some(BackgroundImagePosition::BottomRight),
opacity: Some(0.25),
});
});
@@ -357,6 +495,7 @@ mod tests {
Some(Path::new(&home).join("background.png").as_path())
);
assert_eq!(settings.fit, BackgroundImageFit::Contain);
assert_eq!(settings.position, BackgroundImagePosition::BottomRight);
assert_eq!(settings.opacity, 0.25);
});
}
@@ -510,4 +649,47 @@ mod tests {
assert!(view.image().is_none());
});
}
#[test]
fn test_compute_image_bounds_positions() {
use gpui::{Bounds, DevicePixels, point, size};
let bounds = Bounds {
origin: point(px(0.), px(0.)),
size: size(px(1000.), px(500.)),
};
let img_size = size(DevicePixels(200), DevicePixels(100)); // 2:1 aspect ratio
let scale = 1.0;
// Fit::None at BottomRight -> anchored to bottom right (1000-200, 500-100)
let b = compute_image_bounds(
bounds,
img_size,
scale,
BackgroundImageFit::None,
BackgroundImagePosition::BottomRight,
);
assert_eq!(b.origin, point(px(800.), px(400.)));
assert_eq!(b.size, size(px(200.), px(100.)));
// Fit::None at TopLeft -> anchored to top left (0, 0)
let b = compute_image_bounds(
bounds,
img_size,
scale,
BackgroundImageFit::None,
BackgroundImagePosition::TopLeft,
);
assert_eq!(b.origin, point(px(0.), px(0.)));
// Fit::None at Center -> centered (400, 200)
let b = compute_image_bounds(
bounds,
img_size,
scale,
BackgroundImageFit::None,
BackgroundImagePosition::Center,
);
assert_eq!(b.origin, point(px(400.), px(200.)));
}
}