Files
pixstrip/pixstrip-gtk/src/steps/step_compress.rs
lashman 5bdeb8a2e3 Wire skill level and accessibility settings to UI
- Add detailed_mode to AppState, derived from skill_level setting
- Expand advanced option sections by default in Detailed mode
  (resize, convert, compress, watermark steps)
- Fix high contrast to use HighContrast GTK theme
- Add completion sound via canberra-gtk-play
2026-03-06 15:28:02 +02:00

628 lines
21 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(&gtk::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: &gtk::cairo::Context,
pixbuf: &gtk::gdk_pixbuf::Pixbuf,
clip_x: f64,
clip_w: f64| {
let tw = pixbuf.width() as f64;
let th = pixbuf.height() as f64;
let scale = (w / tw).min(h / th);
let ox = (w - tw * scale) / 2.0;
let oy = (h - th * scale) / 2.0;
cr.save().unwrap();
cr.rectangle(clip_x, 0.0, clip_w, h);
cr.clip();
cr.translate(ox, oy);
cr.scale(scale, scale);
cr.set_source_pixbuf(pixbuf, 0.0, 0.0);
let _ = cr.paint();
cr.restore().unwrap();
};
// Draw original on left side
if let Some(ref pb) = *orig.borrow() {
draw_pixbuf(cr, pb, 0.0, divider_x);
}
// Draw compressed on right side
if let Some(ref pb) = *comp.borrow() {
draw_pixbuf(cr, pb, divider_x, w - divider_x);
}
// Draw divider line
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9);
cr.set_line_width(2.0);
cr.move_to(divider_x, 0.0);
cr.line_to(divider_x, h);
let _ = cr.stroke();
// Draw handle circle
cr.arc(divider_x, h / 2.0, 12.0, 0.0, std::f64::consts::TAU);
cr.set_source_rgba(1.0, 1.0, 1.0, 0.95);
let _ = cr.fill_preserve();
cr.set_source_rgba(0.3, 0.3, 0.3, 1.0);
cr.set_line_width(1.5);
let _ = cr.stroke();
// Draw arrows on handle
cr.set_source_rgba(0.3, 0.3, 0.3, 1.0);
cr.set_line_width(2.0);
// Left arrow
cr.move_to(divider_x - 4.0, h / 2.0);
cr.line_to(divider_x - 8.0, h / 2.0);
let _ = cr.stroke();
// Right arrow
cr.move_to(divider_x + 4.0, h / 2.0);
cr.line_to(divider_x + 8.0, h / 2.0);
let _ = cr.stroke();
// Labels on each side
cr.set_source_rgba(1.0, 1.0, 1.0, 0.7);
cr.set_font_size(11.0);
if divider_x > 60.0 {
cr.move_to(8.0, 16.0);
let _ = cr.show_text("Original");
}
if w - divider_x > 80.0 {
cr.move_to(divider_x + 8.0, 16.0);
let _ = cr.show_text("Compressed");
}
});
}
// Drag gesture for the divider
let drag_gesture = gtk::GestureDrag::new();
{
let dp = divider_pos.clone();
let dr = dragging.clone();
let drawing = preview_drawing.clone();
drag_gesture.connect_drag_begin(move |_, x, _| {
let w = drawing.width() as f64;
let current = *dp.borrow() * w;
// Only start drag if near the divider
if (x - current).abs() < 30.0 {
*dr.borrow_mut() = true;
}
});
}
{
let dp = divider_pos.clone();
let dr = dragging.clone();
let drawing = preview_drawing.clone();
drag_gesture.connect_drag_update(move |gesture, offset_x, _| {
if !*dr.borrow() {
return;
}
if let Some((start_x, _)) = gesture.start_point() {
let w = drawing.width() as f64;
if w > 0.0 {
let new_pos = ((start_x + offset_x) / w).clamp(0.05, 0.95);
*dp.borrow_mut() = new_pos;
drawing.queue_draw();
}
}
});
}
{
let dr = dragging.clone();
drag_gesture.connect_drag_end(move |_, _, _| {
*dr.borrow_mut() = false;
});
}
preview_drawing.add_controller(drag_gesture);
// Click gesture to set divider position directly
let click_gesture = gtk::GestureClick::new();
{
let dp = divider_pos.clone();
let drawing = preview_drawing.clone();
click_gesture.connect_released(move |_, _, x, _| {
let w = drawing.width() as f64;
if w > 0.0 {
*dp.borrow_mut() = (x / w).clamp(0.05, 0.95);
drawing.queue_draw();
}
});
}
preview_drawing.add_controller(click_gesture);
let preview_frame = gtk::Frame::builder()
.halign(gtk::Align::Fill)
.build();
preview_frame.set_child(Some(&preview_drawing));
preview_group.add(&size_box);
preview_group.add(&preview_frame);
// "No image loaded" placeholder
let no_image_label = gtk::Label::builder()
.label("Add images first to see compression preview")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.margin_top(4)
.build();
preview_group.add(&no_image_label);
content.append(&preview_group);
// Advanced options in expander
let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced Options")
.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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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();
Rc::new(move || {
let loaded = files.borrow();
if loaded.is_empty() {
no_img_label.set_visible(true);
return;
}
no_img_label.set_visible(false);
// Pick the first image as sample
let sample_path = loaded[0].clone();
let cfg = jc.borrow();
let preset = cfg.quality_preset;
drop(cfg);
let orig_pb = orig_pb.clone();
let comp_pb = comp_pb.clone();
let drawing = drawing.clone();
let orig_label = orig_label.clone();
let comp_label = comp_label.clone();
// Load and compress in a background thread
let (tx, rx) = std::sync::mpsc::channel::<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,
}
});
})
};
// 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))
}
}