Fix 26 bugs, edge cases, and consistency issues from fifth audit pass

Critical: undo toast now trashes only batch output files (not entire dir),
JPEG scanline write errors propagated, selective metadata write result returned.

High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio
rejection, FM integration toggle infinite recursion guard, saturating counter
arithmetic in executor.

Medium: PNG compression level passed to oxipng, pct mode updates job_config,
external file loading updates step indicator, CLI undo removes history entries,
watch config write failures reported, fast-copy path reads image dimensions for
rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl),
CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot
fix, generation guards on all preview threads to cancel stale results, default
DPI aligned to 0, watermark text width uses char count not byte length.

Low: binary path escaped in Nautilus extension, file dialog filter aligned with
discovery, reset_wizard clears preset_mode and output_dir.
This commit is contained in:
2026-03-07 19:47:23 +02:00
parent 270a7db60d
commit b432cc7431
44 changed files with 5748 additions and 2221 deletions

1
Cargo.lock generated
View File

@@ -1938,6 +1938,7 @@ dependencies = [
"image",
"libadwaita",
"pixstrip-core",
"regex",
]
[[package]]

View File

@@ -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<String> = 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<pixstrip_core::watcher::WatchFolder> = 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<pixstrip_core::watcher::WatchFolder> = 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<Preset> {
// 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<f32, String> {
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 })));
}
}

View File

@@ -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 {

View File

@@ -39,7 +39,7 @@ impl OutputEncoder {
) -> Result<Vec<u8>> {
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<Vec<u8>> {
fn encode_png(&self, img: &image::DynamicImage, level: u8) -> Result<Vec<u8>> {
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<u8> {
// 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<u8> {
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]);

View File

@@ -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()
}

View File

@@ -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<String> {
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!(
" <action>\n\
\x20 <icon>applications-graphics-symbolic</icon>\n\
\x20 <name>Pixstrip: {name}</name>\n\
\x20 <command>{bin} --preset \"{name}\" --files %F</command>\n\
\x20 <description>Process with {name} preset</description>\n\
\x20 <name>Pixstrip: {xml_name}</name>\n\
\x20 <command>{bin} --preset \"{safe_label}\" --files %F</command>\n\
\x20 <description>Process with {xml_name} preset</description>\n\
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
\x20 <image-files/>\n\
\x20 <directories/>\n\
</action>\n",
name = name,
xml_name = name.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;"),
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,
));
}

View File

@@ -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]));

View File

@@ -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<u8> {
// 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<u8> {
result
}
fn remove_metadata_from_png(data: &[u8]) -> Vec<u8> {
// 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
}

View File

@@ -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<String>,
/// 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)
}
}

View File

@@ -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::<Vec<_>>()
.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))
}
}

View File

@@ -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<Vec<u8>> {
})
}
/// 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<Vec<std::path::PathBuf>> {
walkdir_depth(dir, 5)
}
fn walkdir_depth(dir: &std::path::Path, max_depth: u32) -> std::io::Result<Vec<std::path::PathBuf>> {
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<super::WatermarkRotation>,
margin_px: u32,
) -> Result<DynamicImage> {
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<super::WatermarkRotation>,
margin: u32,
) -> Result<DynamicImage> {
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<super::WatermarkRotation>,
margin: u32,
) -> Result<DynamicImage> {
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();

View File

@@ -21,7 +21,7 @@ pub struct ProcessingJob {
pub metadata: Option<MetadataConfig>,
pub watermark: Option<WatermarkConfig>,
pub rename: Option<RenameConfig>,
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,

View File

@@ -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<RenameConfig>,
}
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(),

View File

@@ -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<Preset> {
@@ -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<Preset> {
@@ -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<AppConfig> {
@@ -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<SessionState> {
@@ -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::<u64>().unwrap_or(0) >= cutoff_secs
e.timestamp.parse::<u64>().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::<HistoryEntry>::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)
}
}

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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]

View File

@@ -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;
}
}
}

View File

@@ -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));
}

View File

@@ -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"));

View File

@@ -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<name>.txt", 0), "file<name>.txt");
}
#[test]
fn special_chars_filesystem_safe() {
assert_eq!(apply_special_chars("file<name>", 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"
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ mod settings;
mod step_indicator;
mod steps;
mod tutorial;
pub(crate) mod utils;
mod welcome;
mod wizard;

View File

@@ -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()
}

View File

@@ -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<Cell<i32>> = 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::<Vec<_>>(),
);
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()

View File

@@ -5,7 +5,10 @@ use std::cell::RefCell;
#[derive(Clone)]
pub struct StepIndicator {
container: gtk::Box,
grid: gtk::Grid,
dots: RefCell<Vec<StepDot>>,
/// Maps visual index -> actual step index
step_map: RefCell<Vec<usize>>,
}
#[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<usize> = (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: &gtk::Grid, names: &[String], step_indices: &[usize]) -> Vec<StepDot> {
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<String> = visible_steps.iter().map(|(_, n)| n.clone()).collect();
let indices: Vec<usize> = 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");
}
}
}

View File

@@ -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::<gtk::ListItem>().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::<gtk::ListItem>().unwrap();
if let Some(obj) = item.item() {
if let Some(string_obj) = obj.downcast_ref::<gtk::StringObject>() {
if let Some(label) = item.child().and_downcast_ref::<gtk::Label>() {
label.set_label(&string_obj.string());
}
}
}
});
factory
}

View File

@@ -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(&gtk::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(&gtk::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(&gtk::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(&gtk::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<Cell<usize>> = Rc::new(Cell::new(0));
drop(cfg);
// Wire signals
// === Preview update closure ===
let preview_gen: Rc<Cell<u32>> = 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::<Option<Vec<u8>>>();
std::thread::spawn(move || {
let result = (|| -> Option<Vec<u8>> {
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<u8>| -> 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<Cell<u32>> = 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<ImageFormat>)] = &[
("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<ImageFormat>)] = &[
("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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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<ImageFormat>) -> Option<i32> {
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<ImageFormat> {
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<ImageFormat>) -> 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: &gtk::ListBox,
loaded_files: &[PathBuf],
job_config: &Rc<RefCell<crate::app::JobConfig>>,
) {
// Clear all existing rows from the list
list.remove_all();
// Detect which file extensions are present in loaded files
let mut seen_extensions: HashSet<String> = 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(&gtk::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<String> = 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<String> = 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(&gtk::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);
}
}

View File

@@ -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: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
@@ -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::<gtk::ListItem>().unwrap();
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() 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::<gtk::ListItem>().unwrap();
let item = list_item.item().and_downcast::<ImageItem>().unwrap();
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let Some(item) = list_item.item().and_downcast::<ImageItem>() else { return };
let path = item.path().to_path_buf();
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
let name_label = overlay.next_sibling().and_downcast::<gtk::Label>().unwrap();
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
let Some(name_label) = overlay.next_sibling().and_downcast::<gtk::Label>() 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::<gtk::Frame>().unwrap();
let thumb_stack = frame.child().and_downcast::<gtk::Stack>().unwrap();
let picture = thumb_stack.child_by_name("picture")
.and_downcast::<gtk::Picture>().unwrap();
let Some(frame) = overlay.child().and_downcast::<gtk::Frame>() else { return };
let Some(thumb_stack) = frame.child().and_downcast::<gtk::Stack>() else { return };
let Some(picture) = thumb_stack.child_by_name("picture")
.and_downcast::<gtk::Picture>() 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::<u32>("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::<u32>("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::<gtk::ListItem>().unwrap();
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
if let Some(check) = find_check_button(overlay.upcast_ref::<gtk::Widget>()) {
let handler: Option<glib::SignalHandlerId> = unsafe {

View File

@@ -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()
}

View File

@@ -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))
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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(&gtk::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(&gtk::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(&gtk::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<gtk::ToggleButton> = None;
let buttons: Vec<gtk::ToggleButton> = 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: &gtk::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::<gtk::Button>() {
if let Some(f) = b.child().and_then(|c| c.downcast::<gtk::Frame>().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(&gtk::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(&gtk::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(&gtk::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<Cell<usize>> = Rc::new(Cell::new(0));
drop(cfg);
// Wire signals
// === Preview update closure ===
let preview_gen: Rc<Cell<u32>> = 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::<Option<Vec<u8>>>();
std::thread::spawn(move || {
let result = (|| -> Option<Vec<u8>> {
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<Cell<u32>> = 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(&gtk::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::<gtk::FileFilter>();
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
}

View File

@@ -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(&gtk::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::<gtk::Window>().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(&gtk::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(&gtk::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::<gtk::Window>().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(&gtk::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
}

11
pixstrip-gtk/src/utils.rs Normal file
View File

@@ -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))
}
}

View File

@@ -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<adw::NavigationPage> {