use adw::prelude::*; use crate::app::AppState; 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 - file list let loaded_state = build_loaded_state(state); stack.add_named(&loaded_state, Some("loaded")); stack.set_visible_child_name("empty"); // 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(); 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 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); 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); update_loaded_ui(&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 update_loaded_ui( stack: >k::Stack, loaded_files: &std::rc::Rc>>, excluded: &std::rc::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") { update_count_and_list(&loaded_widget, loaded_files, excluded); } } fn update_count_and_list( widget: >k::Widget, loaded_files: &std::rc::Rc>>, excluded: &std::rc::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); walk_loaded_widgets(widget, count, included_count, &size_str, &files, loaded_files, excluded); } 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>>, ) { 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)); } } 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); child = c.next_sibling(); } } /// Update only the count label without rebuilding the list fn update_count_label( widget: >k::Widget, loaded_files: &std::rc::Rc>>, excluded: &std::rc::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_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(); } } 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)) } } 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"); 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 } 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"); // Wire clear button { let files = state.loaded_files.clone(); let excl = state.excluded_files.clone(); let count_label_c = count_label.clone(); clear_button.connect_clicked(move |btn| { files.borrow_mut().clear(); excl.borrow_mut().clear(); 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 - clears exclusion set, re-checks all checkboxes { 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 - adds all files to exclusion set, unchecks 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); 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 } /// 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 } let mut child = widget.first_child(); while let Some(c) = child { set_all_checkboxes_in(&c, active); child = c.next_sibling(); } }