diff --git a/pixstrip-gtk/src/steps/step_compress.rs b/pixstrip-gtk/src/steps/step_compress.rs index e8f666c..c97fbec 100644 --- a/pixstrip-gtk/src/steps/step_compress.rs +++ b/pixstrip-gtk/src/steps/step_compress.rs @@ -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>> = Rc::new(RefCell::new(None)); + let compressed_pixbuf: Rc>> = 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::(); + + 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, + compressed_bytes: Vec, + 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 { + 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)) + } +}