Skip to main content

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}