From 8f6e4382c4c567bfe33966735d865e584c3511fa Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 12:58:43 +0200 Subject: [PATCH] Add visual format cards, per-image remove, shortcuts dialog, wire threads Convert step: replace ComboRow with visual format card grid showing icon, name, and description for each format. Much more beginner-friendly. Images step: add per-image remove button on each file row so users can exclude individual images from the batch. Shortcuts: use adw::Dialog with structured layout since GtkShortcutsWindow is deprecated in GTK 4.18+. Add file management and undo shortcuts. Settings: wire thread count selection to actually save/restore the ThreadCount config value instead of always defaulting to Auto. --- pixstrip-gtk/src/app.rs | 47 +++++--- pixstrip-gtk/src/settings.rs | 15 ++- pixstrip-gtk/src/steps/step_convert.rs | 153 +++++++++++++++++++------ pixstrip-gtk/src/steps/step_images.rs | 50 ++++++-- 4 files changed, 207 insertions(+), 58 deletions(-) diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 59f98f8..d298994 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -1762,31 +1762,49 @@ fn walk_widgets(widget: &Option, f: &dyn Fn(>k::Widget)) { fn show_shortcuts_window(window: &adw::ApplicationWindow) { - let dialog = adw::AlertDialog::builder() - .heading("Keyboard Shortcuts") + let dialog = adw::Dialog::builder() + .title("Keyboard Shortcuts") + .content_width(400) + .content_height(500) + .build(); + + let toolbar_view = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + toolbar_view.add_top_bar(&header); + + let scrolled = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) .build(); let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(12) + .spacing(0) .margin_start(12) .margin_end(12) + .margin_top(12) + .margin_bottom(12) .build(); let sections: &[(&str, &[(&str, &str)])] = &[ ("Wizard Navigation", &[ - ("Alt+Right", "Next step"), - ("Alt+Left", "Previous step"), - ("Alt+1..9", "Jump to step"), - ("Ctrl+Enter", "Process images"), + ("Alt + Right", "Next step"), + ("Alt + Left", "Previous step"), + ("Alt + 1...9", "Jump to step by number"), + ("Ctrl + Enter", "Process images"), + ("Escape", "Cancel or go back"), ]), ("File Management", &[ - ("Ctrl+O", "Add files"), + ("Ctrl + O", "Add files"), + ("Ctrl + A", "Select all images"), + ("Ctrl + Shift + A", "Deselect all images"), + ("Delete", "Remove selected images"), ]), ("Application", &[ - ("Ctrl+,", "Settings"), - ("Ctrl+? / F1", "Keyboard shortcuts"), - ("Ctrl+Q", "Quit"), + ("Ctrl + ,", "Settings"), + ("F1", "Keyboard shortcuts"), + ("Ctrl + Z", "Undo last batch"), + ("Ctrl + Q", "Quit"), ]), ]; @@ -1802,6 +1820,7 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) { let label = gtk::Label::builder() .label(*accel) .css_classes(["monospace", "dim-label"]) + .valign(gtk::Align::Center) .build(); row.add_suffix(&label); group.add(&row); @@ -1810,9 +1829,9 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) { content.append(&group); } - dialog.set_extra_child(Some(&content)); - dialog.add_response("close", "Close"); - dialog.set_default_response(Some("close")); + scrolled.set_child(Some(&content)); + toolbar_view.set_content(Some(&scrolled)); + dialog.set_child(Some(&toolbar_view)); dialog.present(Some(window)); } diff --git a/pixstrip-gtk/src/settings.rs b/pixstrip-gtk/src/settings.rs index f440a6f..680b37a 100644 --- a/pixstrip-gtk/src/settings.rs +++ b/pixstrip-gtk/src/settings.rs @@ -89,6 +89,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { .build(); let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]); threads_row.set_model(Some(&threads_model)); + threads_row.set_selected(match config.thread_count { + pixstrip_core::config::ThreadCount::Auto => 0, + pixstrip_core::config::ThreadCount::Manual(1) => 1, + pixstrip_core::config::ThreadCount::Manual(2) => 2, + pixstrip_core::config::ThreadCount::Manual(n) if n <= 4 => 3, + pixstrip_core::config::ThreadCount::Manual(_) => 4, + }); let error_row = adw::ComboRow::builder() .title("On error") @@ -192,7 +199,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { 1 => SkillLevel::Detailed, _ => SkillLevel::Simple, }, - thread_count: pixstrip_core::config::ThreadCount::Auto, + thread_count: match threads_row.selected() { + 1 => pixstrip_core::config::ThreadCount::Manual(1), + 2 => pixstrip_core::config::ThreadCount::Manual(2), + 3 => pixstrip_core::config::ThreadCount::Manual(4), + 4 => pixstrip_core::config::ThreadCount::Manual(8), + _ => pixstrip_core::config::ThreadCount::Auto, + }, error_behavior: match error_row.selected() { 1 => ErrorBehavior::PauseOnError, _ => ErrorBehavior::SkipAndContinue, diff --git a/pixstrip-gtk/src/steps/step_convert.rs b/pixstrip-gtk/src/steps/step_convert.rs index 9e46e8f..1cc2c4a 100644 --- a/pixstrip-gtk/src/steps/step_convert.rs +++ b/pixstrip-gtk/src/steps/step_convert.rs @@ -30,29 +30,85 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { enable_group.add(&enable_row); content.append(&enable_group); - // Format selection - let format_group = adw::PreferencesGroup::builder() + // Visual format cards grid + let cards_group = adw::PreferencesGroup::builder() .title("Output Format") .description("Choose the format all images will be converted to") .build(); - let format_row = adw::ComboRow::builder() - .title("Convert to") - .subtitle("Choose the output format for all images") + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .max_children_per_line(4) + .min_children_per_line(2) + .row_spacing(8) + .column_spacing(8) + .homogeneous(true) + .margin_top(4) + .margin_bottom(4) .build(); - let format_model = gtk::StringList::new(&[ - "Keep Original", - "JPEG - universal, lossy, photos", - "PNG - lossless, graphics, transparency", - "WebP - modern, excellent compression", - "AVIF - next-gen, best compression", - "GIF - animations, limited colors", - "TIFF - archival, lossless, large files", - ]); - format_row.set_model(Some(&format_model)); - // Set initial selection - format_row.set_selected(match cfg.convert_format { + let formats: &[(&str, &str, &str, Option)] = &[ + ("Keep Original", "No conversion", "edit-copy-symbolic", None), + ("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)), + ("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)), + ("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)), + ("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)), + ]; + + // Track which card should be initially selected + let initial_format = cfg.convert_format; + + for (name, desc, icon_name, _fmt) in formats { + let card = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + card.add_css_class("card"); + card.set_size_request(130, 110); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .margin_top(12) + .margin_bottom(12) + .margin_start(8) + .margin_end(8) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .vexpand(true) + .build(); + + let icon = gtk::Image::builder() + .icon_name(*icon_name) + .pixel_size(28) + .build(); + + let name_label = gtk::Label::builder() + .label(*name) + .css_classes(["heading"]) + .build(); + + let desc_label = gtk::Label::builder() + .label(*desc) + .css_classes(["caption", "dim-label"]) + .wrap(true) + .justify(gtk::Justification::Center) + .max_width_chars(18) + .build(); + + inner.append(&icon); + inner.append(&name_label); + inner.append(&desc_label); + card.append(&inner); + flow.append(&card); + } + + // Select the initial card + let initial_idx = match initial_format { None => 0, Some(ImageFormat::Jpeg) => 1, Some(ImageFormat::Png) => 2, @@ -60,22 +116,46 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { Some(ImageFormat::Avif) => 4, Some(ImageFormat::Gif) => 5, Some(ImageFormat::Tiff) => 6, - }); + }; + if let Some(child) = flow.child_at_index(initial_idx) { + flow.select_child(&child); + } - format_group.add(&format_row); - - // Format info label + // Format info label (updates based on selection) let info_label = gtk::Label::builder() .label(format_info(cfg.convert_format)) - .css_classes(["dim-label", "caption"]) + .css_classes(["dim-label"]) .halign(gtk::Align::Start) .wrap(true) - .margin_top(4) - .margin_bottom(8) - .margin_start(12) + .margin_top(8) + .margin_bottom(4) + .margin_start(4) .build(); - format_group.add(&info_label); - content.append(&format_group); + + cards_group.add(&flow); + cards_group.add(&info_label); + content.append(&cards_group); + + // Advanced options expander + let advanced_group = adw::PreferencesGroup::builder() + .title("Advanced Options") + .build(); + + let advanced_expander = adw::ExpanderRow::builder() + .title("Format Mapping") + .subtitle("Different input formats can convert to different outputs") + .show_enable_switch(false) + .build(); + + let progressive_row = adw::SwitchRow::builder() + .title("Progressive JPEG") + .subtitle("Loads gradually in browsers, slightly larger") + .active(false) + .build(); + + advanced_expander.add_row(&progressive_row); + advanced_group.add(&advanced_expander); + content.append(&advanced_group); drop(cfg); @@ -89,9 +169,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { { let jc = state.job_config.clone(); let label = info_label; - format_row.connect_selected_notify(move |row| { + flow.connect_child_activated(move |_flow, child| { + let idx = child.index() as usize; let mut c = jc.borrow_mut(); - c.convert_format = match row.selected() { + c.convert_format = match idx { 1 => Some(ImageFormat::Jpeg), 2 => Some(ImageFormat::Png), 3 => Some(ImageFormat::WebP), @@ -120,12 +201,12 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { fn format_info(format: Option) -> String { match format { - None => "Images will keep their original format.".into(), - Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, no transparency. Universally supported.".into(), - Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, logos. Lossless, supports transparency. Larger files.".into(), - Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless compression. Supports transparency and animation. Widely supported in browsers.".into(), - Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1. Best compression ratios, supports transparency and HDR. Slower to encode, growing browser support.".into(), - Some(ImageFormat::Gif) => "GIF: Limited to 256 colors. Supports animation and transparency. Best for simple graphics and short animations.".into(), - Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless, supports layers and metadata. Very large files. Not suitable for web use.".into(), + None => "Images will keep their original format. No conversion applied.".into(), + Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, no transparency support. Universally compatible with all devices and browsers.".into(), + Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. Lossless compression, supports full transparency. Produces larger files than JPEG or WebP.".into(), + Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless compression. Supports transparency and animation. Widely supported in modern browsers.".into(), + Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. Best compression ratios available. Supports transparency and HDR. Slower to encode, growing browser support.".into(), + Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic animation and binary transparency. Best for simple graphics and short animations.".into(), + Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, supports layers and rich metadata. Very large files. Not suitable for web.".into(), } } diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index ead98d4..3ce54f0 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -28,7 +28,6 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { && let Some(path) = file.path() { if path.is_dir() { - // Recursively add images from directory let mut files = loaded_files.borrow_mut(); add_images_from_dir(&path, &mut files); let count = files.len(); @@ -106,11 +105,16 @@ fn update_count_and_list( .sum(); let size_str = format_size(total_size); - // Walk widget tree to find and update components - walk_loaded_widgets(widget, count, &size_str, &files); + walk_loaded_widgets(widget, count, &size_str, &files, loaded_files); } -fn walk_loaded_widgets(widget: >k::Widget, count: usize, size_str: &str, files: &[std::path::PathBuf]) { +fn walk_loaded_widgets( + widget: >k::Widget, + count: usize, + size_str: &str, + files: &[std::path::PathBuf], + loaded_files: &std::rc::Rc>>, +) { if let Some(label) = widget.downcast_ref::() && label.css_classes().iter().any(|c| c == "heading") { @@ -123,8 +127,8 @@ fn walk_loaded_widgets(widget: >k::Widget, count: usize, size_str: &str, files while let Some(row) = list_box.first_child() { list_box.remove(&row); } - // Add rows for each file - for path in files { + // Add rows for each file with remove button + for (idx, path) in files.iter().enumerate() { let name = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); @@ -140,13 +144,45 @@ fn walk_loaded_widgets(widget: >k::Widget, count: usize, size_str: &str, files .subtitle(format!("{} - {}", ext, size)) .build(); row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); + + // Per-image remove button + let remove_btn = gtk::Button::builder() + .icon_name("list-remove-symbolic") + .tooltip_text("Remove this image from batch") + .valign(gtk::Align::Center) + .build(); + remove_btn.add_css_class("flat"); + { + let loaded = loaded_files.clone(); + let list = list_box.clone(); + let file_idx = idx; + remove_btn.connect_clicked(move |_btn| { + let mut files = loaded.borrow_mut(); + if file_idx < files.len() { + files.remove(file_idx); + } + let count = files.len(); + drop(files); + // Refresh by finding the parent stack + if let Some(parent) = list.ancestor(gtk::Stack::static_type()) + && let Some(stack) = parent.downcast_ref::() + { + if count == 0 { + stack.set_visible_child_name("empty"); + } else if let Some(loaded_widget) = stack.child_by_name("loaded") { + update_count_and_list(&loaded_widget, &loaded); + } + } + }); + } + row.add_suffix(&remove_btn); list_box.append(&row); } } // Recurse let mut child = widget.first_child(); while let Some(c) = child { - walk_loaded_widgets(&c, count, size_str, files); + walk_loaded_widgets(&c, count, size_str, files, loaded_files); child = c.next_sibling(); } }