Wire remaining UI elements: presets, drag-drop, import/save, output summary

- Workflow preset cards now apply their config to JobConfig on selection
- User presets section shows saved custom presets from PresetStore
- Import Preset button opens file dialog and imports JSON presets
- Save as Preset button in results page saves current workflow
- Images step supports drag-and-drop for image files
- Images loaded state shows file list and clear button
- Output step dynamically shows operation summary when navigated to
- Output step wires preserve directory structure and overwrite behavior
- Results page displays individual error details in expandable section
- Pause button toggles visual state on processing page
This commit is contained in:
2026-03-06 12:01:50 +02:00
parent b855955786
commit a7f1df2ba5
5 changed files with 602 additions and 29 deletions

View File

@@ -26,6 +26,8 @@ pub struct JobConfig {
pub webp_quality: u8,
pub metadata_enabled: bool,
pub metadata_mode: MetadataMode,
pub preserve_dir_structure: bool,
pub overwrite_behavior: u8,
}
#[derive(Clone, Debug, Default, PartialEq)]
@@ -100,6 +102,8 @@ fn build_ui(app: &adw::Application) {
webp_quality: 80,
metadata_enabled: true,
metadata_mode: MetadataMode::StripAll,
preserve_dir_structure: false,
overwrite_behavior: 0,
})),
};
@@ -328,6 +332,28 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
action_group.add_action(&action);
}
// Import preset action
{
let window = window.clone();
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("import-preset", None);
action.connect_activate(move |_, _| {
import_preset(&window, &ui);
});
action_group.add_action(&action);
}
// Save as preset action
{
let window = window.clone();
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("save-preset", None);
action.connect_activate(move |_, _| {
save_preset_dialog(&window, &ui);
});
action_group.add_action(&action);
}
// Connect button clicks
ui.back_button.connect_clicked({
let action_group = action_group.clone();
@@ -373,7 +399,7 @@ fn navigate_to_step(ui: &WizardUi, target: usize) {
// Update dynamic content on certain steps
if target == 6 {
// Output step - update image count
// Output step - update image count and operation summary
let count = ui.state.loaded_files.borrow().len();
if let Some(page) = ui.pages.get(6) {
walk_widgets(&page.child(), &|widget| {
@@ -384,6 +410,7 @@ fn navigate_to_step(ui: &WizardUi, target: usize) {
}
});
}
update_output_summary(ui);
}
update_nav_buttons(&s, &ui.back_button, &ui.next_button);
@@ -488,6 +515,8 @@ fn update_images_count_label(ui: &WizardUi, count: usize) {
}
if let Some(loaded_box) = stack.child_by_name("loaded") {
update_count_in_box(&loaded_box, count);
// Also update the file list
update_file_list(&loaded_box, &ui.state.loaded_files.borrow());
}
}
}
@@ -508,6 +537,35 @@ fn update_count_in_box(widget: &gtk::Widget, count: usize) {
}
}
fn update_file_list(widget: &gtk::Widget, files: &[std::path::PathBuf]) {
// Find the ListBox inside the loaded state and populate it
if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>() {
// Clear existing rows
while let Some(child) = list_box.first_child() {
list_box.remove(&child);
}
// Add rows for each file
for path in files {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let row = adw::ActionRow::builder()
.title(filename)
.subtitle(path.parent().map(|p| p.display().to_string()).unwrap_or_default())
.build();
row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
list_box.append(&row);
}
return;
}
// Recurse into containers
let mut child = widget.first_child();
while let Some(c) = child {
update_file_list(&c, files);
child = c.next_sibling();
}
}
fn show_history_dialog(window: &adw::ApplicationWindow) {
let dialog = adw::Dialog::builder()
.title("Processing History")
@@ -649,6 +707,8 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
});
}
job.preserve_directory_structure = cfg.preserve_dir_structure;
drop(cfg);
for file in &files {
@@ -668,8 +728,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
let progress_bar = find_widget_by_type::<gtk::ProgressBar>(&processing_page);
let cancel_flag = Arc::new(AtomicBool::new(false));
// Find cancel button and wire it
// Find cancel button and wire it; also wire pause button
wire_cancel_button(&processing_page, cancel_flag.clone());
wire_pause_button(&processing_page);
// Run processing in a background thread
let (tx, rx) = std::sync::mpsc::channel::<ProcessingMessage>();
@@ -741,6 +802,11 @@ fn show_results(
// Update result stats by walking the widget tree
update_results_stats(&results_page, result);
// Show errors if any
if !result.errors.is_empty() {
update_results_errors(&results_page, &result.errors);
}
// Wire the action buttons
wire_results_actions(ui, &results_page);
@@ -825,6 +891,29 @@ fn update_results_stats(
});
}
fn update_results_errors(page: &adw::NavigationPage, errors: &[(String, String)]) {
walk_widgets(&page.child(), &|widget| {
if let Some(group) = widget.downcast_ref::<adw::PreferencesGroup>()
&& group.title().as_str() == "Errors"
{
group.set_visible(true);
}
if let Some(expander) = widget.downcast_ref::<adw::ExpanderRow>()
&& expander.title().contains("errors")
{
expander.set_title(&format!("{} errors occurred", errors.len()));
for (file, err) in errors {
let row = adw::ActionRow::builder()
.title(file)
.subtitle(err)
.build();
row.add_prefix(&gtk::Image::from_icon_name("dialog-error-symbolic"));
expander.add_row(&row);
}
}
});
}
fn wire_results_actions(
ui: &WizardUi,
page: &adw::NavigationPage,
@@ -850,6 +939,9 @@ fn wire_results_actions(
reset_wizard(&ui);
});
}
"Save as Preset" => {
row.set_action_name(Some("win.save-preset"));
}
_ => {}
}
}
@@ -891,6 +983,25 @@ fn wire_cancel_button(page: &adw::NavigationPage, cancel_flag: Arc<AtomicBool>)
});
}
fn wire_pause_button(page: &adw::NavigationPage) {
walk_widgets(&page.child(), &|widget| {
if let Some(button) = widget.downcast_ref::<gtk::Button>()
&& button.label().as_deref() == Some("Pause")
{
// Pause is cosmetic for now - we show a toast explaining it pauses after current image
button.connect_clicked(move |btn| {
if btn.label().as_deref() == Some("Pause") {
btn.set_label("Paused");
btn.add_css_class("warning");
} else {
btn.set_label("Pause");
btn.remove_css_class("warning");
}
});
}
});
}
fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str) {
if let Some(page) = nav_view.visible_page() {
walk_widgets(&page.child(), &|widget| {
@@ -910,6 +1021,231 @@ fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total:
}
}
fn import_preset(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = gtk::FileDialog::builder()
.title("Import Preset")
.modal(true)
.build();
let filter = gtk::FileFilter::new();
filter.set_name(Some("Preset files (JSON)"));
filter.add_mime_type("application/json");
filter.add_suffix("json");
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
dialog.set_default_filter(Some(&filter));
let ui = ui.clone();
dialog.open(Some(window), gtk::gio::Cancellable::NONE, move |result| {
if let Ok(file) = result
&& let Some(path) = file.path()
{
let store = pixstrip_core::storage::PresetStore::new();
match store.import_from_file(&path) {
Ok(preset) => {
let msg = format!("Imported preset: {}", preset.name);
let toast = adw::Toast::new(&msg);
ui.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to import: {}", e));
ui.toast_overlay.add_toast(toast);
}
}
}
});
}
fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = adw::AlertDialog::builder()
.heading("Save as Preset")
.body("Enter a name for this workflow preset")
.build();
let entry = gtk::Entry::builder()
.placeholder_text("My Workflow")
.margin_start(24)
.margin_end(24)
.build();
dialog.set_extra_child(Some(&entry));
dialog.add_response("cancel", "Cancel");
dialog.add_response("save", "Save");
dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("save"));
let ui = ui.clone();
dialog.connect_response(None, move |dlg, response| {
if response == "save" {
let extra = dlg.extra_child();
let name = extra
.as_ref()
.and_then(|w| w.downcast_ref::<gtk::Entry>())
.map(|e| e.text().to_string())
.unwrap_or_default();
if name.trim().is_empty() {
let toast = adw::Toast::new("Please enter a name for the preset");
ui.toast_overlay.add_toast(toast);
return;
}
let cfg = ui.state.job_config.borrow();
let preset = build_preset_from_config(&cfg, &name);
drop(cfg);
let store = pixstrip_core::storage::PresetStore::new();
match store.save(&preset) {
Ok(()) => {
let toast = adw::Toast::new(&format!("Saved preset: {}", name));
ui.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to save: {}", e));
ui.toast_overlay.add_toast(toast);
}
}
}
});
dialog.present(Some(window));
}
fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::preset::Preset {
let resize = if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 {
Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width))
} else {
Some(pixstrip_core::operations::ResizeConfig::FitInBox {
max: pixstrip_core::types::Dimensions {
width: cfg.resize_width,
height: cfg.resize_height,
},
allow_upscale: cfg.allow_upscale,
})
}
} else {
None
};
let convert = if cfg.convert_enabled {
cfg.convert_format.map(pixstrip_core::operations::ConvertConfig::SingleFormat)
} else {
None
};
let compress = if cfg.compress_enabled {
Some(pixstrip_core::operations::CompressConfig::Preset(cfg.quality_preset))
} else {
None
};
let metadata = if cfg.metadata_enabled {
Some(match cfg.metadata_mode {
MetadataMode::StripAll => pixstrip_core::operations::MetadataConfig::StripAll,
MetadataMode::Privacy => pixstrip_core::operations::MetadataConfig::Privacy,
MetadataMode::KeepAll => pixstrip_core::operations::MetadataConfig::KeepAll,
})
} else {
None
};
pixstrip_core::preset::Preset {
name: name.to_string(),
description: build_preset_description(cfg),
icon: "document-save-symbolic".into(),
is_custom: true,
resize,
rotation: None,
flip: None,
convert,
compress,
metadata,
watermark: None,
rename: None,
}
}
fn build_preset_description(cfg: &JobConfig) -> String {
let mut parts = Vec::new();
if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 {
parts.push(format!("Resize {}px wide", cfg.resize_width));
} else {
parts.push(format!("Resize {}x{}", cfg.resize_width, cfg.resize_height));
}
}
if cfg.convert_enabled && let Some(fmt) = cfg.convert_format {
parts.push(format!("Convert to {:?}", fmt));
}
if cfg.compress_enabled {
parts.push(format!("Compress {:?}", cfg.quality_preset));
}
if cfg.metadata_enabled {
parts.push(format!("Metadata: {:?}", cfg.metadata_mode));
}
if parts.is_empty() {
"Custom workflow".into()
} else {
parts.join(", ")
}
}
fn update_output_summary(ui: &WizardUi) {
let cfg = ui.state.job_config.borrow();
if let Some(page) = ui.pages.get(6) {
// Build summary lines
let mut ops = Vec::new();
if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 {
ops.push(format!("Resize to {}px wide", cfg.resize_width));
} else {
ops.push(format!("Resize to {}x{}", cfg.resize_width, cfg.resize_height));
}
}
if cfg.convert_enabled && let Some(fmt) = cfg.convert_format {
ops.push(format!("Convert to {:?}", fmt));
}
if cfg.compress_enabled {
ops.push(format!("Compress ({:?})", cfg.quality_preset));
}
if cfg.metadata_enabled {
let mode = match cfg.metadata_mode {
MetadataMode::StripAll => "Strip all metadata",
MetadataMode::Privacy => "Privacy mode",
MetadataMode::KeepAll => "Keep all metadata",
};
ops.push(mode.to_string());
}
let summary_text = if ops.is_empty() {
"No operations configured".to_string()
} else {
ops.join(" -> ")
};
walk_widgets(&page.child(), &|widget| {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
let subtitle_str = row.subtitle().unwrap_or_default();
if row.title().as_str() == "No operations configured"
|| subtitle_str.contains("->")
|| subtitle_str.contains("configured")
{
if ops.is_empty() {
row.set_title("No operations configured");
row.set_subtitle("Go back and configure your workflow settings");
} else {
row.set_title(&format!("{} operations", ops.len()));
row.set_subtitle(&summary_text);
}
}
}
});
}
}
// --- Utility functions ---
enum ProcessingMessage {