use adw::prelude::*; use gtk::glib; use std::cell::{Cell, RefCell}; use std::rc::Rc; use crate::app::AppState; use crate::utils::format_size; use pixstrip_core::types::{ImageFormat, QualityPreset}; /// Which format and quality to use for the compressed side of the preview. #[derive(Clone, Copy)] enum PreviewCompression { Jpeg(u8), Png(u8), WebP(u8), Avif(u8), } 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 (1-8 range: Low to Maximum) --- 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::Low | QualityPreset::WebOptimized => 1.0, QualityPreset::Medium => 3.0, QualityPreset::High => 5.0, QualityPreset::Maximum => 8.0, }; let quality_scale = gtk::Scale::builder() .orientation(gtk::Orientation::Horizontal) .adjustment(>k::Adjustment::new(initial_val, 1.0, 8.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("Low")); quality_scale.add_mark(2.0, gtk::PositionType::Bottom, None); quality_scale.add_mark(3.0, gtk::PositionType::Bottom, Some("Medium")); quality_scale.add_mark(4.0, gtk::PositionType::Bottom, None); quality_scale.add_mark(5.0, gtk::PositionType::Bottom, Some("High")); quality_scale.add_mark(6.0, gtk::PositionType::Bottom, None); quality_scale.add_mark(7.0, gtk::PositionType::Bottom, None); quality_scale.add_mark(8.0, gtk::PositionType::Bottom, Some("Max")); quality_scale.update_property(&[ gtk::accessible::Property::Label("Compression quality, from Low 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); // --- Per-format quality (collapsible advanced section) --- let performat_group = adw::PreferencesGroup::builder() .title("Per-Format Quality") .build(); let performat_expander = adw::ExpanderRow::builder() .title("Per-Format Quality") .subtitle("Fine-tune quality for each output format individually") .show_enable_switch(false) .expanded(state.is_section_expanded("compress-performat")) .build(); { let st = state.clone(); performat_expander.connect_expanded_notify(move |row| { st.set_section_expanded("compress-performat", row.is_expanded()); }); } // All per-format scales: no marks, uniform fixed width, value shown in subtitle let setup_scale = |scale: >k::Scale| { scale.set_draw_value(false); scale.set_hexpand(false); scale.set_valign(gtk::Align::Center); scale.set_width_request(200); }; // JPEG quality (1-100) let jpeg_row = adw::ActionRow::builder() .title("JPEG Quality") .subtitle(&format!("{}", cfg.jpeg_quality)) .build(); let jpeg_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0); jpeg_scale.set_value(cfg.jpeg_quality as f64); setup_scale(&jpeg_scale); jpeg_scale.update_property(&[gtk::accessible::Property::Label("JPEG quality, 1 to 100")]); jpeg_row.add_suffix(&jpeg_scale); // PNG compression level (1-6) let png_row = adw::ActionRow::builder() .title("PNG Compression") .subtitle(&format!("{}", cfg.png_level)) .build(); let png_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 6.0, 1.0); png_scale.set_value(cfg.png_level as f64); png_scale.set_round_digits(0); setup_scale(&png_scale); png_scale.update_property(&[gtk::accessible::Property::Label("PNG compression level, 1 to 6")]); png_row.add_suffix(&png_scale); // WebP quality (1-100) let webp_row = adw::ActionRow::builder() .title("WebP Quality") .subtitle(&format!("{}", cfg.webp_quality)) .build(); let webp_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0); webp_scale.set_value(cfg.webp_quality as f64); setup_scale(&webp_scale); webp_scale.update_property(&[gtk::accessible::Property::Label("WebP quality, 1 to 100")]); webp_row.add_suffix(&webp_scale); // WebP encoding effort (0-6) let webp_effort_row = adw::ActionRow::builder() .title("WebP Encoding Effort") .subtitle(&format!("{}", cfg.webp_effort)) .build(); let webp_effort_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 6.0, 1.0); webp_effort_scale.set_value(cfg.webp_effort as f64); webp_effort_scale.set_round_digits(0); setup_scale(&webp_effort_scale); webp_effort_scale.update_property(&[gtk::accessible::Property::Label("WebP encoding effort, 0 to 6")]); webp_effort_row.add_suffix(&webp_effort_scale); // AVIF quality (1-100) let avif_row = adw::ActionRow::builder() .title("AVIF Quality") .subtitle(&format!("{}", cfg.avif_quality)) .build(); let avif_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0); avif_scale.set_value(cfg.avif_quality as f64); setup_scale(&avif_scale); avif_scale.update_property(&[gtk::accessible::Property::Label("AVIF quality, 1 to 100")]); avif_row.add_suffix(&avif_scale); // AVIF encoding speed (1-10) let avif_speed_row = adw::ActionRow::builder() .title("AVIF Encoding Speed") .subtitle(&format!("{}", cfg.avif_speed)) .build(); let avif_speed_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 10.0, 1.0); avif_speed_scale.set_value(cfg.avif_speed as f64); avif_speed_scale.set_round_digits(0); setup_scale(&avif_speed_scale); avif_speed_scale.update_property(&[gtk::accessible::Property::Label("AVIF encoding speed, 1 to 10")]); avif_speed_row.add_suffix(&avif_speed_scale); performat_expander.add_row(&jpeg_row); performat_expander.add_row(&png_row); performat_expander.add_row(&webp_row); performat_expander.add_row(&webp_effort_row); performat_expander.add_row(&avif_row); performat_expander.add_row(&avif_speed_row); performat_group.add(&performat_expander); content.append(&performat_group); // --- Compression preview - Squoosh-style split comparison --- let preview_group = adw::PreferencesGroup::builder() .title("Quality Preview") .description("Drag the divider to compare. Drag the image to pan.") .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)); // State for the split preview let divider_pos = Rc::new(RefCell::new(0.5f64)); let original_pixbuf: Rc>> = Rc::new(RefCell::new(None)); let compressed_pixbuf: Rc>> = Rc::new(RefCell::new(None)); let divider_dragging = Rc::new(Cell::new(false)); let image_dragging = Rc::new(Cell::new(false)); // Pan state for cover-fill preview let pan_x: Rc> = Rc::new(Cell::new(0.0)); let pan_y: Rc> = Rc::new(Cell::new(0.0)); let drag_start_pan_x: Rc> = Rc::new(Cell::new(0.0)); let drag_start_pan_y: Rc> = Rc::new(Cell::new(0.0)); let img_dims: Rc> = Rc::new(Cell::new((0.0, 0.0))); // Current preview compression mode let preview_comp: Rc> = Rc::new(Cell::new( PreviewCompression::Jpeg(cfg.jpeg_quality), )); let preview_drawing = gtk::DrawingArea::builder() .height_request(400) .hexpand(true) .vexpand(true) .build(); preview_drawing.update_property(&[ gtk::accessible::Property::Label("Compression quality comparison. Drag the vertical divider to compare original and compressed image. Drag elsewhere to pan."), ]); // Draw function - cover fill with pan support { let dp = divider_pos.clone(); let orig = original_pixbuf.clone(); let comp = compressed_pixbuf.clone(); let px = pan_x.clone(); let py = pan_y.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 with cover fill and pan offset 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).max(h / th); let max_pan_x = ((tw * scale - w) / 2.0).max(0.0); let max_pan_y = ((th * scale - h) / 2.0).max(0.0); let clamped_px = px.get().clamp(-max_pan_x, max_pan_x); let clamped_py = py.get().clamp(-max_pan_y, max_pan_y); let ox = (w - tw * scale) / 2.0 + clamped_px; let oy = (h - th * scale) / 2.0 + clamped_py; let _ = cr.save(); 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(); let _ = cr.restore(); }; if let Some(ref pb) = *orig.borrow() { draw_pixbuf(cr, pb, 0.0, divider_x); } if let Some(ref pb) = *comp.borrow() { draw_pixbuf(cr, pb, divider_x, w - divider_x); } // 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(); // 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(); // Arrows on handle cr.set_source_rgba(0.3, 0.3, 0.3, 1.0); cr.set_line_width(2.0); cr.move_to(divider_x - 4.0, h / 2.0); cr.line_to(divider_x - 8.0, h / 2.0); let _ = cr.stroke(); cr.move_to(divider_x + 4.0, h / 2.0); cr.line_to(divider_x + 8.0, h / 2.0); let _ = cr.stroke(); // Labels with background pills for readability cr.set_font_size(11.0); let draw_label = |cr: >k::cairo::Context, text: &str, x: f64, y: f64| { let Ok(extents) = cr.text_extents(text) else { return }; let pad_x = 6.0; let pad_y = 4.0; let rx = x - pad_x; let ry = y - extents.height() - pad_y; let rw = extents.width() + pad_x * 2.0; let rh = extents.height() + pad_y * 2.0; let radius = 4.0; cr.new_sub_path(); cr.arc(rx + rw - radius, ry + radius, radius, -std::f64::consts::FRAC_PI_2, 0.0); cr.arc(rx + rw - radius, ry + rh - radius, radius, 0.0, std::f64::consts::FRAC_PI_2); cr.arc(rx + radius, ry + rh - radius, radius, std::f64::consts::FRAC_PI_2, std::f64::consts::PI); cr.arc(rx + radius, ry + radius, radius, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2); cr.close_path(); cr.set_source_rgba(0.0, 0.0, 0.0, 0.6); let _ = cr.fill(); cr.set_source_rgba(1.0, 1.0, 1.0, 0.95); cr.move_to(x, y); let _ = cr.show_text(text); }; if divider_x > 60.0 { draw_label(cr, "Original", 8.0, 18.0); } if w - divider_x > 80.0 { draw_label(cr, "Compressed", divider_x + 8.0, 18.0); } }); } // Drag gesture: near divider moves divider, elsewhere pans image let drag_gesture = gtk::GestureDrag::new(); { let dp = divider_pos.clone(); let dd = divider_dragging.clone(); let id = image_dragging.clone(); let drawing = preview_drawing.clone(); let dspx = drag_start_pan_x.clone(); let dspy = drag_start_pan_y.clone(); let px = pan_x.clone(); let py = pan_y.clone(); drag_gesture.connect_drag_begin(move |_, x, _| { let w = drawing.width() as f64; let current = *dp.borrow() * w; if (x - current).abs() < 30.0 { dd.set(true); id.set(false); } else { dd.set(false); id.set(true); dspx.set(px.get()); dspy.set(py.get()); } }); } { let dp = divider_pos.clone(); let dd = divider_dragging.clone(); let id = image_dragging.clone(); let drawing = preview_drawing.clone(); let px = pan_x.clone(); let py = pan_y.clone(); let dspx = drag_start_pan_x.clone(); let dspy = drag_start_pan_y.clone(); let dims = img_dims.clone(); drag_gesture.connect_drag_update(move |gesture, offset_x, offset_y| { if dd.get() { 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(); } } } else if id.get() { let (tw, th) = dims.get(); let w = drawing.width() as f64; let h = drawing.height() as f64; if tw > 0.0 && th > 0.0 && w > 0.0 && h > 0.0 { let scale = (w / tw).max(h / th); let max_px = ((tw * scale - w) / 2.0).max(0.0); let max_py = ((th * scale - h) / 2.0).max(0.0); let new_x = (dspx.get() + offset_x).clamp(-max_px, max_px); let new_y = (dspy.get() + offset_y).clamp(-max_py, max_py); px.set(new_x); py.set(new_y); drawing.queue_draw(); } } }); } { let dd = divider_dragging.clone(); let id = image_dragging.clone(); drag_gesture.connect_drag_end(move |_, _, _| { dd.set(false); id.set(false); }); } preview_drawing.add_controller(drag_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)); { let files = state.loaded_files.borrow(); let max_thumbs = files.len().min(10); 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); } 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); 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(); }); } // Preview update closure - reads compression mode from preview_comp let preview_gen: Rc> = Rc::new(Cell::new(0)); 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 pidx = preview_index.clone(); let dims = img_dims.clone(); let px = pan_x.clone(); let py = pan_y.clone(); let pc = preview_comp.clone(); let bind_gen = preview_gen.clone(); Rc::new(move |reset_pan: bool| { let loaded = files.borrow(); if loaded.is_empty() { no_img_label.set_visible(true); return; } no_img_label.set_visible(false); let idx = (*pidx.borrow()).min(loaded.len().saturating_sub(1)); let sample_path = loaded[idx].clone(); let comp = pc.get(); if reset_pan { px.set(0.0); py.set(0.0); } let my_gen = bind_gen.get().wrapping_add(1); bind_gen.set(my_gen); let gen_check = bind_gen.clone(); 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(); let dims = dims.clone(); let (tx, rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { let result = generate_preview(&sample_path, comp); let _ = tx.send(result); }); glib::timeout_add_local(std::time::Duration::from_millis(100), move || { if gen_check.get() != my_gen { return glib::ControlFlow::Break; } match rx.try_recv() { Ok(result) => { match result { PreviewResult::Success { original_bytes, compressed_bytes, original_size, compressed_size, format_label, } => { if let Ok(pb) = load_pixbuf_from_bytes(&original_bytes) { dims.set((pb.width() as f64, pb.height() as f64)); *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_pct = if original_size > 0 { (1.0 - compressed_size as f64 / original_size as f64) * 100.0 } else { 0.0 }; let savings_text = if savings_pct >= 0.0 { format!("-{:.0}%", savings_pct) } else { format!("+{:.0}%", -savings_pct) }; comp_label.set_label(&format!( "{}: {} ({})", format_label, format_size(compressed_size), savings_text )); 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 { 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(true); 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(); } } // Initial preview load { let up = update_preview.clone(); glib::idle_add_local_once(move || { up(true); }); } // Main quality slider: update preset, sync per-format sliders, preview as JPEG { let jc = state.job_config.clone(); let label = quality_label; let up = update_preview.clone(); let pc = preview_comp.clone(); let js = jpeg_scale.clone(); let ps = png_scale.clone(); let ws = webp_scale.clone(); let wes = webp_effort_scale.clone(); let avs = avif_scale.clone(); let ass = avif_speed_scale.clone(); quality_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u32; let preset = match val { 1 | 2 => QualityPreset::Low, 3 | 4 => QualityPreset::Medium, 5 | 6 => QualityPreset::High, _ => QualityPreset::Maximum, }; jc.borrow_mut().quality_preset = preset; label.set_label(&quality_description(val)); // Sync per-format sliders (their handlers update job_config) js.set_value(preset.jpeg_quality() as f64); ps.set_value(preset.png_level() as f64); ws.set_value(preset.webp_quality() as f64); wes.set_value(preset.webp_effort() as f64); avs.set_value(preset.avif_quality() as f64); ass.set_value(preset.avif_speed() as f64); // Preview as JPEG at the preset quality pc.set(PreviewCompression::Jpeg(preset.jpeg_quality())); up(false); }); } // Per-format slider handlers: update config, set preview to that format, refresh { let jc = state.job_config.clone(); let row = jpeg_row.clone(); let pc = preview_comp.clone(); let up = update_preview.clone(); jpeg_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u8; jc.borrow_mut().jpeg_quality = val; row.set_subtitle(&format!("{}", val)); pc.set(PreviewCompression::Jpeg(val)); up(false); }); } { let jc = state.job_config.clone(); let row = png_row.clone(); let pc = preview_comp.clone(); let up = update_preview.clone(); png_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u8; jc.borrow_mut().png_level = val; row.set_subtitle(&format!("{}", val)); pc.set(PreviewCompression::Png(val)); up(false); }); } { let jc = state.job_config.clone(); let row = webp_row.clone(); let pc = preview_comp.clone(); let up = update_preview.clone(); webp_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u8; jc.borrow_mut().webp_quality = val; row.set_subtitle(&format!("{}", val)); pc.set(PreviewCompression::WebP(val)); up(false); }); } { let jc = state.job_config.clone(); let row = webp_effort_row.clone(); webp_effort_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u8; jc.borrow_mut().webp_effort = val; row.set_subtitle(&format!("{}", val)); }); } { let jc = state.job_config.clone(); let row = avif_row.clone(); let pc = preview_comp.clone(); let up = update_preview.clone(); avif_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u8; jc.borrow_mut().avif_quality = val; row.set_subtitle(&format!("{}", val)); pc.set(PreviewCompression::Avif(val)); up(false); }); } { let jc = state.job_config.clone(); let row = avif_speed_row.clone(); avif_speed_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u8; jc.borrow_mut().avif_speed = val; row.set_subtitle(&format!("{}", val)); }); } scrolled.set_child(Some(&content)); let page = adw::NavigationPage::builder() .title("Compress") .tag("step-compress") .child(&scrolled) .build(); // On page map: refresh preview and show/hide per-format rows { let up = update_preview.clone(); let jc = state.job_config.clone(); let pg = performat_group.clone(); let jr = jpeg_row; let pr = png_row; let wr = webp_row; let wer = webp_effort_row; let ar = avif_row; let asr = avif_speed_row; page.connect_map(move |_| { up(true); let cfg = jc.borrow(); let mut has_jpeg = false; let mut has_png = false; let mut has_webp = false; let mut has_avif = false; match cfg.convert_format { None => { has_jpeg = true; has_png = true; has_webp = true; has_avif = true; } Some(ImageFormat::Jpeg) => has_jpeg = true, Some(ImageFormat::Png) => has_png = true, Some(ImageFormat::WebP) => has_webp = true, Some(ImageFormat::Avif) => has_avif = true, Some(ImageFormat::Gif) | Some(ImageFormat::Tiff) => {} } for (_, &choice_idx) in &cfg.format_mappings { match choice_idx { 0 => {} 1 => { has_jpeg = true; has_png = true; has_webp = true; has_avif = true; } 2 => has_jpeg = true, 3 => has_png = true, 4 => has_webp = true, 5 => has_avif = true, _ => {} } } jr.set_visible(has_jpeg); pr.set_visible(has_png); wr.set_visible(has_webp); wer.set_visible(has_webp); ar.set_visible(has_avif); asr.set_visible(has_avif); pg.set_visible(has_jpeg || has_png || has_webp || has_avif); }); } page } fn quality_description(val: u32) -> String { match val { 1 => "Low - ~50-60% smaller files. Visible quality loss. Good for email attachments and quick sharing.".into(), 2 => "Low+ - ~45-55% smaller files. Slightly better than Low, still compact.".into(), 3 => "Medium - ~30-40% smaller files. Good balance of quality and size. Recommended for most uses.".into(), 4 => "Medium+ - ~25-35% smaller files. A step above Medium with better detail retention.".into(), 5 => "High - ~15-25% smaller files. Minimal quality loss. Good for printing and high-quality output.".into(), 6 => "High+ - ~10-20% smaller files. Very good quality with modest size reduction.".into(), 7 => "Near Maximum - ~5-15% smaller files. Excellent quality, nearly indistinguishable from original.".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, format_label: String, }, Error(#[allow(dead_code)] String), } fn generate_preview(path: &std::path::Path, comp: PreviewCompression) -> 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()), }; 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(); if preview_img .write_to(&mut std::io::Cursor::new(&mut orig_buf), image::ImageFormat::Png) .is_err() { return PreviewResult::Error("Failed to encode original".into()); } // Encode compressed in the requested format let mut comp_buf = Vec::new(); let format_label; match comp { PreviewCompression::Jpeg(quality) => { format_label = format!("JPEG Q{}", quality); let cursor = std::io::Cursor::new(&mut comp_buf); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(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()); } } PreviewCompression::Png(level) => { format_label = format!("PNG L{}", level); let cursor = std::io::Cursor::new(&mut comp_buf); let ct = match level { 1 => image::codecs::png::CompressionType::Fast, 2 | 3 => image::codecs::png::CompressionType::Default, _ => image::codecs::png::CompressionType::Best, }; let encoder = image::codecs::png::PngEncoder::new_with_quality( cursor, ct, image::codecs::png::FilterType::Adaptive, ); let rgba = preview_img.to_rgba8(); if image::ImageEncoder::write_image( encoder, rgba.as_raw(), rgba.width(), rgba.height(), image::ExtendedColorType::Rgba8, ) .is_err() { return PreviewResult::Error("PNG compression failed".into()); } } PreviewCompression::WebP(quality) => { // image 0.25 only has lossless WebP encoding, so approximate with // JPEG at equivalent quality for the visual preview format_label = format!("WebP Q{} (approx)", quality); let cursor = std::io::Cursor::new(&mut comp_buf); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(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("WebP preview failed".into()); } } PreviewCompression::Avif(quality) => { // AVIF encoding not available in image crate - approximate with JPEG format_label = format!("AVIF Q{} (approx)", quality); let cursor = std::io::Cursor::new(&mut comp_buf); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(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("AVIF preview failed".into()); } } } let compressed_size = comp_buf.len() as u64; PreviewResult::Success { original_bytes: orig_buf, compressed_bytes: comp_buf, original_size, compressed_size, format_label, } } 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()) }