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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -5,7 +5,8 @@ Exec=pixstrip-gtk %F
Icon=live.lashman.Pixstrip
Terminal=false
Type=Application
Categories=Graphics;ImageProcessing;
Categories=Graphics;ImageProcessing;GTK;
MimeType=image/jpeg;image/png;image/webp;image/avif;image/gif;image/tiff;image/bmp;
Keywords=image;photo;resize;convert;compress;batch;metadata;strip;watermark;rename;
StartupNotify=true
SingleMainWindow=true

View File

@@ -3,16 +3,18 @@
<id>live.lashman.Pixstrip</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>CC0-1.0</project_license>
<name>Pixstrip</name>
<summary>Batch image processor - resize, convert, compress, and more</summary>
<description>
<p>
Pixstrip is a batch image processor for Linux that combines resize, convert,
compress, metadata strip, watermark, rename, and basic image adjustments into
a single wizard-driven workflow.
Pixstrip is a native GTK4/libadwaita batch image processor for Linux
that combines resize, convert, compress, metadata strip, watermark,
rename, and image adjustments into a single wizard-driven workflow.
It processes everything locally with no cloud dependency.
</p>
<p>Features include:</p>
<p>Key features:</p>
<ul>
<li>Resize images by width, height, fit-in-box, or social media presets</li>
<li>Convert between JPEG, PNG, WebP, AVIF, GIF, and TIFF</li>
@@ -21,42 +23,137 @@
<li>Add text or image watermarks with positioning and rotation</li>
<li>Rename files with templates, counters, regex, and EXIF variables</li>
<li>Adjust brightness, contrast, saturation, and apply effects</li>
<li>Built-in presets for common workflows</li>
<li>Built-in presets for common workflows with one-click processing</li>
<li>Custom workflow builder with step-by-step wizard</li>
<li>Watch folders for automatic processing</li>
<li>Full CLI with feature parity</li>
<li>Processing history with undo via system trash</li>
<li>Full CLI with feature parity for scripting and automation</li>
<li>File manager integration for Nautilus, Nemo, Thunar, and Dolphin</li>
</ul>
</description>
<launchable type="desktop-id">live.lashman.Pixstrip.desktop</launchable>
<icon type="stock">live.lashman.Pixstrip</icon>
<url type="homepage">https://git.lashman.live/lashman/pixstrip</url>
<url type="bugtracker">https://git.lashman.live/lashman/pixstrip/issues</url>
<launchable type="desktop-id">live.lashman.Pixstrip.desktop</launchable>
<developer id="live.lashman">
<name>lashman</name>
</developer>
<url type="homepage">https://git.lashman.live/lashman/pixstrip</url>
<url type="bugtracker">https://git.lashman.live/lashman/pixstrip/issues</url>
<url type="vcs-browser">https://git.lashman.live/lashman/pixstrip</url>
<url type="donation">https://ko-fi.com/lashman</url>
<url type="contact">https://git.lashman.live/lashman/pixstrip/issues</url>
<url type="contribute">https://git.lashman.live/lashman/pixstrip</url>
<update_contact>lashman@robotbrush.com</update_contact>
<screenshots>
<screenshot type="default">
<caption>Workflow selection with built-in presets for common image tasks</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/01.png</image>
</screenshot>
<screenshot>
<caption>Image selection with drag-and-drop and batch file management</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/02.png</image>
</screenshot>
<screenshot>
<caption>Resize step with width, height, fit-in-box, and social media presets</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/03.png</image>
</screenshot>
<screenshot>
<caption>Format conversion between JPEG, PNG, WebP, AVIF, GIF, and TIFF</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/04.png</image>
</screenshot>
<screenshot>
<caption>Compression with live before/after preview and file size estimates</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/05.png</image>
</screenshot>
<screenshot>
<caption>Metadata stripping with selective EXIF field management</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/06.png</image>
</screenshot>
<screenshot>
<caption>Watermark placement with text and image options</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/07.png</image>
</screenshot>
<screenshot>
<caption>Batch rename with templates, counters, and EXIF variables</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/08.png</image>
</screenshot>
<screenshot>
<caption>Image adjustments for brightness, contrast, and saturation</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/09.png</image>
</screenshot>
<screenshot>
<caption>Settings with output preferences and file manager integration</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/10.png</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#99c1f1</color>
<color type="primary" scheme_preference="dark">#1a5fb4</color>
<color type="primary" scheme_preference="light">#57a773</color>
<color type="primary" scheme_preference="dark">#263226</color>
</branding>
<categories>
<category>Graphics</category>
<category>ImageProcessing</category>
<category>GTK</category>
</categories>
<keywords>
<keyword>Image</keyword>
<keyword>Photo</keyword>
<keyword>Resize</keyword>
<keyword>Convert</keyword>
<keyword>Compress</keyword>
<keyword>Batch</keyword>
<keyword>Metadata</keyword>
<keyword>Watermark</keyword>
<keyword>Rename</keyword>
<keyword>EXIF</keyword>
<keyword>WebP</keyword>
<keyword>AVIF</keyword>
</keywords>
<content_rating type="oars-1.1" />
<requires>
<display_length compare="ge">360</display_length>
</requires>
<recommends>
<control>keyboard</control>
<control>pointing</control>
</recommends>
<supports>
<control>pointing</control>
<control>keyboard</control>
<control>touch</control>
</supports>
<provides>
<binary>pixstrip-gtk</binary>
<binary>pixstrip</binary>
</provides>
<releases>
<release version="0.1.0" date="2026-03-06">
<release version="0.1.0" date="2026-03-06" type="stable">
<description>
<p>Initial release with full wizard workflow, 8 built-in presets, CLI parity, watch folders, and file manager integration.</p>
<p>Initial release of Pixstrip with core features:</p>
<ul>
<li>Wizard-driven batch processing with 8 built-in presets</li>
<li>Resize, convert, compress, metadata strip, watermark, rename, and adjust</li>
<li>Optimized encoders: mozjpeg, oxipng, libwebp, and ravif</li>
<li>Live compression preview with before/after comparison</li>
<li>Watch folders for automatic processing</li>
<li>Processing history with undo via system trash</li>
<li>Full CLI with feature parity</li>
<li>File manager integration for Nautilus, Nemo, Thunar, and Dolphin</li>
</ul>
</description>
</release>
</releases>

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ pub struct Preset {
pub name: String,
pub description: String,
pub icon: String,
pub icon_color: String,
pub is_custom: bool,
pub resize: Option<ResizeConfig>,
pub rotation: Option<Rotation>,
@@ -27,6 +28,7 @@ impl Default for Preset {
name: String::new(),
description: String::new(),
icon: "image-x-generic-symbolic".into(),
icon_color: String::new(),
is_custom: true,
resize: None,
rotation: None,
@@ -90,6 +92,7 @@ impl Preset {
name: "Blog Photos".into(),
description: "Resize 1200px wide, JPEG quality High, strip all metadata".into(),
icon: "image-x-generic-symbolic".into(),
icon_color: "accent".into(),
is_custom: false,
resize: Some(ResizeConfig::ByWidth(1200)),
rotation: None,
@@ -107,6 +110,7 @@ impl Preset {
name: "Social Media".into(),
description: "Resize to fit 1080x1080, compress Medium, strip metadata".into(),
icon: "system-users-symbolic".into(),
icon_color: "success".into(),
is_custom: false,
resize: Some(ResizeConfig::FitInBox {
max: Dimensions {
@@ -130,6 +134,7 @@ impl Preset {
name: "Web Optimization".into(),
description: "Convert to WebP, compress High, sequential rename".into(),
icon: "web-browser-symbolic".into(),
icon_color: "accent".into(),
is_custom: false,
resize: None,
rotation: None,
@@ -160,6 +165,7 @@ impl Preset {
name: "Email Friendly".into(),
description: "Resize 800px wide, JPEG quality Medium".into(),
icon: "mail-unread-symbolic".into(),
icon_color: "warning".into(),
is_custom: false,
resize: Some(ResizeConfig::ByWidth(800)),
rotation: None,
@@ -177,6 +183,7 @@ impl Preset {
name: "Privacy Clean".into(),
description: "Strip all metadata, no other changes".into(),
icon: "security-high-symbolic".into(),
icon_color: "error".into(),
is_custom: false,
resize: None,
rotation: None,
@@ -194,6 +201,7 @@ impl Preset {
name: "Photographer Export".into(),
description: "Resize 2048px, compress High, privacy metadata, rename by date".into(),
icon: "camera-photo-symbolic".into(),
icon_color: "success".into(),
is_custom: false,
resize: Some(ResizeConfig::ByWidth(2048)),
rotation: None,
@@ -224,6 +232,7 @@ impl Preset {
name: "Archive Compress".into(),
description: "Lossless compression, preserve metadata".into(),
icon: "folder-symbolic".into(),
icon_color: "warning".into(),
is_custom: false,
resize: None,
rotation: None,
@@ -241,6 +250,7 @@ impl Preset {
name: "Print Ready".into(),
description: "Maximum quality, convert to PNG, keep all metadata".into(),
icon: "printer-symbolic".into(),
icon_color: "success".into(),
is_custom: false,
resize: None,
rotation: None,
@@ -258,6 +268,7 @@ impl Preset {
name: "Fediverse Ready".into(),
description: "Resize 1920x1080, convert to WebP, compress High, strip metadata".into(),
icon: "network-server-symbolic".into(),
icon_color: "accent".into(),
is_custom: false,
resize: Some(ResizeConfig::FitInBox {
max: Dimensions {

View File

@@ -189,6 +189,7 @@ pub struct SessionState {
pub resize_enabled: Option<bool>,
pub resize_width: Option<u32>,
pub resize_height: Option<u32>,
pub adjustments_enabled: Option<bool>,
pub convert_enabled: Option<bool>,
pub convert_format: Option<String>,
pub compress_enabled: Option<bool>,

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;
}

View File

@@ -142,31 +142,41 @@ pub fn build_results_page() -> adw::NavigationPage {
.title("Images processed")
.subtitle("0 images")
.build();
images_row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
let images_icon = gtk::Image::from_icon_name("image-x-generic-symbolic");
images_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
images_row.add_prefix(&images_icon);
let size_before_row = adw::ActionRow::builder()
.title("Original size")
.subtitle("0 B")
.build();
size_before_row.add_prefix(&gtk::Image::from_icon_name("drive-harddisk-symbolic"));
let size_before_icon = gtk::Image::from_icon_name("drive-harddisk-symbolic");
size_before_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
size_before_row.add_prefix(&size_before_icon);
let size_after_row = adw::ActionRow::builder()
.title("Output size")
.subtitle("0 B")
.build();
size_after_row.add_prefix(&gtk::Image::from_icon_name("drive-harddisk-symbolic"));
let size_after_icon = gtk::Image::from_icon_name("drive-harddisk-symbolic");
size_after_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
size_after_row.add_prefix(&size_after_icon);
let savings_row = adw::ActionRow::builder()
.title("Space saved")
.subtitle("0%")
.build();
savings_row.add_prefix(&gtk::Image::from_icon_name("emblem-ok-symbolic"));
let savings_icon = gtk::Image::from_icon_name("emblem-ok-symbolic");
savings_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
savings_row.add_prefix(&savings_icon);
let time_row = adw::ActionRow::builder()
.title("Processing time")
.subtitle("0s")
.build();
time_row.add_prefix(&gtk::Image::from_icon_name("preferences-system-time-symbolic"));
let time_icon = gtk::Image::from_icon_name("preferences-system-time-symbolic");
time_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
time_row.add_prefix(&time_icon);
stats_group.add(&images_row);
stats_group.add(&size_before_row);
@@ -195,32 +205,48 @@ pub fn build_results_page() -> adw::NavigationPage {
.subtitle("View processed images in file manager")
.activatable(true)
.build();
open_row.add_prefix(&gtk::Image::from_icon_name("folder-open-symbolic"));
open_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let open_icon = gtk::Image::from_icon_name("folder-open-symbolic");
open_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
open_row.add_prefix(&open_icon);
let open_arrow = gtk::Image::from_icon_name("go-next-symbolic");
open_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
open_row.add_suffix(&open_arrow);
let process_more_row = adw::ActionRow::builder()
.title("Process Another Batch")
.subtitle("Start over with new images")
.activatable(true)
.build();
process_more_row.add_prefix(&gtk::Image::from_icon_name("view-refresh-symbolic"));
process_more_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let more_icon = gtk::Image::from_icon_name("view-refresh-symbolic");
more_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
process_more_row.add_prefix(&more_icon);
let more_arrow = gtk::Image::from_icon_name("go-next-symbolic");
more_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
process_more_row.add_suffix(&more_arrow);
let save_preset_row = adw::ActionRow::builder()
.title("Save as Preset")
.subtitle("Save this workflow for future use")
.activatable(true)
.build();
save_preset_row.add_prefix(&gtk::Image::from_icon_name("document-save-symbolic"));
save_preset_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let save_icon = gtk::Image::from_icon_name("document-save-symbolic");
save_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
save_preset_row.add_prefix(&save_icon);
let save_arrow = gtk::Image::from_icon_name("go-next-symbolic");
save_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
save_preset_row.add_suffix(&save_arrow);
let add_queue_row = adw::ActionRow::builder()
.title("Add to Queue")
.subtitle("Queue another batch with different images")
.activatable(true)
.build();
add_queue_row.add_prefix(&gtk::Image::from_icon_name("view-list-symbolic"));
add_queue_row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let queue_icon = gtk::Image::from_icon_name("view-list-symbolic");
queue_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
add_queue_row.add_prefix(&queue_icon);
let queue_arrow = gtk::Image::from_icon_name("go-next-symbolic");
queue_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
add_queue_row.add_suffix(&queue_arrow);
action_group.add(&open_row);
action_group.add(&process_more_row);

View File

@@ -57,7 +57,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.activatable(true)
.visible(config.output_fixed_path.is_some())
.build();
fixed_path_row.add_prefix(&gtk::Image::from_icon_name("folder-open-symbolic"));
let fp_icon = gtk::Image::from_icon_name("folder-open-symbolic");
fp_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
fixed_path_row.add_prefix(&fp_icon);
let choose_fixed_btn = gtk::Button::builder()
.icon_name("document-open-symbolic")
@@ -65,6 +67,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.valign(gtk::Align::Center)
.build();
choose_fixed_btn.add_css_class("flat");
choose_fixed_btn.update_property(&[
gtk::accessible::Property::Label("Choose output folder"),
]);
fixed_path_row.add_suffix(&choose_fixed_btn);
// Shared state for fixed path
@@ -164,7 +169,34 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.build();
reset_button.add_css_class("destructive-action");
// Reset welcome wizard / tutorial
let reset_welcome_state: std::rc::Rc<Cell<bool>> = std::rc::Rc::new(Cell::new(false));
let reset_welcome_row = adw::ActionRow::builder()
.title("Reset welcome wizard")
.subtitle("Show the setup wizard and tutorial again on next launch")
.build();
let reset_welcome_btn = gtk::Button::builder()
.label("Reset")
.valign(gtk::Align::Center)
.build();
reset_welcome_btn.add_css_class("destructive-action");
{
let rws = reset_welcome_state.clone();
let row = reset_welcome_row.clone();
reset_welcome_btn.connect_clicked(move |btn| {
rws.set(true);
btn.set_sensitive(false);
row.set_subtitle("Will show on next launch");
});
}
reset_welcome_row.add_suffix(&reset_welcome_btn);
ui_group.add(&skill_row);
ui_group.add(&reset_welcome_row);
general_page.add(&ui_group);
// File Manager Integration
@@ -432,6 +464,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.css_classes(["boxed-list"])
.build();
watch_list.set_widget_name("watch-folder-list");
watch_list.update_property(&[
gtk::accessible::Property::Label("Configured watch folders for automatic processing"),
]);
// Shared state for watch folders
let watch_folders_state: std::rc::Rc<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>> =
@@ -564,9 +599,10 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
// Save settings when the dialog closes
dialog.connect_closed(move |_| {
let welcome_reset = reset_welcome_state.get();
let new_config = AppConfig {
first_run_complete: true,
tutorial_complete: true, // preserve if settings are being saved
first_run_complete: !welcome_reset,
tutorial_complete: !welcome_reset,
output_subfolder: subfolder_row.text().to_string(),
output_fixed_path: if output_mode_row.selected() == 1 {
fixed_path_state.borrow().clone()

View File

@@ -49,10 +49,24 @@ impl StepIndicator {
container.append(&grid);
// First step starts as current
let total = dots.len();
if let Some(first) = dots.first() {
first.icon.set_icon_name(Some("radio-checked-symbolic"));
first.button.set_sensitive(true);
first.label.add_css_class("accent");
first.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step 1 of {}: {} (current)", total, first.label.label())
),
]);
}
// Label all non-current dots
for (i, dot) in dots.iter().enumerate().skip(1) {
dot.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step {} of {}: {}", i + 1, total, dot.label.label())
),
]);
}
Self {
@@ -164,11 +178,17 @@ impl StepIndicator {
pub fn set_completed(&self, actual_index: usize) {
let dots = self.dots.borrow();
let map = self.step_map.borrow();
let total = dots.len();
if let Some(visual_i) = map.iter().position(|&i| i == actual_index) {
if let Some(dot) = dots.get(visual_i) {
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
dot.button.set_sensitive(true);
dot.label.remove_css_class("accent");
dot.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step {} of {}: {} (completed)", visual_i + 1, total, dot.label.label())
),
]);
}
}
}

View File

@@ -25,6 +25,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Adjustments")
.subtitle("Rotate, flip, brightness, contrast, effects")
.active(cfg.adjustments_enabled)
.tooltip_text("Toggle image adjustments on or off")
.build();
enable_group.add(&enable_row);
outer.append(&enable_group);
@@ -37,6 +38,10 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.vexpand(true)
.build();
preview_picture.set_can_target(true);
preview_picture.set_focusable(true);
preview_picture.update_property(&[
gtk::accessible::Property::Label("Adjustments preview - press Space to cycle images"),
]);
let info_label = gtk::Label::builder()
.label("No images loaded")
@@ -78,6 +83,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Rotate")
.subtitle("Rotation applied to all images")
.use_subtitle(true)
.tooltip_text("Rotate all images by a fixed angle or auto-orient from EXIF")
.build();
rotate_row.set_model(Some(&gtk::StringList::new(&[
"None",
@@ -93,6 +99,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Flip")
.subtitle("Mirror the image")
.use_subtitle(true)
.tooltip_text("Mirror images horizontally or vertically")
.build();
flip_row.set_model(Some(&gtk::StringList::new(&["None", "Horizontal", "Vertical"])));
flip_row.set_list_factory(Some(&super::full_text_list_factory()));
@@ -130,6 +137,9 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.tooltip_text("Reset to 0")
.has_frame(false)
.build();
reset_btn.update_property(&[
gtk::accessible::Property::Label(&format!("Reset {} to 0", title)),
]);
reset_btn.set_sensitive(value != 0);
row.add_suffix(&scale);
@@ -204,6 +214,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Crop to Aspect Ratio")
.subtitle("Crop from center to a specific ratio")
.use_subtitle(true)
.tooltip_text("Crop from center to a specific aspect ratio")
.build();
crop_row.set_model(Some(&gtk::StringList::new(&[
"None",
@@ -222,12 +233,14 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Trim Whitespace")
.subtitle("Remove uniform borders around the image")
.active(cfg.trim_whitespace)
.tooltip_text("Detect and remove uniform borders around the image")
.build();
let padding_row = adw::SpinRow::builder()
.title("Canvas Padding")
.subtitle("Add uniform padding (pixels)")
.adjustment(&gtk::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0))
.tooltip_text("Add a white border around each image in pixels")
.build();
crop_group.add(&crop_row);
@@ -506,6 +519,27 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
preview_picture.add_controller(click);
}
// Keyboard support for preview cycling (Space/Enter)
{
let key = gtk::EventControllerKey::new();
let pidx = preview_index.clone();
let files = state.loaded_files.clone();
let up = update_preview.clone();
key.connect_key_pressed(move |_, keyval, _, _| {
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
let loaded = files.borrow();
if loaded.len() > 1 {
let next = (pidx.get() + 1) % loaded.len();
pidx.set(next);
up();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
preview_picture.add_controller(key);
}
// === Wire signals ===
{
@@ -694,12 +728,16 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.child(&outer)
.build();
// Refresh preview and sensitivity when navigating to this page
// Sync enable toggle, refresh preview and sensitivity when navigating to this page
{
let up = update_preview.clone();
let lf = state.loaded_files.clone();
let ctrl = controls.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().adjustments_enabled;
er.set_active(enabled);
ctrl.set_sensitive(!lf.borrow().is_empty());
up();
});

View File

@@ -38,6 +38,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Compression")
.subtitle("Reduce file size with quality control")
.active(cfg.compress_enabled)
.tooltip_text("Toggle compression on or off")
.build();
let enable_group = adw::PreferencesGroup::new();
@@ -234,6 +235,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let compressed_pixbuf: Rc<RefCell<Option<gtk::gdk_pixbuf::Pixbuf>>> = Rc::new(RefCell::new(None));
let divider_dragging = Rc::new(Cell::new(false));
let image_dragging = Rc::new(Cell::new(false));
let divider_hint_visible = Rc::new(Cell::new(true));
// Pan state for cover-fill preview
let pan_x: Rc<Cell<f64>> = Rc::new(Cell::new(0.0));
@@ -256,6 +258,17 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
gtk::accessible::Property::Label("Compression quality comparison. Drag the vertical divider to compare original and compressed image. Drag elsewhere to pan."),
]);
// Hint label shown over the preview until the user first interacts with the divider
let divider_hint_label = gtk::Label::builder()
.label("Drag the divider to compare before and after")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.build();
divider_hint_label.update_property(&[
gtk::accessible::Property::Label("Hint: drag the divider to compare before and after compression"),
]);
// Draw function - cover fill with pan support
{
let dp = divider_pos.clone();
@@ -376,12 +389,19 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let dspy = drag_start_pan_y.clone();
let px = pan_x.clone();
let py = pan_y.clone();
let hint_vis = divider_hint_visible.clone();
let hint_lbl = divider_hint_label.clone();
drag_gesture.connect_drag_begin(move |_, x, _| {
let w = drawing.width() as f64;
let current = *dp.borrow() * w;
if (x - current).abs() < 30.0 {
dd.set(true);
id.set(false);
// Hide the hint on first divider interaction
if hint_vis.get() {
hint_vis.set(false);
hint_lbl.set_visible(false);
}
} else {
dd.set(false);
id.set(true);
@@ -437,10 +457,62 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
}
preview_drawing.add_controller(drag_gesture);
// Keyboard support for divider: Left/Right to move divider, Space to reset to center
{
let dp = divider_pos.clone();
let drawing = preview_drawing.clone();
let hint_vis = divider_hint_visible.clone();
let hint_lbl = divider_hint_label.clone();
let key = gtk::EventControllerKey::new();
key.connect_key_pressed(move |_, keyval, _, _| {
let step = 0.02;
match keyval {
gtk::gdk::Key::Left => {
let new_pos = (*dp.borrow() - step).clamp(0.05, 0.95);
*dp.borrow_mut() = new_pos;
drawing.queue_draw();
if hint_vis.get() {
hint_vis.set(false);
hint_lbl.set_visible(false);
}
return gtk::glib::Propagation::Stop;
}
gtk::gdk::Key::Right => {
let new_pos = (*dp.borrow() + step).clamp(0.05, 0.95);
*dp.borrow_mut() = new_pos;
drawing.queue_draw();
if hint_vis.get() {
hint_vis.set(false);
hint_lbl.set_visible(false);
}
return gtk::glib::Propagation::Stop;
}
gtk::gdk::Key::space => {
*dp.borrow_mut() = 0.5;
drawing.queue_draw();
if hint_vis.get() {
hint_vis.set(false);
hint_lbl.set_visible(false);
}
return gtk::glib::Propagation::Stop;
}
_ => {}
}
gtk::glib::Propagation::Proceed
});
preview_drawing.set_focusable(true);
preview_drawing.add_controller(key);
}
let preview_overlay = gtk::Overlay::builder()
.child(&preview_drawing)
.build();
preview_overlay.add_overlay(&divider_hint_label);
let preview_frame = gtk::Frame::builder()
.halign(gtk::Align::Fill)
.build();
preview_frame.set_child(Some(&preview_drawing));
preview_frame.set_child(Some(&preview_overlay));
preview_group.add(&size_box);
preview_group.add(&preview_frame);
@@ -480,11 +552,22 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
frame.add_css_class("accent");
}
let file_name = files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image");
let btn = gtk::Button::builder()
.child(&frame)
.has_frame(false)
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
.tooltip_text(file_name)
.build();
let selected_label = if i == 0 { "currently selected" } else { "" };
btn.update_property(&[
gtk::accessible::Property::Label(
&if selected_label.is_empty() {
format!("Preview thumbnail: {}", file_name)
} else {
format!("Preview thumbnail: {} ({})", file_name, selected_label)
}
),
]);
thumb_box.append(&btn);
}
@@ -816,7 +899,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
.child(&scrolled)
.build();
// On page map: refresh thumbnail strip, preview, and show/hide per-format rows
// On page map: sync enable toggle, refresh thumbnail strip, preview, and show/hide per-format rows
{
let up = update_preview.clone();
let jc = state.job_config.clone();
@@ -832,7 +915,10 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let ts = thumb_scrolled.clone();
let pidx = preview_index.clone();
let up2 = update_preview.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().compress_enabled;
er.set_active(enabled);
// Rebuild thumbnail strip from current file list
while let Some(child) = tb.first_child() {
tb.remove(&child);
@@ -856,11 +942,22 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let up_c = up2.clone();
let tb_c = tb.clone();
let current_idx = i;
let file_name = files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image");
let btn = gtk::Button::builder()
.child(&frame)
.has_frame(false)
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
.tooltip_text(file_name)
.build();
let is_selected = i == *pidx.borrow();
btn.update_property(&[
gtk::accessible::Property::Label(
&if is_selected {
format!("Preview thumbnail: {} (currently selected)", file_name)
} else {
format!("Preview thumbnail: {}", file_name)
}
),
]);
btn.connect_clicked(move |_| {
*pidx_c.borrow_mut() = current_idx;
up_c(true);

View File

@@ -78,6 +78,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Format Conversion")
.subtitle("Convert images to a different format")
.active(cfg.convert_enabled)
.tooltip_text("Toggle format conversion on or off")
.build();
let enable_group = adw::PreferencesGroup::new();
@@ -101,6 +102,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.margin_bottom(4)
.build();
flow.update_property(&[
gtk::accessible::Property::Label("Output format selection grid"),
]);
let initial_format = cfg.convert_format;
for (name, desc, icon_name, _fmt) in CARD_FORMATS {
@@ -112,6 +117,9 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.build();
card.add_css_class("card");
card.set_size_request(130, 110);
card.update_property(&[
gtk::accessible::Property::Label(&format!("{}: {}", name, desc.replace('\n', ", "))),
]);
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
@@ -129,6 +137,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.icon_name(*icon_name)
.pixel_size(28)
.build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let name_label = gtk::Label::builder()
.label(*name)
@@ -212,6 +221,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.title("Progressive JPEG")
.subtitle("Loads gradually in browsers, slightly larger file size")
.active(cfg.progressive_jpeg)
.tooltip_text("Creates JPEG files that load gradually in web browsers")
.build();
jpeg_group.add(&progressive_row);
@@ -315,12 +325,15 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.child(&scrolled)
.build();
// Rebuild format mapping rows when navigating to this page
// Sync enable toggle and rebuild format mapping rows when navigating to this page
{
let files = state.loaded_files.clone();
let list = mapping_list;
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().convert_enabled;
er.set_active(enabled);
rebuild_format_mapping(&list, &files.borrow(), &jc);
});
}

View File

@@ -500,6 +500,7 @@ fn build_empty_state() -> gtk::Box {
.pixel_size(64)
.css_classes(["dim-label"])
.build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let title = gtk::Label::builder()
.label("Drop images here")
@@ -529,6 +530,16 @@ fn build_empty_state() -> gtk::Box {
browse_button.add_css_class("suggested-action");
browse_button.add_css_class("pill");
let hint = gtk::Label::builder()
.label("Start by adding your images below, then use the Next button to configure each processing step.")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.justify(gtk::Justification::Center)
.wrap(true)
.margin_bottom(8)
.build();
inner.append(&hint);
inner.append(&icon);
inner.append(&title);
inner.append(&subtitle);
@@ -778,11 +789,19 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
}
});
// Set accessible label on thumbnail picture
picture.update_property(&[
gtk::accessible::Property::Label(&format!("Thumbnail of {}", file_name)),
]);
// Set checkbox state
let check = find_check_button(overlay.upcast_ref::<gtk::Widget>());
if let Some(ref check) = check {
let is_excluded = excluded.borrow().contains(&path);
check.set_active(!is_excluded);
check.update_property(&[
gtk::accessible::Property::Label(&format!("Include {} in processing", file_name)),
]);
// Wire checkbox toggle
let excl = excluded.clone();

View File

@@ -23,6 +23,7 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Metadata Handling")
.subtitle("Control what image metadata to keep or remove")
.active(cfg.metadata_enabled)
.tooltip_text("Toggle metadata handling on or off")
.build();
let enable_group = adw::PreferencesGroup::new();
@@ -39,7 +40,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.subtitle("Remove all metadata - smallest files, maximum privacy")
.activatable(true)
.build();
strip_all_row.add_prefix(&gtk::Image::from_icon_name("user-trash-symbolic"));
let strip_all_icon = gtk::Image::from_icon_name("user-trash-symbolic");
strip_all_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
strip_all_row.add_prefix(&strip_all_icon);
let strip_all_check = gtk::CheckButton::new();
strip_all_check.set_active(cfg.metadata_mode == MetadataMode::StripAll);
strip_all_row.add_suffix(&strip_all_check);
@@ -50,7 +53,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.subtitle("Strip GPS and camera serial, keep copyright")
.activatable(true)
.build();
privacy_row.add_prefix(&gtk::Image::from_icon_name("security-medium-symbolic"));
let privacy_icon = gtk::Image::from_icon_name("security-medium-symbolic");
privacy_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
privacy_row.add_prefix(&privacy_icon);
let privacy_check = gtk::CheckButton::new();
privacy_check.set_group(Some(&strip_all_check));
privacy_check.set_active(cfg.metadata_mode == MetadataMode::Privacy);
@@ -62,7 +67,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.subtitle("Preserve all original metadata")
.activatable(true)
.build();
keep_all_row.add_prefix(&gtk::Image::from_icon_name("emblem-ok-symbolic"));
let keep_all_icon = gtk::Image::from_icon_name("emblem-ok-symbolic");
keep_all_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
keep_all_row.add_prefix(&keep_all_icon);
let keep_all_check = gtk::CheckButton::new();
keep_all_check.set_group(Some(&strip_all_check));
keep_all_check.set_active(cfg.metadata_mode == MetadataMode::KeepAll);
@@ -74,7 +81,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.subtitle("Keep copyright and camera model, strip GPS and software")
.activatable(true)
.build();
photographer_row.add_prefix(&gtk::Image::from_icon_name("camera-photo-symbolic"));
let photographer_icon = gtk::Image::from_icon_name("camera-photo-symbolic");
photographer_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
photographer_row.add_prefix(&photographer_icon);
let photographer_check = gtk::CheckButton::new();
photographer_check.set_group(Some(&strip_all_check));
photographer_row.add_suffix(&photographer_check);
@@ -85,7 +94,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.subtitle("Choose exactly which metadata categories to strip")
.activatable(true)
.build();
custom_row.add_prefix(&gtk::Image::from_icon_name("emblem-system-symbolic"));
let custom_icon = gtk::Image::from_icon_name("emblem-system-symbolic");
custom_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
custom_row.add_prefix(&custom_icon);
let custom_check = gtk::CheckButton::new();
custom_check.set_group(Some(&strip_all_check));
custom_check.set_active(cfg.metadata_mode == MetadataMode::Custom);
@@ -109,30 +120,35 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.title("GPS / Location")
.subtitle("GPS coordinates, location name, altitude")
.active(cfg.strip_gps)
.tooltip_text("Strip GPS coordinates, location name, and altitude")
.build();
let camera_row = adw::SwitchRow::builder()
.title("Camera Info")
.subtitle("Camera model, serial number, lens data")
.active(cfg.strip_camera)
.tooltip_text("Strip camera model, serial number, and lens data")
.build();
let software_row = adw::SwitchRow::builder()
.title("Software")
.subtitle("Editing software, processing history")
.active(cfg.strip_software)
.tooltip_text("Strip editing software and processing history")
.build();
let timestamps_row = adw::SwitchRow::builder()
.title("Timestamps")
.subtitle("Date taken, date modified, date digitized")
.active(cfg.strip_timestamps)
.tooltip_text("Strip date taken, date modified, date digitized")
.build();
let copyright_row = adw::SwitchRow::builder()
.title("Copyright / Author")
.subtitle("Copyright notice, artist name, credits")
.active(cfg.strip_copyright)
.tooltip_text("Strip copyright notice, artist name, and credits")
.build();
custom_group.add(&gps_row);
@@ -260,9 +276,21 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
scrolled.set_child(Some(&content));
adw::NavigationPage::builder()
let page = adw::NavigationPage::builder()
.title("Metadata")
.tag("step-metadata")
.child(&scrolled)
.build()
.build();
// Sync enable toggle when navigating to this page
{
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().metadata_enabled;
er.set_active(enabled);
});
}
page
}

View File

@@ -25,6 +25,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Rename")
.subtitle("Rename output files with prefix, suffix, or template")
.active(cfg.rename_enabled)
.tooltip_text("Toggle file renaming on or off")
.build();
enable_group.add(&enable_row);
outer.append(&enable_group);
@@ -74,6 +75,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.visible(false)
.build();
conflict_banner.add_css_class("card");
conflict_banner.set_accessible_role(gtk::AccessibleRole::Alert);
let conflict_icon = gtk::Image::builder()
.icon_name("dialog-warning-symbolic")
@@ -128,6 +130,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.label("Reset to defaults")
.halign(gtk::Align::Start)
.margin_top(4)
.tooltip_text("Reset all rename options to their defaults")
.build();
reset_button.add_css_class("pill");
@@ -140,11 +143,13 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
let prefix_row = adw::EntryRow::builder()
.title("Prefix")
.text(&cfg.rename_prefix)
.tooltip_text("Text added before the original filename")
.build();
let suffix_row = adw::EntryRow::builder()
.title("Suffix")
.text(&cfg.rename_suffix)
.tooltip_text("Text added after the original filename")
.build();
let replace_spaces_row = adw::ComboRow::builder()
@@ -258,6 +263,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
let template_row = adw::EntryRow::builder()
.title("Template")
.text(&cfg.rename_template)
.tooltip_text("Use variables like {name}, {date}, {counter:3} to build filenames")
.build();
// Template preset chips
@@ -375,6 +381,11 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.child(&chip_box)
.has_frame(false)
.build();
btn.update_property(&[
gtk::accessible::Property::Label(
&format!("Insert {} - {}", var_name, description)
),
]);
let tr = template_row.clone();
let var_text = var_name.to_string();
@@ -411,11 +422,13 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
let find_row = adw::EntryRow::builder()
.title("Find (regex)")
.text(&cfg.rename_find)
.tooltip_text("Regular expression pattern to match in filenames")
.build();
let replace_row = adw::EntryRow::builder()
.title("Replace with")
.text(&cfg.rename_replace)
.tooltip_text("Replacement text for matched pattern")
.build();
advanced_expander.add_row(&template_row);
@@ -603,9 +616,17 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.max_width_chars(50)
.build();
// Highlight conflicts
if name_counts.get(new_full.as_str()).copied().unwrap_or(0) > 1 {
// Highlight conflicts with both color AND icon indicator
let is_conflict = name_counts.get(new_full.as_str()).copied().unwrap_or(0) > 1;
if is_conflict {
new_name_label.add_css_class("error");
let conflict_icon = gtk::Image::builder()
.icon_name("dialog-warning-symbolic")
.pixel_size(12)
.tooltip_text("Duplicate filename")
.build();
conflict_icon.add_css_class("warning");
new_line.append(&conflict_icon);
}
new_line.append(&arrow_label);
@@ -643,9 +664,13 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
id.remove();
}
let up2 = up.clone();
let ds2 = ds.clone();
let id = gtk::glib::timeout_add_local_once(
std::time::Duration::from_millis(150),
move || { up2(); },
move || {
ds2.set(None);
up2();
},
);
ds.set(Some(id));
})
@@ -860,10 +885,14 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.child(&outer)
.build();
// Refresh preview when navigating to this page
// Sync enable toggle and refresh preview when navigating to this page
{
let up = update_preview.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().rename_enabled;
er.set_active(enabled);
up();
});
}

View File

@@ -109,6 +109,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Resize")
.subtitle("Scale images to new dimensions")
.active(cfg.resize_enabled)
.tooltip_text("Toggle resizing of images on or off")
.build();
enable_group.add(&enable_row);
outer.append(&enable_group);
@@ -179,6 +180,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
let category_row = adw::ComboRow::builder()
.title("Category")
.use_subtitle(true)
.tooltip_text("Choose a category of size presets")
.build();
category_row.set_model(Some(&gtk::StringList::new(CATEGORIES)));
category_row.set_list_factory(Some(&super::full_text_list_factory()));
@@ -187,6 +189,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
.title("Size")
.subtitle("Select a preset to fill dimensions")
.use_subtitle(true)
.tooltip_text("Pick a preset size to fill dimensions")
.build();
rebuild_size_model(&size_row, 0);
@@ -214,6 +217,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
.label("W")
.css_classes(["dim-label"])
.build();
w_label.set_accessible_role(gtk::AccessibleRole::Presentation);
let width_spin = gtk::SpinButton::builder()
.adjustment(&gtk::Adjustment::new(
cfg.resize_width as f64, 0.0, 10000.0, 1.0, 100.0, 0.0,
@@ -250,12 +254,28 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
.label("H")
.css_classes(["dim-label"])
.build();
h_label.set_accessible_role(gtk::AccessibleRole::Presentation);
// Unit segmented toggle (px / %)
let unit_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
unit_box.add_css_class("linked");
let px_btn = gtk::Button::builder().label("px").build();
let pct_btn = gtk::Button::builder().label("%").build();
unit_box.update_property(&[
gtk::accessible::Property::Label("Dimension unit toggle"),
]);
let px_btn = gtk::Button::builder()
.label("px")
.tooltip_text("Use pixel dimensions (currently active)")
.build();
px_btn.update_property(&[
gtk::accessible::Property::Label("Pixels - currently active"),
]);
let pct_btn = gtk::Button::builder()
.label("%")
.tooltip_text("Use percentage dimensions")
.build();
pct_btn.update_property(&[
gtk::accessible::Property::Label("Percentage"),
]);
px_btn.add_css_class("suggested-action");
unit_box.append(&px_btn);
unit_box.append(&pct_btn);
@@ -273,6 +293,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
.title("Mode")
.subtitle("How dimensions are applied to images")
.use_subtitle(true)
.tooltip_text("Exact stretches to dimensions; Fit keeps aspect ratio")
.build();
mode_row.set_model(Some(&gtk::StringList::new(&[
"Exact Size",
@@ -285,6 +306,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
.title("Allow Upscaling")
.subtitle("Enlarge images smaller than target size")
.active(cfg.allow_upscale)
.tooltip_text("When off, images smaller than target are left as-is")
.build();
dims_group.add(&mode_row);
@@ -568,9 +590,15 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
if btn.is_active() {
lb.set_icon_name("changes-prevent-symbolic");
lb.set_tooltip_text(Some("Aspect ratio locked"));
lb.update_property(&[
gtk::accessible::Property::Label("Aspect ratio locked - click to unlock"),
]);
} else {
lb.set_icon_name("changes-allow-symbolic");
lb.set_tooltip_text(Some("Aspect ratio unlocked"));
lb.update_property(&[
gtk::accessible::Property::Label("Aspect ratio unlocked - click to lock"),
]);
}
});
}
@@ -711,6 +739,14 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
ip.set(false);
px.add_css_class("suggested-action");
pct.remove_css_class("suggested-action");
px.update_property(&[
gtk::accessible::Property::Label("Pixels - currently active"),
]);
pct.update_property(&[
gtk::accessible::Property::Label("Percentage"),
]);
px.set_tooltip_text(Some("Use pixel dimensions (currently active)"));
pct.set_tooltip_text(Some("Use percentage dimensions"));
let dims = get_first_image_dims(&files.borrow());
let pct_w = ws.value();
@@ -755,6 +791,14 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
ip.set(true);
pct.add_css_class("suggested-action");
px.remove_css_class("suggested-action");
pct.update_property(&[
gtk::accessible::Property::Label("Percentage - currently active"),
]);
px.update_property(&[
gtk::accessible::Property::Label("Pixels"),
]);
pct.set_tooltip_text(Some("Use percentage dimensions (currently active)"));
px.set_tooltip_text(Some("Use pixel dimensions"));
let dims = get_first_image_dims(&files.borrow());
let cur_w = ws.value();
@@ -852,10 +896,31 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
gesture.set_state(gtk::EventSequenceState::Claimed);
});
thumb_picture.set_can_target(true);
thumb_picture.set_focusable(true);
thumb_picture.add_controller(click);
thumb_picture.set_cursor_from_name(Some("pointer"));
}
// Keyboard support for preview cycling (Space/Enter)
{
let pi = preview_index.clone();
let rt = render_thumb.clone();
let lf = loaded_files.clone();
let key = gtk::EventControllerKey::new();
key.connect_key_pressed(move |_, keyval, _, _| {
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
let count = lf.borrow().len();
if count > 1 {
pi.set((pi.get() + 1) % count);
rt();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
thumb_picture.add_controller(key);
}
// Initial render
{
let rt = render_thumb.clone();
@@ -868,10 +933,14 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
.child(&outer)
.build();
// Re-render on page map
// Sync enable toggle and re-render on page map
{
let rt = render_thumb.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().resize_enabled;
er.set_active(enabled);
rt();
});
}

View File

@@ -25,6 +25,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Watermark")
.subtitle("Add text or image watermark to processed images")
.active(cfg.watermark_enabled)
.tooltip_text("Toggle watermark on or off")
.build();
enable_group.add(&enable_row);
outer.append(&enable_group);
@@ -37,6 +38,10 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.vexpand(true)
.build();
preview_picture.set_can_target(true);
preview_picture.set_focusable(true);
preview_picture.update_property(&[
gtk::accessible::Property::Label("Watermark preview - press Space to cycle images"),
]);
let info_label = gtk::Label::builder()
.label("No images loaded")
@@ -78,6 +83,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.title("Type")
.subtitle("Choose text or image watermark")
.use_subtitle(true)
.tooltip_text("Choose between text or image/logo overlay")
.build();
type_row.set_model(Some(&gtk::StringList::new(&["Text Watermark", "Image Watermark"])));
type_row.set_list_factory(Some(&super::full_text_list_factory()));
@@ -95,6 +101,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
let text_row = adw::EntryRow::builder()
.title("Watermark Text")
.text(&cfg.watermark_text)
.tooltip_text("The text that appears as a watermark on each image")
.build();
let font_row = adw::ActionRow::builder()
@@ -115,12 +122,16 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family);
font_button.set_font_desc(&desc);
}
font_button.update_property(&[
gtk::accessible::Property::Label("Choose watermark font"),
]);
font_row.add_suffix(&font_button);
let font_size_row = adw::SpinRow::builder()
.title("Font Size")
.subtitle("Size in pixels")
.adjustment(&gtk::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
.tooltip_text("Size of watermark text in pixels")
.build();
text_group.add(&text_row);
@@ -144,7 +155,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
)
.activatable(true)
.build();
image_path_row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
let image_prefix_icon = gtk::Image::from_icon_name("image-x-generic-symbolic");
image_prefix_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
image_path_row.add_prefix(&image_prefix_icon);
let choose_image_button = gtk::Button::builder()
.icon_name("document-open-symbolic")
@@ -152,6 +165,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.valign(gtk::Align::Center)
.has_frame(false)
.build();
choose_image_button.update_property(&[
gtk::accessible::Property::Label("Choose logo image"),
]);
image_path_row.add_suffix(&choose_image_button);
image_group.add(&image_path_row);
@@ -287,6 +303,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.rgba(&initial_color)
.valign(gtk::Align::Center)
.build();
color_button.update_property(&[
gtk::accessible::Property::Label("Choose watermark text color"),
]);
color_row.add_suffix(&color_button);
// Opacity slider + reset
@@ -300,12 +319,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
opacity_scale.set_hexpand(false);
opacity_scale.set_valign(gtk::Align::Center);
opacity_scale.set_width_request(180);
opacity_scale.update_property(&[
gtk::accessible::Property::Label("Watermark opacity, 0 to 100 percent"),
]);
let opacity_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center)
.tooltip_text("Reset to 50%")
.has_frame(false)
.build();
opacity_reset.update_property(&[
gtk::accessible::Property::Label("Reset opacity to 50%"),
]);
opacity_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01);
opacity_row.add_suffix(&opacity_scale);
opacity_row.add_suffix(&opacity_reset);
@@ -321,12 +346,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
rotation_scale.set_hexpand(false);
rotation_scale.set_valign(gtk::Align::Center);
rotation_scale.set_width_request(180);
rotation_scale.update_property(&[
gtk::accessible::Property::Label("Watermark rotation, -180 to +180 degrees"),
]);
let rotation_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center)
.tooltip_text("Reset to 0 degrees")
.has_frame(false)
.build();
rotation_reset.update_property(&[
gtk::accessible::Property::Label("Reset rotation to 0 degrees"),
]);
rotation_reset.set_sensitive(cfg.watermark_rotation != 0);
rotation_row.add_suffix(&rotation_scale);
rotation_row.add_suffix(&rotation_reset);
@@ -336,6 +367,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.title("Tiled / Repeated")
.subtitle("Repeat watermark across the entire image")
.active(cfg.watermark_tiled)
.tooltip_text("Repeat the watermark in a grid pattern across the entire image")
.build();
// Margin slider + reset
@@ -349,12 +381,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
margin_scale.set_hexpand(false);
margin_scale.set_valign(gtk::Align::Center);
margin_scale.set_width_request(180);
margin_scale.update_property(&[
gtk::accessible::Property::Label("Watermark margin from edges, 0 to 200 pixels"),
]);
let margin_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center)
.tooltip_text("Reset to 10 px")
.has_frame(false)
.build();
margin_reset.update_property(&[
gtk::accessible::Property::Label("Reset margin to 10 pixels"),
]);
margin_reset.set_sensitive(cfg.watermark_margin != 10);
margin_row.add_suffix(&margin_scale);
margin_row.add_suffix(&margin_reset);
@@ -371,12 +409,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
scale_scale.set_hexpand(false);
scale_scale.set_valign(gtk::Align::Center);
scale_scale.set_width_request(180);
scale_scale.update_property(&[
gtk::accessible::Property::Label("Watermark scale, 1 to 100 percent of image"),
]);
let scale_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center)
.tooltip_text("Reset to 20%")
.has_frame(false)
.build();
scale_reset.update_property(&[
gtk::accessible::Property::Label("Reset scale to 20%"),
]);
scale_reset.set_sensitive((cfg.watermark_scale - 20.0).abs() > 0.5);
scale_row.add_suffix(&scale_scale);
scale_row.add_suffix(&scale_reset);
@@ -573,6 +617,27 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
preview_picture.add_controller(click);
}
// Keyboard support for preview cycling (Space/Enter)
{
let key = gtk::EventControllerKey::new();
let pidx = preview_index.clone();
let files = state.loaded_files.clone();
let up = update_preview.clone();
key.connect_key_pressed(move |_, keyval, _, _| {
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
let loaded = files.borrow();
if loaded.len() > 1 {
let next = (pidx.get() + 1) % loaded.len();
pidx.set(next);
up();
}
return gtk::glib::Propagation::Stop;
}
gtk::glib::Propagation::Proceed
});
preview_picture.add_controller(key);
}
// === Wire signals ===
// Enable toggle
@@ -857,12 +922,16 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.child(&outer)
.build();
// Refresh preview and sensitivity when navigating to this page
// Sync enable toggle, refresh preview and sensitivity when navigating to this page
{
let up = update_preview.clone();
let lf = state.loaded_files.clone();
let ctrl = controls.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().watermark_enabled;
er.set_active(enabled);
ctrl.set_sensitive(!lf.borrow().is_empty());
up();
});

View File

@@ -33,6 +33,10 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
.homogeneous(true)
.build();
builtin_flow.update_property(&[
gtk::accessible::Property::Label("Workflow preset selection grid"),
]);
// Custom card is always first (index 0)
let custom_card = build_custom_card();
builtin_flow.append(&custom_card);
@@ -181,20 +185,37 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
.description("Import or save your own workflows")
.build();
// Container for dynamically-rebuilt user preset rows
let user_rows_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
// FlowBox for user preset cards (same look as built-in presets)
let user_flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::Single)
.max_children_per_line(5)
.min_children_per_line(2)
.row_spacing(8)
.column_spacing(8)
.homogeneous(true)
.build();
user_group.add(&user_rows_box);
user_flow.update_property(&[
gtk::accessible::Property::Label("Your saved preset selection grid"),
]);
let import_button = gtk::Button::builder()
.label("Import Preset")
.icon_name("document-open-symbolic")
.action_name("win.import-preset")
let user_clamp = adw::Clamp::builder()
.maximum_size(1200)
.child(&user_flow)
.build();
import_button.add_css_class("flat");
user_group.add(&import_button);
let user_empty_label = gtk::Label::builder()
.label("No saved presets yet. Process images and save your workflow as a preset, or import one.")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.wrap(true)
.justify(gtk::Justification::Center)
.margin_top(8)
.margin_bottom(8)
.build();
user_group.add(&user_clamp);
user_group.add(&user_empty_label);
content.append(&user_group);
content.append(&custom_group);
@@ -228,39 +249,94 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
// Refresh user presets every time this page is shown
{
let jc = state.job_config.clone();
let rows_box = user_rows_box.clone();
let uf = user_flow.clone();
let uel = user_empty_label.clone();
page.connect_map(move |_| {
// Clear existing rows
while let Some(child) = rows_box.first_child() {
rows_box.remove(&child);
}
// Clear existing cards
uf.remove_all();
let store = pixstrip_core::storage::PresetStore::new();
let mut has_custom = false;
if let Ok(presets) = store.list() {
for preset in &presets {
if !preset.is_custom {
continue;
}
let list_box = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
has_custom = true;
let overlay = gtk::Overlay::new();
// Card body (same style as built-in presets)
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.hexpand(true)
.vexpand(false)
.build();
card.add_css_class("card");
card.set_size_request(180, 140);
card.update_property(&[
gtk::accessible::Property::Label(&format!("{}: {}", preset.name, preset.description)),
]);
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.vexpand(true)
.build();
let row = adw::ActionRow::builder()
.title(&preset.name)
.subtitle(&preset.description)
.activatable(true)
let icon = gtk::Image::builder()
.icon_name(&preset.icon)
.pixel_size(32)
.build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
if !preset.icon_color.is_empty() {
icon.add_css_class(&preset.icon_color);
}
let name_label = gtk::Label::builder()
.label(&preset.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(16)
.build();
let desc_label = gtk::Label::builder()
.label(&preset.description)
.css_classes(["caption", "dim-label"])
.wrap(true)
.justify(gtk::Justification::Center)
.max_width_chars(20)
.build();
inner.append(&icon);
inner.append(&name_label);
inner.append(&desc_label);
card.append(&inner);
overlay.set_child(Some(&card));
// Action buttons overlay (top-right corner)
let actions_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(0)
.halign(gtk::Align::End)
.valign(gtk::Align::Start)
.margin_top(2)
.margin_end(2)
.build();
row.add_prefix(&gtk::Image::from_icon_name(&preset.icon));
// Export button
let export_btn = gtk::Button::builder()
.icon_name("document-save-as-symbolic")
.tooltip_text("Export preset")
.valign(gtk::Align::Center)
.build();
export_btn.add_css_class("flat");
export_btn.add_css_class("circular");
let preset_for_export = preset.clone();
export_btn.connect_clicked(move |btn| {
let p = preset_for_export.clone();
@@ -280,40 +356,80 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
});
}
});
row.add_suffix(&export_btn);
// Delete button
let delete_btn = gtk::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Delete preset")
.valign(gtk::Align::Center)
.build();
delete_btn.add_css_class("flat");
delete_btn.add_css_class("circular");
delete_btn.add_css_class("error");
let pname = preset.name.clone();
let list_box_ref = list_box.clone();
let rows_box_ref = rows_box.clone();
delete_btn.connect_clicked(move |_| {
let uf_ref = uf.clone();
let uel_ref = uel.clone();
delete_btn.connect_clicked(move |btn| {
let store = pixstrip_core::storage::PresetStore::new();
let _ = store.delete(&pname);
rows_box_ref.remove(&list_box_ref);
// Remove the FlowBoxChild containing this card
if let Some(child) = btn.ancestor(gtk::FlowBoxChild::static_type()) {
if let Some(fbc) = child.downcast_ref::<gtk::FlowBoxChild>() {
uf_ref.remove(fbc);
// Show empty label if only the import card is left
let mut c = uf_ref.first_child();
let mut count = 0;
while let Some(w) = c {
count += 1;
c = w.next_sibling();
}
uel_ref.set_visible(count <= 1);
}
}
});
row.add_suffix(&delete_btn);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
actions_box.append(&export_btn);
actions_box.append(&delete_btn);
overlay.add_overlay(&actions_box);
let jc2 = jc.clone();
let p = preset.clone();
row.connect_activated(move |r| {
let mut cfg = jc2.borrow_mut();
apply_preset_to_config(&mut cfg, &p);
uf.append(&overlay);
}
}
uel.set_visible(!has_custom);
// Always append an "Import Preset" card at the end
let import_card = build_import_card();
uf.append(&import_card);
});
}
// Wire user preset card activation
{
let jc = state.job_config.clone();
user_flow.connect_child_activated(move |flow, child| {
// Count total children to know which is the import card (always last)
let mut total = 0usize;
let mut c = flow.first_child();
while let Some(w) = c {
total += 1;
c = w.next_sibling();
}
let activated_idx = child.index() as usize;
// Last card is always the import card
if activated_idx == total - 1 {
flow.activate_action("win.import-preset", None).ok();
return;
}
let store = pixstrip_core::storage::PresetStore::new();
if let Ok(presets) = store.list() {
let custom_presets: Vec<_> = presets.iter().filter(|p| p.is_custom).collect();
if let Some(preset) = custom_presets.get(activated_idx) {
let mut cfg = jc.borrow_mut();
apply_preset_to_config(&mut cfg, preset);
cfg.preset_mode = true;
drop(cfg);
r.activate_action("win.next-step", None).ok();
});
list_box.append(&row);
rows_box.append(&list_box);
flow.activate_action("win.next-step", None).ok();
}
}
});
@@ -430,15 +546,18 @@ fn build_custom_card() -> gtk::Box {
.vexpand(false)
.build();
card.add_css_class("card");
card.set_size_request(180, 120);
card.set_size_request(180, 140);
card.update_property(&[
gtk::accessible::Property::Label("Custom: Pick and choose operations"),
]);
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.margin_top(6)
.margin_bottom(6)
.margin_start(8)
.margin_end(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.vexpand(true)
@@ -448,6 +567,7 @@ fn build_custom_card() -> gtk::Box {
.icon_name("emblem-system-symbolic")
.pixel_size(32)
.build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let name_label = gtk::Label::builder()
.label("Custom")
@@ -470,6 +590,59 @@ fn build_custom_card() -> gtk::Box {
card
}
fn build_import_card() -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.hexpand(true)
.vexpand(false)
.build();
card.add_css_class("card");
card.set_size_request(180, 140);
card.set_tooltip_text(Some("Import a .pixstrip-preset file from disk"));
card.update_property(&[
gtk::accessible::Property::Label("Import preset from file"),
]);
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.vexpand(true)
.build();
let icon = gtk::Image::builder()
.icon_name("folder-open-symbolic")
.pixel_size(32)
.build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let name_label = gtk::Label::builder()
.label("Import Preset")
.css_classes(["heading"])
.build();
let desc_label = gtk::Label::builder()
.label("Load a preset from file")
.css_classes(["caption", "dim-label"])
.wrap(true)
.justify(gtk::Justification::Center)
.max_width_chars(20)
.build();
inner.append(&icon);
inner.append(&name_label);
inner.append(&desc_label);
card.append(&inner);
card
}
fn build_preset_card(preset: &Preset) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
@@ -478,15 +651,18 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
.vexpand(false)
.build();
card.add_css_class("card");
card.set_size_request(180, 120);
card.set_size_request(180, 140);
card.update_property(&[
gtk::accessible::Property::Label(&format!("{}: {}", preset.name, preset.description)),
]);
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.margin_top(6)
.margin_bottom(6)
.margin_start(8)
.margin_end(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.vexpand(true)
@@ -496,6 +672,10 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
.icon_name(&preset.icon)
.pixel_size(32)
.build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
if !preset.icon_color.is_empty() {
icon.add_css_class(&preset.icon_color);
}
let name_label = gtk::Label::builder()
.label(&preset.name)

View File

@@ -1,46 +1,7 @@
use adw::prelude::*;
use gtk::glib;
struct TourStop {
title: &'static str,
description: &'static str,
icon: &'static str,
}
const TOUR_STOPS: &[TourStop] = &[
TourStop {
title: "Step Indicator",
description: "This bar shows your progress through the wizard. Click any completed step to jump back to it.",
icon: "view-list-symbolic",
},
TourStop {
title: "Choose a Workflow",
description: "Start by picking a preset that matches what you need, or build a custom workflow from scratch.",
icon: "applications-graphics-symbolic",
},
TourStop {
title: "Add Your Images",
description: "Drag and drop files here, or use the Add button. You can paste from the clipboard too.",
icon: "image-x-generic-symbolic",
},
TourStop {
title: "Navigation",
description: "Use the Back and Next buttons to move between steps, or press Alt+Left/Right. Disabled steps are automatically skipped.",
icon: "go-next-symbolic",
},
TourStop {
title: "Main Menu",
description: "Access settings, keyboard shortcuts, processing history, and preset management from here.",
icon: "open-menu-symbolic",
},
TourStop {
title: "You're Ready!",
description: "That's everything you need to know. Each step also has a help button (?) in the header bar for detailed guidance.",
icon: "emblem-ok-symbolic",
},
];
/// Show the tutorial overlay if the user hasn't completed it yet.
/// Show the tutorial tour if the user hasn't completed it yet.
/// Called after the welcome wizard closes on first launch.
pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
let config_store = pixstrip_core::storage::ConfigStore::new();
@@ -53,28 +14,74 @@ pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
// Small delay to let the welcome dialog fully dismiss
let win = window.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(400), move || {
show_tour_dialog(&win, 0);
show_tour_stop(&win, 0);
});
}
fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
let stop = &TOUR_STOPS[stop_index];
let total = TOUR_STOPS.len();
/// Tour stops: (title, description, widget_name, popover_position)
fn tour_stops() -> Vec<(&'static str, &'static str, &'static str, gtk::PositionType)> {
vec![
(
"Choose a Workflow",
"Pick a preset that matches your needs, or scroll down to build a custom workflow from scratch.",
"tour-content",
gtk::PositionType::Bottom,
),
(
"Track Your Progress",
"This bar shows where you are in the wizard. Click any completed step to jump back to it.",
"tour-step-indicator",
gtk::PositionType::Bottom,
),
(
"Navigation",
"Use Back and Next to move between steps, or press Alt+Left / Alt+Right. Disabled steps are automatically skipped.",
"tour-next-button",
gtk::PositionType::Top,
),
(
"Main Menu",
"Settings, keyboard shortcuts, processing history, and preset management live here.",
"tour-menu-button",
gtk::PositionType::Bottom,
),
(
"Get Help",
"Every step has a help button with detailed guidance specific to that step.",
"tour-help-button",
gtk::PositionType::Bottom,
),
]
}
let dialog = adw::Dialog::builder()
.title("Quick Tour")
.content_width(420)
.content_height(300)
.build();
fn show_tour_stop(window: &adw::ApplicationWindow, index: usize) {
let stops = tour_stops();
let total = stops.len();
if index >= total {
mark_tutorial_complete();
return;
}
let (title, description, widget_name, position) = stops[index];
// Find the target widget by name in the widget tree
let Some(root) = window.content() else { return };
let Some(target) = find_widget_by_name(&root, widget_name) else {
// Widget not found - skip to next stop
show_tour_stop(window, index + 1);
return;
};
// Build popover content
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(16)
.margin_top(24)
.margin_bottom(24)
.margin_start(24)
.margin_end(24)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(16)
.margin_end(16)
.build();
content.set_size_request(280, -1);
// Progress dots
let dots_box = gtk::Box::builder()
@@ -82,12 +89,11 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
.spacing(6)
.halign(gtk::Align::Center)
.build();
for i in 0..total {
let dot = gtk::Label::builder()
.label(if i == stop_index { "\u{25CF}" } else { "\u{25CB}" })
.label(if i == index { "\u{25CF}" } else { "\u{25CB}" })
.build();
if i == stop_index {
if i == index {
dot.add_css_class("accent");
} else {
dot.add_css_class("dim-label");
@@ -96,94 +102,132 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
}
content.append(&dots_box);
// Icon
let icon = gtk::Image::builder()
.icon_name(stop.icon)
.pixel_size(64)
.halign(gtk::Align::Center)
.build();
icon.add_css_class("accent");
content.append(&icon);
// Title
let title = gtk::Label::builder()
.label(stop.title)
.css_classes(["title-2"])
.halign(gtk::Align::Center)
let title_label = gtk::Label::builder()
.label(title)
.css_classes(["title-3"])
.halign(gtk::Align::Start)
.build();
content.append(&title);
// Step counter
let counter = gtk::Label::builder()
.label(&format!("{} of {}", stop_index + 1, total))
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.build();
content.append(&counter);
content.append(&title_label);
// Description
let desc = gtk::Label::builder()
.label(stop.description)
let desc_label = gtk::Label::builder()
.label(description)
.wrap(true)
.halign(gtk::Align::Center)
.justify(gtk::Justification::Center)
.max_width_chars(36)
.halign(gtk::Align::Start)
.xalign(0.0)
.build();
content.append(&desc);
content.append(&desc_label);
// Step counter
let counter_label = gtk::Label::builder()
.label(&format!("{} of {}", index + 1, total))
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Start)
.build();
content.append(&counter_label);
// Buttons
let button_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Center)
.margin_top(8)
.halign(gtk::Align::End)
.margin_top(4)
.build();
let skip_button = gtk::Button::builder()
let skip_btn = gtk::Button::builder()
.label("Skip Tour")
.tooltip_text("Close the tour and start using Pixstrip")
.build();
skip_button.add_css_class("flat");
skip_btn.add_css_class("flat");
let is_last = stop_index + 1 >= total;
let next_label = if is_last { "Get Started" } else { "Next" };
let next_button = gtk::Button::builder()
.label(next_label)
let is_last = index + 1 >= total;
let next_btn = gtk::Button::builder()
.label(if is_last { "Done" } else { "Next" })
.tooltip_text(if is_last { "Finish the tour" } else { "Go to the next tour stop" })
.build();
next_button.add_css_class("suggested-action");
next_button.add_css_class("pill");
next_btn.add_css_class("suggested-action");
next_btn.add_css_class("pill");
button_box.append(&skip_button);
button_box.append(&next_button);
button_box.append(&skip_btn);
button_box.append(&next_btn);
content.append(&button_box);
dialog.set_child(Some(&content));
// Create popover attached to the target widget
let popover = gtk::Popover::builder()
.child(&content)
.position(position)
.autohide(false)
.has_arrow(true)
.build();
popover.set_parent(&target);
// Wire skip - mark tutorial complete and close
// For the content area (large widget), point to the upper portion
// where the preset cards are visible
if widget_name == "tour-content" {
let w = target.width();
if w > 0 {
let rect = gtk::gdk::Rectangle::new(w / 2 - 10, 40, 20, 20);
popover.set_pointing_to(Some(&rect));
}
}
// Accessible label for screen readers
popover.update_property(&[
gtk::accessible::Property::Label(
&format!("Tour step {} of {}: {}", index + 1, total, title)
),
]);
// Wire skip button
{
let dlg = dialog.clone();
skip_button.connect_clicked(move |_| {
let pop = popover.clone();
skip_btn.connect_clicked(move |_| {
mark_tutorial_complete();
dlg.close();
pop.popdown();
let p = pop.clone();
glib::idle_add_local_once(move || {
p.unparent();
});
});
}
// Wire next
// Wire next button
{
let dlg = dialog.clone();
let pop = popover.clone();
let win = window.clone();
next_button.connect_clicked(move |_| {
dlg.close();
next_btn.connect_clicked(move |_| {
pop.popdown();
let p = pop.clone();
let w = win.clone();
glib::idle_add_local_once(move || {
p.unparent();
if is_last {
mark_tutorial_complete();
} else {
let w = win.clone();
glib::idle_add_local_once(move || {
show_tour_dialog(&w, stop_index + 1);
});
show_tour_stop(&w, index + 1);
}
});
});
}
dialog.present(Some(window));
popover.popup();
}
/// Recursively search the widget tree for a widget with the given name.
fn find_widget_by_name(root: &gtk::Widget, name: &str) -> Option<gtk::Widget> {
if root.widget_name().as_str() == name {
return Some(root.clone());
}
let mut child = root.first_child();
while let Some(c) = child {
if let Some(found) = find_widget_by_name(&c, name) {
return Some(found);
}
child = c.next_sibling();
}
None
}
fn mark_tutorial_complete() {