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.
Showing 6 changed files with 259 additions and 2 deletions.
| @@ -485,6 +485,12 @@ pub struct Settings { | ||
| 485 | 485 | // ---- appearance ---- |
| 486 | 486 | pub diff_colors: DiffColors, |
| 487 | 487 | 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, | |
| 488 | 494 | |
| 489 | 495 | // ---- sidebar ---- |
| 490 | 496 | /// T3 Code's legacy sidebar: threads in per-project trees instead of the |
| @@ -631,6 +637,8 @@ impl Default for Settings { | ||
| 631 | 637 | diff_side_by_side: false, |
| 632 | 638 | diff_colors: DiffColors::RedGreen, |
| 633 | 639 | chat_width: ChatWidth::Comfortable, |
| 640 | + start_wallpaper: String::new(), | |
| 641 | + start_wallpaper_dim: 30, | |
| 634 | 642 | legacy_sidebar: false, |
| 635 | 643 | thread_order: ThreadOrder::LastMessage, |
| 636 | 644 | visible_threads: 6, |
| @@ -710,6 +718,10 @@ impl Settings { | ||
| 710 | 718 | ("Theme", theme), |
| 711 | 719 | ("Diff colors", self.diff_colors != d.diff_colors), |
| 712 | 720 | ("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 | + ), | |
| 713 | 725 | ("Time format", self.time_format != d.time_format), |
| 714 | 726 | ("Thread notifications", (self.notify, self.notify_sound) != (d.notify, d.notify_sound)), |
| 715 | 727 | ("In-app notifications", self.in_app_notifications != d.in_app_notifications), |
| @@ -13,4 +13,5 @@ pub mod settings; | ||
| 13 | 13 | pub mod terminal; |
| 14 | 14 | pub mod themes; |
| 15 | 15 | pub mod usage; |
| 16 | + pub mod wallpaper; | |
| 16 | 17 | pub mod workspace; |
| @@ -7,9 +7,10 @@ use gpui_kit::prelude::FluentBuilder as _; | ||
| 7 | 7 | use gpui_kit::*; |
| 8 | 8 | |
| 9 | 9 | 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}; | |
| 11 | 11 | 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}; | |
| 13 | 14 | use crate::ui::themes::{self, FAMILIES, Theme}; |
| 14 | 15 | |
| 15 | 16 | const CHAT_WIDTHS: &[(ChatWidth, &str)] = |
| @@ -82,6 +83,7 @@ impl SettingsView { | ||
| 82 | 83 | settings.chat_width = width |
| 83 | 84 | })), |
| 84 | 85 | ) |
| 86 | + .row(self.wallpaper_row(&saved, &view, cx)) | |
| 85 | 87 | .row( |
| 86 | 88 | SettingRow::new("Thread cards") |
| 87 | 89 | .id("thread-cards") |
| @@ -95,6 +97,102 @@ impl SettingsView { | ||
| 95 | 97 | |
| 96 | 98 | vec![colors, interface] |
| 97 | 99 | } |
| 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 | + } | |
| 98 | 196 | } |
| 99 | 197 | |
| 100 | 198 | /// A label over a group on the page, like T3 Code's `h3`s. |
| @@ -54,6 +54,7 @@ pub const ITEMS: &[Item] = &[ | ||
| 54 | 54 | item("diff-color-scheme", "Diff colors", Page::Appearance, "red green blue orange additions deletions changes counts palette colorblind"), |
| 55 | 55 | item("chat-width", "Chat width", Page::Appearance, "wide full width column layout messages composer monitor"), |
| 56 | 56 | 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"), | |
| 57 | 58 | item("thread-cards", "Thread cards", Page::Appearance, "sidebar compact comfortable density"), |
| 58 | 59 | item("project-grouping", "Project grouping", Page::General, "combine matching repositories environments sidebar remote devices"), |
| 59 | 60 | item("project-order", "Project order", Page::General, "sort projects sidebar name created recent"), |
| @@ -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 | + } | |
| @@ -2124,6 +2124,36 @@ impl Workspace { | ||
| 2124 | 2124 | ) |
| 2125 | 2125 | } |
| 2126 | 2126 | |
| 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 | + | |
| 2127 | 2157 | fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement { |
| 2128 | 2158 | let p = metal::palette(cx); |
| 2129 | 2159 | v_flex() |
| @@ -2217,6 +2247,7 @@ impl Render for Workspace { | ||
| 2217 | 2247 | let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() { px(360.) } else { px(0.) }; |
| 2218 | 2248 | let mut drawer = self.render_terminal_drawer(false, window, cx); |
| 2219 | 2249 | let drawer_full_width = panel_visible; |
| 2250 | + let wallpaper = self.render_start_wallpaper(chat.is_some(), cx); | |
| 2220 | 2251 | |
| 2221 | 2252 | h_flex() |
| 2222 | 2253 | .key_context("Workspace") |
| @@ -2342,9 +2373,11 @@ impl Render for Workspace { | ||
| 2342 | 2373 | .when(!self.sidebar_open, |this| this.pl(px(SHEET_INSET))) |
| 2343 | 2374 | .child( |
| 2344 | 2375 | v_flex() |
| 2376 | + .relative() | |
| 2345 | 2377 | .size_full() |
| 2346 | 2378 | .rounded(px(SHEET_RADIUS)) |
| 2347 | 2379 | .plate(p) |
| 2380 | + .children(wallpaper) | |
| 2348 | 2381 | .child(self.render_top_bar(panel_visible, cx)) |
| 2349 | 2382 | .when(self.usage_open, |this| this.child(self.render_usage_sheet(cx))) |
| 2350 | 2383 | .when(!self.usage_open, |this| this.child( |