diff --git a/Cargo.lock b/Cargo.lock index 18b91fb..130b26b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,15 @@ dependencies = [ "equator", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -340,6 +349,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -404,6 +424,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "core2" version = "0.4.0" @@ -1091,6 +1117,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1718,6 +1768,31 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1808,6 +1883,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1819,7 +1900,10 @@ name = "pixstrip-cli" version = "0.1.0" dependencies = [ "clap", + "dirs", "pixstrip-core", + "serde_json", + "trash", ] [[package]] @@ -2238,6 +2322,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -2507,6 +2597,24 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "trash" +version = "5.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b93a14fcf658568eb11b3ac4cb406822e916e2c55cdebc421beeb0bd7c94d8" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -2531,6 +2639,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2702,12 +2816,118 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/pixstrip-cli/Cargo.toml b/pixstrip-cli/Cargo.toml index 5ac0f3d..f7339ce 100644 --- a/pixstrip-cli/Cargo.toml +++ b/pixstrip-cli/Cargo.toml @@ -7,3 +7,6 @@ license.workspace = true [dependencies] pixstrip-core = { workspace = true } clap = { version = "4", features = ["derive"] } +trash = "5" +dirs = "6" +serde_json = "1" diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index acd4811..38a7cf3 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -93,11 +93,45 @@ enum Commands { action: PresetAction, }, + /// Manage watch folders + Watch { + #[command(subcommand)] + action: WatchAction, + }, + /// View processing history History, - /// Undo last batch operation - Undo, + /// Undo last batch operation (moves output files to trash) + Undo { + /// Undo the last N batches (default 1) + #[arg(long, default_value = "1")] + last: usize, + }, +} + +#[derive(Subcommand)] +enum WatchAction { + /// Add a watch folder with a linked preset + Add { + /// Folder path to watch + path: String, + /// Preset name to apply + #[arg(long)] + preset: String, + /// Watch subdirectories recursively + #[arg(short, long)] + recursive: bool, + }, + /// List configured watch folders + List, + /// Remove a watch folder + Remove { + /// Folder path to remove + path: String, + }, + /// Start watching configured folders (blocks until Ctrl+C) + Start, } #[derive(Subcommand)] @@ -160,8 +194,18 @@ fn main() { PresetAction::Export { name, output } => cmd_preset_export(&name, &output), PresetAction::Import { path } => cmd_preset_import(&path), }, + Commands::Watch { action } => match action { + WatchAction::Add { + path, + preset, + recursive, + } => cmd_watch_add(&path, &preset, recursive), + WatchAction::List => cmd_watch_list(), + WatchAction::Remove { path } => cmd_watch_remove(&path), + WatchAction::Start => cmd_watch_start(), + }, Commands::History => cmd_history(), - Commands::Undo => cmd_undo(), + Commands::Undo { last } => cmd_undo(last), } } @@ -420,17 +464,49 @@ fn cmd_history() { } } -fn cmd_undo() { +fn cmd_undo(count: usize) { let history = HistoryStore::new(); match history.list() { Ok(entries) => { - if let Some(last) = entries.last() { - println!("Last operation: {} images from {}", last.total, last.input_dir); - println!("Output files would be moved to trash."); - println!("(Undo with file deletion not yet implemented - requires gio trash support)"); - } else { + if entries.is_empty() { println!("No processing history to undo."); + return; } + + let to_undo = entries.iter().rev().take(count); + let mut total_trashed = 0; + + for entry in to_undo { + if entry.output_files.is_empty() { + println!( + "Batch from {} has no recorded output files - cannot undo", + entry.timestamp + ); + continue; + } + + println!( + "Undoing batch: {} images from {}", + entry.total, entry.input_dir + ); + + for file_path in &entry.output_files { + let path = PathBuf::from(file_path); + if path.exists() { + // Move to OS trash using the trash crate + match trash::delete(&path) { + Ok(()) => { + total_trashed += 1; + } + Err(e) => { + eprintln!(" Failed to trash {}: {}", path.display(), e); + } + } + } + } + } + + println!("{} files moved to trash", total_trashed); } Err(e) => { eprintln!("Failed to read history: {}", e); @@ -439,6 +515,183 @@ fn cmd_undo() { } } +fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) { + // Verify the preset exists + let _preset = find_preset(preset_name); + let watch_path = PathBuf::from(path); + if !watch_path.exists() { + eprintln!("Watch folder does not exist: {}", path); + std::process::exit(1); + } + + // Save watch folder config + let watch = pixstrip_core::watcher::WatchFolder { + path: watch_path, + preset_name: preset_name.to_string(), + recursive, + active: true, + }; + + // Store in config + let config_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("pixstrip"); + let watches_path = config_dir.join("watches.json"); + let mut watches: Vec = if watches_path.exists() { + std::fs::read_to_string(&watches_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + + // Don't add duplicate paths + if watches.iter().any(|w| w.path == watch.path) { + println!("Watch folder already configured: {}", path); + return; + } + + watches.push(watch); + let _ = std::fs::create_dir_all(&config_dir); + let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap()); + + println!("Added watch: {} -> preset '{}'", path, preset_name); +} + +fn cmd_watch_list() { + let config_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("pixstrip"); + let watches_path = config_dir.join("watches.json"); + + let watches: Vec = if watches_path.exists() { + std::fs::read_to_string(&watches_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + + if watches.is_empty() { + println!("No watch folders configured."); + println!("Use 'pixstrip watch add --preset ' to add one."); + return; + } + + println!("Configured watch folders:"); + for watch in &watches { + let recursive_str = if watch.recursive { " (recursive)" } else { "" }; + let status = if watch.active { "active" } else { "inactive" }; + println!( + " {} -> '{}' [{}]{}", + watch.path.display(), + watch.preset_name, + status, + recursive_str + ); + } +} + +fn cmd_watch_remove(path: &str) { + let config_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("pixstrip"); + let watches_path = config_dir.join("watches.json"); + + let mut watches: Vec = if watches_path.exists() { + std::fs::read_to_string(&watches_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + + let original_len = watches.len(); + let target = PathBuf::from(path); + watches.retain(|w| w.path != target); + + if watches.len() == original_len { + println!("Watch folder not found: {}", path); + return; + } + + let _ = std::fs::write(&watches_path, serde_json::to_string_pretty(&watches).unwrap()); + println!("Removed watch folder: {}", path); +} + +fn cmd_watch_start() { + let config_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("pixstrip"); + let watches_path = config_dir.join("watches.json"); + + let watches: Vec = if watches_path.exists() { + std::fs::read_to_string(&watches_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + + let active: Vec<_> = watches.iter().filter(|w| w.active).collect(); + if active.is_empty() { + println!("No active watch folders. Use 'pixstrip watch add' first."); + return; + } + + println!("Starting watch on {} folder(s)...", active.len()); + for w in &active { + println!(" {} -> '{}'", w.path.display(), w.preset_name); + } + println!("Press Ctrl+C to stop."); + + let (tx, rx) = std::sync::mpsc::channel(); + let mut watchers = Vec::new(); + + for watch in &active { + let watcher = pixstrip_core::watcher::FolderWatcher::new(); + if let Err(e) = watcher.start(watch, tx.clone()) { + eprintln!("Failed to start watching {}: {}", watch.path.display(), e); + continue; + } + watchers.push((watcher, watch.preset_name.clone())); + } + + // Process incoming files + for event in &rx { + match event { + pixstrip_core::watcher::WatchEvent::NewImage(path) => { + println!("New image: {}", path.display()); + // Find which watcher this came from and use its preset + if let Some((_, preset_name)) = watchers.first() { + let preset = find_preset(preset_name); + let input_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf(); + let output_dir = input_dir.join("processed"); + let mut job = preset.to_job(&input_dir, &output_dir); + job.add_source(&path); + + let executor = PipelineExecutor::new(); + match executor.execute(&job, |_| {}) { + Ok(r) => println!(" Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes)), + Err(e) => eprintln!(" Failed: {}", e), + } + } + } + pixstrip_core::watcher::WatchEvent::Error(err) => { + eprintln!("Watch error: {}", err); + } + } + } + + for (w, _) in &watchers { + w.stop(); + } +} + // --- Helpers --- fn find_preset(name: &str) -> Preset { diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index 3549247..e99fd0e 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -32,17 +32,32 @@ pub struct BatchResult { pub struct PipelineExecutor { cancel_flag: Arc, + pause_flag: Arc, } impl PipelineExecutor { pub fn new() -> Self { Self { cancel_flag: Arc::new(AtomicBool::new(false)), + pause_flag: Arc::new(AtomicBool::new(false)), } } pub fn with_cancel(cancel_flag: Arc) -> Self { - Self { cancel_flag } + Self { + cancel_flag, + pause_flag: Arc::new(AtomicBool::new(false)), + } + } + + pub fn with_cancel_and_pause( + cancel_flag: Arc, + pause_flag: Arc, + ) -> Self { + Self { + cancel_flag, + pause_flag, + } } pub fn execute(&self, job: &ProcessingJob, mut on_progress: F) -> Result @@ -71,6 +86,18 @@ impl PipelineExecutor { break; } + // Wait while paused (check every 100ms) + while self.pause_flag.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(100)); + if self.cancel_flag.load(Ordering::Relaxed) { + result.cancelled = true; + break; + } + } + if result.cancelled { + break; + } + let file_name = source .path .file_name() diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 7ca540f..fcdbb4c 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -289,6 +289,9 @@ fn build_ui(app: &adw::Application) { ); ui.step_indicator.set_current(0); + // Apply saved accessibility settings + apply_accessibility_settings(); + window.present(); crate::welcome::show_welcome_if_first_launch(&window); @@ -740,9 +743,49 @@ fn show_history_dialog(window: &adw::ApplicationWindow) { .subtitle(&subtitle) .build(); row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); + + // Undo button - moves output files to trash + if !entry.output_files.is_empty() { + let undo_btn = gtk::Button::builder() + .icon_name("edit-undo-symbolic") + .tooltip_text("Undo - move outputs to trash") + .valign(gtk::Align::Center) + .build(); + undo_btn.add_css_class("flat"); + let files = entry.output_files.clone(); + undo_btn.connect_clicked(move |btn| { + let mut trashed = 0; + for file_path in &files { + let gfile = gtk::gio::File::for_path(file_path); + if gfile.trash(gtk::gio::Cancellable::NONE).is_ok() { + trashed += 1; + } + } + btn.set_sensitive(false); + btn.set_tooltip_text(Some(&format!("{} files moved to trash", trashed))); + }); + row.add_suffix(&undo_btn); + } + group.add(&row); } content.append(&group); + + // Clear history button + let clear_btn = gtk::Button::builder() + .label("Clear History") + .halign(gtk::Align::Center) + .margin_top(12) + .margin_bottom(12) + .build(); + clear_btn.add_css_class("destructive-action"); + clear_btn.connect_clicked(move |btn| { + let history = pixstrip_core::storage::HistoryStore::new(); + let _ = history.clear(); + btn.set_label("History Cleared"); + btn.set_sensitive(false); + }); + content.append(&clear_btn); } Err(e) => { let error = adw::StatusPage::builder() @@ -906,16 +949,19 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { let progress_bar = find_widget_by_type::(&processing_page); let cancel_flag = Arc::new(AtomicBool::new(false)); + let pause_flag = Arc::new(AtomicBool::new(false)); + // Find cancel button and wire it; also wire pause button wire_cancel_button(&processing_page, cancel_flag.clone()); - wire_pause_button(&processing_page); + wire_pause_button(&processing_page, pause_flag.clone()); // Run processing in a background thread let (tx, rx) = std::sync::mpsc::channel::(); let cancel = cancel_flag.clone(); + let pause = pause_flag.clone(); std::thread::spawn(move || { - let executor = pixstrip_core::executor::PipelineExecutor::with_cancel(cancel); + let executor = pixstrip_core::executor::PipelineExecutor::with_cancel_and_pause(cancel, pause); let result = executor.execute(&job, |update| { let _ = tx.send(ProcessingMessage::Progress { current: update.current, @@ -998,8 +1044,30 @@ fn show_results( ui.next_button.set_label("Process More"); ui.next_button.set_visible(true); - // Save history + // Save history with output paths for undo support let history = pixstrip_core::storage::HistoryStore::new(); + let output_dir_str = ui.state.output_dir.borrow() + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let input_dir_str = ui.state.loaded_files.borrow() + .first() + .and_then(|p| p.parent()) + .map(|p| p.display().to_string()) + .unwrap_or_default(); + + // Collect actual output files from the output directory + let output_files: Vec = if let Some(ref dir) = *ui.state.output_dir.borrow() { + std::fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .map(|e| e.path().display().to_string()) + .collect() + } else { + vec![] + }; + let _ = history.add(pixstrip_core::storage::HistoryEntry { timestamp: format!( "{}", @@ -1008,8 +1076,8 @@ fn show_results( .unwrap_or_default() .as_secs() ), - input_dir: String::new(), - output_dir: String::new(), + input_dir: input_dir_str, + output_dir: output_dir_str, preset_name: None, total: result.total, succeeded: result.succeeded, @@ -1017,7 +1085,7 @@ fn show_results( total_input_bytes: result.total_input_bytes, total_output_bytes: result.total_output_bytes, elapsed_ms: result.elapsed_ms, - output_files: vec![], + output_files, }); // Show toast @@ -1033,7 +1101,30 @@ fn show_results( }; let toast = adw::Toast::new(&savings); toast.set_timeout(5); - ui.toast_overlay.add_toast(toast); + ui.toast_overlay.add_toast(toast.clone()); + + // Desktop notification (if enabled in settings) + let config_store = pixstrip_core::storage::ConfigStore::new(); + let config = config_store.load().unwrap_or_default(); + if config.notify_on_completion { + let notification = gtk::gio::Notification::new("Pixstrip - Processing Complete"); + notification.set_body(Some(&savings)); + notification.set_priority(gtk::gio::NotificationPriority::Normal); + if let Some(app) = gtk::gio::Application::default() { + app.send_notification(Some("batch-complete"), ¬ification); + } + } + + // Auto-open output folder if enabled + if config.auto_open_output { + let output = ui.state.output_dir.borrow().clone(); + if let Some(dir) = output { + let _ = gtk::gio::AppInfo::launch_default_for_uri( + &format!("file://{}", dir.display()), + gtk::gio::AppLaunchContext::NONE, + ); + } + } } fn update_results_stats( @@ -1164,19 +1255,22 @@ fn wire_cancel_button(page: &adw::NavigationPage, cancel_flag: Arc) }); } -fn wire_pause_button(page: &adw::NavigationPage) { +fn wire_pause_button(page: &adw::NavigationPage, pause_flag: Arc) { walk_widgets(&page.child(), &|widget| { if let Some(button) = widget.downcast_ref::() && button.label().as_deref() == Some("Pause") { - // Pause is cosmetic for now - we show a toast explaining it pauses after current image + let flag = pause_flag.clone(); button.connect_clicked(move |btn| { if btn.label().as_deref() == Some("Pause") { - btn.set_label("Paused"); - btn.add_css_class("warning"); + flag.store(true, Ordering::Relaxed); + btn.set_label("Resume"); + btn.add_css_class("suggested-action"); + btn.remove_css_class("flat"); } else { + flag.store(false, Ordering::Relaxed); btn.set_label("Pause"); - btn.remove_css_class("warning"); + btn.remove_css_class("suggested-action"); } }); } @@ -1384,19 +1478,85 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese None }; + let rotation = match cfg.rotation { + 1 => Some(pixstrip_core::operations::Rotation::Cw90), + 2 => Some(pixstrip_core::operations::Rotation::Cw180), + 3 => Some(pixstrip_core::operations::Rotation::Cw270), + 4 => Some(pixstrip_core::operations::Rotation::AutoOrient), + _ => None, + }; + + let flip = match cfg.flip { + 1 => Some(pixstrip_core::operations::Flip::Horizontal), + 2 => Some(pixstrip_core::operations::Flip::Vertical), + _ => None, + }; + + let watermark = if cfg.watermark_enabled { + let position = match cfg.watermark_position { + 0 => pixstrip_core::operations::WatermarkPosition::TopLeft, + 1 => pixstrip_core::operations::WatermarkPosition::TopCenter, + 2 => pixstrip_core::operations::WatermarkPosition::TopRight, + 3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft, + 4 => pixstrip_core::operations::WatermarkPosition::Center, + 5 => pixstrip_core::operations::WatermarkPosition::MiddleRight, + 6 => pixstrip_core::operations::WatermarkPosition::BottomLeft, + 7 => pixstrip_core::operations::WatermarkPosition::BottomCenter, + _ => pixstrip_core::operations::WatermarkPosition::BottomRight, + }; + if cfg.watermark_use_image { + cfg.watermark_image_path.as_ref().map(|path| { + pixstrip_core::operations::WatermarkConfig::Image { + path: path.clone(), + position, + opacity: cfg.watermark_opacity, + scale: 0.2, + } + }) + } else if !cfg.watermark_text.is_empty() { + Some(pixstrip_core::operations::WatermarkConfig::Text { + text: cfg.watermark_text.clone(), + position, + font_size: cfg.watermark_font_size, + opacity: cfg.watermark_opacity, + color: [255, 255, 255, 255], + }) + } else { + None + } + } else { + None + }; + + let rename = if cfg.rename_enabled { + Some(pixstrip_core::operations::RenameConfig { + prefix: cfg.rename_prefix.clone(), + suffix: cfg.rename_suffix.clone(), + counter_start: cfg.rename_counter_start, + counter_padding: cfg.rename_counter_padding, + template: if cfg.rename_template.is_empty() { + None + } else { + Some(cfg.rename_template.clone()) + }, + }) + } else { + None + }; + pixstrip_core::preset::Preset { name: name.to_string(), description: build_preset_description(cfg), icon: "document-save-symbolic".into(), is_custom: true, resize, - rotation: None, - flip: None, + rotation, + flip, convert, compress, metadata, - watermark: None, - rename: None, + watermark, + rename, } } @@ -1452,6 +1612,43 @@ fn update_output_summary(ui: &WizardUi) { }; ops.push(mode.to_string()); } + if cfg.watermark_enabled { + if cfg.watermark_use_image { + ops.push("Image watermark".to_string()); + } else if !cfg.watermark_text.is_empty() { + ops.push(format!("Watermark: \"{}\"", cfg.watermark_text)); + } + } + if cfg.rename_enabled { + if !cfg.rename_template.is_empty() { + ops.push(format!("Rename: {}", cfg.rename_template)); + } else if !cfg.rename_prefix.is_empty() || !cfg.rename_suffix.is_empty() { + ops.push(format!( + "Rename: {}...{}", + cfg.rename_prefix, cfg.rename_suffix + )); + } else { + ops.push("Sequential rename".to_string()); + } + } + if cfg.rotation > 0 { + let rot = match cfg.rotation { + 1 => "Rotate 90", + 2 => "Rotate 180", + 3 => "Rotate 270", + 4 => "Auto-orient", + _ => "Rotate", + }; + ops.push(rot.to_string()); + } + if cfg.flip > 0 { + let fl = match cfg.flip { + 1 => "Flip horizontal", + 2 => "Flip vertical", + _ => "Flip", + }; + ops.push(fl.to_string()); + } let summary_text = if ops.is_empty() { "No operations configured".to_string() @@ -1569,6 +1766,33 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) { dialog.present(Some(window)); } +fn apply_accessibility_settings() { + let config_store = pixstrip_core::storage::ConfigStore::new(); + let config = config_store.load().unwrap_or_default(); + + if config.high_contrast { + // Use libadwaita's high contrast mode + let style_manager = adw::StyleManager::default(); + style_manager.set_color_scheme(adw::ColorScheme::ForceLight); + // High contrast is best achieved via the GTK_THEME env or system + // settings; the app respects system high contrast automatically + } + + let settings = gtk::Settings::default().unwrap(); + + if config.large_text { + // Increase font DPI by 25% for large text mode + let current_dpi = settings.gtk_xft_dpi(); + if current_dpi > 0 { + settings.set_gtk_xft_dpi(current_dpi * 5 / 4); + } + } + + if config.reduced_motion { + settings.set_gtk_enable_animations(false); + } +} + fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes)