diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index 3805d4d..89441a3 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -573,25 +573,27 @@ fn cmd_undo(count: usize) { entry.total, entry.input_dir ); - let mut batch_trashed = 0; + let mut remaining_files = Vec::new(); for file_path in &entry.output_files { let path = PathBuf::from(file_path); if path.exists() { match trash::delete(&path) { Ok(()) => { - batch_trashed += 1; total_trashed += 1; } Err(e) => { eprintln!(" Failed to trash {}: {}", path.display(), e); + remaining_files.push(file_path.clone()); } } } } - // Keep entry in history if no files were trashed - if batch_trashed == 0 { - failed_entries.push(entry); + // Keep entry with remaining files if some could not be trashed + if !remaining_files.is_empty() { + let mut kept = entry; + kept.output_files = remaining_files; + failed_entries.push(kept); } } @@ -665,7 +667,7 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { } match serde_json::to_string_pretty(&watches) { Ok(json) => { - if let Err(e) = std::fs::write(&watches_path, json) { + if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) { eprintln!("Failed to write watch config: {}", e); std::process::exit(1); } @@ -743,7 +745,7 @@ fn cmd_watch_remove(path: &str) { match serde_json::to_string_pretty(&watches) { Ok(json) => { - if let Err(e) = std::fs::write(&watches_path, json) { + if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) { eprintln!("Failed to write watch config: {}", e); std::process::exit(1); } diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index 95ca7e6..1486316 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -398,7 +398,7 @@ impl PipelineExecutor { let output_path = match job.overwrite_behavior { crate::operations::OverwriteAction::Skip if output_path.exists() => { - return Ok((input_size, 0, std::path::PathBuf::new())); + return Ok((0, 0, std::path::PathBuf::new())); } crate::operations::OverwriteAction::AutoRename if output_path.exists() => { find_unique_path(&output_path) @@ -563,7 +563,7 @@ impl PipelineExecutor { crate::operations::OverwriteAction::Skip => { if output_path.exists() { // Return 0 bytes written - file was skipped - return Ok((input_size, 0, std::path::PathBuf::new())); + return Ok((0, 0, std::path::PathBuf::new())); } output_path } diff --git a/pixstrip-core/src/fm_integration.rs b/pixstrip-core/src/fm_integration.rs index cb52522..2956057 100644 --- a/pixstrip-core/src/fm_integration.rs +++ b/pixstrip-core/src/fm_integration.rs @@ -151,8 +151,8 @@ fn install_nautilus() -> Result<()> { \x20 item.connect('activate', self._on_preset, '{}', files)\n\ \x20 submenu.append_item(item)\n\n", name.replace(' ', "_"), - name.replace('\'', "\\'"), - name.replace('\'', "\\'"), + name.replace('\\', "\\\\").replace('\'', "\\'"), + name.replace('\\', "\\\\").replace('\'', "\\'"), )); } diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index 46ac283..fab1143 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -120,6 +120,11 @@ impl ConvertConfig { } } } + + /// Returns true if this conversion will change at least some file extensions. + pub fn changes_extension(&self) -> bool { + !matches!(self, Self::KeepOriginal) + } } // --- Compress --- @@ -318,6 +323,18 @@ pub struct RenameConfig { fn default_counter_position() -> u32 { 3 } impl RenameConfig { + /// Returns true if this rename config would actually change any filename. + pub fn changes_filename(&self) -> bool { + !self.prefix.is_empty() + || !self.suffix.is_empty() + || self.counter_enabled + || !self.regex_find.is_empty() + || self.case_mode > 0 + || self.replace_spaces > 0 + || self.special_chars > 0 + || self.template.is_some() + } + /// Pre-compile the regex for batch use. Call once before a loop of apply_simple_compiled. pub fn compile_regex(&self) -> Option { rename::compile_rename_regex(&self.regex_find) diff --git a/pixstrip-core/src/operations/watermark.rs b/pixstrip-core/src/operations/watermark.rs index beae823..55fc2a4 100644 --- a/pixstrip-core/src/operations/watermark.rs +++ b/pixstrip-core/src/operations/watermark.rs @@ -198,7 +198,7 @@ fn render_text_to_image( opacity: f32, ) -> image::RgbaImage { let scale = ab_glyph::PxScale::from(font_size); - let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 1.0) as u32).saturating_add(4).min(16384); + let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(16384); let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096); let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8; @@ -296,7 +296,7 @@ fn apply_text_watermark( } else { // No rotation - draw text directly (faster) let scale = ab_glyph::PxScale::from(font_size); - let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 1.0) as u32).saturating_add(4).min(16384); + let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(16384); let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096); let text_dims = Dimensions { width: text_width, @@ -351,9 +351,9 @@ fn apply_tiled_text_watermark( let tw = tile.width(); let th = tile.height(); - let mut y: i64 = spacing as i64; + let mut y: i64 = 0; while y < ih as i64 { - let mut x: i64 = spacing as i64; + let mut x: i64 = 0; while x < iw as i64 { image::imageops::overlay(&mut rgba, &tile, x, y); x += tw as i64 + spacing as i64; @@ -366,12 +366,12 @@ fn apply_tiled_text_watermark( let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8; let draw_color = Rgba([color[0], color[1], color[2], alpha]); - let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 1.0) as i64 + 4).min(16384); + let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as i64 + 4).min(16384); let text_height = ((font_size.min(1000.0) * 1.4) as i64 + 4).min(4096); - let mut y = spacing as i64; + let mut y: i64 = 0; while y < ih as i64 { - let mut x = spacing as i64; + let mut x: i64 = 0; while x < iw as i64 { draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text); x += text_width + spacing as i64; @@ -413,9 +413,9 @@ fn apply_tiled_image_watermark( let mut base = img.into_rgba8(); let (iw, ih) = (base.width(), base.height()); - let mut ty = spacing; + let mut ty = 0u32; while ty < ih { - let mut tx = spacing; + let mut tx = 0u32; while tx < iw { for oy in 0..oh { for ox in 0..ow { diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index e0ba599..0ff0447 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -67,7 +67,7 @@ impl Preset { crate::operations::CompressConfig::Preset(p) => p.avif_speed(), _ => 6, }).unwrap_or(6), - output_dpi: 72, + output_dpi: 0, } } diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 736aa1e..c1ba3cc 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -115,6 +115,8 @@ pub struct AppState { pub detailed_mode: bool, pub batch_queue: Rc>, pub expanded_sections: Rc>>, + /// Guard flag to suppress per-checkbox callbacks during bulk select/deselect + pub batch_updating: Rc>, } impl AppState { @@ -327,6 +329,7 @@ fn build_ui(app: &adw::Application) { detailed_mode: app_cfg.skill_level.is_advanced(), batch_queue: Rc::new(RefCell::new(BatchQueue::default())), expanded_sections: Rc::new(RefCell::new(sess_state.expanded_sections.clone())), + batch_updating: Rc::new(std::cell::Cell::new(false)), job_config: Rc::new(RefCell::new(JobConfig { preset_mode: false, resize_enabled: if remember { sess_state.resize_enabled.unwrap_or(true) } else { true }, @@ -942,7 +945,9 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { && let Some(stack) = page.child().and_downcast::() && let Some(loaded) = stack.child_by_name("loaded") { + ui.state.batch_updating.set(true); crate::steps::step_images::set_all_checkboxes_in(&loaded, true); + ui.state.batch_updating.set(false); let files = ui.state.loaded_files.borrow(); let count = files.len(); drop(files); @@ -969,7 +974,9 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { && let Some(stack) = page.child().and_downcast::() && let Some(loaded) = stack.child_by_name("loaded") { + ui.state.batch_updating.set(true); crate::steps::step_images::set_all_checkboxes_in(&loaded, false); + ui.state.batch_updating.set(false); let files = ui.state.loaded_files.borrow(); let count = files.len(); drop(files); @@ -1799,11 +1806,14 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { // Check if user has customized per-format quality values beyond the preset defaults let preset_jpeg = cfg.quality_preset.jpeg_quality(); let preset_webp = cfg.quality_preset.webp_quality(); + let preset_avif = cfg.quality_preset.avif_quality(); + let preset_avif_speed = cfg.quality_preset.avif_speed(); + let preset_webp_effort = cfg.quality_preset.webp_effort(); let has_custom = cfg.jpeg_quality != preset_jpeg || cfg.webp_quality != preset_webp as u8 - || cfg.avif_quality != preset_webp as u8 - || cfg.avif_speed != 6 - || cfg.webp_effort != 4; + || cfg.avif_quality != preset_avif + || cfg.avif_speed != preset_avif_speed + || cfg.webp_effort != preset_webp_effort; if has_custom { job.compress = Some(pixstrip_core::operations::CompressConfig::Custom { @@ -1967,8 +1977,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { } // Check for existing output files when "Ask" overwrite behavior is set. - // Skip check if rename or format conversion is active (output names will differ). - let has_rename_or_convert = job.rename.is_some() || job.convert.is_some(); + // Skip check if rename or format conversion will actually change output names. + let has_rename_or_convert = job.rename.as_ref().is_some_and(|r| r.changes_filename()) + || job.convert.as_ref().is_some_and(|c| c.changes_extension()); if ask_overwrite && !has_rename_or_convert { let output_dir = ui.state.output_dir.borrow().clone() .unwrap_or_else(|| { @@ -1980,7 +1991,19 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { let conflicts: Vec = files.iter() .filter_map(|f| { let name = f.file_name()?; - let out_path = output_dir.join(name); + let out_path = if job.preserve_directory_structure { + if let Ok(rel) = f.strip_prefix(&job.input_dir) { + if let Some(parent) = rel.parent() { + output_dir.join(parent).join(name) + } else { + output_dir.join(name) + } + } else { + output_dir.join(name) + } + } else { + output_dir.join(name) + }; if out_path.exists() { Some(name.to_string_lossy().into()) } else { None } }) .take(10) @@ -3464,9 +3487,14 @@ fn build_watch_folder_panel() -> gtk::Box { if let Ok(file) = result && let Some(path) = file.path() { + // Use first available preset name + let default_preset = pixstrip_core::preset::Preset::all_builtins() + .first() + .map(|p| p.name.clone()) + .unwrap_or_else(|| "Blog Photos".to_string()); let new_folder = pixstrip_core::watcher::WatchFolder { path: path.clone(), - preset_name: "Blog Photos".to_string(), + preset_name: default_preset, recursive: false, active: true, }; diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index e594904..f46dabf 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -550,8 +550,8 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { .spacing(0) .build(); - // Guard flag to prevent O(n^2) update cascade during batch checkbox operations - let batch_updating: Rc> = Rc::new(std::cell::Cell::new(false)); + // Use the shared batch_updating flag from AppState + let batch_updating = state.batch_updating.clone(); // Toolbar let toolbar = gtk::Box::builder()