Files
pixstrip/pixstrip-gtk/src/app.rs

1949 lines
67 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 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"]);
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("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 build_ui(app: &adw::Application) {
// 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())),
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));
// 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,
};
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
}
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 files = ui.state.loaded_files.borrow().clone();
if files.is_empty() {
let toast = adw::Toast::new("No images loaded - 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);
}
// 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);
}
// 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);
}
// 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());
}
// 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 count = files.len();
let total_size: u64 = files.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.sum();
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 ({})", count, format_bytes(total_size)));
}
});
}
update_output_summary(ui);
}
update_nav_buttons(&s, &ui.back_button, &ui.next_button);
}
fn update_nav_buttons(state: &WizardState, back_button: &gtk::Button, next_button: &gtk::Button) {
back_button.set_sensitive(state.can_go_back());
back_button.set_visible(state.current_step > 0);
if state.is_last_step() {
next_button.set_label("Process");
next_button.remove_css_class("suggested-action");
next_button.add_css_class("suggested-action");
next_button.set_tooltip_text(Some("Start processing (Ctrl+Enter)"));
} else {
next_button.set_label("Next");
next_button.add_css_class("suggested-action");
next_button.set_tooltip_text(Some("Go to next step (Alt+Right)"));
}
}
fn open_file_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = gtk::FileDialog::builder()
.title("Select Images")
.modal(true)
.build();
let filter = gtk::FileFilter::new();
filter.set_name(Some("Image files"));
filter.add_mime_type("image/jpeg");
filter.add_mime_type("image/png");
filter.add_mime_type("image/webp");
filter.add_mime_type("image/avif");
filter.add_mime_type("image/gif");
filter.add_mime_type("image/tiff");
filter.add_mime_type("image/bmp");
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
dialog.set_default_filter(Some(&filter));
let ui = ui.clone();
dialog.open_multiple(Some(window), gtk::gio::Cancellable::NONE, move |result| {
if let Ok(files) = result {
let mut paths = ui.state.loaded_files.borrow_mut();
for i in 0..files.n_items() {
if let Some(file) = files.item(i)
&& let Some(gfile) = file.downcast_ref::<gtk::gio::File>()
&& let Some(path) = gfile.path()
&& !paths.contains(&path)
{
paths.push(path);
}
}
let count = paths.len();
drop(paths);
// Update the images step UI if we can find the label
update_images_count_label(&ui, count);
}
});
}
fn open_output_chooser(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dialog = gtk::FileDialog::builder()
.title("Choose Output Folder")
.modal(true)
.build();
let ui = ui.clone();
dialog.select_folder(Some(window), gtk::gio::Cancellable::NONE, move |result| {
if let Ok(folder) = result
&& let Some(path) = folder.path()
{
*ui.state.output_dir.borrow_mut() = Some(path.clone());
update_output_label(&ui, &path);
}
});
}
fn update_output_label(ui: &WizardUi, path: &std::path::Path) {
// Find the output step page (index 9) and update the output location subtitle
if let Some(page) = ui.pages.get(9) {
walk_widgets(&page.child(), &|widget| {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>()
&& row.title().as_str() == "Output Location"
{
row.set_subtitle(&path.display().to_string());
}
});
}
}
fn update_images_count_label(ui: &WizardUi, count: usize) {
let files = ui.state.loaded_files.borrow();
let total_size: u64 = files.iter()
.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, total_size);
update_file_list(&loaded_box, &files);
}
}
}
fn update_count_in_box(widget: &gtk::Widget, count: usize, total_size: u64) {
if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& label.css_classes().iter().any(|c| c == "heading")
{
label.set_label(&format!("{} images ({})", count, format_bytes(total_size)));
return;
}
let mut child = widget.first_child();
while let Some(c) = child {
update_count_in_box(&c, count, total_size);
child = c.next_sibling();
}
}
fn update_file_list(widget: &gtk::Widget, files: &[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();
row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
list_box.append(&row);
}
return;
}
let mut child = widget.first_child();
while let Some(c) = child {
update_file_list(&c, files);
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(&gtk::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 run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
let files = ui.state.loaded_files.borrow().clone();
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();
std::thread::spawn(move || {
let executor = pixstrip_core::executor::PipelineExecutor::with_cancel_and_pause(cancel, pause);
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 {
bar.set_fraction(current as f64 / total as f64);
bar.set_text(Some(&format!("{}/{} - {}", 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"), &notification);
}
}
// 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(&gtk::Image::from_icon_name("dialog-error-symbolic"));
expander.add_row(&row);
}
}
});
}
fn wire_results_actions(
ui: &WizardUi,
page: &adw::NavigationPage,
) {
walk_widgets(&page.child(), &|widget| {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
match row.title().as_str() {
"Open Output Folder" => {
let ui = ui.clone();
row.connect_activated(move |_| {
let output = ui.state.output_dir.borrow().clone();
if let Some(dir) = output {
let _ = gtk::gio::AppInfo::launch_default_for_uri(
&format!("file://{}", dir.display()),
gtk::gio::AppLaunchContext::NONE,
);
}
});
}
"Process Another Batch" => {
let ui = ui.clone();
row.connect_activated(move |_| {
reset_wizard(&ui);
});
}
"Save as Preset" => {
row.set_action_name(Some("win.save-preset"));
}
_ => {}
}
}
});
}
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();
// 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(&gtk::Widget)) {
let Some(w) = widget else { return };
f(w);
let mut child = w.first_child();
while let Some(c) = child {
walk_widgets(&Some(c.clone()), f);
child = c.next_sibling();
}
}
fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let 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 + 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 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)
}
}