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 Icon=live.lashman.Pixstrip
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Graphics;ImageProcessing; Categories=Graphics;ImageProcessing;GTK;
MimeType=image/jpeg;image/png;image/webp;image/avif;image/gif;image/tiff;image/bmp; 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; Keywords=image;photo;resize;convert;compress;batch;metadata;strip;watermark;rename;
StartupNotify=true StartupNotify=true
SingleMainWindow=true

View File

@@ -3,16 +3,18 @@
<id>live.lashman.Pixstrip</id> <id>live.lashman.Pixstrip</id>
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>CC0-1.0</project_license> <project_license>CC0-1.0</project_license>
<name>Pixstrip</name> <name>Pixstrip</name>
<summary>Batch image processor - resize, convert, compress, and more</summary> <summary>Batch image processor - resize, convert, compress, and more</summary>
<description> <description>
<p> <p>
Pixstrip is a batch image processor for Linux that combines resize, convert, Pixstrip is a native GTK4/libadwaita batch image processor for Linux
compress, metadata strip, watermark, rename, and basic image adjustments into that combines resize, convert, compress, metadata strip, watermark,
a single wizard-driven workflow. rename, and image adjustments into a single wizard-driven workflow.
It processes everything locally with no cloud dependency.
</p> </p>
<p>Features include:</p> <p>Key features:</p>
<ul> <ul>
<li>Resize images by width, height, fit-in-box, or social media presets</li> <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> <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>Add text or image watermarks with positioning and rotation</li>
<li>Rename files with templates, counters, regex, and EXIF variables</li> <li>Rename files with templates, counters, regex, and EXIF variables</li>
<li>Adjust brightness, contrast, saturation, and apply effects</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>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> </ul>
</description> </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> <launchable type="desktop-id">live.lashman.Pixstrip.desktop</launchable>
<url type="bugtracker">https://git.lashman.live/lashman/pixstrip/issues</url>
<developer id="live.lashman"> <developer id="live.lashman">
<name>lashman</name> <name>lashman</name>
</developer> </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> <branding>
<color type="primary" scheme_preference="light">#99c1f1</color> <color type="primary" scheme_preference="light">#57a773</color>
<color type="primary" scheme_preference="dark">#1a5fb4</color> <color type="primary" scheme_preference="dark">#263226</color>
</branding> </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" /> <content_rating type="oars-1.1" />
<requires> <requires>
<display_length compare="ge">360</display_length> <display_length compare="ge">360</display_length>
</requires> </requires>
<recommends>
<control>keyboard</control>
<control>pointing</control>
</recommends>
<supports> <supports>
<control>pointing</control> <control>pointing</control>
<control>keyboard</control> <control>keyboard</control>
<control>touch</control> <control>touch</control>
</supports> </supports>
<provides>
<binary>pixstrip-gtk</binary>
<binary>pixstrip</binary>
</provides>
<releases> <releases>
<release version="0.1.0" date="2026-03-06"> <release version="0.1.0" date="2026-03-06" type="stable">
<description> <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> </description>
</release> </release>
</releases> </releases>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -339,7 +339,7 @@ fn build_ui(app: &adw::Application) {
allow_upscale: false, allow_upscale: false,
resize_algorithm: 0, resize_algorithm: 0,
output_dpi: 72, output_dpi: 72,
adjustments_enabled: false, adjustments_enabled: if remember { sess_state.adjustments_enabled.unwrap_or(false) } else { false },
rotation: 0, rotation: 0,
flip: 0, flip: 0,
brightness: 0, brightness: 0,
@@ -445,6 +445,10 @@ fn build_ui(app: &adw::Application) {
.tooltip_text("Help for this step") .tooltip_text("Help for this step")
.build(); .build();
help_button.add_css_class("flat"); 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); header.pack_end(&help_button);
// Hamburger menu // Hamburger menu
@@ -455,6 +459,7 @@ fn build_ui(app: &adw::Application) {
.primary(true) .primary(true)
.tooltip_text("Main Menu") .tooltip_text("Main Menu")
.build(); .build();
menu_button.set_widget_name("tour-menu-button");
header.pack_end(&menu_button); header.pack_end(&menu_button);
// Step indicator // Step indicator
@@ -462,6 +467,7 @@ fn build_ui(app: &adw::Application) {
// Navigation view for wizard content // Navigation view for wizard content
let nav_view = adw::NavigationView::new(); let nav_view = adw::NavigationView::new();
nav_view.set_widget_name("tour-content");
nav_view.set_vexpand(true); nav_view.set_vexpand(true);
nav_view.update_property(&[ nav_view.update_property(&[
gtk::accessible::Property::Label("Wizard steps. Use Alt+Left/Right to navigate."), 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)") .tooltip_text("Go to next step (Alt+Right)")
.build(); .build();
next_button.add_css_class("suggested-action"); next_button.add_css_class("suggested-action");
next_button.set_widget_name("tour-next-button");
let bottom_box = gtk::CenterBox::new(); let bottom_box = gtk::CenterBox::new();
bottom_box.set_start_widget(Some(&back_button)); bottom_box.set_start_widget(Some(&back_button));
@@ -514,6 +521,9 @@ fn build_ui(app: &adw::Application) {
.tooltip_text("Watch Folders") .tooltip_text("Watch Folders")
.build(); .build();
watch_button.add_css_class("flat"); watch_button.add_css_class("flat");
watch_button.update_property(&[
gtk::accessible::Property::Label("Toggle watch folders panel"),
]);
header.pack_start(&watch_button); header.pack_start(&watch_button);
{ {
@@ -531,6 +541,7 @@ fn build_ui(app: &adw::Application) {
.child(step_indicator.widget()) .child(step_indicator.widget())
.build(); .build();
indicator_scroll.set_size_request(-1, 52); indicator_scroll.set_size_request(-1, 52);
indicator_scroll.set_widget_name("tour-step-indicator");
content_box.append(&indicator_scroll); content_box.append(&indicator_scroll);
content_box.append(&nav_view); content_box.append(&nav_view);
content_box.append(&watch_revealer); content_box.append(&watch_revealer);
@@ -608,6 +619,7 @@ fn build_ui(app: &adw::Application) {
state.resize_enabled = Some(cfg.resize_enabled); state.resize_enabled = Some(cfg.resize_enabled);
state.resize_width = Some(cfg.resize_width); state.resize_width = Some(cfg.resize_width);
state.resize_height = Some(cfg.resize_height); state.resize_height = Some(cfg.resize_height);
state.adjustments_enabled = Some(cfg.adjustments_enabled);
state.convert_enabled = Some(cfg.convert_enabled); state.convert_enabled = Some(cfg.convert_enabled);
state.convert_format = cfg.convert_format.map(|f| format!("{:?}", f)); state.convert_format = cfg.convert_format.map(|f| format!("{:?}", f));
state.compress_enabled = Some(cfg.compress_enabled); state.compress_enabled = Some(cfg.compress_enabled);
@@ -1487,14 +1499,18 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
.subtitle(&format!("{} - {}", time_label, subtitle)) .subtitle(&format!("{} - {}", time_label, subtitle))
.show_enable_switch(false) .show_enable_switch(false)
.build(); .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 // Detail rows inside expander
let input_row = adw::ActionRow::builder() let input_row = adw::ActionRow::builder()
.title("Input") .title("Input")
.subtitle(&entry.input_dir) .subtitle(&entry.input_dir)
.build(); .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); row.add_row(&input_row);
let output_row = adw::ActionRow::builder() let output_row = adw::ActionRow::builder()
@@ -1502,7 +1518,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
.subtitle(&entry.output_dir) .subtitle(&entry.output_dir)
.activatable(true) .activatable(true)
.build(); .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(); let out_dir = entry.output_dir.clone();
output_row.connect_activated(move |_| { output_row.connect_activated(move |_| {
let uri = gtk::gio::File::for_path(&out_dir).uri(); let uri = gtk::gio::File::for_path(&out_dir).uri();
@@ -1522,7 +1540,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
savings savings
)) ))
.build(); .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); row.add_row(&size_row);
if entry.failed > 0 { if entry.failed > 0 {
@@ -1530,7 +1550,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
.title("Errors") .title("Errors")
.subtitle(&format!("{} files failed", entry.failed)) .subtitle(&format!("{} files failed", entry.failed))
.build(); .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); row.add_row(&err_row);
} }
@@ -2138,7 +2160,7 @@ fn continue_processing(
} }
ProcessingMessage::Error(err) => { ProcessingMessage::Error(err) => {
mark_current_queue_batch(&ui_for_rx, false, Some(&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.toast_overlay.add_toast(toast);
ui_for_rx.back_button.set_visible(true); ui_for_rx.back_button.set_visible(true);
ui_for_rx.next_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() { let entries = match history.list() {
Ok(e) => e, Ok(e) => e,
Err(_) => { 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; return;
} }
}; };
let Some(last) = entries.last() else { 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; return;
}; };
if last.output_files.is_empty() { 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; return;
} }
@@ -2457,7 +2479,7 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
// Save the texture to a temp file // Save the texture to a temp file
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard"); let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
if std::fs::create_dir_all(&temp_dir).is_err() { 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; return;
} }
let timestamp = std::time::SystemTime::now() let timestamp = std::time::SystemTime::now()
@@ -2480,10 +2502,10 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
toast.set_timeout(2); toast.set_timeout(2);
ui.toast_overlay.add_toast(toast); ui.toast_overlay.add_toast(toast);
} else { } 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 { } 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); ui.toast_overlay.add_toast(toast);
} }
Err(e) => { 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); ui.toast_overlay.add_toast(toast);
} }
} }
@@ -2732,6 +2754,90 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
.build(); .build();
name_group.add(&desc_entry); 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() let save_new_button = gtk::Button::builder()
.label("Save New Preset") .label("Save New Preset")
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
@@ -2770,9 +2876,15 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
let ui_c = ui.clone(); let ui_c = ui.clone();
let dlg_c = dialog.clone(); let dlg_c = dialog.clone();
let pname = preset_name.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 |_| { row.connect_activated(move |_| {
let cfg = ui_c.state.job_config.borrow(); 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); drop(cfg);
let store = pixstrip_core::storage::PresetStore::new(); 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); ui_c.toast_overlay.add_toast(toast);
} }
Err(e) => { 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); 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 dlg_c = dialog.clone();
let entry_c = name_entry.clone(); let entry_c = name_entry.clone();
let desc_c = desc_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 |_| { save_new_button.connect_clicked(move |_| {
let name = entry_c.text().to_string(); let name = entry_c.text().to_string();
if name.trim().is_empty() { 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 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 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); drop(cfg);
let store = pixstrip_core::storage::PresetStore::new(); 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); ui_c.toast_overlay.add_toast(toast);
} }
Err(e) => { 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); ui_c.toast_overlay.add_toast(toast);
} }
} }
@@ -2836,7 +2954,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
dialog.present(Some(window)); 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 { let resize = if cfg.resize_enabled && cfg.resize_width > 0 {
if cfg.resize_height == 0 { if cfg.resize_height == 0 {
Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width)) 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()) .filter(|d| !d.trim().is_empty())
.map(|d| d.to_string()) .map(|d| d.to_string())
.unwrap_or_else(|| build_preset_description(cfg)), .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, is_custom: true,
resize, resize,
rotation, 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) { fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts") .title("Keyboard Shortcuts")
.content_width(420) .content_width(460)
.content_height(480) .content_height(520)
.build(); .build();
let toolbar_view = adw::ToolbarView::new(); let toolbar_view = adw::ToolbarView::new();
@@ -3201,56 +3321,75 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) {
let scroll = gtk::ScrolledWindow::builder() let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never) .hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.build(); .build();
let content = gtk::Box::builder() let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.margin_start(16) .margin_start(24)
.margin_end(16) .margin_end(24)
.margin_top(8) .margin_top(12)
.margin_bottom(16) .margin_bottom(24)
.spacing(16) .spacing(18)
.build(); .build();
let sections: &[(&str, &[(&str, &str)])] = &[ let sections: &[(&str, &[(&str, &str)])] = &[
("Wizard Navigation", &[ ("Wizard Navigation", &[
("Alt + Right", "Next step"), ("<Alt>Right", "Next step"),
("Alt + Left", "Previous step"), ("<Alt>Left", "Previous step"),
("Alt + 1-9", "Jump to step"), ("<Alt>1", "Jump to step (1-9)"),
("Ctrl + Return", "Process images"), ("<Control>Return", "Process images"),
("Escape", "Cancel or go back"), ("Escape", "Cancel or go back"),
]), ]),
("File Management", &[ ("File Management", &[
("Ctrl + O", "Add files"), ("<Control>o", "Add files"),
("Ctrl + V", "Paste image from clipboard"), ("<Control>v", "Paste image from clipboard"),
("Ctrl + A", "Select all images"), ("<Control>a", "Select all images"),
("Ctrl + Shift + A", "Deselect all images"), ("<Control><Shift>a", "Deselect all images"),
("Delete", "Remove selected images"), ("Delete", "Remove selected images"),
]), ]),
("Application", &[ ("Application", &[
("Ctrl + ,", "Settings"), ("<Control>comma", "Settings"),
("F1", "Keyboard shortcuts"), ("F1", "Keyboard shortcuts"),
("Ctrl + Z", "Undo last batch"), ("<Control>z", "Undo last batch"),
("Ctrl + Q", "Quit"), ("<Control>q", "Quit"),
]), ]),
]; ];
for (section_title, shortcuts) in sections { for (section_title, shortcuts) in sections {
let group = adw::PreferencesGroup::builder() let group = gtk::Box::builder()
.title(*section_title) .orientation(gtk::Orientation::Vertical)
.spacing(6)
.build(); .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 { for (accel, description) in *shortcuts {
let row = adw::ActionRow::builder() let row = gtk::Box::builder()
.title(*description) .orientation(gtk::Orientation::Horizontal)
.spacing(12)
.build(); .build();
let label = gtk::Label::builder()
.label(*accel) let desc_label = gtk::Label::builder()
.css_classes(["dim-label", "monospace"]) .label(*description)
.valign(gtk::Align::Center) .halign(gtk::Align::Start)
.hexpand(true)
.build(); .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); content.append(&group);
@@ -3288,82 +3427,143 @@ fn apply_accessibility_settings() {
} }
fn show_step_help(window: &adw::ApplicationWindow, step: usize) { fn show_step_help(window: &adw::ApplicationWindow, step: usize) {
let (title, body) = match step { let (title, icon_name, body) = match step {
0 => ("Workflow", concat!( 0 => ("Workflow", "view-grid-symbolic", concat!(
"Choose a preset to start quickly, or configure each step manually.\n\n", "Pick a built-in preset to start quickly, or select the Custom card to choose ",
"Presets apply recommended settings for common tasks like web optimization, ", "which operations to include.\n\n",
"social media, or print preparation. You can customize any preset after applying it.\n\n", "Built-in presets auto-advance to the Images step with recommended settings. ",
"Use Import/Export to share presets with others." "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", "Add the images you want to process.\n\n",
"- Drag and drop files or folders onto this area\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", "- 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." "Ctrl+A selects all, Ctrl+Shift+A deselects all."
)), )),
2 => ("Resize", concat!( 2 => ("Resize", "view-fullscreen-symbolic", concat!(
"Scale images to specific dimensions.\n\n", "Scale images to specific dimensions with a live preview.\n\n",
"Choose a preset size or enter custom dimensions. Width-only or height-only ", "Pick a category and preset size, or enter custom width and height. ",
"resizing preserves the original aspect ratio.\n\n", "Toggle between pixel and percentage units. Lock the aspect ratio to keep proportions.\n\n",
"Enable 'Allow upscale' if you need images smaller than the target to be enlarged." "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!( 3 => ("Adjustments", "preferences-color-symbolic", concat!(
"Fine-tune image appearance.\n\n", "Fine-tune image appearance with a live preview.\n\n",
"Adjust brightness, contrast, and saturation with sliders. ", "Orientation: rotate (including auto-orient from EXIF) and flip.\n",
"Apply rotation, flipping, grayscale, or sepia effects.\n\n", "Color: adjust brightness, contrast, and saturation with sliders.\n",
"Crop to a specific aspect ratio or trim whitespace borders automatically." "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", "Change image file format.\n\n",
"Convert between JPEG, PNG, WebP, AVIF, GIF, TIFF, and BMP. ", "Select a target format from the card grid (JPEG, PNG, WebP, AVIF) or use the ",
"Each format has trade-offs between quality, file size, and compatibility.\n\n", "Other Formats dropdown for GIF, TIFF, and BMP. Keep Original preserves each file's format.\n\n",
"WebP and AVIF offer the best compression for web use." "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", "Reduce file size while preserving quality.\n\n",
"Choose a quality preset (Lossless, High, Balanced, Small, Tiny) or set custom ", "Use the quality slider to set the overall level from Low to Maximum. ",
"quality values per format.\n\n", "The split preview shows a side-by-side before/after comparison - drag the divider ",
"Expand Advanced Options for fine control over WebP encoding effort and AVIF speed." "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!( 6 => ("Metadata", "dialog-password-symbolic", concat!(
"Control what metadata is kept or removed.\n\n", "Control what image metadata is kept or removed.\n\n",
"Strip All removes everything. Privacy mode keeps copyright and camera info but ", "- Strip All: remove everything for smallest files and maximum privacy\n",
"removes GPS and timestamps. Custom mode lets you pick exactly what to strip.\n\n", "- Privacy: strip GPS and camera serial, keep copyright\n",
"Removing metadata reduces file size and protects privacy." "- 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!( 7 => ("Watermark", "emblem-photos-symbolic", concat!(
"Add a text or image watermark.\n\n", "Add a text or image watermark with a live preview.\n\n",
"Choose text or logo mode. Position the watermark using the visual grid. ", "Text mode: enter your text, choose a font and size.\n",
"Expand Advanced Options for opacity, rotation, tiling, margin, and scale controls.\n\n", "Image mode: select a logo file (PNG with transparency works best).\n\n",
"Logo watermarks support PNG images with transparency." "Position the watermark using the 3x3 grid. Expand Advanced Options for text color, ",
"opacity, rotation, tiling, margin, and scale controls."
)), )),
8 => ("Rename", concat!( 8 => ("Rename", "document-edit-symbolic", concat!(
"Rename output files using patterns.\n\n", "Rename output files with a live preview showing before and after names.\n\n",
"Add a prefix, suffix, or use a full template with placeholders:\n", "Simple options: add a prefix or suffix, replace spaces, filter special characters, ",
"- {name} - original filename\n", "convert case, and add a sequential counter.\n\n",
"- {n} - counter number\n", "Expand Advanced for a template engine with variables like {name}, {counter}, {date}, ",
"- {date} - current date\n", "{exif_date}, {camera}, {width}, {height}, and more. Also includes find-and-replace with regex."
"- {ext} - original extension\n\n",
"Expand Advanced Options for case conversion and find-and-replace."
)), )),
9 => ("Output", concat!( 9 => ("Output", "folder-download-symbolic", concat!(
"Review settings and choose where to save.\n\n", "Review and start processing.\n\n",
"The summary shows all operations that will be applied. ", "The operation summary lists all enabled steps and their settings. ",
"Choose an output folder or use the default 'processed' subfolder.\n\n", "Choose an output folder or use the default 'processed' subfolder.\n\n",
"Set overwrite behavior for when output files already exist. ", "Toggle Preserve Directory Structure to keep subfolder hierarchy in output. ",
"Press Process or Ctrl+Enter to start." "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() let dialog = adw::Dialog::builder()
.heading(format!("Help: {}", title)) .title(format!("Help: {}", title))
.body(body) .content_width(420)
.content_height(360)
.build(); .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)); dialog.present(Some(window));
} }
@@ -3415,6 +3615,9 @@ fn build_watch_folder_panel() -> gtk::Box {
.tooltip_text("Add watch folder") .tooltip_text("Add watch folder")
.build(); .build();
add_btn.add_css_class("flat"); add_btn.add_css_class("flat");
add_btn.update_property(&[
gtk::accessible::Property::Label("Add watch folder"),
]);
header_box.append(&add_btn); header_box.append(&add_btn);
inner.append(&header_box); inner.append(&header_box);
@@ -3451,15 +3654,29 @@ fn build_watch_folder_panel() -> gtk::Box {
.title(display_name) .title(display_name)
.subtitle(&folder.preset_name) .subtitle(&folder.preset_name)
.build(); .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 // 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() let status = gtk::Label::builder()
.label("Watching") .label("Watching")
.css_classes(["caption", "accent"]) .css_classes(["caption", "accent"])
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.build(); .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); list_box.append(&row);
} }
@@ -3518,14 +3735,28 @@ fn build_watch_folder_panel() -> gtk::Box {
.title(&display_name) .title(&display_name)
.subtitle(&new_folder.preset_name) .subtitle(&new_folder.preset_name)
.build(); .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() let status = gtk::Label::builder()
.label("Watching") .label("Watching")
.css_classes(["caption", "accent"]) .css_classes(["caption", "accent"])
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.build(); .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.append(&row);
list_box_c.set_visible(true); list_box_c.set_visible(true);
@@ -3644,7 +3875,11 @@ fn refresh_queue_list(ui: &WizardUi) {
.title(&batch.name) .title(&batch.name)
.subtitle(&format!("{} images - {}", batch.files.len(), status_text)) .subtitle(&format!("{} images - {}", batch.files.len(), status_text))
.build(); .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 // Add remove button for pending batches
if batch.status == BatchStatus::Pending { if batch.status == BatchStatus::Pending {
@@ -3686,7 +3921,7 @@ fn add_current_batch_to_queue(ui: &WizardUi) {
}; };
if files.is_empty() { 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; return;
} }

View File

@@ -142,31 +142,41 @@ pub fn build_results_page() -> adw::NavigationPage {
.title("Images processed") .title("Images processed")
.subtitle("0 images") .subtitle("0 images")
.build(); .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() let size_before_row = adw::ActionRow::builder()
.title("Original size") .title("Original size")
.subtitle("0 B") .subtitle("0 B")
.build(); .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() let size_after_row = adw::ActionRow::builder()
.title("Output size") .title("Output size")
.subtitle("0 B") .subtitle("0 B")
.build(); .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() let savings_row = adw::ActionRow::builder()
.title("Space saved") .title("Space saved")
.subtitle("0%") .subtitle("0%")
.build(); .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() let time_row = adw::ActionRow::builder()
.title("Processing time") .title("Processing time")
.subtitle("0s") .subtitle("0s")
.build(); .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(&images_row);
stats_group.add(&size_before_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") .subtitle("View processed images in file manager")
.activatable(true) .activatable(true)
.build(); .build();
open_row.add_prefix(&gtk::Image::from_icon_name("folder-open-symbolic")); let open_icon = gtk::Image::from_icon_name("folder-open-symbolic");
open_row.add_suffix(&gtk::Image::from_icon_name("go-next-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() let process_more_row = adw::ActionRow::builder()
.title("Process Another Batch") .title("Process Another Batch")
.subtitle("Start over with new images") .subtitle("Start over with new images")
.activatable(true) .activatable(true)
.build(); .build();
process_more_row.add_prefix(&gtk::Image::from_icon_name("view-refresh-symbolic")); let more_icon = gtk::Image::from_icon_name("view-refresh-symbolic");
process_more_row.add_suffix(&gtk::Image::from_icon_name("go-next-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() let save_preset_row = adw::ActionRow::builder()
.title("Save as Preset") .title("Save as Preset")
.subtitle("Save this workflow for future use") .subtitle("Save this workflow for future use")
.activatable(true) .activatable(true)
.build(); .build();
save_preset_row.add_prefix(&gtk::Image::from_icon_name("document-save-symbolic")); let save_icon = gtk::Image::from_icon_name("document-save-symbolic");
save_preset_row.add_suffix(&gtk::Image::from_icon_name("go-next-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() let add_queue_row = adw::ActionRow::builder()
.title("Add to Queue") .title("Add to Queue")
.subtitle("Queue another batch with different images") .subtitle("Queue another batch with different images")
.activatable(true) .activatable(true)
.build(); .build();
add_queue_row.add_prefix(&gtk::Image::from_icon_name("view-list-symbolic")); let queue_icon = gtk::Image::from_icon_name("view-list-symbolic");
add_queue_row.add_suffix(&gtk::Image::from_icon_name("go-next-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(&open_row);
action_group.add(&process_more_row); action_group.add(&process_more_row);

View File

@@ -57,7 +57,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.activatable(true) .activatable(true)
.visible(config.output_fixed_path.is_some()) .visible(config.output_fixed_path.is_some())
.build(); .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() let choose_fixed_btn = gtk::Button::builder()
.icon_name("document-open-symbolic") .icon_name("document-open-symbolic")
@@ -65,6 +67,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.build(); .build();
choose_fixed_btn.add_css_class("flat"); 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); fixed_path_row.add_suffix(&choose_fixed_btn);
// Shared state for fixed path // Shared state for fixed path
@@ -164,7 +169,34 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.build(); .build();
reset_button.add_css_class("destructive-action"); 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(&skill_row);
ui_group.add(&reset_welcome_row);
general_page.add(&ui_group); general_page.add(&ui_group);
// File Manager Integration // File Manager Integration
@@ -432,6 +464,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.css_classes(["boxed-list"]) .css_classes(["boxed-list"])
.build(); .build();
watch_list.set_widget_name("watch-folder-list"); 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 // Shared state for watch folders
let watch_folders_state: std::rc::Rc<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>> = 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 // Save settings when the dialog closes
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let welcome_reset = reset_welcome_state.get();
let new_config = AppConfig { let new_config = AppConfig {
first_run_complete: true, first_run_complete: !welcome_reset,
tutorial_complete: true, // preserve if settings are being saved tutorial_complete: !welcome_reset,
output_subfolder: subfolder_row.text().to_string(), output_subfolder: subfolder_row.text().to_string(),
output_fixed_path: if output_mode_row.selected() == 1 { output_fixed_path: if output_mode_row.selected() == 1 {
fixed_path_state.borrow().clone() fixed_path_state.borrow().clone()

View File

@@ -49,10 +49,24 @@ impl StepIndicator {
container.append(&grid); container.append(&grid);
// First step starts as current // First step starts as current
let total = dots.len();
if let Some(first) = dots.first() { if let Some(first) = dots.first() {
first.icon.set_icon_name(Some("radio-checked-symbolic")); first.icon.set_icon_name(Some("radio-checked-symbolic"));
first.button.set_sensitive(true); first.button.set_sensitive(true);
first.label.add_css_class("accent"); 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 { Self {
@@ -164,11 +178,17 @@ impl StepIndicator {
pub fn set_completed(&self, actual_index: usize) { pub fn set_completed(&self, actual_index: usize) {
let dots = self.dots.borrow(); let dots = self.dots.borrow();
let map = self.step_map.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(visual_i) = map.iter().position(|&i| i == actual_index) {
if let Some(dot) = dots.get(visual_i) { if let Some(dot) = dots.get(visual_i) {
dot.icon.set_icon_name(Some("emblem-ok-symbolic")); dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
dot.button.set_sensitive(true); dot.button.set_sensitive(true);
dot.label.remove_css_class("accent"); 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") .title("Enable Adjustments")
.subtitle("Rotate, flip, brightness, contrast, effects") .subtitle("Rotate, flip, brightness, contrast, effects")
.active(cfg.adjustments_enabled) .active(cfg.adjustments_enabled)
.tooltip_text("Toggle image adjustments on or off")
.build(); .build();
enable_group.add(&enable_row); enable_group.add(&enable_row);
outer.append(&enable_group); outer.append(&enable_group);
@@ -37,6 +38,10 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.vexpand(true) .vexpand(true)
.build(); .build();
preview_picture.set_can_target(true); 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() let info_label = gtk::Label::builder()
.label("No images loaded") .label("No images loaded")
@@ -78,6 +83,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Rotate") .title("Rotate")
.subtitle("Rotation applied to all images") .subtitle("Rotation applied to all images")
.use_subtitle(true) .use_subtitle(true)
.tooltip_text("Rotate all images by a fixed angle or auto-orient from EXIF")
.build(); .build();
rotate_row.set_model(Some(&gtk::StringList::new(&[ rotate_row.set_model(Some(&gtk::StringList::new(&[
"None", "None",
@@ -93,6 +99,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Flip") .title("Flip")
.subtitle("Mirror the image") .subtitle("Mirror the image")
.use_subtitle(true) .use_subtitle(true)
.tooltip_text("Mirror images horizontally or vertically")
.build(); .build();
flip_row.set_model(Some(&gtk::StringList::new(&["None", "Horizontal", "Vertical"]))); flip_row.set_model(Some(&gtk::StringList::new(&["None", "Horizontal", "Vertical"])));
flip_row.set_list_factory(Some(&super::full_text_list_factory())); 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") .tooltip_text("Reset to 0")
.has_frame(false) .has_frame(false)
.build(); .build();
reset_btn.update_property(&[
gtk::accessible::Property::Label(&format!("Reset {} to 0", title)),
]);
reset_btn.set_sensitive(value != 0); reset_btn.set_sensitive(value != 0);
row.add_suffix(&scale); row.add_suffix(&scale);
@@ -204,6 +214,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Crop to Aspect Ratio") .title("Crop to Aspect Ratio")
.subtitle("Crop from center to a specific ratio") .subtitle("Crop from center to a specific ratio")
.use_subtitle(true) .use_subtitle(true)
.tooltip_text("Crop from center to a specific aspect ratio")
.build(); .build();
crop_row.set_model(Some(&gtk::StringList::new(&[ crop_row.set_model(Some(&gtk::StringList::new(&[
"None", "None",
@@ -222,12 +233,14 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.title("Trim Whitespace") .title("Trim Whitespace")
.subtitle("Remove uniform borders around the image") .subtitle("Remove uniform borders around the image")
.active(cfg.trim_whitespace) .active(cfg.trim_whitespace)
.tooltip_text("Detect and remove uniform borders around the image")
.build(); .build();
let padding_row = adw::SpinRow::builder() let padding_row = adw::SpinRow::builder()
.title("Canvas Padding") .title("Canvas Padding")
.subtitle("Add uniform padding (pixels)") .subtitle("Add uniform padding (pixels)")
.adjustment(&gtk::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0)) .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(); .build();
crop_group.add(&crop_row); crop_group.add(&crop_row);
@@ -506,6 +519,27 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
preview_picture.add_controller(click); 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 === // === Wire signals ===
{ {
@@ -694,12 +728,16 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
.child(&outer) .child(&outer)
.build(); .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 up = update_preview.clone();
let lf = state.loaded_files.clone(); let lf = state.loaded_files.clone();
let ctrl = controls.clone(); let ctrl = controls.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| { page.connect_map(move |_| {
let enabled = jc.borrow().adjustments_enabled;
er.set_active(enabled);
ctrl.set_sensitive(!lf.borrow().is_empty()); ctrl.set_sensitive(!lf.borrow().is_empty());
up(); up();
}); });

View File

@@ -38,6 +38,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Compression") .title("Enable Compression")
.subtitle("Reduce file size with quality control") .subtitle("Reduce file size with quality control")
.active(cfg.compress_enabled) .active(cfg.compress_enabled)
.tooltip_text("Toggle compression on or off")
.build(); .build();
let enable_group = adw::PreferencesGroup::new(); 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 compressed_pixbuf: Rc<RefCell<Option<gtk::gdk_pixbuf::Pixbuf>>> = Rc::new(RefCell::new(None));
let divider_dragging = Rc::new(Cell::new(false)); let divider_dragging = Rc::new(Cell::new(false));
let image_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 // Pan state for cover-fill preview
let pan_x: Rc<Cell<f64>> = Rc::new(Cell::new(0.0)); 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."), 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 // Draw function - cover fill with pan support
{ {
let dp = divider_pos.clone(); 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 dspy = drag_start_pan_y.clone();
let px = pan_x.clone(); let px = pan_x.clone();
let py = pan_y.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, _| { drag_gesture.connect_drag_begin(move |_, x, _| {
let w = drawing.width() as f64; let w = drawing.width() as f64;
let current = *dp.borrow() * w; let current = *dp.borrow() * w;
if (x - current).abs() < 30.0 { if (x - current).abs() < 30.0 {
dd.set(true); dd.set(true);
id.set(false); id.set(false);
// Hide the hint on first divider interaction
if hint_vis.get() {
hint_vis.set(false);
hint_lbl.set_visible(false);
}
} else { } else {
dd.set(false); dd.set(false);
id.set(true); id.set(true);
@@ -437,10 +457,62 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
} }
preview_drawing.add_controller(drag_gesture); 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() let preview_frame = gtk::Frame::builder()
.halign(gtk::Align::Fill) .halign(gtk::Align::Fill)
.build(); .build();
preview_frame.set_child(Some(&preview_drawing)); preview_frame.set_child(Some(&preview_overlay));
preview_group.add(&size_box); preview_group.add(&size_box);
preview_group.add(&preview_frame); preview_group.add(&preview_frame);
@@ -480,11 +552,22 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
frame.add_css_class("accent"); 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() let btn = gtk::Button::builder()
.child(&frame) .child(&frame)
.has_frame(false) .has_frame(false)
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image")) .tooltip_text(file_name)
.build(); .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); thumb_box.append(&btn);
} }
@@ -816,7 +899,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
.child(&scrolled) .child(&scrolled)
.build(); .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 up = update_preview.clone();
let jc = state.job_config.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 ts = thumb_scrolled.clone();
let pidx = preview_index.clone(); let pidx = preview_index.clone();
let up2 = update_preview.clone(); let up2 = update_preview.clone();
let er = enable_row.clone();
page.connect_map(move |_| { page.connect_map(move |_| {
let enabled = jc.borrow().compress_enabled;
er.set_active(enabled);
// Rebuild thumbnail strip from current file list // Rebuild thumbnail strip from current file list
while let Some(child) = tb.first_child() { while let Some(child) = tb.first_child() {
tb.remove(&child); tb.remove(&child);
@@ -856,11 +942,22 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
let up_c = up2.clone(); let up_c = up2.clone();
let tb_c = tb.clone(); let tb_c = tb.clone();
let current_idx = i; 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() let btn = gtk::Button::builder()
.child(&frame) .child(&frame)
.has_frame(false) .has_frame(false)
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image")) .tooltip_text(file_name)
.build(); .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 |_| { btn.connect_clicked(move |_| {
*pidx_c.borrow_mut() = current_idx; *pidx_c.borrow_mut() = current_idx;
up_c(true); up_c(true);

View File

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

View File

@@ -500,6 +500,7 @@ fn build_empty_state() -> gtk::Box {
.pixel_size(64) .pixel_size(64)
.css_classes(["dim-label"]) .css_classes(["dim-label"])
.build(); .build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let title = gtk::Label::builder() let title = gtk::Label::builder()
.label("Drop images here") .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("suggested-action");
browse_button.add_css_class("pill"); 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(&icon);
inner.append(&title); inner.append(&title);
inner.append(&subtitle); 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 // Set checkbox state
let check = find_check_button(overlay.upcast_ref::<gtk::Widget>()); let check = find_check_button(overlay.upcast_ref::<gtk::Widget>());
if let Some(ref check) = check { if let Some(ref check) = check {
let is_excluded = excluded.borrow().contains(&path); let is_excluded = excluded.borrow().contains(&path);
check.set_active(!is_excluded); check.set_active(!is_excluded);
check.update_property(&[
gtk::accessible::Property::Label(&format!("Include {} in processing", file_name)),
]);
// Wire checkbox toggle // Wire checkbox toggle
let excl = excluded.clone(); let excl = excluded.clone();

View File

@@ -23,6 +23,7 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Metadata Handling") .title("Enable Metadata Handling")
.subtitle("Control what image metadata to keep or remove") .subtitle("Control what image metadata to keep or remove")
.active(cfg.metadata_enabled) .active(cfg.metadata_enabled)
.tooltip_text("Toggle metadata handling on or off")
.build(); .build();
let enable_group = adw::PreferencesGroup::new(); 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") .subtitle("Remove all metadata - smallest files, maximum privacy")
.activatable(true) .activatable(true)
.build(); .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(); let strip_all_check = gtk::CheckButton::new();
strip_all_check.set_active(cfg.metadata_mode == MetadataMode::StripAll); strip_all_check.set_active(cfg.metadata_mode == MetadataMode::StripAll);
strip_all_row.add_suffix(&strip_all_check); 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") .subtitle("Strip GPS and camera serial, keep copyright")
.activatable(true) .activatable(true)
.build(); .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(); let privacy_check = gtk::CheckButton::new();
privacy_check.set_group(Some(&strip_all_check)); privacy_check.set_group(Some(&strip_all_check));
privacy_check.set_active(cfg.metadata_mode == MetadataMode::Privacy); 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") .subtitle("Preserve all original metadata")
.activatable(true) .activatable(true)
.build(); .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(); let keep_all_check = gtk::CheckButton::new();
keep_all_check.set_group(Some(&strip_all_check)); keep_all_check.set_group(Some(&strip_all_check));
keep_all_check.set_active(cfg.metadata_mode == MetadataMode::KeepAll); 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") .subtitle("Keep copyright and camera model, strip GPS and software")
.activatable(true) .activatable(true)
.build(); .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(); let photographer_check = gtk::CheckButton::new();
photographer_check.set_group(Some(&strip_all_check)); photographer_check.set_group(Some(&strip_all_check));
photographer_row.add_suffix(&photographer_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") .subtitle("Choose exactly which metadata categories to strip")
.activatable(true) .activatable(true)
.build(); .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(); let custom_check = gtk::CheckButton::new();
custom_check.set_group(Some(&strip_all_check)); custom_check.set_group(Some(&strip_all_check));
custom_check.set_active(cfg.metadata_mode == MetadataMode::Custom); 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") .title("GPS / Location")
.subtitle("GPS coordinates, location name, altitude") .subtitle("GPS coordinates, location name, altitude")
.active(cfg.strip_gps) .active(cfg.strip_gps)
.tooltip_text("Strip GPS coordinates, location name, and altitude")
.build(); .build();
let camera_row = adw::SwitchRow::builder() let camera_row = adw::SwitchRow::builder()
.title("Camera Info") .title("Camera Info")
.subtitle("Camera model, serial number, lens data") .subtitle("Camera model, serial number, lens data")
.active(cfg.strip_camera) .active(cfg.strip_camera)
.tooltip_text("Strip camera model, serial number, and lens data")
.build(); .build();
let software_row = adw::SwitchRow::builder() let software_row = adw::SwitchRow::builder()
.title("Software") .title("Software")
.subtitle("Editing software, processing history") .subtitle("Editing software, processing history")
.active(cfg.strip_software) .active(cfg.strip_software)
.tooltip_text("Strip editing software and processing history")
.build(); .build();
let timestamps_row = adw::SwitchRow::builder() let timestamps_row = adw::SwitchRow::builder()
.title("Timestamps") .title("Timestamps")
.subtitle("Date taken, date modified, date digitized") .subtitle("Date taken, date modified, date digitized")
.active(cfg.strip_timestamps) .active(cfg.strip_timestamps)
.tooltip_text("Strip date taken, date modified, date digitized")
.build(); .build();
let copyright_row = adw::SwitchRow::builder() let copyright_row = adw::SwitchRow::builder()
.title("Copyright / Author") .title("Copyright / Author")
.subtitle("Copyright notice, artist name, credits") .subtitle("Copyright notice, artist name, credits")
.active(cfg.strip_copyright) .active(cfg.strip_copyright)
.tooltip_text("Strip copyright notice, artist name, and credits")
.build(); .build();
custom_group.add(&gps_row); custom_group.add(&gps_row);
@@ -260,9 +276,21 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
scrolled.set_child(Some(&content)); scrolled.set_child(Some(&content));
adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title("Metadata") .title("Metadata")
.tag("step-metadata") .tag("step-metadata")
.child(&scrolled) .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") .title("Enable Rename")
.subtitle("Rename output files with prefix, suffix, or template") .subtitle("Rename output files with prefix, suffix, or template")
.active(cfg.rename_enabled) .active(cfg.rename_enabled)
.tooltip_text("Toggle file renaming on or off")
.build(); .build();
enable_group.add(&enable_row); enable_group.add(&enable_row);
outer.append(&enable_group); outer.append(&enable_group);
@@ -74,6 +75,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.visible(false) .visible(false)
.build(); .build();
conflict_banner.add_css_class("card"); conflict_banner.add_css_class("card");
conflict_banner.set_accessible_role(gtk::AccessibleRole::Alert);
let conflict_icon = gtk::Image::builder() let conflict_icon = gtk::Image::builder()
.icon_name("dialog-warning-symbolic") .icon_name("dialog-warning-symbolic")
@@ -128,6 +130,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.label("Reset to defaults") .label("Reset to defaults")
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.margin_top(4) .margin_top(4)
.tooltip_text("Reset all rename options to their defaults")
.build(); .build();
reset_button.add_css_class("pill"); 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() let prefix_row = adw::EntryRow::builder()
.title("Prefix") .title("Prefix")
.text(&cfg.rename_prefix) .text(&cfg.rename_prefix)
.tooltip_text("Text added before the original filename")
.build(); .build();
let suffix_row = adw::EntryRow::builder() let suffix_row = adw::EntryRow::builder()
.title("Suffix") .title("Suffix")
.text(&cfg.rename_suffix) .text(&cfg.rename_suffix)
.tooltip_text("Text added after the original filename")
.build(); .build();
let replace_spaces_row = adw::ComboRow::builder() 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() let template_row = adw::EntryRow::builder()
.title("Template") .title("Template")
.text(&cfg.rename_template) .text(&cfg.rename_template)
.tooltip_text("Use variables like {name}, {date}, {counter:3} to build filenames")
.build(); .build();
// Template preset chips // Template preset chips
@@ -375,6 +381,11 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.child(&chip_box) .child(&chip_box)
.has_frame(false) .has_frame(false)
.build(); .build();
btn.update_property(&[
gtk::accessible::Property::Label(
&format!("Insert {} - {}", var_name, description)
),
]);
let tr = template_row.clone(); let tr = template_row.clone();
let var_text = var_name.to_string(); 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() let find_row = adw::EntryRow::builder()
.title("Find (regex)") .title("Find (regex)")
.text(&cfg.rename_find) .text(&cfg.rename_find)
.tooltip_text("Regular expression pattern to match in filenames")
.build(); .build();
let replace_row = adw::EntryRow::builder() let replace_row = adw::EntryRow::builder()
.title("Replace with") .title("Replace with")
.text(&cfg.rename_replace) .text(&cfg.rename_replace)
.tooltip_text("Replacement text for matched pattern")
.build(); .build();
advanced_expander.add_row(&template_row); advanced_expander.add_row(&template_row);
@@ -603,9 +616,17 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.max_width_chars(50) .max_width_chars(50)
.build(); .build();
// Highlight conflicts // Highlight conflicts with both color AND icon indicator
if name_counts.get(new_full.as_str()).copied().unwrap_or(0) > 1 { 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"); 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); new_line.append(&arrow_label);
@@ -643,9 +664,13 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
id.remove(); id.remove();
} }
let up2 = up.clone(); let up2 = up.clone();
let ds2 = ds.clone();
let id = gtk::glib::timeout_add_local_once( let id = gtk::glib::timeout_add_local_once(
std::time::Duration::from_millis(150), std::time::Duration::from_millis(150),
move || { up2(); }, move || {
ds2.set(None);
up2();
},
); );
ds.set(Some(id)); ds.set(Some(id));
}) })
@@ -860,10 +885,14 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
.child(&outer) .child(&outer)
.build(); .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 up = update_preview.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| { page.connect_map(move |_| {
let enabled = jc.borrow().rename_enabled;
er.set_active(enabled);
up(); up();
}); });
} }

View File

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

View File

@@ -25,6 +25,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.title("Enable Watermark") .title("Enable Watermark")
.subtitle("Add text or image watermark to processed images") .subtitle("Add text or image watermark to processed images")
.active(cfg.watermark_enabled) .active(cfg.watermark_enabled)
.tooltip_text("Toggle watermark on or off")
.build(); .build();
enable_group.add(&enable_row); enable_group.add(&enable_row);
outer.append(&enable_group); outer.append(&enable_group);
@@ -37,6 +38,10 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.vexpand(true) .vexpand(true)
.build(); .build();
preview_picture.set_can_target(true); 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() let info_label = gtk::Label::builder()
.label("No images loaded") .label("No images loaded")
@@ -78,6 +83,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.title("Type") .title("Type")
.subtitle("Choose text or image watermark") .subtitle("Choose text or image watermark")
.use_subtitle(true) .use_subtitle(true)
.tooltip_text("Choose between text or image/logo overlay")
.build(); .build();
type_row.set_model(Some(&gtk::StringList::new(&["Text Watermark", "Image Watermark"]))); type_row.set_model(Some(&gtk::StringList::new(&["Text Watermark", "Image Watermark"])));
type_row.set_list_factory(Some(&super::full_text_list_factory())); 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() let text_row = adw::EntryRow::builder()
.title("Watermark Text") .title("Watermark Text")
.text(&cfg.watermark_text) .text(&cfg.watermark_text)
.tooltip_text("The text that appears as a watermark on each image")
.build(); .build();
let font_row = adw::ActionRow::builder() 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); let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family);
font_button.set_font_desc(&desc); font_button.set_font_desc(&desc);
} }
font_button.update_property(&[
gtk::accessible::Property::Label("Choose watermark font"),
]);
font_row.add_suffix(&font_button); font_row.add_suffix(&font_button);
let font_size_row = adw::SpinRow::builder() let font_size_row = adw::SpinRow::builder()
.title("Font Size") .title("Font Size")
.subtitle("Size in pixels") .subtitle("Size in pixels")
.adjustment(&gtk::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0)) .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(); .build();
text_group.add(&text_row); text_group.add(&text_row);
@@ -144,7 +155,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
) )
.activatable(true) .activatable(true)
.build(); .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() let choose_image_button = gtk::Button::builder()
.icon_name("document-open-symbolic") .icon_name("document-open-symbolic")
@@ -152,6 +165,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.has_frame(false) .has_frame(false)
.build(); .build();
choose_image_button.update_property(&[
gtk::accessible::Property::Label("Choose logo image"),
]);
image_path_row.add_suffix(&choose_image_button); image_path_row.add_suffix(&choose_image_button);
image_group.add(&image_path_row); image_group.add(&image_path_row);
@@ -287,6 +303,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.rgba(&initial_color) .rgba(&initial_color)
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.build(); .build();
color_button.update_property(&[
gtk::accessible::Property::Label("Choose watermark text color"),
]);
color_row.add_suffix(&color_button); color_row.add_suffix(&color_button);
// Opacity slider + reset // Opacity slider + reset
@@ -300,12 +319,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
opacity_scale.set_hexpand(false); opacity_scale.set_hexpand(false);
opacity_scale.set_valign(gtk::Align::Center); opacity_scale.set_valign(gtk::Align::Center);
opacity_scale.set_width_request(180); 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() let opacity_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic") .icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.tooltip_text("Reset to 50%") .tooltip_text("Reset to 50%")
.has_frame(false) .has_frame(false)
.build(); .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_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01);
opacity_row.add_suffix(&opacity_scale); opacity_row.add_suffix(&opacity_scale);
opacity_row.add_suffix(&opacity_reset); 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_hexpand(false);
rotation_scale.set_valign(gtk::Align::Center); rotation_scale.set_valign(gtk::Align::Center);
rotation_scale.set_width_request(180); 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() let rotation_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic") .icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.tooltip_text("Reset to 0 degrees") .tooltip_text("Reset to 0 degrees")
.has_frame(false) .has_frame(false)
.build(); .build();
rotation_reset.update_property(&[
gtk::accessible::Property::Label("Reset rotation to 0 degrees"),
]);
rotation_reset.set_sensitive(cfg.watermark_rotation != 0); rotation_reset.set_sensitive(cfg.watermark_rotation != 0);
rotation_row.add_suffix(&rotation_scale); rotation_row.add_suffix(&rotation_scale);
rotation_row.add_suffix(&rotation_reset); rotation_row.add_suffix(&rotation_reset);
@@ -336,6 +367,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.title("Tiled / Repeated") .title("Tiled / Repeated")
.subtitle("Repeat watermark across the entire image") .subtitle("Repeat watermark across the entire image")
.active(cfg.watermark_tiled) .active(cfg.watermark_tiled)
.tooltip_text("Repeat the watermark in a grid pattern across the entire image")
.build(); .build();
// Margin slider + reset // Margin slider + reset
@@ -349,12 +381,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
margin_scale.set_hexpand(false); margin_scale.set_hexpand(false);
margin_scale.set_valign(gtk::Align::Center); margin_scale.set_valign(gtk::Align::Center);
margin_scale.set_width_request(180); 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() let margin_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic") .icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.tooltip_text("Reset to 10 px") .tooltip_text("Reset to 10 px")
.has_frame(false) .has_frame(false)
.build(); .build();
margin_reset.update_property(&[
gtk::accessible::Property::Label("Reset margin to 10 pixels"),
]);
margin_reset.set_sensitive(cfg.watermark_margin != 10); margin_reset.set_sensitive(cfg.watermark_margin != 10);
margin_row.add_suffix(&margin_scale); margin_row.add_suffix(&margin_scale);
margin_row.add_suffix(&margin_reset); 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_hexpand(false);
scale_scale.set_valign(gtk::Align::Center); scale_scale.set_valign(gtk::Align::Center);
scale_scale.set_width_request(180); 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() let scale_reset = gtk::Button::builder()
.icon_name("edit-undo-symbolic") .icon_name("edit-undo-symbolic")
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.tooltip_text("Reset to 20%") .tooltip_text("Reset to 20%")
.has_frame(false) .has_frame(false)
.build(); .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_reset.set_sensitive((cfg.watermark_scale - 20.0).abs() > 0.5);
scale_row.add_suffix(&scale_scale); scale_row.add_suffix(&scale_scale);
scale_row.add_suffix(&scale_reset); scale_row.add_suffix(&scale_reset);
@@ -573,6 +617,27 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
preview_picture.add_controller(click); 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 === // === Wire signals ===
// Enable toggle // Enable toggle
@@ -857,12 +922,16 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
.child(&outer) .child(&outer)
.build(); .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 up = update_preview.clone();
let lf = state.loaded_files.clone(); let lf = state.loaded_files.clone();
let ctrl = controls.clone(); let ctrl = controls.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| { page.connect_map(move |_| {
let enabled = jc.borrow().watermark_enabled;
er.set_active(enabled);
ctrl.set_sensitive(!lf.borrow().is_empty()); ctrl.set_sensitive(!lf.borrow().is_empty());
up(); up();
}); });

View File

@@ -33,6 +33,10 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
.homogeneous(true) .homogeneous(true)
.build(); .build();
builtin_flow.update_property(&[
gtk::accessible::Property::Label("Workflow preset selection grid"),
]);
// Custom card is always first (index 0) // Custom card is always first (index 0)
let custom_card = build_custom_card(); let custom_card = build_custom_card();
builtin_flow.append(&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") .description("Import or save your own workflows")
.build(); .build();
// Container for dynamically-rebuilt user preset rows // FlowBox for user preset cards (same look as built-in presets)
let user_rows_box = gtk::Box::builder() let user_flow = gtk::FlowBox::builder()
.orientation(gtk::Orientation::Vertical) .selection_mode(gtk::SelectionMode::Single)
.spacing(0) .max_children_per_line(5)
.min_children_per_line(2)
.row_spacing(8)
.column_spacing(8)
.homogeneous(true)
.build(); .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() let user_clamp = adw::Clamp::builder()
.label("Import Preset") .maximum_size(1200)
.icon_name("document-open-symbolic") .child(&user_flow)
.action_name("win.import-preset")
.build(); .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(&user_group);
content.append(&custom_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 // Refresh user presets every time this page is shown
{ {
let jc = state.job_config.clone(); let uf = user_flow.clone();
let rows_box = user_rows_box.clone(); let uel = user_empty_label.clone();
page.connect_map(move |_| { page.connect_map(move |_| {
// Clear existing rows // Clear existing cards
while let Some(child) = rows_box.first_child() { uf.remove_all();
rows_box.remove(&child);
}
let store = pixstrip_core::storage::PresetStore::new(); let store = pixstrip_core::storage::PresetStore::new();
let mut has_custom = false;
if let Ok(presets) = store.list() { if let Ok(presets) = store.list() {
for preset in &presets { for preset in &presets {
if !preset.is_custom { if !preset.is_custom {
continue; continue;
} }
let list_box = gtk::ListBox::builder() has_custom = true;
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"]) 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(); .build();
let row = adw::ActionRow::builder() let icon = gtk::Image::builder()
.title(&preset.name) .icon_name(&preset.icon)
.subtitle(&preset.description) .pixel_size(32)
.activatable(true) .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(); .build();
row.add_prefix(&gtk::Image::from_icon_name(&preset.icon));
// Export button
let export_btn = gtk::Button::builder() let export_btn = gtk::Button::builder()
.icon_name("document-save-as-symbolic") .icon_name("document-save-as-symbolic")
.tooltip_text("Export preset") .tooltip_text("Export preset")
.valign(gtk::Align::Center)
.build(); .build();
export_btn.add_css_class("flat"); export_btn.add_css_class("flat");
export_btn.add_css_class("circular");
let preset_for_export = preset.clone(); let preset_for_export = preset.clone();
export_btn.connect_clicked(move |btn| { export_btn.connect_clicked(move |btn| {
let p = preset_for_export.clone(); 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() let delete_btn = gtk::Button::builder()
.icon_name("user-trash-symbolic") .icon_name("user-trash-symbolic")
.tooltip_text("Delete preset") .tooltip_text("Delete preset")
.valign(gtk::Align::Center)
.build(); .build();
delete_btn.add_css_class("flat"); delete_btn.add_css_class("flat");
delete_btn.add_css_class("circular");
delete_btn.add_css_class("error"); delete_btn.add_css_class("error");
let pname = preset.name.clone(); let pname = preset.name.clone();
let list_box_ref = list_box.clone(); let uf_ref = uf.clone();
let rows_box_ref = rows_box.clone(); let uel_ref = uel.clone();
delete_btn.connect_clicked(move |_| { delete_btn.connect_clicked(move |btn| {
let store = pixstrip_core::storage::PresetStore::new(); let store = pixstrip_core::storage::PresetStore::new();
let _ = store.delete(&pname); 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()) {
row.add_suffix(&delete_btn); if let Some(fbc) = child.downcast_ref::<gtk::FlowBoxChild>() {
uf_ref.remove(fbc);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic")); // Show empty label if only the import card is left
let mut c = uf_ref.first_child();
let jc2 = jc.clone(); let mut count = 0;
let p = preset.clone(); while let Some(w) = c {
row.connect_activated(move |r| { count += 1;
let mut cfg = jc2.borrow_mut(); c = w.next_sibling();
apply_preset_to_config(&mut cfg, &p); }
cfg.preset_mode = true; uel_ref.set_visible(count <= 1);
drop(cfg); }
r.activate_action("win.next-step", None).ok(); }
}); });
list_box.append(&row); actions_box.append(&export_btn);
rows_box.append(&list_box); actions_box.append(&delete_btn);
overlay.add_overlay(&actions_box);
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);
flow.activate_action("win.next-step", None).ok();
} }
} }
}); });
@@ -430,15 +546,18 @@ fn build_custom_card() -> gtk::Box {
.vexpand(false) .vexpand(false)
.build(); .build();
card.add_css_class("card"); 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() let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(4) .spacing(4)
.margin_top(6) .margin_top(12)
.margin_bottom(6) .margin_bottom(12)
.margin_start(8) .margin_start(12)
.margin_end(8) .margin_end(12)
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.vexpand(true) .vexpand(true)
@@ -448,6 +567,7 @@ fn build_custom_card() -> gtk::Box {
.icon_name("emblem-system-symbolic") .icon_name("emblem-system-symbolic")
.pixel_size(32) .pixel_size(32)
.build(); .build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let name_label = gtk::Label::builder() let name_label = gtk::Label::builder()
.label("Custom") .label("Custom")
@@ -470,6 +590,59 @@ fn build_custom_card() -> gtk::Box {
card 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 { fn build_preset_card(preset: &Preset) -> gtk::Box {
let card = gtk::Box::builder() let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
@@ -478,15 +651,18 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
.vexpand(false) .vexpand(false)
.build(); .build();
card.add_css_class("card"); 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() let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(4) .spacing(4)
.margin_top(6) .margin_top(12)
.margin_bottom(6) .margin_bottom(12)
.margin_start(8) .margin_start(12)
.margin_end(8) .margin_end(12)
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.vexpand(true) .vexpand(true)
@@ -496,6 +672,10 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
.icon_name(&preset.icon) .icon_name(&preset.icon)
.pixel_size(32) .pixel_size(32)
.build(); .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() let name_label = gtk::Label::builder()
.label(&preset.name) .label(&preset.name)

View File

@@ -1,46 +1,7 @@
use adw::prelude::*; use adw::prelude::*;
use gtk::glib; use gtk::glib;
struct TourStop { /// Show the tutorial tour if the user hasn't completed it yet.
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.
/// Called after the welcome wizard closes on first launch. /// Called after the welcome wizard closes on first launch.
pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) { pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
let config_store = pixstrip_core::storage::ConfigStore::new(); 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 // Small delay to let the welcome dialog fully dismiss
let win = window.clone(); let win = window.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(400), move || { 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) { /// Tour stops: (title, description, widget_name, popover_position)
let stop = &TOUR_STOPS[stop_index]; fn tour_stops() -> Vec<(&'static str, &'static str, &'static str, gtk::PositionType)> {
let total = TOUR_STOPS.len(); 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() fn show_tour_stop(window: &adw::ApplicationWindow, index: usize) {
.title("Quick Tour") let stops = tour_stops();
.content_width(420) let total = stops.len();
.content_height(300) if index >= total {
.build(); 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() let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(16) .spacing(8)
.margin_top(24) .margin_top(12)
.margin_bottom(24) .margin_bottom(12)
.margin_start(24) .margin_start(16)
.margin_end(24) .margin_end(16)
.build(); .build();
content.set_size_request(280, -1);
// Progress dots // Progress dots
let dots_box = gtk::Box::builder() let dots_box = gtk::Box::builder()
@@ -82,12 +89,11 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
.spacing(6) .spacing(6)
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.build(); .build();
for i in 0..total { for i in 0..total {
let dot = gtk::Label::builder() 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(); .build();
if i == stop_index { if i == index {
dot.add_css_class("accent"); dot.add_css_class("accent");
} else { } else {
dot.add_css_class("dim-label"); dot.add_css_class("dim-label");
@@ -96,94 +102,132 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
} }
content.append(&dots_box); 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 // Title
let title = gtk::Label::builder() let title_label = gtk::Label::builder()
.label(stop.title) .label(title)
.css_classes(["title-2"]) .css_classes(["title-3"])
.halign(gtk::Align::Center) .halign(gtk::Align::Start)
.build(); .build();
content.append(&title); content.append(&title_label);
// 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);
// Description // Description
let desc = gtk::Label::builder() let desc_label = gtk::Label::builder()
.label(stop.description) .label(description)
.wrap(true) .wrap(true)
.halign(gtk::Align::Center) .max_width_chars(36)
.justify(gtk::Justification::Center) .halign(gtk::Align::Start)
.xalign(0.0)
.build(); .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 // Buttons
let button_box = gtk::Box::builder() let button_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
.spacing(12) .spacing(12)
.halign(gtk::Align::Center) .halign(gtk::Align::End)
.margin_top(8) .margin_top(4)
.build(); .build();
let skip_button = gtk::Button::builder() let skip_btn = gtk::Button::builder()
.label("Skip Tour") .label("Skip Tour")
.tooltip_text("Close the tour and start using Pixstrip")
.build(); .build();
skip_button.add_css_class("flat"); skip_btn.add_css_class("flat");
let is_last = stop_index + 1 >= total; let is_last = index + 1 >= total;
let next_label = if is_last { "Get Started" } else { "Next" }; let next_btn = gtk::Button::builder()
let next_button = gtk::Button::builder() .label(if is_last { "Done" } else { "Next" })
.label(next_label) .tooltip_text(if is_last { "Finish the tour" } else { "Go to the next tour stop" })
.build(); .build();
next_button.add_css_class("suggested-action"); next_btn.add_css_class("suggested-action");
next_button.add_css_class("pill"); next_btn.add_css_class("pill");
button_box.append(&skip_button); button_box.append(&skip_btn);
button_box.append(&next_button); button_box.append(&next_btn);
content.append(&button_box); 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(); let pop = popover.clone();
skip_button.connect_clicked(move |_| { skip_btn.connect_clicked(move |_| {
mark_tutorial_complete(); 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(); let win = window.clone();
next_button.connect_clicked(move |_| { next_btn.connect_clicked(move |_| {
dlg.close(); pop.popdown();
if is_last { let p = pop.clone();
mark_tutorial_complete(); let w = win.clone();
} else { glib::idle_add_local_once(move || {
let w = win.clone(); p.unparent();
glib::idle_add_local_once(move || { if is_last {
show_tour_dialog(&w, stop_index + 1); mark_tutorial_complete();
}); } else {
} 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() { fn mark_tutorial_complete() {