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.
This commit is contained in:
2026-03-06 12:58:43 +02:00
parent 29770be8b5
commit 8f6e4382c4
4 changed files with 207 additions and 58 deletions

View File

@@ -1762,30 +1762,48 @@ fn walk_widgets(widget: &Option<gtk::Widget>, f: &dyn Fn(&gtk::Widget)) {
fn show_shortcuts_window(window: &adw::ApplicationWindow) { fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let dialog = adw::AlertDialog::builder() let dialog = adw::Dialog::builder()
.heading("Keyboard Shortcuts") .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(); .build();
let content = gtk::Box::builder() let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(12) .spacing(0)
.margin_start(12) .margin_start(12)
.margin_end(12) .margin_end(12)
.margin_top(12)
.margin_bottom(12)
.build(); .build();
let sections: &[(&str, &[(&str, &str)])] = &[ let sections: &[(&str, &[(&str, &str)])] = &[
("Wizard Navigation", &[ ("Wizard Navigation", &[
("Alt + Right", "Next step"), ("Alt + Right", "Next step"),
("Alt + Left", "Previous step"), ("Alt + Left", "Previous step"),
("Alt+1..9", "Jump to step"), ("Alt + 1...9", "Jump to step by number"),
("Ctrl + Enter", "Process images"), ("Ctrl + Enter", "Process images"),
("Escape", "Cancel or go back"),
]), ]),
("File Management", &[ ("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", &[ ("Application", &[
("Ctrl + ,", "Settings"), ("Ctrl + ,", "Settings"),
("Ctrl+? / F1", "Keyboard shortcuts"), ("F1", "Keyboard shortcuts"),
("Ctrl + Z", "Undo last batch"),
("Ctrl + Q", "Quit"), ("Ctrl + Q", "Quit"),
]), ]),
]; ];
@@ -1802,6 +1820,7 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let label = gtk::Label::builder() let label = gtk::Label::builder()
.label(*accel) .label(*accel)
.css_classes(["monospace", "dim-label"]) .css_classes(["monospace", "dim-label"])
.valign(gtk::Align::Center)
.build(); .build();
row.add_suffix(&label); row.add_suffix(&label);
group.add(&row); group.add(&row);
@@ -1810,9 +1829,9 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) {
content.append(&group); content.append(&group);
} }
dialog.set_extra_child(Some(&content)); scrolled.set_child(Some(&content));
dialog.add_response("close", "Close"); toolbar_view.set_content(Some(&scrolled));
dialog.set_default_response(Some("close")); dialog.set_child(Some(&toolbar_view));
dialog.present(Some(window)); dialog.present(Some(window));
} }

View File

@@ -89,6 +89,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.build(); .build();
let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]); let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]);
threads_row.set_model(Some(&threads_model)); 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() let error_row = adw::ComboRow::builder()
.title("On error") .title("On error")
@@ -192,7 +199,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
1 => SkillLevel::Detailed, 1 => SkillLevel::Detailed,
_ => SkillLevel::Simple, _ => 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() { error_behavior: match error_row.selected() {
1 => ErrorBehavior::PauseOnError, 1 => ErrorBehavior::PauseOnError,
_ => ErrorBehavior::SkipAndContinue, _ => ErrorBehavior::SkipAndContinue,

View File

@@ -30,29 +30,85 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
enable_group.add(&enable_row); enable_group.add(&enable_row);
content.append(&enable_group); content.append(&enable_group);
// Format selection // Visual format cards grid
let format_group = adw::PreferencesGroup::builder() let cards_group = adw::PreferencesGroup::builder()
.title("Output Format") .title("Output Format")
.description("Choose the format all images will be converted to") .description("Choose the format all images will be converted to")
.build(); .build();
let format_row = adw::ComboRow::builder() let flow = gtk::FlowBox::builder()
.title("Convert to") .selection_mode(gtk::SelectionMode::Single)
.subtitle("Choose the output format for all images") .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(); .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 let formats: &[(&str, &str, &str, Option<ImageFormat>)] = &[
format_row.set_selected(match cfg.convert_format { ("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, None => 0,
Some(ImageFormat::Jpeg) => 1, Some(ImageFormat::Jpeg) => 1,
Some(ImageFormat::Png) => 2, Some(ImageFormat::Png) => 2,
@@ -60,22 +116,46 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
Some(ImageFormat::Avif) => 4, Some(ImageFormat::Avif) => 4,
Some(ImageFormat::Gif) => 5, Some(ImageFormat::Gif) => 5,
Some(ImageFormat::Tiff) => 6, 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 (updates based on selection)
// Format info label
let info_label = gtk::Label::builder() let info_label = gtk::Label::builder()
.label(format_info(cfg.convert_format)) .label(format_info(cfg.convert_format))
.css_classes(["dim-label", "caption"]) .css_classes(["dim-label"])
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.wrap(true) .wrap(true)
.margin_top(4) .margin_top(8)
.margin_bottom(8) .margin_bottom(4)
.margin_start(12) .margin_start(4)
.build(); .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); drop(cfg);
@@ -89,9 +169,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let label = info_label; 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(); let mut c = jc.borrow_mut();
c.convert_format = match row.selected() { c.convert_format = match idx {
1 => Some(ImageFormat::Jpeg), 1 => Some(ImageFormat::Jpeg),
2 => Some(ImageFormat::Png), 2 => Some(ImageFormat::Png),
3 => Some(ImageFormat::WebP), 3 => Some(ImageFormat::WebP),
@@ -120,12 +201,12 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
fn format_info(format: Option<ImageFormat>) -> String { fn format_info(format: Option<ImageFormat>) -> String {
match format { match format {
None => "Images will keep their original format.".into(), None => "Images will keep their original format. No conversion applied.".into(),
Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, no transparency. Universally supported.".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, logos. Lossless, supports transparency. Larger files.".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 browsers.".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. Best compression ratios, supports transparency and HDR. Slower to encode, growing browser support.".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. Supports animation and transparency. Best for simple graphics and short animations.".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, supports layers and metadata. Very large files. Not suitable for web use.".into(), Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, supports layers and rich metadata. Very large files. Not suitable for web.".into(),
} }
} }

View File

@@ -28,7 +28,6 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
&& let Some(path) = file.path() && let Some(path) = file.path()
{ {
if path.is_dir() { if path.is_dir() {
// Recursively add images from directory
let mut files = loaded_files.borrow_mut(); let mut files = loaded_files.borrow_mut();
add_images_from_dir(&path, &mut files); add_images_from_dir(&path, &mut files);
let count = files.len(); let count = files.len();
@@ -106,11 +105,16 @@ fn update_count_and_list(
.sum(); .sum();
let size_str = format_size(total_size); let size_str = format_size(total_size);
// Walk widget tree to find and update components walk_loaded_widgets(widget, count, &size_str, &files, loaded_files);
walk_loaded_widgets(widget, count, &size_str, &files);
} }
fn walk_loaded_widgets(widget: &gtk::Widget, count: usize, size_str: &str, files: &[std::path::PathBuf]) { fn walk_loaded_widgets(
widget: &gtk::Widget,
count: usize,
size_str: &str,
files: &[std::path::PathBuf],
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
) {
if let Some(label) = widget.downcast_ref::<gtk::Label>() if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& label.css_classes().iter().any(|c| c == "heading") && label.css_classes().iter().any(|c| c == "heading")
{ {
@@ -123,8 +127,8 @@ fn walk_loaded_widgets(widget: &gtk::Widget, count: usize, size_str: &str, files
while let Some(row) = list_box.first_child() { while let Some(row) = list_box.first_child() {
list_box.remove(&row); list_box.remove(&row);
} }
// Add rows for each file // Add rows for each file with remove button
for path in files { for (idx, path) in files.iter().enumerate() {
let name = path.file_name() let name = path.file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("unknown"); .unwrap_or("unknown");
@@ -140,13 +144,45 @@ fn walk_loaded_widgets(widget: &gtk::Widget, count: usize, size_str: &str, files
.subtitle(format!("{} - {}", ext, size)) .subtitle(format!("{} - {}", ext, size))
.build(); .build();
row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic")); row.add_prefix(&gtk::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::<gtk::Stack>()
{
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); list_box.append(&row);
} }
} }
// Recurse // Recurse
let mut child = widget.first_child(); let mut child = widget.first_child();
while let Some(c) = 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(); child = c.next_sibling();
} }
} }