diff --git a/Cargo.lock b/Cargo.lock index f3fb5f5..18b91fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,7 +203,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -223,6 +223,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -280,7 +286,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cairo-sys-rs", "glib", "libc", @@ -471,6 +477,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -621,6 +648,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -643,6 +681,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -852,7 +899,7 @@ version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16877c6e619447e0bcb6de326a42a8bd02b36328cfeeda210135425e576efa3d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -1121,6 +1168,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -1191,6 +1267,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1284,6 +1380,18 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall", +] + [[package]] name = "libwebp-sys" version = "0.9.6" @@ -1394,6 +1502,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -1485,6 +1605,34 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num" version = "0.4.3" @@ -1582,6 +1730,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -1673,12 +1827,14 @@ name = "pixstrip-core" version = "0.1.0" dependencies = [ "ab_glyph", + "dirs", "fast_image_resize", "image", "imageproc", "little_exif", "magick_rust", "mozjpeg", + "notify", "oxipng", "rayon", "serde", @@ -1704,13 +1860,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -1966,6 +2128,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.3" @@ -2025,7 +2207,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -2479,7 +2661,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2526,13 +2708,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -2544,6 +2735,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -2551,58 +2758,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -2676,7 +2931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 6b246b5..e075c95 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -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>, + pub loaded_files: Rc>>, + pub output_dir: Rc>>, +} + #[derive(Clone)] struct WizardUi { nav_view: adw::NavigationView, @@ -15,7 +26,8 @@ struct WizardUi { next_button: gtk::Button, title: adw::WindowTitle, pages: Vec, - state: Rc>, + 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::()) { 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: >k::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::(); + 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) + } +} diff --git a/pixstrip-gtk/src/processing.rs b/pixstrip-gtk/src/processing.rs index 9d4cdd3..7dad018 100644 --- a/pixstrip-gtk/src/processing.rs +++ b/pixstrip-gtk/src/processing.rs @@ -1,6 +1,5 @@ use adw::prelude::*; -#[allow(dead_code)] pub fn build_processing_page() -> adw::NavigationPage { let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -98,7 +97,6 @@ pub fn build_processing_page() -> adw::NavigationPage { .build() } -#[allow(dead_code)] pub fn build_results_page() -> adw::NavigationPage { let scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) diff --git a/pixstrip-gtk/src/settings.rs b/pixstrip-gtk/src/settings.rs index ae0b2ab..d5e7f1b 100644 --- a/pixstrip-gtk/src/settings.rs +++ b/pixstrip-gtk/src/settings.rs @@ -1,6 +1,5 @@ use adw::prelude::*; -#[allow(dead_code)] pub fn build_settings_dialog() -> adw::PreferencesDialog { let dialog = adw::PreferencesDialog::builder() .title("Settings")