Add real pause, desktop notifications, undo, accessibility, CLI watch

This commit is contained in:
2026-03-06 12:38:16 +02:00
parent b21b9edb36
commit 9efcbd082e
5 changed files with 753 additions and 26 deletions

220
Cargo.lock generated
View File

@@ -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"

View File

@@ -7,3 +7,6 @@ license.workspace = true
[dependencies]
pixstrip-core = { workspace = true }
clap = { version = "4", features = ["derive"] }
trash = "5"
dirs = "6"
serde_json = "1"

View File

@@ -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<pixstrip_core::watcher::WatchFolder> = 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<pixstrip_core::watcher::WatchFolder> = 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 <path> --preset <name>' 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<pixstrip_core::watcher::WatchFolder> = 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<pixstrip_core::watcher::WatchFolder> = 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 {

View File

@@ -32,17 +32,32 @@ pub struct BatchResult {
pub struct PipelineExecutor {
cancel_flag: Arc<AtomicBool>,
pause_flag: Arc<AtomicBool>,
}
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<AtomicBool>) -> Self {
Self { cancel_flag }
Self {
cancel_flag,
pause_flag: Arc::new(AtomicBool::new(false)),
}
}
pub fn with_cancel_and_pause(
cancel_flag: Arc<AtomicBool>,
pause_flag: Arc<AtomicBool>,
) -> Self {
Self {
cancel_flag,
pause_flag,
}
}
pub fn execute<F>(&self, job: &ProcessingJob, mut on_progress: F) -> Result<BatchResult>
@@ -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()

View File

@@ -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(&gtk::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::<gtk::ProgressBar>(&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::<ProcessingMessage>();
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<String> = 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"), &notification);
}
}
// 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<AtomicBool>)
});
}
fn wire_pause_button(page: &adw::NavigationPage) {
fn wire_pause_button(page: &adw::NavigationPage, pause_flag: Arc<AtomicBool>) {
walk_widgets(&page.child(), &|widget| {
if let Some(button) = widget.downcast_ref::<gtk::Button>()
&& 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)