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:
1 parent
ef0ddca6ce
commit
d9f6214406
5 files changed
+267
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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.)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user