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

@@ -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<ImageFormat>)] = &[
("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<ImageFormat>) -> 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(),
}
}