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
BIN
data/icons/hicolor/128x128/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
data/icons/hicolor/16x16/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 894 B |
BIN
data/icons/hicolor/256x256/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
data/icons/hicolor/32x32/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
data/icons/hicolor/48x48/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
data/icons/hicolor/512x512/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
data/icons/hicolor/64x64/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::Image::from_icon_name("folder-open-symbolic"));
|
let open_icon = gtk::Image::from_icon_name("folder-open-symbolic");
|
||||||
open_row.add_suffix(>k::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(>k::Image::from_icon_name("view-refresh-symbolic"));
|
let more_icon = gtk::Image::from_icon_name("view-refresh-symbolic");
|
||||||
process_more_row.add_suffix(>k::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(>k::Image::from_icon_name("document-save-symbolic"));
|
let save_icon = gtk::Image::from_icon_name("document-save-symbolic");
|
||||||
save_preset_row.add_suffix(>k::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(>k::Image::from_icon_name("view-list-symbolic"));
|
let queue_icon = gtk::Image::from_icon_name("view-list-symbolic");
|
||||||
add_queue_row.add_suffix(>k::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);
|
||||||
|
|||||||
@@ -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(>k::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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(>k::StringList::new(&[
|
rotate_row.set_model(Some(>k::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(>k::StringList::new(&["None", "Horizontal", "Vertical"])));
|
flip_row.set_model(Some(>k::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(>k::StringList::new(&[
|
crop_row.set_model(Some(>k::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(>k::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0))
|
.adjustment(>k::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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(÷r_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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(>k::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(>k::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(>k::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(>k::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(>k::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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(>k::StringList::new(CATEGORIES)));
|
category_row.set_model(Some(>k::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(>k::Adjustment::new(
|
.adjustment(>k::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(>k::StringList::new(&[
|
mode_row.set_model(Some(>k::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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(>k::StringList::new(&["Text Watermark", "Image Watermark"])));
|
type_row.set_model(Some(>k::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(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
|
.adjustment(>k::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(>k::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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(>k::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(>k::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)
|
||||||
|
|||||||
@@ -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: >k::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() {
|
||||||
|
|||||||