use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use etagere::{AllocId, AtlasAllocator, size2};
use glam::Vec3;
use crate::gizmos::{ATLAS_SIDE, Upload};
use crate::ui::icons;
pub const SIZES: [u32; 4] = [32, 64, 128, 256];
const ICON: u32 = 64;
const PAD: i32 = 1;
const WORKERS: usize = 3;
const WAVEFORM_SECONDS: usize = 90;
const RASTER: &[&str] = &[
"png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif", "ico", "tga", "qoi", "hdr", "exr",
"avif", "dds", "pnm", "ppm",
];
const AUDIO: &[&str] = &[
"mp3", "flac", "ogg", "oga", "wav", "m4a", "aac", "opus", "aiff",
];
const VIDEO: &[&str] = &[
"mp4", "mkv", "mov", "webm", "avi", "m4v", "mpg", "mpeg", "wmv",
];
const ARCHIVE: &[&str] = &["zip", "jar", "apk", "epub", "docx", "xlsx", "pptx", "odt"];
const FONT: &[&str] = &["ttf", "otf"];
const TEXT: &[&str] = &[
"txt",
"md",
"toml",
"json",
"yaml",
"yml",
"wgsl",
"glsl",
"hlsl",
"py",
"js",
"ts",
"tsx",
"jsx",
"html",
"css",
"c",
"h",
"cpp",
"hpp",
"sh",
"ps1",
"lock",
"xml",
"csv",
"cfg",
"ini",
"ron",
"go",
"java",
"kt",
"swift",
"rb",
"php",
"lua",
"zig",
"rs",
"gitignore",
"env",
"log",
];
const CODE: &[&str] = &[
"py", "js", "ts", "tsx", "jsx", "html", "css", "c", "h", "cpp", "hpp", "sh", "ps1", "go",
"java", "kt", "swift", "rb", "php", "lua", "zig", "wgsl", "glsl", "hlsl",
];
pub fn bucket(across: f32) -> u32 {
SIZES
.into_iter()
.find(|&size| across <= size as f32 * 1.2)
.unwrap_or(SIZES[SIZES.len() - 1])
}
pub fn is_font(path: &Path) -> bool {
FONT.contains(&extension(path).as_str())
}
#[derive(Clone, Debug)]
pub struct Rgba {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
}
impl Rgba {
fn blank(width: u32, height: u32, fill: [u8; 4]) -> Self {
let data = fill.repeat((width * height) as usize);
Self {
width,
height,
data,
}
}
fn blend(&mut self, x: i32, y: i32, color: [u8; 3], alpha: f32) {
if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 || alpha <= 0.0 {
return;
}
let at = ((y as u32 * self.width + x as u32) * 4) as usize;
let alpha = alpha.min(1.0);
for c in 0..3 {
let under = self.data[at + c] as f32;
self.data[at + c] = (under + (color[c] as f32 - under) * alpha) as u8;
}
let under = self.data[at + 3] as f32;
self.data[at + 3] = (under + (255.0 - under) * alpha) as u8;
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Thumb {
pub uv: [f32; 4],
pub aspect: f32,
}
struct Held {
thumb: Thumb,
id: AllocId,
used: u64,
}
enum Slot {
Pending,
Missing,
Ready(Held),
}
struct Queue {
items: Vec<(f32, PathBuf, u32)>,
stop: bool,
}
type Key = (PathBuf, u32);
pub struct Thumbnails {
atlas: AtlasAllocator,
slots: HashMap<Key, Slot>,
icons: HashMap<&'static str, Option<Thumb>>,
queue: Arc<(Mutex<Queue>, Condvar)>,
results: Receiver<(Key, Option<Rgba>)>,
uploads: Vec<Upload>,
now: u64,
}
impl Default for Thumbnails {
fn default() -> Self {
Self::new()
}
}
impl Thumbnails {
pub fn new() -> Self {
let queue = Arc::new((
Mutex::new(Queue {
items: Vec::new(),
stop: false,
}),
Condvar::new(),
));
let (tx, results) = mpsc::channel();
for _ in 0..WORKERS {
let queue = Arc::clone(&queue);
let tx: Sender<(Key, Option<Rgba>)> = tx.clone();
std::thread::spawn(move || {
while let Some((path, size)) = next(&queue) {
let made = cached(&path, size).or_else(|| {
let made = produce(&path, size);
if let Some(image) = &made {
cache(&path, size, image);
}
made
});
if tx.send(((path, size), made)).is_err() {
return;
}
}
});
}
Self {
atlas: AtlasAllocator::new(size2(ATLAS_SIDE as i32, ATLAS_SIDE as i32)),
slots: HashMap::new(),
icons: HashMap::new(),
queue,
results,
uploads: Vec::new(),
now: 0,
}
}
pub fn get(&mut self, path: &Path, across: f32) -> Option<Thumb> {
let wanted = bucket(across);
let now = self.now;
let key = (path.to_path_buf(), wanted);
match self.slots.get_mut(&key) {
Some(Slot::Ready(held)) => {
held.used = now;
return Some(held.thumb);
}
Some(Slot::Missing) => return None,
Some(Slot::Pending) => {}
None => {
self.request(path, wanted, across);
self.slots.insert(key, Slot::Pending);
}
}
let mut nearest: Option<(u32, Thumb)> = None;
for size in SIZES {
if let Some(Slot::Ready(held)) = self.slots.get_mut(&(path.to_path_buf(), size)) {
let closer =
nearest.is_none_or(|(have, _)| size.abs_diff(wanted) < have.abs_diff(wanted));
if closer {
held.used = now;
nearest = Some((size, held.thumb));
}
}
}
nearest.map(|(_, thumb)| thumb)
}
fn request(&self, path: &Path, size: u32, priority: f32) {
let (lock, wake) = &*self.queue;
let mut queue = lock.lock().expect("thumbnail queue poisoned");
match queue
.items
.iter_mut()
.find(|(_, p, s)| p == path && *s == size)
{
Some(item) => item.0 = item.0.max(priority),
None => {
queue.items.push((priority, path.to_path_buf(), size));
wake.notify_one();
}
}
}
pub fn icon(&mut self, path: &'static str) -> Option<Thumb> {
if let Some(thumb) = self.icons.get(path) {
return *thumb;
}
let thumb = icons::source(path)
.and_then(|svg| icons::rasterize(svg.as_bytes(), ICON))
.map(|data| Rgba {
width: ICON,
height: ICON,
data,
})
.and_then(|image| self.place(&image))
.map(|(thumb, _)| thumb);
self.icons.insert(path, thumb);
thumb
}
pub fn poll(&mut self) -> Vec<Upload> {
self.now += 1;
while let Ok((key, made)) = self.results.try_recv() {
let slot = match made.and_then(|image| self.place(&image)) {
Some((thumb, id)) => Slot::Ready(Held {
thumb,
id,
used: self.now,
}),
None => Slot::Missing,
};
self.slots.insert(key, slot);
}
std::mem::take(&mut self.uploads)
}
pub fn pending(&self) -> usize {
self.queue.0.lock().map(|q| q.items.len()).unwrap_or(0)
}
fn place(&mut self, image: &Rgba) -> Option<(Thumb, AllocId)> {
let padded = size2(image.width as i32 + 2 * PAD, image.height as i32 + 2 * PAD);
let allocation = match self.atlas.allocate(padded) {
Some(allocation) => allocation,
None => {
self.evict(padded.area() * 4);
self.atlas.allocate(padded)?
}
};
let x = allocation.rectangle.min.x + PAD;
let y = allocation.rectangle.min.y + PAD;
self.uploads.push(Upload {
x: x as u32,
y: y as u32,
width: image.width,
height: image.height,
rgba: image.data.clone(),
});
let side = ATLAS_SIDE as f32;
let thumb = Thumb {
uv: [
x as f32 / side,
y as f32 / side,
(x as f32 + image.width as f32) / side,
(y as f32 + image.height as f32) / side,
],
aspect: image.width as f32 / image.height.max(1) as f32,
};
Some((thumb, allocation.id))
}
fn evict(&mut self, area: i32) {
let mut held: Vec<(u64, Key)> = self
.slots
.iter()
.filter_map(|(key, slot)| match slot {
Slot::Ready(held) if held.used < self.now => Some((held.used, key.clone())),
_ => None,
})
.collect();
held.sort();
let mut freed = 0;
for (_, key) in held {
if freed >= area {
break;
}
if let Some(Slot::Ready(held)) = self.slots.remove(&key) {
freed += self.atlas.get(held.id).area();
self.atlas.deallocate(held.id);
}
}
}
}
impl Drop for Thumbnails {
fn drop(&mut self) {
if let Ok(mut queue) = self.queue.0.lock() {
queue.stop = true;
}
self.queue.1.notify_all();
}
}
fn next(queue: &(Mutex<Queue>, Condvar)) -> Option<(PathBuf, u32)> {
let (lock, wake) = queue;
let mut queue = lock.lock().ok()?;
loop {
if queue.stop {
return None;
}
if let Some(best) = (0..queue.items.len()).max_by(|&a, &b| {
queue.items[a]
.0
.partial_cmp(&queue.items[b].0)
.unwrap_or(std::cmp::Ordering::Equal)
}) {
let (_, path, size) = queue.items.swap_remove(best);
return Some((path, size));
}
queue = wake.wait(queue).ok()?;
}
}
pub fn icon_for(path: &Path) -> &'static str {
use icons::path as p;
let ext = extension(path);
match ext.as_str() {
"rs" => p::FILE_RS,
"md" => p::FILE_MD,
"svg" => p::FILE_SVG,
"pdf" => p::FILE_PDF,
"glb" | "gltf" => p::CUBE,
e if RASTER.contains(&e) => p::FILE_IMAGE,
e if AUDIO.contains(&e) => p::FILE_AUDIO,
e if VIDEO.contains(&e) => p::FILE_VIDEO,
e if ARCHIVE.contains(&e) || e == "gz" || e == "tar" || e == "7z" => p::FILE_ZIP,
e if FONT.contains(&e) => p::TEXT_AA,
e if CODE.contains(&e) => p::FILE_CODE,
e if TEXT.contains(&e) => p::FILE_TEXT,
_ => p::FILE,
}
}
fn extension(path: &Path) -> String {
path.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default()
}
pub fn produce(path: &Path, size: u32) -> Option<Rgba> {
let ext = extension(path);
match ext.as_str() {
"svg" => svg(path, size),
"glb" => model(path, size),
"pdf" => pdf(path, size),
e if RASTER.contains(&e) => raster(path, size),
e if FONT.contains(&e) => font_sample(path, size),
e if AUDIO.contains(&e) => audio(path, size),
e if VIDEO.contains(&e) => video(path, size),
e if ARCHIVE.contains(&e) => archive(path, size),
e if TEXT.contains(&e) => text_file(path, size),
_ => None,
}
}
fn svg(path: &Path, size: u32) -> Option<Rgba> {
let bytes = std::fs::read(path).ok()?;
icons::rasterize_as(&bytes, size, "#dcdce6").map(|data| Rgba {
width: size,
height: size,
data,
})
}
fn raster(path: &Path, size: u32) -> Option<Rgba> {
let decoded = image::ImageReader::open(path)
.ok()?
.with_guessed_format()
.ok()?
.decode()
.ok()?;
Some(fit(decoded, size))
}
fn fit(decoded: image::DynamicImage, size: u32) -> Rgba {
let small = decoded.thumbnail(size, size).to_rgba8();
Rgba {
width: small.width(),
height: small.height(),
data: small.into_raw(),
}
}
fn dark_panel(size: u32) -> Rgba {
Rgba::blank(size, size, [20, 20, 30, 235])
}
const TEXT_COLOR: [u8; 3] = [222, 222, 236];
const DIM_COLOR: [u8; 3] = [140, 144, 160];
fn glyph_run(
image: &mut Rgba,
font: &fontdue::Font,
text: &str,
px: f32,
x: f32,
y: f32,
color: [u8; 3],
) -> f32 {
let mut pen = x;
for ch in text.chars() {
if pen > image.width as f32 {
break;
}
let (metrics, coverage) = font.rasterize(ch, px);
let left = pen + metrics.xmin as f32;
let top = y - metrics.height as f32 - metrics.ymin as f32;
for row in 0..metrics.height {
for column in 0..metrics.width {
let alpha = coverage[row * metrics.width + column] as f32 / 255.0;
image.blend(
left as i32 + column as i32,
top as i32 + row as i32,
color,
alpha,
);
}
}
pen += metrics.advance_width;
}
pen
}
fn text_image(lines: &[String], size: u32) -> Rgba {
let font = crate::ui::font::Family::Neon.load();
let mut image = dark_panel(size);
let px = (size as f32 / 18.0).clamp(5.0, 14.0);
let line_height = px * 1.35;
let margin = (size as f32 * 0.03).max(2.0);
let rows = ((size as f32 - margin) / line_height) as usize;
let columns = ((size as f32 - 2.0 * margin) / (px * 0.62)) as usize;
for (row, line) in lines.iter().take(rows).enumerate() {
let baseline = margin + px + row as f32 * line_height;
let line: String = line.replace('\t', " ").chars().take(columns).collect();
glyph_run(&mut image, &font, &line, px, margin, baseline, TEXT_COLOR);
}
image
}
fn text_file(path: &Path, size: u32) -> Option<Rgba> {
let mut source = String::new();
{
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
file.take(16_384).read_to_string(&mut source).ok()?;
}
let lines: Vec<String> = source.lines().take(40).map(str::to_string).collect();
if lines.is_empty() {
return None;
}
Some(text_image(&lines, size))
}
fn font_sample(path: &Path, size: u32) -> Option<Rgba> {
let bytes = std::fs::read(path).ok()?;
let font = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()).ok()?;
let mut image = dark_panel(size);
let s = size as f32;
glyph_run(
&mut image,
&font,
"Ag",
s * 0.44,
s * 0.08,
s * 0.5,
TEXT_COLOR,
);
glyph_run(
&mut image,
&font,
"abc 0123",
s * 0.11,
s * 0.08,
s * 0.75,
TEXT_COLOR,
);
let name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let system = crate::ui::font::Family::Neon.load();
glyph_run(
&mut image,
&system,
&name,
s * 0.055,
s * 0.05,
s * 0.94,
DIM_COLOR,
);
Some(image)
}
fn model(path: &Path, size: u32) -> Option<Rgba> {
let bytes = std::fs::read(path).ok()?;
let meshes = crate::mesh::load_glb(&bytes).ok()?;
let mut triangles: Vec<([Vec3; 3], [f32; 3])> = Vec::new();
for mesh in &meshes {
let color = mesh
.base_color
.map(|c| {
let c = c.to_srgba();
[c.red, c.green, c.blue]
})
.unwrap_or([0.75, 0.78, 0.85]);
for tri in mesh.indices.chunks_exact(3) {
let corner = |i: u32| Vec3::from(mesh.vertices[i as usize].position);
triangles.push(([corner(tri[0]), corner(tri[1]), corner(tri[2])], color));
}
}
if triangles.is_empty() {
return None;
}
Some(render_triangles(&triangles, size))
}
fn render_triangles(triangles: &[([Vec3; 3], [f32; 3])], size: u32) -> Rgba {
let (yaw, pitch) = (-0.65f32, 0.4f32);
let turn = glam::Quat::from_rotation_x(pitch) * glam::Quat::from_rotation_y(yaw);
let turned: Vec<([Vec3; 3], [f32; 3])> = triangles
.iter()
.map(|(corners, color)| (corners.map(|c| turn * c), *color))
.collect();
let mut low = Vec3::splat(f32::MAX);
let mut high = Vec3::splat(f32::MIN);
for (corners, _) in &turned {
for c in corners {
low = low.min(*c);
high = high.max(*c);
}
}
let centre = (low + high) * 0.5;
let radius = ((high - low).truncate().length() * 0.5).max(1e-4);
let scale = size as f32 * 0.46 / radius;
let light = Vec3::new(0.4, 0.8, 0.6).normalize();
let mut image = Rgba::blank(size, size, [0, 0, 0, 0]);
let mut depth = vec![f32::MIN; (size * size) as usize];
let to_screen = |p: Vec3| {
let d = (p - centre) * scale;
(size as f32 * 0.5 + d.x, size as f32 * 0.5 - d.y, d.z)
};
for (corners, color) in &turned {
let normal = (corners[1] - corners[0])
.cross(corners[2] - corners[0])
.normalize_or_zero();
let lit = 0.3 + 0.7 * normal.dot(light).abs();
let shade = color.map(|c| (c * lit * 255.0).clamp(0.0, 255.0) as u8);
let (a, b, c) = (
to_screen(corners[0]),
to_screen(corners[1]),
to_screen(corners[2]),
);
let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
if area.abs() < 1e-6 {
continue;
}
let edge = size as f32 - 1.0;
let x0 = a.0.min(b.0).min(c.0).floor().max(0.0) as i32;
let x1 = a.0.max(b.0).max(c.0).ceil().min(edge) as i32;
let y0 = a.1.min(b.1).min(c.1).floor().max(0.0) as i32;
let y1 = a.1.max(b.1).max(c.1).ceil().min(edge) as i32;
for y in y0..=y1 {
for x in x0..=x1 {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area;
let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area;
let w2 = 1.0 - w0 - w1;
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
continue;
}
let z = w0 * a.2 + w1 * b.2 + w2 * c.2;
let at = (y as u32 * size + x as u32) as usize;
if z > depth[at] {
depth[at] = z;
image.data[at * 4..at * 4 + 4]
.copy_from_slice(&[shade[0], shade[1], shade[2], 255]);
}
}
}
}
image
}
fn audio(path: &Path, size: u32) -> Option<Rgba> {
cover_art(path, size).or_else(|| waveform(path, size))
}
fn cover_art(path: &Path, size: u32) -> Option<Rgba> {
use lofty::file::TaggedFileExt;
let tagged = lofty::read_from_path(path).ok()?;
let tag = tagged.primary_tag().or_else(|| tagged.first_tag())?;
let picture = tag.pictures().first()?;
let decoded = image::load_from_memory(picture.data()).ok()?;
Some(fit(decoded, size))
}
fn waveform(path: &Path, size: u32) -> Option<Rgba> {
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions};
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
let file = std::fs::File::open(path).ok()?;
let stream = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
hint.with_extension(&extension(path));
let probed = symphonia::default::get_probe()
.format(
&hint,
stream,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.ok()?;
let mut format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)?;
let track_id = track.id;
let rate = track.codec_params.sample_rate.unwrap_or(44_100) as usize;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.ok()?;
let mut peaks: Vec<f32> = Vec::new();
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
let Ok(decoded) = decoder.decode(&packet) else {
continue;
};
let spec = *decoded.spec();
let mut buffer = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
buffer.copy_interleaved_ref(decoded);
let channels = spec.channels.count().max(1);
for frame in buffer.samples().chunks(channels) {
peaks.push(frame.iter().fold(0.0f32, |m, s| m.max(s.abs())));
}
if peaks.len() > rate * WAVEFORM_SECONDS {
break;
}
}
if peaks.is_empty() {
return None;
}
let mut image = dark_panel(size);
let columns = size as usize;
let per_column = (peaks.len() / columns).max(1);
let middle = size as f32 * 0.5;
for column in 0..columns {
let start = column * per_column;
let end = (start + per_column).min(peaks.len());
if start >= end {
break;
}
let peak = peaks[start..end].iter().copied().fold(0.0, f32::max);
let half = (peak.min(1.0) * middle * 0.9).max(0.5);
for y in (middle - half) as i32..=(middle + half) as i32 {
image.blend(column as i32, y, [110, 170, 240], 1.0);
}
}
Some(image)
}
fn archive(path: &Path, size: u32) -> Option<Rgba> {
let file = std::fs::File::open(path).ok()?;
let archive = zip::ZipArchive::new(file).ok()?;
let total = archive.len();
let mut lines: Vec<String> = archive.file_names().take(40).map(str::to_string).collect();
lines.sort();
if total > lines.len() {
lines.push(format!("… {} more", total - lines.len()));
}
Some(text_image(&lines, size))
}
struct Tools {
ffmpeg: bool,
pdftoppm: bool,
mutool: bool,
}
fn tools() -> &'static Tools {
static TOOLS: OnceLock<Tools> = OnceLock::new();
TOOLS.get_or_init(|| {
let present = |name: &str, flag: &str| {
std::process::Command::new(name)
.arg(flag)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
};
Tools {
ffmpeg: present("ffmpeg", "-version"),
pdftoppm: present("pdftoppm", "-v"),
mutool: present("mutool", "-v"),
}
})
}
fn scratch(path: &Path, suffix: &str) -> Option<PathBuf> {
let dir = cache_dir()?;
Some(dir.join(format!("scratch-{}-{suffix}", key(path, 0))))
}
fn quiet(command: &mut std::process::Command) -> bool {
command
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn video(path: &Path, size: u32) -> Option<Rgba> {
if !tools().ffmpeg {
return None;
}
let out = scratch(path, "frame.png")?;
let ok = quiet(
std::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-ss", "1", "-i"])
.arg(path)
.args([
"-frames:v",
"1",
"-vf",
&format!("scale={size}:-1"),
"-f",
"image2",
])
.arg(&out),
);
let made = ok.then(|| raster(&out, size)).flatten();
let _ = std::fs::remove_file(&out);
made
}
fn pdf(path: &Path, size: u32) -> Option<Rgba> {
let tools = tools();
let out = scratch(path, "page")?;
if tools.pdftoppm
&& quiet(
std::process::Command::new("pdftoppm")
.args(["-png", "-f", "1", "-l", "1", "-scale-to", &size.to_string()])
.arg(path)
.arg(&out),
)
{
let prefix = out.file_name()?.to_string_lossy().into_owned();
let page = std::fs::read_dir(out.parent()?)
.ok()?
.flatten()
.map(|e| e.path())
.find(|p| {
p.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with(&prefix))
})?;
let made = raster(&page, size);
let _ = std::fs::remove_file(&page);
return made;
}
if tools.mutool {
let out = out.with_extension("png");
let ok = quiet(
std::process::Command::new("mutool")
.args(["draw", "-o"])
.arg(&out)
.args(["-w", &size.to_string()])
.arg(path)
.arg("1"),
);
let made = ok.then(|| raster(&out, size)).flatten();
let _ = std::fs::remove_file(&out);
return made;
}
None
}
fn cache_dir() -> Option<PathBuf> {
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
DIR.get_or_init(|| {
let dirs = directories::ProjectDirs::from("dev", "netron", "codecraft")?;
let dir = dirs.cache_dir().join("thumbs");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
})
.clone()
}
fn key(path: &Path, size: u32) -> String {
let mut hasher = std::hash::DefaultHasher::new();
path.hash(&mut hasher);
if let Ok(meta) = std::fs::metadata(path) {
meta.len().hash(&mut hasher);
if let Ok(modified) = meta.modified()
&& let Ok(since) = modified.duration_since(std::time::UNIX_EPOCH)
{
since.as_nanos().hash(&mut hasher);
}
}
size.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn cached(path: &Path, size: u32) -> Option<Rgba> {
let file = cache_dir()?.join(format!("{}.png", key(path, size)));
let decoded = image::ImageReader::open(file)
.ok()?
.decode()
.ok()?
.to_rgba8();
Some(Rgba {
width: decoded.width(),
height: decoded.height(),
data: decoded.into_raw(),
})
}
fn cache(path: &Path, size: u32, image: &Rgba) {
let Some(dir) = cache_dir() else {
return;
};
let file = dir.join(format!("{}.png", key(path, size)));
let Ok(out) = std::fs::File::create(&file) else {
return;
};
let mut encoder = png::Encoder::new(std::io::BufWriter::new(out), image.width, image.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
if let Ok(mut writer) = encoder.write_header()
&& writer.write_image_data(&image.data).is_err()
{
let _ = std::fs::remove_file(&file);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_file(name: &str, bytes: &[u8]) -> PathBuf {
let path =
std::env::temp_dir().join(format!("codecraft-thumb-{}-{name}", std::process::id()));
std::fs::write(&path, bytes).unwrap();
path
}
#[test]
fn icons_follow_the_extension() {
assert_eq!(icon_for(Path::new("a/b.rs")), icons::path::FILE_RS);
assert_eq!(icon_for(Path::new("photo.JPG")), icons::path::FILE_IMAGE);
assert_eq!(icon_for(Path::new("song.flac")), icons::path::FILE_AUDIO);
assert_eq!(icon_for(Path::new("clip.mp4")), icons::path::FILE_VIDEO);
assert_eq!(icon_for(Path::new("bundle.zip")), icons::path::FILE_ZIP);
assert_eq!(icon_for(Path::new("Mono.ttf")), icons::path::TEXT_AA);
assert!(is_font(Path::new("Mono.ttf")));
assert_eq!(icon_for(Path::new("main.py")), icons::path::FILE_CODE);
assert_eq!(icon_for(Path::new("mystery")), icons::path::FILE);
}
#[test]
fn sizes_go_up_in_buckets() {
assert_eq!(bucket(10.0), 32);
assert_eq!(bucket(38.0), 32);
assert_eq!(bucket(60.0), 64);
assert_eq!(bucket(140.0), 128);
assert_eq!(bucket(900.0), 256);
}
#[test]
fn an_svg_and_a_text_file_become_pictures() {
let svg = scratch_file(
"a.svg",
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="red"/></svg>"#,
);
let image = produce(&svg, 64).expect("an svg rasterises");
assert_eq!((image.width, image.height), (64, 64));
assert!(
image.data.chunks(4).any(|p| p[0] > 200 && p[3] > 200),
"red pixels"
);
let text = scratch_file("notes.md", b"# Title\n\nsome words\n");
let image = produce(&text, 128).expect("text becomes a peek");
assert!(
image.data.chunks(4).any(|p| p[0] > 150),
"light lettering on the panel"
);
let unknown = scratch_file("blob.xyz", b"\0\0\0");
assert!(produce(&unknown, 64).is_none());
for p in [svg, text, unknown] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn a_triangle_renders_with_depth_and_light() {
let tri = [
Vec3::new(-1.0, -1.0, 0.0),
Vec3::new(1.0, -1.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
];
let image = render_triangles(&[(tri, [1.0, 1.0, 1.0])], 128);
let lit = image.data.chunks(4).filter(|p| p[3] == 255).count();
assert!(lit > 500, "{lit} pixels covered");
assert!(
image.data.chunks(4).any(|p| p[3] == 0),
"corners stay clear"
);
}
#[test]
fn a_zip_lists_its_names() {
let path = std::env::temp_dir().join(format!("codecraft-thumb-{}.zip", std::process::id()));
{
let file = std::fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
for name in ["a.txt", "b/c.txt"] {
zip.start_file(name, options).unwrap();
std::io::Write::write_all(&mut zip, b"hi").unwrap();
}
zip.finish().unwrap();
}
assert!(produce(&path, 64).is_some());
let _ = std::fs::remove_file(path);
}
#[test]
fn the_atlas_hands_out_distinct_rectangles_and_uploads() {
let mut thumbs = Thumbnails::new();
let a = thumbs.icon(icons::path::FILE).expect("an icon");
let b = thumbs.icon(icons::path::FOLDER).expect("another");
assert_ne!(a.uv, b.uv);
assert_eq!(thumbs.icon(icons::path::FILE), Some(a), "asked once");
let uploads = thumbs.poll();
assert_eq!(uploads.len(), 2);
assert_eq!(uploads[0].width, ICON);
assert!(thumbs.poll().is_empty(), "taken");
}
#[test]
fn a_full_atlas_lets_the_least_recently_used_go() {
let mut thumbs = Thumbnails::new();
let image = Rgba::blank(1000, 1000, [255, 0, 0, 255]);
for i in 0..16 {
let (thumb, id) = thumbs.place(&image).expect("room");
thumbs.slots.insert(
(PathBuf::from(format!("{i}.png")), 128),
Slot::Ready(Held {
thumb,
id,
used: i as u64,
}),
);
thumbs.now = 100;
}
assert!(thumbs.place(&image).is_some(), "the oldest made way");
assert!(
!thumbs.slots.contains_key(&(PathBuf::from("0.png"), 128)),
"the least recently used went first"
);
assert!(thumbs.slots.contains_key(&(PathBuf::from("15.png"), 128)));
}
}
#[cfg(test)]
mod model_tests {
use super::*;
#[test]
fn a_glb_renders_shaded() {
let assets = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/chess/assets");
let Some(glb) = std::fs::read_dir(assets).ok().and_then(|dir| {
dir.flatten()
.map(|e| e.path())
.find(|p| extension(p) == "glb")
}) else {
return;
};
let image = produce(&glb, 128).expect("a glb renders");
let lit = image.data.chunks(4).filter(|p| p[3] == 255).count();
assert!(lit > 200, "{lit} pixels covered");
}
}