use adw::prelude::*; use gtk::glib; use gtk::subclass::prelude::ObjectSubclassIsExt; use std::cell::RefCell; use std::collections::HashSet; use std::path::PathBuf; use std::rc::Rc; use crate::app::AppState; use crate::utils::format_size; const THUMB_SIZE: i32 = 120; pub fn build_images_page(state: &AppState) -> adw::NavigationPage { let stack = gtk::Stack::builder() .transition_type(gtk::StackTransitionType::Crossfade) .build(); // Empty state - drop zone let empty_state = build_empty_state(); stack.add_named(&empty_state, Some("empty")); // Loaded state - thumbnail grid let loaded_state = build_loaded_state(state); stack.add_named(&loaded_state, Some("loaded")); stack.set_visible_child_name("empty"); // Session-level remembered subfolder choice (None = not yet asked) let subfolder_choice: Rc>> = Rc::new(RefCell::new(None)); // Set up drag-and-drop on the entire page // Accept both FileList (from file managers) and single File let drop_target = gtk::DropTarget::new(gtk::gdk::FileList::static_type(), gtk::gdk::DragAction::COPY | gtk::gdk::DragAction::MOVE); drop_target.set_types(&[gtk::gdk::FileList::static_type(), gtk::gio::File::static_type(), glib::GString::static_type()]); drop_target.set_preload(true); { let loaded_files = state.loaded_files.clone(); let excluded = state.excluded_files.clone(); let sizes = state.file_sizes.clone(); let stack_ref = stack.clone(); let subfolder_choice = subfolder_choice.clone(); drop_target.connect_drop(move |target, value, _x, _y| { // Collect paths from FileList, single File, or URI text let mut paths: Vec = Vec::new(); if let Ok(file_list) = value.get::() { for file in file_list.files() { if let Some(path) = file.path() { paths.push(path); } } } else if let Ok(file) = value.get::() { if let Some(path) = file.path() { paths.push(path); } } else if let Ok(text) = value.get::() { // Handle URI text drops (from web browsers) let text = text.trim().to_string(); let lower = text.to_lowercase(); let is_image_url = (lower.starts_with("http://") || lower.starts_with("https://")) && (lower.ends_with(".jpg") || lower.ends_with(".jpeg") || lower.ends_with(".png") || lower.ends_with(".webp") || lower.ends_with(".gif") || lower.ends_with(".avif") || lower.ends_with(".tiff") || lower.ends_with(".bmp") || lower.contains(".jpg?") || lower.contains(".jpeg?") || lower.contains(".png?") || lower.contains(".webp?")); if is_image_url { let loaded = loaded_files.clone(); let excl = excluded.clone(); let sz = sizes.clone(); let sr = stack_ref.clone(); let (tx, rx) = std::sync::mpsc::channel::>(); let url = text.clone(); std::thread::spawn(move || { let result = download_image_url(&url); let _ = tx.send(result); }); glib::timeout_add_local(std::time::Duration::from_millis(100), move || { match rx.try_recv() { Ok(Some(path)) => { let mut files = loaded.borrow_mut(); if !files.contains(&path) { files.push(path); } let count = files.len(); drop(files); refresh_grid(&sr, &loaded, &excl, &sz, count); glib::ControlFlow::Break } Ok(None) => glib::ControlFlow::Break, Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(_) => glib::ControlFlow::Break, } }); return true; } return false; } if paths.is_empty() { return false; } for path in paths { if path.is_dir() { let has_subdirs = has_subfolders(&path); if !has_subdirs { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); drop(files); } else { let choice = *subfolder_choice.borrow(); match choice { Some(true) => { let mut files = loaded_files.borrow_mut(); add_images_from_dir(&path, &mut files); drop(files); } Some(false) => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); drop(files); } None => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); drop(files); let loaded_files = loaded_files.clone(); let excluded = excluded.clone(); let sz = sizes.clone(); let stack_ref = stack_ref.clone(); let subfolder_choice = subfolder_choice.clone(); let dir_path = path.clone(); let window = target.widget() .and_then(|w| w.root()) .and_then(|r| r.downcast::().ok()); glib::idle_add_local_once(move || { show_subfolder_prompt( window.as_ref(), &dir_path, &loaded_files, &excluded, &sz, &stack_ref, &subfolder_choice, ); }); } } } } else if is_image_file(&path) { let mut files = loaded_files.borrow_mut(); if !files.contains(&path) { files.push(path); } drop(files); } } let count = loaded_files.borrow().len(); refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); true }); } stack.add_controller(drop_target); adw::NavigationPage::builder() .title("Add Images") .tag("step-images") .child(&stack) .build() } 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" | "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, } } fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec) { let existing: std::collections::HashSet = 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, existing: &std::collections::HashSet, ) { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { add_images_from_dir_inner(&path, files, existing); } else if is_image_file(&path) && !existing.contains(&path) && !files.contains(&path) { files.push(path); } } } } fn add_images_flat(dir: &std::path::Path, files: &mut Vec) { let existing: std::collections::HashSet = files.iter().cloned().collect(); if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() && is_image_file(&path) && !existing.contains(&path) { files.push(path); } } } } fn has_subfolders(dir: &std::path::Path) -> bool { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { if entry.path().is_dir() { return true; } } } false } fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec) { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { add_images_from_dir(&path, files); } } } } fn show_subfolder_prompt( window: Option<>k::Window>, dir: &std::path::Path, loaded_files: &Rc>>, excluded: &Rc>>, file_sizes: &Rc>>, stack: >k::Stack, subfolder_choice: &Rc>>, ) { let dialog = adw::AlertDialog::builder() .heading("Include subfolders?") .body("This folder contains subfolders. Would you like to include images from subfolders too?") .build(); dialog.add_response("no", "Top-level Only"); dialog.add_response("yes", "Include Subfolders"); dialog.set_default_response(Some("yes")); dialog.set_response_appearance("yes", adw::ResponseAppearance::Suggested); let loaded_files = loaded_files.clone(); let excluded = excluded.clone(); let file_sizes = file_sizes.clone(); let stack = stack.clone(); let subfolder_choice = subfolder_choice.clone(); let dir = dir.to_path_buf(); dialog.connect_response(None, move |_dialog, response| { let include_subdirs = response == "yes"; *subfolder_choice.borrow_mut() = Some(include_subdirs); if include_subdirs { let mut files = loaded_files.borrow_mut(); add_images_from_subdirs(&dir, &mut files); let count = files.len(); drop(files); refresh_grid(&stack, &loaded_files, &excluded, &file_sizes, count); } }); if let Some(win) = window { dialog.present(Some(win)); } } // ------------------------------------------------------------------ // Thumbnail grid // ------------------------------------------------------------------ /// Refresh the grid view and count label from current loaded_files state fn refresh_grid( stack: >k::Stack, loaded_files: &Rc>>, excluded: &Rc>>, file_sizes: &Rc>>, count: usize, ) { if count > 0 { stack.set_visible_child_name("loaded"); } else { stack.set_visible_child_name("empty"); } if let Some(loaded_widget) = stack.child_by_name("loaded") { rebuild_grid_model(&loaded_widget, loaded_files, excluded, file_sizes); } } /// Walk the widget tree to find our ListStore and count label, then rebuild pub fn rebuild_grid_model( widget: >k::Widget, loaded_files: &Rc>>, excluded: &Rc>>, file_sizes: &Rc>>, ) { let files = loaded_files.borrow(); let excluded_set = excluded.borrow(); let count = files.len(); let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count(); // Populate size cache for any new files { let mut sizes = file_sizes.borrow_mut(); for p in files.iter() { sizes.entry(p.clone()).or_insert_with(|| { std::fs::metadata(p).map(|m| m.len()).unwrap_or(0) }); } } let sizes = file_sizes.borrow(); let total_size: u64 = files.iter() .filter(|p| !excluded_set.contains(*p)) .map(|p| sizes.get(p).copied().unwrap_or(0)) .sum(); let size_str = format_size(total_size); // Update the count label update_heading_label(widget, count, included_count, &size_str); // Find the GridView and rebuild its model if let Some(grid_view) = find_grid_view(widget) { if let Some(model) = grid_view.model() && let Some(sel) = model.downcast_ref::() && let Some(store) = sel.model() && let Some(store) = store.downcast_ref::() { store.remove_all(); for path in files.iter() { let item = ImageItem::new(path); store.append(&item); } } } } fn find_grid_view(widget: >k::Widget) -> Option { if let Some(gv) = widget.downcast_ref::() { return Some(gv.clone()); } let mut child = widget.first_child(); while let Some(c) = child { if let Some(gv) = find_grid_view(&c) { return Some(gv); } child = c.next_sibling(); } None } fn update_heading_label(widget: >k::Widget, count: usize, included_count: usize, size_str: &str) { if let Some(label) = widget.downcast_ref::() && label.css_classes().iter().any(|c| c == "heading") { if included_count == count { label.set_label(&format!("{} images ({})", count, size_str)); } else { label.set_label(&format!("{}/{} images selected ({})", included_count, count, size_str)); } } let mut child = widget.first_child(); while let Some(c) = child { update_heading_label(&c, count, included_count, size_str); child = c.next_sibling(); } } /// Update only the count label without rebuilding the grid fn update_count_label( widget: >k::Widget, loaded_files: &Rc>>, excluded: &Rc>>, file_sizes: &Rc>>, ) { let files = loaded_files.borrow(); let excluded_set = excluded.borrow(); let sizes = file_sizes.borrow(); let count = files.len(); let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count(); let total_size: u64 = files.iter() .filter(|p| !excluded_set.contains(*p)) .map(|p| sizes.get(p).copied().unwrap_or(0)) .sum(); let size_str = format_size(total_size); update_heading_label(widget, count, included_count, &size_str); } // ------------------------------------------------------------------ // GObject wrapper for list store items // ------------------------------------------------------------------ mod imp { use super::*; use std::cell::OnceCell; #[derive(Default)] pub struct ImageItem { pub path: OnceCell, } #[glib::object_subclass] impl glib::subclass::types::ObjectSubclass for ImageItem { const NAME: &'static str = "PixstripImageItem"; type Type = super::ImageItem; type ParentType = glib::Object; } impl glib::subclass::object::ObjectImpl for ImageItem {} } glib::wrapper! { pub struct ImageItem(ObjectSubclass); } impl ImageItem { fn new(path: &std::path::Path) -> Self { let obj: Self = glib::Object::builder().build(); let _ = obj.imp().path.set(path.to_path_buf()); obj } pub fn path(&self) -> &std::path::Path { self.imp().path.get().map(|p: &PathBuf| p.as_path()).unwrap_or(std::path::Path::new("")) } } // ------------------------------------------------------------------ // Empty state // ------------------------------------------------------------------ fn build_empty_state() -> gtk::Box { let container = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .vexpand(true) .hexpand(true) .build(); let drop_zone = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(12) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .vexpand(true) .margin_top(48) .margin_bottom(48) .margin_start(48) .margin_end(48) .build(); drop_zone.add_css_class("card"); drop_zone.update_property(&[ gtk::accessible::Property::Label("Image drop zone. Drop images here or use the Browse Files button."), ]); let inner = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(12) .margin_top(48) .margin_bottom(48) .margin_start(64) .margin_end(64) .halign(gtk::Align::Center) .build(); let icon = gtk::Image::builder() .icon_name("folder-pictures-symbolic") .pixel_size(64) .css_classes(["dim-label"]) .build(); icon.set_accessible_role(gtk::AccessibleRole::Presentation); let title = gtk::Label::builder() .label("Drop images here") .css_classes(["title-2"]) .build(); let subtitle = gtk::Label::builder() .label("or click Browse to select files.\nYou can also drop folders.") .css_classes(["dim-label"]) .halign(gtk::Align::Center) .justify(gtk::Justification::Center) .build(); let formats_label = gtk::Label::builder() .label("Supports all common image formats including RAW") .css_classes(["dim-label", "caption"]) .halign(gtk::Align::Center) .margin_top(8) .build(); let browse_button = gtk::Button::builder() .label("Browse Files") .tooltip_text("Add image files (Ctrl+O)") .halign(gtk::Align::Center) .action_name("win.add-files") .build(); browse_button.add_css_class("suggested-action"); browse_button.add_css_class("pill"); let hint = gtk::Label::builder() .label("Start by adding your images below, then use the Next button to configure each processing step.") .css_classes(["dim-label", "caption"]) .halign(gtk::Align::Center) .justify(gtk::Justification::Center) .wrap(true) .margin_bottom(8) .build(); inner.append(&hint); inner.append(&icon); inner.append(&title); inner.append(&subtitle); inner.append(&formats_label); inner.append(&browse_button); drop_zone.append(&inner); container.append(&drop_zone); container } // ------------------------------------------------------------------ // Loaded state with thumbnail GridView // ------------------------------------------------------------------ fn build_loaded_state(state: &AppState) -> gtk::Box { let container = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(0) .build(); // Use the shared batch_updating flag from AppState let batch_updating = state.batch_updating.clone(); // Toolbar let toolbar = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(8) .margin_start(12) .margin_end(12) .margin_top(8) .margin_bottom(8) .build(); let count_label = gtk::Label::builder() .label("0 images") .hexpand(true) .halign(gtk::Align::Start) .css_classes(["heading"]) .build(); let add_button = gtk::Button::builder() .icon_name("list-add-symbolic") .tooltip_text("Add more images (Ctrl+O)") .action_name("win.add-files") .build(); add_button.add_css_class("flat"); let select_all_button = gtk::Button::builder() .icon_name("edit-select-all-symbolic") .tooltip_text("Select all images (Ctrl+A)") .sensitive(false) .build(); 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() .icon_name("edit-clear-symbolic") .tooltip_text("Deselect all images (Ctrl+Shift+A)") .sensitive(false) .build(); 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() .icon_name("edit-clear-all-symbolic") .tooltip_text("Remove all images") .sensitive(false) .build(); clear_button.add_css_class("flat"); clear_button.update_property(&[ gtk::accessible::Property::Label("Remove all images from list"), ]); // Build the grid view model let store = gtk::gio::ListStore::new::(); let selection = gtk::NoSelection::new(Some(store.clone())); let factory = gtk::SignalListItemFactory::new(); // Factory: setup { factory.connect_setup(move |_factory, list_item| { let Some(list_item) = list_item.downcast_ref::() else { return }; let overlay = gtk::Overlay::builder() .width_request(THUMB_SIZE) .height_request(THUMB_SIZE) .build(); let picture = gtk::Picture::builder() .content_fit(gtk::ContentFit::Cover) .width_request(THUMB_SIZE) .height_request(THUMB_SIZE) .build(); picture.add_css_class("thumbnail-image"); // Placeholder icon shown while loading let placeholder = gtk::Image::builder() .icon_name("image-x-generic-symbolic") .pixel_size(48) .css_classes(["dim-label"]) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .build(); let thumb_stack = gtk::Stack::builder() .transition_type(gtk::StackTransitionType::Crossfade) .transition_duration(150) .width_request(THUMB_SIZE) .height_request(THUMB_SIZE) .build(); thumb_stack.add_named(&placeholder, Some("placeholder")); thumb_stack.add_named(&picture, Some("picture")); thumb_stack.set_visible_child_name("placeholder"); // Frame to clip rounded corners let frame = gtk::Frame::builder() .child(&thumb_stack) .overflow(gtk::Overflow::Hidden) .build(); frame.add_css_class("thumbnail-frame"); overlay.set_child(Some(&frame)); // Checkbox overlay in top-left corner let check = gtk::CheckButton::builder() .active(true) .tooltip_text("Include in processing") .halign(gtk::Align::Start) .valign(gtk::Align::Start) .margin_start(4) .margin_top(4) .build(); check.add_css_class("thumbnail-check"); overlay.add_overlay(&check); // File name label at the bottom let name_label = gtk::Label::builder() .css_classes(["caption"]) .halign(gtk::Align::Center) .ellipsize(gtk::pango::EllipsizeMode::Middle) .max_width_chars(14) .build(); let vbox = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(4) .halign(gtk::Align::Center) .margin_top(4) .margin_bottom(4) .margin_start(4) .margin_end(4) .build(); vbox.append(&overlay); vbox.append(&name_label); list_item.set_child(Some(&vbox)); }); } // Factory: bind { let excluded = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); let cached_sizes = state.file_sizes.clone(); let batch_flag = batch_updating.clone(); factory.connect_bind(move |_factory, list_item| { let Some(list_item) = list_item.downcast_ref::() else { return }; let Some(item) = list_item.item().and_downcast::() else { return }; let path = item.path().to_path_buf(); let Some(vbox) = list_item.child().and_downcast::() else { return }; let Some(overlay) = vbox.first_child().and_downcast::() else { return }; let Some(name_label) = overlay.next_sibling().and_downcast::() else { return }; // Set filename let file_name = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); name_label.set_label(file_name); // Get the frame -> stack -> picture let Some(frame) = overlay.child().and_downcast::() else { return }; let Some(thumb_stack) = frame.child().and_downcast::() else { return }; let Some(picture) = thumb_stack.child_by_name("picture") .and_downcast::() 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::("bind-gen") .map(|p| *p.as_ref()) .unwrap_or(0) .wrapping_add(1) }; unsafe { thumb_stack.set_data("bind-gen", bind_gen); } // 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(); 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 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, } }); // Set accessible label on thumbnail picture picture.update_property(&[ gtk::accessible::Property::Label(&format!("Thumbnail of {}", file_name)), ]); // Set checkbox state let check = find_check_button(overlay.upcast_ref::()); if let Some(ref check) = check { let is_excluded = excluded.borrow().contains(&path); check.set_active(!is_excluded); check.update_property(&[ gtk::accessible::Property::Label(&format!("Include {} in processing", file_name)), ]); // Wire checkbox toggle let excl = excluded.clone(); let loaded_ref = loaded.clone(); let sizes_ref = cached_sizes.clone(); let batch_guard = batch_flag.clone(); let file_path = path.clone(); let handler_id = check.connect_toggled(move |btn| { { let mut excl = excl.borrow_mut(); if btn.is_active() { excl.remove(&file_path); } else { excl.insert(file_path.clone()); } } // Skip per-toggle label update during batch select/deselect if batch_guard.get() { return; } // Update count label if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) && let Some(stack) = parent.downcast_ref::() && let Some(loaded_widget) = stack.child_by_name("loaded") { update_count_label(&loaded_widget, &loaded_ref, &excl, &sizes_ref); } }); // Store handler id so we can disconnect in unbind unsafe { check.set_data("handler-id", handler_id); } } }); } // Factory: unbind - disconnect signal to avoid stale closures { factory.connect_unbind(move |_factory, list_item| { let Some(list_item) = list_item.downcast_ref::() else { return }; let Some(vbox) = list_item.child().and_downcast::() else { return }; let Some(overlay) = vbox.first_child().and_downcast::() else { return }; if let Some(check) = find_check_button(overlay.upcast_ref::()) { let handler: Option = unsafe { check.steal_data("handler-id") }; if let Some(id) = handler { check.disconnect(id); } } }); } let grid_view = gtk::GridView::builder() .model(&selection) .factory(&factory) .min_columns(2) .max_columns(10) .build(); grid_view.add_css_class("thumbnail-grid"); grid_view.update_property(&[ gtk::accessible::Property::Label("Image thumbnail grid. Use arrow keys to navigate."), ]); let scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&grid_view) .build(); // Wire clear button { let files = state.loaded_files.clone(); let excl = state.excluded_files.clone(); let count_label_c = count_label.clone(); let store_c = store.clone(); clear_button.connect_clicked(move |btn| { files.borrow_mut().clear(); excl.borrow_mut().clear(); store_c.remove_all(); count_label_c.set_label("0 images"); if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) && let Some(stack) = parent.downcast_ref::() { stack.set_visible_child_name("empty"); } }); } // Wire Select All { let excl = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); let sizes = state.file_sizes.clone(); let batch_flag = batch_updating.clone(); select_all_button.connect_clicked(move |btn| { excl.borrow_mut().clear(); if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) && let Some(stack) = parent.downcast_ref::() && let Some(loaded_widget) = stack.child_by_name("loaded") { batch_flag.set(true); set_all_checkboxes_in(&loaded_widget, true); batch_flag.set(false); update_count_label(&loaded_widget, &loaded, &excl, &sizes); } }); } // Wire Deselect All { let excl = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); let sizes = state.file_sizes.clone(); let batch_flag = batch_updating.clone(); deselect_all_button.connect_clicked(move |btn| { { let files = loaded.borrow(); let mut excl_set = excl.borrow_mut(); for f in files.iter() { excl_set.insert(f.clone()); } } if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) && let Some(stack) = parent.downcast_ref::() && let Some(loaded_widget) = stack.child_by_name("loaded") { batch_flag.set(true); set_all_checkboxes_in(&loaded_widget, false); batch_flag.set(false); update_count_label(&loaded_widget, &loaded, &excl, &sizes); } }); } toolbar.append(&count_label); toolbar.append(&select_all_button); toolbar.append(&deselect_all_button); toolbar.append(&add_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(&[ gtk::accessible::Property::Label("Image toolbar with count, selection, and add controls"), ]); let separator = gtk::Separator::new(gtk::Orientation::Horizontal); container.append(&toolbar); container.append(&separator); container.append(&scrolled); container } fn find_check_button(widget: >k::Widget) -> Option { if let Some(cb) = widget.downcast_ref::() { return Some(cb.clone()); } let mut child = widget.first_child(); while let Some(c) = child { if let Some(cb) = find_check_button(&c) { return Some(cb); } child = c.next_sibling(); } None } /// 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"); std::fs::create_dir_all(&temp_dir).ok()?; // Extract and sanitize filename from URL to prevent path traversal let url_path = url.split('?').next().unwrap_or(url); let raw_name = url_path .rsplit('/') .next() .unwrap_or("downloaded.jpg"); let sanitized = std::path::Path::new(raw_name) .file_name() .and_then(|f| f.to_str()) .unwrap_or("downloaded.jpg"); let filename = if sanitized.is_empty() { "downloaded.jpg" } else { sanitized }; // Avoid overwriting previous downloads with the same filename let dest = { let base = temp_dir.join(filename); if base.exists() { let stem = std::path::Path::new(filename).file_stem() .and_then(|s| s.to_str()).unwrap_or("downloaded"); let ext = std::path::Path::new(filename).extension() .and_then(|e| e.to_str()).unwrap_or("jpg"); let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()).unwrap_or(0); temp_dir.join(format!("{}_{}.{}", stem, ts, ext)) } else { base } }; // Use GIO for the download (synchronous, runs in background thread) let gfile = gtk::gio::File::for_uri(url); let stream = gfile.read(gtk::gio::Cancellable::NONE).ok()?; const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024; // 100 MB let mut buf = Vec::new(); loop { let bytes = stream.read_bytes(8192, gtk::gio::Cancellable::NONE).ok()?; if bytes.is_empty() { break; } buf.extend_from_slice(&bytes); if buf.len() > MAX_DOWNLOAD_BYTES { return None; } } if buf.is_empty() { return None; } std::fs::write(&dest, &buf).ok()?; // Verify it's actually an image if is_image_file(&dest) { Some(dest) } else { let _ = std::fs::remove_file(&dest); None } } /// Set all CheckButton widgets within a container to a given state pub fn set_all_checkboxes_in(widget: >k::Widget, active: bool) { if let Some(check) = widget.downcast_ref::() { check.set_active(active); return; } let mut child = widget.first_child(); while let Some(c) = child { set_all_checkboxes_in(&c, active); child = c.next_sibling(); } }