Vectrum
Sign In

Optional start wallpaper behind the new-thread screen and its composer

Settings → Appearance → Start wallpaper picks an image (decoded off the
main thread, scaled to at most 2560px and cached until the file changes)
with a Dim stepper veiling it in the sheet's color. Conversations,
Usage and settings keep the plain sheet.
JjohannSocommitted 1 parent 80f5a4ecommit6ccf6f5

Showing 6 changed files with 259 additions and 2 deletions.

+12−0src/settings.rsView File
@@ -485,6 +485,12 @@ pub struct Settings {
485485 // ---- appearance ----
486486 pub diff_colors: DiffColors,
487487 pub chat_width: ChatWidth,
488+ /// An image behind the new-thread screen, by path; empty for none.
489+ /// Conversations keep the plain sheet.
490+ pub start_wallpaper: String,
491+ /// How much the sheet's color covers the wallpaper, in percent, so the
492+ /// heading stays legible.
493+ pub start_wallpaper_dim: u32,
488494
489495 // ---- sidebar ----
490496 /// T3 Code's legacy sidebar: threads in per-project trees instead of the
@@ -631,6 +637,8 @@ impl Default for Settings {
631637 diff_side_by_side: false,
632638 diff_colors: DiffColors::RedGreen,
633639 chat_width: ChatWidth::Comfortable,
640+ start_wallpaper: String::new(),
641+ start_wallpaper_dim: 30,
634642 legacy_sidebar: false,
635643 thread_order: ThreadOrder::LastMessage,
636644 visible_threads: 6,
@@ -710,6 +718,10 @@ impl Settings {
710718 ("Theme", theme),
711719 ("Diff colors", self.diff_colors != d.diff_colors),
712720 ("Chat width", self.chat_width != d.chat_width),
721+ (
722+ "Start wallpaper",
723+ (&self.start_wallpaper, self.start_wallpaper_dim) != (&d.start_wallpaper, d.start_wallpaper_dim),
724+ ),
713725 ("Time format", self.time_format != d.time_format),
714726 ("Thread notifications", (self.notify, self.notify_sound) != (d.notify, d.notify_sound)),
715727 ("In-app notifications", self.in_app_notifications != d.in_app_notifications),
+1−0src/ui/mod.rsView File
@@ -13,4 +13,5 @@ pub mod settings;
1313 pub mod terminal;
1414 pub mod themes;
1515 pub mod usage;
16+ pub mod wallpaper;
1617 pub mod workspace;
+100−2src/ui/settings/appearance.rsView File
@@ -7,9 +7,10 @@ use gpui_kit::prelude::FluentBuilder as _;
77 use gpui_kit::*;
88
99 use super::SettingsView;
10- use super::widgets::{Section, SettingRow, choice, choice_of, reset, switch};
10+ use super::widgets::{Section, SettingRow, choice, choice_of, edit, reset, stepper, switch};
1111 use crate::settings::{self, ChatWidth, DiffColors, ThemeMode};
12- use crate::ui::metal::{self, MetalStyled as _, Palette, vertical};
12+ use crate::ui::icons::Lucide;
13+ use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette, vertical};
1314 use crate::ui::themes::{self, FAMILIES, Theme};
1415
1516 const CHAT_WIDTHS: &[(ChatWidth, &str)] =
@@ -82,6 +83,7 @@ impl SettingsView {
8283 settings.chat_width = width
8384 })),
8485 )
86+ .row(self.wallpaper_row(&saved, &view, cx))
8587 .row(
8688 SettingRow::new("Thread cards")
8789 .id("thread-cards")
@@ -95,6 +97,102 @@ impl SettingsView {
9597
9698 vec![colors, interface]
9799 }
100+
101+ /// An optional image behind the new-thread screen: a thumbnail, Choose
102+ /// and Remove, and how strongly the sheet's color veils it.
103+ fn wallpaper_row(&self, saved: &settings::Settings, view: &WeakEntity<SettingsView>, cx: &mut Context<Self>) -> SettingRow {
104+ let p = metal::palette(cx);
105+ let path = saved.start_wallpaper.trim().to_string();
106+ let set = !path.is_empty();
107+ let file = settings::expand_home(&path);
108+ let name = file.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
109+ let missing = set && !file.exists();
110+ let choose = {
111+ let view = view.clone();
112+ MetalButton::new("start-wallpaper-choose")
113+ .compact()
114+ .icon(Icon::new(Lucide::Image))
115+ .label(if set { "Change" } else { "Choose image" })
116+ .on_click(move |_, window, cx| {
117+ let _ = view.update(cx, |view, cx| view.pick_wallpaper(window, cx));
118+ })
119+ };
120+ let remove = {
121+ let view = view.clone();
122+ MetalButton::new("start-wallpaper-remove")
123+ .compact()
124+ .ghost()
125+ .icon(Icon::new(Lucide::Trash))
126+ .tooltip("Remove wallpaper")
127+ .on_click(move |_, window, cx| {
128+ edit(&view, window, cx, |settings| settings.start_wallpaper.clear());
129+ })
130+ };
131+ let preview = (set && !missing).then(|| crate::ui::wallpaper::image(&file, cx.entity_id(), cx)).flatten();
132+ let thumbnail = set.then(|| {
133+ div()
134+ .w(px(56.))
135+ .h(px(34.))
136+ .flex_none()
137+ .rounded(px(8.))
138+ .overflow_hidden()
139+ .well(p)
140+ .when_some(preview, |this, image| {
141+ this.child(img(ImageSource::Render(image)).size_full().object_fit(ObjectFit::Cover))
142+ })
143+ });
144+ let status: SharedString = match (set, missing) {
145+ (false, _) => "".into(),
146+ (true, true) => format!("{name} can't be found.").into(),
147+ (true, false) => name.into(),
148+ };
149+ let row = SettingRow::new("Start wallpaper")
150+ .id("start-wallpaper")
151+ .description("Show an image behind the new-thread screen and its composer. Conversations keep the plain sheet.")
152+ .status(status)
153+ .reset(reset(view, cx, |s| (s.start_wallpaper.clone(), s.start_wallpaper_dim), |s, (path, dim)| {
154+ s.start_wallpaper = path;
155+ s.start_wallpaper_dim = dim;
156+ }))
157+ .control(h_flex().gap(px(8.)).children(thumbnail).child(choose).when(set, |this| this.child(remove)));
158+ if !set {
159+ return row;
160+ }
161+ row.below(
162+ h_flex()
163+ .gap(px(12.))
164+ .child(div().text_size(px(12.5)).text_color(p.ink3).child("Dim"))
165+ .child(stepper(
166+ "start-wallpaper-dim",
167+ u64::from(saved.start_wallpaper_dim.min(100)),
168+ 10,
169+ (0, 90),
170+ "%",
171+ view,
172+ p,
173+ |settings, dim| settings.start_wallpaper_dim = dim as u32,
174+ )),
175+ )
176+ }
177+
178+ /// Asks for an image to use as the start wallpaper.
179+ fn pick_wallpaper(&mut self, window: &mut Window, cx: &mut Context<Self>) {
180+ let paths = cx.prompt_for_paths(PathPromptOptions {
181+ files: true,
182+ directories: false,
183+ multiple: false,
184+ prompt: Some("Use as wallpaper".into()),
185+ });
186+ cx.spawn_in(window, async move |this, cx| {
187+ let Ok(Ok(Some(paths))) = paths.await else { return };
188+ let Some(path) = paths.into_iter().next() else { return };
189+ let path = path.display().to_string();
190+ let _ = this.update_in(cx, |this, window, cx| {
191+ this.apply(move |settings| settings.start_wallpaper = path.clone(), window, cx);
192+ });
193+ })
194+ .detach();
195+ }
98196 }
99197
100198 /// A label over a group on the page, like T3 Code's `h3`s.
+1−0src/ui/settings/search.rsView File
@@ -54,6 +54,7 @@ pub const ITEMS: &[Item] = &[
5454 item("diff-color-scheme", "Diff colors", Page::Appearance, "red green blue orange additions deletions changes counts palette colorblind"),
5555 item("chat-width", "Chat width", Page::Appearance, "wide full width column layout messages composer monitor"),
5656 item("composer-context", "Composer context", Page::Appearance, "branch worktree controls strip below composer"),
57+ item("start-wallpaper", "Start wallpaper", Page::Appearance, "wallpaper background image picture photo new thread start screen composer"),
5758 item("thread-cards", "Thread cards", Page::Appearance, "sidebar compact comfortable density"),
5859 item("project-grouping", "Project grouping", Page::General, "combine matching repositories environments sidebar remote devices"),
5960 item("project-order", "Project order", Page::General, "sort projects sidebar name created recent"),
+112−0src/ui/wallpaper.rsaddedView File
@@ -0,0 +1,112 @@
1+ //! The start wallpaper: the image Settings → Appearance can put behind the
2+ //! new-thread screen. It's decoded once on a background thread, scaled down
3+ //! to what a screen can show, and kept until the file changes, so drawing it
4+ //! costs nothing after the first frame.
5+
6+ use std::{
7+ collections::HashMap,
8+ path::{Path, PathBuf},
9+ sync::Arc,
10+ time::SystemTime,
11+ };
12+
13+ use gpui_kit::*;
14+
15+ /// Wider or taller images are scaled down to this before they're kept.
16+ const MAX_SIDE: u32 = 2560;
17+
18+ /// A wallpaper by path, as it was when last read.
19+ enum Entry {
20+ Loading(Option<SystemTime>),
21+ Ready(Option<SystemTime>, Arc<RenderImage>),
22+ Failed(Option<SystemTime>),
23+ }
24+
25+ impl Entry {
26+ fn modified(&self) -> Option<SystemTime> {
27+ match self {
28+ Entry::Loading(modified) | Entry::Ready(modified, _) | Entry::Failed(modified) => *modified,
29+ }
30+ }
31+ }
32+
33+ #[derive(Default)]
34+ struct Wallpapers {
35+ entries: HashMap<PathBuf, Entry>,
36+ }
37+
38+ impl Global for Wallpapers {}
39+
40+ /// The wallpaper at `path`, once it's decoded. The first call starts
41+ /// decoding it and `view` repaints once it's ready; a file that changed
42+ /// since is read again.
43+ pub fn image(path: &Path, view: EntityId, cx: &mut App) -> Option<Arc<RenderImage>> {
44+ let modified = std::fs::metadata(path).and_then(|meta| meta.modified()).ok();
45+ let cache = cx.default_global::<Wallpapers>();
46+ match cache.entries.get(path) {
47+ Some(entry) if entry.modified() == modified => {
48+ return match entry {
49+ Entry::Ready(_, image) => Some(image.clone()),
50+ _ => None,
51+ };
52+ }
53+ _ => {}
54+ }
55+ cache.entries.insert(path.to_path_buf(), Entry::Loading(modified));
56+ let file = path.to_path_buf();
57+ let decode = cx.background_executor().spawn(async move { decode(&file) });
58+ let path = path.to_path_buf();
59+ cx.spawn(async move |cx| {
60+ let result = decode.await;
61+ cx.update(|cx| {
62+ let entry = match result {
63+ Ok(image) => Entry::Ready(modified, Arc::new(image)),
64+ Err(err) => {
65+ eprintln!("vectrum-code: couldn't load wallpaper {}: {err}", path.display());
66+ Entry::Failed(modified)
67+ }
68+ };
69+ cx.default_global::<Wallpapers>().entries.insert(path, entry);
70+ cx.notify(view);
71+ cx.refresh_windows();
72+ });
73+ })
74+ .detach();
75+ None
76+ }
77+
78+ /// Reads an image file into the BGRA frame GPUI draws, no larger than
79+ /// [`MAX_SIDE`] on either side.
80+ fn decode(path: &Path) -> anyhow::Result<RenderImage> {
81+ let decoded = ::image::ImageReader::open(path)?.with_guessed_format()?.decode()?;
82+ let decoded = if decoded.width() > MAX_SIDE || decoded.height() > MAX_SIDE {
83+ decoded.resize(MAX_SIDE, MAX_SIDE, ::image::imageops::FilterType::Triangle)
84+ } else {
85+ decoded
86+ };
87+ let mut pixels = decoded.into_rgba8();
88+ for pixel in pixels.chunks_exact_mut(4) {
89+ pixel.swap(0, 2);
90+ }
91+ Ok(RenderImage::new(vec![::image::Frame::new(pixels)]))
92+ }
93+
94+ #[cfg(test)]
95+ mod tests {
96+ use super::{MAX_SIDE, decode};
97+
98+ #[::core::prelude::v1::test]
99+ fn decodes_and_scales_down() {
100+ let dir = std::env::temp_dir().join(format!("vectrum-wallpaper-{}", std::process::id()));
101+ std::fs::create_dir_all(&dir).unwrap();
102+ let file = dir.join("wide.png");
103+ ::image::RgbaImage::from_pixel(4000, 1000, ::image::Rgba([255, 0, 0, 255])).save(&file).unwrap();
104+ let image = decode(&file).unwrap();
105+ let size = image.size(0);
106+ assert_eq!((size.width.0, size.height.0), (MAX_SIDE as i32, MAX_SIDE as i32 / 4));
107+ // Red comes out as BGRA.
108+ assert_eq!(&image.as_bytes(0).unwrap()[..4], &[0, 0, 255, 255]);
109+ assert!(decode(&dir.join("missing.png")).is_err());
110+ let _ = std::fs::remove_file(&file);
111+ }
112+ }
+33−0src/ui/workspace.rsView File
@@ -2124,6 +2124,36 @@ impl Workspace {
21242124 )
21252125 }
21262126
2127+ /// The wallpaper Settings → Appearance can put behind the new-thread
2128+ /// screen, under a veil of the sheet's color so the heading and the
2129+ /// composer stay legible. Conversations, Usage and settings keep the
2130+ /// plain sheet. Nothing shows until the image is decoded.
2131+ fn render_start_wallpaper(&self, has_chat: bool, cx: &mut Context<Self>) -> Option<AnyElement> {
2132+ let (path, dim) = settings::get(|settings| (settings.start_wallpaper.clone(), settings.start_wallpaper_dim));
2133+ let path = path.trim();
2134+ if path.is_empty() || !has_chat || self.usage_open || self.settings_open {
2135+ return None;
2136+ }
2137+ if !self.active.as_ref().is_some_and(|session| session.read(cx).is_blank()) {
2138+ return None;
2139+ }
2140+ let image = crate::ui::wallpaper::image(&settings::expand_home(path), cx.entity_id(), cx)?;
2141+ let p = metal::palette(cx);
2142+ let veil = (dim.min(100) as f32) / 100.;
2143+ // Content masks are square, so the image and the veil round their own corners.
2144+ let radius = px(SHEET_RADIUS);
2145+ Some(
2146+ div()
2147+ .absolute()
2148+ .inset_0()
2149+ .child(img(ImageSource::Render(image)).size_full().rounded(radius).object_fit(ObjectFit::Cover))
2150+ .when(veil > 0., |this| {
2151+ this.child(div().absolute().inset_0().rounded(radius).bg(p.s.opacity(veil)))
2152+ })
2153+ .into_any_element(),
2154+ )
2155+ }
2156+
21272157 fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement {
21282158 let p = metal::palette(cx);
21292159 v_flex()
@@ -2217,6 +2247,7 @@ impl Render for Workspace {
22172247 let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() { px(360.) } else { px(0.) };
22182248 let mut drawer = self.render_terminal_drawer(false, window, cx);
22192249 let drawer_full_width = panel_visible;
2250+ let wallpaper = self.render_start_wallpaper(chat.is_some(), cx);
22202251
22212252 h_flex()
22222253 .key_context("Workspace")
@@ -2342,9 +2373,11 @@ impl Render for Workspace {
23422373 .when(!self.sidebar_open, |this| this.pl(px(SHEET_INSET)))
23432374 .child(
23442375 v_flex()
2376+ .relative()
23452377 .size_full()
23462378 .rounded(px(SHEET_RADIUS))
23472379 .plate(p)
2380+ .children(wallpaper)
23482381 .child(self.render_top_bar(panel_visible, cx))
23492382 .when(self.usage_open, |this| this.child(self.render_usage_sheet(cx)))
23502383 .when(!self.usage_open, |this| this.child(