diff --git a/Cargo.lock b/Cargo.lock index 84dc2bd..32812c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1938,6 +1938,7 @@ dependencies = [ "image", "libadwaita", "pixstrip-core", + "regex", ] [[package]] diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index 2b5759c..c7d69b1 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -6,6 +6,7 @@ use pixstrip_core::pipeline::ProcessingJob; use pixstrip_core::preset::Preset; use pixstrip_core::storage::{HistoryStore, PresetStore}; use pixstrip_core::types::*; +use std::io::Write; use std::path::PathBuf; #[derive(Parser)] @@ -67,7 +68,7 @@ enum Commands { watermark_position: String, /// Watermark opacity (0.0-1.0) - #[arg(long, default_value = "0.5")] + #[arg(long, default_value = "0.5", value_parser = parse_opacity)] watermark_opacity: f32, /// Rename with prefix @@ -275,7 +276,10 @@ fn cmd_process(args: CmdProcessArgs) { .unwrap_or_else(|| input_dir.join("processed")); let mut job = if let Some(ref name) = args.preset { - let preset = find_preset(name); + let preset = find_preset(name).unwrap_or_else(|| { + eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name); + std::process::exit(1); + }); println!("Using preset: {}", preset.name); preset.to_job(&input_dir, &output_dir) } else { @@ -286,14 +290,12 @@ fn cmd_process(args: CmdProcessArgs) { if let Some(ref resize_str) = args.resize { job.resize = Some(parse_resize(resize_str)); } - if let Some(ref fmt_str) = args.format - && let Some(fmt) = parse_format(fmt_str) - { + if let Some(ref fmt_str) = args.format { + let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1)); job.convert = Some(ConvertConfig::SingleFormat(fmt)); } - if let Some(ref q_str) = args.quality - && let Some(preset) = parse_quality(q_str) - { + if let Some(ref q_str) = args.quality { + let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1)); job.compress = Some(CompressConfig::Preset(preset)); } if args.strip_metadata { @@ -320,13 +322,28 @@ fn cmd_process(args: CmdProcessArgs) { }); } if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() { + if let Some(ref tmpl) = args.rename_template { + if args.rename_prefix.is_some() || args.rename_suffix.is_some() { + eprintln!("Warning: --rename-template overrides --rename-prefix/--rename-suffix"); + } + if !tmpl.contains('{') { + eprintln!("Warning: rename template '{}' has no placeholders. Use {{name}}, {{counter}}, {{ext}}, etc.", tmpl); + } + if !tmpl.contains("{ext}") && !tmpl.contains('.') { + eprintln!("Warning: rename template has no {{ext}} or file extension - output files may lack extensions"); + } + } job.rename = Some(RenameConfig { prefix: args.rename_prefix.unwrap_or_default(), suffix: args.rename_suffix.unwrap_or_default(), counter_start: 1, counter_padding: 3, + counter_enabled: true, + counter_position: 3, template: args.rename_template, case_mode: 0, + replace_spaces: 0, + special_chars: 0, regex_find: String::new(), regex_replace: String::new(), }); @@ -336,13 +353,21 @@ fn cmd_process(args: CmdProcessArgs) { "catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom, "bilinear" => ResizeAlgorithm::Bilinear, "nearest" => ResizeAlgorithm::Nearest, - _ => ResizeAlgorithm::Lanczos3, + "lanczos3" | "lanczos" => ResizeAlgorithm::Lanczos3, + other => { + eprintln!("Warning: unknown algorithm '{}', using lanczos3. Valid: lanczos3, catmullrom, bilinear, nearest", other); + ResizeAlgorithm::Lanczos3 + } }; job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() { - "overwrite" | "always" => OverwriteBehavior::Overwrite, - "skip" => OverwriteBehavior::Skip, - _ => OverwriteBehavior::AutoRename, + "overwrite" | "always" => OverwriteAction::Overwrite, + "skip" => OverwriteAction::Skip, + "auto-rename" | "autorename" | "rename" => OverwriteAction::AutoRename, + other => { + eprintln!("Warning: unknown overwrite mode '{}', using auto-rename. Valid: auto-rename, overwrite, skip", other); + OverwriteAction::AutoRename + } }; for file in &source_files { @@ -357,6 +382,7 @@ fn cmd_process(args: CmdProcessArgs) { "\r[{}/{}] {}...", update.current, update.total, update.current_file ); + let _ = std::io::stderr().flush(); }) .unwrap_or_else(|e| { eprintln!("\nProcessing failed: {}", e); @@ -395,20 +421,39 @@ fn cmd_process(args: CmdProcessArgs) { // Save to history let history = HistoryStore::new(); + let output_ext = match job.convert { + Some(ConvertConfig::SingleFormat(fmt)) => fmt.extension(), + _ => "", + }; let output_files: Vec = source_files .iter() - .map(|f| { - output_dir - .join(f.file_name().unwrap_or_default()) - .to_string_lossy() - .into() + .enumerate() + .map(|(i, f)| { + let stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("file"); + let ext = if output_ext.is_empty() { + f.extension().and_then(|e| e.to_str()).unwrap_or("jpg") + } else { + output_ext + }; + let name = if let Some(ref rename) = job.rename { + if let Some(ref tmpl) = rename.template { + pixstrip_core::operations::rename::apply_template( + tmpl, stem, ext, rename.counter_start.saturating_add(i as u32), None, + ) + } else { + rename.apply_simple(stem, ext, (i as u32).saturating_add(1)) + } + } else { + format!("{}.{}", stem, ext) + }; + output_dir.join(name).to_string_lossy().into() }) .collect(); - let _ = history.add(pixstrip_core::storage::HistoryEntry { + if let Err(e) = 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(), + input_dir: input_dir.canonicalize().unwrap_or_else(|_| input_dir.to_path_buf()).to_string_lossy().into(), + output_dir: output_dir.canonicalize().unwrap_or_else(|_| output_dir.to_path_buf()).to_string_lossy().into(), preset_name: args.preset, total: result.total, succeeded: result.succeeded, @@ -417,7 +462,9 @@ fn cmd_process(args: CmdProcessArgs) { total_output_bytes: result.total_output_bytes, elapsed_ms: result.elapsed_ms, output_files, - }); + }, 50, 30) { + eprintln!("Warning: failed to save history (undo may not work): {}", e); + } } fn cmd_preset_list() { @@ -439,7 +486,10 @@ fn cmd_preset_list() { } fn cmd_preset_export(name: &str, output: &str) { - let preset = find_preset(name); + let preset = find_preset(name).unwrap_or_else(|| { + eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name); + std::process::exit(1); + }); let store = PresetStore::new(); match store.export_to_file(&preset, &PathBuf::from(output)) { Ok(()) => println!("Exported '{}' to '{}'", name, output), @@ -499,18 +549,23 @@ fn cmd_history() { } fn cmd_undo(count: usize) { + if count == 0 { + eprintln!("Must undo at least 1 batch"); + std::process::exit(1); + } let history = HistoryStore::new(); match history.list() { - Ok(entries) => { + Ok(mut entries) => { if entries.is_empty() { println!("No processing history to undo."); return; } - let to_undo = entries.iter().rev().take(count); + let undo_count = count.min(entries.len()); + let to_undo = entries.split_off(entries.len() - undo_count); let mut total_trashed = 0; - for entry in to_undo { + for entry in &to_undo { if entry.output_files.is_empty() { println!( "Batch from {} has no recorded output files - cannot undo", @@ -527,7 +582,6 @@ fn cmd_undo(count: usize) { for file_path in &entry.output_files { let path = PathBuf::from(file_path); if path.exists() { - // Move to OS trash using the trash crate match trash::delete(&path) { Ok(()) => { total_trashed += 1; @@ -540,6 +594,11 @@ fn cmd_undo(count: usize) { } } + // Remove undone entries from history + if let Err(e) = history.write_all(&entries) { + eprintln!("Warning: failed to update history after undo: {}", e); + } + println!("{} files moved to trash", total_trashed); } Err(e) => { @@ -551,12 +610,19 @@ fn cmd_undo(count: usize) { fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { // Verify the preset exists - let _preset = find_preset(preset_name); + if find_preset(preset_name).is_none() { + eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", preset_name); + std::process::exit(1); + } let watch_path = PathBuf::from(path); if !watch_path.exists() { eprintln!("Watch folder does not exist: {}", path); std::process::exit(1); } + if !watch_path.is_dir() { + eprintln!("Watch path is not a directory: {}", path); + std::process::exit(1); + } // Save watch folder config let watch = pixstrip_core::watcher::WatchFolder { @@ -568,7 +634,8 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { // Store in config let config_dir = dirs::config_dir() - .unwrap_or_else(|| PathBuf::from("~/.config")) + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) + .unwrap_or_else(|| std::env::temp_dir()) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); let mut watches: Vec = if watches_path.exists() { @@ -587,15 +654,27 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { } watches.push(watch); - let _ = std::fs::create_dir_all(&config_dir); - let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap()); + if let Err(e) = std::fs::create_dir_all(&config_dir) { + eprintln!("Warning: failed to create config directory: {}", e); + } + 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); + } + } + Err(e) => { + eprintln!("Warning: failed to serialize watch config: {}", e); + } + } println!("Added watch: {} -> preset '{}'", path, preset_name); } fn cmd_watch_list() { let config_dir = dirs::config_dir() - .unwrap_or_else(|| PathBuf::from("~/.config")) + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) + .unwrap_or_else(|| std::env::temp_dir()) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); @@ -630,7 +709,8 @@ fn cmd_watch_list() { fn cmd_watch_remove(path: &str) { let config_dir = dirs::config_dir() - .unwrap_or_else(|| PathBuf::from("~/.config")) + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) + .unwrap_or_else(|| std::env::temp_dir()) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); @@ -652,21 +732,33 @@ fn cmd_watch_remove(path: &str) { return; } - let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap()); + if let Ok(json) = serde_json::to_string_pretty(&watches) { + let _ = std::fs::write(&watches_path, json); + } println!("Removed watch folder: {}", path); } fn cmd_watch_start() { let config_dir = dirs::config_dir() - .unwrap_or_else(|| PathBuf::from("~/.config")) + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) + .unwrap_or_else(|| std::env::temp_dir()) .join("pixstrip"); let watches_path = config_dir.join("watches.json"); let watches: Vec = if watches_path.exists() { - std::fs::read_to_string(&watches_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() + match std::fs::read_to_string(&watches_path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(w) => w, + Err(e) => { + eprintln!("Warning: failed to parse watches.json: {}. Using empty config.", e); + Vec::new() + } + }, + Err(e) => { + eprintln!("Warning: failed to read watches.json: {}", e); + Vec::new() + } + } } else { Vec::new() }; @@ -700,9 +792,15 @@ fn cmd_watch_start() { match event { pixstrip_core::watcher::WatchEvent::NewImage(path) => { println!("New image: {}", path.display()); - // Find which watcher this came from and use its preset - if let Some((_, preset_name)) = watchers.first() { - let preset = find_preset(preset_name); + // Find which watcher owns this path and use its preset + let matched = active.iter() + .find(|w| path.starts_with(&w.path)) + .map(|w| w.preset_name.clone()); + if let Some(preset_name) = matched.as_deref().or_else(|| watchers.first().map(|(_, n)| n.as_str())) { + let Some(preset) = find_preset(preset_name) else { + eprintln!(" Preset '{}' not found, skipping", preset_name); + continue; + }; let input_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf(); let output_dir = input_dir.join("processed"); let mut job = preset.to_job(&input_dir, &output_dir); @@ -728,23 +826,29 @@ fn cmd_watch_start() { // --- Helpers --- -fn find_preset(name: &str) -> Preset { - // Check builtins first +fn find_preset(name: &str) -> Option { + // Check builtins first (case-insensitive) let lower = name.to_lowercase(); for preset in Preset::all_builtins() { if preset.name.to_lowercase() == lower { - return preset; + return Some(preset); } } - // Check user presets + // Check user presets - try exact match first, then case-insensitive let store = PresetStore::new(); if let Ok(preset) = store.load(name) { - return preset; + return Some(preset); + } + if let Ok(presets) = store.list() { + for preset in presets { + if preset.name.to_lowercase() == lower { + return Some(preset); + } + } } - eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name); - std::process::exit(1); + None } fn parse_resize(s: &str) -> ResizeConfig { @@ -757,12 +861,20 @@ fn parse_resize(s: &str) -> ResizeConfig { eprintln!("Invalid resize height: '{}'", h); std::process::exit(1); }); + if width == 0 || height == 0 { + eprintln!("Resize dimensions must be greater than zero"); + std::process::exit(1); + } ResizeConfig::Exact(Dimensions { width, height }) } else { let width: u32 = s.parse().unwrap_or_else(|_| { eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s); std::process::exit(1); }); + if width == 0 { + eprintln!("Resize width must be greater than zero"); + std::process::exit(1); + } ResizeConfig::ByWidth(width) } } @@ -840,6 +952,14 @@ fn parse_watermark_position(s: &str) -> WatermarkPosition { } } +fn parse_opacity(s: &str) -> std::result::Result { + let v: f32 = s.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?; + if !(0.0..=1.0).contains(&v) { + return Err("opacity must be between 0.0 and 1.0".into()); + } + Ok(v) +} + fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes) @@ -872,3 +992,88 @@ fn chrono_timestamp() -> String { .unwrap_or_default(); format!("{}", duration.as_secs()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_format_valid() { + assert_eq!(parse_format("jpeg"), Some(ImageFormat::Jpeg)); + assert_eq!(parse_format("jpg"), Some(ImageFormat::Jpeg)); + assert_eq!(parse_format("PNG"), Some(ImageFormat::Png)); + assert_eq!(parse_format("webp"), Some(ImageFormat::WebP)); + assert_eq!(parse_format("avif"), Some(ImageFormat::Avif)); + } + + #[test] + fn parse_format_invalid() { + assert_eq!(parse_format("bmp"), None); + assert_eq!(parse_format("xyz"), None); + } + + #[test] + fn parse_quality_valid() { + assert_eq!(parse_quality("maximum"), Some(QualityPreset::Maximum)); + assert_eq!(parse_quality("max"), Some(QualityPreset::Maximum)); + assert_eq!(parse_quality("high"), Some(QualityPreset::High)); + assert_eq!(parse_quality("medium"), Some(QualityPreset::Medium)); + assert_eq!(parse_quality("med"), Some(QualityPreset::Medium)); + assert_eq!(parse_quality("low"), Some(QualityPreset::Low)); + assert_eq!(parse_quality("web"), Some(QualityPreset::WebOptimized)); + } + + #[test] + fn parse_quality_invalid() { + assert_eq!(parse_quality("ultra"), None); + assert_eq!(parse_quality(""), None); + } + + #[test] + fn parse_opacity_valid() { + assert!(parse_opacity("0.5").is_ok()); + assert!(parse_opacity("0.0").is_ok()); + assert!(parse_opacity("1.0").is_ok()); + } + + #[test] + fn parse_opacity_invalid() { + assert!(parse_opacity("1.5").is_err()); + assert!(parse_opacity("-0.1").is_err()); + assert!(parse_opacity("abc").is_err()); + } + + #[test] + fn format_bytes_ranges() { + assert_eq!(format_bytes(500), "500 B"); + assert_eq!(format_bytes(1024), "1.0 KB"); + assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); + } + + #[test] + fn format_duration_ranges() { + assert_eq!(format_duration(500), "500ms"); + assert_eq!(format_duration(1500), "1.5s"); + assert_eq!(format_duration(90_000), "1m 30s"); + } + + #[test] + fn find_preset_builtins() { + assert!(find_preset("Blog Photos").is_some()); + assert!(find_preset("blog photos").is_some()); + assert!(find_preset("nonexistent preset xyz").is_none()); + } + + #[test] + fn parse_resize_width_only() { + let config = parse_resize("1200"); + assert!(matches!(config, ResizeConfig::ByWidth(1200))); + } + + #[test] + fn parse_resize_exact() { + let config = parse_resize("1200x900"); + assert!(matches!(config, ResizeConfig::Exact(Dimensions { width: 1200, height: 900 }))); + } +} diff --git a/pixstrip-core/src/discovery.rs b/pixstrip-core/src/discovery.rs index 6a7255b..91d5533 100644 --- a/pixstrip-core/src/discovery.rs +++ b/pixstrip-core/src/discovery.rs @@ -3,8 +3,7 @@ use std::path::{Path, PathBuf}; use walkdir::WalkDir; const IMAGE_EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp", "heic", "heif", "jxl", - "svg", "ico", + "jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp", ]; fn is_image_extension(ext: &str) -> bool { diff --git a/pixstrip-core/src/encoder.rs b/pixstrip-core/src/encoder.rs index 1e98a9e..89e658d 100644 --- a/pixstrip-core/src/encoder.rs +++ b/pixstrip-core/src/encoder.rs @@ -39,7 +39,7 @@ impl OutputEncoder { ) -> Result> { match format { ImageFormat::Jpeg => self.encode_jpeg(img, quality.unwrap_or(85)), - ImageFormat::Png => self.encode_png(img), + ImageFormat::Png => self.encode_png(img, quality.unwrap_or(3)), ImageFormat::WebP => self.encode_webp(img, quality.unwrap_or(80)), ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(80)), ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif), @@ -66,7 +66,7 @@ impl OutputEncoder { match format { ImageFormat::Jpeg => preset.jpeg_quality(), ImageFormat::WebP => preset.webp_quality() as u8, - ImageFormat::Avif => preset.webp_quality() as u8, + ImageFormat::Avif => preset.avif_quality() as u8, ImageFormat::Png => preset.png_level(), _ => preset.jpeg_quality(), } @@ -101,7 +101,10 @@ impl OutputEncoder { for y in 0..height { let start = y * row_stride; let end = start + row_stride; - let _ = started.write_scanlines(&pixels[start..end]); + started.write_scanlines(&pixels[start..end]).map_err(|e| PixstripError::Processing { + operation: "jpeg_scanline".into(), + reason: e.to_string(), + })?; } started.finish().map_err(|e| PixstripError::Processing { @@ -112,7 +115,7 @@ impl OutputEncoder { Ok(output) } - fn encode_png(&self, img: &image::DynamicImage) -> Result> { + fn encode_png(&self, img: &image::DynamicImage, level: u8) -> Result> { let mut buf = Vec::new(); let cursor = Cursor::new(&mut buf); let rgba = img.to_rgba8(); @@ -129,12 +132,16 @@ impl OutputEncoder { reason: e.to_string(), })?; - // Insert pHYs chunk for DPI if requested + // Insert pHYs chunk for DPI if requested (before oxipng, which preserves it) if self.options.output_dpi > 0 { buf = insert_png_phys_chunk(&buf, self.options.output_dpi); } - let optimized = oxipng::optimize_from_memory(&buf, &oxipng::Options::default()) + let mut opts = oxipng::Options::default(); + opts.optimize_alpha = true; + opts.deflater = oxipng::Deflater::Libdeflater { compression: level.clamp(1, 12) }; + + let optimized = oxipng::optimize_from_memory(&buf, &opts) .map_err(|e| PixstripError::Processing { operation: "png_optimize".into(), reason: e.to_string(), @@ -156,7 +163,7 @@ impl OutputEncoder { let mut buf = Vec::new(); let cursor = Cursor::new(&mut buf); let rgba = img.to_rgba8(); - let speed = self.options.avif_speed.clamp(1, 10); + let speed = self.options.avif_speed.clamp(0, 10); let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality( cursor, speed, @@ -226,6 +233,7 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec { // PNG structure: 8-byte signature, then chunks (each: 4 len + 4 type + data + 4 crc) let mut result = Vec::with_capacity(png_data.len() + phys_chunk.len()); let mut pos = 8; // skip PNG signature + let mut phys_inserted = false; result.extend_from_slice(&png_data[..8]); while pos + 8 <= png_data.len() { @@ -235,16 +243,20 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec { let chunk_type = &png_data[pos + 4..pos + 8]; let total_chunk_size = 4 + 4 + chunk_len + 4; // len + type + data + crc - if chunk_type == b"IDAT" || chunk_type == b"pHYs" { - if chunk_type == b"IDAT" { - // Insert pHYs before first IDAT - result.extend_from_slice(&phys_chunk); - } - // If existing pHYs, skip it (we're replacing it) - if chunk_type == b"pHYs" { - pos += total_chunk_size; - continue; - } + if pos + total_chunk_size > png_data.len() { + break; + } + + // Skip any existing pHYs (we're replacing it) + if chunk_type == b"pHYs" { + pos += total_chunk_size; + continue; + } + + // Insert our pHYs before the first IDAT + if chunk_type == b"IDAT" && !phys_inserted { + result.extend_from_slice(&phys_chunk); + phys_inserted = true; } result.extend_from_slice(&png_data[pos..pos + total_chunk_size]); diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index 0205a07..109547c 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -321,6 +321,79 @@ impl PipelineExecutor { .map(|m| m.len()) .unwrap_or(0); + // Fast path: if no pixel processing needed (rename-only or rename+metadata), + // just copy the file instead of decoding/re-encoding. + if !job.needs_pixel_processing() { + let output_path = if let Some(ref rename) = job.rename { + let stem = source.path.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); + let ext = source.path.extension().and_then(|e| e.to_str()).unwrap_or("jpg"); + if let Some(ref template) = rename.template { + // Read dimensions without full decode for {width}/{height} templates + let dims = image::ImageReader::open(&source.path) + .ok() + .and_then(|r| r.with_guessed_format().ok()) + .and_then(|r| r.into_dimensions().ok()); + let new_name = crate::operations::rename::apply_template_full( + template, stem, ext, + rename.counter_start.saturating_add(index as u32), + dims, Some(ext), Some(&source.path), None, + ); + let new_name = if rename.case_mode > 0 { + if let Some(dot_pos) = new_name.rfind('.') { + let (name_part, ext_part) = new_name.split_at(dot_pos); + format!("{}{}", crate::operations::rename::apply_case_conversion(name_part, rename.case_mode), ext_part) + } else { + crate::operations::rename::apply_case_conversion(&new_name, rename.case_mode) + } + } else { + new_name + }; + job.output_dir.join(new_name) + } else { + let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1)); + job.output_dir.join(new_name) + } + } else { + job.output_path_for(source, None) + }; + + let output_path = match job.overwrite_behavior { + crate::operations::OverwriteAction::Skip if output_path.exists() => { + return Ok((input_size, 0)); + } + crate::operations::OverwriteAction::AutoRename if output_path.exists() => { + find_unique_path(&output_path) + } + _ => output_path, + }; + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; + } + + std::fs::copy(&source.path, &output_path).map_err(PixstripError::Io)?; + + // Metadata handling on the copy + if let Some(ref meta_config) = job.metadata { + match meta_config { + crate::operations::MetadataConfig::KeepAll => { + // Already a copy - metadata preserved + } + crate::operations::MetadataConfig::StripAll => { + if !strip_all_metadata(&output_path) { + eprintln!("Warning: failed to strip metadata from {}", output_path.display()); + } + } + _ => { + strip_selective_metadata(&output_path, meta_config); + } + } + } + + let output_size = std::fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0); + return Ok((input_size, output_size)); + } + // Load image let mut img = loader.load_pixels(&source.path)?; @@ -404,7 +477,7 @@ impl PipelineExecutor { template, &working_stem, ext, - rename.counter_start + index as u32, + rename.counter_start.saturating_add(index as u32), dims, original_ext, Some(&source.path), @@ -423,7 +496,7 @@ impl PipelineExecutor { }; job.output_dir.join(new_name) } else { - let new_name = rename.apply_simple(stem, ext, index as u32 + 1); + let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1)); job.output_dir.join(new_name) } } else { @@ -432,21 +505,21 @@ impl PipelineExecutor { // Handle overwrite behavior let output_path = match job.overwrite_behavior { - crate::operations::OverwriteBehavior::Skip => { + crate::operations::OverwriteAction::Skip => { if output_path.exists() { // Return 0 bytes written - file was skipped return Ok((input_size, 0)); } output_path } - crate::operations::OverwriteBehavior::AutoRename => { + crate::operations::OverwriteAction::AutoRename => { if output_path.exists() { find_unique_path(&output_path) } else { output_path } } - crate::operations::OverwriteBehavior::Overwrite => output_path, + crate::operations::OverwriteAction::Overwrite => output_path, }; // Ensure output directory exists @@ -469,14 +542,18 @@ impl PipelineExecutor { if let Some(ref meta_config) = job.metadata { match meta_config { crate::operations::MetadataConfig::KeepAll => { - copy_metadata_from_source(&source.path, &output_path); + if !copy_metadata_from_source(&source.path, &output_path) { + eprintln!("Warning: failed to copy metadata to {}", output_path.display()); + } } crate::operations::MetadataConfig::StripAll => { // Already stripped by re-encoding - nothing to do } _ => { // Privacy or Custom: copy all metadata back, then strip unwanted tags - copy_metadata_from_source(&source.path, &output_path); + 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); } } @@ -537,9 +614,9 @@ fn auto_orient_from_exif( 2 => img.fliph(), // Flipped horizontal 3 => img.rotate180(), // Rotated 180 4 => img.flipv(), // Flipped vertical - 5 => img.fliph().rotate270(), // Transposed + 5 => img.rotate90().fliph(), // Transposed 6 => img.rotate90(), // Rotated 90 CW - 7 => img.fliph().rotate90(), // Transverse + 7 => img.rotate270().fliph(), // Transverse 8 => img.rotate270(), // Rotated 270 CW _ => img, } @@ -569,25 +646,28 @@ fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf { .unwrap_or(0), ext)) } -fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) { - // Best-effort: try to copy EXIF from source to output using little_exif. - // If it fails (e.g. non-JPEG, no EXIF), silently continue. +fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) -> bool { let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(source) else { - return; + return false; }; - let _: std::result::Result<(), std::io::Error> = metadata.write_to_file(output); + metadata.write_to_file(output).is_ok() +} + +fn strip_all_metadata(path: &std::path::Path) -> bool { + let empty = little_exif::metadata::Metadata::new(); + empty.write_to_file(path).is_ok() } fn strip_selective_metadata( path: &std::path::Path, config: &crate::operations::MetadataConfig, -) { +) -> bool { use little_exif::exif_tag::ExifTag; use little_exif::metadata::Metadata; // Read the metadata we just wrote back let Ok(source_meta) = Metadata::new_from_path(path) else { - return; + return false; }; // Build a set of tag IDs to strip @@ -639,5 +719,5 @@ fn strip_selective_metadata( } } - let _: std::result::Result<(), std::io::Error> = new_meta.write_to_file(path); + new_meta.write_to_file(path).is_ok() } diff --git a/pixstrip-core/src/fm_integration.rs b/pixstrip-core/src/fm_integration.rs index b53fcb4..32d2047 100644 --- a/pixstrip-core/src/fm_integration.rs +++ b/pixstrip-core/src/fm_integration.rs @@ -76,6 +76,14 @@ pub fn regenerate_all() -> Result<()> { Ok(()) } +/// Sanitize a string for safe use in shell Exec= lines and XML command elements. +/// Removes or replaces characters that could cause shell injection. +fn shell_safe(s: &str) -> String { + s.chars() + .filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '(' | ')' | ',')) + .collect() +} + fn pixstrip_bin() -> String { // Try to find the pixstrip binary path if let Ok(exe) = std::env::current_exe() { @@ -116,7 +124,7 @@ fn get_preset_names() -> Vec { fn nautilus_extension_dir() -> PathBuf { let data = std::env::var("XDG_DATA_HOME") .unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_else(|_| "~".into()); + let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.local/share", home) }); PathBuf::from(data).join("nautilus-python").join("extensions") @@ -143,11 +151,12 @@ fn install_nautilus() -> Result<()> { \x20 item.connect('activate', self._on_preset, '{}', files)\n\ \x20 submenu.append_item(item)\n\n", name.replace(' ', "_"), - name, - name, + name.replace('\'', "\\'"), + name.replace('\'', "\\'"), )); } + let escaped_bin = bin.replace('\\', "\\\\").replace('\'', "\\'"); let script = format!( r#"import subprocess from gi.repository import Nautilus, GObject @@ -202,7 +211,7 @@ class PixstripExtension(GObject.GObject, Nautilus.MenuProvider): subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths) "#, preset_items = preset_items, - bin = bin, + bin = escaped_bin, ); std::fs::write(nautilus_extension_path(), script)?; @@ -222,7 +231,7 @@ fn uninstall_nautilus() -> Result<()> { fn nemo_action_dir() -> PathBuf { let data = std::env::var("XDG_DATA_HOME") .unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_else(|_| "~".into()); + let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.local/share", home) }); PathBuf::from(data).join("nemo").join("actions") @@ -261,12 +270,13 @@ fn install_nemo() -> Result<()> { "[Nemo Action]\n\ Name=Pixstrip: {name}\n\ Comment=Process with {name} preset\n\ - Exec={bin} --preset \"{name}\" --files %F\n\ + Exec={bin} --preset \"{safe_label}\" --files %F\n\ Icon-Name=applications-graphics-symbolic\n\ Selection=Any\n\ Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\ Mimetypes=image/*;\n", name = name, + safe_label = shell_safe(name), bin = bin, ); std::fs::write(action_path, action)?; @@ -300,7 +310,7 @@ fn uninstall_nemo() -> Result<()> { fn thunar_action_dir() -> PathBuf { let config = std::env::var("XDG_CONFIG_HOME") .unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_else(|_| "~".into()); + let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.config", home) }); PathBuf::from(config).join("Thunar") @@ -337,14 +347,15 @@ fn install_thunar() -> Result<()> { actions.push_str(&format!( " \n\ \x20 applications-graphics-symbolic\n\ - \x20 Pixstrip: {name}\n\ - \x20 {bin} --preset \"{name}\" --files %F\n\ - \x20 Process with {name} preset\n\ + \x20 Pixstrip: {xml_name}\n\ + \x20 {bin} --preset \"{safe_label}\" --files %F\n\ + \x20 Process with {xml_name} preset\n\ \x20 *.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp\n\ \x20 \n\ \x20 \n\ \n", - name = name, + xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), + safe_label = shell_safe(name), bin = bin, )); } @@ -367,7 +378,7 @@ fn uninstall_thunar() -> Result<()> { fn dolphin_service_dir() -> PathBuf { let data = std::env::var("XDG_DATA_HOME") .unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_else(|_| "~".into()); + let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.local/share", home) }); PathBuf::from(data).join("kio").join("servicemenus") @@ -410,9 +421,10 @@ fn install_dolphin() -> Result<()> { "[Desktop Action Preset{i}]\n\ Name={name}\n\ Icon=applications-graphics-symbolic\n\ - Exec={bin} --preset \"{name}\" --files %F\n\n", + Exec={bin} --preset \"{safe_label}\" --files %F\n\n", i = i, name = name, + safe_label = shell_safe(name), bin = bin, )); } diff --git a/pixstrip-core/src/operations/adjustments.rs b/pixstrip-core/src/operations/adjustments.rs index c1d7e0c..cdc0dd6 100644 --- a/pixstrip-core/src/operations/adjustments.rs +++ b/pixstrip-core/src/operations/adjustments.rs @@ -57,21 +57,27 @@ pub fn apply_adjustments( fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage { let (iw, ih) = (img.width(), img.height()); + if !w_ratio.is_finite() || !h_ratio.is_finite() + || w_ratio <= 0.0 || h_ratio <= 0.0 + || iw == 0 || ih == 0 + { + return img; + } let target_ratio = w_ratio / h_ratio; let current_ratio = iw as f64 / ih as f64; let (crop_w, crop_h) = if current_ratio > target_ratio { // Image is wider than target, crop width let new_w = (ih as f64 * target_ratio) as u32; - (new_w, ih) + (new_w.min(iw), ih) } else { // Image is taller than target, crop height let new_h = (iw as f64 / target_ratio) as u32; - (iw, new_h) + (iw, new_h.min(ih)) }; - let x = (iw - crop_w) / 2; - let y = (ih - crop_h) / 2; + let x = iw.saturating_sub(crop_w) / 2; + let y = ih.saturating_sub(crop_h) / 2; img.crop_imm(x, y, crop_w, crop_h) } @@ -140,8 +146,8 @@ fn trim_whitespace(img: DynamicImage) -> DynamicImage { } } - let crop_w = right.saturating_sub(left) + 1; - let crop_h = bottom.saturating_sub(top) + 1; + let crop_w = right.saturating_sub(left).saturating_add(1); + let crop_h = bottom.saturating_sub(top).saturating_add(1); if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) { return img; @@ -151,6 +157,7 @@ fn trim_whitespace(img: DynamicImage) -> DynamicImage { } fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage { + let amount = amount.clamp(-100, 100); let mut rgba = img.into_rgba8(); let factor = 1.0 + (amount as f64 / 100.0); @@ -187,8 +194,8 @@ fn apply_sepia(img: DynamicImage) -> DynamicImage { fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage { let (w, h) = (img.width(), img.height()); - let new_w = w + padding * 2; - let new_h = h + padding * 2; + let new_w = w.saturating_add(padding.saturating_mul(2)); + let new_h = h.saturating_add(padding.saturating_mul(2)); let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255])); diff --git a/pixstrip-core/src/operations/metadata.rs b/pixstrip-core/src/operations/metadata.rs index 4a48b89..13305a2 100644 --- a/pixstrip-core/src/operations/metadata.rs +++ b/pixstrip-core/src/operations/metadata.rs @@ -33,7 +33,11 @@ pub fn strip_metadata( fn strip_all_exif(input: &Path, output: &Path) -> Result<()> { let data = std::fs::read(input).map_err(PixstripError::Io)?; - let cleaned = remove_exif_from_jpeg(&data); + let cleaned = if data.len() >= 8 && &data[..8] == b"\x89PNG\r\n\x1a\n" { + remove_metadata_from_png(&data) + } else { + remove_exif_from_jpeg(&data) + }; std::fs::write(output, cleaned).map_err(PixstripError::Io)?; Ok(()) } @@ -60,7 +64,12 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec { // APP1 (0xE1) contains EXIF - skip it if marker == 0xE1 && i + 3 < data.len() { let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize); - i += 2 + len; + let skip = 2 + len; + if i + skip <= data.len() { + i += skip; + } else { + break; + } continue; } @@ -89,3 +98,41 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec { result } + +fn remove_metadata_from_png(data: &[u8]) -> Vec { + // PNG: 8-byte signature + chunks (4 len + 4 type + data + 4 CRC) + // Keep only rendering-essential chunks, strip textual/EXIF metadata. + const KEEP_CHUNKS: &[&[u8; 4]] = &[ + b"IHDR", b"PLTE", b"IDAT", b"IEND", + b"tRNS", b"gAMA", b"cHRM", b"sRGB", b"iCCP", b"sBIT", + b"pHYs", b"bKGD", b"hIST", b"sPLT", + b"acTL", b"fcTL", b"fdAT", + ]; + + if data.len() < 8 { + return data.to_vec(); + } + + let mut result = Vec::with_capacity(data.len()); + result.extend_from_slice(&data[..8]); // PNG signature + + let mut pos = 8; + while pos + 12 <= data.len() { + let chunk_len = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize; + let chunk_type = &data[pos + 4..pos + 8]; + let total = 12 + chunk_len; // 4 len + 4 type + data + 4 CRC + + if pos + total > data.len() { + break; + } + + let keep = KEEP_CHUNKS.iter().any(|k| *k == chunk_type); + if keep { + result.extend_from_slice(&data[pos..pos + total]); + } + + pos += total; + } + + result +} diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index e2e722c..cfc7151 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -23,25 +23,43 @@ pub enum ResizeConfig { impl ResizeConfig { pub fn target_for(&self, original: Dimensions) -> Dimensions { - match self { + if original.width == 0 || original.height == 0 { + return original; + } + let result = match self { Self::ByWidth(w) => { + if *w == 0 { + return original; + } let scale = *w as f64 / original.width as f64; Dimensions { width: *w, - height: (original.height as f64 * scale).round() as u32, + height: (original.height as f64 * scale).round().max(1.0) as u32, } } Self::ByHeight(h) => { + if *h == 0 { + return original; + } let scale = *h as f64 / original.height as f64; Dimensions { - width: (original.width as f64 * scale).round() as u32, + width: (original.width as f64 * scale).round().max(1.0) as u32, height: *h, } } Self::FitInBox { max, allow_upscale } => { original.fit_within(*max, *allow_upscale) } - Self::Exact(dims) => *dims, + Self::Exact(dims) => { + if dims.width == 0 || dims.height == 0 { + return original; + } + *dims + } + }; + Dimensions { + width: result.width.max(1), + height: result.height.max(1), } } } @@ -224,6 +242,7 @@ pub enum WatermarkRotation { Degrees45, DegreesNeg45, Degrees90, + Custom(f32), } // --- Adjustments --- @@ -255,16 +274,16 @@ impl AdjustmentsConfig { } } -// --- Overwrite Behavior --- +// --- Overwrite Action (concrete action, no "Ask" variant) --- #[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub enum OverwriteBehavior { +pub enum OverwriteAction { AutoRename, Overwrite, Skip, } -impl Default for OverwriteBehavior { +impl Default for OverwriteAction { fn default() -> Self { Self::AutoRename } @@ -278,39 +297,85 @@ pub struct RenameConfig { pub suffix: String, pub counter_start: u32, pub counter_padding: u32, + #[serde(default)] + pub counter_enabled: bool, + /// 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name + #[serde(default = "default_counter_position")] + pub counter_position: u32, pub template: Option, /// 0=none, 1=lowercase, 2=uppercase, 3=title case pub case_mode: u32, + /// 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove + #[serde(default)] + pub replace_spaces: u32, + /// 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only + #[serde(default)] + pub special_chars: u32, pub regex_find: String, pub regex_replace: String, } +fn default_counter_position() -> u32 { 3 } + impl RenameConfig { pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String { - let counter = self.counter_start + index - 1; - let counter_str = format!( - "{:0>width$}", - counter, - width = self.counter_padding as usize - ); - - // Apply regex find-and-replace on the original name + // 1. Apply regex find-and-replace on the original name let working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace); - let mut name = String::new(); - if !self.prefix.is_empty() { - name.push_str(&self.prefix); - } - name.push_str(&working_name); - if !self.suffix.is_empty() { - name.push_str(&self.suffix); - } - name.push('_'); - name.push_str(&counter_str); + // 2. Apply space replacement + let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces); - // Apply case conversion - let name = rename::apply_case_conversion(&name, self.case_mode); + // 3. Apply special character filtering + let working_name = rename::apply_special_chars(&working_name, self.special_chars); - format!("{}.{}", name, extension) + // 4. Build counter string + let counter_str = if self.counter_enabled { + let counter = self.counter_start.saturating_add(index.saturating_sub(1)); + let padding = (self.counter_padding as usize).min(10); + format!("{:0>width$}", counter, width = padding) + } else { + String::new() + }; + + let has_counter = self.counter_enabled && !counter_str.is_empty(); + + // 5. Assemble parts based on counter position + // Positions: 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name + let mut result = String::new(); + + if has_counter && self.counter_position == 0 { + result.push_str(&counter_str); + result.push('_'); + } + + result.push_str(&self.prefix); + + if has_counter && self.counter_position == 1 { + result.push_str(&counter_str); + result.push('_'); + } + + if has_counter && self.counter_position == 4 { + result.push_str(&counter_str); + } else { + result.push_str(&working_name); + } + + if has_counter && self.counter_position == 2 { + result.push('_'); + result.push_str(&counter_str); + } + + result.push_str(&self.suffix); + + if has_counter && self.counter_position == 3 { + result.push('_'); + result.push_str(&counter_str); + } + + // 6. Apply case conversion + let result = rename::apply_case_conversion(&result, self.case_mode); + + format!("{}.{}", result, extension) } } diff --git a/pixstrip-core/src/operations/rename.rs b/pixstrip-core/src/operations/rename.rs index fe0e686..2bcbd28 100644 --- a/pixstrip-core/src/operations/rename.rs +++ b/pixstrip-core/src/operations/rename.rs @@ -122,6 +122,9 @@ fn days_to_ymd(total_days: u64) -> (u64, u64, u64) { let mut days = total_days; let mut year = 1970u64; loop { + if year > 9999 { + break; + } let days_in_year = if is_leap(year) { 366 } else { 365 }; if days < days_in_year { break; @@ -149,6 +152,46 @@ fn is_leap(year: u64) -> bool { (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } +/// Apply space replacement on a filename +/// mode: 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove +pub fn apply_space_replacement(name: &str, mode: u32) -> String { + match mode { + 1 => name.replace(' ', "_"), + 2 => name.replace(' ', "-"), + 3 => name.replace(' ', "."), + 4 => { + let mut result = String::with_capacity(name.len()); + let mut capitalize_next = false; + for ch in name.chars() { + if ch == ' ' { + capitalize_next = true; + } else if capitalize_next { + result.extend(ch.to_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + } + } + result + } + 5 => name.replace(' ', ""), + _ => name.to_string(), + } +} + +/// Apply special character filtering on a filename +/// mode: 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only +pub fn apply_special_chars(name: &str, mode: u32) -> String { + match mode { + 1 => name.chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')).collect(), + 2 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(), + 3 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(), + 4 => name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect(), + 5 => name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(), + _ => name.to_string(), + } +} + /// Apply case conversion to a filename (without extension) /// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case pub fn apply_case_conversion(name: &str, case_mode: u32) -> String { @@ -156,20 +199,25 @@ pub fn apply_case_conversion(name: &str, case_mode: u32) -> String { 1 => name.to_lowercase(), 2 => name.to_uppercase(), 3 => { - // Title case: capitalize first letter of each word (split on _ - space) - name.split(|c: char| c == '_' || c == '-' || c == ' ') - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - Some(first) => { - let upper: String = first.to_uppercase().collect(); - upper + &chars.as_str().to_lowercase() - } - None => String::new(), + // Title case: capitalize first letter of each word, preserve original separators + let mut result = String::with_capacity(name.len()); + let mut capitalize_next = true; + for c in name.chars() { + if c == '_' || c == '-' || c == ' ' { + result.push(c); + capitalize_next = true; + } else if capitalize_next { + for uc in c.to_uppercase() { + result.push(uc); } - }) - .collect::>() - .join("_") + capitalize_next = false; + } else { + for lc in c.to_lowercase() { + result.push(lc); + } + } + } + result } _ => name.to_string(), } @@ -180,7 +228,10 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String { if find.is_empty() { return name.to_string(); } - match regex::Regex::new(find) { + match regex::RegexBuilder::new(find) + .size_limit(1 << 16) + .build() + { Ok(re) => re.replace_all(name, replace).into_owned(), Err(_) => name.to_string(), } @@ -213,5 +264,9 @@ pub fn resolve_collision(path: &Path) -> PathBuf { } // Fallback - should never happen with 1000 attempts - parent.join(format!("{}_{}.{}", stem, "overflow", ext)) + if ext.is_empty() { + parent.join(format!("{}_overflow", stem)) + } else { + parent.join(format!("{}_overflow.{}", stem, ext)) + } } diff --git a/pixstrip-core/src/operations/watermark.rs b/pixstrip-core/src/operations/watermark.rs index dec262f..629bad3 100644 --- a/pixstrip-core/src/operations/watermark.rs +++ b/pixstrip-core/src/operations/watermark.rs @@ -19,8 +19,8 @@ pub fn calculate_position( let center_x = iw.saturating_sub(ww) / 2; let center_y = ih.saturating_sub(wh) / 2; - let right_x = iw.saturating_sub(ww + margin); - let bottom_y = ih.saturating_sub(wh + margin); + let right_x = iw.saturating_sub(ww).saturating_sub(margin); + let bottom_y = ih.saturating_sub(wh).saturating_sub(margin); match position { WatermarkPosition::TopLeft => (margin, margin), @@ -69,7 +69,7 @@ pub fn apply_watermark( if *tiled { apply_tiled_image_watermark(img, path, *opacity, *scale, *rotation, *margin) } else { - apply_image_watermark(img, path, *position, *opacity, *scale, *rotation) + apply_image_watermark(img, path, *position, *opacity, *scale, *rotation, *margin) } } } @@ -128,20 +128,29 @@ fn find_system_font(family: Option<&str>) -> Result> { }) } -/// Recursively walk a directory and collect file paths +/// Recursively walk a directory and collect file paths (max depth 5) fn walkdir(dir: &std::path::Path) -> std::io::Result> { + walkdir_depth(dir, 5) +} + +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 dir.is_dir() { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - if let Ok(sub) = walkdir(&path) { - results.extend(sub); - } - } else { - results.push(path); + if max_depth == 0 || !dir.is_dir() { + return Ok(results); + } + for entry in std::fs::read_dir(dir)? { + if results.len() >= MAX_RESULTS { + break; + } + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Ok(sub) = walkdir_depth(&path, max_depth - 1) { + results.extend(sub); } + } else { + results.push(path); } } Ok(results) @@ -156,8 +165,8 @@ fn render_text_to_image( opacity: f32, ) -> image::RgbaImage { let scale = ab_glyph::PxScale::from(font_size); - let text_width = (text.len() as f32 * font_size * 0.6) as u32 + 4; - let text_height = (font_size * 1.4) as u32 + 4; + let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192); + 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; let draw_color = Rgba([color[0], color[1], color[2], alpha]); @@ -167,6 +176,30 @@ fn render_text_to_image( buf } +/// Expand canvas so rotated content fits without clipping, then rotate +fn rotate_on_expanded_canvas(img: &image::RgbaImage, radians: f32) -> DynamicImage { + let w = img.width() as f32; + let h = img.height() as f32; + let cos = radians.abs().cos(); + let sin = radians.abs().sin(); + // Bounding box of rotated rectangle + let new_w = (w * cos + h * sin).ceil() as u32 + 2; + let new_h = (w * sin + h * cos).ceil() as u32 + 2; + + // Place original in center of expanded transparent canvas + let mut expanded = image::RgbaImage::new(new_w, new_h); + let ox = (new_w.saturating_sub(img.width())) / 2; + let oy = (new_h.saturating_sub(img.height())) / 2; + image::imageops::overlay(&mut expanded, img, ox as i64, oy as i64); + + imageproc::geometric_transformations::rotate_about_center( + &expanded, + radians, + imageproc::geometric_transformations::Interpolation::Bilinear, + Rgba([0, 0, 0, 0]), + ).into() +} + /// Rotate an RGBA image by the given WatermarkRotation fn rotate_watermark_image( img: DynamicImage, @@ -175,20 +208,13 @@ fn rotate_watermark_image( match rotation { super::WatermarkRotation::Degrees90 => img.rotate90(), super::WatermarkRotation::Degrees45 => { - imageproc::geometric_transformations::rotate_about_center( - &img.to_rgba8(), - std::f32::consts::FRAC_PI_4, - imageproc::geometric_transformations::Interpolation::Bilinear, - Rgba([0, 0, 0, 0]), - ).into() + rotate_on_expanded_canvas(&img.to_rgba8(), std::f32::consts::FRAC_PI_4) } super::WatermarkRotation::DegreesNeg45 => { - imageproc::geometric_transformations::rotate_about_center( - &img.to_rgba8(), - -std::f32::consts::FRAC_PI_4, - imageproc::geometric_transformations::Interpolation::Bilinear, - Rgba([0, 0, 0, 0]), - ).into() + rotate_on_expanded_canvas(&img.to_rgba8(), -std::f32::consts::FRAC_PI_4) + } + super::WatermarkRotation::Custom(degrees) => { + rotate_on_expanded_canvas(&img.to_rgba8(), degrees.to_radians()) } } } @@ -204,6 +230,9 @@ fn apply_text_watermark( rotation: Option, margin_px: u32, ) -> Result { + if text.is_empty() { + return Ok(img); + } let font_data = find_system_font(font_family)?; let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| { PixstripError::Processing { @@ -234,8 +263,8 @@ fn apply_text_watermark( } else { // No rotation - draw text directly (faster) let scale = ab_glyph::PxScale::from(font_size); - let text_width = (text.len() as f32 * font_size * 0.6) as u32; - let text_height = font_size as u32; + let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192); + let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096); let text_dims = Dimensions { width: text_width, height: text_height, @@ -266,6 +295,9 @@ fn apply_tiled_text_watermark( rotation: Option, margin: u32, ) -> Result { + if text.is_empty() { + return Ok(img); + } let font_data = find_system_font(font_family)?; let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| { PixstripError::Processing { @@ -301,17 +333,17 @@ 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() as f32 * font_size * 0.6) as u32; - let text_height = font_size as u32; + let text_width = ((text.len().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 i32; - while y < ih as i32 { - let mut x = spacing as i32; - while x < iw as i32 { - draw_text_mut(&mut rgba, draw_color, x, y, scale, &font, text); - x += text_width as i32 + spacing as i32; + let mut y = spacing as i64; + while y < ih as i64 { + let mut x = spacing as i64; + 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; } - y += text_height as i32 + spacing as i32; + y += text_height + spacing as i64; } } @@ -390,6 +422,7 @@ fn apply_image_watermark( opacity: f32, scale: f32, rotation: Option, + margin: u32, ) -> Result { let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing { operation: "watermark".into(), @@ -423,7 +456,6 @@ fn apply_image_watermark( height: watermark.height(), }; - let margin = 10; let (x, y) = calculate_position(position, image_dims, wm_dims, margin); let mut base = img.into_rgba8(); diff --git a/pixstrip-core/src/pipeline.rs b/pixstrip-core/src/pipeline.rs index 01390e1..40616da 100644 --- a/pixstrip-core/src/pipeline.rs +++ b/pixstrip-core/src/pipeline.rs @@ -21,7 +21,7 @@ pub struct ProcessingJob { pub metadata: Option, pub watermark: Option, pub rename: Option, - pub overwrite_behavior: OverwriteBehavior, + pub overwrite_behavior: OverwriteAction, pub preserve_directory_structure: bool, pub progressive_jpeg: bool, pub avif_speed: u8, @@ -44,11 +44,11 @@ impl ProcessingJob { metadata: None, watermark: None, rename: None, - overwrite_behavior: OverwriteBehavior::default(), + overwrite_behavior: OverwriteAction::default(), preserve_directory_structure: false, progressive_jpeg: false, avif_speed: 6, - output_dpi: 72, + output_dpi: 0, } } @@ -70,6 +70,18 @@ impl ProcessingJob { count } + /// Returns true if the job requires decoding/encoding pixel data. + /// When false, we can use a fast copy-and-rename path. + pub fn needs_pixel_processing(&self) -> bool { + self.resize.is_some() + || matches!(self.rotation, Some(r) if !matches!(r, Rotation::None)) + || matches!(self.flip, Some(f) if !matches!(f, Flip::None)) + || self.adjustments.as_ref().is_some_and(|a| !a.is_noop()) + || self.convert.is_some() + || self.compress.is_some() + || self.watermark.is_some() + } + pub fn output_path_for( &self, source: &ImageSource, diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index 63917b5..5072a78 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -5,6 +5,7 @@ use crate::pipeline::ProcessingJob; use crate::types::*; #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct Preset { pub name: String, pub description: String, @@ -20,6 +21,25 @@ pub struct Preset { pub rename: Option, } +impl Default for Preset { + fn default() -> Self { + Self { + name: String::new(), + description: String::new(), + icon: "image-x-generic-symbolic".into(), + is_custom: true, + resize: None, + rotation: None, + flip: None, + convert: None, + compress: None, + metadata: None, + watermark: None, + rename: None, + } + } +} + impl Preset { pub fn to_job( &self, @@ -40,7 +60,7 @@ impl Preset { metadata: self.metadata.clone(), watermark: self.watermark.clone(), rename: self.rename.clone(), - overwrite_behavior: crate::operations::OverwriteBehavior::default(), + overwrite_behavior: crate::operations::OverwriteAction::default(), preserve_directory_structure: false, progressive_jpeg: false, avif_speed: 6, @@ -58,6 +78,7 @@ impl Preset { Self::builtin_photographer_export(), Self::builtin_archive_compress(), Self::builtin_fediverse_ready(), + Self::builtin_print_ready(), ] } @@ -119,8 +140,12 @@ impl Preset { suffix: String::new(), counter_start: 1, counter_padding: 3, + counter_enabled: true, + counter_position: 3, template: None, case_mode: 0, + replace_spaces: 0, + special_chars: 0, regex_find: String::new(), regex_replace: String::new(), }), @@ -179,8 +204,12 @@ impl Preset { suffix: String::new(), counter_start: 1, counter_padding: 4, + counter_enabled: true, + counter_position: 3, template: Some("{exif_date}_{name}_{counter:4}".into()), case_mode: 0, + replace_spaces: 0, + special_chars: 0, regex_find: String::new(), regex_replace: String::new(), }), @@ -204,6 +233,23 @@ impl Preset { } } + pub fn builtin_print_ready() -> Preset { + Preset { + name: "Print Ready".into(), + description: "Maximum quality, convert to PNG, keep all metadata".into(), + icon: "printer-symbolic".into(), + is_custom: false, + resize: None, + rotation: None, + flip: None, + convert: Some(ConvertConfig::SingleFormat(ImageFormat::Png)), + compress: Some(CompressConfig::Preset(QualityPreset::Maximum)), + metadata: Some(MetadataConfig::KeepAll), + watermark: None, + rename: None, + } + } + pub fn builtin_fediverse_ready() -> Preset { Preset { name: "Fediverse Ready".into(), diff --git a/pixstrip-core/src/storage.rs b/pixstrip-core/src/storage.rs index 46a91e4..a45773b 100644 --- a/pixstrip-core/src/storage.rs +++ b/pixstrip-core/src/storage.rs @@ -8,10 +8,19 @@ use crate::preset::Preset; fn default_config_dir() -> PathBuf { dirs::config_dir() - .unwrap_or_else(|| PathBuf::from("~/.config")) + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) + .unwrap_or_else(std::env::temp_dir) .join("pixstrip") } +/// Write to a temporary file then rename, for crash safety. +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)?; + Ok(()) +} + fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| match c { @@ -54,7 +63,7 @@ impl PresetStore { let path = self.preset_path(&preset.name); let json = serde_json::to_string_pretty(preset) .map_err(|e| PixstripError::Preset(e.to_string()))?; - std::fs::write(&path, json).map_err(PixstripError::Io) + atomic_write(&path, &json).map_err(PixstripError::Io) } pub fn load(&self, name: &str) -> Result { @@ -103,7 +112,7 @@ impl PresetStore { pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> { let json = serde_json::to_string_pretty(preset) .map_err(|e| PixstripError::Preset(e.to_string()))?; - std::fs::write(path, json).map_err(PixstripError::Io) + atomic_write(path, &json).map_err(PixstripError::Io) } pub fn import_from_file(&self, path: &Path) -> Result { @@ -146,7 +155,7 @@ impl ConfigStore { } let json = serde_json::to_string_pretty(config) .map_err(|e| PixstripError::Config(e.to_string()))?; - std::fs::write(&self.config_path, json).map_err(PixstripError::Io) + atomic_write(&self.config_path, &json).map_err(PixstripError::Io) } pub fn load(&self) -> Result { @@ -215,7 +224,7 @@ impl SessionStore { } let json = serde_json::to_string_pretty(state) .map_err(|e| PixstripError::Config(e.to_string()))?; - std::fs::write(&self.session_path, json).map_err(PixstripError::Io) + atomic_write(&self.session_path, &json).map_err(PixstripError::Io) } pub fn load(&self) -> Result { @@ -267,10 +276,11 @@ impl HistoryStore { } } - pub fn add(&self, entry: HistoryEntry) -> Result<()> { + pub fn add(&self, entry: HistoryEntry, max_entries: usize, max_days: u32) -> Result<()> { let mut entries = self.list()?; entries.push(entry); - self.write_all(&entries) + self.write_all(&entries)?; + self.prune(max_entries, max_days) } pub fn prune(&self, max_entries: usize, max_days: u32) -> Result<()> { @@ -285,9 +295,9 @@ impl HistoryStore { .as_secs(); let cutoff_secs = now_secs.saturating_sub(max_days as u64 * 86400); - // Remove entries older than max_days + // Remove entries older than max_days (keep entries with unparseable timestamps) entries.retain(|e| { - e.timestamp.parse::().unwrap_or(0) >= cutoff_secs + e.timestamp.parse::().map_or(true, |ts| ts >= cutoff_secs) }); // Trim to max_entries (keep the most recent) @@ -314,13 +324,13 @@ impl HistoryStore { self.write_all(&Vec::::new()) } - fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> { + pub fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> { if let Some(parent) = self.history_path.parent() { std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; } let json = serde_json::to_string_pretty(entries) .map_err(|e| PixstripError::Config(e.to_string()))?; - std::fs::write(&self.history_path, json).map_err(PixstripError::Io) + atomic_write(&self.history_path, &json).map_err(PixstripError::Io) } } diff --git a/pixstrip-core/src/types.rs b/pixstrip-core/src/types.rs index f512637..3f840b6 100644 --- a/pixstrip-core/src/types.rs +++ b/pixstrip-core/src/types.rs @@ -66,10 +66,17 @@ pub struct Dimensions { impl Dimensions { pub fn aspect_ratio(&self) -> f64 { + if self.height == 0 { + return 1.0; + } self.width as f64 / self.height as f64 } pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions { + if self.width == 0 || self.height == 0 || max.width == 0 || max.height == 0 { + return self; + } + if !allow_upscale && self.width <= max.width && self.height <= max.height { return self; } @@ -83,8 +90,8 @@ impl Dimensions { } Dimensions { - width: (self.width as f64 * scale).round() as u32, - height: (self.height as f64 * scale).round() as u32, + width: (self.width as f64 * scale).round().max(1.0) as u32, + height: (self.height as f64 * scale).round().max(1.0) as u32, } } } @@ -135,6 +142,36 @@ impl QualityPreset { } } + pub fn webp_effort(&self) -> u8 { + match self { + Self::Maximum => 6, + Self::High => 5, + Self::Medium => 4, + Self::Low => 3, + Self::WebOptimized => 4, + } + } + + pub fn avif_quality(&self) -> u8 { + match self { + Self::Maximum => 80, + Self::High => 63, + Self::Medium => 50, + Self::Low => 35, + Self::WebOptimized => 40, + } + } + + pub fn avif_speed(&self) -> u8 { + match self { + Self::Maximum => 4, + Self::High => 6, + Self::Medium => 6, + Self::Low => 8, + Self::WebOptimized => 8, + } + } + pub fn label(&self) -> &'static str { match self { Self::Maximum => "Maximum", diff --git a/pixstrip-core/tests/adjustments_tests.rs b/pixstrip-core/tests/adjustments_tests.rs new file mode 100644 index 0000000..899f758 --- /dev/null +++ b/pixstrip-core/tests/adjustments_tests.rs @@ -0,0 +1,145 @@ +use pixstrip_core::operations::AdjustmentsConfig; +use pixstrip_core::operations::adjustments::apply_adjustments; +use image::DynamicImage; + +fn noop_config() -> AdjustmentsConfig { + AdjustmentsConfig { + brightness: 0, + contrast: 0, + saturation: 0, + sharpen: false, + grayscale: false, + sepia: false, + crop_aspect_ratio: None, + trim_whitespace: false, + canvas_padding: 0, + } +} + +#[test] +fn is_noop_default() { + assert!(noop_config().is_noop()); +} + +#[test] +fn is_noop_with_brightness() { + let mut config = noop_config(); + config.brightness = 10; + assert!(!config.is_noop()); +} + +#[test] +fn is_noop_with_sharpen() { + let mut config = noop_config(); + config.sharpen = true; + assert!(!config.is_noop()); +} + +#[test] +fn is_noop_with_crop() { + let mut config = noop_config(); + config.crop_aspect_ratio = Some((16.0, 9.0)); + assert!(!config.is_noop()); +} + +#[test] +fn is_noop_with_trim() { + let mut config = noop_config(); + config.trim_whitespace = true; + assert!(!config.is_noop()); +} + +#[test] +fn is_noop_with_padding() { + let mut config = noop_config(); + config.canvas_padding = 20; + assert!(!config.is_noop()); +} + +#[test] +fn is_noop_with_grayscale() { + let mut config = noop_config(); + config.grayscale = true; + assert!(!config.is_noop()); +} + +#[test] +fn is_noop_with_sepia() { + let mut config = noop_config(); + config.sepia = true; + assert!(!config.is_noop()); +} + +// --- crop_to_aspect_ratio edge cases --- + +fn make_test_image(w: u32, h: u32) -> DynamicImage { + DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(w, h, image::Rgba([128, 128, 128, 255]))) +} + +#[test] +fn crop_zero_ratio_returns_original() { + let img = make_test_image(100, 100); + let config = AdjustmentsConfig { + crop_aspect_ratio: Some((0.0, 9.0)), + ..noop_config() + }; + let result = apply_adjustments(img, &config).unwrap(); + assert_eq!(result.width(), 100); + assert_eq!(result.height(), 100); +} + +#[test] +fn crop_zero_height_ratio_returns_original() { + let img = make_test_image(100, 100); + let config = AdjustmentsConfig { + crop_aspect_ratio: Some((16.0, 0.0)), + ..noop_config() + }; + let result = apply_adjustments(img, &config).unwrap(); + assert_eq!(result.width(), 100); + assert_eq!(result.height(), 100); +} + +#[test] +fn crop_square_on_landscape() { + let img = make_test_image(200, 100); + let config = AdjustmentsConfig { + crop_aspect_ratio: Some((1.0, 1.0)), + ..noop_config() + }; + let result = apply_adjustments(img, &config).unwrap(); + assert_eq!(result.width(), 100); + assert_eq!(result.height(), 100); +} + +#[test] +fn crop_16_9_on_square() { + let img = make_test_image(100, 100); + let config = AdjustmentsConfig { + crop_aspect_ratio: Some((16.0, 9.0)), + ..noop_config() + }; + let result = apply_adjustments(img, &config).unwrap(); + assert_eq!(result.width(), 100); + assert_eq!(result.height(), 56); +} + +#[test] +fn canvas_padding_large_value() { + let img = make_test_image(10, 10); + let config = AdjustmentsConfig { + canvas_padding: 500, + ..noop_config() + }; + let result = apply_adjustments(img, &config).unwrap(); + assert_eq!(result.width(), 1010); + assert_eq!(result.height(), 1010); +} + +#[test] +fn noop_config_returns_same_dimensions() { + let img = make_test_image(200, 100); + let result = apply_adjustments(img, &noop_config()).unwrap(); + assert_eq!(result.width(), 200); + assert_eq!(result.height(), 100); +} diff --git a/pixstrip-core/tests/executor_tests.rs b/pixstrip-core/tests/executor_tests.rs index 2a66189..44844ba 100644 --- a/pixstrip-core/tests/executor_tests.rs +++ b/pixstrip-core/tests/executor_tests.rs @@ -112,9 +112,14 @@ fn execute_with_cancellation() { let executor = PipelineExecutor::with_cancel(cancel); let result = executor.execute(&job, |_| {}).unwrap(); - // With immediate cancellation, fewer images should be processed - assert!(result.succeeded + result.failed <= 2); - assert!(result.cancelled); + // Cancellation flag should be set + assert!(result.cancelled, "result.cancelled should be true when cancel flag is set"); + // Total processed should be less than total sources (at least some skipped) + assert!( + result.succeeded + result.failed <= 2, + "processed count ({}) should not exceed total (2)", + result.succeeded + result.failed + ); } #[test] diff --git a/pixstrip-core/tests/metadata_tests.rs b/pixstrip-core/tests/metadata_tests.rs index f37c862..e1716a4 100644 --- a/pixstrip-core/tests/metadata_tests.rs +++ b/pixstrip-core/tests/metadata_tests.rs @@ -42,3 +42,84 @@ fn privacy_mode_strips_gps() { strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap(); assert!(output.exists()); } + +fn create_test_png(path: &Path) { + let img = image::RgbaImage::from_fn(100, 80, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]) + }); + img.save_with_format(path, image::ImageFormat::Png).unwrap(); +} + +#[test] +fn strip_png_metadata_produces_valid_png() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("test.png"); + let output = dir.path().join("stripped.png"); + create_test_png(&input); + + strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap(); + assert!(output.exists()); + // Output must be a valid PNG that can be opened + let img = image::open(&output).unwrap(); + assert_eq!(img.width(), 100); + assert_eq!(img.height(), 80); +} + +#[test] +fn strip_png_removes_text_chunks() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("test.png"); + let output = dir.path().join("stripped.png"); + create_test_png(&input); + + strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap(); + // Read output and verify no tEXt chunks remain + let data = std::fs::read(&output).unwrap(); + let mut pos = 8; // skip PNG signature + while pos + 12 <= data.len() { + let chunk_len = u32::from_be_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]) as usize; + let chunk_type = &data[pos+4..pos+8]; + assert_ne!(chunk_type, b"tEXt", "tEXt chunk should be stripped"); + assert_ne!(chunk_type, b"iTXt", "iTXt chunk should be stripped"); + assert_ne!(chunk_type, b"zTXt", "zTXt chunk should be stripped"); + pos += 12 + chunk_len; + } +} + +#[test] +fn strip_png_output_smaller_or_equal() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("test.png"); + let output = dir.path().join("stripped.png"); + create_test_png(&input); + + let input_size = std::fs::metadata(&input).unwrap().len(); + strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap(); + let output_size = std::fs::metadata(&output).unwrap().len(); + assert!(output_size <= input_size); +} + +#[test] +fn strip_jpeg_removes_app1_exif() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("test.jpg"); + let output = dir.path().join("stripped.jpg"); + create_test_jpeg(&input); + + strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap(); + // Verify no APP1 (0xFFE1) markers remain + let data = std::fs::read(&output).unwrap(); + let mut i = 2; // skip SOI + while i + 1 < data.len() { + if data[i] != 0xFF { break; } + let marker = data[i + 1]; + if marker == 0xDA { break; } // SOS - rest is image data + assert_ne!(marker, 0xE1, "APP1/EXIF marker should be stripped"); + if i + 3 < data.len() { + let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize); + i += 2 + len; + } else { + break; + } + } +} diff --git a/pixstrip-core/tests/operations_tests.rs b/pixstrip-core/tests/operations_tests.rs index 5c471a6..7f46cf7 100644 --- a/pixstrip-core/tests/operations_tests.rs +++ b/pixstrip-core/tests/operations_tests.rs @@ -91,8 +91,12 @@ fn rename_config_simple_template() { suffix: String::new(), counter_start: 1, counter_padding: 3, + counter_enabled: true, + counter_position: 3, template: None, case_mode: 0, + replace_spaces: 0, + special_chars: 0, regex_find: String::new(), regex_replace: String::new(), }; @@ -107,11 +111,101 @@ fn rename_config_with_suffix() { suffix: "_web".into(), counter_start: 1, counter_padding: 2, + counter_enabled: true, + counter_position: 3, template: None, case_mode: 0, + replace_spaces: 0, + special_chars: 0, regex_find: String::new(), regex_replace: String::new(), }; let result = config.apply_simple("photo", "webp", 5); assert_eq!(result, "photo_web_05.webp"); } + +#[test] +fn rename_counter_overflow_saturates() { + let config = RenameConfig { + prefix: String::new(), + suffix: String::new(), + counter_start: u32::MAX, + counter_padding: 1, + counter_enabled: true, + counter_position: 3, + template: None, + case_mode: 0, + replace_spaces: 0, + special_chars: 0, + regex_find: String::new(), + regex_replace: String::new(), + }; + // Should not panic - saturating arithmetic + let result = config.apply_simple("photo", "jpg", u32::MAX); + assert!(result.contains("photo")); +} + +#[test] +fn metadata_config_custom_selective() { + let config = MetadataConfig::Custom { + strip_gps: true, + strip_camera: false, + strip_software: true, + strip_timestamps: false, + strip_copyright: false, + }; + assert!(config.should_strip_gps()); + assert!(!config.should_strip_camera()); + assert!(!config.should_strip_copyright()); +} + +#[test] +fn metadata_config_custom_all_off() { + let config = MetadataConfig::Custom { + strip_gps: false, + strip_camera: false, + strip_software: false, + strip_timestamps: false, + strip_copyright: false, + }; + assert!(!config.should_strip_gps()); + assert!(!config.should_strip_camera()); + assert!(!config.should_strip_copyright()); +} + +#[test] +fn metadata_config_custom_all_on() { + let config = MetadataConfig::Custom { + strip_gps: true, + strip_camera: true, + strip_software: true, + strip_timestamps: true, + strip_copyright: true, + }; + assert!(config.should_strip_gps()); + assert!(config.should_strip_camera()); + assert!(config.should_strip_copyright()); +} + +#[test] +fn watermark_rotation_variants_exist() { + let rotations = [ + WatermarkRotation::Degrees45, + WatermarkRotation::DegreesNeg45, + WatermarkRotation::Degrees90, + WatermarkRotation::Custom(30.0), + ]; + assert_eq!(rotations.len(), 4); +} + +#[test] +fn rotation_auto_orient_variant() { + let rotation = Rotation::AutoOrient; + assert!(matches!(rotation, Rotation::AutoOrient)); +} + +#[test] +fn overwrite_action_default_is_auto_rename() { + let default = OverwriteAction::default(); + assert!(matches!(default, OverwriteAction::AutoRename)); +} diff --git a/pixstrip-core/tests/preset_tests.rs b/pixstrip-core/tests/preset_tests.rs index 12c17bc..ea4a0de 100644 --- a/pixstrip-core/tests/preset_tests.rs +++ b/pixstrip-core/tests/preset_tests.rs @@ -37,7 +37,7 @@ fn preset_serialization_roundtrip() { #[test] fn all_builtin_presets() { let presets = Preset::all_builtins(); - assert_eq!(presets.len(), 8); + assert_eq!(presets.len(), 9); let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); assert!(names.contains(&"Blog Photos")); assert!(names.contains(&"Social Media")); diff --git a/pixstrip-core/tests/rename_tests.rs b/pixstrip-core/tests/rename_tests.rs index f78adc8..d54eb01 100644 --- a/pixstrip-core/tests/rename_tests.rs +++ b/pixstrip-core/tests/rename_tests.rs @@ -1,4 +1,7 @@ -use pixstrip_core::operations::rename::{apply_template, resolve_collision}; +use pixstrip_core::operations::rename::{ + apply_template, apply_regex_replace, apply_space_replacement, + apply_special_chars, apply_case_conversion, resolve_collision, +}; #[test] fn template_basic_variables() { @@ -93,3 +96,185 @@ fn no_collision_returns_same() { let resolved = resolve_collision(&path); assert_eq!(resolved, path); } + +// --- Regex replace tests --- + +#[test] +fn regex_replace_basic() { + let result = apply_regex_replace("hello_world", "_", "-"); + assert_eq!(result, "hello-world"); +} + +#[test] +fn regex_replace_pattern() { + let result = apply_regex_replace("IMG_20260307_001", r"\d{8}", "DATE"); + assert_eq!(result, "IMG_DATE_001"); +} + +#[test] +fn regex_replace_invalid_pattern_returns_original() { + let result = apply_regex_replace("hello", "[invalid", "x"); + assert_eq!(result, "hello"); +} + +#[test] +fn regex_replace_empty_find_returns_original() { + let result = apply_regex_replace("hello", "", "x"); + assert_eq!(result, "hello"); +} + +// --- Space replacement tests --- + +#[test] +fn space_replacement_none() { + assert_eq!(apply_space_replacement("hello world", 0), "hello world"); +} + +#[test] +fn space_replacement_underscore() { + assert_eq!(apply_space_replacement("hello world", 1), "hello_world"); +} + +#[test] +fn space_replacement_hyphen() { + assert_eq!(apply_space_replacement("hello world", 2), "hello-world"); +} + +#[test] +fn space_replacement_dot() { + assert_eq!(apply_space_replacement("hello world", 3), "hello.world"); +} + +#[test] +fn space_replacement_camelcase() { + assert_eq!(apply_space_replacement("hello world", 4), "helloWorld"); +} + +#[test] +fn space_replacement_remove() { + assert_eq!(apply_space_replacement("hello world", 5), "helloworld"); +} + +// --- Special chars tests --- + +#[test] +fn special_chars_keep_all() { + assert_eq!(apply_special_chars("file.txt", 0), "file.txt"); +} + +#[test] +fn special_chars_filesystem_safe() { + assert_eq!(apply_special_chars("file", 1), "filename"); +} + +#[test] +fn special_chars_web_safe() { + assert_eq!(apply_special_chars("file name!@#", 2), "filename"); +} + +#[test] +fn special_chars_alphanumeric_only() { + assert_eq!(apply_special_chars("file-name_123", 5), "filename123"); +} + +// --- Case conversion tests --- + +#[test] +fn case_conversion_none() { + assert_eq!(apply_case_conversion("Hello World", 0), "Hello World"); +} + +#[test] +fn case_conversion_lowercase() { + assert_eq!(apply_case_conversion("Hello World", 1), "hello world"); +} + +#[test] +fn case_conversion_uppercase() { + assert_eq!(apply_case_conversion("Hello World", 2), "HELLO WORLD"); +} + +#[test] +fn case_conversion_title_case() { + assert_eq!(apply_case_conversion("hello world", 3), "Hello World"); +} + +#[test] +fn case_conversion_title_preserves_separators() { + assert_eq!(apply_case_conversion("hello-world_foo", 3), "Hello-World_Foo"); +} + +// --- RenameConfig::apply_simple edge cases --- + +use pixstrip_core::operations::RenameConfig; + +fn default_rename_config() -> RenameConfig { + RenameConfig { + prefix: String::new(), + suffix: String::new(), + counter_start: 1, + counter_padding: 3, + counter_enabled: false, + counter_position: 3, + template: None, + case_mode: 0, + replace_spaces: 0, + special_chars: 0, + regex_find: String::new(), + regex_replace: String::new(), + } +} + +#[test] +fn apply_simple_no_changes() { + let cfg = default_rename_config(); + assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo.jpg"); +} + +#[test] +fn apply_simple_with_prefix_suffix() { + let mut cfg = default_rename_config(); + cfg.prefix = "web_".into(); + cfg.suffix = "_final".into(); + assert_eq!(cfg.apply_simple("photo", "jpg", 1), "web_photo_final.jpg"); +} + +#[test] +fn apply_simple_counter_after_suffix() { + let mut cfg = default_rename_config(); + cfg.counter_enabled = true; + cfg.counter_position = 3; + assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo_001.jpg"); +} + +#[test] +fn apply_simple_counter_replaces_name() { + let mut cfg = default_rename_config(); + cfg.counter_enabled = true; + cfg.counter_position = 4; + assert_eq!(cfg.apply_simple("photo", "jpg", 1), "001.jpg"); +} + +#[test] +fn apply_simple_empty_name() { + let cfg = default_rename_config(); + assert_eq!(cfg.apply_simple("", "png", 1), ".png"); +} + +#[test] +fn apply_simple_case_lowercase() { + let mut cfg = default_rename_config(); + cfg.case_mode = 1; + assert_eq!(cfg.apply_simple("MyPhoto", "JPG", 1), "myphoto.JPG"); +} + +#[test] +fn apply_simple_large_counter_padding_capped() { + let mut cfg = default_rename_config(); + cfg.counter_enabled = true; + cfg.counter_padding = 100; // should be capped to 10 + cfg.counter_position = 3; + let result = cfg.apply_simple("photo", "jpg", 1); + // Counter portion should be at most 10 digits + assert!(result.len() <= 22); // "photo_" + 10 digits + ".jpg" +} diff --git a/pixstrip-core/tests/storage_tests.rs b/pixstrip-core/tests/storage_tests.rs index 6a6d3b7..bb974c1 100644 --- a/pixstrip-core/tests/storage_tests.rs +++ b/pixstrip-core/tests/storage_tests.rs @@ -208,7 +208,7 @@ fn add_and_list_history_entries() { ], }; - history.add(entry.clone()).unwrap(); + history.add(entry.clone(), 50, 30).unwrap(); let entries = history.list().unwrap(); assert_eq!(entries.len(), 1); @@ -236,7 +236,7 @@ fn history_appends_entries() { total_output_bytes: 500, elapsed_ms: 100, output_files: vec![], - }) + }, 50, 30) .unwrap(); } @@ -263,7 +263,7 @@ fn clear_history() { total_output_bytes: 500, elapsed_ms: 100, output_files: vec![], - }) + }, 50, 30) .unwrap(); history.clear().unwrap(); diff --git a/pixstrip-core/tests/types_tests.rs b/pixstrip-core/tests/types_tests.rs index 5ef828b..748b033 100644 --- a/pixstrip-core/tests/types_tests.rs +++ b/pixstrip-core/tests/types_tests.rs @@ -81,3 +81,39 @@ fn quality_preset_values() { assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality()); assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality()); } + +#[test] +fn dimensions_zero_height_aspect_ratio() { + let dims = Dimensions { width: 1920, height: 0 }; + assert_eq!(dims.aspect_ratio(), 1.0); +} + +#[test] +fn dimensions_zero_both_aspect_ratio() { + let dims = Dimensions { width: 0, height: 0 }; + assert_eq!(dims.aspect_ratio(), 1.0); +} + +#[test] +fn dimensions_fit_within_zero_self_width() { + let original = Dimensions { width: 0, height: 600 }; + let max_box = Dimensions { width: 1200, height: 1200 }; + let fitted = original.fit_within(max_box, false); + assert_eq!(fitted, original); +} + +#[test] +fn dimensions_fit_within_zero_self_height() { + let original = Dimensions { width: 800, height: 0 }; + let max_box = Dimensions { width: 1200, height: 1200 }; + let fitted = original.fit_within(max_box, false); + assert_eq!(fitted, original); +} + +#[test] +fn dimensions_fit_within_zero_max() { + let original = Dimensions { width: 800, height: 600 }; + let max_box = Dimensions { width: 0, height: 0 }; + let fitted = original.fit_within(max_box, false); + assert_eq!(fitted, original); +} diff --git a/pixstrip-core/tests/watcher_tests.rs b/pixstrip-core/tests/watcher_tests.rs index 9e18681..eccf6a5 100644 --- a/pixstrip-core/tests/watcher_tests.rs +++ b/pixstrip-core/tests/watcher_tests.rs @@ -54,7 +54,7 @@ fn watcher_detects_new_image() { watcher.start(&folder, tx).unwrap(); // Wait for watcher to be ready - std::thread::sleep(std::time::Duration::from_millis(200)); + std::thread::sleep(std::time::Duration::from_millis(500)); // Create an image file let img_path = dir.path().join("new_photo.jpg"); @@ -87,7 +87,7 @@ fn watcher_ignores_non_image_files() { let (tx, rx) = mpsc::channel(); watcher.start(&folder, tx).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(200)); + std::thread::sleep(std::time::Duration::from_millis(500)); // Create a non-image file std::fs::write(dir.path().join("readme.txt"), b"text file").unwrap(); diff --git a/pixstrip-core/tests/watermark_tests.rs b/pixstrip-core/tests/watermark_tests.rs index 91eb7c0..ab1cdee 100644 --- a/pixstrip-core/tests/watermark_tests.rs +++ b/pixstrip-core/tests/watermark_tests.rs @@ -109,3 +109,137 @@ fn position_bottom_left() { assert_eq!(x, 10); assert_eq!(y, 1020); } + +// --- Margin variation tests --- + +#[test] +fn margin_zero_top_left() { + let (x, y) = calculate_position( + WatermarkPosition::TopLeft, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 0, + ); + assert_eq!(x, 0); + assert_eq!(y, 0); +} + +#[test] +fn margin_zero_bottom_right() { + let (x, y) = calculate_position( + WatermarkPosition::BottomRight, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 0, + ); + assert_eq!(x, 1720); // 1920 - 200 + assert_eq!(y, 1030); // 1080 - 50 +} + +#[test] +fn large_margin_top_left() { + let (x, y) = calculate_position( + WatermarkPosition::TopLeft, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 100, + ); + assert_eq!(x, 100); + assert_eq!(y, 100); +} + +#[test] +fn large_margin_bottom_right() { + let (x, y) = calculate_position( + WatermarkPosition::BottomRight, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 100, + ); + assert_eq!(x, 1620); // 1920 - 200 - 100 + assert_eq!(y, 930); // 1080 - 50 - 100 +} + +#[test] +fn margin_does_not_affect_center() { + let (x1, y1) = calculate_position( + WatermarkPosition::Center, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 0, + ); + let (x2, y2) = calculate_position( + WatermarkPosition::Center, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 100, + ); + assert_eq!(x1, x2); + assert_eq!(y1, y2); +} + +// --- Edge case tests --- + +#[test] +fn watermark_larger_than_image() { + // Watermark is bigger than the image - should not panic, saturating_sub clamps to 0 + let (x, y) = calculate_position( + WatermarkPosition::BottomRight, + Dimensions { width: 100, height: 100 }, + Dimensions { width: 200, height: 200 }, + 10, + ); + // (100 - 200 - 10) saturates to 0 + assert_eq!(x, 0); + assert_eq!(y, 0); +} + +#[test] +fn watermark_exact_image_size() { + let (x, y) = calculate_position( + WatermarkPosition::Center, + Dimensions { width: 200, height: 100 }, + Dimensions { width: 200, height: 100 }, + 0, + ); + assert_eq!(x, 0); + assert_eq!(y, 0); +} + +#[test] +fn zero_size_image() { + let (x, y) = calculate_position( + WatermarkPosition::Center, + Dimensions { width: 0, height: 0 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 0); + assert_eq!(y, 0); +} + +#[test] +fn margin_exceeds_available_space() { + // Margin is huge relative to image size + let (x, y) = calculate_position( + WatermarkPosition::BottomRight, + Dimensions { width: 100, height: 100 }, + Dimensions { width: 50, height: 50 }, + 200, + ); + // saturating_sub: 100 - 50 - 200 = 0 + assert_eq!(x, 0); + assert_eq!(y, 0); +} + +#[test] +fn one_pixel_image() { + let (x, y) = calculate_position( + WatermarkPosition::TopLeft, + Dimensions { width: 1, height: 1 }, + Dimensions { width: 1, height: 1 }, + 0, + ); + assert_eq!(x, 0); + assert_eq!(y, 0); +} diff --git a/pixstrip-gtk/Cargo.toml b/pixstrip-gtk/Cargo.toml index 525dbdf..b5c1796 100644 --- a/pixstrip-gtk/Cargo.toml +++ b/pixstrip-gtk/Cargo.toml @@ -9,3 +9,4 @@ pixstrip-core = { workspace = true } gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] } adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] } image = "0.25" +regex = "1" diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 7701843..cddf722 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -1,11 +1,14 @@ use adw::prelude::*; use gtk::glib; use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::step_indicator::StepIndicator; +use crate::steps; +use crate::utils::format_size; use crate::wizard::WizardState; pub const APP_ID: &str = "live.lashman.Pixstrip"; @@ -13,10 +16,13 @@ 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 { + // When true, skip all intermediate steps (2-8) - go straight from Images to Output + pub preset_mode: bool, // Resize pub resize_enabled: bool, pub resize_width: u32, pub resize_height: u32, + pub resize_mode: u32, // 0=exact, 1=fit within box pub allow_upscale: bool, pub resize_algorithm: u32, pub output_dpi: u32, @@ -37,10 +43,7 @@ pub struct JobConfig { pub convert_enabled: bool, pub convert_format: Option, pub progressive_jpeg: bool, - pub format_mapping_jpeg: u32, - pub format_mapping_png: u32, - pub format_mapping_webp: u32, - pub format_mapping_tiff: u32, + pub format_mappings: HashMap, // Compress pub compress_enabled: bool, pub quality_preset: pixstrip_core::types::QualityPreset, @@ -71,14 +74,19 @@ pub struct JobConfig { pub watermark_tiled: bool, pub watermark_margin: u32, pub watermark_scale: f32, + pub watermark_rotation: i32, // Rename pub rename_enabled: bool, pub rename_prefix: String, pub rename_suffix: String, + pub rename_counter_enabled: bool, pub rename_counter_start: u32, pub rename_counter_padding: u32, + pub rename_counter_position: u32, // 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name + pub rename_replace_spaces: u32, // 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove + pub rename_special_chars: u32, // 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only + pub rename_case: u32, // 0=none, 1=lower, 2=upper, 3=title pub rename_template: String, - pub rename_case: u32, // 0=none, 1=lower, 2=upper, 3=title pub rename_find: String, pub rename_replace: String, // Output @@ -318,9 +326,11 @@ fn build_ui(app: &adw::Application) { batch_queue: Rc::new(RefCell::new(BatchQueue::default())), expanded_sections: Rc::new(RefCell::new(sess_state.expanded_sections.clone())), job_config: Rc::new(RefCell::new(JobConfig { + preset_mode: false, resize_enabled: if remember { sess_state.resize_enabled.unwrap_or(true) } else { true }, resize_width: if remember { sess_state.resize_width.unwrap_or(1200) } else { 1200 }, resize_height: if remember { sess_state.resize_height.unwrap_or(0) } else { 0 }, + resize_mode: 0, allow_upscale: false, resize_algorithm: 0, output_dpi: 72, @@ -343,10 +353,7 @@ fn build_ui(app: &adw::Application) { None }, progressive_jpeg: false, - format_mapping_jpeg: 0, - format_mapping_png: 0, - format_mapping_webp: 0, - format_mapping_tiff: 0, + format_mappings: HashMap::new(), compress_enabled: if remember { sess_state.compress_enabled.unwrap_or(true) } else { true }, quality_preset: if remember { sess_state.quality_preset.as_deref() @@ -386,13 +393,18 @@ fn build_ui(app: &adw::Application) { watermark_tiled: false, watermark_margin: 10, watermark_scale: 20.0, + watermark_rotation: 0, rename_enabled: if remember { sess_state.rename_enabled.unwrap_or(false) } else { false }, rename_prefix: String::new(), rename_suffix: String::new(), + rename_counter_enabled: false, rename_counter_start: 1, rename_counter_padding: 3, - rename_template: String::new(), + rename_counter_position: 3, // after suffix + rename_replace_spaces: 0, + rename_special_chars: 0, rename_case: 0, + rename_template: String::new(), rename_find: String::new(), rename_replace: String::new(), preserve_dir_structure: false, @@ -692,7 +704,8 @@ fn start_watch_folder_monitoring(ui: &WizardUi) { let (tx, rx) = std::sync::mpsc::channel::(); - // Start a watcher for each active folder + // Start a watcher for each active folder, keeping them alive + let mut watchers = Vec::new(); for folder in &active_folders { let watcher = pixstrip_core::watcher::FolderWatcher::new(); let folder_tx = tx.clone(); @@ -700,8 +713,7 @@ fn start_watch_folder_monitoring(ui: &WizardUi) { eprintln!("Failed to start watching {}: {}", folder.path.display(), e); continue; } - // Leak the watcher so it stays alive for the lifetime of the app - std::mem::forget(watcher); + watchers.push(watcher); } // Build a lookup from folder path to preset name @@ -713,7 +725,9 @@ fn start_watch_folder_monitoring(ui: &WizardUi) { let toast_overlay = ui.toast_overlay.clone(); // Poll the channel from the main loop + // Move watchers into closure to keep them alive for the app lifetime glib::timeout_add_local(std::time::Duration::from_millis(500), move || { + let _watchers = &watchers; // prevent drop let mut batch: Vec<(std::path::PathBuf, String)> = Vec::new(); // Drain all pending events @@ -853,7 +867,9 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { Some(&i32::static_variant_type()), ); action.connect_activate(move |_, param| { - if let Some(step) = param.and_then(|p| p.get::()) { + if let Some(step) = param.and_then(|p| p.get::()) + && step >= 1 + { let target = (step - 1) as usize; let s = ui.state.wizard.borrow(); let cfg = ui.state.job_config.borrow(); @@ -1091,7 +1107,14 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { // Navigate to step 2 (images) if we're on step 1 or earlier let current = ui.state.wizard.borrow().current_step; if current <= 1 { - ui.state.wizard.borrow_mut().current_step = 1; + { + let mut w = ui.state.wizard.borrow_mut(); + w.current_step = 1; + if w.visited.len() > 1 { + w.visited[1] = true; + } + } + ui.step_indicator.set_current(1); if let Some(page) = ui.pages.get(1) { ui.nav_view.push(page); } @@ -1108,6 +1131,7 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { fn should_skip_step(step: usize, cfg: &JobConfig) -> bool { match step { 0 | 1 | 9 => false, // Workflow, Images, Output - always shown + 2..=8 if cfg.preset_mode => true, // Preset mode: skip all intermediate steps 2 => !cfg.resize_enabled, 3 => !cfg.adjustments_enabled, 4 => !cfg.convert_enabled, @@ -1119,7 +1143,35 @@ fn should_skip_step(step: usize, cfg: &JobConfig) -> bool { } } +fn rebuild_step_indicator(ui: &WizardUi) { + let cfg = ui.state.job_config.borrow(); + let all_names = [ + "Workflow", "Images", "Resize", "Adjustments", "Convert", + "Compress", "Metadata", "Watermark", "Rename", "Output", + ]; + let visible: Vec<(usize, String)> = all_names.iter().enumerate() + .filter(|&(i, _)| !should_skip_step(i, &cfg)) + .map(|(i, name)| (i, name.to_string())) + .collect(); + drop(cfg); + ui.step_indicator.rebuild(&visible); +} + fn navigate_to_step(ui: &WizardUi, target: usize) { + // Rebuild indicator: show all steps on Workflow, only relevant steps elsewhere + if target == 0 { + let all_names = [ + "Workflow", "Images", "Resize", "Adjustments", "Convert", + "Compress", "Metadata", "Watermark", "Rename", "Output", + ]; + let all: Vec<(usize, String)> = all_names.iter().enumerate() + .map(|(i, name)| (i, name.to_string())) + .collect(); + ui.step_indicator.rebuild(&all); + } else { + rebuild_step_indicator(ui); + } + let s = ui.state.wizard.borrow(); // Update step indicator @@ -1170,7 +1222,7 @@ fn navigate_to_step(ui: &WizardUi, target: usize) { if let Some(row) = widget.downcast_ref::() && row.title().as_str() == "Images to process" { - row.set_subtitle(&format!("{} images ({})", included_count, format_bytes(total_size))); + row.set_subtitle(&format!("{} images ({})", included_count, format_size(total_size))); } }); } @@ -1204,6 +1256,7 @@ fn open_file_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) { let filter = gtk::FileFilter::new(); filter.set_name(Some("Image files")); + // Common raster formats filter.add_mime_type("image/jpeg"); filter.add_mime_type("image/png"); filter.add_mime_type("image/webp"); @@ -1211,6 +1264,45 @@ fn open_file_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) { filter.add_mime_type("image/gif"); filter.add_mime_type("image/tiff"); filter.add_mime_type("image/bmp"); + // Note: ico/heic/svg excluded - not supported by processing pipeline + filter.add_mime_type("image/x-tga"); + filter.add_mime_type("image/x-portable-anymap"); + filter.add_mime_type("image/x-portable-bitmap"); + filter.add_mime_type("image/x-portable-graymap"); + filter.add_mime_type("image/x-portable-pixmap"); + filter.add_mime_type("image/x-pcx"); + filter.add_mime_type("image/x-xpixmap"); + filter.add_mime_type("image/x-xbitmap"); + filter.add_mime_type("image/vnd.wap.wbmp"); + filter.add_mime_type("image/vnd.ms-dds"); + // HDR / EXR + filter.add_mime_type("image/vnd.radiance"); + filter.add_mime_type("image/x-exr"); + // Modern formats + filter.add_mime_type("image/jxl"); + filter.add_mime_type("image/heic"); + filter.add_mime_type("image/heif"); + filter.add_mime_type("image/jp2"); + filter.add_mime_type("image/jpx"); + filter.add_mime_type("image/x-qoi"); + // Vector + filter.add_mime_type("image/svg+xml"); + // RAW camera formats + filter.add_mime_type("image/x-canon-cr2"); + filter.add_mime_type("image/x-canon-cr3"); + filter.add_mime_type("image/x-nikon-nef"); + filter.add_mime_type("image/x-sony-arw"); + filter.add_mime_type("image/x-sony-srf"); + filter.add_mime_type("image/x-sony-sr2"); + filter.add_mime_type("image/x-olympus-orf"); + filter.add_mime_type("image/x-panasonic-rw2"); + filter.add_mime_type("image/x-fuji-raf"); + filter.add_mime_type("image/x-adobe-dng"); + filter.add_mime_type("image/x-pentax-pef"); + filter.add_mime_type("image/x-samsung-srw"); + filter.add_mime_type("image/x-sigma-x3f"); + // Catch-all pattern for any image type the system recognizes + filter.add_mime_type("image/*"); let filters = gtk::gio::ListStore::new::(); filters.append(&filter); @@ -1270,15 +1362,6 @@ fn update_output_label(ui: &WizardUi, path: &std::path::Path) { } fn update_images_count_label(ui: &WizardUi, count: usize) { - let files = ui.state.loaded_files.borrow(); - let excluded = ui.state.excluded_files.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(); - // Find the step-images page and switch its stack to "loaded" if we have files if let Some(page) = ui.pages.get(1) && let Some(stack) = page.child().and_downcast::() @@ -1289,70 +1372,15 @@ fn update_images_count_label(ui: &WizardUi, count: usize) { stack.set_visible_child_name("empty"); } if let Some(loaded_box) = stack.child_by_name("loaded") { - update_count_in_box(&loaded_box, count, included_count, total_size); - update_file_list(&loaded_box, &files, &excluded); + steps::step_images::rebuild_grid_model( + &loaded_box, + &ui.state.loaded_files, + &ui.state.excluded_files, + ); } } } -fn update_count_in_box(widget: >k::Widget, count: usize, included_count: usize, total_size: u64) { - if let Some(label) = widget.downcast_ref::() - && label.css_classes().iter().any(|c| c == "heading") - { - if included_count == count { - label.set_label(&format!("{} images ({})", count, format_bytes(total_size))); - } else { - label.set_label(&format!("{}/{} images selected ({})", included_count, count, format_bytes(total_size))); - } - return; - } - let mut child = widget.first_child(); - while let Some(c) = child { - update_count_in_box(&c, count, included_count, total_size); - child = c.next_sibling(); - } -} - -fn update_file_list(widget: >k::Widget, files: &[std::path::PathBuf], excluded: &std::collections::HashSet) { - if let Some(list_box) = widget.downcast_ref::() - && list_box.css_classes().iter().any(|c| c == "boxed-list") - { - while let Some(child) = list_box.first_child() { - list_box.remove(&child); - } - for path in files { - let name = path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - let size = std::fs::metadata(path) - .map(|m| format_bytes(m.len())) - .unwrap_or_default(); - let ext = path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_uppercase(); - let row = adw::ActionRow::builder() - .title(name) - .subtitle(format!("{} - {}", ext, size)) - .build(); - let check = gtk::CheckButton::builder() - .active(!excluded.contains(path)) - .tooltip_text("Include in processing") - .valign(gtk::Align::Center) - .build(); - row.add_prefix(&check); - row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); - list_box.append(&row); - } - return; - } - let mut child = widget.first_child(); - while let Some(c) = child { - update_file_list(&c, files, excluded); - child = c.next_sibling(); - } -} - fn show_history_dialog(window: &adw::ApplicationWindow) { let dialog = adw::Dialog::builder() .title("Processing History") @@ -1457,8 +1485,8 @@ fn show_history_dialog(window: &adw::ApplicationWindow) { .title("Size") .subtitle(&format!( "{} -> {} ({})", - format_bytes(entry.total_input_bytes), - format_bytes(entry.total_output_bytes), + format_size(entry.total_input_bytes), + format_size(entry.total_output_bytes), savings )) .build(); @@ -1661,11 +1689,15 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { let target_h = cfg.resize_height; if target_h == 0 { job.resize = Some(pixstrip_core::operations::ResizeConfig::ByWidth(target_w)); - } else { + } else if cfg.resize_mode == 1 { job.resize = Some(pixstrip_core::operations::ResizeConfig::FitInBox { max: pixstrip_core::types::Dimensions { width: target_w, height: target_h }, allow_upscale: cfg.allow_upscale, }); + } else { + job.resize = Some(pixstrip_core::operations::ResizeConfig::Exact( + pixstrip_core::types::Dimensions { width: target_w, height: target_h }, + )); } job.resize_algorithm = match cfg.resize_algorithm { 1 => pixstrip_core::operations::ResizeAlgorithm::CatmullRom, @@ -1677,38 +1709,33 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { if cfg.convert_enabled { // Check if any per-format mappings are set (non-zero = overridden) - let has_mapping = cfg.format_mapping_jpeg > 0 - || cfg.format_mapping_png > 0 - || cfg.format_mapping_webp > 0 - || cfg.format_mapping_tiff > 0; + let has_mapping = cfg.format_mappings.values().any(|&v| v > 0); if has_mapping { + // Dropdown order: 0=Same as above, 1=Keep Original, + // 2=JPEG, 3=PNG, 4=WebP, 5=AVIF, 6=GIF, 7=TIFF let mapping_to_format = |idx: u32, default: Option| -> Option { match idx { - 1 => Some(pixstrip_core::types::ImageFormat::Jpeg), - 2 => Some(pixstrip_core::types::ImageFormat::Png), - 3 => Some(pixstrip_core::types::ImageFormat::WebP), - 4 => Some(pixstrip_core::types::ImageFormat::Avif), - 5 => None, // Keep Original - _ => default, // "Same as above" - use global format + 1 => None, // Keep Original + 2 => Some(pixstrip_core::types::ImageFormat::Jpeg), + 3 => Some(pixstrip_core::types::ImageFormat::Png), + 4 => Some(pixstrip_core::types::ImageFormat::WebP), + 5 => Some(pixstrip_core::types::ImageFormat::Avif), + 6 => Some(pixstrip_core::types::ImageFormat::Gif), + 7 => Some(pixstrip_core::types::ImageFormat::Tiff), + _ => default, // 0 = "Same as above" - use global format } }; let global = cfg.convert_format; let mut map = Vec::new(); - // For each input format, determine output - let input_formats = [ - (pixstrip_core::types::ImageFormat::Jpeg, cfg.format_mapping_jpeg), - (pixstrip_core::types::ImageFormat::Png, cfg.format_mapping_png), - (pixstrip_core::types::ImageFormat::WebP, cfg.format_mapping_webp), - (pixstrip_core::types::ImageFormat::Tiff, cfg.format_mapping_tiff), - ]; - - for (input_fmt, mapping_idx) in input_formats { - if let Some(output_fmt) = mapping_to_format(mapping_idx, global) { - if output_fmt != input_fmt { - map.push((input_fmt, output_fmt)); + for (ext, &mapping_idx) in &cfg.format_mappings { + if let Some(input_fmt) = pixstrip_core::types::ImageFormat::from_extension(ext) { + if let Some(output_fmt) = mapping_to_format(mapping_idx, global) { + if output_fmt != input_fmt { + map.push((input_fmt, output_fmt)); + } } } } @@ -1821,6 +1848,12 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { _ => pixstrip_core::operations::WatermarkPosition::BottomRight, }; + let wm_rotation = if cfg.watermark_rotation != 0 { + Some(pixstrip_core::operations::WatermarkRotation::Custom(cfg.watermark_rotation as f32)) + } else { + None + }; + if cfg.watermark_use_image { if let Some(ref path) = cfg.watermark_image_path { job.watermark = Some(pixstrip_core::operations::WatermarkConfig::Image { @@ -1828,7 +1861,7 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { position, opacity: cfg.watermark_opacity, scale: cfg.watermark_scale / 100.0, - rotation: None, + rotation: wm_rotation, tiled: cfg.watermark_tiled, margin: cfg.watermark_margin, }); @@ -1841,7 +1874,7 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { opacity: cfg.watermark_opacity, color: cfg.watermark_color, font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) }, - rotation: None, + rotation: wm_rotation, tiled: cfg.watermark_tiled, margin: cfg.watermark_margin, }); @@ -1855,12 +1888,16 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { suffix: cfg.rename_suffix.clone(), counter_start: cfg.rename_counter_start, counter_padding: cfg.rename_counter_padding, + counter_enabled: cfg.rename_counter_enabled, + counter_position: cfg.rename_counter_position, template: if cfg.rename_template.is_empty() { None } else { Some(cfg.rename_template.clone()) }, case_mode: cfg.rename_case, + replace_spaces: cfg.rename_replace_spaces, + special_chars: cfg.rename_special_chars, regex_find: cfg.rename_find.clone(), regex_replace: cfg.rename_replace.clone(), }); @@ -1870,10 +1907,10 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { job.output_dpi = cfg.output_dpi; let ask_overwrite = cfg.overwrite_behavior == 0; job.overwrite_behavior = match cfg.overwrite_behavior { - 1 => pixstrip_core::operations::OverwriteBehavior::AutoRename, - 2 => pixstrip_core::operations::OverwriteBehavior::Overwrite, - 3 => pixstrip_core::operations::OverwriteBehavior::Skip, - _ => pixstrip_core::operations::OverwriteBehavior::AutoRename, + 1 => pixstrip_core::operations::OverwriteAction::AutoRename, + 2 => pixstrip_core::operations::OverwriteAction::Overwrite, + 3 => pixstrip_core::operations::OverwriteAction::Skip, + _ => pixstrip_core::operations::OverwriteAction::AutoRename, }; drop(cfg); @@ -1923,9 +1960,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { let mut job_c = job; confirm.choose(Some(_window), gtk::gio::Cancellable::NONE, move |response| { job_c.overwrite_behavior = match response.as_str() { - "overwrite" => pixstrip_core::operations::OverwriteBehavior::Overwrite, - "skip" => pixstrip_core::operations::OverwriteBehavior::Skip, - _ => pixstrip_core::operations::OverwriteBehavior::AutoRename, + "overwrite" => pixstrip_core::operations::OverwriteAction::Overwrite, + "skip" => pixstrip_core::operations::OverwriteAction::Skip, + _ => pixstrip_core::operations::OverwriteAction::AutoRename, }; continue_processing(&window_c, &ui_c, job_c); }); @@ -2114,8 +2151,8 @@ fn show_results( total_input_bytes: result.total_input_bytes, total_output_bytes: result.total_output_bytes, elapsed_ms: result.elapsed_ms, - output_files, - }); + output_files: output_files.clone(), + }, 50, 30); // Prune old history entries let config_store = pixstrip_core::storage::ConfigStore::new(); @@ -2138,22 +2175,20 @@ fn show_results( undo_toast.set_button_label(Some("Undo")); undo_toast.set_timeout(10); { - let output_dir = ui.state.output_dir.borrow().clone(); + let undo_files = output_files; undo_toast.connect_button_clicked(move |t| { - if let Some(ref dir) = output_dir { - let mut trashed = 0; - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let gfile = gtk::gio::File::for_path(entry.path()); - if gfile.trash(gtk::gio::Cancellable::NONE).is_ok() { - trashed += 1; - } + let mut trashed = 0; + for path_str in &undo_files { + let path = std::path::Path::new(path_str); + if path.exists() { + let gfile = gtk::gio::File::for_path(path); + if gfile.trash(gtk::gio::Cancellable::NONE).is_ok() { + trashed += 1; } } - t.dismiss(); - // Will show a new toast from the caller - let _ = trashed; } + t.dismiss(); + let _ = trashed; }); } ui.toast_overlay.add_toast(undo_toast); @@ -2215,10 +2250,10 @@ fn update_results_stats( row.set_subtitle(&format!("{} images", result.succeeded)); } "Original size" => { - row.set_subtitle(&format_bytes(result.total_input_bytes)); + row.set_subtitle(&format_size(result.total_input_bytes)); } "Output size" => { - row.set_subtitle(&format_bytes(result.total_output_bytes)); + row.set_subtitle(&format_size(result.total_output_bytes)); } "Space saved" => { if result.total_input_bytes > 0 { @@ -2355,7 +2390,7 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_millis(); + .as_nanos(); let temp_path = temp_dir.join(format!("clipboard-{}.png", timestamp)); let bytes = texture.save_to_png_bytes(); @@ -2381,6 +2416,12 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) { } fn reset_wizard(ui: &WizardUi) { + // Clean up clipboard temp files + let temp_dir = std::env::temp_dir().join("pixstrip-clipboard"); + if temp_dir.is_dir() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + // Reset state { let mut s = ui.state.wizard.borrow_mut(); @@ -2390,9 +2431,21 @@ fn reset_wizard(ui: &WizardUi) { } ui.state.loaded_files.borrow_mut().clear(); ui.state.excluded_files.borrow_mut().clear(); + // Reset job config to clear preset_mode so wizard steps show up again + ui.state.job_config.borrow_mut().preset_mode = false; + *ui.state.output_dir.borrow_mut() = None; // Reset nav ui.nav_view.replace(&ui.pages[..1]); + // Rebuild indicator with all steps + let all_names = [ + "Workflow", "Images", "Resize", "Adjustments", "Convert", + "Compress", "Metadata", "Watermark", "Rename", "Output", + ]; + let all: Vec<(usize, String)> = all_names.iter().enumerate() + .map(|(i, name)| (i, name.to_string())) + .collect(); + ui.step_indicator.rebuild(&all); ui.step_indicator.set_current(0); ui.step_indicator.widget().set_visible(true); ui.title.set_subtitle("Batch Image Processor"); @@ -2586,6 +2639,12 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) { .build(); name_group.add(&name_entry); + let desc_entry = adw::EntryRow::builder() + .title("Description (optional)") + .text(&summary) + .build(); + name_group.add(&desc_entry); + let save_new_button = gtk::Button::builder() .label("Save New Preset") .halign(gtk::Align::Center) @@ -2618,7 +2677,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) { .title(preset_name) .activatable(true) .build(); - row.add_prefix(>k::Image::from_icon_name("document-save-symbolic")); + row.add_prefix(>k::Image::from_icon_name("user-bookmarks-symbolic")); row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); let ui_c = ui.clone(); @@ -2626,7 +2685,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) { let pname = preset_name.clone(); row.connect_activated(move |_| { let cfg = ui_c.state.job_config.borrow(); - let preset = build_preset_from_config(&cfg, &pname); + let preset = build_preset_from_config(&cfg, &pname, None); drop(cfg); let store = pixstrip_core::storage::PresetStore::new(); @@ -2654,6 +2713,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) { let ui_c = ui.clone(); let dlg_c = dialog.clone(); let entry_c = name_entry.clone(); + let desc_c = desc_entry.clone(); save_new_button.connect_clicked(move |_| { let name = entry_c.text().to_string(); if name.trim().is_empty() { @@ -2662,8 +2722,9 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) { return; } + let desc_text = desc_c.text().to_string(); let cfg = ui_c.state.job_config.borrow(); - let preset = build_preset_from_config(&cfg, &name); + let preset = build_preset_from_config(&cfg, &name, Some(&desc_text)); drop(cfg); let store = pixstrip_core::storage::PresetStore::new(); @@ -2688,11 +2749,11 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) { dialog.present(Some(window)); } -fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::preset::Preset { +fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&str>) -> pixstrip_core::preset::Preset { let resize = if cfg.resize_enabled && cfg.resize_width > 0 { if cfg.resize_height == 0 { Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width)) - } else { + } else if cfg.resize_mode == 1 { Some(pixstrip_core::operations::ResizeConfig::FitInBox { max: pixstrip_core::types::Dimensions { width: cfg.resize_width, @@ -2700,6 +2761,13 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese }, allow_upscale: cfg.allow_upscale, }) + } else { + Some(pixstrip_core::operations::ResizeConfig::Exact( + pixstrip_core::types::Dimensions { + width: cfg.resize_width, + height: cfg.resize_height, + }, + )) } } else { None @@ -2760,6 +2828,11 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese 7 => pixstrip_core::operations::WatermarkPosition::BottomCenter, _ => pixstrip_core::operations::WatermarkPosition::BottomRight, }; + let wm_rotation = if cfg.watermark_rotation != 0 { + Some(pixstrip_core::operations::WatermarkRotation::Custom(cfg.watermark_rotation as f32)) + } else { + None + }; if cfg.watermark_use_image { cfg.watermark_image_path.as_ref().map(|path| { pixstrip_core::operations::WatermarkConfig::Image { @@ -2767,7 +2840,7 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese position, opacity: cfg.watermark_opacity, scale: cfg.watermark_scale / 100.0, - rotation: None, + rotation: wm_rotation, tiled: cfg.watermark_tiled, margin: cfg.watermark_margin, } @@ -2780,7 +2853,7 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese opacity: cfg.watermark_opacity, color: cfg.watermark_color, font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) }, - rotation: None, + rotation: wm_rotation, tiled: cfg.watermark_tiled, margin: cfg.watermark_margin, }) @@ -2797,12 +2870,16 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese suffix: cfg.rename_suffix.clone(), counter_start: cfg.rename_counter_start, counter_padding: cfg.rename_counter_padding, + counter_enabled: cfg.rename_counter_enabled, + counter_position: cfg.rename_counter_position, template: if cfg.rename_template.is_empty() { None } else { Some(cfg.rename_template.clone()) }, case_mode: cfg.rename_case, + replace_spaces: cfg.rename_replace_spaces, + special_chars: cfg.rename_special_chars, regex_find: cfg.rename_find.clone(), regex_replace: cfg.rename_replace.clone(), }) @@ -2812,8 +2889,11 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese pixstrip_core::preset::Preset { name: name.to_string(), - description: build_preset_description(cfg), - icon: "document-save-symbolic".into(), + description: description + .filter(|d| !d.trim().is_empty()) + .map(|d| d.to_string()) + .unwrap_or_else(|| build_preset_description(cfg)), + icon: "user-bookmarks-symbolic".into(), is_custom: true, resize, rotation, @@ -3022,102 +3102,91 @@ pub fn walk_widgets(widget: &Option, f: &dyn Fn(>k::Widget)) { fn show_shortcuts_window(window: &adw::ApplicationWindow) { - let shortcuts_window = gtk::ShortcutsWindow::builder() - .transient_for(window) - .modal(true) - .build(); - - // Wizard Navigation section - let wizard_group = gtk::ShortcutsGroup::builder() - .title("Wizard Navigation") - .build(); - - let shortcuts_nav: &[(&str, &str)] = &[ - ("Right", "Next step"), - ("Left", "Previous step"), - ("1", "Jump to step 1"), - ("2", "Jump to step 2"), - ("3", "Jump to step 3"), - ("Return", "Process images"), - ("Escape", "Cancel or go back"), - ]; - - for (accel, title) in shortcuts_nav { - let shortcut = gtk::ShortcutsShortcut::builder() - .accelerator(*accel) - .title(*title) - .build(); - wizard_group.add_shortcut(&shortcut); - } - - // File Management section - let files_group = gtk::ShortcutsGroup::builder() - .title("File Management") - .build(); - - let shortcuts_files: &[(&str, &str)] = &[ - ("o", "Add files"), - ("v", "Paste image from clipboard"), - ("a", "Select all images"), - ("a", "Deselect all images"), - ("Delete", "Remove selected images"), - ]; - - for (accel, title) in shortcuts_files { - let shortcut = gtk::ShortcutsShortcut::builder() - .accelerator(*accel) - .title(*title) - .build(); - files_group.add_shortcut(&shortcut); - } - - // Application section - let app_group = gtk::ShortcutsGroup::builder() - .title("Application") - .build(); - - let shortcuts_app: &[(&str, &str)] = &[ - ("comma", "Settings"), - ("F1", "Keyboard shortcuts"), - ("z", "Undo last batch"), - ("q", "Quit"), - ]; - - for (accel, title) in shortcuts_app { - let shortcut = gtk::ShortcutsShortcut::builder() - .accelerator(*accel) - .title(*title) - .build(); - app_group.add_shortcut(&shortcut); - } - - let section = gtk::ShortcutsSection::builder() + let dialog = adw::Dialog::builder() .title("Keyboard Shortcuts") + .content_width(420) + .content_height(480) .build(); - #[allow(deprecated)] - { - section.add_group(&wizard_group); - section.add_group(&files_group); - section.add_group(&app_group); - shortcuts_window.add_section(§ion); + let toolbar_view = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + toolbar_view.add_top_bar(&header); + + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .margin_start(16) + .margin_end(16) + .margin_top(8) + .margin_bottom(16) + .spacing(16) + .build(); + + let sections: &[(&str, &[(&str, &str)])] = &[ + ("Wizard Navigation", &[ + ("Alt + Right", "Next step"), + ("Alt + Left", "Previous step"), + ("Alt + 1-9", "Jump to step"), + ("Ctrl + Return", "Process images"), + ("Escape", "Cancel or go back"), + ]), + ("File Management", &[ + ("Ctrl + O", "Add files"), + ("Ctrl + V", "Paste image from clipboard"), + ("Ctrl + A", "Select all images"), + ("Ctrl + Shift + A", "Deselect all images"), + ("Delete", "Remove selected images"), + ]), + ("Application", &[ + ("Ctrl + ,", "Settings"), + ("F1", "Keyboard shortcuts"), + ("Ctrl + Z", "Undo last batch"), + ("Ctrl + Q", "Quit"), + ]), + ]; + + for (section_title, shortcuts) in sections { + let group = adw::PreferencesGroup::builder() + .title(*section_title) + .build(); + + for (accel, description) in *shortcuts { + let row = adw::ActionRow::builder() + .title(*description) + .build(); + let label = gtk::Label::builder() + .label(*accel) + .css_classes(["dim-label", "monospace"]) + .valign(gtk::Align::Center) + .build(); + row.add_suffix(&label); + group.add(&row); + } + + content.append(&group); } - shortcuts_window.present(); + + scroll.set_child(Some(&content)); + toolbar_view.set_content(Some(&scroll)); + dialog.set_child(Some(&toolbar_view)); + dialog.present(Some(window)); } fn apply_accessibility_settings() { let config_store = pixstrip_core::storage::ConfigStore::new(); let config = config_store.load().unwrap_or_default(); + let Some(settings) = gtk::Settings::default() else { + return; + }; + if config.high_contrast { - // Request high contrast by switching to the HighContrast theme. - // This works on GNOME systems with the standard HC theme installed. - let settings = gtk::Settings::default().unwrap(); settings.set_gtk_theme_name(Some("HighContrast")); } - let settings = gtk::Settings::default().unwrap(); - if config.large_text { // Increase font DPI by 25% for large text mode let current_dpi = settings.gtk_xft_dpi(); @@ -3131,18 +3200,6 @@ fn apply_accessibility_settings() { } } -fn format_bytes(bytes: u64) -> String { - if bytes < 1024 { - format!("{} B", bytes) - } else if bytes < 1024 * 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) - } else { - format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } -} - fn show_step_help(window: &adw::ApplicationWindow, step: usize) { let (title, body) = match step { 0 => ("Workflow", concat!( @@ -3282,7 +3339,7 @@ fn build_watch_folder_panel() -> gtk::Box { .build(); let empty_label = gtk::Label::builder() - .label("No watch folders active. Add one in Settings or click +.") + .label("No watch folders active. Click + to add one.") .css_classes(["dim-label", "caption"]) .halign(gtk::Align::Center) .wrap(true) @@ -3326,14 +3383,64 @@ fn build_watch_folder_panel() -> gtk::Box { inner.append(&list_box); inner.append(&empty_label); - // Wire add button to open Settings > Watch Folders + // Wire add button to open a folder chooser directly { - let inner_ref = inner.clone(); + let list_box_c = list_box.clone(); + let empty_label_c = empty_label.clone(); add_btn.connect_clicked(move |btn| { - if let Some(root) = btn.root() { - root.activate_action("win.show-settings", None).ok(); + let list_box_c = list_box_c.clone(); + let empty_label_c = empty_label_c.clone(); + let dialog = gtk::FileDialog::builder() + .title("Choose Watch Folder") + .modal(true) + .build(); + + if let Some(window) = btn.root().and_then(|r| r.downcast::().ok()) { + dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |result| { + if let Ok(file) = result + && let Some(path) = file.path() + { + let new_folder = pixstrip_core::watcher::WatchFolder { + path: path.clone(), + preset_name: "Blog Photos".to_string(), + recursive: false, + active: true, + }; + + // Save to config + let config_store = pixstrip_core::storage::ConfigStore::new(); + let mut config = config_store.load().unwrap_or_default(); + // Avoid duplicates + if !config.watch_folders.iter().any(|f| f.path == new_folder.path) { + config.watch_folders.push(new_folder.clone()); + let _ = config_store.save(&config); + } + + // Add row to the panel list + let display_name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or_else(|| path.to_str().unwrap_or("Unknown")) + .to_string(); + + let row = adw::ActionRow::builder() + .title(&display_name) + .subtitle(&new_folder.preset_name) + .build(); + row.add_prefix(>k::Image::from_icon_name("folder-visiting-symbolic")); + + let status = gtk::Label::builder() + .label("Watching") + .css_classes(["caption", "accent"]) + .valign(gtk::Align::Center) + .build(); + row.add_suffix(&status); + + list_box_c.append(&row); + list_box_c.set_visible(true); + empty_label_c.set_visible(false); + } + }); } - let _ = &inner_ref; }); } @@ -3426,7 +3533,7 @@ fn refresh_queue_list(ui: &WizardUi) { } list_box.set_visible(!queue.batches.is_empty()); - for (i, batch) in queue.batches.iter().enumerate() { + for (_i, batch) in queue.batches.iter().enumerate() { let status_icon = match &batch.status { BatchStatus::Pending => "content-loading-symbolic", BatchStatus::Processing => "emblem-synchronizing-symbolic", @@ -3458,9 +3565,13 @@ fn refresh_queue_list(ui: &WizardUi) { let queue_ref = ui.state.batch_queue.clone(); let ui_clone = ui.clone(); - let idx = i; + let batch_name = batch.name.clone(); remove_btn.connect_clicked(move |_| { - queue_ref.borrow_mut().batches.remove(idx); + let mut q = queue_ref.borrow_mut(); + if let Some(pos) = q.batches.iter().position(|b| b.name == batch_name && b.status == BatchStatus::Pending) { + q.batches.remove(pos); + } + drop(q); refresh_queue_list(&ui_clone); }); diff --git a/pixstrip-gtk/src/main.rs b/pixstrip-gtk/src/main.rs index c30ebcb..a5626f5 100644 --- a/pixstrip-gtk/src/main.rs +++ b/pixstrip-gtk/src/main.rs @@ -4,6 +4,7 @@ mod settings; mod step_indicator; mod steps; mod tutorial; +pub(crate) mod utils; mod welcome; mod wizard; diff --git a/pixstrip-gtk/src/processing.rs b/pixstrip-gtk/src/processing.rs index 42d57a4..5957498 100644 --- a/pixstrip-gtk/src/processing.rs +++ b/pixstrip-gtk/src/processing.rs @@ -88,15 +88,10 @@ pub fn build_processing_page() -> adw::NavigationPage { content.append(&log_group); content.append(&button_box); - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&content) - .build(); - adw::NavigationPage::builder() .title("Processing") .tag("processing") - .child(&clamp) + .child(&content) .build() } @@ -232,14 +227,9 @@ pub fn build_results_page() -> adw::NavigationPage { scrolled.set_child(Some(&content)); - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&scrolled) - .build(); - adw::NavigationPage::builder() .title("Results") .tag("results") - .child(&clamp) + .child(&scrolled) .build() } diff --git a/pixstrip-gtk/src/settings.rs b/pixstrip-gtk/src/settings.rs index 494d67f..1a0c6e7 100644 --- a/pixstrip-gtk/src/settings.rs +++ b/pixstrip-gtk/src/settings.rs @@ -1,4 +1,5 @@ use adw::prelude::*; +use std::cell::Cell; use pixstrip_core::config::{AppConfig, ErrorBehavior, OverwriteBehavior, SkillLevel}; use pixstrip_core::storage::ConfigStore; @@ -8,7 +9,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { .build(); let config_store = ConfigStore::new(); - let config = config_store.load().unwrap_or_default(); + let config = match config_store.load() { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load config, using defaults: {}", e); + AppConfig::default() + } + }; // General page let general_page = adw::PreferencesPage::builder() @@ -24,12 +31,14 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { let output_mode_row = adw::ComboRow::builder() .title("Default output location") .subtitle("Where processed images are saved by default") + .use_subtitle(true) .build(); let output_mode_model = gtk::StringList::new(&[ "Subfolder next to originals", "Fixed output folder", ]); output_mode_row.set_model(Some(&output_mode_model)); + output_mode_row.set_list_factory(Some(&crate::steps::full_text_list_factory())); output_mode_row.set_selected(if config.output_fixed_path.is_some() { 1 } else { 0 }); let subfolder_row = adw::EntryRow::builder() @@ -101,6 +110,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { let overwrite_row = adw::ComboRow::builder() .title("Default overwrite behavior") .subtitle("What to do when output files already exist") + .use_subtitle(true) .build(); let overwrite_model = gtk::StringList::new(&[ "Ask before overwriting", @@ -109,6 +119,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { "Skip existing files", ]); overwrite_row.set_model(Some(&overwrite_model)); + overwrite_row.set_list_factory(Some(&crate::steps::full_text_list_factory())); overwrite_row.set_selected(match config.overwrite_behavior { OverwriteBehavior::Ask => 0, OverwriteBehavior::AutoRename => 1, @@ -136,9 +147,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { let skill_row = adw::ComboRow::builder() .title("Detail level") .subtitle("Controls how many options are visible by default") + .use_subtitle(true) .build(); let skill_model = gtk::StringList::new(&["Simple", "Detailed"]); skill_row.set_model(Some(&skill_model)); + skill_row.set_list_factory(Some(&crate::steps::full_text_list_factory())); skill_row.set_selected(match config.skill_level { SkillLevel::Simple => 0, SkillLevel::Detailed => 1, @@ -188,11 +201,22 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { .build(); let fm_copy = *fm; + let reverting = std::rc::Rc::new(std::cell::Cell::new(false)); + let reverting_clone = reverting.clone(); row.connect_active_notify(move |row| { - if row.is_active() { - let _ = fm_copy.install(); + if reverting_clone.get() { + return; + } + let result = if row.is_active() { + fm_copy.install() } else { - let _ = fm_copy.uninstall(); + fm_copy.uninstall() + }; + if let Err(e) = result { + eprintln!("File manager integration error for {}: {}", fm_copy.name(), e); + reverting_clone.set(true); + row.set_active(!row.is_active()); + reverting_clone.set(false); } }); @@ -231,9 +255,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { let threads_row = adw::ComboRow::builder() .title("Processing threads") .subtitle("Auto uses all available CPU cores") + .use_subtitle(true) .build(); let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]); threads_row.set_model(Some(&threads_model)); + threads_row.set_list_factory(Some(&crate::steps::full_text_list_factory())); threads_row.set_selected(match config.thread_count { pixstrip_core::config::ThreadCount::Auto => 0, pixstrip_core::config::ThreadCount::Manual(1) => 1, @@ -245,9 +271,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { let error_row = adw::ComboRow::builder() .title("On error") .subtitle("What to do when an image fails to process") + .use_subtitle(true) .build(); let error_model = gtk::StringList::new(&["Skip and continue", "Pause on error"]); error_row.set_model(Some(&error_model)); + error_row.set_list_factory(Some(&crate::steps::full_text_list_factory())); error_row.set_selected(match config.error_behavior { ErrorBehavior::SkipAndContinue => 0, ErrorBehavior::PauseOnError => 1, @@ -287,6 +315,53 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { .active(config.reduced_motion) .build(); + // Wire high contrast to apply immediately + { + contrast_row.connect_active_notify(move |row| { + if let Some(settings) = gtk::Settings::default() { + if row.is_active() { + settings.set_gtk_theme_name(Some("HighContrast")); + } else { + // Revert to the default Adwaita theme + settings.set_gtk_theme_name(Some("Adwaita")); + } + } + }); + } + + // Wire large text to apply immediately + { + let original_dpi: std::rc::Rc> = std::rc::Rc::new(Cell::new(0)); + 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 + let current_dpi = settings.gtk_xft_dpi(); + if current_dpi > 0 { + orig_dpi.set(current_dpi); + settings.set_gtk_xft_dpi(current_dpi * 5 / 4); + } + } else { + // Restore the original DPI (only if we actually changed it) + let saved = orig_dpi.get(); + if saved > 0 { + settings.set_gtk_xft_dpi(saved); + } + } + } + }); + } + + // Wire reduced motion to apply immediately + { + motion_row.connect_active_notify(move |row| { + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(!row.is_active()); + } + }); + } + a11y_group.add(&contrast_row); a11y_group.add(&large_text_row); a11y_group.add(&motion_row); @@ -443,6 +518,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { let auto_open = auto_open_row.clone(); let output_mode = output_mode_row.clone(); let fps_reset = fixed_path_state.clone(); + let wfs_reset = watch_folders_state.clone(); + let wl_reset = watch_list.clone(); + let el_reset = empty_label.clone(); reset_button.connect_clicked(move |_| { let defaults = AppConfig::default(); subfolder.set_text(&defaults.output_subfolder); @@ -459,6 +537,10 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { auto_open.set_active(defaults.auto_open_output); output_mode.set_selected(0); *fps_reset.borrow_mut() = None; + // Clear watch folders + wfs_reset.borrow_mut().clear(); + wl_reset.remove_all(); + el_reset.set_visible(true); }); } @@ -539,11 +621,13 @@ fn build_watch_folder_row( let preset_row = adw::ComboRow::builder() .title("Linked Preset") .subtitle("Preset to apply to new images") + .use_subtitle(true) .build(); let preset_model = gtk::StringList::new( &preset_names.iter().map(|s| s.as_str()).collect::>(), ); preset_row.set_model(Some(&preset_model)); + preset_row.set_list_factory(Some(&crate::steps::full_text_list_factory())); // Set selected to matching preset let selected_idx = preset_names.iter() diff --git a/pixstrip-gtk/src/step_indicator.rs b/pixstrip-gtk/src/step_indicator.rs index ea1325f..0413563 100644 --- a/pixstrip-gtk/src/step_indicator.rs +++ b/pixstrip-gtk/src/step_indicator.rs @@ -5,7 +5,10 @@ use std::cell::RefCell; #[derive(Clone)] pub struct StepIndicator { container: gtk::Box, + grid: gtk::Grid, dots: RefCell>, + /// Maps visual index -> actual step index + step_map: RefCell>, } #[derive(Clone)] @@ -27,65 +30,23 @@ impl StepIndicator { .margin_end(12) .build(); - // Prevent negative allocation warnings when window is narrow container.set_overflow(gtk::Overflow::Hidden); container.update_property(&[ gtk::accessible::Property::Label("Wizard step indicator"), ]); - let mut dots = Vec::new(); + let grid = gtk::Grid::builder() + .column_homogeneous(false) + .row_spacing(2) + .column_spacing(0) + .hexpand(false) + .build(); - for (i, name) in step_names.iter().enumerate() { - if i > 0 { - // Connector line between dots - let line = gtk::Separator::builder() - .orientation(gtk::Orientation::Horizontal) - .hexpand(false) - .valign(gtk::Align::Center) - .build(); - line.set_size_request(12, -1); - container.append(&line); - } + let indices: Vec = (0..step_names.len()).collect(); + let dots = Self::build_dots(&grid, step_names, &indices); - let dot_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(2) - .halign(gtk::Align::Center) - .build(); - - let icon = gtk::Image::builder() - .icon_name("radio-symbolic") - .pixel_size(16) - .build(); - - let button = gtk::Button::builder() - .child(&icon) - .has_frame(false) - .tooltip_text(format!("Step {}: {} (Alt+{})", i + 1, name, i + 1)) - .sensitive(false) - .action_name("win.goto-step") - .action_target(&(i as i32 + 1).to_variant()) - .build(); - button.add_css_class("circular"); - - let label = gtk::Label::builder() - .label(name) - .css_classes(["caption"]) - .ellipsize(gtk::pango::EllipsizeMode::End) - .max_width_chars(8) - .build(); - - dot_box.append(&button); - dot_box.append(&label); - container.append(&dot_box); - - dots.push(StepDot { - button, - icon, - label, - }); - } + container.append(&grid); // First step starts as current if let Some(first) = dots.first() { @@ -96,22 +57,95 @@ impl StepIndicator { Self { container, + grid, dots: RefCell::new(dots), + step_map: RefCell::new(indices), } } - pub fn set_current(&self, index: usize) { + fn build_dots(grid: >k::Grid, names: &[String], step_indices: &[usize]) -> Vec { + let mut dots = Vec::new(); + + for (visual_i, (name, &actual_i)) in names.iter().zip(step_indices.iter()).enumerate() { + let col = (visual_i * 2) as i32; + + if visual_i > 0 { + let line = gtk::Separator::builder() + .orientation(gtk::Orientation::Horizontal) + .hexpand(false) + .valign(gtk::Align::Center) + .build(); + line.set_size_request(12, -1); + grid.attach(&line, col - 1, 0, 1, 1); + } + + let icon = gtk::Image::builder() + .icon_name("radio-symbolic") + .pixel_size(16) + .build(); + + let button = gtk::Button::builder() + .child(&icon) + .has_frame(false) + .tooltip_text(format!("Step {}: {} (Alt+{})", visual_i + 1, name, actual_i + 1)) + .sensitive(false) + .action_name("win.goto-step") + .action_target(&(actual_i as i32 + 1).to_variant()) + .halign(gtk::Align::Center) + .build(); + button.add_css_class("circular"); + + let label = gtk::Label::builder() + .label(name) + .css_classes(["caption"]) + .ellipsize(gtk::pango::EllipsizeMode::End) + .width_chars(10) + .halign(gtk::Align::Center) + .build(); + + grid.attach(&button, col, 0, 1, 1); + grid.attach(&label, col, 1, 1, 1); + + dots.push(StepDot { + button, + icon, + label, + }); + } + + dots + } + + /// Rebuild the indicator to show only the given steps. + /// `visible_steps` is a list of (actual_step_index, name). + pub fn rebuild(&self, visible_steps: &[(usize, String)]) { + // Clear the grid + while let Some(child) = self.grid.first_child() { + self.grid.remove(&child); + } + + let names: Vec = visible_steps.iter().map(|(_, n)| n.clone()).collect(); + let indices: Vec = visible_steps.iter().map(|(i, _)| *i).collect(); + + let dots = Self::build_dots(&self.grid, &names, &indices); + *self.dots.borrow_mut() = dots; + *self.step_map.borrow_mut() = indices; + } + + /// Set the current step by actual step index. Finds the visual position. + pub fn set_current(&self, actual_index: usize) { let dots = self.dots.borrow(); + let map = self.step_map.borrow(); let total = dots.len(); - for (i, dot) in dots.iter().enumerate() { - if i == index { + for (visual_i, dot) in dots.iter().enumerate() { + let is_current = map.get(visual_i) == Some(&actual_index); + if is_current { dot.icon.set_icon_name(Some("radio-checked-symbolic")); dot.button.set_sensitive(true); dot.label.add_css_class("accent"); - // Update accessible description for screen readers dot.button.update_property(&[ gtk::accessible::Property::Label( - &format!("Step {} of {}: {} (current)", i + 1, total, dot.label.label()) + &format!("Step {} of {}: {} (current)", visual_i + 1, total, dot.label.label()) ), ]); } else if dot.icon.icon_name().as_deref() != Some("emblem-ok-symbolic") { @@ -119,19 +153,23 @@ impl StepIndicator { dot.label.remove_css_class("accent"); dot.button.update_property(&[ gtk::accessible::Property::Label( - &format!("Step {} of {}: {}", i + 1, total, dot.label.label()) + &format!("Step {} of {}: {}", visual_i + 1, total, dot.label.label()) ), ]); } } } - pub fn set_completed(&self, index: usize) { + /// Mark a step as completed by actual step index. + pub fn set_completed(&self, actual_index: usize) { let dots = self.dots.borrow(); - if let Some(dot) = dots.get(index) { - dot.icon.set_icon_name(Some("emblem-ok-symbolic")); - dot.button.set_sensitive(true); - dot.label.remove_css_class("accent"); + let map = self.step_map.borrow(); + if let Some(visual_i) = map.iter().position(|&i| i == actual_index) { + if let Some(dot) = dots.get(visual_i) { + dot.icon.set_icon_name(Some("emblem-ok-symbolic")); + dot.button.set_sensitive(true); + dot.label.remove_css_class("accent"); + } } } diff --git a/pixstrip-gtk/src/steps/mod.rs b/pixstrip-gtk/src/steps/mod.rs index 760e9f9..582d901 100644 --- a/pixstrip-gtk/src/steps/mod.rs +++ b/pixstrip-gtk/src/steps/mod.rs @@ -8,3 +8,33 @@ pub mod step_rename; pub mod step_resize; pub mod step_watermark; pub mod step_workflow; + +use gtk::prelude::*; + +/// Creates a list factory for ComboRow dropdowns where labels never truncate. +/// The default GTK factory ellipsizes text, which cuts off long option names. +pub fn full_text_list_factory() -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let label = gtk::Label::builder() + .xalign(0.0) + .margin_start(8) + .margin_end(8) + .margin_top(8) + .margin_bottom(8) + .build(); + item.set_child(Some(&label)); + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + if let Some(obj) = item.item() { + if let Some(string_obj) = obj.downcast_ref::() { + if let Some(label) = item.child().and_downcast_ref::() { + label.set_label(&string_obj.string()); + } + } + } + }); + factory +} diff --git a/pixstrip-gtk/src/steps/step_adjustments.rs b/pixstrip-gtk/src/steps/step_adjustments.rs index 2344fd3..e84c1ef 100644 --- a/pixstrip-gtk/src/steps/step_adjustments.rs +++ b/pixstrip-gtk/src/steps/step_adjustments.rs @@ -1,65 +1,211 @@ use adw::prelude::*; +use gtk::glib; +use std::cell::Cell; +use std::rc::Rc; + use crate::app::AppState; pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { - let scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) + let cfg = state.job_config.borrow(); + + // === OUTER LAYOUT === + let outer = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) .vexpand(true) .build(); - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) + // --- Enable toggle (full width) --- + let enable_group = adw::PreferencesGroup::builder() + .margin_start(12) + .margin_end(12) .margin_top(12) - .margin_bottom(12) - .margin_start(24) - .margin_end(24) + .build(); + let enable_row = adw::SwitchRow::builder() + .title("Enable Adjustments") + .subtitle("Rotate, flip, brightness, contrast, effects") + .active(cfg.adjustments_enabled) + .build(); + enable_group.add(&enable_row); + outer.append(&enable_group); + + // === LEFT SIDE: Preview === + + let preview_picture = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Contain) + .hexpand(true) + .vexpand(true) + .build(); + preview_picture.set_can_target(true); + + let info_label = gtk::Label::builder() + .label("No images loaded") + .css_classes(["dim-label", "caption"]) + .halign(gtk::Align::Center) + .margin_top(4) + .margin_bottom(4) .build(); - let cfg = state.job_config.borrow(); + let preview_frame = gtk::Frame::builder() + .hexpand(true) + .vexpand(true) + .build(); + preview_frame.set_child(Some(&preview_picture)); - // Rotate - let rotate_group = adw::PreferencesGroup::builder() + let preview_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .hexpand(true) + .vexpand(true) + .build(); + preview_box.append(&preview_frame); + preview_box.append(&info_label); + + // === RIGHT SIDE: Controls (scrollable) === + + let controls = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_start(12) + .build(); + + // --- Orientation group --- + let orient_group = adw::PreferencesGroup::builder() .title("Orientation") - .description("Rotate and flip images") .build(); let rotate_row = adw::ComboRow::builder() .title("Rotate") .subtitle("Rotation applied to all images") + .use_subtitle(true) .build(); - let rotate_model = gtk::StringList::new(&[ + rotate_row.set_model(Some(>k::StringList::new(&[ "None", "90 clockwise", "180", "270 clockwise", "Auto-orient (from EXIF)", - ]); - rotate_row.set_model(Some(&rotate_model)); + ]))); + rotate_row.set_list_factory(Some(&super::full_text_list_factory())); rotate_row.set_selected(cfg.rotation); let flip_row = adw::ComboRow::builder() .title("Flip") .subtitle("Mirror the image") + .use_subtitle(true) .build(); - let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]); - flip_row.set_model(Some(&flip_model)); + flip_row.set_model(Some(>k::StringList::new(&["None", "Horizontal", "Vertical"]))); + flip_row.set_list_factory(Some(&super::full_text_list_factory())); flip_row.set_selected(cfg.flip); - rotate_group.add(&rotate_row); - rotate_group.add(&flip_row); - content.append(&rotate_group); + orient_group.add(&rotate_row); + orient_group.add(&flip_row); + controls.append(&orient_group); - // Crop and canvas group + // --- Color adjustments group --- + let color_group = adw::PreferencesGroup::builder() + .title("Color") + .build(); + + // Helper to build a slider row with reset button + let make_slider_row = |title: &str, label_text: &str, value: i32| -> (adw::ActionRow, gtk::Scale, gtk::Button) { + let row = adw::ActionRow::builder() + .title(title) + .subtitle(&format!("{}", value)) + .build(); + let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0); + scale.set_value(value as f64); + scale.set_draw_value(false); + scale.set_hexpand(false); + scale.set_valign(gtk::Align::Center); + scale.set_width_request(180); + scale.set_tooltip_text(Some(label_text)); + scale.update_property(&[ + gtk::accessible::Property::Label(label_text), + ]); + + let reset_btn = gtk::Button::builder() + .icon_name("edit-undo-symbolic") + .valign(gtk::Align::Center) + .tooltip_text("Reset to 0") + .has_frame(false) + .build(); + reset_btn.set_sensitive(value != 0); + + row.add_suffix(&scale); + row.add_suffix(&reset_btn); + (row, scale, reset_btn) + }; + + let (brightness_row, brightness_scale, brightness_reset) = + make_slider_row("Brightness", "Brightness adjustment, -100 to +100", cfg.brightness); + let (contrast_row, contrast_scale, contrast_reset) = + make_slider_row("Contrast", "Contrast adjustment, -100 to +100", cfg.contrast); + let (saturation_row, saturation_scale, saturation_reset) = + make_slider_row("Saturation", "Saturation adjustment, -100 to +100", cfg.saturation); + + color_group.add(&brightness_row); + color_group.add(&contrast_row); + color_group.add(&saturation_row); + controls.append(&color_group); + + // --- Effects group (compact toggle buttons) --- + let effects_group = adw::PreferencesGroup::builder() + .title("Effects") + .build(); + + let effects_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_top(4) + .margin_bottom(4) + .halign(gtk::Align::Start) + .build(); + + let grayscale_btn = gtk::ToggleButton::builder() + .label("Grayscale") + .active(cfg.grayscale) + .tooltip_text("Convert to grayscale") + .build(); + grayscale_btn.update_property(&[ + gtk::accessible::Property::Label("Grayscale effect toggle"), + ]); + + let sepia_btn = gtk::ToggleButton::builder() + .label("Sepia") + .active(cfg.sepia) + .tooltip_text("Apply sepia tone") + .build(); + sepia_btn.update_property(&[ + gtk::accessible::Property::Label("Sepia effect toggle"), + ]); + + let sharpen_btn = gtk::ToggleButton::builder() + .label("Sharpen") + .active(cfg.sharpen) + .tooltip_text("Sharpen the image") + .build(); + sharpen_btn.update_property(&[ + gtk::accessible::Property::Label("Sharpen effect toggle"), + ]); + + effects_box.append(&grayscale_btn); + effects_box.append(&sepia_btn); + effects_box.append(&sharpen_btn); + effects_group.add(&effects_box); + controls.append(&effects_group); + + // --- Crop & Canvas group --- let crop_group = adw::PreferencesGroup::builder() .title("Crop and Canvas") .build(); let crop_row = adw::ComboRow::builder() .title("Crop to Aspect Ratio") - .subtitle("Crop images to a specific aspect ratio from center") + .subtitle("Crop from center to a specific ratio") + .use_subtitle(true) .build(); - let crop_model = gtk::StringList::new(&[ + crop_row.set_model(Some(>k::StringList::new(&[ "None", "1:1 (Square)", "4:3", @@ -68,8 +214,8 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { "9:16 (Portrait)", "3:4 (Portrait)", "2:3 (Portrait)", - ]); - crop_row.set_model(Some(&crop_model)); + ]))); + crop_row.set_list_factory(Some(&super::full_text_list_factory())); crop_row.set_selected(cfg.crop_aspect_ratio); let trim_row = adw::SwitchRow::builder() @@ -80,201 +226,481 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { let padding_row = adw::SpinRow::builder() .title("Canvas Padding") - .subtitle("Add uniform padding around the image (pixels)") + .subtitle("Add uniform padding (pixels)") .adjustment(>k::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0)) .build(); crop_group.add(&crop_row); crop_group.add(&trim_row); crop_group.add(&padding_row); - content.append(&crop_group); + controls.append(&crop_group); - // Image adjustments - let adjust_group = adw::PreferencesGroup::builder() - .title("Image Adjustments") + // Scrollable controls + let controls_scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .width_request(360) + .child(&controls) .build(); - let adjust_expander = adw::ExpanderRow::builder() - .title("Advanced Adjustments") - .subtitle("Brightness, contrast, saturation, effects") - .show_enable_switch(false) - .expanded(state.is_section_expanded("adjustments-advanced")) + // === Main layout: 60/40 side-by-side === + let main_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .vexpand(true) .build(); - { - let st = state.clone(); - adjust_expander.connect_expanded_notify(move |row| { - st.set_section_expanded("adjustments-advanced", row.is_expanded()); - }); - } + preview_box.set_width_request(400); + main_box.append(&preview_box); + main_box.append(&controls_scrolled); + outer.append(&main_box); - // Brightness slider (-100 to +100) - let brightness_row = adw::ActionRow::builder() - .title("Brightness") - .subtitle(format!("{}", cfg.brightness)) - .build(); - let brightness_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0); - brightness_scale.set_value(cfg.brightness as f64); - brightness_scale.set_hexpand(true); - brightness_scale.set_valign(gtk::Align::Center); - brightness_scale.set_size_request(200, -1); - brightness_scale.set_draw_value(false); - brightness_scale.update_property(&[ - gtk::accessible::Property::Label("Brightness adjustment, -100 to +100"), - ]); - brightness_row.add_suffix(&brightness_scale); - adjust_expander.add_row(&brightness_row); - - // Contrast slider (-100 to +100) - let contrast_row = adw::ActionRow::builder() - .title("Contrast") - .subtitle(format!("{}", cfg.contrast)) - .build(); - let contrast_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0); - contrast_scale.set_value(cfg.contrast as f64); - contrast_scale.set_hexpand(true); - contrast_scale.set_valign(gtk::Align::Center); - contrast_scale.set_size_request(200, -1); - contrast_scale.set_draw_value(false); - contrast_scale.update_property(&[ - gtk::accessible::Property::Label("Contrast adjustment, -100 to +100"), - ]); - contrast_row.add_suffix(&contrast_scale); - adjust_expander.add_row(&contrast_row); - - // Saturation slider (-100 to +100) - let saturation_row = adw::ActionRow::builder() - .title("Saturation") - .subtitle(format!("{}", cfg.saturation)) - .build(); - let saturation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0); - saturation_scale.set_value(cfg.saturation as f64); - saturation_scale.set_hexpand(true); - saturation_scale.set_valign(gtk::Align::Center); - saturation_scale.set_size_request(200, -1); - saturation_scale.set_draw_value(false); - saturation_scale.update_property(&[ - gtk::accessible::Property::Label("Saturation adjustment, -100 to +100"), - ]); - saturation_row.add_suffix(&saturation_scale); - adjust_expander.add_row(&saturation_row); - - // Sharpen after resize - let sharpen_row = adw::SwitchRow::builder() - .title("Sharpen after resize") - .subtitle("Apply subtle sharpening to resized images") - .active(cfg.sharpen) - .build(); - adjust_expander.add_row(&sharpen_row); - - // Grayscale - let grayscale_row = adw::SwitchRow::builder() - .title("Grayscale") - .subtitle("Convert images to black and white") - .active(cfg.grayscale) - .build(); - adjust_expander.add_row(&grayscale_row); - - // Sepia - let sepia_row = adw::SwitchRow::builder() - .title("Sepia") - .subtitle("Apply a warm vintage tone") - .active(cfg.sepia) - .build(); - adjust_expander.add_row(&sepia_row); - - adjust_group.add(&adjust_expander); - content.append(&adjust_group); + // Preview state + let preview_index: Rc> = Rc::new(Cell::new(0)); drop(cfg); - // Wire signals + // === 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() - 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); + } + + // === Wire signals === + { let jc = state.job_config.clone(); + enable_row.connect_active_notify(move |row| { + jc.borrow_mut().adjustments_enabled = row.is_active(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); rotate_row.connect_selected_notify(move |row| { jc.borrow_mut().rotation = row.selected(); + up(); }); } { let jc = state.job_config.clone(); + let up = update_preview.clone(); flip_row.connect_selected_notify(move |row| { jc.borrow_mut().flip = row.selected(); + up(); }); } + + // Shared debounce counter for slider-driven previews + let slider_debounce: Rc> = Rc::new(Cell::new(0)); + + // Brightness { let jc = state.job_config.clone(); - crop_row.connect_selected_notify(move |row| { - jc.borrow_mut().crop_aspect_ratio = row.selected(); - }); - } - { - let jc = state.job_config.clone(); - trim_row.connect_active_notify(move |row| { - jc.borrow_mut().trim_whitespace = row.is_active(); - }); - } - { - let jc = state.job_config.clone(); - padding_row.connect_value_notify(move |row| { - jc.borrow_mut().canvas_padding = row.value() as u32; - }); - } - { - let jc = state.job_config.clone(); - let label = brightness_row; + let row = brightness_row.clone(); + let up = update_preview.clone(); + let rst = brightness_reset.clone(); + let did = slider_debounce.clone(); brightness_scale.connect_value_changed(move |scale| { let val = scale.value().round() as i32; jc.borrow_mut().brightness = val; - label.set_subtitle(&format!("{}", val)); + row.set_subtitle(&format!("{}", val)); + rst.set_sensitive(val != 0); + let up = up.clone(); + let did = did.clone(); + let id = did.get().wrapping_add(1); + did.set(id); + glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { + if did.get() == id { + up(); + } + }); }); } + { + let scale = brightness_scale.clone(); + brightness_reset.connect_clicked(move |_| { + scale.set_value(0.0); + }); + } + + // Contrast { let jc = state.job_config.clone(); - let label = contrast_row; + let row = contrast_row.clone(); + let up = update_preview.clone(); + let rst = contrast_reset.clone(); + let did = slider_debounce.clone(); contrast_scale.connect_value_changed(move |scale| { let val = scale.value().round() as i32; jc.borrow_mut().contrast = val; - label.set_subtitle(&format!("{}", val)); + row.set_subtitle(&format!("{}", val)); + rst.set_sensitive(val != 0); + let up = up.clone(); + let did = did.clone(); + let id = did.get().wrapping_add(1); + did.set(id); + glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { + if did.get() == id { + up(); + } + }); }); } + { + let scale = contrast_scale.clone(); + contrast_reset.connect_clicked(move |_| { + scale.set_value(0.0); + }); + } + + // Saturation { let jc = state.job_config.clone(); - let label = saturation_row; + let row = saturation_row.clone(); + let up = update_preview.clone(); + let rst = saturation_reset.clone(); + let did = slider_debounce.clone(); saturation_scale.connect_value_changed(move |scale| { let val = scale.value().round() as i32; jc.borrow_mut().saturation = val; - label.set_subtitle(&format!("{}", val)); + row.set_subtitle(&format!("{}", val)); + rst.set_sensitive(val != 0); + let up = up.clone(); + let did = did.clone(); + let id = did.get().wrapping_add(1); + did.set(id); + glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { + if did.get() == id { + up(); + } + }); + }); + } + { + let scale = saturation_scale.clone(); + saturation_reset.connect_clicked(move |_| { + scale.set_value(0.0); + }); + } + + // Effects toggle buttons + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + grayscale_btn.connect_toggled(move |btn| { + jc.borrow_mut().grayscale = btn.is_active(); + up(); }); } { let jc = state.job_config.clone(); - sharpen_row.connect_active_notify(move |row| { - jc.borrow_mut().sharpen = row.is_active(); + let up = update_preview.clone(); + sepia_btn.connect_toggled(move |btn| { + jc.borrow_mut().sepia = btn.is_active(); + up(); }); } { let jc = state.job_config.clone(); - grayscale_row.connect_active_notify(move |row| { - jc.borrow_mut().grayscale = row.is_active(); + let up = update_preview.clone(); + sharpen_btn.connect_toggled(move |btn| { + jc.borrow_mut().sharpen = btn.is_active(); + up(); + }); + } + + // Crop & Canvas + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + crop_row.connect_selected_notify(move |row| { + jc.borrow_mut().crop_aspect_ratio = row.selected(); + up(); }); } { let jc = state.job_config.clone(); - sepia_row.connect_active_notify(move |row| { - jc.borrow_mut().sepia = row.is_active(); + let up = update_preview.clone(); + trim_row.connect_active_notify(move |row| { + jc.borrow_mut().trim_whitespace = row.is_active(); + up(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + let did = slider_debounce.clone(); + padding_row.connect_value_notify(move |row| { + jc.borrow_mut().canvas_padding = row.value() as u32; + let up = up.clone(); + let did = did.clone(); + let id = did.get().wrapping_add(1); + did.set(id); + glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { + if did.get() == id { + up(); + } + }); }); } - scrolled.set_child(Some(&content)); - - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&scrolled) - .build(); - - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title("Adjustments") .tag("step-adjustments") - .child(&clamp) - .build() + .child(&outer) + .build(); + + // Refresh preview and sensitivity when navigating to this page + { + let up = update_preview.clone(); + let lf = state.loaded_files.clone(); + let ctrl = controls.clone(); + page.connect_map(move |_| { + ctrl.set_sensitive(!lf.borrow().is_empty()); + up(); + }); + } + + page } diff --git a/pixstrip-gtk/src/steps/step_compress.rs b/pixstrip-gtk/src/steps/step_compress.rs index ac02a96..4a6bb12 100644 --- a/pixstrip-gtk/src/steps/step_compress.rs +++ b/pixstrip-gtk/src/steps/step_compress.rs @@ -1,10 +1,20 @@ use adw::prelude::*; use gtk::glib; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use crate::app::AppState; -use pixstrip_core::types::QualityPreset; +use crate::utils::format_size; +use pixstrip_core::types::{ImageFormat, QualityPreset}; + +/// Which format and quality to use for the compressed side of the preview. +#[derive(Clone, Copy)] +enum PreviewCompression { + Jpeg(u8), + Png(u8), + WebP(u8), + Avif(u8), +} pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { let scrolled = gtk::ScrolledWindow::builder() @@ -34,35 +44,37 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { enable_group.add(&enable_row); content.append(&enable_group); - // Quality slider + // --- Quality slider (1-8 range: Low to Maximum) --- let quality_group = adw::PreferencesGroup::builder() .title("Quality Level") .description("Higher quality means larger files. This sets the overall quality target.") .build(); let initial_val = match cfg.quality_preset { - QualityPreset::WebOptimized => 1.0, - QualityPreset::Low => 2.0, + QualityPreset::Low | QualityPreset::WebOptimized => 1.0, QualityPreset::Medium => 3.0, - QualityPreset::High => 4.0, - QualityPreset::Maximum => 5.0, + QualityPreset::High => 5.0, + QualityPreset::Maximum => 8.0, }; let quality_scale = gtk::Scale::builder() .orientation(gtk::Orientation::Horizontal) - .adjustment(>k::Adjustment::new(initial_val, 1.0, 5.0, 1.0, 1.0, 0.0)) + .adjustment(>k::Adjustment::new(initial_val, 1.0, 8.0, 1.0, 1.0, 0.0)) .draw_value(false) .hexpand(true) .build(); quality_scale.set_round_digits(0); - quality_scale.add_mark(1.0, gtk::PositionType::Bottom, Some("Web")); - quality_scale.add_mark(2.0, gtk::PositionType::Bottom, Some("Low")); + quality_scale.add_mark(1.0, gtk::PositionType::Bottom, Some("Low")); + quality_scale.add_mark(2.0, gtk::PositionType::Bottom, None); quality_scale.add_mark(3.0, gtk::PositionType::Bottom, Some("Medium")); - quality_scale.add_mark(4.0, gtk::PositionType::Bottom, Some("High")); - quality_scale.add_mark(5.0, gtk::PositionType::Bottom, Some("Maximum")); + quality_scale.add_mark(4.0, gtk::PositionType::Bottom, None); + quality_scale.add_mark(5.0, gtk::PositionType::Bottom, Some("High")); + quality_scale.add_mark(6.0, gtk::PositionType::Bottom, None); + quality_scale.add_mark(7.0, gtk::PositionType::Bottom, None); + quality_scale.add_mark(8.0, gtk::PositionType::Bottom, Some("Max")); quality_scale.update_property(&[ - gtk::accessible::Property::Label("Compression quality, from Web Optimized to Maximum"), + gtk::accessible::Property::Label("Compression quality, from Low to Maximum"), ]); let quality_label = gtk::Label::builder() @@ -85,10 +97,116 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { quality_group.add(&quality_box); content.append(&quality_group); - // Compression preview - Squoosh-style split comparison + // --- Per-format quality (collapsible advanced section) --- + let performat_group = adw::PreferencesGroup::builder() + .title("Per-Format Quality") + .build(); + + let performat_expander = adw::ExpanderRow::builder() + .title("Per-Format Quality") + .subtitle("Fine-tune quality for each output format individually") + .show_enable_switch(false) + .expanded(state.is_section_expanded("compress-performat")) + .build(); + + { + let st = state.clone(); + performat_expander.connect_expanded_notify(move |row| { + st.set_section_expanded("compress-performat", row.is_expanded()); + }); + } + + // All per-format scales: no marks, uniform fixed width, value shown in subtitle + let setup_scale = |scale: >k::Scale| { + scale.set_draw_value(false); + scale.set_hexpand(false); + scale.set_valign(gtk::Align::Center); + scale.set_width_request(200); + }; + + // JPEG quality (1-100) + let jpeg_row = adw::ActionRow::builder() + .title("JPEG Quality") + .subtitle(&format!("{}", cfg.jpeg_quality)) + .build(); + let jpeg_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0); + jpeg_scale.set_value(cfg.jpeg_quality as f64); + setup_scale(&jpeg_scale); + jpeg_scale.update_property(&[gtk::accessible::Property::Label("JPEG quality, 1 to 100")]); + jpeg_row.add_suffix(&jpeg_scale); + + // PNG compression level (1-6) + let png_row = adw::ActionRow::builder() + .title("PNG Compression") + .subtitle(&format!("{}", cfg.png_level)) + .build(); + let png_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 6.0, 1.0); + png_scale.set_value(cfg.png_level as f64); + png_scale.set_round_digits(0); + setup_scale(&png_scale); + png_scale.update_property(&[gtk::accessible::Property::Label("PNG compression level, 1 to 6")]); + png_row.add_suffix(&png_scale); + + // WebP quality (1-100) + let webp_row = adw::ActionRow::builder() + .title("WebP Quality") + .subtitle(&format!("{}", cfg.webp_quality)) + .build(); + let webp_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0); + webp_scale.set_value(cfg.webp_quality as f64); + setup_scale(&webp_scale); + webp_scale.update_property(&[gtk::accessible::Property::Label("WebP quality, 1 to 100")]); + webp_row.add_suffix(&webp_scale); + + // WebP encoding effort (0-6) + let webp_effort_row = adw::ActionRow::builder() + .title("WebP Encoding Effort") + .subtitle(&format!("{}", cfg.webp_effort)) + .build(); + let webp_effort_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 6.0, 1.0); + webp_effort_scale.set_value(cfg.webp_effort as f64); + webp_effort_scale.set_round_digits(0); + setup_scale(&webp_effort_scale); + webp_effort_scale.update_property(&[gtk::accessible::Property::Label("WebP encoding effort, 0 to 6")]); + webp_effort_row.add_suffix(&webp_effort_scale); + + // AVIF quality (1-100) + let avif_row = adw::ActionRow::builder() + .title("AVIF Quality") + .subtitle(&format!("{}", cfg.avif_quality)) + .build(); + let avif_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0); + avif_scale.set_value(cfg.avif_quality as f64); + setup_scale(&avif_scale); + avif_scale.update_property(&[gtk::accessible::Property::Label("AVIF quality, 1 to 100")]); + avif_row.add_suffix(&avif_scale); + + // AVIF encoding speed (1-10) + let avif_speed_row = adw::ActionRow::builder() + .title("AVIF Encoding Speed") + .subtitle(&format!("{}", cfg.avif_speed)) + .build(); + let avif_speed_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 10.0, 1.0); + avif_speed_scale.set_value(cfg.avif_speed as f64); + avif_speed_scale.set_round_digits(0); + setup_scale(&avif_speed_scale); + avif_speed_scale.update_property(&[gtk::accessible::Property::Label("AVIF encoding speed, 1 to 10")]); + avif_speed_row.add_suffix(&avif_speed_scale); + + performat_expander.add_row(&jpeg_row); + performat_expander.add_row(&png_row); + performat_expander.add_row(&webp_row); + performat_expander.add_row(&webp_effort_row); + performat_expander.add_row(&avif_row); + performat_expander.add_row(&avif_speed_row); + + performat_group.add(&performat_expander); + content.append(&performat_group); + + // --- Compression preview - Squoosh-style split comparison --- let preview_group = adw::PreferencesGroup::builder() .title("Quality Preview") - .description("Drag the divider to compare original and compressed") + .description("Drag the divider to compare. Drag the image to pan.") .build(); // Size labels above the preview @@ -110,25 +228,41 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { size_box.set_start_widget(Some(&original_size_label)); size_box.set_end_widget(Some(&compressed_size_label)); - // Drawing area for the split preview - let divider_pos = Rc::new(RefCell::new(0.5f64)); // 0.0 to 1.0 + // State for the split preview + let divider_pos = Rc::new(RefCell::new(0.5f64)); let original_pixbuf: Rc>> = Rc::new(RefCell::new(None)); let compressed_pixbuf: Rc>> = Rc::new(RefCell::new(None)); - let dragging = Rc::new(RefCell::new(false)); + let divider_dragging = Rc::new(Cell::new(false)); + let image_dragging = Rc::new(Cell::new(false)); + + // Pan state for cover-fill preview + let pan_x: Rc> = Rc::new(Cell::new(0.0)); + let pan_y: Rc> = Rc::new(Cell::new(0.0)); + let drag_start_pan_x: Rc> = Rc::new(Cell::new(0.0)); + let drag_start_pan_y: Rc> = Rc::new(Cell::new(0.0)); + let img_dims: Rc> = Rc::new(Cell::new((0.0, 0.0))); + + // Current preview compression mode + let preview_comp: Rc> = Rc::new(Cell::new( + PreviewCompression::Jpeg(cfg.jpeg_quality), + )); let preview_drawing = gtk::DrawingArea::builder() - .height_request(250) + .height_request(400) .hexpand(true) + .vexpand(true) .build(); preview_drawing.update_property(&[ - gtk::accessible::Property::Label("Compression quality comparison. Drag the vertical divider to compare original and compressed image."), + gtk::accessible::Property::Label("Compression quality comparison. Drag the vertical divider to compare original and compressed image. Drag elsewhere to pan."), ]); - // Draw function + // Draw function - cover fill with pan support { let dp = divider_pos.clone(); let orig = original_pixbuf.clone(); let comp = compressed_pixbuf.clone(); + let px = pan_x.clone(); + let py = pan_y.clone(); preview_drawing.set_draw_func(move |_drawing, cr, width, height| { let w = width as f64; let h = height as f64; @@ -140,45 +274,46 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { let pos = *dp.borrow(); let divider_x = w * pos; - // Helper to draw a pixbuf scaled to fit the area + // Helper to draw a pixbuf with cover fill and pan offset let draw_pixbuf = |cr: >k::cairo::Context, pixbuf: >k::gdk_pixbuf::Pixbuf, clip_x: f64, clip_w: f64| { let tw = pixbuf.width() as f64; let th = pixbuf.height() as f64; - let scale = (w / tw).min(h / th); - let ox = (w - tw * scale) / 2.0; - let oy = (h - th * scale) / 2.0; + let scale = (w / tw).max(h / th); + let max_pan_x = ((tw * scale - w) / 2.0).max(0.0); + let max_pan_y = ((th * scale - h) / 2.0).max(0.0); + let clamped_px = px.get().clamp(-max_pan_x, max_pan_x); + let clamped_py = py.get().clamp(-max_pan_y, max_pan_y); + let ox = (w - tw * scale) / 2.0 + clamped_px; + let oy = (h - th * scale) / 2.0 + clamped_py; - cr.save().unwrap(); + let _ = cr.save(); cr.rectangle(clip_x, 0.0, clip_w, h); cr.clip(); cr.translate(ox, oy); cr.scale(scale, scale); cr.set_source_pixbuf(pixbuf, 0.0, 0.0); let _ = cr.paint(); - cr.restore().unwrap(); + let _ = cr.restore(); }; - // Draw original on left side if let Some(ref pb) = *orig.borrow() { draw_pixbuf(cr, pb, 0.0, divider_x); } - - // Draw compressed on right side if let Some(ref pb) = *comp.borrow() { draw_pixbuf(cr, pb, divider_x, w - divider_x); } - // Draw divider line + // Divider line cr.set_source_rgba(1.0, 1.0, 1.0, 0.9); cr.set_line_width(2.0); cr.move_to(divider_x, 0.0); cr.line_to(divider_x, h); let _ = cr.stroke(); - // Draw handle circle + // Handle circle cr.arc(divider_x, h / 2.0, 12.0, 0.0, std::f64::consts::TAU); cr.set_source_rgba(1.0, 1.0, 1.0, 0.95); let _ = cr.fill_preserve(); @@ -186,88 +321,122 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { cr.set_line_width(1.5); let _ = cr.stroke(); - // Draw arrows on handle + // Arrows on handle cr.set_source_rgba(0.3, 0.3, 0.3, 1.0); cr.set_line_width(2.0); - // Left arrow cr.move_to(divider_x - 4.0, h / 2.0); cr.line_to(divider_x - 8.0, h / 2.0); let _ = cr.stroke(); - // Right arrow cr.move_to(divider_x + 4.0, h / 2.0); cr.line_to(divider_x + 8.0, h / 2.0); let _ = cr.stroke(); - // Labels on each side - cr.set_source_rgba(1.0, 1.0, 1.0, 0.7); + // Labels with background pills for readability cr.set_font_size(11.0); + let draw_label = |cr: >k::cairo::Context, text: &str, x: f64, y: f64| { + let Ok(extents) = cr.text_extents(text) else { return }; + let pad_x = 6.0; + let pad_y = 4.0; + let rx = x - pad_x; + let ry = y - extents.height() - pad_y; + let rw = extents.width() + pad_x * 2.0; + let rh = extents.height() + pad_y * 2.0; + let radius = 4.0; + + cr.new_sub_path(); + cr.arc(rx + rw - radius, ry + radius, radius, -std::f64::consts::FRAC_PI_2, 0.0); + cr.arc(rx + rw - radius, ry + rh - radius, radius, 0.0, std::f64::consts::FRAC_PI_2); + cr.arc(rx + radius, ry + rh - radius, radius, std::f64::consts::FRAC_PI_2, std::f64::consts::PI); + cr.arc(rx + radius, ry + radius, radius, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2); + cr.close_path(); + cr.set_source_rgba(0.0, 0.0, 0.0, 0.6); + let _ = cr.fill(); + + cr.set_source_rgba(1.0, 1.0, 1.0, 0.95); + cr.move_to(x, y); + let _ = cr.show_text(text); + }; if divider_x > 60.0 { - cr.move_to(8.0, 16.0); - let _ = cr.show_text("Original"); + draw_label(cr, "Original", 8.0, 18.0); } if w - divider_x > 80.0 { - cr.move_to(divider_x + 8.0, 16.0); - let _ = cr.show_text("Compressed"); + draw_label(cr, "Compressed", divider_x + 8.0, 18.0); } }); } - // Drag gesture for the divider + // Drag gesture: near divider moves divider, elsewhere pans image let drag_gesture = gtk::GestureDrag::new(); { let dp = divider_pos.clone(); - let dr = dragging.clone(); + let dd = divider_dragging.clone(); + let id = image_dragging.clone(); let drawing = preview_drawing.clone(); + let dspx = drag_start_pan_x.clone(); + let dspy = drag_start_pan_y.clone(); + let px = pan_x.clone(); + let py = pan_y.clone(); drag_gesture.connect_drag_begin(move |_, x, _| { let w = drawing.width() as f64; let current = *dp.borrow() * w; - // Only start drag if near the divider if (x - current).abs() < 30.0 { - *dr.borrow_mut() = true; + dd.set(true); + id.set(false); + } else { + dd.set(false); + id.set(true); + dspx.set(px.get()); + dspy.set(py.get()); } }); } { let dp = divider_pos.clone(); - let dr = dragging.clone(); + let dd = divider_dragging.clone(); + let id = image_dragging.clone(); let drawing = preview_drawing.clone(); - drag_gesture.connect_drag_update(move |gesture, offset_x, _| { - if !*dr.borrow() { - return; - } - if let Some((start_x, _)) = gesture.start_point() { + let px = pan_x.clone(); + let py = pan_y.clone(); + let dspx = drag_start_pan_x.clone(); + let dspy = drag_start_pan_y.clone(); + let dims = img_dims.clone(); + drag_gesture.connect_drag_update(move |gesture, offset_x, offset_y| { + if dd.get() { + if let Some((start_x, _)) = gesture.start_point() { + let w = drawing.width() as f64; + if w > 0.0 { + let new_pos = ((start_x + offset_x) / w).clamp(0.05, 0.95); + *dp.borrow_mut() = new_pos; + drawing.queue_draw(); + } + } + } else if id.get() { + let (tw, th) = dims.get(); let w = drawing.width() as f64; - if w > 0.0 { - let new_pos = ((start_x + offset_x) / w).clamp(0.05, 0.95); - *dp.borrow_mut() = new_pos; + let h = drawing.height() as f64; + if tw > 0.0 && th > 0.0 && w > 0.0 && h > 0.0 { + let scale = (w / tw).max(h / th); + let max_px = ((tw * scale - w) / 2.0).max(0.0); + let max_py = ((th * scale - h) / 2.0).max(0.0); + let new_x = (dspx.get() + offset_x).clamp(-max_px, max_px); + let new_y = (dspy.get() + offset_y).clamp(-max_py, max_py); + px.set(new_x); + py.set(new_y); drawing.queue_draw(); } } }); } { - let dr = dragging.clone(); + let dd = divider_dragging.clone(); + let id = image_dragging.clone(); drag_gesture.connect_drag_end(move |_, _, _| { - *dr.borrow_mut() = false; + dd.set(false); + id.set(false); }); } preview_drawing.add_controller(drag_gesture); - // Click gesture to set divider position directly - let click_gesture = gtk::GestureClick::new(); - { - let dp = divider_pos.clone(); - let drawing = preview_drawing.clone(); - click_gesture.connect_released(move |_, _, x, _| { - let w = drawing.width() as f64; - if w > 0.0 { - *dp.borrow_mut() = (x / w).clamp(0.05, 0.95); - drawing.queue_draw(); - } - }); - } - preview_drawing.add_controller(click_gesture); - let preview_frame = gtk::Frame::builder() .halign(gtk::Align::Fill) .build(); @@ -294,10 +463,9 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { let preview_index: Rc> = Rc::new(RefCell::new(0)); - // Populate thumbnails from loaded files { let files = state.loaded_files.borrow(); - let max_thumbs = files.len().min(10); // Show at most 10 thumbnails + let max_thumbs = files.len().min(10); for i in 0..max_thumbs { let pic = gtk::Picture::builder() .content_fit(gtk::ContentFit::Cover) @@ -323,7 +491,6 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { thumb_scrolled.set_visible(max_thumbs > 1); } - // "No image loaded" placeholder let no_image_label = gtk::Label::builder() .label("Add images first to see compression preview") .css_classes(["dim-label"]) @@ -335,80 +502,10 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { content.append(&preview_group); - // Advanced options in expander - let advanced_group = adw::PreferencesGroup::builder() - .title("Advanced Options") - .build(); - - let advanced_expander = adw::ExpanderRow::builder() - .title("Per-Format Quality") - .subtitle("Fine-tune quality for each format individually") - .expanded(state.is_section_expanded("compress-advanced")) - .build(); - - { - let st = state.clone(); - advanced_expander.connect_expanded_notify(move |row| { - st.set_section_expanded("compress-advanced", row.is_expanded()); - }); - } - - let jpeg_row = adw::SpinRow::builder() - .title("JPEG Quality") - .subtitle("1-100, higher is better quality") - .adjustment(>k::Adjustment::new(cfg.jpeg_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) - .build(); - - let png_row = adw::SpinRow::builder() - .title("PNG Compression Level") - .subtitle("1-6, higher is slower but smaller") - .adjustment(>k::Adjustment::new(cfg.png_level as f64, 1.0, 6.0, 1.0, 1.0, 0.0)) - .build(); - - let webp_row = adw::SpinRow::builder() - .title("WebP Quality") - .subtitle("1-100, higher is better quality") - .adjustment(>k::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) - .build(); - - let avif_row = adw::SpinRow::builder() - .title("AVIF Quality") - .subtitle("1-100, higher is better quality") - .adjustment(>k::Adjustment::new(cfg.avif_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) - .build(); - - let progressive_row = adw::SwitchRow::builder() - .title("Progressive JPEG") - .subtitle("Loads gradually, slightly larger files") - .active(cfg.progressive_jpeg) - .build(); - - let webp_effort_row = adw::SpinRow::builder() - .title("WebP Encoding Effort") - .subtitle("0-6, higher is slower but smaller files") - .adjustment(>k::Adjustment::new(cfg.webp_effort as f64, 0.0, 6.0, 1.0, 1.0, 0.0)) - .build(); - - let avif_speed_row = adw::SpinRow::builder() - .title("AVIF Encoding Speed") - .subtitle("1-10, lower is slower but better compression") - .adjustment(>k::Adjustment::new(cfg.avif_speed as f64, 1.0, 10.0, 1.0, 1.0, 0.0)) - .build(); - - advanced_expander.add_row(&jpeg_row); - advanced_expander.add_row(&progressive_row); - advanced_expander.add_row(&png_row); - advanced_expander.add_row(&webp_row); - advanced_expander.add_row(&webp_effort_row); - advanced_expander.add_row(&avif_row); - advanced_expander.add_row(&avif_speed_row); - - advanced_group.add(&advanced_expander); - content.append(&advanced_group); - drop(cfg); - // Wire signals + // --- Wire signals --- + { let jc = state.job_config.clone(); enable_row.connect_active_notify(move |row| { @@ -416,7 +513,8 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { }); } - // Helper to load preview with a given quality preset + // Preview update closure - reads compression mode from preview_comp + let preview_gen: Rc> = Rc::new(Cell::new(0)); let update_preview = { let files = state.loaded_files.clone(); let orig_pb = original_pixbuf.clone(); @@ -425,10 +523,14 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { let orig_label = original_size_label.clone(); let comp_label = compressed_size_label.clone(); let no_img_label = no_image_label.clone(); - let jc = state.job_config.clone(); let pidx = preview_index.clone(); + let dims = img_dims.clone(); + let px = pan_x.clone(); + let py = pan_y.clone(); + let pc = preview_comp.clone(); + let bind_gen = preview_gen.clone(); - Rc::new(move || { + Rc::new(move |reset_pan: bool| { let loaded = files.borrow(); if loaded.is_empty() { no_img_label.set_visible(true); @@ -436,28 +538,37 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { } no_img_label.set_visible(false); - // Pick the selected preview image - let idx = *pidx.borrow(); - let sample_path = loaded.get(idx).cloned().unwrap_or_else(|| loaded[0].clone()); - let cfg = jc.borrow(); - let preset = cfg.quality_preset; - drop(cfg); + let idx = (*pidx.borrow()).min(loaded.len().saturating_sub(1)); + let sample_path = loaded[idx].clone(); + let comp = pc.get(); + + if reset_pan { + px.set(0.0); + py.set(0.0); + } + + let my_gen = bind_gen.get().wrapping_add(1); + bind_gen.set(my_gen); + let gen_check = bind_gen.clone(); let orig_pb = orig_pb.clone(); let comp_pb = comp_pb.clone(); let drawing = drawing.clone(); let orig_label = orig_label.clone(); let comp_label = comp_label.clone(); + let dims = dims.clone(); - // Load and compress in a background thread let (tx, rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { - let result = generate_preview(&sample_path, preset); + let result = generate_preview(&sample_path, comp); 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(result) => { match result { @@ -466,8 +577,10 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { compressed_bytes, original_size, compressed_size, + format_label, } => { if let Ok(pb) = load_pixbuf_from_bytes(&original_bytes) { + dims.set((pb.width() as f64, pb.height() as f64)); *orig_pb.borrow_mut() = Some(pb); } if let Ok(pb) = load_pixbuf_from_bytes(&compressed_bytes) { @@ -478,16 +591,21 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { "Original: {}", format_size(original_size) )); - let savings = if original_size > 0 { - ((1.0 - compressed_size as f64 / original_size as f64) * 100.0) - as i32 + let savings_pct = if original_size > 0 { + (1.0 - compressed_size as f64 / original_size as f64) * 100.0 } else { - 0 + 0.0 + }; + let savings_text = if savings_pct >= 0.0 { + format!("-{:.0}%", savings_pct) + } else { + format!("+{:.0}%", -savings_pct) }; comp_label.set_label(&format!( - "Compressed: {} (-{}%)", + "{}: {} ({})", + format_label, format_size(compressed_size), - savings + savings_text )); drawing.queue_draw(); @@ -506,7 +624,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { }) }; - // Wire thumbnail buttons to switch preview image + // Wire thumbnail buttons { let mut child = thumb_box.first_child(); let mut idx = 0usize; @@ -518,8 +636,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { let current_idx = idx; btn.connect_clicked(move |_| { *pidx.borrow_mut() = current_idx; - up(); - // Update highlight on thumbnails + up(true); let mut c = tb.first_child(); let mut j = 0usize; while let Some(w) = c { @@ -542,96 +659,204 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { } } - // Trigger initial preview load + // Initial preview load { let up = update_preview.clone(); glib::idle_add_local_once(move || { - up(); + up(true); }); } + // Main quality slider: update preset, sync per-format sliders, preview as JPEG { let jc = state.job_config.clone(); let label = quality_label; - let up = update_preview; + let up = update_preview.clone(); + let pc = preview_comp.clone(); + let js = jpeg_scale.clone(); + let ps = png_scale.clone(); + let ws = webp_scale.clone(); + let wes = webp_effort_scale.clone(); + let avs = avif_scale.clone(); + let ass = avif_speed_scale.clone(); quality_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u32; - let mut c = jc.borrow_mut(); - c.quality_preset = match val { - 1 => QualityPreset::WebOptimized, - 2 => QualityPreset::Low, - 3 => QualityPreset::Medium, - 4 => QualityPreset::High, + let preset = match val { + 1 | 2 => QualityPreset::Low, + 3 | 4 => QualityPreset::Medium, + 5 | 6 => QualityPreset::High, _ => QualityPreset::Maximum, }; + jc.borrow_mut().quality_preset = preset; label.set_label(&quality_description(val)); - drop(c); - up(); + // Sync per-format sliders (their handlers update job_config) + js.set_value(preset.jpeg_quality() as f64); + ps.set_value(preset.png_level() as f64); + ws.set_value(preset.webp_quality() as f64); + wes.set_value(preset.webp_effort() as f64); + avs.set_value(preset.avif_quality() as f64); + ass.set_value(preset.avif_speed() as f64); + // Preview as JPEG at the preset quality + pc.set(PreviewCompression::Jpeg(preset.jpeg_quality())); + up(false); + }); + } + + // Per-format slider handlers: update config, set preview to that format, refresh + { + let jc = state.job_config.clone(); + let row = jpeg_row.clone(); + let pc = preview_comp.clone(); + let up = update_preview.clone(); + jpeg_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as u8; + jc.borrow_mut().jpeg_quality = val; + row.set_subtitle(&format!("{}", val)); + pc.set(PreviewCompression::Jpeg(val)); + up(false); }); } { let jc = state.job_config.clone(); - jpeg_row.connect_value_notify(move |row| { - jc.borrow_mut().jpeg_quality = row.value() as u8; + let row = png_row.clone(); + let pc = preview_comp.clone(); + let up = update_preview.clone(); + png_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as u8; + jc.borrow_mut().png_level = val; + row.set_subtitle(&format!("{}", val)); + pc.set(PreviewCompression::Png(val)); + up(false); }); } { let jc = state.job_config.clone(); - png_row.connect_value_notify(move |row| { - jc.borrow_mut().png_level = row.value() as u8; + let row = webp_row.clone(); + let pc = preview_comp.clone(); + let up = update_preview.clone(); + webp_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as u8; + jc.borrow_mut().webp_quality = val; + row.set_subtitle(&format!("{}", val)); + pc.set(PreviewCompression::WebP(val)); + up(false); }); } { let jc = state.job_config.clone(); - webp_row.connect_value_notify(move |row| { - jc.borrow_mut().webp_quality = row.value() as u8; + let row = webp_effort_row.clone(); + webp_effort_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as u8; + jc.borrow_mut().webp_effort = val; + row.set_subtitle(&format!("{}", val)); }); } { let jc = state.job_config.clone(); - avif_row.connect_value_notify(move |row| { - jc.borrow_mut().avif_quality = row.value() as u8; + let row = avif_row.clone(); + let pc = preview_comp.clone(); + let up = update_preview.clone(); + avif_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as u8; + jc.borrow_mut().avif_quality = val; + row.set_subtitle(&format!("{}", val)); + pc.set(PreviewCompression::Avif(val)); + up(false); }); } { let jc = state.job_config.clone(); - progressive_row.connect_active_notify(move |row| { - jc.borrow_mut().progressive_jpeg = row.is_active(); - }); - } - { - let jc = state.job_config.clone(); - webp_effort_row.connect_value_notify(move |row| { - jc.borrow_mut().webp_effort = row.value() as u8; - }); - } - { - let jc = state.job_config.clone(); - avif_speed_row.connect_value_notify(move |row| { - jc.borrow_mut().avif_speed = row.value() as u8; + let row = avif_speed_row.clone(); + avif_speed_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as u8; + jc.borrow_mut().avif_speed = val; + row.set_subtitle(&format!("{}", val)); }); } scrolled.set_child(Some(&content)); - let clamp = adw::Clamp::builder() - .maximum_size(600) + let page = adw::NavigationPage::builder() + .title("Compress") + .tag("step-compress") .child(&scrolled) .build(); - adw::NavigationPage::builder() - .title("Compress") - .tag("step-compress") - .child(&clamp) - .build() + // On page map: refresh preview and show/hide per-format rows + { + let up = update_preview.clone(); + let jc = state.job_config.clone(); + let pg = performat_group.clone(); + let jr = jpeg_row; + let pr = png_row; + let wr = webp_row; + let wer = webp_effort_row; + let ar = avif_row; + let asr = avif_speed_row; + page.connect_map(move |_| { + up(true); + + let cfg = jc.borrow(); + + let mut has_jpeg = false; + let mut has_png = false; + let mut has_webp = false; + let mut has_avif = false; + + match cfg.convert_format { + None => { + has_jpeg = true; + has_png = true; + has_webp = true; + has_avif = true; + } + Some(ImageFormat::Jpeg) => has_jpeg = true, + Some(ImageFormat::Png) => has_png = true, + Some(ImageFormat::WebP) => has_webp = true, + Some(ImageFormat::Avif) => has_avif = true, + Some(ImageFormat::Gif) | Some(ImageFormat::Tiff) => {} + } + + for (_, &choice_idx) in &cfg.format_mappings { + match choice_idx { + 0 => {} + 1 => { + has_jpeg = true; + has_png = true; + has_webp = true; + has_avif = true; + } + 2 => has_jpeg = true, + 3 => has_png = true, + 4 => has_webp = true, + 5 => has_avif = true, + _ => {} + } + } + + jr.set_visible(has_jpeg); + pr.set_visible(has_png); + wr.set_visible(has_webp); + wer.set_visible(has_webp); + ar.set_visible(has_avif); + asr.set_visible(has_avif); + + pg.set_visible(has_jpeg || has_png || has_webp || has_avif); + }); + } + + page } fn quality_description(val: u32) -> String { match val { - 1 => "Web Optimized - ~70-80% smaller files. Noticeable quality loss. Best for thumbnails and web previews.".into(), - 2 => "Low - ~50-60% smaller files. Some visible quality loss. Good for email attachments and quick sharing.".into(), + 1 => "Low - ~50-60% smaller files. Visible quality loss. Good for email attachments and quick sharing.".into(), + 2 => "Low+ - ~45-55% smaller files. Slightly better than Low, still compact.".into(), 3 => "Medium - ~30-40% smaller files. Good balance of quality and size. Recommended for most uses.".into(), - 4 => "High - ~15-25% smaller files. Minimal quality loss. Good for printing and high-quality output.".into(), + 4 => "Medium+ - ~25-35% smaller files. A step above Medium with better detail retention.".into(), + 5 => "High - ~15-25% smaller files. Minimal quality loss. Good for printing and high-quality output.".into(), + 6 => "High+ - ~10-20% smaller files. Very good quality with modest size reduction.".into(), + 7 => "Near Maximum - ~5-15% smaller files. Excellent quality, nearly indistinguishable from original.".into(), _ => "Maximum - ~5-10% smaller files. Best possible quality, largest files. Archival and professional use.".into(), } } @@ -642,11 +867,12 @@ enum PreviewResult { compressed_bytes: Vec, original_size: u64, compressed_size: u64, + format_label: String, }, Error(#[allow(dead_code)] String), } -fn generate_preview(path: &std::path::Path, preset: QualityPreset) -> PreviewResult { +fn generate_preview(path: &std::path::Path, comp: PreviewCompression) -> PreviewResult { let original_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); let img = match image::open(path) { @@ -654,7 +880,6 @@ fn generate_preview(path: &std::path::Path, preset: QualityPreset) -> PreviewRes Err(e) => return PreviewResult::Error(e.to_string()), }; - // Scale down for preview to avoid large memory usage let preview_img = if img.width() > 800 || img.height() > 800 { img.resize(800, 800, image::imageops::FilterType::Triangle) } else { @@ -663,31 +888,98 @@ fn generate_preview(path: &std::path::Path, preset: QualityPreset) -> PreviewRes // Encode original as PNG for lossless reference let mut orig_buf = Vec::new(); - let orig_cursor = std::io::Cursor::new(&mut orig_buf); if preview_img - .write_to(orig_cursor, image::ImageFormat::Png) + .write_to(&mut std::io::Cursor::new(&mut orig_buf), image::ImageFormat::Png) .is_err() { return PreviewResult::Error("Failed to encode original".into()); } - // Encode compressed as JPEG at the preset quality - let quality = preset.jpeg_quality(); + // Encode compressed in the requested format let mut comp_buf = Vec::new(); - let comp_cursor = std::io::Cursor::new(&mut comp_buf); + let format_label; - let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(comp_cursor, quality); - let rgb = preview_img.to_rgb8(); - if image::ImageEncoder::write_image( - encoder, - rgb.as_raw(), - rgb.width(), - rgb.height(), - image::ExtendedColorType::Rgb8, - ) - .is_err() - { - return PreviewResult::Error("JPEG compression failed".into()); + match comp { + PreviewCompression::Jpeg(quality) => { + format_label = format!("JPEG Q{}", quality); + let cursor = std::io::Cursor::new(&mut comp_buf); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(cursor, quality); + let rgb = preview_img.to_rgb8(); + if image::ImageEncoder::write_image( + encoder, + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) + .is_err() + { + return PreviewResult::Error("JPEG compression failed".into()); + } + } + PreviewCompression::Png(level) => { + format_label = format!("PNG L{}", level); + let cursor = std::io::Cursor::new(&mut comp_buf); + let ct = match level { + 1 => image::codecs::png::CompressionType::Fast, + 2 | 3 => image::codecs::png::CompressionType::Default, + _ => image::codecs::png::CompressionType::Best, + }; + let encoder = image::codecs::png::PngEncoder::new_with_quality( + cursor, + ct, + image::codecs::png::FilterType::Adaptive, + ); + let rgba = preview_img.to_rgba8(); + if image::ImageEncoder::write_image( + encoder, + rgba.as_raw(), + rgba.width(), + rgba.height(), + image::ExtendedColorType::Rgba8, + ) + .is_err() + { + return PreviewResult::Error("PNG compression failed".into()); + } + } + PreviewCompression::WebP(quality) => { + // image 0.25 only has lossless WebP encoding, so approximate with + // JPEG at equivalent quality for the visual preview + format_label = format!("WebP Q{} (approx)", quality); + let cursor = std::io::Cursor::new(&mut comp_buf); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(cursor, quality); + let rgb = preview_img.to_rgb8(); + if image::ImageEncoder::write_image( + encoder, + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) + .is_err() + { + return PreviewResult::Error("WebP preview failed".into()); + } + } + PreviewCompression::Avif(quality) => { + // AVIF encoding not available in image crate - approximate with JPEG + format_label = format!("AVIF Q{} (approx)", quality); + let cursor = std::io::Cursor::new(&mut comp_buf); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(cursor, quality); + let rgb = preview_img.to_rgb8(); + if image::ImageEncoder::write_image( + encoder, + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) + .is_err() + { + return PreviewResult::Error("AVIF preview failed".into()); + } + } } let compressed_size = comp_buf.len() as u64; @@ -697,6 +989,7 @@ fn generate_preview(path: &std::path::Path, preset: QualityPreset) -> PreviewRes compressed_bytes: comp_buf, original_size, compressed_size, + format_label, } } @@ -708,13 +1001,3 @@ fn load_pixbuf_from_bytes(bytes: &[u8]) -> Result String { - if bytes < 1024 { - format!("{} B", bytes) - } else if bytes < 1024 * 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) - } -} diff --git a/pixstrip-gtk/src/steps/step_convert.rs b/pixstrip-gtk/src/steps/step_convert.rs index 28d65d0..978b60e 100644 --- a/pixstrip-gtk/src/steps/step_convert.rs +++ b/pixstrip-gtk/src/steps/step_convert.rs @@ -1,7 +1,61 @@ use adw::prelude::*; +use std::cell::RefCell; +use std::collections::HashSet; +use std::path::PathBuf; +use std::rc::Rc; + use crate::app::AppState; use pixstrip_core::types::ImageFormat; +/// All format labels shown in the card grid. +/// Keep Original + 7 common formats = 8 cards. +const CARD_FORMATS: &[(&str, &str, &str, Option)] = &[ + ("Keep Original", "No conversion", "edit-copy-symbolic", None), + ("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)), + ("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)), + ("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)), + ("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), +]; + +/// Extra formats available only in the "Other Formats" dropdown: (short name, dropdown label) +const DROPDOWN_ONLY_FORMATS: &[(&str, &str)] = &[ + ("ICO", "ICO - Favicon and icon format, max 256x256 pixels"), + ("HDR", "HDR - Radiance HDR, high dynamic range imaging"), + ("PNM/PPM", "PNM/PPM - Portable anymap, simple uncompressed"), + ("TGA", "TGA - Targa, legacy game/video format"), + ("HEIC/HEIF", "HEIC/HEIF - Apple's photo format, excellent compression"), + ("JXL (JPEG XL)", "JXL (JPEG XL) - Next-gen JPEG successor, lossless and lossy"), + ("QOI", "QOI - Quite OK Image, fast lossless compression"), + ("EXR", "EXR - OpenEXR, VFX/film industry HDR standard"), + ("Farbfeld", "Farbfeld - Minimal 16-bit lossless, suckless format"), +]; + +/// Ordered list of format labels for the per-format mapping dropdowns. +/// Index 0 = "Same as above", 1 = "Keep Original", then common formats with descriptions. +const MAPPING_CHOICES: &[&str] = &[ + "Same as above - use the global output format", + "Keep Original - no conversion for this type", + "JPEG - universal photo format, lossy compression", + "PNG - lossless with transparency, larger files", + "WebP - modern web format, excellent compression", + "AVIF - next-gen, best compression, slower encode", + "GIF - 256 colors, animation support", + "TIFF - archival lossless, very large files", + "BMP - uncompressed bitmap, legacy format", + "ICO - favicon/icon format, max 256x256", + "HDR - Radiance high dynamic range", + "PNM/PPM - portable anymap, uncompressed", + "TGA - Targa, legacy game/video format", + "HEIC/HEIF - Apple photo format, great compression", + "JXL (JPEG XL) - next-gen JPEG successor", + "QOI - fast lossless compression", + "EXR - OpenEXR, VFX/film HDR standard", + "Farbfeld - minimal 16-bit lossless", +]; + pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { let scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) @@ -30,7 +84,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { enable_group.add(&enable_row); content.append(&enable_group); - // Visual format cards grid + // --- Visual format cards grid --- let cards_group = adw::PreferencesGroup::builder() .title("Output Format") .description("Choose the format all images will be converted to") @@ -47,25 +101,14 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { .margin_bottom(4) .build(); - let formats: &[(&str, &str, &str, Option)] = &[ - ("Keep Original", "No conversion", "edit-copy-symbolic", None), - ("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)), - ("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)), - ("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)), - ("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)), - ]; - - // Track which card should be initially selected let initial_format = cfg.convert_format; - for (name, desc, icon_name, _fmt) in formats { + for (name, desc, icon_name, _fmt) in CARD_FORMATS { let card = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(4) - .halign(gtk::Align::Center) - .valign(gtk::Align::Center) + .hexpand(true) + .vexpand(false) .build(); card.add_css_class("card"); card.set_size_request(130, 110); @@ -73,10 +116,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { let inner = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(4) - .margin_top(12) - .margin_bottom(12) - .margin_start(8) - .margin_end(8) + .margin_top(6) + .margin_bottom(6) + .margin_start(4) + .margin_end(4) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .vexpand(true) @@ -107,20 +150,20 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { flow.append(&card); } - // Select the initial card - let initial_idx = match initial_format { - None => 0, - Some(ImageFormat::Jpeg) => 1, - Some(ImageFormat::Png) => 2, - Some(ImageFormat::WebP) => 3, - Some(ImageFormat::Avif) => 4, - Some(ImageFormat::Gif) => 5, - Some(ImageFormat::Tiff) => 6, - }; - if let Some(child) = flow.child_at_index(initial_idx) { + // Select the initial card (only if format matches a card) + let initial_idx = card_index_for_format(initial_format); + if let Some(idx) = initial_idx + && let Some(child) = flow.child_at_index(idx) + { flow.select_child(&child); } + // Wrap FlowBox in a Clamp so cards don't stretch too wide when maximized + let clamp = adw::Clamp::builder() + .maximum_size(800) + .child(&flow) + .build(); + // Format info label (updates based on selection) let info_label = gtk::Label::builder() .label(format_info(cfg.convert_format)) @@ -132,161 +175,316 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { .margin_start(4) .build(); - cards_group.add(&flow); + cards_group.add(&clamp); cards_group.add(&info_label); - content.append(&cards_group); - // Advanced options expander - let advanced_group = adw::PreferencesGroup::builder() - .title("Advanced Options") + // --- "Other Formats" dropdown for less common formats --- + let other_group = adw::PreferencesGroup::builder() + .title("Other Formats") + .description("Less common formats not shown in the card grid") .build(); - let advanced_expander = adw::ExpanderRow::builder() - .title("Format Mapping") - .subtitle("Different input formats can convert to different outputs") - .show_enable_switch(false) - .expanded(state.is_section_expanded("convert-advanced")) + let other_combo = adw::ComboRow::builder() + .title("Select format") + .subtitle("Choosing a format here deselects the card grid") + .use_subtitle(true) .build(); - { - let st = state.clone(); - advanced_expander.connect_expanded_notify(move |row| { - st.set_section_expanded("convert-advanced", row.is_expanded()); - }); + // Build model: first entry is "(none)" for no selection, then extra formats with descriptions + let mut other_items: Vec<&str> = vec!["(none)"]; + for (_short, label) in DROPDOWN_ONLY_FORMATS { + other_items.push(label); } + other_combo.set_model(Some(>k::StringList::new(&other_items))); + other_combo.set_list_factory(Some(&super::full_text_list_factory())); + other_combo.set_selected(0); + + other_group.add(&other_combo); + content.append(&cards_group); + content.append(&other_group); + + // --- JPEG encoding options (only visible when JPEG or Keep Original is selected) --- + let jpeg_group = adw::PreferencesGroup::builder() + .title("JPEG Encoding") + .build(); let progressive_row = adw::SwitchRow::builder() .title("Progressive JPEG") - .subtitle("Loads gradually in browsers, slightly larger") + .subtitle("Loads gradually in browsers, slightly larger file size") .active(cfg.progressive_jpeg) .build(); - // Format mapping rows - per input format output selection - let mapping_header = adw::ActionRow::builder() - .title("Per-Format Mapping") - .subtitle("Override the output format for specific input types") + jpeg_group.add(&progressive_row); + + // Show only for JPEG (card index 1) or Keep Original (card index 0) + let shows_jpeg = matches!(initial_format, None | Some(ImageFormat::Jpeg)); + jpeg_group.set_visible(shows_jpeg); + + content.append(&jpeg_group); + + // --- Format mapping group (dynamic, rebuilt on page map) --- + let mapping_group = adw::PreferencesGroup::builder() + .title("Format Mapping") + .description("Override the output format for specific input types") .build(); - mapping_header.add_prefix(>k::Image::from_icon_name("preferences-system-symbolic")); - let output_choices = ["Same as above", "JPEG", "PNG", "WebP", "AVIF", "Keep Original"]; - - let jpeg_mapping = adw::ComboRow::builder() - .title("JPEG inputs") - .subtitle("Output format for JPEG source files") + // Container for dynamically added mapping rows + let mapping_list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) .build(); - jpeg_mapping.set_model(Some(>k::StringList::new(&output_choices))); - jpeg_mapping.set_selected(cfg.format_mapping_jpeg); - let png_mapping = adw::ComboRow::builder() - .title("PNG inputs") - .subtitle("Output format for PNG source files") - .build(); - png_mapping.set_model(Some(>k::StringList::new(&output_choices))); - png_mapping.set_selected(cfg.format_mapping_png); - - let webp_mapping = adw::ComboRow::builder() - .title("WebP inputs") - .subtitle("Output format for WebP source files") - .build(); - webp_mapping.set_model(Some(>k::StringList::new(&output_choices))); - webp_mapping.set_selected(cfg.format_mapping_webp); - - let tiff_mapping = adw::ComboRow::builder() - .title("TIFF inputs") - .subtitle("Output format for TIFF source files") - .build(); - tiff_mapping.set_model(Some(>k::StringList::new(&output_choices))); - tiff_mapping.set_selected(cfg.format_mapping_tiff); - - advanced_expander.add_row(&progressive_row); - advanced_expander.add_row(&mapping_header); - advanced_expander.add_row(&jpeg_mapping); - advanced_expander.add_row(&png_mapping); - advanced_expander.add_row(&webp_mapping); - advanced_expander.add_row(&tiff_mapping); - advanced_group.add(&advanced_expander); - content.append(&advanced_group); + mapping_group.add(&mapping_list); + content.append(&mapping_group); drop(cfg); - // Wire signals + // --- Wire signals --- + + // Enable toggle { let jc = state.job_config.clone(); enable_row.connect_active_notify(move |row| { jc.borrow_mut().convert_enabled = row.is_active(); }); } + + // Card grid selection { let jc = state.job_config.clone(); - let label = info_label; + let label = info_label.clone(); + let combo = other_combo.clone(); + let jg = jpeg_group.clone(); flow.connect_child_activated(move |_flow, child| { let idx = child.index() as usize; let mut c = jc.borrow_mut(); - c.convert_format = match idx { - 1 => Some(ImageFormat::Jpeg), - 2 => Some(ImageFormat::Png), - 3 => Some(ImageFormat::WebP), - 4 => Some(ImageFormat::Avif), - 5 => Some(ImageFormat::Gif), - 6 => Some(ImageFormat::Tiff), - _ => None, - }; + c.convert_format = format_for_card_index(idx); label.set_label(&format_info(c.convert_format)); + // Deselect the "Other Formats" dropdown when a card is picked + combo.set_selected(0); + // Progressive JPEG only relevant for JPEG or Keep Original + jg.set_visible(idx == 0 || idx == 1); }); } + + // "Other Formats" dropdown selection + { + let jc = state.job_config.clone(); + let label = info_label; + let fl = flow.clone(); + let jg = jpeg_group.clone(); + other_combo.connect_selected_notify(move |row| { + let selected = row.selected(); + if selected == 0 { + // "(none)" selected - do nothing, cards take priority + return; + } + // Deselect all cards + fl.unselect_all(); + // Hide progressive JPEG - none of the "other" formats are JPEG + jg.set_visible(false); + // These formats are not in the ImageFormat enum, + // so set convert_format to None and show a note + let mut c = jc.borrow_mut(); + c.convert_format = None; + let name = DROPDOWN_ONLY_FORMATS + .get((selected - 1) as usize) + .map(|(short, _)| *short) + .unwrap_or("Unknown"); + label.set_label(&format!( + "{}: This format is not yet supported by the processing engine. \ + Support is planned for a future release.", + name + )); + }); + } + + // Progressive JPEG toggle { let jc = state.job_config.clone(); progressive_row.connect_active_notify(move |row| { jc.borrow_mut().progressive_jpeg = row.is_active(); }); } - { - let jc = state.job_config.clone(); - jpeg_mapping.connect_selected_notify(move |row| { - jc.borrow_mut().format_mapping_jpeg = row.selected(); - }); - } - { - let jc = state.job_config.clone(); - png_mapping.connect_selected_notify(move |row| { - jc.borrow_mut().format_mapping_png = row.selected(); - }); - } - { - let jc = state.job_config.clone(); - webp_mapping.connect_selected_notify(move |row| { - jc.borrow_mut().format_mapping_webp = row.selected(); - }); - } - { - let jc = state.job_config.clone(); - tiff_mapping.connect_selected_notify(move |row| { - jc.borrow_mut().format_mapping_tiff = row.selected(); - }); - } scrolled.set_child(Some(&content)); - let clamp = adw::Clamp::builder() - .maximum_size(600) + let page = adw::NavigationPage::builder() + .title("Convert") + .tag("step-convert") .child(&scrolled) .build(); - adw::NavigationPage::builder() - .title("Convert") - .tag("step-convert") - .child(&clamp) - .build() + // Rebuild format mapping rows when navigating to this page + { + let files = state.loaded_files.clone(); + let list = mapping_list; + let jc = state.job_config.clone(); + page.connect_map(move |_| { + rebuild_format_mapping(&list, &files.borrow(), &jc); + }); + } + + page +} + +/// Returns the card grid index for a given ImageFormat, or None if not in the card grid. +fn card_index_for_format(format: Option) -> Option { + match format { + None => Some(0), + Some(ImageFormat::Jpeg) => Some(1), + Some(ImageFormat::Png) => Some(2), + Some(ImageFormat::WebP) => Some(3), + Some(ImageFormat::Avif) => Some(4), + Some(ImageFormat::Gif) => Some(5), + Some(ImageFormat::Tiff) => Some(6), + } +} + +/// Returns the ImageFormat for a given card grid index. +fn format_for_card_index(idx: usize) -> Option { + match idx { + 1 => Some(ImageFormat::Jpeg), + 2 => Some(ImageFormat::Png), + 3 => Some(ImageFormat::WebP), + 4 => Some(ImageFormat::Avif), + 5 => Some(ImageFormat::Gif), + 6 => Some(ImageFormat::Tiff), + _ => None, // 0 = Keep Original, 7 = BMP (not in enum) + } } fn format_info(format: Option) -> String { match format { None => "Images will keep their original format. No conversion applied.".into(), - Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, no transparency support. Universally compatible with all devices and browsers.".into(), - Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. Lossless compression, supports full transparency. Produces larger files than JPEG or WebP.".into(), - Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless compression. Supports transparency and animation. Widely supported in modern browsers.".into(), - Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. Best compression ratios available. Supports transparency and HDR. Slower to encode, growing browser support.".into(), - Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic animation and binary transparency. Best for simple graphics and short animations.".into(), - Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, supports layers and rich metadata. Very large files. Not suitable for web.".into(), + Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, \ + no transparency support. Universally compatible with all devices and browsers." + .into(), + Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. \ + Lossless compression, supports full transparency. Produces larger files \ + than JPEG or WebP." + .into(), + Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless \ + compression. Supports transparency and animation. Widely supported in modern browsers." + .into(), + Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. \ + Best compression ratios available. Supports transparency and HDR. Slower to encode, \ + growing browser support." + .into(), + Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic \ + animation and binary transparency. Best for simple graphics and short animations." + .into(), + Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, \ + supports layers and rich metadata. Very large files. Not suitable for web." + .into(), + } +} + +/// Rebuild the per-format mapping rows based on which file types are actually loaded. +/// Uses a dedicated ListBox container that can be easily cleared and rebuilt. +fn rebuild_format_mapping( + list: >k::ListBox, + loaded_files: &[PathBuf], + job_config: &Rc>, +) { + // Clear all existing rows from the list + list.remove_all(); + + // Detect which file extensions are present in loaded files + let mut seen_extensions: HashSet = HashSet::new(); + for path in loaded_files { + if let Some(ext) = path.extension() + && let Some(ext_str) = ext.to_str() + { + seen_extensions.insert(ext_str.to_lowercase()); + } + } + + if seen_extensions.is_empty() { + // No files loaded yet - add a placeholder row + let placeholder = adw::ActionRow::builder() + .title("No files loaded") + .subtitle("Load images first to configure per-format mappings") + .build(); + placeholder.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic")); + list.append(&placeholder); + return; + } + + // Normalize extensions to canonical display names, maintaining a stable order + let mut format_entries: Vec<(String, String)> = Vec::new(); // (canonical ext, display name) + let ext_to_name: &[(&[&str], &str)] = &[ + (&["jpg", "jpeg"], "JPEG"), + (&["png"], "PNG"), + (&["webp"], "WebP"), + (&["avif"], "AVIF"), + (&["gif"], "GIF"), + (&["tiff", "tif"], "TIFF"), + (&["bmp"], "BMP"), + (&["ico"], "ICO"), + (&["hdr"], "HDR"), + (&["pnm", "ppm", "pgm", "pbm"], "PNM/PPM"), + (&["tga"], "TGA"), + (&["heic", "heif"], "HEIC/HEIF"), + (&["jxl"], "JXL (JPEG XL)"), + (&["qoi"], "QOI"), + (&["exr"], "EXR"), + (&["ff", "farbfeld"], "Farbfeld"), + ]; + + let mut added_names: HashSet = HashSet::new(); + for (exts, display_name) in ext_to_name { + for ext in *exts { + if seen_extensions.contains(*ext) && added_names.insert(display_name.to_string()) { + // Use the first extension as canonical key + format_entries.push((exts[0].to_string(), display_name.to_string())); + break; + } + } + } + + // Also handle any unknown extensions in sorted order + let mut unknown: Vec = Vec::new(); + for ext in &seen_extensions { + let known = ext_to_name + .iter() + .any(|(exts, _)| exts.contains(&ext.as_str())); + if !known { + unknown.push(ext.clone()); + } + } + unknown.sort(); + for ext in unknown { + let upper = ext.to_uppercase(); + if added_names.insert(upper.clone()) { + format_entries.push((ext, upper)); + } + } + + let cfg = job_config.borrow(); + + for (canonical_ext, display_name) in &format_entries { + let combo = adw::ComboRow::builder() + .title(format!("{} inputs", display_name)) + .subtitle(format!("Output format for {} source files", display_name)) + .use_subtitle(true) + .build(); + combo.set_model(Some(>k::StringList::new(MAPPING_CHOICES))); + combo.set_list_factory(Some(&super::full_text_list_factory())); + + // Restore saved selection if any + let saved = cfg.format_mappings.get(canonical_ext).copied().unwrap_or(0); + combo.set_selected(saved); + + // Wire signal to save selection + let jc = job_config.clone(); + let ext_key = canonical_ext.clone(); + combo.connect_selected_notify(move |row| { + jc.borrow_mut() + .format_mappings + .insert(ext_key.clone(), row.selected()); + }); + + list.append(&combo); } } diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index 0cec33d..4c11a66 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::rc::Rc; use crate::app::AppState; +use crate::utils::format_size; const THUMB_SIZE: i32 = 120; @@ -183,7 +184,16 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { fn is_image_file(path: &std::path::Path) -> bool { match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) { - Some(ext) => matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"), + Some(ext) => matches!(ext.as_str(), + "jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp" + | "ico" | "hdr" | "exr" | "pnm" | "ppm" | "pgm" | "pbm" | "pam" + | "tga" | "dds" | "ff" | "farbfeld" | "qoi" + | "heic" | "heif" | "jxl" + | "svg" | "svgz" + | "raw" | "cr2" | "cr3" | "nef" | "nrw" | "arw" | "srf" | "sr2" + | "orf" | "rw2" | "raf" | "dng" | "pef" | "srw" | "x3f" + | "pcx" | "xpm" | "xbm" | "wbmp" | "jp2" | "j2k" | "jpf" | "jpx" + ), None => false, } } @@ -295,7 +305,7 @@ fn refresh_grid( } /// Walk the widget tree to find our ListStore and count label, then rebuild -fn rebuild_grid_model( +pub fn rebuild_grid_model( widget: >k::Widget, loaded_files: &Rc>>, excluded: &Rc>>, @@ -380,18 +390,6 @@ fn update_count_label( update_heading_label(widget, count, included_count, &size_str); } -fn format_size(bytes: u64) -> String { - if bytes < 1024 { - format!("{} B", bytes) - } else if bytes < 1024 * 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) - } else { - format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } -} - // ------------------------------------------------------------------ // GObject wrapper for list store items // ------------------------------------------------------------------ @@ -487,7 +485,7 @@ fn build_empty_state() -> gtk::Box { .build(); let formats_label = gtk::Label::builder() - .label("Supported: JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP") + .label("Supports all common image formats including RAW") .css_classes(["dim-label", "caption"]) .halign(gtk::Align::Center) .margin_top(8) @@ -573,7 +571,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { // Factory: setup { factory.connect_setup(move |_factory, list_item| { - let list_item = list_item.downcast_ref::().unwrap(); + let Some(list_item) = list_item.downcast_ref::() else { return }; let overlay = gtk::Overlay::builder() .width_request(THUMB_SIZE) @@ -656,13 +654,13 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { let excluded = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); factory.connect_bind(move |_factory, list_item| { - let list_item = list_item.downcast_ref::().unwrap(); - let item = list_item.item().and_downcast::().unwrap(); + let Some(list_item) = list_item.downcast_ref::() else { return }; + let Some(item) = list_item.item().and_downcast::() else { return }; let path = item.path().to_path_buf(); - let vbox = list_item.child().and_downcast::().unwrap(); - let overlay = vbox.first_child().and_downcast::().unwrap(); - let name_label = overlay.next_sibling().and_downcast::().unwrap(); + let Some(vbox) = list_item.child().and_downcast::() else { return }; + let Some(overlay) = vbox.first_child().and_downcast::() else { return }; + let Some(name_label) = overlay.next_sibling().and_downcast::() else { return }; // Set filename let file_name = path.file_name() @@ -671,19 +669,36 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { name_label.set_label(file_name); // Get the frame -> stack -> picture - let frame = overlay.child().and_downcast::().unwrap(); - let thumb_stack = frame.child().and_downcast::().unwrap(); - let picture = thumb_stack.child_by_name("picture") - .and_downcast::().unwrap(); + let Some(frame) = overlay.child().and_downcast::() else { return }; + let Some(thumb_stack) = frame.child().and_downcast::() else { return }; + let Some(picture) = thumb_stack.child_by_name("picture") + .and_downcast::() else { return }; // Reset to placeholder thumb_stack.set_visible_child_name("placeholder"); + // Bump bind generation so stale idle callbacks are ignored + let bind_gen: u32 = unsafe { + thumb_stack.data::("bind-gen") + .map(|p| *p.as_ref()) + .unwrap_or(0) + .wrapping_add(1) + }; + unsafe { thumb_stack.set_data("bind-gen", bind_gen); } + // Load thumbnail asynchronously let thumb_stack_c = thumb_stack.clone(); let picture_c = picture.clone(); let path_c = path.clone(); glib::idle_add_local_once(move || { + let current: u32 = unsafe { + thumb_stack_c.data::("bind-gen") + .map(|p| *p.as_ref()) + .unwrap_or(0) + }; + if current != bind_gen { + return; // Item was recycled; skip stale load + } load_thumbnail(&path_c, &picture_c, &thumb_stack_c); }); @@ -725,9 +740,9 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { // Factory: unbind - disconnect signal to avoid stale closures { factory.connect_unbind(move |_factory, list_item| { - let list_item = list_item.downcast_ref::().unwrap(); - let vbox = list_item.child().and_downcast::().unwrap(); - let overlay = vbox.first_child().and_downcast::().unwrap(); + let Some(list_item) = list_item.downcast_ref::() else { return }; + let Some(vbox) = list_item.child().and_downcast::() else { return }; + let Some(overlay) = vbox.first_child().and_downcast::() else { return }; if let Some(check) = find_check_button(overlay.upcast_ref::()) { let handler: Option = unsafe { diff --git a/pixstrip-gtk/src/steps/step_metadata.rs b/pixstrip-gtk/src/steps/step_metadata.rs index ac3cf5c..489983d 100644 --- a/pixstrip-gtk/src/steps/step_metadata.rs +++ b/pixstrip-gtk/src/steps/step_metadata.rs @@ -185,21 +185,23 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage { let copyright_c = copyright_row.clone(); photographer_check.connect_toggled(move |check| { if check.is_active() { - let mut cfg = jc.borrow_mut(); - cfg.metadata_mode = MetadataMode::Custom; - // Photographer: keep copyright + camera model, strip GPS + software - cfg.strip_gps = true; - cfg.strip_camera = false; - cfg.strip_software = true; - cfg.strip_timestamps = false; - cfg.strip_copyright = false; - // Update UI to match + { + let mut cfg = jc.borrow_mut(); + cfg.metadata_mode = MetadataMode::Custom; + // Photographer: keep copyright + camera model, strip GPS + software + cfg.strip_gps = true; + cfg.strip_camera = false; + cfg.strip_software = true; + cfg.strip_timestamps = false; + cfg.strip_copyright = false; + } + // Update UI to match (after dropping borrow to avoid re-entrancy) gps_c.set_active(true); camera_c.set_active(false); software_c.set_active(true); timestamps_c.set_active(false); copyright_c.set_active(false); - cg.set_visible(true); + cg.set_visible(false); } }); } @@ -258,14 +260,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage { scrolled.set_child(Some(&content)); - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&scrolled) - .build(); - adw::NavigationPage::builder() .title("Metadata") .tag("step-metadata") - .child(&clamp) + .child(&scrolled) .build() } diff --git a/pixstrip-gtk/src/steps/step_output.rs b/pixstrip-gtk/src/steps/step_output.rs index 5023cb3..852e849 100644 --- a/pixstrip-gtk/src/steps/step_output.rs +++ b/pixstrip-gtk/src/steps/step_output.rs @@ -1,5 +1,6 @@ use adw::prelude::*; use crate::app::AppState; +use crate::utils::format_size; pub fn build_output_page(state: &AppState) -> adw::NavigationPage { let scrolled = gtk::ScrolledWindow::builder() @@ -79,6 +80,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { let overwrite_row = adw::ComboRow::builder() .title("Overwrite Behavior") .subtitle("What to do when output file already exists") + .use_subtitle(true) .build(); let overwrite_model = gtk::StringList::new(&[ "Ask before overwriting", @@ -87,6 +89,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { "Skip existing files", ]); overwrite_row.set_model(Some(&overwrite_model)); + overwrite_row.set_list_factory(Some(&super::full_text_list_factory())); overwrite_row.set_selected(cfg.overwrite_behavior as u32); overwrite_group.add(&overwrite_row); @@ -137,26 +140,9 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { scrolled.set_child(Some(&content)); - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&scrolled) - .build(); - adw::NavigationPage::builder() .title("Output & Process") .tag("step-output") - .child(&clamp) + .child(&scrolled) .build() } - -fn format_size(bytes: u64) -> String { - if bytes < 1024 { - format!("{} B", bytes) - } else if bytes < 1024 * 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) - } else { - format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } -} diff --git a/pixstrip-gtk/src/steps/step_rename.rs b/pixstrip-gtk/src/steps/step_rename.rs index edc74b3..f9b3f18 100644 --- a/pixstrip-gtk/src/steps/step_rename.rs +++ b/pixstrip-gtk/src/steps/step_rename.rs @@ -1,38 +1,140 @@ use adw::prelude::*; +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; + use crate::app::AppState; pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { - let scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) + let cfg = state.job_config.borrow(); + + // === OUTER LAYOUT === + let outer = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) .vexpand(true) .build(); - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) + // --- Enable toggle (full width) --- + let enable_group = adw::PreferencesGroup::builder() + .margin_start(12) + .margin_end(12) .margin_top(12) - .margin_bottom(12) - .margin_start(24) - .margin_end(24) .build(); - - let 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); + outer.append(&enable_group); - // Simple mode: prefix + suffix + counter + // === LEFT SIDE: Preview === + + let show_all: Rc> = Rc::new(Cell::new(false)); + + let preview_header = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_bottom(4) + .build(); + + let showing_label = gtk::Label::builder() + .css_classes(["dim-label", "caption"]) + .halign(gtk::Align::Start) + .hexpand(true) + .build(); + + let show_all_button = gtk::Button::builder() + .label("Show all") + .build(); + show_all_button.add_css_class("pill"); + show_all_button.add_css_class("caption"); + + preview_header.append(&showing_label); + preview_header.append(&show_all_button); + + // Preview rows container + let preview_rows = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .build(); + + let preview_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&preview_rows) + .build(); + + // Conflict banner + let conflict_banner = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_top(8) + .visible(false) + .build(); + conflict_banner.add_css_class("card"); + + let conflict_icon = gtk::Image::builder() + .icon_name("dialog-warning-symbolic") + .margin_start(8) + .margin_top(4) + .margin_bottom(4) + .build(); + conflict_icon.add_css_class("warning"); + + let conflict_label = gtk::Label::builder() + .css_classes(["caption"]) + .halign(gtk::Align::Start) + .hexpand(true) + .wrap(true) + .margin_top(4) + .margin_bottom(4) + .margin_end(8) + .build(); + + conflict_banner.append(&conflict_icon); + conflict_banner.append(&conflict_label); + + // Stats label + let stats_label = gtk::Label::builder() + .css_classes(["dim-label", "caption"]) + .halign(gtk::Align::Start) + .wrap(true) + .margin_top(4) + .build(); + + let preview_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .hexpand(true) + .vexpand(true) + .build(); + preview_box.append(&preview_header); + preview_box.append(&preview_scroll); + preview_box.append(&conflict_banner); + preview_box.append(&stats_label); + + // === RIGHT SIDE: Controls (scrollable) === + + let controls = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_start(12) + .build(); + + // Reset button at end of controls (added later) + let reset_button = gtk::Button::builder() + .label("Reset to defaults") + .halign(gtk::Align::Start) + .margin_top(4) + .build(); + reset_button.add_css_class("pill"); + + // --- Simple Rename group --- let simple_group = adw::PreferencesGroup::builder() .title("Simple Rename") - .description("Add prefix, suffix, and sequential counter") + .description("Add prefix, suffix, and text transformations") .build(); let prefix_row = adw::EntryRow::builder() @@ -45,6 +147,71 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { .text(&cfg.rename_suffix) .build(); + let replace_spaces_row = adw::ComboRow::builder() + .title("Replace Spaces") + .subtitle("How to handle spaces in filenames") + .use_subtitle(true) + .build(); + replace_spaces_row.set_model(Some(>k::StringList::new(&[ + "No change", + "Underscores (_)", + "Hyphens (-)", + "Dots (.)", + "CamelCase", + "Remove spaces", + ]))); + replace_spaces_row.set_list_factory(Some(&super::full_text_list_factory())); + replace_spaces_row.set_selected(cfg.rename_replace_spaces); + + let special_chars_row = adw::ComboRow::builder() + .title("Special Characters") + .subtitle("Filter non-standard characters from filenames") + .use_subtitle(true) + .build(); + special_chars_row.set_model(Some(>k::StringList::new(&[ + "Keep all", + "Filesystem-safe (remove / \\ : * ? \" < > |)", + "Web-safe (a-z, 0-9, -, _, .)", + "Hyphens + underscores (a-z, 0-9, -, _)", + "Hyphens only (a-z, 0-9, -)", + "Alphanumeric only (a-z, 0-9)", + ]))); + special_chars_row.set_list_factory(Some(&super::full_text_list_factory())); + special_chars_row.set_selected(cfg.rename_special_chars); + + let case_row = adw::ComboRow::builder() + .title("Case Conversion") + .subtitle("Convert filename case") + .use_subtitle(true) + .build(); + case_row.set_model(Some(>k::StringList::new(&["No change", "lowercase", "UPPERCASE", "Title Case"]))); + case_row.set_list_factory(Some(&super::full_text_list_factory())); + case_row.set_selected(cfg.rename_case); + + // Counter toggle + expandable sub-options + let counter_row = adw::ExpanderRow::builder() + .title("Add Sequential Counter") + .subtitle("Append a numbered sequence to filenames") + .show_enable_switch(true) + .enable_expansion(cfg.rename_counter_enabled) + .expanded(cfg.rename_counter_enabled) + .build(); + + let counter_position_row = adw::ComboRow::builder() + .title("Counter Position") + .subtitle("Where the counter number appears") + .use_subtitle(true) + .build(); + counter_position_row.set_model(Some(>k::StringList::new(&[ + "Before prefix", + "Before name", + "After name", + "After suffix", + "Replace name", + ]))); + counter_position_row.set_list_factory(Some(&super::full_text_list_factory())); + counter_position_row.set_selected(cfg.rename_counter_position); + let counter_start_row = adw::SpinRow::builder() .title("Counter Start") .subtitle("First number in sequence") @@ -57,64 +224,43 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { .adjustment(>k::Adjustment::new(cfg.rename_counter_padding as f64, 1.0, 10.0, 1.0, 1.0, 0.0)) .build(); + counter_row.add_row(&counter_position_row); + counter_row.add_row(&counter_start_row); + counter_row.add_row(&counter_padding_row); + 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); + simple_group.add(&replace_spaces_row); + simple_group.add(&special_chars_row); + simple_group.add(&case_row); + simple_group.add(&counter_row); + controls.append(&simple_group); - // Live preview showing first 5 filenames - let preview_group = adw::PreferencesGroup::builder() - .title("Preview") - .description("How your files will be renamed") - .build(); - - let preview_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(2) - .margin_top(8) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .build(); - - // Create 5 preview labels - for _ in 0..5 { - let label = gtk::Label::builder() - .css_classes(["monospace", "dim-label", "caption"]) - .halign(gtk::Align::Start) - .build(); - preview_box.append(&label); - } - - preview_group.add(&preview_box); - content.append(&preview_group); - - // Advanced: template engine + // --- Advanced group --- let advanced_group = adw::PreferencesGroup::builder() - .title("Advanced: Template Engine") - .description("Use variables in curly braces for full control") + .title("Advanced") .build(); + let advanced_expander = adw::ExpanderRow::builder() + .title("Template Engine and Find/Replace") + .subtitle("Advanced rename options with variables and regex") + .show_enable_switch(false) + .expanded(state.is_section_expanded("rename-advanced")) + .build(); + + { + let st = state.clone(); + advanced_expander.connect_expanded_notify(move |row| { + st.set_section_expanded("rename-advanced", row.is_expanded()); + }); + } + let template_row = adw::EntryRow::builder() .title("Template") .text(&cfg.rename_template) .build(); - // Preset template quick-fill buttons - let presets_flow = gtk::FlowBox::builder() - .selection_mode(gtk::SelectionMode::None) - .max_children_per_line(4) - .min_children_per_line(2) - .row_spacing(4) - .column_spacing(4) - .margin_top(4) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .homogeneous(false) - .build(); - + // Template preset chips let preset_templates = [ ("Date + Name", "{date}_{name}"), ("EXIF Date + Name", "{exif_date}_{name}"), @@ -122,76 +268,391 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { ("Dimensions", "{name}_{width}x{height}"), ("Camera + Date", "{camera}_{exif_date}_{counter:3}"), ("Web-safe", "{name}_web"), + ("Date + Counter", "{date}_{counter:4}"), + ("Name + Size", "{name}_{width}x{height}_{counter:3}"), ]; + let presets_box = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .max_children_per_line(6) + .min_children_per_line(2) + .row_spacing(4) + .column_spacing(4) + .margin_top(4) + .margin_bottom(4) + .margin_start(12) + .margin_end(12) + .homogeneous(false) + .build(); + for (label, template) in &preset_templates { let btn = gtk::Button::builder() .label(*label) .tooltip_text(*template) .build(); - btn.add_css_class("pill"); + btn.add_css_class("flat"); let tr = template_row.clone(); let tmpl = template.to_string(); btn.connect_clicked(move |_| { tr.set_text(&tmpl); + tr.set_position(tmpl.chars().count() as i32); }); - presets_flow.append(&btn); + presets_box.append(&btn); } - 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) + let presets_list_row = gtk::ListBoxRow::builder() + .activatable(false) + .selectable(false) + .child(&presets_box) + .build(); + + // Variable chips (collapsible) + let variables_expander = adw::ExpanderRow::builder() + .title("Available Variables") + .subtitle("Click to insert at cursor position in template") + .show_enable_switch(false) + .expanded(state.is_section_expanded("rename-variables")) + .build(); + + { + let st = state.clone(); + variables_expander.connect_expanded_notify(move |row| { + st.set_section_expanded("rename-variables", row.is_expanded()); + }); + } + + let variables = [ + ("{name}", "Original filename (no extension)"), + ("{ext}", "Output file extension"), + ("{original_ext}", "Original file extension"), + ("{counter}", "Sequential counter number"), + ("{counter:3}", "Counter, zero-padded to 3 digits"), + ("{counter:4}", "Counter, zero-padded to 4 digits"), + ("{date}", "Today's date (YYYY-MM-DD)"), + ("{exif_date}", "Date photo was taken (from EXIF)"), + ("{camera}", "Camera model (from EXIF)"), + ("{width}", "Output image width in pixels"), + ("{height}", "Output image height in pixels"), + ]; + + let vars_flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .max_children_per_line(3) + .min_children_per_line(1) + .row_spacing(4) + .column_spacing(4) .margin_top(4) - .margin_bottom(8) + .margin_bottom(4) .margin_start(12) + .margin_end(12) + .homogeneous(false) .build(); - let case_row = adw::ComboRow::builder() - .title("Case Conversion") - .subtitle("Convert filename case") - .build(); - let case_model = gtk::StringList::new(&["No change", "lowercase", "UPPERCASE", "Title Case"]); - case_row.set_model(Some(&case_model)); + for (var_name, description) in &variables { + let chip_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(1) + .build(); - let regex_group = adw::PreferencesGroup::builder() - .title("Find and Replace") - .description("Regex find-and-replace on original filename") - .build(); + let name_label = gtk::Label::builder() + .label(*var_name) + .css_classes(["monospace", "caption"]) + .halign(gtk::Align::Start) + .build(); + let desc_label = gtk::Label::builder() + .label(*description) + .css_classes(["dim-label"]) + .halign(gtk::Align::Start) + .build(); + desc_label.add_css_class("caption"); + + chip_box.append(&name_label); + chip_box.append(&desc_label); + + let btn = gtk::Button::builder() + .child(&chip_box) + .has_frame(false) + .build(); + + let tr = template_row.clone(); + let var_text = var_name.to_string(); + btn.connect_clicked(move |_| { + let current = tr.text().to_string(); + if current.is_empty() { + tr.set_text(&var_text); + tr.set_position(var_text.chars().count() as i32); + } else { + let pos = tr.position() as usize; + let byte_pos = current.char_indices() + .nth(pos) + .map(|(i, _)| i) + .unwrap_or(current.len()); + let mut new_text = current.clone(); + new_text.insert_str(byte_pos, &var_text); + tr.set_text(&new_text); + tr.set_position((pos + var_text.chars().count()) as i32); + } + }); + + vars_flow.append(&btn); + } + + let vars_list_row = gtk::ListBoxRow::builder() + .activatable(false) + .selectable(false) + .child(&vars_flow) + .build(); + variables_expander.add_row(&vars_list_row); + + // Find/replace let find_row = adw::EntryRow::builder() .title("Find (regex)") + .text(&cfg.rename_find) .build(); let replace_row = adw::EntryRow::builder() .title("Replace with") + .text(&cfg.rename_replace) .build(); - regex_group.add(&find_row); - regex_group.add(&replace_row); + advanced_expander.add_row(&template_row); + advanced_expander.add_row(&presets_list_row); + advanced_expander.add_row(&find_row); + advanced_expander.add_row(&replace_row); - advanced_group.add(&template_row); - advanced_group.add(&presets_flow); - advanced_group.add(&help_label); - advanced_group.add(&case_row); - content.append(&advanced_group); - content.append(®ex_group); + advanced_group.add(&advanced_expander); + advanced_group.add(&variables_expander); + controls.append(&advanced_group); + controls.append(&reset_button); + + // Scrollable controls + let controls_scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .width_request(420) + .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); drop(cfg); - // Wire signals + // === Preview update closure === + let update_preview = { + let files = state.loaded_files.clone(); + let jc = state.job_config.clone(); + let rows_box = preview_rows.clone(); + let showing = showing_label.clone(); + let show_all_state = show_all.clone(); + let conflict_banner_c = conflict_banner.clone(); + let conflict_label_c = conflict_label.clone(); + let stats = stats_label.clone(); + let show_btn = show_all_button.clone(); + + Rc::new(move || { + let loaded = files.borrow(); + let cfg = jc.borrow(); + let total = loaded.len(); + + // Clear existing rows + while let Some(child) = rows_box.first_child() { + rows_box.remove(&child); + } + + if total == 0 { + showing.set_label("No images loaded"); + stats.set_label(""); + conflict_banner_c.set_visible(false); + show_btn.set_visible(false); + return; + } + + let display_count = if show_all_state.get() { total } else { total.min(5) }; + show_btn.set_visible(total > 5); + if show_all_state.get() { + show_btn.set_label("Show less"); + showing.set_label(&format!("All {} files", total)); + } else { + show_btn.set_label("Show all"); + showing.set_label(&format!("Showing {} of {} files", display_count, total)); + } + + // Compute all renames for conflict detection + let mut all_results: Vec = Vec::with_capacity(total); + let mut ext_counts: HashMap = HashMap::new(); + let mut longest_name = 0usize; + + for (i, path) in loaded.iter().enumerate() { + let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file"); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg"); + + *ext_counts.entry(ext.to_string()).or_insert(0) += 1; + + let result = if !cfg.rename_template.is_empty() { + let counter = cfg.rename_counter_start + i as u32; + pixstrip_core::operations::rename::apply_template( + &cfg.rename_template, name, ext, counter, None, + ) + } else { + 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, + counter_enabled: cfg.rename_counter_enabled, + counter_position: cfg.rename_counter_position, + template: None, + case_mode: cfg.rename_case, + replace_spaces: cfg.rename_replace_spaces, + special_chars: cfg.rename_special_chars, + regex_find: cfg.rename_find.clone(), + regex_replace: cfg.rename_replace.clone(), + }; + rename_cfg.apply_simple(name, ext, (i + 1) as u32) + }; + + if result.len() > longest_name { + longest_name = result.len(); + } + all_results.push(result); + } + + // Detect conflicts + let mut name_counts: HashMap<&str, usize> = HashMap::new(); + for result in &all_results { + *name_counts.entry(result.as_str()).or_insert(0) += 1; + } + let conflicts: usize = name_counts.values().filter(|&&c| c > 1).map(|c| c).sum(); + + if conflicts > 0 { + conflict_banner_c.set_visible(true); + let dupes: Vec<&str> = name_counts.iter() + .filter(|(_, c)| **c > 1) + .take(3) + .map(|(n, _)| *n) + .collect(); + let dupe_list = dupes.join(", "); + let msg = if name_counts.values().filter(|c| **c > 1).count() > 3 { + format!("{} files have duplicate names (e.g. {}, ...)", conflicts, dupe_list) + } else { + format!("{} files have duplicate names: {}", conflicts, dupe_list) + }; + conflict_label_c.set_label(&msg); + } else { + conflict_banner_c.set_visible(false); + } + + // Build preview rows (vertical: original on top, new name below) + for (i, path) in loaded.iter().take(display_count).enumerate() { + let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file"); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg"); + + let new_full = &all_results[i]; + + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(1) + .margin_top(3) + .margin_bottom(3) + .build(); + + let orig_label = gtk::Label::builder() + .label(&format!("{}.{}", name, ext)) + .css_classes(["monospace", "caption", "dim-label"]) + .halign(gtk::Align::Start) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .max_width_chars(50) + .build(); + + let new_line = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .build(); + + let arrow_label = gtk::Label::builder() + .label("->") + .css_classes(["dim-label", "caption"]) + .build(); + + let new_name_label = gtk::Label::builder() + .label(new_full.as_str()) + .css_classes(["monospace", "caption"]) + .halign(gtk::Align::Start) + .hexpand(true) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .max_width_chars(50) + .build(); + + // Highlight conflicts + if name_counts.get(new_full.as_str()).copied().unwrap_or(0) > 1 { + new_name_label.add_css_class("error"); + } + + new_line.append(&arrow_label); + new_line.append(&new_name_label); + + row.append(&orig_label); + row.append(&new_line); + rows_box.append(&row); + } + + // Stats + let mut ext_parts: Vec = ext_counts + .iter() + .map(|(e, c)| format!("{} .{}", c, e)) + .collect(); + ext_parts.sort(); + + stats.set_label(&format!( + "{} files | {} conflicts | {} | Longest: {} chars", + total, + conflicts, + ext_parts.join(", "), + longest_name, + )); + }) + }; + + // Debounced wrapper for text entry handlers (150ms) + let debounce_source: Rc>> = Rc::new(Cell::new(None)); + let debounced_preview = { + let up = update_preview.clone(); + let ds = debounce_source.clone(); + Rc::new(move || { + if let Some(id) = ds.take() { + id.remove(); + } + let up2 = up.clone(); + let id = gtk::glib::timeout_add_local_once( + std::time::Duration::from_millis(150), + move || { up2(); }, + ); + ds.set(Some(id)); + }) + }; + + // Call once for initial state + update_preview(); + + // === Wire signals === + + // Enable toggle { let jc = state.job_config.clone(); enable_row.connect_active_notify(move |row| { @@ -199,135 +660,107 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { }); } - // Update preview helper - shows first 5 filenames from the batch - let update_preview = { - let files = state.loaded_files.clone(); - let jc = state.job_config.clone(); - let preview = preview_box.clone(); - move || { - let cfg = jc.borrow(); - let loaded = files.borrow(); - - // Sample filenames: use actual loaded files, or fallback examples - let samples: Vec<(&str, &str)> = if loaded.is_empty() { - vec![ - ("photo", "jpg"), - ("sunset", "png"), - ("beach", "jpg"), - ("portrait", "webp"), - ("landscape", "jpg"), - ] - } else { - loaded - .iter() - .take(5) - .map(|p| { - let name = p - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("photo"); - let ext = p - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("jpg"); - (name, ext) - }) - .collect() - }; - - let mut child = preview.first_child(); - for (i, (name, ext)) in samples.iter().enumerate() { - let Some(widget) = child.clone() else { break }; - if let Some(label) = widget.downcast_ref::() { - let counter = cfg.rename_counter_start + i as u32; - if !cfg.rename_template.is_empty() { - let result = cfg - .rename_template - .replace("{name}", name) - .replace("{ext}", ext) - .replace( - "{counter}", - &format!( - "{:0>width$}", - counter, - width = cfg.rename_counter_padding as usize - ), - ) - .replace("{date}", "2026-03-06") - .replace("{width}", "1200") - .replace("{height}", "800"); - label.set_label(&format!("{}.{} -> {}", name, ext, result)); - } else { - 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, - case_mode: cfg.rename_case, - regex_find: cfg.rename_find.clone(), - regex_replace: cfg.rename_replace.clone(), - }; - let result = - rename_cfg.apply_simple(name, ext, (i + 1) as u32); - label.set_label(&format!("{}.{} -> {}", name, ext, result)); - } - label.set_visible(true); - } - child = widget.next_sibling(); - } - - // Hide unused labels - while let Some(widget) = child.clone() { - widget.set_visible(false); - child = widget.next_sibling(); - } - } - }; - - // Call once to set initial preview - update_preview(); + // Show all toggle + { + let sa = show_all.clone(); + let up = update_preview.clone(); + show_all_button.connect_clicked(move |_| { + sa.set(!sa.get()); + up(); + }); + } + // Reset button { let jc = state.job_config.clone(); let up = update_preview.clone(); + let prefix_r = prefix_row.clone(); + let suffix_r = suffix_row.clone(); + let spaces_r = replace_spaces_row.clone(); + let special_r = special_chars_row.clone(); + let case_r = case_row.clone(); + let counter_r = counter_row.clone(); + let counter_pos_r = counter_position_row.clone(); + let counter_start_r = counter_start_row.clone(); + let counter_pad_r = counter_padding_row.clone(); + let template_r = template_row.clone(); + let find_r = find_row.clone(); + let replace_r = replace_row.clone(); + + reset_button.connect_clicked(move |_| { + { + let mut cfg = jc.borrow_mut(); + cfg.rename_prefix = String::new(); + cfg.rename_suffix = String::new(); + cfg.rename_counter_enabled = false; + cfg.rename_counter_start = 1; + cfg.rename_counter_padding = 3; + cfg.rename_counter_position = 3; + cfg.rename_replace_spaces = 0; + cfg.rename_special_chars = 0; + cfg.rename_case = 0; + cfg.rename_template = String::new(); + cfg.rename_find = String::new(); + cfg.rename_replace = String::new(); + } + prefix_r.set_text(""); + suffix_r.set_text(""); + spaces_r.set_selected(0); + special_r.set_selected(0); + case_r.set_selected(0); + counter_r.set_enable_expansion(false); + counter_r.set_expanded(false); + counter_pos_r.set_selected(3); + counter_start_r.set_value(1.0); + counter_pad_r.set_value(3.0); + template_r.set_text(""); + find_r.set_text(""); + replace_r.set_text(""); + up(); + }); + } + + // Prefix + { + let jc = state.job_config.clone(); + let up = debounced_preview.clone(); prefix_row.connect_changed(move |row| { jc.borrow_mut().rename_prefix = row.text().to_string(); up(); }); } + + // Suffix { let jc = state.job_config.clone(); - let up = update_preview.clone(); + let up = debounced_preview.clone(); suffix_row.connect_changed(move |row| { jc.borrow_mut().rename_suffix = row.text().to_string(); up(); }); } + + // Replace spaces { 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; + replace_spaces_row.connect_selected_notify(move |row| { + jc.borrow_mut().rename_replace_spaces = row.selected(); up(); }); } + + // Special chars { 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.clone(); - template_row.connect_changed(move |row| { - jc.borrow_mut().rename_template = row.text().to_string(); + special_chars_row.connect_selected_notify(move |row| { + jc.borrow_mut().rename_special_chars = row.selected(); up(); }); } + + // Case conversion { let jc = state.job_config.clone(); let up = update_preview.clone(); @@ -336,33 +769,100 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { up(); }); } + + // Counter enable { let jc = state.job_config.clone(); let up = update_preview.clone(); - find_row.connect_changed(move |row| { - jc.borrow_mut().rename_find = row.text().to_string(); + counter_row.connect_enable_expansion_notify(move |row| { + jc.borrow_mut().rename_counter_enabled = row.enables_expansion(); up(); }); } + + // Counter position { let jc = state.job_config.clone(); - let up = update_preview; + let up = update_preview.clone(); + counter_position_row.connect_selected_notify(move |row| { + jc.borrow_mut().rename_counter_position = row.selected(); + up(); + }); + } + + // Counter start + { + 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(); + }); + } + + // Counter padding + { + 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(); + }); + } + + // Template + { + let jc = state.job_config.clone(); + let up = debounced_preview.clone(); + template_row.connect_changed(move |row| { + jc.borrow_mut().rename_template = row.text().to_string(); + up(); + }); + } + + // Find regex + { + let jc = state.job_config.clone(); + let up = debounced_preview.clone(); + find_row.connect_changed(move |row| { + let text = row.text().to_string(); + if !text.is_empty() { + if regex::Regex::new(&text).is_err() { + row.add_css_class("error"); + } else { + row.remove_css_class("error"); + } + } else { + row.remove_css_class("error"); + } + jc.borrow_mut().rename_find = text; + up(); + }); + } + + // Replace + { + let jc = state.job_config.clone(); + let up = debounced_preview.clone(); replace_row.connect_changed(move |row| { jc.borrow_mut().rename_replace = row.text().to_string(); up(); }); } - scrolled.set_child(Some(&content)); - - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&scrolled) - .build(); - - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title("Rename") .tag("step-rename") - .child(&clamp) - .build() + .child(&outer) + .build(); + + // Refresh preview when navigating to this page + { + let up = update_preview.clone(); + page.connect_map(move |_| { + up(); + }); + } + + page } diff --git a/pixstrip-gtk/src/steps/step_resize.rs b/pixstrip-gtk/src/steps/step_resize.rs index 9810da0..4996331 100644 --- a/pixstrip-gtk/src/steps/step_resize.rs +++ b/pixstrip-gtk/src/steps/step_resize.rs @@ -1,9 +1,9 @@ use adw::prelude::*; use gtk::glib; +use std::cell::Cell; +use std::rc::Rc; use crate::app::AppState; -/// Get the aspect ratio (width/height) of the first loaded image. -/// Returns 0.0 if no images are loaded or the image can't be read. fn get_first_image_aspect(files: &[std::path::PathBuf]) -> f64 { let Some(first) = files.first() else { return 0.0 }; let Ok((w, h)) = image::image_dimensions(first) else { return 0.0 }; @@ -11,402 +11,454 @@ fn get_first_image_aspect(files: &[std::path::PathBuf]) -> f64 { w as f64 / h as f64 } +fn get_image_dims(path: &std::path::Path) -> (u32, u32) { + image::image_dimensions(path).unwrap_or((4000, 3000)) +} + +fn get_first_image_dims(files: &[std::path::PathBuf]) -> (u32, u32) { + let Some(first) = files.first() else { return (4000, 3000) }; + get_image_dims(first) +} + +fn algo_index_to_filter(idx: u32) -> image::imageops::FilterType { + match idx { + 1 => image::imageops::FilterType::CatmullRom, + 2 => image::imageops::FilterType::Triangle, + 3 => image::imageops::FilterType::Nearest, + _ => image::imageops::FilterType::Lanczos3, + } +} + +const CATEGORIES: &[&str] = &[ + "Fediverse / Open Platforms", + "Mainstream Platforms", + "Common / Web", +]; + +fn presets_for_category(cat: u32) -> &'static [(&'static str, u32, u32)] { + match cat { + 0 => &[ + ("Mastodon Post (1920 x 1080)", 1920, 1080), + ("Mastodon Profile (400 x 400)", 400, 400), + ("Mastodon Header (1500 x 500)", 1500, 500), + ("Pixelfed Post (1080 x 1080)", 1080, 1080), + ("Pixelfed Story (1080 x 1920)", 1080, 1920), + ("Bluesky Post (1200 x 630)", 1200, 630), + ("Bluesky Profile (400 x 400)", 400, 400), + ("Bluesky Banner (1500 x 500)", 1500, 500), + ("Lemmy Post (1200 x 630)", 1200, 630), + ("PeerTube Thumbnail (1280 x 720)", 1280, 720), + ("Friendica Post (1200 x 630)", 1200, 630), + ("Funkwhale Cover (1400 x 1400)", 1400, 1400), + ], + 1 => &[ + ("Instagram Post Square (1080 x 1080)", 1080, 1080), + ("Instagram Post Portrait (1080 x 1350)", 1080, 1350), + ("Instagram Story/Reel (1080 x 1920)", 1080, 1920), + ("Facebook Post (1200 x 630)", 1200, 630), + ("Facebook Cover (820 x 312)", 820, 312), + ("Facebook Profile (170 x 170)", 170, 170), + ("YouTube Thumbnail (1280 x 720)", 1280, 720), + ("YouTube Channel Art (2560 x 1440)", 2560, 1440), + ("LinkedIn Post (1200 x 627)", 1200, 627), + ("LinkedIn Cover (1584 x 396)", 1584, 396), + ("LinkedIn Profile (400 x 400)", 400, 400), + ("Pinterest Pin (1000 x 1500)", 1000, 1500), + ("TikTok Video Cover (1080 x 1920)", 1080, 1920), + ("Threads Post (1080 x 1080)", 1080, 1080), + ], + _ => &[ + ("4K UHD (3840 x 2160)", 3840, 2160), + ("Full HD (1920 x 1080)", 1920, 1080), + ("HD Ready (1280 x 720)", 1280, 720), + ("Blog Standard (800 wide)", 800, 0), + ("Email Header (600 x 200)", 600, 200), + ("Large Thumbnail (300 x 300)", 300, 300), + ("Small Thumbnail (150 x 150)", 150, 150), + ("Favicon (32 x 32)", 32, 32), + ], + } +} + +fn rebuild_size_model(size_row: &adw::ComboRow, cat: u32) { + let presets = presets_for_category(cat); + let mut names: Vec<&str> = vec!["(select a size)"]; + names.extend(presets.iter().map(|p| p.0)); + size_row.set_model(Some(>k::StringList::new(&names))); + size_row.set_list_factory(Some(&super::full_text_list_factory())); + size_row.set_selected(0); +} + pub fn build_resize_page(state: &AppState) -> adw::NavigationPage { - let scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) + let cfg = state.job_config.borrow(); + + // === OUTER LAYOUT === + let outer = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) .vexpand(true) .build(); - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) + // --- Enable toggle (full width) --- + let enable_group = adw::PreferencesGroup::builder() + .margin_start(12) + .margin_end(12) .margin_top(12) - .margin_bottom(12) - .margin_start(24) - .margin_end(24) .build(); - - let cfg = state.job_config.borrow(); - - // Enable toggle let enable_row = adw::SwitchRow::builder() .title("Enable Resize") - .subtitle("Resize images to new dimensions") + .subtitle("Scale images to new dimensions") .active(cfg.resize_enabled) .build(); - - let enable_group = adw::PreferencesGroup::new(); enable_group.add(&enable_row); - content.append(&enable_group); + outer.append(&enable_group); - // Mode selector using GtkStack with a stack switcher - let mode_stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .build(); - - let switcher = gtk::StackSwitcher::builder() - .stack(&mode_stack) - .halign(gtk::Align::Center) - .margin_top(6) - .margin_bottom(6) - .build(); - - content.append(&switcher); - - // --- Mode 1: Width/Height --- - let wh_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) + // --- Horizontal split: Preview (left 60%) | Controls (right 40%) --- + let split = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) .spacing(12) + .margin_start(12) + .margin_end(12) + .margin_top(12) + .margin_bottom(12) + .vexpand(true) .build(); - let wh_group = adw::PreferencesGroup::builder() - .title("Target Dimensions") - .description("Set width and/or height. Set either to 0 to maintain aspect ratio.") - .build(); - - let width_row = adw::SpinRow::builder() - .title("Width") - .subtitle("Target width in pixels") - .adjustment(>k::Adjustment::new(cfg.resize_width as f64, 0.0, 10000.0, 1.0, 100.0, 0.0)) - .build(); - - let lock_row = adw::SwitchRow::builder() - .title("Lock Aspect Ratio") - .subtitle("Changing one dimension auto-calculates the other") - .active(true) - .build(); - lock_row.add_prefix(>k::Image::from_icon_name("changes-prevent-symbolic")); - - let height_row = adw::SpinRow::builder() - .title("Height") - .subtitle("Target height in pixels (0 = auto from aspect ratio)") - .adjustment(>k::Adjustment::new(cfg.resize_height as f64, 0.0, 10000.0, 1.0, 100.0, 0.0)) - .build(); - - wh_group.add(&width_row); - wh_group.add(&lock_row); - wh_group.add(&height_row); - wh_box.append(&wh_group); - - mode_stack.add_titled(&wh_box, Some("width-height"), "Width / Height"); - - // --- Mode 2: Preset Dimensions --- - let preset_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) - .build(); - - let presets_group = adw::PreferencesGroup::builder() - .title("Quick Dimension Presets") - .description("Select a preset to set the dimensions") - .build(); - - // Clone for closures - let width_for_preset = width_row.clone(); - let height_for_preset = height_row.clone(); - - let build_preset_section = |title: &str, subtitle: &str, presets: &[(&str, u32, u32)]| -> adw::ExpanderRow { - let expander = adw::ExpanderRow::builder() - .title(title) - .subtitle(subtitle) - .build(); - - for (name, w, h) in presets { - let row = adw::ActionRow::builder() - .title(*name) - .subtitle(if *h == 0 { format!("{} wide", w) } else { format!("{} x {}", w, h) }) - .activatable(true) - .build(); - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - let width_c = width_for_preset.clone(); - let height_c = height_for_preset.clone(); - let w = *w; - let h = *h; - let stack_c = mode_stack.clone(); - row.connect_activated(move |_| { - width_c.set_value(w as f64); - height_c.set_value(h as f64); - // Switch to width/height tab to show the values - stack_c.set_visible_child_name("width-height"); - }); - expander.add_row(&row); - } - - expander - }; - - let fedi_expander = build_preset_section( - "Fediverse / Open Platforms", - "Mastodon, Pixelfed, Bluesky, Lemmy, PeerTube", - &[ - ("Mastodon Post", 1920, 1080), - ("Mastodon Profile", 400, 400), - ("Mastodon Header", 1500, 500), - ("Pixelfed Post", 1080, 1080), - ("Pixelfed Story", 1080, 1920), - ("Bluesky Post", 1200, 630), - ("Bluesky Profile", 400, 400), - ("Bluesky Banner", 1500, 500), - ("Lemmy Post", 1200, 630), - ("PeerTube Thumbnail", 1280, 720), - ("Friendica Post", 1200, 630), - ("Funkwhale Cover", 1400, 1400), - ], - ); - - let mainstream_expander = build_preset_section( - "Mainstream Platforms", - "Instagram, YouTube, LinkedIn, Facebook, TikTok", - &[ - ("Instagram Post Square", 1080, 1080), - ("Instagram Post Portrait", 1080, 1350), - ("Instagram Story/Reel", 1080, 1920), - ("Facebook Post", 1200, 630), - ("Facebook Cover", 820, 312), - ("Facebook Profile", 170, 170), - ("YouTube Thumbnail", 1280, 720), - ("YouTube Channel Art", 2560, 1440), - ("LinkedIn Post", 1200, 627), - ("LinkedIn Cover", 1584, 396), - ("LinkedIn Profile", 400, 400), - ("Pinterest Pin", 1000, 1500), - ("TikTok Video Cover", 1080, 1920), - ("Threads Post", 1080, 1080), - ], - ); - - let common_expander = build_preset_section( - "Common Sizes", - "HD, 4K, Blog, Thumbnails", - &[ - ("4K UHD", 3840, 2160), - ("Full HD", 1920, 1080), - ("HD Ready", 1280, 720), - ("Blog Standard", 800, 0), - ("Email Header", 600, 200), - ("Large Thumbnail", 300, 300), - ("Small Thumbnail", 150, 150), - ("Favicon", 32, 32), - ], - ); - - presets_group.add(&fedi_expander); - presets_group.add(&mainstream_expander); - presets_group.add(&common_expander); - preset_box.append(&presets_group); - - mode_stack.add_titled(&preset_box, Some("presets"), "Presets"); - - // --- Mode 3: Fit in Box --- - let fit_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) - .build(); - - let fit_group = adw::PreferencesGroup::builder() - .title("Fit in Bounding Box") - .description("Images are scaled down to fit within these maximum dimensions while maintaining their aspect ratio. Images smaller than the box are not enlarged.") - .build(); - - let max_width_row = adw::SpinRow::builder() - .title("Maximum Width") - .subtitle("Images wider than this are scaled down") - .adjustment(>k::Adjustment::new(1200.0, 1.0, 10000.0, 1.0, 100.0, 0.0)) - .build(); - - let max_height_row = adw::SpinRow::builder() - .title("Maximum Height") - .subtitle("Images taller than this are scaled down") - .adjustment(>k::Adjustment::new(1200.0, 1.0, 10000.0, 1.0, 100.0, 0.0)) - .build(); - - fit_group.add(&max_width_row); - fit_group.add(&max_height_row); - fit_box.append(&fit_group); - - // Wire fit-in-box to update width/height - { - let width_c = width_row.clone(); - let height_c = height_row.clone(); - max_width_row.connect_value_notify(move |row| { - width_c.set_value(row.value()); - }); - let height_c2 = height_row.clone(); - max_height_row.connect_value_notify(move |row| { - height_c2.set_value(row.value()); - }); - let _ = height_c; // suppress unused - } - - mode_stack.add_titled(&fit_box, Some("fit-box"), "Fit in Box"); - - content.append(&mode_stack); - - // Size preview visualization - let preview_group = adw::PreferencesGroup::builder() - .title("Size Preview") - .description("Visual comparison of original and output dimensions") - .build(); - - // Use shared state for the preview drawing - let preview_width = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_width)); - let preview_height = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_height)); - let loaded_files = state.loaded_files.clone(); - - let drawing = gtk::DrawingArea::builder() - .content_width(300) - .content_height(150) - .halign(gtk::Align::Center) - .margin_top(8) - .margin_bottom(8) - .build(); - drawing.update_property(&[ - gtk::accessible::Property::Label("Visual comparison of original and target image dimensions"), - ]); - - let pw = preview_width.clone(); - let ph = preview_height.clone(); - let lf = loaded_files.clone(); - drawing.set_draw_func(move |_area, cr, width, height| { - let target_w = *pw.borrow() as f64; - let target_h = *ph.borrow() as f64; - - // Try to get actual first image dimensions - let (orig_w, orig_h) = { - let files = lf.borrow(); - if let Some(first) = files.first() { - image_dimensions(first).unwrap_or((4000.0, 3000.0)) - } else { - (4000.0, 3000.0) - } - }; - - let actual_target_w = if target_w > 0.0 { target_w } else { orig_w }; - let actual_target_h = if target_h > 0.0 { - target_h - } else if target_w > 0.0 { - orig_h * target_w / orig_w - } else { - orig_h - }; - - let max_dim = orig_w.max(orig_h).max(actual_target_w).max(actual_target_h); - if max_dim == 0.0 { - return; - } - - let pad = 20.0; - let avail_w = width as f64 - pad * 2.0; - let avail_h = height as f64 - pad * 2.0; - let scale = (avail_w / max_dim).min(avail_h / max_dim); - - let ow = orig_w * scale; - let oh = orig_h * scale; - let tw = actual_target_w * scale; - let th = actual_target_h * scale; - - // Draw original rectangle (semi-transparent) - let _ = cr.set_source_rgba(0.5, 0.5, 0.5, 0.3); - let _ = cr.rectangle(pad, pad, ow, oh); - let _ = cr.fill(); - let _ = cr.set_source_rgba(0.5, 0.5, 0.5, 0.7); - let _ = cr.rectangle(pad, pad, ow, oh); - let _ = cr.stroke(); - - // Draw target rectangle (accent color) - let _ = cr.set_source_rgba(0.2, 0.5, 0.9, 0.3); - let _ = cr.rectangle(pad, pad, tw, th); - let _ = cr.fill(); - let _ = cr.set_source_rgba(0.2, 0.5, 0.9, 0.9); - let _ = cr.rectangle(pad, pad, tw, th); - let _ = cr.stroke(); - - // Labels - let _ = cr.set_source_rgba(0.6, 0.6, 0.6, 1.0); - let _ = cr.set_font_size(10.0); - let _ = cr.move_to(pad + ow + 4.0, pad + oh / 2.0); - let _ = cr.show_text(&format!("{}x{}", orig_w as u32, orig_h as u32)); - - let _ = cr.set_source_rgba(0.2, 0.5, 0.9, 1.0); - let _ = cr.move_to(pad + tw + 4.0, pad + th / 2.0 + 12.0); - let _ = cr.show_text(&format!("{}x{}", actual_target_w as u32, actual_target_h as u32)); - }); - - let preview_label = gtk::Label::builder() - .label("Gray = original, Blue = target size") - .css_classes(["dim-label", "caption"]) - .halign(gtk::Align::Center) - .build(); - + // ========== LEFT: Preview ========== let preview_box = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(4) - .margin_top(4) - .margin_bottom(4) + .spacing(8) + .hexpand(true) + .valign(gtk::Align::Start) .build(); - // Thumbnail re-render preview + let thumb_picture = gtk::Picture::builder() .content_fit(gtk::ContentFit::Contain) - .height_request(120) - .halign(gtk::Align::Center) + .height_request(250) + .hexpand(true) .build(); + thumb_picture.add_css_class("card"); thumb_picture.update_property(&[ - gtk::accessible::Property::Label("Thumbnail preview of resized image"), + gtk::accessible::Property::Label("Resize preview - click to cycle images"), ]); - let thumb_label = gtk::Label::builder() - .label("Resized thumbnail preview") + let dims_label = gtk::Label::builder() .css_classes(["dim-label", "caption"]) .halign(gtk::Align::Center) + .margin_top(4) + .build(); + + let no_preview_label = gtk::Label::builder() + .label("Add images to see resize preview") + .css_classes(["dim-label"]) + .halign(gtk::Align::Center) .build(); - preview_box.append(&drawing); - preview_box.append(&preview_label); preview_box.append(&thumb_picture); - preview_box.append(&thumb_label); - preview_group.add(&preview_box); - content.append(&preview_group); + preview_box.append(&dims_label); + preview_box.append(&no_preview_label); - // Shared thumbnail re-render closure + // ========== RIGHT: Controls ========== + let controls_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .width_request(340) + .build(); + + let controls = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .build(); + + // --- Group 1: Preset (two-level: category then size) --- + let preset_group = adw::PreferencesGroup::builder() + .title("Preset") + .description("Pick a category, then a size") + .build(); + + let category_row = adw::ComboRow::builder() + .title("Category") + .use_subtitle(true) + .build(); + category_row.set_model(Some(>k::StringList::new(CATEGORIES))); + category_row.set_list_factory(Some(&super::full_text_list_factory())); + + let size_row = adw::ComboRow::builder() + .title("Size") + .subtitle("Select a preset to fill dimensions") + .use_subtitle(true) + .build(); + rebuild_size_model(&size_row, 0); + + preset_group.add(&category_row); + preset_group.add(&size_row); + controls.append(&preset_group); + + // --- Group 2: Dimensions --- + let dims_group = adw::PreferencesGroup::builder() + .title("Dimensions") + .build(); + + // Custom horizontal row: [W] [width_spin] [lock] [height_spin] [H] [px|%] + let dim_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_start(12) + .margin_end(12) + .margin_top(10) + .margin_bottom(10) + .halign(gtk::Align::Center) + .build(); + + let w_label = gtk::Label::builder() + .label("W") + .css_classes(["dim-label"]) + .build(); + let width_spin = gtk::SpinButton::builder() + .adjustment(>k::Adjustment::new( + cfg.resize_width as f64, 0.0, 10000.0, 1.0, 100.0, 0.0, + )) + .numeric(true) + .width_chars(6) + .tooltip_text("Width") + .build(); + width_spin.update_property(&[ + gtk::accessible::Property::Label("Width"), + ]); + + let lock_btn = gtk::ToggleButton::builder() + .icon_name("changes-prevent-symbolic") + .active(true) + .tooltip_text("Aspect ratio locked") + .build(); + lock_btn.update_property(&[ + gtk::accessible::Property::Label("Lock aspect ratio"), + ]); + + let height_spin = gtk::SpinButton::builder() + .adjustment(>k::Adjustment::new( + cfg.resize_height as f64, 0.0, 10000.0, 1.0, 100.0, 0.0, + )) + .numeric(true) + .width_chars(6) + .tooltip_text("Height") + .build(); + height_spin.update_property(&[ + gtk::accessible::Property::Label("Height"), + ]); + let h_label = gtk::Label::builder() + .label("H") + .css_classes(["dim-label"]) + .build(); + + // Unit segmented toggle (px / %) + let unit_box = gtk::Box::new(gtk::Orientation::Horizontal, 0); + unit_box.add_css_class("linked"); + let px_btn = gtk::Button::builder().label("px").build(); + let pct_btn = gtk::Button::builder().label("%").build(); + px_btn.add_css_class("suggested-action"); + unit_box.append(&px_btn); + unit_box.append(&pct_btn); + + dim_row.append(&w_label); + dim_row.append(&width_spin); + dim_row.append(&lock_btn); + dim_row.append(&height_spin); + dim_row.append(&h_label); + dim_row.append(&unit_box); + dims_group.add(&dim_row); + + // Mode + let mode_row = adw::ComboRow::builder() + .title("Mode") + .subtitle("How dimensions are applied to images") + .use_subtitle(true) + .build(); + mode_row.set_model(Some(>k::StringList::new(&[ + "Exact Size", + "Fit Within Box", + ]))); + mode_row.set_list_factory(Some(&super::full_text_list_factory())); + + // Upscale + let upscale_row = adw::SwitchRow::builder() + .title("Allow Upscaling") + .subtitle("Enlarge images smaller than target size") + .active(cfg.allow_upscale) + .build(); + + dims_group.add(&mode_row); + dims_group.add(&upscale_row); + controls.append(&dims_group); + + // --- Group 3: Advanced --- + let advanced_group = adw::PreferencesGroup::builder() + .title("Advanced") + .build(); + let advanced_expander = adw::ExpanderRow::builder() + .title("Advanced Settings") + .subtitle("Resize algorithm and output DPI") + .show_enable_switch(false) + .expanded(state.is_section_expanded("resize-advanced")) + .build(); + { + let st = state.clone(); + advanced_expander.connect_expanded_notify(move |row| { + st.set_section_expanded("resize-advanced", row.is_expanded()); + }); + } + + let algorithm_row = adw::ComboRow::builder() + .title("Resize Algorithm") + .use_subtitle(true) + .build(); + algorithm_row.set_model(Some(>k::StringList::new(&[ + "Lanczos3 (Best quality)", + "CatmullRom (Good, faster)", + "Bilinear (Fast)", + "Nearest (Pixelated)", + ]))); + algorithm_row.set_list_factory(Some(&super::full_text_list_factory())); + + let dpi_row = adw::SpinRow::builder() + .title("DPI") + .subtitle("Output resolution in dots per inch") + .adjustment(>k::Adjustment::new(72.0, 72.0, 600.0, 1.0, 10.0, 0.0)) + .build(); + + advanced_expander.add_row(&algorithm_row); + advanced_expander.add_row(&dpi_row); + advanced_group.add(&advanced_expander); + controls.append(&advanced_group); + + controls_scroll.set_child(Some(&controls)); + + split.append(&preview_box); + split.append(&controls_scroll); + outer.append(&split); + + // === SHARED STATE === + let preview_width = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_width)); + let preview_height = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_height)); + let preview_upscale = std::rc::Rc::new(std::cell::Cell::new(cfg.allow_upscale)); + let preview_mode = std::rc::Rc::new(std::cell::Cell::new(cfg.resize_mode)); + let preview_algo = std::rc::Rc::new(std::cell::Cell::new(0u32)); + let preview_index = std::rc::Rc::new(std::cell::Cell::new(0usize)); + let is_pct = std::rc::Rc::new(std::cell::Cell::new(false)); + let updating = std::rc::Rc::new(std::cell::Cell::new(false)); + let loaded_files = state.loaded_files.clone(); + + drop(cfg); + + // === RENDER CLOSURE === + let resize_preview_gen: Rc> = Rc::new(Cell::new(0)); let render_thumb = { let lf = loaded_files.clone(); let pw = preview_width.clone(); let ph = preview_height.clone(); + let pu = preview_upscale.clone(); + let pm = preview_mode.clone(); + let pa = preview_algo.clone(); + let pi = preview_index.clone(); let pic = thumb_picture.clone(); - let lbl = thumb_label.clone(); + let dlbl = dims_label.clone(); + let npl = no_preview_label.clone(); + let bind_gen = resize_preview_gen.clone(); std::rc::Rc::new(move || { let files = lf.borrow(); - let Some(first) = files.first() else { + if files.is_empty() { pic.set_paintable(gtk::gdk::Paintable::NONE); - lbl.set_label("Add images to see resize preview"); + pic.set_visible(false); + npl.set_visible(true); + npl.set_label("Add images to see resize preview"); + dlbl.set_label(""); return; - }; + } + let idx = pi.get() % files.len(); + let Some(current) = files.get(idx) else { return }; + pic.set_visible(true); + npl.set_visible(false); + let tw = *pw.borrow(); let th = *ph.borrow(); if tw == 0 && th == 0 { pic.set_paintable(gtk::gdk::Paintable::NONE); - lbl.set_label("Set dimensions to preview"); + npl.set_visible(true); + npl.set_label("Set dimensions to preview"); + pic.set_visible(false); + dlbl.set_label(""); return; } - let path = first.clone(); + + let file_count = files.len(); + let (orig_w, orig_h) = get_image_dims(current.as_path()); + + let actual_tw = if tw > 0 { tw } else { orig_w }; + let actual_th = if th > 0 { + th + } else if tw > 0 { + let scale = tw as f64 / orig_w as f64; + (orig_h as f64 * scale).round() as u32 + } else { + orig_h + }; + + let allow_up = pu.get(); + let (render_tw, render_th) = if !allow_up { + (actual_tw.min(orig_w), actual_th.min(orig_h)) + } else { + (actual_tw, actual_th) + }; + + let scale_pct = if orig_w > 0 { + (render_tw as f64 / orig_w as f64 * 100.0).round() as u32 + } else { + 100 + }; + + let counter = if file_count > 1 { + format!(" [{}/{}]", idx + 1, file_count) + } else { + String::new() + }; + + let clamp_note = if !allow_up && (actual_tw > orig_w || actual_th > orig_h) { + " (clamped)" + } else { + "" + }; + + dlbl.set_label(&format!( + "{} x {} -> {} x {} ({}%){}{}", orig_w, orig_h, + render_tw, render_th, scale_pct, clamp_note, counter, + )); + + let my_gen = bind_gen.get().wrapping_add(1); + bind_gen.set(my_gen); + let gen_check = bind_gen.clone(); + + let path = current.clone(); let pic = pic.clone(); - let lbl = lbl.clone(); + let algo = algo_index_to_filter(pa.get()); + let mode = pm.get(); let (tx, rx) = std::sync::mpsc::channel::>>(); std::thread::spawn(move || { let result = (|| -> Option> { let img = image::open(&path).ok()?; - let target_w = if tw > 0 { tw } else { img.width() }; - let target_h = if th > 0 { - th + let target_w = if render_tw > 0 { render_tw } else { img.width() }; + let target_h = if render_th > 0 { + render_th } else { let scale = target_w as f64 / img.width() as f64; (img.height() as f64 * scale).round() as u32 }; - let resized = img.resize( - target_w.min(400), - target_h.min(400), - image::imageops::FilterType::Lanczos3, - ); + let resized = if mode == 0 && render_th > 0 { + // Exact: stretch to exact dimensions + img.resize_exact(target_w.min(1024), target_h.min(1024), algo) + } else { + // Fit within box (or width-only): maintain aspect ratio + img.resize(target_w.min(1024), target_h.min(1024), algo) + }; let mut buf = Vec::new(); resized.write_to( &mut std::io::Cursor::new(&mut buf), @@ -417,222 +469,365 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage { 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); - let stream = gtk::gio::MemoryInputStream::from_bytes(&gbytes); - if let Ok(pb) = gtk::gdk_pixbuf::Pixbuf::from_stream( - &stream, - gtk::gio::Cancellable::NONE, - ) { - let texture = gtk::gdk::Texture::for_pixbuf(&pb); + if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) { pic.set_paintable(Some(&texture)); - lbl.set_label(&format!( - "Preview at {}x{}", - pb.width(), - pb.height() - )); } glib::ControlFlow::Break } - Ok(None) => { - lbl.set_label("Preview unavailable"); - glib::ControlFlow::Break - } + Ok(None) => glib::ControlFlow::Break, Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(_) => glib::ControlFlow::Break, } }); }) }; - // Trigger initial render - { - let rt = render_thumb.clone(); - glib::idle_add_local_once(move || rt()); - } - // Basic orientation adjustments (folded into resize step per design doc) - let orientation_group = adw::PreferencesGroup::builder() - .title("Orientation") - .description("Rotate and flip applied before resize") - .build(); + // === WIRE SIGNALS === - let rotate_row = adw::ComboRow::builder() - .title("Rotate") - .subtitle("Rotation applied to all images") - .build(); - let rotate_model = gtk::StringList::new(&[ - "None", - "90 clockwise", - "180", - "270 clockwise", - "Auto-orient (from EXIF)", - ]); - rotate_row.set_model(Some(&rotate_model)); - rotate_row.set_selected(cfg.rotation); - - 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); - - orientation_group.add(&rotate_row); - orientation_group.add(&flip_row); - content.append(&orientation_group); - - // Advanced options (AdwExpanderRow per design doc) - let advanced_group = adw::PreferencesGroup::builder() - .title("Advanced") - .build(); - - let advanced_expander = adw::ExpanderRow::builder() - .title("Advanced Options") - .subtitle("Resize algorithm, DPI, upscale behavior") - .show_enable_switch(false) - .expanded(state.is_section_expanded("resize-advanced")) - .build(); - - { - let st = state.clone(); - advanced_expander.connect_expanded_notify(move |row| { - st.set_section_expanded("resize-advanced", row.is_expanded()); - }); - } - - let upscale_row = adw::SwitchRow::builder() - .title("Allow Upscaling") - .subtitle("Enlarge images smaller than target size") - .active(cfg.allow_upscale) - .build(); - - let algorithm_row = adw::ComboRow::builder() - .title("Resize Algorithm") - .subtitle("Method used for pixel interpolation") - .build(); - let algo_model = gtk::StringList::new(&[ - "Lanczos3 (Best quality)", - "CatmullRom (Good quality, faster)", - "Bilinear (Fast, lower quality)", - "Nearest (Fastest, pixelated)", - ]); - algorithm_row.set_model(Some(&algo_model)); - - let dpi_row = adw::SpinRow::builder() - .title("DPI") - .subtitle("Output resolution in dots per inch") - .adjustment(>k::Adjustment::new(72.0, 72.0, 600.0, 1.0, 10.0, 0.0)) - .build(); - - advanced_expander.add_row(&upscale_row); - advanced_expander.add_row(&algorithm_row); - advanced_expander.add_row(&dpi_row); - advanced_group.add(&advanced_expander); - content.append(&advanced_group); - - drop(cfg); - - // Wire signals + // Enable toggle { let jc = state.job_config.clone(); enable_row.connect_active_notify(move |row| { jc.borrow_mut().resize_enabled = row.is_active(); }); } - // Shared flag to prevent recursive updates from aspect ratio lock - let updating_lock = std::rc::Rc::new(std::cell::Cell::new(false)); + + // Category selection - rebuild the size dropdown + { + let sr = size_row.clone(); + category_row.connect_selected_notify(move |row| { + rebuild_size_model(&sr, row.selected()); + }); + } + + // Size selection - auto-fill dimensions and lock aspect ratio + { + let ws = width_spin.clone(); + let hs = height_spin.clone(); + let lb = lock_btn.clone(); + let jc = state.job_config.clone(); + let pw = preview_width.clone(); + let ph = preview_height.clone(); + let upd = updating.clone(); + let ip = is_pct.clone(); + let px = px_btn.clone(); + let pct = pct_btn.clone(); + let rt = render_thumb.clone(); + let cr = category_row.clone(); + size_row.connect_selected_notify(move |row| { + let sel = row.selected() as usize; + if sel == 0 { + return; // "(select a size)" - don't change anything + } + let presets = presets_for_category(cr.selected()); + let Some(&(_, w, h)) = presets.get(sel - 1) else { return }; + + // Switch to pixels if currently in percentage + if ip.get() { + ip.set(false); + px.add_css_class("suggested-action"); + pct.remove_css_class("suggested-action"); + ws.set_range(0.0, 10000.0); + ws.set_increments(1.0, 100.0); + hs.set_range(0.0, 10000.0); + hs.set_increments(1.0, 100.0); + } + + upd.set(true); + ws.set_value(w as f64); + hs.set_value(h as f64); + upd.set(false); + + // Lock aspect ratio + if !lb.is_active() { + lb.set_active(true); + } + + let mut c = jc.borrow_mut(); + c.resize_width = w; + c.resize_height = h; + *pw.borrow_mut() = w; + *ph.borrow_mut() = h; + + rt(); + }); + } + + // Lock button icon change + { + let lb = lock_btn.clone(); + lock_btn.connect_active_notify(move |btn| { + if btn.is_active() { + lb.set_icon_name("changes-prevent-symbolic"); + lb.set_tooltip_text(Some("Aspect ratio locked")); + } else { + lb.set_icon_name("changes-allow-symbolic"); + lb.set_tooltip_text(Some("Aspect ratio unlocked")); + } + }); + } + + // Width spin with aspect lock { let jc = state.job_config.clone(); let pw = preview_width.clone(); let ph = preview_height.clone(); - let draw = drawing.clone(); let rt = render_thumb.clone(); - let lock = lock_row.clone(); - let hr = height_row.clone(); + let lb = lock_btn.clone(); + let hs = height_spin.clone(); let files = state.loaded_files.clone(); - let upd = updating_lock.clone(); - width_row.connect_value_notify(move |row| { + let upd = updating.clone(); + let ip = is_pct.clone(); + let pr = size_row.clone(); + width_spin.connect_value_notify(move |spin| { if upd.get() { return; } - let val = row.value() as u32; - jc.borrow_mut().resize_width = val; - *pw.borrow_mut() = val; + let val = spin.value(); - // Auto-calculate height if lock is active - if lock.is_active() && val > 0 { - let aspect = get_first_image_aspect(&files.borrow()); - if aspect > 0.0 { - let new_h = (val as f64 / aspect).round() as u32; + let pixel_val = if ip.get() { + let dims = get_first_image_dims(&files.borrow()); + (val / 100.0 * dims.0 as f64).round() as u32 + } else { + val as u32 + }; + + jc.borrow_mut().resize_width = pixel_val; + *pw.borrow_mut() = pixel_val; + + // Reset preset to Custom when manually editing + if pr.selected() != 0 { + upd.set(true); + pr.set_selected(0); + upd.set(false); + } + + if lb.is_active() && val > 0.0 { + if ip.get() { upd.set(true); - hr.set_value(new_h as f64); - jc.borrow_mut().resize_height = new_h; - *ph.borrow_mut() = new_h; + hs.set_value(val); + let dims = get_first_image_dims(&files.borrow()); + let pixel_h = (val / 100.0 * dims.1 as f64).round() as u32; + jc.borrow_mut().resize_height = pixel_h; + *ph.borrow_mut() = pixel_h; upd.set(false); + } else { + let aspect = get_first_image_aspect(&files.borrow()); + if aspect > 0.0 { + let new_h = (val / aspect).round() as u32; + upd.set(true); + hs.set_value(new_h as f64); + jc.borrow_mut().resize_height = new_h; + *ph.borrow_mut() = new_h; + upd.set(false); + } } } - draw.queue_draw(); rt(); }); } + + // Height spin with aspect lock { let jc = state.job_config.clone(); let pw = preview_width.clone(); let ph = preview_height.clone(); - let draw = drawing.clone(); let rt = render_thumb.clone(); - let lock = lock_row.clone(); - let wr = width_row.clone(); + let lb = lock_btn.clone(); + let ws = width_spin.clone(); let files = state.loaded_files.clone(); - let upd = updating_lock.clone(); - height_row.connect_value_notify(move |row| { + let upd = updating.clone(); + let ip = is_pct.clone(); + let pr = size_row.clone(); + height_spin.connect_value_notify(move |spin| { if upd.get() { return; } - let val = row.value() as u32; - jc.borrow_mut().resize_height = val; - *ph.borrow_mut() = val; + let val = spin.value(); - // Auto-calculate width if lock is active - if lock.is_active() && val > 0 { - let aspect = get_first_image_aspect(&files.borrow()); - if aspect > 0.0 { - let new_w = (val as f64 * aspect).round() as u32; + let pixel_val = if ip.get() { + let dims = get_first_image_dims(&files.borrow()); + (val / 100.0 * dims.1 as f64).round() as u32 + } else { + val as u32 + }; + + jc.borrow_mut().resize_height = pixel_val; + *ph.borrow_mut() = pixel_val; + + // Reset preset to Custom when manually editing + if pr.selected() != 0 { + upd.set(true); + pr.set_selected(0); + upd.set(false); + } + + if lb.is_active() && val > 0.0 { + if ip.get() { upd.set(true); - wr.set_value(new_w as f64); - jc.borrow_mut().resize_width = new_w; - *pw.borrow_mut() = new_w; + ws.set_value(val); + let dims = get_first_image_dims(&files.borrow()); + let pixel_w = (val / 100.0 * dims.0 as f64).round() as u32; + jc.borrow_mut().resize_width = pixel_w; + *pw.borrow_mut() = pixel_w; upd.set(false); + } else { + let aspect = get_first_image_aspect(&files.borrow()); + if aspect > 0.0 { + let new_w = (val * aspect).round() as u32; + upd.set(true); + ws.set_value(new_w as f64); + jc.borrow_mut().resize_width = new_w; + *pw.borrow_mut() = new_w; + upd.set(false); + } } } - draw.queue_draw(); rt(); }); } + + // Unit toggle: px button + { + let pct = pct_btn.clone(); + let px = px_btn.clone(); + let ip = is_pct.clone(); + let ws = width_spin.clone(); + let hs = height_spin.clone(); + let files = state.loaded_files.clone(); + let upd = updating.clone(); + let jc = state.job_config.clone(); + let pw = preview_width.clone(); + let ph = preview_height.clone(); + let rt = render_thumb.clone(); + px_btn.connect_clicked(move |_| { + if !ip.get() { return; } // already pixels + ip.set(false); + px.add_css_class("suggested-action"); + pct.remove_css_class("suggested-action"); + + let dims = get_first_image_dims(&files.borrow()); + let pct_w = ws.value(); + let pct_h = hs.value(); + let pixel_w = (pct_w / 100.0 * dims.0 as f64).round(); + let pixel_h = (pct_h / 100.0 * dims.1 as f64).round(); + + upd.set(true); + ws.set_range(0.0, 10000.0); + ws.set_increments(1.0, 100.0); + hs.set_range(0.0, 10000.0); + hs.set_increments(1.0, 100.0); + ws.set_value(pixel_w); + hs.set_value(pixel_h); + upd.set(false); + + let mut c = jc.borrow_mut(); + c.resize_width = pixel_w as u32; + c.resize_height = pixel_h as u32; + *pw.borrow_mut() = pixel_w as u32; + *ph.borrow_mut() = pixel_h as u32; + + rt(); + }); + } + + // Unit toggle: % button + { + let px = px_btn.clone(); + let pct = pct_btn.clone(); + let ip = is_pct.clone(); + let ws = width_spin.clone(); + let hs = height_spin.clone(); + let files = state.loaded_files.clone(); + let upd = updating.clone(); + let rt = render_thumb.clone(); + let jc = state.job_config.clone(); + let pw = preview_width.clone(); + let ph = preview_height.clone(); + pct_btn.connect_clicked(move |_| { + if ip.get() { return; } // already percentage + ip.set(true); + pct.add_css_class("suggested-action"); + px.remove_css_class("suggested-action"); + + let dims = get_first_image_dims(&files.borrow()); + let cur_w = ws.value(); + let cur_h = hs.value(); + let pct_w = if dims.0 > 0 { (cur_w / dims.0 as f64 * 100.0).round() } else { 100.0 }; + let pct_h = if dims.1 > 0 { (cur_h / dims.1 as f64 * 100.0).round() } else { 100.0 }; + + upd.set(true); + ws.set_range(0.0, 1000.0); + ws.set_increments(1.0, 10.0); + hs.set_range(0.0, 1000.0); + hs.set_increments(1.0, 10.0); + ws.set_value(pct_w); + hs.set_value(pct_h); + upd.set(false); + + // Update job_config with the pixel values + let pixel_w = (pct_w / 100.0 * dims.0 as f64).round() as u32; + let pixel_h = (pct_h / 100.0 * dims.1 as f64).round() as u32; + let mut c = jc.borrow_mut(); + c.resize_width = pixel_w; + c.resize_height = pixel_h; + *pw.borrow_mut() = pixel_w; + *ph.borrow_mut() = pixel_h; + + rt(); + }); + } + + // Mode toggle - update labels and job_config + { + let ws = width_spin.clone(); + let hs = height_spin.clone(); + let jc = state.job_config.clone(); + let rt = render_thumb.clone(); + let pmode = preview_mode.clone(); + mode_row.connect_selected_notify(move |row| { + jc.borrow_mut().resize_mode = row.selected(); + pmode.set(row.selected()); + if row.selected() == 1 { + ws.set_tooltip_text(Some("Maximum width")); + hs.set_tooltip_text(Some("Maximum height")); + } else { + ws.set_tooltip_text(Some("Width")); + hs.set_tooltip_text(Some("Height")); + } + rt(); + }); + } + + // Upscale toggle { let jc = state.job_config.clone(); + let pu = preview_upscale.clone(); + let rt = render_thumb.clone(); upscale_row.connect_active_notify(move |row| { jc.borrow_mut().allow_upscale = row.is_active(); + pu.set(row.is_active()); + rt(); }); } + + // Algorithm { 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(); - }); - } - { - let jc = state.job_config.clone(); + let pa = preview_algo.clone(); + let rt = render_thumb.clone(); algorithm_row.connect_selected_notify(move |row| { jc.borrow_mut().resize_algorithm = row.selected(); + pa.set(row.selected()); + rt(); }); } + + // DPI { let jc = state.job_config.clone(); dpi_row.connect_value_notify(move |row| { @@ -640,24 +835,44 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage { }); } - scrolled.set_child(Some(&content)); + // Click preview to cycle images + { + let pi = preview_index.clone(); + let rt = render_thumb.clone(); + let lf = loaded_files.clone(); + let click = gtk::GestureClick::new(); + click.connect_released(move |gesture, _, _, _| { + let count = lf.borrow().len(); + if count > 1 { + pi.set((pi.get() + 1) % count); + rt(); + } + gesture.set_state(gtk::EventSequenceState::Claimed); + }); + thumb_picture.set_can_target(true); + thumb_picture.add_controller(click); + thumb_picture.set_cursor_from_name(Some("pointer")); + } - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&scrolled) - .build(); + // Initial render + { + let rt = render_thumb.clone(); + glib::idle_add_local_once(move || rt()); + } - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title("Resize") .tag("step-resize") - .child(&clamp) - .build() -} + .child(&outer) + .build(); -/// Get dimensions of an image file without loading the full image -fn image_dimensions(path: &std::path::Path) -> Option<(f64, f64)> { - let reader = image::ImageReader::open(path).ok()?; - let reader = reader.with_guessed_format().ok()?; - let (w, h) = reader.into_dimensions().ok()?; - Some((w as f64, h as f64)) + // Re-render on page map + { + let rt = render_thumb.clone(); + page.connect_map(move |_| { + rt(); + }); + } + + page } diff --git a/pixstrip-gtk/src/steps/step_watermark.rs b/pixstrip-gtk/src/steps/step_watermark.rs index 3f6fa2b..a0a25b4 100644 --- a/pixstrip-gtk/src/steps/step_watermark.rs +++ b/pixstrip-gtk/src/steps/step_watermark.rs @@ -1,35 +1,75 @@ use adw::prelude::*; +use gtk::glib; +use std::cell::Cell; +use std::rc::Rc; + use crate::app::AppState; pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { - let scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) + let cfg = state.job_config.borrow(); + + // === OUTER LAYOUT === + let outer = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) .vexpand(true) .build(); - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) + // --- Enable toggle (full width) --- + let enable_group = adw::PreferencesGroup::builder() + .margin_start(12) + .margin_end(12) .margin_top(12) - .margin_bottom(12) - .margin_start(24) - .margin_end(24) .build(); - - let 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); + outer.append(&enable_group); - // Watermark type selection + // === LEFT SIDE: Preview === + + let preview_picture = gtk::Picture::builder() + .content_fit(gtk::ContentFit::Contain) + .hexpand(true) + .vexpand(true) + .build(); + preview_picture.set_can_target(true); + + let info_label = gtk::Label::builder() + .label("No images loaded") + .css_classes(["dim-label", "caption"]) + .halign(gtk::Align::Center) + .margin_top(4) + .margin_bottom(4) + .build(); + + let 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(); + + // --- Watermark type --- let type_group = adw::PreferencesGroup::builder() .title("Watermark Type") .build(); @@ -37,17 +77,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { let type_row = adw::ComboRow::builder() .title("Type") .subtitle("Choose text or image watermark") + .use_subtitle(true) .build(); - let type_model = gtk::StringList::new(&["Text Watermark", "Image Watermark"]); - type_row.set_model(Some(&type_model)); + type_row.set_model(Some(>k::StringList::new(&["Text Watermark", "Image Watermark"]))); + type_row.set_list_factory(Some(&super::full_text_list_factory())); type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 }); type_group.add(&type_row); - content.append(&type_group); + controls.append(&type_group); - // Text watermark settings + // --- Text watermark settings --- let text_group = adw::PreferencesGroup::builder() .title("Text Watermark") + .visible(!cfg.watermark_use_image) .build(); let text_row = adw::EntryRow::builder() @@ -55,13 +97,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { .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(); - - // Font family picker let font_row = adw::ActionRow::builder() .title("Font Family") .subtitle("Choose a typeface for the watermark text") @@ -76,20 +111,24 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { .valign(gtk::Align::Center) .build(); - // Set initial font if one was previously selected if !cfg.watermark_font_family.is_empty() { let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family); font_button.set_font_desc(&desc); } - font_row.add_suffix(&font_button); + 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_row); text_group.add(&font_size_row); - content.append(&text_group); + controls.append(&text_group); - // Image watermark settings + // --- Image watermark settings --- let image_group = adw::PreferencesGroup::builder() .title("Image Watermark") .visible(cfg.watermark_use_image) @@ -111,14 +150,14 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { .icon_name("document-open-symbolic") .tooltip_text("Choose logo image") .valign(gtk::Align::Center) + .has_frame(false) .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); + controls.append(&image_group); - // Visual 9-point position grid + // --- Position group with 3x3 grid --- let position_group = adw::PreferencesGroup::builder() .title("Position") .description("Choose where the watermark appears on the image") @@ -130,7 +169,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { "Bottom Left", "Bottom Center", "Bottom Right", ]; - // Build a 3x3 grid of toggle buttons let grid = gtk::Grid::builder() .row_spacing(4) .column_spacing(4) @@ -139,7 +177,12 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { .margin_bottom(8) .build(); - // Create a visual "image" area as background context + // Frame styled to look like a miniature image + let grid_outer = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .halign(gtk::Align::Center) + .build(); + let grid_frame = gtk::Frame::builder() .halign(gtk::Align::Center) .build(); @@ -148,6 +191,17 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { gtk::accessible::Property::Label("Watermark position grid. Select where the watermark appears on the image."), ]); + // Image outline label above the grid + let grid_title = gtk::Label::builder() + .label("Image") + .css_classes(["dim-label", "caption"]) + .halign(gtk::Align::Center) + .margin_bottom(4) + .build(); + + grid_outer.append(&grid_title); + grid_outer.append(&grid_frame); + let mut first_button: Option = None; let buttons: Vec = position_names.iter().enumerate().map(|(i, name)| { let btn = gtk::ToggleButton::builder() @@ -156,7 +210,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { .height_request(48) .build(); - // Use a dot icon for each position let icon = if i == cfg.watermark_position as usize { "radio-checked-symbolic" } else { @@ -178,163 +231,26 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { btn }).collect(); - position_group.add(&grid_frame); + position_group.add(&grid_outer); - // Position label showing current selection let position_label = gtk::Label::builder() - .label(position_names[cfg.watermark_position as usize]) + .label(position_names.get(cfg.watermark_position as usize).copied().unwrap_or("Center")) .css_classes(["dim-label"]) .halign(gtk::Align::Center) .margin_bottom(4) .build(); position_group.add(&position_label); - content.append(&position_group); + controls.append(&position_group); - // Live preview section - let preview_group = adw::PreferencesGroup::builder() - .title("Preview") - .description("Shows how the watermark will appear on your image") - .build(); - - // Overlay container for image + watermark text - let preview_overlay = gtk::Overlay::builder() - .halign(gtk::Align::Center) - .build(); - - let preview_picture = gtk::Picture::builder() - .content_fit(gtk::ContentFit::Contain) - .width_request(300) - .height_request(200) - .build(); - preview_picture.add_css_class("card"); - preview_overlay.set_child(Some(&preview_picture)); - - // Watermark text label overlay - let watermark_label = gtk::Label::builder() - .label(&cfg.watermark_text) - .css_classes(["heading"]) - .opacity(cfg.watermark_opacity as f64) - .build(); - preview_overlay.add_overlay(&watermark_label); - - // Position the watermark label according to grid position - fn set_watermark_alignment(label: >k::Label, position: u32) { - let (h, v) = match position { - 0 => (gtk::Align::Start, gtk::Align::Start), // Top Left - 1 => (gtk::Align::Center, gtk::Align::Start), // Top Center - 2 => (gtk::Align::End, gtk::Align::Start), // Top Right - 3 => (gtk::Align::Start, gtk::Align::Center), // Middle Left - 4 => (gtk::Align::Center, gtk::Align::Center), // Center - 5 => (gtk::Align::End, gtk::Align::Center), // Middle Right - 6 => (gtk::Align::Start, gtk::Align::End), // Bottom Left - 7 => (gtk::Align::Center, gtk::Align::End), // Bottom Center - _ => (gtk::Align::End, gtk::Align::End), // Bottom Right - }; - label.set_halign(h); - label.set_valign(v); - label.set_margin_start(8); - label.set_margin_end(8); - label.set_margin_top(8); - label.set_margin_bottom(8); - } - set_watermark_alignment(&watermark_label, cfg.watermark_position); - - // Load first image from batch as preview background - { - let files = state.loaded_files.borrow(); - if let Some(first) = files.first() { - preview_picture.set_filename(Some(first)); - } - } - - // "No preview" placeholder - let no_preview_label = gtk::Label::builder() - .label("Add images to see a preview") - .css_classes(["dim-label"]) - .halign(gtk::Align::Center) - .valign(gtk::Align::Center) - .build(); - { - let has_files = !state.loaded_files.borrow().is_empty(); - no_preview_label.set_visible(!has_files); - preview_picture.set_visible(has_files); - } - - // Thumbnail strip for selecting preview image - let wm_thumb_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(4) - .halign(gtk::Align::Center) - .margin_top(4) - .build(); - { - let files = state.loaded_files.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(40) - .height_request(40) - .build(); - pic.set_filename(Some(&files[i])); - let frame = gtk::Frame::builder() - .child(&pic) - .build(); - if i == 0 { frame.add_css_class("accent"); } - - 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(); - - let pp = preview_picture.clone(); - let path = files[i].clone(); - let tb = wm_thumb_box.clone(); - let current_idx = i; - btn.connect_clicked(move |_| { - pp.set_filename(Some(&path)); - let mut c = tb.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; - } - }); - - wm_thumb_box.append(&btn); - } - wm_thumb_box.set_visible(max_thumbs > 1); - } - - let preview_stack = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(4) - .margin_top(8) - .margin_bottom(8) - .build(); - preview_stack.append(&preview_overlay); - preview_stack.append(&wm_thumb_box); - preview_stack.append(&no_preview_label); - - preview_group.add(&preview_stack); - content.append(&preview_group); - - // Advanced options + // --- Advanced options --- let advanced_group = adw::PreferencesGroup::builder() .title("Advanced") .build(); let advanced_expander = adw::ExpanderRow::builder() .title("Advanced Options") - .subtitle("Opacity, rotation, tiling, margin") + .subtitle("Color, opacity, rotation, tiling, margin, scale") .show_enable_switch(false) .expanded(state.is_section_expanded("watermark-advanced")) .build(); @@ -369,37 +285,97 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { .build(); color_row.add_suffix(&color_button); - let opacity_row = adw::SpinRow::builder() + // Opacity slider + reset + let opacity_row = adw::ActionRow::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) + .subtitle(&format!("{}%", (cfg.watermark_opacity * 100.0).round() as i32)) .build(); + let opacity_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 100.0, 1.0); + opacity_scale.set_value((cfg.watermark_opacity * 100.0) as f64); + opacity_scale.set_draw_value(false); + opacity_scale.set_hexpand(false); + opacity_scale.set_valign(gtk::Align::Center); + opacity_scale.set_width_request(180); + let opacity_reset = gtk::Button::builder() + .icon_name("edit-undo-symbolic") + .valign(gtk::Align::Center) + .tooltip_text("Reset to 50%") + .has_frame(false) + .build(); + opacity_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01); + opacity_row.add_suffix(&opacity_scale); + opacity_row.add_suffix(&opacity_reset); - let rotation_row = adw::ComboRow::builder() + // Rotation slider + reset (-180 to +180) + let rotation_row = adw::ActionRow::builder() .title("Rotation") - .subtitle("Rotate the watermark") + .subtitle(&format!("{} degrees", cfg.watermark_rotation)) .build(); - let rotation_model = gtk::StringList::new(&["None", "45 degrees", "-45 degrees", "90 degrees"]); - rotation_row.set_model(Some(&rotation_model)); + let rotation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -180.0, 180.0, 1.0); + rotation_scale.set_value(cfg.watermark_rotation as f64); + rotation_scale.set_draw_value(false); + rotation_scale.set_hexpand(false); + rotation_scale.set_valign(gtk::Align::Center); + rotation_scale.set_width_request(180); + let rotation_reset = gtk::Button::builder() + .icon_name("edit-undo-symbolic") + .valign(gtk::Align::Center) + .tooltip_text("Reset to 0 degrees") + .has_frame(false) + .build(); + rotation_reset.set_sensitive(cfg.watermark_rotation != 0); + rotation_row.add_suffix(&rotation_scale); + rotation_row.add_suffix(&rotation_reset); + // Tiled toggle let tiled_row = adw::SwitchRow::builder() .title("Tiled / Repeated") .subtitle("Repeat watermark across the entire image") - .active(false) + .active(cfg.watermark_tiled) .build(); - let margin_row = adw::SpinRow::builder() + // Margin slider + reset + let margin_row = adw::ActionRow::builder() .title("Margin from Edges") - .subtitle("Padding in pixels from image edges") - .adjustment(>k::Adjustment::new(10.0, 0.0, 200.0, 1.0, 10.0, 0.0)) + .subtitle(&format!("{} px", cfg.watermark_margin)) .build(); + let margin_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 200.0, 1.0); + margin_scale.set_value(cfg.watermark_margin as f64); + margin_scale.set_draw_value(false); + margin_scale.set_hexpand(false); + margin_scale.set_valign(gtk::Align::Center); + margin_scale.set_width_request(180); + let margin_reset = gtk::Button::builder() + .icon_name("edit-undo-symbolic") + .valign(gtk::Align::Center) + .tooltip_text("Reset to 10 px") + .has_frame(false) + .build(); + margin_reset.set_sensitive(cfg.watermark_margin != 10); + margin_row.add_suffix(&margin_scale); + margin_row.add_suffix(&margin_reset); - let scale_row = adw::SpinRow::builder() + // Scale slider + reset (only relevant for image watermarks) + let scale_row = adw::ActionRow::builder() .title("Scale (% of image)") - .subtitle("Watermark size relative to image") - .adjustment(>k::Adjustment::new(20.0, 1.0, 100.0, 1.0, 5.0, 0.0)) + .subtitle(&format!("{}%", cfg.watermark_scale.round() as i32)) + .visible(cfg.watermark_use_image) .build(); + let scale_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0); + scale_scale.set_value(cfg.watermark_scale as f64); + scale_scale.set_draw_value(false); + scale_scale.set_hexpand(false); + scale_scale.set_valign(gtk::Align::Center); + scale_scale.set_width_request(180); + let scale_reset = gtk::Button::builder() + .icon_name("edit-undo-symbolic") + .valign(gtk::Align::Center) + .tooltip_text("Reset to 20%") + .has_frame(false) + .build(); + scale_reset.set_sensitive((cfg.watermark_scale - 20.0).abs() > 0.5); + scale_row.add_suffix(&scale_scale); + scale_row.add_suffix(&scale_reset); advanced_expander.add_row(&color_row); advanced_expander.add_row(&opacity_row); @@ -409,67 +385,274 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { advanced_expander.add_row(&scale_row); advanced_group.add(&advanced_expander); - content.append(&advanced_group); + controls.append(&advanced_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); - // Wire signals + // === 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() - 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 wm_text = cfg.watermark_text.clone(); + let wm_use_image = cfg.watermark_use_image; + let wm_image_path = cfg.watermark_image_path.clone(); + let wm_position = cfg.watermark_position; + let wm_opacity = cfg.watermark_opacity; + let wm_font_size = cfg.watermark_font_size; + let wm_color = cfg.watermark_color; + let wm_font_family = cfg.watermark_font_family.clone(); + let wm_tiled = cfg.watermark_tiled; + let wm_margin = cfg.watermark_margin; + let wm_scale = cfg.watermark_scale; + let wm_rotation = cfg.watermark_rotation; + let wm_enabled = cfg.watermark_enabled; + 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); + + let img = if wm_enabled { + let position = match wm_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, + }; + let rotation = if wm_rotation != 0 { + Some(pixstrip_core::operations::WatermarkRotation::Custom(wm_rotation as f32)) + } else { + None + }; + + let wm_config = if wm_use_image { + wm_image_path.as_ref().map(|p| { + pixstrip_core::operations::WatermarkConfig::Image { + path: p.clone(), + position, + opacity: wm_opacity, + scale: wm_scale / 100.0, + rotation, + tiled: wm_tiled, + margin: wm_margin, + } + }) + } else if !wm_text.is_empty() { + Some(pixstrip_core::operations::WatermarkConfig::Text { + text: wm_text, + position, + font_size: wm_font_size, + opacity: wm_opacity, + color: wm_color, + font_family: if wm_font_family.is_empty() { None } else { Some(wm_font_family) }, + rotation, + tiled: wm_tiled, + margin: wm_margin, + }) + } else { + None + }; + + if let Some(config) = wm_config { + pixstrip_core::operations::watermark::apply_watermark(img, &config).ok()? + } else { + img + } + } else { + img + }; + + let mut buf = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut buf), + image::ImageFormat::Png, + ).ok()?; + Some(buf) + })(); + let _ = tx.send(result); + }); + + glib::timeout_add_local(std::time::Duration::from_millis(100), move || { + if gen_check.get() != my_gen { + return glib::ControlFlow::Break; + } + match rx.try_recv() { + Ok(Some(bytes)) => { + let gbytes = glib::Bytes::from(&bytes); + if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) { + pic.set_paintable(Some(&texture)); + } + glib::ControlFlow::Break + } + Ok(None) => glib::ControlFlow::Break, + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(_) => glib::ControlFlow::Break, + } + }); + }) + }; + + // Click-to-cycle on preview + { + let click = gtk::GestureClick::new(); + let pidx = preview_index.clone(); + let files = state.loaded_files.clone(); + let up = update_preview.clone(); + click.connect_released(move |_, _, _, _| { + let loaded = files.borrow(); + if loaded.len() > 1 { + let next = (pidx.get() + 1) % loaded.len(); + pidx.set(next); + up(); + } + }); + preview_picture.add_controller(click); + } + + // === Wire signals === + + // Enable toggle { let jc = state.job_config.clone(); + let up = update_preview.clone(); enable_row.connect_active_notify(move |row| { jc.borrow_mut().watermark_enabled = row.is_active(); + up(); }); } + + // Type selector { let jc = state.job_config.clone(); let text_group_c = text_group.clone(); let image_group_c = image_group.clone(); + let scale_row_c = scale_row.clone(); + let up = update_preview.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); + scale_row_c.set_visible(use_image); + up(); }); } + + // Text entry (debounced to avoid preview on every keystroke) { let jc = state.job_config.clone(); - let wl = watermark_label.clone(); + let up = update_preview.clone(); + let debounce_id: Rc> = Rc::new(Cell::new(0)); text_row.connect_changed(move |row| { let text = row.text().to_string(); - wl.set_label(&text); jc.borrow_mut().watermark_text = text; + let up = up.clone(); + let did = debounce_id.clone(); + let id = did.get().wrapping_add(1); + did.set(id); + glib::timeout_add_local_once(std::time::Duration::from_millis(300), move || { + if did.get() == id { + up(); + } + }); }); } + + // Font family { let jc = state.job_config.clone(); - font_size_row.connect_value_notify(move |row| { - jc.borrow_mut().watermark_font_size = row.value() as f32; - }); - } - // Wire font family picker - { - let jc = state.job_config.clone(); + let up = update_preview.clone(); font_button.connect_font_desc_notify(move |btn| { if let Some(desc) = btn.font_desc() { if let Some(family) = desc.family() { jc.borrow_mut().watermark_font_family = family.to_string(); + up(); } } }); } - // Wire position grid buttons + + // Font size + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + font_size_row.connect_value_notify(move |row| { + jc.borrow_mut().watermark_font_size = row.value() as f32; + up(); + }); + } + + // Position grid buttons for (i, btn) in buttons.iter().enumerate() { let jc = state.job_config.clone(); let label = position_label.clone(); let names = position_names; let all_buttons = buttons.clone(); - let wl = watermark_label.clone(); + let up = update_preview.clone(); btn.connect_toggled(move |b| { if b.is_active() { jc.borrow_mut().watermark_position = i as u32; label.set_label(names[i]); - set_watermark_alignment(&wl, i as u32); - // Update icons for (j, other) in all_buttons.iter().enumerate() { let icon_name = if j == i { "radio-checked-symbolic" @@ -478,21 +661,15 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { }; other.set_child(Some(>k::Image::from_icon_name(icon_name))); } + up(); } }); } + + // Color picker { let jc = state.job_config.clone(); - let wl = watermark_label.clone(); - opacity_row.connect_value_notify(move |row| { - let val = row.value() as f32; - wl.set_opacity(val as f64); - jc.borrow_mut().watermark_opacity = val; - }); - } - // Wire color picker - { - let jc = state.job_config.clone(); + let up = update_preview.clone(); color_button.connect_rgba_notify(move |btn| { let c = btn.rgba(); jc.borrow_mut().watermark_color = [ @@ -501,44 +678,123 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { (c.blue() * 255.0) as u8, (c.alpha() * 255.0) as u8, ]; + up(); }); } - // Wire tiled toggle + + // Opacity slider { let jc = state.job_config.clone(); + let row = opacity_row.clone(); + let up = update_preview.clone(); + let rst = opacity_reset.clone(); + opacity_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as i32; + let opacity = val as f32 / 100.0; + jc.borrow_mut().watermark_opacity = opacity; + row.set_subtitle(&format!("{}%", val)); + rst.set_sensitive(val != 50); + up(); + }); + } + { + let scale = opacity_scale.clone(); + opacity_reset.connect_clicked(move |_| { + scale.set_value(50.0); + }); + } + + // Rotation slider + { + let jc = state.job_config.clone(); + let row = rotation_row.clone(); + let up = update_preview.clone(); + let rst = rotation_reset.clone(); + rotation_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as i32; + jc.borrow_mut().watermark_rotation = val; + row.set_subtitle(&format!("{} degrees", val)); + rst.set_sensitive(val != 0); + up(); + }); + } + { + let scale = rotation_scale.clone(); + rotation_reset.connect_clicked(move |_| { + scale.set_value(0.0); + }); + } + + // Tiled toggle + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); tiled_row.connect_active_notify(move |row| { jc.borrow_mut().watermark_tiled = row.is_active(); + up(); }); } - // Wire margin spinner + + // Margin slider { let jc = state.job_config.clone(); - margin_row.connect_value_notify(move |row| { - jc.borrow_mut().watermark_margin = row.value() as u32; + let row = margin_row.clone(); + let up = update_preview.clone(); + let rst = margin_reset.clone(); + margin_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as i32; + jc.borrow_mut().watermark_margin = val as u32; + row.set_subtitle(&format!("{} px", val)); + rst.set_sensitive(val != 10); + up(); }); } - // Wire scale spinner + { + let scale = margin_scale.clone(); + margin_reset.connect_clicked(move |_| { + scale.set_value(10.0); + }); + } + + // Scale slider { let jc = state.job_config.clone(); - scale_row.connect_value_notify(move |row| { - jc.borrow_mut().watermark_scale = row.value() as f32; + let row = scale_row.clone(); + let up = update_preview.clone(); + let rst = scale_reset.clone(); + scale_scale.connect_value_changed(move |scale| { + let val = scale.value().round() as i32; + jc.borrow_mut().watermark_scale = val as f32; + row.set_subtitle(&format!("{}%", val)); + rst.set_sensitive((val - 20).abs() > 0); + up(); }); } - // Wire image chooser button + { + let scale = scale_scale.clone(); + scale_reset.connect_clicked(move |_| { + scale.set_value(20.0); + }); + } + + // Image chooser button { let jc = state.job_config.clone(); let path_row = image_path_row.clone(); + let up = update_preview.clone(); choose_image_button.connect_clicked(move |btn| { let jc = jc.clone(); let path_row = path_row.clone(); + let up = up.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.set_name(Some("Images")); filter.add_mime_type("image/png"); + filter.add_mime_type("image/svg+xml"); let filters = gtk::gio::ListStore::new::(); filters.append(&filter); dialog.set_filters(Some(&filters)); @@ -550,22 +806,29 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { { path_row.set_subtitle(&path.display().to_string()); jc.borrow_mut().watermark_image_path = Some(path); + up(); } }); } }); } - scrolled.set_child(Some(&content)); - - let clamp = adw::Clamp::builder() - .maximum_size(600) - .child(&scrolled) - .build(); - - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title("Watermark") .tag("step-watermark") - .child(&clamp) - .build() + .child(&outer) + .build(); + + // Refresh preview and sensitivity when navigating to this page + { + let up = update_preview.clone(); + let lf = state.loaded_files.clone(); + let ctrl = controls.clone(); + page.connect_map(move |_| { + ctrl.set_sensitive(!lf.borrow().is_empty()); + up(); + }); + } + + page } diff --git a/pixstrip-gtk/src/steps/step_workflow.rs b/pixstrip-gtk/src/steps/step_workflow.rs index 420ff33..8e342ed 100644 --- a/pixstrip-gtk/src/steps/step_workflow.rs +++ b/pixstrip-gtk/src/steps/step_workflow.rs @@ -26,38 +26,29 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage { let builtin_flow = gtk::FlowBox::builder() .selection_mode(gtk::SelectionMode::Single) - .max_children_per_line(4) + .max_children_per_line(5) .min_children_per_line(2) .row_spacing(8) .column_spacing(8) .homogeneous(true) .build(); + // Custom card is always first (index 0) + let custom_card = build_custom_card(); + builtin_flow.append(&custom_card); + + // Then all built-in presets (indices 1..=9) let builtins = Preset::all_builtins(); for preset in &builtins { let card = build_preset_card(preset); builtin_flow.append(&card); } - // When a preset card is activated, apply it to JobConfig and advance - { - let jc = state.job_config.clone(); - builtin_flow.connect_child_activated(move |flow, child| { - let idx = child.index() as usize; - if let Some(preset) = builtins.get(idx) { - apply_preset_to_config(&mut jc.borrow_mut(), preset); - } - flow.activate_action("win.next-step", None).ok(); - }); - } - - builtin_group.add(&builtin_flow); - content.append(&builtin_group); - - // Custom workflow section + // Custom workflow section (hidden until Custom card is selected) let custom_group = adw::PreferencesGroup::builder() .title("Custom Workflow") .description("Choose which operations to include, then click Next") + .visible(false) .build(); let resize_check = adw::SwitchRow::builder() @@ -110,6 +101,36 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage { custom_group.add(&watermark_check); custom_group.add(&rename_check); + // When a card is activated: Custom shows toggles, presets apply config and advance + { + let jc = state.job_config.clone(); + let custom_group_c = custom_group.clone(); + builtin_flow.connect_child_activated(move |flow, child| { + let idx = child.index() as usize; + if idx == 0 { + // Custom card - show toggles, don't advance, enable step-by-step mode + jc.borrow_mut().preset_mode = false; + custom_group_c.set_visible(true); + } else { + // Built-in preset - apply config, skip intermediate steps, advance + custom_group_c.set_visible(false); + if let Some(preset) = builtins.get(idx - 1) { + let mut cfg = jc.borrow_mut(); + apply_preset_to_config(&mut cfg, preset); + cfg.preset_mode = true; + } + flow.activate_action("win.next-step", None).ok(); + } + }); + } + + let builtin_clamp = adw::Clamp::builder() + .maximum_size(1200) + .child(&builtin_flow) + .build(); + builtin_group.add(&builtin_clamp); + content.append(&builtin_group); + // Wire custom operation toggles to job config { let jc = state.job_config.clone(); @@ -154,86 +175,18 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage { }); } - content.append(&custom_group); - // User presets section let user_group = adw::PreferencesGroup::builder() .title("Your Presets") .description("Import or save your own workflows") .build(); - // Show saved user presets - let store = pixstrip_core::storage::PresetStore::new(); - if let Ok(presets) = store.list() { - for preset in &presets { - if !preset.is_custom { - continue; - } - let row = adw::ActionRow::builder() - .title(&preset.name) - .subtitle(&preset.description) - .activatable(true) - .build(); - row.add_prefix(>k::Image::from_icon_name(&preset.icon)); - - // Export button - let export_btn = gtk::Button::builder() - .icon_name("document-save-as-symbolic") - .tooltip_text("Export preset") - .valign(gtk::Align::Center) - .build(); - export_btn.add_css_class("flat"); - let preset_for_export = preset.clone(); - export_btn.connect_clicked(move |btn| { - let p = preset_for_export.clone(); - let dialog = gtk::FileDialog::builder() - .title("Export Preset") - .initial_name(&format!("{}.pixstrip-preset", p.name)) - .modal(true) - .build(); - if let Some(window) = btn.root().and_then(|r| r.downcast::().ok()) { - dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |result| { - if let Ok(file) = result - && let Some(path) = file.path() - { - let store = pixstrip_core::storage::PresetStore::new(); - let _ = store.export_to_file(&p, &path); - } - }); - } - }); - row.add_suffix(&export_btn); - - // Delete button - let delete_btn = gtk::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Delete preset") - .valign(gtk::Align::Center) - .build(); - delete_btn.add_css_class("flat"); - delete_btn.add_css_class("error"); - let pname = preset.name.clone(); - let row_ref = row.clone(); - let group_ref = user_group.clone(); - delete_btn.connect_clicked(move |_| { - let store = pixstrip_core::storage::PresetStore::new(); - let _ = store.delete(&pname); - group_ref.remove(&row_ref); - }); - row.add_suffix(&delete_btn); - - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - - let jc = state.job_config.clone(); - let p = preset.clone(); - row.connect_activated(move |r| { - apply_preset_to_config(&mut jc.borrow_mut(), &p); - r.activate_action("win.next-step", None).ok(); - }); - - user_group.add(&row); - } - } + // Container for dynamically-rebuilt user preset rows + let user_rows_box = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) + .build(); + user_group.add(&user_rows_box); let import_button = gtk::Button::builder() .label("Import Preset") @@ -243,14 +196,10 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage { import_button.add_css_class("flat"); user_group.add(&import_button); content.append(&user_group); + content.append(&custom_group); scrolled.set_child(Some(&content)); - let clamp = adw::Clamp::builder() - .maximum_size(800) - .child(&scrolled) - .build(); - // Drop target for .pixstrip-preset files let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY); let jc_drop = state.job_config.clone(); @@ -269,13 +218,108 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage { } false }); - clamp.add_controller(drop_target); + scrolled.add_controller(drop_target); - adw::NavigationPage::builder() + let page = adw::NavigationPage::builder() .title("Choose a Workflow") .tag("step-workflow") - .child(&clamp) - .build() + .child(&scrolled) + .build(); + + // Refresh user presets every time this page is shown + { + let jc = state.job_config.clone(); + let rows_box = user_rows_box.clone(); + page.connect_map(move |_| { + // Clear existing rows + while let Some(child) = rows_box.first_child() { + rows_box.remove(&child); + } + + let store = pixstrip_core::storage::PresetStore::new(); + if let Ok(presets) = store.list() { + for preset in &presets { + if !preset.is_custom { + continue; + } + let list_box = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + + let row = adw::ActionRow::builder() + .title(&preset.name) + .subtitle(&preset.description) + .activatable(true) + .build(); + row.add_prefix(>k::Image::from_icon_name(&preset.icon)); + + // Export button + let export_btn = gtk::Button::builder() + .icon_name("document-save-as-symbolic") + .tooltip_text("Export preset") + .valign(gtk::Align::Center) + .build(); + export_btn.add_css_class("flat"); + let preset_for_export = preset.clone(); + export_btn.connect_clicked(move |btn| { + let p = preset_for_export.clone(); + let dialog = gtk::FileDialog::builder() + .title("Export Preset") + .initial_name(&format!("{}.pixstrip-preset", p.name)) + .modal(true) + .build(); + if let Some(window) = btn.root().and_then(|r| r.downcast::().ok()) { + dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |result| { + if let Ok(file) = result + && let Some(path) = file.path() + { + let store = pixstrip_core::storage::PresetStore::new(); + let _ = store.export_to_file(&p, &path); + } + }); + } + }); + row.add_suffix(&export_btn); + + // Delete button + let delete_btn = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Delete preset") + .valign(gtk::Align::Center) + .build(); + delete_btn.add_css_class("flat"); + delete_btn.add_css_class("error"); + let pname = preset.name.clone(); + let list_box_ref = list_box.clone(); + let rows_box_ref = rows_box.clone(); + delete_btn.connect_clicked(move |_| { + let store = pixstrip_core::storage::PresetStore::new(); + let _ = store.delete(&pname); + rows_box_ref.remove(&list_box_ref); + }); + row.add_suffix(&delete_btn); + + row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + + let jc2 = jc.clone(); + let p = preset.clone(); + row.connect_activated(move |r| { + let mut cfg = jc2.borrow_mut(); + apply_preset_to_config(&mut cfg, &p); + cfg.preset_mode = true; + drop(cfg); + r.activate_action("win.next-step", None).ok(); + }); + + list_box.append(&row); + rows_box.append(&list_box); + } + } + }); + } + + page } fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) { @@ -378,12 +422,12 @@ fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) { } } -fn build_preset_card(preset: &Preset) -> gtk::Box { +fn build_custom_card() -> gtk::Box { let card = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(8) - .halign(gtk::Align::Center) - .valign(gtk::Align::Start) + .hexpand(true) + .vexpand(false) .build(); card.add_css_class("card"); card.set_size_request(180, 120); @@ -391,10 +435,58 @@ fn build_preset_card(preset: &Preset) -> gtk::Box { let inner = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(4) - .margin_top(16) - .margin_bottom(16) - .margin_start(12) - .margin_end(12) + .margin_top(6) + .margin_bottom(6) + .margin_start(8) + .margin_end(8) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .vexpand(true) + .build(); + + let icon = gtk::Image::builder() + .icon_name("emblem-system-symbolic") + .pixel_size(32) + .build(); + + let name_label = gtk::Label::builder() + .label("Custom") + .css_classes(["heading"]) + .build(); + + let desc_label = gtk::Label::builder() + .label("Pick and choose operations") + .css_classes(["caption", "dim-label"]) + .wrap(true) + .justify(gtk::Justification::Center) + .max_width_chars(20) + .build(); + + inner.append(&icon); + inner.append(&name_label); + inner.append(&desc_label); + card.append(&inner); + + card +} + +fn build_preset_card(preset: &Preset) -> gtk::Box { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .hexpand(true) + .vexpand(false) + .build(); + card.add_css_class("card"); + card.set_size_request(180, 120); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .margin_top(6) + .margin_bottom(6) + .margin_start(8) + .margin_end(8) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .vexpand(true) @@ -425,4 +517,3 @@ fn build_preset_card(preset: &Preset) -> gtk::Box { card } - diff --git a/pixstrip-gtk/src/utils.rs b/pixstrip-gtk/src/utils.rs new file mode 100644 index 0000000..8e04891 --- /dev/null +++ b/pixstrip-gtk/src/utils.rs @@ -0,0 +1,11 @@ +pub fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} diff --git a/pixstrip-gtk/src/wizard.rs b/pixstrip-gtk/src/wizard.rs index 1b6f076..df9e6d5 100644 --- a/pixstrip-gtk/src/wizard.rs +++ b/pixstrip-gtk/src/wizard.rs @@ -49,19 +49,6 @@ impl WizardState { pub fn is_last_step(&self) -> bool { self.current_step == self.total_steps - 1 } - - pub fn go_next(&mut self) { - if self.can_go_next() { - self.current_step += 1; - self.visited[self.current_step] = true; - } - } - - pub fn go_back(&mut self) { - if self.can_go_back() { - self.current_step -= 1; - } - } } pub fn build_wizard_pages(state: &AppState) -> Vec {