frus_core/image.rs
1//! Bitmap images: decoded, shared pixels, and how they fit inside a box.
2//!
3//! `frus-core` neither decodes (PNG/JPEG and friends) nor uploads to the GPU: it
4//! holds only the **raw pixels** ([`ImageData`], RGBA sRGB) behind a shared handle
5//! ([`ImageHandle`]), plus the **fitting** logic ([`BoxFit`]). Decoding lives in a
6//! dedicated layer; uploading lives in `frus-gpu`, which caches the texture by
7//! [`ImageData::id`].
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
11use std::sync::{Arc, Mutex};
12
13use crate::{Alignment, Rect, Size};
14
15/// Identity counter: every [`ImageData`] gets a unique, stable id, which is the
16/// GPU-side cache key — it keeps the same pixels from being re-uploaded each frame.
17static NEXT_ID: AtomicU64 = AtomicU64::new(1);
18
19/// An image's **pixels**: 8-bit RGBA, sRGB, row by row from the top-left corner
20/// (`width * height * 4` bytes). Immutable once built.
21#[derive(Debug)]
22pub struct ImageData {
23 id: u64,
24 width: u32,
25 height: u32,
26 rgba: Vec<u8>,
27}
28
29impl ImageData {
30 /// Builds an image from raw RGBA pixels. Panics if the length is not
31 /// `width * height * 4`.
32 pub fn from_rgba(width: u32, height: u32, rgba: Vec<u8>) -> Self {
33 assert_eq!(
34 rgba.len(),
35 (width as usize) * (height as usize) * 4,
36 "RGBA pixels must be width*height*4 bytes"
37 );
38 Self {
39 id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
40 width,
41 height,
42 rgba,
43 }
44 }
45
46 /// Wraps the image in a shared handle (cheap to clone, stable for caching).
47 pub fn into_handle(self) -> ImageHandle {
48 Arc::new(self)
49 }
50
51 /// Unique, stable identity (the GPU cache key).
52 pub fn id(&self) -> u64 {
53 self.id
54 }
55
56 /// Width in pixels.
57 pub fn width(&self) -> u32 {
58 self.width
59 }
60
61 /// Height in pixels.
62 pub fn height(&self) -> u32 {
63 self.height
64 }
65
66 /// Size in pixels, as a [`Size`].
67 pub fn size(&self) -> Size {
68 Size::new(self.width as f32, self.height as f32)
69 }
70
71 /// The RGBA bytes (sRGB), row by row.
72 pub fn rgba(&self) -> &[u8] {
73 &self.rgba
74 }
75}
76
77/// Two images are "equal" when they share the same identity — the same resource —
78/// without comparing pixels. That keeps cache lookups and scene equality cheap.
79impl PartialEq for ImageData {
80 fn eq(&self, other: &Self) -> bool {
81 self.id == other.id
82 }
83}
84
85impl Eq for ImageData {}
86
87/// A **shared** image handle: cheap to clone (reference counted), and stored as-is
88/// inside a [`crate::Primitive::Image`].
89pub type ImageHandle = Arc<ImageData>;
90
91/// **The size an image is decoded at**, in physical pixels: the full size of the file, or
92/// a smaller one that the place it is shown at needs.
93///
94/// A twelve-megapixel photograph shown as an 80×80 thumbnail does not need 48 MB of
95/// pixels, and on a phone a gallery of them is what gets an application killed for
96/// memory. Decoding at the size shown keeps the cost to what is on the screen.
97///
98/// - Neither side given: the file's own size ([`DecodeSize::FULL`]).
99/// - One side: that side, and the other from the image's own ratio.
100/// - Both: the smallest size, at the image's own ratio, that **covers** both. The ratio
101/// is never changed, so a picture cropped to a square box keeps its shape, and one
102/// letterboxed into it is not short of pixels either.
103///
104/// An image is **never decoded larger** than the file: asking for more gives the file's
105/// own size.
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
107pub struct DecodeSize {
108 /// The width wanted, in physical pixels.
109 pub width: Option<u32>,
110 /// The height wanted, in physical pixels.
111 pub height: Option<u32>,
112}
113
114impl DecodeSize {
115 /// The file's own size.
116 pub const FULL: Self = Self {
117 width: None,
118 height: None,
119 };
120
121 /// A size with either side, both, or neither.
122 pub fn new(width: Option<u32>, height: Option<u32>) -> Self {
123 Self { width, height }
124 }
125
126 /// Whether this is the file's own size.
127 pub fn is_full(self) -> bool {
128 self.width.is_none() && self.height.is_none()
129 }
130
131 /// The size a `full_width` × `full_height` image is decoded at, by the rules above.
132 ///
133 /// ```
134 /// use frus_core::DecodeSize;
135 /// let photo = (4000, 3000);
136 /// // A square thumbnail covered by the photo, at the photo's own ratio.
137 /// assert_eq!(DecodeSize::new(Some(200), Some(200)).target(photo.0, photo.1), (267, 200));
138 /// // One side: the other follows.
139 /// assert_eq!(DecodeSize::new(Some(400), None).target(photo.0, photo.1), (400, 300));
140 /// // Never larger than the file.
141 /// assert_eq!(DecodeSize::new(Some(8000), None).target(photo.0, photo.1), photo);
142 /// ```
143 pub fn target(self, full_width: u32, full_height: u32) -> (u32, u32) {
144 if full_width == 0 || full_height == 0 {
145 return (full_width, full_height);
146 }
147 let along = |wanted: Option<u32>, full: u32| wanted.map(|w| w as f64 / full as f64);
148 let scale = match (
149 along(self.width, full_width),
150 along(self.height, full_height),
151 ) {
152 (None, None) => return (full_width, full_height),
153 (Some(s), None) | (None, Some(s)) => s,
154 // Covering both is the larger of the two scales.
155 (Some(a), Some(b)) => a.max(b),
156 };
157 if scale >= 1.0 {
158 return (full_width, full_height);
159 }
160 let side = |full: u32| ((full as f64 * scale).round() as u32).clamp(1, full);
161 (side(full_width), side(full_height))
162 }
163}
164
165/// The process-wide store of images already turned into pixels, keyed by where their
166/// bytes live.
167///
168/// Decoding a PNG is not free and a view is rebuilt every frame, so an image resolved
169/// in `view` must be resolved **once** rather than sixty times a second. That is what
170/// this is for, and it is a process-wide store for the same reason the font registry is
171/// one: the same asset shown on three screens is one image, not three.
172///
173/// A failure is cached too. A file that is not a PNG will not become one on the next
174/// frame, and retrying the decode every frame would turn one broken asset into a
175/// permanent cost.
176///
177/// An entry is [`Fetched::Loading`] while a decode started by [`decoded_at`] is running
178/// on a decoding thread.
179static CACHE: Mutex<Option<HashMap<(usize, DecodeSize), Fetched>>> = Mutex::new(None);
180
181/// How many threads decode images in the background, at most.
182///
183/// Two, whatever the machine: a decode is a burst of memory — a PNG is decoded at the
184/// file's own size before it is brought down — and a gallery asking for forty pictures at
185/// once must not hold forty of those bursts at the same time. Two keep a phone's other
186/// cores for the interface and still overlap one decode with the next.
187#[cfg(not(target_family = "wasm"))]
188const DECODING_THREADS: usize = 2;
189
190/// Work for the decoding threads.
191type Job = Box<dyn FnOnce() + Send + 'static>;
192
193/// Runs `job` on one of the image decoding threads, or right here where there are none.
194///
195/// The threads are started on the first job and live for the process; the queue is first
196/// in, first out. The Web has no threads to start, so there the job runs in place, which
197/// is what every decode did before there were threads.
198fn in_background(job: Job) {
199 #[cfg(target_family = "wasm")]
200 job();
201 #[cfg(not(target_family = "wasm"))]
202 {
203 static QUEUE: std::sync::OnceLock<Option<std::sync::mpsc::Sender<Job>>> =
204 std::sync::OnceLock::new();
205 let queue = QUEUE.get_or_init(|| {
206 let (sender, receiver) = std::sync::mpsc::channel::<Job>();
207 let receiver = Arc::new(Mutex::new(receiver));
208 let workers = std::thread::available_parallelism()
209 .map_or(1, |n| n.get().saturating_sub(1))
210 .clamp(1, DECODING_THREADS);
211 let started = (0..workers)
212 .filter(|i| {
213 let receiver = Arc::clone(&receiver);
214 std::thread::Builder::new()
215 .name(format!("frus-image-{i}"))
216 .spawn(move || loop {
217 let next = receiver.lock().unwrap_or_else(|e| e.into_inner()).recv();
218 match next {
219 Ok(job) => job(),
220 Err(_) => return,
221 }
222 })
223 .is_ok()
224 })
225 .count();
226 // A platform that refuses threads decodes in place rather than queueing work
227 // nobody will ever pick up.
228 (started > 0).then_some(sender)
229 });
230 match queue {
231 Some(sender) => {
232 if let Err(refused) = sender.send(job) {
233 (refused.0)();
234 }
235 }
236 None => job(),
237 }
238 }
239}
240
241/// `decode`, with a panic in it turned into a failure: a decoder that panics on a
242/// decoding thread would otherwise leave its picture loading for ever, and the interface
243/// redrawing for ever with it.
244fn guarded(decode: impl FnOnce() -> Result<ImageData, String>) -> Fetched {
245 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(decode)) {
246 Ok(Ok(data)) => Fetched::Ready(data.into_handle()),
247 Ok(Err(why)) => Fetched::Failed(why),
248 Err(_) => Fetched::Failed("the image decoder panicked".to_string()),
249 }
250}
251
252/// Resolves `bytes` to pixels **once**, returning the same handle on every later call.
253///
254/// The key is the **address** of the slice rather than its contents. That is exact for
255/// what this is for — `include_bytes!` gives a `&'static [u8]` whose address is fixed
256/// for the life of the process, and two distinct assets are two distinct statics — and
257/// it costs a pointer comparison, where hashing the bytes would cost re-reading the
258/// whole file on every frame that shows it.
259///
260/// The `'static` bound is what makes that sound: bytes that can be freed could have
261/// their address reused by something else, and the cache would hand back the wrong
262/// picture. An application holding runtime bytes decodes them itself and keeps the
263/// [`ImageHandle`].
264///
265/// `load` runs only on the first call for a given slice, and its failure is remembered.
266pub fn cached(
267 bytes: &'static [u8],
268 load: impl FnOnce(&[u8]) -> Result<ImageData, String>,
269) -> Result<ImageHandle, String> {
270 cached_at(bytes, DecodeSize::FULL, load)
271}
272
273/// [`cached`], at a [`DecodeSize`]: the same bytes decoded at two sizes are two entries,
274/// and `load` is the one that decodes at `size`.
275pub fn cached_at(
276 bytes: &'static [u8],
277 size: DecodeSize,
278 load: impl FnOnce(&[u8]) -> Result<ImageData, String>,
279) -> Result<ImageHandle, String> {
280 let key = (bytes.as_ptr() as usize, size);
281 let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
282 let cache = guard.get_or_insert_with(HashMap::new);
283 match cache.get(&key) {
284 Some(Fetched::Ready(handle)) => return Ok(handle.clone()),
285 Some(Fetched::Failed(why)) => return Err(why.clone()),
286 // Still decoding on a thread, for [`decoded_at`]: the caller of this one wants the
287 // pixels now, so it decodes them itself, and the thread's copy replaces this one
288 // when it lands.
289 Some(Fetched::Loading) | None => {}
290 }
291 let resolved = load(bytes).map(ImageData::into_handle);
292 let state = match &resolved {
293 Ok(handle) => Fetched::Ready(handle.clone()),
294 Err(why) => Fetched::Failed(why.clone()),
295 };
296 cache.insert(key, state);
297 resolved
298}
299
300/// [`cached_at`], **decoding on a background thread** when `in_the_background` says so:
301/// [`Fetched::Loading`] until the pixels are there, and the frames keep coming meanwhile
302/// ([`images_in_flight`] counts it).
303///
304/// A large photograph takes tens of milliseconds to decode on a phone — several frames —
305/// and an interface that decodes it in the frame that first shows it stops for that long.
306/// A small picture decodes in less time than a thread takes to pick it up, and showing it
307/// a frame late would only make it blink in, so the caller says which this is.
308///
309/// On the Web, which has no threads, the decode happens here either way.
310pub fn decoded_at(
311 bytes: &'static [u8],
312 size: DecodeSize,
313 decode: fn(&[u8], DecodeSize) -> Result<ImageData, String>,
314 in_the_background: bool,
315) -> Fetched {
316 let key = (bytes.as_ptr() as usize, size);
317 let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
318 let cache = guard.get_or_insert_with(HashMap::new);
319 if let Some(found) = cache.get(&key) {
320 return found.clone();
321 }
322 if !in_the_background {
323 let state = guarded(|| decode(bytes, size));
324 cache.insert(key, state.clone());
325 return state;
326 }
327 cache.insert(key, Fetched::Loading);
328 // The lock goes before the job is handed over: where there are no threads, the job
329 // runs on this one and takes the same lock to put its result in.
330 drop(guard);
331 in_background(Box::new(move || {
332 let state = guarded(|| decode(bytes, size));
333 let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
334 guard.get_or_insert_with(HashMap::new).insert(key, state);
335 }));
336 CACHE
337 .lock()
338 .unwrap_or_else(|e| e.into_inner())
339 .as_ref()
340 .and_then(|cache| cache.get(&key).cloned())
341 .unwrap_or(Fetched::Loading)
342}
343
344/// Empties the store. For tests that want a decode to happen again; an application has
345/// no reason to call it, since the entries are one per embedded asset and size, and
346/// bounded by how many the binary carries. [`trim_image_caches`] is the one for memory.
347pub fn forget_cached_images() {
348 if let Ok(mut guard) = CACHE.lock() {
349 *guard = None;
350 }
351}
352
353/// **Gives back every decoded image nobody is showing**: what the system asks for when it
354/// is short of memory.
355///
356/// Both stores are swept — embedded images and fetched ones — and only the pictures held
357/// by nothing but the store go. One on screen stays, since dropping it would only make the
358/// next frame decode it again; one in flight stays, since forgetting it does not cancel it.
359/// What goes comes back, decoded again, the next time it is shown.
360///
361/// The shell calls this when the platform says memory is low (Android's low-memory
362/// callback, iOS's memory warning). An application can call it too — on leaving a screen
363/// full of photographs, say.
364pub fn trim_image_caches() {
365 if let Ok(mut guard) = CACHE.lock() {
366 if let Some(cache) = guard.as_mut() {
367 cache.retain(|_, entry| match entry {
368 Fetched::Ready(handle) => Arc::strong_count(handle) > 1,
369 // A failure costs a sentence, and remembering it is what keeps a broken
370 // asset from being decoded again on every frame; one still decoding is on
371 // its way to somebody who asked.
372 Fetched::Failed(_) | Fetched::Loading => true,
373 });
374 }
375 }
376 let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
377 if let Some(store) = guard.as_mut() {
378 evict_over_budget(store, 0);
379 }
380}
381
382/// What is known about an image that has to be **fetched** before it can be shown.
383///
384/// Three states rather than two, because "not here yet" and "will never be here" are
385/// different answers and an interface shows them differently: one is a placeholder, the
386/// other is a message.
387#[derive(Clone, Debug)]
388pub enum Fetched {
389 /// In flight. Ask again on a later frame.
390 Loading,
391 /// Here.
392 Ready(ImageHandle),
393 /// It will not arrive, and this is why.
394 Failed(String),
395}
396
397/// Hands the bytes at `url` to the callback, once, from wherever suits the platform.
398///
399/// A **function pointer** and not a closure: this is registered once for the process, and
400/// a plain `fn` needs no allocation, no lifetime and no `Sync` wrapper to store.
401pub type ImageFetcher = fn(&str, Box<dyn FnOnce(Result<Vec<u8>, String>) + Send + 'static>);
402
403impl Fetched {
404 /// The decoded pixels this state is holding on to, in bytes.
405 ///
406 /// `Loading` holds nothing yet and `Failed` holds a sentence; only `Ready` is
407 /// heavy, and it is as heavy as the picture is big.
408 fn bytes(&self) -> usize {
409 match self {
410 Fetched::Ready(handle) => handle.rgba.len(),
411 _ => 0,
412 }
413 }
414}
415
416/// A store entry: the state, plus **when it was last asked for**.
417struct Entry {
418 state: Fetched,
419 /// A tick from [`CLOCK`], not a wall time.
420 used: u64,
421}
422
423/// The default ceiling on decoded network images held in memory: the reference's own
424/// figure.
425///
426/// It is a **default**, not a rule — see [`set_image_cache_budget`]. A phone with a
427/// modest heap wants less; a desktop tool showing one enormous photograph may want more
428/// than one picture's worth.
429pub const DEFAULT_IMAGE_CACHE_BYTES: usize = 100 * 1024 * 1024;
430
431/// The ceiling on the total decoded size of `Ready` entries.
432static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_IMAGE_CACHE_BYTES);
433
434/// A monotonic tick, bumped every time an image is asked for, and the store's whole
435/// notion of *recently*.
436///
437/// A counter rather than a clock. It needs no platform time source — `frus-core`
438/// compiles for the Web, where `Instant::now` is not a thing — it cannot go backwards
439/// when a machine's clock is corrected, and *least recently used* only ever asks which
440/// of two numbers is smaller. A wall time would answer the same question with more
441/// machinery and one more way to be wrong.
442static CLOCK: AtomicU64 = AtomicU64::new(0);
443
444static FETCHER: Mutex<Option<ImageFetcher>> = Mutex::new(None);
445static FETCHED: Mutex<Option<HashMap<String, Entry>>> = Mutex::new(None);
446
447/// Sets the ceiling on decoded network images held in memory, in bytes.
448///
449/// `0` keeps nothing that nobody is holding: every image is dropped the moment it
450/// leaves the screen and fetched again when it comes back. That is a legitimate answer
451/// for a device with almost no memory, and a bad one for anything else.
452pub fn set_image_cache_budget(bytes: usize) {
453 BUDGET.store(bytes, Ordering::Relaxed);
454 let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
455 if let Some(store) = guard.as_mut() {
456 evict_over_budget(store, bytes);
457 }
458}
459
460/// The ceiling currently in force.
461pub fn image_cache_budget() -> usize {
462 BUDGET.load(Ordering::Relaxed)
463}
464
465/// The decoded bytes the store is currently holding.
466pub fn image_cache_bytes() -> usize {
467 FETCHED
468 .lock()
469 .unwrap_or_else(|e| e.into_inner())
470 .as_ref()
471 .map_or(0, |store| store.values().map(|e| e.state.bytes()).sum())
472}
473
474/// Drops least-recently-used images until the store is inside its budget.
475///
476/// **Two entries are never dropped**, and both exclusions are the difference between a
477/// cache and a bug:
478///
479/// - a `Loading` one is *in flight*. Dropping it does not cancel the request — nothing
480/// here can — it only forgets that one was made, so the next frame starts a second,
481/// and [`images_in_flight`] falls to zero, and the redraw loop that was keeping the
482/// frame alive until the picture arrived stops. The picture then lands in a store
483/// nobody will look at again until something else forces a frame.
484/// - one whose handle is held **elsewhere** is on screen, or in a scene about to be
485/// drawn. Dropping that is not unsafe — the `Arc` keeps the pixels alive for whoever
486/// holds them — but the next `view` finds nothing, asks again, and the image flickers
487/// through a placeholder on its way back to exactly where it was. A store that evicts
488/// what is visible is a store that fetches the same picture for ever.
489///
490/// `Arc::strong_count == 1` is how the second is asked: one reference, and it is this
491/// store's own.
492///
493/// The budget is a **parameter** rather than a read of [`BUDGET`] inside. The rule is
494/// worth testing on its own — which of two pictures goes first, and which two never do
495/// — and a function that reached for a process-wide value would make those tests race
496/// each other for it.
497fn evict_over_budget(store: &mut HashMap<String, Entry>, budget: usize) {
498 let mut held: usize = store.values().map(|e| e.state.bytes()).sum();
499 while held > budget {
500 let victim = store
501 .iter()
502 .filter(|(_, entry)| match &entry.state {
503 Fetched::Ready(handle) => Arc::strong_count(handle) == 1,
504 _ => false,
505 })
506 .min_by_key(|(_, entry)| entry.used)
507 .map(|(url, _)| url.clone());
508 // Nothing left that can go: every remaining picture is either in flight or on
509 // screen. Over budget is the right answer here — the alternative is dropping
510 // something that is being looked at.
511 let Some(url) = victim else { return };
512 if let Some(entry) = store.remove(&url) {
513 held -= entry.state.bytes();
514 }
515 }
516}
517
518/// Names the thing that gets bytes over the network.
519///
520/// The widget layer cannot do this itself and should not: it has no runtime, no socket
521/// and no dependency on the shell — the dependency runs the other way. So the shell,
522/// which has all three, says how, and the widget layer only asks. It is the shape the
523/// image **decoder** took a step earlier, for the same reason.
524///
525/// An application using the framework's own shell never calls this: the shell registers
526/// its `fetch_bytes` on the way up. One embedding frus in a host that already has an HTTP
527/// client can point this at that client instead.
528pub fn set_image_fetcher(fetcher: ImageFetcher) {
529 if let Ok(mut guard) = FETCHER.lock() {
530 *guard = Some(fetcher);
531 }
532}
533
534/// The fetcher [`set_image_fetcher`] registered, if one was: for the other documents an
535/// application shows from an address — a picture drawn as paths, an animation — which go
536/// through the same network the images do.
537pub fn image_fetcher() -> Option<ImageFetcher> {
538 *FETCHER.lock().unwrap_or_else(|e| e.into_inner())
539}
540
541/// What is known about the image at `url`, starting the fetch if this is the first ask.
542///
543/// Called from a view, which runs every frame: the **first** call starts the work and
544/// says `Loading`, and every later one is a lookup. That is what makes it safe to write
545/// in a `view` at all — a fetch per frame would be sixty requests a second for one
546/// picture.
547///
548/// A failure is remembered like any other, and for the same reason as milestone 372's:
549/// a URL that 404s will 404 again, and retrying every frame turns one bad link into a
550/// permanent load on somebody's server.
551pub fn fetched(url: &str, decode: fn(&[u8]) -> Result<ImageData, String>) -> Fetched {
552 fetch(url, url, Box::new(decode))
553}
554
555/// [`fetched`], at a [`DecodeSize`]: `decode` is handed the size along with the bytes.
556///
557/// The same address at two sizes is two entries, each fetched on its own: a thumbnail and
558/// the full picture it opens are two downloads, though an HTTP cache usually answers the
559/// second.
560pub fn fetched_at(
561 url: &str,
562 size: DecodeSize,
563 decode: fn(&[u8], DecodeSize) -> Result<ImageData, String>,
564) -> Fetched {
565 if size.is_full() {
566 return fetch(url, url, Box::new(move |bytes| decode(bytes, size)));
567 }
568 let key = sized_key(url, size);
569 fetch(&key, url, Box::new(move |bytes| decode(bytes, size)))
570}
571
572/// The store's key for `url` at a size other than the full one. The full size keeps the
573/// address alone, so [`fetched`] and a full-size [`fetched_at`] share an entry; a NUL
574/// cannot appear in a URL, so no address collides with a sized key.
575fn sized_key(url: &str, size: DecodeSize) -> String {
576 let side = |s: Option<u32>| s.map_or(String::new(), |s| s.to_string());
577 format!("{url}\0{}x{}", side(size.width), side(size.height))
578}
579
580/// What turns the fetched bytes into pixels.
581type Decoder = Box<dyn FnOnce(&[u8]) -> Result<ImageData, String> + Send + 'static>;
582
583/// The store's lookup, and the start of the fetch when `key` is new.
584fn fetch(key: &str, url: &str, decode: Decoder) -> Fetched {
585 let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
586 let store = guard.get_or_insert_with(HashMap::new);
587 let now = CLOCK.fetch_add(1, Ordering::Relaxed);
588 if let Some(known) = store.get_mut(key) {
589 // Asked for is used: a picture on a screen nobody has scrolled away from is
590 // asked for on every frame, which is exactly what keeps it out of the way of
591 // the eviction sweep.
592 known.used = now;
593 return known.state.clone();
594 }
595 let Some(fetcher) = *FETCHER.lock().unwrap_or_else(|e| e.into_inner()) else {
596 // No fetcher: a build without the network, or a host that never named one.
597 // Saying so is an answer; hanging on `Loading` for ever is not.
598 let failed = Fetched::Failed("no image fetcher registered".to_string());
599 store.insert(
600 key.to_string(),
601 Entry {
602 state: failed.clone(),
603 used: now,
604 },
605 );
606 return failed;
607 };
608 store.insert(
609 key.to_string(),
610 Entry {
611 state: Fetched::Loading,
612 used: now,
613 },
614 );
615 // The lock goes **before** the call. A fetcher is free to answer on this thread —
616 // a cache hit in a host client, a test double — and its callback locks the same
617 // store, which would be a deadlock against a guard still held here.
618 drop(guard);
619
620 let key = key.to_string();
621 fetcher(
622 url,
623 Box::new(move |result| match result {
624 // The bytes are decoded on the decoding threads, not on whichever thread the
625 // fetcher answered on: that one is the network's, and a picture is a burst of
626 // memory that has to be counted with the others.
627 Ok(bytes) => in_background(Box::new(move || {
628 let outcome = guarded(|| decode(&bytes));
629 land(key, outcome);
630 })),
631 Err(why) => land(key, Fetched::Failed(why)),
632 }),
633 );
634 Fetched::Loading
635}
636
637/// Puts what became of a fetch in the store.
638fn land(key: String, outcome: Fetched) {
639 if let Ok(mut guard) = FETCHED.lock() {
640 let store = guard.get_or_insert_with(HashMap::new);
641 store.insert(
642 key,
643 Entry {
644 state: outcome,
645 used: CLOCK.fetch_add(1, Ordering::Relaxed),
646 },
647 );
648 // The one moment the store grows. Sweeping here rather than on every ask means
649 // the cost is paid once per picture that arrives, not once per frame per picture
650 // on screen.
651 evict_over_budget(store, BUDGET.load(Ordering::Relaxed));
652 }
653}
654
655/// How many images are in flight right now.
656///
657/// The interface has to keep drawing while any of them are, or the frame that would show
658/// the picture never happens. A count rather than a flag on the widget, because the
659/// application may well have taken the image **out** of the tree while it loads — that
660/// is what showing a placeholder means — and the work is still going on.
661///
662/// Counted off the store rather than kept in a separate tally beside it. A tally is a
663/// second copy of the same fact and the two can disagree: incremented here and
664/// decremented in a callback, it survives a [`forget_fetched_images`] that empties the
665/// store, and it can be decremented below zero by a callback that arrives after one.
666/// Either way the interface is left redrawing for ever over work that finished. The
667/// store already knows — the entries that say `Loading` **are** the ones in flight —
668/// and there are only ever as many entries as the application has network images.
669pub fn images_in_flight() -> usize {
670 let fetched = FETCHED
671 .lock()
672 .unwrap_or_else(|e| e.into_inner())
673 .as_ref()
674 .map_or(0, |store| {
675 store
676 .values()
677 .filter(|entry| matches!(entry.state, Fetched::Loading))
678 .count()
679 });
680 // And the embedded pictures still decoding on a thread.
681 let decoding = CACHE
682 .lock()
683 .unwrap_or_else(|e| e.into_inner())
684 .as_ref()
685 .map_or(0, |cache| {
686 cache
687 .values()
688 .filter(|state| matches!(state, Fetched::Loading))
689 .count()
690 });
691 fetched + decoding
692}
693
694/// Forgets every fetched image and every remembered failure. For tests; see
695/// [`forget_cached_images`].
696pub fn forget_fetched_images() {
697 if let Ok(mut guard) = FETCHED.lock() {
698 *guard = None;
699 }
700}
701
702/// How an image is fitted into its destination box — the same set of modes as the
703/// CSS `object-fit` property.
704#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
705pub enum BoxFit {
706 /// Stretches to fill the box; the aspect ratio is **not** preserved.
707 Fill,
708 /// The largest scale that still **fits** inside the box (letterbox).
709 #[default]
710 Contain,
711 /// The smallest scale that **covers** the box (cropping).
712 Cover,
713 /// Fits to the **width**; may overflow vertically.
714 FitWidth,
715 /// Fits to the **height**; may overflow horizontally.
716 FitHeight,
717 /// Natural size, centred — neither scaled up nor down.
718 None,
719 /// Like [`BoxFit::Contain`], but **never scaled up** — only down.
720 ScaleDown,
721}
722
723impl BoxFit {
724 /// Computes the **destination** rectangle (inside or around `dst`) and the **UV**
725 /// rectangle (the sub-region of the texture, in `0..1`) for drawing an image of
726 /// size `src` in this mode. `dst` is never cropped: letterboxing shrinks `dst`
727 /// and keeps full UV; cropping keeps `dst` full and shrinks the UV.
728 pub fn apply(self, src: Size, dst: Rect) -> (Rect, Rect) {
729 self.apply_aligned(src, dst, Alignment::CENTER)
730 }
731
732 /// [`BoxFit::apply`] with a say in **where** the spare room goes: which part of the
733 /// box a letterboxed image sits in, and which part of the image a cropping one keeps.
734 ///
735 /// Centre is the reference's default and what `apply` uses. It matters as soon as an
736 /// image is not the shape of its box: a portrait cropped to a banner should usually
737 /// keep its top, not its middle.
738 pub fn apply_aligned(self, src: Size, dst: Rect, align: Alignment) -> (Rect, Rect) {
739 let full_uv = Rect::new(0.0, 0.0, 1.0, 1.0);
740 if src.width <= 0.0 || src.height <= 0.0 || dst.width <= 0.0 || dst.height <= 0.0 {
741 return (dst, full_uv);
742 }
743 let sx = dst.width / src.width;
744 let sy = dst.height / src.height;
745 let (fx, fy) = (align.fraction_x(), align.fraction_y());
746
747 // Letterbox: the image at `scale`, placed inside `dst` by the alignment, with
748 // full UV.
749 let letterbox = |scale: f32| {
750 let w = src.width * scale;
751 let h = src.height * scale;
752 let x = dst.x + (dst.width - w) * fx;
753 let y = dst.y + (dst.height - h) * fy;
754 (Rect::new(x, y, w, h), full_uv)
755 };
756
757 match self {
758 BoxFit::Fill => (dst, full_uv),
759 BoxFit::Contain => letterbox(sx.min(sy)),
760 BoxFit::FitWidth => letterbox(sx),
761 BoxFit::FitHeight => letterbox(sy),
762 BoxFit::None => letterbox(1.0),
763 BoxFit::ScaleDown => letterbox(sx.min(sy).min(1.0)),
764 BoxFit::Cover => {
765 // The image covers `dst`; the crop happens in the UV, centred.
766 let scale = sx.max(sy);
767 let scaled_w = src.width * scale;
768 let scaled_h = src.height * scale;
769 let uv_w = (dst.width / scaled_w).min(1.0);
770 let uv_h = (dst.height / scaled_h).min(1.0);
771 // The crop travels the other way: aligning the image to the top means
772 // keeping the **top** of it, which is the low end of the UV.
773 let uv = Rect::new((1.0 - uv_w) * fx, (1.0 - uv_h) * fy, uv_w, uv_h);
774 (dst, uv)
775 }
776 }
777 }
778
779 /// The `(sx, sy)` scale factors that fit content of size `src` into a box of
780 /// size `dst` in this mode — the building block of `FittedBox`, where the
781 /// content, unlike a sampled image, is **scaled** and then centred. `Fill`
782 /// stretches per axis; every other mode is **uniform**, preserving the aspect
783 /// ratio. A degenerate `src` yields `(1, 1)`.
784 pub fn scale(self, src: Size, dst: Size) -> (f32, f32) {
785 if src.width <= 0.0 || src.height <= 0.0 {
786 return (1.0, 1.0);
787 }
788 let sx = dst.width / src.width;
789 let sy = dst.height / src.height;
790 match self {
791 BoxFit::Fill => (sx, sy),
792 BoxFit::Contain => {
793 let s = sx.min(sy);
794 (s, s)
795 }
796 BoxFit::Cover => {
797 let s = sx.max(sy);
798 (s, s)
799 }
800 BoxFit::FitWidth => (sx, sx),
801 BoxFit::FitHeight => (sy, sy),
802 BoxFit::None => (1.0, 1.0),
803 BoxFit::ScaleDown => {
804 let s = sx.min(sy).min(1.0);
805 (s, s)
806 }
807 }
808 }
809}
810
811/// The eviction sweep, driven directly rather than through a fetcher.
812///
813/// A network image needs a socket, a runtime and a shell; the rule being checked here
814/// needs none of them, and a test that stood up all three to prove that the older of two
815/// pictures goes first would be checking the shell.
816#[cfg(test)]
817mod cache_tests {
818 use super::*;
819
820 /// A picture of `mb` megabytes, ready and held only by the store.
821 fn ready(mb: usize) -> Fetched {
822 let side = 512u32;
823 let bytes = (side * side * 4) as usize;
824 let count = mb * 1024 * 1024 / bytes;
825 let rgba = vec![0u8; bytes * count.max(1)];
826 Fetched::Ready(
827 ImageData::from_rgba(side, (side as usize * count.max(1)) as u32, rgba).into_handle(),
828 )
829 }
830
831 fn store_of(entries: Vec<(&str, Fetched, u64)>) -> HashMap<String, Entry> {
832 entries
833 .into_iter()
834 .map(|(url, state, used)| (url.to_string(), Entry { state, used }))
835 .collect()
836 }
837
838 fn held(store: &HashMap<String, Entry>) -> usize {
839 store.values().map(|e| e.state.bytes()).sum()
840 }
841
842 /// Only `Ready` weighs anything: a request in flight holds no pixels yet, and a
843 /// failure holds a sentence.
844 #[test]
845 fn only_a_ready_image_weighs_anything() {
846 assert_eq!(Fetched::Loading.bytes(), 0);
847 assert_eq!(Fetched::Failed("gone".into()).bytes(), 0);
848 assert!(ready(4).bytes() >= 4 * 1024 * 1024);
849 }
850
851 /// Over the budget, the **least recently asked for** goes first.
852 #[test]
853 fn the_oldest_unheld_picture_goes_first() {
854 let mut store = store_of(vec![
855 ("old", ready(4), 1),
856 ("middling", ready(4), 5),
857 ("fresh", ready(4), 9),
858 ]);
859 evict_over_budget(&mut store, 9 * 1024 * 1024);
860 assert!(!store.contains_key("old"), "the oldest goes");
861 assert!(store.contains_key("fresh"), "the freshest stays");
862 assert!(held(&store) <= 9 * 1024 * 1024);
863 }
864
865 /// **A picture in flight is never dropped.** Dropping it cancels nothing — nothing
866 /// here can — it only forgets the request was made, so the next frame starts a
867 /// second one *and* `images_in_flight` falls to zero, stopping the redraw loop that
868 /// was keeping the frame alive until the picture arrived.
869 #[test]
870 fn a_picture_in_flight_is_never_dropped() {
871 let mut store = store_of(vec![
872 ("arriving", Fetched::Loading, 0),
873 ("big", ready(8), 1),
874 ]);
875 evict_over_budget(&mut store, 0);
876 assert!(store.contains_key("arriving"), "still in flight");
877 assert!(!store.contains_key("big"), "the pixels go");
878 }
879
880 /// **A picture somebody else is holding is never dropped.** It is on screen. Losing
881 /// it is not unsafe — the `Arc` keeps the pixels alive for whoever holds them —
882 /// but the next `view` would find nothing, ask again, and the image would flicker
883 /// through a placeholder on its way back to exactly where it was.
884 #[test]
885 fn a_picture_on_screen_is_never_dropped() {
886 let onscreen = ready(8);
887 // What a scene holding the image looks like from here: a second reference.
888 let Fetched::Ready(handle) = &onscreen else {
889 panic!("ready")
890 };
891 let _in_a_scene = handle.clone();
892
893 let mut store = store_of(vec![("visible", onscreen, 0), ("stale", ready(8), 1)]);
894 evict_over_budget(&mut store, 0);
895 assert!(store.contains_key("visible"), "held elsewhere: it stays");
896 assert!(!store.contains_key("stale"), "held only here: it goes");
897 }
898
899 /// When **everything** left is in flight or on screen, the sweep stops rather than
900 /// spinning. Over budget is the right answer there: the alternative is dropping
901 /// something being looked at.
902 #[test]
903 fn a_store_that_cannot_shrink_stops_rather_than_spins() {
904 let onscreen = ready(8);
905 let Fetched::Ready(handle) = &onscreen else {
906 panic!("ready")
907 };
908 let _in_a_scene = handle.clone();
909
910 let mut store = store_of(vec![
911 ("visible", onscreen, 0),
912 ("arriving", Fetched::Loading, 1),
913 ]);
914 evict_over_budget(&mut store, 0);
915 assert_eq!(store.len(), 2, "nothing could go, and nothing hung");
916 }
917
918 /// The default is the reference\'s figure, and it is a **default**: a caller can set
919 /// its own, including nothing at all.
920 #[test]
921 fn the_budget_is_the_callers() {
922 assert_eq!(DEFAULT_IMAGE_CACHE_BYTES, 100 * 1024 * 1024);
923 let before = image_cache_budget();
924 set_image_cache_budget(7);
925 assert_eq!(image_cache_budget(), 7);
926 set_image_cache_budget(before);
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933
934 fn img(w: u32, h: u32) -> ImageData {
935 ImageData::from_rgba(w, h, vec![0u8; (w * h * 4) as usize])
936 }
937
938 #[test]
939 fn ids_are_unique_and_stable() {
940 let a = img(1, 1);
941 let b = img(1, 1);
942 assert_ne!(a.id(), b.id());
943 assert_eq!(a.id(), a.id());
944 assert_ne!(a, b); // equality is by identity, not by pixels
945 }
946
947 #[test]
948 fn fill_uses_the_whole_box_and_full_uv() {
949 let (dst, uv) = BoxFit::Fill.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 50.0));
950 assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 50.0));
951 assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
952 }
953
954 #[test]
955 fn contain_letterboxes_preserving_aspect() {
956 // A square source in a wide box → full height, centred horizontally.
957 let (dst, uv) =
958 BoxFit::Contain.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
959 assert_eq!(dst, Rect::new(30.0, 0.0, 40.0, 40.0));
960 assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
961 }
962
963 #[test]
964 fn scale_fits_content_per_mode() {
965 let src = Size::new(40.0, 20.0); // wide
966 let dst = Size::new(200.0, 200.0);
967 // Fill: per axis.
968 assert_eq!(BoxFit::Fill.scale(src, dst), (5.0, 10.0));
969 // Contain: the smaller factor (it fits) → uniform.
970 assert_eq!(BoxFit::Contain.scale(src, dst), (5.0, 5.0));
971 // Cover: the larger factor (it covers) → uniform.
972 assert_eq!(BoxFit::Cover.scale(src, dst), (10.0, 10.0));
973 // FitWidth / FitHeight follow a single axis, uniformly.
974 assert_eq!(BoxFit::FitWidth.scale(src, dst), (5.0, 5.0));
975 assert_eq!(BoxFit::FitHeight.scale(src, dst), (10.0, 10.0));
976 // None does not scale; ScaleDown only ever shrinks.
977 assert_eq!(BoxFit::None.scale(src, dst), (1.0, 1.0));
978 assert_eq!(BoxFit::ScaleDown.scale(src, dst), (1.0, 1.0));
979 // ScaleDown does shrink when the box is smaller.
980 assert_eq!(
981 BoxFit::ScaleDown.scale(src, Size::new(20.0, 20.0)),
982 (0.5, 0.5)
983 );
984 // Degenerate source → neutral.
985 assert_eq!(BoxFit::Cover.scale(Size::new(0.0, 10.0), dst), (1.0, 1.0));
986 }
987
988 #[test]
989 fn cover_fills_the_box_and_crops_uv() {
990 // A square source in a wide box → covers everything, crops vertically (UV).
991 let (dst, uv) =
992 BoxFit::Cover.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
993 assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 40.0));
994 // scale = max(10, 4) = 10 → a 100×100 image; visible = 40/100 = 0.4 of its height.
995 assert_eq!(uv.width, 1.0);
996 assert!((uv.height - 0.4).abs() < 1e-6);
997 assert!((uv.y - 0.3).abs() < 1e-6);
998 }
999}