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

@@ -1,6 +1,7 @@
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()
.transition_type(gtk::StackTransitionType::Crossfade)
.build();
@@ -9,12 +10,40 @@ pub fn build_images_page() -> adw::NavigationPage {
let empty_state = build_empty_state();
stack.add_named(&empty_state, Some("empty"));
// Loaded state - thumbnail grid (placeholder for now)
let loaded_state = build_loaded_state();
// Loaded state - thumbnail grid
let loaded_state = build_loaded_state(state);
stack.add_named(&loaded_state, Some("loaded"));
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()
.title("Add Images")
.tag("step-images")
@@ -22,6 +51,38 @@ pub fn build_images_page() -> adw::NavigationPage {
.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: &gtk::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: &gtk::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 {
let container = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
@@ -87,7 +148,7 @@ fn build_empty_state() -> gtk::Box {
container
}
fn build_loaded_state() -> gtk::Box {
fn build_loaded_state(state: &AppState) -> gtk::Box {
let container = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
@@ -104,7 +165,7 @@ fn build_loaded_state() -> gtk::Box {
.build();
let count_label = gtk::Label::builder()
.label("0 images (0 B)")
.label("0 images loaded")
.hexpand(true)
.halign(gtk::Align::Start)
.css_classes(["heading"])
@@ -117,28 +178,54 @@ fn build_loaded_state() -> gtk::Box {
.build();
add_button.add_css_class("flat");
let select_all_button = gtk::Button::builder()
.label("Select All")
.tooltip_text("Select all images (Ctrl+A)")
let clear_button = gtk::Button::builder()
.icon_name("edit-clear-all-symbolic")
.tooltip_text("Remove all images")
.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(&add_button);
toolbar.append(&select_all_button);
toolbar.append(&clear_button);
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
// Thumbnail grid placeholder
let grid_placeholder = adw::StatusPage::builder()
.title("Images will appear here")
.icon_name("image-x-generic-symbolic")
// File list showing loaded images
let list_scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.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(&separator);
container.append(&grid_placeholder);
container.append(&list_scrolled);
container
}