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 output_dir: Rc<RefCell<Option<std::path::PathBuf>>>,
|
||||||
pub job_config: Rc<RefCell<JobConfig>>,
|
pub job_config: Rc<RefCell<JobConfig>>,
|
||||||
pub detailed_mode: bool,
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -107,6 +132,8 @@ struct WizardUi {
|
|||||||
title: adw::WindowTitle,
|
title: adw::WindowTitle,
|
||||||
pages: Vec<adw::NavigationPage>,
|
pages: Vec<adw::NavigationPage>,
|
||||||
toast_overlay: adw::ToastOverlay,
|
toast_overlay: adw::ToastOverlay,
|
||||||
|
queue_button: gtk::ToggleButton,
|
||||||
|
queue_list_box: gtk::ListBox,
|
||||||
state: AppState,
|
state: AppState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +248,7 @@ fn build_ui(app: &adw::Application) {
|
|||||||
excluded_files: Rc::new(RefCell::new(std::collections::HashSet::new())),
|
excluded_files: Rc::new(RefCell::new(std::collections::HashSet::new())),
|
||||||
output_dir: Rc::new(RefCell::new(None)),
|
output_dir: Rc::new(RefCell::new(None)),
|
||||||
detailed_mode: app_cfg.skill_level.is_advanced(),
|
detailed_mode: app_cfg.skill_level.is_advanced(),
|
||||||
|
batch_queue: Rc::new(RefCell::new(BatchQueue::default())),
|
||||||
job_config: Rc::new(RefCell::new(JobConfig {
|
job_config: Rc::new(RefCell::new(JobConfig {
|
||||||
resize_enabled: if remember { sess_state.resize_enabled.unwrap_or(true) } else { true },
|
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 },
|
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");
|
let title = adw::WindowTitle::new("Pixstrip", "Batch Image Processor");
|
||||||
header.set_title_widget(Some(&title));
|
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
|
// Help button for per-step contextual help
|
||||||
let help_button = gtk::Button::builder()
|
let help_button = gtk::Button::builder()
|
||||||
.icon_name("help-about-symbolic")
|
.icon_name("help-about-symbolic")
|
||||||
@@ -368,10 +405,31 @@ fn build_ui(app: &adw::Application) {
|
|||||||
content_box.append(step_indicator.widget());
|
content_box.append(step_indicator.widget());
|
||||||
content_box.append(&nav_view);
|
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
|
// Toolbar view with header and bottom bar
|
||||||
let toolbar_view = adw::ToolbarView::new();
|
let toolbar_view = adw::ToolbarView::new();
|
||||||
toolbar_view.add_top_bar(&header);
|
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);
|
toolbar_view.add_bottom_bar(&bottom_bar);
|
||||||
|
|
||||||
// Toast overlay wraps everything for in-app notifications
|
// Toast overlay wraps everything for in-app notifications
|
||||||
@@ -442,6 +500,8 @@ fn build_ui(app: &adw::Application) {
|
|||||||
title,
|
title,
|
||||||
pages,
|
pages,
|
||||||
toast_overlay,
|
toast_overlay,
|
||||||
|
queue_button,
|
||||||
|
queue_list_box,
|
||||||
state: app_state,
|
state: app_state,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1799,6 +1859,13 @@ fn wire_results_actions(
|
|||||||
reset_wizard(&ui);
|
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" => {
|
"Save as Preset" => {
|
||||||
row.set_action_name(Some("win.save-preset"));
|
row.set_action_name(Some("win.save-preset"));
|
||||||
}
|
}
|
||||||
@@ -2725,3 +2792,173 @@ fn format_duration(ms: u64) -> String {
|
|||||||
format!("{}m {}s", mins, secs)
|
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_prefix(>k::Image::from_icon_name("document-save-symbolic"));
|
||||||
save_preset_row.add_suffix(>k::Image::from_icon_name("go-next-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(&open_row);
|
||||||
action_group.add(&process_more_row);
|
action_group.add(&process_more_row);
|
||||||
|
action_group.add(&add_queue_row);
|
||||||
action_group.add(&save_preset_row);
|
action_group.add(&save_preset_row);
|
||||||
content.append(&action_group);
|
content.append(&action_group);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user