From abf2b9d7d315566c53130ed9f6d8c1476e70dc80 Mon Sep 17 00:00:00 2001 From: Sunli Date: Wed, 23 Apr 2025 04:30:21 +0800 Subject: [PATCH] gpui: Add ImageCache (#27774) Closes #27414 `ImageCache` is independent of the original image loader and can actively release its cached images to solve the problem of images loaded from the network or files not being released. It has two constructors: - `ImageCache::new`: Manually manage the cache. - `ImageCache::max_items`: Remove the least recently used items when the cache reaches the specified number. When creating an `img` element, you can specify the cache object with `Img::cache`, and the image cache will be managed by `ImageCache`. In the example `crates\gpui\examples\image-gallery.rs`, the `ImageCache::clear` method is actively called when switching a set of images, and the memory will no longer continuously increase. Release Notes: - N/A --------- Co-authored-by: Ben Kunkle --- crates/gpui/examples/image_gallery.rs | 134 +++++++++++ crates/gpui/src/app.rs | 18 +- crates/gpui/src/elements/image_cache.rs | 286 ++++++++++++++++++++++++ crates/gpui/src/elements/img.rs | 54 ++++- crates/gpui/src/elements/mod.rs | 2 + crates/gpui/src/platform.rs | 2 +- crates/gpui/src/window.rs | 15 +- 7 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 crates/gpui/examples/image_gallery.rs create mode 100644 crates/gpui/src/elements/image_cache.rs diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs new file mode 100644 index 0000000000000000000000000000000000000000..dd96af0ddfb4e6ae76d16610bc08b46fe888edb9 --- /dev/null +++ b/crates/gpui/examples/image_gallery.rs @@ -0,0 +1,134 @@ +use gpui::{ + App, AppContext, Application, Bounds, ClickEvent, Context, Entity, HashMapImageCache, + KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions, + actions, div, image_cache, img, prelude::*, px, rgb, size, +}; +use reqwest_client::ReqwestClient; +use std::sync::Arc; + +struct ImageGallery { + image_key: String, + items_count: usize, + total_count: usize, + image_cache: Entity, +} + +impl ImageGallery { + fn on_next_image(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.image_cache + .update(cx, |image_cache, cx| image_cache.clear(window, cx)); + + let t = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + + self.image_key = format!("{}", t); + self.total_count += self.items_count; + cx.notify(); + } +} + +impl Render for ImageGallery { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let image_url: SharedString = + format!("https://picsum.photos/400/200?t={}", self.image_key).into(); + + image_cache(&self.image_cache).child( + div() + .id("main") + .font_family(".SystemUIFont") + .bg(rgb(0xE9E9E9)) + .overflow_y_scroll() + .p_4() + .size_full() + .flex() + .flex_col() + .items_center() + .gap_2() + .child( + div() + .w_full() + .flex() + .flex_row() + .justify_between() + .child(format!( + "Example to show images and test memory usage (Rendered: {} images).", + self.total_count + )) + .child( + div() + .id("btn") + .py_1() + .px_4() + .bg(gpui::black()) + .hover(|this| this.opacity(0.8)) + .text_color(gpui::white()) + .text_center() + .w_40() + .child("Next Photos") + .on_click(cx.listener(Self::on_next_image)), + ), + ) + .child( + div() + .id("image-gallery") + .flex() + .flex_row() + .flex_wrap() + .gap_x_4() + .gap_y_2() + .justify_around() + .children( + (0..self.items_count) + .map(|ix| img(format!("{}-{}", image_url, ix)).size_20()), + ), + ), + ) + } +} + +actions!(image, [Quit]); + +fn main() { + env_logger::init(); + + Application::new().run(move |cx: &mut App| { + let http_client = ReqwestClient::user_agent("gpui example").unwrap(); + cx.set_http_client(Arc::new(http_client)); + + cx.activate(true); + cx.on_action(|_: &Quit, cx| cx.quit()); + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + cx.set_menus(vec![Menu { + name: "Image Gallery".into(), + items: vec![MenuItem::action("Quit", Quit)], + }]); + + let window_options = WindowOptions { + titlebar: Some(TitlebarOptions { + title: Some(SharedString::from("Image Gallery")), + appears_transparent: false, + ..Default::default() + }), + + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(1100.), px(860.)), + cx, + ))), + + ..Default::default() + }; + + cx.open_window(window_options, |_, cx| { + cx.new(|ctx| ImageGallery { + image_key: "".into(), + items_count: 99, + total_count: 0, + image_cache: HashMapImageCache::new(ctx), + }) + }) + .unwrap(); + }); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 6938c16c69170869807ab1c2e978be2ab1d57bcd..ee3ddf53d5273569f3f589c2ace796a71529e51b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -35,7 +35,7 @@ use crate::{ AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, - PlatformKeyboardLayout, Point, PromptBuilder, PromptHandle, PromptLevel, Render, + PlatformKeyboardLayout, Point, PromptBuilder, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, current_platform, hash, init_app_menus, @@ -1616,6 +1616,22 @@ impl App { pub fn can_select_mixed_files_and_dirs(&self) -> bool { self.platform.can_select_mixed_files_and_dirs() } + + /// Removes an image from the sprite atlas on all windows. + /// + /// If the current window is being updated, it will be removed from `App.windows``, you can use `current_window` to specify the current window. + /// This is a no-op if the image is not in the sprite atlas. + pub fn drop_image(&mut self, image: Arc, current_window: Option<&mut Window>) { + // remove the texture from all other windows + for window in self.windows.values_mut().flatten() { + _ = window.drop_image(image.clone()); + } + + // remove the texture from the current window + if let Some(window) = current_window { + _ = window.drop_image(image); + } + } } impl AppContext for App { diff --git a/crates/gpui/src/elements/image_cache.rs b/crates/gpui/src/elements/image_cache.rs new file mode 100644 index 0000000000000000000000000000000000000000..33e2200f70e33c38e1f934d1330103a65b10266f --- /dev/null +++ b/crates/gpui/src/elements/image_cache.rs @@ -0,0 +1,286 @@ +use crate::{ + AnyElement, AnyEntity, App, AppContext, Asset, AssetLogger, Bounds, Element, ElementId, Entity, + GlobalElementId, ImageAssetLoader, ImageCacheError, IntoElement, LayoutId, ParentElement, + Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Task, Window, hash, +}; + +use futures::{FutureExt, future::Shared}; +use refineable::Refineable; +use smallvec::SmallVec; +use std::{collections::HashMap, fmt, sync::Arc}; + +/// An image cache element, all its child img elements will use the cache specified by this element. +pub fn image_cache(image_cache: &Entity) -> ImageCacheElement { + ImageCacheElement { + image_cache: image_cache.clone().into(), + style: StyleRefinement::default(), + children: SmallVec::default(), + } +} + +/// A dynamically typed image cache, which can be used to store any image cache +#[derive(Clone)] +pub struct AnyImageCache { + image_cache: AnyEntity, + load_fn: fn( + image_cache: &AnyEntity, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>>, +} + +impl From> for AnyImageCache { + fn from(image_cache: Entity) -> Self { + Self { + image_cache: image_cache.into_any(), + load_fn: any_image_cache::load::, + } + } +} + +impl AnyImageCache { + /// Load an image given a resource + /// returns the result of loading the image if it has finished loading, or None if it is still loading + pub fn load( + &self, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>> { + (self.load_fn)(&self.image_cache, resource, window, cx) + } +} + +mod any_image_cache { + use super::*; + + pub(crate) fn load( + image_cache: &AnyEntity, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>> { + let image_cache = image_cache.clone().downcast::().unwrap(); + return image_cache.update(cx, |image_cache, cx| image_cache.load(resource, window, cx)); + } +} + +/// An image cache element. +pub struct ImageCacheElement { + image_cache: AnyImageCache, + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl ParentElement for ImageCacheElement { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Styled for ImageCacheElement { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl IntoElement for ImageCacheElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ImageCacheElement { + type RequestLayoutState = SmallVec<[LayoutId; 4]>; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + window.with_image_cache(self.image_cache.clone(), |window| { + let child_layout_ids = self + .children + .iter_mut() + .map(|child| child.request_layout(window, cx)) + .collect::>(); + let mut style = Style::default(); + style.refine(&self.style); + let layout_id = window.request_layout(style, child_layout_ids.iter().copied(), cx); + (layout_id, child_layout_ids) + }) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + for child in &mut self.children { + child.prepaint(window, cx); + } + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + window.with_image_cache(self.image_cache.clone(), |window| { + for child in &mut self.children { + child.paint(window, cx); + } + }) + } +} + +type ImageLoadingTask = Shared, ImageCacheError>>>; + +enum CacheItem { + Loading(ImageLoadingTask), + Loaded(Result, ImageCacheError>), +} + +impl CacheItem { + fn get(&mut self) -> Option, ImageCacheError>> { + match self { + CacheItem::Loading(task) => { + let res = task.now_or_never()?; + *self = CacheItem::Loaded(res.clone()); + Some(res) + } + CacheItem::Loaded(res) => Some(res.clone()), + } + } +} + +/// An object that can handle the caching and unloading of images. +/// Implementations of this trait should ensure that images are removed from all windows when they are no longer needed. +pub trait ImageCache: 'static { + /// Load an image given a resource + /// returns the result of loading the image if it has finished loading, or None if it is still loading + fn load( + &mut self, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>>; +} + +/// An implementation of ImageCache, that uses an LRU caching strategy to unload images when the cache is full +pub struct HashMapImageCache(HashMap); + +impl fmt::Debug for HashMapImageCache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HashMapImageCache") + .field("num_images", &self.0.len()) + .finish() + } +} + +impl HashMapImageCache { + /// Create a new image cache. + #[inline] + pub fn new(cx: &mut App) -> Entity { + let e = cx.new(|_cx| HashMapImageCache(HashMap::new())); + cx.observe_release(&e, |image_cache, cx| { + for (_, mut item) in std::mem::replace(&mut image_cache.0, HashMap::new()) { + if let Some(Ok(image)) = item.get() { + cx.drop_image(image, None); + } + } + }) + .detach(); + e + } + + /// Load an image from the given source. + /// + /// Returns `None` if the image is loading. + pub fn load( + &mut self, + source: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>> { + let hash = hash(source); + + if let Some(item) = self.0.get_mut(&hash) { + return item.get(); + } + + let fut = AssetLogger::::load(source.clone(), cx); + let task = cx.background_executor().spawn(fut).shared(); + self.0.insert(hash, CacheItem::Loading(task.clone())); + + let entity = window.current_view(); + window + .spawn(cx, { + async move |cx| { + _ = task.await; + cx.on_next_frame(move |_, cx| { + cx.notify(entity); + }); + } + }) + .detach(); + + None + } + + /// Clear the image cache. + pub fn clear(&mut self, window: &mut Window, cx: &mut App) { + for (_, mut item) in std::mem::replace(&mut self.0, HashMap::new()) { + if let Some(Ok(image)) = item.get() { + cx.drop_image(image, Some(window)); + } + } + } + + /// Remove the image from the cache by the given source. + pub fn remove(&mut self, source: &Resource, window: &mut Window, cx: &mut App) { + let hash = hash(source); + if let Some(mut item) = self.0.remove(&hash) { + if let Some(Ok(image)) = item.get() { + cx.drop_image(image, Some(window)); + } + } + } + + /// Returns the number of images in the cache. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl ImageCache for HashMapImageCache { + fn load( + &mut self, + resource: &Resource, + window: &mut Window, + cx: &mut App, + ) -> Option, ImageCacheError>> { + HashMapImageCache::load(self, resource, window, cx) + } +} diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 19309cd038842987f34472ec9407da965ec06f94..9592f0ae4d702e895513b30cd0eaf1603eeb3466 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -1,9 +1,9 @@ use crate::{ - AbsoluteLength, AnyElement, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, - ElementId, GlobalElementId, Hitbox, Image, InteractiveElement, Interactivity, IntoElement, - LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, SMOOTH_SVG_SCALE_FACTOR, - SharedString, SharedUri, StyleRefinement, Styled, SvgSize, Task, Window, px, - swap_rgba_pa_to_bgra, + AbsoluteLength, AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, + Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InteractiveElement, + Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, + SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement, Styled, SvgSize, Task, + Window, px, swap_rgba_pa_to_bgra, }; use anyhow::{Result, anyhow}; @@ -190,6 +190,7 @@ pub struct Img { interactivity: Interactivity, source: ImageSource, style: ImageStyle, + image_cache: Option, } /// Create a new image element. @@ -198,6 +199,7 @@ pub fn img(source: impl Into) -> Img { interactivity: Interactivity::default(), source: source.into(), style: ImageStyle::default(), + image_cache: None, } } @@ -210,6 +212,23 @@ impl Img { "hdr", "exr", "pbm", "pam", "ppm", "pgm", "ff", "farbfeld", "qoi", "svg", ] } + + /// Sets the image cache for the current node. + /// + /// If the `image_cache` is not explicitly provided, the function will determine the image cache by: + /// + /// 1. Checking if any ancestor node of the current node contains an `ImageCacheElement`, If such a node exists, the image cache specified by that ancestor will be used. + /// 2. If no ancestor node contains an `ImageCacheElement`, the global image cache will be used as a fallback. + /// + /// This mechanism provides a flexible way to manage image caching, allowing precise control when needed, + /// while ensuring a default behavior when no cache is explicitly specified. + #[inline] + pub fn image_cache(self, image_cache: &Entity) -> Self { + Self { + image_cache: Some(image_cache.clone().into()), + ..self + } + } } impl Deref for Stateful { @@ -276,7 +295,13 @@ impl Element for Img { |mut style, window, cx| { let mut replacement_id = None; - match self.source.use_data(window, cx) { + match self.source.use_data( + self.image_cache + .clone() + .or_else(|| window.image_cache_stack.last().cloned()), + window, + cx, + ) { Some(Ok(data)) => { if let Some(state) = &mut state { let frame_count = data.frame_count(); @@ -421,7 +446,13 @@ impl Element for Img { window, cx, |style, window, cx| { - if let Some(Ok(data)) = source.use_data(window, cx) { + if let Some(Ok(data)) = source.use_data( + self.image_cache + .clone() + .or_else(|| window.image_cache_stack.last().cloned()), + window, + cx, + ) { let new_bounds = self .style .object_fit @@ -474,11 +505,18 @@ impl StatefulInteractiveElement for Img {} impl ImageSource { pub(crate) fn use_data( &self, + cache: Option, window: &mut Window, cx: &mut App, ) -> Option, ImageCacheError>> { match self { - ImageSource::Resource(resource) => window.use_asset::(&resource, cx), + ImageSource::Resource(resource) => { + if let Some(cache) = cache { + cache.load(resource, window, cx) + } else { + window.use_asset::(resource, cx) + } + } ImageSource::Custom(loading_fn) => loading_fn(window, cx), ImageSource::Render(data) => Some(Ok(data.to_owned())), ImageSource::Image(data) => window.use_asset::>(data, cx), diff --git a/crates/gpui/src/elements/mod.rs b/crates/gpui/src/elements/mod.rs index 0a680e4399463fd62ba954f2cdcb4aa7f202d56b..b208d3027a72e6c1f55aca3090acc43eeb97ce43 100644 --- a/crates/gpui/src/elements/mod.rs +++ b/crates/gpui/src/elements/mod.rs @@ -4,6 +4,7 @@ mod canvas; mod common; mod deferred; mod div; +mod image_cache; mod img; mod list; mod surface; @@ -17,6 +18,7 @@ pub use canvas::*; pub use common::*; pub use deferred::*; pub use div::*; +pub use image_cache::*; pub use img::*; pub use list::*; pub use surface::*; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 270107f80c0a3724ebcf5a63718a73ccd78ce623..117c999675e8ff31cc3427561896e185460fd872 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1518,7 +1518,7 @@ impl Image { cx: &mut App, ) -> Option> { ImageSource::Image(self) - .use_data(window, cx) + .use_data(None, window, cx) .and_then(|result| result.ok()) } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 63c0a3a37121fb482f5c6f397737cb0c54026c97..3959012cb54d09b37ec38e969181f4c0d1e5e758 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1,5 +1,5 @@ use crate::{ - Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, AppContext, Arena, Asset, + Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset, AsyncWindowContext, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Context, Corners, CursorStyle, Decorations, DevicePixels, DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, FontId, @@ -617,6 +617,7 @@ pub struct Window { pub(crate) element_opacity: Option, pub(crate) content_mask_stack: Vec>, pub(crate) requested_autoscroll: Option>, + pub(crate) image_cache_stack: Vec, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, pub(crate) next_hitbox_id: HitboxId, @@ -933,6 +934,7 @@ impl Window { pending_input_observers: SubscriberSet::new(), prompt: None, client_inset: None, + image_cache_stack: Vec::new(), }) } @@ -2857,6 +2859,17 @@ impl Window { result } + /// Executes the provided function with the specified image cache. + pub(crate) fn with_image_cache(&mut self, image_cache: AnyImageCache, f: F) -> R + where + F: FnOnce(&mut Self) -> R, + { + self.image_cache_stack.push(image_cache); + let result = f(self); + self.image_cache_stack.pop(); + result + } + /// Sets an input handler, such as [`ElementInputHandler`][element_input_handler], which interfaces with the /// platform to receive textual input with proper integration with concerns such /// as IME interactions. This handler will be active for the upcoming frame until the following frame is