diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index cfc7151..46ac283 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -318,6 +318,11 @@ pub struct RenameConfig { fn default_counter_position() -> u32 { 3 } impl RenameConfig { + /// Pre-compile the regex for batch use. Call once before a loop of apply_simple_compiled. + pub fn compile_regex(&self) -> Option { + rename::compile_rename_regex(&self.regex_find) + } + pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String { // 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); @@ -378,4 +383,68 @@ impl RenameConfig { format!("{}.{}", result, extension) } + + /// Like apply_simple but uses a pre-compiled regex (avoids recompiling per file). + pub fn apply_simple_compiled(&self, original_name: &str, extension: &str, index: u32, compiled_re: Option<®ex::Regex>) -> String { + // 1. Apply regex find-and-replace on the original name + let working_name = match compiled_re { + Some(re) => rename::apply_regex_replace_compiled(original_name, re, &self.regex_replace), + None => original_name.to_string(), + }; + + // 2. Apply space replacement + let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces); + + // 3. Apply special character filtering + let working_name = rename::apply_special_chars(&working_name, self.special_chars); + + // 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 + 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) + } } diff --git a/pixstrip-core/src/operations/watermark.rs b/pixstrip-core/src/operations/watermark.rs index a74162d..beae823 100644 --- a/pixstrip-core/src/operations/watermark.rs +++ b/pixstrip-core/src/operations/watermark.rs @@ -75,16 +75,34 @@ pub fn apply_watermark( } } +/// Cache for font data to avoid repeated filesystem walks during preview updates +static FONT_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); +static DEFAULT_FONT_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +struct FontCache { + entries: std::collections::HashMap>, +} + fn find_system_font(family: Option<&str>) -> Result> { - // If a specific font family was requested, try to find it via fontconfig + // If a specific font family was requested, check the cache first if let Some(name) = family { if !name.is_empty() { - // Try common font paths with the family name + let name_lower = name.to_lowercase(); + + // Check cache + if let Ok(cache) = FONT_CACHE.lock() { + if let Some(ref c) = *cache { + if let Some(data) = c.entries.get(&name_lower) { + return Ok(data.clone()); + } + } + } + + // Cache miss - search filesystem let search_dirs = [ "/usr/share/fonts", "/usr/local/share/fonts", ]; - let name_lower = name.to_lowercase(); for dir in &search_dirs { if let Ok(entries) = walkdir(std::path::Path::new(dir)) { for path in entries { @@ -97,6 +115,13 @@ fn find_system_font(family: Option<&str>) -> Result> { && (file_name.contains("regular") || (!file_name.contains("bold") && !file_name.contains("italic"))) { if let Ok(data) = std::fs::read(&path) { + // Store in cache + if let Ok(mut cache) = FONT_CACHE.lock() { + let c = cache.get_or_insert_with(|| FontCache { + entries: std::collections::HashMap::new(), + }); + c.entries.insert(name_lower, data.clone()); + } return Ok(data); } } @@ -106,26 +131,31 @@ fn find_system_font(family: Option<&str>) -> Result> { } } - // Fall back to default system fonts - let candidates = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", - "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/noto/NotoSans-Regular.ttf", - ]; - - for path in &candidates { - if let Ok(data) = std::fs::read(path) { - return Ok(data); + // Fall back to default system fonts (cached via OnceLock) + let default = DEFAULT_FONT_CACHE.get_or_init(|| { + let candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + ]; + for path in &candidates { + if let Ok(data) = std::fs::read(path) { + return Some(data); + } } - } + None + }); - Err(PixstripError::Processing { - operation: "watermark".into(), - reason: "No system font found for text watermark".into(), - }) + match default { + Some(data) => Ok(data.clone()), + None => Err(PixstripError::Processing { + operation: "watermark".into(), + reason: "No system font found for text watermark".into(), + }), + } } /// Recursively walk a directory and collect file paths (max depth 5) diff --git a/pixstrip-gtk/src/steps/step_compress.rs b/pixstrip-gtk/src/steps/step_compress.rs index 3d6e069..2457073 100644 --- a/pixstrip-gtk/src/steps/step_compress.rs +++ b/pixstrip-gtk/src/steps/step_compress.rs @@ -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> = Rc::new(Cell::new(0)); + let png_debounce: Rc> = Rc::new(Cell::new(0)); + let webp_debounce: Rc> = Rc::new(Cell::new(0)); + let avif_debounce: Rc> = 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); } + }); }); } { diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index 7ce5ee9..e594904 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -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::>>(); + std::thread::spawn(move || { + let result = (|| -> Option> { + 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::("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: >k::Widget) -> Option { None } -/// Load a thumbnail for the given path into the Picture widget -fn load_thumbnail(path: &std::path::Path, picture: >k::Picture, stack: >k::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 { let temp_dir = std::env::temp_dir().join("pixstrip-downloads"); diff --git a/pixstrip-gtk/src/steps/step_rename.rs b/pixstrip-gtk/src/steps/step_rename.rs index da3e575..d6a04a2 100644 --- a/pixstrip-gtk/src/steps/step_rename.rs +++ b/pixstrip-gtk/src/steps/step_rename.rs @@ -499,6 +499,23 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { let mut ext_counts: HashMap = 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 { diff --git a/pixstrip-gtk/src/steps/step_watermark.rs b/pixstrip-gtk/src/steps/step_watermark.rs index 0c89d71..a4be52a 100644 --- a/pixstrip-gtk/src/steps/step_watermark.rs +++ b/pixstrip-gtk/src/steps/step_watermark.rs @@ -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> = Rc::new(Cell::new(0)); + let rotation_debounce: Rc> = Rc::new(Cell::new(0)); + let margin_debounce: Rc> = Rc::new(Cell::new(0)); + let scale_debounce: Rc> = 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(); } + }); }); } {