Users can now click different batch images in a thumbnail strip to switch which image is used for the quality comparison preview and watermark position preview. Shows up to 10 thumbnails with accent highlight on the selected one.
714 lines
24 KiB
Rust
714 lines
24 KiB
Rust
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<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);
|
|
|
|
// 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));
|
|
|
|
// 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::<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,
|
|
}
|
|
});
|
|
})
|
|
};
|
|
|
|
// 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::<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();
|
|
// 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::<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();
|
|
}
|
|
}
|
|
|
|
// 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<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))
|
|
}
|
|
}
|