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
This commit is contained in:
2026-03-06 13:24:18 +02:00
parent 32ea206c8c
commit 2911c608c2
2 changed files with 188 additions and 24 deletions

View File

@@ -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::<gtk::gio::File>()
@@ -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<std::path::PathBuf
fn update_loaded_ui(
stack: &gtk::Stack,
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
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: &gtk::Widget,
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
) {
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: &gtk::Widget,
count: usize,
included_count: usize,
size_str: &str,
files: &[std::path::PathBuf],
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
) {
if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& 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::<gtk::ListBox>()
&& 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::<gtk::Stack>()
&& let Some(loaded_widget) = stack.child_by_name("loaded")
{
update_count_label(&loaded_widget, &loaded, &excluded_ref);
}
});
}
row.add_prefix(&check);
row.add_prefix(&gtk::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::<gtk::Stack>()
@@ -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: &gtk::Widget,
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
) {
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: &gtk::Widget, count: usize, included_count: usize, size_str: &str) {
if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& 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::<gtk::Stack>()
@@ -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::<gtk::Stack>()
&& 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::<gtk::Stack>()
&& 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: &gtk::Widget, active: bool) {
if let Some(check) = widget.downcast_ref::<gtk::CheckButton>() {
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();
}
}