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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1938,6 +1938,7 @@ dependencies = [
|
||||
"image",
|
||||
"libadwaita",
|
||||
"pixstrip-core",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -6,6 +6,7 @@ use pixstrip_core::pipeline::ProcessingJob;
|
||||
use pixstrip_core::preset::Preset;
|
||||
use pixstrip_core::storage::{HistoryStore, PresetStore};
|
||||
use pixstrip_core::types::*;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -67,7 +68,7 @@ enum Commands {
|
||||
watermark_position: String,
|
||||
|
||||
/// Watermark opacity (0.0-1.0)
|
||||
#[arg(long, default_value = "0.5")]
|
||||
#[arg(long, default_value = "0.5", value_parser = parse_opacity)]
|
||||
watermark_opacity: f32,
|
||||
|
||||
/// Rename with prefix
|
||||
@@ -275,7 +276,10 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
.unwrap_or_else(|| input_dir.join("processed"));
|
||||
|
||||
let mut job = if let Some(ref name) = args.preset {
|
||||
let preset = find_preset(name);
|
||||
let preset = find_preset(name).unwrap_or_else(|| {
|
||||
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
|
||||
std::process::exit(1);
|
||||
});
|
||||
println!("Using preset: {}", preset.name);
|
||||
preset.to_job(&input_dir, &output_dir)
|
||||
} else {
|
||||
@@ -286,14 +290,12 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
if let Some(ref resize_str) = args.resize {
|
||||
job.resize = Some(parse_resize(resize_str));
|
||||
}
|
||||
if let Some(ref fmt_str) = args.format
|
||||
&& let Some(fmt) = parse_format(fmt_str)
|
||||
{
|
||||
if let Some(ref fmt_str) = args.format {
|
||||
let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1));
|
||||
job.convert = Some(ConvertConfig::SingleFormat(fmt));
|
||||
}
|
||||
if let Some(ref q_str) = args.quality
|
||||
&& let Some(preset) = parse_quality(q_str)
|
||||
{
|
||||
if let Some(ref q_str) = args.quality {
|
||||
let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1));
|
||||
job.compress = Some(CompressConfig::Preset(preset));
|
||||
}
|
||||
if args.strip_metadata {
|
||||
@@ -320,13 +322,28 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
});
|
||||
}
|
||||
if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() {
|
||||
if let Some(ref tmpl) = args.rename_template {
|
||||
if args.rename_prefix.is_some() || args.rename_suffix.is_some() {
|
||||
eprintln!("Warning: --rename-template overrides --rename-prefix/--rename-suffix");
|
||||
}
|
||||
if !tmpl.contains('{') {
|
||||
eprintln!("Warning: rename template '{}' has no placeholders. Use {{name}}, {{counter}}, {{ext}}, etc.", tmpl);
|
||||
}
|
||||
if !tmpl.contains("{ext}") && !tmpl.contains('.') {
|
||||
eprintln!("Warning: rename template has no {{ext}} or file extension - output files may lack extensions");
|
||||
}
|
||||
}
|
||||
job.rename = Some(RenameConfig {
|
||||
prefix: args.rename_prefix.unwrap_or_default(),
|
||||
suffix: args.rename_suffix.unwrap_or_default(),
|
||||
counter_start: 1,
|
||||
counter_padding: 3,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: args.rename_template,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
});
|
||||
@@ -336,13 +353,21 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
"catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom,
|
||||
"bilinear" => ResizeAlgorithm::Bilinear,
|
||||
"nearest" => ResizeAlgorithm::Nearest,
|
||||
_ => ResizeAlgorithm::Lanczos3,
|
||||
"lanczos3" | "lanczos" => ResizeAlgorithm::Lanczos3,
|
||||
other => {
|
||||
eprintln!("Warning: unknown algorithm '{}', using lanczos3. Valid: lanczos3, catmullrom, bilinear, nearest", other);
|
||||
ResizeAlgorithm::Lanczos3
|
||||
}
|
||||
};
|
||||
|
||||
job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() {
|
||||
"overwrite" | "always" => OverwriteBehavior::Overwrite,
|
||||
"skip" => OverwriteBehavior::Skip,
|
||||
_ => OverwriteBehavior::AutoRename,
|
||||
"overwrite" | "always" => OverwriteAction::Overwrite,
|
||||
"skip" => OverwriteAction::Skip,
|
||||
"auto-rename" | "autorename" | "rename" => OverwriteAction::AutoRename,
|
||||
other => {
|
||||
eprintln!("Warning: unknown overwrite mode '{}', using auto-rename. Valid: auto-rename, overwrite, skip", other);
|
||||
OverwriteAction::AutoRename
|
||||
}
|
||||
};
|
||||
|
||||
for file in &source_files {
|
||||
@@ -357,6 +382,7 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
"\r[{}/{}] {}...",
|
||||
update.current, update.total, update.current_file
|
||||
);
|
||||
let _ = std::io::stderr().flush();
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("\nProcessing failed: {}", e);
|
||||
@@ -395,20 +421,39 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
|
||||
// Save to history
|
||||
let history = HistoryStore::new();
|
||||
let output_ext = match job.convert {
|
||||
Some(ConvertConfig::SingleFormat(fmt)) => fmt.extension(),
|
||||
_ => "",
|
||||
};
|
||||
let output_files: Vec<String> = source_files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
output_dir
|
||||
.join(f.file_name().unwrap_or_default())
|
||||
.to_string_lossy()
|
||||
.into()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = if output_ext.is_empty() {
|
||||
f.extension().and_then(|e| e.to_str()).unwrap_or("jpg")
|
||||
} else {
|
||||
output_ext
|
||||
};
|
||||
let name = if let Some(ref rename) = job.rename {
|
||||
if let Some(ref tmpl) = rename.template {
|
||||
pixstrip_core::operations::rename::apply_template(
|
||||
tmpl, stem, ext, rename.counter_start.saturating_add(i as u32), None,
|
||||
)
|
||||
} else {
|
||||
rename.apply_simple(stem, ext, (i as u32).saturating_add(1))
|
||||
}
|
||||
} else {
|
||||
format!("{}.{}", stem, ext)
|
||||
};
|
||||
output_dir.join(name).to_string_lossy().into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = history.add(pixstrip_core::storage::HistoryEntry {
|
||||
if let Err(e) = history.add(pixstrip_core::storage::HistoryEntry {
|
||||
timestamp: chrono_timestamp(),
|
||||
input_dir: input_dir.to_string_lossy().into(),
|
||||
output_dir: output_dir.to_string_lossy().into(),
|
||||
input_dir: input_dir.canonicalize().unwrap_or_else(|_| input_dir.to_path_buf()).to_string_lossy().into(),
|
||||
output_dir: output_dir.canonicalize().unwrap_or_else(|_| output_dir.to_path_buf()).to_string_lossy().into(),
|
||||
preset_name: args.preset,
|
||||
total: result.total,
|
||||
succeeded: result.succeeded,
|
||||
@@ -417,7 +462,9 @@ fn cmd_process(args: CmdProcessArgs) {
|
||||
total_output_bytes: result.total_output_bytes,
|
||||
elapsed_ms: result.elapsed_ms,
|
||||
output_files,
|
||||
});
|
||||
}, 50, 30) {
|
||||
eprintln!("Warning: failed to save history (undo may not work): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_preset_list() {
|
||||
@@ -439,7 +486,10 @@ fn cmd_preset_list() {
|
||||
}
|
||||
|
||||
fn cmd_preset_export(name: &str, output: &str) {
|
||||
let preset = find_preset(name);
|
||||
let preset = find_preset(name).unwrap_or_else(|| {
|
||||
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
|
||||
std::process::exit(1);
|
||||
});
|
||||
let store = PresetStore::new();
|
||||
match store.export_to_file(&preset, &PathBuf::from(output)) {
|
||||
Ok(()) => println!("Exported '{}' to '{}'", name, output),
|
||||
@@ -499,18 +549,23 @@ fn cmd_history() {
|
||||
}
|
||||
|
||||
fn cmd_undo(count: usize) {
|
||||
if count == 0 {
|
||||
eprintln!("Must undo at least 1 batch");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let history = HistoryStore::new();
|
||||
match history.list() {
|
||||
Ok(entries) => {
|
||||
Ok(mut entries) => {
|
||||
if entries.is_empty() {
|
||||
println!("No processing history to undo.");
|
||||
return;
|
||||
}
|
||||
|
||||
let to_undo = entries.iter().rev().take(count);
|
||||
let undo_count = count.min(entries.len());
|
||||
let to_undo = entries.split_off(entries.len() - undo_count);
|
||||
let mut total_trashed = 0;
|
||||
|
||||
for entry in to_undo {
|
||||
for entry in &to_undo {
|
||||
if entry.output_files.is_empty() {
|
||||
println!(
|
||||
"Batch from {} has no recorded output files - cannot undo",
|
||||
@@ -527,7 +582,6 @@ fn cmd_undo(count: usize) {
|
||||
for file_path in &entry.output_files {
|
||||
let path = PathBuf::from(file_path);
|
||||
if path.exists() {
|
||||
// Move to OS trash using the trash crate
|
||||
match trash::delete(&path) {
|
||||
Ok(()) => {
|
||||
total_trashed += 1;
|
||||
@@ -540,6 +594,11 @@ fn cmd_undo(count: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove undone entries from history
|
||||
if let Err(e) = history.write_all(&entries) {
|
||||
eprintln!("Warning: failed to update history after undo: {}", e);
|
||||
}
|
||||
|
||||
println!("{} files moved to trash", total_trashed);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -551,12 +610,19 @@ fn cmd_undo(count: usize) {
|
||||
|
||||
fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
|
||||
// Verify the preset exists
|
||||
let _preset = find_preset(preset_name);
|
||||
if find_preset(preset_name).is_none() {
|
||||
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", preset_name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let watch_path = PathBuf::from(path);
|
||||
if !watch_path.exists() {
|
||||
eprintln!("Watch folder does not exist: {}", path);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if !watch_path.is_dir() {
|
||||
eprintln!("Watch path is not a directory: {}", path);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Save watch folder config
|
||||
let watch = pixstrip_core::watcher::WatchFolder {
|
||||
@@ -568,7 +634,8 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
|
||||
|
||||
// Store in config
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join("pixstrip");
|
||||
let watches_path = config_dir.join("watches.json");
|
||||
let mut watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
|
||||
@@ -587,15 +654,27 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
|
||||
}
|
||||
|
||||
watches.push(watch);
|
||||
let _ = std::fs::create_dir_all(&config_dir);
|
||||
let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap());
|
||||
if let Err(e) = std::fs::create_dir_all(&config_dir) {
|
||||
eprintln!("Warning: failed to create config directory: {}", e);
|
||||
}
|
||||
match serde_json::to_string_pretty(&watches) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&watches_path, json) {
|
||||
eprintln!("Warning: failed to write watch config: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to serialize watch config: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Added watch: {} -> preset '{}'", path, preset_name);
|
||||
}
|
||||
|
||||
fn cmd_watch_list() {
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join("pixstrip");
|
||||
let watches_path = config_dir.join("watches.json");
|
||||
|
||||
@@ -630,7 +709,8 @@ fn cmd_watch_list() {
|
||||
|
||||
fn cmd_watch_remove(path: &str) {
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join("pixstrip");
|
||||
let watches_path = config_dir.join("watches.json");
|
||||
|
||||
@@ -652,21 +732,33 @@ fn cmd_watch_remove(path: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap());
|
||||
if let Ok(json) = serde_json::to_string_pretty(&watches) {
|
||||
let _ = std::fs::write(&watches_path, json);
|
||||
}
|
||||
println!("Removed watch folder: {}", path);
|
||||
}
|
||||
|
||||
fn cmd_watch_start() {
|
||||
let config_dir = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| std::env::temp_dir())
|
||||
.join("pixstrip");
|
||||
let watches_path = config_dir.join("watches.json");
|
||||
|
||||
let watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
|
||||
std::fs::read_to_string(&watches_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
match std::fs::read_to_string(&watches_path) {
|
||||
Ok(content) => match serde_json::from_str(&content) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to parse watches.json: {}. Using empty config.", e);
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to read watches.json: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -700,9 +792,15 @@ fn cmd_watch_start() {
|
||||
match event {
|
||||
pixstrip_core::watcher::WatchEvent::NewImage(path) => {
|
||||
println!("New image: {}", path.display());
|
||||
// Find which watcher this came from and use its preset
|
||||
if let Some((_, preset_name)) = watchers.first() {
|
||||
let preset = find_preset(preset_name);
|
||||
// Find which watcher owns this path and use its preset
|
||||
let matched = active.iter()
|
||||
.find(|w| path.starts_with(&w.path))
|
||||
.map(|w| w.preset_name.clone());
|
||||
if let Some(preset_name) = matched.as_deref().or_else(|| watchers.first().map(|(_, n)| n.as_str())) {
|
||||
let Some(preset) = find_preset(preset_name) else {
|
||||
eprintln!(" Preset '{}' not found, skipping", preset_name);
|
||||
continue;
|
||||
};
|
||||
let input_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf();
|
||||
let output_dir = input_dir.join("processed");
|
||||
let mut job = preset.to_job(&input_dir, &output_dir);
|
||||
@@ -728,23 +826,29 @@ fn cmd_watch_start() {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn find_preset(name: &str) -> Preset {
|
||||
// Check builtins first
|
||||
fn find_preset(name: &str) -> Option<Preset> {
|
||||
// Check builtins first (case-insensitive)
|
||||
let lower = name.to_lowercase();
|
||||
for preset in Preset::all_builtins() {
|
||||
if preset.name.to_lowercase() == lower {
|
||||
return preset;
|
||||
return Some(preset);
|
||||
}
|
||||
}
|
||||
|
||||
// Check user presets
|
||||
// Check user presets - try exact match first, then case-insensitive
|
||||
let store = PresetStore::new();
|
||||
if let Ok(preset) = store.load(name) {
|
||||
return preset;
|
||||
return Some(preset);
|
||||
}
|
||||
if let Ok(presets) = store.list() {
|
||||
for preset in presets {
|
||||
if preset.name.to_lowercase() == lower {
|
||||
return Some(preset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
|
||||
std::process::exit(1);
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_resize(s: &str) -> ResizeConfig {
|
||||
@@ -757,12 +861,20 @@ fn parse_resize(s: &str) -> ResizeConfig {
|
||||
eprintln!("Invalid resize height: '{}'", h);
|
||||
std::process::exit(1);
|
||||
});
|
||||
if width == 0 || height == 0 {
|
||||
eprintln!("Resize dimensions must be greater than zero");
|
||||
std::process::exit(1);
|
||||
}
|
||||
ResizeConfig::Exact(Dimensions { width, height })
|
||||
} else {
|
||||
let width: u32 = s.parse().unwrap_or_else(|_| {
|
||||
eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s);
|
||||
std::process::exit(1);
|
||||
});
|
||||
if width == 0 {
|
||||
eprintln!("Resize width must be greater than zero");
|
||||
std::process::exit(1);
|
||||
}
|
||||
ResizeConfig::ByWidth(width)
|
||||
}
|
||||
}
|
||||
@@ -840,6 +952,14 @@ fn parse_watermark_position(s: &str) -> WatermarkPosition {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_opacity(s: &str) -> std::result::Result<f32, String> {
|
||||
let v: f32 = s.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
|
||||
if !(0.0..=1.0).contains(&v) {
|
||||
return Err("opacity must be between 0.0 and 1.0".into());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
@@ -872,3 +992,88 @@ fn chrono_timestamp() -> String {
|
||||
.unwrap_or_default();
|
||||
format!("{}", duration.as_secs())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_format_valid() {
|
||||
assert_eq!(parse_format("jpeg"), Some(ImageFormat::Jpeg));
|
||||
assert_eq!(parse_format("jpg"), Some(ImageFormat::Jpeg));
|
||||
assert_eq!(parse_format("PNG"), Some(ImageFormat::Png));
|
||||
assert_eq!(parse_format("webp"), Some(ImageFormat::WebP));
|
||||
assert_eq!(parse_format("avif"), Some(ImageFormat::Avif));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_format_invalid() {
|
||||
assert_eq!(parse_format("bmp"), None);
|
||||
assert_eq!(parse_format("xyz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_quality_valid() {
|
||||
assert_eq!(parse_quality("maximum"), Some(QualityPreset::Maximum));
|
||||
assert_eq!(parse_quality("max"), Some(QualityPreset::Maximum));
|
||||
assert_eq!(parse_quality("high"), Some(QualityPreset::High));
|
||||
assert_eq!(parse_quality("medium"), Some(QualityPreset::Medium));
|
||||
assert_eq!(parse_quality("med"), Some(QualityPreset::Medium));
|
||||
assert_eq!(parse_quality("low"), Some(QualityPreset::Low));
|
||||
assert_eq!(parse_quality("web"), Some(QualityPreset::WebOptimized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_quality_invalid() {
|
||||
assert_eq!(parse_quality("ultra"), None);
|
||||
assert_eq!(parse_quality(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_opacity_valid() {
|
||||
assert!(parse_opacity("0.5").is_ok());
|
||||
assert!(parse_opacity("0.0").is_ok());
|
||||
assert!(parse_opacity("1.0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_opacity_invalid() {
|
||||
assert!(parse_opacity("1.5").is_err());
|
||||
assert!(parse_opacity("-0.1").is_err());
|
||||
assert!(parse_opacity("abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_bytes_ranges() {
|
||||
assert_eq!(format_bytes(500), "500 B");
|
||||
assert_eq!(format_bytes(1024), "1.0 KB");
|
||||
assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
|
||||
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_duration_ranges() {
|
||||
assert_eq!(format_duration(500), "500ms");
|
||||
assert_eq!(format_duration(1500), "1.5s");
|
||||
assert_eq!(format_duration(90_000), "1m 30s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_preset_builtins() {
|
||||
assert!(find_preset("Blog Photos").is_some());
|
||||
assert!(find_preset("blog photos").is_some());
|
||||
assert!(find_preset("nonexistent preset xyz").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resize_width_only() {
|
||||
let config = parse_resize("1200");
|
||||
assert!(matches!(config, ResizeConfig::ByWidth(1200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_resize_exact() {
|
||||
let config = parse_resize("1200x900");
|
||||
assert!(matches!(config, ResizeConfig::Exact(Dimensions { width: 1200, height: 900 })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const IMAGE_EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp", "heic", "heif", "jxl",
|
||||
"svg", "ico",
|
||||
"jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp",
|
||||
];
|
||||
|
||||
fn is_image_extension(ext: &str) -> bool {
|
||||
|
||||
@@ -39,7 +39,7 @@ impl OutputEncoder {
|
||||
) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
ImageFormat::Jpeg => self.encode_jpeg(img, quality.unwrap_or(85)),
|
||||
ImageFormat::Png => self.encode_png(img),
|
||||
ImageFormat::Png => self.encode_png(img, quality.unwrap_or(3)),
|
||||
ImageFormat::WebP => self.encode_webp(img, quality.unwrap_or(80)),
|
||||
ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(80)),
|
||||
ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif),
|
||||
@@ -66,7 +66,7 @@ impl OutputEncoder {
|
||||
match format {
|
||||
ImageFormat::Jpeg => preset.jpeg_quality(),
|
||||
ImageFormat::WebP => preset.webp_quality() as u8,
|
||||
ImageFormat::Avif => preset.webp_quality() as u8,
|
||||
ImageFormat::Avif => preset.avif_quality() as u8,
|
||||
ImageFormat::Png => preset.png_level(),
|
||||
_ => preset.jpeg_quality(),
|
||||
}
|
||||
@@ -101,7 +101,10 @@ impl OutputEncoder {
|
||||
for y in 0..height {
|
||||
let start = y * row_stride;
|
||||
let end = start + row_stride;
|
||||
let _ = started.write_scanlines(&pixels[start..end]);
|
||||
started.write_scanlines(&pixels[start..end]).map_err(|e| PixstripError::Processing {
|
||||
operation: "jpeg_scanline".into(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
|
||||
started.finish().map_err(|e| PixstripError::Processing {
|
||||
@@ -112,7 +115,7 @@ impl OutputEncoder {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode_png(&self, img: &image::DynamicImage) -> Result<Vec<u8>> {
|
||||
fn encode_png(&self, img: &image::DynamicImage, level: u8) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let cursor = Cursor::new(&mut buf);
|
||||
let rgba = img.to_rgba8();
|
||||
@@ -129,12 +132,16 @@ impl OutputEncoder {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Insert pHYs chunk for DPI if requested
|
||||
// Insert pHYs chunk for DPI if requested (before oxipng, which preserves it)
|
||||
if self.options.output_dpi > 0 {
|
||||
buf = insert_png_phys_chunk(&buf, self.options.output_dpi);
|
||||
}
|
||||
|
||||
let optimized = oxipng::optimize_from_memory(&buf, &oxipng::Options::default())
|
||||
let mut opts = oxipng::Options::default();
|
||||
opts.optimize_alpha = true;
|
||||
opts.deflater = oxipng::Deflater::Libdeflater { compression: level.clamp(1, 12) };
|
||||
|
||||
let optimized = oxipng::optimize_from_memory(&buf, &opts)
|
||||
.map_err(|e| PixstripError::Processing {
|
||||
operation: "png_optimize".into(),
|
||||
reason: e.to_string(),
|
||||
@@ -156,7 +163,7 @@ impl OutputEncoder {
|
||||
let mut buf = Vec::new();
|
||||
let cursor = Cursor::new(&mut buf);
|
||||
let rgba = img.to_rgba8();
|
||||
let speed = self.options.avif_speed.clamp(1, 10);
|
||||
let speed = self.options.avif_speed.clamp(0, 10);
|
||||
let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(
|
||||
cursor,
|
||||
speed,
|
||||
@@ -226,6 +233,7 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec<u8> {
|
||||
// PNG structure: 8-byte signature, then chunks (each: 4 len + 4 type + data + 4 crc)
|
||||
let mut result = Vec::with_capacity(png_data.len() + phys_chunk.len());
|
||||
let mut pos = 8; // skip PNG signature
|
||||
let mut phys_inserted = false;
|
||||
result.extend_from_slice(&png_data[..8]);
|
||||
|
||||
while pos + 8 <= png_data.len() {
|
||||
@@ -235,16 +243,20 @@ fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec<u8> {
|
||||
let chunk_type = &png_data[pos + 4..pos + 8];
|
||||
let total_chunk_size = 4 + 4 + chunk_len + 4; // len + type + data + crc
|
||||
|
||||
if chunk_type == b"IDAT" || chunk_type == b"pHYs" {
|
||||
if chunk_type == b"IDAT" {
|
||||
// Insert pHYs before first IDAT
|
||||
result.extend_from_slice(&phys_chunk);
|
||||
if pos + total_chunk_size > png_data.len() {
|
||||
break;
|
||||
}
|
||||
// If existing pHYs, skip it (we're replacing it)
|
||||
|
||||
// Skip any existing pHYs (we're replacing it)
|
||||
if chunk_type == b"pHYs" {
|
||||
pos += total_chunk_size;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert our pHYs before the first IDAT
|
||||
if chunk_type == b"IDAT" && !phys_inserted {
|
||||
result.extend_from_slice(&phys_chunk);
|
||||
phys_inserted = true;
|
||||
}
|
||||
|
||||
result.extend_from_slice(&png_data[pos..pos + total_chunk_size]);
|
||||
|
||||
@@ -321,6 +321,79 @@ impl PipelineExecutor {
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Fast path: if no pixel processing needed (rename-only or rename+metadata),
|
||||
// just copy the file instead of decoding/re-encoding.
|
||||
if !job.needs_pixel_processing() {
|
||||
let output_path = if let Some(ref rename) = job.rename {
|
||||
let stem = source.path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
|
||||
let ext = source.path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||
if let Some(ref template) = rename.template {
|
||||
// Read dimensions without full decode for {width}/{height} templates
|
||||
let dims = image::ImageReader::open(&source.path)
|
||||
.ok()
|
||||
.and_then(|r| r.with_guessed_format().ok())
|
||||
.and_then(|r| r.into_dimensions().ok());
|
||||
let new_name = crate::operations::rename::apply_template_full(
|
||||
template, stem, ext,
|
||||
rename.counter_start.saturating_add(index as u32),
|
||||
dims, Some(ext), Some(&source.path), None,
|
||||
);
|
||||
let new_name = if rename.case_mode > 0 {
|
||||
if let Some(dot_pos) = new_name.rfind('.') {
|
||||
let (name_part, ext_part) = new_name.split_at(dot_pos);
|
||||
format!("{}{}", crate::operations::rename::apply_case_conversion(name_part, rename.case_mode), ext_part)
|
||||
} else {
|
||||
crate::operations::rename::apply_case_conversion(&new_name, rename.case_mode)
|
||||
}
|
||||
} else {
|
||||
new_name
|
||||
};
|
||||
job.output_dir.join(new_name)
|
||||
} else {
|
||||
let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1));
|
||||
job.output_dir.join(new_name)
|
||||
}
|
||||
} else {
|
||||
job.output_path_for(source, None)
|
||||
};
|
||||
|
||||
let output_path = match job.overwrite_behavior {
|
||||
crate::operations::OverwriteAction::Skip if output_path.exists() => {
|
||||
return Ok((input_size, 0));
|
||||
}
|
||||
crate::operations::OverwriteAction::AutoRename if output_path.exists() => {
|
||||
find_unique_path(&output_path)
|
||||
}
|
||||
_ => output_path,
|
||||
};
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||
}
|
||||
|
||||
std::fs::copy(&source.path, &output_path).map_err(PixstripError::Io)?;
|
||||
|
||||
// Metadata handling on the copy
|
||||
if let Some(ref meta_config) = job.metadata {
|
||||
match meta_config {
|
||||
crate::operations::MetadataConfig::KeepAll => {
|
||||
// Already a copy - metadata preserved
|
||||
}
|
||||
crate::operations::MetadataConfig::StripAll => {
|
||||
if !strip_all_metadata(&output_path) {
|
||||
eprintln!("Warning: failed to strip metadata from {}", output_path.display());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
strip_selective_metadata(&output_path, meta_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output_size = std::fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0);
|
||||
return Ok((input_size, output_size));
|
||||
}
|
||||
|
||||
// Load image
|
||||
let mut img = loader.load_pixels(&source.path)?;
|
||||
|
||||
@@ -404,7 +477,7 @@ impl PipelineExecutor {
|
||||
template,
|
||||
&working_stem,
|
||||
ext,
|
||||
rename.counter_start + index as u32,
|
||||
rename.counter_start.saturating_add(index as u32),
|
||||
dims,
|
||||
original_ext,
|
||||
Some(&source.path),
|
||||
@@ -423,7 +496,7 @@ impl PipelineExecutor {
|
||||
};
|
||||
job.output_dir.join(new_name)
|
||||
} else {
|
||||
let new_name = rename.apply_simple(stem, ext, index as u32 + 1);
|
||||
let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1));
|
||||
job.output_dir.join(new_name)
|
||||
}
|
||||
} else {
|
||||
@@ -432,21 +505,21 @@ impl PipelineExecutor {
|
||||
|
||||
// Handle overwrite behavior
|
||||
let output_path = match job.overwrite_behavior {
|
||||
crate::operations::OverwriteBehavior::Skip => {
|
||||
crate::operations::OverwriteAction::Skip => {
|
||||
if output_path.exists() {
|
||||
// Return 0 bytes written - file was skipped
|
||||
return Ok((input_size, 0));
|
||||
}
|
||||
output_path
|
||||
}
|
||||
crate::operations::OverwriteBehavior::AutoRename => {
|
||||
crate::operations::OverwriteAction::AutoRename => {
|
||||
if output_path.exists() {
|
||||
find_unique_path(&output_path)
|
||||
} else {
|
||||
output_path
|
||||
}
|
||||
}
|
||||
crate::operations::OverwriteBehavior::Overwrite => output_path,
|
||||
crate::operations::OverwriteAction::Overwrite => output_path,
|
||||
};
|
||||
|
||||
// Ensure output directory exists
|
||||
@@ -469,14 +542,18 @@ impl PipelineExecutor {
|
||||
if let Some(ref meta_config) = job.metadata {
|
||||
match meta_config {
|
||||
crate::operations::MetadataConfig::KeepAll => {
|
||||
copy_metadata_from_source(&source.path, &output_path);
|
||||
if !copy_metadata_from_source(&source.path, &output_path) {
|
||||
eprintln!("Warning: failed to copy metadata to {}", output_path.display());
|
||||
}
|
||||
}
|
||||
crate::operations::MetadataConfig::StripAll => {
|
||||
// Already stripped by re-encoding - nothing to do
|
||||
}
|
||||
_ => {
|
||||
// Privacy or Custom: copy all metadata back, then strip unwanted tags
|
||||
copy_metadata_from_source(&source.path, &output_path);
|
||||
if !copy_metadata_from_source(&source.path, &output_path) {
|
||||
eprintln!("Warning: failed to copy metadata to {}", output_path.display());
|
||||
}
|
||||
strip_selective_metadata(&output_path, meta_config);
|
||||
}
|
||||
}
|
||||
@@ -537,9 +614,9 @@ fn auto_orient_from_exif(
|
||||
2 => img.fliph(), // Flipped horizontal
|
||||
3 => img.rotate180(), // Rotated 180
|
||||
4 => img.flipv(), // Flipped vertical
|
||||
5 => img.fliph().rotate270(), // Transposed
|
||||
5 => img.rotate90().fliph(), // Transposed
|
||||
6 => img.rotate90(), // Rotated 90 CW
|
||||
7 => img.fliph().rotate90(), // Transverse
|
||||
7 => img.rotate270().fliph(), // Transverse
|
||||
8 => img.rotate270(), // Rotated 270 CW
|
||||
_ => img,
|
||||
}
|
||||
@@ -569,25 +646,28 @@ fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
.unwrap_or(0), ext))
|
||||
}
|
||||
|
||||
fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) {
|
||||
// Best-effort: try to copy EXIF from source to output using little_exif.
|
||||
// If it fails (e.g. non-JPEG, no EXIF), silently continue.
|
||||
fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) -> bool {
|
||||
let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(source) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let _: std::result::Result<(), std::io::Error> = metadata.write_to_file(output);
|
||||
metadata.write_to_file(output).is_ok()
|
||||
}
|
||||
|
||||
fn strip_all_metadata(path: &std::path::Path) -> bool {
|
||||
let empty = little_exif::metadata::Metadata::new();
|
||||
empty.write_to_file(path).is_ok()
|
||||
}
|
||||
|
||||
fn strip_selective_metadata(
|
||||
path: &std::path::Path,
|
||||
config: &crate::operations::MetadataConfig,
|
||||
) {
|
||||
) -> bool {
|
||||
use little_exif::exif_tag::ExifTag;
|
||||
use little_exif::metadata::Metadata;
|
||||
|
||||
// Read the metadata we just wrote back
|
||||
let Ok(source_meta) = Metadata::new_from_path(path) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Build a set of tag IDs to strip
|
||||
@@ -639,5 +719,5 @@ fn strip_selective_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
let _: std::result::Result<(), std::io::Error> = new_meta.write_to_file(path);
|
||||
new_meta.write_to_file(path).is_ok()
|
||||
}
|
||||
|
||||
@@ -76,6 +76,14 @@ pub fn regenerate_all() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sanitize a string for safe use in shell Exec= lines and XML command elements.
|
||||
/// Removes or replaces characters that could cause shell injection.
|
||||
fn shell_safe(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '(' | ')' | ','))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pixstrip_bin() -> String {
|
||||
// Try to find the pixstrip binary path
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
@@ -116,7 +124,7 @@ fn get_preset_names() -> Vec<String> {
|
||||
fn nautilus_extension_dir() -> PathBuf {
|
||||
let data = std::env::var("XDG_DATA_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.local/share", home)
|
||||
});
|
||||
PathBuf::from(data).join("nautilus-python").join("extensions")
|
||||
@@ -143,11 +151,12 @@ fn install_nautilus() -> Result<()> {
|
||||
\x20 item.connect('activate', self._on_preset, '{}', files)\n\
|
||||
\x20 submenu.append_item(item)\n\n",
|
||||
name.replace(' ', "_"),
|
||||
name,
|
||||
name,
|
||||
name.replace('\'', "\\'"),
|
||||
name.replace('\'', "\\'"),
|
||||
));
|
||||
}
|
||||
|
||||
let escaped_bin = bin.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
let script = format!(
|
||||
r#"import subprocess
|
||||
from gi.repository import Nautilus, GObject
|
||||
@@ -202,7 +211,7 @@ class PixstripExtension(GObject.GObject, Nautilus.MenuProvider):
|
||||
subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths)
|
||||
"#,
|
||||
preset_items = preset_items,
|
||||
bin = bin,
|
||||
bin = escaped_bin,
|
||||
);
|
||||
|
||||
std::fs::write(nautilus_extension_path(), script)?;
|
||||
@@ -222,7 +231,7 @@ fn uninstall_nautilus() -> Result<()> {
|
||||
fn nemo_action_dir() -> PathBuf {
|
||||
let data = std::env::var("XDG_DATA_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.local/share", home)
|
||||
});
|
||||
PathBuf::from(data).join("nemo").join("actions")
|
||||
@@ -261,12 +270,13 @@ fn install_nemo() -> Result<()> {
|
||||
"[Nemo Action]\n\
|
||||
Name=Pixstrip: {name}\n\
|
||||
Comment=Process with {name} preset\n\
|
||||
Exec={bin} --preset \"{name}\" --files %F\n\
|
||||
Exec={bin} --preset \"{safe_label}\" --files %F\n\
|
||||
Icon-Name=applications-graphics-symbolic\n\
|
||||
Selection=Any\n\
|
||||
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
||||
Mimetypes=image/*;\n",
|
||||
name = name,
|
||||
safe_label = shell_safe(name),
|
||||
bin = bin,
|
||||
);
|
||||
std::fs::write(action_path, action)?;
|
||||
@@ -300,7 +310,7 @@ fn uninstall_nemo() -> Result<()> {
|
||||
fn thunar_action_dir() -> PathBuf {
|
||||
let config = std::env::var("XDG_CONFIG_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.config", home)
|
||||
});
|
||||
PathBuf::from(config).join("Thunar")
|
||||
@@ -337,14 +347,15 @@ fn install_thunar() -> Result<()> {
|
||||
actions.push_str(&format!(
|
||||
" <action>\n\
|
||||
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
||||
\x20 <name>Pixstrip: {name}</name>\n\
|
||||
\x20 <command>{bin} --preset \"{name}\" --files %F</command>\n\
|
||||
\x20 <description>Process with {name} preset</description>\n\
|
||||
\x20 <name>Pixstrip: {xml_name}</name>\n\
|
||||
\x20 <command>{bin} --preset \"{safe_label}\" --files %F</command>\n\
|
||||
\x20 <description>Process with {xml_name} preset</description>\n\
|
||||
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
||||
\x20 <image-files/>\n\
|
||||
\x20 <directories/>\n\
|
||||
</action>\n",
|
||||
name = name,
|
||||
xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """),
|
||||
safe_label = shell_safe(name),
|
||||
bin = bin,
|
||||
));
|
||||
}
|
||||
@@ -367,7 +378,7 @@ fn uninstall_thunar() -> Result<()> {
|
||||
fn dolphin_service_dir() -> PathBuf {
|
||||
let data = std::env::var("XDG_DATA_HOME")
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||
format!("{}/.local/share", home)
|
||||
});
|
||||
PathBuf::from(data).join("kio").join("servicemenus")
|
||||
@@ -410,9 +421,10 @@ fn install_dolphin() -> Result<()> {
|
||||
"[Desktop Action Preset{i}]\n\
|
||||
Name={name}\n\
|
||||
Icon=applications-graphics-symbolic\n\
|
||||
Exec={bin} --preset \"{name}\" --files %F\n\n",
|
||||
Exec={bin} --preset \"{safe_label}\" --files %F\n\n",
|
||||
i = i,
|
||||
name = name,
|
||||
safe_label = shell_safe(name),
|
||||
bin = bin,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -57,21 +57,27 @@ pub fn apply_adjustments(
|
||||
|
||||
fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage {
|
||||
let (iw, ih) = (img.width(), img.height());
|
||||
if !w_ratio.is_finite() || !h_ratio.is_finite()
|
||||
|| w_ratio <= 0.0 || h_ratio <= 0.0
|
||||
|| iw == 0 || ih == 0
|
||||
{
|
||||
return img;
|
||||
}
|
||||
let target_ratio = w_ratio / h_ratio;
|
||||
let current_ratio = iw as f64 / ih as f64;
|
||||
|
||||
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
||||
// Image is wider than target, crop width
|
||||
let new_w = (ih as f64 * target_ratio) as u32;
|
||||
(new_w, ih)
|
||||
(new_w.min(iw), ih)
|
||||
} else {
|
||||
// Image is taller than target, crop height
|
||||
let new_h = (iw as f64 / target_ratio) as u32;
|
||||
(iw, new_h)
|
||||
(iw, new_h.min(ih))
|
||||
};
|
||||
|
||||
let x = (iw - crop_w) / 2;
|
||||
let y = (ih - crop_h) / 2;
|
||||
let x = iw.saturating_sub(crop_w) / 2;
|
||||
let y = ih.saturating_sub(crop_h) / 2;
|
||||
|
||||
img.crop_imm(x, y, crop_w, crop_h)
|
||||
}
|
||||
@@ -140,8 +146,8 @@ fn trim_whitespace(img: DynamicImage) -> DynamicImage {
|
||||
}
|
||||
}
|
||||
|
||||
let crop_w = right.saturating_sub(left) + 1;
|
||||
let crop_h = bottom.saturating_sub(top) + 1;
|
||||
let crop_w = right.saturating_sub(left).saturating_add(1);
|
||||
let crop_h = bottom.saturating_sub(top).saturating_add(1);
|
||||
|
||||
if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) {
|
||||
return img;
|
||||
@@ -151,6 +157,7 @@ fn trim_whitespace(img: DynamicImage) -> DynamicImage {
|
||||
}
|
||||
|
||||
fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage {
|
||||
let amount = amount.clamp(-100, 100);
|
||||
let mut rgba = img.into_rgba8();
|
||||
let factor = 1.0 + (amount as f64 / 100.0);
|
||||
|
||||
@@ -187,8 +194,8 @@ fn apply_sepia(img: DynamicImage) -> DynamicImage {
|
||||
|
||||
fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let new_w = w + padding * 2;
|
||||
let new_h = h + padding * 2;
|
||||
let new_w = w.saturating_add(padding.saturating_mul(2));
|
||||
let new_h = h.saturating_add(padding.saturating_mul(2));
|
||||
|
||||
let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255]));
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ pub fn strip_metadata(
|
||||
|
||||
fn strip_all_exif(input: &Path, output: &Path) -> Result<()> {
|
||||
let data = std::fs::read(input).map_err(PixstripError::Io)?;
|
||||
let cleaned = remove_exif_from_jpeg(&data);
|
||||
let cleaned = if data.len() >= 8 && &data[..8] == b"\x89PNG\r\n\x1a\n" {
|
||||
remove_metadata_from_png(&data)
|
||||
} else {
|
||||
remove_exif_from_jpeg(&data)
|
||||
};
|
||||
std::fs::write(output, cleaned).map_err(PixstripError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -60,7 +64,12 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec<u8> {
|
||||
// APP1 (0xE1) contains EXIF - skip it
|
||||
if marker == 0xE1 && i + 3 < data.len() {
|
||||
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
|
||||
i += 2 + len;
|
||||
let skip = 2 + len;
|
||||
if i + skip <= data.len() {
|
||||
i += skip;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -89,3 +98,41 @@ fn remove_exif_from_jpeg(data: &[u8]) -> Vec<u8> {
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn remove_metadata_from_png(data: &[u8]) -> Vec<u8> {
|
||||
// PNG: 8-byte signature + chunks (4 len + 4 type + data + 4 CRC)
|
||||
// Keep only rendering-essential chunks, strip textual/EXIF metadata.
|
||||
const KEEP_CHUNKS: &[&[u8; 4]] = &[
|
||||
b"IHDR", b"PLTE", b"IDAT", b"IEND",
|
||||
b"tRNS", b"gAMA", b"cHRM", b"sRGB", b"iCCP", b"sBIT",
|
||||
b"pHYs", b"bKGD", b"hIST", b"sPLT",
|
||||
b"acTL", b"fcTL", b"fdAT",
|
||||
];
|
||||
|
||||
if data.len() < 8 {
|
||||
return data.to_vec();
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(data.len());
|
||||
result.extend_from_slice(&data[..8]); // PNG signature
|
||||
|
||||
let mut pos = 8;
|
||||
while pos + 12 <= data.len() {
|
||||
let chunk_len = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
|
||||
let chunk_type = &data[pos + 4..pos + 8];
|
||||
let total = 12 + chunk_len; // 4 len + 4 type + data + 4 CRC
|
||||
|
||||
if pos + total > data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let keep = KEEP_CHUNKS.iter().any(|k| *k == chunk_type);
|
||||
if keep {
|
||||
result.extend_from_slice(&data[pos..pos + total]);
|
||||
}
|
||||
|
||||
pos += total;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -23,25 +23,43 @@ pub enum ResizeConfig {
|
||||
|
||||
impl ResizeConfig {
|
||||
pub fn target_for(&self, original: Dimensions) -> Dimensions {
|
||||
match self {
|
||||
if original.width == 0 || original.height == 0 {
|
||||
return original;
|
||||
}
|
||||
let result = match self {
|
||||
Self::ByWidth(w) => {
|
||||
if *w == 0 {
|
||||
return original;
|
||||
}
|
||||
let scale = *w as f64 / original.width as f64;
|
||||
Dimensions {
|
||||
width: *w,
|
||||
height: (original.height as f64 * scale).round() as u32,
|
||||
height: (original.height as f64 * scale).round().max(1.0) as u32,
|
||||
}
|
||||
}
|
||||
Self::ByHeight(h) => {
|
||||
if *h == 0 {
|
||||
return original;
|
||||
}
|
||||
let scale = *h as f64 / original.height as f64;
|
||||
Dimensions {
|
||||
width: (original.width as f64 * scale).round() as u32,
|
||||
width: (original.width as f64 * scale).round().max(1.0) as u32,
|
||||
height: *h,
|
||||
}
|
||||
}
|
||||
Self::FitInBox { max, allow_upscale } => {
|
||||
original.fit_within(*max, *allow_upscale)
|
||||
}
|
||||
Self::Exact(dims) => *dims,
|
||||
Self::Exact(dims) => {
|
||||
if dims.width == 0 || dims.height == 0 {
|
||||
return original;
|
||||
}
|
||||
*dims
|
||||
}
|
||||
};
|
||||
Dimensions {
|
||||
width: result.width.max(1),
|
||||
height: result.height.max(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +242,7 @@ pub enum WatermarkRotation {
|
||||
Degrees45,
|
||||
DegreesNeg45,
|
||||
Degrees90,
|
||||
Custom(f32),
|
||||
}
|
||||
|
||||
// --- Adjustments ---
|
||||
@@ -255,16 +274,16 @@ impl AdjustmentsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Overwrite Behavior ---
|
||||
// --- Overwrite Action (concrete action, no "Ask" variant) ---
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum OverwriteBehavior {
|
||||
pub enum OverwriteAction {
|
||||
AutoRename,
|
||||
Overwrite,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Default for OverwriteBehavior {
|
||||
impl Default for OverwriteAction {
|
||||
fn default() -> Self {
|
||||
Self::AutoRename
|
||||
}
|
||||
@@ -278,39 +297,85 @@ pub struct RenameConfig {
|
||||
pub suffix: String,
|
||||
pub counter_start: u32,
|
||||
pub counter_padding: u32,
|
||||
#[serde(default)]
|
||||
pub counter_enabled: bool,
|
||||
/// 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
||||
#[serde(default = "default_counter_position")]
|
||||
pub counter_position: u32,
|
||||
pub template: Option<String>,
|
||||
/// 0=none, 1=lowercase, 2=uppercase, 3=title case
|
||||
pub case_mode: u32,
|
||||
/// 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
||||
#[serde(default)]
|
||||
pub replace_spaces: u32,
|
||||
/// 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
||||
#[serde(default)]
|
||||
pub special_chars: u32,
|
||||
pub regex_find: String,
|
||||
pub regex_replace: String,
|
||||
}
|
||||
|
||||
fn default_counter_position() -> u32 { 3 }
|
||||
|
||||
impl RenameConfig {
|
||||
pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String {
|
||||
let counter = self.counter_start + index - 1;
|
||||
let counter_str = format!(
|
||||
"{:0>width$}",
|
||||
counter,
|
||||
width = self.counter_padding as usize
|
||||
);
|
||||
|
||||
// Apply regex find-and-replace on the original name
|
||||
// 1. Apply regex find-and-replace on the original name
|
||||
let working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace);
|
||||
|
||||
let mut name = String::new();
|
||||
if !self.prefix.is_empty() {
|
||||
name.push_str(&self.prefix);
|
||||
}
|
||||
name.push_str(&working_name);
|
||||
if !self.suffix.is_empty() {
|
||||
name.push_str(&self.suffix);
|
||||
}
|
||||
name.push('_');
|
||||
name.push_str(&counter_str);
|
||||
// 2. Apply space replacement
|
||||
let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
|
||||
|
||||
// Apply case conversion
|
||||
let name = rename::apply_case_conversion(&name, self.case_mode);
|
||||
// 3. Apply special character filtering
|
||||
let working_name = rename::apply_special_chars(&working_name, self.special_chars);
|
||||
|
||||
format!("{}.{}", name, extension)
|
||||
// 4. Build counter string
|
||||
let counter_str = if self.counter_enabled {
|
||||
let counter = self.counter_start.saturating_add(index.saturating_sub(1));
|
||||
let padding = (self.counter_padding as usize).min(10);
|
||||
format!("{:0>width$}", counter, width = padding)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let has_counter = self.counter_enabled && !counter_str.is_empty();
|
||||
|
||||
// 5. Assemble parts based on counter position
|
||||
// Positions: 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
||||
let mut result = String::new();
|
||||
|
||||
if has_counter && self.counter_position == 0 {
|
||||
result.push_str(&counter_str);
|
||||
result.push('_');
|
||||
}
|
||||
|
||||
result.push_str(&self.prefix);
|
||||
|
||||
if has_counter && self.counter_position == 1 {
|
||||
result.push_str(&counter_str);
|
||||
result.push('_');
|
||||
}
|
||||
|
||||
if has_counter && self.counter_position == 4 {
|
||||
result.push_str(&counter_str);
|
||||
} else {
|
||||
result.push_str(&working_name);
|
||||
}
|
||||
|
||||
if has_counter && self.counter_position == 2 {
|
||||
result.push('_');
|
||||
result.push_str(&counter_str);
|
||||
}
|
||||
|
||||
result.push_str(&self.suffix);
|
||||
|
||||
if has_counter && self.counter_position == 3 {
|
||||
result.push('_');
|
||||
result.push_str(&counter_str);
|
||||
}
|
||||
|
||||
// 6. Apply case conversion
|
||||
let result = rename::apply_case_conversion(&result, self.case_mode);
|
||||
|
||||
format!("{}.{}", result, extension)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,9 @@ fn days_to_ymd(total_days: u64) -> (u64, u64, u64) {
|
||||
let mut days = total_days;
|
||||
let mut year = 1970u64;
|
||||
loop {
|
||||
if year > 9999 {
|
||||
break;
|
||||
}
|
||||
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||
if days < days_in_year {
|
||||
break;
|
||||
@@ -149,6 +152,46 @@ fn is_leap(year: u64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
/// Apply space replacement on a filename
|
||||
/// mode: 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
||||
pub fn apply_space_replacement(name: &str, mode: u32) -> String {
|
||||
match mode {
|
||||
1 => name.replace(' ', "_"),
|
||||
2 => name.replace(' ', "-"),
|
||||
3 => name.replace(' ', "."),
|
||||
4 => {
|
||||
let mut result = String::with_capacity(name.len());
|
||||
let mut capitalize_next = false;
|
||||
for ch in name.chars() {
|
||||
if ch == ' ' {
|
||||
capitalize_next = true;
|
||||
} else if capitalize_next {
|
||||
result.extend(ch.to_uppercase());
|
||||
capitalize_next = false;
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
5 => name.replace(' ', ""),
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply special character filtering on a filename
|
||||
/// mode: 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
||||
pub fn apply_special_chars(name: &str, mode: u32) -> String {
|
||||
match mode {
|
||||
1 => name.chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')).collect(),
|
||||
2 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(),
|
||||
3 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(),
|
||||
4 => name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect(),
|
||||
5 => name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(),
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply case conversion to a filename (without extension)
|
||||
/// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case
|
||||
pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
|
||||
@@ -156,20 +199,25 @@ pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
|
||||
1 => name.to_lowercase(),
|
||||
2 => name.to_uppercase(),
|
||||
3 => {
|
||||
// Title case: capitalize first letter of each word (split on _ - space)
|
||||
name.split(|c: char| c == '_' || c == '-' || c == ' ')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
upper + &chars.as_str().to_lowercase()
|
||||
// Title case: capitalize first letter of each word, preserve original separators
|
||||
let mut result = String::with_capacity(name.len());
|
||||
let mut capitalize_next = true;
|
||||
for c in name.chars() {
|
||||
if c == '_' || c == '-' || c == ' ' {
|
||||
result.push(c);
|
||||
capitalize_next = true;
|
||||
} else if capitalize_next {
|
||||
for uc in c.to_uppercase() {
|
||||
result.push(uc);
|
||||
}
|
||||
None => String::new(),
|
||||
capitalize_next = false;
|
||||
} else {
|
||||
for lc in c.to_lowercase() {
|
||||
result.push(lc);
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("_")
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => name.to_string(),
|
||||
}
|
||||
@@ -180,7 +228,10 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
|
||||
if find.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
match regex::Regex::new(find) {
|
||||
match regex::RegexBuilder::new(find)
|
||||
.size_limit(1 << 16)
|
||||
.build()
|
||||
{
|
||||
Ok(re) => re.replace_all(name, replace).into_owned(),
|
||||
Err(_) => name.to_string(),
|
||||
}
|
||||
@@ -213,5 +264,9 @@ pub fn resolve_collision(path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
// Fallback - should never happen with 1000 attempts
|
||||
parent.join(format!("{}_{}.{}", stem, "overflow", ext))
|
||||
if ext.is_empty() {
|
||||
parent.join(format!("{}_overflow", stem))
|
||||
} else {
|
||||
parent.join(format!("{}_overflow.{}", stem, ext))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ pub fn calculate_position(
|
||||
|
||||
let center_x = iw.saturating_sub(ww) / 2;
|
||||
let center_y = ih.saturating_sub(wh) / 2;
|
||||
let right_x = iw.saturating_sub(ww + margin);
|
||||
let bottom_y = ih.saturating_sub(wh + margin);
|
||||
let right_x = iw.saturating_sub(ww).saturating_sub(margin);
|
||||
let bottom_y = ih.saturating_sub(wh).saturating_sub(margin);
|
||||
|
||||
match position {
|
||||
WatermarkPosition::TopLeft => (margin, margin),
|
||||
@@ -69,7 +69,7 @@ pub fn apply_watermark(
|
||||
if *tiled {
|
||||
apply_tiled_image_watermark(img, path, *opacity, *scale, *rotation, *margin)
|
||||
} else {
|
||||
apply_image_watermark(img, path, *position, *opacity, *scale, *rotation)
|
||||
apply_image_watermark(img, path, *position, *opacity, *scale, *rotation, *margin)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,22 +128,31 @@ fn find_system_font(family: Option<&str>) -> Result<Vec<u8>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively walk a directory and collect file paths
|
||||
/// Recursively walk a directory and collect file paths (max depth 5)
|
||||
fn walkdir(dir: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>> {
|
||||
walkdir_depth(dir, 5)
|
||||
}
|
||||
|
||||
fn walkdir_depth(dir: &std::path::Path, max_depth: u32) -> std::io::Result<Vec<std::path::PathBuf>> {
|
||||
const MAX_RESULTS: usize = 10_000;
|
||||
let mut results = Vec::new();
|
||||
if dir.is_dir() {
|
||||
if max_depth == 0 || !dir.is_dir() {
|
||||
return Ok(results);
|
||||
}
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
if results.len() >= MAX_RESULTS {
|
||||
break;
|
||||
}
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Ok(sub) = walkdir(&path) {
|
||||
if let Ok(sub) = walkdir_depth(&path, max_depth - 1) {
|
||||
results.extend(sub);
|
||||
}
|
||||
} else {
|
||||
results.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -156,8 +165,8 @@ fn render_text_to_image(
|
||||
opacity: f32,
|
||||
) -> image::RgbaImage {
|
||||
let scale = ab_glyph::PxScale::from(font_size);
|
||||
let text_width = (text.len() as f32 * font_size * 0.6) as u32 + 4;
|
||||
let text_height = (font_size * 1.4) as u32 + 4;
|
||||
let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192);
|
||||
let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096);
|
||||
|
||||
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||
@@ -167,6 +176,30 @@ fn render_text_to_image(
|
||||
buf
|
||||
}
|
||||
|
||||
/// Expand canvas so rotated content fits without clipping, then rotate
|
||||
fn rotate_on_expanded_canvas(img: &image::RgbaImage, radians: f32) -> DynamicImage {
|
||||
let w = img.width() as f32;
|
||||
let h = img.height() as f32;
|
||||
let cos = radians.abs().cos();
|
||||
let sin = radians.abs().sin();
|
||||
// Bounding box of rotated rectangle
|
||||
let new_w = (w * cos + h * sin).ceil() as u32 + 2;
|
||||
let new_h = (w * sin + h * cos).ceil() as u32 + 2;
|
||||
|
||||
// Place original in center of expanded transparent canvas
|
||||
let mut expanded = image::RgbaImage::new(new_w, new_h);
|
||||
let ox = (new_w.saturating_sub(img.width())) / 2;
|
||||
let oy = (new_h.saturating_sub(img.height())) / 2;
|
||||
image::imageops::overlay(&mut expanded, img, ox as i64, oy as i64);
|
||||
|
||||
imageproc::geometric_transformations::rotate_about_center(
|
||||
&expanded,
|
||||
radians,
|
||||
imageproc::geometric_transformations::Interpolation::Bilinear,
|
||||
Rgba([0, 0, 0, 0]),
|
||||
).into()
|
||||
}
|
||||
|
||||
/// Rotate an RGBA image by the given WatermarkRotation
|
||||
fn rotate_watermark_image(
|
||||
img: DynamicImage,
|
||||
@@ -175,20 +208,13 @@ fn rotate_watermark_image(
|
||||
match rotation {
|
||||
super::WatermarkRotation::Degrees90 => img.rotate90(),
|
||||
super::WatermarkRotation::Degrees45 => {
|
||||
imageproc::geometric_transformations::rotate_about_center(
|
||||
&img.to_rgba8(),
|
||||
std::f32::consts::FRAC_PI_4,
|
||||
imageproc::geometric_transformations::Interpolation::Bilinear,
|
||||
Rgba([0, 0, 0, 0]),
|
||||
).into()
|
||||
rotate_on_expanded_canvas(&img.to_rgba8(), std::f32::consts::FRAC_PI_4)
|
||||
}
|
||||
super::WatermarkRotation::DegreesNeg45 => {
|
||||
imageproc::geometric_transformations::rotate_about_center(
|
||||
&img.to_rgba8(),
|
||||
-std::f32::consts::FRAC_PI_4,
|
||||
imageproc::geometric_transformations::Interpolation::Bilinear,
|
||||
Rgba([0, 0, 0, 0]),
|
||||
).into()
|
||||
rotate_on_expanded_canvas(&img.to_rgba8(), -std::f32::consts::FRAC_PI_4)
|
||||
}
|
||||
super::WatermarkRotation::Custom(degrees) => {
|
||||
rotate_on_expanded_canvas(&img.to_rgba8(), degrees.to_radians())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +230,9 @@ fn apply_text_watermark(
|
||||
rotation: Option<super::WatermarkRotation>,
|
||||
margin_px: u32,
|
||||
) -> Result<DynamicImage> {
|
||||
if text.is_empty() {
|
||||
return Ok(img);
|
||||
}
|
||||
let font_data = find_system_font(font_family)?;
|
||||
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||
PixstripError::Processing {
|
||||
@@ -234,8 +263,8 @@ fn apply_text_watermark(
|
||||
} else {
|
||||
// No rotation - draw text directly (faster)
|
||||
let scale = ab_glyph::PxScale::from(font_size);
|
||||
let text_width = (text.len() as f32 * font_size * 0.6) as u32;
|
||||
let text_height = font_size as u32;
|
||||
let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192);
|
||||
let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096);
|
||||
let text_dims = Dimensions {
|
||||
width: text_width,
|
||||
height: text_height,
|
||||
@@ -266,6 +295,9 @@ fn apply_tiled_text_watermark(
|
||||
rotation: Option<super::WatermarkRotation>,
|
||||
margin: u32,
|
||||
) -> Result<DynamicImage> {
|
||||
if text.is_empty() {
|
||||
return Ok(img);
|
||||
}
|
||||
let font_data = find_system_font(font_family)?;
|
||||
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||
PixstripError::Processing {
|
||||
@@ -301,17 +333,17 @@ fn apply_tiled_text_watermark(
|
||||
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||
|
||||
let text_width = (text.len() as f32 * font_size * 0.6) as u32;
|
||||
let text_height = font_size as u32;
|
||||
let text_width = ((text.len().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as i64 + 4).min(8192);
|
||||
let text_height = ((font_size.min(1000.0) * 1.4) as i64 + 4).min(4096);
|
||||
|
||||
let mut y = spacing as i32;
|
||||
while y < ih as i32 {
|
||||
let mut x = spacing as i32;
|
||||
while x < iw as i32 {
|
||||
draw_text_mut(&mut rgba, draw_color, x, y, scale, &font, text);
|
||||
x += text_width as i32 + spacing as i32;
|
||||
let mut y = spacing as i64;
|
||||
while y < ih as i64 {
|
||||
let mut x = spacing as i64;
|
||||
while x < iw as i64 {
|
||||
draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text);
|
||||
x += text_width + spacing as i64;
|
||||
}
|
||||
y += text_height as i32 + spacing as i32;
|
||||
y += text_height + spacing as i64;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +422,7 @@ fn apply_image_watermark(
|
||||
opacity: f32,
|
||||
scale: f32,
|
||||
rotation: Option<super::WatermarkRotation>,
|
||||
margin: u32,
|
||||
) -> Result<DynamicImage> {
|
||||
let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing {
|
||||
operation: "watermark".into(),
|
||||
@@ -423,7 +456,6 @@ fn apply_image_watermark(
|
||||
height: watermark.height(),
|
||||
};
|
||||
|
||||
let margin = 10;
|
||||
let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
|
||||
|
||||
let mut base = img.into_rgba8();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct ProcessingJob {
|
||||
pub metadata: Option<MetadataConfig>,
|
||||
pub watermark: Option<WatermarkConfig>,
|
||||
pub rename: Option<RenameConfig>,
|
||||
pub overwrite_behavior: OverwriteBehavior,
|
||||
pub overwrite_behavior: OverwriteAction,
|
||||
pub preserve_directory_structure: bool,
|
||||
pub progressive_jpeg: bool,
|
||||
pub avif_speed: u8,
|
||||
@@ -44,11 +44,11 @@ impl ProcessingJob {
|
||||
metadata: None,
|
||||
watermark: None,
|
||||
rename: None,
|
||||
overwrite_behavior: OverwriteBehavior::default(),
|
||||
overwrite_behavior: OverwriteAction::default(),
|
||||
preserve_directory_structure: false,
|
||||
progressive_jpeg: false,
|
||||
avif_speed: 6,
|
||||
output_dpi: 72,
|
||||
output_dpi: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,18 @@ impl ProcessingJob {
|
||||
count
|
||||
}
|
||||
|
||||
/// Returns true if the job requires decoding/encoding pixel data.
|
||||
/// When false, we can use a fast copy-and-rename path.
|
||||
pub fn needs_pixel_processing(&self) -> bool {
|
||||
self.resize.is_some()
|
||||
|| matches!(self.rotation, Some(r) if !matches!(r, Rotation::None))
|
||||
|| matches!(self.flip, Some(f) if !matches!(f, Flip::None))
|
||||
|| self.adjustments.as_ref().is_some_and(|a| !a.is_noop())
|
||||
|| self.convert.is_some()
|
||||
|| self.compress.is_some()
|
||||
|| self.watermark.is_some()
|
||||
}
|
||||
|
||||
pub fn output_path_for(
|
||||
&self,
|
||||
source: &ImageSource,
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::pipeline::ProcessingJob;
|
||||
use crate::types::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Preset {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
@@ -20,6 +21,25 @@ pub struct Preset {
|
||||
pub rename: Option<RenameConfig>,
|
||||
}
|
||||
|
||||
impl Default for Preset {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
icon: "image-x-generic-symbolic".into(),
|
||||
is_custom: true,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
flip: None,
|
||||
convert: None,
|
||||
compress: None,
|
||||
metadata: None,
|
||||
watermark: None,
|
||||
rename: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Preset {
|
||||
pub fn to_job(
|
||||
&self,
|
||||
@@ -40,7 +60,7 @@ impl Preset {
|
||||
metadata: self.metadata.clone(),
|
||||
watermark: self.watermark.clone(),
|
||||
rename: self.rename.clone(),
|
||||
overwrite_behavior: crate::operations::OverwriteBehavior::default(),
|
||||
overwrite_behavior: crate::operations::OverwriteAction::default(),
|
||||
preserve_directory_structure: false,
|
||||
progressive_jpeg: false,
|
||||
avif_speed: 6,
|
||||
@@ -58,6 +78,7 @@ impl Preset {
|
||||
Self::builtin_photographer_export(),
|
||||
Self::builtin_archive_compress(),
|
||||
Self::builtin_fediverse_ready(),
|
||||
Self::builtin_print_ready(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -119,8 +140,12 @@ impl Preset {
|
||||
suffix: String::new(),
|
||||
counter_start: 1,
|
||||
counter_padding: 3,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: None,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
}),
|
||||
@@ -179,8 +204,12 @@ impl Preset {
|
||||
suffix: String::new(),
|
||||
counter_start: 1,
|
||||
counter_padding: 4,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: Some("{exif_date}_{name}_{counter:4}".into()),
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
}),
|
||||
@@ -204,6 +233,23 @@ impl Preset {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_print_ready() -> Preset {
|
||||
Preset {
|
||||
name: "Print Ready".into(),
|
||||
description: "Maximum quality, convert to PNG, keep all metadata".into(),
|
||||
icon: "printer-symbolic".into(),
|
||||
is_custom: false,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
flip: None,
|
||||
convert: Some(ConvertConfig::SingleFormat(ImageFormat::Png)),
|
||||
compress: Some(CompressConfig::Preset(QualityPreset::Maximum)),
|
||||
metadata: Some(MetadataConfig::KeepAll),
|
||||
watermark: None,
|
||||
rename: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_fediverse_ready() -> Preset {
|
||||
Preset {
|
||||
name: "Fediverse Ready".into(),
|
||||
|
||||
@@ -8,10 +8,19 @@ use crate::preset::Preset;
|
||||
|
||||
fn default_config_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("pixstrip")
|
||||
}
|
||||
|
||||
/// Write to a temporary file then rename, for crash safety.
|
||||
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, contents)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
@@ -54,7 +63,7 @@ impl PresetStore {
|
||||
let path = self.preset_path(&preset.name);
|
||||
let json = serde_json::to_string_pretty(preset)
|
||||
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
||||
std::fs::write(&path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn load(&self, name: &str) -> Result<Preset> {
|
||||
@@ -103,7 +112,7 @@ impl PresetStore {
|
||||
pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> {
|
||||
let json = serde_json::to_string_pretty(preset)
|
||||
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
||||
std::fs::write(path, json).map_err(PixstripError::Io)
|
||||
atomic_write(path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn import_from_file(&self, path: &Path) -> Result<Preset> {
|
||||
@@ -146,7 +155,7 @@ impl ConfigStore {
|
||||
}
|
||||
let json = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||
std::fs::write(&self.config_path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&self.config_path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<AppConfig> {
|
||||
@@ -215,7 +224,7 @@ impl SessionStore {
|
||||
}
|
||||
let json = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||
std::fs::write(&self.session_path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&self.session_path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<SessionState> {
|
||||
@@ -267,10 +276,11 @@ impl HistoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&self, entry: HistoryEntry) -> Result<()> {
|
||||
pub fn add(&self, entry: HistoryEntry, max_entries: usize, max_days: u32) -> Result<()> {
|
||||
let mut entries = self.list()?;
|
||||
entries.push(entry);
|
||||
self.write_all(&entries)
|
||||
self.write_all(&entries)?;
|
||||
self.prune(max_entries, max_days)
|
||||
}
|
||||
|
||||
pub fn prune(&self, max_entries: usize, max_days: u32) -> Result<()> {
|
||||
@@ -285,9 +295,9 @@ impl HistoryStore {
|
||||
.as_secs();
|
||||
let cutoff_secs = now_secs.saturating_sub(max_days as u64 * 86400);
|
||||
|
||||
// Remove entries older than max_days
|
||||
// Remove entries older than max_days (keep entries with unparseable timestamps)
|
||||
entries.retain(|e| {
|
||||
e.timestamp.parse::<u64>().unwrap_or(0) >= cutoff_secs
|
||||
e.timestamp.parse::<u64>().map_or(true, |ts| ts >= cutoff_secs)
|
||||
});
|
||||
|
||||
// Trim to max_entries (keep the most recent)
|
||||
@@ -314,13 +324,13 @@ impl HistoryStore {
|
||||
self.write_all(&Vec::<HistoryEntry>::new())
|
||||
}
|
||||
|
||||
fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> {
|
||||
pub fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> {
|
||||
if let Some(parent) = self.history_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(entries)
|
||||
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||
std::fs::write(&self.history_path, json).map_err(PixstripError::Io)
|
||||
atomic_write(&self.history_path, &json).map_err(PixstripError::Io)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,10 +66,17 @@ pub struct Dimensions {
|
||||
|
||||
impl Dimensions {
|
||||
pub fn aspect_ratio(&self) -> f64 {
|
||||
if self.height == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.width as f64 / self.height as f64
|
||||
}
|
||||
|
||||
pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions {
|
||||
if self.width == 0 || self.height == 0 || max.width == 0 || max.height == 0 {
|
||||
return self;
|
||||
}
|
||||
|
||||
if !allow_upscale && self.width <= max.width && self.height <= max.height {
|
||||
return self;
|
||||
}
|
||||
@@ -83,8 +90,8 @@ impl Dimensions {
|
||||
}
|
||||
|
||||
Dimensions {
|
||||
width: (self.width as f64 * scale).round() as u32,
|
||||
height: (self.height as f64 * scale).round() as u32,
|
||||
width: (self.width as f64 * scale).round().max(1.0) as u32,
|
||||
height: (self.height as f64 * scale).round().max(1.0) as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +142,36 @@ impl QualityPreset {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webp_effort(&self) -> u8 {
|
||||
match self {
|
||||
Self::Maximum => 6,
|
||||
Self::High => 5,
|
||||
Self::Medium => 4,
|
||||
Self::Low => 3,
|
||||
Self::WebOptimized => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn avif_quality(&self) -> u8 {
|
||||
match self {
|
||||
Self::Maximum => 80,
|
||||
Self::High => 63,
|
||||
Self::Medium => 50,
|
||||
Self::Low => 35,
|
||||
Self::WebOptimized => 40,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn avif_speed(&self) -> u8 {
|
||||
match self {
|
||||
Self::Maximum => 4,
|
||||
Self::High => 6,
|
||||
Self::Medium => 6,
|
||||
Self::Low => 8,
|
||||
Self::WebOptimized => 8,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Maximum => "Maximum",
|
||||
|
||||
145
pixstrip-core/tests/adjustments_tests.rs
Normal file
145
pixstrip-core/tests/adjustments_tests.rs
Normal 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);
|
||||
}
|
||||
@@ -112,9 +112,14 @@ fn execute_with_cancellation() {
|
||||
let executor = PipelineExecutor::with_cancel(cancel);
|
||||
let result = executor.execute(&job, |_| {}).unwrap();
|
||||
|
||||
// With immediate cancellation, fewer images should be processed
|
||||
assert!(result.succeeded + result.failed <= 2);
|
||||
assert!(result.cancelled);
|
||||
// Cancellation flag should be set
|
||||
assert!(result.cancelled, "result.cancelled should be true when cancel flag is set");
|
||||
// Total processed should be less than total sources (at least some skipped)
|
||||
assert!(
|
||||
result.succeeded + result.failed <= 2,
|
||||
"processed count ({}) should not exceed total (2)",
|
||||
result.succeeded + result.failed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,3 +42,84 @@ fn privacy_mode_strips_gps() {
|
||||
strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap();
|
||||
assert!(output.exists());
|
||||
}
|
||||
|
||||
fn create_test_png(path: &Path) {
|
||||
let img = image::RgbaImage::from_fn(100, 80, |x, y| {
|
||||
image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255])
|
||||
});
|
||||
img.save_with_format(path, image::ImageFormat::Png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_png_metadata_produces_valid_png() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.png");
|
||||
let output = dir.path().join("stripped.png");
|
||||
create_test_png(&input);
|
||||
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
assert!(output.exists());
|
||||
// Output must be a valid PNG that can be opened
|
||||
let img = image::open(&output).unwrap();
|
||||
assert_eq!(img.width(), 100);
|
||||
assert_eq!(img.height(), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_png_removes_text_chunks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.png");
|
||||
let output = dir.path().join("stripped.png");
|
||||
create_test_png(&input);
|
||||
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
// Read output and verify no tEXt chunks remain
|
||||
let data = std::fs::read(&output).unwrap();
|
||||
let mut pos = 8; // skip PNG signature
|
||||
while pos + 12 <= data.len() {
|
||||
let chunk_len = u32::from_be_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]) as usize;
|
||||
let chunk_type = &data[pos+4..pos+8];
|
||||
assert_ne!(chunk_type, b"tEXt", "tEXt chunk should be stripped");
|
||||
assert_ne!(chunk_type, b"iTXt", "iTXt chunk should be stripped");
|
||||
assert_ne!(chunk_type, b"zTXt", "zTXt chunk should be stripped");
|
||||
pos += 12 + chunk_len;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_png_output_smaller_or_equal() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.png");
|
||||
let output = dir.path().join("stripped.png");
|
||||
create_test_png(&input);
|
||||
|
||||
let input_size = std::fs::metadata(&input).unwrap().len();
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
let output_size = std::fs::metadata(&output).unwrap().len();
|
||||
assert!(output_size <= input_size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jpeg_removes_app1_exif() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input = dir.path().join("test.jpg");
|
||||
let output = dir.path().join("stripped.jpg");
|
||||
create_test_jpeg(&input);
|
||||
|
||||
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||
// Verify no APP1 (0xFFE1) markers remain
|
||||
let data = std::fs::read(&output).unwrap();
|
||||
let mut i = 2; // skip SOI
|
||||
while i + 1 < data.len() {
|
||||
if data[i] != 0xFF { break; }
|
||||
let marker = data[i + 1];
|
||||
if marker == 0xDA { break; } // SOS - rest is image data
|
||||
assert_ne!(marker, 0xE1, "APP1/EXIF marker should be stripped");
|
||||
if i + 3 < data.len() {
|
||||
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
|
||||
i += 2 + len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,12 @@ fn rename_config_simple_template() {
|
||||
suffix: String::new(),
|
||||
counter_start: 1,
|
||||
counter_padding: 3,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: None,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
};
|
||||
@@ -107,11 +111,101 @@ fn rename_config_with_suffix() {
|
||||
suffix: "_web".into(),
|
||||
counter_start: 1,
|
||||
counter_padding: 2,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: None,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
};
|
||||
let result = config.apply_simple("photo", "webp", 5);
|
||||
assert_eq!(result, "photo_web_05.webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_counter_overflow_saturates() {
|
||||
let config = RenameConfig {
|
||||
prefix: String::new(),
|
||||
suffix: String::new(),
|
||||
counter_start: u32::MAX,
|
||||
counter_padding: 1,
|
||||
counter_enabled: true,
|
||||
counter_position: 3,
|
||||
template: None,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
};
|
||||
// Should not panic - saturating arithmetic
|
||||
let result = config.apply_simple("photo", "jpg", u32::MAX);
|
||||
assert!(result.contains("photo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_config_custom_selective() {
|
||||
let config = MetadataConfig::Custom {
|
||||
strip_gps: true,
|
||||
strip_camera: false,
|
||||
strip_software: true,
|
||||
strip_timestamps: false,
|
||||
strip_copyright: false,
|
||||
};
|
||||
assert!(config.should_strip_gps());
|
||||
assert!(!config.should_strip_camera());
|
||||
assert!(!config.should_strip_copyright());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_config_custom_all_off() {
|
||||
let config = MetadataConfig::Custom {
|
||||
strip_gps: false,
|
||||
strip_camera: false,
|
||||
strip_software: false,
|
||||
strip_timestamps: false,
|
||||
strip_copyright: false,
|
||||
};
|
||||
assert!(!config.should_strip_gps());
|
||||
assert!(!config.should_strip_camera());
|
||||
assert!(!config.should_strip_copyright());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_config_custom_all_on() {
|
||||
let config = MetadataConfig::Custom {
|
||||
strip_gps: true,
|
||||
strip_camera: true,
|
||||
strip_software: true,
|
||||
strip_timestamps: true,
|
||||
strip_copyright: true,
|
||||
};
|
||||
assert!(config.should_strip_gps());
|
||||
assert!(config.should_strip_camera());
|
||||
assert!(config.should_strip_copyright());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_rotation_variants_exist() {
|
||||
let rotations = [
|
||||
WatermarkRotation::Degrees45,
|
||||
WatermarkRotation::DegreesNeg45,
|
||||
WatermarkRotation::Degrees90,
|
||||
WatermarkRotation::Custom(30.0),
|
||||
];
|
||||
assert_eq!(rotations.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_auto_orient_variant() {
|
||||
let rotation = Rotation::AutoOrient;
|
||||
assert!(matches!(rotation, Rotation::AutoOrient));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_action_default_is_auto_rename() {
|
||||
let default = OverwriteAction::default();
|
||||
assert!(matches!(default, OverwriteAction::AutoRename));
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ fn preset_serialization_roundtrip() {
|
||||
#[test]
|
||||
fn all_builtin_presets() {
|
||||
let presets = Preset::all_builtins();
|
||||
assert_eq!(presets.len(), 8);
|
||||
assert_eq!(presets.len(), 9);
|
||||
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"Blog Photos"));
|
||||
assert!(names.contains(&"Social Media"));
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use pixstrip_core::operations::rename::{apply_template, resolve_collision};
|
||||
use pixstrip_core::operations::rename::{
|
||||
apply_template, apply_regex_replace, apply_space_replacement,
|
||||
apply_special_chars, apply_case_conversion, resolve_collision,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn template_basic_variables() {
|
||||
@@ -93,3 +96,185 @@ fn no_collision_returns_same() {
|
||||
let resolved = resolve_collision(&path);
|
||||
assert_eq!(resolved, path);
|
||||
}
|
||||
|
||||
// --- Regex replace tests ---
|
||||
|
||||
#[test]
|
||||
fn regex_replace_basic() {
|
||||
let result = apply_regex_replace("hello_world", "_", "-");
|
||||
assert_eq!(result, "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_pattern() {
|
||||
let result = apply_regex_replace("IMG_20260307_001", r"\d{8}", "DATE");
|
||||
assert_eq!(result, "IMG_DATE_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_invalid_pattern_returns_original() {
|
||||
let result = apply_regex_replace("hello", "[invalid", "x");
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_empty_find_returns_original() {
|
||||
let result = apply_regex_replace("hello", "", "x");
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
// --- Space replacement tests ---
|
||||
|
||||
#[test]
|
||||
fn space_replacement_none() {
|
||||
assert_eq!(apply_space_replacement("hello world", 0), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_underscore() {
|
||||
assert_eq!(apply_space_replacement("hello world", 1), "hello_world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_hyphen() {
|
||||
assert_eq!(apply_space_replacement("hello world", 2), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_dot() {
|
||||
assert_eq!(apply_space_replacement("hello world", 3), "hello.world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_camelcase() {
|
||||
assert_eq!(apply_space_replacement("hello world", 4), "helloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_replacement_remove() {
|
||||
assert_eq!(apply_space_replacement("hello world", 5), "helloworld");
|
||||
}
|
||||
|
||||
// --- Special chars tests ---
|
||||
|
||||
#[test]
|
||||
fn special_chars_keep_all() {
|
||||
assert_eq!(apply_special_chars("file<name>.txt", 0), "file<name>.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars_filesystem_safe() {
|
||||
assert_eq!(apply_special_chars("file<name>", 1), "filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars_web_safe() {
|
||||
assert_eq!(apply_special_chars("file name!@#", 2), "filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chars_alphanumeric_only() {
|
||||
assert_eq!(apply_special_chars("file-name_123", 5), "filename123");
|
||||
}
|
||||
|
||||
// --- Case conversion tests ---
|
||||
|
||||
#[test]
|
||||
fn case_conversion_none() {
|
||||
assert_eq!(apply_case_conversion("Hello World", 0), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_lowercase() {
|
||||
assert_eq!(apply_case_conversion("Hello World", 1), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_uppercase() {
|
||||
assert_eq!(apply_case_conversion("Hello World", 2), "HELLO WORLD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_title_case() {
|
||||
assert_eq!(apply_case_conversion("hello world", 3), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_conversion_title_preserves_separators() {
|
||||
assert_eq!(apply_case_conversion("hello-world_foo", 3), "Hello-World_Foo");
|
||||
}
|
||||
|
||||
// --- RenameConfig::apply_simple edge cases ---
|
||||
|
||||
use pixstrip_core::operations::RenameConfig;
|
||||
|
||||
fn default_rename_config() -> RenameConfig {
|
||||
RenameConfig {
|
||||
prefix: String::new(),
|
||||
suffix: String::new(),
|
||||
counter_start: 1,
|
||||
counter_padding: 3,
|
||||
counter_enabled: false,
|
||||
counter_position: 3,
|
||||
template: None,
|
||||
case_mode: 0,
|
||||
replace_spaces: 0,
|
||||
special_chars: 0,
|
||||
regex_find: String::new(),
|
||||
regex_replace: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_no_changes() {
|
||||
let cfg = default_rename_config();
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_with_prefix_suffix() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.prefix = "web_".into();
|
||||
cfg.suffix = "_final".into();
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "web_photo_final.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_counter_after_suffix() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.counter_enabled = true;
|
||||
cfg.counter_position = 3;
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo_001.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_counter_replaces_name() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.counter_enabled = true;
|
||||
cfg.counter_position = 4;
|
||||
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "001.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_empty_name() {
|
||||
let cfg = default_rename_config();
|
||||
assert_eq!(cfg.apply_simple("", "png", 1), ".png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_case_lowercase() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.case_mode = 1;
|
||||
assert_eq!(cfg.apply_simple("MyPhoto", "JPG", 1), "myphoto.JPG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_simple_large_counter_padding_capped() {
|
||||
let mut cfg = default_rename_config();
|
||||
cfg.counter_enabled = true;
|
||||
cfg.counter_padding = 100; // should be capped to 10
|
||||
cfg.counter_position = 3;
|
||||
let result = cfg.apply_simple("photo", "jpg", 1);
|
||||
// Counter portion should be at most 10 digits
|
||||
assert!(result.len() <= 22); // "photo_" + 10 digits + ".jpg"
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ fn add_and_list_history_entries() {
|
||||
],
|
||||
};
|
||||
|
||||
history.add(entry.clone()).unwrap();
|
||||
history.add(entry.clone(), 50, 30).unwrap();
|
||||
let entries = history.list().unwrap();
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
@@ -236,7 +236,7 @@ fn history_appends_entries() {
|
||||
total_output_bytes: 500,
|
||||
elapsed_ms: 100,
|
||||
output_files: vec![],
|
||||
})
|
||||
}, 50, 30)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ fn clear_history() {
|
||||
total_output_bytes: 500,
|
||||
elapsed_ms: 100,
|
||||
output_files: vec![],
|
||||
})
|
||||
}, 50, 30)
|
||||
.unwrap();
|
||||
|
||||
history.clear().unwrap();
|
||||
|
||||
@@ -81,3 +81,39 @@ fn quality_preset_values() {
|
||||
assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality());
|
||||
assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_zero_height_aspect_ratio() {
|
||||
let dims = Dimensions { width: 1920, height: 0 };
|
||||
assert_eq!(dims.aspect_ratio(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_zero_both_aspect_ratio() {
|
||||
let dims = Dimensions { width: 0, height: 0 };
|
||||
assert_eq!(dims.aspect_ratio(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_within_zero_self_width() {
|
||||
let original = Dimensions { width: 0, height: 600 };
|
||||
let max_box = Dimensions { width: 1200, height: 1200 };
|
||||
let fitted = original.fit_within(max_box, false);
|
||||
assert_eq!(fitted, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_within_zero_self_height() {
|
||||
let original = Dimensions { width: 800, height: 0 };
|
||||
let max_box = Dimensions { width: 1200, height: 1200 };
|
||||
let fitted = original.fit_within(max_box, false);
|
||||
assert_eq!(fitted, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_fit_within_zero_max() {
|
||||
let original = Dimensions { width: 800, height: 600 };
|
||||
let max_box = Dimensions { width: 0, height: 0 };
|
||||
let fitted = original.fit_within(max_box, false);
|
||||
assert_eq!(fitted, original);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ fn watcher_detects_new_image() {
|
||||
watcher.start(&folder, tx).unwrap();
|
||||
|
||||
// Wait for watcher to be ready
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
// Create an image file
|
||||
let img_path = dir.path().join("new_photo.jpg");
|
||||
@@ -87,7 +87,7 @@ fn watcher_ignores_non_image_files() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
watcher.start(&folder, tx).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
// Create a non-image file
|
||||
std::fs::write(dir.path().join("readme.txt"), b"text file").unwrap();
|
||||
|
||||
@@ -109,3 +109,137 @@ fn position_bottom_left() {
|
||||
assert_eq!(x, 10);
|
||||
assert_eq!(y, 1020);
|
||||
}
|
||||
|
||||
// --- Margin variation tests ---
|
||||
|
||||
#[test]
|
||||
fn margin_zero_top_left() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::TopLeft,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_zero_bottom_right() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 1720); // 1920 - 200
|
||||
assert_eq!(y, 1030); // 1080 - 50
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_margin_top_left() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::TopLeft,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
100,
|
||||
);
|
||||
assert_eq!(x, 100);
|
||||
assert_eq!(y, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_margin_bottom_right() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
100,
|
||||
);
|
||||
assert_eq!(x, 1620); // 1920 - 200 - 100
|
||||
assert_eq!(y, 930); // 1080 - 50 - 100
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_does_not_affect_center() {
|
||||
let (x1, y1) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
0,
|
||||
);
|
||||
let (x2, y2) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 1920, height: 1080 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
100,
|
||||
);
|
||||
assert_eq!(x1, x2);
|
||||
assert_eq!(y1, y2);
|
||||
}
|
||||
|
||||
// --- Edge case tests ---
|
||||
|
||||
#[test]
|
||||
fn watermark_larger_than_image() {
|
||||
// Watermark is bigger than the image - should not panic, saturating_sub clamps to 0
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 100, height: 100 },
|
||||
Dimensions { width: 200, height: 200 },
|
||||
10,
|
||||
);
|
||||
// (100 - 200 - 10) saturates to 0
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_exact_image_size() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 200, height: 100 },
|
||||
Dimensions { width: 200, height: 100 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_size_image() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::Center,
|
||||
Dimensions { width: 0, height: 0 },
|
||||
Dimensions { width: 200, height: 50 },
|
||||
10,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_exceeds_available_space() {
|
||||
// Margin is huge relative to image size
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::BottomRight,
|
||||
Dimensions { width: 100, height: 100 },
|
||||
Dimensions { width: 50, height: 50 },
|
||||
200,
|
||||
);
|
||||
// saturating_sub: 100 - 50 - 200 = 0
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pixel_image() {
|
||||
let (x, y) = calculate_position(
|
||||
WatermarkPosition::TopLeft,
|
||||
Dimensions { width: 1, height: 1 },
|
||||
Dimensions { width: 1, height: 1 },
|
||||
0,
|
||||
);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(y, 0);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ pixstrip-core = { workspace = true }
|
||||
gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] }
|
||||
adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] }
|
||||
image = "0.25"
|
||||
regex = "1"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ mod settings;
|
||||
mod step_indicator;
|
||||
mod steps;
|
||||
mod tutorial;
|
||||
pub(crate) mod utils;
|
||||
mod welcome;
|
||||
mod wizard;
|
||||
|
||||
|
||||
@@ -88,15 +88,10 @@ pub fn build_processing_page() -> adw::NavigationPage {
|
||||
content.append(&log_group);
|
||||
content.append(&button_box);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&content)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Processing")
|
||||
.tag("processing")
|
||||
.child(&clamp)
|
||||
.child(&content)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -232,14 +227,9 @@ pub fn build_results_page() -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Results")
|
||||
.tag("results")
|
||||
.child(&clamp)
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::Cell;
|
||||
use pixstrip_core::config::{AppConfig, ErrorBehavior, OverwriteBehavior, SkillLevel};
|
||||
use pixstrip_core::storage::ConfigStore;
|
||||
|
||||
@@ -8,7 +9,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.build();
|
||||
|
||||
let config_store = ConfigStore::new();
|
||||
let config = config_store.load().unwrap_or_default();
|
||||
let config = match config_store.load() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to load config, using defaults: {}", e);
|
||||
AppConfig::default()
|
||||
}
|
||||
};
|
||||
|
||||
// General page
|
||||
let general_page = adw::PreferencesPage::builder()
|
||||
@@ -24,12 +31,14 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let output_mode_row = adw::ComboRow::builder()
|
||||
.title("Default output location")
|
||||
.subtitle("Where processed images are saved by default")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let output_mode_model = gtk::StringList::new(&[
|
||||
"Subfolder next to originals",
|
||||
"Fixed output folder",
|
||||
]);
|
||||
output_mode_row.set_model(Some(&output_mode_model));
|
||||
output_mode_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
output_mode_row.set_selected(if config.output_fixed_path.is_some() { 1 } else { 0 });
|
||||
|
||||
let subfolder_row = adw::EntryRow::builder()
|
||||
@@ -101,6 +110,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let overwrite_row = adw::ComboRow::builder()
|
||||
.title("Default overwrite behavior")
|
||||
.subtitle("What to do when output files already exist")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let overwrite_model = gtk::StringList::new(&[
|
||||
"Ask before overwriting",
|
||||
@@ -109,6 +119,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
"Skip existing files",
|
||||
]);
|
||||
overwrite_row.set_model(Some(&overwrite_model));
|
||||
overwrite_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
overwrite_row.set_selected(match config.overwrite_behavior {
|
||||
OverwriteBehavior::Ask => 0,
|
||||
OverwriteBehavior::AutoRename => 1,
|
||||
@@ -136,9 +147,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let skill_row = adw::ComboRow::builder()
|
||||
.title("Detail level")
|
||||
.subtitle("Controls how many options are visible by default")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let skill_model = gtk::StringList::new(&["Simple", "Detailed"]);
|
||||
skill_row.set_model(Some(&skill_model));
|
||||
skill_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
skill_row.set_selected(match config.skill_level {
|
||||
SkillLevel::Simple => 0,
|
||||
SkillLevel::Detailed => 1,
|
||||
@@ -188,11 +201,22 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.build();
|
||||
|
||||
let fm_copy = *fm;
|
||||
let reverting = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let reverting_clone = reverting.clone();
|
||||
row.connect_active_notify(move |row| {
|
||||
if row.is_active() {
|
||||
let _ = fm_copy.install();
|
||||
if reverting_clone.get() {
|
||||
return;
|
||||
}
|
||||
let result = if row.is_active() {
|
||||
fm_copy.install()
|
||||
} else {
|
||||
let _ = fm_copy.uninstall();
|
||||
fm_copy.uninstall()
|
||||
};
|
||||
if let Err(e) = result {
|
||||
eprintln!("File manager integration error for {}: {}", fm_copy.name(), e);
|
||||
reverting_clone.set(true);
|
||||
row.set_active(!row.is_active());
|
||||
reverting_clone.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,9 +255,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let threads_row = adw::ComboRow::builder()
|
||||
.title("Processing threads")
|
||||
.subtitle("Auto uses all available CPU cores")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]);
|
||||
threads_row.set_model(Some(&threads_model));
|
||||
threads_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
threads_row.set_selected(match config.thread_count {
|
||||
pixstrip_core::config::ThreadCount::Auto => 0,
|
||||
pixstrip_core::config::ThreadCount::Manual(1) => 1,
|
||||
@@ -245,9 +271,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let error_row = adw::ComboRow::builder()
|
||||
.title("On error")
|
||||
.subtitle("What to do when an image fails to process")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let error_model = gtk::StringList::new(&["Skip and continue", "Pause on error"]);
|
||||
error_row.set_model(Some(&error_model));
|
||||
error_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
error_row.set_selected(match config.error_behavior {
|
||||
ErrorBehavior::SkipAndContinue => 0,
|
||||
ErrorBehavior::PauseOnError => 1,
|
||||
@@ -287,6 +315,53 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.active(config.reduced_motion)
|
||||
.build();
|
||||
|
||||
// Wire high contrast to apply immediately
|
||||
{
|
||||
contrast_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
if row.is_active() {
|
||||
settings.set_gtk_theme_name(Some("HighContrast"));
|
||||
} else {
|
||||
// Revert to the default Adwaita theme
|
||||
settings.set_gtk_theme_name(Some("Adwaita"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire large text to apply immediately
|
||||
{
|
||||
let original_dpi: std::rc::Rc<Cell<i32>> = std::rc::Rc::new(Cell::new(0));
|
||||
let orig_dpi = original_dpi.clone();
|
||||
large_text_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
if row.is_active() {
|
||||
// Store original DPI before modifying
|
||||
let current_dpi = settings.gtk_xft_dpi();
|
||||
if current_dpi > 0 {
|
||||
orig_dpi.set(current_dpi);
|
||||
settings.set_gtk_xft_dpi(current_dpi * 5 / 4);
|
||||
}
|
||||
} else {
|
||||
// Restore the original DPI (only if we actually changed it)
|
||||
let saved = orig_dpi.get();
|
||||
if saved > 0 {
|
||||
settings.set_gtk_xft_dpi(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire reduced motion to apply immediately
|
||||
{
|
||||
motion_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
settings.set_gtk_enable_animations(!row.is_active());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
a11y_group.add(&contrast_row);
|
||||
a11y_group.add(&large_text_row);
|
||||
a11y_group.add(&motion_row);
|
||||
@@ -443,6 +518,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let auto_open = auto_open_row.clone();
|
||||
let output_mode = output_mode_row.clone();
|
||||
let fps_reset = fixed_path_state.clone();
|
||||
let wfs_reset = watch_folders_state.clone();
|
||||
let wl_reset = watch_list.clone();
|
||||
let el_reset = empty_label.clone();
|
||||
reset_button.connect_clicked(move |_| {
|
||||
let defaults = AppConfig::default();
|
||||
subfolder.set_text(&defaults.output_subfolder);
|
||||
@@ -459,6 +537,10 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
auto_open.set_active(defaults.auto_open_output);
|
||||
output_mode.set_selected(0);
|
||||
*fps_reset.borrow_mut() = None;
|
||||
// Clear watch folders
|
||||
wfs_reset.borrow_mut().clear();
|
||||
wl_reset.remove_all();
|
||||
el_reset.set_visible(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,11 +621,13 @@ fn build_watch_folder_row(
|
||||
let preset_row = adw::ComboRow::builder()
|
||||
.title("Linked Preset")
|
||||
.subtitle("Preset to apply to new images")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let preset_model = gtk::StringList::new(
|
||||
&preset_names.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
||||
);
|
||||
preset_row.set_model(Some(&preset_model));
|
||||
preset_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
|
||||
// Set selected to matching preset
|
||||
let selected_idx = preset_names.iter()
|
||||
|
||||
@@ -5,7 +5,10 @@ use std::cell::RefCell;
|
||||
#[derive(Clone)]
|
||||
pub struct StepIndicator {
|
||||
container: gtk::Box,
|
||||
grid: gtk::Grid,
|
||||
dots: RefCell<Vec<StepDot>>,
|
||||
/// Maps visual index -> actual step index
|
||||
step_map: RefCell<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -27,65 +30,23 @@ impl StepIndicator {
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
// Prevent negative allocation warnings when window is narrow
|
||||
container.set_overflow(gtk::Overflow::Hidden);
|
||||
|
||||
container.update_property(&[
|
||||
gtk::accessible::Property::Label("Wizard step indicator"),
|
||||
]);
|
||||
|
||||
let mut dots = Vec::new();
|
||||
|
||||
for (i, name) in step_names.iter().enumerate() {
|
||||
if i > 0 {
|
||||
// Connector line between dots
|
||||
let line = gtk::Separator::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
let grid = gtk::Grid::builder()
|
||||
.column_homogeneous(false)
|
||||
.row_spacing(2)
|
||||
.column_spacing(0)
|
||||
.hexpand(false)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
line.set_size_request(12, -1);
|
||||
container.append(&line);
|
||||
}
|
||||
|
||||
let dot_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(2)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("radio-symbolic")
|
||||
.pixel_size(16)
|
||||
.build();
|
||||
let indices: Vec<usize> = (0..step_names.len()).collect();
|
||||
let dots = Self::build_dots(&grid, step_names, &indices);
|
||||
|
||||
let button = gtk::Button::builder()
|
||||
.child(&icon)
|
||||
.has_frame(false)
|
||||
.tooltip_text(format!("Step {}: {} (Alt+{})", i + 1, name, i + 1))
|
||||
.sensitive(false)
|
||||
.action_name("win.goto-step")
|
||||
.action_target(&(i as i32 + 1).to_variant())
|
||||
.build();
|
||||
button.add_css_class("circular");
|
||||
|
||||
let label = gtk::Label::builder()
|
||||
.label(name)
|
||||
.css_classes(["caption"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(8)
|
||||
.build();
|
||||
|
||||
dot_box.append(&button);
|
||||
dot_box.append(&label);
|
||||
container.append(&dot_box);
|
||||
|
||||
dots.push(StepDot {
|
||||
button,
|
||||
icon,
|
||||
label,
|
||||
});
|
||||
}
|
||||
container.append(&grid);
|
||||
|
||||
// First step starts as current
|
||||
if let Some(first) = dots.first() {
|
||||
@@ -96,22 +57,95 @@ impl StepIndicator {
|
||||
|
||||
Self {
|
||||
container,
|
||||
grid,
|
||||
dots: RefCell::new(dots),
|
||||
step_map: RefCell::new(indices),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current(&self, index: usize) {
|
||||
fn build_dots(grid: >k::Grid, names: &[String], step_indices: &[usize]) -> Vec<StepDot> {
|
||||
let mut dots = Vec::new();
|
||||
|
||||
for (visual_i, (name, &actual_i)) in names.iter().zip(step_indices.iter()).enumerate() {
|
||||
let col = (visual_i * 2) as i32;
|
||||
|
||||
if visual_i > 0 {
|
||||
let line = gtk::Separator::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.hexpand(false)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
line.set_size_request(12, -1);
|
||||
grid.attach(&line, col - 1, 0, 1, 1);
|
||||
}
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("radio-symbolic")
|
||||
.pixel_size(16)
|
||||
.build();
|
||||
|
||||
let button = gtk::Button::builder()
|
||||
.child(&icon)
|
||||
.has_frame(false)
|
||||
.tooltip_text(format!("Step {}: {} (Alt+{})", visual_i + 1, name, actual_i + 1))
|
||||
.sensitive(false)
|
||||
.action_name("win.goto-step")
|
||||
.action_target(&(actual_i as i32 + 1).to_variant())
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
button.add_css_class("circular");
|
||||
|
||||
let label = gtk::Label::builder()
|
||||
.label(name)
|
||||
.css_classes(["caption"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.width_chars(10)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
grid.attach(&button, col, 0, 1, 1);
|
||||
grid.attach(&label, col, 1, 1, 1);
|
||||
|
||||
dots.push(StepDot {
|
||||
button,
|
||||
icon,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
dots
|
||||
}
|
||||
|
||||
/// Rebuild the indicator to show only the given steps.
|
||||
/// `visible_steps` is a list of (actual_step_index, name).
|
||||
pub fn rebuild(&self, visible_steps: &[(usize, String)]) {
|
||||
// Clear the grid
|
||||
while let Some(child) = self.grid.first_child() {
|
||||
self.grid.remove(&child);
|
||||
}
|
||||
|
||||
let names: Vec<String> = visible_steps.iter().map(|(_, n)| n.clone()).collect();
|
||||
let indices: Vec<usize> = visible_steps.iter().map(|(i, _)| *i).collect();
|
||||
|
||||
let dots = Self::build_dots(&self.grid, &names, &indices);
|
||||
*self.dots.borrow_mut() = dots;
|
||||
*self.step_map.borrow_mut() = indices;
|
||||
}
|
||||
|
||||
/// Set the current step by actual step index. Finds the visual position.
|
||||
pub fn set_current(&self, actual_index: usize) {
|
||||
let dots = self.dots.borrow();
|
||||
let map = self.step_map.borrow();
|
||||
let total = dots.len();
|
||||
for (i, dot) in dots.iter().enumerate() {
|
||||
if i == index {
|
||||
for (visual_i, dot) in dots.iter().enumerate() {
|
||||
let is_current = map.get(visual_i) == Some(&actual_index);
|
||||
if is_current {
|
||||
dot.icon.set_icon_name(Some("radio-checked-symbolic"));
|
||||
dot.button.set_sensitive(true);
|
||||
dot.label.add_css_class("accent");
|
||||
// Update accessible description for screen readers
|
||||
dot.button.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Step {} of {}: {} (current)", i + 1, total, dot.label.label())
|
||||
&format!("Step {} of {}: {} (current)", visual_i + 1, total, dot.label.label())
|
||||
),
|
||||
]);
|
||||
} else if dot.icon.icon_name().as_deref() != Some("emblem-ok-symbolic") {
|
||||
@@ -119,21 +153,25 @@ impl StepIndicator {
|
||||
dot.label.remove_css_class("accent");
|
||||
dot.button.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Step {} of {}: {}", i + 1, total, dot.label.label())
|
||||
&format!("Step {} of {}: {}", visual_i + 1, total, dot.label.label())
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_completed(&self, index: usize) {
|
||||
/// Mark a step as completed by actual step index.
|
||||
pub fn set_completed(&self, actual_index: usize) {
|
||||
let dots = self.dots.borrow();
|
||||
if let Some(dot) = dots.get(index) {
|
||||
let map = self.step_map.borrow();
|
||||
if let Some(visual_i) = map.iter().position(|&i| i == actual_index) {
|
||||
if let Some(dot) = dots.get(visual_i) {
|
||||
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
dot.button.set_sensitive(true);
|
||||
dot.label.remove_css_class("accent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> >k::Box {
|
||||
&self.container
|
||||
|
||||
@@ -8,3 +8,33 @@ pub mod step_rename;
|
||||
pub mod step_resize;
|
||||
pub mod step_watermark;
|
||||
pub mod step_workflow;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
/// Creates a list factory for ComboRow dropdowns where labels never truncate.
|
||||
/// The default GTK factory ellipsizes text, which cuts off long option names.
|
||||
pub fn full_text_list_factory() -> gtk::SignalListItemFactory {
|
||||
let factory = gtk::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let label = gtk::Label::builder()
|
||||
.xalign(0.0)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
item.set_child(Some(&label));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
if let Some(obj) = item.item() {
|
||||
if let Some(string_obj) = obj.downcast_ref::<gtk::StringObject>() {
|
||||
if let Some(label) = item.child().and_downcast_ref::<gtk::Label>() {
|
||||
label.set_label(&string_obj.string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
factory
|
||||
}
|
||||
|
||||
@@ -1,65 +1,211 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// === OUTER LAYOUT ===
|
||||
let outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
// --- Enable toggle (full width) ---
|
||||
let enable_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
let enable_row = adw::SwitchRow::builder()
|
||||
.title("Enable Adjustments")
|
||||
.subtitle("Rotate, flip, brightness, contrast, effects")
|
||||
.active(cfg.adjustments_enabled)
|
||||
.build();
|
||||
enable_group.add(&enable_row);
|
||||
outer.append(&enable_group);
|
||||
|
||||
// === LEFT SIDE: Preview ===
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let cfg = state.job_config.borrow();
|
||||
let preview_frame = gtk::Frame::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_frame.set_child(Some(&preview_picture));
|
||||
|
||||
// Rotate
|
||||
let rotate_group = adw::PreferencesGroup::builder()
|
||||
let preview_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_box.append(&preview_frame);
|
||||
preview_box.append(&info_label);
|
||||
|
||||
// === RIGHT SIDE: Controls (scrollable) ===
|
||||
|
||||
let controls = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_start(12)
|
||||
.build();
|
||||
|
||||
// --- Orientation group ---
|
||||
let orient_group = adw::PreferencesGroup::builder()
|
||||
.title("Orientation")
|
||||
.description("Rotate and flip images")
|
||||
.build();
|
||||
|
||||
let rotate_row = adw::ComboRow::builder()
|
||||
.title("Rotate")
|
||||
.subtitle("Rotation applied to all images")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let rotate_model = gtk::StringList::new(&[
|
||||
rotate_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
"90 clockwise",
|
||||
"180",
|
||||
"270 clockwise",
|
||||
"Auto-orient (from EXIF)",
|
||||
]);
|
||||
rotate_row.set_model(Some(&rotate_model));
|
||||
])));
|
||||
rotate_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
rotate_row.set_selected(cfg.rotation);
|
||||
|
||||
let flip_row = adw::ComboRow::builder()
|
||||
.title("Flip")
|
||||
.subtitle("Mirror the image")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]);
|
||||
flip_row.set_model(Some(&flip_model));
|
||||
flip_row.set_model(Some(>k::StringList::new(&["None", "Horizontal", "Vertical"])));
|
||||
flip_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
flip_row.set_selected(cfg.flip);
|
||||
|
||||
rotate_group.add(&rotate_row);
|
||||
rotate_group.add(&flip_row);
|
||||
content.append(&rotate_group);
|
||||
orient_group.add(&rotate_row);
|
||||
orient_group.add(&flip_row);
|
||||
controls.append(&orient_group);
|
||||
|
||||
// Crop and canvas group
|
||||
// --- Color adjustments group ---
|
||||
let color_group = adw::PreferencesGroup::builder()
|
||||
.title("Color")
|
||||
.build();
|
||||
|
||||
// Helper to build a slider row with reset button
|
||||
let make_slider_row = |title: &str, label_text: &str, value: i32| -> (adw::ActionRow, gtk::Scale, gtk::Button) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(&format!("{}", value))
|
||||
.build();
|
||||
let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
scale.set_value(value as f64);
|
||||
scale.set_draw_value(false);
|
||||
scale.set_hexpand(false);
|
||||
scale.set_valign(gtk::Align::Center);
|
||||
scale.set_width_request(180);
|
||||
scale.set_tooltip_text(Some(label_text));
|
||||
scale.update_property(&[
|
||||
gtk::accessible::Property::Label(label_text),
|
||||
]);
|
||||
|
||||
let reset_btn = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 0")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
reset_btn.set_sensitive(value != 0);
|
||||
|
||||
row.add_suffix(&scale);
|
||||
row.add_suffix(&reset_btn);
|
||||
(row, scale, reset_btn)
|
||||
};
|
||||
|
||||
let (brightness_row, brightness_scale, brightness_reset) =
|
||||
make_slider_row("Brightness", "Brightness adjustment, -100 to +100", cfg.brightness);
|
||||
let (contrast_row, contrast_scale, contrast_reset) =
|
||||
make_slider_row("Contrast", "Contrast adjustment, -100 to +100", cfg.contrast);
|
||||
let (saturation_row, saturation_scale, saturation_reset) =
|
||||
make_slider_row("Saturation", "Saturation adjustment, -100 to +100", cfg.saturation);
|
||||
|
||||
color_group.add(&brightness_row);
|
||||
color_group.add(&contrast_row);
|
||||
color_group.add(&saturation_row);
|
||||
controls.append(&color_group);
|
||||
|
||||
// --- Effects group (compact toggle buttons) ---
|
||||
let effects_group = adw::PreferencesGroup::builder()
|
||||
.title("Effects")
|
||||
.build();
|
||||
|
||||
let effects_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
|
||||
let grayscale_btn = gtk::ToggleButton::builder()
|
||||
.label("Grayscale")
|
||||
.active(cfg.grayscale)
|
||||
.tooltip_text("Convert to grayscale")
|
||||
.build();
|
||||
grayscale_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Grayscale effect toggle"),
|
||||
]);
|
||||
|
||||
let sepia_btn = gtk::ToggleButton::builder()
|
||||
.label("Sepia")
|
||||
.active(cfg.sepia)
|
||||
.tooltip_text("Apply sepia tone")
|
||||
.build();
|
||||
sepia_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Sepia effect toggle"),
|
||||
]);
|
||||
|
||||
let sharpen_btn = gtk::ToggleButton::builder()
|
||||
.label("Sharpen")
|
||||
.active(cfg.sharpen)
|
||||
.tooltip_text("Sharpen the image")
|
||||
.build();
|
||||
sharpen_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Sharpen effect toggle"),
|
||||
]);
|
||||
|
||||
effects_box.append(&grayscale_btn);
|
||||
effects_box.append(&sepia_btn);
|
||||
effects_box.append(&sharpen_btn);
|
||||
effects_group.add(&effects_box);
|
||||
controls.append(&effects_group);
|
||||
|
||||
// --- Crop & Canvas group ---
|
||||
let crop_group = adw::PreferencesGroup::builder()
|
||||
.title("Crop and Canvas")
|
||||
.build();
|
||||
|
||||
let crop_row = adw::ComboRow::builder()
|
||||
.title("Crop to Aspect Ratio")
|
||||
.subtitle("Crop images to a specific aspect ratio from center")
|
||||
.subtitle("Crop from center to a specific ratio")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let crop_model = gtk::StringList::new(&[
|
||||
crop_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
"1:1 (Square)",
|
||||
"4:3",
|
||||
@@ -68,8 +214,8 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
"9:16 (Portrait)",
|
||||
"3:4 (Portrait)",
|
||||
"2:3 (Portrait)",
|
||||
]);
|
||||
crop_row.set_model(Some(&crop_model));
|
||||
])));
|
||||
crop_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
crop_row.set_selected(cfg.crop_aspect_ratio);
|
||||
|
||||
let trim_row = adw::SwitchRow::builder()
|
||||
@@ -80,201 +226,481 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
let padding_row = adw::SpinRow::builder()
|
||||
.title("Canvas Padding")
|
||||
.subtitle("Add uniform padding around the image (pixels)")
|
||||
.subtitle("Add uniform padding (pixels)")
|
||||
.adjustment(>k::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
crop_group.add(&crop_row);
|
||||
crop_group.add(&trim_row);
|
||||
crop_group.add(&padding_row);
|
||||
content.append(&crop_group);
|
||||
controls.append(&crop_group);
|
||||
|
||||
// Image adjustments
|
||||
let adjust_group = adw::PreferencesGroup::builder()
|
||||
.title("Image Adjustments")
|
||||
// Scrollable controls
|
||||
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.width_request(360)
|
||||
.child(&controls)
|
||||
.build();
|
||||
|
||||
let adjust_expander = adw::ExpanderRow::builder()
|
||||
.title("Advanced Adjustments")
|
||||
.subtitle("Brightness, contrast, saturation, effects")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("adjustments-advanced"))
|
||||
// === Main layout: 60/40 side-by-side ===
|
||||
let main_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
{
|
||||
let st = state.clone();
|
||||
adjust_expander.connect_expanded_notify(move |row| {
|
||||
st.set_section_expanded("adjustments-advanced", row.is_expanded());
|
||||
});
|
||||
}
|
||||
preview_box.set_width_request(400);
|
||||
main_box.append(&preview_box);
|
||||
main_box.append(&controls_scrolled);
|
||||
outer.append(&main_box);
|
||||
|
||||
// Brightness slider (-100 to +100)
|
||||
let brightness_row = adw::ActionRow::builder()
|
||||
.title("Brightness")
|
||||
.subtitle(format!("{}", cfg.brightness))
|
||||
.build();
|
||||
let brightness_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
brightness_scale.set_value(cfg.brightness as f64);
|
||||
brightness_scale.set_hexpand(true);
|
||||
brightness_scale.set_valign(gtk::Align::Center);
|
||||
brightness_scale.set_size_request(200, -1);
|
||||
brightness_scale.set_draw_value(false);
|
||||
brightness_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Brightness adjustment, -100 to +100"),
|
||||
]);
|
||||
brightness_row.add_suffix(&brightness_scale);
|
||||
adjust_expander.add_row(&brightness_row);
|
||||
|
||||
// Contrast slider (-100 to +100)
|
||||
let contrast_row = adw::ActionRow::builder()
|
||||
.title("Contrast")
|
||||
.subtitle(format!("{}", cfg.contrast))
|
||||
.build();
|
||||
let contrast_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
contrast_scale.set_value(cfg.contrast as f64);
|
||||
contrast_scale.set_hexpand(true);
|
||||
contrast_scale.set_valign(gtk::Align::Center);
|
||||
contrast_scale.set_size_request(200, -1);
|
||||
contrast_scale.set_draw_value(false);
|
||||
contrast_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Contrast adjustment, -100 to +100"),
|
||||
]);
|
||||
contrast_row.add_suffix(&contrast_scale);
|
||||
adjust_expander.add_row(&contrast_row);
|
||||
|
||||
// Saturation slider (-100 to +100)
|
||||
let saturation_row = adw::ActionRow::builder()
|
||||
.title("Saturation")
|
||||
.subtitle(format!("{}", cfg.saturation))
|
||||
.build();
|
||||
let saturation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
saturation_scale.set_value(cfg.saturation as f64);
|
||||
saturation_scale.set_hexpand(true);
|
||||
saturation_scale.set_valign(gtk::Align::Center);
|
||||
saturation_scale.set_size_request(200, -1);
|
||||
saturation_scale.set_draw_value(false);
|
||||
saturation_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Saturation adjustment, -100 to +100"),
|
||||
]);
|
||||
saturation_row.add_suffix(&saturation_scale);
|
||||
adjust_expander.add_row(&saturation_row);
|
||||
|
||||
// Sharpen after resize
|
||||
let sharpen_row = adw::SwitchRow::builder()
|
||||
.title("Sharpen after resize")
|
||||
.subtitle("Apply subtle sharpening to resized images")
|
||||
.active(cfg.sharpen)
|
||||
.build();
|
||||
adjust_expander.add_row(&sharpen_row);
|
||||
|
||||
// Grayscale
|
||||
let grayscale_row = adw::SwitchRow::builder()
|
||||
.title("Grayscale")
|
||||
.subtitle("Convert images to black and white")
|
||||
.active(cfg.grayscale)
|
||||
.build();
|
||||
adjust_expander.add_row(&grayscale_row);
|
||||
|
||||
// Sepia
|
||||
let sepia_row = adw::SwitchRow::builder()
|
||||
.title("Sepia")
|
||||
.subtitle("Apply a warm vintage tone")
|
||||
.active(cfg.sepia)
|
||||
.build();
|
||||
adjust_expander.add_row(&sepia_row);
|
||||
|
||||
adjust_group.add(&adjust_expander);
|
||||
content.append(&adjust_group);
|
||||
// Preview state
|
||||
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// === Preview update closure ===
|
||||
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let update_preview = {
|
||||
let files = state.loaded_files.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let pic = preview_picture.clone();
|
||||
let info = info_label.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let bind_gen = preview_gen.clone();
|
||||
|
||||
Rc::new(move || {
|
||||
let loaded = files.borrow();
|
||||
if loaded.is_empty() {
|
||||
info.set_label("No images loaded");
|
||||
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = pidx.get().min(loaded.len() - 1);
|
||||
pidx.set(idx);
|
||||
let path = loaded[idx].clone();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||
|
||||
let cfg = jc.borrow();
|
||||
let rotation = cfg.rotation;
|
||||
let flip = cfg.flip;
|
||||
let brightness = cfg.brightness;
|
||||
let contrast = cfg.contrast;
|
||||
let saturation = cfg.saturation;
|
||||
let grayscale = cfg.grayscale;
|
||||
let sepia = cfg.sepia;
|
||||
let sharpen = cfg.sharpen;
|
||||
let crop_aspect = cfg.crop_aspect_ratio;
|
||||
let trim_ws = cfg.trim_whitespace;
|
||||
let padding = cfg.canvas_padding;
|
||||
drop(cfg);
|
||||
|
||||
let my_gen = bind_gen.get().wrapping_add(1);
|
||||
bind_gen.set(my_gen);
|
||||
let gen_check = bind_gen.clone();
|
||||
|
||||
let pic = pic.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Option<Vec<u8>> {
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
|
||||
// Rotation
|
||||
let mut img = match rotation {
|
||||
1 => img.rotate90(),
|
||||
2 => img.rotate180(),
|
||||
3 => img.rotate270(),
|
||||
// 4 = auto-orient from EXIF - skip in preview (would need exif crate)
|
||||
_ => img,
|
||||
};
|
||||
|
||||
// Flip
|
||||
match flip {
|
||||
1 => img = img.fliph(),
|
||||
2 => img = img.flipv(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Crop to aspect ratio
|
||||
if crop_aspect > 0 {
|
||||
let (target_w, target_h): (f64, f64) = match crop_aspect {
|
||||
1 => (1.0, 1.0), // 1:1
|
||||
2 => (4.0, 3.0), // 4:3
|
||||
3 => (3.0, 2.0), // 3:2
|
||||
4 => (16.0, 9.0), // 16:9
|
||||
5 => (9.0, 16.0), // 9:16
|
||||
6 => (3.0, 4.0), // 3:4
|
||||
7 => (2.0, 3.0), // 2:3
|
||||
_ => (1.0, 1.0),
|
||||
};
|
||||
let iw = img.width() as f64;
|
||||
let ih = img.height() as f64;
|
||||
let target_ratio = target_w / target_h;
|
||||
let current_ratio = iw / ih;
|
||||
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
||||
((ih * target_ratio) as u32, img.height())
|
||||
} else {
|
||||
(img.width(), (iw / target_ratio) as u32)
|
||||
};
|
||||
let cx = (img.width().saturating_sub(crop_w)) / 2;
|
||||
let cy = (img.height().saturating_sub(crop_h)) / 2;
|
||||
img = img.crop_imm(cx, cy, crop_w, crop_h);
|
||||
}
|
||||
|
||||
// Trim whitespace (matches core algorithm with threshold)
|
||||
if trim_ws {
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width(), rgba.height());
|
||||
if w > 2 && h > 2 {
|
||||
let bg = *rgba.get_pixel(0, 0);
|
||||
let threshold = 30u32;
|
||||
let is_bg = |p: &image::Rgba<u8>| -> bool {
|
||||
let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs();
|
||||
let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs();
|
||||
let db = (p[2] as i32 - bg[2] as i32).unsigned_abs();
|
||||
dr + dg + db < threshold
|
||||
};
|
||||
let mut top = 0u32;
|
||||
let mut bottom = h - 1;
|
||||
let mut left = 0u32;
|
||||
let mut right = w - 1;
|
||||
'top: for y in 0..h {
|
||||
for x in 0..w {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { top = y; break 'top; }
|
||||
}
|
||||
}
|
||||
'bottom: for y in (0..h).rev() {
|
||||
for x in 0..w {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { bottom = y; break 'bottom; }
|
||||
}
|
||||
}
|
||||
'left: for x in 0..w {
|
||||
for y in top..=bottom {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { left = x; break 'left; }
|
||||
}
|
||||
}
|
||||
'right: for x in (0..w).rev() {
|
||||
for y in top..=bottom {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { right = x; break 'right; }
|
||||
}
|
||||
}
|
||||
let cw = right.saturating_sub(left).saturating_add(1);
|
||||
let ch = bottom.saturating_sub(top).saturating_add(1);
|
||||
if cw > 0 && ch > 0 && (cw < w || ch < h) {
|
||||
img = img.crop_imm(left, top, cw, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness
|
||||
if brightness != 0 {
|
||||
img = img.brighten(brightness);
|
||||
}
|
||||
// Contrast
|
||||
if contrast != 0 {
|
||||
img = img.adjust_contrast(contrast as f32);
|
||||
}
|
||||
// Saturation
|
||||
if saturation != 0 {
|
||||
let sat = saturation.clamp(-100, 100);
|
||||
let factor = 1.0 + (sat as f64 / 100.0);
|
||||
let mut rgba = img.into_rgba8();
|
||||
for pixel in rgba.pixels_mut() {
|
||||
let r = pixel[0] as f64;
|
||||
let g = pixel[1] as f64;
|
||||
let b = pixel[2] as f64;
|
||||
let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
img = image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
// Sharpen
|
||||
if sharpen {
|
||||
img = img.unsharpen(1.0, 5);
|
||||
}
|
||||
// Grayscale
|
||||
if grayscale {
|
||||
img = image::DynamicImage::ImageLuma8(img.to_luma8());
|
||||
}
|
||||
// Sepia
|
||||
if sepia {
|
||||
let mut rgba = img.into_rgba8();
|
||||
for pixel in rgba.pixels_mut() {
|
||||
let r = pixel[0] as f64;
|
||||
let g = pixel[1] as f64;
|
||||
let b = pixel[2] as f64;
|
||||
pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8;
|
||||
pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8;
|
||||
pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
img = image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
|
||||
// Canvas padding
|
||||
if padding > 0 {
|
||||
let pad = padding.min(200); // cap for preview
|
||||
let new_w = img.width().saturating_add(pad.saturating_mul(2));
|
||||
let new_h = img.height().saturating_add(pad.saturating_mul(2));
|
||||
let mut canvas = image::RgbaImage::from_pixel(
|
||||
new_w, new_h,
|
||||
image::Rgba([255, 255, 255, 255]),
|
||||
);
|
||||
image::imageops::overlay(&mut canvas, &img.to_rgba8(), pad as i64, pad as i64);
|
||||
img = image::DynamicImage::ImageRgba8(canvas);
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
).ok()?;
|
||||
Some(buf)
|
||||
})();
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
if gen_check.get() != my_gen {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(Some(bytes)) => {
|
||||
let gbytes = glib::Bytes::from(&bytes);
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||
pic.set_paintable(Some(&texture));
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(None) => glib::ControlFlow::Break,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(_) => glib::ControlFlow::Break,
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Click-to-cycle on preview
|
||||
{
|
||||
let click = gtk::GestureClick::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
click.connect_released(move |_, _, _, _| {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
});
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().adjustments_enabled = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
rotate_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().rotation = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
flip_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().flip = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Shared debounce counter for slider-driven previews
|
||||
let slider_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
|
||||
// Brightness
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
crop_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().crop_aspect_ratio = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
trim_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().trim_whitespace = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
padding_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = brightness_row;
|
||||
let row = brightness_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = brightness_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
brightness_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().brightness = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = brightness_scale.clone();
|
||||
brightness_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Contrast
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = contrast_row;
|
||||
let row = contrast_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = contrast_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
contrast_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().contrast = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = contrast_scale.clone();
|
||||
contrast_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Saturation
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = saturation_row;
|
||||
let row = saturation_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = saturation_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
saturation_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().saturation = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = saturation_scale.clone();
|
||||
saturation_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Effects toggle buttons
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
grayscale_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().grayscale = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
sharpen_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().sharpen = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
sepia_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().sepia = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
grayscale_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().grayscale = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
sharpen_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().sharpen = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Crop & Canvas
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
crop_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().crop_aspect_ratio = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
sepia_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().sepia = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
trim_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().trim_whitespace = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
let did = slider_debounce.clone();
|
||||
padding_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Adjustments")
|
||||
.tag("step-adjustments")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
page.connect_map(move |_| {
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,61 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
use pixstrip_core::types::ImageFormat;
|
||||
|
||||
/// All format labels shown in the card grid.
|
||||
/// Keep Original + 7 common formats = 8 cards.
|
||||
const CARD_FORMATS: &[(&str, &str, &str, Option<ImageFormat>)] = &[
|
||||
("Keep Original", "No conversion", "edit-copy-symbolic", None),
|
||||
("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)),
|
||||
("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)),
|
||||
("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)),
|
||||
("AVIF", "Next-gen format\nBest compression", "emblem-favorite-symbolic", Some(ImageFormat::Avif)),
|
||||
("GIF", "Animations, 256 colors\nSimple graphics", "media-playback-start-symbolic", Some(ImageFormat::Gif)),
|
||||
("TIFF", "Archival, lossless\nVery large files", "drive-harddisk-symbolic", Some(ImageFormat::Tiff)),
|
||||
("BMP", "Uncompressed bitmap\nLegacy format", "image-x-generic-symbolic", None),
|
||||
];
|
||||
|
||||
/// Extra formats available only in the "Other Formats" dropdown: (short name, dropdown label)
|
||||
const DROPDOWN_ONLY_FORMATS: &[(&str, &str)] = &[
|
||||
("ICO", "ICO - Favicon and icon format, max 256x256 pixels"),
|
||||
("HDR", "HDR - Radiance HDR, high dynamic range imaging"),
|
||||
("PNM/PPM", "PNM/PPM - Portable anymap, simple uncompressed"),
|
||||
("TGA", "TGA - Targa, legacy game/video format"),
|
||||
("HEIC/HEIF", "HEIC/HEIF - Apple's photo format, excellent compression"),
|
||||
("JXL (JPEG XL)", "JXL (JPEG XL) - Next-gen JPEG successor, lossless and lossy"),
|
||||
("QOI", "QOI - Quite OK Image, fast lossless compression"),
|
||||
("EXR", "EXR - OpenEXR, VFX/film industry HDR standard"),
|
||||
("Farbfeld", "Farbfeld - Minimal 16-bit lossless, suckless format"),
|
||||
];
|
||||
|
||||
/// Ordered list of format labels for the per-format mapping dropdowns.
|
||||
/// Index 0 = "Same as above", 1 = "Keep Original", then common formats with descriptions.
|
||||
const MAPPING_CHOICES: &[&str] = &[
|
||||
"Same as above - use the global output format",
|
||||
"Keep Original - no conversion for this type",
|
||||
"JPEG - universal photo format, lossy compression",
|
||||
"PNG - lossless with transparency, larger files",
|
||||
"WebP - modern web format, excellent compression",
|
||||
"AVIF - next-gen, best compression, slower encode",
|
||||
"GIF - 256 colors, animation support",
|
||||
"TIFF - archival lossless, very large files",
|
||||
"BMP - uncompressed bitmap, legacy format",
|
||||
"ICO - favicon/icon format, max 256x256",
|
||||
"HDR - Radiance high dynamic range",
|
||||
"PNM/PPM - portable anymap, uncompressed",
|
||||
"TGA - Targa, legacy game/video format",
|
||||
"HEIC/HEIF - Apple photo format, great compression",
|
||||
"JXL (JPEG XL) - next-gen JPEG successor",
|
||||
"QOI - fast lossless compression",
|
||||
"EXR - OpenEXR, VFX/film HDR standard",
|
||||
"Farbfeld - minimal 16-bit lossless",
|
||||
];
|
||||
|
||||
pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
@@ -30,7 +84,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
enable_group.add(&enable_row);
|
||||
content.append(&enable_group);
|
||||
|
||||
// Visual format cards grid
|
||||
// --- Visual format cards grid ---
|
||||
let cards_group = adw::PreferencesGroup::builder()
|
||||
.title("Output Format")
|
||||
.description("Choose the format all images will be converted to")
|
||||
@@ -47,25 +101,14 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let formats: &[(&str, &str, &str, Option<ImageFormat>)] = &[
|
||||
("Keep Original", "No conversion", "edit-copy-symbolic", None),
|
||||
("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)),
|
||||
("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)),
|
||||
("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)),
|
||||
("AVIF", "Next-gen format\nBest compression", "emblem-favorite-symbolic", Some(ImageFormat::Avif)),
|
||||
("GIF", "Animations, 256 colors\nSimple graphics", "media-playback-start-symbolic", Some(ImageFormat::Gif)),
|
||||
("TIFF", "Archival, lossless\nVery large files", "drive-harddisk-symbolic", Some(ImageFormat::Tiff)),
|
||||
];
|
||||
|
||||
// Track which card should be initially selected
|
||||
let initial_format = cfg.convert_format;
|
||||
|
||||
for (name, desc, icon_name, _fmt) in formats {
|
||||
for (name, desc, icon_name, _fmt) in CARD_FORMATS {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(130, 110);
|
||||
@@ -73,10 +116,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(4)
|
||||
.margin_end(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
@@ -107,20 +150,20 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
flow.append(&card);
|
||||
}
|
||||
|
||||
// Select the initial card
|
||||
let initial_idx = match initial_format {
|
||||
None => 0,
|
||||
Some(ImageFormat::Jpeg) => 1,
|
||||
Some(ImageFormat::Png) => 2,
|
||||
Some(ImageFormat::WebP) => 3,
|
||||
Some(ImageFormat::Avif) => 4,
|
||||
Some(ImageFormat::Gif) => 5,
|
||||
Some(ImageFormat::Tiff) => 6,
|
||||
};
|
||||
if let Some(child) = flow.child_at_index(initial_idx) {
|
||||
// Select the initial card (only if format matches a card)
|
||||
let initial_idx = card_index_for_format(initial_format);
|
||||
if let Some(idx) = initial_idx
|
||||
&& let Some(child) = flow.child_at_index(idx)
|
||||
{
|
||||
flow.select_child(&child);
|
||||
}
|
||||
|
||||
// Wrap FlowBox in a Clamp so cards don't stretch too wide when maximized
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.child(&flow)
|
||||
.build();
|
||||
|
||||
// Format info label (updates based on selection)
|
||||
let info_label = gtk::Label::builder()
|
||||
.label(format_info(cfg.convert_format))
|
||||
@@ -132,161 +175,316 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_start(4)
|
||||
.build();
|
||||
|
||||
cards_group.add(&flow);
|
||||
cards_group.add(&clamp);
|
||||
cards_group.add(&info_label);
|
||||
content.append(&cards_group);
|
||||
|
||||
// Advanced options expander
|
||||
let advanced_group = adw::PreferencesGroup::builder()
|
||||
.title("Advanced Options")
|
||||
// --- "Other Formats" dropdown for less common formats ---
|
||||
let other_group = adw::PreferencesGroup::builder()
|
||||
.title("Other Formats")
|
||||
.description("Less common formats not shown in the card grid")
|
||||
.build();
|
||||
|
||||
let advanced_expander = adw::ExpanderRow::builder()
|
||||
.title("Format Mapping")
|
||||
.subtitle("Different input formats can convert to different outputs")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("convert-advanced"))
|
||||
let other_combo = adw::ComboRow::builder()
|
||||
.title("Select format")
|
||||
.subtitle("Choosing a format here deselects the card grid")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
|
||||
{
|
||||
let st = state.clone();
|
||||
advanced_expander.connect_expanded_notify(move |row| {
|
||||
st.set_section_expanded("convert-advanced", row.is_expanded());
|
||||
});
|
||||
// Build model: first entry is "(none)" for no selection, then extra formats with descriptions
|
||||
let mut other_items: Vec<&str> = vec!["(none)"];
|
||||
for (_short, label) in DROPDOWN_ONLY_FORMATS {
|
||||
other_items.push(label);
|
||||
}
|
||||
other_combo.set_model(Some(>k::StringList::new(&other_items)));
|
||||
other_combo.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
other_combo.set_selected(0);
|
||||
|
||||
other_group.add(&other_combo);
|
||||
content.append(&cards_group);
|
||||
content.append(&other_group);
|
||||
|
||||
// --- JPEG encoding options (only visible when JPEG or Keep Original is selected) ---
|
||||
let jpeg_group = adw::PreferencesGroup::builder()
|
||||
.title("JPEG Encoding")
|
||||
.build();
|
||||
|
||||
let progressive_row = adw::SwitchRow::builder()
|
||||
.title("Progressive JPEG")
|
||||
.subtitle("Loads gradually in browsers, slightly larger")
|
||||
.subtitle("Loads gradually in browsers, slightly larger file size")
|
||||
.active(cfg.progressive_jpeg)
|
||||
.build();
|
||||
|
||||
// Format mapping rows - per input format output selection
|
||||
let mapping_header = adw::ActionRow::builder()
|
||||
.title("Per-Format Mapping")
|
||||
.subtitle("Override the output format for specific input types")
|
||||
jpeg_group.add(&progressive_row);
|
||||
|
||||
// Show only for JPEG (card index 1) or Keep Original (card index 0)
|
||||
let shows_jpeg = matches!(initial_format, None | Some(ImageFormat::Jpeg));
|
||||
jpeg_group.set_visible(shows_jpeg);
|
||||
|
||||
content.append(&jpeg_group);
|
||||
|
||||
// --- Format mapping group (dynamic, rebuilt on page map) ---
|
||||
let mapping_group = adw::PreferencesGroup::builder()
|
||||
.title("Format Mapping")
|
||||
.description("Override the output format for specific input types")
|
||||
.build();
|
||||
mapping_header.add_prefix(>k::Image::from_icon_name("preferences-system-symbolic"));
|
||||
|
||||
let output_choices = ["Same as above", "JPEG", "PNG", "WebP", "AVIF", "Keep Original"];
|
||||
|
||||
let jpeg_mapping = adw::ComboRow::builder()
|
||||
.title("JPEG inputs")
|
||||
.subtitle("Output format for JPEG source files")
|
||||
// Container for dynamically added mapping rows
|
||||
let mapping_list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
jpeg_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
jpeg_mapping.set_selected(cfg.format_mapping_jpeg);
|
||||
|
||||
let png_mapping = adw::ComboRow::builder()
|
||||
.title("PNG inputs")
|
||||
.subtitle("Output format for PNG source files")
|
||||
.build();
|
||||
png_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
png_mapping.set_selected(cfg.format_mapping_png);
|
||||
|
||||
let webp_mapping = adw::ComboRow::builder()
|
||||
.title("WebP inputs")
|
||||
.subtitle("Output format for WebP source files")
|
||||
.build();
|
||||
webp_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
webp_mapping.set_selected(cfg.format_mapping_webp);
|
||||
|
||||
let tiff_mapping = adw::ComboRow::builder()
|
||||
.title("TIFF inputs")
|
||||
.subtitle("Output format for TIFF source files")
|
||||
.build();
|
||||
tiff_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
tiff_mapping.set_selected(cfg.format_mapping_tiff);
|
||||
|
||||
advanced_expander.add_row(&progressive_row);
|
||||
advanced_expander.add_row(&mapping_header);
|
||||
advanced_expander.add_row(&jpeg_mapping);
|
||||
advanced_expander.add_row(&png_mapping);
|
||||
advanced_expander.add_row(&webp_mapping);
|
||||
advanced_expander.add_row(&tiff_mapping);
|
||||
advanced_group.add(&advanced_expander);
|
||||
content.append(&advanced_group);
|
||||
mapping_group.add(&mapping_list);
|
||||
content.append(&mapping_group);
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// --- Wire signals ---
|
||||
|
||||
// Enable toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().convert_enabled = row.is_active();
|
||||
});
|
||||
}
|
||||
|
||||
// Card grid selection
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = info_label;
|
||||
let label = info_label.clone();
|
||||
let combo = other_combo.clone();
|
||||
let jg = jpeg_group.clone();
|
||||
flow.connect_child_activated(move |_flow, child| {
|
||||
let idx = child.index() as usize;
|
||||
let mut c = jc.borrow_mut();
|
||||
c.convert_format = match idx {
|
||||
1 => Some(ImageFormat::Jpeg),
|
||||
2 => Some(ImageFormat::Png),
|
||||
3 => Some(ImageFormat::WebP),
|
||||
4 => Some(ImageFormat::Avif),
|
||||
5 => Some(ImageFormat::Gif),
|
||||
6 => Some(ImageFormat::Tiff),
|
||||
_ => None,
|
||||
};
|
||||
c.convert_format = format_for_card_index(idx);
|
||||
label.set_label(&format_info(c.convert_format));
|
||||
// Deselect the "Other Formats" dropdown when a card is picked
|
||||
combo.set_selected(0);
|
||||
// Progressive JPEG only relevant for JPEG or Keep Original
|
||||
jg.set_visible(idx == 0 || idx == 1);
|
||||
});
|
||||
}
|
||||
|
||||
// "Other Formats" dropdown selection
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = info_label;
|
||||
let fl = flow.clone();
|
||||
let jg = jpeg_group.clone();
|
||||
other_combo.connect_selected_notify(move |row| {
|
||||
let selected = row.selected();
|
||||
if selected == 0 {
|
||||
// "(none)" selected - do nothing, cards take priority
|
||||
return;
|
||||
}
|
||||
// Deselect all cards
|
||||
fl.unselect_all();
|
||||
// Hide progressive JPEG - none of the "other" formats are JPEG
|
||||
jg.set_visible(false);
|
||||
// These formats are not in the ImageFormat enum,
|
||||
// so set convert_format to None and show a note
|
||||
let mut c = jc.borrow_mut();
|
||||
c.convert_format = None;
|
||||
let name = DROPDOWN_ONLY_FORMATS
|
||||
.get((selected - 1) as usize)
|
||||
.map(|(short, _)| *short)
|
||||
.unwrap_or("Unknown");
|
||||
label.set_label(&format!(
|
||||
"{}: This format is not yet supported by the processing engine. \
|
||||
Support is planned for a future release.",
|
||||
name
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// Progressive JPEG toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
progressive_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().progressive_jpeg = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
jpeg_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_jpeg = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
png_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_png = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
webp_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_webp = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
tiff_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_tiff = row.selected();
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Convert")
|
||||
.tag("step-convert")
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Convert")
|
||||
.tag("step-convert")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
// Rebuild format mapping rows when navigating to this page
|
||||
{
|
||||
let files = state.loaded_files.clone();
|
||||
let list = mapping_list;
|
||||
let jc = state.job_config.clone();
|
||||
page.connect_map(move |_| {
|
||||
rebuild_format_mapping(&list, &files.borrow(), &jc);
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
/// Returns the card grid index for a given ImageFormat, or None if not in the card grid.
|
||||
fn card_index_for_format(format: Option<ImageFormat>) -> Option<i32> {
|
||||
match format {
|
||||
None => Some(0),
|
||||
Some(ImageFormat::Jpeg) => Some(1),
|
||||
Some(ImageFormat::Png) => Some(2),
|
||||
Some(ImageFormat::WebP) => Some(3),
|
||||
Some(ImageFormat::Avif) => Some(4),
|
||||
Some(ImageFormat::Gif) => Some(5),
|
||||
Some(ImageFormat::Tiff) => Some(6),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the ImageFormat for a given card grid index.
|
||||
fn format_for_card_index(idx: usize) -> Option<ImageFormat> {
|
||||
match idx {
|
||||
1 => Some(ImageFormat::Jpeg),
|
||||
2 => Some(ImageFormat::Png),
|
||||
3 => Some(ImageFormat::WebP),
|
||||
4 => Some(ImageFormat::Avif),
|
||||
5 => Some(ImageFormat::Gif),
|
||||
6 => Some(ImageFormat::Tiff),
|
||||
_ => None, // 0 = Keep Original, 7 = BMP (not in enum)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_info(format: Option<ImageFormat>) -> String {
|
||||
match format {
|
||||
None => "Images will keep their original format. No conversion applied.".into(),
|
||||
Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, no transparency support. Universally compatible with all devices and browsers.".into(),
|
||||
Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. Lossless compression, supports full transparency. Produces larger files than JPEG or WebP.".into(),
|
||||
Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless compression. Supports transparency and animation. Widely supported in modern browsers.".into(),
|
||||
Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. Best compression ratios available. Supports transparency and HDR. Slower to encode, growing browser support.".into(),
|
||||
Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic animation and binary transparency. Best for simple graphics and short animations.".into(),
|
||||
Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, supports layers and rich metadata. Very large files. Not suitable for web.".into(),
|
||||
Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, \
|
||||
no transparency support. Universally compatible with all devices and browsers."
|
||||
.into(),
|
||||
Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. \
|
||||
Lossless compression, supports full transparency. Produces larger files \
|
||||
than JPEG or WebP."
|
||||
.into(),
|
||||
Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless \
|
||||
compression. Supports transparency and animation. Widely supported in modern browsers."
|
||||
.into(),
|
||||
Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. \
|
||||
Best compression ratios available. Supports transparency and HDR. Slower to encode, \
|
||||
growing browser support."
|
||||
.into(),
|
||||
Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic \
|
||||
animation and binary transparency. Best for simple graphics and short animations."
|
||||
.into(),
|
||||
Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, \
|
||||
supports layers and rich metadata. Very large files. Not suitable for web."
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the per-format mapping rows based on which file types are actually loaded.
|
||||
/// Uses a dedicated ListBox container that can be easily cleared and rebuilt.
|
||||
fn rebuild_format_mapping(
|
||||
list: >k::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(>k::Image::from_icon_name("dialog-information-symbolic"));
|
||||
list.append(&placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize extensions to canonical display names, maintaining a stable order
|
||||
let mut format_entries: Vec<(String, String)> = Vec::new(); // (canonical ext, display name)
|
||||
let ext_to_name: &[(&[&str], &str)] = &[
|
||||
(&["jpg", "jpeg"], "JPEG"),
|
||||
(&["png"], "PNG"),
|
||||
(&["webp"], "WebP"),
|
||||
(&["avif"], "AVIF"),
|
||||
(&["gif"], "GIF"),
|
||||
(&["tiff", "tif"], "TIFF"),
|
||||
(&["bmp"], "BMP"),
|
||||
(&["ico"], "ICO"),
|
||||
(&["hdr"], "HDR"),
|
||||
(&["pnm", "ppm", "pgm", "pbm"], "PNM/PPM"),
|
||||
(&["tga"], "TGA"),
|
||||
(&["heic", "heif"], "HEIC/HEIF"),
|
||||
(&["jxl"], "JXL (JPEG XL)"),
|
||||
(&["qoi"], "QOI"),
|
||||
(&["exr"], "EXR"),
|
||||
(&["ff", "farbfeld"], "Farbfeld"),
|
||||
];
|
||||
|
||||
let mut added_names: HashSet<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(>k::StringList::new(MAPPING_CHOICES)));
|
||||
combo.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
|
||||
// Restore saved selection if any
|
||||
let saved = cfg.format_mappings.get(canonical_ext).copied().unwrap_or(0);
|
||||
combo.set_selected(saved);
|
||||
|
||||
// Wire signal to save selection
|
||||
let jc = job_config.clone();
|
||||
let ext_key = canonical_ext.clone();
|
||||
combo.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut()
|
||||
.format_mappings
|
||||
.insert(ext_key.clone(), row.selected());
|
||||
});
|
||||
|
||||
list.append(&combo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::utils::format_size;
|
||||
|
||||
const THUMB_SIZE: i32 = 120;
|
||||
|
||||
@@ -183,7 +184,16 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
fn is_image_file(path: &std::path::Path) -> bool {
|
||||
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) {
|
||||
Some(ext) => matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"),
|
||||
Some(ext) => matches!(ext.as_str(),
|
||||
"jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"
|
||||
| "ico" | "hdr" | "exr" | "pnm" | "ppm" | "pgm" | "pbm" | "pam"
|
||||
| "tga" | "dds" | "ff" | "farbfeld" | "qoi"
|
||||
| "heic" | "heif" | "jxl"
|
||||
| "svg" | "svgz"
|
||||
| "raw" | "cr2" | "cr3" | "nef" | "nrw" | "arw" | "srf" | "sr2"
|
||||
| "orf" | "rw2" | "raf" | "dng" | "pef" | "srw" | "x3f"
|
||||
| "pcx" | "xpm" | "xbm" | "wbmp" | "jp2" | "j2k" | "jpf" | "jpx"
|
||||
),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
@@ -295,7 +305,7 @@ fn refresh_grid(
|
||||
}
|
||||
|
||||
/// Walk the widget tree to find our ListStore and count label, then rebuild
|
||||
fn rebuild_grid_model(
|
||||
pub fn rebuild_grid_model(
|
||||
widget: >k::Widget,
|
||||
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
|
||||
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
|
||||
@@ -380,18 +390,6 @@ fn update_count_label(
|
||||
update_heading_label(widget, count, included_count, &size_str);
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else if bytes < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GObject wrapper for list store items
|
||||
// ------------------------------------------------------------------
|
||||
@@ -487,7 +485,7 @@ fn build_empty_state() -> gtk::Box {
|
||||
.build();
|
||||
|
||||
let formats_label = gtk::Label::builder()
|
||||
.label("Supported: JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP")
|
||||
.label("Supports all common image formats including RAW")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(8)
|
||||
@@ -573,7 +571,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
// Factory: setup
|
||||
{
|
||||
factory.connect_setup(move |_factory, list_item| {
|
||||
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
|
||||
|
||||
let overlay = gtk::Overlay::builder()
|
||||
.width_request(THUMB_SIZE)
|
||||
@@ -656,13 +654,13 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
let excluded = state.excluded_files.clone();
|
||||
let loaded = state.loaded_files.clone();
|
||||
factory.connect_bind(move |_factory, list_item| {
|
||||
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let item = list_item.item().and_downcast::<ImageItem>().unwrap();
|
||||
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
|
||||
let Some(item) = list_item.item().and_downcast::<ImageItem>() else { return };
|
||||
let path = item.path().to_path_buf();
|
||||
|
||||
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
|
||||
let name_label = overlay.next_sibling().and_downcast::<gtk::Label>().unwrap();
|
||||
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
|
||||
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
|
||||
let Some(name_label) = overlay.next_sibling().and_downcast::<gtk::Label>() else { return };
|
||||
|
||||
// Set filename
|
||||
let file_name = path.file_name()
|
||||
@@ -671,19 +669,36 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
name_label.set_label(file_name);
|
||||
|
||||
// Get the frame -> stack -> picture
|
||||
let frame = overlay.child().and_downcast::<gtk::Frame>().unwrap();
|
||||
let thumb_stack = frame.child().and_downcast::<gtk::Stack>().unwrap();
|
||||
let picture = thumb_stack.child_by_name("picture")
|
||||
.and_downcast::<gtk::Picture>().unwrap();
|
||||
let Some(frame) = overlay.child().and_downcast::<gtk::Frame>() else { return };
|
||||
let Some(thumb_stack) = frame.child().and_downcast::<gtk::Stack>() else { return };
|
||||
let Some(picture) = thumb_stack.child_by_name("picture")
|
||||
.and_downcast::<gtk::Picture>() else { return };
|
||||
|
||||
// Reset to placeholder
|
||||
thumb_stack.set_visible_child_name("placeholder");
|
||||
|
||||
// Bump bind generation so stale idle callbacks are ignored
|
||||
let bind_gen: u32 = unsafe {
|
||||
thumb_stack.data::<u32>("bind-gen")
|
||||
.map(|p| *p.as_ref())
|
||||
.unwrap_or(0)
|
||||
.wrapping_add(1)
|
||||
};
|
||||
unsafe { thumb_stack.set_data("bind-gen", bind_gen); }
|
||||
|
||||
// Load thumbnail asynchronously
|
||||
let thumb_stack_c = thumb_stack.clone();
|
||||
let picture_c = picture.clone();
|
||||
let path_c = path.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
let current: u32 = unsafe {
|
||||
thumb_stack_c.data::<u32>("bind-gen")
|
||||
.map(|p| *p.as_ref())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
if current != bind_gen {
|
||||
return; // Item was recycled; skip stale load
|
||||
}
|
||||
load_thumbnail(&path_c, &picture_c, &thumb_stack_c);
|
||||
});
|
||||
|
||||
@@ -725,9 +740,9 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
// Factory: unbind - disconnect signal to avoid stale closures
|
||||
{
|
||||
factory.connect_unbind(move |_factory, list_item| {
|
||||
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
|
||||
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
|
||||
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
|
||||
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
|
||||
|
||||
if let Some(check) = find_check_button(overlay.upcast_ref::<gtk::Widget>()) {
|
||||
let handler: Option<glib::SignalHandlerId> = unsafe {
|
||||
|
||||
@@ -185,6 +185,7 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
let copyright_c = copyright_row.clone();
|
||||
photographer_check.connect_toggled(move |check| {
|
||||
if check.is_active() {
|
||||
{
|
||||
let mut cfg = jc.borrow_mut();
|
||||
cfg.metadata_mode = MetadataMode::Custom;
|
||||
// Photographer: keep copyright + camera model, strip GPS + software
|
||||
@@ -193,13 +194,14 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
cfg.strip_software = true;
|
||||
cfg.strip_timestamps = false;
|
||||
cfg.strip_copyright = false;
|
||||
// Update UI to match
|
||||
}
|
||||
// Update UI to match (after dropping borrow to avoid re-entrancy)
|
||||
gps_c.set_active(true);
|
||||
camera_c.set_active(false);
|
||||
software_c.set_active(true);
|
||||
timestamps_c.set_active(false);
|
||||
copyright_c.set_active(false);
|
||||
cg.set_visible(true);
|
||||
cg.set_visible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -258,14 +260,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Metadata")
|
||||
.tag("step-metadata")
|
||||
.child(&clamp)
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use adw::prelude::*;
|
||||
use crate::app::AppState;
|
||||
use crate::utils::format_size;
|
||||
|
||||
pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
@@ -79,6 +80,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
let overwrite_row = adw::ComboRow::builder()
|
||||
.title("Overwrite Behavior")
|
||||
.subtitle("What to do when output file already exists")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let overwrite_model = gtk::StringList::new(&[
|
||||
"Ask before overwriting",
|
||||
@@ -87,6 +89,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
"Skip existing files",
|
||||
]);
|
||||
overwrite_row.set_model(Some(&overwrite_model));
|
||||
overwrite_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
overwrite_row.set_selected(cfg.overwrite_behavior as u32);
|
||||
|
||||
overwrite_group.add(&overwrite_row);
|
||||
@@ -137,26 +140,9 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Output & Process")
|
||||
.tag("step-output")
|
||||
.child(&clamp)
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else if bytes < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,75 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// === OUTER LAYOUT ===
|
||||
let outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
// --- Enable toggle (full width) ---
|
||||
let enable_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// Enable toggle
|
||||
let enable_row = adw::SwitchRow::builder()
|
||||
.title("Enable Watermark")
|
||||
.subtitle("Add text or image watermark to processed images")
|
||||
.active(cfg.watermark_enabled)
|
||||
.build();
|
||||
|
||||
let enable_group = adw::PreferencesGroup::new();
|
||||
enable_group.add(&enable_row);
|
||||
content.append(&enable_group);
|
||||
outer.append(&enable_group);
|
||||
|
||||
// Watermark type selection
|
||||
// === LEFT SIDE: Preview ===
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let preview_frame = gtk::Frame::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_frame.set_child(Some(&preview_picture));
|
||||
|
||||
let preview_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_box.append(&preview_frame);
|
||||
preview_box.append(&info_label);
|
||||
|
||||
// === RIGHT SIDE: Controls (scrollable) ===
|
||||
|
||||
let controls = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_start(12)
|
||||
.build();
|
||||
|
||||
// --- Watermark type ---
|
||||
let type_group = adw::PreferencesGroup::builder()
|
||||
.title("Watermark Type")
|
||||
.build();
|
||||
@@ -37,17 +77,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let type_row = adw::ComboRow::builder()
|
||||
.title("Type")
|
||||
.subtitle("Choose text or image watermark")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let type_model = gtk::StringList::new(&["Text Watermark", "Image Watermark"]);
|
||||
type_row.set_model(Some(&type_model));
|
||||
type_row.set_model(Some(>k::StringList::new(&["Text Watermark", "Image Watermark"])));
|
||||
type_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 });
|
||||
|
||||
type_group.add(&type_row);
|
||||
content.append(&type_group);
|
||||
controls.append(&type_group);
|
||||
|
||||
// Text watermark settings
|
||||
// --- Text watermark settings ---
|
||||
let text_group = adw::PreferencesGroup::builder()
|
||||
.title("Text Watermark")
|
||||
.visible(!cfg.watermark_use_image)
|
||||
.build();
|
||||
|
||||
let text_row = adw::EntryRow::builder()
|
||||
@@ -55,13 +97,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.text(&cfg.watermark_text)
|
||||
.build();
|
||||
|
||||
let font_size_row = adw::SpinRow::builder()
|
||||
.title("Font Size")
|
||||
.subtitle("Size in pixels")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
// Font family picker
|
||||
let font_row = adw::ActionRow::builder()
|
||||
.title("Font Family")
|
||||
.subtitle("Choose a typeface for the watermark text")
|
||||
@@ -76,20 +111,24 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
// Set initial font if one was previously selected
|
||||
if !cfg.watermark_font_family.is_empty() {
|
||||
let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family);
|
||||
font_button.set_font_desc(&desc);
|
||||
}
|
||||
|
||||
font_row.add_suffix(&font_button);
|
||||
|
||||
let font_size_row = adw::SpinRow::builder()
|
||||
.title("Font Size")
|
||||
.subtitle("Size in pixels")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
text_group.add(&text_row);
|
||||
text_group.add(&font_row);
|
||||
text_group.add(&font_size_row);
|
||||
content.append(&text_group);
|
||||
controls.append(&text_group);
|
||||
|
||||
// Image watermark settings
|
||||
// --- Image watermark settings ---
|
||||
let image_group = adw::PreferencesGroup::builder()
|
||||
.title("Image Watermark")
|
||||
.visible(cfg.watermark_use_image)
|
||||
@@ -111,14 +150,14 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.icon_name("document-open-symbolic")
|
||||
.tooltip_text("Choose logo image")
|
||||
.valign(gtk::Align::Center)
|
||||
.has_frame(false)
|
||||
.build();
|
||||
choose_image_button.add_css_class("flat");
|
||||
image_path_row.add_suffix(&choose_image_button);
|
||||
|
||||
image_group.add(&image_path_row);
|
||||
content.append(&image_group);
|
||||
controls.append(&image_group);
|
||||
|
||||
// Visual 9-point position grid
|
||||
// --- Position group with 3x3 grid ---
|
||||
let position_group = adw::PreferencesGroup::builder()
|
||||
.title("Position")
|
||||
.description("Choose where the watermark appears on the image")
|
||||
@@ -130,7 +169,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
"Bottom Left", "Bottom Center", "Bottom Right",
|
||||
];
|
||||
|
||||
// Build a 3x3 grid of toggle buttons
|
||||
let grid = gtk::Grid::builder()
|
||||
.row_spacing(4)
|
||||
.column_spacing(4)
|
||||
@@ -139,7 +177,12 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
// Create a visual "image" area as background context
|
||||
// Frame styled to look like a miniature image
|
||||
let grid_outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let grid_frame = gtk::Frame::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
@@ -148,6 +191,17 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
gtk::accessible::Property::Label("Watermark position grid. Select where the watermark appears on the image."),
|
||||
]);
|
||||
|
||||
// Image outline label above the grid
|
||||
let grid_title = gtk::Label::builder()
|
||||
.label("Image")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
grid_outer.append(&grid_title);
|
||||
grid_outer.append(&grid_frame);
|
||||
|
||||
let mut first_button: Option<gtk::ToggleButton> = None;
|
||||
let buttons: Vec<gtk::ToggleButton> = position_names.iter().enumerate().map(|(i, name)| {
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
@@ -156,7 +210,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.height_request(48)
|
||||
.build();
|
||||
|
||||
// Use a dot icon for each position
|
||||
let icon = if i == cfg.watermark_position as usize {
|
||||
"radio-checked-symbolic"
|
||||
} else {
|
||||
@@ -178,163 +231,26 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
btn
|
||||
}).collect();
|
||||
|
||||
position_group.add(&grid_frame);
|
||||
position_group.add(&grid_outer);
|
||||
|
||||
// Position label showing current selection
|
||||
let position_label = gtk::Label::builder()
|
||||
.label(position_names[cfg.watermark_position as usize])
|
||||
.label(position_names.get(cfg.watermark_position as usize).copied().unwrap_or("Center"))
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
position_group.add(&position_label);
|
||||
|
||||
content.append(&position_group);
|
||||
controls.append(&position_group);
|
||||
|
||||
// Live preview section
|
||||
let preview_group = adw::PreferencesGroup::builder()
|
||||
.title("Preview")
|
||||
.description("Shows how the watermark will appear on your image")
|
||||
.build();
|
||||
|
||||
// Overlay container for image + watermark text
|
||||
let preview_overlay = gtk::Overlay::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.width_request(300)
|
||||
.height_request(200)
|
||||
.build();
|
||||
preview_picture.add_css_class("card");
|
||||
preview_overlay.set_child(Some(&preview_picture));
|
||||
|
||||
// Watermark text label overlay
|
||||
let watermark_label = gtk::Label::builder()
|
||||
.label(&cfg.watermark_text)
|
||||
.css_classes(["heading"])
|
||||
.opacity(cfg.watermark_opacity as f64)
|
||||
.build();
|
||||
preview_overlay.add_overlay(&watermark_label);
|
||||
|
||||
// Position the watermark label according to grid position
|
||||
fn set_watermark_alignment(label: >k::Label, position: u32) {
|
||||
let (h, v) = match position {
|
||||
0 => (gtk::Align::Start, gtk::Align::Start), // Top Left
|
||||
1 => (gtk::Align::Center, gtk::Align::Start), // Top Center
|
||||
2 => (gtk::Align::End, gtk::Align::Start), // Top Right
|
||||
3 => (gtk::Align::Start, gtk::Align::Center), // Middle Left
|
||||
4 => (gtk::Align::Center, gtk::Align::Center), // Center
|
||||
5 => (gtk::Align::End, gtk::Align::Center), // Middle Right
|
||||
6 => (gtk::Align::Start, gtk::Align::End), // Bottom Left
|
||||
7 => (gtk::Align::Center, gtk::Align::End), // Bottom Center
|
||||
_ => (gtk::Align::End, gtk::Align::End), // Bottom Right
|
||||
};
|
||||
label.set_halign(h);
|
||||
label.set_valign(v);
|
||||
label.set_margin_start(8);
|
||||
label.set_margin_end(8);
|
||||
label.set_margin_top(8);
|
||||
label.set_margin_bottom(8);
|
||||
}
|
||||
set_watermark_alignment(&watermark_label, cfg.watermark_position);
|
||||
|
||||
// Load first image from batch as preview background
|
||||
{
|
||||
let files = state.loaded_files.borrow();
|
||||
if let Some(first) = files.first() {
|
||||
preview_picture.set_filename(Some(first));
|
||||
}
|
||||
}
|
||||
|
||||
// "No preview" placeholder
|
||||
let no_preview_label = gtk::Label::builder()
|
||||
.label("Add images to see a preview")
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
{
|
||||
let has_files = !state.loaded_files.borrow().is_empty();
|
||||
no_preview_label.set_visible(!has_files);
|
||||
preview_picture.set_visible(has_files);
|
||||
}
|
||||
|
||||
// Thumbnail strip for selecting preview image
|
||||
let wm_thumb_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.build();
|
||||
{
|
||||
let files = state.loaded_files.borrow();
|
||||
let max_thumbs = files.len().min(10);
|
||||
for i in 0..max_thumbs {
|
||||
let pic = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Cover)
|
||||
.width_request(40)
|
||||
.height_request(40)
|
||||
.build();
|
||||
pic.set_filename(Some(&files[i]));
|
||||
let frame = gtk::Frame::builder()
|
||||
.child(&pic)
|
||||
.build();
|
||||
if i == 0 { frame.add_css_class("accent"); }
|
||||
|
||||
let btn = gtk::Button::builder()
|
||||
.child(&frame)
|
||||
.has_frame(false)
|
||||
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
|
||||
.build();
|
||||
|
||||
let pp = preview_picture.clone();
|
||||
let path = files[i].clone();
|
||||
let tb = wm_thumb_box.clone();
|
||||
let current_idx = i;
|
||||
btn.connect_clicked(move |_| {
|
||||
pp.set_filename(Some(&path));
|
||||
let mut c = tb.first_child();
|
||||
let mut j = 0usize;
|
||||
while let Some(w) = c {
|
||||
if let Some(b) = w.downcast_ref::<gtk::Button>() {
|
||||
if let Some(f) = b.child().and_then(|c| c.downcast::<gtk::Frame>().ok()) {
|
||||
if j == current_idx { f.add_css_class("accent"); }
|
||||
else { f.remove_css_class("accent"); }
|
||||
}
|
||||
}
|
||||
c = w.next_sibling();
|
||||
j += 1;
|
||||
}
|
||||
});
|
||||
|
||||
wm_thumb_box.append(&btn);
|
||||
}
|
||||
wm_thumb_box.set_visible(max_thumbs > 1);
|
||||
}
|
||||
|
||||
let preview_stack = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
preview_stack.append(&preview_overlay);
|
||||
preview_stack.append(&wm_thumb_box);
|
||||
preview_stack.append(&no_preview_label);
|
||||
|
||||
preview_group.add(&preview_stack);
|
||||
content.append(&preview_group);
|
||||
|
||||
// Advanced options
|
||||
// --- Advanced options ---
|
||||
let advanced_group = adw::PreferencesGroup::builder()
|
||||
.title("Advanced")
|
||||
.build();
|
||||
|
||||
let advanced_expander = adw::ExpanderRow::builder()
|
||||
.title("Advanced Options")
|
||||
.subtitle("Opacity, rotation, tiling, margin")
|
||||
.subtitle("Color, opacity, rotation, tiling, margin, scale")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("watermark-advanced"))
|
||||
.build();
|
||||
@@ -369,37 +285,97 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.build();
|
||||
color_row.add_suffix(&color_button);
|
||||
|
||||
let opacity_row = adw::SpinRow::builder()
|
||||
// Opacity slider + reset
|
||||
let opacity_row = adw::ActionRow::builder()
|
||||
.title("Opacity")
|
||||
.subtitle("0.0 (invisible) to 1.0 (fully opaque)")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_opacity as f64, 0.0, 1.0, 0.05, 0.1, 0.0))
|
||||
.digits(2)
|
||||
.subtitle(&format!("{}%", (cfg.watermark_opacity * 100.0).round() as i32))
|
||||
.build();
|
||||
let opacity_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 100.0, 1.0);
|
||||
opacity_scale.set_value((cfg.watermark_opacity * 100.0) as f64);
|
||||
opacity_scale.set_draw_value(false);
|
||||
opacity_scale.set_hexpand(false);
|
||||
opacity_scale.set_valign(gtk::Align::Center);
|
||||
opacity_scale.set_width_request(180);
|
||||
let opacity_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 50%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
opacity_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01);
|
||||
opacity_row.add_suffix(&opacity_scale);
|
||||
opacity_row.add_suffix(&opacity_reset);
|
||||
|
||||
let rotation_row = adw::ComboRow::builder()
|
||||
// Rotation slider + reset (-180 to +180)
|
||||
let rotation_row = adw::ActionRow::builder()
|
||||
.title("Rotation")
|
||||
.subtitle("Rotate the watermark")
|
||||
.subtitle(&format!("{} degrees", cfg.watermark_rotation))
|
||||
.build();
|
||||
let rotation_model = gtk::StringList::new(&["None", "45 degrees", "-45 degrees", "90 degrees"]);
|
||||
rotation_row.set_model(Some(&rotation_model));
|
||||
let rotation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -180.0, 180.0, 1.0);
|
||||
rotation_scale.set_value(cfg.watermark_rotation as f64);
|
||||
rotation_scale.set_draw_value(false);
|
||||
rotation_scale.set_hexpand(false);
|
||||
rotation_scale.set_valign(gtk::Align::Center);
|
||||
rotation_scale.set_width_request(180);
|
||||
let rotation_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 0 degrees")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
rotation_reset.set_sensitive(cfg.watermark_rotation != 0);
|
||||
rotation_row.add_suffix(&rotation_scale);
|
||||
rotation_row.add_suffix(&rotation_reset);
|
||||
|
||||
// Tiled toggle
|
||||
let tiled_row = adw::SwitchRow::builder()
|
||||
.title("Tiled / Repeated")
|
||||
.subtitle("Repeat watermark across the entire image")
|
||||
.active(false)
|
||||
.active(cfg.watermark_tiled)
|
||||
.build();
|
||||
|
||||
let margin_row = adw::SpinRow::builder()
|
||||
// Margin slider + reset
|
||||
let margin_row = adw::ActionRow::builder()
|
||||
.title("Margin from Edges")
|
||||
.subtitle("Padding in pixels from image edges")
|
||||
.adjustment(>k::Adjustment::new(10.0, 0.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.subtitle(&format!("{} px", cfg.watermark_margin))
|
||||
.build();
|
||||
let margin_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 200.0, 1.0);
|
||||
margin_scale.set_value(cfg.watermark_margin as f64);
|
||||
margin_scale.set_draw_value(false);
|
||||
margin_scale.set_hexpand(false);
|
||||
margin_scale.set_valign(gtk::Align::Center);
|
||||
margin_scale.set_width_request(180);
|
||||
let margin_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 10 px")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
margin_reset.set_sensitive(cfg.watermark_margin != 10);
|
||||
margin_row.add_suffix(&margin_scale);
|
||||
margin_row.add_suffix(&margin_reset);
|
||||
|
||||
let scale_row = adw::SpinRow::builder()
|
||||
// Scale slider + reset (only relevant for image watermarks)
|
||||
let scale_row = adw::ActionRow::builder()
|
||||
.title("Scale (% of image)")
|
||||
.subtitle("Watermark size relative to image")
|
||||
.adjustment(>k::Adjustment::new(20.0, 1.0, 100.0, 1.0, 5.0, 0.0))
|
||||
.subtitle(&format!("{}%", cfg.watermark_scale.round() as i32))
|
||||
.visible(cfg.watermark_use_image)
|
||||
.build();
|
||||
let scale_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0);
|
||||
scale_scale.set_value(cfg.watermark_scale as f64);
|
||||
scale_scale.set_draw_value(false);
|
||||
scale_scale.set_hexpand(false);
|
||||
scale_scale.set_valign(gtk::Align::Center);
|
||||
scale_scale.set_width_request(180);
|
||||
let scale_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 20%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
scale_reset.set_sensitive((cfg.watermark_scale - 20.0).abs() > 0.5);
|
||||
scale_row.add_suffix(&scale_scale);
|
||||
scale_row.add_suffix(&scale_reset);
|
||||
|
||||
advanced_expander.add_row(&color_row);
|
||||
advanced_expander.add_row(&opacity_row);
|
||||
@@ -409,67 +385,274 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
advanced_expander.add_row(&scale_row);
|
||||
|
||||
advanced_group.add(&advanced_expander);
|
||||
content.append(&advanced_group);
|
||||
controls.append(&advanced_group);
|
||||
|
||||
// Scrollable controls
|
||||
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.width_request(360)
|
||||
.child(&controls)
|
||||
.build();
|
||||
|
||||
// === Main layout: 60/40 side-by-side ===
|
||||
let main_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
preview_box.set_width_request(400);
|
||||
main_box.append(&preview_box);
|
||||
main_box.append(&controls_scrolled);
|
||||
outer.append(&main_box);
|
||||
|
||||
// Preview state
|
||||
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// === Preview update closure ===
|
||||
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let update_preview = {
|
||||
let files = state.loaded_files.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let pic = preview_picture.clone();
|
||||
let info = info_label.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let bind_gen = preview_gen.clone();
|
||||
|
||||
Rc::new(move || {
|
||||
let loaded = files.borrow();
|
||||
if loaded.is_empty() {
|
||||
info.set_label("No images loaded");
|
||||
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = pidx.get().min(loaded.len() - 1);
|
||||
pidx.set(idx);
|
||||
let path = loaded[idx].clone();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||
|
||||
let cfg = jc.borrow();
|
||||
let wm_text = cfg.watermark_text.clone();
|
||||
let wm_use_image = cfg.watermark_use_image;
|
||||
let wm_image_path = cfg.watermark_image_path.clone();
|
||||
let wm_position = cfg.watermark_position;
|
||||
let wm_opacity = cfg.watermark_opacity;
|
||||
let wm_font_size = cfg.watermark_font_size;
|
||||
let wm_color = cfg.watermark_color;
|
||||
let wm_font_family = cfg.watermark_font_family.clone();
|
||||
let wm_tiled = cfg.watermark_tiled;
|
||||
let wm_margin = cfg.watermark_margin;
|
||||
let wm_scale = cfg.watermark_scale;
|
||||
let wm_rotation = cfg.watermark_rotation;
|
||||
let wm_enabled = cfg.watermark_enabled;
|
||||
drop(cfg);
|
||||
|
||||
let my_gen = bind_gen.get().wrapping_add(1);
|
||||
bind_gen.set(my_gen);
|
||||
let gen_check = bind_gen.clone();
|
||||
|
||||
let pic = pic.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Option<Vec<u8>> {
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
|
||||
let img = if wm_enabled {
|
||||
let position = match wm_position {
|
||||
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
|
||||
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
|
||||
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
|
||||
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
|
||||
4 => pixstrip_core::operations::WatermarkPosition::Center,
|
||||
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
|
||||
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
|
||||
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
|
||||
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
|
||||
};
|
||||
let rotation = if wm_rotation != 0 {
|
||||
Some(pixstrip_core::operations::WatermarkRotation::Custom(wm_rotation as f32))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let wm_config = if wm_use_image {
|
||||
wm_image_path.as_ref().map(|p| {
|
||||
pixstrip_core::operations::WatermarkConfig::Image {
|
||||
path: p.clone(),
|
||||
position,
|
||||
opacity: wm_opacity,
|
||||
scale: wm_scale / 100.0,
|
||||
rotation,
|
||||
tiled: wm_tiled,
|
||||
margin: wm_margin,
|
||||
}
|
||||
})
|
||||
} else if !wm_text.is_empty() {
|
||||
Some(pixstrip_core::operations::WatermarkConfig::Text {
|
||||
text: wm_text,
|
||||
position,
|
||||
font_size: wm_font_size,
|
||||
opacity: wm_opacity,
|
||||
color: wm_color,
|
||||
font_family: if wm_font_family.is_empty() { None } else { Some(wm_font_family) },
|
||||
rotation,
|
||||
tiled: wm_tiled,
|
||||
margin: wm_margin,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(config) = wm_config {
|
||||
pixstrip_core::operations::watermark::apply_watermark(img, &config).ok()?
|
||||
} else {
|
||||
img
|
||||
}
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
).ok()?;
|
||||
Some(buf)
|
||||
})();
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
if gen_check.get() != my_gen {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(Some(bytes)) => {
|
||||
let gbytes = glib::Bytes::from(&bytes);
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||
pic.set_paintable(Some(&texture));
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(None) => glib::ControlFlow::Break,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(_) => glib::ControlFlow::Break,
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Click-to-cycle on preview
|
||||
{
|
||||
let click = gtk::GestureClick::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
click.connect_released(move |_, _, _, _| {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
});
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
// Enable toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().watermark_enabled = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Type selector
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let text_group_c = text_group.clone();
|
||||
let image_group_c = image_group.clone();
|
||||
let scale_row_c = scale_row.clone();
|
||||
let up = update_preview.clone();
|
||||
type_row.connect_selected_notify(move |row| {
|
||||
let use_image = row.selected() == 1;
|
||||
jc.borrow_mut().watermark_use_image = use_image;
|
||||
text_group_c.set_visible(!use_image);
|
||||
image_group_c.set_visible(use_image);
|
||||
scale_row_c.set_visible(use_image);
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Text entry (debounced to avoid preview on every keystroke)
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let wl = watermark_label.clone();
|
||||
let up = update_preview.clone();
|
||||
let debounce_id: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
text_row.connect_changed(move |row| {
|
||||
let text = row.text().to_string();
|
||||
wl.set_label(&text);
|
||||
jc.borrow_mut().watermark_text = text;
|
||||
let up = up.clone();
|
||||
let did = debounce_id.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(300), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Font family
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
font_size_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_font_size = row.value() as f32;
|
||||
});
|
||||
}
|
||||
// Wire font family picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
font_button.connect_font_desc_notify(move |btn| {
|
||||
if let Some(desc) = btn.font_desc() {
|
||||
if let Some(family) = desc.family() {
|
||||
jc.borrow_mut().watermark_font_family = family.to_string();
|
||||
up();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Wire position grid buttons
|
||||
|
||||
// Font size
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
font_size_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_font_size = row.value() as f32;
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Position grid buttons
|
||||
for (i, btn) in buttons.iter().enumerate() {
|
||||
let jc = state.job_config.clone();
|
||||
let label = position_label.clone();
|
||||
let names = position_names;
|
||||
let all_buttons = buttons.clone();
|
||||
let wl = watermark_label.clone();
|
||||
let up = update_preview.clone();
|
||||
btn.connect_toggled(move |b| {
|
||||
if b.is_active() {
|
||||
jc.borrow_mut().watermark_position = i as u32;
|
||||
label.set_label(names[i]);
|
||||
set_watermark_alignment(&wl, i as u32);
|
||||
// Update icons
|
||||
for (j, other) in all_buttons.iter().enumerate() {
|
||||
let icon_name = if j == i {
|
||||
"radio-checked-symbolic"
|
||||
@@ -478,21 +661,15 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
};
|
||||
other.set_child(Some(>k::Image::from_icon_name(icon_name)));
|
||||
}
|
||||
up();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Color picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let wl = watermark_label.clone();
|
||||
opacity_row.connect_value_notify(move |row| {
|
||||
let val = row.value() as f32;
|
||||
wl.set_opacity(val as f64);
|
||||
jc.borrow_mut().watermark_opacity = val;
|
||||
});
|
||||
}
|
||||
// Wire color picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
color_button.connect_rgba_notify(move |btn| {
|
||||
let c = btn.rgba();
|
||||
jc.borrow_mut().watermark_color = [
|
||||
@@ -501,44 +678,123 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
(c.blue() * 255.0) as u8,
|
||||
(c.alpha() * 255.0) as u8,
|
||||
];
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire tiled toggle
|
||||
|
||||
// Opacity slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let row = opacity_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = opacity_reset.clone();
|
||||
opacity_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
let opacity = val as f32 / 100.0;
|
||||
jc.borrow_mut().watermark_opacity = opacity;
|
||||
row.set_subtitle(&format!("{}%", val));
|
||||
rst.set_sensitive(val != 50);
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = opacity_scale.clone();
|
||||
opacity_reset.connect_clicked(move |_| {
|
||||
scale.set_value(50.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Rotation slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let row = rotation_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = rotation_reset.clone();
|
||||
rotation_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_rotation = val;
|
||||
row.set_subtitle(&format!("{} degrees", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = rotation_scale.clone();
|
||||
rotation_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Tiled toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
tiled_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().watermark_tiled = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire margin spinner
|
||||
|
||||
// Margin slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
margin_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_margin = row.value() as u32;
|
||||
let row = margin_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = margin_reset.clone();
|
||||
margin_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_margin = val as u32;
|
||||
row.set_subtitle(&format!("{} px", val));
|
||||
rst.set_sensitive(val != 10);
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire scale spinner
|
||||
{
|
||||
let scale = margin_scale.clone();
|
||||
margin_reset.connect_clicked(move |_| {
|
||||
scale.set_value(10.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Scale slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
scale_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_scale = row.value() as f32;
|
||||
let row = scale_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = scale_reset.clone();
|
||||
scale_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_scale = val as f32;
|
||||
row.set_subtitle(&format!("{}%", val));
|
||||
rst.set_sensitive((val - 20).abs() > 0);
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire image chooser button
|
||||
{
|
||||
let scale = scale_scale.clone();
|
||||
scale_reset.connect_clicked(move |_| {
|
||||
scale.set_value(20.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Image chooser button
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let path_row = image_path_row.clone();
|
||||
let up = update_preview.clone();
|
||||
choose_image_button.connect_clicked(move |btn| {
|
||||
let jc = jc.clone();
|
||||
let path_row = path_row.clone();
|
||||
let up = up.clone();
|
||||
let dialog = gtk::FileDialog::builder()
|
||||
.title("Choose Watermark Image")
|
||||
.modal(true)
|
||||
.build();
|
||||
|
||||
let filter = gtk::FileFilter::new();
|
||||
filter.set_name(Some("PNG images"));
|
||||
filter.set_name(Some("Images"));
|
||||
filter.add_mime_type("image/png");
|
||||
filter.add_mime_type("image/svg+xml");
|
||||
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
|
||||
filters.append(&filter);
|
||||
dialog.set_filters(Some(&filters));
|
||||
@@ -550,22 +806,29 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
{
|
||||
path_row.set_subtitle(&path.display().to_string());
|
||||
jc.borrow_mut().watermark_image_path = Some(path);
|
||||
up();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Watermark")
|
||||
.tag("step-watermark")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
page.connect_map(move |_| {
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
@@ -26,38 +26,29 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
let builtin_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.max_children_per_line(4)
|
||||
.max_children_per_line(5)
|
||||
.min_children_per_line(2)
|
||||
.row_spacing(8)
|
||||
.column_spacing(8)
|
||||
.homogeneous(true)
|
||||
.build();
|
||||
|
||||
// Custom card is always first (index 0)
|
||||
let custom_card = build_custom_card();
|
||||
builtin_flow.append(&custom_card);
|
||||
|
||||
// Then all built-in presets (indices 1..=9)
|
||||
let builtins = Preset::all_builtins();
|
||||
for preset in &builtins {
|
||||
let card = build_preset_card(preset);
|
||||
builtin_flow.append(&card);
|
||||
}
|
||||
|
||||
// When a preset card is activated, apply it to JobConfig and advance
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
builtin_flow.connect_child_activated(move |flow, child| {
|
||||
let idx = child.index() as usize;
|
||||
if let Some(preset) = builtins.get(idx) {
|
||||
apply_preset_to_config(&mut jc.borrow_mut(), preset);
|
||||
}
|
||||
flow.activate_action("win.next-step", None).ok();
|
||||
});
|
||||
}
|
||||
|
||||
builtin_group.add(&builtin_flow);
|
||||
content.append(&builtin_group);
|
||||
|
||||
// Custom workflow section
|
||||
// Custom workflow section (hidden until Custom card is selected)
|
||||
let custom_group = adw::PreferencesGroup::builder()
|
||||
.title("Custom Workflow")
|
||||
.description("Choose which operations to include, then click Next")
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
let resize_check = adw::SwitchRow::builder()
|
||||
@@ -110,6 +101,36 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
custom_group.add(&watermark_check);
|
||||
custom_group.add(&rename_check);
|
||||
|
||||
// When a card is activated: Custom shows toggles, presets apply config and advance
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let custom_group_c = custom_group.clone();
|
||||
builtin_flow.connect_child_activated(move |flow, child| {
|
||||
let idx = child.index() as usize;
|
||||
if idx == 0 {
|
||||
// Custom card - show toggles, don't advance, enable step-by-step mode
|
||||
jc.borrow_mut().preset_mode = false;
|
||||
custom_group_c.set_visible(true);
|
||||
} else {
|
||||
// Built-in preset - apply config, skip intermediate steps, advance
|
||||
custom_group_c.set_visible(false);
|
||||
if let Some(preset) = builtins.get(idx - 1) {
|
||||
let mut cfg = jc.borrow_mut();
|
||||
apply_preset_to_config(&mut cfg, preset);
|
||||
cfg.preset_mode = true;
|
||||
}
|
||||
flow.activate_action("win.next-step", None).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let builtin_clamp = adw::Clamp::builder()
|
||||
.maximum_size(1200)
|
||||
.child(&builtin_flow)
|
||||
.build();
|
||||
builtin_group.add(&builtin_clamp);
|
||||
content.append(&builtin_group);
|
||||
|
||||
// Wire custom operation toggles to job config
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
@@ -154,21 +175,78 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
});
|
||||
}
|
||||
|
||||
content.append(&custom_group);
|
||||
|
||||
// User presets section
|
||||
let user_group = adw::PreferencesGroup::builder()
|
||||
.title("Your Presets")
|
||||
.description("Import or save your own workflows")
|
||||
.build();
|
||||
|
||||
// Show saved user presets
|
||||
// Container for dynamically-rebuilt user preset rows
|
||||
let user_rows_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.build();
|
||||
user_group.add(&user_rows_box);
|
||||
|
||||
let import_button = gtk::Button::builder()
|
||||
.label("Import Preset")
|
||||
.icon_name("document-open-symbolic")
|
||||
.action_name("win.import-preset")
|
||||
.build();
|
||||
import_button.add_css_class("flat");
|
||||
user_group.add(&import_button);
|
||||
content.append(&user_group);
|
||||
content.append(&custom_group);
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
// Drop target for .pixstrip-preset files
|
||||
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
||||
let jc_drop = state.job_config.clone();
|
||||
drop_target.connect_drop(move |_target, value, _x, _y| {
|
||||
if let Ok(file) = value.get::<gtk::gio::File>() {
|
||||
if let Some(path) = file.path() {
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("pixstrip-preset") {
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
if let Ok(preset) = store.import_from_file(&path) {
|
||||
apply_preset_to_config(&mut jc_drop.borrow_mut(), &preset);
|
||||
let _ = store.save(&preset);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
scrolled.add_controller(drop_target);
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Choose a Workflow")
|
||||
.tag("step-workflow")
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
// Refresh user presets every time this page is shown
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let rows_box = user_rows_box.clone();
|
||||
page.connect_map(move |_| {
|
||||
// Clear existing rows
|
||||
while let Some(child) = rows_box.first_child() {
|
||||
rows_box.remove(&child);
|
||||
}
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
if let Ok(presets) = store.list() {
|
||||
for preset in &presets {
|
||||
if !preset.is_custom {
|
||||
continue;
|
||||
}
|
||||
let list_box = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&preset.name)
|
||||
.subtitle(&preset.description)
|
||||
@@ -213,69 +291,35 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
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();
|
||||
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);
|
||||
group_ref.remove(&row_ref);
|
||||
rows_box_ref.remove(&list_box_ref);
|
||||
});
|
||||
row.add_suffix(&delete_btn);
|
||||
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
|
||||
let jc = state.job_config.clone();
|
||||
let jc2 = jc.clone();
|
||||
let p = preset.clone();
|
||||
row.connect_activated(move |r| {
|
||||
apply_preset_to_config(&mut jc.borrow_mut(), &p);
|
||||
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();
|
||||
});
|
||||
|
||||
user_group.add(&row);
|
||||
list_box.append(&row);
|
||||
rows_box.append(&list_box);
|
||||
}
|
||||
}
|
||||
|
||||
let import_button = gtk::Button::builder()
|
||||
.label("Import Preset")
|
||||
.icon_name("document-open-symbolic")
|
||||
.action_name("win.import-preset")
|
||||
.build();
|
||||
import_button.add_css_class("flat");
|
||||
user_group.add(&import_button);
|
||||
content.append(&user_group);
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
// Drop target for .pixstrip-preset files
|
||||
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
||||
let jc_drop = state.job_config.clone();
|
||||
drop_target.connect_drop(move |_target, value, _x, _y| {
|
||||
if let Ok(file) = value.get::<gtk::gio::File>() {
|
||||
if let Some(path) = file.path() {
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("pixstrip-preset") {
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
if let Ok(preset) = store.import_from_file(&path) {
|
||||
apply_preset_to_config(&mut jc_drop.borrow_mut(), &preset);
|
||||
let _ = store.save(&preset);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
clamp.add_controller(drop_target);
|
||||
}
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Choose a Workflow")
|
||||
.tag("step-workflow")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
page
|
||||
}
|
||||
|
||||
fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
||||
@@ -378,12 +422,12 @@ fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
fn build_custom_card() -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Start)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 120);
|
||||
@@ -391,10 +435,58 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(16)
|
||||
.margin_bottom(16)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("emblem-system-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label("Custom")
|
||||
.css_classes(["heading"])
|
||||
.build();
|
||||
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label("Pick and choose operations")
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.wrap(true)
|
||||
.justify(gtk::Justification::Center)
|
||||
.max_width_chars(20)
|
||||
.build();
|
||||
|
||||
inner.append(&icon);
|
||||
inner.append(&name_label);
|
||||
inner.append(&desc_label);
|
||||
card.append(&inner);
|
||||
|
||||
card
|
||||
}
|
||||
|
||||
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 120);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
@@ -425,4 +517,3 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
|
||||
card
|
||||
}
|
||||
|
||||
|
||||
11
pixstrip-gtk/src/utils.rs
Normal file
11
pixstrip-gtk/src/utils.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -49,19 +49,6 @@ impl WizardState {
|
||||
pub fn is_last_step(&self) -> bool {
|
||||
self.current_step == self.total_steps - 1
|
||||
}
|
||||
|
||||
pub fn go_next(&mut self) {
|
||||
if self.can_go_next() {
|
||||
self.current_step += 1;
|
||||
self.visited[self.current_step] = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go_back(&mut self) {
|
||||
if self.can_go_back() {
|
||||
self.current_step -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_wizard_pages(state: &AppState) -> Vec<adw::NavigationPage> {
|
||||
|
||||
Reference in New Issue
Block a user