From 8154324929828f5620c62db2238419f11ba7f565 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 12:15:02 +0200 Subject: [PATCH] Add Adjustments, Watermark, Rename wizard steps; expand to 10-step wizard - New step_adjustments: rotation (5 options) and flip (3 options) - New step_watermark: text/image watermark with position, opacity, font size - New step_rename: prefix/suffix/counter with live preview and template engine - Updated step_metadata: added Custom mode with per-category checkboxes (GPS, camera, software, timestamps, copyright) with show/hide toggle - Expanded JobConfig with all operation fields (watermark, rename, metadata custom) - Updated wizard from 7 to 10 steps in correct pipeline order - Fixed page index references from 6 to 9 for output step - Added MetadataMode::Custom handling in preset builder and output summary --- pixstrip-gtk/src/app.rs | 139 ++++++++++++- pixstrip-gtk/src/steps/mod.rs | 3 + pixstrip-gtk/src/steps/step_adjustments.rs | 87 ++++++++ pixstrip-gtk/src/steps/step_metadata.rs | 108 ++++++++++ pixstrip-gtk/src/steps/step_rename.rs | 229 +++++++++++++++++++++ pixstrip-gtk/src/steps/step_watermark.rs | 222 ++++++++++++++++++++ pixstrip-gtk/src/wizard.rs | 20 +- 7 files changed, 796 insertions(+), 12 deletions(-) create mode 100644 pixstrip-gtk/src/steps/step_adjustments.rs create mode 100644 pixstrip-gtk/src/steps/step_rename.rs create mode 100644 pixstrip-gtk/src/steps/step_watermark.rs diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 5d0fdfb..753095f 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -13,19 +13,47 @@ pub const APP_ID: &str = "live.lashman.Pixstrip"; /// User's choices from the wizard steps, used to build the ProcessingJob #[derive(Clone, Debug)] pub struct JobConfig { + // Resize pub resize_enabled: bool, pub resize_width: u32, pub resize_height: u32, pub allow_upscale: bool, + // Adjustments + pub rotation: u32, + pub flip: u32, + // Convert pub convert_enabled: bool, pub convert_format: Option, + // Compress pub compress_enabled: bool, pub quality_preset: pixstrip_core::types::QualityPreset, pub jpeg_quality: u8, pub png_level: u8, pub webp_quality: u8, + // Metadata pub metadata_enabled: bool, pub metadata_mode: MetadataMode, + pub strip_gps: bool, + pub strip_camera: bool, + pub strip_software: bool, + pub strip_timestamps: bool, + pub strip_copyright: bool, + // Watermark + pub watermark_enabled: bool, + pub watermark_text: String, + pub watermark_image_path: Option, + pub watermark_position: u32, + pub watermark_opacity: f32, + pub watermark_font_size: f32, + pub watermark_use_image: bool, + // Rename + pub rename_enabled: bool, + pub rename_prefix: String, + pub rename_suffix: String, + pub rename_counter_start: u32, + pub rename_counter_padding: u32, + pub rename_template: String, + // Output pub preserve_dir_structure: bool, pub overwrite_behavior: u8, } @@ -36,6 +64,7 @@ pub enum MetadataMode { StripAll, Privacy, KeepAll, + Custom, } /// Shared app state accessible from all UI callbacks @@ -93,6 +122,8 @@ fn build_ui(app: &adw::Application) { resize_width: 1200, resize_height: 0, allow_upscale: false, + rotation: 0, + flip: 0, convert_enabled: false, convert_format: None, compress_enabled: true, @@ -102,6 +133,24 @@ fn build_ui(app: &adw::Application) { webp_quality: 80, metadata_enabled: true, metadata_mode: MetadataMode::StripAll, + strip_gps: true, + strip_camera: true, + strip_software: true, + strip_timestamps: true, + strip_copyright: true, + watermark_enabled: false, + watermark_text: String::new(), + watermark_image_path: None, + watermark_position: 8, // BottomRight + watermark_opacity: 0.5, + watermark_font_size: 24.0, + watermark_use_image: false, + rename_enabled: false, + rename_prefix: String::new(), + rename_suffix: String::new(), + rename_counter_start: 1, + rename_counter_padding: 3, + rename_template: String::new(), preserve_dir_structure: false, overwrite_behavior: 0, })), @@ -398,10 +447,10 @@ fn navigate_to_step(ui: &WizardUi, target: usize) { } // Update dynamic content on certain steps - if target == 6 { + if target == 9 { // Output step - update image count and operation summary let count = ui.state.loaded_files.borrow().len(); - if let Some(page) = ui.pages.get(6) { + if let Some(page) = ui.pages.get(9) { walk_widgets(&page.child(), &|widget| { if let Some(row) = widget.downcast_ref::() && row.title().as_str() == "Images to process" @@ -493,8 +542,8 @@ fn open_output_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) { } fn update_output_label(ui: &WizardUi, path: &std::path::Path) { - // Find the output step page (index 6) and update the output location subtitle - if let Some(page) = ui.pages.get(6) { + // Find the output step page (index 9) and update the output location subtitle + if let Some(page) = ui.pages.get(9) { walk_widgets(&page.child(), &|widget| { if let Some(row) = widget.downcast_ref::() && row.title().as_str() == "Output Location" @@ -704,6 +753,78 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { MetadataMode::StripAll => pixstrip_core::operations::MetadataConfig::StripAll, MetadataMode::Privacy => pixstrip_core::operations::MetadataConfig::Privacy, MetadataMode::KeepAll => pixstrip_core::operations::MetadataConfig::KeepAll, + MetadataMode::Custom => pixstrip_core::operations::MetadataConfig::Custom { + strip_gps: cfg.strip_gps, + strip_camera: cfg.strip_camera, + strip_software: cfg.strip_software, + strip_timestamps: cfg.strip_timestamps, + strip_copyright: cfg.strip_copyright, + }, + }); + } + + // Rotation + job.rotation = Some(match cfg.rotation { + 1 => pixstrip_core::operations::Rotation::Cw90, + 2 => pixstrip_core::operations::Rotation::Cw180, + 3 => pixstrip_core::operations::Rotation::Cw270, + 4 => pixstrip_core::operations::Rotation::AutoOrient, + _ => pixstrip_core::operations::Rotation::None, + }); + + // Flip + job.flip = Some(match cfg.flip { + 1 => pixstrip_core::operations::Flip::Horizontal, + 2 => pixstrip_core::operations::Flip::Vertical, + _ => pixstrip_core::operations::Flip::None, + }); + + // Watermark + if cfg.watermark_enabled { + let position = match cfg.watermark_position { + 0 => pixstrip_core::operations::WatermarkPosition::TopLeft, + 1 => pixstrip_core::operations::WatermarkPosition::TopCenter, + 2 => pixstrip_core::operations::WatermarkPosition::TopRight, + 3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft, + 4 => pixstrip_core::operations::WatermarkPosition::Center, + 5 => pixstrip_core::operations::WatermarkPosition::MiddleRight, + 6 => pixstrip_core::operations::WatermarkPosition::BottomLeft, + 7 => pixstrip_core::operations::WatermarkPosition::BottomCenter, + _ => pixstrip_core::operations::WatermarkPosition::BottomRight, + }; + + if cfg.watermark_use_image { + if let Some(ref path) = cfg.watermark_image_path { + job.watermark = Some(pixstrip_core::operations::WatermarkConfig::Image { + path: path.clone(), + position, + opacity: cfg.watermark_opacity, + scale: 0.2, + }); + } + } else if !cfg.watermark_text.is_empty() { + job.watermark = Some(pixstrip_core::operations::WatermarkConfig::Text { + text: cfg.watermark_text.clone(), + position, + font_size: cfg.watermark_font_size, + opacity: cfg.watermark_opacity, + color: [255, 255, 255, 255], + }); + } + } + + // Rename + if cfg.rename_enabled { + job.rename = Some(pixstrip_core::operations::RenameConfig { + prefix: cfg.rename_prefix.clone(), + suffix: cfg.rename_suffix.clone(), + counter_start: cfg.rename_counter_start, + counter_padding: cfg.rename_counter_padding, + template: if cfg.rename_template.is_empty() { + None + } else { + Some(cfg.rename_template.clone()) + }, }); } @@ -1147,6 +1268,13 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese MetadataMode::StripAll => pixstrip_core::operations::MetadataConfig::StripAll, MetadataMode::Privacy => pixstrip_core::operations::MetadataConfig::Privacy, MetadataMode::KeepAll => pixstrip_core::operations::MetadataConfig::KeepAll, + MetadataMode::Custom => pixstrip_core::operations::MetadataConfig::Custom { + strip_gps: cfg.strip_gps, + strip_camera: cfg.strip_camera, + strip_software: cfg.strip_software, + strip_timestamps: cfg.strip_timestamps, + strip_copyright: cfg.strip_copyright, + }, }) } else { None @@ -1195,7 +1323,7 @@ fn build_preset_description(cfg: &JobConfig) -> String { fn update_output_summary(ui: &WizardUi) { let cfg = ui.state.job_config.borrow(); - if let Some(page) = ui.pages.get(6) { + if let Some(page) = ui.pages.get(9) { // Build summary lines let mut ops = Vec::new(); if cfg.resize_enabled && cfg.resize_width > 0 { @@ -1216,6 +1344,7 @@ fn update_output_summary(ui: &WizardUi) { MetadataMode::StripAll => "Strip all metadata", MetadataMode::Privacy => "Privacy mode", MetadataMode::KeepAll => "Keep all metadata", + MetadataMode::Custom => "Custom metadata stripping", }; ops.push(mode.to_string()); } diff --git a/pixstrip-gtk/src/steps/mod.rs b/pixstrip-gtk/src/steps/mod.rs index 8e39277..760e9f9 100644 --- a/pixstrip-gtk/src/steps/mod.rs +++ b/pixstrip-gtk/src/steps/mod.rs @@ -1,7 +1,10 @@ +pub mod step_adjustments; pub mod step_compress; pub mod step_convert; pub mod step_images; pub mod step_metadata; pub mod step_output; +pub mod step_rename; pub mod step_resize; +pub mod step_watermark; pub mod step_workflow; diff --git a/pixstrip-gtk/src/steps/step_adjustments.rs b/pixstrip-gtk/src/steps/step_adjustments.rs new file mode 100644 index 0000000..d82976b --- /dev/null +++ b/pixstrip-gtk/src/steps/step_adjustments.rs @@ -0,0 +1,87 @@ +use adw::prelude::*; +use crate::app::AppState; + +pub fn build_adjustments_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(); + + // Rotate + let rotate_group = adw::PreferencesGroup::builder() + .title("Rotation") + .build(); + + let rotate_row = adw::ComboRow::builder() + .title("Rotate") + .subtitle("Rotation applied after resize") + .build(); + let rotate_model = gtk::StringList::new(&[ + "None", + "90 clockwise", + "180", + "270 clockwise", + "Auto-orient (EXIF)", + ]); + rotate_row.set_model(Some(&rotate_model)); + rotate_row.set_selected(cfg.rotation); + + rotate_group.add(&rotate_row); + content.append(&rotate_group); + + // Flip + let flip_group = adw::PreferencesGroup::builder() + .title("Flip") + .build(); + + let flip_row = adw::ComboRow::builder() + .title("Flip") + .subtitle("Mirror the image") + .build(); + let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]); + flip_row.set_model(Some(&flip_model)); + flip_row.set_selected(cfg.flip); + + flip_group.add(&flip_row); + content.append(&flip_group); + + drop(cfg); + + // Wire signals + { + let jc = state.job_config.clone(); + rotate_row.connect_selected_notify(move |row| { + jc.borrow_mut().rotation = row.selected(); + }); + } + { + let jc = state.job_config.clone(); + flip_row.connect_selected_notify(move |row| { + jc.borrow_mut().flip = row.selected(); + }); + } + + scrolled.set_child(Some(&content)); + + let clamp = adw::Clamp::builder() + .maximum_size(600) + .child(&scrolled) + .build(); + + adw::NavigationPage::builder() + .title("Adjustments") + .tag("step-adjustments") + .child(&clamp) + .build() +} diff --git a/pixstrip-gtk/src/steps/step_metadata.rs b/pixstrip-gtk/src/steps/step_metadata.rs index e2abaef..fe9818f 100644 --- a/pixstrip-gtk/src/steps/step_metadata.rs +++ b/pixstrip-gtk/src/steps/step_metadata.rs @@ -69,11 +69,71 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage { keep_all_row.add_suffix(&keep_all_check); keep_all_row.set_activatable_widget(Some(&keep_all_check)); + let custom_row = adw::ActionRow::builder() + .title("Custom") + .subtitle("Choose exactly which metadata categories to strip") + .activatable(true) + .build(); + custom_row.add_prefix(>k::Image::from_icon_name("emblem-system-symbolic")); + let custom_check = gtk::CheckButton::new(); + custom_check.set_group(Some(&strip_all_check)); + custom_check.set_active(cfg.metadata_mode == MetadataMode::Custom); + custom_row.add_suffix(&custom_check); + custom_row.set_activatable_widget(Some(&custom_check)); + presets_group.add(&strip_all_row); presets_group.add(&privacy_row); presets_group.add(&keep_all_row); + presets_group.add(&custom_row); content.append(&presets_group); + // Custom category checkboxes + let custom_group = adw::PreferencesGroup::builder() + .title("Custom Categories") + .description("Select which metadata categories to strip") + .build(); + + let gps_row = adw::SwitchRow::builder() + .title("GPS / Location") + .subtitle("GPS coordinates, location name, altitude") + .active(cfg.strip_gps) + .build(); + + let camera_row = adw::SwitchRow::builder() + .title("Camera Info") + .subtitle("Camera model, serial number, lens data") + .active(cfg.strip_camera) + .build(); + + let software_row = adw::SwitchRow::builder() + .title("Software") + .subtitle("Editing software, processing history") + .active(cfg.strip_software) + .build(); + + let timestamps_row = adw::SwitchRow::builder() + .title("Timestamps") + .subtitle("Date taken, date modified, date digitized") + .active(cfg.strip_timestamps) + .build(); + + let copyright_row = adw::SwitchRow::builder() + .title("Copyright / Author") + .subtitle("Copyright notice, artist name, credits") + .active(cfg.strip_copyright) + .build(); + + custom_group.add(&gps_row); + custom_group.add(&camera_row); + custom_group.add(&software_row); + custom_group.add(×tamps_row); + custom_group.add(©right_row); + + // Only show custom group when Custom mode is selected + custom_group.set_visible(cfg.metadata_mode == MetadataMode::Custom); + + content.append(&custom_group); + drop(cfg); // Wire signals @@ -85,28 +145,76 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage { } { let jc = state.job_config.clone(); + let cg = custom_group.clone(); strip_all_check.connect_toggled(move |check| { if check.is_active() { jc.borrow_mut().metadata_mode = MetadataMode::StripAll; + cg.set_visible(false); } }); } { let jc = state.job_config.clone(); + let cg = custom_group.clone(); privacy_check.connect_toggled(move |check| { if check.is_active() { jc.borrow_mut().metadata_mode = MetadataMode::Privacy; + cg.set_visible(false); } }); } { let jc = state.job_config.clone(); + let cg = custom_group.clone(); keep_all_check.connect_toggled(move |check| { if check.is_active() { jc.borrow_mut().metadata_mode = MetadataMode::KeepAll; + cg.set_visible(false); } }); } + { + let jc = state.job_config.clone(); + let cg = custom_group; + custom_check.connect_toggled(move |check| { + if check.is_active() { + jc.borrow_mut().metadata_mode = MetadataMode::Custom; + cg.set_visible(true); + } + }); + } + + // Wire custom category toggles + { + let jc = state.job_config.clone(); + gps_row.connect_active_notify(move |row| { + jc.borrow_mut().strip_gps = row.is_active(); + }); + } + { + let jc = state.job_config.clone(); + camera_row.connect_active_notify(move |row| { + jc.borrow_mut().strip_camera = row.is_active(); + }); + } + { + let jc = state.job_config.clone(); + software_row.connect_active_notify(move |row| { + jc.borrow_mut().strip_software = row.is_active(); + }); + } + { + let jc = state.job_config.clone(); + timestamps_row.connect_active_notify(move |row| { + jc.borrow_mut().strip_timestamps = row.is_active(); + }); + } + { + let jc = state.job_config.clone(); + copyright_row.connect_active_notify(move |row| { + jc.borrow_mut().strip_copyright = row.is_active(); + }); + } scrolled.set_child(Some(&content)); diff --git a/pixstrip-gtk/src/steps/step_rename.rs b/pixstrip-gtk/src/steps/step_rename.rs new file mode 100644 index 0000000..b7c2b04 --- /dev/null +++ b/pixstrip-gtk/src/steps/step_rename.rs @@ -0,0 +1,229 @@ +use adw::prelude::*; +use crate::app::AppState; + +pub fn build_rename_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 Rename") + .subtitle("Rename output files with prefix, suffix, or template") + .active(cfg.rename_enabled) + .build(); + + let enable_group = adw::PreferencesGroup::new(); + enable_group.add(&enable_row); + content.append(&enable_group); + + // Simple mode: prefix + suffix + counter + let simple_group = adw::PreferencesGroup::builder() + .title("Simple Rename") + .description("Add prefix, suffix, and sequential counter") + .build(); + + let prefix_row = adw::EntryRow::builder() + .title("Prefix") + .text(&cfg.rename_prefix) + .build(); + + let suffix_row = adw::EntryRow::builder() + .title("Suffix") + .text(&cfg.rename_suffix) + .build(); + + let counter_start_row = adw::SpinRow::builder() + .title("Counter Start") + .subtitle("First number in sequence") + .adjustment(>k::Adjustment::new(cfg.rename_counter_start as f64, 0.0, 99999.0, 1.0, 10.0, 0.0)) + .build(); + + let counter_padding_row = adw::SpinRow::builder() + .title("Counter Padding") + .subtitle("Minimum digits (e.g., 3 = 001, 002, 003)") + .adjustment(>k::Adjustment::new(cfg.rename_counter_padding as f64, 1.0, 10.0, 1.0, 1.0, 0.0)) + .build(); + + simple_group.add(&prefix_row); + simple_group.add(&suffix_row); + simple_group.add(&counter_start_row); + simple_group.add(&counter_padding_row); + content.append(&simple_group); + + // Live preview + let preview_group = adw::PreferencesGroup::builder() + .title("Preview") + .description("Example of how files will be renamed") + .build(); + + let preview_label = gtk::Label::builder() + .label("photo.jpg -> photo_001.jpg") + .css_classes(["monospace", "dim-label"]) + .halign(gtk::Align::Start) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .build(); + + preview_group.add(&preview_label); + content.append(&preview_group); + + // Advanced: template engine + let advanced_group = adw::PreferencesGroup::builder() + .title("Advanced: Template Engine") + .description("Use variables in curly braces for full control") + .build(); + + let template_row = adw::EntryRow::builder() + .title("Template") + .text(&cfg.rename_template) + .build(); + + let help_label = gtk::Label::builder() + .label( + "Available variables:\n\ + {name} - original filename (no extension)\n\ + {ext} - output extension\n\ + {counter} or {counter:3} - zero-padded counter\n\ + {date} - today's date\n\ + {exif_date} - EXIF date taken\n\ + {camera} - camera model from EXIF\n\ + {width} x {height} - output dimensions\n\ + {original_ext} - original file extension" + ) + .css_classes(["dim-label", "caption"]) + .halign(gtk::Align::Start) + .wrap(true) + .margin_top(4) + .margin_bottom(8) + .margin_start(12) + .build(); + + advanced_group.add(&template_row); + advanced_group.add(&help_label); + content.append(&advanced_group); + + drop(cfg); + + // Wire signals + { + let jc = state.job_config.clone(); + enable_row.connect_active_notify(move |row| { + jc.borrow_mut().rename_enabled = row.is_active(); + }); + } + + // Update preview helper + let update_preview = { + let files = state.loaded_files.clone(); + let jc = state.job_config.clone(); + let preview = preview_label.clone(); + move || { + let cfg = jc.borrow(); + let loaded = files.borrow(); + let sample_name = loaded + .first() + .and_then(|p| p.file_stem()) + .and_then(|s| s.to_str()) + .unwrap_or("photo"); + let sample_ext = loaded + .first() + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + .unwrap_or("jpg"); + + if !cfg.rename_template.is_empty() { + // Template mode preview + let result = cfg.rename_template + .replace("{name}", sample_name) + .replace("{ext}", sample_ext) + .replace("{counter}", &format!("{:0>width$}", cfg.rename_counter_start, width = cfg.rename_counter_padding as usize)) + .replace("{date}", "2026-03-06") + .replace("{width}", "1200") + .replace("{height}", "800"); + preview.set_label(&format!("{}.{} -> {}", sample_name, sample_ext, result)); + } else { + // Simple mode preview + let rename_cfg = pixstrip_core::operations::RenameConfig { + prefix: cfg.rename_prefix.clone(), + suffix: cfg.rename_suffix.clone(), + counter_start: cfg.rename_counter_start, + counter_padding: cfg.rename_counter_padding, + template: None, + }; + let result = rename_cfg.apply_simple(sample_name, sample_ext, 1); + preview.set_label(&format!("{}.{} -> {}", sample_name, sample_ext, result)); + } + } + }; + + // Call once to set initial preview + update_preview(); + + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + prefix_row.connect_changed(move |row| { + jc.borrow_mut().rename_prefix = row.text().to_string(); + up(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + suffix_row.connect_changed(move |row| { + jc.borrow_mut().rename_suffix = row.text().to_string(); + up(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + counter_start_row.connect_value_notify(move |row| { + jc.borrow_mut().rename_counter_start = row.value() as u32; + up(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + counter_padding_row.connect_value_notify(move |row| { + jc.borrow_mut().rename_counter_padding = row.value() as u32; + up(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview; + template_row.connect_changed(move |row| { + jc.borrow_mut().rename_template = row.text().to_string(); + up(); + }); + } + + scrolled.set_child(Some(&content)); + + let clamp = adw::Clamp::builder() + .maximum_size(600) + .child(&scrolled) + .build(); + + adw::NavigationPage::builder() + .title("Rename") + .tag("step-rename") + .child(&clamp) + .build() +} diff --git a/pixstrip-gtk/src/steps/step_watermark.rs b/pixstrip-gtk/src/steps/step_watermark.rs new file mode 100644 index 0000000..c9b6902 --- /dev/null +++ b/pixstrip-gtk/src/steps/step_watermark.rs @@ -0,0 +1,222 @@ +use adw::prelude::*; +use crate::app::AppState; + +pub fn build_watermark_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 Watermark") + .subtitle("Add text or image watermark to processed images") + .active(cfg.watermark_enabled) + .build(); + + let enable_group = adw::PreferencesGroup::new(); + enable_group.add(&enable_row); + content.append(&enable_group); + + // Watermark type selection + let type_group = adw::PreferencesGroup::builder() + .title("Watermark Type") + .build(); + + let type_row = adw::ComboRow::builder() + .title("Type") + .subtitle("Choose text or image watermark") + .build(); + let type_model = gtk::StringList::new(&["Text Watermark", "Image Watermark"]); + type_row.set_model(Some(&type_model)); + type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 }); + + type_group.add(&type_row); + content.append(&type_group); + + // Text watermark settings + let text_group = adw::PreferencesGroup::builder() + .title("Text Watermark") + .build(); + + let text_row = adw::EntryRow::builder() + .title("Watermark Text") + .text(&cfg.watermark_text) + .build(); + + let font_size_row = adw::SpinRow::builder() + .title("Font Size") + .subtitle("Size in pixels") + .adjustment(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0)) + .build(); + + text_group.add(&text_row); + text_group.add(&font_size_row); + content.append(&text_group); + + // Image watermark settings + let image_group = adw::PreferencesGroup::builder() + .title("Image Watermark") + .visible(cfg.watermark_use_image) + .build(); + + let image_path_row = adw::ActionRow::builder() + .title("Logo Image") + .subtitle( + cfg.watermark_image_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "No image selected".to_string()), + ) + .activatable(true) + .build(); + image_path_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); + + let choose_image_button = gtk::Button::builder() + .icon_name("document-open-symbolic") + .tooltip_text("Choose logo image") + .valign(gtk::Align::Center) + .build(); + choose_image_button.add_css_class("flat"); + image_path_row.add_suffix(&choose_image_button); + + image_group.add(&image_path_row); + content.append(&image_group); + + // Position grid (9-point) + let position_group = adw::PreferencesGroup::builder() + .title("Position") + .description("Choose where the watermark appears on the image") + .build(); + + let position_names = [ + "Top Left", "Top Center", "Top Right", + "Middle Left", "Center", "Middle Right", + "Bottom Left", "Bottom Center", "Bottom Right", + ]; + + let position_row = adw::ComboRow::builder() + .title("Watermark Position") + .build(); + let position_model = gtk::StringList::new(&position_names); + position_row.set_model(Some(&position_model)); + position_row.set_selected(cfg.watermark_position); + + position_group.add(&position_row); + content.append(&position_group); + + // Advanced options + let advanced_group = adw::PreferencesGroup::builder() + .title("Advanced Options") + .build(); + + let opacity_row = adw::SpinRow::builder() + .title("Opacity") + .subtitle("0.0 (invisible) to 1.0 (fully opaque)") + .adjustment(>k::Adjustment::new(cfg.watermark_opacity as f64, 0.0, 1.0, 0.05, 0.1, 0.0)) + .digits(2) + .build(); + + advanced_group.add(&opacity_row); + content.append(&advanced_group); + + drop(cfg); + + // Wire signals + { + let jc = state.job_config.clone(); + enable_row.connect_active_notify(move |row| { + jc.borrow_mut().watermark_enabled = row.is_active(); + }); + } + { + let jc = state.job_config.clone(); + let text_group_c = text_group.clone(); + let image_group_c = image_group.clone(); + type_row.connect_selected_notify(move |row| { + let use_image = row.selected() == 1; + jc.borrow_mut().watermark_use_image = use_image; + text_group_c.set_visible(!use_image); + image_group_c.set_visible(use_image); + }); + } + { + let jc = state.job_config.clone(); + text_row.connect_changed(move |row| { + jc.borrow_mut().watermark_text = row.text().to_string(); + }); + } + { + let jc = state.job_config.clone(); + font_size_row.connect_value_notify(move |row| { + jc.borrow_mut().watermark_font_size = row.value() as f32; + }); + } + { + let jc = state.job_config.clone(); + position_row.connect_selected_notify(move |row| { + jc.borrow_mut().watermark_position = row.selected(); + }); + } + { + let jc = state.job_config.clone(); + opacity_row.connect_value_notify(move |row| { + jc.borrow_mut().watermark_opacity = row.value() as f32; + }); + } + // Wire image chooser button + { + let jc = state.job_config.clone(); + let path_row = image_path_row.clone(); + choose_image_button.connect_clicked(move |btn| { + let jc = jc.clone(); + let path_row = path_row.clone(); + let dialog = gtk::FileDialog::builder() + .title("Choose Watermark Image") + .modal(true) + .build(); + + let filter = gtk::FileFilter::new(); + filter.set_name(Some("PNG images")); + filter.add_mime_type("image/png"); + let filters = gtk::gio::ListStore::new::(); + filters.append(&filter); + dialog.set_filters(Some(&filters)); + + if let Some(window) = btn.root().and_then(|r| r.downcast::().ok()) { + dialog.open(Some(&window), gtk::gio::Cancellable::NONE, move |result| { + if let Ok(file) = result + && let Some(path) = file.path() + { + path_row.set_subtitle(&path.display().to_string()); + jc.borrow_mut().watermark_image_path = Some(path); + } + }); + } + }); + } + + scrolled.set_child(Some(&content)); + + let clamp = adw::Clamp::builder() + .maximum_size(600) + .child(&scrolled) + .build(); + + adw::NavigationPage::builder() + .title("Watermark") + .tag("step-watermark") + .child(&clamp) + .build() +} diff --git a/pixstrip-gtk/src/wizard.rs b/pixstrip-gtk/src/wizard.rs index d2f38d9..1b6f076 100644 --- a/pixstrip-gtk/src/wizard.rs +++ b/pixstrip-gtk/src/wizard.rs @@ -14,9 +14,12 @@ impl WizardState { "Workflow".into(), "Images".into(), "Resize".into(), + "Adjustments".into(), "Convert".into(), "Compress".into(), "Metadata".into(), + "Watermark".into(), + "Rename".into(), "Output".into(), ]; let total = names.len(); @@ -63,12 +66,15 @@ impl WizardState { pub fn build_wizard_pages(state: &AppState) -> Vec { vec![ - steps::step_workflow::build_workflow_page(state), - steps::step_images::build_images_page(state), - steps::step_resize::build_resize_page(state), - steps::step_convert::build_convert_page(state), - steps::step_compress::build_compress_page(state), - steps::step_metadata::build_metadata_page(state), - steps::step_output::build_output_page(state), + steps::step_workflow::build_workflow_page(state), // 0: Workflow + steps::step_images::build_images_page(state), // 1: Images + steps::step_resize::build_resize_page(state), // 2: Resize + steps::step_adjustments::build_adjustments_page(state),// 3: Adjustments + steps::step_convert::build_convert_page(state), // 4: Convert + steps::step_compress::build_compress_page(state), // 5: Compress + steps::step_metadata::build_metadata_page(state), // 6: Metadata + steps::step_watermark::build_watermark_page(state), // 7: Watermark + steps::step_rename::build_rename_page(state), // 8: Rename + steps::step_output::build_output_page(state), // 9: Output ] }