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"; /// Shared app state accessible from all UI callbacks #[derive(Clone)] pub struct AppState { pub wizard: Rc>, pub loaded_files: Rc>>, pub output_dir: 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 } 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"]); } fn build_ui(app: &adw::Application) { 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)), }; // 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(); 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)); // Window let window = adw::ApplicationWindow::builder() .application(app) .default_width(900) .default_height(700) .content(&toast_overlay) .title("Pixstrip") .build(); 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); window.present(); } 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 } 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); } // 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_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 update_images_count_label(ui: &WizardUi, count: usize) { // Find the step-images page and switch its stack to "loaded" if we have files if let Some(page) = ui.pages.get(1) && let Some(stack) = page.child().and_downcast::() { if count > 0 { stack.set_visible_child_name("loaded"); } if let Some(loaded_box) = stack.child_by_name("loaded") { update_count_in_box(&loaded_box, count); } } } fn update_count_in_box(widget: >k::Widget, count: usize) { // Walk the widget tree to find the heading label with "images" text if let Some(label) = widget.downcast_ref::() { if label.css_classes().iter().any(|c| c == "heading") { let files = pixstrip_core::storage::PresetStore::new(); // just for format_bytes let _ = files; // unused label.set_label(&format!("{} images loaded", count)); } return; } if let Some(bx) = widget.downcast_ref::() { let mut child = bx.first_child(); while let Some(c) = child { update_count_in_box(&c, count); 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")); group.add(&row); } content.append(&group); } 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 - for now use default settings (resize off, compress high, strip metadata) let mut job = pixstrip_core::pipeline::ProcessingJob::new(&input_dir, &output_dir); job.compress = Some(pixstrip_core::operations::CompressConfig::Preset( pixstrip_core::types::QualityPreset::High, )); job.metadata = Some(pixstrip_core::operations::MetadataConfig::StripAll); 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)); // Find cancel button and wire it wire_cancel_button(&processing_page, cancel_flag.clone()); // Run processing in a background thread let (tx, rx) = std::sync::mpsc::channel::(); let cancel = cancel_flag.clone(); std::thread::spawn(move || { let executor = pixstrip_core::executor::PipelineExecutor::with_cancel(cancel); 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(); 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))); } update_progress_labels(&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); // 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 let history = pixstrip_core::storage::HistoryStore::new(); 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: String::new(), output_dir: String::new(), 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: vec![], }); // 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) }; let toast = adw::Toast::new(&savings); toast.set_timeout(5); ui.toast_overlay.add_toast(toast); } 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 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); }); } _ => {} } } }); } 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 update_progress_labels(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(label) = widget.downcast_ref::() { if label.css_classes().iter().any(|c| c == "heading") && label.label().contains("images") { label.set_label(&format!("{} / {} images", current, total)); } if label.css_classes().iter().any(|c| c == "dim-label") && label.label().contains("Estimating") { label.set_label(&format!("Current: {}", file)); } } }); } } // --- 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 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) } }