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
This commit is contained in:
2026-03-06 12:22:15 +02:00
parent e8cdddd08d
commit 4fc4ea7017
4 changed files with 173 additions and 50 deletions

View File

@@ -33,7 +33,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
// Quality slider // Quality slider
let quality_group = adw::PreferencesGroup::builder() let quality_group = adw::PreferencesGroup::builder()
.title("Quality Level") .title("Quality Level")
.description("Higher quality means larger files") .description("Higher quality means larger files. This sets the overall quality target.")
.build(); .build();
let initial_val = match cfg.quality_preset { 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); quality_group.add(&quality_box);
content.append(&quality_group); content.append(&quality_group);
// Advanced options // Advanced options in expander
let advanced_group = adw::PreferencesGroup::builder() let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced Options")
.build();
let advanced_expander = adw::ExpanderRow::builder()
.title("Per-Format Quality") .title("Per-Format Quality")
.description("Fine-tune quality for each format individually") .subtitle("Fine-tune quality for each format individually")
.build(); .build();
let jpeg_row = adw::SpinRow::builder() let jpeg_row = adw::SpinRow::builder()
@@ -102,9 +106,11 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
.adjustment(&gtk::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0)) .adjustment(&gtk::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0))
.build(); .build();
advanced_group.add(&jpeg_row); advanced_expander.add_row(&jpeg_row);
advanced_group.add(&png_row); advanced_expander.add_row(&png_row);
advanced_group.add(&webp_row); advanced_expander.add_row(&webp_row);
advanced_group.add(&advanced_expander);
content.append(&advanced_group); content.append(&advanced_group);
drop(cfg); drop(cfg);
@@ -118,7 +124,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
} }
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let label = quality_label.clone(); let label = quality_label;
quality_scale.connect_value_changed(move |scale| { quality_scale.connect_value_changed(move |scale| {
let val = scale.value().round() as u32; let val = scale.value().round() as u32;
let mut c = jc.borrow_mut(); 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 { fn quality_description(val: u32) -> String {
match val { match val {
1 => "Web Optimized - smallest files, noticeable quality loss".into(), 1 => "Web Optimized - smallest files, noticeable quality loss. Best for thumbnails.".into(),
2 => "Low - small files, some quality loss".into(), 2 => "Low - small files, some quality loss. Good for email attachments.".into(),
3 => "Medium - good balance of quality and size".into(), 3 => "Medium - good balance of quality and size. Recommended for most uses.".into(),
4 => "High - large files, minimal quality loss".into(), 4 => "High - large files, minimal quality loss. Good for printing.".into(),
_ => "Maximum - largest files, best possible quality".into(), _ => "Maximum - largest files, best possible quality. Archival use.".into(),
} }
} }

View File

@@ -10,7 +10,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
let empty_state = build_empty_state(); let empty_state = build_empty_state();
stack.add_named(&empty_state, Some("empty")); stack.add_named(&empty_state, Some("empty"));
// Loaded state - thumbnail grid // Loaded state - file list
let loaded_state = build_loaded_state(state); let loaded_state = build_loaded_state(state);
stack.add_named(&loaded_state, Some("loaded")); 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 loaded_files = state.loaded_files.clone();
let stack_ref = stack.clone(); let stack_ref = stack.clone();
drop_target.connect_drop(move |_target, value, _x, _y| { drop_target.connect_drop(move |_target, value, _x, _y| {
// Try single file
if let Ok(file) = value.get::<gtk::gio::File>() if let Ok(file) = value.get::<gtk::gio::File>()
&& let Some(path) = file.path() && let Some(path) = file.path()
&& is_image_file(&path)
{ {
let mut files = loaded_files.borrow_mut(); if path.is_dir() {
if !files.contains(&path) { // Recursively add images from directory
files.push(path); 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 false
}); });
@@ -58,28 +66,100 @@ fn is_image_file(path: &std::path::Path) -> bool {
} }
} }
fn update_loaded_ui(stack: &gtk::Stack, count: usize) { fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
if count > 0 { if let Ok(entries) = std::fs::read_dir(dir) {
stack.set_visible_child_name("loaded"); for entry in entries.flatten() {
} let path = entry.path();
if let Some(loaded_box) = stack.child_by_name("loaded") { if path.is_dir() {
update_count_label(&loaded_box, count); add_images_from_dir(&path, files);
} else if is_image_file(&path) && !files.contains(&path) {
files.push(path);
}
}
} }
} }
fn update_count_label(widget: &gtk::Widget, count: usize) { fn update_loaded_ui(
stack: &gtk::Stack,
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
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: &gtk::Widget,
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
) {
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: &gtk::Widget, count: usize, size_str: &str, files: &[std::path::PathBuf]) {
if let Some(label) = widget.downcast_ref::<gtk::Label>() if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& label.css_classes().iter().any(|c| c == "heading") && label.css_classes().iter().any(|c| c == "heading")
{ {
label.set_label(&format!("{} images loaded", count)); label.set_label(&format!("{} images ({})", count, size_str));
return;
} }
if let Some(bx) = widget.downcast_ref::<gtk::Box>() { if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>()
let mut child = bx.first_child(); && list_box.css_classes().iter().any(|c| c == "boxed-list")
while let Some(c) = child { {
update_count_label(&c, count); // Clear existing rows
child = c.next_sibling(); 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(&gtk::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(); .build();
let subtitle = gtk::Label::builder() 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"]) .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(); .build();
let browse_button = gtk::Button::builder() let browse_button = gtk::Button::builder()
@@ -141,6 +230,7 @@ fn build_empty_state() -> gtk::Box {
inner.append(&icon); inner.append(&icon);
inner.append(&title); inner.append(&title);
inner.append(&subtitle); inner.append(&subtitle);
inner.append(&formats_label);
inner.append(&browse_button); inner.append(&browse_button);
drop_zone.append(&inner); drop_zone.append(&inner);
@@ -165,7 +255,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
.build(); .build();
let count_label = gtk::Label::builder() let count_label = gtk::Label::builder()
.label("0 images loaded") .label("0 images")
.hexpand(true) .hexpand(true)
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.css_classes(["heading"]) .css_classes(["heading"])
@@ -173,7 +263,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
let add_button = gtk::Button::builder() let add_button = gtk::Button::builder()
.icon_name("list-add-symbolic") .icon_name("list-add-symbolic")
.tooltip_text("Add more images") .tooltip_text("Add more images (Ctrl+O)")
.action_name("win.add-files") .action_name("win.add-files")
.build(); .build();
add_button.add_css_class("flat"); 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(); let count_label_c = count_label.clone();
clear_button.connect_clicked(move |btn| { clear_button.connect_clicked(move |btn| {
files.borrow_mut().clear(); files.borrow_mut().clear();
count_label_c.set_label("0 images loaded"); count_label_c.set_label("0 images");
// Navigate back to empty state by finding parent stack
if let Some(parent) = btn.ancestor(gtk::Stack::static_type()) if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>() && let Some(stack) = parent.downcast_ref::<gtk::Stack>()
{ {
@@ -206,7 +295,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
let separator = gtk::Separator::new(gtk::Orientation::Horizontal); let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
// File list showing loaded images // File list
let list_scrolled = gtk::ScrolledWindow::builder() let list_scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never) .hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true) .vexpand(true)

View File

@@ -22,13 +22,13 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
.description("Review your processing settings before starting") .description("Review your processing settings before starting")
.build(); .build();
let summary_placeholder = adw::ActionRow::builder() let summary_row = adw::ActionRow::builder()
.title("No operations configured") .title("No operations configured")
.subtitle("Go back and configure your workflow settings") .subtitle("Go back and configure your workflow settings")
.build(); .build();
summary_placeholder.add_prefix(&gtk::Image::from_icon_name("dialog-information-symbolic")); summary_row.add_prefix(&gtk::Image::from_icon_name("dialog-information-symbolic"));
summary_group.add(&summary_placeholder); summary_group.add(&summary_row);
content.append(&summary_group); content.append(&summary_group);
// Output directory // 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(&choose_button);
output_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic")); output_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let cfg = state.job_config.borrow();
let structure_row = adw::SwitchRow::builder() let structure_row = adw::SwitchRow::builder()
.title("Preserve Directory Structure") .title("Preserve Directory Structure")
.subtitle("Keep subfolder hierarchy in output") .subtitle("Keep subfolder hierarchy in output")
.active(false) .active(cfg.preserve_dir_structure)
.build(); .build();
output_group.add(&output_row); output_group.add(&output_row);
@@ -80,24 +82,33 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
"Skip existing files", "Skip existing files",
]); ]);
overwrite_row.set_model(Some(&overwrite_model)); overwrite_row.set_model(Some(&overwrite_model));
overwrite_row.set_selected(cfg.overwrite_behavior as u32);
overwrite_group.add(&overwrite_row); overwrite_group.add(&overwrite_row);
content.append(&overwrite_group); content.append(&overwrite_group);
// Image count // Image count - dynamically updated
let stats_group = adw::PreferencesGroup::builder() let stats_group = adw::PreferencesGroup::builder()
.title("Batch Info") .title("Batch Info")
.build(); .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() let count_row = adw::ActionRow::builder()
.title("Images to process") .title("Images to process")
.subtitle("0 images") .subtitle(format!("{} images ({})", file_count, format_size(total_size)))
.build(); .build();
count_row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic")); count_row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
stats_group.add(&count_row); stats_group.add(&count_row);
content.append(&stats_group); content.append(&stats_group);
drop(cfg);
// Wire preserve directory structure // Wire preserve directory structure
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
@@ -127,3 +138,15 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
.child(&clamp) .child(&clamp)
.build() .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))
}
}

View File

@@ -213,9 +213,14 @@ fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
cfg.metadata_enabled = true; cfg.metadata_enabled = true;
cfg.metadata_mode = MetadataMode::KeepAll; 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_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 => { None => {
cfg.metadata_enabled = false; cfg.metadata_enabled = false;