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:
2026-03-07 19:47:23 +02:00
parent 270a7db60d
commit b432cc7431
44 changed files with 5748 additions and 2221 deletions

View File

@@ -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(&gtk::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(&gtk::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(&gtk::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(&gtk::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
}