From 4fc4ea70179d85e3b9a6601176a31c771fc59a12 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 12:22:15 +0200 Subject: [PATCH] Improve Images, Compress, Output, Workflow steps - Images step: folder drag-and-drop with recursive image scanning, per-file list with format and size info, total file size in header, supported formats label in empty state - Compress step: per-format quality controls moved into AdwExpanderRow, improved quality level descriptions - Output step: dynamic image count with total size from loaded_files, initial overwrite behavior from config - Workflow step: properly handle MetadataConfig::Custom in preset import, mapping all custom metadata fields to JobConfig --- pixstrip-gtk/src/steps/step_compress.rs | 30 +++-- pixstrip-gtk/src/steps/step_images.rs | 149 +++++++++++++++++++----- pixstrip-gtk/src/steps/step_output.rs | 35 +++++- pixstrip-gtk/src/steps/step_workflow.rs | 9 +- 4 files changed, 173 insertions(+), 50 deletions(-) diff --git a/pixstrip-gtk/src/steps/step_compress.rs b/pixstrip-gtk/src/steps/step_compress.rs index f8750ca..54e313f 100644 --- a/pixstrip-gtk/src/steps/step_compress.rs +++ b/pixstrip-gtk/src/steps/step_compress.rs @@ -33,7 +33,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { // Quality slider let quality_group = adw::PreferencesGroup::builder() .title("Quality Level") - .description("Higher quality means larger files") + .description("Higher quality means larger files. This sets the overall quality target.") .build(); let initial_val = match cfg.quality_preset { @@ -78,10 +78,14 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { quality_group.add(&quality_box); content.append(&quality_group); - // Advanced options + // Advanced options in expander let advanced_group = adw::PreferencesGroup::builder() + .title("Advanced Options") + .build(); + + let advanced_expander = adw::ExpanderRow::builder() .title("Per-Format Quality") - .description("Fine-tune quality for each format individually") + .subtitle("Fine-tune quality for each format individually") .build(); let jpeg_row = adw::SpinRow::builder() @@ -102,9 +106,11 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { .adjustment(>k::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) .build(); - advanced_group.add(&jpeg_row); - advanced_group.add(&png_row); - advanced_group.add(&webp_row); + advanced_expander.add_row(&jpeg_row); + advanced_expander.add_row(&png_row); + advanced_expander.add_row(&webp_row); + + advanced_group.add(&advanced_expander); content.append(&advanced_group); drop(cfg); @@ -118,7 +124,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { } { let jc = state.job_config.clone(); - let label = quality_label.clone(); + let label = quality_label; quality_scale.connect_value_changed(move |scale| { let val = scale.value().round() as u32; let mut c = jc.borrow_mut(); @@ -167,10 +173,10 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage { fn quality_description(val: u32) -> String { match val { - 1 => "Web Optimized - smallest files, noticeable quality loss".into(), - 2 => "Low - small files, some quality loss".into(), - 3 => "Medium - good balance of quality and size".into(), - 4 => "High - large files, minimal quality loss".into(), - _ => "Maximum - largest files, best possible quality".into(), + 1 => "Web Optimized - smallest files, noticeable quality loss. Best for thumbnails.".into(), + 2 => "Low - small files, some quality loss. Good for email attachments.".into(), + 3 => "Medium - good balance of quality and size. Recommended for most uses.".into(), + 4 => "High - large files, minimal quality loss. Good for printing.".into(), + _ => "Maximum - largest files, best possible quality. Archival use.".into(), } } diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index 209952e..ead98d4 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -10,7 +10,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { let empty_state = build_empty_state(); stack.add_named(&empty_state, Some("empty")); - // Loaded state - thumbnail grid + // Loaded state - file list let loaded_state = build_loaded_state(state); stack.add_named(&loaded_state, Some("loaded")); @@ -24,19 +24,27 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { let loaded_files = state.loaded_files.clone(); let stack_ref = stack.clone(); drop_target.connect_drop(move |_target, value, _x, _y| { - // Try single file if let Ok(file) = value.get::() && let Some(path) = file.path() - && is_image_file(&path) { - let mut files = loaded_files.borrow_mut(); - if !files.contains(&path) { - files.push(path); + if path.is_dir() { + // Recursively add images from directory + 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, 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, count); + return true; } - let count = files.len(); - drop(files); - update_loaded_ui(&stack_ref, count); - return true; } false }); @@ -58,28 +66,100 @@ fn is_image_file(path: &std::path::Path) -> bool { } } -fn update_loaded_ui(stack: >k::Stack, count: usize) { - if count > 0 { - stack.set_visible_child_name("loaded"); - } - if let Some(loaded_box) = stack.child_by_name("loaded") { - update_count_label(&loaded_box, count); +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_count_label(widget: >k::Widget, count: usize) { +fn update_loaded_ui( + stack: >k::Stack, + loaded_files: &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); + } +} + +fn update_count_and_list( + widget: >k::Widget, + loaded_files: &std::rc::Rc>>, +) { + let files = loaded_files.borrow(); + let count = files.len(); + let total_size: u64 = files.iter() + .filter_map(|p| std::fs::metadata(p).ok()) + .map(|m| m.len()) + .sum(); + let size_str = format_size(total_size); + + // Walk widget tree to find and update components + walk_loaded_widgets(widget, count, &size_str, &files); +} + +fn walk_loaded_widgets(widget: >k::Widget, count: usize, size_str: &str, files: &[std::path::PathBuf]) { if let Some(label) = widget.downcast_ref::() && label.css_classes().iter().any(|c| c == "heading") { - label.set_label(&format!("{} images loaded", count)); - return; + label.set_label(&format!("{} images ({})", count, size_str)); } - if let Some(bx) = widget.downcast_ref::() { - let mut child = bx.first_child(); - while let Some(c) = child { - update_count_label(&c, count); - child = c.next_sibling(); + 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); } + // Add rows for each file + for path in files { + 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(); + row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); + list_box.append(&row); + } + } + // Recurse + let mut child = widget.first_child(); + while let Some(c) = child { + walk_loaded_widgets(&c, count, size_str, files); + 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)) } } @@ -125,8 +205,17 @@ fn build_empty_state() -> gtk::Box { .build(); let subtitle = gtk::Label::builder() - .label("or click Browse to select files") + .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() @@ -141,6 +230,7 @@ fn build_empty_state() -> gtk::Box { inner.append(&icon); inner.append(&title); inner.append(&subtitle); + inner.append(&formats_label); inner.append(&browse_button); drop_zone.append(&inner); @@ -165,7 +255,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { .build(); let count_label = gtk::Label::builder() - .label("0 images loaded") + .label("0 images") .hexpand(true) .halign(gtk::Align::Start) .css_classes(["heading"]) @@ -173,7 +263,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { let add_button = gtk::Button::builder() .icon_name("list-add-symbolic") - .tooltip_text("Add more images") + .tooltip_text("Add more images (Ctrl+O)") .action_name("win.add-files") .build(); add_button.add_css_class("flat"); @@ -190,8 +280,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { let count_label_c = count_label.clone(); clear_button.connect_clicked(move |btn| { files.borrow_mut().clear(); - count_label_c.set_label("0 images loaded"); - // Navigate back to empty state by finding parent stack + count_label_c.set_label("0 images"); if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) && let Some(stack) = parent.downcast_ref::() { @@ -206,7 +295,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box { let separator = gtk::Separator::new(gtk::Orientation::Horizontal); - // File list showing loaded images + // File list let list_scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) diff --git a/pixstrip-gtk/src/steps/step_output.rs b/pixstrip-gtk/src/steps/step_output.rs index 1cd4a37..5943dd7 100644 --- a/pixstrip-gtk/src/steps/step_output.rs +++ b/pixstrip-gtk/src/steps/step_output.rs @@ -22,13 +22,13 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { .description("Review your processing settings before starting") .build(); - let summary_placeholder = adw::ActionRow::builder() + let summary_row = adw::ActionRow::builder() .title("No operations configured") .subtitle("Go back and configure your workflow settings") .build(); - summary_placeholder.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic")); + summary_row.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic")); - summary_group.add(&summary_placeholder); + summary_group.add(&summary_row); content.append(&summary_group); // Output directory @@ -54,10 +54,12 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { output_row.add_suffix(&choose_button); output_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + let cfg = state.job_config.borrow(); + let structure_row = adw::SwitchRow::builder() .title("Preserve Directory Structure") .subtitle("Keep subfolder hierarchy in output") - .active(false) + .active(cfg.preserve_dir_structure) .build(); output_group.add(&output_row); @@ -80,24 +82,33 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { "Skip existing files", ]); overwrite_row.set_model(Some(&overwrite_model)); + overwrite_row.set_selected(cfg.overwrite_behavior as u32); overwrite_group.add(&overwrite_row); content.append(&overwrite_group); - // Image count + // Image count - dynamically updated let stats_group = adw::PreferencesGroup::builder() .title("Batch Info") .build(); + let file_count = state.loaded_files.borrow().len(); + let total_size: u64 = state.loaded_files.borrow().iter() + .filter_map(|p| std::fs::metadata(p).ok()) + .map(|m| m.len()) + .sum(); + let count_row = adw::ActionRow::builder() .title("Images to process") - .subtitle("0 images") + .subtitle(format!("{} images ({})", file_count, format_size(total_size))) .build(); count_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); stats_group.add(&count_row); content.append(&stats_group); + drop(cfg); + // Wire preserve directory structure { let jc = state.job_config.clone(); @@ -127,3 +138,15 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage { .child(&clamp) .build() } + +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)) + } +} diff --git a/pixstrip-gtk/src/steps/step_workflow.rs b/pixstrip-gtk/src/steps/step_workflow.rs index 46c3775..5d27b2d 100644 --- a/pixstrip-gtk/src/steps/step_workflow.rs +++ b/pixstrip-gtk/src/steps/step_workflow.rs @@ -213,9 +213,14 @@ fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) { cfg.metadata_enabled = true; cfg.metadata_mode = MetadataMode::KeepAll; } - Some(MetadataConfig::Custom { .. }) => { + Some(MetadataConfig::Custom { strip_gps, strip_camera, strip_software, strip_timestamps, strip_copyright }) => { cfg.metadata_enabled = true; - cfg.metadata_mode = MetadataMode::StripAll; + cfg.metadata_mode = MetadataMode::Custom; + cfg.strip_gps = *strip_gps; + cfg.strip_camera = *strip_camera; + cfg.strip_software = *strip_software; + cfg.strip_timestamps = *strip_timestamps; + cfg.strip_copyright = *strip_copyright; } None => { cfg.metadata_enabled = false;