diff --git a/Cargo.lock b/Cargo.lock index 7db15b1c29..fb6a4dcdd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22281,6 +22281,7 @@ dependencies = [ "git", "gpui", "http_client", + "image", "itertools 0.14.0", "language", "log", @@ -22288,6 +22289,7 @@ dependencies = [ "menu", "node_runtime", "parking_lot", + "paths", "postage", "pretty_assertions", "project", @@ -22299,6 +22301,7 @@ dependencies = [ "serde_json", "session", "settings", + "shellexpand", "smallvec", "sqlez", "strum 0.28.0", diff --git a/assets/settings/default.json b/assets/settings/default.json index 671653708d..7349eb2eef 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -597,6 +597,16 @@ // Any unrecognized input device will fall back to system default. "experimental.input_audio_device": null, }, + // Background image settings + "background": { + // 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". + "fit": "cover", + // The opacity of the background image, between 0.0 (fully transparent) and 1.0 (fully opaque). + "opacity": 0.6, + }, // Scrollbar related settings "scrollbar": { // When to show the scrollbar in the editor. diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 3e25d4ecc6..f287e9d817 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -176,6 +176,7 @@ impl VsCodeSettings { agent_servers: None, audio: None, auto_update: None, + background: None, base_keymap: Some(BaseKeymapContent::VSCode), calls: None, collaboration_panel: None, diff --git a/crates/settings_content/src/background_image.rs b/crates/settings_content/src/background_image.rs new file mode 100644 index 0000000000..87f8081971 --- /dev/null +++ b/crates/settings_content/src/background_image.rs @@ -0,0 +1,42 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings_macros::MergeFrom; + +/// Determines how the workspace background image is fitted into the window bounds. +/// +/// Default: cover +#[derive( + Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom, +)] +#[serde(rename_all = "snake_case")] +pub enum BackgroundImageFit { + /// The image is scaled to cover the entire workspace bounds. + #[default] + Cover, + /// The image is stretched to fill the workspace bounds. + Fill, + /// The image is scaled to fit entirely within the workspace bounds. + Contain, +} + +/// Settings for the image displayed behind the workspace UI. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct BackgroundImageSettingsContent { + /// The path to the image file to display behind the workspace UI. + /// + /// `~` and environment variables are expanded. When `null`, no background + /// image is displayed. + pub path: Option, + + /// How the image is fitted into the workspace bounds: "cover", "fill" or + /// "contain". + /// + /// Default: cover + pub fit: Option, + + /// The opacity of the background image, between 0.0 (fully transparent) + /// and 1.0 (fully opaque). + /// + /// Default: 0.6 + pub opacity: Option, +} diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5e00367889..0c46b5a4cf 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1,5 +1,6 @@ mod action; mod agent; +mod background_image; mod editor; mod extension; mod fallible_options; @@ -15,6 +16,7 @@ mod workspace; pub use action::{ActionName, ActionWithArguments, CommandAliasTarget}; pub use agent::*; +pub use background_image::*; use anyhow::Context; pub use editor::*; pub use extension::*; @@ -209,6 +211,9 @@ pub struct SettingsContent { /// Configuration of audio in Zed. pub audio: Option, + /// Configuration of the background image displayed behind the workspace UI. + pub background: Option, + /// Whether or not to automatically check for updates. /// /// Default: true @@ -400,7 +405,7 @@ fallible_options::flattened_deserialize!(SettingsContent { sections: { project, theme, extension, workspace, editor, remote }, options: { call_hierarchy, file_finder, git_panel, tabs, tab_bar, status_bar, preview_tabs, agent, - agent_servers, audio, auto_update, base_keymap, collaboration_panel, debugger, diagnostics, + agent_servers, audio, auto_update, background, base_keymap, collaboration_panel, debugger, diagnostics, git, global_lsp_settings, image_viewer, markdown_preview, repl, helix_mode, hide_mouse, journal, log, line_indicator_format, language_models, outline_panel, project_panel, diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index abb49eda1f..4f88774b69 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -43,6 +43,7 @@ futures.workspace = true git.workspace = true gpui.workspace = true http_client.workspace = true +image.workspace = true itertools.workspace = true language.workspace = true log.workspace = true @@ -50,6 +51,7 @@ menu.workspace = true markdown.workspace = true node_runtime.workspace = true parking_lot.workspace = true +paths.workspace = true postage.workspace = true project.workspace = true remote.workspace = true @@ -58,6 +60,7 @@ serde.workspace = true serde_json.workspace = true session.workspace = true settings.workspace = true +shellexpand.workspace = true smallvec.workspace = true sqlez.workspace = true strum.workspace = true diff --git a/crates/workspace/src/background_image.rs b/crates/workspace/src/background_image.rs new file mode 100644 index 0000000000..39f68f057c --- /dev/null +++ b/crates/workspace/src/background_image.rs @@ -0,0 +1,503 @@ +use std::{ + hash::BuildHasher, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use fs::Fs; +use gpui::{ + BackgroundExecutor, Context, Empty, ImageSource, IntoElement, ObjectFit, ParentElement, + Render, RenderImage, Styled, StyledImage, Subscription, Task, Window, img, +}; +use image::Frame; +use settings::{BackgroundImageFit, RegisterSetting, Settings, SettingsStore}; +use smallvec::SmallVec; + +/// Default opacity of the workspace background image. +pub const DEFAULT_BACKGROUND_IMAGE_OPACITY: f32 = 0.6; +/// 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>, + /// How the image is fitted into the workspace bounds. + pub fit: BackgroundImageFit, + /// The opacity of the image, between 0.0 and 1.0. + pub opacity: f32, +} + +impl Default for BackgroundImageSettings { + fn default() -> Self { + Self { + path: None, + fit: BackgroundImageFit::Cover, + 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(), + 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, + fs: Arc, + image: Option>, + _load_task: Option>, + _settings_subscription: Subscription, +} + +impl BackgroundImage { + pub fn new(fs: Arc, cache_dir: Arc, cx: &mut Context) -> Self { + let _settings_subscription = cx.observe_global::(|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.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 image = + load_background_image(&fs, &path, &cache_dir, executor.clone()).await; + + this.update(cx, |this, cx| { + // Drop the result if the settings changed while loading. + if this.settings.path.as_deref() == Some(path.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> { + self.image.as_ref() + } +} + +impl Render for BackgroundImage { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let Some(image) = self.image.clone() else { + 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) + .opacity(self.settings.opacity), + ) + .into_any_element() + } +} + +async fn load_background_image( + fs: &Arc, + path: &Arc, + cache_dir: &Arc, + executor: BackgroundExecutor, +) -> Option> { + 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, + path: &Arc, + cache_dir: &Arc, +) -> Option> { + 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, path: &Path, cache_dir: &Arc, bytes: Vec) { + 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> { + 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, + ) -> gpui::Entity { + 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) -> Vec { + 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.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), + 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.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()); + }); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ca41b281fb..83bb654807 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,4 +1,5 @@ pub mod active_file_name; +pub mod background_image; pub mod dock; pub mod history_manager; pub mod invalid_item_view; @@ -163,6 +164,7 @@ pub use workspace_settings::{ use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::NotificationId}; +use crate::background_image::BackgroundImage; use crate::{ persistence::{ SerializedAxis, @@ -1461,6 +1463,7 @@ pub struct Workspace { status_bar: Entity, pub(crate) modal_layer: Entity, toast_layer: Entity, + background_image: Entity, titlebar_item: Option, titlebar_focus_handle: FocusHandle, region_focus_handles: RegionFocusHandles, @@ -1821,6 +1824,13 @@ impl Workspace { cx.emit(Event::WorkspaceCreated(weak_handle.clone())); let modal_layer = cx.new(|_| ModalLayer::new()); let toast_layer = cx.new(|_| ToastLayer::new()); + let background_image = cx.new(|cx| { + BackgroundImage::new( + app_state.fs.clone(), + Arc::from(paths::data_dir().join("background_images")), + cx, + ) + }); cx.subscribe( &modal_layer, |_, _, _: &modal_layer::ModalOpenedEvent, cx| { @@ -1929,6 +1939,7 @@ impl Workspace { status_bar, modal_layer, toast_layer, + background_image, titlebar_item: None, titlebar_focus_handle: cx.focus_handle(), region_focus_handles: RegionFocusHandles::new(cx), @@ -9314,6 +9325,7 @@ impl Render for Workspace { div() .relative() + .child(self.background_image.clone()) .size_full() .flex() .flex_col()