diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs new file mode 100644 index 0000000000..dd96af0ddf --- /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 6938c16c69..ee3ddf53d5 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 0000000000..33e2200f70 --- /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 19309cd038..9592f0ae4d 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 0a680e4399..b208d3027a 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 270107f80c..117c999675 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 63c0a3a371..3959012cb5 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