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", "image",
"libadwaita", "libadwaita",
"pixstrip-core", "pixstrip-core",
"regex",
] ]
[[package]] [[package]]

View File

@@ -6,6 +6,7 @@ use pixstrip_core::pipeline::ProcessingJob;
use pixstrip_core::preset::Preset; use pixstrip_core::preset::Preset;
use pixstrip_core::storage::{HistoryStore, PresetStore}; use pixstrip_core::storage::{HistoryStore, PresetStore};
use pixstrip_core::types::*; use pixstrip_core::types::*;
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
@@ -67,7 +68,7 @@ enum Commands {
watermark_position: String, watermark_position: String,
/// Watermark opacity (0.0-1.0) /// 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, watermark_opacity: f32,
/// Rename with prefix /// Rename with prefix
@@ -275,7 +276,10 @@ fn cmd_process(args: CmdProcessArgs) {
.unwrap_or_else(|| input_dir.join("processed")); .unwrap_or_else(|| input_dir.join("processed"));
let mut job = if let Some(ref name) = args.preset { 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); println!("Using preset: {}", preset.name);
preset.to_job(&input_dir, &output_dir) preset.to_job(&input_dir, &output_dir)
} else { } else {
@@ -286,14 +290,12 @@ fn cmd_process(args: CmdProcessArgs) {
if let Some(ref resize_str) = args.resize { if let Some(ref resize_str) = args.resize {
job.resize = Some(parse_resize(resize_str)); job.resize = Some(parse_resize(resize_str));
} }
if let Some(ref fmt_str) = args.format if let Some(ref fmt_str) = args.format {
&& let Some(fmt) = parse_format(fmt_str) let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1));
{
job.convert = Some(ConvertConfig::SingleFormat(fmt)); job.convert = Some(ConvertConfig::SingleFormat(fmt));
} }
if let Some(ref q_str) = args.quality if let Some(ref q_str) = args.quality {
&& let Some(preset) = parse_quality(q_str) let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1));
{
job.compress = Some(CompressConfig::Preset(preset)); job.compress = Some(CompressConfig::Preset(preset));
} }
if args.strip_metadata { 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 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 { job.rename = Some(RenameConfig {
prefix: args.rename_prefix.unwrap_or_default(), prefix: args.rename_prefix.unwrap_or_default(),
suffix: args.rename_suffix.unwrap_or_default(), suffix: args.rename_suffix.unwrap_or_default(),
counter_start: 1, counter_start: 1,
counter_padding: 3, counter_padding: 3,
counter_enabled: true,
counter_position: 3,
template: args.rename_template, template: args.rename_template,
case_mode: 0, case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(), regex_find: String::new(),
regex_replace: String::new(), regex_replace: String::new(),
}); });
@@ -336,13 +353,21 @@ fn cmd_process(args: CmdProcessArgs) {
"catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom, "catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom,
"bilinear" => ResizeAlgorithm::Bilinear, "bilinear" => ResizeAlgorithm::Bilinear,
"nearest" => ResizeAlgorithm::Nearest, "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() { job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() {
"overwrite" | "always" => OverwriteBehavior::Overwrite, "overwrite" | "always" => OverwriteAction::Overwrite,
"skip" => OverwriteBehavior::Skip, "skip" => OverwriteAction::Skip,
_ => OverwriteBehavior::AutoRename, "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 { for file in &source_files {
@@ -357,6 +382,7 @@ fn cmd_process(args: CmdProcessArgs) {
"\r[{}/{}] {}...", "\r[{}/{}] {}...",
update.current, update.total, update.current_file update.current, update.total, update.current_file
); );
let _ = std::io::stderr().flush();
}) })
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
eprintln!("\nProcessing failed: {}", e); eprintln!("\nProcessing failed: {}", e);
@@ -395,20 +421,39 @@ fn cmd_process(args: CmdProcessArgs) {
// Save to history // Save to history
let history = HistoryStore::new(); let history = HistoryStore::new();
let output_ext = match job.convert {
Some(ConvertConfig::SingleFormat(fmt)) => fmt.extension(),
_ => "",
};
let output_files: Vec<String> = source_files let output_files: Vec<String> = source_files
.iter() .iter()
.map(|f| { .enumerate()
output_dir .map(|(i, f)| {
.join(f.file_name().unwrap_or_default()) let stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
.to_string_lossy() let ext = if output_ext.is_empty() {
.into() 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(); .collect();
let _ = history.add(pixstrip_core::storage::HistoryEntry { if let Err(e) = history.add(pixstrip_core::storage::HistoryEntry {
timestamp: chrono_timestamp(), timestamp: chrono_timestamp(),
input_dir: input_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.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, preset_name: args.preset,
total: result.total, total: result.total,
succeeded: result.succeeded, succeeded: result.succeeded,
@@ -417,7 +462,9 @@ fn cmd_process(args: CmdProcessArgs) {
total_output_bytes: result.total_output_bytes, total_output_bytes: result.total_output_bytes,
elapsed_ms: result.elapsed_ms, elapsed_ms: result.elapsed_ms,
output_files, output_files,
}); }, 50, 30) {
eprintln!("Warning: failed to save history (undo may not work): {}", e);
}
} }
fn cmd_preset_list() { fn cmd_preset_list() {
@@ -439,7 +486,10 @@ fn cmd_preset_list() {
} }
fn cmd_preset_export(name: &str, output: &str) { 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(); let store = PresetStore::new();
match store.export_to_file(&preset, &PathBuf::from(output)) { match store.export_to_file(&preset, &PathBuf::from(output)) {
Ok(()) => println!("Exported '{}' to '{}'", name, output), Ok(()) => println!("Exported '{}' to '{}'", name, output),
@@ -499,18 +549,23 @@ fn cmd_history() {
} }
fn cmd_undo(count: usize) { fn cmd_undo(count: usize) {
if count == 0 {
eprintln!("Must undo at least 1 batch");
std::process::exit(1);
}
let history = HistoryStore::new(); let history = HistoryStore::new();
match history.list() { match history.list() {
Ok(entries) => { Ok(mut entries) => {
if entries.is_empty() { if entries.is_empty() {
println!("No processing history to undo."); println!("No processing history to undo.");
return; 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; let mut total_trashed = 0;
for entry in to_undo { for entry in &to_undo {
if entry.output_files.is_empty() { if entry.output_files.is_empty() {
println!( println!(
"Batch from {} has no recorded output files - cannot undo", "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 { for file_path in &entry.output_files {
let path = PathBuf::from(file_path); let path = PathBuf::from(file_path);
if path.exists() { if path.exists() {
// Move to OS trash using the trash crate
match trash::delete(&path) { match trash::delete(&path) {
Ok(()) => { Ok(()) => {
total_trashed += 1; 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); println!("{} files moved to trash", total_trashed);
} }
Err(e) => { Err(e) => {
@@ -551,12 +610,19 @@ fn cmd_undo(count: usize) {
fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
// Verify the preset exists // 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); let watch_path = PathBuf::from(path);
if !watch_path.exists() { if !watch_path.exists() {
eprintln!("Watch folder does not exist: {}", path); eprintln!("Watch folder does not exist: {}", path);
std::process::exit(1); 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 // Save watch folder config
let watch = pixstrip_core::watcher::WatchFolder { let watch = pixstrip_core::watcher::WatchFolder {
@@ -568,7 +634,8 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
// Store in config // Store in config
let config_dir = dirs::config_dir() 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"); .join("pixstrip");
let watches_path = config_dir.join("watches.json"); let watches_path = config_dir.join("watches.json");
let mut watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() { 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); watches.push(watch);
let _ = std::fs::create_dir_all(&config_dir); if let Err(e) = std::fs::create_dir_all(&config_dir) {
let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap()); 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); println!("Added watch: {} -> preset '{}'", path, preset_name);
} }
fn cmd_watch_list() { fn cmd_watch_list() {
let config_dir = dirs::config_dir() 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"); .join("pixstrip");
let watches_path = config_dir.join("watches.json"); let watches_path = config_dir.join("watches.json");
@@ -630,7 +709,8 @@ fn cmd_watch_list() {
fn cmd_watch_remove(path: &str) { fn cmd_watch_remove(path: &str) {
let config_dir = dirs::config_dir() 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"); .join("pixstrip");
let watches_path = config_dir.join("watches.json"); let watches_path = config_dir.join("watches.json");
@@ -652,21 +732,33 @@ fn cmd_watch_remove(path: &str) {
return; 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); println!("Removed watch folder: {}", path);
} }
fn cmd_watch_start() { fn cmd_watch_start() {
let config_dir = dirs::config_dir() 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"); .join("pixstrip");
let watches_path = config_dir.join("watches.json"); let watches_path = config_dir.join("watches.json");
let watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() { let watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
std::fs::read_to_string(&watches_path) match std::fs::read_to_string(&watches_path) {
.ok() Ok(content) => match serde_json::from_str(&content) {
.and_then(|s| serde_json::from_str(&s).ok()) Ok(w) => w,
.unwrap_or_default() 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 { } else {
Vec::new() Vec::new()
}; };
@@ -700,9 +792,15 @@ fn cmd_watch_start() {
match event { match event {
pixstrip_core::watcher::WatchEvent::NewImage(path) => { pixstrip_core::watcher::WatchEvent::NewImage(path) => {
println!("New image: {}", path.display()); println!("New image: {}", path.display());
// Find which watcher this came from and use its preset // Find which watcher owns this path and use its preset
if let Some((_, preset_name)) = watchers.first() { let matched = active.iter()
let preset = find_preset(preset_name); .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 input_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf();
let output_dir = input_dir.join("processed"); let output_dir = input_dir.join("processed");
let mut job = preset.to_job(&input_dir, &output_dir); let mut job = preset.to_job(&input_dir, &output_dir);
@@ -728,23 +826,29 @@ fn cmd_watch_start() {
// --- Helpers --- // --- Helpers ---
fn find_preset(name: &str) -> Preset { fn find_preset(name: &str) -> Option<Preset> {
// Check builtins first // Check builtins first (case-insensitive)
let lower = name.to_lowercase(); let lower = name.to_lowercase();
for preset in Preset::all_builtins() { for preset in Preset::all_builtins() {
if preset.name.to_lowercase() == lower { 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(); let store = PresetStore::new();
if let Ok(preset) = store.load(name) { 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); None
std::process::exit(1);
} }
fn parse_resize(s: &str) -> ResizeConfig { fn parse_resize(s: &str) -> ResizeConfig {
@@ -757,12 +861,20 @@ fn parse_resize(s: &str) -> ResizeConfig {
eprintln!("Invalid resize height: '{}'", h); eprintln!("Invalid resize height: '{}'", h);
std::process::exit(1); 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 }) ResizeConfig::Exact(Dimensions { width, height })
} else { } else {
let width: u32 = s.parse().unwrap_or_else(|_| { let width: u32 = s.parse().unwrap_or_else(|_| {
eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s); eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s);
std::process::exit(1); std::process::exit(1);
}); });
if width == 0 {
eprintln!("Resize width must be greater than zero");
std::process::exit(1);
}
ResizeConfig::ByWidth(width) 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 { fn format_bytes(bytes: u64) -> String {
if bytes < 1024 { if bytes < 1024 {
format!("{} B", bytes) format!("{} B", bytes)
@@ -872,3 +992,88 @@ fn chrono_timestamp() -> String {
.unwrap_or_default(); .unwrap_or_default();
format!("{}", duration.as_secs()) 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; use walkdir::WalkDir;
const IMAGE_EXTENSIONS: &[&str] = &[ const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp", "heic", "heif", "jxl", "jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp",
"svg", "ico",
]; ];
fn is_image_extension(ext: &str) -> bool { fn is_image_extension(ext: &str) -> bool {

View File

@@ -39,7 +39,7 @@ impl OutputEncoder {
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
match format { match format {
ImageFormat::Jpeg => self.encode_jpeg(img, quality.unwrap_or(85)), 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::WebP => self.encode_webp(img, quality.unwrap_or(80)),
ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(80)), ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(80)),
ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif), ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif),
@@ -66,7 +66,7 @@ impl OutputEncoder {
match format { match format {
ImageFormat::Jpeg => preset.jpeg_quality(), ImageFormat::Jpeg => preset.jpeg_quality(),
ImageFormat::WebP => preset.webp_quality() as u8, 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(), ImageFormat::Png => preset.png_level(),
_ => preset.jpeg_quality(), _ => preset.jpeg_quality(),
} }
@@ -101,7 +101,10 @@ impl OutputEncoder {
for y in 0..height { for y in 0..height {
let start = y * row_stride; let start = y * row_stride;
let end = start + 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 { started.finish().map_err(|e| PixstripError::Processing {
@@ -112,7 +115,7 @@ impl OutputEncoder {
Ok(output) 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 mut buf = Vec::new();
let cursor = Cursor::new(&mut buf); let cursor = Cursor::new(&mut buf);
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();
@@ -129,12 +132,16 @@ impl OutputEncoder {
reason: e.to_string(), 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 { if self.options.output_dpi > 0 {
buf = insert_png_phys_chunk(&buf, self.options.output_dpi); 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 { .map_err(|e| PixstripError::Processing {
operation: "png_optimize".into(), operation: "png_optimize".into(),
reason: e.to_string(), reason: e.to_string(),
@@ -156,7 +163,7 @@ impl OutputEncoder {
let mut buf = Vec::new(); let mut buf = Vec::new();
let cursor = Cursor::new(&mut buf); let cursor = Cursor::new(&mut buf);
let rgba = img.to_rgba8(); 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( let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(
cursor, cursor,
speed, 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) // 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 result = Vec::with_capacity(png_data.len() + phys_chunk.len());
let mut pos = 8; // skip PNG signature let mut pos = 8; // skip PNG signature
let mut phys_inserted = false;
result.extend_from_slice(&png_data[..8]); result.extend_from_slice(&png_data[..8]);
while pos + 8 <= png_data.len() { 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 chunk_type = &png_data[pos + 4..pos + 8];
let total_chunk_size = 4 + 4 + chunk_len + 4; // len + type + data + crc let total_chunk_size = 4 + 4 + chunk_len + 4; // len + type + data + crc
if chunk_type == b"IDAT" || chunk_type == b"pHYs" { if pos + total_chunk_size > png_data.len() {
if chunk_type == b"IDAT" { break;
// Insert pHYs before first IDAT }
result.extend_from_slice(&phys_chunk);
} // Skip any existing pHYs (we're replacing it)
// If existing pHYs, skip it (we're replacing it) if chunk_type == b"pHYs" {
if chunk_type == b"pHYs" { pos += total_chunk_size;
pos += total_chunk_size; continue;
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]); result.extend_from_slice(&png_data[pos..pos + total_chunk_size]);

View File

@@ -321,6 +321,79 @@ impl PipelineExecutor {
.map(|m| m.len()) .map(|m| m.len())
.unwrap_or(0); .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 // Load image
let mut img = loader.load_pixels(&source.path)?; let mut img = loader.load_pixels(&source.path)?;
@@ -404,7 +477,7 @@ impl PipelineExecutor {
template, template,
&working_stem, &working_stem,
ext, ext,
rename.counter_start + index as u32, rename.counter_start.saturating_add(index as u32),
dims, dims,
original_ext, original_ext,
Some(&source.path), Some(&source.path),
@@ -423,7 +496,7 @@ impl PipelineExecutor {
}; };
job.output_dir.join(new_name) job.output_dir.join(new_name)
} else { } 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) job.output_dir.join(new_name)
} }
} else { } else {
@@ -432,21 +505,21 @@ impl PipelineExecutor {
// Handle overwrite behavior // Handle overwrite behavior
let output_path = match job.overwrite_behavior { let output_path = match job.overwrite_behavior {
crate::operations::OverwriteBehavior::Skip => { crate::operations::OverwriteAction::Skip => {
if output_path.exists() { if output_path.exists() {
// Return 0 bytes written - file was skipped // Return 0 bytes written - file was skipped
return Ok((input_size, 0)); return Ok((input_size, 0));
} }
output_path output_path
} }
crate::operations::OverwriteBehavior::AutoRename => { crate::operations::OverwriteAction::AutoRename => {
if output_path.exists() { if output_path.exists() {
find_unique_path(&output_path) find_unique_path(&output_path)
} else { } else {
output_path output_path
} }
} }
crate::operations::OverwriteBehavior::Overwrite => output_path, crate::operations::OverwriteAction::Overwrite => output_path,
}; };
// Ensure output directory exists // Ensure output directory exists
@@ -469,14 +542,18 @@ impl PipelineExecutor {
if let Some(ref meta_config) = job.metadata { if let Some(ref meta_config) = job.metadata {
match meta_config { match meta_config {
crate::operations::MetadataConfig::KeepAll => { 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 => { crate::operations::MetadataConfig::StripAll => {
// Already stripped by re-encoding - nothing to do // Already stripped by re-encoding - nothing to do
} }
_ => { _ => {
// Privacy or Custom: copy all metadata back, then strip unwanted tags // 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); strip_selective_metadata(&output_path, meta_config);
} }
} }
@@ -537,9 +614,9 @@ fn auto_orient_from_exif(
2 => img.fliph(), // Flipped horizontal 2 => img.fliph(), // Flipped horizontal
3 => img.rotate180(), // Rotated 180 3 => img.rotate180(), // Rotated 180
4 => img.flipv(), // Flipped vertical 4 => img.flipv(), // Flipped vertical
5 => img.fliph().rotate270(), // Transposed 5 => img.rotate90().fliph(), // Transposed
6 => img.rotate90(), // Rotated 90 CW 6 => img.rotate90(), // Rotated 90 CW
7 => img.fliph().rotate90(), // Transverse 7 => img.rotate270().fliph(), // Transverse
8 => img.rotate270(), // Rotated 270 CW 8 => img.rotate270(), // Rotated 270 CW
_ => img, _ => img,
} }
@@ -569,25 +646,28 @@ fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf {
.unwrap_or(0), ext)) .unwrap_or(0), ext))
} }
fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) { fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) -> bool {
// Best-effort: try to copy EXIF from source to output using little_exif.
// If it fails (e.g. non-JPEG, no EXIF), silently continue.
let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(source) else { 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( fn strip_selective_metadata(
path: &std::path::Path, path: &std::path::Path,
config: &crate::operations::MetadataConfig, config: &crate::operations::MetadataConfig,
) { ) -> bool {
use little_exif::exif_tag::ExifTag; use little_exif::exif_tag::ExifTag;
use little_exif::metadata::Metadata; use little_exif::metadata::Metadata;
// Read the metadata we just wrote back // Read the metadata we just wrote back
let Ok(source_meta) = Metadata::new_from_path(path) else { let Ok(source_meta) = Metadata::new_from_path(path) else {
return; return false;
}; };
// Build a set of tag IDs to strip // 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(()) 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 { fn pixstrip_bin() -> String {
// Try to find the pixstrip binary path // Try to find the pixstrip binary path
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
@@ -116,7 +124,7 @@ fn get_preset_names() -> Vec<String> {
fn nautilus_extension_dir() -> PathBuf { fn nautilus_extension_dir() -> PathBuf {
let data = std::env::var("XDG_DATA_HOME") let data = std::env::var("XDG_DATA_HOME")
.unwrap_or_else(|_| { .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) format!("{}/.local/share", home)
}); });
PathBuf::from(data).join("nautilus-python").join("extensions") 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 item.connect('activate', self._on_preset, '{}', files)\n\
\x20 submenu.append_item(item)\n\n", \x20 submenu.append_item(item)\n\n",
name.replace(' ', "_"), name.replace(' ', "_"),
name, name.replace('\'', "\\'"),
name, name.replace('\'', "\\'"),
)); ));
} }
let escaped_bin = bin.replace('\\', "\\\\").replace('\'', "\\'");
let script = format!( let script = format!(
r#"import subprocess r#"import subprocess
from gi.repository import Nautilus, GObject from gi.repository import Nautilus, GObject
@@ -202,7 +211,7 @@ class PixstripExtension(GObject.GObject, Nautilus.MenuProvider):
subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths) subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths)
"#, "#,
preset_items = preset_items, preset_items = preset_items,
bin = bin, bin = escaped_bin,
); );
std::fs::write(nautilus_extension_path(), script)?; std::fs::write(nautilus_extension_path(), script)?;
@@ -222,7 +231,7 @@ fn uninstall_nautilus() -> Result<()> {
fn nemo_action_dir() -> PathBuf { fn nemo_action_dir() -> PathBuf {
let data = std::env::var("XDG_DATA_HOME") let data = std::env::var("XDG_DATA_HOME")
.unwrap_or_else(|_| { .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) format!("{}/.local/share", home)
}); });
PathBuf::from(data).join("nemo").join("actions") PathBuf::from(data).join("nemo").join("actions")
@@ -261,12 +270,13 @@ fn install_nemo() -> Result<()> {
"[Nemo Action]\n\ "[Nemo Action]\n\
Name=Pixstrip: {name}\n\ Name=Pixstrip: {name}\n\
Comment=Process with {name} preset\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\ Icon-Name=applications-graphics-symbolic\n\
Selection=Any\n\ Selection=Any\n\
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\ Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
Mimetypes=image/*;\n", Mimetypes=image/*;\n",
name = name, name = name,
safe_label = shell_safe(name),
bin = bin, bin = bin,
); );
std::fs::write(action_path, action)?; std::fs::write(action_path, action)?;
@@ -300,7 +310,7 @@ fn uninstall_nemo() -> Result<()> {
fn thunar_action_dir() -> PathBuf { fn thunar_action_dir() -> PathBuf {
let config = std::env::var("XDG_CONFIG_HOME") let config = std::env::var("XDG_CONFIG_HOME")
.unwrap_or_else(|_| { .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) format!("{}/.config", home)
}); });
PathBuf::from(config).join("Thunar") PathBuf::from(config).join("Thunar")
@@ -337,14 +347,15 @@ fn install_thunar() -> Result<()> {
actions.push_str(&format!( actions.push_str(&format!(
" <action>\n\ " <action>\n\
\x20 <icon>applications-graphics-symbolic</icon>\n\ \x20 <icon>applications-graphics-symbolic</icon>\n\
\x20 <name>Pixstrip: {name}</name>\n\ \x20 <name>Pixstrip: {xml_name}</name>\n\
\x20 <command>{bin} --preset \"{name}\" --files %F</command>\n\ \x20 <command>{bin} --preset \"{safe_label}\" --files %F</command>\n\
\x20 <description>Process with {name} preset</description>\n\ \x20 <description>Process with {xml_name} preset</description>\n\
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\ \x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
\x20 <image-files/>\n\ \x20 <image-files/>\n\
\x20 <directories/>\n\ \x20 <directories/>\n\
</action>\n", </action>\n",
name = name, xml_name = name.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;"),
safe_label = shell_safe(name),
bin = bin, bin = bin,
)); ));
} }
@@ -367,7 +378,7 @@ fn uninstall_thunar() -> Result<()> {
fn dolphin_service_dir() -> PathBuf { fn dolphin_service_dir() -> PathBuf {
let data = std::env::var("XDG_DATA_HOME") let data = std::env::var("XDG_DATA_HOME")
.unwrap_or_else(|_| { .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) format!("{}/.local/share", home)
}); });
PathBuf::from(data).join("kio").join("servicemenus") PathBuf::from(data).join("kio").join("servicemenus")
@@ -410,9 +421,10 @@ fn install_dolphin() -> Result<()> {
"[Desktop Action Preset{i}]\n\ "[Desktop Action Preset{i}]\n\
Name={name}\n\ Name={name}\n\
Icon=applications-graphics-symbolic\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, i = i,
name = name, name = name,
safe_label = shell_safe(name),
bin = bin, 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 { fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage {
let (iw, ih) = (img.width(), img.height()); 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 target_ratio = w_ratio / h_ratio;
let current_ratio = iw as f64 / ih as f64; let current_ratio = iw as f64 / ih as f64;
let (crop_w, crop_h) = if current_ratio > target_ratio { let (crop_w, crop_h) = if current_ratio > target_ratio {
// Image is wider than target, crop width // Image is wider than target, crop width
let new_w = (ih as f64 * target_ratio) as u32; let new_w = (ih as f64 * target_ratio) as u32;
(new_w, ih) (new_w.min(iw), ih)
} else { } else {
// Image is taller than target, crop height // Image is taller than target, crop height
let new_h = (iw as f64 / target_ratio) as u32; 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 x = iw.saturating_sub(crop_w) / 2;
let y = (ih - crop_h) / 2; let y = ih.saturating_sub(crop_h) / 2;
img.crop_imm(x, y, crop_w, crop_h) 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_w = right.saturating_sub(left).saturating_add(1);
let crop_h = bottom.saturating_sub(top) + 1; let crop_h = bottom.saturating_sub(top).saturating_add(1);
if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) { if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) {
return img; return img;
@@ -151,6 +157,7 @@ fn trim_whitespace(img: DynamicImage) -> DynamicImage {
} }
fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage { fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage {
let amount = amount.clamp(-100, 100);
let mut rgba = img.into_rgba8(); let mut rgba = img.into_rgba8();
let factor = 1.0 + (amount as f64 / 100.0); 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 { fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height()); let (w, h) = (img.width(), img.height());
let new_w = w + padding * 2; let new_w = w.saturating_add(padding.saturating_mul(2));
let new_h = h + padding * 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])); 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<()> { fn strip_all_exif(input: &Path, output: &Path) -> Result<()> {
let data = std::fs::read(input).map_err(PixstripError::Io)?; 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)?; std::fs::write(output, cleaned).map_err(PixstripError::Io)?;
Ok(()) Ok(())
} }
@@ -60,7 +64,12 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec<u8> {
// APP1 (0xE1) contains EXIF - skip it // APP1 (0xE1) contains EXIF - skip it
if marker == 0xE1 && i + 3 < data.len() { if marker == 0xE1 && i + 3 < data.len() {
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize); 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; continue;
} }
@@ -89,3 +98,41 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec<u8> {
result 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 { impl ResizeConfig {
pub fn target_for(&self, original: Dimensions) -> Dimensions { 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) => { Self::ByWidth(w) => {
if *w == 0 {
return original;
}
let scale = *w as f64 / original.width as f64; let scale = *w as f64 / original.width as f64;
Dimensions { Dimensions {
width: *w, 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) => { Self::ByHeight(h) => {
if *h == 0 {
return original;
}
let scale = *h as f64 / original.height as f64; let scale = *h as f64 / original.height as f64;
Dimensions { Dimensions {
width: (original.width as f64 * scale).round() as u32, width: (original.width as f64 * scale).round().max(1.0) as u32,
height: *h, height: *h,
} }
} }
Self::FitInBox { max, allow_upscale } => { Self::FitInBox { max, allow_upscale } => {
original.fit_within(*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, Degrees45,
DegreesNeg45, DegreesNeg45,
Degrees90, Degrees90,
Custom(f32),
} }
// --- Adjustments --- // --- Adjustments ---
@@ -255,16 +274,16 @@ impl AdjustmentsConfig {
} }
} }
// --- Overwrite Behavior --- // --- Overwrite Action (concrete action, no "Ask" variant) ---
#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum OverwriteBehavior { pub enum OverwriteAction {
AutoRename, AutoRename,
Overwrite, Overwrite,
Skip, Skip,
} }
impl Default for OverwriteBehavior { impl Default for OverwriteAction {
fn default() -> Self { fn default() -> Self {
Self::AutoRename Self::AutoRename
} }
@@ -278,39 +297,85 @@ pub struct RenameConfig {
pub suffix: String, pub suffix: String,
pub counter_start: u32, pub counter_start: u32,
pub counter_padding: 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>, pub template: Option<String>,
/// 0=none, 1=lowercase, 2=uppercase, 3=title case /// 0=none, 1=lowercase, 2=uppercase, 3=title case
pub case_mode: u32, 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_find: String,
pub regex_replace: String, pub regex_replace: String,
} }
fn default_counter_position() -> u32 { 3 }
impl RenameConfig { impl RenameConfig {
pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String { pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String {
let counter = self.counter_start + index - 1; // 1. Apply regex find-and-replace on the original name
let counter_str = format!(
"{:0>width$}",
counter,
width = self.counter_padding as usize
);
// 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 working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace);
let mut name = String::new(); // 2. Apply space replacement
if !self.prefix.is_empty() { let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
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);
// Apply case conversion // 3. Apply special character filtering
let name = rename::apply_case_conversion(&name, self.case_mode); 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 days = total_days;
let mut year = 1970u64; let mut year = 1970u64;
loop { loop {
if year > 9999 {
break;
}
let days_in_year = if is_leap(year) { 366 } else { 365 }; let days_in_year = if is_leap(year) { 366 } else { 365 };
if days < days_in_year { if days < days_in_year {
break; break;
@@ -149,6 +152,46 @@ fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0 (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) /// Apply case conversion to a filename (without extension)
/// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case /// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case
pub fn apply_case_conversion(name: &str, case_mode: u32) -> String { 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(), 1 => name.to_lowercase(),
2 => name.to_uppercase(), 2 => name.to_uppercase(),
3 => { 3 => {
// Title case: capitalize first letter of each word (split on _ - space) // Title case: capitalize first letter of each word, preserve original separators
name.split(|c: char| c == '_' || c == '-' || c == ' ') let mut result = String::with_capacity(name.len());
.map(|word| { let mut capitalize_next = true;
let mut chars = word.chars(); for c in name.chars() {
match chars.next() { if c == '_' || c == '-' || c == ' ' {
Some(first) => { result.push(c);
let upper: String = first.to_uppercase().collect(); capitalize_next = true;
upper + &chars.as_str().to_lowercase() } else if capitalize_next {
} for uc in c.to_uppercase() {
None => String::new(), result.push(uc);
} }
}) capitalize_next = false;
.collect::<Vec<_>>() } else {
.join("_") for lc in c.to_lowercase() {
result.push(lc);
}
}
}
result
} }
_ => name.to_string(), _ => name.to_string(),
} }
@@ -180,7 +228,10 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
if find.is_empty() { if find.is_empty() {
return name.to_string(); 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(), Ok(re) => re.replace_all(name, replace).into_owned(),
Err(_) => name.to_string(), Err(_) => name.to_string(),
} }
@@ -213,5 +264,9 @@ pub fn resolve_collision(path: &Path) -> PathBuf {
} }
// Fallback - should never happen with 1000 attempts // 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_x = iw.saturating_sub(ww) / 2;
let center_y = ih.saturating_sub(wh) / 2; let center_y = ih.saturating_sub(wh) / 2;
let right_x = iw.saturating_sub(ww + margin); let right_x = iw.saturating_sub(ww).saturating_sub(margin);
let bottom_y = ih.saturating_sub(wh + margin); let bottom_y = ih.saturating_sub(wh).saturating_sub(margin);
match position { match position {
WatermarkPosition::TopLeft => (margin, margin), WatermarkPosition::TopLeft => (margin, margin),
@@ -69,7 +69,7 @@ pub fn apply_watermark(
if *tiled { if *tiled {
apply_tiled_image_watermark(img, path, *opacity, *scale, *rotation, *margin) apply_tiled_image_watermark(img, path, *opacity, *scale, *rotation, *margin)
} else { } 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>> { 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(); let mut results = Vec::new();
if dir.is_dir() { if max_depth == 0 || !dir.is_dir() {
for entry in std::fs::read_dir(dir)? { return Ok(results);
let entry = entry?; }
let path = entry.path(); for entry in std::fs::read_dir(dir)? {
if path.is_dir() { if results.len() >= MAX_RESULTS {
if let Ok(sub) = walkdir(&path) { break;
results.extend(sub); }
} let entry = entry?;
} else { let path = entry.path();
results.push(path); if path.is_dir() {
if let Ok(sub) = walkdir_depth(&path, max_depth - 1) {
results.extend(sub);
} }
} else {
results.push(path);
} }
} }
Ok(results) Ok(results)
@@ -156,8 +165,8 @@ fn render_text_to_image(
opacity: f32, opacity: f32,
) -> image::RgbaImage { ) -> image::RgbaImage {
let scale = ab_glyph::PxScale::from(font_size); let scale = ab_glyph::PxScale::from(font_size);
let text_width = (text.len() as f32 * font_size * 0.6) 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 * 1.4) as u32 + 4; 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 alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
let draw_color = Rgba([color[0], color[1], color[2], alpha]); let draw_color = Rgba([color[0], color[1], color[2], alpha]);
@@ -167,6 +176,30 @@ fn render_text_to_image(
buf 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 /// Rotate an RGBA image by the given WatermarkRotation
fn rotate_watermark_image( fn rotate_watermark_image(
img: DynamicImage, img: DynamicImage,
@@ -175,20 +208,13 @@ fn rotate_watermark_image(
match rotation { match rotation {
super::WatermarkRotation::Degrees90 => img.rotate90(), super::WatermarkRotation::Degrees90 => img.rotate90(),
super::WatermarkRotation::Degrees45 => { super::WatermarkRotation::Degrees45 => {
imageproc::geometric_transformations::rotate_about_center( rotate_on_expanded_canvas(&img.to_rgba8(), std::f32::consts::FRAC_PI_4)
&img.to_rgba8(),
std::f32::consts::FRAC_PI_4,
imageproc::geometric_transformations::Interpolation::Bilinear,
Rgba([0, 0, 0, 0]),
).into()
} }
super::WatermarkRotation::DegreesNeg45 => { super::WatermarkRotation::DegreesNeg45 => {
imageproc::geometric_transformations::rotate_about_center( rotate_on_expanded_canvas(&img.to_rgba8(), -std::f32::consts::FRAC_PI_4)
&img.to_rgba8(), }
-std::f32::consts::FRAC_PI_4, super::WatermarkRotation::Custom(degrees) => {
imageproc::geometric_transformations::Interpolation::Bilinear, rotate_on_expanded_canvas(&img.to_rgba8(), degrees.to_radians())
Rgba([0, 0, 0, 0]),
).into()
} }
} }
} }
@@ -204,6 +230,9 @@ fn apply_text_watermark(
rotation: Option<super::WatermarkRotation>, rotation: Option<super::WatermarkRotation>,
margin_px: u32, margin_px: u32,
) -> Result<DynamicImage> { ) -> Result<DynamicImage> {
if text.is_empty() {
return Ok(img);
}
let font_data = find_system_font(font_family)?; let font_data = find_system_font(font_family)?;
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| { let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
PixstripError::Processing { PixstripError::Processing {
@@ -234,8 +263,8 @@ fn apply_text_watermark(
} else { } else {
// No rotation - draw text directly (faster) // No rotation - draw text directly (faster)
let scale = ab_glyph::PxScale::from(font_size); let scale = ab_glyph::PxScale::from(font_size);
let text_width = (text.len() as f32 * font_size * 0.6) 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 as u32; let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096);
let text_dims = Dimensions { let text_dims = Dimensions {
width: text_width, width: text_width,
height: text_height, height: text_height,
@@ -266,6 +295,9 @@ fn apply_tiled_text_watermark(
rotation: Option<super::WatermarkRotation>, rotation: Option<super::WatermarkRotation>,
margin: u32, margin: u32,
) -> Result<DynamicImage> { ) -> Result<DynamicImage> {
if text.is_empty() {
return Ok(img);
}
let font_data = find_system_font(font_family)?; let font_data = find_system_font(font_family)?;
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| { let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
PixstripError::Processing { PixstripError::Processing {
@@ -301,17 +333,17 @@ fn apply_tiled_text_watermark(
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8; let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
let draw_color = Rgba([color[0], color[1], color[2], alpha]); 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_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 as u32; let text_height = ((font_size.min(1000.0) * 1.4) as i64 + 4).min(4096);
let mut y = spacing as i32; let mut y = spacing as i64;
while y < ih as i32 { while y < ih as i64 {
let mut x = spacing as i32; let mut x = spacing as i64;
while x < iw as i32 { while x < iw as i64 {
draw_text_mut(&mut rgba, draw_color, x, y, scale, &font, text); draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text);
x += text_width as i32 + spacing as i32; 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, opacity: f32,
scale: f32, scale: f32,
rotation: Option<super::WatermarkRotation>, rotation: Option<super::WatermarkRotation>,
margin: u32,
) -> Result<DynamicImage> { ) -> Result<DynamicImage> {
let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing { let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing {
operation: "watermark".into(), operation: "watermark".into(),
@@ -423,7 +456,6 @@ fn apply_image_watermark(
height: watermark.height(), height: watermark.height(),
}; };
let margin = 10;
let (x, y) = calculate_position(position, image_dims, wm_dims, margin); let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
let mut base = img.into_rgba8(); let mut base = img.into_rgba8();

View File

@@ -21,7 +21,7 @@ pub struct ProcessingJob {
pub metadata: Option<MetadataConfig>, pub metadata: Option<MetadataConfig>,
pub watermark: Option<WatermarkConfig>, pub watermark: Option<WatermarkConfig>,
pub rename: Option<RenameConfig>, pub rename: Option<RenameConfig>,
pub overwrite_behavior: OverwriteBehavior, pub overwrite_behavior: OverwriteAction,
pub preserve_directory_structure: bool, pub preserve_directory_structure: bool,
pub progressive_jpeg: bool, pub progressive_jpeg: bool,
pub avif_speed: u8, pub avif_speed: u8,
@@ -44,11 +44,11 @@ impl ProcessingJob {
metadata: None, metadata: None,
watermark: None, watermark: None,
rename: None, rename: None,
overwrite_behavior: OverwriteBehavior::default(), overwrite_behavior: OverwriteAction::default(),
preserve_directory_structure: false, preserve_directory_structure: false,
progressive_jpeg: false, progressive_jpeg: false,
avif_speed: 6, avif_speed: 6,
output_dpi: 72, output_dpi: 0,
} }
} }
@@ -70,6 +70,18 @@ impl ProcessingJob {
count 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( pub fn output_path_for(
&self, &self,
source: &ImageSource, source: &ImageSource,

View File

@@ -5,6 +5,7 @@ use crate::pipeline::ProcessingJob;
use crate::types::*; use crate::types::*;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Preset { pub struct Preset {
pub name: String, pub name: String,
pub description: String, pub description: String,
@@ -20,6 +21,25 @@ pub struct Preset {
pub rename: Option<RenameConfig>, 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 { impl Preset {
pub fn to_job( pub fn to_job(
&self, &self,
@@ -40,7 +60,7 @@ impl Preset {
metadata: self.metadata.clone(), metadata: self.metadata.clone(),
watermark: self.watermark.clone(), watermark: self.watermark.clone(),
rename: self.rename.clone(), rename: self.rename.clone(),
overwrite_behavior: crate::operations::OverwriteBehavior::default(), overwrite_behavior: crate::operations::OverwriteAction::default(),
preserve_directory_structure: false, preserve_directory_structure: false,
progressive_jpeg: false, progressive_jpeg: false,
avif_speed: 6, avif_speed: 6,
@@ -58,6 +78,7 @@ impl Preset {
Self::builtin_photographer_export(), Self::builtin_photographer_export(),
Self::builtin_archive_compress(), Self::builtin_archive_compress(),
Self::builtin_fediverse_ready(), Self::builtin_fediverse_ready(),
Self::builtin_print_ready(),
] ]
} }
@@ -119,8 +140,12 @@ impl Preset {
suffix: String::new(), suffix: String::new(),
counter_start: 1, counter_start: 1,
counter_padding: 3, counter_padding: 3,
counter_enabled: true,
counter_position: 3,
template: None, template: None,
case_mode: 0, case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(), regex_find: String::new(),
regex_replace: String::new(), regex_replace: String::new(),
}), }),
@@ -179,8 +204,12 @@ impl Preset {
suffix: String::new(), suffix: String::new(),
counter_start: 1, counter_start: 1,
counter_padding: 4, counter_padding: 4,
counter_enabled: true,
counter_position: 3,
template: Some("{exif_date}_{name}_{counter:4}".into()), template: Some("{exif_date}_{name}_{counter:4}".into()),
case_mode: 0, case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(), regex_find: String::new(),
regex_replace: 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 { pub fn builtin_fediverse_ready() -> Preset {
Preset { Preset {
name: "Fediverse Ready".into(), name: "Fediverse Ready".into(),

View File

@@ -8,10 +8,19 @@ use crate::preset::Preset;
fn default_config_dir() -> PathBuf { fn default_config_dir() -> PathBuf {
dirs::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") .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 { fn sanitize_filename(name: &str) -> String {
name.chars() name.chars()
.map(|c| match c { .map(|c| match c {
@@ -54,7 +63,7 @@ impl PresetStore {
let path = self.preset_path(&preset.name); let path = self.preset_path(&preset.name);
let json = serde_json::to_string_pretty(preset) let json = serde_json::to_string_pretty(preset)
.map_err(|e| PixstripError::Preset(e.to_string()))?; .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> { 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<()> { pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(preset) let json = serde_json::to_string_pretty(preset)
.map_err(|e| PixstripError::Preset(e.to_string()))?; .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> { pub fn import_from_file(&self, path: &Path) -> Result<Preset> {
@@ -146,7 +155,7 @@ impl ConfigStore {
} }
let json = serde_json::to_string_pretty(config) let json = serde_json::to_string_pretty(config)
.map_err(|e| PixstripError::Config(e.to_string()))?; .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> { pub fn load(&self) -> Result<AppConfig> {
@@ -215,7 +224,7 @@ impl SessionStore {
} }
let json = serde_json::to_string_pretty(state) let json = serde_json::to_string_pretty(state)
.map_err(|e| PixstripError::Config(e.to_string()))?; .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> { 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()?; let mut entries = self.list()?;
entries.push(entry); 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<()> { pub fn prune(&self, max_entries: usize, max_days: u32) -> Result<()> {
@@ -285,9 +295,9 @@ impl HistoryStore {
.as_secs(); .as_secs();
let cutoff_secs = now_secs.saturating_sub(max_days as u64 * 86400); 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| { 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) // Trim to max_entries (keep the most recent)
@@ -314,13 +324,13 @@ impl HistoryStore {
self.write_all(&Vec::<HistoryEntry>::new()) 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() { if let Some(parent) = self.history_path.parent() {
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
} }
let json = serde_json::to_string_pretty(entries) let json = serde_json::to_string_pretty(entries)
.map_err(|e| PixstripError::Config(e.to_string()))?; .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 { impl Dimensions {
pub fn aspect_ratio(&self) -> f64 { pub fn aspect_ratio(&self) -> f64 {
if self.height == 0 {
return 1.0;
}
self.width as f64 / self.height as f64 self.width as f64 / self.height as f64
} }
pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions { 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 { if !allow_upscale && self.width <= max.width && self.height <= max.height {
return self; return self;
} }
@@ -83,8 +90,8 @@ impl Dimensions {
} }
Dimensions { Dimensions {
width: (self.width 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() 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 { pub fn label(&self) -> &'static str {
match self { match self {
Self::Maximum => "Maximum", 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 executor = PipelineExecutor::with_cancel(cancel);
let result = executor.execute(&job, |_| {}).unwrap(); let result = executor.execute(&job, |_| {}).unwrap();
// With immediate cancellation, fewer images should be processed // Cancellation flag should be set
assert!(result.succeeded + result.failed <= 2); assert!(result.cancelled, "result.cancelled should be true when cancel flag is set");
assert!(result.cancelled); // 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] #[test]

View File

@@ -42,3 +42,84 @@ fn privacy_mode_strips_gps() {
strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap(); strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap();
assert!(output.exists()); 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(), suffix: String::new(),
counter_start: 1, counter_start: 1,
counter_padding: 3, counter_padding: 3,
counter_enabled: true,
counter_position: 3,
template: None, template: None,
case_mode: 0, case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(), regex_find: String::new(),
regex_replace: String::new(), regex_replace: String::new(),
}; };
@@ -107,11 +111,101 @@ fn rename_config_with_suffix() {
suffix: "_web".into(), suffix: "_web".into(),
counter_start: 1, counter_start: 1,
counter_padding: 2, counter_padding: 2,
counter_enabled: true,
counter_position: 3,
template: None, template: None,
case_mode: 0, case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(), regex_find: String::new(),
regex_replace: String::new(), regex_replace: String::new(),
}; };
let result = config.apply_simple("photo", "webp", 5); let result = config.apply_simple("photo", "webp", 5);
assert_eq!(result, "photo_web_05.webp"); 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] #[test]
fn all_builtin_presets() { fn all_builtin_presets() {
let presets = Preset::all_builtins(); 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(); let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"Blog Photos")); assert!(names.contains(&"Blog Photos"));
assert!(names.contains(&"Social Media")); 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] #[test]
fn template_basic_variables() { fn template_basic_variables() {
@@ -93,3 +96,185 @@ fn no_collision_returns_same() {
let resolved = resolve_collision(&path); let resolved = resolve_collision(&path);
assert_eq!(resolved, 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(); let entries = history.list().unwrap();
assert_eq!(entries.len(), 1); assert_eq!(entries.len(), 1);
@@ -236,7 +236,7 @@ fn history_appends_entries() {
total_output_bytes: 500, total_output_bytes: 500,
elapsed_ms: 100, elapsed_ms: 100,
output_files: vec![], output_files: vec![],
}) }, 50, 30)
.unwrap(); .unwrap();
} }
@@ -263,7 +263,7 @@ fn clear_history() {
total_output_bytes: 500, total_output_bytes: 500,
elapsed_ms: 100, elapsed_ms: 100,
output_files: vec![], output_files: vec![],
}) }, 50, 30)
.unwrap(); .unwrap();
history.clear().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::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality());
assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.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(); watcher.start(&folder, tx).unwrap();
// Wait for watcher to be ready // 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 // Create an image file
let img_path = dir.path().join("new_photo.jpg"); let img_path = dir.path().join("new_photo.jpg");
@@ -87,7 +87,7 @@ fn watcher_ignores_non_image_files() {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
watcher.start(&folder, tx).unwrap(); 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 // Create a non-image file
std::fs::write(dir.path().join("readme.txt"), b"text file").unwrap(); 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!(x, 10);
assert_eq!(y, 1020); 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"] } gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] }
adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] } adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] }
image = "0.25" 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 step_indicator;
mod steps; mod steps;
mod tutorial; mod tutorial;
pub(crate) mod utils;
mod welcome; mod welcome;
mod wizard; mod wizard;

View File

@@ -88,15 +88,10 @@ pub fn build_processing_page() -> adw::NavigationPage {
content.append(&log_group); content.append(&log_group);
content.append(&button_box); content.append(&button_box);
let clamp = adw::Clamp::builder()
.maximum_size(600)
.child(&content)
.build();
adw::NavigationPage::builder() adw::NavigationPage::builder()
.title("Processing") .title("Processing")
.tag("processing") .tag("processing")
.child(&clamp) .child(&content)
.build() .build()
} }
@@ -232,14 +227,9 @@ pub fn build_results_page() -> adw::NavigationPage {
scrolled.set_child(Some(&content)); scrolled.set_child(Some(&content));
let clamp = adw::Clamp::builder()
.maximum_size(600)
.child(&scrolled)
.build();
adw::NavigationPage::builder() adw::NavigationPage::builder()
.title("Results") .title("Results")
.tag("results") .tag("results")
.child(&clamp) .child(&scrolled)
.build() .build()
} }

View File

@@ -1,4 +1,5 @@
use adw::prelude::*; use adw::prelude::*;
use std::cell::Cell;
use pixstrip_core::config::{AppConfig, ErrorBehavior, OverwriteBehavior, SkillLevel}; use pixstrip_core::config::{AppConfig, ErrorBehavior, OverwriteBehavior, SkillLevel};
use pixstrip_core::storage::ConfigStore; use pixstrip_core::storage::ConfigStore;
@@ -8,7 +9,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.build(); .build();
let config_store = ConfigStore::new(); 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 // General page
let general_page = adw::PreferencesPage::builder() let general_page = adw::PreferencesPage::builder()
@@ -24,12 +31,14 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
let output_mode_row = adw::ComboRow::builder() let output_mode_row = adw::ComboRow::builder()
.title("Default output location") .title("Default output location")
.subtitle("Where processed images are saved by default") .subtitle("Where processed images are saved by default")
.use_subtitle(true)
.build(); .build();
let output_mode_model = gtk::StringList::new(&[ let output_mode_model = gtk::StringList::new(&[
"Subfolder next to originals", "Subfolder next to originals",
"Fixed output folder", "Fixed output folder",
]); ]);
output_mode_row.set_model(Some(&output_mode_model)); 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 }); output_mode_row.set_selected(if config.output_fixed_path.is_some() { 1 } else { 0 });
let subfolder_row = adw::EntryRow::builder() let subfolder_row = adw::EntryRow::builder()
@@ -101,6 +110,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
let overwrite_row = adw::ComboRow::builder() let overwrite_row = adw::ComboRow::builder()
.title("Default overwrite behavior") .title("Default overwrite behavior")
.subtitle("What to do when output files already exist") .subtitle("What to do when output files already exist")
.use_subtitle(true)
.build(); .build();
let overwrite_model = gtk::StringList::new(&[ let overwrite_model = gtk::StringList::new(&[
"Ask before overwriting", "Ask before overwriting",
@@ -109,6 +119,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
"Skip existing files", "Skip existing files",
]); ]);
overwrite_row.set_model(Some(&overwrite_model)); 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 { overwrite_row.set_selected(match config.overwrite_behavior {
OverwriteBehavior::Ask => 0, OverwriteBehavior::Ask => 0,
OverwriteBehavior::AutoRename => 1, OverwriteBehavior::AutoRename => 1,
@@ -136,9 +147,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
let skill_row = adw::ComboRow::builder() let skill_row = adw::ComboRow::builder()
.title("Detail level") .title("Detail level")
.subtitle("Controls how many options are visible by default") .subtitle("Controls how many options are visible by default")
.use_subtitle(true)
.build(); .build();
let skill_model = gtk::StringList::new(&["Simple", "Detailed"]); let skill_model = gtk::StringList::new(&["Simple", "Detailed"]);
skill_row.set_model(Some(&skill_model)); 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 { skill_row.set_selected(match config.skill_level {
SkillLevel::Simple => 0, SkillLevel::Simple => 0,
SkillLevel::Detailed => 1, SkillLevel::Detailed => 1,
@@ -188,11 +201,22 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.build(); .build();
let fm_copy = *fm; 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| { row.connect_active_notify(move |row| {
if row.is_active() { if reverting_clone.get() {
let _ = fm_copy.install(); return;
}
let result = if row.is_active() {
fm_copy.install()
} else { } 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() let threads_row = adw::ComboRow::builder()
.title("Processing threads") .title("Processing threads")
.subtitle("Auto uses all available CPU cores") .subtitle("Auto uses all available CPU cores")
.use_subtitle(true)
.build(); .build();
let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]); let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]);
threads_row.set_model(Some(&threads_model)); 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 { threads_row.set_selected(match config.thread_count {
pixstrip_core::config::ThreadCount::Auto => 0, pixstrip_core::config::ThreadCount::Auto => 0,
pixstrip_core::config::ThreadCount::Manual(1) => 1, pixstrip_core::config::ThreadCount::Manual(1) => 1,
@@ -245,9 +271,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
let error_row = adw::ComboRow::builder() let error_row = adw::ComboRow::builder()
.title("On error") .title("On error")
.subtitle("What to do when an image fails to process") .subtitle("What to do when an image fails to process")
.use_subtitle(true)
.build(); .build();
let error_model = gtk::StringList::new(&["Skip and continue", "Pause on error"]); let error_model = gtk::StringList::new(&["Skip and continue", "Pause on error"]);
error_row.set_model(Some(&error_model)); 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 { error_row.set_selected(match config.error_behavior {
ErrorBehavior::SkipAndContinue => 0, ErrorBehavior::SkipAndContinue => 0,
ErrorBehavior::PauseOnError => 1, ErrorBehavior::PauseOnError => 1,
@@ -287,6 +315,53 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.active(config.reduced_motion) .active(config.reduced_motion)
.build(); .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(&contrast_row);
a11y_group.add(&large_text_row); a11y_group.add(&large_text_row);
a11y_group.add(&motion_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 auto_open = auto_open_row.clone();
let output_mode = output_mode_row.clone(); let output_mode = output_mode_row.clone();
let fps_reset = fixed_path_state.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 |_| { reset_button.connect_clicked(move |_| {
let defaults = AppConfig::default(); let defaults = AppConfig::default();
subfolder.set_text(&defaults.output_subfolder); 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); auto_open.set_active(defaults.auto_open_output);
output_mode.set_selected(0); output_mode.set_selected(0);
*fps_reset.borrow_mut() = None; *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() let preset_row = adw::ComboRow::builder()
.title("Linked Preset") .title("Linked Preset")
.subtitle("Preset to apply to new images") .subtitle("Preset to apply to new images")
.use_subtitle(true)
.build(); .build();
let preset_model = gtk::StringList::new( let preset_model = gtk::StringList::new(
&preset_names.iter().map(|s| s.as_str()).collect::<Vec<_>>(), &preset_names.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
); );
preset_row.set_model(Some(&preset_model)); preset_row.set_model(Some(&preset_model));
preset_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
// Set selected to matching preset // Set selected to matching preset
let selected_idx = preset_names.iter() let selected_idx = preset_names.iter()

View File

@@ -5,7 +5,10 @@ use std::cell::RefCell;
#[derive(Clone)] #[derive(Clone)]
pub struct StepIndicator { pub struct StepIndicator {
container: gtk::Box, container: gtk::Box,
grid: gtk::Grid,
dots: RefCell<Vec<StepDot>>, dots: RefCell<Vec<StepDot>>,
/// Maps visual index -> actual step index
step_map: RefCell<Vec<usize>>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -27,65 +30,23 @@ impl StepIndicator {
.margin_end(12) .margin_end(12)
.build(); .build();
// Prevent negative allocation warnings when window is narrow
container.set_overflow(gtk::Overflow::Hidden); container.set_overflow(gtk::Overflow::Hidden);
container.update_property(&[ container.update_property(&[
gtk::accessible::Property::Label("Wizard step indicator"), 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() { let indices: Vec<usize> = (0..step_names.len()).collect();
if i > 0 { let dots = Self::build_dots(&grid, step_names, &indices);
// 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 dot_box = gtk::Box::builder() container.append(&grid);
.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,
});
}
// First step starts as current // First step starts as current
if let Some(first) = dots.first() { if let Some(first) = dots.first() {
@@ -96,22 +57,95 @@ impl StepIndicator {
Self { Self {
container, container,
grid,
dots: RefCell::new(dots), 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 dots = self.dots.borrow();
let map = self.step_map.borrow();
let total = dots.len(); let total = dots.len();
for (i, dot) in dots.iter().enumerate() { for (visual_i, dot) in dots.iter().enumerate() {
if i == index { let is_current = map.get(visual_i) == Some(&actual_index);
if is_current {
dot.icon.set_icon_name(Some("radio-checked-symbolic")); dot.icon.set_icon_name(Some("radio-checked-symbolic"));
dot.button.set_sensitive(true); dot.button.set_sensitive(true);
dot.label.add_css_class("accent"); dot.label.add_css_class("accent");
// Update accessible description for screen readers
dot.button.update_property(&[ dot.button.update_property(&[
gtk::accessible::Property::Label( 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") { } 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.label.remove_css_class("accent");
dot.button.update_property(&[ dot.button.update_property(&[
gtk::accessible::Property::Label( 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(); let dots = self.dots.borrow();
if let Some(dot) = dots.get(index) { let map = self.step_map.borrow();
dot.icon.set_icon_name(Some("emblem-ok-symbolic")); if let Some(visual_i) = map.iter().position(|&i| i == actual_index) {
dot.button.set_sensitive(true); if let Some(dot) = dots.get(visual_i) {
dot.label.remove_css_class("accent"); 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_resize;
pub mod step_watermark; pub mod step_watermark;
pub mod step_workflow; 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 adw::prelude::*;
use gtk::glib;
use std::cell::Cell;
use std::rc::Rc;
use crate::app::AppState; use crate::app::AppState;
pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage { pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
let scrolled = gtk::ScrolledWindow::builder() let cfg = state.job_config.borrow();
.hscrollbar_policy(gtk::PolicyType::Never)
// === OUTER LAYOUT ===
let outer = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.vexpand(true) .vexpand(true)
.build(); .build();
let content = gtk::Box::builder() // --- Enable toggle (full width) ---
.orientation(gtk::Orientation::Vertical) let enable_group = adw::PreferencesGroup::builder()
.spacing(12) .margin_start(12)
.margin_end(12)
.margin_top(12) .margin_top(12)
.margin_bottom(12) .build();
.margin_start(24) let enable_row = adw::SwitchRow::builder()
.margin_end(24) .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(); .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 preview_box = gtk::Box::builder()
let rotate_group = adw::PreferencesGroup::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") .title("Orientation")
.description("Rotate and flip images")
.build(); .build();
let rotate_row = adw::ComboRow::builder() let rotate_row = adw::ComboRow::builder()
.title("Rotate") .title("Rotate")
.subtitle("Rotation applied to all images") .subtitle("Rotation applied to all images")
.use_subtitle(true)
.build(); .build();
let rotate_model = gtk::StringList::new(&[ rotate_row.set_model(Some(&gtk::StringList::new(&[
"None", "None",
"90 clockwise", "90 clockwise",
"180", "180",
"270 clockwise", "270 clockwise",
"Auto-orient (from EXIF)", "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); rotate_row.set_selected(cfg.rotation);
let flip_row = adw::ComboRow::builder() let flip_row = adw::ComboRow::builder()
.title("Flip") .title("Flip")
.subtitle("Mirror the image") .subtitle("Mirror the image")
.use_subtitle(true)
.build(); .build();
let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]); flip_row.set_model(Some(&gtk::StringList::new(&["None", "Horizontal", "Vertical"])));
flip_row.set_model(Some(&flip_model)); flip_row.set_list_factory(Some(&super::full_text_list_factory()));
flip_row.set_selected(cfg.flip); flip_row.set_selected(cfg.flip);
rotate_group.add(&rotate_row); orient_group.add(&rotate_row);
rotate_group.add(&flip_row); orient_group.add(&flip_row);
content.append(&rotate_group); 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() let crop_group = adw::PreferencesGroup::builder()
.title("Crop and Canvas") .title("Crop and Canvas")
.build(); .build();
let crop_row = adw::ComboRow::builder() let crop_row = adw::ComboRow::builder()
.title("Crop to Aspect Ratio") .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(); .build();
let crop_model = gtk::StringList::new(&[ crop_row.set_model(Some(&gtk::StringList::new(&[
"None", "None",
"1:1 (Square)", "1:1 (Square)",
"4:3", "4:3",
@@ -68,8 +214,8 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
"9:16 (Portrait)", "9:16 (Portrait)",
"3:4 (Portrait)", "3:4 (Portrait)",
"2:3 (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); crop_row.set_selected(cfg.crop_aspect_ratio);
let trim_row = adw::SwitchRow::builder() 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() let padding_row = adw::SpinRow::builder()
.title("Canvas Padding") .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)) .adjustment(&gtk::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0))
.build(); .build();
crop_group.add(&crop_row); crop_group.add(&crop_row);
crop_group.add(&trim_row); crop_group.add(&trim_row);
crop_group.add(&padding_row); crop_group.add(&padding_row);
content.append(&crop_group); controls.append(&crop_group);
// Image adjustments // Scrollable controls
let adjust_group = adw::PreferencesGroup::builder() let controls_scrolled = gtk::ScrolledWindow::builder()
.title("Image Adjustments") .hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.width_request(360)
.child(&controls)
.build(); .build();
let adjust_expander = adw::ExpanderRow::builder() // === Main layout: 60/40 side-by-side ===
.title("Advanced Adjustments") let main_box = gtk::Box::builder()
.subtitle("Brightness, contrast, saturation, effects") .orientation(gtk::Orientation::Horizontal)
.show_enable_switch(false) .spacing(12)
.expanded(state.is_section_expanded("adjustments-advanced")) .margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.vexpand(true)
.build(); .build();
{ preview_box.set_width_request(400);
let st = state.clone(); main_box.append(&preview_box);
adjust_expander.connect_expanded_notify(move |row| { main_box.append(&controls_scrolled);
st.set_section_expanded("adjustments-advanced", row.is_expanded()); outer.append(&main_box);
});
}
// Brightness slider (-100 to +100) // Preview state
let brightness_row = adw::ActionRow::builder() let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
.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);
drop(cfg); 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(); 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| { rotate_row.connect_selected_notify(move |row| {
jc.borrow_mut().rotation = row.selected(); jc.borrow_mut().rotation = row.selected();
up();
}); });
} }
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let up = update_preview.clone();
flip_row.connect_selected_notify(move |row| { flip_row.connect_selected_notify(move |row| {
jc.borrow_mut().flip = row.selected(); 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(); let jc = state.job_config.clone();
crop_row.connect_selected_notify(move |row| { let row = brightness_row.clone();
jc.borrow_mut().crop_aspect_ratio = row.selected(); let up = update_preview.clone();
}); let rst = brightness_reset.clone();
} let did = slider_debounce.clone();
{
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;
brightness_scale.connect_value_changed(move |scale| { brightness_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as i32; let val = scale.value().round() as i32;
jc.borrow_mut().brightness = val; 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 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| { contrast_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as i32; let val = scale.value().round() as i32;
jc.borrow_mut().contrast = val; 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 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| { saturation_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as i32; let val = scale.value().round() as i32;
jc.borrow_mut().saturation = val; 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(); let jc = state.job_config.clone();
sharpen_row.connect_active_notify(move |row| { let up = update_preview.clone();
jc.borrow_mut().sharpen = row.is_active(); sepia_btn.connect_toggled(move |btn| {
jc.borrow_mut().sepia = btn.is_active();
up();
}); });
} }
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
grayscale_row.connect_active_notify(move |row| { let up = update_preview.clone();
jc.borrow_mut().grayscale = row.is_active(); 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(); let jc = state.job_config.clone();
sepia_row.connect_active_notify(move |row| { let up = update_preview.clone();
jc.borrow_mut().sepia = row.is_active(); 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 page = adw::NavigationPage::builder()
let clamp = adw::Clamp::builder()
.maximum_size(600)
.child(&scrolled)
.build();
adw::NavigationPage::builder()
.title("Adjustments") .title("Adjustments")
.tag("step-adjustments") .tag("step-adjustments")
.child(&clamp) .child(&outer)
.build() .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 adw::prelude::*;
use std::cell::RefCell;
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use crate::app::AppState; use crate::app::AppState;
use pixstrip_core::types::ImageFormat; 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 { pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
let scrolled = gtk::ScrolledWindow::builder() let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never) .hscrollbar_policy(gtk::PolicyType::Never)
@@ -30,7 +84,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
enable_group.add(&enable_row); enable_group.add(&enable_row);
content.append(&enable_group); content.append(&enable_group);
// Visual format cards grid // --- Visual format cards grid ---
let cards_group = adw::PreferencesGroup::builder() let cards_group = adw::PreferencesGroup::builder()
.title("Output Format") .title("Output Format")
.description("Choose the format all images will be converted to") .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) .margin_bottom(4)
.build(); .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; 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() let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(4) .spacing(4)
.halign(gtk::Align::Center) .hexpand(true)
.valign(gtk::Align::Center) .vexpand(false)
.build(); .build();
card.add_css_class("card"); card.add_css_class("card");
card.set_size_request(130, 110); card.set_size_request(130, 110);
@@ -73,10 +116,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
let inner = gtk::Box::builder() let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(4) .spacing(4)
.margin_top(12) .margin_top(6)
.margin_bottom(12) .margin_bottom(6)
.margin_start(8) .margin_start(4)
.margin_end(8) .margin_end(4)
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.vexpand(true) .vexpand(true)
@@ -107,20 +150,20 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
flow.append(&card); flow.append(&card);
} }
// Select the initial card // Select the initial card (only if format matches a card)
let initial_idx = match initial_format { let initial_idx = card_index_for_format(initial_format);
None => 0, if let Some(idx) = initial_idx
Some(ImageFormat::Jpeg) => 1, && let Some(child) = flow.child_at_index(idx)
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) {
flow.select_child(&child); 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) // Format info label (updates based on selection)
let info_label = gtk::Label::builder() let info_label = gtk::Label::builder()
.label(format_info(cfg.convert_format)) .label(format_info(cfg.convert_format))
@@ -132,161 +175,316 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.margin_start(4) .margin_start(4)
.build(); .build();
cards_group.add(&flow); cards_group.add(&clamp);
cards_group.add(&info_label); cards_group.add(&info_label);
content.append(&cards_group);
// Advanced options expander // --- "Other Formats" dropdown for less common formats ---
let advanced_group = adw::PreferencesGroup::builder() let other_group = adw::PreferencesGroup::builder()
.title("Advanced Options") .title("Other Formats")
.description("Less common formats not shown in the card grid")
.build(); .build();
let advanced_expander = adw::ExpanderRow::builder() let other_combo = adw::ComboRow::builder()
.title("Format Mapping") .title("Select format")
.subtitle("Different input formats can convert to different outputs") .subtitle("Choosing a format here deselects the card grid")
.show_enable_switch(false) .use_subtitle(true)
.expanded(state.is_section_expanded("convert-advanced"))
.build(); .build();
{ // Build model: first entry is "(none)" for no selection, then extra formats with descriptions
let st = state.clone(); let mut other_items: Vec<&str> = vec!["(none)"];
advanced_expander.connect_expanded_notify(move |row| { for (_short, label) in DROPDOWN_ONLY_FORMATS {
st.set_section_expanded("convert-advanced", row.is_expanded()); 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() let progressive_row = adw::SwitchRow::builder()
.title("Progressive JPEG") .title("Progressive JPEG")
.subtitle("Loads gradually in browsers, slightly larger") .subtitle("Loads gradually in browsers, slightly larger file size")
.active(cfg.progressive_jpeg) .active(cfg.progressive_jpeg)
.build(); .build();
// Format mapping rows - per input format output selection jpeg_group.add(&progressive_row);
let mapping_header = adw::ActionRow::builder()
.title("Per-Format Mapping") // Show only for JPEG (card index 1) or Keep Original (card index 0)
.subtitle("Override the output format for specific input types") 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(); .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"]; // Container for dynamically added mapping rows
let mapping_list = gtk::ListBox::builder()
let jpeg_mapping = adw::ComboRow::builder() .selection_mode(gtk::SelectionMode::None)
.title("JPEG inputs") .css_classes(["boxed-list"])
.subtitle("Output format for JPEG source files")
.build(); .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() mapping_group.add(&mapping_list);
.title("PNG inputs") content.append(&mapping_group);
.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);
drop(cfg); drop(cfg);
// Wire signals // --- Wire signals ---
// Enable toggle
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
enable_row.connect_active_notify(move |row| { enable_row.connect_active_notify(move |row| {
jc.borrow_mut().convert_enabled = row.is_active(); jc.borrow_mut().convert_enabled = row.is_active();
}); });
} }
// Card grid selection
{ {
let jc = state.job_config.clone(); 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| { flow.connect_child_activated(move |_flow, child| {
let idx = child.index() as usize; let idx = child.index() as usize;
let mut c = jc.borrow_mut(); let mut c = jc.borrow_mut();
c.convert_format = match idx { c.convert_format = format_for_card_index(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,
};
label.set_label(&format_info(c.convert_format)); 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(); let jc = state.job_config.clone();
progressive_row.connect_active_notify(move |row| { progressive_row.connect_active_notify(move |row| {
jc.borrow_mut().progressive_jpeg = row.is_active(); 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)); scrolled.set_child(Some(&content));
let clamp = adw::Clamp::builder() let page = adw::NavigationPage::builder()
.maximum_size(600) .title("Convert")
.tag("step-convert")
.child(&scrolled) .child(&scrolled)
.build(); .build();
adw::NavigationPage::builder() // Rebuild format mapping rows when navigating to this page
.title("Convert") {
.tag("step-convert") let files = state.loaded_files.clone();
.child(&clamp) let list = mapping_list;
.build() 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 { fn format_info(format: Option<ImageFormat>) -> String {
match format { match format {
None => "Images will keep their original format. No conversion applied.".into(), 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::Jpeg) => "JPEG: Best for photographs. Lossy compression, \
Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. Lossless compression, supports full transparency. Produces larger files than JPEG or WebP.".into(), no transparency support. Universally compatible with all devices and browsers."
Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless compression. Supports transparency and animation. Widely supported in modern browsers.".into(), .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::Png) => "PNG: Best for graphics, screenshots, and logos. \
Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic animation and binary transparency. Best for simple graphics and short animations.".into(), Lossless compression, supports full transparency. Produces larger files \
Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, supports layers and rich metadata. Very large files. Not suitable for web.".into(), 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 std::rc::Rc;
use crate::app::AppState; use crate::app::AppState;
use crate::utils::format_size;
const THUMB_SIZE: i32 = 120; 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 { fn is_image_file(path: &std::path::Path) -> bool {
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) { 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, None => false,
} }
} }
@@ -295,7 +305,7 @@ fn refresh_grid(
} }
/// Walk the widget tree to find our ListStore and count label, then rebuild /// 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, widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>, loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>, excluded: &Rc<RefCell<HashSet<PathBuf>>>,
@@ -380,18 +390,6 @@ fn update_count_label(
update_heading_label(widget, count, included_count, &size_str); 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 // GObject wrapper for list store items
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -487,7 +485,7 @@ fn build_empty_state() -> gtk::Box {
.build(); .build();
let formats_label = gtk::Label::builder() 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"]) .css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.margin_top(8) .margin_top(8)
@@ -573,7 +571,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
// Factory: setup // Factory: setup
{ {
factory.connect_setup(move |_factory, list_item| { 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() let overlay = gtk::Overlay::builder()
.width_request(THUMB_SIZE) .width_request(THUMB_SIZE)
@@ -656,13 +654,13 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
let excluded = state.excluded_files.clone(); let excluded = state.excluded_files.clone();
let loaded = state.loaded_files.clone(); let loaded = state.loaded_files.clone();
factory.connect_bind(move |_factory, list_item| { factory.connect_bind(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 item = list_item.item().and_downcast::<ImageItem>().unwrap(); let Some(item) = list_item.item().and_downcast::<ImageItem>() else { return };
let path = item.path().to_path_buf(); let path = item.path().to_path_buf();
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap(); let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap(); let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
let name_label = overlay.next_sibling().and_downcast::<gtk::Label>().unwrap(); let Some(name_label) = overlay.next_sibling().and_downcast::<gtk::Label>() else { return };
// Set filename // Set filename
let file_name = path.file_name() let file_name = path.file_name()
@@ -671,19 +669,36 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
name_label.set_label(file_name); name_label.set_label(file_name);
// Get the frame -> stack -> picture // Get the frame -> stack -> picture
let frame = overlay.child().and_downcast::<gtk::Frame>().unwrap(); let Some(frame) = overlay.child().and_downcast::<gtk::Frame>() else { return };
let thumb_stack = frame.child().and_downcast::<gtk::Stack>().unwrap(); let Some(thumb_stack) = frame.child().and_downcast::<gtk::Stack>() else { return };
let picture = thumb_stack.child_by_name("picture") let Some(picture) = thumb_stack.child_by_name("picture")
.and_downcast::<gtk::Picture>().unwrap(); .and_downcast::<gtk::Picture>() else { return };
// Reset to placeholder // Reset to placeholder
thumb_stack.set_visible_child_name("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 // Load thumbnail asynchronously
let thumb_stack_c = thumb_stack.clone(); let thumb_stack_c = thumb_stack.clone();
let picture_c = picture.clone(); let picture_c = picture.clone();
let path_c = path.clone(); let path_c = path.clone();
glib::idle_add_local_once(move || { 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); 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: unbind - disconnect signal to avoid stale closures
{ {
factory.connect_unbind(move |_factory, list_item| { factory.connect_unbind(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 vbox = list_item.child().and_downcast::<gtk::Box>().unwrap(); let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap(); let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
if let Some(check) = find_check_button(overlay.upcast_ref::<gtk::Widget>()) { if let Some(check) = find_check_button(overlay.upcast_ref::<gtk::Widget>()) {
let handler: Option<glib::SignalHandlerId> = unsafe { 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(); let copyright_c = copyright_row.clone();
photographer_check.connect_toggled(move |check| { photographer_check.connect_toggled(move |check| {
if check.is_active() { if check.is_active() {
let mut cfg = jc.borrow_mut(); {
cfg.metadata_mode = MetadataMode::Custom; let mut cfg = jc.borrow_mut();
// Photographer: keep copyright + camera model, strip GPS + software cfg.metadata_mode = MetadataMode::Custom;
cfg.strip_gps = true; // Photographer: keep copyright + camera model, strip GPS + software
cfg.strip_camera = false; cfg.strip_gps = true;
cfg.strip_software = true; cfg.strip_camera = false;
cfg.strip_timestamps = false; cfg.strip_software = true;
cfg.strip_copyright = false; cfg.strip_timestamps = false;
// Update UI to match cfg.strip_copyright = false;
}
// Update UI to match (after dropping borrow to avoid re-entrancy)
gps_c.set_active(true); gps_c.set_active(true);
camera_c.set_active(false); camera_c.set_active(false);
software_c.set_active(true); software_c.set_active(true);
timestamps_c.set_active(false); timestamps_c.set_active(false);
copyright_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)); scrolled.set_child(Some(&content));
let clamp = adw::Clamp::builder()
.maximum_size(600)
.child(&scrolled)
.build();
adw::NavigationPage::builder() adw::NavigationPage::builder()
.title("Metadata") .title("Metadata")
.tag("step-metadata") .tag("step-metadata")
.child(&clamp) .child(&scrolled)
.build() .build()
} }

View File

@@ -1,5 +1,6 @@
use adw::prelude::*; use adw::prelude::*;
use crate::app::AppState; use crate::app::AppState;
use crate::utils::format_size;
pub fn build_output_page(state: &AppState) -> adw::NavigationPage { pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
let scrolled = gtk::ScrolledWindow::builder() let scrolled = gtk::ScrolledWindow::builder()
@@ -79,6 +80,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
let overwrite_row = adw::ComboRow::builder() let overwrite_row = adw::ComboRow::builder()
.title("Overwrite Behavior") .title("Overwrite Behavior")
.subtitle("What to do when output file already exists") .subtitle("What to do when output file already exists")
.use_subtitle(true)
.build(); .build();
let overwrite_model = gtk::StringList::new(&[ let overwrite_model = gtk::StringList::new(&[
"Ask before overwriting", "Ask before overwriting",
@@ -87,6 +89,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
"Skip existing files", "Skip existing files",
]); ]);
overwrite_row.set_model(Some(&overwrite_model)); 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_row.set_selected(cfg.overwrite_behavior as u32);
overwrite_group.add(&overwrite_row); overwrite_group.add(&overwrite_row);
@@ -137,26 +140,9 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
scrolled.set_child(Some(&content)); scrolled.set_child(Some(&content));
let clamp = adw::Clamp::builder()
.maximum_size(600)
.child(&scrolled)
.build();
adw::NavigationPage::builder() adw::NavigationPage::builder()
.title("Output & Process") .title("Output & Process")
.tag("step-output") .tag("step-output")
.child(&clamp) .child(&scrolled)
.build() .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 adw::prelude::*;
use gtk::glib;
use std::cell::Cell;
use std::rc::Rc;
use crate::app::AppState; use crate::app::AppState;
pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
let scrolled = gtk::ScrolledWindow::builder() let cfg = state.job_config.borrow();
.hscrollbar_policy(gtk::PolicyType::Never)
// === OUTER LAYOUT ===
let outer = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.vexpand(true) .vexpand(true)
.build(); .build();
let content = gtk::Box::builder() // --- Enable toggle (full width) ---
.orientation(gtk::Orientation::Vertical) let enable_group = adw::PreferencesGroup::builder()
.spacing(12) .margin_start(12)
.margin_end(12)
.margin_top(12) .margin_top(12)
.margin_bottom(12)
.margin_start(24)
.margin_end(24)
.build(); .build();
let cfg = state.job_config.borrow();
// Enable toggle
let enable_row = adw::SwitchRow::builder() let enable_row = adw::SwitchRow::builder()
.title("Enable Watermark") .title("Enable Watermark")
.subtitle("Add text or image watermark to processed images") .subtitle("Add text or image watermark to processed images")
.active(cfg.watermark_enabled) .active(cfg.watermark_enabled)
.build(); .build();
let enable_group = adw::PreferencesGroup::new();
enable_group.add(&enable_row); 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() let type_group = adw::PreferencesGroup::builder()
.title("Watermark Type") .title("Watermark Type")
.build(); .build();
@@ -37,17 +77,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
let type_row = adw::ComboRow::builder() let type_row = adw::ComboRow::builder()
.title("Type") .title("Type")
.subtitle("Choose text or image watermark") .subtitle("Choose text or image watermark")
.use_subtitle(true)
.build(); .build();
let type_model = gtk::StringList::new(&["Text Watermark", "Image Watermark"]); type_row.set_model(Some(&gtk::StringList::new(&["Text Watermark", "Image Watermark"])));
type_row.set_model(Some(&type_model)); type_row.set_list_factory(Some(&super::full_text_list_factory()));
type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 }); type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 });
type_group.add(&type_row); 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() let text_group = adw::PreferencesGroup::builder()
.title("Text Watermark") .title("Text Watermark")
.visible(!cfg.watermark_use_image)
.build(); .build();
let text_row = adw::EntryRow::builder() let text_row = adw::EntryRow::builder()
@@ -55,13 +97,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.text(&cfg.watermark_text) .text(&cfg.watermark_text)
.build(); .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() let font_row = adw::ActionRow::builder()
.title("Font Family") .title("Font Family")
.subtitle("Choose a typeface for the watermark text") .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) .valign(gtk::Align::Center)
.build(); .build();
// Set initial font if one was previously selected
if !cfg.watermark_font_family.is_empty() { if !cfg.watermark_font_family.is_empty() {
let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family); let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family);
font_button.set_font_desc(&desc); font_button.set_font_desc(&desc);
} }
font_row.add_suffix(&font_button); 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(&text_row);
text_group.add(&font_row); text_group.add(&font_row);
text_group.add(&font_size_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() let image_group = adw::PreferencesGroup::builder()
.title("Image Watermark") .title("Image Watermark")
.visible(cfg.watermark_use_image) .visible(cfg.watermark_use_image)
@@ -111,14 +150,14 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.icon_name("document-open-symbolic") .icon_name("document-open-symbolic")
.tooltip_text("Choose logo image") .tooltip_text("Choose logo image")
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.has_frame(false)
.build(); .build();
choose_image_button.add_css_class("flat");
image_path_row.add_suffix(&choose_image_button); image_path_row.add_suffix(&choose_image_button);
image_group.add(&image_path_row); 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() let position_group = adw::PreferencesGroup::builder()
.title("Position") .title("Position")
.description("Choose where the watermark appears on the image") .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", "Bottom Left", "Bottom Center", "Bottom Right",
]; ];
// Build a 3x3 grid of toggle buttons
let grid = gtk::Grid::builder() let grid = gtk::Grid::builder()
.row_spacing(4) .row_spacing(4)
.column_spacing(4) .column_spacing(4)
@@ -139,7 +177,12 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.margin_bottom(8) .margin_bottom(8)
.build(); .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() let grid_frame = gtk::Frame::builder()
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.build(); .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."), 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 mut first_button: Option<gtk::ToggleButton> = None;
let buttons: Vec<gtk::ToggleButton> = position_names.iter().enumerate().map(|(i, name)| { let buttons: Vec<gtk::ToggleButton> = position_names.iter().enumerate().map(|(i, name)| {
let btn = gtk::ToggleButton::builder() let btn = gtk::ToggleButton::builder()
@@ -156,7 +210,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.height_request(48) .height_request(48)
.build(); .build();
// Use a dot icon for each position
let icon = if i == cfg.watermark_position as usize { let icon = if i == cfg.watermark_position as usize {
"radio-checked-symbolic" "radio-checked-symbolic"
} else { } else {
@@ -178,163 +231,26 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
btn btn
}).collect(); }).collect();
position_group.add(&grid_frame); position_group.add(&grid_outer);
// Position label showing current selection
let position_label = gtk::Label::builder() 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"]) .css_classes(["dim-label"])
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.margin_bottom(4) .margin_bottom(4)
.build(); .build();
position_group.add(&position_label); position_group.add(&position_label);
content.append(&position_group); controls.append(&position_group);
// Live preview section // --- Advanced options ---
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
let advanced_group = adw::PreferencesGroup::builder() let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced") .title("Advanced")
.build(); .build();
let advanced_expander = adw::ExpanderRow::builder() let advanced_expander = adw::ExpanderRow::builder()
.title("Advanced Options") .title("Advanced Options")
.subtitle("Opacity, rotation, tiling, margin") .subtitle("Color, opacity, rotation, tiling, margin, scale")
.show_enable_switch(false) .show_enable_switch(false)
.expanded(state.is_section_expanded("watermark-advanced")) .expanded(state.is_section_expanded("watermark-advanced"))
.build(); .build();
@@ -369,37 +285,97 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.build(); .build();
color_row.add_suffix(&color_button); color_row.add_suffix(&color_button);
let opacity_row = adw::SpinRow::builder() // Opacity slider + reset
let opacity_row = adw::ActionRow::builder()
.title("Opacity") .title("Opacity")
.subtitle("0.0 (invisible) to 1.0 (fully opaque)") .subtitle(&format!("{}%", (cfg.watermark_opacity * 100.0).round() as i32))
.adjustment(&gtk::Adjustment::new(cfg.watermark_opacity as f64, 0.0, 1.0, 0.05, 0.1, 0.0))
.digits(2)
.build(); .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") .title("Rotation")
.subtitle("Rotate the watermark") .subtitle(&format!("{} degrees", cfg.watermark_rotation))
.build(); .build();
let rotation_model = gtk::StringList::new(&["None", "45 degrees", "-45 degrees", "90 degrees"]); let rotation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -180.0, 180.0, 1.0);
rotation_row.set_model(Some(&rotation_model)); 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() let tiled_row = adw::SwitchRow::builder()
.title("Tiled / Repeated") .title("Tiled / Repeated")
.subtitle("Repeat watermark across the entire image") .subtitle("Repeat watermark across the entire image")
.active(false) .active(cfg.watermark_tiled)
.build(); .build();
let margin_row = adw::SpinRow::builder() // Margin slider + reset
let margin_row = adw::ActionRow::builder()
.title("Margin from Edges") .title("Margin from Edges")
.subtitle("Padding in pixels from image edges") .subtitle(&format!("{} px", cfg.watermark_margin))
.adjustment(&gtk::Adjustment::new(10.0, 0.0, 200.0, 1.0, 10.0, 0.0))
.build(); .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)") .title("Scale (% of image)")
.subtitle("Watermark size relative to image") .subtitle(&format!("{}%", cfg.watermark_scale.round() as i32))
.adjustment(&gtk::Adjustment::new(20.0, 1.0, 100.0, 1.0, 5.0, 0.0)) .visible(cfg.watermark_use_image)
.build(); .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(&color_row);
advanced_expander.add_row(&opacity_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_expander.add_row(&scale_row);
advanced_group.add(&advanced_expander); 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); 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 jc = state.job_config.clone();
let up = update_preview.clone();
enable_row.connect_active_notify(move |row| { enable_row.connect_active_notify(move |row| {
jc.borrow_mut().watermark_enabled = row.is_active(); jc.borrow_mut().watermark_enabled = row.is_active();
up();
}); });
} }
// Type selector
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let text_group_c = text_group.clone(); let text_group_c = text_group.clone();
let image_group_c = image_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| { type_row.connect_selected_notify(move |row| {
let use_image = row.selected() == 1; let use_image = row.selected() == 1;
jc.borrow_mut().watermark_use_image = use_image; jc.borrow_mut().watermark_use_image = use_image;
text_group_c.set_visible(!use_image); text_group_c.set_visible(!use_image);
image_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 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| { text_row.connect_changed(move |row| {
let text = row.text().to_string(); let text = row.text().to_string();
wl.set_label(&text);
jc.borrow_mut().watermark_text = 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(); let jc = state.job_config.clone();
font_size_row.connect_value_notify(move |row| { let up = update_preview.clone();
jc.borrow_mut().watermark_font_size = row.value() as f32;
});
}
// Wire font family picker
{
let jc = state.job_config.clone();
font_button.connect_font_desc_notify(move |btn| { font_button.connect_font_desc_notify(move |btn| {
if let Some(desc) = btn.font_desc() { if let Some(desc) = btn.font_desc() {
if let Some(family) = desc.family() { if let Some(family) = desc.family() {
jc.borrow_mut().watermark_font_family = family.to_string(); 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() { for (i, btn) in buttons.iter().enumerate() {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let label = position_label.clone(); let label = position_label.clone();
let names = position_names; let names = position_names;
let all_buttons = buttons.clone(); let all_buttons = buttons.clone();
let wl = watermark_label.clone(); let up = update_preview.clone();
btn.connect_toggled(move |b| { btn.connect_toggled(move |b| {
if b.is_active() { if b.is_active() {
jc.borrow_mut().watermark_position = i as u32; jc.borrow_mut().watermark_position = i as u32;
label.set_label(names[i]); label.set_label(names[i]);
set_watermark_alignment(&wl, i as u32);
// Update icons
for (j, other) in all_buttons.iter().enumerate() { for (j, other) in all_buttons.iter().enumerate() {
let icon_name = if j == i { let icon_name = if j == i {
"radio-checked-symbolic" "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))); other.set_child(Some(&gtk::Image::from_icon_name(icon_name)));
} }
up();
} }
}); });
} }
// Color picker
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let wl = watermark_label.clone(); let up = update_preview.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();
color_button.connect_rgba_notify(move |btn| { color_button.connect_rgba_notify(move |btn| {
let c = btn.rgba(); let c = btn.rgba();
jc.borrow_mut().watermark_color = [ 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.blue() * 255.0) as u8,
(c.alpha() * 255.0) as u8, (c.alpha() * 255.0) as u8,
]; ];
up();
}); });
} }
// Wire tiled toggle
// Opacity slider
{ {
let jc = state.job_config.clone(); 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| { tiled_row.connect_active_notify(move |row| {
jc.borrow_mut().watermark_tiled = row.is_active(); jc.borrow_mut().watermark_tiled = row.is_active();
up();
}); });
} }
// Wire margin spinner
// Margin slider
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
margin_row.connect_value_notify(move |row| { let row = margin_row.clone();
jc.borrow_mut().watermark_margin = row.value() as u32; 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(); let jc = state.job_config.clone();
scale_row.connect_value_notify(move |row| { let row = scale_row.clone();
jc.borrow_mut().watermark_scale = row.value() as f32; 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 jc = state.job_config.clone();
let path_row = image_path_row.clone(); let path_row = image_path_row.clone();
let up = update_preview.clone();
choose_image_button.connect_clicked(move |btn| { choose_image_button.connect_clicked(move |btn| {
let jc = jc.clone(); let jc = jc.clone();
let path_row = path_row.clone(); let path_row = path_row.clone();
let up = up.clone();
let dialog = gtk::FileDialog::builder() let dialog = gtk::FileDialog::builder()
.title("Choose Watermark Image") .title("Choose Watermark Image")
.modal(true) .modal(true)
.build(); .build();
let filter = gtk::FileFilter::new(); 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/png");
filter.add_mime_type("image/svg+xml");
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>(); let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
filters.append(&filter); filters.append(&filter);
dialog.set_filters(Some(&filters)); 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()); path_row.set_subtitle(&path.display().to_string());
jc.borrow_mut().watermark_image_path = Some(path); jc.borrow_mut().watermark_image_path = Some(path);
up();
} }
}); });
} }
}); });
} }
scrolled.set_child(Some(&content)); let page = adw::NavigationPage::builder()
let clamp = adw::Clamp::builder()
.maximum_size(600)
.child(&scrolled)
.build();
adw::NavigationPage::builder()
.title("Watermark") .title("Watermark")
.tag("step-watermark") .tag("step-watermark")
.child(&clamp) .child(&outer)
.build() .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() let builtin_flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::Single) .selection_mode(gtk::SelectionMode::Single)
.max_children_per_line(4) .max_children_per_line(5)
.min_children_per_line(2) .min_children_per_line(2)
.row_spacing(8) .row_spacing(8)
.column_spacing(8) .column_spacing(8)
.homogeneous(true) .homogeneous(true)
.build(); .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(); let builtins = Preset::all_builtins();
for preset in &builtins { for preset in &builtins {
let card = build_preset_card(preset); let card = build_preset_card(preset);
builtin_flow.append(&card); builtin_flow.append(&card);
} }
// When a preset card is activated, apply it to JobConfig and advance // Custom workflow section (hidden until Custom card is selected)
{
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
let custom_group = adw::PreferencesGroup::builder() let custom_group = adw::PreferencesGroup::builder()
.title("Custom Workflow") .title("Custom Workflow")
.description("Choose which operations to include, then click Next") .description("Choose which operations to include, then click Next")
.visible(false)
.build(); .build();
let resize_check = adw::SwitchRow::builder() 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(&watermark_check);
custom_group.add(&rename_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 // Wire custom operation toggles to job config
{ {
let jc = state.job_config.clone(); 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 // User presets section
let user_group = adw::PreferencesGroup::builder() let user_group = adw::PreferencesGroup::builder()
.title("Your Presets") .title("Your Presets")
.description("Import or save your own workflows") .description("Import or save your own workflows")
.build(); .build();
// Show saved user presets // Container for dynamically-rebuilt user preset rows
let store = pixstrip_core::storage::PresetStore::new(); let user_rows_box = gtk::Box::builder()
if let Ok(presets) = store.list() { .orientation(gtk::Orientation::Vertical)
for preset in &presets { .spacing(0)
if !preset.is_custom { .build();
continue; user_group.add(&user_rows_box);
}
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);
}
}
let import_button = gtk::Button::builder() let import_button = gtk::Button::builder()
.label("Import Preset") .label("Import Preset")
@@ -243,14 +196,10 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
import_button.add_css_class("flat"); import_button.add_css_class("flat");
user_group.add(&import_button); user_group.add(&import_button);
content.append(&user_group); content.append(&user_group);
content.append(&custom_group);
scrolled.set_child(Some(&content)); scrolled.set_child(Some(&content));
let clamp = adw::Clamp::builder()
.maximum_size(800)
.child(&scrolled)
.build();
// Drop target for .pixstrip-preset files // Drop target for .pixstrip-preset files
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY); let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
let jc_drop = state.job_config.clone(); let jc_drop = state.job_config.clone();
@@ -269,13 +218,108 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
} }
false false
}); });
clamp.add_controller(drop_target); scrolled.add_controller(drop_target);
adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title("Choose a Workflow") .title("Choose a Workflow")
.tag("step-workflow") .tag("step-workflow")
.child(&clamp) .child(&scrolled)
.build() .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) { 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() let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(8) .spacing(8)
.halign(gtk::Align::Center) .hexpand(true)
.valign(gtk::Align::Start) .vexpand(false)
.build(); .build();
card.add_css_class("card"); card.add_css_class("card");
card.set_size_request(180, 120); card.set_size_request(180, 120);
@@ -391,10 +435,58 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
let inner = gtk::Box::builder() let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(4) .spacing(4)
.margin_top(16) .margin_top(6)
.margin_bottom(16) .margin_bottom(6)
.margin_start(12) .margin_start(8)
.margin_end(12) .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) .halign(gtk::Align::Center)
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.vexpand(true) .vexpand(true)
@@ -425,4 +517,3 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
card 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 { pub fn is_last_step(&self) -> bool {
self.current_step == self.total_steps - 1 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> { pub fn build_wizard_pages(state: &AppState) -> Vec<adw::NavigationPage> {