From a09462fd53ccb727870aebf2c615b517307d934e Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 15:52:18 +0200 Subject: [PATCH] Add batch queue with slide-out side panel Queue panel with OverlaySplitView sidebar. Users can add batches from the results page via "Add to Queue" action. Queue shows pending/active/completed batches with status icons. Toggle via header bar button. Batches can be removed while pending. --- pixstrip-gtk/src/app.rs | 239 ++++++++++++++++++++++++++++++++- pixstrip-gtk/src/processing.rs | 9 ++ 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 3cd5225..d23128b 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -96,6 +96,31 @@ pub struct AppState { pub output_dir: Rc>>, pub job_config: Rc>, pub detailed_mode: bool, + pub batch_queue: Rc>, +} + +/// A queued batch of images with their processing settings +#[derive(Clone, Debug)] +pub struct QueuedBatch { + pub name: String, + pub files: Vec, + pub output_dir: std::path::PathBuf, + pub job_config: JobConfig, + pub status: BatchStatus, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum BatchStatus { + Pending, + Processing, + Completed, + Failed(String), +} + +/// Batch queue holding pending, active, and completed batches +#[derive(Clone, Debug, Default)] +pub struct BatchQueue { + pub batches: Vec, } #[derive(Clone)] @@ -107,6 +132,8 @@ struct WizardUi { title: adw::WindowTitle, pages: Vec, toast_overlay: adw::ToastOverlay, + queue_button: gtk::ToggleButton, + queue_list_box: gtk::ListBox, state: AppState, } @@ -221,6 +248,7 @@ fn build_ui(app: &adw::Application) { excluded_files: Rc::new(RefCell::new(std::collections::HashSet::new())), output_dir: Rc::new(RefCell::new(None)), detailed_mode: app_cfg.skill_level.is_advanced(), + batch_queue: Rc::new(RefCell::new(BatchQueue::default())), job_config: Rc::new(RefCell::new(JobConfig { resize_enabled: if remember { sess_state.resize_enabled.unwrap_or(true) } else { true }, resize_width: if remember { sess_state.resize_width.unwrap_or(1200) } else { 1200 }, @@ -306,6 +334,15 @@ fn build_ui(app: &adw::Application) { let title = adw::WindowTitle::new("Pixstrip", "Batch Image Processor"); header.set_title_widget(Some(&title)); + // Queue toggle button + let queue_button = gtk::ToggleButton::builder() + .icon_name("view-list-symbolic") + .tooltip_text("Batch Queue") + .visible(false) + .build(); + queue_button.add_css_class("flat"); + header.pack_start(&queue_button); + // Help button for per-step contextual help let help_button = gtk::Button::builder() .icon_name("help-about-symbolic") @@ -368,10 +405,31 @@ fn build_ui(app: &adw::Application) { content_box.append(step_indicator.widget()); content_box.append(&nav_view); + // Queue side panel + let (queue_panel, queue_list_box) = build_queue_panel(&app_state); + + // Overlay split view: queue sidebar + main content + let split_view = adw::OverlaySplitView::builder() + .sidebar(&queue_panel) + .sidebar_position(gtk::PackType::End) + .show_sidebar(false) + .max_sidebar_width(300.0) + .min_sidebar_width(250.0) + .build(); + + // Wire queue toggle button to show/hide sidebar + { + let sv = split_view.clone(); + queue_button.connect_toggled(move |btn| { + sv.set_show_sidebar(btn.is_active()); + }); + } + // Toolbar view with header and bottom bar let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); - toolbar_view.set_content(Some(&content_box)); + split_view.set_content(Some(&content_box)); + toolbar_view.set_content(Some(&split_view)); toolbar_view.add_bottom_bar(&bottom_bar); // Toast overlay wraps everything for in-app notifications @@ -442,6 +500,8 @@ fn build_ui(app: &adw::Application) { title, pages, toast_overlay, + queue_button, + queue_list_box, state: app_state, }; @@ -1799,6 +1859,13 @@ fn wire_results_actions( reset_wizard(&ui); }); } + "Add to Queue" => { + let ui = ui.clone(); + row.connect_activated(move |_| { + add_current_batch_to_queue(&ui); + reset_wizard(&ui); + }); + } "Save as Preset" => { row.set_action_name(Some("win.save-preset")); } @@ -2725,3 +2792,173 @@ fn format_duration(ms: u64) -> String { format!("{}m {}s", mins, secs) } } + +fn build_queue_panel(_state: &AppState) -> (gtk::Box, gtk::ListBox) { + let panel = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) + .width_request(260) + .build(); + + // Header + let header_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .margin_top(12) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build(); + + let header_label = gtk::Label::builder() + .label("Batch Queue") + .css_classes(["heading"]) + .hexpand(true) + .halign(gtk::Align::Start) + .build(); + header_box.append(&header_label); + + panel.append(&header_box); + panel.append(>k::Separator::new(gtk::Orientation::Horizontal)); + + // Empty state + let empty_label = gtk::Label::builder() + .label("No batches queued") + .css_classes(["dim-label"]) + .vexpand(true) + .valign(gtk::Align::Center) + .halign(gtk::Align::Center) + .build(); + + let list_box = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .margin_start(8) + .margin_end(8) + .margin_top(8) + .visible(false) + .build(); + + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + inner.append(&empty_label); + inner.append(&list_box); + scrolled.set_child(Some(&inner)); + + panel.append(&scrolled); + + (panel, list_box) +} + +fn refresh_queue_list(ui: &WizardUi) { + let list_box = &ui.queue_list_box; + + // Remove all existing rows + while let Some(child) = list_box.first_child() { + list_box.remove(&child); + } + + let queue = ui.state.batch_queue.borrow(); + + // Toggle empty state visibility + if let Some(parent) = list_box.parent() { + // Find sibling empty label + if let Some(first_child) = parent.first_child() { + if let Some(label) = first_child.downcast_ref::() { + label.set_visible(queue.batches.is_empty()); + } + } + } + list_box.set_visible(!queue.batches.is_empty()); + + for (i, batch) in queue.batches.iter().enumerate() { + let status_icon = match &batch.status { + BatchStatus::Pending => "content-loading-symbolic", + BatchStatus::Processing => "emblem-synchronizing-symbolic", + BatchStatus::Completed => "emblem-ok-symbolic", + BatchStatus::Failed(_) => "dialog-error-symbolic", + }; + + let status_text = match &batch.status { + BatchStatus::Pending => "Pending".to_string(), + BatchStatus::Processing => "Processing...".to_string(), + BatchStatus::Completed => "Completed".to_string(), + BatchStatus::Failed(e) => format!("Failed: {}", e), + }; + + let row = adw::ActionRow::builder() + .title(&batch.name) + .subtitle(&format!("{} images - {}", batch.files.len(), status_text)) + .build(); + row.add_prefix(>k::Image::from_icon_name(status_icon)); + + // Add remove button for pending batches + if batch.status == BatchStatus::Pending { + let remove_btn = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Remove from queue") + .valign(gtk::Align::Center) + .build(); + remove_btn.add_css_class("flat"); + + let queue_ref = ui.state.batch_queue.clone(); + let ui_clone = ui.clone(); + let idx = i; + remove_btn.connect_clicked(move |_| { + queue_ref.borrow_mut().batches.remove(idx); + refresh_queue_list(&ui_clone); + }); + + row.add_suffix(&remove_btn); + } + + list_box.append(&row); + } + + // Show/hide queue button badge + let has_pending = queue.batches.iter().any(|b| b.status == BatchStatus::Pending); + ui.queue_button.set_visible(!queue.batches.is_empty() || has_pending); +} + +fn add_current_batch_to_queue(ui: &WizardUi) { + let files: Vec = { + let loaded = ui.state.loaded_files.borrow(); + let excluded = ui.state.excluded_files.borrow(); + loaded.iter().filter(|p| !excluded.contains(*p)).cloned().collect() + }; + + if files.is_empty() { + ui.toast_overlay.add_toast(adw::Toast::new("No images to queue")); + return; + } + + let output_dir = ui.state.output_dir.borrow().clone() + .unwrap_or_else(|| { + files[0].parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("processed") + }); + + let config = ui.state.job_config.borrow().clone(); + let batch_num = ui.state.batch_queue.borrow().batches.len() + 1; + + let batch = QueuedBatch { + name: format!("Batch {}", batch_num), + files, + output_dir, + job_config: config, + status: BatchStatus::Pending, + }; + + ui.state.batch_queue.borrow_mut().batches.push(batch); + ui.queue_button.set_visible(true); + refresh_queue_list(ui); + ui.toast_overlay.add_toast(adw::Toast::new("Batch added to queue")); +} diff --git a/pixstrip-gtk/src/processing.rs b/pixstrip-gtk/src/processing.rs index 492c42c..42d57a4 100644 --- a/pixstrip-gtk/src/processing.rs +++ b/pixstrip-gtk/src/processing.rs @@ -216,8 +216,17 @@ pub fn build_results_page() -> adw::NavigationPage { save_preset_row.add_prefix(>k::Image::from_icon_name("document-save-symbolic")); save_preset_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + let add_queue_row = adw::ActionRow::builder() + .title("Add to Queue") + .subtitle("Queue another batch with different images") + .activatable(true) + .build(); + add_queue_row.add_prefix(>k::Image::from_icon_name("view-list-symbolic")); + add_queue_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + action_group.add(&open_row); action_group.add(&process_more_row); + action_group.add(&add_queue_row); action_group.add(&save_preset_row); content.append(&action_group);