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, // 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, 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>, pub loaded_files: Rc>>, pub output_dir: Rc>>, pub job_config: Rc>, } #[derive(Clone)] struct WizardUi { nav_view: adw::NavigationView, step_indicator: StepIndicator, back_button: gtk::Button, next_button: gtk::Button, title: adw::WindowTitle, pages: Vec, 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", &["Right"]); app.set_accels_for_action("win.prev-step", &["Left"]); app.set_accels_for_action("win.process", &["Return"]); for i in 1..=9 { app.set_accels_for_action( &format!("win.goto-step({})", i), &[&format!("{}", i)], ); } app.set_accels_for_action("win.add-files", &["o"]); app.set_accels_for_action("app.quit", &["q"]); app.set_accels_for_action("win.show-settings", &["comma"]); app.set_accels_for_action("win.show-shortcuts", &["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::()) { 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::() && 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: >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::(); 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::() && 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::() && 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::() { 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: >k::Widget, count: usize, total_size: u64) { if let Some(label) = widget.downcast_ref::() && 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: >k::Widget, files: &[std::path::PathBuf]) { if let Some(list_box) = widget.downcast_ref::() && 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(>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); 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 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::(&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::(); 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 = 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::() { 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::() && group.title().as_str() == "Errors" { group.set_visible(true); } if let Some(expander) = widget.downcast_ref::() && 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::() { 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) { walk_widgets(&page.child(), &|widget| { if let Some(button) = widget.downcast_ref::() && 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) { walk_widgets(&page.child(), &|widget| { if let Some(button) = widget.downcast_ref::() && 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::() { 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::() && 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::() { 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::(); 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::()) .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::() { 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>(page: &adw::NavigationPage) -> Option { let result: RefCell> = RefCell::new(None); walk_widgets(&page.child(), &|widget| { if result.borrow().is_none() && let Some(w) = widget.downcast_ref::() { *result.borrow_mut() = Some(w.clone()); } }); result.into_inner() } fn walk_widgets(widget: &Option, 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 + 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) } }