use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use crate::{Alignment, Rect, Size};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug)]
pub struct ImageData {
id: u64,
width: u32,
height: u32,
rgba: Vec<u8>,
}
impl ImageData {
pub fn from_rgba(width: u32, height: u32, rgba: Vec<u8>) -> Self {
assert_eq!(
rgba.len(),
(width as usize) * (height as usize) * 4,
"RGBA pixels must be width*height*4 bytes"
);
Self {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
width,
height,
rgba,
}
}
pub fn into_handle(self) -> ImageHandle {
Arc::new(self)
}
pub fn id(&self) -> u64 {
self.id
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn size(&self) -> Size {
Size::new(self.width as f32, self.height as f32)
}
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
}
impl PartialEq for ImageData {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for ImageData {}
pub type ImageHandle = Arc<ImageData>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct DecodeSize {
pub width: Option<u32>,
pub height: Option<u32>,
}
impl DecodeSize {
pub const FULL: Self = Self {
width: None,
height: None,
};
pub fn new(width: Option<u32>, height: Option<u32>) -> Self {
Self { width, height }
}
pub fn is_full(self) -> bool {
self.width.is_none() && self.height.is_none()
}
pub fn target(self, full_width: u32, full_height: u32) -> (u32, u32) {
if full_width == 0 || full_height == 0 {
return (full_width, full_height);
}
let along = |wanted: Option<u32>, full: u32| wanted.map(|w| w as f64 / full as f64);
let scale = match (
along(self.width, full_width),
along(self.height, full_height),
) {
(None, None) => return (full_width, full_height),
(Some(s), None) | (None, Some(s)) => s,
(Some(a), Some(b)) => a.max(b),
};
if scale >= 1.0 {
return (full_width, full_height);
}
let side = |full: u32| ((full as f64 * scale).round() as u32).clamp(1, full);
(side(full_width), side(full_height))
}
}
static CACHE: Mutex<Option<HashMap<(usize, DecodeSize), Fetched>>> = Mutex::new(None);
#[cfg(not(target_family = "wasm"))]
const DECODING_THREADS: usize = 2;
type Job = Box<dyn FnOnce() + Send + 'static>;
fn in_background(job: Job) {
#[cfg(target_family = "wasm")]
job();
#[cfg(not(target_family = "wasm"))]
{
static QUEUE: std::sync::OnceLock<Option<std::sync::mpsc::Sender<Job>>> =
std::sync::OnceLock::new();
let queue = QUEUE.get_or_init(|| {
let (sender, receiver) = std::sync::mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = std::thread::available_parallelism()
.map_or(1, |n| n.get().saturating_sub(1))
.clamp(1, DECODING_THREADS);
let started = (0..workers)
.filter(|i| {
let receiver = Arc::clone(&receiver);
std::thread::Builder::new()
.name(format!("frus-image-{i}"))
.spawn(move || loop {
let next = receiver.lock().unwrap_or_else(|e| e.into_inner()).recv();
match next {
Ok(job) => job(),
Err(_) => return,
}
})
.is_ok()
})
.count();
(started > 0).then_some(sender)
});
match queue {
Some(sender) => {
if let Err(refused) = sender.send(job) {
(refused.0)();
}
}
None => job(),
}
}
}
fn guarded(decode: impl FnOnce() -> Result<ImageData, String>) -> Fetched {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(decode)) {
Ok(Ok(data)) => Fetched::Ready(data.into_handle()),
Ok(Err(why)) => Fetched::Failed(why),
Err(_) => Fetched::Failed("the image decoder panicked".to_string()),
}
}
pub fn cached(
bytes: &'static [u8],
load: impl FnOnce(&[u8]) -> Result<ImageData, String>,
) -> Result<ImageHandle, String> {
cached_at(bytes, DecodeSize::FULL, load)
}
pub fn cached_at(
bytes: &'static [u8],
size: DecodeSize,
load: impl FnOnce(&[u8]) -> Result<ImageData, String>,
) -> Result<ImageHandle, String> {
let key = (bytes.as_ptr() as usize, size);
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = guard.get_or_insert_with(HashMap::new);
match cache.get(&key) {
Some(Fetched::Ready(handle)) => return Ok(handle.clone()),
Some(Fetched::Failed(why)) => return Err(why.clone()),
Some(Fetched::Loading) | None => {}
}
let resolved = load(bytes).map(ImageData::into_handle);
let state = match &resolved {
Ok(handle) => Fetched::Ready(handle.clone()),
Err(why) => Fetched::Failed(why.clone()),
};
cache.insert(key, state);
resolved
}
pub fn decoded_at(
bytes: &'static [u8],
size: DecodeSize,
decode: fn(&[u8], DecodeSize) -> Result<ImageData, String>,
in_the_background: bool,
) -> Fetched {
let key = (bytes.as_ptr() as usize, size);
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = guard.get_or_insert_with(HashMap::new);
if let Some(found) = cache.get(&key) {
return found.clone();
}
if !in_the_background {
let state = guarded(|| decode(bytes, size));
cache.insert(key, state.clone());
return state;
}
cache.insert(key, Fetched::Loading);
drop(guard);
in_background(Box::new(move || {
let state = guarded(|| decode(bytes, size));
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
guard.get_or_insert_with(HashMap::new).insert(key, state);
}));
CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.and_then(|cache| cache.get(&key).cloned())
.unwrap_or(Fetched::Loading)
}
pub fn forget_cached_images() {
if let Ok(mut guard) = CACHE.lock() {
*guard = None;
}
}
pub fn trim_image_caches() {
if let Ok(mut guard) = CACHE.lock() {
if let Some(cache) = guard.as_mut() {
cache.retain(|_, entry| match entry {
Fetched::Ready(handle) => Arc::strong_count(handle) > 1,
Fetched::Failed(_) | Fetched::Loading => true,
});
}
}
let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
if let Some(store) = guard.as_mut() {
evict_over_budget(store, 0);
}
}
#[derive(Clone, Debug)]
pub enum Fetched {
Loading,
Ready(ImageHandle),
Failed(String),
}
pub type ImageFetcher = fn(&str, Box<dyn FnOnce(Result<Vec<u8>, String>) + Send + 'static>);
impl Fetched {
fn bytes(&self) -> usize {
match self {
Fetched::Ready(handle) => handle.rgba.len(),
_ => 0,
}
}
}
struct Entry {
state: Fetched,
used: u64,
}
pub const DEFAULT_IMAGE_CACHE_BYTES: usize = 100 * 1024 * 1024;
static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_IMAGE_CACHE_BYTES);
static CLOCK: AtomicU64 = AtomicU64::new(0);
static FETCHER: Mutex<Option<ImageFetcher>> = Mutex::new(None);
static FETCHED: Mutex<Option<HashMap<String, Entry>>> = Mutex::new(None);
pub fn set_image_cache_budget(bytes: usize) {
BUDGET.store(bytes, Ordering::Relaxed);
let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
if let Some(store) = guard.as_mut() {
evict_over_budget(store, bytes);
}
}
pub fn image_cache_budget() -> usize {
BUDGET.load(Ordering::Relaxed)
}
pub fn image_cache_bytes() -> usize {
FETCHED
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map_or(0, |store| store.values().map(|e| e.state.bytes()).sum())
}
fn evict_over_budget(store: &mut HashMap<String, Entry>, budget: usize) {
let mut held: usize = store.values().map(|e| e.state.bytes()).sum();
while held > budget {
let victim = store
.iter()
.filter(|(_, entry)| match &entry.state {
Fetched::Ready(handle) => Arc::strong_count(handle) == 1,
_ => false,
})
.min_by_key(|(_, entry)| entry.used)
.map(|(url, _)| url.clone());
let Some(url) = victim else { return };
if let Some(entry) = store.remove(&url) {
held -= entry.state.bytes();
}
}
}
pub fn set_image_fetcher(fetcher: ImageFetcher) {
if let Ok(mut guard) = FETCHER.lock() {
*guard = Some(fetcher);
}
}
pub fn image_fetcher() -> Option<ImageFetcher> {
*FETCHER.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn fetched(url: &str, decode: fn(&[u8]) -> Result<ImageData, String>) -> Fetched {
fetch(url, url, Box::new(decode))
}
pub fn fetched_at(
url: &str,
size: DecodeSize,
decode: fn(&[u8], DecodeSize) -> Result<ImageData, String>,
) -> Fetched {
if size.is_full() {
return fetch(url, url, Box::new(move |bytes| decode(bytes, size)));
}
let key = sized_key(url, size);
fetch(&key, url, Box::new(move |bytes| decode(bytes, size)))
}
fn sized_key(url: &str, size: DecodeSize) -> String {
let side = |s: Option<u32>| s.map_or(String::new(), |s| s.to_string());
format!("{url}\0{}x{}", side(size.width), side(size.height))
}
type Decoder = Box<dyn FnOnce(&[u8]) -> Result<ImageData, String> + Send + 'static>;
fn fetch(key: &str, url: &str, decode: Decoder) -> Fetched {
let mut guard = FETCHED.lock().unwrap_or_else(|e| e.into_inner());
let store = guard.get_or_insert_with(HashMap::new);
let now = CLOCK.fetch_add(1, Ordering::Relaxed);
if let Some(known) = store.get_mut(key) {
known.used = now;
return known.state.clone();
}
let Some(fetcher) = *FETCHER.lock().unwrap_or_else(|e| e.into_inner()) else {
let failed = Fetched::Failed("no image fetcher registered".to_string());
store.insert(
key.to_string(),
Entry {
state: failed.clone(),
used: now,
},
);
return failed;
};
store.insert(
key.to_string(),
Entry {
state: Fetched::Loading,
used: now,
},
);
drop(guard);
let key = key.to_string();
fetcher(
url,
Box::new(move |result| match result {
Ok(bytes) => in_background(Box::new(move || {
let outcome = guarded(|| decode(&bytes));
land(key, outcome);
})),
Err(why) => land(key, Fetched::Failed(why)),
}),
);
Fetched::Loading
}
fn land(key: String, outcome: Fetched) {
if let Ok(mut guard) = FETCHED.lock() {
let store = guard.get_or_insert_with(HashMap::new);
store.insert(
key,
Entry {
state: outcome,
used: CLOCK.fetch_add(1, Ordering::Relaxed),
},
);
evict_over_budget(store, BUDGET.load(Ordering::Relaxed));
}
}
pub fn images_in_flight() -> usize {
let fetched = FETCHED
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map_or(0, |store| {
store
.values()
.filter(|entry| matches!(entry.state, Fetched::Loading))
.count()
});
let decoding = CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map_or(0, |cache| {
cache
.values()
.filter(|state| matches!(state, Fetched::Loading))
.count()
});
fetched + decoding
}
pub fn forget_fetched_images() {
if let Ok(mut guard) = FETCHED.lock() {
*guard = None;
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum BoxFit {
Fill,
#[default]
Contain,
Cover,
FitWidth,
FitHeight,
None,
ScaleDown,
}
impl BoxFit {
pub fn apply(self, src: Size, dst: Rect) -> (Rect, Rect) {
self.apply_aligned(src, dst, Alignment::CENTER)
}
pub fn apply_aligned(self, src: Size, dst: Rect, align: Alignment) -> (Rect, Rect) {
let full_uv = Rect::new(0.0, 0.0, 1.0, 1.0);
if src.width <= 0.0 || src.height <= 0.0 || dst.width <= 0.0 || dst.height <= 0.0 {
return (dst, full_uv);
}
let sx = dst.width / src.width;
let sy = dst.height / src.height;
let (fx, fy) = (align.fraction_x(), align.fraction_y());
let letterbox = |scale: f32| {
let w = src.width * scale;
let h = src.height * scale;
let x = dst.x + (dst.width - w) * fx;
let y = dst.y + (dst.height - h) * fy;
(Rect::new(x, y, w, h), full_uv)
};
match self {
BoxFit::Fill => (dst, full_uv),
BoxFit::Contain => letterbox(sx.min(sy)),
BoxFit::FitWidth => letterbox(sx),
BoxFit::FitHeight => letterbox(sy),
BoxFit::None => letterbox(1.0),
BoxFit::ScaleDown => letterbox(sx.min(sy).min(1.0)),
BoxFit::Cover => {
let scale = sx.max(sy);
let scaled_w = src.width * scale;
let scaled_h = src.height * scale;
let uv_w = (dst.width / scaled_w).min(1.0);
let uv_h = (dst.height / scaled_h).min(1.0);
let uv = Rect::new((1.0 - uv_w) * fx, (1.0 - uv_h) * fy, uv_w, uv_h);
(dst, uv)
}
}
}
pub fn scale(self, src: Size, dst: Size) -> (f32, f32) {
if src.width <= 0.0 || src.height <= 0.0 {
return (1.0, 1.0);
}
let sx = dst.width / src.width;
let sy = dst.height / src.height;
match self {
BoxFit::Fill => (sx, sy),
BoxFit::Contain => {
let s = sx.min(sy);
(s, s)
}
BoxFit::Cover => {
let s = sx.max(sy);
(s, s)
}
BoxFit::FitWidth => (sx, sx),
BoxFit::FitHeight => (sy, sy),
BoxFit::None => (1.0, 1.0),
BoxFit::ScaleDown => {
let s = sx.min(sy).min(1.0);
(s, s)
}
}
}
}
#[cfg(test)]
mod cache_tests {
use super::*;
fn ready(mb: usize) -> Fetched {
let side = 512u32;
let bytes = (side * side * 4) as usize;
let count = mb * 1024 * 1024 / bytes;
let rgba = vec![0u8; bytes * count.max(1)];
Fetched::Ready(
ImageData::from_rgba(side, (side as usize * count.max(1)) as u32, rgba).into_handle(),
)
}
fn store_of(entries: Vec<(&str, Fetched, u64)>) -> HashMap<String, Entry> {
entries
.into_iter()
.map(|(url, state, used)| (url.to_string(), Entry { state, used }))
.collect()
}
fn held(store: &HashMap<String, Entry>) -> usize {
store.values().map(|e| e.state.bytes()).sum()
}
#[test]
fn only_a_ready_image_weighs_anything() {
assert_eq!(Fetched::Loading.bytes(), 0);
assert_eq!(Fetched::Failed("gone".into()).bytes(), 0);
assert!(ready(4).bytes() >= 4 * 1024 * 1024);
}
#[test]
fn the_oldest_unheld_picture_goes_first() {
let mut store = store_of(vec![
("old", ready(4), 1),
("middling", ready(4), 5),
("fresh", ready(4), 9),
]);
evict_over_budget(&mut store, 9 * 1024 * 1024);
assert!(!store.contains_key("old"), "the oldest goes");
assert!(store.contains_key("fresh"), "the freshest stays");
assert!(held(&store) <= 9 * 1024 * 1024);
}
#[test]
fn a_picture_in_flight_is_never_dropped() {
let mut store = store_of(vec![
("arriving", Fetched::Loading, 0),
("big", ready(8), 1),
]);
evict_over_budget(&mut store, 0);
assert!(store.contains_key("arriving"), "still in flight");
assert!(!store.contains_key("big"), "the pixels go");
}
#[test]
fn a_picture_on_screen_is_never_dropped() {
let onscreen = ready(8);
let Fetched::Ready(handle) = &onscreen else {
panic!("ready")
};
let _in_a_scene = handle.clone();
let mut store = store_of(vec![("visible", onscreen, 0), ("stale", ready(8), 1)]);
evict_over_budget(&mut store, 0);
assert!(store.contains_key("visible"), "held elsewhere: it stays");
assert!(!store.contains_key("stale"), "held only here: it goes");
}
#[test]
fn a_store_that_cannot_shrink_stops_rather_than_spins() {
let onscreen = ready(8);
let Fetched::Ready(handle) = &onscreen else {
panic!("ready")
};
let _in_a_scene = handle.clone();
let mut store = store_of(vec![
("visible", onscreen, 0),
("arriving", Fetched::Loading, 1),
]);
evict_over_budget(&mut store, 0);
assert_eq!(store.len(), 2, "nothing could go, and nothing hung");
}
#[test]
fn the_budget_is_the_callers() {
assert_eq!(DEFAULT_IMAGE_CACHE_BYTES, 100 * 1024 * 1024);
let before = image_cache_budget();
set_image_cache_budget(7);
assert_eq!(image_cache_budget(), 7);
set_image_cache_budget(before);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn img(w: u32, h: u32) -> ImageData {
ImageData::from_rgba(w, h, vec![0u8; (w * h * 4) as usize])
}
#[test]
fn ids_are_unique_and_stable() {
let a = img(1, 1);
let b = img(1, 1);
assert_ne!(a.id(), b.id());
assert_eq!(a.id(), a.id());
assert_ne!(a, b); }
#[test]
fn fill_uses_the_whole_box_and_full_uv() {
let (dst, uv) = BoxFit::Fill.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 50.0));
assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 50.0));
assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
}
#[test]
fn contain_letterboxes_preserving_aspect() {
let (dst, uv) =
BoxFit::Contain.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
assert_eq!(dst, Rect::new(30.0, 0.0, 40.0, 40.0));
assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
}
#[test]
fn scale_fits_content_per_mode() {
let src = Size::new(40.0, 20.0); let dst = Size::new(200.0, 200.0);
assert_eq!(BoxFit::Fill.scale(src, dst), (5.0, 10.0));
assert_eq!(BoxFit::Contain.scale(src, dst), (5.0, 5.0));
assert_eq!(BoxFit::Cover.scale(src, dst), (10.0, 10.0));
assert_eq!(BoxFit::FitWidth.scale(src, dst), (5.0, 5.0));
assert_eq!(BoxFit::FitHeight.scale(src, dst), (10.0, 10.0));
assert_eq!(BoxFit::None.scale(src, dst), (1.0, 1.0));
assert_eq!(BoxFit::ScaleDown.scale(src, dst), (1.0, 1.0));
assert_eq!(
BoxFit::ScaleDown.scale(src, Size::new(20.0, 20.0)),
(0.5, 0.5)
);
assert_eq!(BoxFit::Cover.scale(Size::new(0.0, 10.0), dst), (1.0, 1.0));
}
#[test]
fn cover_fills_the_box_and_crops_uv() {
let (dst, uv) =
BoxFit::Cover.apply(Size::new(10.0, 10.0), Rect::new(0.0, 0.0, 100.0, 40.0));
assert_eq!(dst, Rect::new(0.0, 0.0, 100.0, 40.0));
assert_eq!(uv.width, 1.0);
assert!((uv.height - 0.4).abs() < 1e-6);
assert!((uv.y - 0.3).abs() < 1e-6);
}
}