- Settings menu opens PreferencesDialog - History menu shows HistoryStore entries in a dialog - Add Files (Ctrl+O) opens FileDialog with image MIME filters - Process button runs PipelineExecutor in background thread - Progress bar updates via mpsc channel polled with glib timeout - Cancel button sets AtomicBool flag to stop processing - Results page shows real stats (images, sizes, savings, time) - Open Output Folder launches default file manager - Process Another Batch resets wizard to step 1 - Toast notifications via ToastOverlay for feedback - History entries saved after each processing run - Remove dead_code allows from processing.rs and settings.rs
845 lines
27 KiB
Rust
845 lines
27 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";
|
|
|
|
/// 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>>>,
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
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"]);
|
|
}
|
|
|
|
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::<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);
|
|
}
|
|
|
|
// 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::<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 update_images_count_label(ui: &WizardUi, count: usize) {
|
|
// Find the step-images page and switch its stack to "loaded" if we have files
|
|
if let Some(page) = ui.pages.get(1)
|
|
&& let Some(stack) = page.child().and_downcast::<gtk::Stack>()
|
|
{
|
|
if count > 0 {
|
|
stack.set_visible_child_name("loaded");
|
|
}
|
|
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::<gtk::Label>() {
|
|
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::<gtk::Box>() {
|
|
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::<gtk::ProgressBar>(&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::<ProcessingMessage>();
|
|
|
|
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::<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 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);
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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 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::<gtk::Label>() {
|
|
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<T: IsA<gtk::Widget>>(page: &adw::NavigationPage) -> Option<T> {
|
|
let result: RefCell<Option<T>> = RefCell::new(None);
|
|
walk_widgets(&page.child(), &|widget| {
|
|
if result.borrow().is_none()
|
|
&& let Some(w) = widget.downcast_ref::<T>()
|
|
{
|
|
*result.borrow_mut() = Some(w.clone());
|
|
}
|
|
});
|
|
result.into_inner()
|
|
}
|
|
|
|
fn walk_widgets(widget: &Option<gtk::Widget>, f: &dyn Fn(>k::Widget)) {
|
|
let Some(w) = widget else { return };
|
|
f(w);
|
|
let mut child = w.first_child();
|
|
while let Some(c) = child {
|
|
walk_widgets(&Some(c.clone()), f);
|
|
child = c.next_sibling();
|
|
}
|
|
}
|
|
|
|
|
|
fn 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)
|
|
}
|
|
}
|