1004 lines
37 KiB
Rust
1004 lines
37 KiB
Rust
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<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 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<Cell<f64>> = Rc::new(Cell::new(0.0));
|
|
let pan_y: Rc<Cell<f64>> = Rc::new(Cell::new(0.0));
|
|
let drag_start_pan_x: Rc<Cell<f64>> = Rc::new(Cell::new(0.0));
|
|
let drag_start_pan_y: Rc<Cell<f64>> = Rc::new(Cell::new(0.0));
|
|
let img_dims: Rc<Cell<(f64, f64)>> = Rc::new(Cell::new((0.0, 0.0)));
|
|
|
|
// Current preview compression mode
|
|
let preview_comp: Rc<Cell<PreviewCompression>> = 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<RefCell<usize>> = 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<Cell<u32>> = 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::<PreviewResult>();
|
|
|
|
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::<gtk::Button>() {
|
|
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::<gtk::Button>() {
|
|
if let Some(f) = b.child().and_then(|c| c.downcast::<gtk::Frame>().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<u8>,
|
|
compressed_bytes: Vec<u8>,
|
|
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<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())
|
|
}
|