Fix 5 deferred performance/UX issues from audit

M8: Pre-compile regex once before rename preview loop instead of
recompiling per file. Adds apply_simple_compiled() to RenameConfig.

M9: Cache font data in watermark module using OnceLock (default font)
and Mutex<HashMap> (named fonts) to avoid repeated filesystem walks
during preview updates.

M12: Add 150ms debounce to watermark opacity, rotation, margin, and
scale sliders to avoid spawning preview threads on every pixel of
slider movement.

M13: Add 150ms debounce to compress per-format quality sliders (JPEG,
PNG, WebP, AVIF) for the same reason.

M14: Move thumbnail loading to background threads instead of blocking
the GTK main loop. Each thumbnail is decoded via image crate in a
spawned thread and delivered to the main thread via channel polling.
This commit is contained in:
2026-03-07 23:11:00 +02:00
parent 9fcbe237bd
commit 1a174d40a7
6 changed files with 249 additions and 65 deletions

View File

@@ -702,18 +702,31 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
});
}
// Per-format slider handlers: update config, set preview to that format, refresh
// Per-slider debounce counters (separate to avoid cross-slider cancellation)
let jpeg_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
let png_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
let webp_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
let avif_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
// Per-format slider handlers: update config, set preview to that format, refresh (debounced)
{
let jc = state.job_config.clone();
let row = jpeg_row.clone();
let pc = preview_comp.clone();
let up = update_preview.clone();
let did = jpeg_debounce.clone();
jpeg_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as u8;
jc.borrow_mut().jpeg_quality = val;
row.set_subtitle(&format!("{}", val));
pc.set(PreviewCompression::Jpeg(val));
up(false);
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(false); }
});
});
}
{
@@ -721,12 +734,19 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let row = png_row.clone();
let pc = preview_comp.clone();
let up = update_preview.clone();
let did = png_debounce.clone();
png_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as u8;
jc.borrow_mut().png_level = val;
row.set_subtitle(&format!("{}", val));
pc.set(PreviewCompression::Png(val));
up(false);
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(false); }
});
});
}
{
@@ -734,12 +754,19 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let row = webp_row.clone();
let pc = preview_comp.clone();
let up = update_preview.clone();
let did = webp_debounce.clone();
webp_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as u8;
jc.borrow_mut().webp_quality = val;
row.set_subtitle(&format!("{}", val));
pc.set(PreviewCompression::WebP(val));
up(false);
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(false); }
});
});
}
{
@@ -756,12 +783,19 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let row = avif_row.clone();
let pc = preview_comp.clone();
let up = update_preview.clone();
let did = avif_debounce.clone();
avif_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as u8;
jc.borrow_mut().avif_quality = val;
row.set_subtitle(&format!("{}", val));
pc.set(PreviewCompression::Avif(val));
up(false);
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(false); }
});
});
}
{

View File

@@ -732,20 +732,50 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
};
unsafe { thumb_stack.set_data("bind-gen", bind_gen); }
// Load thumbnail asynchronously
// Load thumbnail in background thread to avoid blocking the UI
let thumb_stack_c = thumb_stack.clone();
let picture_c = picture.clone();
let path_c = path.clone();
glib::idle_add_local_once(move || {
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
std::thread::spawn(move || {
let result = (|| -> Option<Vec<u8>> {
let img = image::open(&path_c).ok()?;
let thumb = img.resize(
(THUMB_SIZE * 2) as u32,
(THUMB_SIZE * 2) as u32,
image::imageops::FilterType::Triangle,
);
let mut buf = Vec::new();
thumb.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(50), 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
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) {
picture_c.set_paintable(Some(&texture));
thumb_stack_c.set_visible_child_name("picture");
}
glib::ControlFlow::Break
}
Ok(None) => glib::ControlFlow::Break, // decode failed, leave placeholder
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(_) => glib::ControlFlow::Break,
}
load_thumbnail(&path_c, &picture_c, &thumb_stack_c);
});
// Set checkbox state
@@ -935,22 +965,6 @@ fn find_check_button(widget: &gtk::Widget) -> Option<gtk::CheckButton> {
None
}
/// Load a thumbnail for the given path into the Picture widget
fn load_thumbnail(path: &std::path::Path, picture: &gtk::Picture, stack: &gtk::Stack) {
// Use GdkPixbuf to load at reduced size for speed
match gtk::gdk_pixbuf::Pixbuf::from_file_at_scale(path, THUMB_SIZE * 2, THUMB_SIZE * 2, true) {
Ok(pixbuf) => {
#[allow(deprecated)]
let texture = gtk::gdk::Texture::for_pixbuf(&pixbuf);
picture.set_paintable(Some(&texture));
stack.set_visible_child_name("picture");
}
Err(_) => {
// Leave placeholder visible
}
}
}
/// Download an image from a URL to a temporary file
fn download_image_url(url: &str) -> Option<std::path::PathBuf> {
let temp_dir = std::env::temp_dir().join("pixstrip-downloads");

View File

@@ -499,6 +499,23 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
let mut ext_counts: HashMap<String, usize> = HashMap::new();
let mut longest_name = 0usize;
// Pre-compile regex once for the entire batch (avoids recompiling per file)
let rename_cfg = pixstrip_core::operations::RenameConfig {
prefix: cfg.rename_prefix.clone(),
suffix: cfg.rename_suffix.clone(),
counter_start: cfg.rename_counter_start,
counter_padding: cfg.rename_counter_padding,
counter_enabled: cfg.rename_counter_enabled,
counter_position: cfg.rename_counter_position,
template: None,
case_mode: cfg.rename_case,
replace_spaces: cfg.rename_replace_spaces,
special_chars: cfg.rename_special_chars,
regex_find: cfg.rename_find.clone(),
regex_replace: cfg.rename_replace.clone(),
};
let compiled_re = rename_cfg.compile_regex();
for (i, path) in loaded.iter().enumerate() {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
@@ -511,21 +528,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
&cfg.rename_template, name, ext, counter, None,
)
} else {
let rename_cfg = pixstrip_core::operations::RenameConfig {
prefix: cfg.rename_prefix.clone(),
suffix: cfg.rename_suffix.clone(),
counter_start: cfg.rename_counter_start,
counter_padding: cfg.rename_counter_padding,
counter_enabled: cfg.rename_counter_enabled,
counter_position: cfg.rename_counter_position,
template: None,
case_mode: cfg.rename_case,
replace_spaces: cfg.rename_replace_spaces,
special_chars: cfg.rename_special_chars,
regex_find: cfg.rename_find.clone(),
regex_replace: cfg.rename_replace.clone(),
};
rename_cfg.apply_simple(name, ext, (i + 1) as u32)
rename_cfg.apply_simple_compiled(name, ext, (i + 1) as u32, compiled_re.as_ref())
};
if result.len() > longest_name {

View File

@@ -686,19 +686,32 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
});
}
// Per-slider debounce counters (separate to avoid cross-slider cancellation)
let opacity_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
let rotation_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
let margin_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
let scale_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
// Opacity slider
{
let jc = state.job_config.clone();
let row = opacity_row.clone();
let up = update_preview.clone();
let rst = opacity_reset.clone();
let did = opacity_debounce.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 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(); }
});
});
}
{
@@ -714,12 +727,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
let row = rotation_row.clone();
let up = update_preview.clone();
let rst = rotation_reset.clone();
let did = rotation_debounce.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 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(); }
});
});
}
{
@@ -745,12 +765,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
let row = margin_row.clone();
let up = update_preview.clone();
let rst = margin_reset.clone();
let did = margin_debounce.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();
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(); }
});
});
}
{
@@ -766,12 +793,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
let row = scale_row.clone();
let up = update_preview.clone();
let rst = scale_reset.clone();
let did = scale_debounce.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();
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(); }
});
});
}
{