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 process-wide store of images already turned into pixels, keyed by where their
92/// bytes live.
93///
94/// Decoding a PNG is not free and a view is rebuilt every frame, so an image resolved
95/// in `view` must be resolved **once** rather than sixty times a second. That is what
96/// this is for, and it is a process-wide store for the same reason the font registry is
97/// one: the same asset shown on three screens is one image, not three.
98///
99/// A failure is cached too. A file that is not a PNG will not become one on the next
100/// frame, and retrying the decode every frame would turn one broken asset into a
101/// permanent cost.
102static CACHE: Mutex<Option<HashMap<usize, Result<ImageHandle, String>>>> = Mutex::new(None);
103
104/// Resolves `bytes` to pixels **once**, returning the same handle on every later call.
105///
106/// The key is the **address** of the slice rather than its contents. That is exact for
107/// what this is for — `include_bytes!` gives a `&'static [u8]` whose address is fixed
108/// for the life of the process, and two distinct assets are two distinct statics — and
109/// it costs a pointer comparison, where hashing the bytes would cost re-reading the
110/// whole file on every frame that shows it.
111///
112/// The `'static` bound is what makes that sound: bytes that can be freed could have
113/// their address reused by something else, and the cache would hand back the wrong
114/// picture. An application holding runtime bytes decodes them itself and keeps the
115/// [`ImageHandle`].
116///
117/// `load` runs only on the first call for a given slice, and its failure is remembered.
118pub fn cached(
119    bytes: &'static [u8],
120    load: impl FnOnce(&[u8]) -> Result<ImageData, String>,
121) -> Result<ImageHandle, String> {
122    let key = bytes.as_ptr() as usize;
123    let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
124    let cache = guard.get_or_insert_with(HashMap::new);
125    if let Some(found) = cache.get(&key) {
126        return found.clone();
127    }
128    let resolved = load(bytes).map(ImageData::into_handle);
129    cache.insert(key, resolved.clone());
130    resolved
131}
132
133/// Empties the store. For tests that want a decode to happen again; an application has
134/// no reason to call it, since the entries are one per embedded asset and bounded by
135/// how many the binary carries.
136pub fn forget_cached_images() {
137    if let Ok(mut guard) = CACHE.lock() {
138        *guard = None;
139    }
140}
141
142/// What is known about an image that has to be **fetched** before it can be shown.
143///
144/// Three states rather than two, because "not here yet" and "will never be here" are
145/// different answers and an interface shows them differently: one is a placeholder, the
146/// other is a message.
147#[derive(Clone, Debug)]
148pub enum Fetched {
149    /// In flight. Ask again on a later frame.
150    Loading,
151    /// Here.
152    Ready(ImageHandle),
153    /// It will not arrive, and this is why.
154    Failed(String),
155}
156
157/// Hands the bytes at `url` to the callback, once, from wherever suits the platform.
158///
159/// A **function pointer** and not a closure: this is registered once for the process, and
160/// a plain `fn` needs no allocation, no lifetime and no `Sync` wrapper to store.
161pub type ImageFetcher = fn(&str, Box<dyn FnOnce(Result<Vec<u8>, String>) + Send + 'static>);
162
163impl Fetched {
164    /// The decoded pixels this state is holding on to, in bytes.
165    ///
166    /// `Loading` holds nothing yet and `Failed` holds a sentence; only `Ready` is
167    /// heavy, and it is as heavy as the picture is big.
168    fn bytes(&self) -> usize {
169        match self {
170            Fetched::Ready(handle) => handle.rgba.len(),
171            _ => 0,
172        }
173    }
174}
175
176/// A store entry: the state, plus **when it was last asked for**.
177struct Entry {
178    state: Fetched,
179    /// A tick from [`CLOCK`], not a wall time.
180    used: u64,
181}
182
183/// The default ceiling on decoded network images held in memory: the reference's own
184/// figure.
185///
186/// It is a **default**, not a rule — see [`set_image_cache_budget`]. A phone with a
187/// modest heap wants less; a desktop tool showing one enormous photograph may want more
188/// than one picture's worth.
189pub const DEFAULT_IMAGE_CACHE_BYTES: usize = 100 * 1024 * 1024;
190
191/// The ceiling on the total decoded size of `Ready` entries.
192static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_IMAGE_CACHE_BYTES);
193
194/// A monotonic tick, bumped every time an image is asked for, and the store's whole
195/// notion of *recently*.
196///
197/// A counter rather than a clock. It needs no platform time source — `frus-core`
198/// compiles for the Web, where `Instant::now` is not a thing — it cannot go backwards
199/// when a machine's clock is corrected, and *least recently used* only ever asks which
200/// of two numbers is smaller. A wall time would answer the same question with more
201/// machinery and one more way to be wrong.
202static CLOCK: AtomicU64 = AtomicU64::new(0);
203
204static FETCHER: Mutex<Option<ImageFetcher>> = Mutex::new(None);
205static FETCHED: Mutex<Option<HashMap<String, Entry>>> = Mutex::new(None);
206
207/// Sets the ceiling on decoded network images held in memory, in bytes.
208///
209/// `0` keeps nothing that nobody is holding: every image is dropped the moment it
210/// leaves the screen and fetched again when it comes back. That is a legitimate answer
211/// for a device with almost no memory, and a bad one for anything else.
212pub fn set_image_cache_budget(bytes: usize) {
213    BUDGET.store(bytes, Ordering::Relaxed);
214    let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
215    if let Some(store) = guard.as_mut() {
216        evict_over_budget(store, bytes);
217    }
218}
219
220/// The ceiling currently in force.
221pub fn image_cache_budget() -> usize {
222    BUDGET.load(Ordering::Relaxed)
223}
224
225/// The decoded bytes the store is currently holding.
226pub fn image_cache_bytes() -> usize {
227    FETCHED
228        .lock()
229        .unwrap_or_else(|e| e.into_inner())
230        .as_ref()
231        .map_or(0, |store| store.values().map(|e| e.state.bytes()).sum())
232}
233
234/// Drops least-recently-used images until the store is inside its budget.
235///
236/// **Two entries are never dropped**, and both exclusions are the difference between a
237/// cache and a bug:
238///
239/// - a `Loading` one is *in flight*. Dropping it does not cancel the request — nothing
240///   here can — it only forgets that one was made, so the next frame starts a second,
241///   and [`images_in_flight`] falls to zero, and the redraw loop that was keeping the
242///   frame alive until the picture arrived stops. The picture then lands in a store
243///   nobody will look at again until something else forces a frame.
244/// - one whose handle is held **elsewhere** is on screen, or in a scene about to be
245///   drawn. Dropping that is not unsafe — the `Arc` keeps the pixels alive for whoever
246///   holds them — but the next `view` finds nothing, asks again, and the image flickers
247///   through a placeholder on its way back to exactly where it was. A store that evicts
248///   what is visible is a store that fetches the same picture for ever.
249///
250/// `Arc::strong_count == 1` is how the second is asked: one reference, and it is this
251/// store's own.
252///
253/// The budget is a **parameter** rather than a read of [`BUDGET`] inside. The rule is
254/// worth testing on its own — which of two pictures goes first, and which two never do
255/// — and a function that reached for a process-wide value would make those tests race
256/// each other for it.
257fn evict_over_budget(store: &mut HashMap<String, Entry>, budget: usize) {
258    let mut held: usize = store.values().map(|e| e.state.bytes()).sum();
259    while held > budget {
260        let victim = store
261            .iter()
262            .filter(|(_, entry)| match &entry.state {
263                Fetched::Ready(handle) => Arc::strong_count(handle) == 1,
264                _ => false,
265            })
266            .min_by_key(|(_, entry)| entry.used)
267            .map(|(url, _)| url.clone());
268        // Nothing left that can go: every remaining picture is either in flight or on
269        // screen. Over budget is the right answer here — the alternative is dropping
270        // something that is being looked at.
271        let Some(url) = victim else { return };
272        if let Some(entry) = store.remove(&url) {
273            held -= entry.state.bytes();
274        }
275    }
276}
277
278/// Names the thing that gets bytes over the network.
279///
280/// The widget layer cannot do this itself and should not: it has no runtime, no socket
281/// and no dependency on the shell — the dependency runs the other way. So the shell,
282/// which has all three, says how, and the widget layer only asks. It is the shape the
283/// image **decoder** took a step earlier, for the same reason.
284///
285/// An application using the framework's own shell never calls this: the shell registers
286/// its `fetch_bytes` on the way up. One embedding frus in a host that already has an HTTP
287/// client can point this at that client instead.
288pub fn set_image_fetcher(fetcher: ImageFetcher) {
289    if let Ok(mut guard) = FETCHER.lock() {
290        *guard = Some(fetcher);
291    }
292}
293
294/// What is known about the image at `url`, starting the fetch if this is the first ask.
295///
296/// Called from a view, which runs every frame: the **first** call starts the work and
297/// says `Loading`, and every later one is a lookup. That is what makes it safe to write
298/// in a `view` at all — a fetch per frame would be sixty requests a second for one
299/// picture.
300///
301/// A failure is remembered like any other, and for the same reason as milestone 372's:
302/// a URL that 404s will 404 again, and retrying every frame turns one bad link into a
303/// permanent load on somebody's server.
304pub fn fetched(url: &str, decode: fn(&[u8]) -> Result<ImageData, String>) -> Fetched {
305    let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
306    let store = guard.get_or_insert_with(HashMap::new);
307    let now = CLOCK.fetch_add(1, Ordering::Relaxed);
308    if let Some(known) = store.get_mut(url) {
309        // Asked for is used: a picture on a screen nobody has scrolled away from is
310        // asked for on every frame, which is exactly what keeps it out of the way of
311        // the eviction sweep.
312        known.used = now;
313        return known.state.clone();
314    }
315    let Some(fetcher) = *FETCHER.lock().unwrap_or_else(|e| e.into_inner()) else {
316        // No fetcher: a build without the network, or a host that never named one.
317        // Saying so is an answer; hanging on `Loading` for ever is not.
318        let failed = Fetched::Failed("no image fetcher registered".to_string());
319        store.insert(
320            url.to_string(),
321            Entry {
322                state: failed.clone(),
323                used: now,
324            },
325        );
326        return failed;
327    };
328    store.insert(
329        url.to_string(),
330        Entry {
331            state: Fetched::Loading,
332            used: now,
333        },
334    );
335    // The lock goes **before** the call. A fetcher is free to answer on this thread —
336    // a cache hit in a host client, a test double — and its callback locks the same
337    // store, which would be a deadlock against a guard still held here.
338    drop(guard);
339
340    let key = url.to_string();
341    fetcher(
342        url,
343        Box::new(move |result| {
344            let outcome = match result {
345                Ok(bytes) => match decode(&bytes) {
346                    Ok(data) => Fetched::Ready(data.into_handle()),
347                    Err(why) => Fetched::Failed(why),
348                },
349                Err(why) => Fetched::Failed(why),
350            };
351            if let Ok(mut guard) = FETCHED.lock() {
352                let store = guard.get_or_insert_with(HashMap::new);
353                store.insert(
354                    key,
355                    Entry {
356                        state: outcome,
357                        used: CLOCK.fetch_add(1, Ordering::Relaxed),
358                    },
359                );
360                // The one moment the store grows. Sweeping here rather than on every
361                // ask means the cost is paid once per picture that arrives, not once
362                // per frame per picture on screen.
363                evict_over_budget(store, BUDGET.load(Ordering::Relaxed));
364            }
365        }),
366    );
367    Fetched::Loading
368}
369
370/// How many images are in flight right now.
371///
372/// The interface has to keep drawing while any of them are, or the frame that would show
373/// the picture never happens. A count rather than a flag on the widget, because the
374/// application may well have taken the image **out** of the tree while it loads — that
375/// is what showing a placeholder means — and the work is still going on.
376///
377/// Counted off the store rather than kept in a separate tally beside it. A tally is a
378/// second copy of the same fact and the two can disagree: incremented here and
379/// decremented in a callback, it survives a [`forget_fetched_images`] that empties the
380/// store, and it can be decremented below zero by a callback that arrives after one.
381/// Either way the interface is left redrawing for ever over work that finished. The
382/// store already knows — the entries that say `Loading` **are** the ones in flight —
383/// and there are only ever as many entries as the application has network images.
384pub fn images_in_flight() -> usize {
385    FETCHED
386        .lock()
387        .unwrap_or_else(|e| e.into_inner())
388        .as_ref()
389        .map_or(0, |store| {
390            store
391                .values()
392                .filter(|entry| matches!(entry.state, Fetched::Loading))
393                .count()
394        })
395}
396
397/// Forgets every fetched image and every remembered failure. For tests; see
398/// [`forget_cached_images`].
399pub fn forget_fetched_images() {
400    if let Ok(mut guard) = FETCHED.lock() {
401        *guard = None;
402    }
403}
404
405/// How an image is fitted into its destination box — the same set of modes as the
406/// CSS `object-fit` property.
407#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
408pub enum BoxFit {
409    /// Stretches to fill the box; the aspect ratio is **not** preserved.
410    Fill,
411    /// The largest scale that still **fits** inside the box (letterbox).
412    #[default]
413    Contain,
414    /// The smallest scale that **covers** the box (cropping).
415    Cover,
416    /// Fits to the **width**; may overflow vertically.
417    FitWidth,
418    /// Fits to the **height**; may overflow horizontally.
419    FitHeight,
420    /// Natural size, centred — neither scaled up nor down.
421    None,
422    /// Like [`BoxFit::Contain`], but **never scaled up** — only down.
423    ScaleDown,
424}
425
426impl BoxFit {
427    /// Computes the **destination** rectangle (inside or around `dst`) and the **UV**
428    /// rectangle (the sub-region of the texture, in `0..1`) for drawing an image of
429    /// size `src` in this mode. `dst` is never cropped: letterboxing shrinks `dst`
430    /// and keeps full UV; cropping keeps `dst` full and shrinks the UV.
431    pub fn apply(self, src: Size, dst: Rect) -> (Rect, Rect) {
432        self.apply_aligned(src, dst, Alignment::CENTER)
433    }
434
435    /// [`BoxFit::apply`] with a say in **where** the spare room goes: which part of the
436    /// box a letterboxed image sits in, and which part of the image a cropping one keeps.
437    ///
438    /// Centre is the reference's default and what `apply` uses. It matters as soon as an
439    /// image is not the shape of its box: a portrait cropped to a banner should usually
440    /// keep its top, not its middle.
441    pub fn apply_aligned(self, src: Size, dst: Rect, align: Alignment) -> (Rect, Rect) {
442        let full_uv = Rect::new(0.0, 0.0, 1.0, 1.0);
443        if src.width <= 0.0 || src.height <= 0.0 || dst.width <= 0.0 || dst.height <= 0.0 {
444            return (dst, full_uv);
445        }
446        let sx = dst.width / src.width;
447        let sy = dst.height / src.height;
448        let (fx, fy) = (align.fraction_x(), align.fraction_y());
449
450        // Letterbox: the image at `scale`, placed inside `dst` by the alignment, with
451        // full UV.
452        let letterbox = |scale: f32| {
453            let w = src.width * scale;
454            let h = src.height * scale;
455            let x = dst.x + (dst.width - w) * fx;
456            let y = dst.y + (dst.height - h) * fy;
457            (Rect::new(x, y, w, h), full_uv)
458        };
459
460        match self {
461            BoxFit::Fill => (dst, full_uv),
462            BoxFit::Contain => letterbox(sx.min(sy)),
463            BoxFit::FitWidth => letterbox(sx),
464            BoxFit::FitHeight => letterbox(sy),
465            BoxFit::None => letterbox(1.0),
466            BoxFit::ScaleDown => letterbox(sx.min(sy).min(1.0)),
467            BoxFit::Cover => {
468                // The image covers `dst`; the crop happens in the UV, centred.
469                let scale = sx.max(sy);
470                let scaled_w = src.width * scale;
471                let scaled_h = src.height * scale;
472                let uv_w = (dst.width / scaled_w).min(1.0);
473                let uv_h = (dst.height / scaled_h).min(1.0);
474                // The crop travels the other way: aligning the image to the top means
475                // keeping the **top** of it, which is the low end of the UV.
476                let uv = Rect::new((1.0 - uv_w) * fx, (1.0 - uv_h) * fy, uv_w, uv_h);
477                (dst, uv)
478            }
479        }
480    }
481
482    /// The `(sx, sy)` scale factors that fit content of size `src` into a box of
483    /// size `dst` in this mode — the building block of `FittedBox`, where the
484    /// content, unlike a sampled image, is **scaled** and then centred. `Fill`
485    /// stretches per axis; every other mode is **uniform**, preserving the aspect
486    /// ratio. A degenerate `src` yields `(1, 1)`.
487    pub fn scale(self, src: Size, dst: Size) -> (f32, f32) {
488        if src.width <= 0.0 || src.height <= 0.0 {
489            return (1.0, 1.0);
490        }
491        let sx = dst.width / src.width;
492        let sy = dst.height / src.height;
493        match self {
494            BoxFit::Fill => (sx, sy),
495            BoxFit::Contain => {
496                let s = sx.min(sy);
497                (s, s)
498            }
499            BoxFit::Cover => {
500                let s = sx.max(sy);
501                (s, s)
502            }
503            BoxFit::FitWidth => (sx, sx),
504            BoxFit::FitHeight => (sy, sy),
505            BoxFit::None => (1.0, 1.0),
506            BoxFit::ScaleDown => {
507                let s = sx.min(sy).min(1.0);
508                (s, s)
509            }
510        }
511    }
512}
513
514/// The eviction sweep, driven directly rather than through a fetcher.
515///
516/// A network image needs a socket, a runtime and a shell; the rule being checked here
517/// needs none of them, and a test that stood up all three to prove that the older of two
518/// pictures goes first would be checking the shell.
519#[cfg(test)]
520mod cache_tests {
521    use super::*;
522
523    /// A picture of `mb` megabytes, ready and held only by the store.
524    fn ready(mb: usize) -> Fetched {
525        let side = 512u32;
526        let bytes = (side * side * 4) as usize;
527        let count = mb * 1024 * 1024 / bytes;
528        let rgba = vec![0u8; bytes * count.max(1)];
529        Fetched::Ready(
530            ImageData::from_rgba(side, (side as usize * count.max(1)) as u32, rgba).into_handle(),
531        )
532    }
533
534    fn store_of(entries: Vec<(&str, Fetched, u64)>) -> HashMap<String, Entry> {
535        entries
536            .into_iter()
537            .map(|(url, state, used)| (url.to_string(), Entry { state, used }))
538            .collect()
539    }
540
541    fn held(store: &HashMap<String, Entry>) -> usize {
542        store.values().map(|e| e.state.bytes()).sum()
543    }
544
545    /// Only `Ready` weighs anything: a request in flight holds no pixels yet, and a
546    /// failure holds a sentence.
547    #[test]
548    fn only_a_ready_image_weighs_anything() {
549        assert_eq!(Fetched::Loading.bytes(), 0);
550        assert_eq!(Fetched::Failed("gone".into()).bytes(), 0);
551        assert!(ready(4).bytes() >= 4 * 1024 * 1024);
552    }
553
554    /// Over the budget, the **least recently asked for** goes first.
555    #[test]
556    fn the_oldest_unheld_picture_goes_first() {
557        let mut store = store_of(vec![
558            ("old", ready(4), 1),
559            ("middling", ready(4), 5),
560            ("fresh", ready(4), 9),
561        ]);
562        evict_over_budget(&mut store, 9 * 1024 * 1024);
563        assert!(!store.contains_key("old"), "the oldest goes");
564        assert!(store.contains_key("fresh"), "the freshest stays");
565        assert!(held(&store) <= 9 * 1024 * 1024);
566    }
567
568    /// **A picture in flight is never dropped.** Dropping it cancels nothing — nothing
569    /// here can — it only forgets the request was made, so the next frame starts a
570    /// second one *and* `images_in_flight` falls to zero, stopping the redraw loop that
571    /// was keeping the frame alive until the picture arrived.
572    #[test]
573    fn a_picture_in_flight_is_never_dropped() {
574        let mut store = store_of(vec![
575            ("arriving", Fetched::Loading, 0),
576            ("big", ready(8), 1),
577        ]);
578        evict_over_budget(&mut store, 0);
579        assert!(store.contains_key("arriving"), "still in flight");
580        assert!(!store.contains_key("big"), "the pixels go");
581    }
582
583    /// **A picture somebody else is holding is never dropped.** It is on screen. Losing
584    /// it is not unsafe — the `Arc` keeps the pixels alive for whoever holds them —
585    /// but the next `view` would find nothing, ask again, and the image would flicker
586    /// through a placeholder on its way back to exactly where it was.
587    #[test]
588    fn a_picture_on_screen_is_never_dropped() {
589        let onscreen = ready(8);
590        // What a scene holding the image looks like from here: a second reference.
591        let Fetched::Ready(handle) = &onscreen else {
592            panic!("ready")
593        };
594        let _in_a_scene = handle.clone();
595
596        let mut store = store_of(vec![("visible", onscreen, 0), ("stale", ready(8), 1)]);
597        evict_over_budget(&mut store, 0);
598        assert!(store.contains_key("visible"), "held elsewhere: it stays");
599        assert!(!store.contains_key("stale"), "held only here: it goes");
600    }
601
602    /// When **everything** left is in flight or on screen, the sweep stops rather than
603    /// spinning. Over budget is the right answer there: the alternative is dropping
604    /// something being looked at.
605    #[test]
606    fn a_store_that_cannot_shrink_stops_rather_than_spins() {
607        let onscreen = ready(8);
608        let Fetched::Ready(handle) = &onscreen else {
609            panic!("ready")
610        };
611        let _in_a_scene = handle.clone();
612
613        let mut store = store_of(vec![
614            ("visible", onscreen, 0),
615            ("arriving", Fetched::Loading, 1),
616        ]);
617        evict_over_budget(&mut store, 0);
618        assert_eq!(store.len(), 2, "nothing could go, and nothing hung");
619    }
620
621    /// The default is the reference\'s figure, and it is a **default**: a caller can set
622    /// its own, including nothing at all.
623    #[test]
624    fn the_budget_is_the_callers() {
625        assert_eq!(DEFAULT_IMAGE_CACHE_BYTES, 100 * 1024 * 1024);
626        let before = image_cache_budget();
627        set_image_cache_budget(7);
628        assert_eq!(image_cache_budget(), 7);
629        set_image_cache_budget(before);
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    fn img(w: u32, h: u32) -> ImageData {
638        ImageData::from_rgba(w, h, vec![0u8; (w * h * 4) as usize])
639    }
640
641    #[test]
642    fn ids_are_unique_and_stable() {
643        let a = img(1, 1);
644        let b = img(1, 1);
645        assert_ne!(a.id(), b.id());
646        assert_eq!(a.id(), a.id());
647        assert_ne!(a, b); // equality is by identity, not by pixels
648    }
649
650    #[test]
651    fn fill_uses_the_whole_box_and_full_uv() {
652        let (dst, uv) = BoxFit::Fill.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 50.0));
653        assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 50.0));
654        assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
655    }
656
657    #[test]
658    fn contain_letterboxes_preserving_aspect() {
659        // A square source in a wide box → full height, centred horizontally.
660        let (dst, uv) =
661            BoxFit::Contain.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
662        assert_eq!(dst, Rect::new(30.0, 0.0, 40.0, 40.0));
663        assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
664    }
665
666    #[test]
667    fn scale_fits_content_per_mode() {
668        let src = Size::new(40.0, 20.0); // wide
669        let dst = Size::new(200.0, 200.0);
670        // Fill: per axis.
671        assert_eq!(BoxFit::Fill.scale(src, dst), (5.0, 10.0));
672        // Contain: the smaller factor (it fits) → uniform.
673        assert_eq!(BoxFit::Contain.scale(src, dst), (5.0, 5.0));
674        // Cover: the larger factor (it covers) → uniform.
675        assert_eq!(BoxFit::Cover.scale(src, dst), (10.0, 10.0));
676        // FitWidth / FitHeight follow a single axis, uniformly.
677        assert_eq!(BoxFit::FitWidth.scale(src, dst), (5.0, 5.0));
678        assert_eq!(BoxFit::FitHeight.scale(src, dst), (10.0, 10.0));
679        // None does not scale; ScaleDown only ever shrinks.
680        assert_eq!(BoxFit::None.scale(src, dst), (1.0, 1.0));
681        assert_eq!(BoxFit::ScaleDown.scale(src, dst), (1.0, 1.0));
682        // ScaleDown does shrink when the box is smaller.
683        assert_eq!(
684            BoxFit::ScaleDown.scale(src, Size::new(20.0, 20.0)),
685            (0.5, 0.5)
686        );
687        // Degenerate source → neutral.
688        assert_eq!(BoxFit::Cover.scale(Size::new(0.0, 10.0), dst), (1.0, 1.0));
689    }
690
691    #[test]
692    fn cover_fills_the_box_and_crops_uv() {
693        // A square source in a wide box → covers everything, crops vertically (UV).
694        let (dst, uv) =
695            BoxFit::Cover.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
696        assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 40.0));
697        // scale = max(10, 4) = 10 → a 100×100 image; visible = 40/100 = 0.4 of its height.
698        assert_eq!(uv.width, 1.0);
699        assert!((uv.height - 0.4).abs() < 1e-6);
700        assert!((uv.y - 0.3).abs() < 1e-6);
701    }
702}