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}