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:
@@ -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(>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::<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(>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<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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user