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:
@@ -1,4 +1,8 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
use pixstrip_core::types::QualityPreset;
|
||||
|
||||
@@ -81,6 +85,208 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
quality_group.add(&quality_box);
|
||||
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: >k::cairo::Context,
|
||||
pixbuf: >k::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
|
||||
let advanced_group = adw::PreferencesGroup::builder()
|
||||
.title("Advanced Options")
|
||||
@@ -153,9 +359,107 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
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 label = quality_label;
|
||||
let up = update_preview;
|
||||
quality_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as u32;
|
||||
let mut c = jc.borrow_mut();
|
||||
@@ -167,6 +471,8 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
_ => QualityPreset::Maximum,
|
||||
};
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user