Skip to main content

journey/widgets/
image_diff_view.rs

1//! A graphical image-diff pane.
2//!
3//! Shown in place of [`DiffView`](crate::widgets::DiffView) when the selected
4//! file is a raster image (see [`crate::imagediff::is_image_path`]). It decodes
5//! the two sides and offers the same comparison modes as the author's `imgap`
6//! CLI — 2-up, swipe, onion skin, difference, and single left/right — selected
7//! from a row of buttons along the bottom, with a slider driving the swipe /
8//! onion-skin position. The image floats on the same sunken white field the
9//! text diff uses, over a transparency checkerboard.
10//!
11//! Like [`DiffView`] this is a self-contained widget: it embeds no child
12//! widgets, hand-drawing its buttons and slider and tracking their hit-rects,
13//! so the cross-pane wiring in [`crate::ui`] stays simple.
14
15use saudade::{Color, Event, EventCtx, MouseButton, Painter, Point, Rect, Theme, Widget};
16
17use crate::imagediff::{CompareMode, ImageComparison};
18
19const PAD: i32 = 4;
20/// Height of the metadata line along the top.
21const META_H: i32 = 18;
22/// Height of the mode-button row along the bottom.
23const BTN_H: i32 = 22;
24/// Height of the slider row above the buttons (present only for slider modes).
25const SLIDER_H: i32 = 16;
26/// Gap between adjacent mode buttons.
27const BTN_GAP: i32 = 3;
28
29/// Backdrop behind the centered image (the letterbox area). Matches the field
30/// so an aspect-fit image just floats on white.
31const LETTERBOX: Color = Color::WHITE;
32const META_FG: Color = Color::rgb(0x40, 0x40, 0x40);
33/// Filled portion of the slider trough.
34const SLIDER_FILL: Color = Color::rgb(0x00, 0x00, 0x80);
35
36/// A graphical comparison of the two versions of an image file.
37pub struct ImageDiffView {
38    rect: Rect,
39    comparison: Option<ImageComparison>,
40    mode: CompareMode,
41    /// The comparison mode to return to from a single-image view (the `s` key).
42    last_compare_mode: CompareMode,
43    /// Swipe / onion-skin position, 0..1.
44    slider: f32,
45    font_size: f32,
46    /// Mode-button hit-rects from the last paint, for event hit-testing.
47    button_rects: Vec<(CompareMode, Rect)>,
48    dragging_slider: bool,
49    /// A drag on the image body is steering the slider (swipe / onion modes).
50    dragging_image: bool,
51}
52
53impl ImageDiffView {
54    pub fn new(rect: Rect) -> Self {
55        Self {
56            rect,
57            comparison: None,
58            mode: CompareMode::TwoUp,
59            last_compare_mode: CompareMode::TwoUp,
60            slider: 0.5,
61            font_size: 12.0,
62            button_rects: Vec::new(),
63            dragging_slider: false,
64            dragging_image: false,
65        }
66    }
67
68    pub fn with_font_size(mut self, size: f32) -> Self {
69        self.font_size = size;
70        self
71    }
72
73    /// Show `comparison` (or clear the pane with `None`). The chosen mode and
74    /// slider position persist across files, matching the comparison the user
75    /// last picked.
76    pub fn set_comparison(&mut self, comparison: Option<ImageComparison>) {
77        self.comparison = comparison;
78        self.dragging_slider = false;
79        self.dragging_image = false;
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.comparison.is_none()
84    }
85
86    /// The currently selected comparison mode (exposed for tests).
87    #[cfg(test)]
88    pub fn mode(&self) -> CompareMode {
89        self.mode
90    }
91
92    /// The current slider position (exposed for tests).
93    #[cfg(test)]
94    pub fn slider(&self) -> f32 {
95        self.slider
96    }
97
98    /// The sunken field, inset one pixel inside the border.
99    fn field(&self) -> Rect {
100        self.rect
101    }
102
103    fn inner(&self) -> Rect {
104        self.field().inset(PAD)
105    }
106
107    /// Height the control strip occupies (buttons, plus the slider row for
108    /// slider modes).
109    fn controls_h(&self) -> i32 {
110        if self.mode.uses_slider() {
111            BTN_H + SLIDER_H + PAD
112        } else {
113            BTN_H
114        }
115    }
116
117    /// The metadata line along the top.
118    fn meta_rect(&self) -> Rect {
119        let inner = self.inner();
120        Rect::new(inner.x, inner.y, inner.w, META_H)
121    }
122
123    /// The area the composed image is centered in.
124    fn image_area(&self) -> Rect {
125        let inner = self.inner();
126        let top = inner.y + META_H + PAD;
127        let bottom = inner.bottom() - self.controls_h() - PAD;
128        Rect::new(inner.x, top, inner.w, (bottom - top).max(0))
129    }
130
131    /// The slider trough rect (valid only in slider modes).
132    fn slider_track(&self) -> Rect {
133        let inner = self.inner();
134        let y = inner.bottom() - BTN_H - PAD - SLIDER_H;
135        // Leave room for a trailing percentage readout.
136        let pct_w = 40;
137        Rect::new(
138            inner.x,
139            y + (SLIDER_H - 6) / 2,
140            (inner.w - pct_w).max(10),
141            6,
142        )
143    }
144
145    /// A generous hit band around the slider trough (covering the thumb, which
146    /// overhangs it), so the slider is easy to grab. Shares the trough's `x` and
147    /// width, so mapping an x coordinate over it matches the drawn thumb.
148    fn slider_hit(&self) -> Rect {
149        let t = self.slider_track();
150        Rect::new(t.x, t.y - 6, t.w, t.h + 12)
151    }
152
153    /// The mode-button row along the bottom.
154    fn button_row(&self) -> Rect {
155        let inner = self.inner();
156        Rect::new(inner.x, inner.bottom() - BTN_H, inner.w, BTN_H)
157    }
158
159    /// Map an x coordinate within `track` to a 0..1 slider value.
160    fn value_at(track: Rect, x: i32) -> f32 {
161        if track.w <= 1 {
162            return 0.0;
163        }
164        ((x - track.x) as f32 / track.w as f32).clamp(0.0, 1.0)
165    }
166
167    /// Switch to a comparison mode, remembering it as the one to restore from a
168    /// single-image view.
169    fn select_mode(&mut self, mode: CompareMode) {
170        self.mode = mode;
171        if !mode.is_single() {
172            self.last_compare_mode = mode;
173        }
174    }
175
176    /// Cycle to the next comparison mode — the View ▸ Switch Mode action
177    /// (Ctrl+M). The single-image views cycle back to 2-Up.
178    pub fn cycle_mode(&mut self) {
179        self.select_mode(self.mode.next());
180    }
181
182    /// Show just the "before" (old) or "after" (new) image at full size — the
183    /// View ▸ Before / After Image actions (Ctrl+Left / Ctrl+Right).
184    pub fn show_side(&mut self, before: bool) {
185        self.select_mode(if before {
186            CompareMode::Left
187        } else {
188            CompareMode::Right
189        });
190    }
191
192    /// Handle a press at `pos`; returns whether it was consumed.
193    fn press(&mut self, pos: Point) -> bool {
194        if let Some((mode, _)) = self
195            .button_rects
196            .iter()
197            .find(|(_, r)| r.contains(pos))
198            .copied()
199        {
200            self.select_mode(mode);
201            return true;
202        }
203        if self.mode.uses_slider() {
204            let track = self.slider_hit();
205            if track.contains(pos) {
206                self.slider = Self::value_at(track, pos.x);
207                self.dragging_slider = true;
208                return true;
209            }
210            let area = self.image_area();
211            if area.contains(pos) {
212                self.slider = Self::value_at(area, pos.x);
213                self.dragging_image = true;
214                return true;
215            }
216        }
217        false
218    }
219
220    fn paint_meta(&self, painter: &mut Painter) {
221        let Some(cmp) = &self.comparison else {
222            return;
223        };
224        let meta = cmp.meta();
225        if meta.is_empty() {
226            return;
227        }
228        let r = self.meta_rect();
229        let y = r.y + (r.h - self.font_size as i32) / 2 - 1;
230        painter.text(r.x, y, meta, self.font_size, META_FG);
231    }
232
233    /// Blit the composed comparison, centered in the image area.
234    fn paint_image(&mut self, painter: &mut Painter) {
235        let area = self.image_area();
236        painter.fill_rect(area, LETTERBOX);
237        if area.w <= 0 || area.h <= 0 {
238            return;
239        }
240        let Some(cmp) = &mut self.comparison else {
241            return;
242        };
243        let canvas = cmp.render(self.mode, self.slider, area.w as u32, area.h as u32);
244        if canvas.w == 0 || canvas.h == 0 {
245            return;
246        }
247        let ox = area.x + (area.w - canvas.w as i32) / 2;
248        let oy = area.y + (area.h - canvas.h as i32) / 2;
249        // One bulk blit rather than a `pixel()` call per pixel: the composed
250        // canvas is opaque, so it goes straight into the framebuffer with the
251        // logical→physical snap done once per row/column instead of per pixel.
252        let saved = painter.push_clip(area);
253        painter.blit_argb(ox, oy, canvas.w, canvas.h, &canvas.argb);
254        painter.restore_clip(saved);
255    }
256
257    fn paint_slider(&self, painter: &mut Painter, theme: &Theme) {
258        if !self.mode.uses_slider() {
259            return;
260        }
261        let track = self.slider_track();
262        painter.fill_rect(track, Color::WHITE);
263        painter.sunken_bevel(track, theme.highlight, theme.shadow);
264        painter.stroke_rect(track, theme.border);
265        // Filled portion up to the thumb.
266        let fill_w = ((track.w - 2) as f32 * self.slider) as i32;
267        if fill_w > 0 {
268            painter.fill_rect(
269                Rect::new(track.x + 1, track.y + 1, fill_w, track.h - 2),
270                SLIDER_FILL,
271            );
272        }
273        // Thumb.
274        let thumb_w = 8;
275        let tx = track.x + ((track.w - thumb_w) as f32 * self.slider) as i32;
276        let thumb = Rect::new(tx, track.y - 4, thumb_w, track.h + 8);
277        painter.button(thumb, theme, false, false);
278        // Percentage readout.
279        let pct = format!("{:>3}%", (self.slider * 100.0).round() as i32);
280        let pr = Rect::new(track.right() + PAD, track.y - 5, 36, 16);
281        painter.text_centered(pr, &pct, self.font_size - 1.0, META_FG);
282    }
283
284    fn paint_buttons(&mut self, painter: &mut Painter, theme: &Theme) {
285        self.button_rects.clear();
286        let row = self.button_row();
287        let mut x = row.x;
288        for mode in CompareMode::ALL {
289            let label = mode.label();
290            let w = painter.measure_text(label, self.font_size).w + 14;
291            let brect = Rect::new(x, row.y, w, BTN_H);
292            let active = mode == self.mode;
293            painter.button(brect, theme, active, false);
294            let label_rect = if active {
295                Rect::new(brect.x + 1, brect.y + 1, brect.w, brect.h)
296            } else {
297                brect
298            };
299            painter.text_centered(label_rect, label, self.font_size, theme.text);
300            self.button_rects.push((mode, brect));
301            x += w + BTN_GAP;
302        }
303    }
304}
305
306impl Widget for ImageDiffView {
307    fn bounds(&self) -> Rect {
308        self.rect
309    }
310
311    fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
312        let field = self.field();
313        painter.fill_rect(field, Color::WHITE);
314        painter.sunken_bevel(field, theme.highlight, theme.shadow);
315        painter.stroke_rect(field, theme.border);
316
317        let saved = painter.push_clip(field.inset(1));
318        self.paint_meta(painter);
319        self.paint_image(painter);
320        self.paint_slider(painter, theme);
321        self.paint_buttons(painter, theme);
322        painter.restore_clip(saved);
323    }
324
325    fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
326        match event {
327            Event::PointerDown {
328                pos,
329                button: MouseButton::Left,
330                ..
331            } => {
332                ctx.request_focus();
333                if self.press(*pos) {
334                    ctx.request_paint();
335                }
336            }
337            Event::PointerMove { pos } if self.dragging_slider => {
338                self.slider = Self::value_at(self.slider_hit(), pos.x);
339                ctx.request_paint();
340            }
341            Event::PointerMove { pos } if self.dragging_image => {
342                self.slider = Self::value_at(self.image_area(), pos.x);
343                ctx.request_paint();
344            }
345            Event::PointerUp {
346                button: MouseButton::Left,
347                ..
348            } if self.dragging_slider || self.dragging_image => {
349                self.dragging_slider = false;
350                self.dragging_image = false;
351                ctx.request_paint();
352            }
353            // Keyboard control is driven from the View menu's accelerators
354            // (Ctrl+M / Ctrl+Left / Ctrl+Right) via [`Self::cycle_mode`] /
355            // [`Self::show_side`], handled application-side in [`crate::ui`], not
356            // here — so the bare m/s/arrow keys are intentionally not bound.
357            _ => {}
358        }
359    }
360
361    fn captures_pointer(&self) -> bool {
362        self.dragging_slider || self.dragging_image
363    }
364
365    fn focusable(&self) -> bool {
366        true
367    }
368
369    fn set_focused(&mut self, _focused: bool) {
370        // No focus-dependent state: keyboard control comes from the View menu's
371        // accelerators (see [`crate::ui`]), not from this widget owning focus.
372    }
373
374    fn layout(&mut self, bounds: Rect) {
375        self.rect = bounds;
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::backend::BlobPair;
383    use crate::imagediff::ImageComparison;
384    use saudade::mock::MockBackend;
385    use saudade::{Modifiers, Point};
386
387    const W: i32 = 480;
388    const H: i32 = 320;
389
390    fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
391        let img = image::RgbaImage::from_pixel(w, h, image::Rgba(color));
392        let mut bytes = Vec::new();
393        image::DynamicImage::ImageRgba8(img)
394            .write_to(
395                &mut std::io::Cursor::new(&mut bytes),
396                image::ImageFormat::Png,
397            )
398            .unwrap();
399        bytes
400    }
401
402    fn view() -> (MockBackend, ImageDiffView) {
403        let be = MockBackend::new(W, H).with_scale(1.0);
404        let mut v = ImageDiffView::new(Rect::new(0, 0, W, H));
405        v.set_focused(true);
406        let cmp = ImageComparison::from_blobs(&BlobPair {
407            old: Some(png(16, 16, [255, 0, 0, 255])),
408            new: Some(png(16, 16, [0, 0, 255, 255])),
409        });
410        v.set_comparison(cmp);
411        v.layout(Rect::new(0, 0, W, H));
412        let _ = be.render(&mut v);
413        (be, v)
414    }
415
416    #[test]
417    fn clicking_a_mode_button_selects_it() {
418        let (be, mut v) = view();
419        // Find the "Diff" (difference) button rect placed at paint.
420        let (_, rect) = v
421            .button_rects
422            .iter()
423            .find(|(m, _)| *m == CompareMode::Difference)
424            .copied()
425            .expect("difference button laid out");
426        let center = Point::new(rect.x + rect.w / 2, rect.y + rect.h / 2);
427        be.dispatch(
428            &mut v,
429            &Event::PointerDown {
430                pos: center,
431                button: MouseButton::Left,
432                modifiers: Modifiers::default(),
433            },
434        );
435        assert_eq!(v.mode(), CompareMode::Difference);
436    }
437
438    #[test]
439    fn cycle_mode_and_show_side() {
440        let (_be, mut v) = view();
441        assert_eq!(v.mode(), CompareMode::TwoUp);
442        v.cycle_mode();
443        assert_eq!(v.mode(), CompareMode::Swipe);
444        v.cycle_mode();
445        assert_eq!(v.mode(), CompareMode::Onion);
446        // Before / after jump straight to the single-image views…
447        v.show_side(true);
448        assert_eq!(v.mode(), CompareMode::Left);
449        v.show_side(false);
450        assert_eq!(v.mode(), CompareMode::Right);
451        // …and cycling out of a single view returns to the comparison set.
452        v.cycle_mode();
453        assert_eq!(v.mode(), CompareMode::TwoUp);
454    }
455
456    #[test]
457    fn dragging_the_slider_moves_it() {
458        let (be, mut v) = view();
459        v.cycle_mode(); // -> Swipe, which uses the slider
460        assert_eq!(v.mode(), CompareMode::Swipe);
461
462        let hit = v.slider_hit();
463        let cy = hit.y + hit.h / 2;
464        let press = |x: i32| Event::PointerDown {
465            pos: Point::new(x, cy),
466            button: MouseButton::Left,
467            modifiers: Modifiers::default(),
468        };
469
470        // Press near the right end: the value jumps high and the drag captures.
471        be.dispatch(&mut v, &press(hit.x + hit.w - 1));
472        assert!(v.slider() > 0.9, "press near the right sets a high value");
473        assert!(v.captures_pointer(), "the slider drag captures the pointer");
474
475        // Drag back to the left: the value follows the pointer down.
476        be.dispatch(
477            &mut v,
478            &Event::PointerMove {
479                pos: Point::new(hit.x + 1, cy),
480            },
481        );
482        assert!(v.slider() < 0.1, "dragging left lowers the value");
483
484        // Releasing ends the drag.
485        be.dispatch(
486            &mut v,
487            &Event::PointerUp {
488                pos: Point::new(hit.x + 1, cy),
489                button: MouseButton::Left,
490                modifiers: Modifiers::default(),
491            },
492        );
493        assert!(!v.captures_pointer());
494    }
495}