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

@@ -81,6 +81,7 @@ pub enum MetadataMode {
pub struct AppState {
pub wizard: Rc<RefCell<WizardState>>,
pub loaded_files: Rc<RefCell<Vec<std::path::PathBuf>>>,
pub excluded_files: Rc<RefCell<std::collections::HashSet<std::path::PathBuf>>>,
pub output_dir: Rc<RefCell<Option<std::path::PathBuf>>>,
pub job_config: Rc<RefCell<JobConfig>>,
}
@@ -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: &gtk::Widget, count: usize, total_size: u64) {
fn update_count_in_box(widget: &gtk::Widget, count: usize, included_count: usize, total_size: u64) {
if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& 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: &gtk::Widget, files: &[std::path::PathBuf]) {
fn update_file_list(widget: &gtk::Widget, files: &[std::path::PathBuf], excluded: &std::collections::HashSet<std::path::PathBuf>) {
if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>()
&& list_box.css_classes().iter().any(|c| c == "boxed-list")
{
@@ -734,6 +745,12 @@ fn update_file_list(widget: &gtk::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(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
list_box.append(&row);
}
@@ -741,7 +758,7 @@ fn update_file_list(widget: &gtk::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<std::path::PathBuf> = 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]);