Fix 26 bugs, edge cases, and consistency issues from fifth audit pass
Critical: undo toast now trashes only batch output files (not entire dir), JPEG scanline write errors propagated, selective metadata write result returned. High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio rejection, FM integration toggle infinite recursion guard, saturating counter arithmetic in executor. Medium: PNG compression level passed to oxipng, pct mode updates job_config, external file loading updates step indicator, CLI undo removes history entries, watch config write failures reported, fast-copy path reads image dimensions for rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl), CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot fix, generation guards on all preview threads to cancel stale results, default DPI aligned to 0, watermark text width uses char count not byte length. Low: binary path escaped in Nautilus extension, file dialog filter aligned with discovery, reset_wizard clears preset_mode and output_dir.
This commit is contained in:
@@ -1,65 +1,211 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// === OUTER LAYOUT ===
|
||||
let outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
// --- Enable toggle (full width) ---
|
||||
let enable_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
let enable_row = adw::SwitchRow::builder()
|
||||
.title("Enable Adjustments")
|
||||
.subtitle("Rotate, flip, brightness, contrast, effects")
|
||||
.active(cfg.adjustments_enabled)
|
||||
.build();
|
||||
enable_group.add(&enable_row);
|
||||
outer.append(&enable_group);
|
||||
|
||||
// === LEFT SIDE: Preview ===
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let cfg = state.job_config.borrow();
|
||||
let preview_frame = gtk::Frame::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_frame.set_child(Some(&preview_picture));
|
||||
|
||||
// Rotate
|
||||
let rotate_group = adw::PreferencesGroup::builder()
|
||||
let preview_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_box.append(&preview_frame);
|
||||
preview_box.append(&info_label);
|
||||
|
||||
// === RIGHT SIDE: Controls (scrollable) ===
|
||||
|
||||
let controls = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_start(12)
|
||||
.build();
|
||||
|
||||
// --- Orientation group ---
|
||||
let orient_group = adw::PreferencesGroup::builder()
|
||||
.title("Orientation")
|
||||
.description("Rotate and flip images")
|
||||
.build();
|
||||
|
||||
let rotate_row = adw::ComboRow::builder()
|
||||
.title("Rotate")
|
||||
.subtitle("Rotation applied to all images")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let rotate_model = gtk::StringList::new(&[
|
||||
rotate_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
"90 clockwise",
|
||||
"180",
|
||||
"270 clockwise",
|
||||
"Auto-orient (from EXIF)",
|
||||
]);
|
||||
rotate_row.set_model(Some(&rotate_model));
|
||||
])));
|
||||
rotate_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
rotate_row.set_selected(cfg.rotation);
|
||||
|
||||
let flip_row = adw::ComboRow::builder()
|
||||
.title("Flip")
|
||||
.subtitle("Mirror the image")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]);
|
||||
flip_row.set_model(Some(&flip_model));
|
||||
flip_row.set_model(Some(>k::StringList::new(&["None", "Horizontal", "Vertical"])));
|
||||
flip_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
flip_row.set_selected(cfg.flip);
|
||||
|
||||
rotate_group.add(&rotate_row);
|
||||
rotate_group.add(&flip_row);
|
||||
content.append(&rotate_group);
|
||||
orient_group.add(&rotate_row);
|
||||
orient_group.add(&flip_row);
|
||||
controls.append(&orient_group);
|
||||
|
||||
// Crop and canvas group
|
||||
// --- Color adjustments group ---
|
||||
let color_group = adw::PreferencesGroup::builder()
|
||||
.title("Color")
|
||||
.build();
|
||||
|
||||
// Helper to build a slider row with reset button
|
||||
let make_slider_row = |title: &str, label_text: &str, value: i32| -> (adw::ActionRow, gtk::Scale, gtk::Button) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(&format!("{}", value))
|
||||
.build();
|
||||
let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
scale.set_value(value as f64);
|
||||
scale.set_draw_value(false);
|
||||
scale.set_hexpand(false);
|
||||
scale.set_valign(gtk::Align::Center);
|
||||
scale.set_width_request(180);
|
||||
scale.set_tooltip_text(Some(label_text));
|
||||
scale.update_property(&[
|
||||
gtk::accessible::Property::Label(label_text),
|
||||
]);
|
||||
|
||||
let reset_btn = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 0")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
reset_btn.set_sensitive(value != 0);
|
||||
|
||||
row.add_suffix(&scale);
|
||||
row.add_suffix(&reset_btn);
|
||||
(row, scale, reset_btn)
|
||||
};
|
||||
|
||||
let (brightness_row, brightness_scale, brightness_reset) =
|
||||
make_slider_row("Brightness", "Brightness adjustment, -100 to +100", cfg.brightness);
|
||||
let (contrast_row, contrast_scale, contrast_reset) =
|
||||
make_slider_row("Contrast", "Contrast adjustment, -100 to +100", cfg.contrast);
|
||||
let (saturation_row, saturation_scale, saturation_reset) =
|
||||
make_slider_row("Saturation", "Saturation adjustment, -100 to +100", cfg.saturation);
|
||||
|
||||
color_group.add(&brightness_row);
|
||||
color_group.add(&contrast_row);
|
||||
color_group.add(&saturation_row);
|
||||
controls.append(&color_group);
|
||||
|
||||
// --- Effects group (compact toggle buttons) ---
|
||||
let effects_group = adw::PreferencesGroup::builder()
|
||||
.title("Effects")
|
||||
.build();
|
||||
|
||||
let effects_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
|
||||
let grayscale_btn = gtk::ToggleButton::builder()
|
||||
.label("Grayscale")
|
||||
.active(cfg.grayscale)
|
||||
.tooltip_text("Convert to grayscale")
|
||||
.build();
|
||||
grayscale_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Grayscale effect toggle"),
|
||||
]);
|
||||
|
||||
let sepia_btn = gtk::ToggleButton::builder()
|
||||
.label("Sepia")
|
||||
.active(cfg.sepia)
|
||||
.tooltip_text("Apply sepia tone")
|
||||
.build();
|
||||
sepia_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Sepia effect toggle"),
|
||||
]);
|
||||
|
||||
let sharpen_btn = gtk::ToggleButton::builder()
|
||||
.label("Sharpen")
|
||||
.active(cfg.sharpen)
|
||||
.tooltip_text("Sharpen the image")
|
||||
.build();
|
||||
sharpen_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Sharpen effect toggle"),
|
||||
]);
|
||||
|
||||
effects_box.append(&grayscale_btn);
|
||||
effects_box.append(&sepia_btn);
|
||||
effects_box.append(&sharpen_btn);
|
||||
effects_group.add(&effects_box);
|
||||
controls.append(&effects_group);
|
||||
|
||||
// --- Crop & Canvas group ---
|
||||
let crop_group = adw::PreferencesGroup::builder()
|
||||
.title("Crop and Canvas")
|
||||
.build();
|
||||
|
||||
let crop_row = adw::ComboRow::builder()
|
||||
.title("Crop to Aspect Ratio")
|
||||
.subtitle("Crop images to a specific aspect ratio from center")
|
||||
.subtitle("Crop from center to a specific ratio")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let crop_model = gtk::StringList::new(&[
|
||||
crop_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
"1:1 (Square)",
|
||||
"4:3",
|
||||
@@ -68,8 +214,8 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
"9:16 (Portrait)",
|
||||
"3:4 (Portrait)",
|
||||
"2:3 (Portrait)",
|
||||
]);
|
||||
crop_row.set_model(Some(&crop_model));
|
||||
])));
|
||||
crop_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
crop_row.set_selected(cfg.crop_aspect_ratio);
|
||||
|
||||
let trim_row = adw::SwitchRow::builder()
|
||||
@@ -80,201 +226,481 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
let padding_row = adw::SpinRow::builder()
|
||||
.title("Canvas Padding")
|
||||
.subtitle("Add uniform padding around the image (pixels)")
|
||||
.subtitle("Add uniform padding (pixels)")
|
||||
.adjustment(>k::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
crop_group.add(&crop_row);
|
||||
crop_group.add(&trim_row);
|
||||
crop_group.add(&padding_row);
|
||||
content.append(&crop_group);
|
||||
controls.append(&crop_group);
|
||||
|
||||
// Image adjustments
|
||||
let adjust_group = adw::PreferencesGroup::builder()
|
||||
.title("Image Adjustments")
|
||||
// Scrollable controls
|
||||
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.width_request(360)
|
||||
.child(&controls)
|
||||
.build();
|
||||
|
||||
let adjust_expander = adw::ExpanderRow::builder()
|
||||
.title("Advanced Adjustments")
|
||||
.subtitle("Brightness, contrast, saturation, effects")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("adjustments-advanced"))
|
||||
// === Main layout: 60/40 side-by-side ===
|
||||
let main_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
{
|
||||
let st = state.clone();
|
||||
adjust_expander.connect_expanded_notify(move |row| {
|
||||
st.set_section_expanded("adjustments-advanced", row.is_expanded());
|
||||
});
|
||||
}
|
||||
preview_box.set_width_request(400);
|
||||
main_box.append(&preview_box);
|
||||
main_box.append(&controls_scrolled);
|
||||
outer.append(&main_box);
|
||||
|
||||
// Brightness slider (-100 to +100)
|
||||
let brightness_row = adw::ActionRow::builder()
|
||||
.title("Brightness")
|
||||
.subtitle(format!("{}", cfg.brightness))
|
||||
.build();
|
||||
let brightness_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
brightness_scale.set_value(cfg.brightness as f64);
|
||||
brightness_scale.set_hexpand(true);
|
||||
brightness_scale.set_valign(gtk::Align::Center);
|
||||
brightness_scale.set_size_request(200, -1);
|
||||
brightness_scale.set_draw_value(false);
|
||||
brightness_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Brightness adjustment, -100 to +100"),
|
||||
]);
|
||||
brightness_row.add_suffix(&brightness_scale);
|
||||
adjust_expander.add_row(&brightness_row);
|
||||
|
||||
// Contrast slider (-100 to +100)
|
||||
let contrast_row = adw::ActionRow::builder()
|
||||
.title("Contrast")
|
||||
.subtitle(format!("{}", cfg.contrast))
|
||||
.build();
|
||||
let contrast_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
contrast_scale.set_value(cfg.contrast as f64);
|
||||
contrast_scale.set_hexpand(true);
|
||||
contrast_scale.set_valign(gtk::Align::Center);
|
||||
contrast_scale.set_size_request(200, -1);
|
||||
contrast_scale.set_draw_value(false);
|
||||
contrast_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Contrast adjustment, -100 to +100"),
|
||||
]);
|
||||
contrast_row.add_suffix(&contrast_scale);
|
||||
adjust_expander.add_row(&contrast_row);
|
||||
|
||||
// Saturation slider (-100 to +100)
|
||||
let saturation_row = adw::ActionRow::builder()
|
||||
.title("Saturation")
|
||||
.subtitle(format!("{}", cfg.saturation))
|
||||
.build();
|
||||
let saturation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
saturation_scale.set_value(cfg.saturation as f64);
|
||||
saturation_scale.set_hexpand(true);
|
||||
saturation_scale.set_valign(gtk::Align::Center);
|
||||
saturation_scale.set_size_request(200, -1);
|
||||
saturation_scale.set_draw_value(false);
|
||||
saturation_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Saturation adjustment, -100 to +100"),
|
||||
]);
|
||||
saturation_row.add_suffix(&saturation_scale);
|
||||
adjust_expander.add_row(&saturation_row);
|
||||
|
||||
// Sharpen after resize
|
||||
let sharpen_row = adw::SwitchRow::builder()
|
||||
.title("Sharpen after resize")
|
||||
.subtitle("Apply subtle sharpening to resized images")
|
||||
.active(cfg.sharpen)
|
||||
.build();
|
||||
adjust_expander.add_row(&sharpen_row);
|
||||
|
||||
// Grayscale
|
||||
let grayscale_row = adw::SwitchRow::builder()
|
||||
.title("Grayscale")
|
||||
.subtitle("Convert images to black and white")
|
||||
.active(cfg.grayscale)
|
||||
.build();
|
||||
adjust_expander.add_row(&grayscale_row);
|
||||
|
||||
// Sepia
|
||||
let sepia_row = adw::SwitchRow::builder()
|
||||
.title("Sepia")
|
||||
.subtitle("Apply a warm vintage tone")
|
||||
.active(cfg.sepia)
|
||||
.build();
|
||||
adjust_expander.add_row(&sepia_row);
|
||||
|
||||
adjust_group.add(&adjust_expander);
|
||||
content.append(&adjust_group);
|
||||
// Preview state
|
||||
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// === Preview update closure ===
|
||||
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let update_preview = {
|
||||
let files = state.loaded_files.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let pic = preview_picture.clone();
|
||||
let info = info_label.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let bind_gen = preview_gen.clone();
|
||||
|
||||
Rc::new(move || {
|
||||
let loaded = files.borrow();
|
||||
if loaded.is_empty() {
|
||||
info.set_label("No images loaded");
|
||||
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = pidx.get().min(loaded.len() - 1);
|
||||
pidx.set(idx);
|
||||
let path = loaded[idx].clone();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||
|
||||
let cfg = jc.borrow();
|
||||
let rotation = cfg.rotation;
|
||||
let flip = cfg.flip;
|
||||
let brightness = cfg.brightness;
|
||||
let contrast = cfg.contrast;
|
||||
let saturation = cfg.saturation;
|
||||
let grayscale = cfg.grayscale;
|
||||
let sepia = cfg.sepia;
|
||||
let sharpen = cfg.sharpen;
|
||||
let crop_aspect = cfg.crop_aspect_ratio;
|
||||
let trim_ws = cfg.trim_whitespace;
|
||||
let padding = cfg.canvas_padding;
|
||||
drop(cfg);
|
||||
|
||||
let my_gen = bind_gen.get().wrapping_add(1);
|
||||
bind_gen.set(my_gen);
|
||||
let gen_check = bind_gen.clone();
|
||||
|
||||
let pic = pic.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Option<Vec<u8>> {
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
|
||||
// Rotation
|
||||
let mut img = match rotation {
|
||||
1 => img.rotate90(),
|
||||
2 => img.rotate180(),
|
||||
3 => img.rotate270(),
|
||||
// 4 = auto-orient from EXIF - skip in preview (would need exif crate)
|
||||
_ => img,
|
||||
};
|
||||
|
||||
// Flip
|
||||
match flip {
|
||||
1 => img = img.fliph(),
|
||||
2 => img = img.flipv(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Crop to aspect ratio
|
||||
if crop_aspect > 0 {
|
||||
let (target_w, target_h): (f64, f64) = match crop_aspect {
|
||||
1 => (1.0, 1.0), // 1:1
|
||||
2 => (4.0, 3.0), // 4:3
|
||||
3 => (3.0, 2.0), // 3:2
|
||||
4 => (16.0, 9.0), // 16:9
|
||||
5 => (9.0, 16.0), // 9:16
|
||||
6 => (3.0, 4.0), // 3:4
|
||||
7 => (2.0, 3.0), // 2:3
|
||||
_ => (1.0, 1.0),
|
||||
};
|
||||
let iw = img.width() as f64;
|
||||
let ih = img.height() as f64;
|
||||
let target_ratio = target_w / target_h;
|
||||
let current_ratio = iw / ih;
|
||||
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
||||
((ih * target_ratio) as u32, img.height())
|
||||
} else {
|
||||
(img.width(), (iw / target_ratio) as u32)
|
||||
};
|
||||
let cx = (img.width().saturating_sub(crop_w)) / 2;
|
||||
let cy = (img.height().saturating_sub(crop_h)) / 2;
|
||||
img = img.crop_imm(cx, cy, crop_w, crop_h);
|
||||
}
|
||||
|
||||
// Trim whitespace (matches core algorithm with threshold)
|
||||
if trim_ws {
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width(), rgba.height());
|
||||
if w > 2 && h > 2 {
|
||||
let bg = *rgba.get_pixel(0, 0);
|
||||
let threshold = 30u32;
|
||||
let is_bg = |p: &image::Rgba<u8>| -> bool {
|
||||
let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs();
|
||||
let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs();
|
||||
let db = (p[2] as i32 - bg[2] as i32).unsigned_abs();
|
||||
dr + dg + db < threshold
|
||||
};
|
||||
let mut top = 0u32;
|
||||
let mut bottom = h - 1;
|
||||
let mut left = 0u32;
|
||||
let mut right = w - 1;
|
||||
'top: for y in 0..h {
|
||||
for x in 0..w {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { top = y; break 'top; }
|
||||
}
|
||||
}
|
||||
'bottom: for y in (0..h).rev() {
|
||||
for x in 0..w {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { bottom = y; break 'bottom; }
|
||||
}
|
||||
}
|
||||
'left: for x in 0..w {
|
||||
for y in top..=bottom {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { left = x; break 'left; }
|
||||
}
|
||||
}
|
||||
'right: for x in (0..w).rev() {
|
||||
for y in top..=bottom {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { right = x; break 'right; }
|
||||
}
|
||||
}
|
||||
let cw = right.saturating_sub(left).saturating_add(1);
|
||||
let ch = bottom.saturating_sub(top).saturating_add(1);
|
||||
if cw > 0 && ch > 0 && (cw < w || ch < h) {
|
||||
img = img.crop_imm(left, top, cw, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness
|
||||
if brightness != 0 {
|
||||
img = img.brighten(brightness);
|
||||
}
|
||||
// Contrast
|
||||
if contrast != 0 {
|
||||
img = img.adjust_contrast(contrast as f32);
|
||||
}
|
||||
// Saturation
|
||||
if saturation != 0 {
|
||||
let sat = saturation.clamp(-100, 100);
|
||||
let factor = 1.0 + (sat as f64 / 100.0);
|
||||
let mut rgba = img.into_rgba8();
|
||||
for pixel in rgba.pixels_mut() {
|
||||
let r = pixel[0] as f64;
|
||||
let g = pixel[1] as f64;
|
||||
let b = pixel[2] as f64;
|
||||
let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
img = image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
// Sharpen
|
||||
if sharpen {
|
||||
img = img.unsharpen(1.0, 5);
|
||||
}
|
||||
// Grayscale
|
||||
if grayscale {
|
||||
img = image::DynamicImage::ImageLuma8(img.to_luma8());
|
||||
}
|
||||
// Sepia
|
||||
if sepia {
|
||||
let mut rgba = img.into_rgba8();
|
||||
for pixel in rgba.pixels_mut() {
|
||||
let r = pixel[0] as f64;
|
||||
let g = pixel[1] as f64;
|
||||
let b = pixel[2] as f64;
|
||||
pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8;
|
||||
pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8;
|
||||
pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
img = image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
|
||||
// Canvas padding
|
||||
if padding > 0 {
|
||||
let pad = padding.min(200); // cap for preview
|
||||
let new_w = img.width().saturating_add(pad.saturating_mul(2));
|
||||
let new_h = img.height().saturating_add(pad.saturating_mul(2));
|
||||
let mut canvas = image::RgbaImage::from_pixel(
|
||||
new_w, new_h,
|
||||
image::Rgba([255, 255, 255, 255]),
|
||||
);
|
||||
image::imageops::overlay(&mut canvas, &img.to_rgba8(), pad as i64, pad as i64);
|
||||
img = image::DynamicImage::ImageRgba8(canvas);
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
).ok()?;
|
||||
Some(buf)
|
||||
})();
|
||||
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(Some(bytes)) => {
|
||||
let gbytes = glib::Bytes::from(&bytes);
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||
pic.set_paintable(Some(&texture));
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(None) => glib::ControlFlow::Break,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(_) => glib::ControlFlow::Break,
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Click-to-cycle on preview
|
||||
{
|
||||
let click = gtk::GestureClick::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
click.connect_released(move |_, _, _, _| {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
});
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().adjustments_enabled = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
rotate_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().rotation = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
flip_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().flip = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Shared debounce counter for slider-driven previews
|
||||
let slider_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
|
||||
// Brightness
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
crop_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().crop_aspect_ratio = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
trim_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().trim_whitespace = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
padding_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = brightness_row;
|
||||
let row = brightness_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = brightness_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
brightness_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().brightness = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = brightness_scale.clone();
|
||||
brightness_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Contrast
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = contrast_row;
|
||||
let row = contrast_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = contrast_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
contrast_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().contrast = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = contrast_scale.clone();
|
||||
contrast_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Saturation
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = saturation_row;
|
||||
let row = saturation_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = saturation_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
saturation_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().saturation = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = saturation_scale.clone();
|
||||
saturation_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Effects toggle buttons
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
grayscale_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().grayscale = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
sharpen_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().sharpen = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
sepia_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().sepia = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
grayscale_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().grayscale = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
sharpen_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().sharpen = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Crop & Canvas
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
crop_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().crop_aspect_ratio = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
sepia_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().sepia = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
trim_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().trim_whitespace = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
let did = slider_debounce.clone();
|
||||
padding_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Adjustments")
|
||||
.tag("step-adjustments")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
page.connect_map(move |_| {
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user