use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::time::{Duration, Instant, SystemTime};
use corundum_framework_image::{Error, Image, Limits, Source, thumbnail};
use crate::{icons, tr};
const SIZES: [u32; 4] = [256, 512, 1024, 2048];
const KEPT: usize = 8;
const MAX_PIXELS: u64 = 64_000_000;
const RECHECK: Duration = Duration::from_secs(2);
const ICON: f32 = 48.0;
#[derive(Clone, PartialEq, Eq, Debug)]
struct Key {
path: PathBuf,
modified: Option<SystemTime>,
size: u64,
}
impl Key {
fn of(path: &Path) -> Key {
let meta = std::fs::metadata(path).ok();
Key {
path: path.to_owned(),
modified: meta.as_ref().and_then(|m| m.modified().ok()),
size: meta.map_or(0, |m| m.len()),
}
}
}
struct Request {
path: PathBuf,
longest: u32,
limits: Limits,
known: Option<Key>,
}
enum Answer {
Unchanged { path: PathBuf, longest: u32 },
Decoded {
key: Key,
longest: u32,
decoded: Decoded,
},
}
enum Decoded {
Picture(egui::ColorImage),
Unsupported,
TooLarge,
Failed(String),
}
impl Decoded {
fn of(result: Result<Image, Error>) -> Decoded {
match result {
Ok(image) => {
let size = [image.width as usize, image.height as usize];
Decoded::Picture(egui::ColorImage::from_rgba_unmultiplied(size, &image.rgba))
}
Err(Error::Unsupported) => Decoded::Unsupported,
Err(Error::TooLarge) => Decoded::TooLarge,
Err(error) => Decoded::Failed(error.to_string()),
}
}
}
enum Outcome {
Picture(egui::TextureHandle),
Unsupported,
TooLarge,
Failed(String),
}
struct Kept {
key: Key,
longest: u32,
outcome: Outcome,
checked: Instant,
}
impl Kept {
fn serves(&self, longest: u32) -> bool {
match &self.outcome {
Outcome::Picture(texture) => {
let [w, h] = texture.size();
self.longest >= longest || (w.max(h) as u32) < self.longest
}
_ => true,
}
}
}
struct Worker {
requests: Sender<Request>,
answers: Receiver<Answer>,
pending: Arc<AtomicUsize>,
}
impl Worker {
fn spawn(ctx: egui::Context) -> Worker {
let (requests, inbox) = channel::<Request>();
let (outbox, answers) = channel::<Answer>();
let pending = Arc::new(AtomicUsize::new(0));
let count = pending.clone();
std::thread::Builder::new()
.name("image preview".into())
.spawn(move || {
while let Ok(mut request) = inbox.recv() {
let mut taken = 1;
while let Ok(newer) = inbox.try_recv() {
request = newer;
taken += 1;
}
let sent = outbox.send(answer(request));
count.fetch_sub(taken, Ordering::SeqCst);
if sent.is_err() {
break;
}
ctx.request_repaint();
}
})
.expect("spawn the image preview's thread");
Worker {
requests,
answers,
pending,
}
}
}
fn answer(request: Request) -> Answer {
let Request {
path,
longest,
limits,
known,
} = request;
let key = Key::of(&path);
if known.as_ref() == Some(&key) {
return Answer::Unchanged { path, longest };
}
let decoded = catch_unwind(AssertUnwindSafe(|| {
Decoded::of(decode(&key.path, longest, &limits))
}))
.unwrap_or_else(|_| Decoded::Failed("the image preview panicked".into()));
Answer::Decoded {
key,
longest,
decoded,
}
}
fn decode(path: &Path, longest: u32, limits: &Limits) -> Result<Image, Error> {
#[cfg(test)]
if path.file_name() == Some(std::ffi::OsStr::new(PANIC_NAME)) {
PANICS.fetch_add(1, Ordering::SeqCst);
panic!("a test's panic in the image preview's worker");
}
thumbnail(Source::Path(path), longest, limits)
}
#[cfg(test)]
pub(super) const PANIC_NAME: &str = "corundum-preview-panics.png";
#[cfg(test)]
static PANICS: AtomicUsize = AtomicUsize::new(0);
#[cfg(test)]
pub(super) fn panics() -> usize {
PANICS.load(Ordering::SeqCst)
}
pub struct ImagePreview {
worker: Option<Worker>,
asked: Option<(PathBuf, u32)>,
current: Option<PathBuf>,
kept: Vec<Kept>,
recheck: Duration,
limits: Limits,
}
impl Default for ImagePreview {
fn default() -> ImagePreview {
ImagePreview {
worker: None,
asked: None,
current: None,
kept: Vec::new(),
recheck: RECHECK,
limits: Limits::default().with_max_pixels(MAX_PIXELS),
}
}
}
impl ImagePreview {
pub fn new() -> ImagePreview {
ImagePreview::default()
}
pub fn with_limits(self, limits: Limits) -> ImagePreview {
ImagePreview { limits, ..self }
}
pub fn ui(&mut self, ui: &mut egui::Ui, path: &Path) {
self.receive(ui.ctx());
let area = ui.available_rect_before_wrap();
ui.allocate_rect(area, egui::Sense::hover());
let ppp = ui.ctx().pixels_per_point();
let max_side = ui.ctx().input(|i| i.max_texture_side) as u32;
let longest = target(area.size(), ppp).min(max_side.max(1));
let picked = self.current.as_deref() != Some(path);
if picked {
self.current = Some(path.to_owned());
}
let Some(at) = self.kept.iter().position(|k| k.key.path == path) else {
self.ask(ui.ctx(), path, longest, None);
egui::Spinner::new().paint_at(
ui,
egui::Rect::from_center_size(area.center(), egui::vec2(32.0, 32.0)),
);
return;
};
let kept = self.kept.remove(at);
self.kept.push(kept);
let kept = &self.kept[self.kept.len() - 1];
if !kept.serves(longest) {
self.ask(ui.ctx(), path, longest, None);
} else if picked || kept.checked.elapsed() >= self.recheck {
let known = Some(kept.key.clone());
self.ask(ui.ctx(), path, longest, known);
}
ui.ctx().request_repaint_after(self.recheck);
self.draw(ui, area, self.kept.len() - 1);
}
fn draw(&self, ui: &mut egui::Ui, area: egui::Rect, at: usize) {
let message = match &self.kept[at].outcome {
Outcome::Picture(texture) => {
let ppp = ui.ctx().pixels_per_point();
let full = texture.size_vec2() / ppp;
let scale = (area.width() / full.x).min(area.height() / full.y).min(1.0);
let shown = egui::Rect::from_center_size(area.center(), full * scale);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter()
.image(texture.id(), shown, uv, egui::Color32::WHITE);
return;
}
Outcome::Unsupported => tr!(ui, "corundum-preview-unsupported"),
Outcome::TooLarge => tr!(ui, "corundum-preview-too-large"),
Outcome::Failed(reason) => tr!(ui, "corundum-preview-failed", reason = reason.as_str()),
};
let mut inner = ui.new_child(
egui::UiBuilder::new()
.max_rect(area)
.layout(egui::Layout::top_down(egui::Align::Center)),
);
inner.add_space((area.height() / 2.0 - ICON).max(0.0));
inner.add(icons::image(&inner, icons::file(), ICON));
inner.add(egui::Label::new(message).wrap());
}
fn ask(&mut self, ctx: &egui::Context, path: &Path, longest: u32, known: Option<Key>) {
if self
.asked
.as_ref()
.is_some_and(|(p, l)| p == path && *l == longest)
{
return;
}
let worker = self
.worker
.get_or_insert_with(|| Worker::spawn(ctx.clone()));
worker.pending.fetch_add(1, Ordering::SeqCst);
let request = Request {
path: path.to_owned(),
longest,
limits: self.limits,
known,
};
if worker.requests.send(request).is_err() {
self.worker = None;
self.asked = None;
return;
}
self.asked = Some((path.to_owned(), longest));
}
fn receive(&mut self, ctx: &egui::Context) {
let Some(worker) = &self.worker else {
return;
};
loop {
match worker.answers.try_recv() {
Ok(Answer::Unchanged { path, longest }) => {
answered(&mut self.asked, &path, longest);
if let Some(kept) = self.kept.iter_mut().find(|k| k.key.path == path) {
kept.checked = Instant::now();
}
}
Ok(Answer::Decoded {
key,
longest,
decoded,
}) => {
answered(&mut self.asked, &key.path, longest);
let outcome = match decoded {
Decoded::Picture(pixels) => Outcome::Picture(ctx.load_texture(
format!("corundum-preview {}", key.path.display()),
pixels,
egui::TextureOptions::LINEAR,
)),
Decoded::Unsupported => Outcome::Unsupported,
Decoded::TooLarge => Outcome::TooLarge,
Decoded::Failed(reason) => Outcome::Failed(reason),
};
self.kept.retain(|k| k.key.path != key.path);
self.kept.push(Kept {
key,
longest,
outcome,
checked: Instant::now(),
});
if self.kept.len() > KEPT {
self.kept.remove(0);
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.worker = None;
self.asked = None;
break;
}
}
}
}
#[cfg(test)]
pub(super) fn limits(&self) -> Limits {
self.limits
}
#[cfg(test)]
pub(super) fn set_recheck(&mut self, recheck: Duration) {
self.recheck = recheck;
}
#[cfg(test)]
pub(super) fn thread_token(&self) -> Option<std::sync::Weak<AtomicUsize>> {
self.worker.as_ref().map(|w| Arc::downgrade(&w.pending))
}
#[cfg(test)]
pub(super) fn idle(&self) -> bool {
self.worker
.as_ref()
.is_none_or(|w| w.pending.load(Ordering::SeqCst) == 0)
}
}
fn answered(asked: &mut Option<(PathBuf, u32)>, path: &Path, longest: u32) {
if asked
.as_ref()
.is_some_and(|(p, l)| p == path && *l == longest)
{
*asked = None;
}
}
pub(super) fn target(size: egui::Vec2, ppp: f32) -> u32 {
let wanted = (size.x.max(size.y) * ppp).ceil().max(0.0) as u32;
SIZES
.into_iter()
.find(|&s| s >= wanted)
.unwrap_or(SIZES[SIZES.len() - 1])
}