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

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

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

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

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

View File

@@ -7,6 +7,7 @@ use std::path::PathBuf;
use std::rc::Rc;
use crate::app::AppState;
use crate::utils::format_size;
const THUMB_SIZE: i32 = 120;
@@ -183,7 +184,16 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
fn is_image_file(path: &std::path::Path) -> bool {
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) {
Some(ext) => matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"),
Some(ext) => matches!(ext.as_str(),
"jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"
| "ico" | "hdr" | "exr" | "pnm" | "ppm" | "pgm" | "pbm" | "pam"
| "tga" | "dds" | "ff" | "farbfeld" | "qoi"
| "heic" | "heif" | "jxl"
| "svg" | "svgz"
| "raw" | "cr2" | "cr3" | "nef" | "nrw" | "arw" | "srf" | "sr2"
| "orf" | "rw2" | "raf" | "dng" | "pef" | "srw" | "x3f"
| "pcx" | "xpm" | "xbm" | "wbmp" | "jp2" | "j2k" | "jpf" | "jpx"
),
None => false,
}
}
@@ -295,7 +305,7 @@ fn refresh_grid(
}
/// Walk the widget tree to find our ListStore and count label, then rebuild
fn rebuild_grid_model(
pub fn rebuild_grid_model(
widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
@@ -380,18 +390,6 @@ fn update_count_label(
update_heading_label(widget, count, included_count, &size_str);
}
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
// ------------------------------------------------------------------
// GObject wrapper for list store items
// ------------------------------------------------------------------
@@ -487,7 +485,7 @@ fn build_empty_state() -> gtk::Box {
.build();
let formats_label = gtk::Label::builder()
.label("Supported: JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP")
.label("Supports all common image formats including RAW")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.margin_top(8)
@@ -573,7 +571,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
// Factory: setup
{
factory.connect_setup(move |_factory, list_item| {
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let overlay = gtk::Overlay::builder()
.width_request(THUMB_SIZE)
@@ -656,13 +654,13 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
let excluded = state.excluded_files.clone();
let loaded = state.loaded_files.clone();
factory.connect_bind(move |_factory, list_item| {
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
let item = list_item.item().and_downcast::<ImageItem>().unwrap();
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let Some(item) = list_item.item().and_downcast::<ImageItem>() else { return };
let path = item.path().to_path_buf();
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
let name_label = overlay.next_sibling().and_downcast::<gtk::Label>().unwrap();
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
let Some(name_label) = overlay.next_sibling().and_downcast::<gtk::Label>() else { return };
// Set filename
let file_name = path.file_name()
@@ -671,19 +669,36 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
name_label.set_label(file_name);
// Get the frame -> stack -> picture
let frame = overlay.child().and_downcast::<gtk::Frame>().unwrap();
let thumb_stack = frame.child().and_downcast::<gtk::Stack>().unwrap();
let picture = thumb_stack.child_by_name("picture")
.and_downcast::<gtk::Picture>().unwrap();
let Some(frame) = overlay.child().and_downcast::<gtk::Frame>() else { return };
let Some(thumb_stack) = frame.child().and_downcast::<gtk::Stack>() else { return };
let Some(picture) = thumb_stack.child_by_name("picture")
.and_downcast::<gtk::Picture>() else { return };
// Reset to placeholder
thumb_stack.set_visible_child_name("placeholder");
// Bump bind generation so stale idle callbacks are ignored
let bind_gen: u32 = unsafe {
thumb_stack.data::<u32>("bind-gen")
.map(|p| *p.as_ref())
.unwrap_or(0)
.wrapping_add(1)
};
unsafe { thumb_stack.set_data("bind-gen", bind_gen); }
// Load thumbnail asynchronously
let thumb_stack_c = thumb_stack.clone();
let picture_c = picture.clone();
let path_c = path.clone();
glib::idle_add_local_once(move || {
let current: u32 = unsafe {
thumb_stack_c.data::<u32>("bind-gen")
.map(|p| *p.as_ref())
.unwrap_or(0)
};
if current != bind_gen {
return; // Item was recycled; skip stale load
}
load_thumbnail(&path_c, &picture_c, &thumb_stack_c);
});
@@ -725,9 +740,9 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
// Factory: unbind - disconnect signal to avoid stale closures
{
factory.connect_unbind(move |_factory, list_item| {
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
if let Some(check) = find_check_button(overlay.upcast_ref::<gtk::Widget>()) {
let handler: Option<glib::SignalHandlerId> = unsafe {