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