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; 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 let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY); drop_target.set_types(&[gtk::gio::File::static_type()]); { let loaded_files = state.loaded_files.clone(); let excluded = state.excluded_files.clone(); let stack_ref = stack.clone(); let subfolder_choice = subfolder_choice.clone(); drop_target.connect_drop(move |target, value, _x, _y| { if let Ok(file) = value.get::() && let Some(path) = file.path() { 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); let count = files.len(); drop(files); refresh_grid(&stack_ref, &loaded_files, &excluded, count); } else { let choice = *subfolder_choice.borrow(); match choice { Some(true) => { let mut files = loaded_files.borrow_mut(); add_images_from_dir(&path, &mut files); let count = files.len(); drop(files); refresh_grid(&stack_ref, &loaded_files, &excluded, count); } Some(false) => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); let count = files.len(); drop(files); refresh_grid(&stack_ref, &loaded_files, &excluded, count); } None => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); let count = files.len(); drop(files); refresh_grid(&stack_ref, &loaded_files, &excluded, count); let loaded_files = loaded_files.clone(); let excluded = excluded.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, &stack_ref, &subfolder_choice, ); }); } } } return true; } else if is_image_file(&path) { let mut files = loaded_files.borrow_mut(); if !files.contains(&path) { files.push(path); } let count = files.len(); drop(files); refresh_grid(&stack_ref, &loaded_files, &excluded, count); return true; } } false }); } 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"), None => false, } } fn add_images_from_dir(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); } else if is_image_file(&path) && !files.contains(&path) { files.push(path); } } } } fn add_images_flat(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_file() && is_image_file(&path) && !files.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>>, 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 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, 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>>, 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); } } /// Walk the widget tree to find our ListStore and count label, then rebuild fn rebuild_grid_model( widget: >k::Widget, loaded_files: &Rc>>, excluded: &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(); let total_size: u64 = files.iter() .filter(|p| !excluded_set.contains(*p)) .filter_map(|p| std::fs::metadata(p).ok()) .map(|m| m.len()) .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>>, ) { 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(); let total_size: u64 = files.iter() .filter(|p| !excluded_set.contains(*p)) .filter_map(|p| std::fs::metadata(p).ok()) .map(|m| m.len()) .sum(); let size_str = format_size(total_size); 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 // ------------------------------------------------------------------ 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(); 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("Supported: JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP") .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"); 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(); // 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)") .build(); select_all_button.add_css_class("flat"); let deselect_all_button = gtk::Button::builder() .icon_name("edit-clear-symbolic") .tooltip_text("Deselect all images (Ctrl+Shift+A)") .build(); deselect_all_button.add_css_class("flat"); let clear_button = gtk::Button::builder() .icon_name("edit-clear-all-symbolic") .tooltip_text("Remove all images") .build(); clear_button.add_css_class("flat"); // 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 list_item = list_item.downcast_ref::().unwrap(); 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(); factory.connect_bind(move |_factory, list_item| { let list_item = list_item.downcast_ref::().unwrap(); let item = list_item.item().and_downcast::().unwrap(); let path = item.path().to_path_buf(); let vbox = list_item.child().and_downcast::().unwrap(); let overlay = vbox.first_child().and_downcast::().unwrap(); let name_label = overlay.next_sibling().and_downcast::().unwrap(); // 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 frame = overlay.child().and_downcast::().unwrap(); let thumb_stack = frame.child().and_downcast::().unwrap(); let picture = thumb_stack.child_by_name("picture") .and_downcast::().unwrap(); // Reset to placeholder thumb_stack.set_visible_child_name("placeholder"); // 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 || { load_thumbnail(&path_c, &picture_c, &thumb_stack_c); }); // 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); // Wire checkbox toggle let excl = excluded.clone(); let loaded_ref = loaded.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()); } } // 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); } }); // 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 list_item = list_item.downcast_ref::().unwrap(); let vbox = list_item.child().and_downcast::().unwrap(); let overlay = vbox.first_child().and_downcast::().unwrap(); 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(); 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") { set_all_checkboxes_in(&loaded_widget, true); update_count_label(&loaded_widget, &loaded, &excl); } }); } // Wire Deselect All { let excl = state.excluded_files.clone(); let loaded = state.loaded_files.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") { set_all_checkboxes_in(&loaded_widget, false); update_count_label(&loaded_widget, &loaded, &excl); } }); } toolbar.append(&count_label); toolbar.append(&select_all_button); toolbar.append(&deselect_all_button); toolbar.append(&add_button); toolbar.append(&clear_button); 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 } /// 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 } } } /// 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(); } }