From 2911c608c217e8418d53fe5cf6ac3fa00fa41937 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 13:24:18 +0200 Subject: [PATCH] Add per-image checkboxes and wire Select All / Deselect All buttons - Each image row now has a CheckButton for include/exclude from processing - Select All clears exclusion set, Deselect All adds all files to it - Count label shows "X/Y images selected" when some are excluded - Processing respects excluded files - only processes checked images - Clear All also resets exclusion set - AppState gains excluded_files HashSet for tracking --- pixstrip-gtk/src/app.rs | 45 +++++-- pixstrip-gtk/src/steps/step_images.rs | 167 ++++++++++++++++++++++++-- 2 files changed, 188 insertions(+), 24 deletions(-) diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 70db4bd..83b3b54 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -81,6 +81,7 @@ pub enum MetadataMode { pub struct AppState { pub wizard: Rc>, pub loaded_files: Rc>>, + pub excluded_files: Rc>>, pub output_dir: Rc>>, pub job_config: Rc>, } @@ -143,6 +144,7 @@ fn build_ui(app: &adw::Application) { let app_state = AppState { wizard: Rc::new(RefCell::new(WizardState::new())), loaded_files: Rc::new(RefCell::new(Vec::new())), + excluded_files: Rc::new(RefCell::new(std::collections::HashSet::new())), output_dir: Rc::new(RefCell::new(None)), job_config: Rc::new(RefCell::new(JobConfig { resize_enabled: if remember { sess_state.resize_enabled.unwrap_or(true) } else { true }, @@ -415,9 +417,11 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { let window = window.clone(); let action = gtk::gio::SimpleAction::new("process", None); action.connect_activate(move |_, _| { - let files = ui.state.loaded_files.borrow().clone(); - if files.is_empty() { - let toast = adw::Toast::new("No images loaded - go to Step 2 to add images"); + let excluded = ui.state.excluded_files.borrow(); + let has_included = ui.state.loaded_files.borrow().iter().any(|p| !excluded.contains(p)); + drop(excluded); + if !has_included { + let toast = adw::Toast::new("No images selected - go to Step 2 to add images"); ui.toast_overlay.add_toast(toast); return; } @@ -677,7 +681,10 @@ fn update_output_label(ui: &WizardUi, path: &std::path::Path) { fn update_images_count_label(ui: &WizardUi, count: usize) { let files = ui.state.loaded_files.borrow(); + let excluded = ui.state.excluded_files.borrow(); + let included_count = files.iter().filter(|p| !excluded.contains(*p)).count(); let total_size: u64 = files.iter() + .filter(|p| !excluded.contains(*p)) .filter_map(|p| std::fs::metadata(p).ok()) .map(|m| m.len()) .sum(); @@ -692,27 +699,31 @@ fn update_images_count_label(ui: &WizardUi, count: usize) { stack.set_visible_child_name("empty"); } if let Some(loaded_box) = stack.child_by_name("loaded") { - update_count_in_box(&loaded_box, count, total_size); - update_file_list(&loaded_box, &files); + update_count_in_box(&loaded_box, count, included_count, total_size); + update_file_list(&loaded_box, &files, &excluded); } } } -fn update_count_in_box(widget: >k::Widget, count: usize, total_size: u64) { +fn update_count_in_box(widget: >k::Widget, count: usize, included_count: usize, total_size: u64) { if let Some(label) = widget.downcast_ref::() && label.css_classes().iter().any(|c| c == "heading") { - label.set_label(&format!("{} images ({})", count, format_bytes(total_size))); + if included_count == count { + label.set_label(&format!("{} images ({})", count, format_bytes(total_size))); + } else { + label.set_label(&format!("{}/{} images selected ({})", included_count, count, format_bytes(total_size))); + } return; } let mut child = widget.first_child(); while let Some(c) = child { - update_count_in_box(&c, count, total_size); + update_count_in_box(&c, count, included_count, total_size); child = c.next_sibling(); } } -fn update_file_list(widget: >k::Widget, files: &[std::path::PathBuf]) { +fn update_file_list(widget: >k::Widget, files: &[std::path::PathBuf], excluded: &std::collections::HashSet) { if let Some(list_box) = widget.downcast_ref::() && list_box.css_classes().iter().any(|c| c == "boxed-list") { @@ -734,6 +745,12 @@ fn update_file_list(widget: >k::Widget, files: &[std::path::PathBuf]) { .title(name) .subtitle(format!("{} - {}", ext, size)) .build(); + let check = gtk::CheckButton::builder() + .active(!excluded.contains(path)) + .tooltip_text("Include in processing") + .valign(gtk::Align::Center) + .build(); + row.add_prefix(&check); row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); list_box.append(&row); } @@ -741,7 +758,7 @@ fn update_file_list(widget: >k::Widget, files: &[std::path::PathBuf]) { } let mut child = widget.first_child(); while let Some(c) = child { - update_file_list(&c, files); + update_file_list(&c, files, excluded); child = c.next_sibling(); } } @@ -948,7 +965,12 @@ fn show_whats_new_dialog(window: &adw::ApplicationWindow) { } fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { - let files = ui.state.loaded_files.borrow().clone(); + let excluded = ui.state.excluded_files.borrow().clone(); + let files: Vec = ui.state.loaded_files.borrow() + .iter() + .filter(|p| !excluded.contains(*p)) + .cloned() + .collect(); if files.is_empty() { return; } @@ -1393,6 +1415,7 @@ fn reset_wizard(ui: &WizardUi) { s.visited[0] = true; } ui.state.loaded_files.borrow_mut().clear(); + ui.state.excluded_files.borrow_mut().clear(); // Reset nav ui.nav_view.replace(&ui.pages[..1]); diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index 36b06df..bb2659b 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -22,6 +22,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { { 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::() @@ -32,7 +33,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { add_images_from_dir(&path, &mut files); let count = files.len(); drop(files); - update_loaded_ui(&stack_ref, &loaded_files, count); + update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); return true; } else if is_image_file(&path) { let mut files = loaded_files.borrow_mut(); @@ -41,7 +42,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { } let count = files.len(); drop(files); - update_loaded_ui(&stack_ref, &loaded_files, count); + update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); return true; } } @@ -81,6 +82,7 @@ fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec>>, + excluded: &std::rc::Rc>>, count: usize, ) { if count > 0 { @@ -89,36 +91,46 @@ 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); + 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, &size_str, &files, loaded_files); + 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") { - label.set_label(&format!("{} images ({})", count, size_str)); + 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") @@ -127,7 +139,8 @@ fn walk_loaded_widgets( while let Some(row) = list_box.first_child() { list_box.remove(&row); } - // Add rows for each file with remove button + 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()) @@ -143,6 +156,38 @@ fn walk_loaded_widgets( .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 @@ -154,15 +199,21 @@ fn walk_loaded_widgets( 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 mut files = loaded.borrow_mut(); - if file_idx < files.len() { - files.remove(file_idx); + let removed_path; + { + let mut files = loaded.borrow_mut(); + if file_idx < files.len() { + removed_path = files.remove(file_idx); + } else { + return; + } } - let count = files.len(); - drop(files); + 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::() @@ -170,7 +221,7 @@ fn walk_loaded_widgets( 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); + update_count_and_list(&loaded_widget, &loaded, &excl); } } }); @@ -182,7 +233,44 @@ fn walk_loaded_widgets( // Recurse let mut child = widget.first_child(); while let Some(c) = child { - walk_loaded_widgets(&c, count, size_str, files, loaded_files); + 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(); } } @@ -325,9 +413,11 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { // 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::() @@ -337,6 +427,44 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { }); } + // 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(&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(&loaded_widget, false); + update_count_label(&loaded_widget, &loaded, &excl); + } + }); + } + toolbar.append(&count_label); toolbar.append(&select_all_button); toolbar.append(&deselect_all_button); @@ -368,3 +496,16 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { container } + +/// Set all CheckButton widgets within a container to a given state +fn set_all_checkboxes(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(&c, active); + child = c.next_sibling(); + } +}