Skip to main content

journey/
imagediff.rs

1//! Graphical diffing of image blobs.
2//!
3//! When a changed file is an image, a unified text diff is useless ("Binary
4//! files differ"). Instead journey decodes the two sides and shows them
5//! visually. This module is the pure, toolkit-independent half: it decodes the
6//! [`BlobPair`](crate::backend::BlobPair) the backend hands over and composes
7//! the two images into a single ARGB [`Canvas`] for one of several comparison
8//! modes — the same set the author's `imgap` CLI offers interactively:
9//!
10//! * **2-up** — the before/after images side by side.
11//! * **Swipe** — a single frame split left-from-`old`, right-from-`new`, the
12//!   split following a 0..1 slider.
13//! * **Onion skin** — the two cross-faded by the slider.
14//! * **Difference** — a heatmap of per-pixel difference (black = identical,
15//!   through blue/green/yellow to red = maximal; magenta where the images
16//!   differ in size, so don't overlap).
17//! * **Left** / **Right** — just one side, full size.
18//!
19//! [`ImageComparison`] owns the decoded images, a cache of the fit-scaled
20//! buffers, and the last fully-composed [`Canvas`] keyed by what produced it —
21//! so [`ImageComparison::render`] is cheap to call every frame: an unchanged
22//! repaint (a caret blink, a scroll in another pane) returns the cached canvas
23//! without re-scaling or re-compositing. The widget that drives it lives in
24//! [`crate::widgets::ImageDiffView`].
25
26use image::{Rgba, RgbaImage};
27
28use crate::backend::BlobPair;
29
30/// Edge of one transparency-checker square, in canvas pixels.
31const CHECK_SIZE: u32 = 8;
32const CHECK_LIGHT: [u8; 3] = [0xFF, 0xFF, 0xFF];
33const CHECK_DARK: [u8; 3] = [0xCC, 0xCC, 0xCC];
34/// Gap (and its color) between the two images in 2-up mode.
35const TWO_UP_SEP: u32 = 4;
36const SEP_COLOR: Rgba<u8> = Rgba([0x80, 0x80, 0x80, 0xFF]);
37/// The 1px divider drawn at the swipe split.
38const SWIPE_DIVIDER: Rgba<u8> = Rgba([0xFF, 0xDC, 0x00, 0xFF]);
39
40/// File extensions journey treats as raster images for the graphical diff.
41/// SVG is deliberately excluded — it's text, so its normal diff is meaningful.
42const IMAGE_EXTENSIONS: &[&str] = &[
43    "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "tif", "ico", "qoi", "tga", "pnm", "ppm",
44    "pgm", "pbm",
45];
46
47/// Whether `path` looks like a raster image worth showing graphically.
48pub fn is_image_path(path: &str) -> bool {
49    path.rsplit('.')
50        .next()
51        .filter(|_| path.contains('.'))
52        .map(|ext| ext.to_ascii_lowercase())
53        .is_some_and(|ext| IMAGE_EXTENSIONS.contains(&ext.as_str()))
54}
55
56/// One of the ways the two images can be compared.
57#[derive(Clone, Copy, PartialEq, Eq, Debug)]
58pub enum CompareMode {
59    TwoUp,
60    Swipe,
61    Onion,
62    Difference,
63    Left,
64    Right,
65}
66
67impl CompareMode {
68    /// The comparison modes offered in the control bar, left to right. The
69    /// single-image views (`Left`/`Right`) come last.
70    pub const ALL: [CompareMode; 6] = [
71        CompareMode::TwoUp,
72        CompareMode::Swipe,
73        CompareMode::Onion,
74        CompareMode::Difference,
75        CompareMode::Left,
76        CompareMode::Right,
77    ];
78
79    /// Short label for the mode button.
80    pub fn label(self) -> &'static str {
81        match self {
82            CompareMode::TwoUp => "2-Up",
83            CompareMode::Swipe => "Swipe",
84            CompareMode::Onion => "Onion",
85            CompareMode::Difference => "Diff",
86            CompareMode::Left => "Left",
87            CompareMode::Right => "Right",
88        }
89    }
90
91    /// Cycle to the next comparison mode (the View ▸ Switch Mode action). The
92    /// single-image views are skipped — they cycle back to 2-Up.
93    pub fn next(self) -> Self {
94        match self {
95            CompareMode::TwoUp => CompareMode::Swipe,
96            CompareMode::Swipe => CompareMode::Onion,
97            CompareMode::Onion => CompareMode::Difference,
98            CompareMode::Difference => CompareMode::TwoUp,
99            CompareMode::Left | CompareMode::Right => CompareMode::TwoUp,
100        }
101    }
102
103    /// Whether this mode is steered by the 0..1 slider.
104    pub fn uses_slider(self) -> bool {
105        matches!(self, CompareMode::Swipe | CompareMode::Onion)
106    }
107
108    /// Whether this mode shows just one side.
109    pub fn is_single(self) -> bool {
110        matches!(self, CompareMode::Left | CompareMode::Right)
111    }
112}
113
114/// A composed, fully-opaque ARGB image ready to blit, sized to the actual
115/// content (≤ the box passed to [`ImageComparison::render`], which centers it).
116pub struct Canvas {
117    pub w: u32,
118    pub h: u32,
119    /// `0xAARRGGBB` per pixel, row-major, alpha always `0xFF`.
120    pub argb: Vec<u32>,
121}
122
123impl Canvas {
124    fn empty() -> Self {
125        Canvas {
126            w: 0,
127            h: 0,
128            argb: Vec::new(),
129        }
130    }
131}
132
133/// How finely the slider position is quantized for the [`RenderCache`] key.
134/// 1000 steps is finer than a pixel on any real pane, so the cache only misses
135/// when the composited image would actually change.
136const SLIDER_STEPS: u32 = 1000;
137
138/// The last fully-composed [`Canvas`] and the key it was built for. A repaint
139/// that changes none of `(mode, slider, box)` re-blits these pixels instead of
140/// re-running the scale + composite + flatten pipeline — which matters because
141/// the toolkit repaints the whole tree on any event (a caret blink, a scroll
142/// elsewhere), each of which would otherwise recompose the image from scratch.
143struct RenderCache {
144    mode: CompareMode,
145    /// Slider position quantized to [`SLIDER_STEPS`]; held at 0 for modes that
146    /// ignore the slider, so their cache survives an unrelated slider change.
147    slider_key: u32,
148    box_w: u32,
149    box_h: u32,
150    canvas: Canvas,
151}
152
153/// Fit-scaled buffers cached for a given render box, so the slider modes don't
154/// re-scale the originals every frame.
155struct FitCache {
156    box_w: u32,
157    box_h: u32,
158    /// Both sides laid out on a common canvas and scaled by one shared factor,
159    /// so identical coordinates line up for swipe / onion.
160    norm_old: RgbaImage,
161    norm_new: RgbaImage,
162    /// The difference heatmap, scaled to the same box.
163    diff: RgbaImage,
164}
165
166/// The decoded two sides of an image change, plus a metadata summary line.
167pub struct ImageComparison {
168    old: Option<RgbaImage>,
169    new: Option<RgbaImage>,
170    meta: String,
171    cache: Option<FitCache>,
172    render_cache: Option<RenderCache>,
173}
174
175impl ImageComparison {
176    /// Decode both sides of `blobs`. Returns `None` when neither side decodes to
177    /// an image (so the caller falls back to the text diff).
178    pub fn from_blobs(blobs: &BlobPair) -> Option<Self> {
179        let old = blobs.old.as_deref().and_then(decode);
180        let new = blobs.new.as_deref().and_then(decode);
181        if old.is_none() && new.is_none() {
182            return None;
183        }
184        let meta = meta_line(blobs, old.as_ref(), new.as_ref());
185        Some(ImageComparison {
186            old,
187            new,
188            meta,
189            cache: None,
190            render_cache: None,
191        })
192    }
193
194    /// The `PNG 64x64 1.2KiB→1.4KiB`-style summary shown above the image.
195    pub fn meta(&self) -> &str {
196        &self.meta
197    }
198
199    /// The opaque canvas for `mode` at slider position `slider` (0..1), fitting
200    /// within `box_w` × `box_h`. Returns a cached canvas when nothing that
201    /// affects the pixels has changed since the last call, so repaints driven by
202    /// unrelated UI activity don't recompose the image (see [`RenderCache`]).
203    pub fn render(&mut self, mode: CompareMode, slider: f32, box_w: u32, box_h: u32) -> &Canvas {
204        let slider_key = if mode.uses_slider() {
205            (slider.clamp(0.0, 1.0) * SLIDER_STEPS as f32).round() as u32
206        } else {
207            0
208        };
209        let hit = self.render_cache.as_ref().is_some_and(|c| {
210            c.mode == mode && c.slider_key == slider_key && c.box_w == box_w && c.box_h == box_h
211        });
212        if !hit {
213            let canvas = self.compose(mode, slider, box_w, box_h);
214            self.render_cache = Some(RenderCache {
215                mode,
216                slider_key,
217                box_w,
218                box_h,
219                canvas,
220            });
221        }
222        &self
223            .render_cache
224            .as_ref()
225            .expect("cache just populated")
226            .canvas
227    }
228
229    /// Run the scale + composite + flatten pipeline for `mode`, with no caching.
230    fn compose(&mut self, mode: CompareMode, slider: f32, box_w: u32, box_h: u32) -> Canvas {
231        if box_w == 0 || box_h == 0 {
232            return Canvas::empty();
233        }
234        let composed = match mode {
235            CompareMode::TwoUp => self.two_up(box_w, box_h),
236            CompareMode::Left => single(self.old.as_ref(), box_w, box_h),
237            CompareMode::Right => single(self.new.as_ref(), box_w, box_h),
238            CompareMode::Swipe | CompareMode::Onion | CompareMode::Difference => {
239                self.ensure_cache(box_w, box_h);
240                let c = self.cache.as_ref().expect("cache just built");
241                match mode {
242                    CompareMode::Swipe => compose_swipe(&c.norm_old, &c.norm_new, slider),
243                    CompareMode::Onion => compose_onion(&c.norm_old, &c.norm_new, slider),
244                    CompareMode::Difference => c.diff.clone(),
245                    _ => unreachable!(),
246                }
247            }
248        };
249        flatten_to_canvas(&composed)
250    }
251
252    /// The before/after images side by side, each in half the width.
253    fn two_up(&self, box_w: u32, box_h: u32) -> RgbaImage {
254        let half = box_w.saturating_sub(TWO_UP_SEP) / 2;
255        let left = single(self.old.as_ref(), half.max(1), box_h);
256        let right = single(self.new.as_ref(), half.max(1), box_h);
257        let h = left.height().max(right.height());
258        let w = left.width() + TWO_UP_SEP + right.width();
259        let mut canvas = RgbaImage::new(w, h);
260        // Vertical separator bar between the two panels.
261        for y in 0..h {
262            for x in left.width()..(left.width() + TWO_UP_SEP) {
263                canvas.put_pixel(x, y, SEP_COLOR);
264            }
265        }
266        let lw = left.width();
267        image::imageops::overlay(&mut canvas, &left, 0, 0);
268        image::imageops::overlay(&mut canvas, &right, (lw + TWO_UP_SEP) as i64, 0);
269        canvas
270    }
271
272    /// Build (or reuse) the fit-scaled buffers for `box_w` × `box_h`.
273    fn ensure_cache(&mut self, box_w: u32, box_h: u32) {
274        if self
275            .cache
276            .as_ref()
277            .is_some_and(|c| c.box_w == box_w && c.box_h == box_h)
278        {
279            return;
280        }
281        let (na, nb) = normalize_pair(self.old.as_ref(), self.new.as_ref());
282        let (norm_old, norm_new) = scale_pair(&na, &nb, box_w, box_h);
283        let diff = scale_fit(
284            &build_diff_heatmap(self.old.as_ref(), self.new.as_ref()),
285            box_w,
286            box_h,
287        );
288        self.cache = Some(FitCache {
289            box_w,
290            box_h,
291            norm_old,
292            norm_new,
293            diff,
294        });
295    }
296}
297
298/// Decode raw image bytes to RGBA, or `None` if the format isn't recognized.
299fn decode(bytes: &[u8]) -> Option<RgbaImage> {
300    image::load_from_memory(bytes)
301        .ok()
302        .map(|img| img.to_rgba8())
303}
304
305/// Scale one image to fit within `max_w` × `max_h`, preserving aspect ratio.
306/// Returned unchanged when it already fits.
307fn scale_fit(img: &RgbaImage, max_w: u32, max_h: u32) -> RgbaImage {
308    let (w, h) = (img.width(), img.height());
309    if w == 0 || h == 0 || (w <= max_w && h <= max_h) {
310        return img.clone();
311    }
312    let scale = (max_w as f64 / w as f64).min(max_h as f64 / h as f64);
313    let nw = ((w as f64 * scale) as u32).max(1);
314    let nh = ((h as f64 * scale) as u32).max(1);
315    image::imageops::resize(img, nw, nh, image::imageops::FilterType::Triangle)
316}
317
318/// A single side scaled to fit the box, or a transparent placeholder (which
319/// flattens to the checkerboard) when that side is absent.
320fn single(img: Option<&RgbaImage>, box_w: u32, box_h: u32) -> RgbaImage {
321    match img {
322        Some(img) => scale_fit(img, box_w, box_h),
323        None => RgbaImage::new(box_w.max(1), box_h.max(1)),
324    }
325}
326
327/// Lay both sides on a common canvas (the max of the two sizes) so identical
328/// coordinates line up. A missing side becomes a transparent canvas.
329fn normalize_pair(a: Option<&RgbaImage>, b: Option<&RgbaImage>) -> (RgbaImage, RgbaImage) {
330    let w = a
331        .map_or(0, |i| i.width())
332        .max(b.map_or(0, |i| i.width()))
333        .max(1);
334    let h = a
335        .map_or(0, |i| i.height())
336        .max(b.map_or(0, |i| i.height()))
337        .max(1);
338    let mut out_a = RgbaImage::new(w, h);
339    let mut out_b = RgbaImage::new(w, h);
340    if let Some(a) = a {
341        image::imageops::overlay(&mut out_a, a, 0, 0);
342    }
343    if let Some(b) = b {
344        image::imageops::overlay(&mut out_b, b, 0, 0);
345    }
346    (out_a, out_b)
347}
348
349/// Scale two equally-sized images by the same factor to fit the box.
350fn scale_pair(a: &RgbaImage, b: &RgbaImage, max_w: u32, max_h: u32) -> (RgbaImage, RgbaImage) {
351    let (w, h) = (a.width(), a.height());
352    if w == 0 || h == 0 || (w <= max_w && h <= max_h) {
353        return (a.clone(), b.clone());
354    }
355    let scale = (max_w as f64 / w as f64).min(max_h as f64 / h as f64);
356    let nw = ((w as f64 * scale) as u32).max(1);
357    let nh = ((h as f64 * scale) as u32).max(1);
358    let f = image::imageops::FilterType::Triangle;
359    (
360        image::imageops::resize(a, nw, nh, f),
361        image::imageops::resize(b, nw, nh, f),
362    )
363}
364
365/// A vertical-split composite: left of `t` comes from `a`, right from `b`, with
366/// a 1px divider at the split. `a` and `b` must share dimensions.
367fn compose_swipe(a: &RgbaImage, b: &RgbaImage, t: f32) -> RgbaImage {
368    let (w, h) = a.dimensions();
369    let split = ((w as f32) * t.clamp(0.0, 1.0)) as u32;
370    let mut out = RgbaImage::new(w, h);
371    for y in 0..h {
372        for x in 0..w {
373            let px = if x < split { a } else { b }.get_pixel(x, y);
374            out.put_pixel(x, y, *px);
375        }
376        if split < w {
377            out.put_pixel(split, y, SWIPE_DIVIDER);
378        }
379    }
380    out
381}
382
383/// A cross-fade: `a*(1-t) + b*t` per channel. `a` and `b` share dimensions.
384fn compose_onion(a: &RgbaImage, b: &RgbaImage, t: f32) -> RgbaImage {
385    let t = (t.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
386    let inv = 255 - t;
387    let (w, h) = a.dimensions();
388    let ra = a.as_raw();
389    let rb = b.as_raw();
390    let mut out = vec![0u8; ra.len()];
391    for i in 0..ra.len() {
392        out[i] = ((ra[i] as u32 * inv + rb[i] as u32 * t + 127) / 255) as u8;
393    }
394    RgbaImage::from_raw(w, h, out).expect("onion buffer matches dimensions")
395}
396
397/// A per-pixel difference heatmap at native resolution. Overlapping pixels are
398/// colored by luminance-weighted difference (black→blue→green→yellow→red);
399/// regions only one image covers stay magenta.
400fn build_diff_heatmap(a: Option<&RgbaImage>, b: Option<&RgbaImage>) -> RgbaImage {
401    let aw = a.map_or(0, |i| i.width());
402    let ah = a.map_or(0, |i| i.height());
403    let bw = b.map_or(0, |i| i.width());
404    let bh = b.map_or(0, |i| i.height());
405    let w = aw.max(bw).max(1);
406    let h = ah.max(bh).max(1);
407    let mut out = RgbaImage::from_pixel(w, h, Rgba([0xFF, 0x00, 0xFF, 0xFF]));
408
409    // Only the overlapping rectangle gets a real difference value.
410    let (Some(a), Some(b)) = (a, b) else {
411        return out;
412    };
413    let common_w = aw.min(bw);
414    let common_h = ah.min(bh);
415    for y in 0..common_h {
416        for x in 0..common_w {
417            let pa = a.get_pixel(x, y).0;
418            let pb = b.get_pixel(x, y).0;
419            let dr = (pa[0] as i16 - pb[0] as i16).unsigned_abs() as u32;
420            let dg = (pa[1] as i16 - pb[1] as i16).unsigned_abs() as u32;
421            let db = (pa[2] as i16 - pb[2] as i16).unsigned_abs() as u32;
422            let diff = (77 * dr + 151 * dg + 28 * db) as f32 / (255.0 * 256.0);
423            out.put_pixel(x, y, heatmap_color(diff));
424        }
425    }
426    out
427}
428
429/// Map a 0..1 difference magnitude to the black→blue→green→yellow→red ramp.
430fn heatmap_color(t: f32) -> Rgba<u8> {
431    let t = t.clamp(0.0, 1.0);
432    let (r, g, b) = if t < 0.25 {
433        (0.0, 0.0, t / 0.25)
434    } else if t < 0.5 {
435        let s = (t - 0.25) / 0.25;
436        (0.0, s, 1.0 - s)
437    } else if t < 0.75 {
438        let s = (t - 0.5) / 0.25;
439        (s, 1.0, 0.0)
440    } else {
441        let s = (t - 0.75) / 0.25;
442        (1.0, 1.0 - s, 0.0)
443    };
444    Rgba([(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255])
445}
446
447/// Composite an RGBA image over the transparency checkerboard, yielding opaque
448/// `0xAARRGGBB` pixels ready to blit.
449fn flatten_to_canvas(img: &RgbaImage) -> Canvas {
450    let (w, h) = img.dimensions();
451    let mut argb = vec![0u32; (w * h) as usize];
452    for y in 0..h {
453        for x in 0..w {
454            let px = img.get_pixel(x, y).0;
455            let a = px[3] as u32;
456            let bg = checker(x, y);
457            let blend = |s: u8, d: u8| (s as u32 * a + d as u32 * (255 - a)) / 255;
458            let r = blend(px[0], bg[0]);
459            let g = blend(px[1], bg[1]);
460            let b = blend(px[2], bg[2]);
461            argb[(y * w + x) as usize] = 0xFF00_0000 | (r << 16) | (g << 8) | b;
462        }
463    }
464    Canvas { w, h, argb }
465}
466
467/// The transparency-checker color at an absolute canvas pixel.
468fn checker(x: u32, y: u32) -> [u8; 3] {
469    if ((x / CHECK_SIZE) + (y / CHECK_SIZE)).is_multiple_of(2) {
470        CHECK_LIGHT
471    } else {
472        CHECK_DARK
473    }
474}
475
476/// Build the metadata summary: `FORMAT WxH SIZE`, with each property collapsed
477/// to one value when both sides match and rendered `old→new` when they differ.
478fn meta_line(blobs: &BlobPair, old: Option<&RgbaImage>, new: Option<&RgbaImage>) -> String {
479    let fmt = pair_str(
480        blobs.old.as_deref().and_then(format_name),
481        blobs.new.as_deref().and_then(format_name),
482    );
483    let dims = pair_str(
484        old.map(|i| format!("{}x{}", i.width(), i.height())),
485        new.map(|i| format!("{}x{}", i.width(), i.height())),
486    );
487    let size = pair_str(
488        blobs.old.as_ref().map(|b| human_size(b.len())),
489        blobs.new.as_ref().map(|b| human_size(b.len())),
490    );
491    [fmt, dims, size]
492        .into_iter()
493        .flatten()
494        .collect::<Vec<_>>()
495        .join("  ")
496}
497
498/// Collapse a per-side value into a display string: one value when the sides
499/// match, `a→b` when they differ, and just the present side when one is absent.
500fn pair_str(a: Option<String>, b: Option<String>) -> Option<String> {
501    match (a, b) {
502        (Some(a), Some(b)) if a == b => Some(a),
503        (Some(a), Some(b)) => Some(format!("{a}→{b}")),
504        (Some(a), None) | (None, Some(a)) => Some(a),
505        (None, None) => None,
506    }
507}
508
509/// The image format name guessed from a blob's magic bytes.
510fn format_name(bytes: &[u8]) -> Option<String> {
511    use image::ImageFormat as F;
512    let name = match image::guess_format(bytes).ok()? {
513        F::Png => "PNG",
514        F::Jpeg => "JPEG",
515        F::Gif => "GIF",
516        F::WebP => "WebP",
517        F::Bmp => "BMP",
518        F::Tiff => "TIFF",
519        F::Ico => "ICO",
520        F::Pnm => "PNM",
521        F::Tga => "TGA",
522        F::Qoi => "QOI",
523        _ => return None,
524    };
525    Some(name.to_string())
526}
527
528/// Human-readable byte size, e.g. `1.4KiB`.
529fn human_size(bytes: usize) -> String {
530    const KIB: f64 = 1024.0;
531    const MIB: f64 = KIB * 1024.0;
532    let b = bytes as f64;
533    if b >= MIB {
534        format!("{:.1}MiB", b / MIB)
535    } else if b >= KIB {
536        format!("{:.1}KiB", b / KIB)
537    } else {
538        format!("{bytes}B")
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    /// Encode a solid-color `w`×`h` PNG for tests.
547    fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
548        let img = RgbaImage::from_pixel(w, h, Rgba(color));
549        let mut bytes = Vec::new();
550        image::DynamicImage::ImageRgba8(img)
551            .write_to(
552                &mut std::io::Cursor::new(&mut bytes),
553                image::ImageFormat::Png,
554            )
555            .unwrap();
556        bytes
557    }
558
559    #[test]
560    fn recognizes_image_extensions() {
561        assert!(is_image_path("a/b/logo.png"));
562        assert!(is_image_path("ICON.PNG"));
563        assert!(is_image_path("photo.jpeg"));
564        assert!(!is_image_path("src/main.rs"));
565        assert!(!is_image_path("drawing.svg")); // SVG stays a text diff
566        assert!(!is_image_path("Makefile"));
567    }
568
569    #[test]
570    fn from_blobs_none_when_undecodable() {
571        let blobs = BlobPair {
572            old: Some(b"not an image".to_vec()),
573            new: Some(b"still not".to_vec()),
574        };
575        assert!(ImageComparison::from_blobs(&blobs).is_none());
576    }
577
578    #[test]
579    fn renders_every_mode_to_opaque_canvas() {
580        let blobs = BlobPair {
581            old: Some(png(8, 8, [255, 0, 0, 255])),
582            new: Some(png(8, 8, [0, 0, 255, 255])),
583        };
584        let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
585        for mode in CompareMode::ALL {
586            let canvas = cmp.render(mode, 0.5, 64, 64);
587            assert!(canvas.w > 0 && canvas.h > 0, "{mode:?} produced no canvas");
588            assert_eq!(canvas.argb.len(), (canvas.w * canvas.h) as usize);
589            // Every pixel must be fully opaque so the blit needs no alpha math.
590            assert!(
591                canvas.argb.iter().all(|p| p >> 24 == 0xFF),
592                "{mode:?} left transparent pixels"
593            );
594        }
595    }
596
597    #[test]
598    fn difference_of_identical_images_is_black() {
599        let bytes = png(8, 8, [10, 200, 30, 255]);
600        let blobs = BlobPair {
601            old: Some(bytes.clone()),
602            new: Some(bytes),
603        };
604        let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
605        let canvas = cmp.render(CompareMode::Difference, 0.0, 32, 32);
606        // Identical inputs → zero difference → black (0xFF000000) everywhere.
607        assert!(canvas.argb.iter().all(|&p| p == 0xFF00_0000));
608    }
609
610    #[test]
611    fn render_reuses_the_cached_canvas_until_the_key_changes() {
612        let blobs = BlobPair {
613            old: Some(png(8, 8, [255, 0, 0, 255])),
614            new: Some(png(8, 8, [0, 0, 255, 255])),
615        };
616        let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
617        // The buffer address identifies the cached canvas: a cache hit returns
618        // the same stored `Canvas`, a miss composes a fresh one (a new alloc,
619        // built while the old is still live, so the pointers always differ).
620        let ptr = |c: &mut ImageComparison, m, s, w, h| c.render(m, s, w, h).argb.as_ptr();
621
622        let a = ptr(&mut cmp, CompareMode::TwoUp, 0.5, 64, 64);
623        assert_eq!(
624            a,
625            ptr(&mut cmp, CompareMode::TwoUp, 0.5, 64, 64),
626            "same key reuses the cached canvas"
627        );
628        assert_eq!(
629            a,
630            ptr(&mut cmp, CompareMode::TwoUp, 0.9, 64, 64),
631            "the slider is irrelevant in 2-up, so the cache stays warm"
632        );
633        let b = ptr(&mut cmp, CompareMode::TwoUp, 0.5, 80, 64);
634        assert_ne!(a, b, "a different box size recomposes");
635        let c = ptr(&mut cmp, CompareMode::Swipe, 0.5, 80, 64);
636        assert_ne!(b, c, "a different mode recomposes");
637        assert_eq!(
638            c,
639            ptr(&mut cmp, CompareMode::Swipe, 0.5, 80, 64),
640            "a slider mode caches at a fixed slider position"
641        );
642        assert_ne!(
643            c,
644            ptr(&mut cmp, CompareMode::Swipe, 0.95, 80, 64),
645            "moving the slider in a slider mode recomposes"
646        );
647    }
648
649    #[test]
650    fn added_image_has_one_side_and_collapsed_meta() {
651        let blobs = BlobPair {
652            old: None,
653            new: Some(png(16, 16, [0, 0, 0, 255])),
654        };
655        let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
656        assert!(cmp.meta().contains("16x16"));
657        // Left (the missing old side) renders as the checkerboard only.
658        let left = cmp.render(CompareMode::Left, 0.0, 32, 32);
659        assert!(left.w > 0 && left.h > 0);
660    }
661}