use gtk4::cairo::Context;
use gtk4::gdk::prelude::GdkCairoContextExt;
use gtk4::gdk_pixbuf::{InterpType, Pixbuf};
use crate::backend::ImagePaintResult;
use crate::event::Rect as QRect;
use crate::image_cache::ImageCache;
use crate::primitives::image::{Image, ImageSource};
fn load_pixbuf(source: &ImageSource) -> Option<Pixbuf> {
match source {
ImageSource::Path(path) => Pixbuf::from_file(path).ok(),
ImageSource::Bytes(bytes) => Pixbuf::from_read(std::io::Cursor::new(bytes.clone())).ok(),
}
}
fn paint_scaled(cr: &Context, bounds: QRect, target: QRect, scaled: &Pixbuf) -> ImagePaintResult {
if cr.save().is_err() {
return ImagePaintResult::Unsupported;
}
cr.rectangle(
bounds.x as f64,
bounds.y as f64,
bounds.width as f64,
bounds.height as f64,
);
cr.clip();
cr.set_source_pixbuf(scaled, target.x as f64, target.y as f64);
let _ = cr.paint();
let _ = cr.restore();
ImagePaintResult::Painted
}
pub fn draw_image(cr: &Context, x: f64, y: f64, w: f64, h: f64, image: &Image) -> ImagePaintResult {
if w <= 0.0 || h <= 0.0 {
return ImagePaintResult::Unsupported;
}
let Some(pixbuf) = load_pixbuf(&image.source) else {
return ImagePaintResult::Unsupported;
};
let bounds = QRect::new(x as f32, y as f32, w as f32, h as f32);
let target = image.layout(bounds).bounds;
let tw = (target.width.round() as i32).max(1);
let th = (target.height.round() as i32).max(1);
let Some(scaled) = pixbuf.scale_simple(tw, th, InterpType::Bilinear) else {
return ImagePaintResult::Unsupported;
};
paint_scaled(cr, bounds, target, &scaled)
}
pub(crate) fn draw_image_cached(
cr: &Context,
bounds: QRect,
image: &Image,
cache: &mut ImageCache<Pixbuf>,
dpi_scale: f32,
) -> ImagePaintResult {
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return ImagePaintResult::Unsupported;
}
let target = image.layout(bounds).bounds;
let tw = (target.width.round() as i32).max(1);
let th = (target.height.round() as i32).max(1);
let source = &image.source;
let scaled = cache.get_or_decode(source, tw as u32, th as u32, dpi_scale, || {
let pixbuf = load_pixbuf(source)?;
pixbuf.scale_simple(tw, th, InterpType::Bilinear)
});
let Some(scaled) = scaled else {
return ImagePaintResult::Unsupported;
};
paint_scaled(cr, bounds, target, scaled)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::image::ImageFit;
use crate::types::WidgetId;
use gtk4::cairo::{Context as CairoContext, Format, ImageSurface};
use gtk4::gdk_pixbuf::Colorspace;
fn surface() -> CairoContext {
let surface = ImageSurface::create(Format::ARgb32, 200, 200).expect("create ImageSurface");
CairoContext::new(&surface).expect("Context::new")
}
fn tiny_png_bytes() -> Vec<u8> {
let pixbuf = Pixbuf::new(Colorspace::Rgb, false, 8, 4, 4).expect("Pixbuf::new");
pixbuf.fill(0xff0000ff); pixbuf
.save_to_bufferv("png", &[])
.expect("Pixbuf::save_to_bufferv")
}
fn image(source: ImageSource, fit: ImageFit) -> Image {
Image {
id: WidgetId::new("logo"),
source,
intrinsic_size: Some((4, 4)),
fit,
fallback_text: "[LOGO]".into(),
}
}
fn cache() -> ImageCache<Pixbuf> {
ImageCache::with_capacity(4)
}
fn bounds(w: f32, h: f32) -> QRect {
QRect::new(0.0, 0.0, w, h)
}
#[test]
fn draw_image_from_bytes_reports_painted() {
let cr = surface();
let img = image(ImageSource::Bytes(tiny_png_bytes()), ImageFit::Contain);
let result = draw_image(&cr, 0.0, 0.0, 40.0, 40.0, &img);
assert_eq!(result, ImagePaintResult::Painted);
}
#[test]
fn draw_image_from_missing_path_reports_unsupported() {
let cr = surface();
let img = image(
ImageSource::Path("/nonexistent/does-not-exist.png".into()),
ImageFit::Contain,
);
let result = draw_image(&cr, 0.0, 0.0, 40.0, 40.0, &img);
assert_eq!(result, ImagePaintResult::Unsupported);
}
#[test]
fn draw_image_from_corrupt_bytes_reports_unsupported() {
let cr = surface();
let img = image(ImageSource::Bytes(vec![0, 1, 2, 3]), ImageFit::Contain);
let result = draw_image(&cr, 0.0, 0.0, 40.0, 40.0, &img);
assert_eq!(result, ImagePaintResult::Unsupported);
}
#[test]
fn zero_size_rect_reports_unsupported_without_decoding() {
let cr = surface();
let img = image(ImageSource::Bytes(tiny_png_bytes()), ImageFit::Contain);
let result = draw_image(&cr, 0.0, 0.0, 0.0, 0.0, &img);
assert_eq!(result, ImagePaintResult::Unsupported);
}
#[test]
fn draw_image_cached_from_bytes_reports_painted() {
let cr = surface();
let img = image(ImageSource::Bytes(tiny_png_bytes()), ImageFit::Contain);
let result = draw_image_cached(&cr, bounds(40.0, 40.0), &img, &mut cache(), 1.0);
assert_eq!(result, ImagePaintResult::Painted);
}
#[test]
fn draw_image_cached_from_missing_path_reports_unsupported() {
let cr = surface();
let img = image(
ImageSource::Path("/nonexistent/does-not-exist.png".into()),
ImageFit::Contain,
);
let result = draw_image_cached(&cr, bounds(40.0, 40.0), &img, &mut cache(), 1.0);
assert_eq!(result, ImagePaintResult::Unsupported);
}
#[test]
fn draw_image_cached_from_corrupt_bytes_reports_unsupported() {
let cr = surface();
let img = image(ImageSource::Bytes(vec![0, 1, 2, 3]), ImageFit::Contain);
let result = draw_image_cached(&cr, bounds(40.0, 40.0), &img, &mut cache(), 1.0);
assert_eq!(result, ImagePaintResult::Unsupported);
}
#[test]
fn draw_image_cached_zero_size_rect_reports_unsupported_without_decoding() {
let cr = surface();
let img = image(ImageSource::Bytes(tiny_png_bytes()), ImageFit::Contain);
let result = draw_image_cached(&cr, bounds(0.0, 0.0), &img, &mut cache(), 1.0);
assert_eq!(result, ImagePaintResult::Unsupported);
}
#[test]
fn cached_decode_survives_source_becoming_unreadable_on_disk() {
let path = std::env::temp_dir().join(format!(
"quadraui_image_cache_test_{}_{}.png",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
std::fs::write(&path, tiny_png_bytes()).expect("write tiny png fixture");
let mut shared_cache = cache();
let img = image(ImageSource::Path(path.clone()), ImageFit::Contain);
let cr1 = surface();
let first = draw_image_cached(&cr1, bounds(40.0, 40.0), &img, &mut shared_cache, 1.0);
assert_eq!(
first,
ImagePaintResult::Painted,
"first decode should succeed"
);
std::fs::write(&path, b"not a png").expect("corrupt fixture on disk");
let cr2 = surface();
let second = draw_image_cached(&cr2, bounds(40.0, 40.0), &img, &mut shared_cache, 1.0);
assert_eq!(
second,
ImagePaintResult::Painted,
"second draw at the same size/scale must hit the cache rather than \
re-decoding the now-corrupt file"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn different_target_size_still_decodes_and_paints() {
let mut shared_cache = cache();
let img = image(ImageSource::Bytes(tiny_png_bytes()), ImageFit::Fill);
let cr1 = surface();
let first = draw_image_cached(&cr1, bounds(40.0, 40.0), &img, &mut shared_cache, 1.0);
assert_eq!(first, ImagePaintResult::Painted);
let cr2 = surface();
let second = draw_image_cached(&cr2, bounds(80.0, 80.0), &img, &mut shared_cache, 1.0);
assert_eq!(second, ImagePaintResult::Painted);
}
}