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 cfg = state.job_config.borrow(); // === OUTER LAYOUT === let outer = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(0) .vexpand(true) .build(); // --- Enable toggle (full width) --- let enable_group = adw::PreferencesGroup::builder() .margin_start(12) .margin_end(12) .margin_top(12) .build(); let enable_row = adw::SwitchRow::builder() .title("Enable Adjustments") .subtitle("Rotate, flip, brightness, contrast, effects") .active(cfg.adjustments_enabled) .tooltip_text("Toggle image adjustments on or off") .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); preview_picture.set_focusable(true); preview_picture.update_property(&[ gtk::accessible::Property::Label("Adjustments preview - press Space to cycle images"), ]); 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 preview_frame = gtk::Frame::builder() .hexpand(true) .vexpand(true) .build(); preview_frame.set_child(Some(&preview_picture)); 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") .build(); let rotate_row = adw::ComboRow::builder() .title("Rotate") .subtitle("Rotation applied to all images") .use_subtitle(true) .tooltip_text("Rotate all images by a fixed angle or auto-orient from EXIF") .build(); rotate_row.set_model(Some(>k::StringList::new(&[ "None", "90 clockwise", "180", "270 clockwise", "Auto-orient (from EXIF)", ]))); 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) .tooltip_text("Mirror images horizontally or vertically") .build(); 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); orient_group.add(&rotate_row); orient_group.add(&flip_row); controls.append(&orient_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.update_property(&[ gtk::accessible::Property::Label(&format!("Reset {} to 0", title)), ]); 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 from center to a specific ratio") .use_subtitle(true) .tooltip_text("Crop from center to a specific aspect ratio") .build(); crop_row.set_model(Some(>k::StringList::new(&[ "None", "1:1 (Square)", "4:3", "3:2", "16:9", "9:16 (Portrait)", "3:4 (Portrait)", "2:3 (Portrait)", ]))); 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() .title("Trim Whitespace") .subtitle("Remove uniform borders around the image") .active(cfg.trim_whitespace) .tooltip_text("Detect and remove uniform borders around the image") .build(); let padding_row = adw::SpinRow::builder() .title("Canvas Padding") .subtitle("Add uniform padding (pixels)") .adjustment(>k::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0)) .tooltip_text("Add a white border around each image in pixels") .build(); crop_group.add(&crop_row); crop_group.add(&trim_row); crop_group.add(&padding_row); controls.append(&crop_group); // Scrollable controls let controls_scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .width_request(360) .child(&controls) .build(); // === 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(); preview_box.set_width_request(400); main_box.append(&preview_box); main_box.append(&controls_scrolled); outer.append(&main_box); // Preview state let preview_index: Rc> = Rc::new(Cell::new(0)); drop(cfg); // === Preview update closure === let preview_gen: Rc> = 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().saturating_sub(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::>>(); std::thread::spawn(move || { let result = (|| -> Option> { 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| -> 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); } // Keyboard support for preview cycling (Space/Enter) { let key = gtk::EventControllerKey::new(); let pidx = preview_index.clone(); let files = state.loaded_files.clone(); let up = update_preview.clone(); key.connect_key_pressed(move |_, keyval, _, _| { if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return { let loaded = files.borrow(); if loaded.len() > 1 { let next = (pidx.get() + 1) % loaded.len(); pidx.set(next); up(); } return glib::Propagation::Stop; } glib::Propagation::Proceed }); preview_picture.add_controller(key); } // === 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(); }); } // Per-slider debounce counters (separate to avoid cross-slider cancellation) let brightness_debounce: Rc> = Rc::new(Cell::new(0)); let contrast_debounce: Rc> = Rc::new(Cell::new(0)); let saturation_debounce: Rc> = Rc::new(Cell::new(0)); let padding_debounce: Rc> = Rc::new(Cell::new(0)); // Brightness { let jc = state.job_config.clone(); let row = brightness_row.clone(); let up = update_preview.clone(); let rst = brightness_reset.clone(); let did = brightness_debounce.clone(); brightness_scale.connect_value_changed(move |scale| { let val = scale.value().round() as i32; jc.borrow_mut().brightness = 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 row = contrast_row.clone(); let up = update_preview.clone(); let rst = contrast_reset.clone(); let did = contrast_debounce.clone(); contrast_scale.connect_value_changed(move |scale| { let val = scale.value().round() as i32; jc.borrow_mut().contrast = 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 row = saturation_row.clone(); let up = update_preview.clone(); let rst = saturation_reset.clone(); let did = saturation_debounce.clone(); saturation_scale.connect_value_changed(move |scale| { let val = scale.value().round() as i32; jc.borrow_mut().saturation = 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(); 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(); 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(); 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 = padding_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(); } }); }); } let page = adw::NavigationPage::builder() .title("Adjustments") .tag("step-adjustments") .child(&outer) .build(); // Sync enable toggle, refresh preview and sensitivity when navigating to this page { let up = update_preview.clone(); let lf = state.loaded_files.clone(); let ctrl = controls.clone(); let jc = state.job_config.clone(); let er = enable_row.clone(); page.connect_map(move |_| { let enabled = jc.borrow().adjustments_enabled; er.set_active(enabled); ctrl.set_sensitive(!lf.borrow().is_empty()); up(); }); } page }