Skip to main content

gpui_kit_grid/state/
images.rs

1//! Images in cells: where the image of a [`CellValue::Image`] comes from.
2//!
3//! A source finds an image by the id the row gives. The default,
4//! [`AssetImageSource`], loads it through GPUI's image loader (assets,
5//! files, URIs; rasters and colour SVGs, decoded in the background) and
6//! keeps what it loaded; an application with its own store of thumbnails
7//! gives the grid a source of its own ([`CellImages::custom`]).
8//!
9//! [`CellValue::Image`]: crate::CellValue::Image
10
11use std::{
12    cell::RefCell,
13    hash::{Hash as _, Hasher as _},
14    path::Path,
15    rc::Rc,
16    sync::Arc,
17};
18
19use gpui_kit::{
20    App, ImgResourceLoader, ObjectFit, RenderImage, Resource, SharedString, SharedUri, Window,
21};
22use rustc_hash::{FxHashMap, FxHasher};
23
24use super::{LiveGridDelegate, LiveGridState};
25
26/// How an image fits the room of its cell (the cell without its padding),
27/// as CSS `object-fit`. A column's parameter:
28/// `GridColumn::with_image_fit`.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum ImageFit {
32    /// Scaled to fit inside, keeping its proportions (the default).
33    #[default]
34    Contain,
35    /// Scaled to cover the room, keeping its proportions; the cell crops
36    /// the rest.
37    Cover,
38    /// Stretched to the room.
39    Fill,
40    /// As `Contain` if larger than the room, else at its own size.
41    ScaleDown,
42    /// At its own size, cropped by the cell.
43    None,
44}
45
46impl ImageFit {
47    /// GPUI's fit of the same name.
48    pub(crate) fn object_fit(self) -> ObjectFit {
49        match self {
50            Self::Contain => ObjectFit::Contain,
51            Self::Cover => ObjectFit::Cover,
52            Self::Fill => ObjectFit::Fill,
53            Self::ScaleDown => ObjectFit::ScaleDown,
54            Self::None => ObjectFit::None,
55        }
56    }
57}
58
59/// What a [`CellImageSource`] has for an id now.
60#[derive(Clone)]
61#[non_exhaustive]
62pub enum CellImage {
63    /// The image, decoded: the body paints it.
64    Ready(Arc<RenderImage>),
65    /// Still loading: the cell stays empty until the source has it, and
66    /// the source has the grid repainted then.
67    Loading,
68    /// No image for the id: the cell stays empty; its text is still in the
69    /// tooltip and for a screen reader.
70    Missing,
71}
72
73/// Finds the images of [`CellValue::Image`] cells by their ids.
74///
75/// Called in prepaint for every visible image cell, every frame, with the
76/// grid borrowed: it must not read, update or notify the grid entity. Look
77/// an id up by its hash and keep what was found: after the first frame an
78/// id costs no allocation. An image that becomes ready later must repaint
79/// the grid itself, as `Window::use_asset` does for the view being drawn.
80///
81/// [`CellValue::Image`]: crate::CellValue::Image
82pub trait CellImageSource: 'static {
83    /// The image for `id` now.
84    fn image(&self, id: &str, window: &mut Window, cx: &mut App) -> CellImage;
85}
86
87/// The image source of a grid: the built-in one, or the application's own.
88/// Static dispatch for the built-in source on the hot path.
89#[derive(Clone)]
90#[non_exhaustive]
91pub enum CellImages {
92    /// GPUI's image loader (the default).
93    Assets(AssetImageSource),
94    /// The application's own source.
95    Custom(Arc<dyn CellImageSource>),
96}
97
98impl Default for CellImages {
99    fn default() -> Self {
100        Self::Assets(AssetImageSource::default())
101    }
102}
103
104impl From<AssetImageSource> for CellImages {
105    fn from(source: AssetImageSource) -> Self {
106        Self::Assets(source)
107    }
108}
109
110impl From<Arc<dyn CellImageSource>> for CellImages {
111    fn from(source: Arc<dyn CellImageSource>) -> Self {
112        Self::Custom(source)
113    }
114}
115
116impl CellImages {
117    /// The application's own source.
118    pub fn custom(source: impl CellImageSource) -> Self {
119        Self::Custom(Arc::new(source))
120    }
121
122    /// A frame begins: the built-in source keeps the images it asks for
123    /// until the next one.
124    pub(crate) fn begin_frame(&self) {
125        if let Self::Assets(source) = self {
126            source.begin_frame();
127        }
128    }
129
130    /// The image for `id` now.
131    #[inline]
132    pub(crate) fn image(&self, id: &str, window: &mut Window, cx: &mut App) -> CellImage {
133        match self {
134            Self::Assets(source) => source.image(id, window, cx),
135            Self::Custom(source) => source.image(id, window, cx),
136        }
137    }
138}
139
140/// The default image source: GPUI's image loader
141/// (`Window::use_asset::<ImgResourceLoader>`), which reads and decodes in
142/// the background, renders an SVG in its own colours, and repaints the grid
143/// when an image is ready. An animated GIF shows its first frame.
144///
145/// The id says where the image is:
146///
147/// | Id | Image | In a browser |
148/// |---|---|---|
149/// | `http://…`, `https://…` | A URI, fetched by the application's HTTP client (`App::set_http_client`); without one, missing | With `gpui_web`'s `FetchHttpClient` installed (and the server allowing the page's origin); without it, missing |
150/// | An absolute path | A file | Missing: no file system |
151/// | Anything else | An asset of the application's `AssetSource` | Works |
152///
153/// A missing image is never an error of the frame: the cell is empty and
154/// its text stays in the tooltip and for a screen reader.
155///
156/// Every image loaded is kept, as GPUI keeps its assets: memory grows with
157/// the number of different images shown. [`Self::with_capacity`] keeps the
158/// most recently used ones only. A clone shares the cache, so an
159/// application can keep one to read [`Self::asset_requests`].
160#[derive(Clone, Default)]
161pub struct AssetImageSource {
162    cache: Rc<RefCell<ImageCache>>,
163}
164
165#[derive(Default)]
166struct ImageCache {
167    entries: FxHashMap<u64, ImageEntry>,
168    /// The most entries kept; `None` keeps every one.
169    capacity: Option<usize>,
170    /// Lookups so far: an entry's last use, for the least recently used.
171    lookups: u64,
172    /// Frames begun so far: the images of the frame being planned are
173    /// never dropped for one another.
174    frame: u64,
175    /// Calls of `Window::use_asset`: one per frame for each image while it
176    /// loads, none once it is ready or missing.
177    asset_requests: u64,
178}
179
180struct ImageEntry {
181    id: Box<str>,
182    resource: Resource,
183    image: CellImage,
184    last_used: u64,
185    /// The frame that last used it.
186    frame: u64,
187}
188
189impl AssetImageSource {
190    /// Keeps at most `capacity` images (at least one), dropping the least
191    /// recently used one, and GPUI's copy of it, when another one comes.
192    /// The images of one frame are never dropped for one another: while
193    /// more different images than `capacity` are on screen, it keeps them
194    /// all.
195    pub fn with_capacity(self, capacity: usize) -> Self {
196        self.cache.borrow_mut().capacity = Some(capacity.max(1));
197        self
198    }
199
200    /// How many times this source asked GPUI's asset cache for an image: an
201    /// image asks once a frame while it loads, and never once it is ready or
202    /// missing. For benchmarks and tests.
203    pub fn asset_requests(&self) -> u64 {
204        self.cache.borrow().asset_requests
205    }
206
207    /// The images kept now, loaded or not.
208    pub fn len(&self) -> usize {
209        self.cache.borrow().entries.len()
210    }
211
212    /// Returns true if no image is kept.
213    pub fn is_empty(&self) -> bool {
214        self.cache.borrow().entries.is_empty()
215    }
216
217    /// A frame begins: the images it asks for stay until the next one.
218    fn begin_frame(&self) {
219        self.cache.borrow_mut().frame += 1;
220    }
221}
222
223/// Where an image id points: a URI, a file or an asset.
224fn resource(id: &str) -> Resource {
225    if id.starts_with("http://") || id.starts_with("https://") {
226        Resource::Uri(SharedUri::from(id.to_owned()))
227    } else if Path::new(id).is_absolute() {
228        Resource::Path(Path::new(id).into())
229    } else {
230        Resource::Embedded(SharedString::from(id.to_owned()))
231    }
232}
233
234impl CellImageSource for AssetImageSource {
235    fn image(&self, id: &str, window: &mut Window, cx: &mut App) -> CellImage {
236        let mut hasher = FxHasher::default();
237        id.hash(&mut hasher);
238        let key = hasher.finish();
239        let mut cache = self.cache.borrow_mut();
240        cache.lookups += 1;
241        let (lookup, frame) = (cache.lookups, cache.frame);
242        let known = cache
243            .entries
244            .get(&key)
245            .is_some_and(|entry| &*entry.id == id);
246        if !known {
247            cache.make_room(window, cx);
248            cache.entries.insert(
249                key,
250                ImageEntry {
251                    id: id.into(),
252                    resource: resource(id),
253                    image: CellImage::Loading,
254                    last_used: lookup,
255                    frame,
256                },
257            );
258        }
259        let ImageCache {
260            entries,
261            asset_requests,
262            ..
263        } = &mut *cache;
264        let entry = entries.get_mut(&key).expect("inserted above");
265        entry.last_used = lookup;
266        entry.frame = frame;
267        if let CellImage::Loading = entry.image {
268            *asset_requests += 1;
269            entry.image = match window.use_asset::<ImgResourceLoader>(&entry.resource, cx) {
270                None => CellImage::Loading,
271                Some(Ok(image)) => CellImage::Ready(image),
272                Some(Err(_)) => CellImage::Missing,
273            };
274        }
275        entry.image.clone()
276    }
277}
278
279impl ImageCache {
280    /// Drops the least recently used entries while the cache is full, with
281    /// GPUI's copy of their images and their textures; never one the frame
282    /// being planned uses, so images on screen do not push each other out
283    /// and load again and again.
284    fn make_room(&mut self, window: &mut Window, cx: &mut App) {
285        let Some(capacity) = self.capacity else {
286            return;
287        };
288        while self.entries.len() >= capacity {
289            let frame = self.frame;
290            let Some(oldest) = self
291                .entries
292                .iter()
293                .filter(|(_, entry)| entry.frame != frame)
294                .min_by_key(|(_, entry)| entry.last_used)
295                .map(|(key, _)| *key)
296            else {
297                return;
298            };
299            let entry = self.entries.remove(&oldest).expect("found above");
300            cx.remove_asset::<ImgResourceLoader>(&entry.resource);
301            if let CellImage::Ready(image) = entry.image {
302                let _ = window.drop_image(image);
303            }
304        }
305    }
306}
307
308impl<D: LiveGridDelegate> LiveGridState<D> {
309    /// Sets where the images of `CellValue::Image` cells come from (default
310    /// [`AssetImageSource`]: GPUI's image loader, every image kept).
311    pub fn with_image_source(mut self, source: impl Into<CellImages>) -> Self {
312        self.images = source.into();
313        self
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_image_resource() {
323        assert!(matches!(
324            resource("https://example.com/a.png"),
325            Resource::Uri(uri) if uri.as_ref() == "https://example.com/a.png"
326        ));
327        assert!(matches!(
328            resource("http://localhost/a.png"),
329            Resource::Uri(_)
330        ));
331        #[cfg(not(windows))]
332        assert!(matches!(
333            resource("/tmp/a.png"),
334            Resource::Path(path) if path.as_ref() == Path::new("/tmp/a.png")
335        ));
336        assert!(matches!(
337            resource("images/a.png"),
338            Resource::Embedded(path) if path.as_ref() == "images/a.png"
339        ));
340    }
341}