quadraui 0.6.0

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
//! Direct2D + WIC rasteriser for [`crate::primitives::image::Image`]
//!
//! Decodes `image.source`'s encoded bytes through WIC
//! (`IWICImagingFactory`/`IWICBitmapDecoder`/`IWICFormatConverter`),
//! converts to a pixel format the 2D API can paint directly, and
//! stretches it into `image.layout(rect)`'s resolved target rect.
//! Collapses to `Unsupported` on any decode failure (missing file,
//! corrupt bytes, unrecognised format) — see
//! [`crate::backend::Backend::draw_image`]'s doc comment for why.
//!
//! Painting is always clipped to the caller's `rect` — the only
//! [`crate::primitives::image::ImageFit`] variants that can extend past
//! it are `Cover` and `None`, and neither should bleed into whatever the
//! host paints next to it (same [`super::text::push_clip`]/`pop_clip`
//! pair `super::panel`/`super::sidebar_panel` use for their own
//! overflow-prone content).
//!
//! # No decode cache here yet
//!
//! An [`ID2D1Bitmap`] is bound to the [`ID2D1RenderTarget`]
//! `CreateBitmapFromWicBitmap` built it from. `WinBackend::ensure_surface`'s
//! device-lost recovery (`D2DERR_RECREATE_TARGET`, see `win::backend`'s
//! module docs) can replace that target, so a cache would hand back a
//! bitmap bound to a target that no longer exists post-recovery.
//! Wiring this in correctly needs the cache to be invalidated in lockstep
//! with `ensure_surface`'s rebuild, which needs verifying against a real
//! Direct2D device-loss cycle — out of scope for now.
//!
//! # Why WIC instead of `ID2D1RenderTarget::CreateBitmap` from raw pixels
//!
//! `CreateBitmap` takes already-decoded pixel bytes at a known stride —
//! it has no PNG/JPEG decoder of its own. WIC is Direct2D's designated
//! decode partner for exactly this (`CreateBitmapFromWicBitmap`, gated in
//! the `windows` crate on the `Win32_Graphics_Imaging` feature added
//! alongside this module — see `Cargo.toml`'s comment on that feature).
//!
//! # Why COM must be initialized first
//!
//! `CoCreateInstance(CLSID_WICImagingFactory, ..)` needs
//! `CoInitializeEx` to have run on the calling thread first, exactly like
//! the `IFileOpenDialog`/`IFileSaveDialog` construction in
//! `super::services`. Reuses that module's
//! [`super::services::ensure_com_initialized`] thread-local guard rather
//! than duplicating it — both call sites want "COM is ready on this
//! thread," not two independent `Cell<bool>` dances.
//!
//! Only compiled on `target_os = "windows"` — see `super::mod`'s
//! `#[cfg(target_os = "windows")] mod image;` and `backend.rs`'s module
//! docs for why the rest of this repo's `--features win` compile gate
//! stays meaningful without a Windows host.

use windows::core::IUnknown;
use windows::Win32::Graphics::Direct2D::Common::D2D_RECT_F;
use windows::Win32::Graphics::Direct2D::{
    ID2D1Bitmap, ID2D1RenderTarget, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR,
};
use windows::Win32::Graphics::Imaging::{
    CLSID_WICImagingFactory, GUID_WICPixelFormat32bppPBGRA, IWICImagingFactory,
    WICBitmapDitherTypeNone, WICBitmapPaletteTypeCustom, WICDecodeMetadataCacheOnLoad,
};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
use windows::Win32::UI::Shell::SHCreateMemStream;

use super::services::ensure_com_initialized;
use super::text::{pop_clip, push_clip};
use crate::backend::ImagePaintResult;
use crate::event::Rect;
use crate::primitives::image::{Image, ImageSource};

/// Decode `source`'s encoded bytes into an [`ID2D1Bitmap`] bound to
/// `target`, or `None` on any failure. `Path` and `Bytes` funnel through
/// the same in-memory-stream path (`SHCreateMemStream` +
/// `CreateDecoderFromStream`) rather than WIC's separate
/// `CreateDecoderFromFilename` entry point, so both sources are sniffed
/// from content identically — mirroring
/// [`crate::gtk::image::load_pixbuf`]'s "the decoder sniffs the format,
/// no separate MIME hint" contract (see `primitives::image`'s module
/// docs).
fn decode_bitmap(target: &ID2D1RenderTarget, source: &ImageSource) -> Option<ID2D1Bitmap> {
    let bytes: Vec<u8> = match source {
        ImageSource::Path(path) => std::fs::read(path).ok()?,
        ImageSource::Bytes(bytes) => bytes.clone(),
    };

    ensure_com_initialized();
    // SAFETY: COM is initialized on this thread by the call above (see
    // the module doc's "Why COM must be initialized first"). `factory`
    // is a live COM interface this scope owns and releases on drop;
    // every other call below is an ordinary COM call through it or
    // through an interface it returned.
    let factory: IWICImagingFactory = unsafe {
        CoCreateInstance(
            &CLSID_WICImagingFactory,
            None::<&IUnknown>,
            CLSCTX_INPROC_SERVER,
        )
        .ok()?
    };
    // SAFETY: `bytes` is a local `Vec<u8>` that outlives this call;
    // `SHCreateMemStream` copies it into its own backing store rather
    // than borrowing the slice, per the documented API contract.
    let stream = unsafe { SHCreateMemStream(Some(&bytes)) }?;
    // SAFETY: `stream` is the live `IStream` returned above; `null()` for
    // the vendor GUID asks WIC to sniff the format from content, matching
    // this function's own doc comment on why `Path`/`Bytes` are decoded
    // identically.
    let decoder = unsafe {
        factory.CreateDecoderFromStream(&stream, std::ptr::null(), WICDecodeMetadataCacheOnLoad)
    }
    .ok()?;
    // SAFETY: `decoder` is the live `IWICBitmapDecoder` from the call
    // above; frame index 0 is always present for a decoder that
    // succeeded (WIC guarantees at least one frame on success).
    let frame = unsafe { decoder.GetFrame(0) }.ok()?;

    // Direct2D bitmaps want a premultiplied-alpha BGRA buffer; the source
    // frame could be indexed, grayscale, straight-alpha, ... — the format
    // converter normalises whatever WIC decoded to the one format
    // `CreateBitmapFromWicBitmap` is guaranteed to accept.
    // SAFETY: `factory` is still the live interface from above.
    let converter = unsafe { factory.CreateFormatConverter() }.ok()?;
    // SAFETY: `converter` is the interface just returned; `frame` is the
    // live `IWICBitmapFrameDecode` from above and only borrowed for the
    // duration of this synchronous `Initialize` call.
    unsafe {
        converter.Initialize(
            &frame,
            &GUID_WICPixelFormat32bppPBGRA,
            WICBitmapDitherTypeNone,
            None,
            0.0,
            WICBitmapPaletteTypeCustom,
        )
    }
    .ok()?;

    // SAFETY: `target` is the caller's live `ID2D1RenderTarget`;
    // `converter` was just initialized above into the one pixel format
    // this call is guaranteed to accept, per the comment on
    // `converter.Initialize` above.
    unsafe { target.CreateBitmapFromWicBitmap(&converter, None) }.ok()
}

/// Paint `image` within `rect` (DIPs, target-relative), honoring
/// `image.fit`. See the module docs.
pub fn draw_image(target: &ID2D1RenderTarget, rect: Rect, image: &Image) -> ImagePaintResult {
    if rect.width <= 0.0 || rect.height <= 0.0 {
        return ImagePaintResult::Unsupported;
    }
    let Some(bitmap) = decode_bitmap(target, &image.source) else {
        return ImagePaintResult::Unsupported;
    };

    let dest = image.layout(rect).bounds;
    let dest_f = D2D_RECT_F {
        left: dest.x,
        top: dest.y,
        right: dest.x + dest.width,
        bottom: dest.y + dest.height,
    };

    push_clip(target, rect);
    // SAFETY: `target` is the caller's live `ID2D1RenderTarget`; `bitmap`
    // came from `decode_bitmap` above, bound to this same `target` (see
    // the module doc's "No decode cache here yet" note on why a bitmap
    // must stay bound to the target that created it). `dest_f` is a
    // plain stack struct borrowed for the duration of this call.
    unsafe {
        target.DrawBitmap(
            &bitmap,
            Some(&dest_f),
            1.0,
            D2D1_BITMAP_INTERPOLATION_MODE_LINEAR,
            None,
        );
    }
    pop_clip(target);

    ImagePaintResult::Painted
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::primitives::image::ImageFit;
    use crate::theme::Theme;
    use crate::types::WidgetId;
    use crate::win::testing::HeadlessSurface;

    const W: u32 = 200;
    const H: u32 = 200;

    /// A tiny solid-colour PNG, generated at test time rather than
    /// checked in as a binary fixture — same approach
    /// `gtk::image::tests::tiny_png_bytes` takes, just built with the
    /// `png` crate's minimal writer instead of `gdk_pixbuf`.
    fn tiny_png_bytes() -> Vec<u8> {
        // Hand-rolled minimal 4x4 opaque-red PNG: a real decoder (WIC)
        // must parse this from scratch, so this is not a stand-in for a
        // "loads bytes" no-op — it exercises the actual decode path.
        let mut png = Vec::new();
        png.extend_from_slice(b"\x89PNG\r\n\x1a\n");

        fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
            out.extend_from_slice(&(data.len() as u32).to_be_bytes());
            let mut body = Vec::with_capacity(4 + data.len());
            body.extend_from_slice(kind);
            body.extend_from_slice(data);
            out.extend_from_slice(&body);
            out.extend_from_slice(&crc32(&body).to_be_bytes());
        }

        // IHDR: 4x4, 8-bit depth, colour type 2 (RGB), no interlace.
        let mut ihdr = Vec::new();
        ihdr.extend_from_slice(&4u32.to_be_bytes());
        ihdr.extend_from_slice(&4u32.to_be_bytes());
        ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
        chunk(&mut png, b"IHDR", &ihdr);

        // IDAT: 4 scanlines, each a filter byte (0 = None) + 4 RGB pixels
        // of opaque red, zlib-wrapped with stored (uncompressed) blocks.
        let mut raw = Vec::new();
        for _ in 0..4 {
            raw.push(0u8);
            for _ in 0..4 {
                raw.extend_from_slice(&[0xff, 0x00, 0x00]);
            }
        }
        let idat = zlib_store(&raw);
        chunk(&mut png, b"IDAT", &idat);

        chunk(&mut png, b"IEND", &[]);
        png
    }

    /// Zlib container with a single stored (uncompressed) deflate block —
    /// enough for WIC's PNG decoder to accept, no compression needed for
    /// a 4x4 fixture.
    fn zlib_store(data: &[u8]) -> Vec<u8> {
        let mut out = vec![0x78, 0x01]; // zlib header (no compression)
        let len = data.len() as u16;
        out.push(1); // BFINAL=1, BTYPE=00 (stored)
        out.extend_from_slice(&len.to_le_bytes());
        out.extend_from_slice(&(!len).to_le_bytes());
        out.extend_from_slice(data);
        out.extend_from_slice(&adler32(data).to_be_bytes());
        out
    }

    fn adler32(data: &[u8]) -> u32 {
        let mut a: u32 = 1;
        let mut b: u32 = 0;
        for &byte in data {
            a = (a + byte as u32) % 65521;
            b = (b + a) % 65521;
        }
        (b << 16) | a
    }

    fn crc32(data: &[u8]) -> u32 {
        let mut crc: u32 = 0xFFFF_FFFF;
        for &byte in data {
            crc ^= byte as u32;
            for _ in 0..8 {
                let mask = (crc & 1).wrapping_neg();
                crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
            }
        }
        !crc
    }

    fn image(source: ImageSource, fit: ImageFit) -> Image {
        Image {
            id: WidgetId::new("logo"),
            source,
            intrinsic_size: Some((4, 4)),
            fit,
            fallback_text: "[LOGO]".into(),
        }
    }

    /// The impl this replaced was a genuine `todo!()` — every image paint
    /// panicked.
    #[test]
    fn draw_image_from_bytes_paints_real_pixels() {
        let surface = HeadlessSurface::new(W, H).expect("create surface");
        surface
            .fill_rect(
                Rect::new(0.0, 0.0, W as f32, H as f32),
                Theme::default().background,
            )
            .expect("fill bg");

        let img = image(ImageSource::Bytes(tiny_png_bytes()), ImageFit::Fill);
        let rect = Rect::new(20.0, 20.0, 60.0, 60.0);
        let mut result = ImagePaintResult::Unsupported;
        surface
            .paint(|target| {
                result = draw_image(target, rect, &img);
            })
            .expect("paint image");

        assert_eq!(result, ImagePaintResult::Painted);
        let c = surface.pixel_at(50, 50);
        assert_eq!((c.r, c.g, c.b), (255, 0, 0), "decoded PNG should be red");

        // Outside the target rect: untouched.
        let outside = surface.pixel_at(5, 5);
        let bg = Theme::default().background;
        assert_eq!((outside.r, outside.g, outside.b), (bg.r, bg.g, bg.b));
    }

    #[test]
    fn draw_image_from_missing_path_reports_unsupported() {
        let surface = HeadlessSurface::new(W, H).expect("create surface");
        let img = image(
            ImageSource::Path("/nonexistent/does-not-exist.png".into()),
            ImageFit::Contain,
        );
        let mut result = ImagePaintResult::Painted;
        surface
            .paint(|target| {
                result = draw_image(target, Rect::new(0.0, 0.0, 40.0, 40.0), &img);
            })
            .expect("paint attempt");
        assert_eq!(result, ImagePaintResult::Unsupported);
    }

    #[test]
    fn draw_image_from_corrupt_bytes_reports_unsupported() {
        let surface = HeadlessSurface::new(W, H).expect("create surface");
        let img = image(ImageSource::Bytes(vec![0, 1, 2, 3]), ImageFit::Contain);
        let mut result = ImagePaintResult::Painted;
        surface
            .paint(|target| {
                result = draw_image(target, Rect::new(0.0, 0.0, 40.0, 40.0), &img);
            })
            .expect("paint attempt");
        assert_eq!(result, ImagePaintResult::Unsupported);
    }

    #[test]
    fn zero_size_rect_reports_unsupported_without_decoding() {
        let surface = HeadlessSurface::new(W, H).expect("create surface");
        let img = image(ImageSource::Bytes(tiny_png_bytes()), ImageFit::Contain);
        let mut result = ImagePaintResult::Painted;
        surface
            .paint(|target| {
                result = draw_image(target, Rect::new(0.0, 0.0, 0.0, 0.0), &img);
            })
            .expect("paint attempt");
        assert_eq!(result, ImagePaintResult::Unsupported);
    }
}