Add real pause, desktop notifications, undo, accessibility, CLI watch
This commit is contained in:
@@ -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::<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"), ¬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<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)
|
||||
|
||||
Reference in New Issue
Block a user