diff --git a/apps/desktop-gpui/src/editor_crop.rs b/apps/desktop-gpui/src/editor_crop.rs index e9bfd7b828..d9c43ebadc 100644 --- a/apps/desktop-gpui/src/editor_crop.rs +++ b/apps/desktop-gpui/src/editor_crop.rs @@ -1081,6 +1081,15 @@ pub struct CropState { } impl CropState { + /// The display frame is a full-resolution decode and the window's atlas + /// keeps every painted image until it is told otherwise, so a closed crop + /// would otherwise stay resident until the editor window closes. + pub fn release_frame(&mut self, window: &mut Window) { + if let Some(frame) = self.frame.take() { + let _ = window.drop_image(frame); + } + } + /// `computeInitialBounds` (`:483-517`) at open, with the container the /// viewport gives. pub fn new(target: (u32, u32), box_size: (f32, f32), initial: CropBounds) -> Self { @@ -1606,9 +1615,10 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { - if self.crop.take().is_none() { + let Some(mut state) = self.crop.take() else { return; - } + }; + state.release_frame(window); tracing::info!(reason, "crop cancelled"); self.publish_project(); cx.notify(); @@ -1630,6 +1640,7 @@ impl EditorWindow { cx.notify(); return; } + state.release_frame(window); let bounds = state.real(); let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), @@ -2154,12 +2165,16 @@ impl EditorWindow { Some(task) => task.await.ok().flatten(), None => None, }; - this.update(cx, |this: &mut Self, cx| { + this.update_in(cx, |this: &mut Self, window, cx| { let Some(state) = this.crop.as_mut() else { return; }; match image { - Some(image) => state.frame = Some(image), + Some(image) => { + if let Some(previous) = state.frame.replace(image) { + let _ = window.drop_image(previous); + } + } None => { tracing::warn!("crop: display frame decode failed"); state.frame_failed = true; diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index 73afc538ea..42691523df 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -596,6 +596,36 @@ fn noise_texture(width: u32, height: u32, base_frequency: f32) -> Arc)>, + retired: Vec>, +} + +impl NoiseCache { + fn image(&mut self, scale: f32) -> Arc { + let key = scale.round() as i32; + if let Some((cached, image)) = &self.current + && *cached == key + { + return image.clone(); + } + let image = noise_texture( + GRADIENT_PREVIEW_WIDTH as u32, + GRADIENT_PREVIEW_HEIGHT as u32, + noise_base_frequency(scale), + ); + if let Some((_, previous)) = self.current.replace((key, image.clone())) { + self.retired.push(previous); + } + image + } + + fn take_retired(&mut self) -> Vec> { + std::mem::take(&mut self.retired) + } +} + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- @@ -787,7 +817,7 @@ pub struct SidebarState { /// The gradient preview's grain, cached by the grain-scale step it was /// generated for -- regenerating 42k pixels of noise on every frame of a /// slider drag would be the one expensive thing in this panel. - noise: std::cell::RefCell)>>, + noise: std::cell::RefCell, /// The open `KSelect`'s menu, if any -- one slot for every select in the /// sidebar, the settings window's `Menu.popup()` stand-in. @@ -876,7 +906,7 @@ impl SidebarState { cursor_previews: std::cell::RefCell::new(BTreeMap::new()), cursor_scale: 2., panel_sections: std::cell::RefCell::new(HashMap::new()), - noise: std::cell::RefCell::new(None), + noise: std::cell::RefCell::default(), menu: None, caption_model: "best", caption_language: "auto", @@ -905,19 +935,16 @@ impl SidebarState { /// The grain texture for a grain-scale value, generated once per step. fn noise_image(&self, scale: f32) -> Arc { - let key = scale.round() as i32; - if let Some((cached, image)) = self.noise.borrow().as_ref() - && *cached == key - { - return image.clone(); + self.noise.borrow_mut().image(scale) + } + + /// Evicts the grain textures earlier steps of a slider drag left in the + /// window's atlas. The panel is built without a `&mut Window`, so they + /// are queued there and released from `render` on the next frame. + pub(crate) fn release_retired_images(&self, window: &mut Window) { + for image in self.noise.borrow_mut().take_retired() { + let _ = window.drop_image(image); } - let image = noise_texture( - GRADIENT_PREVIEW_WIDTH as u32, - GRADIENT_PREVIEW_HEIGHT as u32, - noise_base_frequency(scale), - ); - *self.noise.borrow_mut() = Some((key, image.clone())); - image } /// The stored track rect, without creating one -- the pointer maths reads @@ -2137,7 +2164,9 @@ impl EditorWindow { /// selected path. fn ensure_preview(&mut self, window: &mut Window, cx: &mut Context) { let Some(path) = self.preview_path() else { - self.sidebar.preview = None; + if let Some((_, previous)) = self.sidebar.preview.take() { + let _ = window.drop_image(previous); + } return; }; if self @@ -2157,8 +2186,10 @@ impl EditorWindow { .await }; let Some(image) = decoded else { return }; - this.update(cx, |this, cx| { - this.sidebar.preview = Some((target, image)); + this.update_in(cx, |this, window, cx| { + if let Some((_, previous)) = this.sidebar.preview.replace((target, image)) { + let _ = window.drop_image(previous); + } cx.notify(); }) .ok(); @@ -4070,6 +4101,24 @@ impl EditorWindow { mod tests { use super::*; + #[test] + fn a_new_grain_step_retires_the_old_texture_for_atlas_eviction() { + let mut noise = NoiseCache::default(); + let first = noise.image(20.); + assert!(Arc::ptr_eq(&noise.image(20.2), &first)); + assert!(noise.take_retired().is_empty()); + + let second = noise.image(35.); + let third = noise.image(50.); + assert!(!Arc::ptr_eq(&second, &first)); + let retired = noise.take_retired(); + assert_eq!(retired.len(), 2); + assert!(Arc::ptr_eq(&retired[0], &first)); + assert!(Arc::ptr_eq(&retired[1], &second)); + assert!(noise.take_retired().is_empty()); + assert!(Arc::ptr_eq(&noise.image(50.), &third)); + } + #[test] fn the_catalogue_matches_the_source() { assert_eq!(WALLPAPER_NAMES.len(), 53); diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index e295f24fab..a2e9749c5d 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -11241,6 +11241,7 @@ impl Render for EditorWindow { }); self.prepare_frame_fields(window, cx); self.sync_crop_container(window); + self.sidebar.release_retired_images(window); let theme = self.theme; // The timeline's own bounds are what `secsPerPixel` divides by, and // this window is resizable, so read them off the viewport rather than diff --git a/apps/desktop-gpui/src/screenshot_crop.rs b/apps/desktop-gpui/src/screenshot_crop.rs index 4e45a960a5..a882b47162 100644 --- a/apps/desktop-gpui/src/screenshot_crop.rs +++ b/apps/desktop-gpui/src/screenshot_crop.rs @@ -86,6 +86,18 @@ pub struct ScreenshotCropDialog { _field_subscriptions: Vec, } +impl ScreenshotCropDialog { + /// The original is decoded at full resolution (about 59 MB at 5K) and the + /// window's atlas keeps every painted image until it is told otherwise, so + /// each closed dialog would otherwise stay resident until the editor + /// window closes. + fn release_image(&mut self, window: &mut Window) { + if let Some(image) = self.image.take() { + let _ = window.drop_image(image); + } + } +} + impl ScreenshotEditorWindow { /// The header's crop button (`Header.tsx:84-93`): open the dialog seeded /// with the crop in force, or the whole image. Gated on `image_size` the @@ -163,10 +175,16 @@ impl ScreenshotEditorWindow { /// Close without writing -- Escape or the backdrop. The project was never /// touched, so there is nothing to roll back. - pub(crate) fn cancel_crop_dialog(&mut self, reason: &'static str, cx: &mut Context) { - if self.crop.take().is_none() { + pub(crate) fn cancel_crop_dialog( + &mut self, + reason: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(mut dialog) = self.crop.take() else { return; - } + }; + dialog.release_image(window); tracing::info!(reason, "screenshot crop cancelled"); cx.notify(); } @@ -174,10 +192,11 @@ impl ScreenshotEditorWindow { /// The footer's Save (`Editor.tsx:463-482`): one `setProject` write of /// `background.crop`, so one history entry, then close. fn save_crop_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(dialog) = self.crop.take() else { + let Some(mut dialog) = self.crop.take() else { return; }; let bounds = dialog.state.real(); + dialog.release_image(window); let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), size: XY::new(bounds.width.max(0.) as u32, bounds.height.max(0.) as u32), @@ -470,7 +489,7 @@ impl ScreenshotEditorWindow { return true; } if key == "escape" { - self.cancel_crop_dialog("escape", cx); + self.cancel_crop_dialog("escape", window, cx); return true; } let Some(nudge) = engine::is_nudge_key(key) else { @@ -668,8 +687,8 @@ impl ScreenshotEditorWindow { .absolute() .inset_0() .bg(gpui::hsla(0., 0., 0., 0.8)) - .on_click(cx.listener(|this, _, _window, cx| { - this.cancel_crop_dialog("backdrop", cx) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_crop_dialog("backdrop", window, cx) })), ) .child( diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index 24707fd268..e56749104e 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -1107,7 +1107,7 @@ impl ScreenshotEditorWindow { if self.bg_tab == BgTab::Wallpaper { self.ensure_wallpapers(cx); } - self.ensure_background_image(cx); + self.ensure_background_image(window, cx); if let Ok(dest) = std::env::var("CAP_GPUI_AUTO_SCREENSHOT_EXPORT") && !dest.trim().is_empty() { @@ -1271,7 +1271,7 @@ impl ScreenshotEditorWindow { { self.selected_annotation = None; } - self.ensure_background_image(cx); + self.ensure_background_image(window, cx); self.publish(); self.schedule_save(window, cx); self.refresh_mask_overlays(window, cx); @@ -1505,9 +1505,11 @@ impl ScreenshotEditorWindow { /// The Image tab's `h-48` preview. Decoded off-thread and cached by path, /// so switching tabs does not re-read the file. - fn ensure_background_image(&mut self, cx: &mut Context) { + fn ensure_background_image(&mut self, window: &mut Window, cx: &mut Context) { let BackgroundSource::Image { path: Some(path) } = &self.project.background.source else { - self.bg_image = None; + if let Some((_, previous)) = self.bg_image.take() { + let _ = window.drop_image(previous); + } return; }; if self @@ -1518,7 +1520,7 @@ impl ScreenshotEditorWindow { return; } let path = path.clone(); - self.bg_image_task = Some(cx.spawn(async move |this, cx| { + self.bg_image_task = Some(cx.spawn_in(window, async move |this, cx| { let decoded = cx .background_executor() .spawn({ @@ -1526,8 +1528,11 @@ impl ScreenshotEditorWindow { async move { decode_preview_image(Path::new(&path)) } }) .await; - this.update(cx, |this, cx| { - this.bg_image = decoded.map(|image| (path, image)); + this.update_in(cx, |this, window, cx| { + let next = decoded.map(|image| (path, image)); + if let Some((_, previous)) = std::mem::replace(&mut this.bg_image, next) { + let _ = window.drop_image(previous); + } cx.notify(); }) .ok(); @@ -1550,7 +1555,7 @@ impl ScreenshotEditorWindow { window, cx, ); - this.ensure_background_image(cx); + this.ensure_background_image(window, cx); }) .ok(); }) @@ -3284,7 +3289,7 @@ impl ScreenshotEditorWindow { window, cx, ); - self.ensure_background_image(cx); + self.ensure_background_image(window, cx); } fn render_wallpaper_tab(&self, cx: &mut Context) -> AnyElement { @@ -3484,7 +3489,7 @@ impl ScreenshotEditorWindow { window, cx, ); - this.ensure_background_image(cx); + this.ensure_background_image(window, cx); })), ) .into_any_element()