Wire up all GTK UI actions to real functionality

- 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
This commit is contained in:
2026-03-06 11:37:32 +02:00
parent eb16149824
commit b6aae711ec
4 changed files with 853 additions and 39 deletions

View File

@@ -1,12 +1,23 @@
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,
@@ -15,7 +26,8 @@ struct WizardUi {
next_button: gtk::Button,
title: adw::WindowTitle,
pages: Vec<adw::NavigationPage>,
state: Rc<RefCell<WizardState>>,
toast_overlay: adw::ToastOverlay,
state: AppState,
}
pub fn build_app() -> adw::Application {
@@ -43,7 +55,11 @@ fn setup_shortcuts(app: &adw::Application) {
}
fn build_ui(app: &adw::Application) {
let state = Rc::new(RefCell::new(WizardState::new()));
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();
@@ -61,7 +77,7 @@ fn build_ui(app: &adw::Application) {
header.pack_end(&menu_button);
// Step indicator
let step_indicator = StepIndicator::new(&state.borrow().step_names());
let step_indicator = StepIndicator::new(&app_state.wizard.borrow().step_names());
// Navigation view for wizard content
let nav_view = adw::NavigationView::new();
@@ -110,12 +126,16 @@ fn build_ui(app: &adw::Application) {
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(&toolbar_view)
.content(&toast_overlay)
.title("Pixstrip")
.build();
@@ -126,11 +146,16 @@ fn build_ui(app: &adw::Application) {
next_button,
title,
pages,
state,
toast_overlay,
state: app_state,
};
setup_window_actions(&window, &ui);
update_nav_buttons(&ui.state.borrow(), &ui.back_button, &ui.next_button);
update_nav_buttons(
&ui.state.wizard.borrow(),
&ui.back_button,
&ui.next_button,
);
ui.step_indicator.set_current(0);
window.present();
@@ -151,7 +176,7 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("next-step", None);
action.connect_activate(move |_, _| {
let mut s = ui.state.borrow_mut();
let mut s = ui.state.wizard.borrow_mut();
if s.can_go_next() {
s.go_next();
let idx = s.current_step;
@@ -167,7 +192,7 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("prev-step", None);
action.connect_activate(move |_, _| {
let mut s = ui.state.borrow_mut();
let mut s = ui.state.wizard.borrow_mut();
if s.can_go_back() {
s.go_back();
let idx = s.current_step;
@@ -188,10 +213,10 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
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.borrow();
let s = ui.state.wizard.borrow();
if target < s.total_steps && s.visited[target] {
drop(s);
ui.state.borrow_mut().current_step = target;
ui.state.wizard.borrow_mut().current_step = target;
navigate_to_step(&ui, target);
}
}
@@ -199,31 +224,52 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
action_group.add_action(&action);
}
// Process action (placeholder)
// 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 |_, _| {});
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 (placeholder)
// 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 |_, _| {});
action.connect_activate(move |_, _| {
open_file_chooser(&window, &ui);
});
action_group.add_action(&action);
}
// Settings action (placeholder)
// Settings action - opens settings dialog
{
let window = window.clone();
let action = gtk::gio::SimpleAction::new("show-settings", None);
action.connect_activate(move |_, _| {});
action.connect_activate(move |_, _| {
let dialog = crate::settings::build_settings_dialog();
dialog.present(Some(&window));
});
action_group.add_action(&action);
}
// History action (placeholder)
// History action - shows history dialog
{
let window = window.clone();
let action = gtk::gio::SimpleAction::new("show-history", None);
action.connect_activate(move |_, _| {});
action.connect_activate(move |_, _| {
show_history_dialog(&window);
});
action_group.add_action(&action);
}
@@ -237,8 +283,16 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
ui.next_button.connect_clicked({
let action_group = action_group.clone();
let ui = ui.clone();
move |_| {
ActionGroupExt::activate_action(&action_group, "next-step", None);
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);
}
}
});
@@ -246,7 +300,7 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
}
fn navigate_to_step(ui: &WizardUi, target: usize) {
let s = ui.state.borrow();
let s = ui.state.wizard.borrow();
// Update step indicator
ui.step_indicator.set_current(target);
@@ -280,3 +334,511 @@ fn update_nav_buttons(state: &WizardState, back_button: &gtk::Button, next_butto
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: &gtk::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(&gtk::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(&gtk::Widget)) {
let Some(w) = widget else { return };
f(w);
let mut child = w.first_child();
while let Some(c) = child {
walk_widgets(&Some(c.clone()), f);
child = c.next_sibling();
}
}
fn 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)
}
}