- PNG chunk parsing overflow protection with checked arithmetic - Font directory traversal bounded with global result limit - find_unique_path TOCTOU race fixed with create_new + marker byte - Watch mode "processed" dir exclusion narrowed to prevent false skips - Metadata copy now checks format support before little_exif calls - Clipboard temp files cleaned up on app exit - Atomic writes for file manager integration scripts - BMP format support added to encoder and convert step - Regex DoS protection with DFA size limit - Watermark NaN/negative scale guard - Selective EXIF stripping for privacy/custom metadata modes - CLI watch mode: file stability checks, per-file history saves - High contrast toggle preserves and restores original theme - Image list deduplication uses O(1) HashSet lookups - Saturation/trim/padding overflow guards in adjustments
3763 lines
136 KiB
Rust
3763 lines
136 KiB
Rust
use adw::prelude::*;
|
|
use gtk::glib;
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
use std::rc::Rc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use crate::step_indicator::StepIndicator;
|
|
use crate::steps;
|
|
use crate::utils::format_size;
|
|
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 {
|
|
// When true, skip all intermediate steps (2-8) - go straight from Images to Output
|
|
pub preset_mode: bool,
|
|
// Resize
|
|
pub resize_enabled: bool,
|
|
pub resize_width: u32,
|
|
pub resize_height: u32,
|
|
pub resize_mode: u32, // 0=exact, 1=fit within box
|
|
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_mappings: HashMap<String, 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_font_family: String,
|
|
pub watermark_use_image: bool,
|
|
pub watermark_tiled: bool,
|
|
pub watermark_margin: u32,
|
|
pub watermark_scale: f32,
|
|
pub watermark_rotation: i32,
|
|
// Rename
|
|
pub rename_enabled: bool,
|
|
pub rename_prefix: String,
|
|
pub rename_suffix: String,
|
|
pub rename_counter_enabled: bool,
|
|
pub rename_counter_start: u32,
|
|
pub rename_counter_padding: u32,
|
|
pub rename_counter_position: u32, // 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
|
pub rename_replace_spaces: u32, // 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
|
pub rename_special_chars: u32, // 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
|
pub rename_case: u32, // 0=none, 1=lower, 2=upper, 3=title
|
|
pub rename_template: String,
|
|
pub rename_find: String,
|
|
pub rename_replace: 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>>,
|
|
pub expanded_sections: Rc<RefCell<std::collections::HashMap<String, bool>>>,
|
|
}
|
|
|
|
impl AppState {
|
|
/// Get the expanded state for a named section, falling back to the skill level default
|
|
pub fn is_section_expanded(&self, section: &str) -> bool {
|
|
self.expanded_sections.borrow().get(section).copied().unwrap_or(self.detailed_mode)
|
|
}
|
|
|
|
/// Set and persist an expander's state
|
|
pub fn set_section_expanded(&self, section: &str, expanded: bool) {
|
|
self.expanded_sections.borrow_mut().insert(section.to_string(), expanded);
|
|
}
|
|
}
|
|
|
|
/// 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");
|
|
if let Some(simple) = action.downcast_ref::<gtk::gio::SimpleAction>() {
|
|
simple.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(
|
|
>k::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())),
|
|
expanded_sections: Rc::new(RefCell::new(sess_state.expanded_sections.clone())),
|
|
job_config: Rc::new(RefCell::new(JobConfig {
|
|
preset_mode: false,
|
|
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 },
|
|
resize_mode: 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_mappings: HashMap::new(),
|
|
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_font_family: String::new(),
|
|
watermark_use_image: false,
|
|
watermark_tiled: false,
|
|
watermark_margin: 10,
|
|
watermark_scale: 20.0,
|
|
watermark_rotation: 0,
|
|
rename_enabled: if remember { sess_state.rename_enabled.unwrap_or(false) } else { false },
|
|
rename_prefix: String::new(),
|
|
rename_suffix: String::new(),
|
|
rename_counter_enabled: false,
|
|
rename_counter_start: 1,
|
|
rename_counter_padding: 3,
|
|
rename_counter_position: 3, // after suffix
|
|
rename_replace_spaces: 0,
|
|
rename_special_chars: 0,
|
|
rename_case: 0,
|
|
rename_template: String::new(),
|
|
rename_find: String::new(),
|
|
rename_replace: 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);
|
|
|
|
// Watch folders collapsible panel
|
|
let watch_revealer = gtk::Revealer::builder()
|
|
.transition_type(gtk::RevealerTransitionType::SlideUp)
|
|
.reveal_child(false)
|
|
.build();
|
|
|
|
let watch_panel = build_watch_folder_panel();
|
|
watch_revealer.set_child(Some(&watch_panel));
|
|
|
|
// Watch folder toggle button in header
|
|
let watch_button = gtk::ToggleButton::builder()
|
|
.icon_name("folder-visiting-symbolic")
|
|
.tooltip_text("Watch Folders")
|
|
.build();
|
|
watch_button.add_css_class("flat");
|
|
header.pack_start(&watch_button);
|
|
|
|
{
|
|
let revealer = watch_revealer.clone();
|
|
watch_button.connect_toggled(move |btn| {
|
|
revealer.set_reveal_child(btn.is_active());
|
|
});
|
|
}
|
|
|
|
// Main content layout
|
|
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
let indicator_scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Automatic)
|
|
.vscrollbar_policy(gtk::PolicyType::Never)
|
|
.child(step_indicator.widget())
|
|
.build();
|
|
indicator_scroll.set_size_request(-1, 52);
|
|
content_box.append(&indicator_scroll);
|
|
content_box.append(&nav_view);
|
|
content_box.append(&watch_revealer);
|
|
|
|
// 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")
|
|
.icon_name(APP_ID)
|
|
.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);
|
|
}
|
|
|
|
// Always save expanded section states
|
|
state.expanded_sections = app_state_for_close.expanded_sections.borrow().clone();
|
|
|
|
let _ = session.save(&state);
|
|
|
|
// Clean up temporary download directory
|
|
let temp_downloads = std::env::temp_dir().join("pixstrip-downloads");
|
|
if temp_downloads.exists() {
|
|
let _ = std::fs::remove_dir_all(&temp_downloads);
|
|
}
|
|
|
|
// Clean up clipboard temp files on exit
|
|
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
|
|
if temp_dir.is_dir() {
|
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start watch folder monitoring for active folders
|
|
start_watch_folder_monitoring(&ui);
|
|
}
|
|
|
|
fn start_watch_folder_monitoring(ui: &WizardUi) {
|
|
let config_store = pixstrip_core::storage::ConfigStore::new();
|
|
let config = config_store.load().unwrap_or_default();
|
|
|
|
let active_folders: Vec<_> = config.watch_folders.iter()
|
|
.filter(|f| f.active && f.path.exists())
|
|
.cloned()
|
|
.collect();
|
|
|
|
if active_folders.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel::<pixstrip_core::watcher::WatchEvent>();
|
|
|
|
// Start a watcher for each active folder, keeping them alive
|
|
let mut watchers = Vec::new();
|
|
for folder in &active_folders {
|
|
let watcher = pixstrip_core::watcher::FolderWatcher::new();
|
|
let folder_tx = tx.clone();
|
|
if let Err(e) = watcher.start(folder, folder_tx) {
|
|
eprintln!("Failed to start watching {}: {}", folder.path.display(), e);
|
|
continue;
|
|
}
|
|
watchers.push(watcher);
|
|
}
|
|
|
|
// Build a lookup from folder path to preset name
|
|
let folder_presets: std::collections::HashMap<std::path::PathBuf, String> = active_folders
|
|
.iter()
|
|
.map(|f| (f.path.clone(), f.preset_name.clone()))
|
|
.collect();
|
|
|
|
let toast_overlay = ui.toast_overlay.clone();
|
|
|
|
// Poll the channel from the main loop
|
|
// Move watchers into closure to keep them alive for the app lifetime
|
|
glib::timeout_add_local(std::time::Duration::from_millis(500), move || {
|
|
let _watchers = &watchers; // prevent drop
|
|
let mut batch: Vec<(std::path::PathBuf, String)> = Vec::new();
|
|
|
|
// Drain all pending events
|
|
while let Ok(event) = rx.try_recv() {
|
|
match event {
|
|
pixstrip_core::watcher::WatchEvent::NewImage(path) => {
|
|
// Find which watch folder this belongs to
|
|
for (folder_path, preset_name) in &folder_presets {
|
|
if path.starts_with(folder_path) {
|
|
batch.push((path.clone(), preset_name.clone()));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
pixstrip_core::watcher::WatchEvent::Error(e) => {
|
|
eprintln!("Watch folder error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !batch.is_empty() {
|
|
// Group by preset name and process
|
|
let preset_store = pixstrip_core::storage::PresetStore::new();
|
|
let mut by_preset: std::collections::HashMap<String, Vec<std::path::PathBuf>> =
|
|
std::collections::HashMap::new();
|
|
for (path, preset) in batch {
|
|
by_preset.entry(preset).or_default().push(path);
|
|
}
|
|
|
|
for (preset_name, files) in by_preset {
|
|
if let Ok(presets) = preset_store.list() {
|
|
if let Some(preset) = presets.iter().find(|p| p.name == preset_name) {
|
|
let count = files.len();
|
|
let preset = preset.clone();
|
|
std::thread::spawn(move || {
|
|
// Build output dir next to the first file
|
|
let output_dir = files.first()
|
|
.and_then(|f| f.parent())
|
|
.map(|p| p.join("processed"))
|
|
.unwrap_or_else(|| std::path::PathBuf::from("processed"));
|
|
let input_dir = files.first()
|
|
.and_then(|f| f.parent())
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.to_path_buf();
|
|
let mut job = preset.to_job(&input_dir, &output_dir);
|
|
for file in &files {
|
|
job.add_source(file);
|
|
}
|
|
let executor = pixstrip_core::executor::PipelineExecutor::new();
|
|
if let Err(e) = executor.execute(&job, |_| {}) {
|
|
eprintln!("Watch folder processing error: {}", e);
|
|
}
|
|
});
|
|
|
|
let toast = adw::Toast::new(&format!(
|
|
"Watch: processing {} new image{}",
|
|
count,
|
|
if count == 1 { "" } else { "s" }
|
|
));
|
|
toast.set_timeout(3);
|
|
toast_overlay.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
glib::ControlFlow::Continue
|
|
});
|
|
}
|
|
|
|
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>())
|
|
&& step >= 1
|
|
{
|
|
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 mut loaded = ui.state.loaded_files.borrow_mut();
|
|
let new_files: Vec<_> = new_files
|
|
.into_iter()
|
|
.filter(|p| !loaded.contains(p))
|
|
.collect();
|
|
let count = new_files.len();
|
|
loaded.extend(new_files);
|
|
drop(loaded);
|
|
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 {
|
|
{
|
|
let mut w = ui.state.wizard.borrow_mut();
|
|
w.current_step = 1;
|
|
if w.visited.len() > 1 {
|
|
w.visited[1] = true;
|
|
}
|
|
}
|
|
ui.step_indicator.set_current(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..=8 if cfg.preset_mode => true, // Preset mode: skip all intermediate steps
|
|
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 rebuild_step_indicator(ui: &WizardUi) {
|
|
let cfg = ui.state.job_config.borrow();
|
|
let all_names = [
|
|
"Workflow", "Images", "Resize", "Adjustments", "Convert",
|
|
"Compress", "Metadata", "Watermark", "Rename", "Output",
|
|
];
|
|
let visible: Vec<(usize, String)> = all_names.iter().enumerate()
|
|
.filter(|&(i, _)| !should_skip_step(i, &cfg))
|
|
.map(|(i, name)| (i, name.to_string()))
|
|
.collect();
|
|
drop(cfg);
|
|
ui.step_indicator.rebuild(&visible);
|
|
}
|
|
|
|
fn navigate_to_step(ui: &WizardUi, target: usize) {
|
|
// Rebuild indicator: show all steps on Workflow, only relevant steps elsewhere
|
|
if target == 0 {
|
|
let all_names = [
|
|
"Workflow", "Images", "Resize", "Adjustments", "Convert",
|
|
"Compress", "Metadata", "Watermark", "Rename", "Output",
|
|
];
|
|
let all: Vec<(usize, String)> = all_names.iter().enumerate()
|
|
.map(|(i, name)| (i, name.to_string()))
|
|
.collect();
|
|
ui.step_indicator.rebuild(&all);
|
|
} else {
|
|
rebuild_step_indicator(ui);
|
|
}
|
|
|
|
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_size(total_size)));
|
|
}
|
|
});
|
|
}
|
|
update_output_summary(ui);
|
|
}
|
|
|
|
update_nav_buttons(&s, &ui.back_button, &ui.next_button);
|
|
}
|
|
|
|
fn update_nav_buttons(state: &WizardState, back_button: >k::Button, next_button: >k::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"));
|
|
// Common raster formats
|
|
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");
|
|
// Note: ico/heic/svg excluded - not supported by processing pipeline
|
|
filter.add_mime_type("image/x-tga");
|
|
filter.add_mime_type("image/x-portable-anymap");
|
|
filter.add_mime_type("image/x-portable-bitmap");
|
|
filter.add_mime_type("image/x-portable-graymap");
|
|
filter.add_mime_type("image/x-portable-pixmap");
|
|
filter.add_mime_type("image/x-pcx");
|
|
filter.add_mime_type("image/x-xpixmap");
|
|
filter.add_mime_type("image/x-xbitmap");
|
|
filter.add_mime_type("image/vnd.wap.wbmp");
|
|
filter.add_mime_type("image/vnd.ms-dds");
|
|
// HDR / EXR
|
|
filter.add_mime_type("image/vnd.radiance");
|
|
filter.add_mime_type("image/x-exr");
|
|
// Modern formats
|
|
filter.add_mime_type("image/jxl");
|
|
filter.add_mime_type("image/heic");
|
|
filter.add_mime_type("image/heif");
|
|
filter.add_mime_type("image/jp2");
|
|
filter.add_mime_type("image/jpx");
|
|
filter.add_mime_type("image/x-qoi");
|
|
// Vector
|
|
filter.add_mime_type("image/svg+xml");
|
|
// RAW camera formats
|
|
filter.add_mime_type("image/x-canon-cr2");
|
|
filter.add_mime_type("image/x-canon-cr3");
|
|
filter.add_mime_type("image/x-nikon-nef");
|
|
filter.add_mime_type("image/x-sony-arw");
|
|
filter.add_mime_type("image/x-sony-srf");
|
|
filter.add_mime_type("image/x-sony-sr2");
|
|
filter.add_mime_type("image/x-olympus-orf");
|
|
filter.add_mime_type("image/x-panasonic-rw2");
|
|
filter.add_mime_type("image/x-fuji-raf");
|
|
filter.add_mime_type("image/x-adobe-dng");
|
|
filter.add_mime_type("image/x-pentax-pef");
|
|
filter.add_mime_type("image/x-samsung-srw");
|
|
filter.add_mime_type("image/x-sigma-x3f");
|
|
// Catch-all pattern for any image type the system recognizes
|
|
filter.add_mime_type("image/*");
|
|
|
|
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) {
|
|
// 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") {
|
|
steps::step_images::rebuild_grid_model(
|
|
&loaded_box,
|
|
&ui.state.loaded_files,
|
|
&ui.state.excluded_files,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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(>k::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(>k::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(>k::Image::from_icon_name("folder-open-symbolic"));
|
|
let out_dir = entry.output_dir.clone();
|
|
output_row.connect_activated(move |_| {
|
|
let uri = gtk::gio::File::for_path(&out_dir).uri();
|
|
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
|
&uri,
|
|
gtk::gio::AppLaunchContext::NONE,
|
|
);
|
|
});
|
|
row.add_row(&output_row);
|
|
|
|
let size_row = adw::ActionRow::builder()
|
|
.title("Size")
|
|
.subtitle(&format!(
|
|
"{} -> {} ({})",
|
|
format_size(entry.total_input_bytes),
|
|
format_size(entry.total_output_bytes),
|
|
savings
|
|
))
|
|
.build();
|
|
size_row.add_prefix(>k::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(>k::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 uri = gtk::gio::File::for_path(&out_dir2).uri();
|
|
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
|
&uri,
|
|
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(>k::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 = {
|
|
let first_parent = files[0]
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.to_path_buf();
|
|
if files.len() == 1 {
|
|
first_parent
|
|
} else {
|
|
// Find common ancestor of all files
|
|
let mut common = first_parent.clone();
|
|
for f in &files[1..] {
|
|
let p = f.parent().unwrap_or_else(|| std::path::Path::new("."));
|
|
while !p.starts_with(&common) {
|
|
if !common.pop() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if common.as_os_str().is_empty() {
|
|
first_parent
|
|
} else {
|
|
common
|
|
}
|
|
}
|
|
};
|
|
|
|
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 if cfg.resize_mode == 1 {
|
|
job.resize = Some(pixstrip_core::operations::ResizeConfig::FitInBox {
|
|
max: pixstrip_core::types::Dimensions { width: target_w, height: target_h },
|
|
allow_upscale: cfg.allow_upscale,
|
|
});
|
|
} else {
|
|
job.resize = Some(pixstrip_core::operations::ResizeConfig::Exact(
|
|
pixstrip_core::types::Dimensions { width: target_w, height: target_h },
|
|
));
|
|
}
|
|
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_mappings.values().any(|&v| v > 0);
|
|
|
|
if has_mapping {
|
|
// Dropdown order: 0=Same as above, 1=Keep Original,
|
|
// 2=JPEG, 3=PNG, 4=WebP, 5=AVIF, 6=GIF, 7=TIFF
|
|
let mapping_to_format = |idx: u32, default: Option<pixstrip_core::types::ImageFormat>| -> Option<pixstrip_core::types::ImageFormat> {
|
|
match idx {
|
|
1 => None, // Keep Original
|
|
2 => Some(pixstrip_core::types::ImageFormat::Jpeg),
|
|
3 => Some(pixstrip_core::types::ImageFormat::Png),
|
|
4 => Some(pixstrip_core::types::ImageFormat::WebP),
|
|
5 => Some(pixstrip_core::types::ImageFormat::Avif),
|
|
6 => Some(pixstrip_core::types::ImageFormat::Gif),
|
|
7 => Some(pixstrip_core::types::ImageFormat::Tiff),
|
|
_ => default, // 0 = "Same as above" - use global format
|
|
}
|
|
};
|
|
|
|
let global = cfg.convert_format;
|
|
let mut map = Vec::new();
|
|
|
|
for (ext, &mapping_idx) in &cfg.format_mappings {
|
|
if let Some(input_fmt) = pixstrip_core::types::ImageFormat::from_extension(ext) {
|
|
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,
|
|
};
|
|
|
|
let wm_rotation = if cfg.watermark_rotation != 0 {
|
|
Some(pixstrip_core::operations::WatermarkRotation::Custom(cfg.watermark_rotation as f32))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
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: cfg.watermark_scale / 100.0,
|
|
rotation: wm_rotation,
|
|
tiled: cfg.watermark_tiled,
|
|
margin: cfg.watermark_margin,
|
|
});
|
|
}
|
|
} 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,
|
|
font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) },
|
|
rotation: wm_rotation,
|
|
tiled: cfg.watermark_tiled,
|
|
margin: cfg.watermark_margin,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
counter_enabled: cfg.rename_counter_enabled,
|
|
counter_position: cfg.rename_counter_position,
|
|
template: if cfg.rename_template.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cfg.rename_template.clone())
|
|
},
|
|
case_mode: cfg.rename_case,
|
|
replace_spaces: cfg.rename_replace_spaces,
|
|
special_chars: cfg.rename_special_chars,
|
|
regex_find: cfg.rename_find.clone(),
|
|
regex_replace: cfg.rename_replace.clone(),
|
|
});
|
|
}
|
|
|
|
job.preserve_directory_structure = cfg.preserve_dir_structure;
|
|
job.output_dpi = cfg.output_dpi;
|
|
let ask_overwrite = cfg.overwrite_behavior == 0;
|
|
job.overwrite_behavior = match cfg.overwrite_behavior {
|
|
1 => pixstrip_core::operations::OverwriteAction::AutoRename,
|
|
2 => pixstrip_core::operations::OverwriteAction::Overwrite,
|
|
3 => pixstrip_core::operations::OverwriteAction::Skip,
|
|
_ => pixstrip_core::operations::OverwriteAction::AutoRename,
|
|
};
|
|
|
|
drop(cfg);
|
|
|
|
for file in &files {
|
|
job.add_source(file);
|
|
}
|
|
|
|
// Check for existing output files when "Ask" overwrite behavior is set.
|
|
// Skip check if rename or format conversion is active (output names will differ).
|
|
let has_rename_or_convert = job.rename.is_some() || job.convert.is_some();
|
|
if ask_overwrite && !has_rename_or_convert {
|
|
let output_dir = ui.state.output_dir.borrow().clone()
|
|
.unwrap_or_else(|| {
|
|
files[0].parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.join("processed")
|
|
});
|
|
if output_dir.exists() {
|
|
let conflicts: Vec<String> = files.iter()
|
|
.filter_map(|f| {
|
|
let name = f.file_name()?;
|
|
let out_path = output_dir.join(name);
|
|
if out_path.exists() { Some(name.to_string_lossy().into()) } else { None }
|
|
})
|
|
.take(10)
|
|
.collect();
|
|
|
|
if !conflicts.is_empty() {
|
|
let msg = if conflicts.len() == 10 {
|
|
format!("{} files already exist in the output folder (showing first 10):\n\n{}", conflicts.len(), conflicts.join("\n"))
|
|
} else {
|
|
format!("{} files already exist in the output folder:\n\n{}", conflicts.len(), conflicts.join("\n"))
|
|
};
|
|
|
|
let confirm = adw::AlertDialog::builder()
|
|
.heading("Files Already Exist")
|
|
.body(&msg)
|
|
.build();
|
|
confirm.add_response("rename", "Auto-rename");
|
|
confirm.add_response("skip", "Skip Existing");
|
|
confirm.add_response("overwrite", "Overwrite All");
|
|
confirm.set_response_appearance("overwrite", adw::ResponseAppearance::Destructive);
|
|
confirm.set_default_response(Some("rename"));
|
|
confirm.set_close_response("rename");
|
|
|
|
let ui_c = ui.clone();
|
|
let window_c = _window.clone();
|
|
let mut job_c = job;
|
|
confirm.choose(Some(_window), gtk::gio::Cancellable::NONE, move |response| {
|
|
job_c.overwrite_behavior = match response.as_str() {
|
|
"overwrite" => pixstrip_core::operations::OverwriteAction::Overwrite,
|
|
"skip" => pixstrip_core::operations::OverwriteAction::Skip,
|
|
_ => pixstrip_core::operations::OverwriteAction::AutoRename,
|
|
};
|
|
continue_processing(&window_c, &ui_c, job_c);
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
continue_processing(_window, ui, job);
|
|
}
|
|
|
|
fn continue_processing(
|
|
_window: &adw::ApplicationWindow,
|
|
ui: &WizardUi,
|
|
job: pixstrip_core::pipeline::ProcessingJob,
|
|
) {
|
|
// 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...");
|
|
|
|
// Disable navigation actions so Escape/shortcuts can't navigate away during processing
|
|
set_nav_actions_enabled(&ui.nav_view, false);
|
|
|
|
// 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 = if total > 0 { (current as f64 / total as f64).clamp(0.0, 1.0) } else { 0.0 };
|
|
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);
|
|
// Re-enable navigation actions
|
|
set_nav_actions_enabled(&ui_for_rx.nav_view, 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);
|
|
|
|
// Re-enable navigation actions
|
|
set_nav_actions_enabled(&ui.nav_view, 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();
|
|
|
|
// Use actual output file paths from the executor (only successfully written files)
|
|
let output_files: Vec<String> = result.output_files.clone();
|
|
|
|
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: output_files.clone(),
|
|
}, 50, 30);
|
|
|
|
// 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 undo_files = output_files;
|
|
undo_toast.connect_button_clicked(move |t| {
|
|
let mut trashed = 0;
|
|
for path_str in &undo_files {
|
|
let path = std::path::Path::new(path_str);
|
|
if path.exists() {
|
|
let gfile = gtk::gio::File::for_path(path);
|
|
if gfile.trash(gtk::gio::Cancellable::NONE).is_ok() {
|
|
trashed += 1;
|
|
}
|
|
}
|
|
}
|
|
t.dismiss();
|
|
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"), ¬ification);
|
|
}
|
|
}
|
|
|
|
// 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 uri = gtk::gio::File::for_path(&dir).uri();
|
|
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
|
&uri,
|
|
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_size(result.total_input_bytes));
|
|
}
|
|
"Output size" => {
|
|
row.set_subtitle(&format_size(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(>k::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 uri = gtk::gio::File::for_path(&dir).uri();
|
|
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
|
&uri,
|
|
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_nanos();
|
|
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) {
|
|
// Clean up clipboard temp files
|
|
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
|
|
if temp_dir.is_dir() {
|
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
|
}
|
|
|
|
// 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 job config to clear preset_mode so wizard steps show up again
|
|
ui.state.job_config.borrow_mut().preset_mode = false;
|
|
*ui.state.output_dir.borrow_mut() = None;
|
|
|
|
// Reset nav
|
|
ui.nav_view.replace(&ui.pages[..1]);
|
|
// Rebuild indicator with all steps
|
|
let all_names = [
|
|
"Workflow", "Images", "Resize", "Adjustments", "Convert",
|
|
"Compress", "Metadata", "Watermark", "Rename", "Output",
|
|
];
|
|
let all: Vec<(usize, String)> = all_names.iter().enumerate()
|
|
.map(|(i, name)| (i, name.to_string()))
|
|
.collect();
|
|
ui.step_indicator.rebuild(&all);
|
|
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");
|
|
}
|
|
|
|
/// Enable or disable navigation actions (prev-step, next-step) to prevent
|
|
/// keyboard shortcuts from navigating away during processing.
|
|
fn set_nav_actions_enabled(nav_view: &adw::NavigationView, enabled: bool) {
|
|
if let Some(root) = nav_view.root() {
|
|
if let Ok(win) = root.downcast::<adw::ApplicationWindow>() {
|
|
for name in ["prev-step", "next-step"] {
|
|
if let Some(action) = win.lookup_action(name) {
|
|
if let Some(simple) = action.downcast_ref::<gtk::gio::SimpleAction>() {
|
|
simple.set_enabled(enabled);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
if current >= total {
|
|
return "Almost done...".into();
|
|
}
|
|
let elapsed = start.elapsed().as_secs_f64();
|
|
let per_image = elapsed / current as f64;
|
|
let remaining = (total.saturating_sub(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 desc_entry = adw::EntryRow::builder()
|
|
.title("Description (optional)")
|
|
.text(&summary)
|
|
.build();
|
|
name_group.add(&desc_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(>k::Image::from_icon_name("user-bookmarks-symbolic"));
|
|
row.add_suffix(>k::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, None);
|
|
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();
|
|
let desc_c = desc_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 desc_text = desc_c.text().to_string();
|
|
let cfg = ui_c.state.job_config.borrow();
|
|
let preset = build_preset_from_config(&cfg, &name, Some(&desc_text));
|
|
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, description: Option<&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 if cfg.resize_mode == 1 {
|
|
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 {
|
|
Some(pixstrip_core::operations::ResizeConfig::Exact(
|
|
pixstrip_core::types::Dimensions {
|
|
width: cfg.resize_width,
|
|
height: cfg.resize_height,
|
|
},
|
|
))
|
|
}
|
|
} 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,
|
|
};
|
|
let wm_rotation = if cfg.watermark_rotation != 0 {
|
|
Some(pixstrip_core::operations::WatermarkRotation::Custom(cfg.watermark_rotation as f32))
|
|
} else {
|
|
None
|
|
};
|
|
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: cfg.watermark_scale / 100.0,
|
|
rotation: wm_rotation,
|
|
tiled: cfg.watermark_tiled,
|
|
margin: cfg.watermark_margin,
|
|
}
|
|
})
|
|
} 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,
|
|
font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) },
|
|
rotation: wm_rotation,
|
|
tiled: cfg.watermark_tiled,
|
|
margin: cfg.watermark_margin,
|
|
})
|
|
} 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,
|
|
counter_enabled: cfg.rename_counter_enabled,
|
|
counter_position: cfg.rename_counter_position,
|
|
template: if cfg.rename_template.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cfg.rename_template.clone())
|
|
},
|
|
case_mode: cfg.rename_case,
|
|
replace_spaces: cfg.rename_replace_spaces,
|
|
special_chars: cfg.rename_special_chars,
|
|
regex_find: cfg.rename_find.clone(),
|
|
regex_replace: cfg.rename_replace.clone(),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
pixstrip_core::preset::Preset {
|
|
name: name.to_string(),
|
|
description: description
|
|
.filter(|d| !d.trim().is_empty())
|
|
.map(|d| d.to_string())
|
|
.unwrap_or_else(|| build_preset_description(cfg)),
|
|
icon: "user-bookmarks-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(>k::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(>k::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(>k::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 dialog = adw::Dialog::builder()
|
|
.title("Keyboard Shortcuts")
|
|
.content_width(420)
|
|
.content_height(480)
|
|
.build();
|
|
|
|
let toolbar_view = adw::ToolbarView::new();
|
|
let header = adw::HeaderBar::new();
|
|
toolbar_view.add_top_bar(&header);
|
|
|
|
let scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.build();
|
|
|
|
let content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.margin_start(16)
|
|
.margin_end(16)
|
|
.margin_top(8)
|
|
.margin_bottom(16)
|
|
.spacing(16)
|
|
.build();
|
|
|
|
let sections: &[(&str, &[(&str, &str)])] = &[
|
|
("Wizard Navigation", &[
|
|
("Alt + Right", "Next step"),
|
|
("Alt + Left", "Previous step"),
|
|
("Alt + 1-9", "Jump to step"),
|
|
("Ctrl + Return", "Process images"),
|
|
("Escape", "Cancel or go back"),
|
|
]),
|
|
("File Management", &[
|
|
("Ctrl + O", "Add files"),
|
|
("Ctrl + V", "Paste image from clipboard"),
|
|
("Ctrl + A", "Select all images"),
|
|
("Ctrl + Shift + A", "Deselect all images"),
|
|
("Delete", "Remove selected images"),
|
|
]),
|
|
("Application", &[
|
|
("Ctrl + ,", "Settings"),
|
|
("F1", "Keyboard shortcuts"),
|
|
("Ctrl + Z", "Undo last batch"),
|
|
("Ctrl + Q", "Quit"),
|
|
]),
|
|
];
|
|
|
|
for (section_title, shortcuts) in sections {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title(*section_title)
|
|
.build();
|
|
|
|
for (accel, description) in *shortcuts {
|
|
let row = adw::ActionRow::builder()
|
|
.title(*description)
|
|
.build();
|
|
let label = gtk::Label::builder()
|
|
.label(*accel)
|
|
.css_classes(["dim-label", "monospace"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
row.add_suffix(&label);
|
|
group.add(&row);
|
|
}
|
|
|
|
content.append(&group);
|
|
}
|
|
|
|
scroll.set_child(Some(&content));
|
|
toolbar_view.set_content(Some(&scroll));
|
|
dialog.set_child(Some(&toolbar_view));
|
|
dialog.present(Some(window));
|
|
}
|
|
|
|
fn apply_accessibility_settings() {
|
|
let config_store = pixstrip_core::storage::ConfigStore::new();
|
|
let config = config_store.load().unwrap_or_default();
|
|
|
|
let Some(settings) = gtk::Settings::default() else {
|
|
return;
|
|
};
|
|
|
|
if config.high_contrast {
|
|
settings.set_gtk_theme_name(Some("HighContrast"));
|
|
}
|
|
|
|
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 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_watch_folder_panel() -> gtk::Box {
|
|
let panel = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(0)
|
|
.build();
|
|
|
|
panel.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(8)
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.build();
|
|
|
|
let header_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(8)
|
|
.build();
|
|
|
|
let header_label = gtk::Label::builder()
|
|
.label("Watch Folders")
|
|
.css_classes(["heading"])
|
|
.hexpand(true)
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
header_box.append(&header_label);
|
|
|
|
// Quick add button
|
|
let add_btn = gtk::Button::builder()
|
|
.icon_name("list-add-symbolic")
|
|
.tooltip_text("Add watch folder")
|
|
.build();
|
|
add_btn.add_css_class("flat");
|
|
header_box.append(&add_btn);
|
|
|
|
inner.append(&header_box);
|
|
|
|
// List of active watch folders
|
|
let list_box = gtk::ListBox::builder()
|
|
.selection_mode(gtk::SelectionMode::None)
|
|
.css_classes(["boxed-list"])
|
|
.build();
|
|
|
|
let empty_label = gtk::Label::builder()
|
|
.label("No watch folders active. Click + to add one.")
|
|
.css_classes(["dim-label", "caption"])
|
|
.halign(gtk::Align::Center)
|
|
.wrap(true)
|
|
.margin_top(4)
|
|
.margin_bottom(4)
|
|
.build();
|
|
|
|
// Populate from config
|
|
let config_store = pixstrip_core::storage::ConfigStore::new();
|
|
let config = config_store.load().unwrap_or_default();
|
|
let has_folders = !config.watch_folders.is_empty();
|
|
|
|
for folder in &config.watch_folders {
|
|
if !folder.active {
|
|
continue;
|
|
}
|
|
let display_name = folder.path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or_else(|| folder.path.to_str().unwrap_or("Unknown"));
|
|
|
|
let row = adw::ActionRow::builder()
|
|
.title(display_name)
|
|
.subtitle(&folder.preset_name)
|
|
.build();
|
|
row.add_prefix(>k::Image::from_icon_name("folder-visiting-symbolic"));
|
|
|
|
// Status indicator
|
|
let status = gtk::Label::builder()
|
|
.label("Watching")
|
|
.css_classes(["caption", "accent"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
row.add_suffix(&status);
|
|
|
|
list_box.append(&row);
|
|
}
|
|
|
|
empty_label.set_visible(!has_folders || config.watch_folders.iter().all(|f| !f.active));
|
|
list_box.set_visible(has_folders && config.watch_folders.iter().any(|f| f.active));
|
|
|
|
inner.append(&list_box);
|
|
inner.append(&empty_label);
|
|
|
|
// Wire add button to open a folder chooser directly
|
|
{
|
|
let list_box_c = list_box.clone();
|
|
let empty_label_c = empty_label.clone();
|
|
add_btn.connect_clicked(move |btn| {
|
|
let list_box_c = list_box_c.clone();
|
|
let empty_label_c = empty_label_c.clone();
|
|
let dialog = gtk::FileDialog::builder()
|
|
.title("Choose Watch Folder")
|
|
.modal(true)
|
|
.build();
|
|
|
|
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
|
dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
|
if let Ok(file) = result
|
|
&& let Some(path) = file.path()
|
|
{
|
|
let new_folder = pixstrip_core::watcher::WatchFolder {
|
|
path: path.clone(),
|
|
preset_name: "Blog Photos".to_string(),
|
|
recursive: false,
|
|
active: true,
|
|
};
|
|
|
|
// Save to config
|
|
let config_store = pixstrip_core::storage::ConfigStore::new();
|
|
let mut config = config_store.load().unwrap_or_default();
|
|
// Avoid duplicates
|
|
if !config.watch_folders.iter().any(|f| f.path == new_folder.path) {
|
|
config.watch_folders.push(new_folder.clone());
|
|
let _ = config_store.save(&config);
|
|
}
|
|
|
|
// Add row to the panel list
|
|
let display_name = path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or_else(|| path.to_str().unwrap_or("Unknown"))
|
|
.to_string();
|
|
|
|
let row = adw::ActionRow::builder()
|
|
.title(&display_name)
|
|
.subtitle(&new_folder.preset_name)
|
|
.build();
|
|
row.add_prefix(>k::Image::from_icon_name("folder-visiting-symbolic"));
|
|
|
|
let status = gtk::Label::builder()
|
|
.label("Watching")
|
|
.css_classes(["caption", "accent"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
row.add_suffix(&status);
|
|
|
|
list_box_c.append(&row);
|
|
list_box_c.set_visible(true);
|
|
empty_label_c.set_visible(false);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
panel.append(&inner);
|
|
panel
|
|
}
|
|
|
|
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 batch_name = batch.name.clone();
|
|
remove_btn.connect_clicked(move |_| {
|
|
let mut q = queue_ref.borrow_mut();
|
|
if let Some(pos) = q.batches.iter().position(|b| b.name == batch_name && b.status == BatchStatus::Pending) {
|
|
q.batches.remove(pos);
|
|
}
|
|
drop(q);
|
|
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);
|
|
}
|