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 webp_quality: u8,
|
||||||
pub metadata_enabled: bool,
|
pub metadata_enabled: bool,
|
||||||
pub metadata_mode: MetadataMode,
|
pub metadata_mode: MetadataMode,
|
||||||
|
pub preserve_dir_structure: bool,
|
||||||
|
pub overwrite_behavior: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
@@ -100,6 +102,8 @@ fn build_ui(app: &adw::Application) {
|
|||||||
webp_quality: 80,
|
webp_quality: 80,
|
||||||
metadata_enabled: true,
|
metadata_enabled: true,
|
||||||
metadata_mode: MetadataMode::StripAll,
|
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);
|
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
|
// Connect button clicks
|
||||||
ui.back_button.connect_clicked({
|
ui.back_button.connect_clicked({
|
||||||
let action_group = action_group.clone();
|
let action_group = action_group.clone();
|
||||||
@@ -373,7 +399,7 @@ fn navigate_to_step(ui: &WizardUi, target: usize) {
|
|||||||
|
|
||||||
// Update dynamic content on certain steps
|
// Update dynamic content on certain steps
|
||||||
if target == 6 {
|
if target == 6 {
|
||||||
// Output step - update image count
|
// Output step - update image count and operation summary
|
||||||
let count = ui.state.loaded_files.borrow().len();
|
let count = ui.state.loaded_files.borrow().len();
|
||||||
if let Some(page) = ui.pages.get(6) {
|
if let Some(page) = ui.pages.get(6) {
|
||||||
walk_widgets(&page.child(), &|widget| {
|
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);
|
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") {
|
if let Some(loaded_box) = stack.child_by_name("loaded") {
|
||||||
update_count_in_box(&loaded_box, count);
|
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) {
|
fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||||
let dialog = adw::Dialog::builder()
|
let dialog = adw::Dialog::builder()
|
||||||
.title("Processing History")
|
.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);
|
drop(cfg);
|
||||||
|
|
||||||
for file in &files {
|
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 progress_bar = find_widget_by_type::<gtk::ProgressBar>(&processing_page);
|
||||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
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_cancel_button(&processing_page, cancel_flag.clone());
|
||||||
|
wire_pause_button(&processing_page);
|
||||||
|
|
||||||
// Run processing in a background thread
|
// Run processing in a background thread
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<ProcessingMessage>();
|
let (tx, rx) = std::sync::mpsc::channel::<ProcessingMessage>();
|
||||||
@@ -741,6 +802,11 @@ fn show_results(
|
|||||||
// Update result stats by walking the widget tree
|
// Update result stats by walking the widget tree
|
||||||
update_results_stats(&results_page, result);
|
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 the action buttons
|
||||||
wire_results_actions(ui, &results_page);
|
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(
|
fn wire_results_actions(
|
||||||
ui: &WizardUi,
|
ui: &WizardUi,
|
||||||
page: &adw::NavigationPage,
|
page: &adw::NavigationPage,
|
||||||
@@ -850,6 +939,9 @@ fn wire_results_actions(
|
|||||||
reset_wizard(&ui);
|
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) {
|
fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str) {
|
||||||
if let Some(page) = nav_view.visible_page() {
|
if let Some(page) = nav_view.visible_page() {
|
||||||
walk_widgets(&page.child(), &|widget| {
|
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 ---
|
// --- Utility functions ---
|
||||||
|
|
||||||
enum ProcessingMessage {
|
enum ProcessingMessage {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
pub fn build_images_page() -> adw::NavigationPage {
|
pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
||||||
let stack = gtk::Stack::builder()
|
let stack = gtk::Stack::builder()
|
||||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||||
.build();
|
.build();
|
||||||
@@ -9,12 +10,40 @@ pub fn build_images_page() -> 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 (placeholder for now)
|
// Loaded state - thumbnail grid
|
||||||
let loaded_state = build_loaded_state();
|
let loaded_state = build_loaded_state(state);
|
||||||
stack.add_named(&loaded_state, Some("loaded"));
|
stack.add_named(&loaded_state, Some("loaded"));
|
||||||
|
|
||||||
stack.set_visible_child_name("empty");
|
stack.set_visible_child_name("empty");
|
||||||
|
|
||||||
|
// Set up drag-and-drop on the entire page
|
||||||
|
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
||||||
|
drop_target.set_types(&[gtk::gio::File::static_type()]);
|
||||||
|
|
||||||
|
{
|
||||||
|
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::<gtk::gio::File>()
|
||||||
|
&& let Some(path) = file.path()
|
||||||
|
&& 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, count);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.add_controller(drop_target);
|
||||||
|
|
||||||
adw::NavigationPage::builder()
|
adw::NavigationPage::builder()
|
||||||
.title("Add Images")
|
.title("Add Images")
|
||||||
.tag("step-images")
|
.tag("step-images")
|
||||||
@@ -22,6 +51,38 @@ pub fn build_images_page() -> adw::NavigationPage {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_image_file(path: &std::path::Path) -> bool {
|
||||||
|
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) {
|
||||||
|
Some(ext) => matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 update_count_label(widget: >k::Widget, count: usize) {
|
||||||
|
if let Some(label) = widget.downcast_ref::<gtk::Label>()
|
||||||
|
&& label.css_classes().iter().any(|c| c == "heading")
|
||||||
|
{
|
||||||
|
label.set_label(&format!("{} images loaded", count));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(bx) = widget.downcast_ref::<gtk::Box>() {
|
||||||
|
let mut child = bx.first_child();
|
||||||
|
while let Some(c) = child {
|
||||||
|
update_count_label(&c, count);
|
||||||
|
child = c.next_sibling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_empty_state() -> gtk::Box {
|
fn build_empty_state() -> gtk::Box {
|
||||||
let container = gtk::Box::builder()
|
let container = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
@@ -87,7 +148,7 @@ fn build_empty_state() -> gtk::Box {
|
|||||||
container
|
container
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_loaded_state() -> gtk::Box {
|
fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||||
let container = gtk::Box::builder()
|
let container = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
.spacing(0)
|
.spacing(0)
|
||||||
@@ -104,7 +165,7 @@ fn build_loaded_state() -> gtk::Box {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let count_label = gtk::Label::builder()
|
let count_label = gtk::Label::builder()
|
||||||
.label("0 images (0 B)")
|
.label("0 images loaded")
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.halign(gtk::Align::Start)
|
.halign(gtk::Align::Start)
|
||||||
.css_classes(["heading"])
|
.css_classes(["heading"])
|
||||||
@@ -117,28 +178,54 @@ fn build_loaded_state() -> gtk::Box {
|
|||||||
.build();
|
.build();
|
||||||
add_button.add_css_class("flat");
|
add_button.add_css_class("flat");
|
||||||
|
|
||||||
let select_all_button = gtk::Button::builder()
|
let clear_button = gtk::Button::builder()
|
||||||
.label("Select All")
|
.icon_name("edit-clear-all-symbolic")
|
||||||
.tooltip_text("Select all images (Ctrl+A)")
|
.tooltip_text("Remove all images")
|
||||||
.build();
|
.build();
|
||||||
select_all_button.add_css_class("flat");
|
clear_button.add_css_class("flat");
|
||||||
|
|
||||||
|
// Wire clear button
|
||||||
|
{
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
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
|
||||||
|
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
|
||||||
|
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
|
||||||
|
{
|
||||||
|
stack.set_visible_child_name("empty");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
toolbar.append(&count_label);
|
toolbar.append(&count_label);
|
||||||
toolbar.append(&add_button);
|
toolbar.append(&add_button);
|
||||||
toolbar.append(&select_all_button);
|
toolbar.append(&clear_button);
|
||||||
|
|
||||||
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||||
|
|
||||||
// Thumbnail grid placeholder
|
// File list showing loaded images
|
||||||
let grid_placeholder = adw::StatusPage::builder()
|
let list_scrolled = gtk::ScrolledWindow::builder()
|
||||||
.title("Images will appear here")
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
.icon_name("image-x-generic-symbolic")
|
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
let list_box = gtk::ListBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
list_scrolled.set_child(Some(&list_box));
|
||||||
|
|
||||||
container.append(&toolbar);
|
container.append(&toolbar);
|
||||||
container.append(&separator);
|
container.append(&separator);
|
||||||
container.append(&grid_placeholder);
|
container.append(&list_scrolled);
|
||||||
|
|
||||||
container
|
container
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
pub fn build_output_page() -> adw::NavigationPage {
|
pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||||
let scrolled = gtk::ScrolledWindow::builder()
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
@@ -39,6 +40,7 @@ pub fn build_output_page() -> adw::NavigationPage {
|
|||||||
.title("Output Location")
|
.title("Output Location")
|
||||||
.subtitle("processed/ (subfolder next to originals)")
|
.subtitle("processed/ (subfolder next to originals)")
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
|
.action_name("win.choose-output")
|
||||||
.build();
|
.build();
|
||||||
output_row.add_prefix(>k::Image::from_icon_name("folder-symbolic"));
|
output_row.add_prefix(>k::Image::from_icon_name("folder-symbolic"));
|
||||||
|
|
||||||
@@ -96,6 +98,22 @@ pub fn build_output_page() -> adw::NavigationPage {
|
|||||||
stats_group.add(&count_row);
|
stats_group.add(&count_row);
|
||||||
content.append(&stats_group);
|
content.append(&stats_group);
|
||||||
|
|
||||||
|
// Wire preserve directory structure
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
structure_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().preserve_dir_structure = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire overwrite behavior
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
overwrite_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().overwrite_behavior = row.selected() as u8;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
scrolled.set_child(Some(&content));
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
let clamp = adw::Clamp::builder()
|
let clamp = adw::Clamp::builder()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use pixstrip_core::preset::Preset;
|
use pixstrip_core::preset::Preset;
|
||||||
|
use pixstrip_core::operations::*;
|
||||||
|
use crate::app::{AppState, JobConfig, MetadataMode};
|
||||||
|
|
||||||
pub fn build_workflow_page() -> adw::NavigationPage {
|
pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||||
let scrolled = gtk::ScrolledWindow::builder()
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
@@ -31,15 +33,23 @@ pub fn build_workflow_page() -> adw::NavigationPage {
|
|||||||
.homogeneous(true)
|
.homogeneous(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
for preset in Preset::all_builtins() {
|
let builtins = Preset::all_builtins();
|
||||||
let card = build_preset_card(&preset);
|
for preset in &builtins {
|
||||||
|
let card = build_preset_card(preset);
|
||||||
builtin_flow.append(&card);
|
builtin_flow.append(&card);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a preset card is activated, advance to the next step
|
// When a preset card is activated, apply it to JobConfig and advance
|
||||||
builtin_flow.connect_child_activated(|flow, _child| {
|
{
|
||||||
flow.activate_action("win.next-step", None).ok();
|
let jc = state.job_config.clone();
|
||||||
});
|
builtin_flow.connect_child_activated(move |flow, child| {
|
||||||
|
let idx = child.index() as usize;
|
||||||
|
if let Some(preset) = builtins.get(idx) {
|
||||||
|
apply_preset_to_config(&mut jc.borrow_mut(), preset);
|
||||||
|
}
|
||||||
|
flow.activate_action("win.next-step", None).ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
builtin_group.add(&builtin_flow);
|
builtin_group.add(&builtin_flow);
|
||||||
content.append(&builtin_group);
|
content.append(&builtin_group);
|
||||||
@@ -63,15 +73,42 @@ pub fn build_workflow_page() -> adw::NavigationPage {
|
|||||||
custom_group.add(&custom_flow);
|
custom_group.add(&custom_flow);
|
||||||
content.append(&custom_group);
|
content.append(&custom_group);
|
||||||
|
|
||||||
// User presets section (initially empty)
|
// User presets section
|
||||||
let user_group = adw::PreferencesGroup::builder()
|
let user_group = adw::PreferencesGroup::builder()
|
||||||
.title("Your Presets")
|
.title("Your Presets")
|
||||||
.description("Import or save your own workflows")
|
.description("Import or save your own workflows")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Show saved user presets
|
||||||
|
let store = pixstrip_core::storage::PresetStore::new();
|
||||||
|
if let Ok(presets) = store.list() {
|
||||||
|
for preset in &presets {
|
||||||
|
if !preset.is_custom {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(&preset.name)
|
||||||
|
.subtitle(&preset.description)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name(&preset.icon));
|
||||||
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let p = preset.clone();
|
||||||
|
row.connect_activated(move |r| {
|
||||||
|
apply_preset_to_config(&mut jc.borrow_mut(), &p);
|
||||||
|
r.activate_action("win.next-step", None).ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
user_group.add(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let import_button = gtk::Button::builder()
|
let import_button = gtk::Button::builder()
|
||||||
.label("Import Preset")
|
.label("Import Preset")
|
||||||
.icon_name("document-open-symbolic")
|
.icon_name("document-open-symbolic")
|
||||||
|
.action_name("win.import-preset")
|
||||||
.build();
|
.build();
|
||||||
import_button.add_css_class("flat");
|
import_button.add_css_class("flat");
|
||||||
user_group.add(&import_button);
|
user_group.add(&import_button);
|
||||||
@@ -91,6 +128,101 @@ pub fn build_workflow_page() -> adw::NavigationPage {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
||||||
|
// Resize
|
||||||
|
match &preset.resize {
|
||||||
|
Some(ResizeConfig::ByWidth(w)) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = *w;
|
||||||
|
cfg.resize_height = 0;
|
||||||
|
cfg.allow_upscale = false;
|
||||||
|
}
|
||||||
|
Some(ResizeConfig::ByHeight(h)) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = 0;
|
||||||
|
cfg.resize_height = *h;
|
||||||
|
cfg.allow_upscale = false;
|
||||||
|
}
|
||||||
|
Some(ResizeConfig::FitInBox { max, allow_upscale }) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = max.width;
|
||||||
|
cfg.resize_height = max.height;
|
||||||
|
cfg.allow_upscale = *allow_upscale;
|
||||||
|
}
|
||||||
|
Some(ResizeConfig::Exact(dims)) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = dims.width;
|
||||||
|
cfg.resize_height = dims.height;
|
||||||
|
cfg.allow_upscale = true;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.resize_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert
|
||||||
|
match &preset.convert {
|
||||||
|
Some(ConvertConfig::SingleFormat(fmt)) => {
|
||||||
|
cfg.convert_enabled = true;
|
||||||
|
cfg.convert_format = Some(*fmt);
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
cfg.convert_enabled = true;
|
||||||
|
cfg.convert_format = None;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.convert_enabled = false;
|
||||||
|
cfg.convert_format = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress
|
||||||
|
match &preset.compress {
|
||||||
|
Some(CompressConfig::Preset(q)) => {
|
||||||
|
cfg.compress_enabled = true;
|
||||||
|
cfg.quality_preset = *q;
|
||||||
|
}
|
||||||
|
Some(CompressConfig::Custom { jpeg_quality, png_level, webp_quality, .. }) => {
|
||||||
|
cfg.compress_enabled = true;
|
||||||
|
if let Some(jq) = jpeg_quality {
|
||||||
|
cfg.jpeg_quality = *jq;
|
||||||
|
}
|
||||||
|
if let Some(pl) = png_level {
|
||||||
|
cfg.png_level = *pl;
|
||||||
|
}
|
||||||
|
if let Some(wq) = webp_quality {
|
||||||
|
cfg.webp_quality = *wq as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.compress_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
match &preset.metadata {
|
||||||
|
Some(MetadataConfig::StripAll) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::StripAll;
|
||||||
|
}
|
||||||
|
Some(MetadataConfig::Privacy) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::Privacy;
|
||||||
|
}
|
||||||
|
Some(MetadataConfig::KeepAll) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::KeepAll;
|
||||||
|
}
|
||||||
|
Some(MetadataConfig::Custom { .. }) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::StripAll;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.metadata_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||||
let card = gtk::Box::builder()
|
let card = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ impl WizardState {
|
|||||||
|
|
||||||
pub fn build_wizard_pages(state: &AppState) -> Vec<adw::NavigationPage> {
|
pub fn build_wizard_pages(state: &AppState) -> Vec<adw::NavigationPage> {
|
||||||
vec![
|
vec![
|
||||||
steps::step_workflow::build_workflow_page(),
|
steps::step_workflow::build_workflow_page(state),
|
||||||
steps::step_images::build_images_page(),
|
steps::step_images::build_images_page(state),
|
||||||
steps::step_resize::build_resize_page(state),
|
steps::step_resize::build_resize_page(state),
|
||||||
steps::step_convert::build_convert_page(state),
|
steps::step_convert::build_convert_page(state),
|
||||||
steps::step_compress::build_compress_page(state),
|
steps::step_compress::build_compress_page(state),
|
||||||
steps::step_metadata::build_metadata_page(state),
|
steps::step_metadata::build_metadata_page(state),
|
||||||
steps::step_output::build_output_page(),
|
steps::step_output::build_output_page(state),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user