1use saudade::{Color, Event, EventCtx, MouseButton, Painter, Point, Rect, Theme, Widget};
16
17use crate::imagediff::{CompareMode, ImageComparison};
18
19const PAD: i32 = 4;
20const META_H: i32 = 18;
22const BTN_H: i32 = 22;
24const SLIDER_H: i32 = 16;
26const BTN_GAP: i32 = 3;
28
29const LETTERBOX: Color = Color::WHITE;
32const META_FG: Color = Color::rgb(0x40, 0x40, 0x40);
33const SLIDER_FILL: Color = Color::rgb(0x00, 0x00, 0x80);
35
36pub struct ImageDiffView {
38 rect: Rect,
39 comparison: Option<ImageComparison>,
40 mode: CompareMode,
41 last_compare_mode: CompareMode,
43 slider: f32,
45 font_size: f32,
46 button_rects: Vec<(CompareMode, Rect)>,
48 dragging_slider: bool,
49 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 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 #[cfg(test)]
88 pub fn mode(&self) -> CompareMode {
89 self.mode
90 }
91
92 #[cfg(test)]
94 pub fn slider(&self) -> f32 {
95 self.slider
96 }
97
98 fn field(&self) -> Rect {
100 self.rect
101 }
102
103 fn inner(&self) -> Rect {
104 self.field().inset(PAD)
105 }
106
107 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 fn meta_rect(&self) -> Rect {
119 let inner = self.inner();
120 Rect::new(inner.x, inner.y, inner.w, META_H)
121 }
122
123 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 fn slider_track(&self) -> Rect {
133 let inner = self.inner();
134 let y = inner.bottom() - BTN_H - PAD - SLIDER_H;
135 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 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 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 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 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 pub fn cycle_mode(&mut self) {
179 self.select_mode(self.mode.next());
180 }
181
182 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 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 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 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 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 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 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 _ => {}
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 }
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 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 v.show_side(true);
448 assert_eq!(v.mode(), CompareMode::Left);
449 v.show_side(false);
450 assert_eq!(v.mode(), CompareMode::Right);
451 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(); 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 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 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 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}