Files
zed/crates/workspace/src/background_image.rs
T
yenru0 b3cca826c8 workspace: Move tabs across windows by drag and drop
Dropping a tab on another window's pane, or releasing it outside every
window, previously had no defined behavior: the item entity would be
re-parented into a pane whose project it does not belong to, or the
drag would simply stick. Both gestures now transfer the item by
round-tripping it through the workspace database: the source item is
serialized, recreated in the target pane via the SerializableItem
registry, and closed in its source window. Items that are dirty or not
serializable snap back instead.

A release outside every window opens a new window for the item, sized
like the source window and placed at the cursor, clamped to the
display. Workspace::new_local_with_bounds exposes the window placement
for that; new_local keeps its previous behavior.
2026-10-14 05:10:05 +09:00

694 lines
22 KiB
Rust

use std::{
hash::BuildHasher,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use fs::Fs;
use gpui::{
BackgroundExecutor, Bounds, Context, Corners, Element, ElementId, Empty, GlobalElementId,
InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Render, RenderImage, Style,
Styled, Subscription, Task, Window, point, px, relative, size,
};
use image::Frame;
use settings::{
BackgroundImageFit, BackgroundImagePosition, RegisterSetting, Settings, SettingsStore,
};
use smallvec::SmallVec;
use util::ResultExt;
/// 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
/// blocks on it.
const LOAD_TIMEOUT: Duration = Duration::from_secs(2);
/// The resolved settings for the workspace background image.
#[derive(Clone, Debug, PartialEq, RegisterSetting)]
pub struct BackgroundImageSettings {
/// The path of the image to display, or `None` to disable the background image.
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).
pub opacity: f32,
}
impl Default for BackgroundImageSettings {
fn default() -> Self {
Self {
path: None,
fit: BackgroundImageFit::Cover,
position: BackgroundImagePosition::Center,
opacity: DEFAULT_BACKGROUND_IMAGE_OPACITY,
}
}
}
impl Settings for BackgroundImageSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let Some(background) = content.background.as_ref() else {
return Self::default();
};
Self {
path: background.path.as_deref().map(|path| {
let expanded = shellexpand::tilde(path);
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))
.unwrap_or(DEFAULT_BACKGROUND_IMAGE_OPACITY),
}
}
}
/// An entity that renders the configured background image behind the
/// workspace UI. The image is loaded asynchronously, off the render path, so
/// an unreadable or unreachable path can never block rendering. The first
/// successfully loaded image is cached locally, so it keeps working when the
/// source path (e.g. a network mount) is unavailable.
pub struct BackgroundImage {
settings: BackgroundImageSettings,
cache_dir: Arc<Path>,
fs: Arc<dyn Fs>,
image: Option<Arc<RenderImage>>,
_load_task: Option<Task<()>>,
_settings_subscription: Subscription,
}
impl BackgroundImage {
pub fn new(fs: Arc<dyn Fs>, cache_dir: Arc<Path>, cx: &mut Context<Self>) -> Self {
let _settings_subscription = cx.observe_global::<SettingsStore>(|this, cx| {
let new_settings = BackgroundImageSettings::get_global(cx).clone();
if this.settings != new_settings {
this.settings = new_settings;
this.schedule_load(cx);
}
});
let mut this = Self {
settings: BackgroundImageSettings::get_global(cx).clone(),
cache_dir,
fs,
image: None,
_load_task: None,
_settings_subscription,
};
this.schedule_load(cx);
this
}
fn schedule_load(&mut self, cx: &mut Context<Self>) {
self.image = None;
let Some(path) = self.settings.path.clone() else {
self._load_task = None;
return;
};
let fs = self.fs.clone();
let cache_dir = self.cache_dir.clone();
let executor = cx.background_executor().clone();
self._load_task = Some(cx.spawn(async move |this, cx| {
let fs = fs.clone();
let path = path.clone();
let path_for_check = path.clone();
let cache_dir = cache_dir.clone();
let load_task = executor.clone().spawn(async move {
load_background_image(&fs, &path, &cache_dir, executor.clone()).await
});
let image = load_task.await;
this.update(cx, |this, cx| {
// Drop the result if the settings changed while loading.
if this.settings.path.as_deref() == Some(path_for_check.as_ref()) {
this.image = image;
}
this._load_task = None;
cx.notify();
})
.ok();
}));
}
#[cfg(any(test, feature = "test-support"))]
pub fn settings(&self) -> &BackgroundImageSettings {
&self.settings
}
#[cfg(any(test, feature = "test-support"))]
pub fn image(&self) -> Option<&Arc<RenderImage>> {
self.image.as_ref()
}
}
impl Render for BackgroundImage {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let Some(image) = self.image.clone() else {
return Empty.into_any_element();
};
gpui::div()
.absolute()
.inset_0()
.size_full()
.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>,
cache_dir: &Arc<Path>,
executor: BackgroundExecutor,
) -> Option<Arc<RenderImage>> {
let load = fs.load_bytes(path);
let timer = executor.timer(LOAD_TIMEOUT);
match futures::future::select(Box::pin(load), Box::pin(timer)).await {
futures::future::Either::Left((Ok(bytes), _)) => match decode_image(&bytes) {
Ok(image) => {
let fs = fs.clone();
let path = path.clone();
let cache_dir = cache_dir.clone();
executor
.spawn(async move {
refresh_cache(&fs, &path, &cache_dir, bytes).await;
})
.detach();
Some(image)
}
Err(error) => {
log::info!(
"failed to decode background image at {}: {error}",
path.display()
);
load_from_cache(fs, path, cache_dir).await
}
},
futures::future::Either::Left((Err(error), _)) => {
log::info!(
"failed to load background image at {}: {error}",
path.display()
);
load_from_cache(fs, path, cache_dir).await
}
futures::future::Either::Right(_) => {
log::info!(
"background image at {} did not become readable within {:?}",
path.display(),
LOAD_TIMEOUT
);
load_from_cache(fs, path, cache_dir).await
}
}
}
/// Reads the locally cached copy of the image. The cache lives under Zed's
/// local data directory, so no timeout is applied here.
async fn load_from_cache(
fs: &Arc<dyn Fs>,
path: &Arc<Path>,
cache_dir: &Arc<Path>,
) -> Option<Arc<RenderImage>> {
let cache_path = cache_path_for(path, cache_dir);
match fs.load_bytes(&cache_path).await {
Ok(bytes) => decode_image(&bytes)
.inspect_err(|error| {
log::info!("failed to decode cached background image: {error}");
})
.ok(),
Err(_) => None,
}
}
async fn refresh_cache(fs: &Arc<dyn Fs>, path: &Path, cache_dir: &Arc<Path>, bytes: Vec<u8>) {
let cache_path = cache_path_for(path, cache_dir);
// Only write the cache once; later successful reads keep reusing it.
if fs.is_file(&cache_path).await {
return;
}
if let Err(error) = fs.write(&cache_path, &bytes).await {
log::info!(
"failed to cache background image at {}: {error}",
cache_path.display()
);
}
}
fn cache_path_for(path: &Path, cache_dir: &Path) -> PathBuf {
let hash = collections::FxBuildHasher.hash_one(path);
cache_dir.join(format!("{hash:016x}"))
}
fn decode_image(bytes: &[u8]) -> anyhow::Result<Arc<RenderImage>> {
let image = image::load_from_memory(bytes)?;
let mut rgba = image.into_rgba8();
// `RenderImage` expects BGRA pixels, matching the conversion the built-in
// image asset loader performs.
for pixel in rgba.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
Ok(Arc::new(RenderImage::new(SmallVec::from_elem(
Frame::new(rgba),
1,
))))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use fs::FakeFs;
use gpui::{AppContext, TestAppContext, UpdateGlobal};
use settings::{BackgroundImageFit, BackgroundImageSettingsContent, SettingsStore};
use super::*;
const CACHE_DIR: &str = "/test-cache/background_images";
fn settings_store(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
fn set_background_path(cx: &mut TestAppContext, path: Option<&str>) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.background = path.map(|path| BackgroundImageSettingsContent {
path: Some(path.to_string()),
..Default::default()
});
});
});
});
}
fn new_background_image(
cx: &mut TestAppContext,
fs: Arc<FakeFs>,
) -> gpui::Entity<BackgroundImage> {
cx.update(|cx| cx.new(|cx| BackgroundImage::new(fs, Arc::from(Path::new(CACHE_DIR)), cx)))
}
/// A minimal 2x2 PNG of the given color.
fn png_bytes(color: image::Rgba<u8>) -> Vec<u8> {
let mut bytes = Vec::new();
let image = image::RgbaImage::from_pixel(2, 2, color);
image::DynamicImage::ImageRgba8(image)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.unwrap();
bytes
}
#[gpui::test]
async fn test_background_image_settings_defaults(cx: &mut TestAppContext) {
settings_store(cx);
cx.update(|cx| {
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);
});
}
#[gpui::test]
async fn test_background_image_settings_from_user_settings(cx: &mut TestAppContext) {
settings_store(cx);
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.background = Some(BackgroundImageSettingsContent {
path: Some("~/background.png".to_string()),
fit: Some(BackgroundImageFit::Contain),
position: Some(BackgroundImagePosition::BottomRight),
opacity: Some(0.25),
});
});
});
let settings = BackgroundImageSettings::get_global(cx);
let home = shellexpand::tilde("~").into_owned();
assert_eq!(
settings.path.as_deref(),
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);
});
}
#[gpui::test]
async fn test_background_image_settings_clamps_opacity(cx: &mut TestAppContext) {
settings_store(cx);
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.background = Some(BackgroundImageSettingsContent {
opacity: Some(2.0),
..Default::default()
});
});
});
assert_eq!(BackgroundImageSettings::get_global(cx).opacity, 1.0);
});
}
#[gpui::test]
async fn test_background_image_loads_from_disk_and_caches(cx: &mut TestAppContext) {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255])))
.await;
let background_image = new_background_image(cx, fs.clone());
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_none());
});
set_background_path(cx, Some("/background.png"));
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_some());
});
let cache_path = cache_path_for(Path::new("/background.png"), Path::new(CACHE_DIR));
assert!(fs.is_file(&cache_path).await);
}
#[gpui::test]
async fn test_background_image_cache_is_written_once(cx: &mut TestAppContext) {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255])))
.await;
let background_image = new_background_image(cx, fs.clone());
set_background_path(cx, Some("/background.png"));
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_some());
});
let cache_path = cache_path_for(Path::new("/background.png"), Path::new(CACHE_DIR));
let cached = fs.load_bytes(&cache_path).await.unwrap();
// Change the source image and reload: the cache keeps the first
// version instead of being rewritten on every successful load.
fs.insert_file("/background.png", png_bytes(image::Rgba([0, 0, 255, 255])))
.await;
set_background_path(cx, None);
cx.run_until_parked();
set_background_path(cx, Some("/background.png"));
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_some());
});
assert_eq!(fs.load_bytes(&cache_path).await.unwrap(), cached);
}
#[gpui::test]
async fn test_background_image_falls_back_to_cache_when_source_missing(
cx: &mut TestAppContext,
) {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
let cache_path = cache_path_for(Path::new("/remote/background.png"), Path::new(CACHE_DIR));
fs.write(&cache_path, &png_bytes(image::Rgba([255, 0, 0, 255])))
.await
.unwrap();
let background_image = new_background_image(cx, fs);
set_background_path(cx, Some("/remote/background.png"));
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_some());
});
}
#[gpui::test]
async fn test_background_image_missing_source_without_cache_renders_nothing(
cx: &mut TestAppContext,
) {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
let background_image = new_background_image(cx, fs);
set_background_path(cx, Some("/does/not/exist.png"));
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_none());
});
}
#[gpui::test]
async fn test_background_image_invalid_content_is_not_fatal(cx: &mut TestAppContext) {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_file("/broken.png", b"not an image".to_vec())
.await;
let background_image = new_background_image(cx, fs);
set_background_path(cx, Some("/broken.png"));
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_none());
});
}
#[gpui::test]
async fn test_background_image_disable_clears_image(cx: &mut TestAppContext) {
settings_store(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255])))
.await;
let background_image = new_background_image(cx, fs);
set_background_path(cx, Some("/background.png"));
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
assert!(view.image().is_some());
});
set_background_path(cx, None);
cx.run_until_parked();
background_image.read_with(cx, |view, _| {
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.)));
}
}