use crate::canvas::{model_textures::color_image, pictures};
use egui::{ColorImage, Context, TextureFilter, TextureHandle, TextureOptions};
use gled_protocol::bytes::SharedBytes;
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 {
Fetching,
Decoding,
Ready(TextureHandle),
Failed,
}
#[derive(Clone)]
pub(crate) struct Images {
loaded: HashMap<PathBuf, Loaded>,
#[cfg(not(target_arch = "wasm32"))]
jobs: Option<Sender<(PathBuf, SharedBytes)>>,
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)) => return Some(texture.clone()),
Some(Loaded::Decoding | Loaded::Failed) => return None,
Some(Loaded::Fetching) | None => {}
}
let loaded = match pictures::get(path) {
None => Loaded::Fetching,
Some(Ok(bytes)) => {
self.decode(ctx, path.to_path_buf(), bytes);
Loaded::Decoding
}
Some(Err(error)) => {
tracing::warn!("Could not load {}: {error}", path.display());
Loaded::Failed
}
};
self.loaded.entry(path.to_path_buf()).or_insert(loaded);
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, bytes: SharedBytes) {
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(), bytes.clone())).is_ok());
if !queued {
let image = decode_picture(&path, &bytes.0);
self.insert(ctx, path, image);
}
}
#[cfg(target_arch = "wasm32")]
fn decode(&mut self, ctx: &Context, path: PathBuf, bytes: SharedBytes) {
let image = decode_picture(&path, &bytes.0);
self.insert(ctx, path, image);
}
#[cfg(not(target_arch = "wasm32"))]
fn start_decoding(&self, ctx: &Context) -> Option<Sender<(PathBuf, SharedBytes)>> {
let (jobs, queue) = unbounded::<(PathBuf, SharedBytes)>();
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, bytes)) = queue.recv() {
let image = decode_picture(&path, &bytes.0);
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_picture(path: &Path, bytes: &[u8]) -> Decoded {
let is_svg = path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("svg"));
if is_svg {
let source = std::str::from_utf8(bytes).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::load_from_memory(bytes).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_broken_picture_is_an_error_not_a_panic() {
assert!(decode_picture(Path::new("photo.png"), b"no png").is_err());
assert!(decode_picture(Path::new("photo.svg"), &[0xFF, 0xFE]).is_err());
}
}