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/// What is known about the image at `url`, starting the fetch if this is the first ask.
535///
536/// Called from a view, which runs every frame: the **first** call starts the work and
537/// says `Loading`, and every later one is a lookup. That is what makes it safe to write
538/// in a `view` at all — a fetch per frame would be sixty requests a second for one
539/// picture.
540///
541/// A failure is remembered like any other, and for the same reason as milestone 372's:
542/// a URL that 404s will 404 again, and retrying every frame turns one bad link into a
543/// permanent load on somebody's server.
544pub fn fetched(url: &str, decode: fn(&[u8]) -> Result<ImageData, String>) -> Fetched {
545    fetch(url, url, Box::new(decode))
546}
547
548/// [`fetched`], at a [`DecodeSize`]: `decode` is handed the size along with the bytes.
549///
550/// The same address at two sizes is two entries, each fetched on its own: a thumbnail and
551/// the full picture it opens are two downloads, though an HTTP cache usually answers the
552/// second.
553pub fn fetched_at(
554    url: &str,
555    size: DecodeSize,
556    decode: fn(&[u8], DecodeSize) -> Result<ImageData, String>,
557) -> Fetched {
558    if size.is_full() {
559        return fetch(url, url, Box::new(move |bytes| decode(bytes, size)));
560    }
561    let key = sized_key(url, size);
562    fetch(&key, url, Box::new(move |bytes| decode(bytes, size)))
563}
564
565/// The store's key for `url` at a size other than the full one. The full size keeps the
566/// address alone, so [`fetched`] and a full-size [`fetched_at`] share an entry; a NUL
567/// cannot appear in a URL, so no address collides with a sized key.
568fn sized_key(url: &str, size: DecodeSize) -> String {
569    let side = |s: Option<u32>| s.map_or(String::new(), |s| s.to_string());
570    format!("{url}\0{}x{}", side(size.width), side(size.height))
571}
572
573/// What turns the fetched bytes into pixels.
574type Decoder = Box<dyn FnOnce(&[u8]) -> Result<ImageData, String> + Send + 'static>;
575
576/// The store's lookup, and the start of the fetch when `key` is new.
577fn fetch(key: &str, url: &str, decode: Decoder) -> Fetched {
578    let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
579    let store = guard.get_or_insert_with(HashMap::new);
580    let now = CLOCK.fetch_add(1, Ordering::Relaxed);
581    if let Some(known) = store.get_mut(key) {
582        // Asked for is used: a picture on a screen nobody has scrolled away from is
583        // asked for on every frame, which is exactly what keeps it out of the way of
584        // the eviction sweep.
585        known.used = now;
586        return known.state.clone();
587    }
588    let Some(fetcher) = *FETCHER.lock().unwrap_or_else(|e| e.into_inner()) else {
589        // No fetcher: a build without the network, or a host that never named one.
590        // Saying so is an answer; hanging on `Loading` for ever is not.
591        let failed = Fetched::Failed("no image fetcher registered".to_string());
592        store.insert(
593            key.to_string(),
594            Entry {
595                state: failed.clone(),
596                used: now,
597            },
598        );
599        return failed;
600    };
601    store.insert(
602        key.to_string(),
603        Entry {
604            state: Fetched::Loading,
605            used: now,
606        },
607    );
608    // The lock goes **before** the call. A fetcher is free to answer on this thread —
609    // a cache hit in a host client, a test double — and its callback locks the same
610    // store, which would be a deadlock against a guard still held here.
611    drop(guard);
612
613    let key = key.to_string();
614    fetcher(
615        url,
616        Box::new(move |result| match result {
617            // The bytes are decoded on the decoding threads, not on whichever thread the
618            // fetcher answered on: that one is the network's, and a picture is a burst of
619            // memory that has to be counted with the others.
620            Ok(bytes) => in_background(Box::new(move || {
621                let outcome = guarded(|| decode(&bytes));
622                land(key, outcome);
623            })),
624            Err(why) => land(key, Fetched::Failed(why)),
625        }),
626    );
627    Fetched::Loading
628}
629
630/// Puts what became of a fetch in the store.
631fn land(key: String, outcome: Fetched) {
632    if let Ok(mut guard) = FETCHED.lock() {
633        let store = guard.get_or_insert_with(HashMap::new);
634        store.insert(
635            key,
636            Entry {
637                state: outcome,
638                used: CLOCK.fetch_add(1, Ordering::Relaxed),
639            },
640        );
641        // The one moment the store grows. Sweeping here rather than on every ask means
642        // the cost is paid once per picture that arrives, not once per frame per picture
643        // on screen.
644        evict_over_budget(store, BUDGET.load(Ordering::Relaxed));
645    }
646}
647
648/// How many images are in flight right now.
649///
650/// The interface has to keep drawing while any of them are, or the frame that would show
651/// the picture never happens. A count rather than a flag on the widget, because the
652/// application may well have taken the image **out** of the tree while it loads — that
653/// is what showing a placeholder means — and the work is still going on.
654///
655/// Counted off the store rather than kept in a separate tally beside it. A tally is a
656/// second copy of the same fact and the two can disagree: incremented here and
657/// decremented in a callback, it survives a [`forget_fetched_images`] that empties the
658/// store, and it can be decremented below zero by a callback that arrives after one.
659/// Either way the interface is left redrawing for ever over work that finished. The
660/// store already knows — the entries that say `Loading` **are** the ones in flight —
661/// and there are only ever as many entries as the application has network images.
662pub fn images_in_flight() -> usize {
663    let fetched = FETCHED
664        .lock()
665        .unwrap_or_else(|e| e.into_inner())
666        .as_ref()
667        .map_or(0, |store| {
668            store
669                .values()
670                .filter(|entry| matches!(entry.state, Fetched::Loading))
671                .count()
672        });
673    // And the embedded pictures still decoding on a thread.
674    let decoding = CACHE
675        .lock()
676        .unwrap_or_else(|e| e.into_inner())
677        .as_ref()
678        .map_or(0, |cache| {
679            cache
680                .values()
681                .filter(|state| matches!(state, Fetched::Loading))
682                .count()
683        });
684    fetched + decoding
685}
686
687/// Forgets every fetched image and every remembered failure. For tests; see
688/// [`forget_cached_images`].
689pub fn forget_fetched_images() {
690    if let Ok(mut guard) = FETCHED.lock() {
691        *guard = None;
692    }
693}
694
695/// How an image is fitted into its destination box — the same set of modes as the
696/// CSS `object-fit` property.
697#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
698pub enum BoxFit {
699    /// Stretches to fill the box; the aspect ratio is **not** preserved.
700    Fill,
701    /// The largest scale that still **fits** inside the box (letterbox).
702    #[default]
703    Contain,
704    /// The smallest scale that **covers** the box (cropping).
705    Cover,
706    /// Fits to the **width**; may overflow vertically.
707    FitWidth,
708    /// Fits to the **height**; may overflow horizontally.
709    FitHeight,
710    /// Natural size, centred — neither scaled up nor down.
711    None,
712    /// Like [`BoxFit::Contain`], but **never scaled up** — only down.
713    ScaleDown,
714}
715
716impl BoxFit {
717    /// Computes the **destination** rectangle (inside or around `dst`) and the **UV**
718    /// rectangle (the sub-region of the texture, in `0..1`) for drawing an image of
719    /// size `src` in this mode. `dst` is never cropped: letterboxing shrinks `dst`
720    /// and keeps full UV; cropping keeps `dst` full and shrinks the UV.
721    pub fn apply(self, src: Size, dst: Rect) -> (Rect, Rect) {
722        self.apply_aligned(src, dst, Alignment::CENTER)
723    }
724
725    /// [`BoxFit::apply`] with a say in **where** the spare room goes: which part of the
726    /// box a letterboxed image sits in, and which part of the image a cropping one keeps.
727    ///
728    /// Centre is the reference's default and what `apply` uses. It matters as soon as an
729    /// image is not the shape of its box: a portrait cropped to a banner should usually
730    /// keep its top, not its middle.
731    pub fn apply_aligned(self, src: Size, dst: Rect, align: Alignment) -> (Rect, Rect) {
732        let full_uv = Rect::new(0.0, 0.0, 1.0, 1.0);
733        if src.width <= 0.0 || src.height <= 0.0 || dst.width <= 0.0 || dst.height <= 0.0 {
734            return (dst, full_uv);
735        }
736        let sx = dst.width / src.width;
737        let sy = dst.height / src.height;
738        let (fx, fy) = (align.fraction_x(), align.fraction_y());
739
740        // Letterbox: the image at `scale`, placed inside `dst` by the alignment, with
741        // full UV.
742        let letterbox = |scale: f32| {
743            let w = src.width * scale;
744            let h = src.height * scale;
745            let x = dst.x + (dst.width - w) * fx;
746            let y = dst.y + (dst.height - h) * fy;
747            (Rect::new(x, y, w, h), full_uv)
748        };
749
750        match self {
751            BoxFit::Fill => (dst, full_uv),
752            BoxFit::Contain => letterbox(sx.min(sy)),
753            BoxFit::FitWidth => letterbox(sx),
754            BoxFit::FitHeight => letterbox(sy),
755            BoxFit::None => letterbox(1.0),
756            BoxFit::ScaleDown => letterbox(sx.min(sy).min(1.0)),
757            BoxFit::Cover => {
758                // The image covers `dst`; the crop happens in the UV, centred.
759                let scale = sx.max(sy);
760                let scaled_w = src.width * scale;
761                let scaled_h = src.height * scale;
762                let uv_w = (dst.width / scaled_w).min(1.0);
763                let uv_h = (dst.height / scaled_h).min(1.0);
764                // The crop travels the other way: aligning the image to the top means
765                // keeping the **top** of it, which is the low end of the UV.
766                let uv = Rect::new((1.0 - uv_w) * fx, (1.0 - uv_h) * fy, uv_w, uv_h);
767                (dst, uv)
768            }
769        }
770    }
771
772    /// The `(sx, sy)` scale factors that fit content of size `src` into a box of
773    /// size `dst` in this mode — the building block of `FittedBox`, where the
774    /// content, unlike a sampled image, is **scaled** and then centred. `Fill`
775    /// stretches per axis; every other mode is **uniform**, preserving the aspect
776    /// ratio. A degenerate `src` yields `(1, 1)`.
777    pub fn scale(self, src: Size, dst: Size) -> (f32, f32) {
778        if src.width <= 0.0 || src.height <= 0.0 {
779            return (1.0, 1.0);
780        }
781        let sx = dst.width / src.width;
782        let sy = dst.height / src.height;
783        match self {
784            BoxFit::Fill => (sx, sy),
785            BoxFit::Contain => {
786                let s = sx.min(sy);
787                (s, s)
788            }
789            BoxFit::Cover => {
790                let s = sx.max(sy);
791                (s, s)
792            }
793            BoxFit::FitWidth => (sx, sx),
794            BoxFit::FitHeight => (sy, sy),
795            BoxFit::None => (1.0, 1.0),
796            BoxFit::ScaleDown => {
797                let s = sx.min(sy).min(1.0);
798                (s, s)
799            }
800        }
801    }
802}
803
804/// The eviction sweep, driven directly rather than through a fetcher.
805///
806/// A network image needs a socket, a runtime and a shell; the rule being checked here
807/// needs none of them, and a test that stood up all three to prove that the older of two
808/// pictures goes first would be checking the shell.
809#[cfg(test)]
810mod cache_tests {
811    use super::*;
812
813    /// A picture of `mb` megabytes, ready and held only by the store.
814    fn ready(mb: usize) -> Fetched {
815        let side = 512u32;
816        let bytes = (side * side * 4) as usize;
817        let count = mb * 1024 * 1024 / bytes;
818        let rgba = vec![0u8; bytes * count.max(1)];
819        Fetched::Ready(
820            ImageData::from_rgba(side, (side as usize * count.max(1)) as u32, rgba).into_handle(),
821        )
822    }
823
824    fn store_of(entries: Vec<(&str, Fetched, u64)>) -> HashMap<String, Entry> {
825        entries
826            .into_iter()
827            .map(|(url, state, used)| (url.to_string(), Entry { state, used }))
828            .collect()
829    }
830
831    fn held(store: &HashMap<String, Entry>) -> usize {
832        store.values().map(|e| e.state.bytes()).sum()
833    }
834
835    /// Only `Ready` weighs anything: a request in flight holds no pixels yet, and a
836    /// failure holds a sentence.
837    #[test]
838    fn only_a_ready_image_weighs_anything() {
839        assert_eq!(Fetched::Loading.bytes(), 0);
840        assert_eq!(Fetched::Failed("gone".into()).bytes(), 0);
841        assert!(ready(4).bytes() >= 4 * 1024 * 1024);
842    }
843
844    /// Over the budget, the **least recently asked for** goes first.
845    #[test]
846    fn the_oldest_unheld_picture_goes_first() {
847        let mut store = store_of(vec![
848            ("old", ready(4), 1),
849            ("middling", ready(4), 5),
850            ("fresh", ready(4), 9),
851        ]);
852        evict_over_budget(&mut store, 9 * 1024 * 1024);
853        assert!(!store.contains_key("old"), "the oldest goes");
854        assert!(store.contains_key("fresh"), "the freshest stays");
855        assert!(held(&store) <= 9 * 1024 * 1024);
856    }
857
858    /// **A picture in flight is never dropped.** Dropping it cancels nothing — nothing
859    /// here can — it only forgets the request was made, so the next frame starts a
860    /// second one *and* `images_in_flight` falls to zero, stopping the redraw loop that
861    /// was keeping the frame alive until the picture arrived.
862    #[test]
863    fn a_picture_in_flight_is_never_dropped() {
864        let mut store = store_of(vec![
865            ("arriving", Fetched::Loading, 0),
866            ("big", ready(8), 1),
867        ]);
868        evict_over_budget(&mut store, 0);
869        assert!(store.contains_key("arriving"), "still in flight");
870        assert!(!store.contains_key("big"), "the pixels go");
871    }
872
873    /// **A picture somebody else is holding is never dropped.** It is on screen. Losing
874    /// it is not unsafe — the `Arc` keeps the pixels alive for whoever holds them —
875    /// but the next `view` would find nothing, ask again, and the image would flicker
876    /// through a placeholder on its way back to exactly where it was.
877    #[test]
878    fn a_picture_on_screen_is_never_dropped() {
879        let onscreen = ready(8);
880        // What a scene holding the image looks like from here: a second reference.
881        let Fetched::Ready(handle) = &onscreen else {
882            panic!("ready")
883        };
884        let _in_a_scene = handle.clone();
885
886        let mut store = store_of(vec![("visible", onscreen, 0), ("stale", ready(8), 1)]);
887        evict_over_budget(&mut store, 0);
888        assert!(store.contains_key("visible"), "held elsewhere: it stays");
889        assert!(!store.contains_key("stale"), "held only here: it goes");
890    }
891
892    /// When **everything** left is in flight or on screen, the sweep stops rather than
893    /// spinning. Over budget is the right answer there: the alternative is dropping
894    /// something being looked at.
895    #[test]
896    fn a_store_that_cannot_shrink_stops_rather_than_spins() {
897        let onscreen = ready(8);
898        let Fetched::Ready(handle) = &onscreen else {
899            panic!("ready")
900        };
901        let _in_a_scene = handle.clone();
902
903        let mut store = store_of(vec![
904            ("visible", onscreen, 0),
905            ("arriving", Fetched::Loading, 1),
906        ]);
907        evict_over_budget(&mut store, 0);
908        assert_eq!(store.len(), 2, "nothing could go, and nothing hung");
909    }
910
911    /// The default is the reference\'s figure, and it is a **default**: a caller can set
912    /// its own, including nothing at all.
913    #[test]
914    fn the_budget_is_the_callers() {
915        assert_eq!(DEFAULT_IMAGE_CACHE_BYTES, 100 * 1024 * 1024);
916        let before = image_cache_budget();
917        set_image_cache_budget(7);
918        assert_eq!(image_cache_budget(), 7);
919        set_image_cache_budget(before);
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    fn img(w: u32, h: u32) -> ImageData {
928        ImageData::from_rgba(w, h, vec![0u8; (w * h * 4) as usize])
929    }
930
931    #[test]
932    fn ids_are_unique_and_stable() {
933        let a = img(1, 1);
934        let b = img(1, 1);
935        assert_ne!(a.id(), b.id());
936        assert_eq!(a.id(), a.id());
937        assert_ne!(a, b); // equality is by identity, not by pixels
938    }
939
940    #[test]
941    fn fill_uses_the_whole_box_and_full_uv() {
942        let (dst, uv) = BoxFit::Fill.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 50.0));
943        assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 50.0));
944        assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
945    }
946
947    #[test]
948    fn contain_letterboxes_preserving_aspect() {
949        // A square source in a wide box → full height, centred horizontally.
950        let (dst, uv) =
951            BoxFit::Contain.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
952        assert_eq!(dst, Rect::new(30.0, 0.0, 40.0, 40.0));
953        assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
954    }
955
956    #[test]
957    fn scale_fits_content_per_mode() {
958        let src = Size::new(40.0, 20.0); // wide
959        let dst = Size::new(200.0, 200.0);
960        // Fill: per axis.
961        assert_eq!(BoxFit::Fill.scale(src, dst), (5.0, 10.0));
962        // Contain: the smaller factor (it fits) → uniform.
963        assert_eq!(BoxFit::Contain.scale(src, dst), (5.0, 5.0));
964        // Cover: the larger factor (it covers) → uniform.
965        assert_eq!(BoxFit::Cover.scale(src, dst), (10.0, 10.0));
966        // FitWidth / FitHeight follow a single axis, uniformly.
967        assert_eq!(BoxFit::FitWidth.scale(src, dst), (5.0, 5.0));
968        assert_eq!(BoxFit::FitHeight.scale(src, dst), (10.0, 10.0));
969        // None does not scale; ScaleDown only ever shrinks.
970        assert_eq!(BoxFit::None.scale(src, dst), (1.0, 1.0));
971        assert_eq!(BoxFit::ScaleDown.scale(src, dst), (1.0, 1.0));
972        // ScaleDown does shrink when the box is smaller.
973        assert_eq!(
974            BoxFit::ScaleDown.scale(src, Size::new(20.0, 20.0)),
975            (0.5, 0.5)
976        );
977        // Degenerate source → neutral.
978        assert_eq!(BoxFit::Cover.scale(Size::new(0.0, 10.0), dst), (1.0, 1.0));
979    }
980
981    #[test]
982    fn cover_fills_the_box_and_crops_uv() {
983        // A square source in a wide box → covers everything, crops vertically (UV).
984        let (dst, uv) =
985            BoxFit::Cover.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
986        assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 40.0));
987        // scale = max(10, 4) = 10 → a 100×100 image; visible = 40/100 = 0.4 of its height.
988        assert_eq!(uv.width, 1.0);
989        assert!((uv.height - 0.4).abs() < 1e-6);
990        assert!((uv.y - 0.3).abs() < 1e-6);
991    }
992}