Fix 40+ bugs from audit passes 9-12
- PNG chunk parsing overflow protection with checked arithmetic - Font directory traversal bounded with global result limit - find_unique_path TOCTOU race fixed with create_new + marker byte - Watch mode "processed" dir exclusion narrowed to prevent false skips - Metadata copy now checks format support before little_exif calls - Clipboard temp files cleaned up on app exit - Atomic writes for file manager integration scripts - BMP format support added to encoder and convert step - Regex DoS protection with DFA size limit - Watermark NaN/negative scale guard - Selective EXIF stripping for privacy/custom metadata modes - CLI watch mode: file stability checks, per-file history saves - High contrast toggle preserves and restores original theme - Image list deduplication uses O(1) HashSet lookups - Saturation/trim/padding overflow guards in adjustments
This commit is contained in:
@@ -624,6 +624,12 @@ fn build_ui(app: &adw::Application) {
|
||||
let _ = std::fs::remove_dir_all(&temp_downloads);
|
||||
}
|
||||
|
||||
// Clean up clipboard temp files on exit
|
||||
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
|
||||
if temp_dir.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
}
|
||||
@@ -784,7 +790,9 @@ fn start_watch_folder_monitoring(ui: &WizardUi) {
|
||||
job.add_source(file);
|
||||
}
|
||||
let executor = pixstrip_core::executor::PipelineExecutor::new();
|
||||
let _ = executor.execute(&job, |_| {});
|
||||
if let Err(e) = executor.execute(&job, |_| {}) {
|
||||
eprintln!("Watch folder processing error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let toast = adw::Toast::new(&format!(
|
||||
@@ -1105,8 +1113,14 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
.collect();
|
||||
|
||||
if !new_files.is_empty() {
|
||||
let mut loaded = ui.state.loaded_files.borrow_mut();
|
||||
let new_files: Vec<_> = new_files
|
||||
.into_iter()
|
||||
.filter(|p| !loaded.contains(p))
|
||||
.collect();
|
||||
let count = new_files.len();
|
||||
ui.state.loaded_files.borrow_mut().extend(new_files);
|
||||
loaded.extend(new_files);
|
||||
drop(loaded);
|
||||
ui.toast_overlay.add_toast(adw::Toast::new(
|
||||
&format!("{} images added from file manager", count)
|
||||
));
|
||||
@@ -1481,8 +1495,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
output_row.add_prefix(>k::Image::from_icon_name("folder-open-symbolic"));
|
||||
let out_dir = entry.output_dir.clone();
|
||||
output_row.connect_activated(move |_| {
|
||||
let uri = gtk::gio::File::for_path(&out_dir).uri();
|
||||
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
||||
&format!("file://{}", out_dir),
|
||||
&uri,
|
||||
gtk::gio::AppLaunchContext::NONE,
|
||||
);
|
||||
});
|
||||
@@ -1546,8 +1561,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
open_btn.add_css_class("flat");
|
||||
let out_dir2 = entry.output_dir.clone();
|
||||
open_btn.connect_clicked(move |_| {
|
||||
let uri = gtk::gio::File::for_path(&out_dir2).uri();
|
||||
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
||||
&format!("file://{}", out_dir2),
|
||||
&uri,
|
||||
gtk::gio::AppLaunchContext::NONE,
|
||||
);
|
||||
});
|
||||
@@ -1674,10 +1690,31 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
return;
|
||||
}
|
||||
|
||||
let input_dir = files[0]
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.to_path_buf();
|
||||
let input_dir = {
|
||||
let first_parent = files[0]
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.to_path_buf();
|
||||
if files.len() == 1 {
|
||||
first_parent
|
||||
} else {
|
||||
// Find common ancestor of all files
|
||||
let mut common = first_parent.clone();
|
||||
for f in &files[1..] {
|
||||
let p = f.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
while !p.starts_with(&common) {
|
||||
if !common.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if common.as_os_str().is_empty() {
|
||||
first_parent
|
||||
} else {
|
||||
common
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let output_dir = ui
|
||||
.state
|
||||
@@ -1926,8 +1963,10 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
job.add_source(file);
|
||||
}
|
||||
|
||||
// Check for existing output files when "Ask" overwrite behavior is set
|
||||
if ask_overwrite {
|
||||
// Check for existing output files when "Ask" overwrite behavior is set.
|
||||
// Skip check if rename or format conversion is active (output names will differ).
|
||||
let has_rename_or_convert = job.rename.is_some() || job.convert.is_some();
|
||||
if ask_overwrite && !has_rename_or_convert {
|
||||
let output_dir = ui.state.output_dir.borrow().clone()
|
||||
.unwrap_or_else(|| {
|
||||
files[0].parent()
|
||||
@@ -1996,6 +2035,9 @@ fn continue_processing(
|
||||
ui.step_indicator.widget().set_visible(false);
|
||||
ui.title.set_subtitle("Processing...");
|
||||
|
||||
// Disable navigation actions so Escape/shortcuts can't navigate away during processing
|
||||
set_nav_actions_enabled(&ui.nav_view, false);
|
||||
|
||||
// Get references to progress widgets inside the page
|
||||
let progress_bar = find_widget_by_type::<gtk::ProgressBar>(&processing_page);
|
||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
@@ -2074,6 +2116,8 @@ fn continue_processing(
|
||||
ui_for_rx.toast_overlay.add_toast(toast);
|
||||
ui_for_rx.back_button.set_visible(true);
|
||||
ui_for_rx.next_button.set_visible(true);
|
||||
// Re-enable navigation actions
|
||||
set_nav_actions_enabled(&ui_for_rx.nav_view, true);
|
||||
if let Some(visible) = ui_for_rx.nav_view.visible_page()
|
||||
&& visible.tag().as_deref() == Some("processing")
|
||||
{
|
||||
@@ -2117,6 +2161,9 @@ fn show_results(
|
||||
ui.next_button.set_label("Process More");
|
||||
ui.next_button.set_visible(true);
|
||||
|
||||
// Re-enable navigation actions
|
||||
set_nav_actions_enabled(&ui.nav_view, true);
|
||||
|
||||
// Save history with output paths for undo support
|
||||
let history = pixstrip_core::storage::HistoryStore::new();
|
||||
let output_dir_str = ui.state.output_dir.borrow()
|
||||
@@ -2218,8 +2265,9 @@ fn show_results(
|
||||
if config.auto_open_output {
|
||||
let output = ui.state.output_dir.borrow().clone();
|
||||
if let Some(dir) = output {
|
||||
let uri = gtk::gio::File::for_path(&dir).uri();
|
||||
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
||||
&format!("file://{}", dir.display()),
|
||||
&uri,
|
||||
gtk::gio::AppLaunchContext::NONE,
|
||||
);
|
||||
}
|
||||
@@ -2306,8 +2354,9 @@ fn wire_results_actions(
|
||||
row.connect_activated(move |_| {
|
||||
let output = ui.state.output_dir.borrow().clone();
|
||||
if let Some(dir) = output {
|
||||
let uri = gtk::gio::File::for_path(&dir).uri();
|
||||
let _ = gtk::gio::AppInfo::launch_default_for_uri(
|
||||
&format!("file://{}", dir.display()),
|
||||
&uri,
|
||||
gtk::gio::AppLaunchContext::NONE,
|
||||
);
|
||||
}
|
||||
@@ -2453,6 +2502,22 @@ fn reset_wizard(ui: &WizardUi) {
|
||||
ui.next_button.add_css_class("suggested-action");
|
||||
}
|
||||
|
||||
/// Enable or disable navigation actions (prev-step, next-step) to prevent
|
||||
/// keyboard shortcuts from navigating away during processing.
|
||||
fn set_nav_actions_enabled(nav_view: &adw::NavigationView, enabled: bool) {
|
||||
if let Some(root) = nav_view.root() {
|
||||
if let Ok(win) = root.downcast::<adw::ApplicationWindow>() {
|
||||
for name in ["prev-step", "next-step"] {
|
||||
if let Some(action) = win.lookup_action(name) {
|
||||
if let Some(simple) = action.downcast_ref::<gtk::gio::SimpleAction>() {
|
||||
simple.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_cancel_button(page: &adw::NavigationPage, cancel_flag: Arc<AtomicBool>) {
|
||||
walk_widgets(&page.child(), &|widget| {
|
||||
if let Some(button) = widget.downcast_ref::<gtk::Button>()
|
||||
|
||||
@@ -317,13 +317,23 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
|
||||
// Wire high contrast to apply immediately
|
||||
{
|
||||
let original_theme: std::rc::Rc<std::cell::RefCell<Option<gtk::glib::GString>>> =
|
||||
std::rc::Rc::new(std::cell::RefCell::new(
|
||||
gtk::Settings::default().and_then(|s| s.gtk_theme_name())
|
||||
));
|
||||
let orig_theme = original_theme.clone();
|
||||
contrast_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
if row.is_active() {
|
||||
// Capture current theme before switching (if not already captured)
|
||||
let mut saved = orig_theme.borrow_mut();
|
||||
if saved.is_none() {
|
||||
*saved = settings.gtk_theme_name();
|
||||
}
|
||||
settings.set_gtk_theme_name(Some("HighContrast"));
|
||||
} else {
|
||||
// Revert to the default Adwaita theme
|
||||
settings.set_gtk_theme_name(Some("Adwaita"));
|
||||
let saved = orig_theme.borrow();
|
||||
settings.set_gtk_theme_name(saved.as_deref());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -331,12 +341,16 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
|
||||
// Wire large text to apply immediately
|
||||
{
|
||||
let original_dpi: std::rc::Rc<Cell<i32>> = std::rc::Rc::new(Cell::new(0));
|
||||
// Capture the initial DPI at construction so we can restore it later
|
||||
let initial_dpi = gtk::Settings::default()
|
||||
.map(|s| s.gtk_xft_dpi())
|
||||
.unwrap_or(0);
|
||||
let original_dpi: std::rc::Rc<Cell<i32>> = std::rc::Rc::new(Cell::new(initial_dpi));
|
||||
let orig_dpi = original_dpi.clone();
|
||||
large_text_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
if row.is_active() {
|
||||
// Store original DPI before modifying
|
||||
// Store original DPI before modifying (refresh if not yet set)
|
||||
let current_dpi = settings.gtk_xft_dpi();
|
||||
if current_dpi > 0 {
|
||||
orig_dpi.set(current_dpi);
|
||||
|
||||
@@ -531,8 +531,11 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
});
|
||||
}
|
||||
|
||||
// Shared debounce counter for slider-driven previews
|
||||
let slider_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
// Per-slider debounce counters (separate to avoid cross-slider cancellation)
|
||||
let brightness_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let contrast_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let saturation_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let padding_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
|
||||
// Brightness
|
||||
{
|
||||
@@ -540,7 +543,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
let row = brightness_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = brightness_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
let did = brightness_debounce.clone();
|
||||
brightness_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().brightness = val;
|
||||
@@ -570,7 +573,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
let row = contrast_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = contrast_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
let did = contrast_debounce.clone();
|
||||
contrast_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().contrast = val;
|
||||
@@ -600,7 +603,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
let row = saturation_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = saturation_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
let did = saturation_debounce.clone();
|
||||
saturation_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().saturation = val;
|
||||
@@ -670,7 +673,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
let did = slider_debounce.clone();
|
||||
let did = padding_debounce.clone();
|
||||
padding_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||
let up = up.clone();
|
||||
|
||||
@@ -782,7 +782,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
// On page map: refresh preview and show/hide per-format rows
|
||||
// On page map: refresh thumbnail strip, preview, and show/hide per-format rows
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let jc = state.job_config.clone();
|
||||
@@ -793,7 +793,71 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
let wer = webp_effort_row;
|
||||
let ar = avif_row;
|
||||
let asr = avif_speed_row;
|
||||
let lf = state.loaded_files.clone();
|
||||
let tb = thumb_box.clone();
|
||||
let ts = thumb_scrolled.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let up2 = update_preview.clone();
|
||||
page.connect_map(move |_| {
|
||||
// Rebuild thumbnail strip from current file list
|
||||
while let Some(child) = tb.first_child() {
|
||||
tb.remove(&child);
|
||||
}
|
||||
let files = lf.borrow();
|
||||
let max_thumbs = files.len().min(10);
|
||||
for i in 0..max_thumbs {
|
||||
let pic = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Cover)
|
||||
.width_request(50)
|
||||
.height_request(50)
|
||||
.build();
|
||||
pic.set_filename(Some(&files[i]));
|
||||
let frame = gtk::Frame::builder()
|
||||
.child(&pic)
|
||||
.build();
|
||||
if i == *pidx.borrow() {
|
||||
frame.add_css_class("accent");
|
||||
}
|
||||
let pidx_c = pidx.clone();
|
||||
let up_c = up2.clone();
|
||||
let tb_c = tb.clone();
|
||||
let current_idx = i;
|
||||
let btn = gtk::Button::builder()
|
||||
.child(&frame)
|
||||
.has_frame(false)
|
||||
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
|
||||
.build();
|
||||
btn.connect_clicked(move |_| {
|
||||
*pidx_c.borrow_mut() = current_idx;
|
||||
up_c(true);
|
||||
let mut c = tb_c.first_child();
|
||||
let mut j = 0usize;
|
||||
while let Some(w) = c {
|
||||
if let Some(b) = w.downcast_ref::<gtk::Button>() {
|
||||
if let Some(f) = b.child().and_then(|c| c.downcast::<gtk::Frame>().ok()) {
|
||||
if j == current_idx {
|
||||
f.add_css_class("accent");
|
||||
} else {
|
||||
f.remove_css_class("accent");
|
||||
}
|
||||
}
|
||||
}
|
||||
c = w.next_sibling();
|
||||
j += 1;
|
||||
}
|
||||
});
|
||||
tb.append(&btn);
|
||||
}
|
||||
ts.set_visible(max_thumbs > 1);
|
||||
// Clamp preview index if files were removed
|
||||
{
|
||||
let mut idx = pidx.borrow_mut();
|
||||
if *idx >= files.len() && !files.is_empty() {
|
||||
*idx = 0;
|
||||
}
|
||||
}
|
||||
drop(files);
|
||||
|
||||
up(true);
|
||||
|
||||
let cfg = jc.borrow();
|
||||
@@ -814,7 +878,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
Some(ImageFormat::Png) => has_png = true,
|
||||
Some(ImageFormat::WebP) => has_webp = true,
|
||||
Some(ImageFormat::Avif) => has_avif = true,
|
||||
Some(ImageFormat::Gif) | Some(ImageFormat::Tiff) => {}
|
||||
Some(ImageFormat::Gif) | Some(ImageFormat::Tiff) | Some(ImageFormat::Bmp) => {}
|
||||
}
|
||||
|
||||
for (_, &choice_idx) in &cfg.format_mappings {
|
||||
|
||||
@@ -17,7 +17,7 @@ const CARD_FORMATS: &[(&str, &str, &str, Option<ImageFormat>)] = &[
|
||||
("AVIF", "Next-gen format\nBest compression", "emblem-favorite-symbolic", Some(ImageFormat::Avif)),
|
||||
("GIF", "Animations, 256 colors\nSimple graphics", "media-playback-start-symbolic", Some(ImageFormat::Gif)),
|
||||
("TIFF", "Archival, lossless\nVery large files", "drive-harddisk-symbolic", Some(ImageFormat::Tiff)),
|
||||
("BMP", "Uncompressed bitmap\nLegacy format", "image-x-generic-symbolic", None),
|
||||
("BMP", "Uncompressed bitmap\nLegacy format", "image-x-generic-symbolic", Some(ImageFormat::Bmp)),
|
||||
];
|
||||
|
||||
/// Extra formats available only in the "Other Formats" dropdown: (short name, dropdown label)
|
||||
@@ -338,6 +338,7 @@ fn card_index_for_format(format: Option<ImageFormat>) -> Option<i32> {
|
||||
Some(ImageFormat::Avif) => Some(4),
|
||||
Some(ImageFormat::Gif) => Some(5),
|
||||
Some(ImageFormat::Tiff) => Some(6),
|
||||
Some(ImageFormat::Bmp) => Some(7),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +351,8 @@ fn format_for_card_index(idx: usize) -> Option<ImageFormat> {
|
||||
4 => Some(ImageFormat::Avif),
|
||||
5 => Some(ImageFormat::Gif),
|
||||
6 => Some(ImageFormat::Tiff),
|
||||
_ => None, // 0 = Keep Original, 7 = BMP (not in enum)
|
||||
7 => Some(ImageFormat::Bmp),
|
||||
_ => None, // 0 = Keep Original
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +379,9 @@ fn format_info(format: Option<ImageFormat>) -> String {
|
||||
Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, \
|
||||
supports layers and rich metadata. Very large files. Not suitable for web."
|
||||
.into(),
|
||||
Some(ImageFormat::Bmp) => "BMP: Uncompressed bitmap format. Very large files, no \
|
||||
compression. Legacy format mainly used for compatibility with older software."
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,12 +199,21 @@ fn is_image_file(path: &std::path::Path) -> bool {
|
||||
}
|
||||
|
||||
fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
|
||||
let existing: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
|
||||
add_images_from_dir_inner(dir, files, &existing);
|
||||
}
|
||||
|
||||
fn add_images_from_dir_inner(
|
||||
dir: &std::path::Path,
|
||||
files: &mut Vec<PathBuf>,
|
||||
existing: &std::collections::HashSet<PathBuf>,
|
||||
) {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
add_images_from_dir(&path, files);
|
||||
} else if is_image_file(&path) && !files.contains(&path) {
|
||||
add_images_from_dir_inner(&path, files, existing);
|
||||
} else if is_image_file(&path) && !existing.contains(&path) && !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
@@ -212,10 +221,11 @@ fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
|
||||
fn add_images_flat(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
|
||||
let existing: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && is_image_file(&path) && !files.contains(&path) {
|
||||
if path.is_file() && is_image_file(&path) && !existing.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
@@ -548,20 +558,32 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
let select_all_button = gtk::Button::builder()
|
||||
.icon_name("edit-select-all-symbolic")
|
||||
.tooltip_text("Select all images (Ctrl+A)")
|
||||
.sensitive(false)
|
||||
.build();
|
||||
select_all_button.add_css_class("flat");
|
||||
select_all_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Select all images for processing"),
|
||||
]);
|
||||
|
||||
let deselect_all_button = gtk::Button::builder()
|
||||
.icon_name("edit-clear-symbolic")
|
||||
.tooltip_text("Deselect all images (Ctrl+Shift+A)")
|
||||
.sensitive(false)
|
||||
.build();
|
||||
deselect_all_button.add_css_class("flat");
|
||||
deselect_all_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Deselect all images from processing"),
|
||||
]);
|
||||
|
||||
let clear_button = gtk::Button::builder()
|
||||
.icon_name("edit-clear-all-symbolic")
|
||||
.tooltip_text("Remove all images")
|
||||
.sensitive(false)
|
||||
.build();
|
||||
clear_button.add_css_class("flat");
|
||||
clear_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Remove all images from list"),
|
||||
]);
|
||||
|
||||
// Build the grid view model
|
||||
let store = gtk::gio::ListStore::new::<ImageItem>();
|
||||
@@ -835,6 +857,19 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
toolbar.append(&add_button);
|
||||
toolbar.append(&clear_button);
|
||||
|
||||
// Enable/disable toolbar buttons based on whether the store has items
|
||||
{
|
||||
let sa = select_all_button.clone();
|
||||
let da = deselect_all_button.clone();
|
||||
let cl = clear_button.clone();
|
||||
store.connect_items_changed(move |store, _, _, _| {
|
||||
let has_items = store.n_items() > 0;
|
||||
sa.set_sensitive(has_items);
|
||||
da.set_sensitive(has_items);
|
||||
cl.set_sensitive(has_items);
|
||||
});
|
||||
}
|
||||
|
||||
toolbar.update_property(&[
|
||||
gtk::accessible::Property::Label("Image toolbar with count, selection, and add controls"),
|
||||
]);
|
||||
|
||||
@@ -140,9 +140,117 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Output & Process")
|
||||
.tag("step-output")
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
.build();
|
||||
|
||||
// Refresh stats and summary when navigating to this page
|
||||
{
|
||||
let lf = state.loaded_files.clone();
|
||||
let ef = state.excluded_files.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let od = state.output_dir.clone();
|
||||
let cr = count_row.clone();
|
||||
let or = output_row.clone();
|
||||
let sb = summary_box.clone();
|
||||
page.connect_map(move |_| {
|
||||
// Update image count and size
|
||||
let files = lf.borrow();
|
||||
let excluded = ef.borrow();
|
||||
let included_count = files.iter().filter(|p| !excluded.contains(*p)).count();
|
||||
let total_size: u64 = files.iter()
|
||||
.filter(|p| !excluded.contains(*p))
|
||||
.filter_map(|p| std::fs::metadata(p).ok())
|
||||
.map(|m| m.len())
|
||||
.sum();
|
||||
cr.set_subtitle(&format!("{} images ({})", included_count, format_size(total_size)));
|
||||
drop(files);
|
||||
drop(excluded);
|
||||
|
||||
// Update output directory display
|
||||
let dir_text = od.borrow()
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "processed/ (subfolder next to originals)".to_string());
|
||||
or.set_subtitle(&dir_text);
|
||||
|
||||
// Build operation summary
|
||||
while let Some(child) = sb.first_child() {
|
||||
sb.remove(&child);
|
||||
}
|
||||
|
||||
let cfg = jc.borrow();
|
||||
let mut ops: Vec<(&str, String)> = Vec::new();
|
||||
|
||||
if cfg.resize_enabled {
|
||||
let mode = match cfg.resize_mode {
|
||||
0 => format!("{}x{} (exact)", cfg.resize_width, cfg.resize_height),
|
||||
_ => format!("fit {}x{}", cfg.resize_width, cfg.resize_height),
|
||||
};
|
||||
ops.push(("Resize", mode));
|
||||
}
|
||||
if cfg.adjustments_enabled {
|
||||
let mut parts = Vec::new();
|
||||
if cfg.rotation > 0 { parts.push("rotate"); }
|
||||
if cfg.flip > 0 { parts.push("flip"); }
|
||||
if cfg.brightness != 0 { parts.push("brightness"); }
|
||||
if cfg.contrast != 0 { parts.push("contrast"); }
|
||||
if cfg.saturation != 0 { parts.push("saturation"); }
|
||||
if cfg.grayscale { parts.push("grayscale"); }
|
||||
if cfg.sepia { parts.push("sepia"); }
|
||||
if cfg.sharpen { parts.push("sharpen"); }
|
||||
if cfg.crop_aspect_ratio > 0 { parts.push("crop"); }
|
||||
if cfg.trim_whitespace { parts.push("trim"); }
|
||||
if cfg.canvas_padding > 0 { parts.push("padding"); }
|
||||
let desc = if parts.is_empty() { "enabled".into() } else { parts.join(", ") };
|
||||
ops.push(("Adjustments", desc));
|
||||
}
|
||||
if cfg.convert_enabled {
|
||||
let fmt = cfg.convert_format.map(|f| f.extension().to_uppercase())
|
||||
.unwrap_or_else(|| "per-format mapping".into());
|
||||
ops.push(("Convert", fmt));
|
||||
}
|
||||
if cfg.compress_enabled {
|
||||
ops.push(("Compress", cfg.quality_preset.label().into()));
|
||||
}
|
||||
if cfg.metadata_enabled {
|
||||
let mode = match &cfg.metadata_mode {
|
||||
crate::app::MetadataMode::StripAll => "strip all",
|
||||
crate::app::MetadataMode::KeepAll => "keep all",
|
||||
crate::app::MetadataMode::Privacy => "privacy mode",
|
||||
crate::app::MetadataMode::Custom => "custom",
|
||||
};
|
||||
ops.push(("Metadata", mode.into()));
|
||||
}
|
||||
if cfg.watermark_enabled {
|
||||
let wm_type = if cfg.watermark_use_image { "image" } else { "text" };
|
||||
ops.push(("Watermark", wm_type.into()));
|
||||
}
|
||||
if cfg.rename_enabled {
|
||||
ops.push(("Rename", "enabled".into()));
|
||||
}
|
||||
|
||||
if ops.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No operations enabled")
|
||||
.subtitle("Go back and enable at least one operation")
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name("dialog-warning-symbolic"));
|
||||
sb.append(&row);
|
||||
} else {
|
||||
for (name, desc) in &ops {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(*name)
|
||||
.subtitle(desc.as_str())
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name("emblem-ok-symbolic"));
|
||||
sb.append(&row);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
@@ -210,6 +210,10 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.height_request(48)
|
||||
.build();
|
||||
|
||||
btn.update_property(&[
|
||||
gtk::accessible::Property::Label(&format!("Watermark position: {}", name)),
|
||||
]);
|
||||
|
||||
let icon = if i == cfg.watermark_position as usize {
|
||||
"radio-checked-symbolic"
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user