diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index 7c1d1cc..7759516 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -626,16 +626,19 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { watches.push(watch); if let Err(e) = std::fs::create_dir_all(&config_dir) { - eprintln!("Warning: failed to create config directory: {}", e); + eprintln!("Failed to create config directory: {}", e); + std::process::exit(1); } match serde_json::to_string_pretty(&watches) { Ok(json) => { if let Err(e) = std::fs::write(&watches_path, json) { - eprintln!("Warning: failed to write watch config: {}", e); + eprintln!("Failed to write watch config: {}", e); + std::process::exit(1); } } Err(e) => { - eprintln!("Warning: failed to serialize watch config: {}", e); + eprintln!("Failed to serialize watch config: {}", e); + std::process::exit(1); } } @@ -773,15 +776,35 @@ fn cmd_watch_start() { for event in &rx { match event { pixstrip_core::watcher::WatchEvent::NewImage(path) => { - // Skip files inside output directories to prevent infinite processing loop - if output_dirs.iter().any(|d| path.starts_with(d)) { + // Skip files inside output directories to prevent infinite processing loop. + // Check if the file is a direct child of any "processed" subdirectory + // under a watched folder. + let in_output_dir = output_dirs.iter().any(|d| path.starts_with(d)) + || active.iter().any(|w| { + // Skip if the file's immediate parent is "processed" under the watch root + path.parent() + .and_then(|p| p.file_name()) + .is_some_and(|name| name == "processed") + && path.starts_with(&w.path) + }); + if in_output_dir { continue; } println!("New image: {}", path.display()); - // Wait briefly for file to be fully written - std::thread::sleep(std::time::Duration::from_millis(500)); + // Wait for file to be fully written (check size stability) + { + let mut last_size = 0u64; + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(200)); + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + if size > 0 && size == last_size { + break; + } + last_size = size; + } + } // Find which watcher owns this path and use its preset let matched = active.iter() @@ -803,7 +826,23 @@ fn cmd_watch_start() { let executor = PipelineExecutor::new(); match executor.execute(&job, |_| {}) { - Ok(r) => println!(" Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes)), + Ok(r) => { + println!(" Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes)); + let history = HistoryStore::new(); + let _ = history.add(pixstrip_core::storage::HistoryEntry { + timestamp: chrono_timestamp(), + input_dir: input_dir.to_string_lossy().into(), + output_dir: output_dir.to_string_lossy().into(), + preset_name: Some(preset_name.to_string()), + total: r.total, + succeeded: r.succeeded, + failed: r.failed, + total_input_bytes: r.total_input_bytes, + total_output_bytes: r.total_output_bytes, + elapsed_ms: r.elapsed_ms, + output_files: r.output_files, + }, 50, 30); + } Err(e) => eprintln!(" Failed: {}", e), } } @@ -882,8 +921,9 @@ fn parse_format(s: &str) -> Option { "avif" => Some(ImageFormat::Avif), "gif" => Some(ImageFormat::Gif), "tiff" | "tif" => Some(ImageFormat::Tiff), + "bmp" => Some(ImageFormat::Bmp), _ => { - eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff", s); + eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff, bmp", s); None } } @@ -980,12 +1020,42 @@ fn format_duration(ms: u64) -> String { } fn chrono_timestamp() -> String { - // Simple timestamp without chrono dependency + // Human-readable timestamp without chrono dependency let now = std::time::SystemTime::now(); - let duration = now + let secs = now .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - format!("{}", duration.as_secs()) + .unwrap_or_default() + .as_secs(); + + // Convert to date/time components + let days = secs / 86400; + let time_secs = secs % 86400; + let hours = time_secs / 3600; + let minutes = (time_secs % 3600) / 60; + let seconds = time_secs % 60; + + let mut d = days; + let mut year = 1970u64; + loop { + let days_in_year = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 366 } else { 365 }; + if d < days_in_year { break; } + d -= days_in_year; + year += 1; + } + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let month_days: [u64; 12] = if leap { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + let mut month = 1u64; + for md in &month_days { + if d < *md { break; } + d -= md; + month += 1; + } + + format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, d + 1, hours, minutes, seconds) } #[cfg(test)] @@ -999,11 +1069,11 @@ mod tests { assert_eq!(parse_format("PNG"), Some(ImageFormat::Png)); assert_eq!(parse_format("webp"), Some(ImageFormat::WebP)); assert_eq!(parse_format("avif"), Some(ImageFormat::Avif)); + assert_eq!(parse_format("bmp"), Some(ImageFormat::Bmp)); } #[test] fn parse_format_invalid() { - assert_eq!(parse_format("bmp"), None); assert_eq!(parse_format("xyz"), None); } diff --git a/pixstrip-core/src/encoder.rs b/pixstrip-core/src/encoder.rs index a44e8a0..a6475a4 100644 --- a/pixstrip-core/src/encoder.rs +++ b/pixstrip-core/src/encoder.rs @@ -44,6 +44,7 @@ impl OutputEncoder { ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(80)), ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif), ImageFormat::Tiff => self.encode_fallback(img, image::ImageFormat::Tiff), + ImageFormat::Bmp => self.encode_fallback(img, image::ImageFormat::Bmp), } } @@ -68,7 +69,7 @@ impl OutputEncoder { ImageFormat::WebP => preset.webp_quality() as u8, ImageFormat::Avif => preset.avif_quality() as u8, ImageFormat::Png => preset.png_level(), - _ => preset.jpeg_quality(), + ImageFormat::Gif | ImageFormat::Tiff | ImageFormat::Bmp => preset.jpeg_quality(), } } @@ -202,6 +203,11 @@ impl OutputEncoder { /// The pHYs chunk must appear before the first IDAT chunk. /// DPI is converted to pixels per meter (1 inch = 0.0254 meters). fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec { + // Not a valid PNG (too short for signature) - return as-is + if png_data.len() < 8 { + return png_data.to_vec(); + } + // PNG pixels per meter = DPI / 0.0254 let ppm = (dpi as f64 / 0.0254).round() as u32; @@ -241,9 +247,12 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec { png_data[pos], png_data[pos + 1], png_data[pos + 2], png_data[pos + 3], ]) as usize; let chunk_type = &png_data[pos + 4..pos + 8]; - let total_chunk_size = 4 + 4 + chunk_len + 4; // len + type + data + crc + // Use checked arithmetic to prevent overflow on malformed PNGs + let Some(total_chunk_size) = chunk_len.checked_add(12) else { + break; // chunk_len so large it overflows - malformed PNG + }; - if pos + total_chunk_size > png_data.len() { + if pos.checked_add(total_chunk_size).map_or(true, |end| end > png_data.len()) { break; } @@ -263,6 +272,11 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec { pos += total_chunk_size; } + // Copy any remaining bytes (e.g. trailing data after a truncated chunk) + if pos < png_data.len() { + result.extend_from_slice(&png_data[pos..]); + } + result } diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index b64bc49..b4c2a31 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -176,16 +176,6 @@ impl PipelineExecutor { .unwrap_or("unknown") .to_string(); - let current = completed_ref.fetch_add(1, Ordering::Relaxed) + 1; - - let _ = tx_clone.send(ProgressUpdate { - current, - total, - current_file: file_name.clone(), - succeeded_so_far: succeeded_ref.load(Ordering::Relaxed), - failed_so_far: failed_ref.load(Ordering::Relaxed), - }); - let loader = ImageLoader::new(); let encoder = OutputEncoder::with_options(EncoderOptions { progressive_jpeg: job.progressive_jpeg, @@ -207,13 +197,23 @@ impl PipelineExecutor { Err(e) => { failed_ref.fetch_add(1, Ordering::Relaxed); if let Ok(mut errs) = errors_ref.lock() { - errs.push((file_name, e.to_string())); + errs.push((file_name.clone(), e.to_string())); } if pause_on_error { pause_flag.store(true, Ordering::Relaxed); } } } + + // Send progress after processing so counts are consistent + let current = completed_ref.fetch_add(1, Ordering::Relaxed) + 1; + let _ = tx_clone.send(ProgressUpdate { + current, + total, + current_file: file_name, + succeeded_so_far: succeeded_ref.load(Ordering::Relaxed), + failed_so_far: failed_ref.load(Ordering::Relaxed), + }); }); }); // Drop sender so the receiver loop ends @@ -493,8 +493,8 @@ impl PipelineExecutor { } => match output_format { ImageFormat::Jpeg => jpeg_quality.unwrap_or(85), ImageFormat::Png => png_level.unwrap_or(6), - ImageFormat::WebP => webp_quality.map(|q| q as u8).unwrap_or(80), - ImageFormat::Avif => avif_quality.map(|q| q as u8).unwrap_or(50), + ImageFormat::WebP => webp_quality.map(|q| q.clamp(0.0, 100.0) as u8).unwrap_or(80), + ImageFormat::Avif => avif_quality.map(|q| q.clamp(0.0, 100.0) as u8).unwrap_or(50), _ => 85, }, }); @@ -599,22 +599,33 @@ impl PipelineExecutor { // KeepAll: copy everything back from source. // Privacy/Custom: copy metadata back, then selectively strip certain tags. // StripAll: do nothing (already stripped by re-encoding). + // Note: little_exif only supports JPEG and TIFF metadata manipulation. if let Some(ref meta_config) = job.metadata { + let format_supports_exif = matches!( + output_format, + ImageFormat::Jpeg | ImageFormat::Tiff + ); match meta_config { crate::operations::MetadataConfig::KeepAll => { - if !copy_metadata_from_source(&source.path, &output_path) { - eprintln!("Warning: failed to copy metadata to {}", output_path.display()); + if format_supports_exif { + if !copy_metadata_from_source(&source.path, &output_path) { + eprintln!("Warning: failed to copy metadata to {}", output_path.display()); + } } + // For non-JPEG/TIFF formats, metadata is lost during re-encoding + // and cannot be restored. This is a known limitation. } crate::operations::MetadataConfig::StripAll => { // Already stripped by re-encoding - nothing to do } _ => { // Privacy or Custom: copy all metadata back, then strip unwanted tags - if !copy_metadata_from_source(&source.path, &output_path) { - eprintln!("Warning: failed to copy metadata to {}", output_path.display()); + if format_supports_exif { + if !copy_metadata_from_source(&source.path, &output_path) { + eprintln!("Warning: failed to copy metadata to {}", output_path.display()); + } + strip_selective_metadata(&output_path, meta_config); } - strip_selective_metadata(&output_path, meta_config); } } } @@ -697,8 +708,17 @@ fn paths_are_same(a: &std::path::Path, b: &std::path::Path) -> bool { match (a.canonicalize(), b.canonicalize()) { (Ok(ca), Ok(cb)) => ca == cb, _ => { - // If canonicalize fails (file doesn't exist yet), compare components directly - a.as_os_str() == b.as_os_str() + // If canonicalize fails (output file doesn't exist yet), + // canonicalize parent directories and compare with filename appended + let resolve = |p: &std::path::Path| -> Option { + let parent = p.parent()?; + let name = p.file_name()?; + Some(parent.canonicalize().ok()?.join(name)) + }; + match (resolve(a), resolve(b)) { + (Some(ra), Some(rb)) => ra == rb, + _ => a.as_os_str() == b.as_os_str(), + } } } } @@ -732,7 +752,12 @@ fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf { .create_new(true) .open(&candidate) { - Ok(_) => return candidate, + Ok(mut f) => { + // Write a marker byte so cleanup_placeholder (which only removes + // 0-byte files) won't delete our reservation before the real write + let _ = std::io::Write::write_all(&mut f, b"~"); + return candidate; + } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(_) => continue, } diff --git a/pixstrip-core/src/fm_integration.rs b/pixstrip-core/src/fm_integration.rs index 32d2047..cb52522 100644 --- a/pixstrip-core/src/fm_integration.rs +++ b/pixstrip-core/src/fm_integration.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use crate::error::Result; use crate::preset::Preset; -use crate::storage::PresetStore; +use crate::storage::{atomic_write, PresetStore}; /// Supported file managers for right-click integration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -214,7 +214,7 @@ class PixstripExtension(GObject.GObject, Nautilus.MenuProvider): bin = escaped_bin, ); - std::fs::write(nautilus_extension_path(), script)?; + atomic_write(&nautilus_extension_path(), &script)?; Ok(()) } @@ -259,7 +259,7 @@ fn install_nemo() -> Result<()> { Mimetypes=image/*;\n", bin = bin, ); - std::fs::write(nemo_action_path(), open_action)?; + atomic_write(&nemo_action_path(), &open_action)?; // Per-preset actions let presets = get_preset_names(); @@ -279,7 +279,7 @@ fn install_nemo() -> Result<()> { safe_label = shell_safe(name), bin = bin, ); - std::fs::write(action_path, action)?; + atomic_write(&action_path, &action)?; } Ok(()) @@ -361,7 +361,7 @@ fn install_thunar() -> Result<()> { } actions.push_str("\n"); - std::fs::write(thunar_action_path(), actions)?; + atomic_write(&thunar_action_path(), &actions)?; Ok(()) } @@ -429,7 +429,7 @@ fn install_dolphin() -> Result<()> { )); } - std::fs::write(dolphin_service_path(), desktop)?; + atomic_write(&dolphin_service_path(), &desktop)?; Ok(()) } diff --git a/pixstrip-core/src/loader.rs b/pixstrip-core/src/loader.rs index 9f5098a..4bc09f0 100644 --- a/pixstrip-core/src/loader.rs +++ b/pixstrip-core/src/loader.rs @@ -34,7 +34,8 @@ impl ImageLoader { reason: e.to_string(), })?; - let img = reader.decode().map_err(|e| PixstripError::ImageLoad { + // Read only the image header for dimensions (avoids full decode into RAM) + let (width, height) = reader.into_dimensions().map_err(|e| PixstripError::ImageLoad { path: path.to_path_buf(), reason: e.to_string(), })?; @@ -45,10 +46,7 @@ impl ImageLoader { .and_then(ImageFormat::from_extension); Ok(ImageInfo { - dimensions: Dimensions { - width: img.width(), - height: img.height(), - }, + dimensions: Dimensions { width, height }, format, file_size: metadata.len(), }) diff --git a/pixstrip-core/src/operations/metadata.rs b/pixstrip-core/src/operations/metadata.rs index 13305a2..6dba59d 100644 --- a/pixstrip-core/src/operations/metadata.rs +++ b/pixstrip-core/src/operations/metadata.rs @@ -16,21 +16,68 @@ pub fn strip_metadata( MetadataConfig::StripAll => { strip_all_exif(input, output)?; } - MetadataConfig::Privacy => { - // Privacy mode strips GPS and camera info but keeps copyright. - // For now, we strip all EXIF as a safe default. - // Selective tag preservation requires full EXIF parsing. - strip_all_exif(input, output)?; - } - MetadataConfig::Custom { .. } => { - // Custom selective stripping - simplified to strip-all for now. - // Full selective stripping requires per-tag EXIF manipulation. - strip_all_exif(input, output)?; + MetadataConfig::Privacy | MetadataConfig::Custom { .. } => { + // Copy file first, then selectively strip using little_exif + std::fs::copy(input, output).map_err(PixstripError::Io)?; + strip_selective_exif(output, config); } } Ok(()) } +fn strip_selective_exif(path: &Path, config: &MetadataConfig) { + use little_exif::exif_tag::ExifTag; + use little_exif::metadata::Metadata; + + let Ok(source_meta) = Metadata::new_from_path(path) else { + return; + }; + + let mut strip_ids: Vec = Vec::new(); + + if config.should_strip_gps() { + strip_ids.push(ExifTag::GPSInfo(Vec::new()).as_u16()); + } + if config.should_strip_camera() { + strip_ids.push(ExifTag::Make(String::new()).as_u16()); + strip_ids.push(ExifTag::Model(String::new()).as_u16()); + strip_ids.push(ExifTag::LensModel(String::new()).as_u16()); + strip_ids.push(ExifTag::LensMake(String::new()).as_u16()); + strip_ids.push(ExifTag::SerialNumber(String::new()).as_u16()); + strip_ids.push(ExifTag::LensSerialNumber(String::new()).as_u16()); + strip_ids.push(ExifTag::LensInfo(Vec::new()).as_u16()); + } + if config.should_strip_software() { + strip_ids.push(ExifTag::Software(String::new()).as_u16()); + strip_ids.push(ExifTag::MakerNote(Vec::new()).as_u16()); + } + if config.should_strip_timestamps() { + strip_ids.push(ExifTag::ModifyDate(String::new()).as_u16()); + strip_ids.push(ExifTag::DateTimeOriginal(String::new()).as_u16()); + strip_ids.push(ExifTag::CreateDate(String::new()).as_u16()); + strip_ids.push(ExifTag::SubSecTime(String::new()).as_u16()); + strip_ids.push(ExifTag::SubSecTimeOriginal(String::new()).as_u16()); + strip_ids.push(ExifTag::SubSecTimeDigitized(String::new()).as_u16()); + strip_ids.push(ExifTag::OffsetTime(String::new()).as_u16()); + strip_ids.push(ExifTag::OffsetTimeOriginal(String::new()).as_u16()); + strip_ids.push(ExifTag::OffsetTimeDigitized(String::new()).as_u16()); + } + if config.should_strip_copyright() { + strip_ids.push(ExifTag::Copyright(String::new()).as_u16()); + strip_ids.push(ExifTag::Artist(String::new()).as_u16()); + strip_ids.push(ExifTag::OwnerName(String::new()).as_u16()); + } + + let mut new_meta = Metadata::new(); + for tag in source_meta.data() { + if !strip_ids.contains(&tag.as_u16()) { + new_meta.set_tag(tag.clone()); + } + } + + let _ = new_meta.write_to_file(path); +} + fn strip_all_exif(input: &Path, output: &Path) -> Result<()> { let data = std::fs::read(input).map_err(PixstripError::Io)?; let cleaned = if data.len() >= 8 && &data[..8] == b"\x89PNG\r\n\x1a\n" { diff --git a/pixstrip-core/src/operations/rename.rs b/pixstrip-core/src/operations/rename.rs index bd83623..b2ac266 100644 --- a/pixstrip-core/src/operations/rename.rs +++ b/pixstrip-core/src/operations/rename.rs @@ -224,6 +224,18 @@ pub fn apply_case_conversion(name: &str, case_mode: u32) -> String { } } +/// Pre-compile a regex for batch use. Returns None (with message) if invalid. +pub fn compile_rename_regex(find: &str) -> Option { + if find.is_empty() { + return None; + } + regex::RegexBuilder::new(find) + .size_limit(1 << 16) + .dfa_size_limit(1 << 16) + .build() + .ok() +} + /// Apply regex find-and-replace on a filename pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String { if find.is_empty() { @@ -231,6 +243,7 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String { } match regex::RegexBuilder::new(find) .size_limit(1 << 16) + .dfa_size_limit(1 << 16) .build() { Ok(re) => re.replace_all(name, replace).into_owned(), @@ -238,9 +251,17 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String { } } +/// Apply a pre-compiled regex find-and-replace on a filename +pub fn apply_regex_replace_compiled(name: &str, re: ®ex::Regex, replace: &str) -> String { + re.replace_all(name, replace).into_owned() +} + pub fn resolve_collision(path: &Path) -> PathBuf { - if !path.exists() { - return path.to_path_buf(); + // Use create_new (O_CREAT|O_EXCL) for atomic reservation, preventing TOCTOU races + match std::fs::OpenOptions::new().write(true).create_new(true).open(path) { + Ok(_) => return path.to_path_buf(), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return path.to_path_buf(), // other errors (e.g. permission) - let caller handle } let parent = path.parent().unwrap_or(Path::new(".")); @@ -259,15 +280,21 @@ pub fn resolve_collision(path: &Path) -> PathBuf { } else { parent.join(format!("{}_{}.{}", stem, i, ext)) }; - if !candidate.exists() { - return candidate; + match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) { + Ok(_) => return candidate, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => continue, } } - // Fallback - should never happen with 1000 attempts + // Fallback with timestamp for uniqueness + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); if ext.is_empty() { - parent.join(format!("{}_overflow", stem)) + parent.join(format!("{}_{}", stem, ts)) } else { - parent.join(format!("{}_overflow.{}", stem, ext)) + parent.join(format!("{}_{}.{}", stem, ts, ext)) } } diff --git a/pixstrip-core/src/operations/watermark.rs b/pixstrip-core/src/operations/watermark.rs index 55980be..0595d59 100644 --- a/pixstrip-core/src/operations/watermark.rs +++ b/pixstrip-core/src/operations/watermark.rs @@ -94,7 +94,7 @@ fn find_system_font(family: Option<&str>) -> Result> { .to_lowercase(); if file_name.contains(&name_lower) && (file_name.ends_with(".ttf") || file_name.ends_with(".otf")) - && (file_name.contains("regular") || !file_name.contains("bold") && !file_name.contains("italic")) + && (file_name.contains("regular") || (!file_name.contains("bold") && !file_name.contains("italic"))) { if let Ok(data) = std::fs::read(&path) { return Ok(data); @@ -136,24 +136,27 @@ fn walkdir(dir: &std::path::Path) -> std::io::Result> { fn walkdir_depth(dir: &std::path::Path, max_depth: u32) -> std::io::Result> { const MAX_RESULTS: usize = 10_000; let mut results = Vec::new(); - if max_depth == 0 || !dir.is_dir() { - return Ok(results); + walkdir_depth_inner(dir, max_depth, &mut results, MAX_RESULTS); + Ok(results) +} + +fn walkdir_depth_inner(dir: &std::path::Path, max_depth: u32, results: &mut Vec, max: usize) { + if max_depth == 0 || !dir.is_dir() || results.len() >= max { + return; } - for entry in std::fs::read_dir(dir)? { - if results.len() >= MAX_RESULTS { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries { + if results.len() >= max { break; } - let entry = entry?; + let Ok(entry) = entry else { continue }; let path = entry.path(); if path.is_dir() { - if let Ok(sub) = walkdir_depth(&path, max_depth - 1) { - results.extend(sub); - } + walkdir_depth_inner(&path, max_depth - 1, results, max); } else { results.push(path); } } - Ok(results) } /// Render text onto a transparent RGBA buffer and return it as a DynamicImage @@ -333,7 +336,7 @@ 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.len().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as i64 + 4).min(8192); + let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as i64 + 4).min(8192); let text_height = ((font_size.min(1000.0) * 1.4) as i64 + 4).min(4096); let mut y = spacing as i64; @@ -363,8 +366,9 @@ fn apply_tiled_image_watermark( reason: format!("Failed to load watermark image: {}", e), })?; - let wm_width = ((watermark.width() as f32 * scale) as u32).clamp(1, 16384); - let wm_height = ((watermark.height() as f32 * scale) as u32).clamp(1, 16384); + let safe_scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let wm_width = ((watermark.width() as f32 * safe_scale) as u32).clamp(1, 16384); + let wm_height = ((watermark.height() as f32 * safe_scale) as u32).clamp(1, 16384); let mut watermark = watermark.resize_exact(wm_width, wm_height, image::imageops::FilterType::Lanczos3); if let Some(rot) = rotation { @@ -426,8 +430,9 @@ fn apply_image_watermark( })?; // Scale the watermark (capped to prevent OOM on extreme scale values) - let wm_width = ((watermark.width() as f32 * scale) as u32).clamp(1, 16384); - let wm_height = ((watermark.height() as f32 * scale) as u32).clamp(1, 16384); + let safe_scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let wm_width = ((watermark.width() as f32 * safe_scale) as u32).clamp(1, 16384); + let wm_height = ((watermark.height() as f32 * safe_scale) as u32).clamp(1, 16384); let mut watermark = watermark.resize_exact( wm_width, diff --git a/pixstrip-core/src/storage.rs b/pixstrip-core/src/storage.rs index a45773b..49cf3ee 100644 --- a/pixstrip-core/src/storage.rs +++ b/pixstrip-core/src/storage.rs @@ -14,7 +14,7 @@ fn default_config_dir() -> PathBuf { } /// Write to a temporary file then rename, for crash safety. -fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { +pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { let tmp = path.with_extension("tmp"); std::fs::write(&tmp, contents)?; std::fs::rename(&tmp, path)?; diff --git a/pixstrip-core/src/types.rs b/pixstrip-core/src/types.rs index 3f840b6..dfdc96b 100644 --- a/pixstrip-core/src/types.rs +++ b/pixstrip-core/src/types.rs @@ -11,6 +11,7 @@ pub enum ImageFormat { Avif, Gif, Tiff, + Bmp, } impl ImageFormat { @@ -22,6 +23,7 @@ impl ImageFormat { "avif" => Some(Self::Avif), "gif" => Some(Self::Gif), "tiff" | "tif" => Some(Self::Tiff), + "bmp" => Some(Self::Bmp), _ => None, } } @@ -34,6 +36,7 @@ impl ImageFormat { Self::Avif => "avif", Self::Gif => "gif", Self::Tiff => "tiff", + Self::Bmp => "bmp", } } } diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 0da0d9c..8b2a6e7 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -624,6 +624,12 @@ fn build_ui(app: &adw::Application) { let _ = std::fs::remove_dir_all(&temp_downloads); } + // Clean up clipboard temp files on exit + let temp_dir = std::env::temp_dir().join("pixstrip-clipboard"); + if temp_dir.is_dir() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + glib::Propagation::Proceed }); } @@ -784,7 +790,9 @@ fn start_watch_folder_monitoring(ui: &WizardUi) { job.add_source(file); } let executor = pixstrip_core::executor::PipelineExecutor::new(); - let _ = executor.execute(&job, |_| {}); + if let Err(e) = executor.execute(&job, |_| {}) { + eprintln!("Watch folder processing error: {}", e); + } }); let toast = adw::Toast::new(&format!( @@ -1105,8 +1113,14 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { .collect(); if !new_files.is_empty() { + let mut loaded = ui.state.loaded_files.borrow_mut(); + let new_files: Vec<_> = new_files + .into_iter() + .filter(|p| !loaded.contains(p)) + .collect(); let count = new_files.len(); - ui.state.loaded_files.borrow_mut().extend(new_files); + loaded.extend(new_files); + drop(loaded); ui.toast_overlay.add_toast(adw::Toast::new( &format!("{} images added from file manager", count) )); @@ -1481,8 +1495,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) { output_row.add_prefix(>k::Image::from_icon_name("folder-open-symbolic")); let out_dir = entry.output_dir.clone(); output_row.connect_activated(move |_| { + let uri = gtk::gio::File::for_path(&out_dir).uri(); let _ = gtk::gio::AppInfo::launch_default_for_uri( - &format!("file://{}", out_dir), + &uri, gtk::gio::AppLaunchContext::NONE, ); }); @@ -1546,8 +1561,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) { open_btn.add_css_class("flat"); let out_dir2 = entry.output_dir.clone(); open_btn.connect_clicked(move |_| { + let uri = gtk::gio::File::for_path(&out_dir2).uri(); let _ = gtk::gio::AppInfo::launch_default_for_uri( - &format!("file://{}", out_dir2), + &uri, gtk::gio::AppLaunchContext::NONE, ); }); @@ -1674,10 +1690,31 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { return; } - let input_dir = files[0] - .parent() - .unwrap_or_else(|| std::path::Path::new(".")) - .to_path_buf(); + let input_dir = { + let first_parent = files[0] + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .to_path_buf(); + if files.len() == 1 { + first_parent + } else { + // Find common ancestor of all files + let mut common = first_parent.clone(); + for f in &files[1..] { + let p = f.parent().unwrap_or_else(|| std::path::Path::new(".")); + while !p.starts_with(&common) { + if !common.pop() { + break; + } + } + } + if common.as_os_str().is_empty() { + first_parent + } else { + common + } + } + }; let output_dir = ui .state @@ -1926,8 +1963,10 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { job.add_source(file); } - // Check for existing output files when "Ask" overwrite behavior is set - if ask_overwrite { + // 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(); + if ask_overwrite && !has_rename_or_convert { let output_dir = ui.state.output_dir.borrow().clone() .unwrap_or_else(|| { files[0].parent() @@ -1996,6 +2035,9 @@ fn continue_processing( ui.step_indicator.widget().set_visible(false); ui.title.set_subtitle("Processing..."); + // Disable navigation actions so Escape/shortcuts can't navigate away during processing + set_nav_actions_enabled(&ui.nav_view, false); + // Get references to progress widgets inside the page let progress_bar = find_widget_by_type::(&processing_page); let cancel_flag = Arc::new(AtomicBool::new(false)); @@ -2074,6 +2116,8 @@ fn continue_processing( ui_for_rx.toast_overlay.add_toast(toast); ui_for_rx.back_button.set_visible(true); ui_for_rx.next_button.set_visible(true); + // Re-enable navigation actions + set_nav_actions_enabled(&ui_for_rx.nav_view, true); if let Some(visible) = ui_for_rx.nav_view.visible_page() && visible.tag().as_deref() == Some("processing") { @@ -2117,6 +2161,9 @@ fn show_results( ui.next_button.set_label("Process More"); ui.next_button.set_visible(true); + // Re-enable navigation actions + set_nav_actions_enabled(&ui.nav_view, true); + // Save history with output paths for undo support let history = pixstrip_core::storage::HistoryStore::new(); let output_dir_str = ui.state.output_dir.borrow() @@ -2218,8 +2265,9 @@ fn show_results( if config.auto_open_output { let output = ui.state.output_dir.borrow().clone(); if let Some(dir) = output { + let uri = gtk::gio::File::for_path(&dir).uri(); let _ = gtk::gio::AppInfo::launch_default_for_uri( - &format!("file://{}", dir.display()), + &uri, gtk::gio::AppLaunchContext::NONE, ); } @@ -2306,8 +2354,9 @@ fn wire_results_actions( row.connect_activated(move |_| { let output = ui.state.output_dir.borrow().clone(); if let Some(dir) = output { + let uri = gtk::gio::File::for_path(&dir).uri(); let _ = gtk::gio::AppInfo::launch_default_for_uri( - &format!("file://{}", dir.display()), + &uri, gtk::gio::AppLaunchContext::NONE, ); } @@ -2453,6 +2502,22 @@ fn reset_wizard(ui: &WizardUi) { ui.next_button.add_css_class("suggested-action"); } +/// Enable or disable navigation actions (prev-step, next-step) to prevent +/// keyboard shortcuts from navigating away during processing. +fn set_nav_actions_enabled(nav_view: &adw::NavigationView, enabled: bool) { + if let Some(root) = nav_view.root() { + if let Ok(win) = root.downcast::() { + for name in ["prev-step", "next-step"] { + if let Some(action) = win.lookup_action(name) { + if let Some(simple) = action.downcast_ref::() { + simple.set_enabled(enabled); + } + } + } + } + } +} + fn wire_cancel_button(page: &adw::NavigationPage, cancel_flag: Arc) { walk_widgets(&page.child(), &|widget| { if let Some(button) = widget.downcast_ref::() diff --git a/pixstrip-gtk/src/settings.rs b/pixstrip-gtk/src/settings.rs index 1a0c6e7..89edd9e 100644 --- a/pixstrip-gtk/src/settings.rs +++ b/pixstrip-gtk/src/settings.rs @@ -317,13 +317,23 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { // Wire high contrast to apply immediately { + let original_theme: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new( + gtk::Settings::default().and_then(|s| s.gtk_theme_name()) + )); + let orig_theme = original_theme.clone(); contrast_row.connect_active_notify(move |row| { if let Some(settings) = gtk::Settings::default() { if row.is_active() { + // Capture current theme before switching (if not already captured) + let mut saved = orig_theme.borrow_mut(); + if saved.is_none() { + *saved = settings.gtk_theme_name(); + } settings.set_gtk_theme_name(Some("HighContrast")); } else { - // Revert to the default Adwaita theme - settings.set_gtk_theme_name(Some("Adwaita")); + let saved = orig_theme.borrow(); + settings.set_gtk_theme_name(saved.as_deref()); } } }); @@ -331,12 +341,16 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { // Wire large text to apply immediately { - let original_dpi: std::rc::Rc> = std::rc::Rc::new(Cell::new(0)); + // Capture the initial DPI at construction so we can restore it later + let initial_dpi = gtk::Settings::default() + .map(|s| s.gtk_xft_dpi()) + .unwrap_or(0); + let original_dpi: std::rc::Rc> = std::rc::Rc::new(Cell::new(initial_dpi)); let orig_dpi = original_dpi.clone(); large_text_row.connect_active_notify(move |row| { if let Some(settings) = gtk::Settings::default() { if row.is_active() { - // Store original DPI before modifying + // Store original DPI before modifying (refresh if not yet set) let current_dpi = settings.gtk_xft_dpi(); if current_dpi > 0 { orig_dpi.set(current_dpi); diff --git a/pixstrip-gtk/src/steps/step_adjustments.rs b/pixstrip-gtk/src/steps/step_adjustments.rs index 4ec63eb..11eb853 100644 --- a/pixstrip-gtk/src/steps/step_adjustments.rs +++ b/pixstrip-gtk/src/steps/step_adjustments.rs @@ -531,8 +531,11 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { }); } - // Shared debounce counter for slider-driven previews - let slider_debounce: Rc> = Rc::new(Cell::new(0)); + // 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 { @@ -540,7 +543,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { let row = brightness_row.clone(); let up = update_preview.clone(); let rst = brightness_reset.clone(); - let did = slider_debounce.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; @@ -570,7 +573,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { let row = contrast_row.clone(); let up = update_preview.clone(); let rst = contrast_reset.clone(); - let did = slider_debounce.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; @@ -600,7 +603,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { let row = saturation_row.clone(); let up = update_preview.clone(); let rst = saturation_reset.clone(); - let did = slider_debounce.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; @@ -670,7 +673,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { { let jc = state.job_config.clone(); let up = update_preview.clone(); - let did = slider_debounce.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(); diff --git a/pixstrip-gtk/src/steps/step_compress.rs b/pixstrip-gtk/src/steps/step_compress.rs index 4a6bb12..39e28c3 100644 --- a/pixstrip-gtk/src/steps/step_compress.rs +++ b/pixstrip-gtk/src/steps/step_compress.rs @@ -782,7 +782,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { .child(&scrolled) .build(); - // On page map: refresh preview and show/hide per-format rows + // On page map: refresh thumbnail strip, preview, and show/hide per-format rows { let up = update_preview.clone(); let jc = state.job_config.clone(); @@ -793,7 +793,71 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { let wer = webp_effort_row; let ar = avif_row; let asr = avif_speed_row; + let lf = state.loaded_files.clone(); + let tb = thumb_box.clone(); + let ts = thumb_scrolled.clone(); + let pidx = preview_index.clone(); + let up2 = update_preview.clone(); page.connect_map(move |_| { + // Rebuild thumbnail strip from current file list + while let Some(child) = tb.first_child() { + tb.remove(&child); + } + let files = lf.borrow(); + let max_thumbs = files.len().min(10); + for i in 0..max_thumbs { + let pic = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Cover) + .width_request(50) + .height_request(50) + .build(); + pic.set_filename(Some(&files[i])); + let frame = gtk::Frame::builder() + .child(&pic) + .build(); + if i == *pidx.borrow() { + frame.add_css_class("accent"); + } + let pidx_c = pidx.clone(); + let up_c = up2.clone(); + let tb_c = tb.clone(); + let current_idx = i; + let btn = gtk::Button::builder() + .child(&frame) + .has_frame(false) + .tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image")) + .build(); + btn.connect_clicked(move |_| { + *pidx_c.borrow_mut() = current_idx; + up_c(true); + let mut c = tb_c.first_child(); + let mut j = 0usize; + while let Some(w) = c { + if let Some(b) = w.downcast_ref::() { + if let Some(f) = b.child().and_then(|c| c.downcast::().ok()) { + if j == current_idx { + f.add_css_class("accent"); + } else { + f.remove_css_class("accent"); + } + } + } + c = w.next_sibling(); + j += 1; + } + }); + tb.append(&btn); + } + ts.set_visible(max_thumbs > 1); + // Clamp preview index if files were removed + { + let mut idx = pidx.borrow_mut(); + if *idx >= files.len() && !files.is_empty() { + *idx = 0; + } + } + drop(files); + up(true); let cfg = jc.borrow(); @@ -814,7 +878,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { Some(ImageFormat::Png) => has_png = true, Some(ImageFormat::WebP) => has_webp = true, Some(ImageFormat::Avif) => has_avif = true, - Some(ImageFormat::Gif) | Some(ImageFormat::Tiff) => {} + Some(ImageFormat::Gif) | Some(ImageFormat::Tiff) | Some(ImageFormat::Bmp) => {} } for (_, &choice_idx) in &cfg.format_mappings { diff --git a/pixstrip-gtk/src/steps/step_convert.rs b/pixstrip-gtk/src/steps/step_convert.rs index 978b60e..7aba909 100644 --- a/pixstrip-gtk/src/steps/step_convert.rs +++ b/pixstrip-gtk/src/steps/step_convert.rs @@ -17,7 +17,7 @@ const CARD_FORMATS: &[(&str, &str, &str, Option)] = &[ ("AVIF", "Next-gen format\nBest compression", "emblem-favorite-symbolic", Some(ImageFormat::Avif)), ("GIF", "Animations, 256 colors\nSimple graphics", "media-playback-start-symbolic", Some(ImageFormat::Gif)), ("TIFF", "Archival, lossless\nVery large files", "drive-harddisk-symbolic", Some(ImageFormat::Tiff)), - ("BMP", "Uncompressed bitmap\nLegacy format", "image-x-generic-symbolic", None), + ("BMP", "Uncompressed bitmap\nLegacy format", "image-x-generic-symbolic", Some(ImageFormat::Bmp)), ]; /// Extra formats available only in the "Other Formats" dropdown: (short name, dropdown label) @@ -338,6 +338,7 @@ fn card_index_for_format(format: Option) -> Option { Some(ImageFormat::Avif) => Some(4), Some(ImageFormat::Gif) => Some(5), Some(ImageFormat::Tiff) => Some(6), + Some(ImageFormat::Bmp) => Some(7), } } @@ -350,7 +351,8 @@ fn format_for_card_index(idx: usize) -> Option { 4 => Some(ImageFormat::Avif), 5 => Some(ImageFormat::Gif), 6 => Some(ImageFormat::Tiff), - _ => None, // 0 = Keep Original, 7 = BMP (not in enum) + 7 => Some(ImageFormat::Bmp), + _ => None, // 0 = Keep Original } } @@ -377,6 +379,9 @@ fn format_info(format: Option) -> String { Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, \ supports layers and rich metadata. Very large files. Not suitable for web." .into(), + Some(ImageFormat::Bmp) => "BMP: Uncompressed bitmap format. Very large files, no \ + compression. Legacy format mainly used for compatibility with older software." + .into(), } } diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index fd8df97..fd300f2 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -199,12 +199,21 @@ fn is_image_file(path: &std::path::Path) -> bool { } fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec) { + let existing: std::collections::HashSet = files.iter().cloned().collect(); + add_images_from_dir_inner(dir, files, &existing); +} + +fn add_images_from_dir_inner( + dir: &std::path::Path, + files: &mut Vec, + existing: &std::collections::HashSet, +) { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { - add_images_from_dir(&path, files); - } else if is_image_file(&path) && !files.contains(&path) { + add_images_from_dir_inner(&path, files, existing); + } else if is_image_file(&path) && !existing.contains(&path) && !files.contains(&path) { files.push(path); } } @@ -212,10 +221,11 @@ fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec) { } fn add_images_flat(dir: &std::path::Path, files: &mut Vec) { + let existing: std::collections::HashSet = files.iter().cloned().collect(); if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); - if path.is_file() && is_image_file(&path) && !files.contains(&path) { + if path.is_file() && is_image_file(&path) && !existing.contains(&path) { files.push(path); } } @@ -548,20 +558,32 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { let select_all_button = gtk::Button::builder() .icon_name("edit-select-all-symbolic") .tooltip_text("Select all images (Ctrl+A)") + .sensitive(false) .build(); select_all_button.add_css_class("flat"); + select_all_button.update_property(&[ + gtk::accessible::Property::Label("Select all images for processing"), + ]); let deselect_all_button = gtk::Button::builder() .icon_name("edit-clear-symbolic") .tooltip_text("Deselect all images (Ctrl+Shift+A)") + .sensitive(false) .build(); deselect_all_button.add_css_class("flat"); + deselect_all_button.update_property(&[ + gtk::accessible::Property::Label("Deselect all images from processing"), + ]); let clear_button = gtk::Button::builder() .icon_name("edit-clear-all-symbolic") .tooltip_text("Remove all images") + .sensitive(false) .build(); clear_button.add_css_class("flat"); + clear_button.update_property(&[ + gtk::accessible::Property::Label("Remove all images from list"), + ]); // Build the grid view model let store = gtk::gio::ListStore::new::(); @@ -835,6 +857,19 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { toolbar.append(&add_button); toolbar.append(&clear_button); + // Enable/disable toolbar buttons based on whether the store has items + { + let sa = select_all_button.clone(); + let da = deselect_all_button.clone(); + let cl = clear_button.clone(); + store.connect_items_changed(move |store, _, _, _| { + let has_items = store.n_items() > 0; + sa.set_sensitive(has_items); + da.set_sensitive(has_items); + cl.set_sensitive(has_items); + }); + } + toolbar.update_property(&[ gtk::accessible::Property::Label("Image toolbar with count, selection, and add controls"), ]); diff --git a/pixstrip-gtk/src/steps/step_output.rs b/pixstrip-gtk/src/steps/step_output.rs index 852e849..ad2e3f8 100644 --- a/pixstrip-gtk/src/steps/step_output.rs +++ b/pixstrip-gtk/src/steps/step_output.rs @@ -140,9 +140,117 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { scrolled.set_child(Some(&content)); - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title("Output & Process") .tag("step-output") .child(&scrolled) - .build() + .build(); + + // Refresh stats and summary when navigating to this page + { + let lf = state.loaded_files.clone(); + let ef = state.excluded_files.clone(); + let jc = state.job_config.clone(); + let od = state.output_dir.clone(); + let cr = count_row.clone(); + let or = output_row.clone(); + let sb = summary_box.clone(); + page.connect_map(move |_| { + // Update image count and size + let files = lf.borrow(); + let excluded = ef.borrow(); + let included_count = files.iter().filter(|p| !excluded.contains(*p)).count(); + let total_size: u64 = files.iter() + .filter(|p| !excluded.contains(*p)) + .filter_map(|p| std::fs::metadata(p).ok()) + .map(|m| m.len()) + .sum(); + cr.set_subtitle(&format!("{} images ({})", included_count, format_size(total_size))); + drop(files); + drop(excluded); + + // Update output directory display + let dir_text = od.borrow() + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "processed/ (subfolder next to originals)".to_string()); + or.set_subtitle(&dir_text); + + // Build operation summary + while let Some(child) = sb.first_child() { + sb.remove(&child); + } + + let cfg = jc.borrow(); + let mut ops: Vec<(&str, String)> = Vec::new(); + + if cfg.resize_enabled { + let mode = match cfg.resize_mode { + 0 => format!("{}x{} (exact)", cfg.resize_width, cfg.resize_height), + _ => format!("fit {}x{}", cfg.resize_width, cfg.resize_height), + }; + ops.push(("Resize", mode)); + } + if cfg.adjustments_enabled { + let mut parts = Vec::new(); + if cfg.rotation > 0 { parts.push("rotate"); } + if cfg.flip > 0 { parts.push("flip"); } + if cfg.brightness != 0 { parts.push("brightness"); } + if cfg.contrast != 0 { parts.push("contrast"); } + if cfg.saturation != 0 { parts.push("saturation"); } + if cfg.grayscale { parts.push("grayscale"); } + if cfg.sepia { parts.push("sepia"); } + if cfg.sharpen { parts.push("sharpen"); } + if cfg.crop_aspect_ratio > 0 { parts.push("crop"); } + if cfg.trim_whitespace { parts.push("trim"); } + if cfg.canvas_padding > 0 { parts.push("padding"); } + let desc = if parts.is_empty() { "enabled".into() } else { parts.join(", ") }; + ops.push(("Adjustments", desc)); + } + if cfg.convert_enabled { + let fmt = cfg.convert_format.map(|f| f.extension().to_uppercase()) + .unwrap_or_else(|| "per-format mapping".into()); + ops.push(("Convert", fmt)); + } + if cfg.compress_enabled { + ops.push(("Compress", cfg.quality_preset.label().into())); + } + if cfg.metadata_enabled { + let mode = match &cfg.metadata_mode { + crate::app::MetadataMode::StripAll => "strip all", + crate::app::MetadataMode::KeepAll => "keep all", + crate::app::MetadataMode::Privacy => "privacy mode", + crate::app::MetadataMode::Custom => "custom", + }; + ops.push(("Metadata", mode.into())); + } + if cfg.watermark_enabled { + let wm_type = if cfg.watermark_use_image { "image" } else { "text" }; + ops.push(("Watermark", wm_type.into())); + } + if cfg.rename_enabled { + ops.push(("Rename", "enabled".into())); + } + + if ops.is_empty() { + let row = adw::ActionRow::builder() + .title("No operations enabled") + .subtitle("Go back and enable at least one operation") + .build(); + row.add_prefix(>k::Image::from_icon_name("dialog-warning-symbolic")); + sb.append(&row); + } else { + for (name, desc) in &ops { + let row = adw::ActionRow::builder() + .title(*name) + .subtitle(desc.as_str()) + .build(); + row.add_prefix(>k::Image::from_icon_name("emblem-ok-symbolic")); + sb.append(&row); + } + } + }); + } + + page } diff --git a/pixstrip-gtk/src/steps/step_watermark.rs b/pixstrip-gtk/src/steps/step_watermark.rs index 3e74a29..0c89d71 100644 --- a/pixstrip-gtk/src/steps/step_watermark.rs +++ b/pixstrip-gtk/src/steps/step_watermark.rs @@ -210,6 +210,10 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { .height_request(48) .build(); + btn.update_property(&[ + gtk::accessible::Property::Label(&format!("Watermark position: {}", name)), + ]); + let icon = if i == cfg.watermark_position as usize { "radio-checked-symbolic" } else {