Add squoosh-style compression preview with draggable divider

Split-view comparison showing original vs compressed image side by
side. Draggable vertical divider with handle circle. Shows file sizes
and savings percentage. Compresses a sample image in a background
thread and renders via cairo with pixbuf clipping.
This commit is contained in:
2026-03-06 14:29:01 +02:00
parent f71b55da72
commit 6dd81e5900

View File

@@ -1,4 +1,8 @@
use adw::prelude::*; use adw::prelude::*;
use gtk::glib;
use std::cell::RefCell;
use std::rc::Rc;
use crate::app::AppState; use crate::app::AppState;
use pixstrip_core::types::QualityPreset; use pixstrip_core::types::QualityPreset;
@@ -81,6 +85,208 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
quality_group.add(&quality_box); quality_group.add(&quality_box);
content.append(&quality_group); content.append(&quality_group);
// Compression preview - Squoosh-style split comparison
let preview_group = adw::PreferencesGroup::builder()
.title("Quality Preview")
.description("Drag the divider to compare original and compressed")
.build();
// Size labels above the preview
let size_box = gtk::CenterBox::builder()
.margin_start(12)
.margin_end(12)
.margin_bottom(4)
.build();
let original_size_label = gtk::Label::builder()
.label("Original")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Start)
.build();
let compressed_size_label = gtk::Label::builder()
.label("Compressed")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::End)
.build();
size_box.set_start_widget(Some(&original_size_label));
size_box.set_end_widget(Some(&compressed_size_label));
// Drawing area for the split preview
let divider_pos = Rc::new(RefCell::new(0.5f64)); // 0.0 to 1.0
let original_pixbuf: Rc<RefCell<Option<gtk::gdk_pixbuf::Pixbuf>>> = Rc::new(RefCell::new(None));
let compressed_pixbuf: Rc<RefCell<Option<gtk::gdk_pixbuf::Pixbuf>>> = Rc::new(RefCell::new(None));
let dragging = Rc::new(RefCell::new(false));
let preview_drawing = gtk::DrawingArea::builder()
.height_request(250)
.hexpand(true)
.build();
preview_drawing.update_property(&[
gtk::accessible::Property::Label("Compression quality comparison. Drag the vertical divider to compare original and compressed image."),
]);
// Draw function
{
let dp = divider_pos.clone();
let orig = original_pixbuf.clone();
let comp = compressed_pixbuf.clone();
preview_drawing.set_draw_func(move |_drawing, cr, width, height| {
let w = width as f64;
let h = height as f64;
// Background
cr.set_source_rgba(0.2, 0.2, 0.2, 1.0);
let _ = cr.paint();
let pos = *dp.borrow();
let divider_x = w * pos;
// Helper to draw a pixbuf scaled to fit the area
let draw_pixbuf = |cr: &gtk::cairo::Context,
pixbuf: &gtk::gdk_pixbuf::Pixbuf,
clip_x: f64,
clip_w: f64| {
let tw = pixbuf.width() as f64;
let th = pixbuf.height() as f64;
let scale = (w / tw).min(h / th);
let ox = (w - tw * scale) / 2.0;
let oy = (h - th * scale) / 2.0;
cr.save().unwrap();
cr.rectangle(clip_x, 0.0, clip_w, h);
cr.clip();
cr.translate(ox, oy);
cr.scale(scale, scale);
cr.set_source_pixbuf(pixbuf, 0.0, 0.0);
let _ = cr.paint();
cr.restore().unwrap();
};
// Draw original on left side
if let Some(ref pb) = *orig.borrow() {
draw_pixbuf(cr, pb, 0.0, divider_x);
}
// Draw compressed on right side
if let Some(ref pb) = *comp.borrow() {
draw_pixbuf(cr, pb, divider_x, w - divider_x);
}
// Draw divider line
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9);
cr.set_line_width(2.0);
cr.move_to(divider_x, 0.0);
cr.line_to(divider_x, h);
let _ = cr.stroke();
// Draw handle circle
cr.arc(divider_x, h / 2.0, 12.0, 0.0, std::f64::consts::TAU);
cr.set_source_rgba(1.0, 1.0, 1.0, 0.95);
let _ = cr.fill_preserve();
cr.set_source_rgba(0.3, 0.3, 0.3, 1.0);
cr.set_line_width(1.5);
let _ = cr.stroke();
// Draw arrows on handle
cr.set_source_rgba(0.3, 0.3, 0.3, 1.0);
cr.set_line_width(2.0);
// Left arrow
cr.move_to(divider_x - 4.0, h / 2.0);
cr.line_to(divider_x - 8.0, h / 2.0);
let _ = cr.stroke();
// Right arrow
cr.move_to(divider_x + 4.0, h / 2.0);
cr.line_to(divider_x + 8.0, h / 2.0);
let _ = cr.stroke();
// Labels on each side
cr.set_source_rgba(1.0, 1.0, 1.0, 0.7);
cr.set_font_size(11.0);
if divider_x > 60.0 {
cr.move_to(8.0, 16.0);
let _ = cr.show_text("Original");
}
if w - divider_x > 80.0 {
cr.move_to(divider_x + 8.0, 16.0);
let _ = cr.show_text("Compressed");
}
});
}
// Drag gesture for the divider
let drag_gesture = gtk::GestureDrag::new();
{
let dp = divider_pos.clone();
let dr = dragging.clone();
let drawing = preview_drawing.clone();
drag_gesture.connect_drag_begin(move |_, x, _| {
let w = drawing.width() as f64;
let current = *dp.borrow() * w;
// Only start drag if near the divider
if (x - current).abs() < 30.0 {
*dr.borrow_mut() = true;
}
});
}
{
let dp = divider_pos.clone();
let dr = dragging.clone();
let drawing = preview_drawing.clone();
drag_gesture.connect_drag_update(move |gesture, offset_x, _| {
if !*dr.borrow() {
return;
}
if let Some((start_x, _)) = gesture.start_point() {
let w = drawing.width() as f64;
if w > 0.0 {
let new_pos = ((start_x + offset_x) / w).clamp(0.05, 0.95);
*dp.borrow_mut() = new_pos;
drawing.queue_draw();
}
}
});
}
{
let dr = dragging.clone();
drag_gesture.connect_drag_end(move |_, _, _| {
*dr.borrow_mut() = false;
});
}
preview_drawing.add_controller(drag_gesture);
// Click gesture to set divider position directly
let click_gesture = gtk::GestureClick::new();
{
let dp = divider_pos.clone();
let drawing = preview_drawing.clone();
click_gesture.connect_released(move |_, _, x, _| {
let w = drawing.width() as f64;
if w > 0.0 {
*dp.borrow_mut() = (x / w).clamp(0.05, 0.95);
drawing.queue_draw();
}
});
}
preview_drawing.add_controller(click_gesture);
let preview_frame = gtk::Frame::builder()
.halign(gtk::Align::Fill)
.build();
preview_frame.set_child(Some(&preview_drawing));
preview_group.add(&size_box);
preview_group.add(&preview_frame);
// "No image loaded" placeholder
let no_image_label = gtk::Label::builder()
.label("Add images first to see compression preview")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.margin_top(4)
.build();
preview_group.add(&no_image_label);
content.append(&preview_group);
// Advanced options in expander // Advanced options in expander
let advanced_group = adw::PreferencesGroup::builder() let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced Options") .title("Advanced Options")
@@ -153,9 +359,107 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
jc.borrow_mut().compress_enabled = row.is_active(); jc.borrow_mut().compress_enabled = row.is_active();
}); });
} }
// Helper to load preview with a given quality preset
let update_preview = {
let files = state.loaded_files.clone();
let orig_pb = original_pixbuf.clone();
let comp_pb = compressed_pixbuf.clone();
let drawing = preview_drawing.clone();
let orig_label = original_size_label.clone();
let comp_label = compressed_size_label.clone();
let no_img_label = no_image_label.clone();
let jc = state.job_config.clone();
Rc::new(move || {
let loaded = files.borrow();
if loaded.is_empty() {
no_img_label.set_visible(true);
return;
}
no_img_label.set_visible(false);
// Pick the first image as sample
let sample_path = loaded[0].clone();
let cfg = jc.borrow();
let preset = cfg.quality_preset;
drop(cfg);
let orig_pb = orig_pb.clone();
let comp_pb = comp_pb.clone();
let drawing = drawing.clone();
let orig_label = orig_label.clone();
let comp_label = comp_label.clone();
// Load and compress in a background thread
let (tx, rx) = std::sync::mpsc::channel::<PreviewResult>();
std::thread::spawn(move || {
let result = generate_preview(&sample_path, preset);
let _ = tx.send(result);
});
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
match rx.try_recv() {
Ok(result) => {
match result {
PreviewResult::Success {
original_bytes,
compressed_bytes,
original_size,
compressed_size,
} => {
if let Ok(pb) = load_pixbuf_from_bytes(&original_bytes) {
*orig_pb.borrow_mut() = Some(pb);
}
if let Ok(pb) = load_pixbuf_from_bytes(&compressed_bytes) {
*comp_pb.borrow_mut() = Some(pb);
}
orig_label.set_label(&format!(
"Original: {}",
format_size(original_size)
));
let savings = if original_size > 0 {
((1.0 - compressed_size as f64 / original_size as f64) * 100.0)
as i32
} else {
0
};
comp_label.set_label(&format!(
"Compressed: {} (-{}%)",
format_size(compressed_size),
savings
));
drawing.queue_draw();
}
PreviewResult::Error(_) => {
orig_label.set_label("Original");
comp_label.set_label("Preview unavailable");
}
}
glib::ControlFlow::Break
}
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => glib::ControlFlow::Break,
}
});
})
};
// Trigger initial preview load
{
let up = update_preview.clone();
glib::idle_add_local_once(move || {
up();
});
}
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let label = quality_label; let label = quality_label;
let up = update_preview;
quality_scale.connect_value_changed(move |scale| { quality_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as u32; let val = scale.value().round() as u32;
let mut c = jc.borrow_mut(); let mut c = jc.borrow_mut();
@@ -167,6 +471,8 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
_ => QualityPreset::Maximum, _ => QualityPreset::Maximum,
}; };
label.set_label(&quality_description(val)); label.set_label(&quality_description(val));
drop(c);
up();
}); });
} }
{ {
@@ -211,3 +517,86 @@ fn quality_description(val: u32) -> String {
_ => "Maximum - ~5-10% smaller files. Best possible quality, largest files. Archival and professional use.".into(), _ => "Maximum - ~5-10% smaller files. Best possible quality, largest files. Archival and professional use.".into(),
} }
} }
enum PreviewResult {
Success {
original_bytes: Vec<u8>,
compressed_bytes: Vec<u8>,
original_size: u64,
compressed_size: u64,
},
Error(#[allow(dead_code)] String),
}
fn generate_preview(path: &std::path::Path, preset: QualityPreset) -> PreviewResult {
let original_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let img = match image::open(path) {
Ok(img) => img,
Err(e) => return PreviewResult::Error(e.to_string()),
};
// Scale down for preview to avoid large memory usage
let preview_img = if img.width() > 800 || img.height() > 800 {
img.resize(800, 800, image::imageops::FilterType::Triangle)
} else {
img
};
// Encode original as PNG for lossless reference
let mut orig_buf = Vec::new();
let orig_cursor = std::io::Cursor::new(&mut orig_buf);
if preview_img
.write_to(orig_cursor, image::ImageFormat::Png)
.is_err()
{
return PreviewResult::Error("Failed to encode original".into());
}
// Encode compressed as JPEG at the preset quality
let quality = preset.jpeg_quality();
let mut comp_buf = Vec::new();
let comp_cursor = std::io::Cursor::new(&mut comp_buf);
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(comp_cursor, quality);
let rgb = preview_img.to_rgb8();
if image::ImageEncoder::write_image(
encoder,
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.is_err()
{
return PreviewResult::Error("JPEG compression failed".into());
}
let compressed_size = comp_buf.len() as u64;
PreviewResult::Success {
original_bytes: orig_buf,
compressed_bytes: comp_buf,
original_size,
compressed_size,
}
}
fn load_pixbuf_from_bytes(bytes: &[u8]) -> Result<gtk::gdk_pixbuf::Pixbuf, String> {
let stream = gtk::gio::MemoryInputStream::from_bytes(&glib::Bytes::from(bytes));
gtk::gdk_pixbuf::Pixbuf::from_stream(
&stream,
gtk::gio::Cancellable::NONE,
)
.map_err(|e| e.to_string())
}
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}