From d9f6214406dc61ea80cdab4e89ded0fb1ff55ad9 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Thu, 3 Sep 2026 16:40:06 +0900 Subject: [PATCH] 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. --- assets/settings/default.json | 4 +- .../settings_content/src/background_image.rs | 50 ++++- crates/settings_ui/src/page_data.rs | 21 +- crates/settings_ui/src/settings_ui.rs | 1 + crates/workspace/src/background_image.rs | 208 ++++++++++++++++-- 5 files changed, 267 insertions(+), 17 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index b377292ad6..dc779b9e31 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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). diff --git a/crates/settings_content/src/background_image.rs b/crates/settings_content/src/background_image.rs index 10a116045f..293a133c59 100644 --- a/crates/settings_content/src/background_image.rs +++ b/crates/settings_content/src/background_image.rs @@ -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, - /// 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, + /// 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, + /// 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 diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 979cc6c9eb..af91f73a29 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -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).", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index ed0e6403cc..e40854cffc 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -675,6 +675,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_output_audio_device_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) // please semicolon stay on next line ; } diff --git a/crates/workspace/src/background_image.rs b/crates/workspace/src/background_image.rs index 3833d79732..7821cffee2 100644 --- a/crates/workspace/src/background_image.rs +++ b/crates/workspace/src/background_image.rs @@ -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>, /// 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, + fit: BackgroundImageFit, + position: BackgroundImagePosition, +} + +impl Element for PositionedBackgroundImage { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + 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, + _state: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut gpui::App, + ) { + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _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, + image_size: gpui::Size, + scale_factor: f32, + fit: BackgroundImageFit, + position: BackgroundImagePosition, +) -> Bounds { + 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, path: &Arc, @@ -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.))); + } }