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:
@@ -1762,30 +1762,48 @@ fn walk_widgets(widget: &Option<gtk::Widget>, 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"),
|
||||
("Alt + 1...9", "Jump to step by number"),
|
||||
("Ctrl + Enter", "Process images"),
|
||||
("Escape", "Cancel or go back"),
|
||||
]),
|
||||
("File Management", &[
|
||||
("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"),
|
||||
("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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
||||
) {
|
||||
if let Some(label) = widget.downcast_ref::<gtk::Label>()
|
||||
&& 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::<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);
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user