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.
This commit is contained in:
2026-03-06 15:52:18 +02:00
parent ced65f10ec
commit a09462fd53
2 changed files with 247 additions and 1 deletions

View File

@@ -96,6 +96,31 @@ pub struct AppState {
pub output_dir: Rc<RefCell<Option<std::path::PathBuf>>>,
pub job_config: Rc<RefCell<JobConfig>>,
pub detailed_mode: bool,
pub batch_queue: Rc<RefCell<BatchQueue>>,
}
/// A queued batch of images with their processing settings
#[derive(Clone, Debug)]
pub struct QueuedBatch {
pub name: String,
pub files: Vec<std::path::PathBuf>,
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<QueuedBatch>,
}
#[derive(Clone)]
@@ -107,6 +132,8 @@ struct WizardUi {
title: adw::WindowTitle,
pages: Vec<adw::NavigationPage>,
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(&gtk::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::<gtk::Label>() {
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(&gtk::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<std::path::PathBuf> = {
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"));
}

View File

@@ -216,8 +216,17 @@ pub fn build_results_page() -> adw::NavigationPage {
save_preset_row.add_prefix(&gtk::Image::from_icon_name("document-save-symbolic"));
save_preset_row.add_suffix(&gtk::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(&gtk::Image::from_icon_name("view-list-symbolic"));
add_queue_row.add_suffix(&gtk::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);