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}