From 2d1430ada55bac2f65a3570cd10b072b0e50b610 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 2 Sep 2026 18:55:05 +0900 Subject: [PATCH] workspace: Add background image setting with offline-resilient loading Adds a workspace background image that renders behind all UI, configured via a new `background` settings section (path, fit, opacity). The image is loaded asynchronously with a 2-second timeout and is never read on the render path, so an unreachable source (e.g. a dead network mount) cannot block or freeze the workspace. The first successful read is cached under the data directory (written once, never rewritten), and when the source is unavailable the cached image is served from that cache, so the background keeps working offline. --- Cargo.lock | 3 + assets/settings/default.json | 10 + crates/settings/src/vscode_import.rs | 1 + .../settings_content/src/background_image.rs | 42 ++ .../settings_content/src/settings_content.rs | 7 +- crates/workspace/Cargo.toml | 3 + crates/workspace/src/background_image.rs | 503 ++++++++++++++++++ crates/workspace/src/workspace.rs | 12 + 8 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 crates/settings_content/src/background_image.rs create mode 100644 crates/workspace/src/background_image.rs 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()