Files
pixstrip/pixstrip-gtk/src/app.rs
lashman 683422f8f5 Improve screen reader support for wizard navigation
- Announce step number and name when navigating between steps
- Add accessible label to batch queue toggle button
- Add accessible label to navigation view with shortcut hint
- Step transitions update accessible property for screen readers
2026-03-06 17:02:04 +02:00

3228 lines
115 KiB
Rust

use adw::prelude::*;
use gtk::glib;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::step_indicator::StepIndicator;
use crate::wizard::WizardState;
pub const APP_ID: &str = "live.lashman.Pixstrip";
/// User's choices from the wizard steps, used to build the ProcessingJob
#[derive(Clone, Debug)]
pub struct JobConfig {
// Resize
pub resize_enabled: bool,
pub resize_width: u32,
pub resize_height: u32,
pub allow_upscale: bool,
pub resize_algorithm: u32,
pub output_dpi: u32,
// Adjustments
pub adjustments_enabled: bool,
pub rotation: u32,
pub flip: u32,
pub brightness: i32,
pub contrast: i32,
pub saturation: i32,
pub sharpen: bool,
pub grayscale: bool,
pub sepia: bool,
pub crop_aspect_ratio: u32,
pub trim_whitespace: bool,
pub canvas_padding: u32,
// Convert
pub convert_enabled: bool,
pub convert_format: Option<pixstrip_core::types::ImageFormat>,
pub progressive_jpeg: bool,
pub format_mapping_jpeg: u32,
pub format_mapping_png: u32,
pub format_mapping_webp: u32,
pub format_mapping_tiff: u32,
// Compress
pub compress_enabled: bool,
pub quality_preset: pixstrip_core::types::QualityPreset,
pub jpeg_quality: u8,
pub png_level: u8,
pub webp_quality: u8,
pub avif_quality: u8,
pub webp_effort: u8,
pub avif_speed: u8,
// Metadata
pub metadata_enabled: bool,
pub metadata_mode: MetadataMode,
pub strip_gps: bool,
pub strip_camera: bool,
pub strip_software: bool,
pub strip_timestamps: bool,
pub strip_copyright: bool,
// Watermark
pub watermark_enabled: bool,
pub watermark_text: String,
pub watermark_image_path: Option<std::path::PathBuf>,
pub watermark_position: u32,
pub watermark_opacity: f32,
pub watermark_font_size: f32,
pub watermark_color: [u8; 4],
pub watermark_use_image: bool,
// Rename
pub rename_enabled: bool,
pub rename_prefix: String,
pub rename_suffix: String,
pub rename_counter_start: u32,
pub rename_counter_padding: u32,
pub rename_template: String,
// Output
pub preserve_dir_structure: bool,
pub overwrite_behavior: u8,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum MetadataMode {
#[default]
StripAll,
Privacy,
KeepAll,
Custom,
}
/// Shared app state accessible from all UI callbacks
#[derive(Clone)]
pub struct AppState {
pub wizard: Rc<RefCell<WizardState>>,
pub loaded_files: Rc<RefCell<Vec<std::path::PathBuf>>>,
pub excluded_files: Rc<RefCell<std::collections::HashSet<std::path::PathBuf>>>,
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)]
struct WizardUi {
nav_view: adw::NavigationView,
step_indicator: StepIndicator,
back_button: gtk::Button,
next_button: gtk::Button,
title: adw::WindowTitle,
pages: Vec<adw::NavigationPage>,
toast_overlay: adw::ToastOverlay,
queue_button: gtk::ToggleButton,
queue_list_box: gtk::ListBox,
state: AppState,
}
pub fn build_app() -> adw::Application {
let app = adw::Application::builder()
.application_id(APP_ID)
.flags(gtk::gio::ApplicationFlags::HANDLES_OPEN)
.build();
app.connect_activate(build_ui);
// Handle files opened via file manager integration or command line
app.connect_open(|app, files, _hint| {
// Ensure the window exists (activate first if needed)
if app.active_window().is_none() {
app.activate();
}
// Collect valid image file paths
let image_paths: Vec<std::path::PathBuf> = files.iter()
.filter_map(|f| f.path())
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| {
matches!(
ext.to_lowercase().as_str(),
"jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"
)
})
})
.collect();
if image_paths.is_empty() {
return;
}
// Find the WizardUi through the window's action group
if let Some(window) = app.active_window() {
// Store files in a global channel for the UI to pick up
if let Some(action) = window.downcast_ref::<adw::ApplicationWindow>()
.and_then(|w| w.lookup_action("load-external-files"))
{
// Serialize paths as string variant
let paths_str: String = image_paths.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("\n");
action.downcast_ref::<gtk::gio::SimpleAction>()
.unwrap()
.activate(Some(&paths_str.to_variant()));
}
}
});
setup_shortcuts(&app);
// App-level quit action
let quit_action = gtk::gio::SimpleAction::new("quit", None);
let app_clone = app.clone();
quit_action.connect_activate(move |_, _| {
app_clone.quit();
});
app.add_action(&quit_action);
app
}
fn setup_shortcuts(app: &adw::Application) {
app.set_accels_for_action("win.next-step", &["<Alt>Right"]);
app.set_accels_for_action("win.prev-step", &["<Alt>Left", "Escape"]);
app.set_accels_for_action("win.process", &["<Control>Return"]);
for i in 1..=9 {
app.set_accels_for_action(
&format!("win.goto-step({})", i),
&[&format!("<Alt>{}", i)],
);
}
app.set_accels_for_action("win.add-files", &["<Control>o"]);
app.set_accels_for_action("win.select-all-images", &["<Control>a"]);
app.set_accels_for_action("win.deselect-all-images", &["<Control><Shift>a"]);
app.set_accels_for_action("win.undo-last-batch", &["<Control>z"]);
app.set_accels_for_action("win.paste-images", &["<Control>v"]);
app.set_accels_for_action("app.quit", &["<Control>q"]);
app.set_accels_for_action("win.show-settings", &["<Control>comma"]);
app.set_accels_for_action("win.show-shortcuts", &["<Control>question", "F1"]);
}
fn load_css() {
let provider = gtk::CssProvider::new();
provider.load_from_string(
r#"
.thumbnail-frame {
border-radius: 8px;
background: @card_bg_color;
}
.thumbnail-grid {
padding: 8px;
}
.thumbnail-check {
opacity: 0.9;
}
"#,
);
gtk::style_context_add_provider_for_display(
&gtk::gdk::Display::default().expect("Could not get default display"),
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
fn parse_image_format(s: &str) -> Option<pixstrip_core::types::ImageFormat> {
match s {
"Jpeg" => Some(pixstrip_core::types::ImageFormat::Jpeg),
"Png" => Some(pixstrip_core::types::ImageFormat::Png),
"WebP" => Some(pixstrip_core::types::ImageFormat::WebP),
"Avif" => Some(pixstrip_core::types::ImageFormat::Avif),
"Gif" => Some(pixstrip_core::types::ImageFormat::Gif),
"Tiff" => Some(pixstrip_core::types::ImageFormat::Tiff),
_ => None,
}
}
fn parse_quality_preset(s: &str) -> Option<pixstrip_core::types::QualityPreset> {
match s {
"Maximum" => Some(pixstrip_core::types::QualityPreset::Maximum),
"High" => Some(pixstrip_core::types::QualityPreset::High),
"Medium" => Some(pixstrip_core::types::QualityPreset::Medium),
"Low" => Some(pixstrip_core::types::QualityPreset::Low),
"WebOptimized" => Some(pixstrip_core::types::QualityPreset::WebOptimized),
_ => None,
}
}
fn parse_metadata_mode(s: &str) -> Option<MetadataMode> {
match s {
"StripAll" => Some(MetadataMode::StripAll),
"Privacy" => Some(MetadataMode::Privacy),
"KeepAll" => Some(MetadataMode::KeepAll),
"Custom" => Some(MetadataMode::Custom),
_ => None,
}
}
fn build_ui(app: &adw::Application) {
load_css();
// Restore last-used wizard settings from session
let sess = pixstrip_core::storage::SessionStore::new();
let sess_state = sess.load().unwrap_or_default();
let cfg_store = pixstrip_core::storage::ConfigStore::new();
let app_cfg = cfg_store.load().unwrap_or_default();
let remember = app_cfg.remember_settings;
let app_state = AppState {
wizard: Rc::new(RefCell::new(WizardState::new())),
loaded_files: Rc::new(RefCell::new(Vec::new())),
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 },
resize_height: if remember { sess_state.resize_height.unwrap_or(0) } else { 0 },
allow_upscale: false,
resize_algorithm: 0,
output_dpi: 72,
adjustments_enabled: false,
rotation: 0,
flip: 0,
brightness: 0,
contrast: 0,
saturation: 0,
sharpen: false,
grayscale: false,
sepia: false,
crop_aspect_ratio: 0,
trim_whitespace: false,
canvas_padding: 0,
convert_enabled: if remember { sess_state.convert_enabled.unwrap_or(false) } else { false },
convert_format: if remember {
sess_state.convert_format.as_deref().and_then(parse_image_format)
} else {
None
},
progressive_jpeg: false,
format_mapping_jpeg: 0,
format_mapping_png: 0,
format_mapping_webp: 0,
format_mapping_tiff: 0,
compress_enabled: if remember { sess_state.compress_enabled.unwrap_or(true) } else { true },
quality_preset: if remember {
sess_state.quality_preset.as_deref()
.and_then(parse_quality_preset)
.unwrap_or(pixstrip_core::types::QualityPreset::Medium)
} else {
pixstrip_core::types::QualityPreset::Medium
},
jpeg_quality: 85,
png_level: 3,
webp_quality: 80,
avif_quality: 50,
webp_effort: 4,
avif_speed: 6,
metadata_enabled: if remember { sess_state.metadata_enabled.unwrap_or(true) } else { true },
metadata_mode: if remember {
sess_state.metadata_mode.as_deref()
.and_then(parse_metadata_mode)
.unwrap_or(MetadataMode::StripAll)
} else {
MetadataMode::StripAll
},
strip_gps: true,
strip_camera: true,
strip_software: true,
strip_timestamps: true,
strip_copyright: true,
watermark_enabled: if remember { sess_state.watermark_enabled.unwrap_or(false) } else { false },
watermark_text: String::new(),
watermark_image_path: None,
watermark_position: 8, // BottomRight
watermark_opacity: 0.5,
watermark_font_size: 24.0,
watermark_color: [255, 255, 255, 255],
watermark_use_image: false,
rename_enabled: if remember { sess_state.rename_enabled.unwrap_or(false) } else { false },
rename_prefix: String::new(),
rename_suffix: String::new(),
rename_counter_start: 1,
rename_counter_padding: 3,
rename_template: String::new(),
preserve_dir_structure: false,
overwrite_behavior: match app_cfg.overwrite_behavior {
pixstrip_core::config::OverwriteBehavior::Ask => 0,
pixstrip_core::config::OverwriteBehavior::AutoRename => 1,
pixstrip_core::config::OverwriteBehavior::Overwrite => 2,
pixstrip_core::config::OverwriteBehavior::Skip => 3,
},
})),
};
// Header bar
let header = adw::HeaderBar::new();
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");
queue_button.update_property(&[
gtk::accessible::Property::Label("Toggle batch queue panel"),
]);
header.pack_start(&queue_button);
// Help button for per-step contextual help
let help_button = gtk::Button::builder()
.icon_name("help-about-symbolic")
.tooltip_text("Help for this step")
.build();
help_button.add_css_class("flat");
header.pack_end(&help_button);
// Hamburger menu
let menu = build_menu();
let menu_button = gtk::MenuButton::builder()
.icon_name("open-menu-symbolic")
.menu_model(&menu)
.primary(true)
.tooltip_text("Main Menu")
.build();
header.pack_end(&menu_button);
// Step indicator
let step_indicator = StepIndicator::new(&app_state.wizard.borrow().step_names());
// Navigation view for wizard content
let nav_view = adw::NavigationView::new();
nav_view.set_vexpand(true);
nav_view.update_property(&[
gtk::accessible::Property::Label("Wizard steps. Use Alt+Left/Right to navigate."),
]);
// Build wizard pages
let pages = crate::wizard::build_wizard_pages(&app_state);
for page in &pages {
nav_view.add(page);
}
// Bottom action bar
let back_button = gtk::Button::builder()
.label("Back")
.tooltip_text("Go to previous step (Alt+Left)")
.build();
back_button.add_css_class("flat");
let next_button = gtk::Button::builder()
.label("Next")
.tooltip_text("Go to next step (Alt+Right)")
.build();
next_button.add_css_class("suggested-action");
let bottom_box = gtk::CenterBox::new();
bottom_box.set_start_widget(Some(&back_button));
bottom_box.set_end_widget(Some(&next_button));
bottom_box.set_margin_start(12);
bottom_box.set_margin_end(12);
bottom_box.set_margin_top(6);
bottom_box.set_margin_bottom(6);
let bottom_bar = gtk::Box::new(gtk::Orientation::Vertical, 0);
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
bottom_bar.append(&separator);
bottom_bar.append(&bottom_box);
// Main content layout
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
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);
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
let toast_overlay = adw::ToastOverlay::new();
toast_overlay.set_child(Some(&toolbar_view));
// Restore window size from session
let session = pixstrip_core::storage::SessionStore::new();
let session_state = session.load().unwrap_or_default();
let win_width = session_state.window_width.unwrap_or(900);
let win_height = session_state.window_height.unwrap_or(700);
let window = adw::ApplicationWindow::builder()
.application(app)
.default_width(win_width)
.default_height(win_height)
.content(&toast_overlay)
.title("Pixstrip")
.build();
if session_state.window_maximized {
window.maximize();
}
// Save window size and wizard settings on close
{
let app_state_for_close = app_state.clone();
window.connect_close_request(move |win| {
let session = pixstrip_core::storage::SessionStore::new();
let mut state = session.load().unwrap_or_default();
state.window_maximized = win.is_maximized();
if !win.is_maximized() {
let (w, h) = (win.default_size().0, win.default_size().1);
if w > 0 && h > 0 {
state.window_width = Some(w);
state.window_height = Some(h);
}
}
// Save last-used wizard settings if remember_settings is enabled
let config_store = pixstrip_core::storage::ConfigStore::new();
let config = config_store.load().unwrap_or_default();
if config.remember_settings {
let cfg = app_state_for_close.job_config.borrow();
state.resize_enabled = Some(cfg.resize_enabled);
state.resize_width = Some(cfg.resize_width);
state.resize_height = Some(cfg.resize_height);
state.convert_enabled = Some(cfg.convert_enabled);
state.convert_format = cfg.convert_format.map(|f| format!("{:?}", f));
state.compress_enabled = Some(cfg.compress_enabled);
state.quality_preset = Some(format!("{:?}", cfg.quality_preset));
state.metadata_enabled = Some(cfg.metadata_enabled);
state.metadata_mode = Some(format!("{:?}", cfg.metadata_mode));
state.watermark_enabled = Some(cfg.watermark_enabled);
state.rename_enabled = Some(cfg.rename_enabled);
}
let _ = session.save(&state);
glib::Propagation::Proceed
});
}
let ui = WizardUi {
nav_view,
step_indicator,
back_button,
next_button,
title,
pages,
toast_overlay,
queue_button,
queue_list_box,
state: app_state,
};
// Wire help button to show contextual help for current step
{
let wizard = ui.state.wizard.clone();
let window_ref = window.clone();
help_button.connect_clicked(move |_| {
let step = wizard.borrow().current_step;
show_step_help(&window_ref, step);
});
}
setup_window_actions(&window, &ui);
update_nav_buttons(
&ui.state.wizard.borrow(),
&ui.back_button,
&ui.next_button,
);
ui.step_indicator.set_current(0);
// Apply saved accessibility settings
apply_accessibility_settings();
window.present();
crate::welcome::show_welcome_if_first_launch(&window);
// Auto-show What's New dialog on first launch after update
{
let current_version = env!("CARGO_PKG_VERSION").to_string();
let session = pixstrip_core::storage::SessionStore::new();
let sess = session.load().unwrap_or_default();
let last_seen = sess.last_seen_version.clone().unwrap_or_default();
if last_seen != current_version {
// Update stored version immediately
let mut updated_sess = sess;
updated_sess.last_seen_version = Some(current_version.clone());
let _ = session.save(&updated_sess);
// Only show dialog if this is not the very first run
// (welcome wizard handles first run)
let config_store = pixstrip_core::storage::ConfigStore::new();
let config = config_store.load().unwrap_or_default();
if config.first_run_complete && !last_seen.is_empty() {
let w = window.clone();
glib::idle_add_local_once(move || {
show_whats_new_dialog(&w);
});
}
}
}
}
fn build_menu() -> gtk::gio::Menu {
let menu = gtk::gio::Menu::new();
menu.append(Some("Settings"), Some("win.show-settings"));
menu.append(Some("History"), Some("win.show-history"));
menu.append(Some("Keyboard Shortcuts"), Some("win.show-shortcuts"));
menu.append(Some("What's New"), Some("win.show-whats-new"));
menu
}
fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
let action_group = gtk::gio::SimpleActionGroup::new();
// Next step action (skips disabled steps)
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("next-step", None);
action.connect_activate(move |_, _| {
let mut s = ui.state.wizard.borrow_mut();
if !s.can_go_next() {
return;
}
let total = s.total_steps;
let cfg = ui.state.job_config.borrow();
let mut next = s.current_step + 1;
while next < total && should_skip_step(next, &cfg) {
next += 1;
}
drop(cfg);
if next < total {
s.current_step = next;
s.visited[next] = true;
let idx = s.current_step;
drop(s);
navigate_to_step(&ui, idx);
}
});
action_group.add_action(&action);
}
// Previous step action (skips disabled steps)
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("prev-step", None);
action.connect_activate(move |_, _| {
let mut s = ui.state.wizard.borrow_mut();
if !s.can_go_back() {
return;
}
let cfg = ui.state.job_config.borrow();
let mut prev = s.current_step.saturating_sub(1);
while prev > 0 && should_skip_step(prev, &cfg) {
prev = prev.saturating_sub(1);
}
drop(cfg);
s.current_step = prev;
s.visited[prev] = true;
let idx = s.current_step;
drop(s);
navigate_to_step(&ui, idx);
});
action_group.add_action(&action);
}
// Go to specific step action
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new(
"goto-step",
Some(&i32::static_variant_type()),
);
action.connect_activate(move |_, param| {
if let Some(step) = param.and_then(|p| p.get::<i32>()) {
let target = (step - 1) as usize;
let s = ui.state.wizard.borrow();
let cfg = ui.state.job_config.borrow();
if target < s.total_steps && s.visited[target] && !should_skip_step(target, &cfg) {
drop(cfg);
drop(s);
ui.state.wizard.borrow_mut().current_step = target;
navigate_to_step(&ui, target);
}
}
});
action_group.add_action(&action);
}
// Process action - runs the pipeline
{
let ui = ui.clone();
let window = window.clone();
let action = gtk::gio::SimpleAction::new("process", None);
action.connect_activate(move |_, _| {
let excluded = ui.state.excluded_files.borrow();
let has_included = ui.state.loaded_files.borrow().iter().any(|p| !excluded.contains(p));
drop(excluded);
if !has_included {
let toast = adw::Toast::new("No images selected - go to Step 2 to add images");
ui.toast_overlay.add_toast(toast);
return;
}
run_processing(&window, &ui);
});
action_group.add_action(&action);
}
// Add files action - opens file chooser
{
let window = window.clone();
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("add-files", None);
action.connect_activate(move |_, _| {
open_file_chooser(&window, &ui);
});
action_group.add_action(&action);
}
// Select all images action
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("select-all-images", None);
action.connect_activate(move |_, _| {
ui.state.excluded_files.borrow_mut().clear();
// Update the images step UI
if let Some(page) = ui.pages.get(1)
&& let Some(stack) = page.child().and_downcast::<gtk::Stack>()
&& let Some(loaded) = stack.child_by_name("loaded")
{
crate::steps::step_images::set_all_checkboxes_in(&loaded, true);
let files = ui.state.loaded_files.borrow();
let count = files.len();
drop(files);
update_images_count_label(&ui, count);
}
});
action_group.add_action(&action);
}
// Deselect all images action
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("deselect-all-images", None);
action.connect_activate(move |_, _| {
{
let files = ui.state.loaded_files.borrow();
let mut excl = ui.state.excluded_files.borrow_mut();
for f in files.iter() {
excl.insert(f.clone());
}
}
// Update the images step UI
if let Some(page) = ui.pages.get(1)
&& let Some(stack) = page.child().and_downcast::<gtk::Stack>()
&& let Some(loaded) = stack.child_by_name("loaded")
{
crate::steps::step_images::set_all_checkboxes_in(&loaded, false);
let files = ui.state.loaded_files.borrow();
let count = files.len();
drop(files);
update_images_count_label(&ui, count);
}
});
action_group.add_action(&action);
}
// Settings action - opens settings dialog
{
let window = window.clone();
let action = gtk::gio::SimpleAction::new("show-settings", None);
action.connect_activate(move |_, _| {
let dialog = crate::settings::build_settings_dialog();
dialog.present(Some(&window));
});
action_group.add_action(&action);
}
// History action - shows history dialog
{
let window = window.clone();
let action = gtk::gio::SimpleAction::new("show-history", None);
action.connect_activate(move |_, _| {
show_history_dialog(&window);
});
action_group.add_action(&action);
}
// Choose output directory action
{
let window = window.clone();
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("choose-output", None);
action.connect_activate(move |_, _| {
open_output_chooser(&window, &ui);
});
action_group.add_action(&action);
}
// Import preset action
{
let window = window.clone();
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("import-preset", None);
action.connect_activate(move |_, _| {
import_preset(&window, &ui);
});
action_group.add_action(&action);
}
// Save as preset action
{
let window = window.clone();
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("save-preset", None);
action.connect_activate(move |_, _| {
save_preset_dialog(&window, &ui);
});
action_group.add_action(&action);
}
// Undo last batch action
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("undo-last-batch", None);
action.connect_activate(move |_, _| {
undo_last_batch(&ui);
});
action_group.add_action(&action);
}
// Keyboard shortcuts window
{
let window = window.clone();
let action = gtk::gio::SimpleAction::new("show-shortcuts", None);
action.connect_activate(move |_, _| {
show_shortcuts_window(&window);
});
action_group.add_action(&action);
}
// What's New action
{
let window = window.clone();
let action = gtk::gio::SimpleAction::new("show-whats-new", None);
action.connect_activate(move |_, _| {
show_whats_new_dialog(&window);
});
action_group.add_action(&action);
}
// Paste images from clipboard (Ctrl+V)
{
let window = window.clone();
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("paste-images", None);
action.connect_activate(move |_, _| {
paste_images_from_clipboard(&window, &ui);
});
action_group.add_action(&action);
}
// Connect button clicks
ui.back_button.connect_clicked({
let action_group = action_group.clone();
move |_| {
ActionGroupExt::activate_action(&action_group, "prev-step", None);
}
});
ui.next_button.connect_clicked({
let action_group = action_group.clone();
let ui = ui.clone();
move |btn| {
// If the button says "Process More", reset the wizard
if btn.label().as_deref() == Some("Process More") {
reset_wizard(&ui);
return;
}
let s = ui.state.wizard.borrow();
if s.is_last_step() {
drop(s);
ActionGroupExt::activate_action(&action_group, "process", None);
} else {
drop(s);
ActionGroupExt::activate_action(&action_group, "next-step", None);
}
}
});
// Load external files action - used by single-instance file open
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("load-external-files", Some(&String::static_variant_type()));
action.connect_activate(move |_, param| {
if let Some(paths_str) = param.and_then(|v| v.get::<String>()) {
let new_files: Vec<std::path::PathBuf> = paths_str
.lines()
.map(std::path::PathBuf::from)
.filter(|p| p.exists())
.collect();
if !new_files.is_empty() {
let count = new_files.len();
ui.state.loaded_files.borrow_mut().extend(new_files);
ui.toast_overlay.add_toast(adw::Toast::new(
&format!("{} images added from file manager", count)
));
// Navigate to step 2 (images) if we're on step 1 or earlier
let current = ui.state.wizard.borrow().current_step;
if current <= 1 {
ui.state.wizard.borrow_mut().current_step = 1;
if let Some(page) = ui.pages.get(1) {
ui.nav_view.push(page);
}
}
}
}
});
action_group.add_action(&action);
}
window.insert_action_group("win", Some(&action_group));
}
fn should_skip_step(step: usize, cfg: &JobConfig) -> bool {
match step {
0 | 1 | 9 => false, // Workflow, Images, Output - always shown
2 => !cfg.resize_enabled,
3 => !cfg.adjustments_enabled,
4 => !cfg.convert_enabled,
5 => !cfg.compress_enabled,
6 => !cfg.metadata_enabled,
7 => !cfg.watermark_enabled,
8 => !cfg.rename_enabled,
_ => false,
}
}
fn navigate_to_step(ui: &WizardUi, target: usize) {
let s = ui.state.wizard.borrow();
// Update step indicator
ui.step_indicator.set_current(target);
for (i, visited) in s.visited.iter().enumerate() {
if *visited && i != target {
ui.step_indicator.set_completed(i);
}
}
// Navigate - replace entire stack up to target
if target < ui.pages.len() {
ui.nav_view.replace(&ui.pages[..=target]);
ui.title.set_subtitle(&ui.pages[target].title());
// Announce step change for screen readers
let step_name = ui.pages[target].title().to_string();
let total_steps = ui.pages.len();
ui.nav_view.update_property(&[
gtk::accessible::Property::Label(
&format!("Step {} of {}: {}", target + 1, total_steps, step_name)
),
]);
// Focus management - move focus to first interactive element on new step
// Use idle callback to let the page fully render first
let page = ui.pages[target].clone();
glib::idle_add_local_once(move || {
page.child_focus(gtk::DirectionType::TabForward);
});
}
// Update dynamic content on certain steps
if target == 9 {
// Output step - update image count, total size, and operation summary
let files = ui.state.loaded_files.borrow();
let excluded = ui.state.excluded_files.borrow();
let included_count = files.iter().filter(|p| !excluded.contains(*p)).count();
let total_size: u64 = files.iter()
.filter(|p| !excluded.contains(*p))
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.sum();
drop(excluded);
drop(files);
if let Some(page) = ui.pages.get(9) {
walk_widgets(&page.child(), &|widget| {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>()
&& row.title().as_str() == "Images to process"
{
row.set_subtitle(&format!("{} images ({})", included_count, format_bytes(total_size)));
}
});
}
update_output_summary(ui);
}
update_nav_buttons(&s, &ui.back_button, &ui.next_button);
}
fn update_nav_buttons(state: &WizardState, back_button: &gtk::Button, next_button: &gtk::Button) {
back_button.set_sensitive(state.can_go_back());
back_button.set_visible(state.current_step > 0);
if state.is_last_step() {
next_button.set_label("Process");
next_button.remove_css_class("suggested-action");
next_button.add_css_class("suggested-action");
next_button.set_tooltip_text(Some("Start processing (Ctrl+Enter)"));
} else {
next_button.set_label("Next");
next_button.add_css_class("suggested-action");
next_button.set_tooltip_text(Some("Go to next step (Alt+Right)"));
}
}
fn open_file_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = gtk::FileDialog::builder()
.title("Select Images")
.modal(true)
.build();
let filter = gtk::FileFilter::new();
filter.set_name(Some("Image files"));
filter.add_mime_type("image/jpeg");
filter.add_mime_type("image/png");
filter.add_mime_type("image/webp");
filter.add_mime_type("image/avif");
filter.add_mime_type("image/gif");
filter.add_mime_type("image/tiff");
filter.add_mime_type("image/bmp");
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
dialog.set_default_filter(Some(&filter));
let ui = ui.clone();
dialog.open_multiple(Some(window), gtk::gio::Cancellable::NONE, move |result| {
if let Ok(files) = result {
let mut paths = ui.state.loaded_files.borrow_mut();
for i in 0..files.n_items() {
if let Some(file) = files.item(i)
&& let Some(gfile) = file.downcast_ref::<gtk::gio::File>()
&& let Some(path) = gfile.path()
&& !paths.contains(&path)
{
paths.push(path);
}
}
let count = paths.len();
drop(paths);
// Update the images step UI if we can find the label
update_images_count_label(&ui, count);
}
});
}
fn open_output_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = gtk::FileDialog::builder()
.title("Choose Output Folder")
.modal(true)
.build();
let ui = ui.clone();
dialog.select_folder(Some(window), gtk::gio::Cancellable::NONE, move |result| {
if let Ok(folder) = result
&& let Some(path) = folder.path()
{
*ui.state.output_dir.borrow_mut() = Some(path.clone());
update_output_label(&ui, &path);
}
});
}
fn update_output_label(ui: &WizardUi, path: &std::path::Path) {
// Find the output step page (index 9) and update the output location subtitle
if let Some(page) = ui.pages.get(9) {
walk_widgets(&page.child(), &|widget| {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>()
&& row.title().as_str() == "Output Location"
{
row.set_subtitle(&path.display().to_string());
}
});
}
}
fn update_images_count_label(ui: &WizardUi, count: usize) {
let files = ui.state.loaded_files.borrow();
let excluded = ui.state.excluded_files.borrow();
let included_count = files.iter().filter(|p| !excluded.contains(*p)).count();
let total_size: u64 = files.iter()
.filter(|p| !excluded.contains(*p))
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.sum();
// Find the step-images page and switch its stack to "loaded" if we have files
if let Some(page) = ui.pages.get(1)
&& let Some(stack) = page.child().and_downcast::<gtk::Stack>()
{
if count > 0 {
stack.set_visible_child_name("loaded");
} else {
stack.set_visible_child_name("empty");
}
if let Some(loaded_box) = stack.child_by_name("loaded") {
update_count_in_box(&loaded_box, count, included_count, total_size);
update_file_list(&loaded_box, &files, &excluded);
}
}
}
fn update_count_in_box(widget: &gtk::Widget, count: usize, included_count: usize, total_size: u64) {
if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& label.css_classes().iter().any(|c| c == "heading")
{
if included_count == count {
label.set_label(&format!("{} images ({})", count, format_bytes(total_size)));
} else {
label.set_label(&format!("{}/{} images selected ({})", included_count, count, format_bytes(total_size)));
}
return;
}
let mut child = widget.first_child();
while let Some(c) = child {
update_count_in_box(&c, count, included_count, total_size);
child = c.next_sibling();
}
}
fn update_file_list(widget: &gtk::Widget, files: &[std::path::PathBuf], excluded: &std::collections::HashSet<std::path::PathBuf>) {
if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>()
&& list_box.css_classes().iter().any(|c| c == "boxed-list")
{
while let Some(child) = list_box.first_child() {
list_box.remove(&child);
}
for path in files {
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let size = std::fs::metadata(path)
.map(|m| format_bytes(m.len()))
.unwrap_or_default();
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_uppercase();
let row = adw::ActionRow::builder()
.title(name)
.subtitle(format!("{} - {}", ext, size))
.build();
let check = gtk::CheckButton::builder()
.active(!excluded.contains(path))
.tooltip_text("Include in processing")
.valign(gtk::Align::Center)
.build();
row.add_prefix(&check);
row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
list_box.append(&row);
}
return;
}
let mut child = widget.first_child();
while let Some(c) = child {
update_file_list(&c, files, excluded);
child = c.next_sibling();
}
}
fn show_history_dialog(window: &adw::ApplicationWindow) {
let dialog = adw::Dialog::builder()
.title("Processing History")
.content_width(500)
.content_height(400)
.build();
let toolbar_view = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar_view.add_top_bar(&header);
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.build();
let history = pixstrip_core::storage::HistoryStore::new();
match history.list() {
Ok(entries) if entries.is_empty() => {
let empty = adw::StatusPage::builder()
.title("No History Yet")
.description("Processed batches will appear here")
.icon_name("document-open-recent-symbolic")
.vexpand(true)
.build();
content.append(&empty);
}
Ok(entries) => {
let group = adw::PreferencesGroup::builder()
.title("Recent Batches")
.margin_start(12)
.margin_end(12)
.margin_top(12)
.build();
for entry in entries.iter().rev() {
let savings = if entry.total_input_bytes > 0 {
let pct = (1.0
- entry.total_output_bytes as f64 / entry.total_input_bytes as f64)
* 100.0;
format!("{:.0}% saved", pct)
} else {
"N/A".into()
};
let subtitle = format!(
"{}/{} succeeded - {} - {}",
entry.succeeded,
entry.total,
savings,
format_duration(entry.elapsed_ms)
);
let preset_label = entry
.preset_name
.as_deref()
.unwrap_or("Custom workflow");
// Format timestamp
let time_label = entry.timestamp.parse::<u64>().ok().and_then(|ts| {
let dt = glib::DateTime::from_unix_local(ts as i64).ok()?;
let formatted = dt.format("%Y-%m-%d %H:%M").ok()?;
Some(formatted.to_string())
}).unwrap_or_else(|| "Unknown date".to_string());
let row = adw::ExpanderRow::builder()
.title(preset_label)
.subtitle(&format!("{} - {}", time_label, subtitle))
.show_enable_switch(false)
.build();
row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
// Detail rows inside expander
let input_row = adw::ActionRow::builder()
.title("Input")
.subtitle(&entry.input_dir)
.build();
input_row.add_prefix(&gtk::Image::from_icon_name("folder-symbolic"));
row.add_row(&input_row);
let output_row = adw::ActionRow::builder()
.title("Output")
.subtitle(&entry.output_dir)
.activatable(true)
.build();
output_row.add_prefix(&gtk::Image::from_icon_name("folder-open-symbolic"));
let out_dir = entry.output_dir.clone();
output_row.connect_activated(move |_| {
let _ = gtk::gio::AppInfo::launch_default_for_uri(
&format!("file://{}", out_dir),
gtk::gio::AppLaunchContext::NONE,
);
});
row.add_row(&output_row);
let size_row = adw::ActionRow::builder()
.title("Size")
.subtitle(&format!(
"{} -> {} ({})",
format_bytes(entry.total_input_bytes),
format_bytes(entry.total_output_bytes),
savings
))
.build();
size_row.add_prefix(&gtk::Image::from_icon_name("drive-harddisk-symbolic"));
row.add_row(&size_row);
if entry.failed > 0 {
let err_row = adw::ActionRow::builder()
.title("Errors")
.subtitle(&format!("{} files failed", entry.failed))
.build();
err_row.add_prefix(&gtk::Image::from_icon_name("dialog-warning-symbolic"));
row.add_row(&err_row);
}
// Action buttons row
let actions_row = adw::ActionRow::builder()
.title("Actions")
.build();
// Undo button - moves output files to trash
if !entry.output_files.is_empty() {
let undo_btn = gtk::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Undo - move outputs to trash")
.valign(gtk::Align::Center)
.build();
undo_btn.add_css_class("flat");
let files = entry.output_files.clone();
undo_btn.connect_clicked(move |btn| {
let mut trashed = 0;
for file_path in &files {
let gfile = gtk::gio::File::for_path(file_path);
if gfile.trash(gtk::gio::Cancellable::NONE).is_ok() {
trashed += 1;
}
}
btn.set_sensitive(false);
btn.set_tooltip_text(Some(&format!("{} files moved to trash", trashed)));
});
actions_row.add_suffix(&undo_btn);
}
// Open output folder button
let open_btn = gtk::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text("Open output folder")
.valign(gtk::Align::Center)
.build();
open_btn.add_css_class("flat");
let out_dir2 = entry.output_dir.clone();
open_btn.connect_clicked(move |_| {
let _ = gtk::gio::AppInfo::launch_default_for_uri(
&format!("file://{}", out_dir2),
gtk::gio::AppLaunchContext::NONE,
);
});
actions_row.add_suffix(&open_btn);
row.add_row(&actions_row);
group.add(&row);
}
content.append(&group);
// Clear history button
let clear_btn = gtk::Button::builder()
.label("Clear History")
.halign(gtk::Align::Center)
.margin_top(12)
.margin_bottom(12)
.build();
clear_btn.add_css_class("destructive-action");
clear_btn.connect_clicked(move |btn| {
let history = pixstrip_core::storage::HistoryStore::new();
let _ = history.clear();
btn.set_label("History Cleared");
btn.set_sensitive(false);
});
content.append(&clear_btn);
}
Err(e) => {
let error = adw::StatusPage::builder()
.title("Could not load history")
.description(e.to_string())
.icon_name("dialog-error-symbolic")
.vexpand(true)
.build();
content.append(&error);
}
}
scrolled.set_child(Some(&content));
toolbar_view.set_content(Some(&scrolled));
dialog.set_child(Some(&toolbar_view));
dialog.present(Some(window));
}
fn show_whats_new_dialog(window: &adw::ApplicationWindow) {
let dialog = adw::Dialog::builder()
.title("What's New")
.content_width(450)
.content_height(350)
.build();
let toolbar_view = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar_view.add_top_bar(&header);
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(24)
.margin_end(24)
.build();
let version_label = gtk::Label::builder()
.label("Pixstrip 0.1.0")
.css_classes(["title-1"])
.halign(gtk::Align::Start)
.build();
let subtitle_label = gtk::Label::builder()
.label("First release")
.css_classes(["dim-label"])
.halign(gtk::Align::Start)
.build();
content.append(&version_label);
content.append(&subtitle_label);
let changes_group = adw::PreferencesGroup::builder()
.title("Changes in this version")
.build();
let features = [
("Wizard workflow", "Step-by-step batch image processing"),
("Resize", "Width/height, social media presets, fit-in-box"),
("Convert", "JPEG, PNG, WebP, AVIF, GIF, TIFF conversion"),
("Compress", "Quality presets with per-format controls"),
("Metadata", "Strip all, privacy mode, photographer mode"),
("Watermark", "Text and image watermarks with positioning"),
("Rename", "Prefix, suffix, counter, template engine"),
("Adjustments", "Brightness, contrast, crop, trim, effects"),
];
for (title, subtitle) in &features {
let row = adw::ActionRow::builder()
.title(*title)
.subtitle(*subtitle)
.build();
row.add_prefix(&gtk::Image::from_icon_name("emblem-ok-symbolic"));
changes_group.add(&row);
}
content.append(&changes_group);
scrolled.set_child(Some(&content));
toolbar_view.set_content(Some(&scrolled));
dialog.set_child(Some(&toolbar_view));
dialog.present(Some(window));
}
fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
let excluded = ui.state.excluded_files.borrow().clone();
let files: Vec<std::path::PathBuf> = ui.state.loaded_files.borrow()
.iter()
.filter(|p| !excluded.contains(*p))
.cloned()
.collect();
if files.is_empty() {
return;
}
let input_dir = files[0]
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf();
let output_dir = ui
.state
.output_dir
.borrow()
.clone()
.unwrap_or_else(|| input_dir.join("processed"));
// Build job from wizard settings
let mut job = pixstrip_core::pipeline::ProcessingJob::new(&input_dir, &output_dir);
let cfg = ui.state.job_config.borrow();
if cfg.resize_enabled && cfg.resize_width > 0 {
let target_w = cfg.resize_width;
let target_h = cfg.resize_height;
if target_h == 0 {
job.resize = Some(pixstrip_core::operations::ResizeConfig::ByWidth(target_w));
} else {
job.resize = Some(pixstrip_core::operations::ResizeConfig::FitInBox {
max: pixstrip_core::types::Dimensions { width: target_w, height: target_h },
allow_upscale: cfg.allow_upscale,
});
}
job.resize_algorithm = match cfg.resize_algorithm {
1 => pixstrip_core::operations::ResizeAlgorithm::CatmullRom,
2 => pixstrip_core::operations::ResizeAlgorithm::Bilinear,
3 => pixstrip_core::operations::ResizeAlgorithm::Nearest,
_ => pixstrip_core::operations::ResizeAlgorithm::Lanczos3,
};
}
if cfg.convert_enabled {
// Check if any per-format mappings are set (non-zero = overridden)
let has_mapping = cfg.format_mapping_jpeg > 0
|| cfg.format_mapping_png > 0
|| cfg.format_mapping_webp > 0
|| cfg.format_mapping_tiff > 0;
if has_mapping {
let mapping_to_format = |idx: u32, default: Option<pixstrip_core::types::ImageFormat>| -> Option<pixstrip_core::types::ImageFormat> {
match idx {
1 => Some(pixstrip_core::types::ImageFormat::Jpeg),
2 => Some(pixstrip_core::types::ImageFormat::Png),
3 => Some(pixstrip_core::types::ImageFormat::WebP),
4 => Some(pixstrip_core::types::ImageFormat::Avif),
5 => None, // Keep Original
_ => default, // "Same as above" - use global format
}
};
let global = cfg.convert_format;
let mut map = Vec::new();
// For each input format, determine output
let input_formats = [
(pixstrip_core::types::ImageFormat::Jpeg, cfg.format_mapping_jpeg),
(pixstrip_core::types::ImageFormat::Png, cfg.format_mapping_png),
(pixstrip_core::types::ImageFormat::WebP, cfg.format_mapping_webp),
(pixstrip_core::types::ImageFormat::Tiff, cfg.format_mapping_tiff),
];
for (input_fmt, mapping_idx) in input_formats {
if let Some(output_fmt) = mapping_to_format(mapping_idx, global) {
if output_fmt != input_fmt {
map.push((input_fmt, output_fmt));
}
}
}
if !map.is_empty() {
job.convert = Some(pixstrip_core::operations::ConvertConfig::FormatMapping(map));
}
} else if let Some(fmt) = cfg.convert_format {
job.convert = Some(pixstrip_core::operations::ConvertConfig::SingleFormat(fmt));
}
}
if cfg.compress_enabled {
// Check if user has customized per-format quality values beyond the preset defaults
let preset_jpeg = cfg.quality_preset.jpeg_quality();
let preset_webp = cfg.quality_preset.webp_quality();
let has_custom = cfg.jpeg_quality != preset_jpeg
|| cfg.webp_quality != preset_webp as u8
|| cfg.avif_quality != preset_webp as u8
|| cfg.avif_speed != 6
|| cfg.webp_effort != 4;
if has_custom {
job.compress = Some(pixstrip_core::operations::CompressConfig::Custom {
jpeg_quality: Some(cfg.jpeg_quality),
png_level: Some(cfg.png_level),
webp_quality: Some(cfg.webp_quality as f32),
avif_quality: Some(cfg.avif_quality as f32),
});
} else {
job.compress = Some(pixstrip_core::operations::CompressConfig::Preset(cfg.quality_preset));
}
}
// Pass encoder options to the job
job.progressive_jpeg = cfg.progressive_jpeg;
job.avif_speed = cfg.avif_speed;
if cfg.metadata_enabled {
job.metadata = Some(match cfg.metadata_mode {
MetadataMode::StripAll => pixstrip_core::operations::MetadataConfig::StripAll,
MetadataMode::Privacy => pixstrip_core::operations::MetadataConfig::Privacy,
MetadataMode::KeepAll => pixstrip_core::operations::MetadataConfig::KeepAll,
MetadataMode::Custom => pixstrip_core::operations::MetadataConfig::Custom {
strip_gps: cfg.strip_gps,
strip_camera: cfg.strip_camera,
strip_software: cfg.strip_software,
strip_timestamps: cfg.strip_timestamps,
strip_copyright: cfg.strip_copyright,
},
});
}
// Rotation and Flip apply from the resize step, so enable when either resize or adjustments is active
if cfg.resize_enabled || cfg.adjustments_enabled {
job.rotation = Some(match cfg.rotation {
1 => pixstrip_core::operations::Rotation::Cw90,
2 => pixstrip_core::operations::Rotation::Cw180,
3 => pixstrip_core::operations::Rotation::Cw270,
4 => pixstrip_core::operations::Rotation::AutoOrient,
_ => pixstrip_core::operations::Rotation::None,
});
job.flip = Some(match cfg.flip {
1 => pixstrip_core::operations::Flip::Horizontal,
2 => pixstrip_core::operations::Flip::Vertical,
_ => pixstrip_core::operations::Flip::None,
});
}
// Adjustments (brightness, contrast, etc.)
if cfg.adjustments_enabled {
let crop = match cfg.crop_aspect_ratio {
1 => Some((1.0, 1.0)),
2 => Some((4.0, 3.0)),
3 => Some((3.0, 2.0)),
4 => Some((16.0, 9.0)),
5 => Some((9.0, 16.0)),
6 => Some((3.0, 4.0)),
7 => Some((2.0, 3.0)),
_ => None,
};
let adj = pixstrip_core::operations::AdjustmentsConfig {
brightness: cfg.brightness,
contrast: cfg.contrast,
saturation: cfg.saturation,
sharpen: cfg.sharpen,
grayscale: cfg.grayscale,
sepia: cfg.sepia,
crop_aspect_ratio: crop,
trim_whitespace: cfg.trim_whitespace,
canvas_padding: cfg.canvas_padding,
};
if !adj.is_noop() {
job.adjustments = Some(adj);
}
}
// Watermark
if cfg.watermark_enabled {
let position = match cfg.watermark_position {
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
4 => pixstrip_core::operations::WatermarkPosition::Center,
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
};
if cfg.watermark_use_image {
if let Some(ref path) = cfg.watermark_image_path {
job.watermark = Some(pixstrip_core::operations::WatermarkConfig::Image {
path: path.clone(),
position,
opacity: cfg.watermark_opacity,
scale: 0.2,
});
}
} else if !cfg.watermark_text.is_empty() {
job.watermark = Some(pixstrip_core::operations::WatermarkConfig::Text {
text: cfg.watermark_text.clone(),
position,
font_size: cfg.watermark_font_size,
opacity: cfg.watermark_opacity,
color: cfg.watermark_color,
});
}
}
// Rename
if cfg.rename_enabled {
job.rename = Some(pixstrip_core::operations::RenameConfig {
prefix: cfg.rename_prefix.clone(),
suffix: cfg.rename_suffix.clone(),
counter_start: cfg.rename_counter_start,
counter_padding: cfg.rename_counter_padding,
template: if cfg.rename_template.is_empty() {
None
} else {
Some(cfg.rename_template.clone())
},
});
}
job.preserve_directory_structure = cfg.preserve_dir_structure;
job.output_dpi = cfg.output_dpi;
job.overwrite_behavior = match cfg.overwrite_behavior {
1 => pixstrip_core::operations::OverwriteBehavior::AutoRename,
2 => pixstrip_core::operations::OverwriteBehavior::Overwrite,
3 => pixstrip_core::operations::OverwriteBehavior::Skip,
_ => pixstrip_core::operations::OverwriteBehavior::AutoRename, // 0 "Ask" defaults to auto-rename in batch
};
drop(cfg);
for file in &files {
job.add_source(file);
}
// Build processing UI inline in the nav_view
let processing_page = crate::processing::build_processing_page();
ui.nav_view.push(&processing_page);
// Hide bottom nav buttons and step indicator during processing
ui.back_button.set_visible(false);
ui.next_button.set_visible(false);
ui.step_indicator.widget().set_visible(false);
ui.title.set_subtitle("Processing...");
// Get references to progress widgets inside the page
let progress_bar = find_widget_by_type::<gtk::ProgressBar>(&processing_page);
let cancel_flag = Arc::new(AtomicBool::new(false));
let pause_flag = Arc::new(AtomicBool::new(false));
// Find cancel button and wire it; also wire pause button
wire_cancel_button(&processing_page, cancel_flag.clone());
wire_pause_button(&processing_page, pause_flag.clone());
// Run processing in a background thread
let (tx, rx) = std::sync::mpsc::channel::<ProcessingMessage>();
let cancel = cancel_flag.clone();
let pause = pause_flag.clone();
// Load processing settings from config
let cfg_store = pixstrip_core::storage::ConfigStore::new();
let app_cfg = cfg_store.load().unwrap_or_default();
std::thread::spawn(move || {
let mut executor = pixstrip_core::executor::PipelineExecutor::with_cancel_and_pause(cancel, pause);
match app_cfg.thread_count {
pixstrip_core::config::ThreadCount::Auto => executor.set_thread_count(0),
pixstrip_core::config::ThreadCount::Manual(n) => executor.set_thread_count(n),
}
executor.set_pause_on_error(app_cfg.error_behavior == pixstrip_core::config::ErrorBehavior::PauseOnError);
let result = executor.execute(&job, |update| {
let _ = tx.send(ProcessingMessage::Progress {
current: update.current,
total: update.total,
file: update.current_file,
});
});
match result {
Ok(r) => {
let _ = tx.send(ProcessingMessage::Done(r));
}
Err(e) => {
let _ = tx.send(ProcessingMessage::Error(e.to_string()));
}
}
});
// Poll for messages from the processing thread
let ui_for_rx = ui.clone();
let start_time = std::time::Instant::now();
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
while let Ok(msg) = rx.try_recv() {
match msg {
ProcessingMessage::Progress {
current,
total,
file,
} => {
if let Some(ref bar) = progress_bar {
let frac = current as f64 / total as f64;
bar.set_fraction(frac);
bar.set_text(Some(&format!("{}/{} - {}", current, total, file)));
bar.update_property(&[
gtk::accessible::Property::ValueNow(frac * 100.0),
gtk::accessible::Property::ValueText(
&format!("Processing {} of {}: {}", current, total, file)
),
]);
}
let eta = calculate_eta(&start_time, current, total);
update_progress_labels(&ui_for_rx.nav_view, current, total, &file, &eta);
add_log_entry(&ui_for_rx.nav_view, current, total, &file);
}
ProcessingMessage::Done(result) => {
show_results(&ui_for_rx, &result);
return glib::ControlFlow::Break;
}
ProcessingMessage::Error(err) => {
mark_current_queue_batch(&ui_for_rx, false, Some(&err));
let toast = adw::Toast::new(&format!("Processing failed: {}", err));
ui_for_rx.toast_overlay.add_toast(toast);
ui_for_rx.back_button.set_visible(true);
ui_for_rx.next_button.set_visible(true);
if let Some(visible) = ui_for_rx.nav_view.visible_page()
&& visible.tag().as_deref() == Some("processing")
{
ui_for_rx.nav_view.pop();
}
// Try to process next batch even if this one failed
let ui_q = ui_for_rx.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
process_next_queued_batch(&ui_q);
});
return glib::ControlFlow::Break;
}
}
}
glib::ControlFlow::Continue
});
}
fn show_results(
ui: &WizardUi,
result: &pixstrip_core::executor::BatchResult,
) {
let results_page = crate::processing::build_results_page();
// Update result stats by walking the widget tree
update_results_stats(&results_page, result);
// Show errors if any
if !result.errors.is_empty() {
update_results_errors(&results_page, &result.errors);
}
// Wire the action buttons
wire_results_actions(ui, &results_page);
ui.nav_view.push(&results_page);
ui.title.set_subtitle("Processing Complete");
ui.step_indicator.widget().set_visible(false);
ui.back_button.set_visible(false);
ui.next_button.set_label("Process More");
ui.next_button.set_visible(true);
// Save history with output paths for undo support
let history = pixstrip_core::storage::HistoryStore::new();
let output_dir_str = ui.state.output_dir.borrow()
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let input_dir_str = ui.state.loaded_files.borrow()
.first()
.and_then(|p| p.parent())
.map(|p| p.display().to_string())
.unwrap_or_default();
// Collect actual output files from the output directory
let output_files: Vec<String> = if let Some(ref dir) = *ui.state.output_dir.borrow() {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path().display().to_string())
.collect()
} else {
vec![]
};
let _ = history.add(pixstrip_core::storage::HistoryEntry {
timestamp: format!(
"{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
),
input_dir: input_dir_str,
output_dir: output_dir_str,
preset_name: None,
total: result.total,
succeeded: result.succeeded,
failed: result.failed,
total_input_bytes: result.total_input_bytes,
total_output_bytes: result.total_output_bytes,
elapsed_ms: result.elapsed_ms,
output_files,
});
// Prune old history entries
let config_store = pixstrip_core::storage::ConfigStore::new();
let app_config = config_store.load().unwrap_or_default();
let _ = history.prune(app_config.history_max_entries, app_config.history_max_days);
// Show toast
let savings = if result.total_input_bytes > 0 {
let pct =
(1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0;
format!(
"{} images processed, {:.0}% space saved",
result.succeeded, pct
)
} else {
format!("{} images processed", result.succeeded)
};
// Undo toast with savings info
let undo_toast = adw::Toast::new(&savings);
undo_toast.set_button_label(Some("Undo"));
undo_toast.set_timeout(10);
{
let output_dir = ui.state.output_dir.borrow().clone();
undo_toast.connect_button_clicked(move |t| {
if let Some(ref dir) = output_dir {
let mut trashed = 0;
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let gfile = gtk::gio::File::for_path(entry.path());
if gfile.trash(gtk::gio::Cancellable::NONE).is_ok() {
trashed += 1;
}
}
}
t.dismiss();
// Will show a new toast from the caller
let _ = trashed;
}
});
}
ui.toast_overlay.add_toast(undo_toast);
// Desktop notification (if enabled in settings)
let config_store = pixstrip_core::storage::ConfigStore::new();
let config = config_store.load().unwrap_or_default();
if config.notify_on_completion {
let notification = gtk::gio::Notification::new("Pixstrip - Processing Complete");
notification.set_body(Some(&savings));
notification.set_priority(gtk::gio::NotificationPriority::Normal);
if let Some(app) = gtk::gio::Application::default() {
app.send_notification(Some("batch-complete"), &notification);
}
}
// Play completion sound (if enabled in settings)
if config.play_completion_sound {
std::thread::spawn(|| {
// Use canberra-gtk-play for system sound (standard on GNOME)
let _ = std::process::Command::new("canberra-gtk-play")
.arg("--id=complete")
.arg("--description=Processing complete")
.output();
});
}
// Auto-open output folder if enabled
if config.auto_open_output {
let output = ui.state.output_dir.borrow().clone();
if let Some(dir) = output {
let _ = gtk::gio::AppInfo::launch_default_for_uri(
&format!("file://{}", dir.display()),
gtk::gio::AppLaunchContext::NONE,
);
}
}
// Mark current queue batch as completed and process next one
mark_current_queue_batch(ui, true, None);
// Auto-start next queued batch after a short delay
let ui_for_queue = ui.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
process_next_queued_batch(&ui_for_queue);
});
}
fn update_results_stats(
page: &adw::NavigationPage,
result: &pixstrip_core::executor::BatchResult,
) {
// Walk the widget tree looking for ActionRows to update
walk_widgets(&page.child(), &|widget| {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
let title = row.title();
match title.as_str() {
"Images processed" => {
row.set_subtitle(&format!("{} images", result.succeeded));
}
"Original size" => {
row.set_subtitle(&format_bytes(result.total_input_bytes));
}
"Output size" => {
row.set_subtitle(&format_bytes(result.total_output_bytes));
}
"Space saved" => {
if result.total_input_bytes > 0 {
let pct = (1.0
- result.total_output_bytes as f64
/ result.total_input_bytes as f64)
* 100.0;
row.set_subtitle(&format!("{:.1}%", pct));
}
}
"Processing time" => {
row.set_subtitle(&format_duration(result.elapsed_ms));
}
_ => {}
}
}
});
}
fn update_results_errors(page: &adw::NavigationPage, errors: &[(String, String)]) {
walk_widgets(&page.child(), &|widget| {
if let Some(group) = widget.downcast_ref::<adw::PreferencesGroup>()
&& group.title().as_str() == "Errors"
{
group.set_visible(true);
}
if let Some(expander) = widget.downcast_ref::<adw::ExpanderRow>()
&& expander.title().contains("errors")
{
expander.set_title(&format!("{} errors occurred", errors.len()));
for (file, err) in errors {
let row = adw::ActionRow::builder()
.title(file)
.subtitle(err)
.build();
row.add_prefix(&gtk::Image::from_icon_name("dialog-error-symbolic"));
expander.add_row(&row);
}
}
});
}
fn wire_results_actions(
ui: &WizardUi,
page: &adw::NavigationPage,
) {
walk_widgets(&page.child(), &|widget| {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
match row.title().as_str() {
"Open Output Folder" => {
let ui = ui.clone();
row.connect_activated(move |_| {
let output = ui.state.output_dir.borrow().clone();
if let Some(dir) = output {
let _ = gtk::gio::AppInfo::launch_default_for_uri(
&format!("file://{}", dir.display()),
gtk::gio::AppLaunchContext::NONE,
);
}
});
}
"Process Another Batch" => {
let ui = ui.clone();
row.connect_activated(move |_| {
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"));
}
_ => {}
}
}
});
}
fn undo_last_batch(ui: &WizardUi) {
let history = pixstrip_core::storage::HistoryStore::new();
let entries = match history.list() {
Ok(e) => e,
Err(_) => {
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available"));
return;
}
};
let Some(last) = entries.last() else {
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo"));
return;
};
if last.output_files.is_empty() {
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch"));
return;
}
// Move output files to trash using GIO
let mut trashed = 0usize;
for path_str in &last.output_files {
let file = gtk::gio::File::for_path(path_str);
if file.trash(gtk::gio::Cancellable::NONE).is_ok() {
trashed += 1;
}
}
let toast = adw::Toast::new(&format!(
"Undo: moved {} files to trash",
trashed
));
toast.set_timeout(5);
ui.toast_overlay.add_toast(toast);
}
fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
let clipboard = window.clipboard();
let ui = ui.clone();
// Try to read a texture (image) from clipboard
clipboard.read_texture_async(gtk::gio::Cancellable::NONE, move |result| {
if let Ok(Some(texture)) = result {
// Save the texture to a temp file
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
if std::fs::create_dir_all(&temp_dir).is_err() {
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory"));
return;
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp_path = temp_dir.join(format!("clipboard-{}.png", timestamp));
let bytes = texture.save_to_png_bytes();
if std::fs::write(&temp_path, bytes.as_ref()).is_ok() {
let mut files = ui.state.loaded_files.borrow_mut();
if !files.contains(&temp_path) {
files.push(temp_path);
}
let count = files.len();
drop(files);
update_images_count_label(&ui, count);
let toast = adw::Toast::new("Pasted image from clipboard");
toast.set_timeout(2);
ui.toast_overlay.add_toast(toast);
} else {
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image"));
}
} else {
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard"));
}
});
}
fn reset_wizard(ui: &WizardUi) {
// Reset state
{
let mut s = ui.state.wizard.borrow_mut();
s.current_step = 0;
s.visited = vec![false; s.total_steps];
s.visited[0] = true;
}
ui.state.loaded_files.borrow_mut().clear();
ui.state.excluded_files.borrow_mut().clear();
// Reset nav
ui.nav_view.replace(&ui.pages[..1]);
ui.step_indicator.set_current(0);
ui.step_indicator.widget().set_visible(true);
ui.title.set_subtitle("Batch Image Processor");
ui.back_button.set_visible(false);
ui.next_button.set_label("Next");
ui.next_button.set_visible(true);
ui.next_button.add_css_class("suggested-action");
}
fn wire_cancel_button(page: &adw::NavigationPage, cancel_flag: Arc<AtomicBool>) {
walk_widgets(&page.child(), &|widget| {
if let Some(button) = widget.downcast_ref::<gtk::Button>()
&& button.label().as_deref() == Some("Cancel")
{
let flag = cancel_flag.clone();
button.connect_clicked(move |btn| {
flag.store(true, Ordering::Relaxed);
btn.set_sensitive(false);
btn.set_label("Cancelling...");
});
}
});
}
fn wire_pause_button(page: &adw::NavigationPage, pause_flag: Arc<AtomicBool>) {
walk_widgets(&page.child(), &|widget| {
if let Some(button) = widget.downcast_ref::<gtk::Button>()
&& button.label().as_deref() == Some("Pause")
{
let flag = pause_flag.clone();
button.connect_clicked(move |btn| {
if btn.label().as_deref() == Some("Pause") {
flag.store(true, Ordering::Relaxed);
btn.set_label("Resume");
btn.add_css_class("suggested-action");
btn.remove_css_class("flat");
} else {
flag.store(false, Ordering::Relaxed);
btn.set_label("Pause");
btn.remove_css_class("suggested-action");
}
});
}
});
}
fn calculate_eta(start: &std::time::Instant, current: usize, total: usize) -> String {
if current == 0 {
return "Estimating time remaining...".into();
}
let elapsed = start.elapsed().as_secs_f64();
let per_image = elapsed / current as f64;
let remaining = (total - current) as f64 * per_image;
if remaining < 1.0 {
"Almost done...".into()
} else {
format!("ETA: ~{}", format_duration(remaining as u64 * 1000))
}
}
fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str, eta: &str) {
if let Some(page) = nav_view.visible_page() {
walk_widgets(&page.child(), &|widget| {
if let Some(label) = widget.downcast_ref::<gtk::Label>() {
if label.css_classes().iter().any(|c| c == "heading")
&& (label.label().contains("images") || label.label().contains("0 /"))
{
label.set_label(&format!("{} / {} images", current, total));
}
if label.css_classes().iter().any(|c| c == "dim-label")
&& (label.label().contains("Estimating") || label.label().contains("ETA") || label.label().contains("Almost") || label.label().contains("Current"))
{
if current < total {
label.set_label(&format!("{} - {}", eta, file));
} else {
label.set_label("Finishing up...");
}
}
}
});
}
}
fn add_log_entry(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str) {
if let Some(page) = nav_view.visible_page() {
walk_widgets(&page.child(), &|widget| {
if let Some(bx) = widget.downcast_ref::<gtk::Box>()
&& bx.spacing() == 2
&& bx.orientation() == gtk::Orientation::Vertical
{
let entry = gtk::Label::builder()
.label(format!("[{}/{}] {} - Done", current, total, file))
.halign(gtk::Align::Start)
.css_classes(["caption", "monospace"])
.build();
bx.append(&entry);
// Auto-scroll: the log box is inside a ScrolledWindow
if let Some(parent) = bx.parent()
&& let Some(sw) = parent.downcast_ref::<gtk::ScrolledWindow>()
{
let adj = sw.vadjustment();
adj.set_value(adj.upper());
}
}
});
}
}
fn import_preset(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = gtk::FileDialog::builder()
.title("Import Preset")
.modal(true)
.build();
let filter = gtk::FileFilter::new();
filter.set_name(Some("Preset files (JSON)"));
filter.add_mime_type("application/json");
filter.add_suffix("json");
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
dialog.set_default_filter(Some(&filter));
let ui = ui.clone();
dialog.open(Some(window), gtk::gio::Cancellable::NONE, move |result| {
if let Ok(file) = result
&& let Some(path) = file.path()
{
let store = pixstrip_core::storage::PresetStore::new();
match store.import_from_file(&path) {
Ok(preset) => {
let msg = format!("Imported preset: {}", preset.name);
let _ = store.save(&preset);
let toast = adw::Toast::new(&msg);
ui.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to import: {}", e));
ui.toast_overlay.add_toast(toast);
}
}
}
});
}
fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = adw::Dialog::builder()
.title("Save as Preset")
.content_width(400)
.content_height(500)
.build();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(24)
.margin_end(24)
.build();
// Show summary of current settings
let cfg = ui.state.job_config.borrow();
let summary = build_preset_description(&cfg);
drop(cfg);
let summary_group = adw::PreferencesGroup::builder()
.title("Workflow Summary")
.description(&summary)
.build();
content.append(&summary_group);
// Name entry
let name_group = adw::PreferencesGroup::builder()
.title("Save as New Preset")
.build();
let name_entry = adw::EntryRow::builder()
.title("Preset Name")
.build();
name_group.add(&name_entry);
let save_new_button = gtk::Button::builder()
.label("Save New Preset")
.halign(gtk::Align::Center)
.margin_top(8)
.build();
save_new_button.add_css_class("suggested-action");
save_new_button.add_css_class("pill");
content.append(&name_group);
content.append(&save_new_button);
// "Update existing" section - show user presets
let store = pixstrip_core::storage::PresetStore::new();
let user_presets: Vec<String> = store
.list()
.unwrap_or_default()
.into_iter()
.filter(|p| p.is_custom)
.map(|p| p.name)
.collect();
if !user_presets.is_empty() {
let update_group = adw::PreferencesGroup::builder()
.title("Or Update Existing Preset")
.description("Overwrite an existing user preset with current settings")
.build();
for preset_name in &user_presets {
let row = adw::ActionRow::builder()
.title(preset_name)
.activatable(true)
.build();
row.add_prefix(&gtk::Image::from_icon_name("document-save-symbolic"));
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let ui_c = ui.clone();
let dlg_c = dialog.clone();
let pname = preset_name.clone();
row.connect_activated(move |_| {
let cfg = ui_c.state.job_config.borrow();
let preset = build_preset_from_config(&cfg, &pname);
drop(cfg);
let store = pixstrip_core::storage::PresetStore::new();
match store.save(&preset) {
Ok(()) => {
let toast = adw::Toast::new(&format!("Updated preset: {}", pname));
ui_c.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to update: {}", e));
ui_c.toast_overlay.add_toast(toast);
}
}
dlg_c.close();
});
update_group.add(&row);
}
content.append(&update_group);
}
// Wire save new button
{
let ui_c = ui.clone();
let dlg_c = dialog.clone();
let entry_c = name_entry.clone();
save_new_button.connect_clicked(move |_| {
let name = entry_c.text().to_string();
if name.trim().is_empty() {
let toast = adw::Toast::new("Please enter a name for the preset");
ui_c.toast_overlay.add_toast(toast);
return;
}
let cfg = ui_c.state.job_config.borrow();
let preset = build_preset_from_config(&cfg, &name);
drop(cfg);
let store = pixstrip_core::storage::PresetStore::new();
match store.save(&preset) {
Ok(()) => {
let toast = adw::Toast::new(&format!("Saved preset: {}", name));
ui_c.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to save: {}", e));
ui_c.toast_overlay.add_toast(toast);
}
}
dlg_c.close();
});
}
scrolled.set_child(Some(&content));
toolbar.set_content(Some(&scrolled));
dialog.set_child(Some(&toolbar));
dialog.present(Some(window));
}
fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::preset::Preset {
let resize = if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 {
Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width))
} else {
Some(pixstrip_core::operations::ResizeConfig::FitInBox {
max: pixstrip_core::types::Dimensions {
width: cfg.resize_width,
height: cfg.resize_height,
},
allow_upscale: cfg.allow_upscale,
})
}
} else {
None
};
let convert = if cfg.convert_enabled {
cfg.convert_format.map(pixstrip_core::operations::ConvertConfig::SingleFormat)
} else {
None
};
let compress = if cfg.compress_enabled {
Some(pixstrip_core::operations::CompressConfig::Preset(cfg.quality_preset))
} else {
None
};
let metadata = if cfg.metadata_enabled {
Some(match cfg.metadata_mode {
MetadataMode::StripAll => pixstrip_core::operations::MetadataConfig::StripAll,
MetadataMode::Privacy => pixstrip_core::operations::MetadataConfig::Privacy,
MetadataMode::KeepAll => pixstrip_core::operations::MetadataConfig::KeepAll,
MetadataMode::Custom => pixstrip_core::operations::MetadataConfig::Custom {
strip_gps: cfg.strip_gps,
strip_camera: cfg.strip_camera,
strip_software: cfg.strip_software,
strip_timestamps: cfg.strip_timestamps,
strip_copyright: cfg.strip_copyright,
},
})
} else {
None
};
let rotation = match cfg.rotation {
1 => Some(pixstrip_core::operations::Rotation::Cw90),
2 => Some(pixstrip_core::operations::Rotation::Cw180),
3 => Some(pixstrip_core::operations::Rotation::Cw270),
4 => Some(pixstrip_core::operations::Rotation::AutoOrient),
_ => None,
};
let flip = match cfg.flip {
1 => Some(pixstrip_core::operations::Flip::Horizontal),
2 => Some(pixstrip_core::operations::Flip::Vertical),
_ => None,
};
let watermark = if cfg.watermark_enabled {
let position = match cfg.watermark_position {
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
4 => pixstrip_core::operations::WatermarkPosition::Center,
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
};
if cfg.watermark_use_image {
cfg.watermark_image_path.as_ref().map(|path| {
pixstrip_core::operations::WatermarkConfig::Image {
path: path.clone(),
position,
opacity: cfg.watermark_opacity,
scale: 0.2,
}
})
} else if !cfg.watermark_text.is_empty() {
Some(pixstrip_core::operations::WatermarkConfig::Text {
text: cfg.watermark_text.clone(),
position,
font_size: cfg.watermark_font_size,
opacity: cfg.watermark_opacity,
color: cfg.watermark_color,
})
} else {
None
}
} else {
None
};
let rename = if cfg.rename_enabled {
Some(pixstrip_core::operations::RenameConfig {
prefix: cfg.rename_prefix.clone(),
suffix: cfg.rename_suffix.clone(),
counter_start: cfg.rename_counter_start,
counter_padding: cfg.rename_counter_padding,
template: if cfg.rename_template.is_empty() {
None
} else {
Some(cfg.rename_template.clone())
},
})
} else {
None
};
pixstrip_core::preset::Preset {
name: name.to_string(),
description: build_preset_description(cfg),
icon: "document-save-symbolic".into(),
is_custom: true,
resize,
rotation,
flip,
convert,
compress,
metadata,
watermark,
rename,
}
}
fn build_preset_description(cfg: &JobConfig) -> String {
let mut parts = Vec::new();
if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 {
parts.push(format!("Resize {}px wide", cfg.resize_width));
} else {
parts.push(format!("Resize {}x{}", cfg.resize_width, cfg.resize_height));
}
}
if cfg.convert_enabled && let Some(fmt) = cfg.convert_format {
parts.push(format!("Convert to {:?}", fmt));
}
if cfg.compress_enabled {
parts.push(format!("Compress {:?}", cfg.quality_preset));
}
if cfg.metadata_enabled {
parts.push(format!("Metadata: {:?}", cfg.metadata_mode));
}
if parts.is_empty() {
"Custom workflow".into()
} else {
parts.join(", ")
}
}
fn update_output_summary(ui: &WizardUi) {
let cfg = ui.state.job_config.borrow();
if let Some(page) = ui.pages.get(9) {
// Build summary lines
let mut ops = Vec::new();
if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 {
ops.push(format!("Resize to {}px wide", cfg.resize_width));
} else {
ops.push(format!("Resize to {}x{}", cfg.resize_width, cfg.resize_height));
}
}
if cfg.convert_enabled && let Some(fmt) = cfg.convert_format {
ops.push(format!("Convert to {:?}", fmt));
}
if cfg.compress_enabled {
ops.push(format!("Compress ({:?})", cfg.quality_preset));
}
if cfg.metadata_enabled {
let mode = match cfg.metadata_mode {
MetadataMode::StripAll => "Strip all metadata",
MetadataMode::Privacy => "Privacy mode",
MetadataMode::KeepAll => "Keep all metadata",
MetadataMode::Custom => "Custom metadata stripping",
};
ops.push(mode.to_string());
}
if cfg.watermark_enabled {
if cfg.watermark_use_image {
ops.push("Image watermark".to_string());
} else if !cfg.watermark_text.is_empty() {
ops.push(format!("Watermark: \"{}\"", cfg.watermark_text));
}
}
if cfg.rename_enabled {
if !cfg.rename_template.is_empty() {
ops.push(format!("Rename: {}", cfg.rename_template));
} else if !cfg.rename_prefix.is_empty() || !cfg.rename_suffix.is_empty() {
ops.push(format!(
"Rename: {}...{}",
cfg.rename_prefix, cfg.rename_suffix
));
} else {
ops.push("Sequential rename".to_string());
}
}
if cfg.adjustments_enabled {
if cfg.rotation > 0 {
let rot = match cfg.rotation {
1 => "Rotate 90",
2 => "Rotate 180",
3 => "Rotate 270",
4 => "Auto-orient",
_ => "Rotate",
};
ops.push(rot.to_string());
}
if cfg.flip > 0 {
let fl = match cfg.flip {
1 => "Flip horizontal",
2 => "Flip vertical",
_ => "Flip",
};
ops.push(fl.to_string());
}
if cfg.brightness != 0 {
ops.push(format!("Brightness {:+}", cfg.brightness));
}
if cfg.contrast != 0 {
ops.push(format!("Contrast {:+}", cfg.contrast));
}
if cfg.saturation != 0 {
ops.push(format!("Saturation {:+}", cfg.saturation));
}
if cfg.sharpen {
ops.push("Sharpen".to_string());
}
if cfg.grayscale {
ops.push("Grayscale".to_string());
}
if cfg.sepia {
ops.push("Sepia".to_string());
}
if cfg.crop_aspect_ratio > 0 {
let ratio = match cfg.crop_aspect_ratio {
1 => "1:1",
2 => "4:3",
3 => "3:2",
4 => "16:9",
5 => "9:16",
6 => "3:4",
7 => "2:3",
_ => "Custom",
};
ops.push(format!("Crop {}", ratio));
}
if cfg.trim_whitespace {
ops.push("Trim whitespace".to_string());
}
if cfg.canvas_padding > 0 {
ops.push(format!("Padding {}px", cfg.canvas_padding));
}
}
// Find the ops-summary-list ListBox and populate it
walk_widgets(&page.child(), &|widget| {
if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>()
&& list_box.widget_name().as_str() == "ops-summary-list"
{
// Clear existing rows
while let Some(child) = list_box.first_child() {
list_box.remove(&child);
}
if ops.is_empty() {
let row = adw::ActionRow::builder()
.title("No operations configured")
.subtitle("Go back and configure your workflow settings")
.build();
row.add_prefix(&gtk::Image::from_icon_name("dialog-information-symbolic"));
list_box.append(&row);
} else {
for op in &ops {
let row = adw::ActionRow::builder()
.title(op.as_str())
.build();
row.add_prefix(&gtk::Image::from_icon_name("emblem-ok-symbolic"));
list_box.append(&row);
}
}
}
});
}
}
// --- Utility functions ---
enum ProcessingMessage {
Progress {
current: usize,
total: usize,
file: String,
},
Done(pixstrip_core::executor::BatchResult),
Error(String),
}
fn find_widget_by_type<T: IsA<gtk::Widget>>(page: &adw::NavigationPage) -> Option<T> {
let result: RefCell<Option<T>> = RefCell::new(None);
walk_widgets(&page.child(), &|widget| {
if result.borrow().is_none()
&& let Some(w) = widget.downcast_ref::<T>()
{
*result.borrow_mut() = Some(w.clone());
}
});
result.into_inner()
}
pub fn walk_widgets(widget: &Option<gtk::Widget>, f: &dyn Fn(&gtk::Widget)) {
let Some(w) = widget else { return };
f(w);
let mut child = w.first_child();
while let Some(c) = child {
walk_widgets(&Some(c.clone()), f);
child = c.next_sibling();
}
}
fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let shortcuts_window = gtk::ShortcutsWindow::builder()
.transient_for(window)
.modal(true)
.build();
// Wizard Navigation section
let wizard_group = gtk::ShortcutsGroup::builder()
.title("Wizard Navigation")
.build();
let shortcuts_nav: &[(&str, &str)] = &[
("<Alt>Right", "Next step"),
("<Alt>Left", "Previous step"),
("<Alt>1", "Jump to step 1"),
("<Alt>2", "Jump to step 2"),
("<Alt>3", "Jump to step 3"),
("<Control>Return", "Process images"),
("Escape", "Cancel or go back"),
];
for (accel, title) in shortcuts_nav {
let shortcut = gtk::ShortcutsShortcut::builder()
.accelerator(*accel)
.title(*title)
.build();
wizard_group.add_shortcut(&shortcut);
}
// File Management section
let files_group = gtk::ShortcutsGroup::builder()
.title("File Management")
.build();
let shortcuts_files: &[(&str, &str)] = &[
("<Control>o", "Add files"),
("<Control>v", "Paste image from clipboard"),
("<Control>a", "Select all images"),
("<Control><Shift>a", "Deselect all images"),
("Delete", "Remove selected images"),
];
for (accel, title) in shortcuts_files {
let shortcut = gtk::ShortcutsShortcut::builder()
.accelerator(*accel)
.title(*title)
.build();
files_group.add_shortcut(&shortcut);
}
// Application section
let app_group = gtk::ShortcutsGroup::builder()
.title("Application")
.build();
let shortcuts_app: &[(&str, &str)] = &[
("<Control>comma", "Settings"),
("F1", "Keyboard shortcuts"),
("<Control>z", "Undo last batch"),
("<Control>q", "Quit"),
];
for (accel, title) in shortcuts_app {
let shortcut = gtk::ShortcutsShortcut::builder()
.accelerator(*accel)
.title(*title)
.build();
app_group.add_shortcut(&shortcut);
}
let section = gtk::ShortcutsSection::builder()
.title("Keyboard Shortcuts")
.build();
#[allow(deprecated)]
{
section.add_group(&wizard_group);
section.add_group(&files_group);
section.add_group(&app_group);
shortcuts_window.add_section(&section);
}
shortcuts_window.present();
}
fn apply_accessibility_settings() {
let config_store = pixstrip_core::storage::ConfigStore::new();
let config = config_store.load().unwrap_or_default();
if config.high_contrast {
// Request high contrast by switching to the HighContrast theme.
// This works on GNOME systems with the standard HC theme installed.
let settings = gtk::Settings::default().unwrap();
settings.set_gtk_theme_name(Some("HighContrast"));
}
let settings = gtk::Settings::default().unwrap();
if config.large_text {
// Increase font DPI by 25% for large text mode
let current_dpi = settings.gtk_xft_dpi();
if current_dpi > 0 {
settings.set_gtk_xft_dpi(current_dpi * 5 / 4);
}
}
if config.reduced_motion {
settings.set_gtk_enable_animations(false);
}
}
fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
fn show_step_help(window: &adw::ApplicationWindow, step: usize) {
let (title, body) = match step {
0 => ("Workflow", concat!(
"Choose a preset to start quickly, or configure each step manually.\n\n",
"Presets apply recommended settings for common tasks like web optimization, ",
"social media, or print preparation. You can customize any preset after applying it.\n\n",
"Use Import/Export to share presets with others."
)),
1 => ("Images", concat!(
"Add the images you want to process.\n\n",
"- Drag and drop files or folders onto this area\n",
"- Use Browse to pick files from a file dialog\n",
"- Press Ctrl+V to paste from clipboard\n\n",
"Use checkboxes to include or exclude individual images. ",
"Ctrl+A selects all, Ctrl+Shift+A deselects all."
)),
2 => ("Resize", concat!(
"Scale images to specific dimensions.\n\n",
"Choose a preset size or enter custom dimensions. Width-only or height-only ",
"resizing preserves the original aspect ratio.\n\n",
"Enable 'Allow upscale' if you need images smaller than the target to be enlarged."
)),
3 => ("Adjustments", concat!(
"Fine-tune image appearance.\n\n",
"Adjust brightness, contrast, and saturation with sliders. ",
"Apply rotation, flipping, grayscale, or sepia effects.\n\n",
"Crop to a specific aspect ratio or trim whitespace borders automatically."
)),
4 => ("Convert", concat!(
"Change image file format.\n\n",
"Convert between JPEG, PNG, WebP, AVIF, GIF, TIFF, and BMP. ",
"Each format has trade-offs between quality, file size, and compatibility.\n\n",
"WebP and AVIF offer the best compression for web use."
)),
5 => ("Compress", concat!(
"Reduce file size while preserving quality.\n\n",
"Choose a quality preset (Lossless, High, Balanced, Small, Tiny) or set custom ",
"quality values per format.\n\n",
"Expand Advanced Options for fine control over WebP encoding effort and AVIF speed."
)),
6 => ("Metadata", concat!(
"Control what metadata is kept or removed.\n\n",
"Strip All removes everything. Privacy mode keeps copyright and camera info but ",
"removes GPS and timestamps. Custom mode lets you pick exactly what to strip.\n\n",
"Removing metadata reduces file size and protects privacy."
)),
7 => ("Watermark", concat!(
"Add a text or image watermark.\n\n",
"Choose text or logo mode. Position the watermark using the visual grid. ",
"Expand Advanced Options for opacity, rotation, tiling, margin, and scale controls.\n\n",
"Logo watermarks support PNG images with transparency."
)),
8 => ("Rename", concat!(
"Rename output files using patterns.\n\n",
"Add a prefix, suffix, or use a full template with placeholders:\n",
"- {name} - original filename\n",
"- {n} - counter number\n",
"- {date} - current date\n",
"- {ext} - original extension\n\n",
"Expand Advanced Options for case conversion and find-and-replace."
)),
9 => ("Output", concat!(
"Review settings and choose where to save.\n\n",
"The summary shows all operations that will be applied. ",
"Choose an output folder or use the default 'processed' subfolder.\n\n",
"Set overwrite behavior for when output files already exist. ",
"Press Process or Ctrl+Enter to start."
)),
_ => ("Help", "No help available for this step."),
};
let dialog = adw::AlertDialog::builder()
.heading(format!("Help: {}", title))
.body(body)
.build();
dialog.add_response("ok", "Got it");
dialog.set_default_response(Some("ok"));
dialog.present(Some(window));
}
fn format_duration(ms: u64) -> String {
if ms < 1000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
let mins = ms / 60_000;
let secs = (ms % 60_000) / 1000;
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"));
}
/// Check for pending batches in the queue and start processing the next one.
/// Returns true if a batch was started.
fn process_next_queued_batch(ui: &WizardUi) -> bool {
// Find the first pending batch
let next_idx = {
let queue = ui.state.batch_queue.borrow();
queue.batches.iter().position(|b| b.status == BatchStatus::Pending)
};
let Some(idx) = next_idx else {
return false;
};
// Mark it as processing
let batch = {
let mut queue = ui.state.batch_queue.borrow_mut();
queue.batches[idx].status = BatchStatus::Processing;
queue.batches[idx].clone()
};
refresh_queue_list(ui);
// Load the batch's config into state so start_processing uses it
*ui.state.job_config.borrow_mut() = batch.job_config;
*ui.state.loaded_files.borrow_mut() = batch.files;
ui.state.excluded_files.borrow_mut().clear();
*ui.state.output_dir.borrow_mut() = Some(batch.output_dir);
ui.toast_overlay.add_toast(adw::Toast::new(&format!("Starting queued batch: {}", batch.name)));
// Pop to a clean state if we're on results page
while let Some(page) = ui.nav_view.visible_page() {
let tag = page.tag();
if tag.as_deref() == Some("processing") || tag.as_deref() == Some("results") {
ui.nav_view.pop();
} else {
break;
}
}
// Store the batch index for marking completion
let batch_idx = idx;
let ui_clone = ui.clone();
// Start processing with a small delay so the UI updates
glib::timeout_add_local_once(std::time::Duration::from_millis(100), move || {
if let Some(root) = ui_clone.nav_view.root()
&& let Some(win) = root.downcast_ref::<adw::ApplicationWindow>()
{
run_processing(win, &ui_clone);
}
});
// We need to mark the batch as completed when processing finishes.
// This is handled in show_results via the queue check.
let _ = batch_idx;
true
}
/// Mark the currently-processing batch in the queue as completed or failed.
fn mark_current_queue_batch(ui: &WizardUi, success: bool, error_msg: Option<&str>) {
let mut queue = ui.state.batch_queue.borrow_mut();
if let Some(batch) = queue.batches.iter_mut().find(|b| b.status == BatchStatus::Processing) {
batch.status = if success {
BatchStatus::Completed
} else {
BatchStatus::Failed(error_msg.unwrap_or("Unknown error").to_string())
};
}
drop(queue);
refresh_queue_list(ui);
}