use crate::canvas::model_textures::color_image;
use egui::{ColorImage, Context, TextureFilter, TextureHandle, TextureOptions};
use kanal::{Receiver, Sender, unbounded};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const PHOTO_SIZE: f32 = 1024.0;
const IMAGE_TEXTURE: TextureOptions = TextureOptions {
mipmap_mode: Some(TextureFilter::Linear),
..TextureOptions::LINEAR
};
pub(crate) type Decoded = Result<ColorImage, String>;
#[derive(Clone)]
enum Loaded {
Decoding,
Ready(TextureHandle),
Failed,
}
#[derive(Clone)]
pub(crate) struct Images {
loaded: HashMap<PathBuf, Loaded>,
#[cfg(not(target_arch = "wasm32"))]
jobs: Option<Sender<PathBuf>>,
decoded: (Sender<(PathBuf, Decoded)>, Receiver<(PathBuf, Decoded)>),
}
impl Default for Images {
fn default() -> Self {
Self {
loaded: HashMap::new(),
#[cfg(not(target_arch = "wasm32"))]
jobs: None,
decoded: unbounded(),
}
}
}
impl std::fmt::Debug for Images {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Images")
.field("loaded", &self.loaded.len())
.finish_non_exhaustive()
}
}
impl Images {
pub(crate) fn texture(&mut self, ctx: &Context, path: &Path) -> Option<TextureHandle> {
self.poll(ctx);
match self.loaded.get(path) {
Some(Loaded::Ready(texture)) => Some(texture.clone()),
Some(Loaded::Decoding | Loaded::Failed) => None,
None => {
self.loaded.insert(path.to_path_buf(), Loaded::Decoding);
self.decode(ctx, path.to_path_buf());
None
}
}
}
pub(crate) fn insert(&mut self, ctx: &Context, path: PathBuf, image: Decoded) {
let loaded = match image {
Ok(image) => Loaded::Ready(ctx.load_texture(
format!("image {}", path.display()),
image,
IMAGE_TEXTURE,
)),
Err(error) => {
tracing::warn!("Could not load {}: {error}", path.display());
if matches!(self.loaded.get(&path), Some(Loaded::Ready(_))) {
return;
}
Loaded::Failed
}
};
self.loaded.insert(path, loaded);
}
fn poll(&mut self, ctx: &Context) {
while let Ok(Some((path, image))) = self.decoded.1.try_recv() {
self.insert(ctx, path, image);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn decode(&mut self, ctx: &Context, path: PathBuf) {
if self.jobs.is_none() {
self.jobs = self.start_decoding(ctx);
}
let queued = self
.jobs
.as_ref()
.is_some_and(|jobs| jobs.send(path.clone()).is_ok());
if !queued {
let image = decode_image(&path);
self.insert(ctx, path, image);
}
}
#[cfg(target_arch = "wasm32")]
fn decode(&mut self, ctx: &Context, path: PathBuf) {
let image = decode_image(&path);
self.insert(ctx, path, image);
}
#[cfg(not(target_arch = "wasm32"))]
fn start_decoding(&self, ctx: &Context) -> Option<Sender<PathBuf>> {
let (jobs, queue) = unbounded::<PathBuf>();
let decoded = self.decoded.0.clone();
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("gled:ui:decode_image".to_string())
.spawn(move || {
#[cfg(feature = "profiling")]
profiling::register_thread!("decode_image");
while let Ok(path) = queue.recv() {
let image = decode_image(&path);
if decoded.send((path, image)).is_err() {
return;
}
ctx.request_repaint();
}
});
match spawned {
Ok(_) => Some(jobs),
Err(error) => {
tracing::warn!("Could not start decoding pictures on a thread: {error}");
None
}
}
}
}
pub(crate) fn decode_image(path: &Path) -> Decoded {
let is_svg = path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("svg"));
if is_svg {
let source = std::fs::read_to_string(path).map_err(|error| error.to_string())?;
let svg = gled_svg::ParsedSvg::parse(&source).map_err(|error| error.to_string())?;
let [width, height] = svg.size_mm();
let (width, height) = photo_raster_size(width / height);
let image = svg
.render(width, height)
.map_err(|error| error.to_string())?;
return Ok(color_image(&image));
}
let image = image::ImageReader::open(path)
.map_err(|error| error.to_string())?
.decode()
.map_err(|error| error.to_string())?;
let size = [image.width() as usize, image.height() as usize];
let buffer = image.to_rgba8();
Ok(ColorImage::from_rgba_unmultiplied(
size,
buffer.as_flat_samples().as_slice(),
))
}
pub(crate) fn sane_aspect(aspect: f32) -> f32 {
if aspect.is_finite() && aspect > 0.0 {
aspect
} else {
1.0
}
}
fn photo_raster_size(aspect: f32) -> (u32, u32) {
let aspect = sane_aspect(aspect);
let long = aspect.max(1.0);
let pixels = |edge: f32| (PHOTO_SIZE * edge / long).round().max(1.0) as u32;
(pixels(aspect), pixels(1.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_drawing_keeps_its_proportions_as_a_photo() {
assert_eq!(photo_raster_size(2.0), (1024, 512));
assert_eq!(photo_raster_size(0.5), (512, 1024));
}
#[test]
fn a_drawing_without_proportions_comes_out_square() {
assert_eq!(photo_raster_size(f32::NAN), (1024, 1024));
}
#[test]
fn a_failed_decode_keeps_the_picture_already_shown() {
let ctx = Context::default();
let mut images = Images::default();
let path = PathBuf::from("shared.png");
images.insert(
&ctx,
path.clone(),
Ok(ColorImage::filled([2, 2], egui::Color32::RED)),
);
images.insert(&ctx, path.clone(), Err("truncated".to_string()));
assert!(images.texture(&ctx, &path).is_some());
}
#[test]
fn a_missing_file_is_an_error_not_a_panic() {
assert!(decode_image(Path::new("/nonexistent/gled/photo.png")).is_err());
assert!(decode_image(Path::new("/nonexistent/gled/photo.svg")).is_err());
}
}