use crate::ecs::{OverlayImage, OverlayImages, TextureHandle};
use concinnity_cook::cache::thumbnails::Thumbnails;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
const HANDLE_BASE: u32 = 0x4000_0000;
const MAX_THUMBS: usize = 256;
#[derive(Debug, Clone, Copy)]
pub(crate) struct Thumb {
pub handle: TextureHandle,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Default)]
pub(crate) struct ThumbSet {
by_name: HashMap<String, Thumb>,
images: Vec<OverlayImage>,
}
impl ThumbSet {
pub(crate) fn get(&self, name: &str) -> Option<Thumb> {
self.by_name.get(name).copied()
}
pub(crate) fn overlay_images(&self) -> OverlayImages {
OverlayImages(self.images.clone())
}
}
struct Cache {
revision: Option<u64>,
current: Arc<ThumbSet>,
injected: Arc<ThumbSet>,
}
fn cache() -> &'static Mutex<Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE.get_or_init(|| {
Mutex::new(Cache {
revision: None,
current: Arc::new(ThumbSet::default()),
injected: Arc::new(ThumbSet::default()),
})
})
}
fn current() -> Arc<ThumbSet> {
let thumbs = Thumbnails::open();
let revision = thumbs.as_ref().map(Thumbnails::revision);
let mut cache = cache().lock().unwrap();
if cache.revision != revision {
cache.current = Arc::new(match &thumbs {
Some(thumbs) => load(thumbs.names(), &|key| thumbs.png(key)),
None => ThumbSet::default(),
});
cache.revision = revision;
}
cache.current.clone()
}
pub(crate) fn capture_for_injection() -> OverlayImages {
let set = current();
let images = set.overlay_images();
tracing::info!(
"thumbnails: {} baked image(s) available to the Content panel",
images.0.len()
);
cache().lock().unwrap().injected = set;
images
}
pub(crate) fn injected() -> Arc<ThumbSet> {
cache().lock().unwrap().injected.clone()
}
fn load(names: &[(String, String)], png: &dyn Fn(&str) -> Option<Vec<u8>>) -> ThumbSet {
let mut set = ThumbSet::default();
for (name, key) in names {
if set.images.len() >= MAX_THUMBS {
tracing::info!(
"thumbnails: cap of {MAX_THUMBS} reached; remaining assets show typed icons"
);
break;
}
let Some((w, h, rgba)) = png(key).and_then(|bytes| decode_png(&bytes)) else {
continue;
};
let handle = TextureHandle(HANDLE_BASE + set.images.len() as u32);
set.by_name.insert(
name.clone(),
Thumb {
handle,
width: w,
height: h,
},
);
set.images.push(OverlayImage {
handle,
width: w,
height: h,
rgba,
});
}
set
}
fn decode_png(bytes: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
let mut reader = decoder.read_info().ok()?;
let mut buf = vec![0; reader.output_buffer_size()?];
let info = reader.next_frame(&mut buf).ok()?;
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
return None;
}
buf.truncate(info.buffer_size());
Some((info.width, info.height, buf))
}
pub(crate) fn fit_rect(rect: [f32; 4], w: u32, h: u32) -> [f32; 4] {
if w == 0 || h == 0 {
return rect;
}
let scale = (rect[2] / w as f32).min(rect[3] / h as f32);
let (fw, fh) = (w as f32 * scale, h as f32 * scale);
[
rect[0] + (rect[2] - fw) * 0.5,
rect[1] + (rect[3] - fh) * 0.5,
fw,
fh,
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fit_rect_letterboxes_and_centers() {
let cell = [10.0, 10.0, 80.0, 80.0];
assert_eq!(fit_rect(cell, 128, 128), cell);
let wide = fit_rect(cell, 128, 64);
assert_eq!(wide, [10.0, 30.0, 80.0, 40.0]);
let tall = fit_rect(cell, 64, 128);
assert_eq!(tall, [30.0, 10.0, 40.0, 80.0]);
assert_eq!(fit_rect(cell, 0, 5), cell, "degenerate dims fill the cell");
}
fn png_of(w: u32, h: u32) -> Vec<u8> {
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
enc.write_header()
.unwrap()
.write_image_data(&vec![128u8; (w * h * 4) as usize])
.unwrap();
out
}
fn names(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(n, k)| ((*n).to_string(), (*k).to_string()))
.collect()
}
#[test]
fn load_reads_the_name_map_and_assigns_reserved_handles() {
let entries: HashMap<&str, Vec<u8>> = [("aaa", png_of(8, 4)), ("bbb", png_of(4, 4))].into();
let set = load(
&names(&[("tex_one", "aaa"), ("tex_two", "bbb"), ("missing", "nope")]),
&|key| entries.get(key).cloned(),
);
assert_eq!(set.images.len(), 2, "the missing entry is skipped");
let one = set.get("tex_one").unwrap();
assert_eq!((one.width, one.height), (8, 4));
assert!(one.handle.0 >= HANDLE_BASE);
let two = set.get("tex_two").unwrap();
assert_ne!(one.handle, two.handle);
assert!(set.get("missing").is_none());
let images = set.overlay_images();
assert_eq!(images.0.len(), 2);
assert_eq!(images.0[0].rgba.len(), 8 * 4 * 4);
}
#[test]
fn two_names_may_share_one_image() {
let entries: HashMap<&str, Vec<u8>> = [("aaa", png_of(4, 4))].into();
let set = load(&names(&[("one", "aaa"), ("two", "aaa")]), &|key| {
entries.get(key).cloned()
});
assert_eq!(set.images.len(), 2);
assert_ne!(
set.get("one").unwrap().handle,
set.get("two").unwrap().handle
);
}
#[test]
fn unreadable_entries_fall_back_to_typed_icons() {
let set = load(&names(&[("one", "aaa"), ("two", "bbb")]), &|key| {
(key == "aaa").then(|| b"not a png at all".to_vec())
});
assert!(set.images.is_empty());
assert!(set.get("one").is_none());
}
#[test]
fn the_cap_bounds_what_loads() {
let png = png_of(4, 4);
let pairs: Vec<(String, String)> = (0..MAX_THUMBS + 8)
.map(|i| (format!("asset{i}"), format!("key{i}")))
.collect();
let set = load(&pairs, &|_| Some(png.clone()));
assert_eq!(set.images.len(), MAX_THUMBS);
assert!(set.get(&format!("asset{}", MAX_THUMBS)).is_none());
}
#[test]
fn a_missing_set_loads_empty() {
let set = load(&[], &|_| None);
assert!(set.images.is_empty());
}
}