use adw::prelude::*; use gtk::glib; use std::cell::RefCell; use std::rc::Rc; use crate::app::AppState; use pixstrip_core::types::QualityPreset; pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { let scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .build(); let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(12) .margin_top(12) .margin_bottom(12) .margin_start(24) .margin_end(24) .build(); let cfg = state.job_config.borrow(); // Enable toggle let enable_row = adw::SwitchRow::builder() .title("Enable Compression") .subtitle("Reduce file size with quality control") .active(cfg.compress_enabled) .build(); let enable_group = adw::PreferencesGroup::new(); enable_group.add(&enable_row); content.append(&enable_group); // Quality slider let quality_group = adw::PreferencesGroup::builder() .title("Quality Level") .description("Higher quality means larger files. This sets the overall quality target.") .build(); let initial_val = match cfg.quality_preset { QualityPreset::WebOptimized => 1.0, QualityPreset::Low => 2.0, QualityPreset::Medium => 3.0, QualityPreset::High => 4.0, QualityPreset::Maximum => 5.0, }; let quality_scale = gtk::Scale::builder() .orientation(gtk::Orientation::Horizontal) .adjustment(>k::Adjustment::new(initial_val, 1.0, 5.0, 1.0, 1.0, 0.0)) .draw_value(false) .hexpand(true) .build(); quality_scale.set_round_digits(0); quality_scale.add_mark(1.0, gtk::PositionType::Bottom, Some("Web")); quality_scale.add_mark(2.0, gtk::PositionType::Bottom, Some("Low")); quality_scale.add_mark(3.0, gtk::PositionType::Bottom, Some("Medium")); quality_scale.add_mark(4.0, gtk::PositionType::Bottom, Some("High")); quality_scale.add_mark(5.0, gtk::PositionType::Bottom, Some("Maximum")); quality_scale.update_property(&[ gtk::accessible::Property::Label("Compression quality, from Web Optimized to Maximum"), ]); let quality_label = gtk::Label::builder() .label(quality_description(initial_val as u32)) .css_classes(["dim-label"]) .margin_top(4) .build(); let quality_box = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(4) .margin_top(8) .margin_bottom(8) .margin_start(12) .margin_end(12) .build(); quality_box.append(&quality_scale); quality_box.append(&quality_label); 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); // Thumbnail strip for selecting preview image let thumb_scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Automatic) .vscrollbar_policy(gtk::PolicyType::Never) .max_content_height(60) .build(); let thumb_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(4) .margin_top(4) .margin_bottom(4) .halign(gtk::Align::Center) .build(); thumb_scrolled.set_child(Some(&thumb_box)); let preview_index: Rc> = Rc::new(RefCell::new(0)); // Populate thumbnails from loaded files { let files = state.loaded_files.borrow(); let max_thumbs = files.len().min(10); // Show at most 10 thumbnails for i in 0..max_thumbs { let pic = gtk::Picture::builder() .content_fit(gtk::ContentFit::Cover) .width_request(50) .height_request(50) .build(); pic.set_filename(Some(&files[i])); let frame = gtk::Frame::builder() .child(&pic) .build(); if i == 0 { frame.add_css_class("accent"); } let btn = gtk::Button::builder() .child(&frame) .has_frame(false) .tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image")) .build(); thumb_box.append(&btn); } thumb_scrolled.set_visible(max_thumbs > 1); } // "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); preview_group.add(&thumb_scrolled); content.append(&preview_group); // Advanced options in expander let advanced_group = adw::PreferencesGroup::builder() .title("Advanced Options") .build(); let advanced_expander = adw::ExpanderRow::builder() .title("Per-Format Quality") .subtitle("Fine-tune quality for each format individually") .expanded(state.detailed_mode) .build(); let jpeg_row = adw::SpinRow::builder() .title("JPEG Quality") .subtitle("1-100, higher is better quality") .adjustment(>k::Adjustment::new(cfg.jpeg_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) .build(); let png_row = adw::SpinRow::builder() .title("PNG Compression Level") .subtitle("1-6, higher is slower but smaller") .adjustment(>k::Adjustment::new(cfg.png_level as f64, 1.0, 6.0, 1.0, 1.0, 0.0)) .build(); let webp_row = adw::SpinRow::builder() .title("WebP Quality") .subtitle("1-100, higher is better quality") .adjustment(>k::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) .build(); let avif_row = adw::SpinRow::builder() .title("AVIF Quality") .subtitle("1-100, higher is better quality") .adjustment(>k::Adjustment::new(cfg.avif_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) .build(); let progressive_row = adw::SwitchRow::builder() .title("Progressive JPEG") .subtitle("Loads gradually, slightly larger files") .active(cfg.progressive_jpeg) .build(); let webp_effort_row = adw::SpinRow::builder() .title("WebP Encoding Effort") .subtitle("0-6, higher is slower but smaller files") .adjustment(>k::Adjustment::new(cfg.webp_effort as f64, 0.0, 6.0, 1.0, 1.0, 0.0)) .build(); let avif_speed_row = adw::SpinRow::builder() .title("AVIF Encoding Speed") .subtitle("1-10, lower is slower but better compression") .adjustment(>k::Adjustment::new(cfg.avif_speed as f64, 1.0, 10.0, 1.0, 1.0, 0.0)) .build(); advanced_expander.add_row(&jpeg_row); advanced_expander.add_row(&progressive_row); advanced_expander.add_row(&png_row); advanced_expander.add_row(&webp_row); advanced_expander.add_row(&webp_effort_row); advanced_expander.add_row(&avif_row); advanced_expander.add_row(&avif_speed_row); advanced_group.add(&advanced_expander); content.append(&advanced_group); drop(cfg); // Wire signals { let jc = state.job_config.clone(); enable_row.connect_active_notify(move |row| { 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(); let pidx = preview_index.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 selected preview image let idx = *pidx.borrow(); let sample_path = loaded.get(idx).cloned().unwrap_or_else(|| 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, } }); }) }; // Wire thumbnail buttons to switch preview image { let mut child = thumb_box.first_child(); let mut idx = 0usize; while let Some(widget) = child { if let Some(btn) = widget.downcast_ref::() { let pidx = preview_index.clone(); let up = update_preview.clone(); let tb = thumb_box.clone(); let current_idx = idx; btn.connect_clicked(move |_| { *pidx.borrow_mut() = current_idx; up(); // Update highlight on thumbnails let mut c = tb.first_child(); let mut j = 0usize; while let Some(w) = c { if let Some(b) = w.downcast_ref::() { if let Some(f) = b.child().and_then(|c| c.downcast::().ok()) { if j == current_idx { f.add_css_class("accent"); } else { f.remove_css_class("accent"); } } } c = w.next_sibling(); j += 1; } }); idx += 1; } child = widget.next_sibling(); } } // 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(); c.quality_preset = match val { 1 => QualityPreset::WebOptimized, 2 => QualityPreset::Low, 3 => QualityPreset::Medium, 4 => QualityPreset::High, _ => QualityPreset::Maximum, }; label.set_label(&quality_description(val)); drop(c); up(); }); } { let jc = state.job_config.clone(); jpeg_row.connect_value_notify(move |row| { jc.borrow_mut().jpeg_quality = row.value() as u8; }); } { let jc = state.job_config.clone(); png_row.connect_value_notify(move |row| { jc.borrow_mut().png_level = row.value() as u8; }); } { let jc = state.job_config.clone(); webp_row.connect_value_notify(move |row| { jc.borrow_mut().webp_quality = row.value() as u8; }); } { let jc = state.job_config.clone(); avif_row.connect_value_notify(move |row| { jc.borrow_mut().avif_quality = row.value() as u8; }); } { let jc = state.job_config.clone(); progressive_row.connect_active_notify(move |row| { jc.borrow_mut().progressive_jpeg = row.is_active(); }); } { let jc = state.job_config.clone(); webp_effort_row.connect_value_notify(move |row| { jc.borrow_mut().webp_effort = row.value() as u8; }); } { let jc = state.job_config.clone(); avif_speed_row.connect_value_notify(move |row| { jc.borrow_mut().avif_speed = row.value() as u8; }); } scrolled.set_child(Some(&content)); let clamp = adw::Clamp::builder() .maximum_size(600) .child(&scrolled) .build(); adw::NavigationPage::builder() .title("Compress") .tag("step-compress") .child(&clamp) .build() } fn quality_description(val: u32) -> String { match val { 1 => "Web Optimized - ~70-80% smaller files. Noticeable quality loss. Best for thumbnails and web previews.".into(), 2 => "Low - ~50-60% smaller files. Some visible quality loss. Good for email attachments and quick sharing.".into(), 3 => "Medium - ~30-40% smaller files. Good balance of quality and size. Recommended for most uses.".into(), 4 => "High - ~15-25% smaller files. Minimal quality loss. Good for printing and high-quality output.".into(), _ => "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)) } }