diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index d4acfee..9912181 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -1,6 +1,15 @@ 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) @@ -10,15 +19,14 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { let empty_state = build_empty_state(); stack.add_named(&empty_state, Some("empty")); - // Loaded state - file list + // 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: std::rc::Rc>> = - std::rc::Rc::new(std::cell::RefCell::new(None)); + 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); @@ -36,40 +44,35 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { if path.is_dir() { let has_subdirs = has_subfolders(&path); if !has_subdirs { - // No subfolders - just load top-level images let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); let count = files.len(); drop(files); - update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, count); } else { let choice = *subfolder_choice.borrow(); match choice { Some(true) => { - // Remembered: include subfolders let mut files = loaded_files.borrow_mut(); add_images_from_dir(&path, &mut files); let count = files.len(); drop(files); - update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, count); } Some(false) => { - // Remembered: top-level only let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); let count = files.len(); drop(files); - update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, count); } None => { - // Not yet asked - add top-level now, then prompt let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); let count = files.len(); drop(files); - update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, count); - // Show dialog asynchronously let loaded_files = loaded_files.clone(); let excluded = excluded.clone(); let stack_ref = stack_ref.clone(); @@ -78,7 +81,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { let window = target.widget() .and_then(|w| w.root()) .and_then(|r| r.downcast::().ok()); - gtk::glib::idle_add_local_once(move || { + glib::idle_add_local_once(move || { show_subfolder_prompt( window.as_ref(), &dir_path, @@ -99,7 +102,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { } let count = files.len(); drop(files); - update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); + refresh_grid(&stack_ref, &loaded_files, &excluded, count); return true; } } @@ -123,7 +126,7 @@ fn is_image_file(path: &std::path::Path) -> bool { } } -fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec) { +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(); @@ -136,8 +139,7 @@ fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec) { +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(); @@ -148,7 +150,6 @@ fn add_images_flat(dir: &std::path::Path, files: &mut Vec) { } } -/// Check if a directory contains any subdirectories fn has_subfolders(dir: &std::path::Path) -> bool { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { @@ -160,8 +161,7 @@ fn has_subfolders(dir: &std::path::Path) -> bool { false } -/// Add only the images from subfolders (not top-level, since those were already added) -fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec) { +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(); @@ -175,10 +175,10 @@ fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec, dir: &std::path::Path, - loaded_files: &std::rc::Rc>>, - excluded: &std::rc::Rc>>, + loaded_files: &Rc>>, + excluded: &Rc>>, stack: >k::Stack, - subfolder_choice: &std::rc::Rc>>, + subfolder_choice: &Rc>>, ) { let dialog = adw::AlertDialog::builder() .heading("Include subfolders?") @@ -202,7 +202,7 @@ fn show_subfolder_prompt( add_images_from_subdirs(&dir, &mut files); let count = files.len(); drop(files); - update_loaded_ui(&stack, &loaded_files, &excluded, count); + refresh_grid(&stack, &loaded_files, &excluded, count); } }); @@ -211,10 +211,15 @@ fn show_subfolder_prompt( } } -fn update_loaded_ui( +// ------------------------------------------------------------------ +// Thumbnail grid +// ------------------------------------------------------------------ + +/// Refresh the grid view and count label from current loaded_files state +fn refresh_grid( stack: >k::Stack, - loaded_files: &std::rc::Rc>>, - excluded: &std::rc::Rc>>, + loaded_files: &Rc>>, + excluded: &Rc>>, count: usize, ) { if count > 0 { @@ -223,14 +228,15 @@ fn update_loaded_ui( stack.set_visible_child_name("empty"); } if let Some(loaded_widget) = stack.child_by_name("loaded") { - update_count_and_list(&loaded_widget, loaded_files, excluded); + rebuild_grid_model(&loaded_widget, loaded_files, excluded); } } -fn update_count_and_list( +/// Walk the widget tree to find our ListStore and count label, then rebuild +fn rebuild_grid_model( widget: >k::Widget, - loaded_files: &std::rc::Rc>>, - excluded: &std::rc::Rc>>, + loaded_files: &Rc>>, + excluded: &Rc>>, ) { let files = loaded_files.borrow(); let excluded_set = excluded.borrow(); @@ -243,18 +249,40 @@ fn update_count_and_list( .sum(); let size_str = format_size(total_size); - walk_loaded_widgets(widget, count, included_count, &size_str, &files, loaded_files, excluded); + // 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 walk_loaded_widgets( - widget: >k::Widget, - count: usize, - included_count: usize, - size_str: &str, - files: &[std::path::PathBuf], - loaded_files: &std::rc::Rc>>, - excluded: &std::rc::Rc>>, -) { +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") { @@ -264,117 +292,18 @@ fn walk_loaded_widgets( label.set_label(&format!("{}/{} images selected ({})", included_count, count, size_str)); } } - if let Some(list_box) = widget.downcast_ref::() - && list_box.css_classes().iter().any(|c| c == "boxed-list") - { - // Clear existing rows - while let Some(row) = list_box.first_child() { - list_box.remove(&row); - } - let excluded_set = excluded.borrow(); - // Add rows for each file with checkbox and remove button - for (idx, path) in files.iter().enumerate() { - let name = path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - let size = std::fs::metadata(path) - .map(|m| format_size(m.len())) - .unwrap_or_default(); - let ext = path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_uppercase(); - let row = adw::ActionRow::builder() - .title(name) - .subtitle(format!("{} - {}", ext, size)) - .build(); - - // Include/exclude checkbox - let check = gtk::CheckButton::builder() - .active(!excluded_set.contains(path)) - .tooltip_text("Include in processing") - .valign(gtk::Align::Center) - .build(); - { - let excl = excluded.clone(); - let file_path = path.clone(); - let list = list_box.clone(); - let loaded = loaded_files.clone(); - let excluded_ref = excluded.clone(); - 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 the count label - if let Some(parent) = list.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, &excluded_ref); - } - }); - } - row.add_prefix(&check); - row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); - - // Per-image remove button - let remove_btn = gtk::Button::builder() - .icon_name("list-remove-symbolic") - .tooltip_text("Remove this image from batch") - .valign(gtk::Align::Center) - .build(); - remove_btn.add_css_class("flat"); - { - let loaded = loaded_files.clone(); - let excl = excluded.clone(); - let list = list_box.clone(); - let file_idx = idx; - remove_btn.connect_clicked(move |_btn| { - let removed_path; - { - let mut files = loaded.borrow_mut(); - if file_idx < files.len() { - removed_path = files.remove(file_idx); - } else { - return; - } - } - excl.borrow_mut().remove(&removed_path); - let count = loaded.borrow().len(); - // Refresh by finding the parent stack - if let Some(parent) = list.ancestor(gtk::Stack::static_type()) - && let Some(stack) = parent.downcast_ref::() - { - if count == 0 { - stack.set_visible_child_name("empty"); - } else if let Some(loaded_widget) = stack.child_by_name("loaded") { - update_count_and_list(&loaded_widget, &loaded, &excl); - } - } - }); - } - row.add_suffix(&remove_btn); - list_box.append(&row); - } - } - // Recurse let mut child = widget.first_child(); while let Some(c) = child { - walk_loaded_widgets(&c, count, included_count, size_str, files, loaded_files, excluded); + update_heading_label(&c, count, included_count, size_str); child = c.next_sibling(); } } -/// Update only the count label without rebuilding the list +/// Update only the count label without rebuilding the grid fn update_count_label( widget: >k::Widget, - loaded_files: &std::rc::Rc>>, - excluded: &std::rc::Rc>>, + loaded_files: &Rc>>, + excluded: &Rc>>, ) { let files = loaded_files.borrow(); let excluded_set = excluded.borrow(); @@ -386,25 +315,7 @@ fn update_count_label( .map(|m| m.len()) .sum(); let size_str = format_size(total_size); - - update_count_label_walk(widget, count, included_count, &size_str); -} - -fn update_count_label_walk(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_count_label_walk(&c, count, included_count, size_str); - child = c.next_sibling(); - } + update_heading_label(widget, count, included_count, &size_str); } fn format_size(bytes: u64) -> String { @@ -419,6 +330,49 @@ fn format_size(bytes: u64) -> String { } } +// ------------------------------------------------------------------ +// 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) @@ -497,6 +451,10 @@ fn build_empty_state() -> gtk::Box { container } +// ------------------------------------------------------------------ +// Loaded state with thumbnail GridView +// ------------------------------------------------------------------ + fn build_loaded_state(state: &AppState) -> gtk::Box { let container = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -545,14 +503,205 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { .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"); + + 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::() @@ -562,7 +711,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { }); } - // Wire Select All - clears exclusion set, re-checks all checkboxes + // Wire Select All { let excl = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); @@ -578,7 +727,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { }); } - // Wire Deselect All - adds all files to exclusion set, unchecks all + // Wire Deselect All { let excl = state.excluded_files.clone(); let loaded = state.loaded_files.clone(); @@ -608,35 +757,48 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { let separator = gtk::Separator::new(gtk::Orientation::Horizontal); - // File list - let list_scrolled = gtk::ScrolledWindow::builder() - .hscrollbar_policy(gtk::PolicyType::Never) - .vexpand(true) - .build(); - - let list_box = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .margin_start(12) - .margin_end(12) - .margin_top(8) - .margin_bottom(8) - .build(); - - list_scrolled.set_child(Some(&list_box)); - container.append(&toolbar); container.append(&separator); - container.append(&list_scrolled); + 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; // Don't recurse into CheckButton children + return; } let mut child = widget.first_child(); while let Some(c) = child {