Load application CSS at startup with rounded corners for thumbnail frames and grid padding.
2355 lines
83 KiB
Rust
2355 lines
83 KiB
Rust
use adw::prelude::*;
|
|
use gtk::glib;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use crate::step_indicator::StepIndicator;
|
|
use crate::wizard::WizardState;
|
|
|
|
pub const APP_ID: &str = "live.lashman.Pixstrip";
|
|
|
|
/// User's choices from the wizard steps, used to build the ProcessingJob
|
|
#[derive(Clone, Debug)]
|
|
pub struct JobConfig {
|
|
// Resize
|
|
pub resize_enabled: bool,
|
|
pub resize_width: u32,
|
|
pub resize_height: u32,
|
|
pub allow_upscale: bool,
|
|
// Adjustments
|
|
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>,
|
|
// Compress
|
|
pub compress_enabled: bool,
|
|
pub quality_preset: pixstrip_core::types::QualityPreset,
|
|
pub jpeg_quality: u8,
|
|
pub png_level: u8,
|
|
pub webp_quality: 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_use_image: bool,
|
|
// Rename
|
|
pub rename_enabled: bool,
|
|
pub rename_prefix: String,
|
|
pub rename_suffix: String,
|
|
pub rename_counter_start: u32,
|
|
pub rename_counter_padding: u32,
|
|
pub rename_template: String,
|
|
// Output
|
|
pub preserve_dir_structure: bool,
|
|
pub overwrite_behavior: u8,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub enum MetadataMode {
|
|
#[default]
|
|
StripAll,
|
|
Privacy,
|
|
KeepAll,
|
|
Custom,
|
|
}
|
|
|
|
/// Shared app state accessible from all UI callbacks
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub wizard: Rc<RefCell<WizardState>>,
|
|
pub loaded_files: Rc<RefCell<Vec<std::path::PathBuf>>>,
|
|
pub excluded_files: Rc<RefCell<std::collections::HashSet<std::path::PathBuf>>>,
|
|
pub output_dir: Rc<RefCell<Option<std::path::PathBuf>>>,
|
|
pub job_config: Rc<RefCell<JobConfig>>,
|
|
}
|
|
|
|
#[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,
|
|
state: AppState,
|
|
}
|
|
|
|
pub fn build_app() -> adw::Application {
|
|
let app = adw::Application::builder()
|
|
.application_id(APP_ID)
|
|
.build();
|
|
|
|
app.connect_activate(build_ui);
|
|
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 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)),
|
|
job_config: Rc::new(RefCell::new(JobConfig {
|
|
resize_enabled: if remember { sess_state.resize_enabled.unwrap_or(true) } else { true },
|
|
resize_width: if remember { sess_state.resize_width.unwrap_or(1200) } else { 1200 },
|
|
resize_height: if remember { sess_state.resize_height.unwrap_or(0) } else { 0 },
|
|
allow_upscale: false,
|
|
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: None,
|
|
compress_enabled: if remember { sess_state.compress_enabled.unwrap_or(true) } else { true },
|
|
quality_preset: pixstrip_core::types::QualityPreset::Medium,
|
|
jpeg_quality: 85,
|
|
png_level: 3,
|
|
webp_quality: 80,
|
|
metadata_enabled: if remember { sess_state.metadata_enabled.unwrap_or(true) } else { true },
|
|
metadata_mode: 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_use_image: false,
|
|
rename_enabled: if remember { sess_state.rename_enabled.unwrap_or(false) } else { false },
|
|
rename_prefix: String::new(),
|
|
rename_suffix: String::new(),
|
|
rename_counter_start: 1,
|
|
rename_counter_padding: 3,
|
|
rename_template: String::new(),
|
|
preserve_dir_structure: false,
|
|
overwrite_behavior: 0,
|
|
})),
|
|
};
|
|
|
|
// Header bar
|
|
let header = adw::HeaderBar::new();
|
|
let title = adw::WindowTitle::new("Pixstrip", "Batch Image Processor");
|
|
header.set_title_widget(Some(&title));
|
|
|
|
// 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);
|
|
|
|
// Build wizard pages
|
|
let pages = crate::wizard::build_wizard_pages(&app_state);
|
|
for page in &pages {
|
|
nav_view.add(page);
|
|
}
|
|
|
|
// Bottom action bar
|
|
let back_button = gtk::Button::builder()
|
|
.label("Back")
|
|
.tooltip_text("Go to previous step (Alt+Left)")
|
|
.build();
|
|
back_button.add_css_class("flat");
|
|
|
|
let next_button = gtk::Button::builder()
|
|
.label("Next")
|
|
.tooltip_text("Go to next step (Alt+Right)")
|
|
.build();
|
|
next_button.add_css_class("suggested-action");
|
|
|
|
let bottom_box = gtk::CenterBox::new();
|
|
bottom_box.set_start_widget(Some(&back_button));
|
|
bottom_box.set_end_widget(Some(&next_button));
|
|
bottom_box.set_margin_start(12);
|
|
bottom_box.set_margin_end(12);
|
|
bottom_box.set_margin_top(6);
|
|
bottom_box.set_margin_bottom(6);
|
|
|
|
let bottom_bar = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
|
bottom_bar.append(&separator);
|
|
bottom_bar.append(&bottom_box);
|
|
|
|
// Main content layout
|
|
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
content_box.append(step_indicator.widget());
|
|
content_box.append(&nav_view);
|
|
|
|
// Toolbar view with header and bottom bar
|
|
let toolbar_view = adw::ToolbarView::new();
|
|
toolbar_view.add_top_bar(&header);
|
|
toolbar_view.set_content(Some(&content_box));
|
|
toolbar_view.add_bottom_bar(&bottom_bar);
|
|
|
|
// Toast overlay wraps everything for in-app notifications
|
|
let toast_overlay = adw::ToastOverlay::new();
|
|
toast_overlay.set_child(Some(&toolbar_view));
|
|
|
|
// Restore window size from session
|
|
let session = pixstrip_core::storage::SessionStore::new();
|
|
let session_state = session.load().unwrap_or_default();
|
|
let win_width = session_state.window_width.unwrap_or(900);
|
|
let win_height = session_state.window_height.unwrap_or(700);
|
|
|
|
let window = adw::ApplicationWindow::builder()
|
|
.application(app)
|
|
.default_width(win_width)
|
|
.default_height(win_height)
|
|
.content(&toast_overlay)
|
|
.title("Pixstrip")
|
|
.build();
|
|
|
|
if session_state.window_maximized {
|
|
window.maximize();
|
|
}
|
|
|
|
// Save window size and wizard settings on close
|
|
{
|
|
let app_state_for_close = app_state.clone();
|
|
window.connect_close_request(move |win| {
|
|
let session = pixstrip_core::storage::SessionStore::new();
|
|
let mut state = session.load().unwrap_or_default();
|
|
state.window_maximized = win.is_maximized();
|
|
if !win.is_maximized() {
|
|
let (w, h) = (win.default_size().0, win.default_size().1);
|
|
if w > 0 && h > 0 {
|
|
state.window_width = Some(w);
|
|
state.window_height = Some(h);
|
|
}
|
|
}
|
|
|
|
// Save last-used wizard settings if remember_settings is enabled
|
|
let config_store = pixstrip_core::storage::ConfigStore::new();
|
|
let config = config_store.load().unwrap_or_default();
|
|
if config.remember_settings {
|
|
let cfg = app_state_for_close.job_config.borrow();
|
|
state.resize_enabled = Some(cfg.resize_enabled);
|
|
state.resize_width = Some(cfg.resize_width);
|
|
state.resize_height = Some(cfg.resize_height);
|
|
state.convert_enabled = Some(cfg.convert_enabled);
|
|
state.convert_format = cfg.convert_format.map(|f| format!("{:?}", f));
|
|
state.compress_enabled = Some(cfg.compress_enabled);
|
|
state.quality_preset = Some(format!("{:?}", cfg.quality_preset));
|
|
state.metadata_enabled = Some(cfg.metadata_enabled);
|
|
state.metadata_mode = Some(format!("{:?}", cfg.metadata_mode));
|
|
state.watermark_enabled = Some(cfg.watermark_enabled);
|
|
state.rename_enabled = Some(cfg.rename_enabled);
|
|
}
|
|
|
|
let _ = session.save(&state);
|
|
glib::Propagation::Proceed
|
|
});
|
|
}
|
|
|
|
let ui = WizardUi {
|
|
nav_view,
|
|
step_indicator,
|
|
back_button,
|
|
next_button,
|
|
title,
|
|
pages,
|
|
toast_overlay,
|
|
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);
|
|
}
|
|
|
|
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
|
|
{
|
|
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() {
|
|
s.go_next();
|
|
let idx = s.current_step;
|
|
drop(s);
|
|
navigate_to_step(&ui, idx);
|
|
}
|
|
});
|
|
action_group.add_action(&action);
|
|
}
|
|
|
|
// Previous step action
|
|
{
|
|
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() {
|
|
s.go_back();
|
|
let idx = s.current_step;
|
|
drop(s);
|
|
navigate_to_step(&ui, idx);
|
|
}
|
|
});
|
|
action_group.add_action(&action);
|
|
}
|
|
|
|
// Go to specific step action
|
|
{
|
|
let ui = ui.clone();
|
|
let action = gtk::gio::SimpleAction::new(
|
|
"goto-step",
|
|
Some(&i32::static_variant_type()),
|
|
);
|
|
action.connect_activate(move |_, param| {
|
|
if let Some(step) = param.and_then(|p| p.get::<i32>()) {
|
|
let target = (step - 1) as usize;
|
|
let s = ui.state.wizard.borrow();
|
|
if target < s.total_steps && s.visited[target] {
|
|
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 |_| {
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
|
|
window.insert_action_group("win", Some(&action_group));
|
|
}
|
|
|
|
fn navigate_to_step(ui: &WizardUi, target: usize) {
|
|
let s = ui.state.wizard.borrow();
|
|
|
|
// Update step indicator
|
|
ui.step_indicator.set_current(target);
|
|
for (i, visited) in s.visited.iter().enumerate() {
|
|
if *visited && i != target {
|
|
ui.step_indicator.set_completed(i);
|
|
}
|
|
}
|
|
|
|
// Navigate - replace entire stack up to target
|
|
if target < ui.pages.len() {
|
|
ui.nav_view.replace(&ui.pages[..=target]);
|
|
ui.title.set_subtitle(&ui.pages[target].title());
|
|
|
|
// Focus management - move focus to first interactive element on new step
|
|
// Use idle callback to let the page fully render first
|
|
let page = ui.pages[target].clone();
|
|
glib::idle_add_local_once(move || {
|
|
page.child_focus(gtk::DirectionType::TabForward);
|
|
});
|
|
}
|
|
|
|
// Update dynamic content on certain steps
|
|
if target == 9 {
|
|
// Output step - update image count, total size, and operation summary
|
|
let files = ui.state.loaded_files.borrow();
|
|
let excluded = ui.state.excluded_files.borrow();
|
|
let included_count = files.iter().filter(|p| !excluded.contains(*p)).count();
|
|
let total_size: u64 = files.iter()
|
|
.filter(|p| !excluded.contains(*p))
|
|
.filter_map(|p| std::fs::metadata(p).ok())
|
|
.map(|m| m.len())
|
|
.sum();
|
|
drop(excluded);
|
|
drop(files);
|
|
if let Some(page) = ui.pages.get(9) {
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(row) = widget.downcast_ref::<adw::ActionRow>()
|
|
&& row.title().as_str() == "Images to process"
|
|
{
|
|
row.set_subtitle(&format!("{} images ({})", included_count, format_bytes(total_size)));
|
|
}
|
|
});
|
|
}
|
|
update_output_summary(ui);
|
|
}
|
|
|
|
update_nav_buttons(&s, &ui.back_button, &ui.next_button);
|
|
}
|
|
|
|
fn update_nav_buttons(state: &WizardState, back_button: >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"));
|
|
filter.add_mime_type("image/jpeg");
|
|
filter.add_mime_type("image/png");
|
|
filter.add_mime_type("image/webp");
|
|
filter.add_mime_type("image/avif");
|
|
filter.add_mime_type("image/gif");
|
|
filter.add_mime_type("image/tiff");
|
|
filter.add_mime_type("image/bmp");
|
|
|
|
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
|
|
filters.append(&filter);
|
|
dialog.set_filters(Some(&filters));
|
|
dialog.set_default_filter(Some(&filter));
|
|
|
|
let ui = ui.clone();
|
|
dialog.open_multiple(Some(window), gtk::gio::Cancellable::NONE, move |result| {
|
|
if let Ok(files) = result {
|
|
let mut paths = ui.state.loaded_files.borrow_mut();
|
|
for i in 0..files.n_items() {
|
|
if let Some(file) = files.item(i)
|
|
&& let Some(gfile) = file.downcast_ref::<gtk::gio::File>()
|
|
&& let Some(path) = gfile.path()
|
|
&& !paths.contains(&path)
|
|
{
|
|
paths.push(path);
|
|
}
|
|
}
|
|
let count = paths.len();
|
|
drop(paths);
|
|
|
|
// Update the images step UI if we can find the label
|
|
update_images_count_label(&ui, count);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn open_output_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
|
let dialog = gtk::FileDialog::builder()
|
|
.title("Choose Output Folder")
|
|
.modal(true)
|
|
.build();
|
|
|
|
let ui = ui.clone();
|
|
dialog.select_folder(Some(window), gtk::gio::Cancellable::NONE, move |result| {
|
|
if let Ok(folder) = result
|
|
&& let Some(path) = folder.path()
|
|
{
|
|
*ui.state.output_dir.borrow_mut() = Some(path.clone());
|
|
update_output_label(&ui, &path);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn update_output_label(ui: &WizardUi, path: &std::path::Path) {
|
|
// Find the output step page (index 9) and update the output location subtitle
|
|
if let Some(page) = ui.pages.get(9) {
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(row) = widget.downcast_ref::<adw::ActionRow>()
|
|
&& row.title().as_str() == "Output Location"
|
|
{
|
|
row.set_subtitle(&path.display().to_string());
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn update_images_count_label(ui: &WizardUi, count: usize) {
|
|
let files = ui.state.loaded_files.borrow();
|
|
let excluded = ui.state.excluded_files.borrow();
|
|
let included_count = files.iter().filter(|p| !excluded.contains(*p)).count();
|
|
let total_size: u64 = files.iter()
|
|
.filter(|p| !excluded.contains(*p))
|
|
.filter_map(|p| std::fs::metadata(p).ok())
|
|
.map(|m| m.len())
|
|
.sum();
|
|
|
|
// Find the step-images page and switch its stack to "loaded" if we have files
|
|
if let Some(page) = ui.pages.get(1)
|
|
&& let Some(stack) = page.child().and_downcast::<gtk::Stack>()
|
|
{
|
|
if count > 0 {
|
|
stack.set_visible_child_name("loaded");
|
|
} else {
|
|
stack.set_visible_child_name("empty");
|
|
}
|
|
if let Some(loaded_box) = stack.child_by_name("loaded") {
|
|
update_count_in_box(&loaded_box, count, included_count, total_size);
|
|
update_file_list(&loaded_box, &files, &excluded);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_count_in_box(widget: >k::Widget, count: usize, included_count: usize, total_size: u64) {
|
|
if let Some(label) = widget.downcast_ref::<gtk::Label>()
|
|
&& label.css_classes().iter().any(|c| c == "heading")
|
|
{
|
|
if included_count == count {
|
|
label.set_label(&format!("{} images ({})", count, format_bytes(total_size)));
|
|
} else {
|
|
label.set_label(&format!("{}/{} images selected ({})", included_count, count, format_bytes(total_size)));
|
|
}
|
|
return;
|
|
}
|
|
let mut child = widget.first_child();
|
|
while let Some(c) = child {
|
|
update_count_in_box(&c, count, included_count, total_size);
|
|
child = c.next_sibling();
|
|
}
|
|
}
|
|
|
|
fn update_file_list(widget: >k::Widget, files: &[std::path::PathBuf], excluded: &std::collections::HashSet<std::path::PathBuf>) {
|
|
if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>()
|
|
&& list_box.css_classes().iter().any(|c| c == "boxed-list")
|
|
{
|
|
while let Some(child) = list_box.first_child() {
|
|
list_box.remove(&child);
|
|
}
|
|
for path in files {
|
|
let name = path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("unknown");
|
|
let size = std::fs::metadata(path)
|
|
.map(|m| format_bytes(m.len()))
|
|
.unwrap_or_default();
|
|
let ext = path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_uppercase();
|
|
let row = adw::ActionRow::builder()
|
|
.title(name)
|
|
.subtitle(format!("{} - {}", ext, size))
|
|
.build();
|
|
let check = gtk::CheckButton::builder()
|
|
.active(!excluded.contains(path))
|
|
.tooltip_text("Include in processing")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
row.add_prefix(&check);
|
|
row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
|
list_box.append(&row);
|
|
}
|
|
return;
|
|
}
|
|
let mut child = widget.first_child();
|
|
while let Some(c) = child {
|
|
update_file_list(&c, files, excluded);
|
|
child = c.next_sibling();
|
|
}
|
|
}
|
|
|
|
fn show_history_dialog(window: &adw::ApplicationWindow) {
|
|
let dialog = adw::Dialog::builder()
|
|
.title("Processing History")
|
|
.content_width(500)
|
|
.content_height(400)
|
|
.build();
|
|
|
|
let toolbar_view = adw::ToolbarView::new();
|
|
let header = adw::HeaderBar::new();
|
|
toolbar_view.add_top_bar(&header);
|
|
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
let content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(0)
|
|
.build();
|
|
|
|
let history = pixstrip_core::storage::HistoryStore::new();
|
|
match history.list() {
|
|
Ok(entries) if entries.is_empty() => {
|
|
let empty = adw::StatusPage::builder()
|
|
.title("No History Yet")
|
|
.description("Processed batches will appear here")
|
|
.icon_name("document-open-recent-symbolic")
|
|
.vexpand(true)
|
|
.build();
|
|
content.append(&empty);
|
|
}
|
|
Ok(entries) => {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Recent Batches")
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.margin_top(12)
|
|
.build();
|
|
|
|
for entry in entries.iter().rev() {
|
|
let savings = if entry.total_input_bytes > 0 {
|
|
let pct = (1.0
|
|
- entry.total_output_bytes as f64 / entry.total_input_bytes as f64)
|
|
* 100.0;
|
|
format!("{:.0}% saved", pct)
|
|
} else {
|
|
"N/A".into()
|
|
};
|
|
|
|
let subtitle = format!(
|
|
"{}/{} succeeded - {} - {}",
|
|
entry.succeeded,
|
|
entry.total,
|
|
savings,
|
|
format_duration(entry.elapsed_ms)
|
|
);
|
|
|
|
let preset_label = entry
|
|
.preset_name
|
|
.as_deref()
|
|
.unwrap_or("Custom workflow");
|
|
|
|
let row = adw::ActionRow::builder()
|
|
.title(preset_label)
|
|
.subtitle(&subtitle)
|
|
.build();
|
|
row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
|
|
|
// 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)));
|
|
});
|
|
row.add_suffix(&undo_btn);
|
|
}
|
|
|
|
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 = files[0]
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.to_path_buf();
|
|
|
|
let output_dir = ui
|
|
.state
|
|
.output_dir
|
|
.borrow()
|
|
.clone()
|
|
.unwrap_or_else(|| input_dir.join("processed"));
|
|
|
|
// Build job from wizard settings
|
|
let mut job = pixstrip_core::pipeline::ProcessingJob::new(&input_dir, &output_dir);
|
|
|
|
let cfg = ui.state.job_config.borrow();
|
|
|
|
if cfg.resize_enabled && cfg.resize_width > 0 {
|
|
let target_w = cfg.resize_width;
|
|
let target_h = cfg.resize_height;
|
|
if target_h == 0 {
|
|
job.resize = Some(pixstrip_core::operations::ResizeConfig::ByWidth(target_w));
|
|
} else {
|
|
job.resize = Some(pixstrip_core::operations::ResizeConfig::FitInBox {
|
|
max: pixstrip_core::types::Dimensions { width: target_w, height: target_h },
|
|
allow_upscale: cfg.allow_upscale,
|
|
});
|
|
}
|
|
}
|
|
|
|
if cfg.convert_enabled && let Some(fmt) = cfg.convert_format {
|
|
job.convert = Some(pixstrip_core::operations::ConvertConfig::SingleFormat(fmt));
|
|
}
|
|
|
|
if cfg.compress_enabled {
|
|
job.compress = Some(pixstrip_core::operations::CompressConfig::Preset(cfg.quality_preset));
|
|
}
|
|
|
|
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
|
|
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,
|
|
});
|
|
|
|
// Flip
|
|
job.flip = Some(match cfg.flip {
|
|
1 => pixstrip_core::operations::Flip::Horizontal,
|
|
2 => pixstrip_core::operations::Flip::Vertical,
|
|
_ => pixstrip_core::operations::Flip::None,
|
|
});
|
|
|
|
// Watermark
|
|
if cfg.watermark_enabled {
|
|
let position = match cfg.watermark_position {
|
|
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
|
|
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
|
|
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
|
|
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
|
|
4 => pixstrip_core::operations::WatermarkPosition::Center,
|
|
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
|
|
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
|
|
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
|
|
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
|
|
};
|
|
|
|
if cfg.watermark_use_image {
|
|
if let Some(ref path) = cfg.watermark_image_path {
|
|
job.watermark = Some(pixstrip_core::operations::WatermarkConfig::Image {
|
|
path: path.clone(),
|
|
position,
|
|
opacity: cfg.watermark_opacity,
|
|
scale: 0.2,
|
|
});
|
|
}
|
|
} else if !cfg.watermark_text.is_empty() {
|
|
job.watermark = Some(pixstrip_core::operations::WatermarkConfig::Text {
|
|
text: cfg.watermark_text.clone(),
|
|
position,
|
|
font_size: cfg.watermark_font_size,
|
|
opacity: cfg.watermark_opacity,
|
|
color: [255, 255, 255, 255],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Rename
|
|
if cfg.rename_enabled {
|
|
job.rename = Some(pixstrip_core::operations::RenameConfig {
|
|
prefix: cfg.rename_prefix.clone(),
|
|
suffix: cfg.rename_suffix.clone(),
|
|
counter_start: cfg.rename_counter_start,
|
|
counter_padding: cfg.rename_counter_padding,
|
|
template: if cfg.rename_template.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cfg.rename_template.clone())
|
|
},
|
|
});
|
|
}
|
|
|
|
job.preserve_directory_structure = cfg.preserve_dir_structure;
|
|
|
|
drop(cfg);
|
|
|
|
for file in &files {
|
|
job.add_source(file);
|
|
}
|
|
|
|
// Build processing UI inline in the nav_view
|
|
let processing_page = crate::processing::build_processing_page();
|
|
ui.nav_view.push(&processing_page);
|
|
|
|
// Hide bottom nav buttons during processing
|
|
ui.back_button.set_visible(false);
|
|
ui.next_button.set_visible(false);
|
|
ui.title.set_subtitle("Processing...");
|
|
|
|
// Get references to progress widgets inside the page
|
|
let progress_bar = find_widget_by_type::<gtk::ProgressBar>(&processing_page);
|
|
let cancel_flag = Arc::new(AtomicBool::new(false));
|
|
|
|
let pause_flag = Arc::new(AtomicBool::new(false));
|
|
|
|
// Find cancel button and wire it; also wire pause button
|
|
wire_cancel_button(&processing_page, cancel_flag.clone());
|
|
wire_pause_button(&processing_page, pause_flag.clone());
|
|
|
|
// Run processing in a background thread
|
|
let (tx, rx) = std::sync::mpsc::channel::<ProcessingMessage>();
|
|
|
|
let cancel = cancel_flag.clone();
|
|
let pause = pause_flag.clone();
|
|
// Load processing settings from config
|
|
let cfg_store = pixstrip_core::storage::ConfigStore::new();
|
|
let app_cfg = cfg_store.load().unwrap_or_default();
|
|
std::thread::spawn(move || {
|
|
let mut executor = pixstrip_core::executor::PipelineExecutor::with_cancel_and_pause(cancel, pause);
|
|
match app_cfg.thread_count {
|
|
pixstrip_core::config::ThreadCount::Auto => executor.set_thread_count(0),
|
|
pixstrip_core::config::ThreadCount::Manual(n) => executor.set_thread_count(n),
|
|
}
|
|
executor.set_pause_on_error(app_cfg.error_behavior == pixstrip_core::config::ErrorBehavior::PauseOnError);
|
|
let result = executor.execute(&job, |update| {
|
|
let _ = tx.send(ProcessingMessage::Progress {
|
|
current: update.current,
|
|
total: update.total,
|
|
file: update.current_file,
|
|
});
|
|
});
|
|
match result {
|
|
Ok(r) => {
|
|
let _ = tx.send(ProcessingMessage::Done(r));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx.send(ProcessingMessage::Error(e.to_string()));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Poll for messages from the processing thread
|
|
let ui_for_rx = ui.clone();
|
|
let start_time = std::time::Instant::now();
|
|
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
|
|
while let Ok(msg) = rx.try_recv() {
|
|
match msg {
|
|
ProcessingMessage::Progress {
|
|
current,
|
|
total,
|
|
file,
|
|
} => {
|
|
if let Some(ref bar) = progress_bar {
|
|
let frac = current as f64 / total as f64;
|
|
bar.set_fraction(frac);
|
|
bar.set_text(Some(&format!("{}/{} - {}", current, total, file)));
|
|
bar.update_property(&[
|
|
gtk::accessible::Property::ValueNow(frac * 100.0),
|
|
gtk::accessible::Property::ValueText(
|
|
&format!("Processing {} of {}: {}", current, total, file)
|
|
),
|
|
]);
|
|
}
|
|
let eta = calculate_eta(&start_time, current, total);
|
|
update_progress_labels(&ui_for_rx.nav_view, current, total, &file, &eta);
|
|
add_log_entry(&ui_for_rx.nav_view, current, total, &file);
|
|
}
|
|
ProcessingMessage::Done(result) => {
|
|
show_results(&ui_for_rx, &result);
|
|
return glib::ControlFlow::Break;
|
|
}
|
|
ProcessingMessage::Error(err) => {
|
|
let toast = adw::Toast::new(&format!("Processing failed: {}", err));
|
|
ui_for_rx.toast_overlay.add_toast(toast);
|
|
ui_for_rx.back_button.set_visible(true);
|
|
ui_for_rx.next_button.set_visible(true);
|
|
if let Some(visible) = ui_for_rx.nav_view.visible_page()
|
|
&& visible.tag().as_deref() == Some("processing")
|
|
{
|
|
ui_for_rx.nav_view.pop();
|
|
}
|
|
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.back_button.set_visible(false);
|
|
ui.next_button.set_label("Process More");
|
|
ui.next_button.set_visible(true);
|
|
|
|
// Save history with output paths for undo support
|
|
let history = pixstrip_core::storage::HistoryStore::new();
|
|
let output_dir_str = ui.state.output_dir.borrow()
|
|
.as_ref()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_default();
|
|
let input_dir_str = ui.state.loaded_files.borrow()
|
|
.first()
|
|
.and_then(|p| p.parent())
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_default();
|
|
|
|
// Collect actual output files from the output directory
|
|
let output_files: Vec<String> = if let Some(ref dir) = *ui.state.output_dir.borrow() {
|
|
std::fs::read_dir(dir)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.path().display().to_string())
|
|
.collect()
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
let _ = history.add(pixstrip_core::storage::HistoryEntry {
|
|
timestamp: format!(
|
|
"{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
),
|
|
input_dir: input_dir_str,
|
|
output_dir: output_dir_str,
|
|
preset_name: None,
|
|
total: result.total,
|
|
succeeded: result.succeeded,
|
|
failed: result.failed,
|
|
total_input_bytes: result.total_input_bytes,
|
|
total_output_bytes: result.total_output_bytes,
|
|
elapsed_ms: result.elapsed_ms,
|
|
output_files,
|
|
});
|
|
|
|
// Show toast
|
|
let savings = if result.total_input_bytes > 0 {
|
|
let pct =
|
|
(1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0;
|
|
format!(
|
|
"{} images processed, {:.0}% space saved",
|
|
result.succeeded, pct
|
|
)
|
|
} else {
|
|
format!("{} images processed", result.succeeded)
|
|
};
|
|
// Undo toast with savings info
|
|
let undo_toast = adw::Toast::new(&savings);
|
|
undo_toast.set_button_label(Some("Undo"));
|
|
undo_toast.set_timeout(10);
|
|
{
|
|
let output_dir = ui.state.output_dir.borrow().clone();
|
|
undo_toast.connect_button_clicked(move |t| {
|
|
if let Some(ref dir) = output_dir {
|
|
let mut trashed = 0;
|
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
for entry in entries.flatten() {
|
|
let gfile = gtk::gio::File::for_path(entry.path());
|
|
if gfile.trash(gtk::gio::Cancellable::NONE).is_ok() {
|
|
trashed += 1;
|
|
}
|
|
}
|
|
}
|
|
t.dismiss();
|
|
// Will show a new toast from the caller
|
|
let _ = trashed;
|
|
}
|
|
});
|
|
}
|
|
ui.toast_overlay.add_toast(undo_toast);
|
|
|
|
// Desktop notification (if enabled in settings)
|
|
let config_store = pixstrip_core::storage::ConfigStore::new();
|
|
let config = config_store.load().unwrap_or_default();
|
|
if config.notify_on_completion {
|
|
let notification = gtk::gio::Notification::new("Pixstrip - Processing Complete");
|
|
notification.set_body(Some(&savings));
|
|
notification.set_priority(gtk::gio::NotificationPriority::Normal);
|
|
if let Some(app) = gtk::gio::Application::default() {
|
|
app.send_notification(Some("batch-complete"), ¬ification);
|
|
}
|
|
}
|
|
|
|
// Auto-open output folder if enabled
|
|
if config.auto_open_output {
|
|
let output = ui.state.output_dir.borrow().clone();
|
|
if let Some(dir) = output {
|
|
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
|
&format!("file://{}", dir.display()),
|
|
gtk::gio::AppLaunchContext::NONE,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_results_stats(
|
|
page: &adw::NavigationPage,
|
|
result: &pixstrip_core::executor::BatchResult,
|
|
) {
|
|
// Walk the widget tree looking for ActionRows to update
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
|
|
let title = row.title();
|
|
match title.as_str() {
|
|
"Images processed" => {
|
|
row.set_subtitle(&format!("{} images", result.succeeded));
|
|
}
|
|
"Original size" => {
|
|
row.set_subtitle(&format_bytes(result.total_input_bytes));
|
|
}
|
|
"Output size" => {
|
|
row.set_subtitle(&format_bytes(result.total_output_bytes));
|
|
}
|
|
"Space saved" => {
|
|
if result.total_input_bytes > 0 {
|
|
let pct = (1.0
|
|
- result.total_output_bytes as f64
|
|
/ result.total_input_bytes as f64)
|
|
* 100.0;
|
|
row.set_subtitle(&format!("{:.1}%", pct));
|
|
}
|
|
}
|
|
"Processing time" => {
|
|
row.set_subtitle(&format_duration(result.elapsed_ms));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn update_results_errors(page: &adw::NavigationPage, errors: &[(String, String)]) {
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(group) = widget.downcast_ref::<adw::PreferencesGroup>()
|
|
&& group.title().as_str() == "Errors"
|
|
{
|
|
group.set_visible(true);
|
|
}
|
|
if let Some(expander) = widget.downcast_ref::<adw::ExpanderRow>()
|
|
&& expander.title().contains("errors")
|
|
{
|
|
expander.set_title(&format!("{} errors occurred", errors.len()));
|
|
for (file, err) in errors {
|
|
let row = adw::ActionRow::builder()
|
|
.title(file)
|
|
.subtitle(err)
|
|
.build();
|
|
row.add_prefix(>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 _ = gtk::gio::AppInfo::launch_default_for_uri(
|
|
&format!("file://{}", dir.display()),
|
|
gtk::gio::AppLaunchContext::NONE,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
"Process Another Batch" => {
|
|
let ui = ui.clone();
|
|
row.connect_activated(move |_| {
|
|
reset_wizard(&ui);
|
|
});
|
|
}
|
|
"Save as Preset" => {
|
|
row.set_action_name(Some("win.save-preset"));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn undo_last_batch(ui: &WizardUi) {
|
|
let history = pixstrip_core::storage::HistoryStore::new();
|
|
let entries = match history.list() {
|
|
Ok(e) => e,
|
|
Err(_) => {
|
|
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let Some(last) = entries.last() else {
|
|
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo"));
|
|
return;
|
|
};
|
|
|
|
if last.output_files.is_empty() {
|
|
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch"));
|
|
return;
|
|
}
|
|
|
|
// Move output files to trash using GIO
|
|
let mut trashed = 0usize;
|
|
for path_str in &last.output_files {
|
|
let file = gtk::gio::File::for_path(path_str);
|
|
if file.trash(gtk::gio::Cancellable::NONE).is_ok() {
|
|
trashed += 1;
|
|
}
|
|
}
|
|
|
|
let toast = adw::Toast::new(&format!(
|
|
"Undo: moved {} files to trash",
|
|
trashed
|
|
));
|
|
toast.set_timeout(5);
|
|
ui.toast_overlay.add_toast(toast);
|
|
}
|
|
|
|
fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
|
let clipboard = window.clipboard();
|
|
let ui = ui.clone();
|
|
|
|
// Try to read a texture (image) from clipboard
|
|
clipboard.read_texture_async(gtk::gio::Cancellable::NONE, move |result| {
|
|
if let Ok(Some(texture)) = result {
|
|
// Save the texture to a temp file
|
|
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
|
|
if std::fs::create_dir_all(&temp_dir).is_err() {
|
|
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory"));
|
|
return;
|
|
}
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis();
|
|
let temp_path = temp_dir.join(format!("clipboard-{}.png", timestamp));
|
|
|
|
let bytes = texture.save_to_png_bytes();
|
|
if std::fs::write(&temp_path, bytes.as_ref()).is_ok() {
|
|
let mut files = ui.state.loaded_files.borrow_mut();
|
|
if !files.contains(&temp_path) {
|
|
files.push(temp_path);
|
|
}
|
|
let count = files.len();
|
|
drop(files);
|
|
update_images_count_label(&ui, count);
|
|
|
|
let toast = adw::Toast::new("Pasted image from clipboard");
|
|
toast.set_timeout(2);
|
|
ui.toast_overlay.add_toast(toast);
|
|
} else {
|
|
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image"));
|
|
}
|
|
} else {
|
|
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard"));
|
|
}
|
|
});
|
|
}
|
|
|
|
fn reset_wizard(ui: &WizardUi) {
|
|
// Reset state
|
|
{
|
|
let mut s = ui.state.wizard.borrow_mut();
|
|
s.current_step = 0;
|
|
s.visited = vec![false; s.total_steps];
|
|
s.visited[0] = true;
|
|
}
|
|
ui.state.loaded_files.borrow_mut().clear();
|
|
ui.state.excluded_files.borrow_mut().clear();
|
|
|
|
// Reset nav
|
|
ui.nav_view.replace(&ui.pages[..1]);
|
|
ui.step_indicator.set_current(0);
|
|
ui.title.set_subtitle("Batch Image Processor");
|
|
ui.back_button.set_visible(false);
|
|
ui.next_button.set_label("Next");
|
|
ui.next_button.set_visible(true);
|
|
ui.next_button.add_css_class("suggested-action");
|
|
}
|
|
|
|
fn wire_cancel_button(page: &adw::NavigationPage, cancel_flag: Arc<AtomicBool>) {
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(button) = widget.downcast_ref::<gtk::Button>()
|
|
&& button.label().as_deref() == Some("Cancel")
|
|
{
|
|
let flag = cancel_flag.clone();
|
|
button.connect_clicked(move |btn| {
|
|
flag.store(true, Ordering::Relaxed);
|
|
btn.set_sensitive(false);
|
|
btn.set_label("Cancelling...");
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
fn wire_pause_button(page: &adw::NavigationPage, pause_flag: Arc<AtomicBool>) {
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(button) = widget.downcast_ref::<gtk::Button>()
|
|
&& button.label().as_deref() == Some("Pause")
|
|
{
|
|
let flag = pause_flag.clone();
|
|
button.connect_clicked(move |btn| {
|
|
if btn.label().as_deref() == Some("Pause") {
|
|
flag.store(true, Ordering::Relaxed);
|
|
btn.set_label("Resume");
|
|
btn.add_css_class("suggested-action");
|
|
btn.remove_css_class("flat");
|
|
} else {
|
|
flag.store(false, Ordering::Relaxed);
|
|
btn.set_label("Pause");
|
|
btn.remove_css_class("suggested-action");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
fn calculate_eta(start: &std::time::Instant, current: usize, total: usize) -> String {
|
|
if current == 0 {
|
|
return "Estimating time remaining...".into();
|
|
}
|
|
let elapsed = start.elapsed().as_secs_f64();
|
|
let per_image = elapsed / current as f64;
|
|
let remaining = (total - current) as f64 * per_image;
|
|
if remaining < 1.0 {
|
|
"Almost done...".into()
|
|
} else {
|
|
format!("ETA: ~{}", format_duration(remaining as u64 * 1000))
|
|
}
|
|
}
|
|
|
|
fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str, eta: &str) {
|
|
if let Some(page) = nav_view.visible_page() {
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(label) = widget.downcast_ref::<gtk::Label>() {
|
|
if label.css_classes().iter().any(|c| c == "heading")
|
|
&& (label.label().contains("images") || label.label().contains("0 /"))
|
|
{
|
|
label.set_label(&format!("{} / {} images", current, total));
|
|
}
|
|
if label.css_classes().iter().any(|c| c == "dim-label")
|
|
&& (label.label().contains("Estimating") || label.label().contains("ETA") || label.label().contains("Almost") || label.label().contains("Current"))
|
|
{
|
|
if current < total {
|
|
label.set_label(&format!("{} - {}", eta, file));
|
|
} else {
|
|
label.set_label("Finishing up...");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn add_log_entry(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str) {
|
|
if let Some(page) = nav_view.visible_page() {
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(bx) = widget.downcast_ref::<gtk::Box>()
|
|
&& bx.spacing() == 2
|
|
&& bx.orientation() == gtk::Orientation::Vertical
|
|
{
|
|
let entry = gtk::Label::builder()
|
|
.label(format!("[{}/{}] {} - Done", current, total, file))
|
|
.halign(gtk::Align::Start)
|
|
.css_classes(["caption", "monospace"])
|
|
.build();
|
|
bx.append(&entry);
|
|
|
|
// Auto-scroll: the log box is inside a ScrolledWindow
|
|
if let Some(parent) = bx.parent()
|
|
&& let Some(sw) = parent.downcast_ref::<gtk::ScrolledWindow>()
|
|
{
|
|
let adj = sw.vadjustment();
|
|
adj.set_value(adj.upper());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn import_preset(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
|
let dialog = gtk::FileDialog::builder()
|
|
.title("Import Preset")
|
|
.modal(true)
|
|
.build();
|
|
|
|
let filter = gtk::FileFilter::new();
|
|
filter.set_name(Some("Preset files (JSON)"));
|
|
filter.add_mime_type("application/json");
|
|
filter.add_suffix("json");
|
|
|
|
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
|
|
filters.append(&filter);
|
|
dialog.set_filters(Some(&filters));
|
|
dialog.set_default_filter(Some(&filter));
|
|
|
|
let ui = ui.clone();
|
|
dialog.open(Some(window), gtk::gio::Cancellable::NONE, move |result| {
|
|
if let Ok(file) = result
|
|
&& let Some(path) = file.path()
|
|
{
|
|
let store = pixstrip_core::storage::PresetStore::new();
|
|
match store.import_from_file(&path) {
|
|
Ok(preset) => {
|
|
let msg = format!("Imported preset: {}", preset.name);
|
|
let 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::AlertDialog::builder()
|
|
.heading("Save as Preset")
|
|
.body("Enter a name for this workflow preset")
|
|
.build();
|
|
|
|
let entry = gtk::Entry::builder()
|
|
.placeholder_text("My Workflow")
|
|
.margin_start(24)
|
|
.margin_end(24)
|
|
.build();
|
|
dialog.set_extra_child(Some(&entry));
|
|
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("save", "Save");
|
|
dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
|
|
dialog.set_default_response(Some("save"));
|
|
|
|
let ui = ui.clone();
|
|
dialog.connect_response(None, move |dlg, response| {
|
|
if response == "save" {
|
|
let extra = dlg.extra_child();
|
|
let name = extra
|
|
.as_ref()
|
|
.and_then(|w| w.downcast_ref::<gtk::Entry>())
|
|
.map(|e| e.text().to_string())
|
|
.unwrap_or_default();
|
|
|
|
if name.trim().is_empty() {
|
|
let toast = adw::Toast::new("Please enter a name for the preset");
|
|
ui.toast_overlay.add_toast(toast);
|
|
return;
|
|
}
|
|
|
|
let cfg = ui.state.job_config.borrow();
|
|
let preset = build_preset_from_config(&cfg, &name);
|
|
drop(cfg);
|
|
|
|
let store = pixstrip_core::storage::PresetStore::new();
|
|
match store.save(&preset) {
|
|
Ok(()) => {
|
|
let toast = adw::Toast::new(&format!("Saved preset: {}", name));
|
|
ui.toast_overlay.add_toast(toast);
|
|
}
|
|
Err(e) => {
|
|
let toast = adw::Toast::new(&format!("Failed to save: {}", e));
|
|
ui.toast_overlay.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
dialog.present(Some(window));
|
|
}
|
|
|
|
fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::preset::Preset {
|
|
let resize = if cfg.resize_enabled && cfg.resize_width > 0 {
|
|
if cfg.resize_height == 0 {
|
|
Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width))
|
|
} else {
|
|
Some(pixstrip_core::operations::ResizeConfig::FitInBox {
|
|
max: pixstrip_core::types::Dimensions {
|
|
width: cfg.resize_width,
|
|
height: cfg.resize_height,
|
|
},
|
|
allow_upscale: cfg.allow_upscale,
|
|
})
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let convert = if cfg.convert_enabled {
|
|
cfg.convert_format.map(pixstrip_core::operations::ConvertConfig::SingleFormat)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let compress = if cfg.compress_enabled {
|
|
Some(pixstrip_core::operations::CompressConfig::Preset(cfg.quality_preset))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let metadata = if cfg.metadata_enabled {
|
|
Some(match cfg.metadata_mode {
|
|
MetadataMode::StripAll => pixstrip_core::operations::MetadataConfig::StripAll,
|
|
MetadataMode::Privacy => pixstrip_core::operations::MetadataConfig::Privacy,
|
|
MetadataMode::KeepAll => pixstrip_core::operations::MetadataConfig::KeepAll,
|
|
MetadataMode::Custom => pixstrip_core::operations::MetadataConfig::Custom {
|
|
strip_gps: cfg.strip_gps,
|
|
strip_camera: cfg.strip_camera,
|
|
strip_software: cfg.strip_software,
|
|
strip_timestamps: cfg.strip_timestamps,
|
|
strip_copyright: cfg.strip_copyright,
|
|
},
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let rotation = match cfg.rotation {
|
|
1 => Some(pixstrip_core::operations::Rotation::Cw90),
|
|
2 => Some(pixstrip_core::operations::Rotation::Cw180),
|
|
3 => Some(pixstrip_core::operations::Rotation::Cw270),
|
|
4 => Some(pixstrip_core::operations::Rotation::AutoOrient),
|
|
_ => None,
|
|
};
|
|
|
|
let flip = match cfg.flip {
|
|
1 => Some(pixstrip_core::operations::Flip::Horizontal),
|
|
2 => Some(pixstrip_core::operations::Flip::Vertical),
|
|
_ => None,
|
|
};
|
|
|
|
let watermark = if cfg.watermark_enabled {
|
|
let position = match cfg.watermark_position {
|
|
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
|
|
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
|
|
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
|
|
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
|
|
4 => pixstrip_core::operations::WatermarkPosition::Center,
|
|
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
|
|
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
|
|
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
|
|
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
|
|
};
|
|
if cfg.watermark_use_image {
|
|
cfg.watermark_image_path.as_ref().map(|path| {
|
|
pixstrip_core::operations::WatermarkConfig::Image {
|
|
path: path.clone(),
|
|
position,
|
|
opacity: cfg.watermark_opacity,
|
|
scale: 0.2,
|
|
}
|
|
})
|
|
} else if !cfg.watermark_text.is_empty() {
|
|
Some(pixstrip_core::operations::WatermarkConfig::Text {
|
|
text: cfg.watermark_text.clone(),
|
|
position,
|
|
font_size: cfg.watermark_font_size,
|
|
opacity: cfg.watermark_opacity,
|
|
color: [255, 255, 255, 255],
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let rename = if cfg.rename_enabled {
|
|
Some(pixstrip_core::operations::RenameConfig {
|
|
prefix: cfg.rename_prefix.clone(),
|
|
suffix: cfg.rename_suffix.clone(),
|
|
counter_start: cfg.rename_counter_start,
|
|
counter_padding: cfg.rename_counter_padding,
|
|
template: if cfg.rename_template.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cfg.rename_template.clone())
|
|
},
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
pixstrip_core::preset::Preset {
|
|
name: name.to_string(),
|
|
description: build_preset_description(cfg),
|
|
icon: "document-save-symbolic".into(),
|
|
is_custom: true,
|
|
resize,
|
|
rotation,
|
|
flip,
|
|
convert,
|
|
compress,
|
|
metadata,
|
|
watermark,
|
|
rename,
|
|
}
|
|
}
|
|
|
|
fn build_preset_description(cfg: &JobConfig) -> String {
|
|
let mut parts = Vec::new();
|
|
if cfg.resize_enabled && cfg.resize_width > 0 {
|
|
if cfg.resize_height == 0 {
|
|
parts.push(format!("Resize {}px wide", cfg.resize_width));
|
|
} else {
|
|
parts.push(format!("Resize {}x{}", cfg.resize_width, cfg.resize_height));
|
|
}
|
|
}
|
|
if cfg.convert_enabled && let Some(fmt) = cfg.convert_format {
|
|
parts.push(format!("Convert to {:?}", fmt));
|
|
}
|
|
if cfg.compress_enabled {
|
|
parts.push(format!("Compress {:?}", cfg.quality_preset));
|
|
}
|
|
if cfg.metadata_enabled {
|
|
parts.push(format!("Metadata: {:?}", cfg.metadata_mode));
|
|
}
|
|
if parts.is_empty() {
|
|
"Custom workflow".into()
|
|
} else {
|
|
parts.join(", ")
|
|
}
|
|
}
|
|
|
|
fn update_output_summary(ui: &WizardUi) {
|
|
let cfg = ui.state.job_config.borrow();
|
|
if let Some(page) = ui.pages.get(9) {
|
|
// Build summary lines
|
|
let mut ops = Vec::new();
|
|
if cfg.resize_enabled && cfg.resize_width > 0 {
|
|
if cfg.resize_height == 0 {
|
|
ops.push(format!("Resize to {}px wide", cfg.resize_width));
|
|
} else {
|
|
ops.push(format!("Resize to {}x{}", cfg.resize_width, cfg.resize_height));
|
|
}
|
|
}
|
|
if cfg.convert_enabled && let Some(fmt) = cfg.convert_format {
|
|
ops.push(format!("Convert to {:?}", fmt));
|
|
}
|
|
if cfg.compress_enabled {
|
|
ops.push(format!("Compress ({:?})", cfg.quality_preset));
|
|
}
|
|
if cfg.metadata_enabled {
|
|
let mode = match cfg.metadata_mode {
|
|
MetadataMode::StripAll => "Strip all metadata",
|
|
MetadataMode::Privacy => "Privacy mode",
|
|
MetadataMode::KeepAll => "Keep all metadata",
|
|
MetadataMode::Custom => "Custom metadata stripping",
|
|
};
|
|
ops.push(mode.to_string());
|
|
}
|
|
if cfg.watermark_enabled {
|
|
if cfg.watermark_use_image {
|
|
ops.push("Image watermark".to_string());
|
|
} else if !cfg.watermark_text.is_empty() {
|
|
ops.push(format!("Watermark: \"{}\"", cfg.watermark_text));
|
|
}
|
|
}
|
|
if cfg.rename_enabled {
|
|
if !cfg.rename_template.is_empty() {
|
|
ops.push(format!("Rename: {}", cfg.rename_template));
|
|
} else if !cfg.rename_prefix.is_empty() || !cfg.rename_suffix.is_empty() {
|
|
ops.push(format!(
|
|
"Rename: {}...{}",
|
|
cfg.rename_prefix, cfg.rename_suffix
|
|
));
|
|
} else {
|
|
ops.push("Sequential rename".to_string());
|
|
}
|
|
}
|
|
if cfg.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));
|
|
}
|
|
|
|
let summary_text = if ops.is_empty() {
|
|
"No operations configured".to_string()
|
|
} else {
|
|
ops.join(" -> ")
|
|
};
|
|
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
|
|
let subtitle_str = row.subtitle().unwrap_or_default();
|
|
if row.title().as_str() == "No operations configured"
|
|
|| subtitle_str.contains("->")
|
|
|| subtitle_str.contains("configured")
|
|
{
|
|
if ops.is_empty() {
|
|
row.set_title("No operations configured");
|
|
row.set_subtitle("Go back and configure your workflow settings");
|
|
} else {
|
|
row.set_title(&format!("{} operations", ops.len()));
|
|
row.set_subtitle(&summary_text);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- 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()
|
|
}
|
|
|
|
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(400)
|
|
.content_height(500)
|
|
.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)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.margin_top(12)
|
|
.margin_bottom(12)
|
|
.build();
|
|
|
|
let sections: &[(&str, &[(&str, &str)])] = &[
|
|
("Wizard Navigation", &[
|
|
("Alt + Right", "Next step"),
|
|
("Alt + Left", "Previous step"),
|
|
("Alt + 1...9", "Jump to step by number"),
|
|
("Ctrl + Enter", "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 (title, shortcuts) in sections {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title(*title)
|
|
.build();
|
|
|
|
for (accel, desc) in *shortcuts {
|
|
let row = adw::ActionRow::builder()
|
|
.title(*desc)
|
|
.build();
|
|
let label = gtk::Label::builder()
|
|
.label(*accel)
|
|
.css_classes(["monospace", "dim-label"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
row.add_suffix(&label);
|
|
group.add(&row);
|
|
}
|
|
|
|
content.append(&group);
|
|
}
|
|
|
|
scrolled.set_child(Some(&content));
|
|
toolbar_view.set_content(Some(&scrolled));
|
|
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();
|
|
|
|
if config.high_contrast {
|
|
// Use libadwaita's high contrast mode
|
|
let style_manager = adw::StyleManager::default();
|
|
style_manager.set_color_scheme(adw::ColorScheme::ForceLight);
|
|
// High contrast is best achieved via the GTK_THEME env or system
|
|
// settings; the app respects system high contrast automatically
|
|
}
|
|
|
|
let settings = gtk::Settings::default().unwrap();
|
|
|
|
if config.large_text {
|
|
// Increase font DPI by 25% for large text mode
|
|
let current_dpi = settings.gtk_xft_dpi();
|
|
if current_dpi > 0 {
|
|
settings.set_gtk_xft_dpi(current_dpi * 5 / 4);
|
|
}
|
|
}
|
|
|
|
if config.reduced_motion {
|
|
settings.set_gtk_enable_animations(false);
|
|
}
|
|
}
|
|
|
|
fn format_bytes(bytes: u64) -> String {
|
|
if bytes < 1024 {
|
|
format!("{} B", bytes)
|
|
} else if bytes < 1024 * 1024 {
|
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
|
} else if bytes < 1024 * 1024 * 1024 {
|
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
|
} else {
|
|
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
|
}
|
|
}
|
|
|
|
fn show_step_help(window: &adw::ApplicationWindow, step: usize) {
|
|
let (title, body) = match step {
|
|
0 => ("Workflow", concat!(
|
|
"Choose a preset to start quickly, or configure each step manually.\n\n",
|
|
"Presets apply recommended settings for common tasks like web optimization, ",
|
|
"social media, or print preparation. You can customize any preset after applying it.\n\n",
|
|
"Use Import/Export to share presets with others."
|
|
)),
|
|
1 => ("Images", concat!(
|
|
"Add the images you want to process.\n\n",
|
|
"- Drag and drop files or folders onto this area\n",
|
|
"- Use Browse to pick files from a file dialog\n",
|
|
"- Press Ctrl+V to paste from clipboard\n\n",
|
|
"Use checkboxes to include or exclude individual images. ",
|
|
"Ctrl+A selects all, Ctrl+Shift+A deselects all."
|
|
)),
|
|
2 => ("Resize", concat!(
|
|
"Scale images to specific dimensions.\n\n",
|
|
"Choose a preset size or enter custom dimensions. Width-only or height-only ",
|
|
"resizing preserves the original aspect ratio.\n\n",
|
|
"Enable 'Allow upscale' if you need images smaller than the target to be enlarged."
|
|
)),
|
|
3 => ("Adjustments", concat!(
|
|
"Fine-tune image appearance.\n\n",
|
|
"Adjust brightness, contrast, and saturation with sliders. ",
|
|
"Apply rotation, flipping, grayscale, or sepia effects.\n\n",
|
|
"Crop to a specific aspect ratio or trim whitespace borders automatically."
|
|
)),
|
|
4 => ("Convert", concat!(
|
|
"Change image file format.\n\n",
|
|
"Convert between JPEG, PNG, WebP, AVIF, GIF, TIFF, and BMP. ",
|
|
"Each format has trade-offs between quality, file size, and compatibility.\n\n",
|
|
"WebP and AVIF offer the best compression for web use."
|
|
)),
|
|
5 => ("Compress", concat!(
|
|
"Reduce file size while preserving quality.\n\n",
|
|
"Choose a quality preset (Lossless, High, Balanced, Small, Tiny) or set custom ",
|
|
"quality values per format.\n\n",
|
|
"Expand Advanced Options for fine control over WebP encoding effort and AVIF speed."
|
|
)),
|
|
6 => ("Metadata", concat!(
|
|
"Control what metadata is kept or removed.\n\n",
|
|
"Strip All removes everything. Privacy mode keeps copyright and camera info but ",
|
|
"removes GPS and timestamps. Custom mode lets you pick exactly what to strip.\n\n",
|
|
"Removing metadata reduces file size and protects privacy."
|
|
)),
|
|
7 => ("Watermark", concat!(
|
|
"Add a text or image watermark.\n\n",
|
|
"Choose text or logo mode. Position the watermark using the visual grid. ",
|
|
"Expand Advanced Options for opacity, rotation, tiling, margin, and scale controls.\n\n",
|
|
"Logo watermarks support PNG images with transparency."
|
|
)),
|
|
8 => ("Rename", concat!(
|
|
"Rename output files using patterns.\n\n",
|
|
"Add a prefix, suffix, or use a full template with placeholders:\n",
|
|
"- {name} - original filename\n",
|
|
"- {n} - counter number\n",
|
|
"- {date} - current date\n",
|
|
"- {ext} - original extension\n\n",
|
|
"Expand Advanced Options for case conversion and find-and-replace."
|
|
)),
|
|
9 => ("Output", concat!(
|
|
"Review settings and choose where to save.\n\n",
|
|
"The summary shows all operations that will be applied. ",
|
|
"Choose an output folder or use the default 'processed' subfolder.\n\n",
|
|
"Set overwrite behavior for when output files already exist. ",
|
|
"Press Process or Ctrl+Enter to start."
|
|
)),
|
|
_ => ("Help", "No help available for this step."),
|
|
};
|
|
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(format!("Help: {}", title))
|
|
.body(body)
|
|
.build();
|
|
dialog.add_response("ok", "Got it");
|
|
dialog.set_default_response(Some("ok"));
|
|
dialog.present(Some(window));
|
|
}
|
|
|
|
fn format_duration(ms: u64) -> String {
|
|
if ms < 1000 {
|
|
format!("{}ms", ms)
|
|
} else if ms < 60_000 {
|
|
format!("{:.1}s", ms as f64 / 1000.0)
|
|
} else {
|
|
let mins = ms / 60_000;
|
|
let secs = (ms % 60_000) / 1000;
|
|
format!("{}m {}s", mins, secs)
|
|
}
|
|
}
|