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.
694 lines
22 KiB
Rust
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.)));
|
|
}
|
|
}
|