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:
@@ -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: >k::Widget, count: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_file_list(widget: >k::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(>k::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(>k::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 {
|
||||
|
||||
Reference in New Issue
Block a user