Improve UX, add popover tour, metadata, and hicolor icons

- Redesign tutorial tour from modal dialogs to popovers pointing at actual UI elements
- Add beginner-friendly improvements: help buttons, tooltips, welcome wizard enhancements
- Add AppStream metainfo with screenshots, branding, categories, keywords, provides
- Update desktop file with GTK category and SingleMainWindow
- Add hicolor icon theme with all sizes (16-512px)
- Fix debounce SourceId panic in rename step
- Various step UI improvements and bug fixes
This commit is contained in:
2026-03-08 14:18:15 +02:00
parent 8d754017fa
commit f3668c45c3
26 changed files with 2292 additions and 473 deletions

View File

@@ -339,7 +339,7 @@ fn build_ui(app: &adw::Application) {
allow_upscale: false,
resize_algorithm: 0,
output_dpi: 72,
adjustments_enabled: false,
adjustments_enabled: if remember { sess_state.adjustments_enabled.unwrap_or(false) } else { false },
rotation: 0,
flip: 0,
brightness: 0,
@@ -445,6 +445,10 @@ fn build_ui(app: &adw::Application) {
.tooltip_text("Help for this step")
.build();
help_button.add_css_class("flat");
help_button.update_property(&[
gtk::accessible::Property::Label("Help for this step"),
]);
help_button.set_widget_name("tour-help-button");
header.pack_end(&help_button);
// Hamburger menu
@@ -455,6 +459,7 @@ fn build_ui(app: &adw::Application) {
.primary(true)
.tooltip_text("Main Menu")
.build();
menu_button.set_widget_name("tour-menu-button");
header.pack_end(&menu_button);
// Step indicator
@@ -462,6 +467,7 @@ fn build_ui(app: &adw::Application) {
// Navigation view for wizard content
let nav_view = adw::NavigationView::new();
nav_view.set_widget_name("tour-content");
nav_view.set_vexpand(true);
nav_view.update_property(&[
gtk::accessible::Property::Label("Wizard steps. Use Alt+Left/Right to navigate."),
@@ -485,6 +491,7 @@ fn build_ui(app: &adw::Application) {
.tooltip_text("Go to next step (Alt+Right)")
.build();
next_button.add_css_class("suggested-action");
next_button.set_widget_name("tour-next-button");
let bottom_box = gtk::CenterBox::new();
bottom_box.set_start_widget(Some(&back_button));
@@ -514,6 +521,9 @@ fn build_ui(app: &adw::Application) {
.tooltip_text("Watch Folders")
.build();
watch_button.add_css_class("flat");
watch_button.update_property(&[
gtk::accessible::Property::Label("Toggle watch folders panel"),
]);
header.pack_start(&watch_button);
{
@@ -531,6 +541,7 @@ fn build_ui(app: &adw::Application) {
.child(step_indicator.widget())
.build();
indicator_scroll.set_size_request(-1, 52);
indicator_scroll.set_widget_name("tour-step-indicator");
content_box.append(&indicator_scroll);
content_box.append(&nav_view);
content_box.append(&watch_revealer);
@@ -608,6 +619,7 @@ fn build_ui(app: &adw::Application) {
state.resize_enabled = Some(cfg.resize_enabled);
state.resize_width = Some(cfg.resize_width);
state.resize_height = Some(cfg.resize_height);
state.adjustments_enabled = Some(cfg.adjustments_enabled);
state.convert_enabled = Some(cfg.convert_enabled);
state.convert_format = cfg.convert_format.map(|f| format!("{:?}", f));
state.compress_enabled = Some(cfg.compress_enabled);
@@ -1487,14 +1499,18 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
.subtitle(&format!("{} - {}", time_label, subtitle))
.show_enable_switch(false)
.build();
row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
let history_icon = gtk::Image::from_icon_name("image-x-generic-symbolic");
history_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
row.add_prefix(&history_icon);
// Detail rows inside expander
let input_row = adw::ActionRow::builder()
.title("Input")
.subtitle(&entry.input_dir)
.build();
input_row.add_prefix(&gtk::Image::from_icon_name("folder-symbolic"));
let input_icon = gtk::Image::from_icon_name("folder-symbolic");
input_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
input_row.add_prefix(&input_icon);
row.add_row(&input_row);
let output_row = adw::ActionRow::builder()
@@ -1502,7 +1518,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
.subtitle(&entry.output_dir)
.activatable(true)
.build();
output_row.add_prefix(&gtk::Image::from_icon_name("folder-open-symbolic"));
let output_icon = gtk::Image::from_icon_name("folder-open-symbolic");
output_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
output_row.add_prefix(&output_icon);
let out_dir = entry.output_dir.clone();
output_row.connect_activated(move |_| {
let uri = gtk::gio::File::for_path(&out_dir).uri();
@@ -1522,7 +1540,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
savings
))
.build();
size_row.add_prefix(&gtk::Image::from_icon_name("drive-harddisk-symbolic"));
let size_icon = gtk::Image::from_icon_name("drive-harddisk-symbolic");
size_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
size_row.add_prefix(&size_icon);
row.add_row(&size_row);
if entry.failed > 0 {
@@ -1530,7 +1550,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
.title("Errors")
.subtitle(&format!("{} files failed", entry.failed))
.build();
err_row.add_prefix(&gtk::Image::from_icon_name("dialog-warning-symbolic"));
let err_icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
err_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
err_row.add_prefix(&err_icon);
row.add_row(&err_row);
}
@@ -2138,7 +2160,7 @@ fn continue_processing(
}
ProcessingMessage::Error(err) => {
mark_current_queue_batch(&ui_for_rx, false, Some(&err));
let toast = adw::Toast::new(&format!("Processing failed: {}", err));
let toast = adw::Toast::new(&format!("Processing failed: {}. Try with fewer images or check that the output folder exists.", err));
ui_for_rx.toast_overlay.add_toast(toast);
ui_for_rx.back_button.set_visible(true);
ui_for_rx.next_button.set_visible(true);
@@ -2415,18 +2437,18 @@ fn undo_last_batch(ui: &WizardUi) {
let entries = match history.list() {
Ok(e) => e,
Err(_) => {
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available"));
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available. Process a batch first before undoing."));
return;
}
};
let Some(last) = entries.last() else {
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo"));
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo. Process some images first."));
return;
};
if last.output_files.is_empty() {
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch"));
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch. The batch may have been already undone."));
return;
}
@@ -2457,7 +2479,7 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
// Save the texture to a temp file
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
if std::fs::create_dir_all(&temp_dir).is_err() {
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory"));
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory. Check disk space and permissions on /tmp."));
return;
}
let timestamp = std::time::SystemTime::now()
@@ -2480,10 +2502,10 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
toast.set_timeout(2);
ui.toast_overlay.add_toast(toast);
} else {
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image"));
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image. The image format may be unsupported."));
}
} else {
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard"));
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard. Copy an image first, then try pasting again."));
}
});
}
@@ -2672,7 +2694,7 @@ fn import_preset(window: &adw::ApplicationWindow, ui: &WizardUi) {
ui.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to import: {}", e));
let toast = adw::Toast::new(&format!("Failed to import preset: {}. Make sure the file is a valid .pixstrip-preset file.", e));
ui.toast_overlay.add_toast(toast);
}
}
@@ -2732,6 +2754,90 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
.build();
name_group.add(&desc_entry);
// Icon picker
let icon_names = [
("user-bookmarks-symbolic", "Bookmark"),
("image-x-generic-symbolic", "Image"),
("camera-photo-symbolic", "Camera"),
("emblem-photos-symbolic", "Photos"),
("applications-graphics-symbolic", "Graphics"),
("starred-symbolic", "Star"),
("emblem-favorite-symbolic", "Heart"),
("folder-symbolic", "Folder"),
("preferences-color-symbolic", "Color"),
("emblem-system-symbolic", "Gear"),
];
let icon_string_list = gtk::StringList::new(&icon_names.map(|(_, label)| label));
let icon_combo = adw::ComboRow::builder()
.title("Icon")
.model(&icon_string_list)
.build();
// Show icon preview as prefix
let icon_preview = gtk::Image::builder()
.icon_name("user-bookmarks-symbolic")
.pixel_size(24)
.build();
icon_preview.set_accessible_role(gtk::AccessibleRole::Presentation);
icon_combo.add_prefix(&icon_preview);
name_group.add(&icon_combo);
// Color picker
let color_labels = ["Default", "Blue", "Green", "Yellow", "Red"];
let color_values = ["", "accent", "success", "warning", "error"];
let color_string_list = gtk::StringList::new(&color_labels);
let color_combo = adw::ComboRow::builder()
.title("Icon Color")
.model(&color_string_list)
.build();
let color_preview = gtk::Image::builder()
.icon_name("user-bookmarks-symbolic")
.pixel_size(24)
.build();
color_preview.set_accessible_role(gtk::AccessibleRole::Presentation);
color_combo.add_prefix(&color_preview);
name_group.add(&color_combo);
// Update icon preview when icon selection changes
{
let ip = icon_preview.clone();
let cp = color_preview.clone();
let cc = color_combo.clone();
icon_combo.connect_selected_notify(move |combo| {
let idx = combo.selected() as usize;
if idx < icon_names.len() {
let icon_name = icon_names[idx].0;
ip.set_icon_name(Some(icon_name));
cp.set_icon_name(Some(icon_name));
// Re-apply color class
for cv in &color_values {
if !cv.is_empty() {
cp.remove_css_class(cv);
}
}
let cidx = cc.selected() as usize;
if cidx < color_values.len() && !color_values[cidx].is_empty() {
cp.add_css_class(color_values[cidx]);
}
}
});
}
// Update color preview when color selection changes
{
let cp = color_preview;
color_combo.connect_selected_notify(move |combo| {
let idx = combo.selected() as usize;
for cv in &color_values {
if !cv.is_empty() {
cp.remove_css_class(cv);
}
}
if idx < color_values.len() && !color_values[idx].is_empty() {
cp.add_css_class(color_values[idx]);
}
});
}
let save_new_button = gtk::Button::builder()
.label("Save New Preset")
.halign(gtk::Align::Center)
@@ -2770,9 +2876,15 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
let ui_c = ui.clone();
let dlg_c = dialog.clone();
let pname = preset_name.clone();
let store_ref = pixstrip_core::storage::PresetStore::new();
let existing_preset = store_ref.load(preset_name).ok();
let existing_icon = existing_preset.as_ref().map(|p| p.icon.clone()).unwrap_or_default();
let existing_color = existing_preset.as_ref().map(|p| p.icon_color.clone()).unwrap_or_default();
row.connect_activated(move |_| {
let cfg = ui_c.state.job_config.borrow();
let preset = build_preset_from_config(&cfg, &pname, None);
let ei = existing_icon.as_str();
let ec = existing_color.as_str();
let preset = build_preset_from_config(&cfg, &pname, None, Some(ei), Some(ec));
drop(cfg);
let store = pixstrip_core::storage::PresetStore::new();
@@ -2782,7 +2894,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
ui_c.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to update: {}", e));
let toast = adw::Toast::new(&format!("Failed to update preset: {}. The preset file may be read-only.", e));
ui_c.toast_overlay.add_toast(toast);
}
}
@@ -2801,6 +2913,8 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
let dlg_c = dialog.clone();
let entry_c = name_entry.clone();
let desc_c = desc_entry.clone();
let icon_combo_c = icon_combo;
let color_combo_c = color_combo;
save_new_button.connect_clicked(move |_| {
let name = entry_c.text().to_string();
if name.trim().is_empty() {
@@ -2810,8 +2924,12 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
}
let desc_text = desc_c.text().to_string();
let icon_idx = icon_combo_c.selected() as usize;
let selected_icon = icon_names.get(icon_idx).map(|(name, _)| *name);
let color_idx = color_combo_c.selected() as usize;
let selected_color = color_values.get(color_idx).copied();
let cfg = ui_c.state.job_config.borrow();
let preset = build_preset_from_config(&cfg, &name, Some(&desc_text));
let preset = build_preset_from_config(&cfg, &name, Some(&desc_text), selected_icon, selected_color);
drop(cfg);
let store = pixstrip_core::storage::PresetStore::new();
@@ -2821,7 +2939,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
ui_c.toast_overlay.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Failed to save: {}", e));
let toast = adw::Toast::new(&format!("Failed to save preset: {}. Check that the presets folder is writable.", e));
ui_c.toast_overlay.add_toast(toast);
}
}
@@ -2836,7 +2954,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
dialog.present(Some(window));
}
fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&str>) -> pixstrip_core::preset::Preset {
fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&str>, icon: Option<&str>, icon_color: Option<&str>) -> pixstrip_core::preset::Preset {
let resize = if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 {
Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width))
@@ -2980,7 +3098,8 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&st
.filter(|d| !d.trim().is_empty())
.map(|d| d.to_string())
.unwrap_or_else(|| build_preset_description(cfg)),
icon: "user-bookmarks-symbolic".into(),
icon: icon.unwrap_or("user-bookmarks-symbolic").to_string(),
icon_color: icon_color.unwrap_or("").to_string(),
is_custom: true,
resize,
rotation,
@@ -3188,11 +3307,12 @@ pub fn walk_widgets(widget: &Option<gtk::Widget>, f: &dyn Fn(&gtk::Widget)) {
}
#[allow(deprecated)] // ShortcutLabel deprecated in 4.18 with no replacement yet
fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts")
.content_width(420)
.content_height(480)
.content_width(460)
.content_height(520)
.build();
let toolbar_view = adw::ToolbarView::new();
@@ -3201,56 +3321,75 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.margin_start(16)
.margin_end(16)
.margin_top(8)
.margin_bottom(16)
.spacing(16)
.margin_start(24)
.margin_end(24)
.margin_top(12)
.margin_bottom(24)
.spacing(18)
.build();
let sections: &[(&str, &[(&str, &str)])] = &[
("Wizard Navigation", &[
("Alt + Right", "Next step"),
("Alt + Left", "Previous step"),
("Alt + 1-9", "Jump to step"),
("Ctrl + Return", "Process images"),
("<Alt>Right", "Next step"),
("<Alt>Left", "Previous step"),
("<Alt>1", "Jump to step (1-9)"),
("<Control>Return", "Process images"),
("Escape", "Cancel or go back"),
]),
("File Management", &[
("Ctrl + O", "Add files"),
("Ctrl + V", "Paste image from clipboard"),
("Ctrl + A", "Select all images"),
("Ctrl + Shift + A", "Deselect all images"),
("<Control>o", "Add files"),
("<Control>v", "Paste image from clipboard"),
("<Control>a", "Select all images"),
("<Control><Shift>a", "Deselect all images"),
("Delete", "Remove selected images"),
]),
("Application", &[
("Ctrl + ,", "Settings"),
("<Control>comma", "Settings"),
("F1", "Keyboard shortcuts"),
("Ctrl + Z", "Undo last batch"),
("Ctrl + Q", "Quit"),
("<Control>z", "Undo last batch"),
("<Control>q", "Quit"),
]),
];
for (section_title, shortcuts) in sections {
let group = adw::PreferencesGroup::builder()
.title(*section_title)
let group = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.build();
let title_label = gtk::Label::builder()
.label(*section_title)
.css_classes(["title-4"])
.halign(gtk::Align::Start)
.margin_bottom(2)
.build();
group.append(&title_label);
for (accel, description) in *shortcuts {
let row = adw::ActionRow::builder()
.title(*description)
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.build();
let label = gtk::Label::builder()
.label(*accel)
.css_classes(["dim-label", "monospace"])
.valign(gtk::Align::Center)
let desc_label = gtk::Label::builder()
.label(*description)
.halign(gtk::Align::Start)
.hexpand(true)
.build();
row.add_suffix(&label);
group.add(&row);
let shortcut_label = gtk::ShortcutLabel::builder()
.accelerator(*accel)
.halign(gtk::Align::End)
.build();
row.append(&desc_label);
row.append(&shortcut_label);
group.append(&row);
}
content.append(&group);
@@ -3288,82 +3427,143 @@ fn apply_accessibility_settings() {
}
fn show_step_help(window: &adw::ApplicationWindow, step: usize) {
let (title, body) = match step {
0 => ("Workflow", concat!(
"Choose a preset to start quickly, or configure each step manually.\n\n",
"Presets apply recommended settings for common tasks like web optimization, ",
"social media, or print preparation. You can customize any preset after applying it.\n\n",
"Use Import/Export to share presets with others."
let (title, icon_name, body) = match step {
0 => ("Workflow", "view-grid-symbolic", concat!(
"Pick a built-in preset to start quickly, or select the Custom card to choose ",
"which operations to include.\n\n",
"Built-in presets auto-advance to the Images step with recommended settings. ",
"Custom mode shows toggle switches for each operation (Resize, Adjustments, Convert, ",
"Compress, Metadata, Watermark, Rename).\n\n",
"Your saved presets appear below the built-in ones. Use Import to load a .pixstrip-preset file, ",
"or drag one onto this page."
)),
1 => ("Images", concat!(
1 => ("Images", "image-x-generic-symbolic", concat!(
"Add the images you want to process.\n\n",
"- Drag and drop files or folders onto this area\n",
"- Use Browse to pick files from a file dialog\n",
"- Drag image URLs from a web browser\n",
"- Click Browse Files or press Ctrl+O\n",
"- Press Ctrl+V to paste from clipboard\n\n",
"Use checkboxes to include or exclude individual images. ",
"When dropping a folder with subfolders, you'll be asked whether to include them. ",
"Use checkboxes on each thumbnail to include or exclude images. ",
"Ctrl+A selects all, Ctrl+Shift+A deselects all."
)),
2 => ("Resize", concat!(
"Scale images to specific dimensions.\n\n",
"Choose a preset size or enter custom dimensions. Width-only or height-only ",
"resizing preserves the original aspect ratio.\n\n",
"Enable 'Allow upscale' if you need images smaller than the target to be enlarged."
2 => ("Resize", "view-fullscreen-symbolic", concat!(
"Scale images to specific dimensions with a live preview.\n\n",
"Pick a category and preset size, or enter custom width and height. ",
"Toggle between pixel and percentage units. Lock the aspect ratio to keep proportions.\n\n",
"Choose Exact Size or Fit Within Box mode. Enable Allow Upscaling to enlarge smaller images. ",
"Expand Advanced Settings for resize algorithm (Lanczos3, CatmullRom, etc.) and output DPI."
)),
3 => ("Adjustments", concat!(
"Fine-tune image appearance.\n\n",
"Adjust brightness, contrast, and saturation with sliders. ",
"Apply rotation, flipping, grayscale, or sepia effects.\n\n",
"Crop to a specific aspect ratio or trim whitespace borders automatically."
3 => ("Adjustments", "preferences-color-symbolic", concat!(
"Fine-tune image appearance with a live preview.\n\n",
"Orientation: rotate (including auto-orient from EXIF) and flip.\n",
"Color: adjust brightness, contrast, and saturation with sliders.\n",
"Effects: toggle grayscale, sepia, or sharpen.\n",
"Crop and Canvas: crop to an aspect ratio, trim whitespace borders, or add padding."
)),
4 => ("Convert", concat!(
4 => ("Convert", "document-save-as-symbolic", concat!(
"Change image file format.\n\n",
"Convert between JPEG, PNG, WebP, AVIF, GIF, TIFF, and BMP. ",
"Each format has trade-offs between quality, file size, and compatibility.\n\n",
"WebP and AVIF offer the best compression for web use."
"Select a target format from the card grid (JPEG, PNG, WebP, AVIF) or use the ",
"Other Formats dropdown for GIF, TIFF, and BMP. Keep Original preserves each file's format.\n\n",
"Enable Progressive JPEG for gradual loading in browsers. Use Format Mapping to override ",
"the output format for specific input types (e.g. convert PNG to WebP but keep JPEG as-is)."
)),
5 => ("Compress", concat!(
5 => ("Compress", "drive-harddisk-symbolic", concat!(
"Reduce file size while preserving quality.\n\n",
"Choose a quality preset (Lossless, High, Balanced, Small, Tiny) or set custom ",
"quality values per format.\n\n",
"Expand Advanced Options for fine control over WebP encoding effort and AVIF speed."
"Use the quality slider to set the overall level from Low to Maximum. ",
"The split preview shows a side-by-side before/after comparison - drag the divider ",
"or use Left/Right arrow keys to compare.\n\n",
"Expand Per-Format Quality for fine control over JPEG quality, PNG compression level, ",
"WebP quality and effort, and AVIF quality and speed."
)),
6 => ("Metadata", concat!(
"Control what metadata is kept or removed.\n\n",
"Strip All removes everything. Privacy mode keeps copyright and camera info but ",
"removes GPS and timestamps. Custom mode lets you pick exactly what to strip.\n\n",
"Removing metadata reduces file size and protects privacy."
6 => ("Metadata", "dialog-password-symbolic", concat!(
"Control what image metadata is kept or removed.\n\n",
"- Strip All: remove everything for smallest files and maximum privacy\n",
"- Privacy: strip GPS and camera serial, keep copyright\n",
"- Photographer: keep copyright and camera model, strip GPS and software\n",
"- Keep All: preserve all original metadata\n",
"- Custom: choose exactly which categories to strip (GPS, camera, software, timestamps, copyright)"
)),
7 => ("Watermark", concat!(
"Add a text or image watermark.\n\n",
"Choose text or logo mode. Position the watermark using the visual grid. ",
"Expand Advanced Options for opacity, rotation, tiling, margin, and scale controls.\n\n",
"Logo watermarks support PNG images with transparency."
7 => ("Watermark", "emblem-photos-symbolic", concat!(
"Add a text or image watermark with a live preview.\n\n",
"Text mode: enter your text, choose a font and size.\n",
"Image mode: select a logo file (PNG with transparency works best).\n\n",
"Position the watermark using the 3x3 grid. Expand Advanced Options for text color, ",
"opacity, rotation, tiling, margin, and scale controls."
)),
8 => ("Rename", concat!(
"Rename output files using patterns.\n\n",
"Add a prefix, suffix, or use a full template with placeholders:\n",
"- {name} - original filename\n",
"- {n} - counter number\n",
"- {date} - current date\n",
"- {ext} - original extension\n\n",
"Expand Advanced Options for case conversion and find-and-replace."
8 => ("Rename", "document-edit-symbolic", concat!(
"Rename output files with a live preview showing before and after names.\n\n",
"Simple options: add a prefix or suffix, replace spaces, filter special characters, ",
"convert case, and add a sequential counter.\n\n",
"Expand Advanced for a template engine with variables like {name}, {counter}, {date}, ",
"{exif_date}, {camera}, {width}, {height}, and more. Also includes find-and-replace with regex."
)),
9 => ("Output", concat!(
"Review settings and choose where to save.\n\n",
"The summary shows all operations that will be applied. ",
9 => ("Output", "folder-download-symbolic", concat!(
"Review and start processing.\n\n",
"The operation summary lists all enabled steps and their settings. ",
"Choose an output folder or use the default 'processed' subfolder.\n\n",
"Set overwrite behavior for when output files already exist. ",
"Press Process or Ctrl+Enter to start."
"Toggle Preserve Directory Structure to keep subfolder hierarchy in output. ",
"Set overwrite behavior for existing files. Press Process or Ctrl+Enter to start."
)),
_ => ("Help", "No help available for this step."),
_ => ("Help", "help-about-symbolic", "No help available for this step."),
};
let dialog = adw::AlertDialog::builder()
.heading(format!("Help: {}", title))
.body(body)
let dialog = adw::Dialog::builder()
.title(format!("Help: {}", title))
.content_width(420)
.content_height(360)
.build();
dialog.add_response("ok", "Got it");
dialog.set_default_response(Some("ok"));
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_top(24)
.margin_bottom(24)
.margin_start(24)
.margin_end(24)
.build();
let icon = gtk::Image::builder()
.icon_name(icon_name)
.pixel_size(64)
.halign(gtk::Align::Center)
.build();
icon.add_css_class("accent");
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
content.append(&icon);
let heading = gtk::Label::builder()
.label(title)
.css_classes(["title-2"])
.halign(gtk::Align::Center)
.build();
content.append(&heading);
let body_label = gtk::Label::builder()
.label(body)
.wrap(true)
.halign(gtk::Align::Center)
.justify(gtk::Justification::Center)
.xalign(0.5)
.vexpand(true)
.build();
content.append(&body_label);
let close_button = gtk::Button::builder()
.label("Got it")
.halign(gtk::Align::Center)
.build();
close_button.add_css_class("suggested-action");
close_button.add_css_class("pill");
let dlg = dialog.clone();
close_button.connect_clicked(move |_| {
dlg.close();
});
content.append(&close_button);
dialog.set_child(Some(&content));
dialog.present(Some(window));
}
@@ -3415,6 +3615,9 @@ fn build_watch_folder_panel() -> gtk::Box {
.tooltip_text("Add watch folder")
.build();
add_btn.add_css_class("flat");
add_btn.update_property(&[
gtk::accessible::Property::Label("Add watch folder"),
]);
header_box.append(&add_btn);
inner.append(&header_box);
@@ -3451,15 +3654,29 @@ fn build_watch_folder_panel() -> gtk::Box {
.title(display_name)
.subtitle(&folder.preset_name)
.build();
row.add_prefix(&gtk::Image::from_icon_name("folder-visiting-symbolic"));
let folder_icon = gtk::Image::from_icon_name("folder-visiting-symbolic");
folder_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
row.add_prefix(&folder_icon);
// Status indicator
let status_icon = gtk::Image::builder()
.icon_name("emblem-ok-symbolic")
.pixel_size(12)
.build();
status_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let status = gtk::Label::builder()
.label("Watching")
.css_classes(["caption", "accent"])
.valign(gtk::Align::Center)
.build();
row.add_suffix(&status);
let status_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.valign(gtk::Align::Center)
.build();
status_box.append(&status_icon);
status_box.append(&status);
row.add_suffix(&status_box);
list_box.append(&row);
}
@@ -3518,14 +3735,28 @@ fn build_watch_folder_panel() -> gtk::Box {
.title(&display_name)
.subtitle(&new_folder.preset_name)
.build();
row.add_prefix(&gtk::Image::from_icon_name("folder-visiting-symbolic"));
let folder_icon = gtk::Image::from_icon_name("folder-visiting-symbolic");
folder_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
row.add_prefix(&folder_icon);
let dyn_status_icon = gtk::Image::builder()
.icon_name("emblem-ok-symbolic")
.pixel_size(12)
.build();
dyn_status_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let status = gtk::Label::builder()
.label("Watching")
.css_classes(["caption", "accent"])
.valign(gtk::Align::Center)
.build();
row.add_suffix(&status);
let dyn_status_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.valign(gtk::Align::Center)
.build();
dyn_status_box.append(&dyn_status_icon);
dyn_status_box.append(&status);
row.add_suffix(&dyn_status_box);
list_box_c.append(&row);
list_box_c.set_visible(true);
@@ -3644,7 +3875,11 @@ fn refresh_queue_list(ui: &WizardUi) {
.title(&batch.name)
.subtitle(&format!("{} images - {}", batch.files.len(), status_text))
.build();
row.add_prefix(&gtk::Image::from_icon_name(status_icon));
let batch_icon = gtk::Image::from_icon_name(status_icon);
batch_icon.update_property(&[
gtk::accessible::Property::Label(&status_text),
]);
row.add_prefix(&batch_icon);
// Add remove button for pending batches
if batch.status == BatchStatus::Pending {
@@ -3686,7 +3921,7 @@ fn add_current_batch_to_queue(ui: &WizardUi) {
};
if files.is_empty() {
ui.toast_overlay.add_toast(adw::Toast::new("No images to queue"));
ui.toast_overlay.add_toast(adw::Toast::new("No images to queue. Go to Step 2 to add images first."));
return;
}