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
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Graphics;ImageProcessing;
|
||||
Categories=Graphics;ImageProcessing;GTK;
|
||||
MimeType=image/jpeg;image/png;image/webp;image/avif;image/gif;image/tiff;image/bmp;
|
||||
Keywords=image;photo;resize;convert;compress;batch;metadata;strip;watermark;rename;
|
||||
StartupNotify=true
|
||||
SingleMainWindow=true
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
<id>live.lashman.Pixstrip</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>CC0-1.0</project_license>
|
||||
|
||||
<name>Pixstrip</name>
|
||||
<summary>Batch image processor - resize, convert, compress, and more</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Pixstrip is a batch image processor for Linux that combines resize, convert,
|
||||
compress, metadata strip, watermark, rename, and basic image adjustments into
|
||||
a single wizard-driven workflow.
|
||||
Pixstrip is a native GTK4/libadwaita batch image processor for Linux
|
||||
that combines resize, convert, compress, metadata strip, watermark,
|
||||
rename, and image adjustments into a single wizard-driven workflow.
|
||||
It processes everything locally with no cloud dependency.
|
||||
</p>
|
||||
<p>Features include:</p>
|
||||
<p>Key features:</p>
|
||||
<ul>
|
||||
<li>Resize images by width, height, fit-in-box, or social media presets</li>
|
||||
<li>Convert between JPEG, PNG, WebP, AVIF, GIF, and TIFF</li>
|
||||
@@ -21,42 +23,137 @@
|
||||
<li>Add text or image watermarks with positioning and rotation</li>
|
||||
<li>Rename files with templates, counters, regex, and EXIF variables</li>
|
||||
<li>Adjust brightness, contrast, saturation, and apply effects</li>
|
||||
<li>Built-in presets for common workflows</li>
|
||||
<li>Built-in presets for common workflows with one-click processing</li>
|
||||
<li>Custom workflow builder with step-by-step wizard</li>
|
||||
<li>Watch folders for automatic processing</li>
|
||||
<li>Full CLI with feature parity</li>
|
||||
<li>Processing history with undo via system trash</li>
|
||||
<li>Full CLI with feature parity for scripting and automation</li>
|
||||
<li>File manager integration for Nautilus, Nemo, Thunar, and Dolphin</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">live.lashman.Pixstrip.desktop</launchable>
|
||||
<icon type="stock">live.lashman.Pixstrip</icon>
|
||||
|
||||
<url type="homepage">https://git.lashman.live/lashman/pixstrip</url>
|
||||
<url type="bugtracker">https://git.lashman.live/lashman/pixstrip/issues</url>
|
||||
<launchable type="desktop-id">live.lashman.Pixstrip.desktop</launchable>
|
||||
|
||||
<developer id="live.lashman">
|
||||
<name>lashman</name>
|
||||
</developer>
|
||||
|
||||
<url type="homepage">https://git.lashman.live/lashman/pixstrip</url>
|
||||
<url type="bugtracker">https://git.lashman.live/lashman/pixstrip/issues</url>
|
||||
<url type="vcs-browser">https://git.lashman.live/lashman/pixstrip</url>
|
||||
<url type="donation">https://ko-fi.com/lashman</url>
|
||||
<url type="contact">https://git.lashman.live/lashman/pixstrip/issues</url>
|
||||
<url type="contribute">https://git.lashman.live/lashman/pixstrip</url>
|
||||
|
||||
<update_contact>lashman@robotbrush.com</update_contact>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Workflow selection with built-in presets for common image tasks</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/01.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Image selection with drag-and-drop and batch file management</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/02.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Resize step with width, height, fit-in-box, and social media presets</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/03.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Format conversion between JPEG, PNG, WebP, AVIF, GIF, and TIFF</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/04.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Compression with live before/after preview and file size estimates</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/05.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Metadata stripping with selective EXIF field management</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/06.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Watermark placement with text and image options</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/07.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Batch rename with templates, counters, and EXIF variables</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/08.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Image adjustments for brightness, contrast, and saturation</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/09.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Settings with output preferences and file manager integration</caption>
|
||||
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/10.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#99c1f1</color>
|
||||
<color type="primary" scheme_preference="dark">#1a5fb4</color>
|
||||
<color type="primary" scheme_preference="light">#57a773</color>
|
||||
<color type="primary" scheme_preference="dark">#263226</color>
|
||||
</branding>
|
||||
|
||||
<categories>
|
||||
<category>Graphics</category>
|
||||
<category>ImageProcessing</category>
|
||||
<category>GTK</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>Image</keyword>
|
||||
<keyword>Photo</keyword>
|
||||
<keyword>Resize</keyword>
|
||||
<keyword>Convert</keyword>
|
||||
<keyword>Compress</keyword>
|
||||
<keyword>Batch</keyword>
|
||||
<keyword>Metadata</keyword>
|
||||
<keyword>Watermark</keyword>
|
||||
<keyword>Rename</keyword>
|
||||
<keyword>EXIF</keyword>
|
||||
<keyword>WebP</keyword>
|
||||
<keyword>AVIF</keyword>
|
||||
</keywords>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<requires>
|
||||
<display_length compare="ge">360</display_length>
|
||||
</requires>
|
||||
|
||||
<recommends>
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
</recommends>
|
||||
|
||||
<supports>
|
||||
<control>pointing</control>
|
||||
<control>keyboard</control>
|
||||
<control>touch</control>
|
||||
</supports>
|
||||
|
||||
<provides>
|
||||
<binary>pixstrip-gtk</binary>
|
||||
<binary>pixstrip</binary>
|
||||
</provides>
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2026-03-06">
|
||||
<release version="0.1.0" date="2026-03-06" type="stable">
|
||||
<description>
|
||||
<p>Initial release with full wizard workflow, 8 built-in presets, CLI parity, watch folders, and file manager integration.</p>
|
||||
<p>Initial release of Pixstrip with core features:</p>
|
||||
<ul>
|
||||
<li>Wizard-driven batch processing with 8 built-in presets</li>
|
||||
<li>Resize, convert, compress, metadata strip, watermark, rename, and adjust</li>
|
||||
<li>Optimized encoders: mozjpeg, oxipng, libwebp, and ravif</li>
|
||||
<li>Live compression preview with before/after comparison</li>
|
||||
<li>Watch folders for automatic processing</li>
|
||||
<li>Processing history with undo via system trash</li>
|
||||
<li>Full CLI with feature parity</li>
|
||||
<li>File manager integration for Nautilus, Nemo, Thunar, and Dolphin</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct Preset {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub icon_color: String,
|
||||
pub is_custom: bool,
|
||||
pub resize: Option<ResizeConfig>,
|
||||
pub rotation: Option<Rotation>,
|
||||
@@ -27,6 +28,7 @@ impl Default for Preset {
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
icon: "image-x-generic-symbolic".into(),
|
||||
icon_color: String::new(),
|
||||
is_custom: true,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
@@ -90,6 +92,7 @@ impl Preset {
|
||||
name: "Blog Photos".into(),
|
||||
description: "Resize 1200px wide, JPEG quality High, strip all metadata".into(),
|
||||
icon: "image-x-generic-symbolic".into(),
|
||||
icon_color: "accent".into(),
|
||||
is_custom: false,
|
||||
resize: Some(ResizeConfig::ByWidth(1200)),
|
||||
rotation: None,
|
||||
@@ -107,6 +110,7 @@ impl Preset {
|
||||
name: "Social Media".into(),
|
||||
description: "Resize to fit 1080x1080, compress Medium, strip metadata".into(),
|
||||
icon: "system-users-symbolic".into(),
|
||||
icon_color: "success".into(),
|
||||
is_custom: false,
|
||||
resize: Some(ResizeConfig::FitInBox {
|
||||
max: Dimensions {
|
||||
@@ -130,6 +134,7 @@ impl Preset {
|
||||
name: "Web Optimization".into(),
|
||||
description: "Convert to WebP, compress High, sequential rename".into(),
|
||||
icon: "web-browser-symbolic".into(),
|
||||
icon_color: "accent".into(),
|
||||
is_custom: false,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
@@ -160,6 +165,7 @@ impl Preset {
|
||||
name: "Email Friendly".into(),
|
||||
description: "Resize 800px wide, JPEG quality Medium".into(),
|
||||
icon: "mail-unread-symbolic".into(),
|
||||
icon_color: "warning".into(),
|
||||
is_custom: false,
|
||||
resize: Some(ResizeConfig::ByWidth(800)),
|
||||
rotation: None,
|
||||
@@ -177,6 +183,7 @@ impl Preset {
|
||||
name: "Privacy Clean".into(),
|
||||
description: "Strip all metadata, no other changes".into(),
|
||||
icon: "security-high-symbolic".into(),
|
||||
icon_color: "error".into(),
|
||||
is_custom: false,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
@@ -194,6 +201,7 @@ impl Preset {
|
||||
name: "Photographer Export".into(),
|
||||
description: "Resize 2048px, compress High, privacy metadata, rename by date".into(),
|
||||
icon: "camera-photo-symbolic".into(),
|
||||
icon_color: "success".into(),
|
||||
is_custom: false,
|
||||
resize: Some(ResizeConfig::ByWidth(2048)),
|
||||
rotation: None,
|
||||
@@ -224,6 +232,7 @@ impl Preset {
|
||||
name: "Archive Compress".into(),
|
||||
description: "Lossless compression, preserve metadata".into(),
|
||||
icon: "folder-symbolic".into(),
|
||||
icon_color: "warning".into(),
|
||||
is_custom: false,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
@@ -241,6 +250,7 @@ impl Preset {
|
||||
name: "Print Ready".into(),
|
||||
description: "Maximum quality, convert to PNG, keep all metadata".into(),
|
||||
icon: "printer-symbolic".into(),
|
||||
icon_color: "success".into(),
|
||||
is_custom: false,
|
||||
resize: None,
|
||||
rotation: None,
|
||||
@@ -258,6 +268,7 @@ impl Preset {
|
||||
name: "Fediverse Ready".into(),
|
||||
description: "Resize 1920x1080, convert to WebP, compress High, strip metadata".into(),
|
||||
icon: "network-server-symbolic".into(),
|
||||
icon_color: "accent".into(),
|
||||
is_custom: false,
|
||||
resize: Some(ResizeConfig::FitInBox {
|
||||
max: Dimensions {
|
||||
|
||||
@@ -189,6 +189,7 @@ pub struct SessionState {
|
||||
pub resize_enabled: Option<bool>,
|
||||
pub resize_width: Option<u32>,
|
||||
pub resize_height: Option<u32>,
|
||||
pub adjustments_enabled: Option<bool>,
|
||||
pub convert_enabled: Option<bool>,
|
||||
pub convert_format: Option<String>,
|
||||
pub compress_enabled: Option<bool>,
|
||||
|
||||
@@ -339,7 +339,7 @@ fn build_ui(app: &adw::Application) {
|
||||
allow_upscale: false,
|
||||
resize_algorithm: 0,
|
||||
output_dpi: 72,
|
||||
adjustments_enabled: false,
|
||||
adjustments_enabled: if remember { sess_state.adjustments_enabled.unwrap_or(false) } else { false },
|
||||
rotation: 0,
|
||||
flip: 0,
|
||||
brightness: 0,
|
||||
@@ -445,6 +445,10 @@ fn build_ui(app: &adw::Application) {
|
||||
.tooltip_text("Help for this step")
|
||||
.build();
|
||||
help_button.add_css_class("flat");
|
||||
help_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Help for this step"),
|
||||
]);
|
||||
help_button.set_widget_name("tour-help-button");
|
||||
header.pack_end(&help_button);
|
||||
|
||||
// Hamburger menu
|
||||
@@ -455,6 +459,7 @@ fn build_ui(app: &adw::Application) {
|
||||
.primary(true)
|
||||
.tooltip_text("Main Menu")
|
||||
.build();
|
||||
menu_button.set_widget_name("tour-menu-button");
|
||||
header.pack_end(&menu_button);
|
||||
|
||||
// Step indicator
|
||||
@@ -462,6 +467,7 @@ fn build_ui(app: &adw::Application) {
|
||||
|
||||
// Navigation view for wizard content
|
||||
let nav_view = adw::NavigationView::new();
|
||||
nav_view.set_widget_name("tour-content");
|
||||
nav_view.set_vexpand(true);
|
||||
nav_view.update_property(&[
|
||||
gtk::accessible::Property::Label("Wizard steps. Use Alt+Left/Right to navigate."),
|
||||
@@ -485,6 +491,7 @@ fn build_ui(app: &adw::Application) {
|
||||
.tooltip_text("Go to next step (Alt+Right)")
|
||||
.build();
|
||||
next_button.add_css_class("suggested-action");
|
||||
next_button.set_widget_name("tour-next-button");
|
||||
|
||||
let bottom_box = gtk::CenterBox::new();
|
||||
bottom_box.set_start_widget(Some(&back_button));
|
||||
@@ -514,6 +521,9 @@ fn build_ui(app: &adw::Application) {
|
||||
.tooltip_text("Watch Folders")
|
||||
.build();
|
||||
watch_button.add_css_class("flat");
|
||||
watch_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Toggle watch folders panel"),
|
||||
]);
|
||||
header.pack_start(&watch_button);
|
||||
|
||||
{
|
||||
@@ -531,6 +541,7 @@ fn build_ui(app: &adw::Application) {
|
||||
.child(step_indicator.widget())
|
||||
.build();
|
||||
indicator_scroll.set_size_request(-1, 52);
|
||||
indicator_scroll.set_widget_name("tour-step-indicator");
|
||||
content_box.append(&indicator_scroll);
|
||||
content_box.append(&nav_view);
|
||||
content_box.append(&watch_revealer);
|
||||
@@ -608,6 +619,7 @@ fn build_ui(app: &adw::Application) {
|
||||
state.resize_enabled = Some(cfg.resize_enabled);
|
||||
state.resize_width = Some(cfg.resize_width);
|
||||
state.resize_height = Some(cfg.resize_height);
|
||||
state.adjustments_enabled = Some(cfg.adjustments_enabled);
|
||||
state.convert_enabled = Some(cfg.convert_enabled);
|
||||
state.convert_format = cfg.convert_format.map(|f| format!("{:?}", f));
|
||||
state.compress_enabled = Some(cfg.compress_enabled);
|
||||
@@ -1487,14 +1499,18 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
.subtitle(&format!("{} - {}", time_label, subtitle))
|
||||
.show_enable_switch(false)
|
||||
.build();
|
||||
row.add_prefix(>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
|
||||
let input_row = adw::ActionRow::builder()
|
||||
.title("Input")
|
||||
.subtitle(&entry.input_dir)
|
||||
.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);
|
||||
|
||||
let output_row = adw::ActionRow::builder()
|
||||
@@ -1502,7 +1518,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
.subtitle(&entry.output_dir)
|
||||
.activatable(true)
|
||||
.build();
|
||||
output_row.add_prefix(>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();
|
||||
output_row.connect_activated(move |_| {
|
||||
let uri = gtk::gio::File::for_path(&out_dir).uri();
|
||||
@@ -1522,7 +1540,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
savings
|
||||
))
|
||||
.build();
|
||||
size_row.add_prefix(>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);
|
||||
|
||||
if entry.failed > 0 {
|
||||
@@ -1530,7 +1550,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
.title("Errors")
|
||||
.subtitle(&format!("{} files failed", entry.failed))
|
||||
.build();
|
||||
err_row.add_prefix(>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);
|
||||
}
|
||||
|
||||
@@ -2138,7 +2160,7 @@ fn continue_processing(
|
||||
}
|
||||
ProcessingMessage::Error(err) => {
|
||||
mark_current_queue_batch(&ui_for_rx, false, Some(&err));
|
||||
let toast = adw::Toast::new(&format!("Processing failed: {}", err));
|
||||
let toast = adw::Toast::new(&format!("Processing failed: {}. Try with fewer images or check that the output folder exists.", err));
|
||||
ui_for_rx.toast_overlay.add_toast(toast);
|
||||
ui_for_rx.back_button.set_visible(true);
|
||||
ui_for_rx.next_button.set_visible(true);
|
||||
@@ -2415,18 +2437,18 @@ fn undo_last_batch(ui: &WizardUi) {
|
||||
let entries = match history.list() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available. Process a batch first before undoing."));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(last) = entries.last() else {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo. Process some images first."));
|
||||
return;
|
||||
};
|
||||
|
||||
if last.output_files.is_empty() {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch. The batch may have been already undone."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2457,7 +2479,7 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
// Save the texture to a temp file
|
||||
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
|
||||
if std::fs::create_dir_all(&temp_dir).is_err() {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory. Check disk space and permissions on /tmp."));
|
||||
return;
|
||||
}
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
@@ -2480,10 +2502,10 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
toast.set_timeout(2);
|
||||
ui.toast_overlay.add_toast(toast);
|
||||
} else {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image. The image format may be unsupported."));
|
||||
}
|
||||
} else {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard. Copy an image first, then try pasting again."));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2672,7 +2694,7 @@ fn import_preset(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
ui.toast_overlay.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Failed to import: {}", e));
|
||||
let toast = adw::Toast::new(&format!("Failed to import preset: {}. Make sure the file is a valid .pixstrip-preset file.", e));
|
||||
ui.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
@@ -2732,6 +2754,90 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
.build();
|
||||
name_group.add(&desc_entry);
|
||||
|
||||
// Icon picker
|
||||
let icon_names = [
|
||||
("user-bookmarks-symbolic", "Bookmark"),
|
||||
("image-x-generic-symbolic", "Image"),
|
||||
("camera-photo-symbolic", "Camera"),
|
||||
("emblem-photos-symbolic", "Photos"),
|
||||
("applications-graphics-symbolic", "Graphics"),
|
||||
("starred-symbolic", "Star"),
|
||||
("emblem-favorite-symbolic", "Heart"),
|
||||
("folder-symbolic", "Folder"),
|
||||
("preferences-color-symbolic", "Color"),
|
||||
("emblem-system-symbolic", "Gear"),
|
||||
];
|
||||
let icon_string_list = gtk::StringList::new(&icon_names.map(|(_, label)| label));
|
||||
let icon_combo = adw::ComboRow::builder()
|
||||
.title("Icon")
|
||||
.model(&icon_string_list)
|
||||
.build();
|
||||
// Show icon preview as prefix
|
||||
let icon_preview = gtk::Image::builder()
|
||||
.icon_name("user-bookmarks-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
icon_preview.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
icon_combo.add_prefix(&icon_preview);
|
||||
name_group.add(&icon_combo);
|
||||
|
||||
// Color picker
|
||||
let color_labels = ["Default", "Blue", "Green", "Yellow", "Red"];
|
||||
let color_values = ["", "accent", "success", "warning", "error"];
|
||||
let color_string_list = gtk::StringList::new(&color_labels);
|
||||
let color_combo = adw::ComboRow::builder()
|
||||
.title("Icon Color")
|
||||
.model(&color_string_list)
|
||||
.build();
|
||||
let color_preview = gtk::Image::builder()
|
||||
.icon_name("user-bookmarks-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
color_preview.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
color_combo.add_prefix(&color_preview);
|
||||
name_group.add(&color_combo);
|
||||
|
||||
// Update icon preview when icon selection changes
|
||||
{
|
||||
let ip = icon_preview.clone();
|
||||
let cp = color_preview.clone();
|
||||
let cc = color_combo.clone();
|
||||
icon_combo.connect_selected_notify(move |combo| {
|
||||
let idx = combo.selected() as usize;
|
||||
if idx < icon_names.len() {
|
||||
let icon_name = icon_names[idx].0;
|
||||
ip.set_icon_name(Some(icon_name));
|
||||
cp.set_icon_name(Some(icon_name));
|
||||
// Re-apply color class
|
||||
for cv in &color_values {
|
||||
if !cv.is_empty() {
|
||||
cp.remove_css_class(cv);
|
||||
}
|
||||
}
|
||||
let cidx = cc.selected() as usize;
|
||||
if cidx < color_values.len() && !color_values[cidx].is_empty() {
|
||||
cp.add_css_class(color_values[cidx]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update color preview when color selection changes
|
||||
{
|
||||
let cp = color_preview;
|
||||
color_combo.connect_selected_notify(move |combo| {
|
||||
let idx = combo.selected() as usize;
|
||||
for cv in &color_values {
|
||||
if !cv.is_empty() {
|
||||
cp.remove_css_class(cv);
|
||||
}
|
||||
}
|
||||
if idx < color_values.len() && !color_values[idx].is_empty() {
|
||||
cp.add_css_class(color_values[idx]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let save_new_button = gtk::Button::builder()
|
||||
.label("Save New Preset")
|
||||
.halign(gtk::Align::Center)
|
||||
@@ -2770,9 +2876,15 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
let ui_c = ui.clone();
|
||||
let dlg_c = dialog.clone();
|
||||
let pname = preset_name.clone();
|
||||
let store_ref = pixstrip_core::storage::PresetStore::new();
|
||||
let existing_preset = store_ref.load(preset_name).ok();
|
||||
let existing_icon = existing_preset.as_ref().map(|p| p.icon.clone()).unwrap_or_default();
|
||||
let existing_color = existing_preset.as_ref().map(|p| p.icon_color.clone()).unwrap_or_default();
|
||||
row.connect_activated(move |_| {
|
||||
let cfg = ui_c.state.job_config.borrow();
|
||||
let preset = build_preset_from_config(&cfg, &pname, None);
|
||||
let ei = existing_icon.as_str();
|
||||
let ec = existing_color.as_str();
|
||||
let preset = build_preset_from_config(&cfg, &pname, None, Some(ei), Some(ec));
|
||||
drop(cfg);
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
@@ -2782,7 +2894,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Failed to update: {}", e));
|
||||
let toast = adw::Toast::new(&format!("Failed to update preset: {}. The preset file may be read-only.", e));
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
@@ -2801,6 +2913,8 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
let dlg_c = dialog.clone();
|
||||
let entry_c = name_entry.clone();
|
||||
let desc_c = desc_entry.clone();
|
||||
let icon_combo_c = icon_combo;
|
||||
let color_combo_c = color_combo;
|
||||
save_new_button.connect_clicked(move |_| {
|
||||
let name = entry_c.text().to_string();
|
||||
if name.trim().is_empty() {
|
||||
@@ -2810,8 +2924,12 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
}
|
||||
|
||||
let desc_text = desc_c.text().to_string();
|
||||
let icon_idx = icon_combo_c.selected() as usize;
|
||||
let selected_icon = icon_names.get(icon_idx).map(|(name, _)| *name);
|
||||
let color_idx = color_combo_c.selected() as usize;
|
||||
let selected_color = color_values.get(color_idx).copied();
|
||||
let cfg = ui_c.state.job_config.borrow();
|
||||
let preset = build_preset_from_config(&cfg, &name, Some(&desc_text));
|
||||
let preset = build_preset_from_config(&cfg, &name, Some(&desc_text), selected_icon, selected_color);
|
||||
drop(cfg);
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
@@ -2821,7 +2939,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Failed to save: {}", e));
|
||||
let toast = adw::Toast::new(&format!("Failed to save preset: {}. Check that the presets folder is writable.", e));
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
@@ -2836,7 +2954,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&str>) -> pixstrip_core::preset::Preset {
|
||||
fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&str>, icon: Option<&str>, icon_color: Option<&str>) -> pixstrip_core::preset::Preset {
|
||||
let resize = if cfg.resize_enabled && cfg.resize_width > 0 {
|
||||
if cfg.resize_height == 0 {
|
||||
Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width))
|
||||
@@ -2980,7 +3098,8 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&st
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| build_preset_description(cfg)),
|
||||
icon: "user-bookmarks-symbolic".into(),
|
||||
icon: icon.unwrap_or("user-bookmarks-symbolic").to_string(),
|
||||
icon_color: icon_color.unwrap_or("").to_string(),
|
||||
is_custom: true,
|
||||
resize,
|
||||
rotation,
|
||||
@@ -3188,11 +3307,12 @@ pub fn walk_widgets(widget: &Option<gtk::Widget>, f: &dyn Fn(>k::Widget)) {
|
||||
}
|
||||
|
||||
|
||||
#[allow(deprecated)] // ShortcutLabel deprecated in 4.18 with no replacement yet
|
||||
fn show_shortcuts_window(window: &adw::ApplicationWindow) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Keyboard Shortcuts")
|
||||
.content_width(420)
|
||||
.content_height(480)
|
||||
.content_width(460)
|
||||
.content_height(520)
|
||||
.build();
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
@@ -3201,56 +3321,75 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) {
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.margin_top(8)
|
||||
.margin_bottom(16)
|
||||
.spacing(16)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.margin_top(12)
|
||||
.margin_bottom(24)
|
||||
.spacing(18)
|
||||
.build();
|
||||
|
||||
let sections: &[(&str, &[(&str, &str)])] = &[
|
||||
("Wizard Navigation", &[
|
||||
("Alt + Right", "Next step"),
|
||||
("Alt + Left", "Previous step"),
|
||||
("Alt + 1-9", "Jump to step"),
|
||||
("Ctrl + Return", "Process images"),
|
||||
("<Alt>Right", "Next step"),
|
||||
("<Alt>Left", "Previous step"),
|
||||
("<Alt>1", "Jump to step (1-9)"),
|
||||
("<Control>Return", "Process images"),
|
||||
("Escape", "Cancel or go back"),
|
||||
]),
|
||||
("File Management", &[
|
||||
("Ctrl + O", "Add files"),
|
||||
("Ctrl + V", "Paste image from clipboard"),
|
||||
("Ctrl + A", "Select all images"),
|
||||
("Ctrl + Shift + A", "Deselect all images"),
|
||||
("<Control>o", "Add files"),
|
||||
("<Control>v", "Paste image from clipboard"),
|
||||
("<Control>a", "Select all images"),
|
||||
("<Control><Shift>a", "Deselect all images"),
|
||||
("Delete", "Remove selected images"),
|
||||
]),
|
||||
("Application", &[
|
||||
("Ctrl + ,", "Settings"),
|
||||
("<Control>comma", "Settings"),
|
||||
("F1", "Keyboard shortcuts"),
|
||||
("Ctrl + Z", "Undo last batch"),
|
||||
("Ctrl + Q", "Quit"),
|
||||
("<Control>z", "Undo last batch"),
|
||||
("<Control>q", "Quit"),
|
||||
]),
|
||||
];
|
||||
|
||||
for (section_title, shortcuts) in sections {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title(*section_title)
|
||||
let group = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.build();
|
||||
|
||||
let title_label = gtk::Label::builder()
|
||||
.label(*section_title)
|
||||
.css_classes(["title-4"])
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_bottom(2)
|
||||
.build();
|
||||
group.append(&title_label);
|
||||
|
||||
for (accel, description) in *shortcuts {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(*description)
|
||||
let row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.build();
|
||||
let label = gtk::Label::builder()
|
||||
.label(*accel)
|
||||
.css_classes(["dim-label", "monospace"])
|
||||
.valign(gtk::Align::Center)
|
||||
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(*description)
|
||||
.halign(gtk::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
row.add_suffix(&label);
|
||||
group.add(&row);
|
||||
|
||||
let shortcut_label = gtk::ShortcutLabel::builder()
|
||||
.accelerator(*accel)
|
||||
.halign(gtk::Align::End)
|
||||
.build();
|
||||
|
||||
row.append(&desc_label);
|
||||
row.append(&shortcut_label);
|
||||
group.append(&row);
|
||||
}
|
||||
|
||||
content.append(&group);
|
||||
@@ -3288,82 +3427,143 @@ fn apply_accessibility_settings() {
|
||||
}
|
||||
|
||||
fn show_step_help(window: &adw::ApplicationWindow, step: usize) {
|
||||
let (title, body) = match step {
|
||||
0 => ("Workflow", concat!(
|
||||
"Choose a preset to start quickly, or configure each step manually.\n\n",
|
||||
"Presets apply recommended settings for common tasks like web optimization, ",
|
||||
"social media, or print preparation. You can customize any preset after applying it.\n\n",
|
||||
"Use Import/Export to share presets with others."
|
||||
let (title, icon_name, body) = match step {
|
||||
0 => ("Workflow", "view-grid-symbolic", concat!(
|
||||
"Pick a built-in preset to start quickly, or select the Custom card to choose ",
|
||||
"which operations to include.\n\n",
|
||||
"Built-in presets auto-advance to the Images step with recommended settings. ",
|
||||
"Custom mode shows toggle switches for each operation (Resize, Adjustments, Convert, ",
|
||||
"Compress, Metadata, Watermark, Rename).\n\n",
|
||||
"Your saved presets appear below the built-in ones. Use Import to load a .pixstrip-preset file, ",
|
||||
"or drag one onto this page."
|
||||
)),
|
||||
1 => ("Images", concat!(
|
||||
1 => ("Images", "image-x-generic-symbolic", concat!(
|
||||
"Add the images you want to process.\n\n",
|
||||
"- Drag and drop files or folders onto this area\n",
|
||||
"- Use Browse to pick files from a file dialog\n",
|
||||
"- Drag image URLs from a web browser\n",
|
||||
"- Click Browse Files or press Ctrl+O\n",
|
||||
"- Press Ctrl+V to paste from clipboard\n\n",
|
||||
"Use checkboxes to include or exclude individual images. ",
|
||||
"When dropping a folder with subfolders, you'll be asked whether to include them. ",
|
||||
"Use checkboxes on each thumbnail to include or exclude images. ",
|
||||
"Ctrl+A selects all, Ctrl+Shift+A deselects all."
|
||||
)),
|
||||
2 => ("Resize", concat!(
|
||||
"Scale images to specific dimensions.\n\n",
|
||||
"Choose a preset size or enter custom dimensions. Width-only or height-only ",
|
||||
"resizing preserves the original aspect ratio.\n\n",
|
||||
"Enable 'Allow upscale' if you need images smaller than the target to be enlarged."
|
||||
2 => ("Resize", "view-fullscreen-symbolic", concat!(
|
||||
"Scale images to specific dimensions with a live preview.\n\n",
|
||||
"Pick a category and preset size, or enter custom width and height. ",
|
||||
"Toggle between pixel and percentage units. Lock the aspect ratio to keep proportions.\n\n",
|
||||
"Choose Exact Size or Fit Within Box mode. Enable Allow Upscaling to enlarge smaller images. ",
|
||||
"Expand Advanced Settings for resize algorithm (Lanczos3, CatmullRom, etc.) and output DPI."
|
||||
)),
|
||||
3 => ("Adjustments", concat!(
|
||||
"Fine-tune image appearance.\n\n",
|
||||
"Adjust brightness, contrast, and saturation with sliders. ",
|
||||
"Apply rotation, flipping, grayscale, or sepia effects.\n\n",
|
||||
"Crop to a specific aspect ratio or trim whitespace borders automatically."
|
||||
3 => ("Adjustments", "preferences-color-symbolic", concat!(
|
||||
"Fine-tune image appearance with a live preview.\n\n",
|
||||
"Orientation: rotate (including auto-orient from EXIF) and flip.\n",
|
||||
"Color: adjust brightness, contrast, and saturation with sliders.\n",
|
||||
"Effects: toggle grayscale, sepia, or sharpen.\n",
|
||||
"Crop and Canvas: crop to an aspect ratio, trim whitespace borders, or add padding."
|
||||
)),
|
||||
4 => ("Convert", concat!(
|
||||
4 => ("Convert", "document-save-as-symbolic", concat!(
|
||||
"Change image file format.\n\n",
|
||||
"Convert between JPEG, PNG, WebP, AVIF, GIF, TIFF, and BMP. ",
|
||||
"Each format has trade-offs between quality, file size, and compatibility.\n\n",
|
||||
"WebP and AVIF offer the best compression for web use."
|
||||
"Select a target format from the card grid (JPEG, PNG, WebP, AVIF) or use the ",
|
||||
"Other Formats dropdown for GIF, TIFF, and BMP. Keep Original preserves each file's format.\n\n",
|
||||
"Enable Progressive JPEG for gradual loading in browsers. Use Format Mapping to override ",
|
||||
"the output format for specific input types (e.g. convert PNG to WebP but keep JPEG as-is)."
|
||||
)),
|
||||
5 => ("Compress", concat!(
|
||||
5 => ("Compress", "drive-harddisk-symbolic", concat!(
|
||||
"Reduce file size while preserving quality.\n\n",
|
||||
"Choose a quality preset (Lossless, High, Balanced, Small, Tiny) or set custom ",
|
||||
"quality values per format.\n\n",
|
||||
"Expand Advanced Options for fine control over WebP encoding effort and AVIF speed."
|
||||
"Use the quality slider to set the overall level from Low to Maximum. ",
|
||||
"The split preview shows a side-by-side before/after comparison - drag the divider ",
|
||||
"or use Left/Right arrow keys to compare.\n\n",
|
||||
"Expand Per-Format Quality for fine control over JPEG quality, PNG compression level, ",
|
||||
"WebP quality and effort, and AVIF quality and speed."
|
||||
)),
|
||||
6 => ("Metadata", concat!(
|
||||
"Control what metadata is kept or removed.\n\n",
|
||||
"Strip All removes everything. Privacy mode keeps copyright and camera info but ",
|
||||
"removes GPS and timestamps. Custom mode lets you pick exactly what to strip.\n\n",
|
||||
"Removing metadata reduces file size and protects privacy."
|
||||
6 => ("Metadata", "dialog-password-symbolic", concat!(
|
||||
"Control what image metadata is kept or removed.\n\n",
|
||||
"- Strip All: remove everything for smallest files and maximum privacy\n",
|
||||
"- Privacy: strip GPS and camera serial, keep copyright\n",
|
||||
"- Photographer: keep copyright and camera model, strip GPS and software\n",
|
||||
"- Keep All: preserve all original metadata\n",
|
||||
"- Custom: choose exactly which categories to strip (GPS, camera, software, timestamps, copyright)"
|
||||
)),
|
||||
7 => ("Watermark", concat!(
|
||||
"Add a text or image watermark.\n\n",
|
||||
"Choose text or logo mode. Position the watermark using the visual grid. ",
|
||||
"Expand Advanced Options for opacity, rotation, tiling, margin, and scale controls.\n\n",
|
||||
"Logo watermarks support PNG images with transparency."
|
||||
7 => ("Watermark", "emblem-photos-symbolic", concat!(
|
||||
"Add a text or image watermark with a live preview.\n\n",
|
||||
"Text mode: enter your text, choose a font and size.\n",
|
||||
"Image mode: select a logo file (PNG with transparency works best).\n\n",
|
||||
"Position the watermark using the 3x3 grid. Expand Advanced Options for text color, ",
|
||||
"opacity, rotation, tiling, margin, and scale controls."
|
||||
)),
|
||||
8 => ("Rename", concat!(
|
||||
"Rename output files using patterns.\n\n",
|
||||
"Add a prefix, suffix, or use a full template with placeholders:\n",
|
||||
"- {name} - original filename\n",
|
||||
"- {n} - counter number\n",
|
||||
"- {date} - current date\n",
|
||||
"- {ext} - original extension\n\n",
|
||||
"Expand Advanced Options for case conversion and find-and-replace."
|
||||
8 => ("Rename", "document-edit-symbolic", concat!(
|
||||
"Rename output files with a live preview showing before and after names.\n\n",
|
||||
"Simple options: add a prefix or suffix, replace spaces, filter special characters, ",
|
||||
"convert case, and add a sequential counter.\n\n",
|
||||
"Expand Advanced for a template engine with variables like {name}, {counter}, {date}, ",
|
||||
"{exif_date}, {camera}, {width}, {height}, and more. Also includes find-and-replace with regex."
|
||||
)),
|
||||
9 => ("Output", concat!(
|
||||
"Review settings and choose where to save.\n\n",
|
||||
"The summary shows all operations that will be applied. ",
|
||||
9 => ("Output", "folder-download-symbolic", concat!(
|
||||
"Review and start processing.\n\n",
|
||||
"The operation summary lists all enabled steps and their settings. ",
|
||||
"Choose an output folder or use the default 'processed' subfolder.\n\n",
|
||||
"Set overwrite behavior for when output files already exist. ",
|
||||
"Press Process or Ctrl+Enter to start."
|
||||
"Toggle Preserve Directory Structure to keep subfolder hierarchy in output. ",
|
||||
"Set overwrite behavior for existing files. Press Process or Ctrl+Enter to start."
|
||||
)),
|
||||
_ => ("Help", "No help available for this step."),
|
||||
_ => ("Help", "help-about-symbolic", "No help available for this step."),
|
||||
};
|
||||
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(format!("Help: {}", title))
|
||||
.body(body)
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(format!("Help: {}", title))
|
||||
.content_width(420)
|
||||
.content_height(360)
|
||||
.build();
|
||||
dialog.add_response("ok", "Got it");
|
||||
dialog.set_default_response(Some("ok"));
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name(icon_name)
|
||||
.pixel_size(64)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
icon.add_css_class("accent");
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
content.append(&icon);
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label(title)
|
||||
.css_classes(["title-2"])
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
content.append(&heading);
|
||||
|
||||
let body_label = gtk::Label::builder()
|
||||
.label(body)
|
||||
.wrap(true)
|
||||
.halign(gtk::Align::Center)
|
||||
.justify(gtk::Justification::Center)
|
||||
.xalign(0.5)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
content.append(&body_label);
|
||||
|
||||
let close_button = gtk::Button::builder()
|
||||
.label("Got it")
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
close_button.add_css_class("suggested-action");
|
||||
close_button.add_css_class("pill");
|
||||
|
||||
let dlg = dialog.clone();
|
||||
close_button.connect_clicked(move |_| {
|
||||
dlg.close();
|
||||
});
|
||||
|
||||
content.append(&close_button);
|
||||
|
||||
dialog.set_child(Some(&content));
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
@@ -3415,6 +3615,9 @@ fn build_watch_folder_panel() -> gtk::Box {
|
||||
.tooltip_text("Add watch folder")
|
||||
.build();
|
||||
add_btn.add_css_class("flat");
|
||||
add_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Add watch folder"),
|
||||
]);
|
||||
header_box.append(&add_btn);
|
||||
|
||||
inner.append(&header_box);
|
||||
@@ -3451,15 +3654,29 @@ fn build_watch_folder_panel() -> gtk::Box {
|
||||
.title(display_name)
|
||||
.subtitle(&folder.preset_name)
|
||||
.build();
|
||||
row.add_prefix(>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
|
||||
let status_icon = gtk::Image::builder()
|
||||
.icon_name("emblem-ok-symbolic")
|
||||
.pixel_size(12)
|
||||
.build();
|
||||
status_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
let status = gtk::Label::builder()
|
||||
.label("Watching")
|
||||
.css_classes(["caption", "accent"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
row.add_suffix(&status);
|
||||
let status_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
status_box.append(&status_icon);
|
||||
status_box.append(&status);
|
||||
row.add_suffix(&status_box);
|
||||
|
||||
list_box.append(&row);
|
||||
}
|
||||
@@ -3518,14 +3735,28 @@ fn build_watch_folder_panel() -> gtk::Box {
|
||||
.title(&display_name)
|
||||
.subtitle(&new_folder.preset_name)
|
||||
.build();
|
||||
row.add_prefix(>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()
|
||||
.label("Watching")
|
||||
.css_classes(["caption", "accent"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
row.add_suffix(&status);
|
||||
let dyn_status_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
dyn_status_box.append(&dyn_status_icon);
|
||||
dyn_status_box.append(&status);
|
||||
row.add_suffix(&dyn_status_box);
|
||||
|
||||
list_box_c.append(&row);
|
||||
list_box_c.set_visible(true);
|
||||
@@ -3644,7 +3875,11 @@ fn refresh_queue_list(ui: &WizardUi) {
|
||||
.title(&batch.name)
|
||||
.subtitle(&format!("{} images - {}", batch.files.len(), status_text))
|
||||
.build();
|
||||
row.add_prefix(>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
|
||||
if batch.status == BatchStatus::Pending {
|
||||
@@ -3686,7 +3921,7 @@ fn add_current_batch_to_queue(ui: &WizardUi) {
|
||||
};
|
||||
|
||||
if files.is_empty() {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No images to queue"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No images to queue. Go to Step 2 to add images first."));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -142,31 +142,41 @@ pub fn build_results_page() -> adw::NavigationPage {
|
||||
.title("Images processed")
|
||||
.subtitle("0 images")
|
||||
.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()
|
||||
.title("Original size")
|
||||
.subtitle("0 B")
|
||||
.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()
|
||||
.title("Output size")
|
||||
.subtitle("0 B")
|
||||
.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()
|
||||
.title("Space saved")
|
||||
.subtitle("0%")
|
||||
.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()
|
||||
.title("Processing time")
|
||||
.subtitle("0s")
|
||||
.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(&size_before_row);
|
||||
@@ -195,32 +205,48 @@ pub fn build_results_page() -> adw::NavigationPage {
|
||||
.subtitle("View processed images in file manager")
|
||||
.activatable(true)
|
||||
.build();
|
||||
open_row.add_prefix(>k::Image::from_icon_name("folder-open-symbolic"));
|
||||
open_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
let open_icon = gtk::Image::from_icon_name("folder-open-symbolic");
|
||||
open_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
open_row.add_prefix(&open_icon);
|
||||
let open_arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
open_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
open_row.add_suffix(&open_arrow);
|
||||
|
||||
let process_more_row = adw::ActionRow::builder()
|
||||
.title("Process Another Batch")
|
||||
.subtitle("Start over with new images")
|
||||
.activatable(true)
|
||||
.build();
|
||||
process_more_row.add_prefix(>k::Image::from_icon_name("view-refresh-symbolic"));
|
||||
process_more_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
let more_icon = gtk::Image::from_icon_name("view-refresh-symbolic");
|
||||
more_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
process_more_row.add_prefix(&more_icon);
|
||||
let more_arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
more_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
process_more_row.add_suffix(&more_arrow);
|
||||
|
||||
let save_preset_row = adw::ActionRow::builder()
|
||||
.title("Save as Preset")
|
||||
.subtitle("Save this workflow for future use")
|
||||
.activatable(true)
|
||||
.build();
|
||||
save_preset_row.add_prefix(>k::Image::from_icon_name("document-save-symbolic"));
|
||||
save_preset_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
let save_icon = gtk::Image::from_icon_name("document-save-symbolic");
|
||||
save_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
save_preset_row.add_prefix(&save_icon);
|
||||
let save_arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
save_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
save_preset_row.add_suffix(&save_arrow);
|
||||
|
||||
let add_queue_row = adw::ActionRow::builder()
|
||||
.title("Add to Queue")
|
||||
.subtitle("Queue another batch with different images")
|
||||
.activatable(true)
|
||||
.build();
|
||||
add_queue_row.add_prefix(>k::Image::from_icon_name("view-list-symbolic"));
|
||||
add_queue_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
let queue_icon = gtk::Image::from_icon_name("view-list-symbolic");
|
||||
queue_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
add_queue_row.add_prefix(&queue_icon);
|
||||
let queue_arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
queue_arrow.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
add_queue_row.add_suffix(&queue_arrow);
|
||||
|
||||
action_group.add(&open_row);
|
||||
action_group.add(&process_more_row);
|
||||
|
||||
@@ -57,7 +57,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.activatable(true)
|
||||
.visible(config.output_fixed_path.is_some())
|
||||
.build();
|
||||
fixed_path_row.add_prefix(>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()
|
||||
.icon_name("document-open-symbolic")
|
||||
@@ -65,6 +67,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
choose_fixed_btn.add_css_class("flat");
|
||||
choose_fixed_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Choose output folder"),
|
||||
]);
|
||||
fixed_path_row.add_suffix(&choose_fixed_btn);
|
||||
|
||||
// Shared state for fixed path
|
||||
@@ -164,7 +169,34 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.build();
|
||||
reset_button.add_css_class("destructive-action");
|
||||
|
||||
// Reset welcome wizard / tutorial
|
||||
let reset_welcome_state: std::rc::Rc<Cell<bool>> = std::rc::Rc::new(Cell::new(false));
|
||||
|
||||
let reset_welcome_row = adw::ActionRow::builder()
|
||||
.title("Reset welcome wizard")
|
||||
.subtitle("Show the setup wizard and tutorial again on next launch")
|
||||
.build();
|
||||
|
||||
let reset_welcome_btn = gtk::Button::builder()
|
||||
.label("Reset")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
reset_welcome_btn.add_css_class("destructive-action");
|
||||
|
||||
{
|
||||
let rws = reset_welcome_state.clone();
|
||||
let row = reset_welcome_row.clone();
|
||||
reset_welcome_btn.connect_clicked(move |btn| {
|
||||
rws.set(true);
|
||||
btn.set_sensitive(false);
|
||||
row.set_subtitle("Will show on next launch");
|
||||
});
|
||||
}
|
||||
|
||||
reset_welcome_row.add_suffix(&reset_welcome_btn);
|
||||
|
||||
ui_group.add(&skill_row);
|
||||
ui_group.add(&reset_welcome_row);
|
||||
general_page.add(&ui_group);
|
||||
|
||||
// File Manager Integration
|
||||
@@ -432,6 +464,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
watch_list.set_widget_name("watch-folder-list");
|
||||
watch_list.update_property(&[
|
||||
gtk::accessible::Property::Label("Configured watch folders for automatic processing"),
|
||||
]);
|
||||
|
||||
// Shared state for watch folders
|
||||
let watch_folders_state: std::rc::Rc<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>> =
|
||||
@@ -564,9 +599,10 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
|
||||
// Save settings when the dialog closes
|
||||
dialog.connect_closed(move |_| {
|
||||
let welcome_reset = reset_welcome_state.get();
|
||||
let new_config = AppConfig {
|
||||
first_run_complete: true,
|
||||
tutorial_complete: true, // preserve if settings are being saved
|
||||
first_run_complete: !welcome_reset,
|
||||
tutorial_complete: !welcome_reset,
|
||||
output_subfolder: subfolder_row.text().to_string(),
|
||||
output_fixed_path: if output_mode_row.selected() == 1 {
|
||||
fixed_path_state.borrow().clone()
|
||||
|
||||
@@ -49,10 +49,24 @@ impl StepIndicator {
|
||||
container.append(&grid);
|
||||
|
||||
// First step starts as current
|
||||
let total = dots.len();
|
||||
if let Some(first) = dots.first() {
|
||||
first.icon.set_icon_name(Some("radio-checked-symbolic"));
|
||||
first.button.set_sensitive(true);
|
||||
first.label.add_css_class("accent");
|
||||
first.button.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Step 1 of {}: {} (current)", total, first.label.label())
|
||||
),
|
||||
]);
|
||||
}
|
||||
// Label all non-current dots
|
||||
for (i, dot) in dots.iter().enumerate().skip(1) {
|
||||
dot.button.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Step {} of {}: {}", i + 1, total, dot.label.label())
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Self {
|
||||
@@ -164,11 +178,17 @@ impl StepIndicator {
|
||||
pub fn set_completed(&self, actual_index: usize) {
|
||||
let dots = self.dots.borrow();
|
||||
let map = self.step_map.borrow();
|
||||
let total = dots.len();
|
||||
if let Some(visual_i) = map.iter().position(|&i| i == actual_index) {
|
||||
if let Some(dot) = dots.get(visual_i) {
|
||||
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
dot.button.set_sensitive(true);
|
||||
dot.label.remove_css_class("accent");
|
||||
dot.button.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Step {} of {}: {} (completed)", visual_i + 1, total, dot.label.label())
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Enable Adjustments")
|
||||
.subtitle("Rotate, flip, brightness, contrast, effects")
|
||||
.active(cfg.adjustments_enabled)
|
||||
.tooltip_text("Toggle image adjustments on or off")
|
||||
.build();
|
||||
enable_group.add(&enable_row);
|
||||
outer.append(&enable_group);
|
||||
@@ -37,6 +38,10 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
preview_picture.set_focusable(true);
|
||||
preview_picture.update_property(&[
|
||||
gtk::accessible::Property::Label("Adjustments preview - press Space to cycle images"),
|
||||
]);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
@@ -78,6 +83,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Rotate")
|
||||
.subtitle("Rotation applied to all images")
|
||||
.use_subtitle(true)
|
||||
.tooltip_text("Rotate all images by a fixed angle or auto-orient from EXIF")
|
||||
.build();
|
||||
rotate_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
@@ -93,6 +99,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Flip")
|
||||
.subtitle("Mirror the image")
|
||||
.use_subtitle(true)
|
||||
.tooltip_text("Mirror images horizontally or vertically")
|
||||
.build();
|
||||
flip_row.set_model(Some(>k::StringList::new(&["None", "Horizontal", "Vertical"])));
|
||||
flip_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
@@ -130,6 +137,9 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.tooltip_text("Reset to 0")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
reset_btn.update_property(&[
|
||||
gtk::accessible::Property::Label(&format!("Reset {} to 0", title)),
|
||||
]);
|
||||
reset_btn.set_sensitive(value != 0);
|
||||
|
||||
row.add_suffix(&scale);
|
||||
@@ -204,6 +214,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Crop to Aspect Ratio")
|
||||
.subtitle("Crop from center to a specific ratio")
|
||||
.use_subtitle(true)
|
||||
.tooltip_text("Crop from center to a specific aspect ratio")
|
||||
.build();
|
||||
crop_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
@@ -222,12 +233,14 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Trim Whitespace")
|
||||
.subtitle("Remove uniform borders around the image")
|
||||
.active(cfg.trim_whitespace)
|
||||
.tooltip_text("Detect and remove uniform borders around the image")
|
||||
.build();
|
||||
|
||||
let padding_row = adw::SpinRow::builder()
|
||||
.title("Canvas Padding")
|
||||
.subtitle("Add uniform padding (pixels)")
|
||||
.adjustment(>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();
|
||||
|
||||
crop_group.add(&crop_row);
|
||||
@@ -506,6 +519,27 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// Keyboard support for preview cycling (Space/Enter)
|
||||
{
|
||||
let key = gtk::EventControllerKey::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
key.connect_key_pressed(move |_, keyval, _, _| {
|
||||
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
preview_picture.add_controller(key);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
{
|
||||
@@ -694,12 +728,16 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
// Sync enable toggle, refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let er = enable_row.clone();
|
||||
page.connect_map(move |_| {
|
||||
let enabled = jc.borrow().adjustments_enabled;
|
||||
er.set_active(enabled);
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Enable Compression")
|
||||
.subtitle("Reduce file size with quality control")
|
||||
.active(cfg.compress_enabled)
|
||||
.tooltip_text("Toggle compression on or off")
|
||||
.build();
|
||||
|
||||
let enable_group = adw::PreferencesGroup::new();
|
||||
@@ -234,6 +235,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
let compressed_pixbuf: Rc<RefCell<Option<gtk::gdk_pixbuf::Pixbuf>>> = Rc::new(RefCell::new(None));
|
||||
let divider_dragging = Rc::new(Cell::new(false));
|
||||
let image_dragging = Rc::new(Cell::new(false));
|
||||
let divider_hint_visible = Rc::new(Cell::new(true));
|
||||
|
||||
// Pan state for cover-fill preview
|
||||
let pan_x: Rc<Cell<f64>> = Rc::new(Cell::new(0.0));
|
||||
@@ -256,6 +258,17 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
gtk::accessible::Property::Label("Compression quality comparison. Drag the vertical divider to compare original and compressed image. Drag elsewhere to pan."),
|
||||
]);
|
||||
|
||||
// Hint label shown over the preview until the user first interacts with the divider
|
||||
let divider_hint_label = gtk::Label::builder()
|
||||
.label("Drag the divider to compare before and after")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
divider_hint_label.update_property(&[
|
||||
gtk::accessible::Property::Label("Hint: drag the divider to compare before and after compression"),
|
||||
]);
|
||||
|
||||
// Draw function - cover fill with pan support
|
||||
{
|
||||
let dp = divider_pos.clone();
|
||||
@@ -376,12 +389,19 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
let dspy = drag_start_pan_y.clone();
|
||||
let px = pan_x.clone();
|
||||
let py = pan_y.clone();
|
||||
let hint_vis = divider_hint_visible.clone();
|
||||
let hint_lbl = divider_hint_label.clone();
|
||||
drag_gesture.connect_drag_begin(move |_, x, _| {
|
||||
let w = drawing.width() as f64;
|
||||
let current = *dp.borrow() * w;
|
||||
if (x - current).abs() < 30.0 {
|
||||
dd.set(true);
|
||||
id.set(false);
|
||||
// Hide the hint on first divider interaction
|
||||
if hint_vis.get() {
|
||||
hint_vis.set(false);
|
||||
hint_lbl.set_visible(false);
|
||||
}
|
||||
} else {
|
||||
dd.set(false);
|
||||
id.set(true);
|
||||
@@ -437,10 +457,62 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
}
|
||||
preview_drawing.add_controller(drag_gesture);
|
||||
|
||||
// Keyboard support for divider: Left/Right to move divider, Space to reset to center
|
||||
{
|
||||
let dp = divider_pos.clone();
|
||||
let drawing = preview_drawing.clone();
|
||||
let hint_vis = divider_hint_visible.clone();
|
||||
let hint_lbl = divider_hint_label.clone();
|
||||
let key = gtk::EventControllerKey::new();
|
||||
key.connect_key_pressed(move |_, keyval, _, _| {
|
||||
let step = 0.02;
|
||||
match keyval {
|
||||
gtk::gdk::Key::Left => {
|
||||
let new_pos = (*dp.borrow() - step).clamp(0.05, 0.95);
|
||||
*dp.borrow_mut() = new_pos;
|
||||
drawing.queue_draw();
|
||||
if hint_vis.get() {
|
||||
hint_vis.set(false);
|
||||
hint_lbl.set_visible(false);
|
||||
}
|
||||
return gtk::glib::Propagation::Stop;
|
||||
}
|
||||
gtk::gdk::Key::Right => {
|
||||
let new_pos = (*dp.borrow() + step).clamp(0.05, 0.95);
|
||||
*dp.borrow_mut() = new_pos;
|
||||
drawing.queue_draw();
|
||||
if hint_vis.get() {
|
||||
hint_vis.set(false);
|
||||
hint_lbl.set_visible(false);
|
||||
}
|
||||
return gtk::glib::Propagation::Stop;
|
||||
}
|
||||
gtk::gdk::Key::space => {
|
||||
*dp.borrow_mut() = 0.5;
|
||||
drawing.queue_draw();
|
||||
if hint_vis.get() {
|
||||
hint_vis.set(false);
|
||||
hint_lbl.set_visible(false);
|
||||
}
|
||||
return gtk::glib::Propagation::Stop;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
gtk::glib::Propagation::Proceed
|
||||
});
|
||||
preview_drawing.set_focusable(true);
|
||||
preview_drawing.add_controller(key);
|
||||
}
|
||||
|
||||
let preview_overlay = gtk::Overlay::builder()
|
||||
.child(&preview_drawing)
|
||||
.build();
|
||||
preview_overlay.add_overlay(÷r_hint_label);
|
||||
|
||||
let preview_frame = gtk::Frame::builder()
|
||||
.halign(gtk::Align::Fill)
|
||||
.build();
|
||||
preview_frame.set_child(Some(&preview_drawing));
|
||||
preview_frame.set_child(Some(&preview_overlay));
|
||||
|
||||
preview_group.add(&size_box);
|
||||
preview_group.add(&preview_frame);
|
||||
@@ -480,11 +552,22 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
frame.add_css_class("accent");
|
||||
}
|
||||
|
||||
let file_name = files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
let btn = gtk::Button::builder()
|
||||
.child(&frame)
|
||||
.has_frame(false)
|
||||
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
|
||||
.tooltip_text(file_name)
|
||||
.build();
|
||||
let selected_label = if i == 0 { "currently selected" } else { "" };
|
||||
btn.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&if selected_label.is_empty() {
|
||||
format!("Preview thumbnail: {}", file_name)
|
||||
} else {
|
||||
format!("Preview thumbnail: {} ({})", file_name, selected_label)
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
thumb_box.append(&btn);
|
||||
}
|
||||
@@ -816,7 +899,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
// On page map: refresh thumbnail strip, preview, and show/hide per-format rows
|
||||
// On page map: sync enable toggle, refresh thumbnail strip, preview, and show/hide per-format rows
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let jc = state.job_config.clone();
|
||||
@@ -832,7 +915,10 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
let ts = thumb_scrolled.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let up2 = update_preview.clone();
|
||||
let er = enable_row.clone();
|
||||
page.connect_map(move |_| {
|
||||
let enabled = jc.borrow().compress_enabled;
|
||||
er.set_active(enabled);
|
||||
// Rebuild thumbnail strip from current file list
|
||||
while let Some(child) = tb.first_child() {
|
||||
tb.remove(&child);
|
||||
@@ -856,11 +942,22 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
||||
let up_c = up2.clone();
|
||||
let tb_c = tb.clone();
|
||||
let current_idx = i;
|
||||
let file_name = files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
let btn = gtk::Button::builder()
|
||||
.child(&frame)
|
||||
.has_frame(false)
|
||||
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
|
||||
.tooltip_text(file_name)
|
||||
.build();
|
||||
let is_selected = i == *pidx.borrow();
|
||||
btn.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&if is_selected {
|
||||
format!("Preview thumbnail: {} (currently selected)", file_name)
|
||||
} else {
|
||||
format!("Preview thumbnail: {}", file_name)
|
||||
}
|
||||
),
|
||||
]);
|
||||
btn.connect_clicked(move |_| {
|
||||
*pidx_c.borrow_mut() = current_idx;
|
||||
up_c(true);
|
||||
|
||||
@@ -78,6 +78,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Enable Format Conversion")
|
||||
.subtitle("Convert images to a different format")
|
||||
.active(cfg.convert_enabled)
|
||||
.tooltip_text("Toggle format conversion on or off")
|
||||
.build();
|
||||
|
||||
let enable_group = adw::PreferencesGroup::new();
|
||||
@@ -101,6 +102,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
flow.update_property(&[
|
||||
gtk::accessible::Property::Label("Output format selection grid"),
|
||||
]);
|
||||
|
||||
let initial_format = cfg.convert_format;
|
||||
|
||||
for (name, desc, icon_name, _fmt) in CARD_FORMATS {
|
||||
@@ -112,6 +117,9 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(130, 110);
|
||||
card.update_property(&[
|
||||
gtk::accessible::Property::Label(&format!("{}: {}", name, desc.replace('\n', ", "))),
|
||||
]);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
@@ -129,6 +137,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.icon_name(*icon_name)
|
||||
.pixel_size(28)
|
||||
.build();
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(*name)
|
||||
@@ -212,6 +221,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Progressive JPEG")
|
||||
.subtitle("Loads gradually in browsers, slightly larger file size")
|
||||
.active(cfg.progressive_jpeg)
|
||||
.tooltip_text("Creates JPEG files that load gradually in web browsers")
|
||||
.build();
|
||||
|
||||
jpeg_group.add(&progressive_row);
|
||||
@@ -315,12 +325,15 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
// Rebuild format mapping rows when navigating to this page
|
||||
// Sync enable toggle and rebuild format mapping rows when navigating to this page
|
||||
{
|
||||
let files = state.loaded_files.clone();
|
||||
let list = mapping_list;
|
||||
let jc = state.job_config.clone();
|
||||
let er = enable_row.clone();
|
||||
page.connect_map(move |_| {
|
||||
let enabled = jc.borrow().convert_enabled;
|
||||
er.set_active(enabled);
|
||||
rebuild_format_mapping(&list, &files.borrow(), &jc);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -500,6 +500,7 @@ fn build_empty_state() -> gtk::Box {
|
||||
.pixel_size(64)
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
|
||||
let title = gtk::Label::builder()
|
||||
.label("Drop images here")
|
||||
@@ -529,6 +530,16 @@ fn build_empty_state() -> gtk::Box {
|
||||
browse_button.add_css_class("suggested-action");
|
||||
browse_button.add_css_class("pill");
|
||||
|
||||
let hint = gtk::Label::builder()
|
||||
.label("Start by adding your images below, then use the Next button to configure each processing step.")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.justify(gtk::Justification::Center)
|
||||
.wrap(true)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
inner.append(&hint);
|
||||
inner.append(&icon);
|
||||
inner.append(&title);
|
||||
inner.append(&subtitle);
|
||||
@@ -778,11 +789,19 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
}
|
||||
});
|
||||
|
||||
// Set accessible label on thumbnail picture
|
||||
picture.update_property(&[
|
||||
gtk::accessible::Property::Label(&format!("Thumbnail of {}", file_name)),
|
||||
]);
|
||||
|
||||
// Set checkbox state
|
||||
let check = find_check_button(overlay.upcast_ref::<gtk::Widget>());
|
||||
if let Some(ref check) = check {
|
||||
let is_excluded = excluded.borrow().contains(&path);
|
||||
check.set_active(!is_excluded);
|
||||
check.update_property(&[
|
||||
gtk::accessible::Property::Label(&format!("Include {} in processing", file_name)),
|
||||
]);
|
||||
|
||||
// Wire checkbox toggle
|
||||
let excl = excluded.clone();
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Enable Metadata Handling")
|
||||
.subtitle("Control what image metadata to keep or remove")
|
||||
.active(cfg.metadata_enabled)
|
||||
.tooltip_text("Toggle metadata handling on or off")
|
||||
.build();
|
||||
|
||||
let enable_group = adw::PreferencesGroup::new();
|
||||
@@ -39,7 +40,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
.subtitle("Remove all metadata - smallest files, maximum privacy")
|
||||
.activatable(true)
|
||||
.build();
|
||||
strip_all_row.add_prefix(>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();
|
||||
strip_all_check.set_active(cfg.metadata_mode == MetadataMode::StripAll);
|
||||
strip_all_row.add_suffix(&strip_all_check);
|
||||
@@ -50,7 +53,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
.subtitle("Strip GPS and camera serial, keep copyright")
|
||||
.activatable(true)
|
||||
.build();
|
||||
privacy_row.add_prefix(>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();
|
||||
privacy_check.set_group(Some(&strip_all_check));
|
||||
privacy_check.set_active(cfg.metadata_mode == MetadataMode::Privacy);
|
||||
@@ -62,7 +67,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
.subtitle("Preserve all original metadata")
|
||||
.activatable(true)
|
||||
.build();
|
||||
keep_all_row.add_prefix(>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();
|
||||
keep_all_check.set_group(Some(&strip_all_check));
|
||||
keep_all_check.set_active(cfg.metadata_mode == MetadataMode::KeepAll);
|
||||
@@ -74,7 +81,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
.subtitle("Keep copyright and camera model, strip GPS and software")
|
||||
.activatable(true)
|
||||
.build();
|
||||
photographer_row.add_prefix(>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();
|
||||
photographer_check.set_group(Some(&strip_all_check));
|
||||
photographer_row.add_suffix(&photographer_check);
|
||||
@@ -85,7 +94,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
.subtitle("Choose exactly which metadata categories to strip")
|
||||
.activatable(true)
|
||||
.build();
|
||||
custom_row.add_prefix(>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();
|
||||
custom_check.set_group(Some(&strip_all_check));
|
||||
custom_check.set_active(cfg.metadata_mode == MetadataMode::Custom);
|
||||
@@ -109,30 +120,35 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("GPS / Location")
|
||||
.subtitle("GPS coordinates, location name, altitude")
|
||||
.active(cfg.strip_gps)
|
||||
.tooltip_text("Strip GPS coordinates, location name, and altitude")
|
||||
.build();
|
||||
|
||||
let camera_row = adw::SwitchRow::builder()
|
||||
.title("Camera Info")
|
||||
.subtitle("Camera model, serial number, lens data")
|
||||
.active(cfg.strip_camera)
|
||||
.tooltip_text("Strip camera model, serial number, and lens data")
|
||||
.build();
|
||||
|
||||
let software_row = adw::SwitchRow::builder()
|
||||
.title("Software")
|
||||
.subtitle("Editing software, processing history")
|
||||
.active(cfg.strip_software)
|
||||
.tooltip_text("Strip editing software and processing history")
|
||||
.build();
|
||||
|
||||
let timestamps_row = adw::SwitchRow::builder()
|
||||
.title("Timestamps")
|
||||
.subtitle("Date taken, date modified, date digitized")
|
||||
.active(cfg.strip_timestamps)
|
||||
.tooltip_text("Strip date taken, date modified, date digitized")
|
||||
.build();
|
||||
|
||||
let copyright_row = adw::SwitchRow::builder()
|
||||
.title("Copyright / Author")
|
||||
.subtitle("Copyright notice, artist name, credits")
|
||||
.active(cfg.strip_copyright)
|
||||
.tooltip_text("Strip copyright notice, artist name, and credits")
|
||||
.build();
|
||||
|
||||
custom_group.add(&gps_row);
|
||||
@@ -260,9 +276,21 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Metadata")
|
||||
.tag("step-metadata")
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
.build();
|
||||
|
||||
// Sync enable toggle when navigating to this page
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let er = enable_row.clone();
|
||||
page.connect_map(move |_| {
|
||||
let enabled = jc.borrow().metadata_enabled;
|
||||
er.set_active(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Enable Rename")
|
||||
.subtitle("Rename output files with prefix, suffix, or template")
|
||||
.active(cfg.rename_enabled)
|
||||
.tooltip_text("Toggle file renaming on or off")
|
||||
.build();
|
||||
enable_group.add(&enable_row);
|
||||
outer.append(&enable_group);
|
||||
@@ -74,6 +75,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
.visible(false)
|
||||
.build();
|
||||
conflict_banner.add_css_class("card");
|
||||
conflict_banner.set_accessible_role(gtk::AccessibleRole::Alert);
|
||||
|
||||
let conflict_icon = gtk::Image::builder()
|
||||
.icon_name("dialog-warning-symbolic")
|
||||
@@ -128,6 +130,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
.label("Reset to defaults")
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_top(4)
|
||||
.tooltip_text("Reset all rename options to their defaults")
|
||||
.build();
|
||||
reset_button.add_css_class("pill");
|
||||
|
||||
@@ -140,11 +143,13 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
let prefix_row = adw::EntryRow::builder()
|
||||
.title("Prefix")
|
||||
.text(&cfg.rename_prefix)
|
||||
.tooltip_text("Text added before the original filename")
|
||||
.build();
|
||||
|
||||
let suffix_row = adw::EntryRow::builder()
|
||||
.title("Suffix")
|
||||
.text(&cfg.rename_suffix)
|
||||
.tooltip_text("Text added after the original filename")
|
||||
.build();
|
||||
|
||||
let replace_spaces_row = adw::ComboRow::builder()
|
||||
@@ -258,6 +263,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
let template_row = adw::EntryRow::builder()
|
||||
.title("Template")
|
||||
.text(&cfg.rename_template)
|
||||
.tooltip_text("Use variables like {name}, {date}, {counter:3} to build filenames")
|
||||
.build();
|
||||
|
||||
// Template preset chips
|
||||
@@ -375,6 +381,11 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&chip_box)
|
||||
.has_frame(false)
|
||||
.build();
|
||||
btn.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Insert {} - {}", var_name, description)
|
||||
),
|
||||
]);
|
||||
|
||||
let tr = template_row.clone();
|
||||
let var_text = var_name.to_string();
|
||||
@@ -411,11 +422,13 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
let find_row = adw::EntryRow::builder()
|
||||
.title("Find (regex)")
|
||||
.text(&cfg.rename_find)
|
||||
.tooltip_text("Regular expression pattern to match in filenames")
|
||||
.build();
|
||||
|
||||
let replace_row = adw::EntryRow::builder()
|
||||
.title("Replace with")
|
||||
.text(&cfg.rename_replace)
|
||||
.tooltip_text("Replacement text for matched pattern")
|
||||
.build();
|
||||
|
||||
advanced_expander.add_row(&template_row);
|
||||
@@ -603,9 +616,17 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
.max_width_chars(50)
|
||||
.build();
|
||||
|
||||
// Highlight conflicts
|
||||
if name_counts.get(new_full.as_str()).copied().unwrap_or(0) > 1 {
|
||||
// Highlight conflicts with both color AND icon indicator
|
||||
let is_conflict = name_counts.get(new_full.as_str()).copied().unwrap_or(0) > 1;
|
||||
if is_conflict {
|
||||
new_name_label.add_css_class("error");
|
||||
let conflict_icon = gtk::Image::builder()
|
||||
.icon_name("dialog-warning-symbolic")
|
||||
.pixel_size(12)
|
||||
.tooltip_text("Duplicate filename")
|
||||
.build();
|
||||
conflict_icon.add_css_class("warning");
|
||||
new_line.append(&conflict_icon);
|
||||
}
|
||||
|
||||
new_line.append(&arrow_label);
|
||||
@@ -643,9 +664,13 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
id.remove();
|
||||
}
|
||||
let up2 = up.clone();
|
||||
let ds2 = ds.clone();
|
||||
let id = gtk::glib::timeout_add_local_once(
|
||||
std::time::Duration::from_millis(150),
|
||||
move || { up2(); },
|
||||
move || {
|
||||
ds2.set(None);
|
||||
up2();
|
||||
},
|
||||
);
|
||||
ds.set(Some(id));
|
||||
})
|
||||
@@ -860,10 +885,14 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview when navigating to this page
|
||||
// Sync enable toggle and refresh preview when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let er = enable_row.clone();
|
||||
page.connect_map(move |_| {
|
||||
let enabled = jc.borrow().rename_enabled;
|
||||
er.set_active(enabled);
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Enable Resize")
|
||||
.subtitle("Scale images to new dimensions")
|
||||
.active(cfg.resize_enabled)
|
||||
.tooltip_text("Toggle resizing of images on or off")
|
||||
.build();
|
||||
enable_group.add(&enable_row);
|
||||
outer.append(&enable_group);
|
||||
@@ -179,6 +180,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
let category_row = adw::ComboRow::builder()
|
||||
.title("Category")
|
||||
.use_subtitle(true)
|
||||
.tooltip_text("Choose a category of size presets")
|
||||
.build();
|
||||
category_row.set_model(Some(>k::StringList::new(CATEGORIES)));
|
||||
category_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
@@ -187,6 +189,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Size")
|
||||
.subtitle("Select a preset to fill dimensions")
|
||||
.use_subtitle(true)
|
||||
.tooltip_text("Pick a preset size to fill dimensions")
|
||||
.build();
|
||||
rebuild_size_model(&size_row, 0);
|
||||
|
||||
@@ -214,6 +217,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
.label("W")
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
w_label.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
let width_spin = gtk::SpinButton::builder()
|
||||
.adjustment(>k::Adjustment::new(
|
||||
cfg.resize_width as f64, 0.0, 10000.0, 1.0, 100.0, 0.0,
|
||||
@@ -250,12 +254,28 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
.label("H")
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
h_label.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
|
||||
// Unit segmented toggle (px / %)
|
||||
let unit_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
unit_box.add_css_class("linked");
|
||||
let px_btn = gtk::Button::builder().label("px").build();
|
||||
let pct_btn = gtk::Button::builder().label("%").build();
|
||||
unit_box.update_property(&[
|
||||
gtk::accessible::Property::Label("Dimension unit toggle"),
|
||||
]);
|
||||
let px_btn = gtk::Button::builder()
|
||||
.label("px")
|
||||
.tooltip_text("Use pixel dimensions (currently active)")
|
||||
.build();
|
||||
px_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Pixels - currently active"),
|
||||
]);
|
||||
let pct_btn = gtk::Button::builder()
|
||||
.label("%")
|
||||
.tooltip_text("Use percentage dimensions")
|
||||
.build();
|
||||
pct_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Percentage"),
|
||||
]);
|
||||
px_btn.add_css_class("suggested-action");
|
||||
unit_box.append(&px_btn);
|
||||
unit_box.append(&pct_btn);
|
||||
@@ -273,6 +293,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Mode")
|
||||
.subtitle("How dimensions are applied to images")
|
||||
.use_subtitle(true)
|
||||
.tooltip_text("Exact stretches to dimensions; Fit keeps aspect ratio")
|
||||
.build();
|
||||
mode_row.set_model(Some(>k::StringList::new(&[
|
||||
"Exact Size",
|
||||
@@ -285,6 +306,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Allow Upscaling")
|
||||
.subtitle("Enlarge images smaller than target size")
|
||||
.active(cfg.allow_upscale)
|
||||
.tooltip_text("When off, images smaller than target are left as-is")
|
||||
.build();
|
||||
|
||||
dims_group.add(&mode_row);
|
||||
@@ -568,9 +590,15 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
if btn.is_active() {
|
||||
lb.set_icon_name("changes-prevent-symbolic");
|
||||
lb.set_tooltip_text(Some("Aspect ratio locked"));
|
||||
lb.update_property(&[
|
||||
gtk::accessible::Property::Label("Aspect ratio locked - click to unlock"),
|
||||
]);
|
||||
} else {
|
||||
lb.set_icon_name("changes-allow-symbolic");
|
||||
lb.set_tooltip_text(Some("Aspect ratio unlocked"));
|
||||
lb.update_property(&[
|
||||
gtk::accessible::Property::Label("Aspect ratio unlocked - click to lock"),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -711,6 +739,14 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
ip.set(false);
|
||||
px.add_css_class("suggested-action");
|
||||
pct.remove_css_class("suggested-action");
|
||||
px.update_property(&[
|
||||
gtk::accessible::Property::Label("Pixels - currently active"),
|
||||
]);
|
||||
pct.update_property(&[
|
||||
gtk::accessible::Property::Label("Percentage"),
|
||||
]);
|
||||
px.set_tooltip_text(Some("Use pixel dimensions (currently active)"));
|
||||
pct.set_tooltip_text(Some("Use percentage dimensions"));
|
||||
|
||||
let dims = get_first_image_dims(&files.borrow());
|
||||
let pct_w = ws.value();
|
||||
@@ -755,6 +791,14 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
ip.set(true);
|
||||
pct.add_css_class("suggested-action");
|
||||
px.remove_css_class("suggested-action");
|
||||
pct.update_property(&[
|
||||
gtk::accessible::Property::Label("Percentage - currently active"),
|
||||
]);
|
||||
px.update_property(&[
|
||||
gtk::accessible::Property::Label("Pixels"),
|
||||
]);
|
||||
pct.set_tooltip_text(Some("Use percentage dimensions (currently active)"));
|
||||
px.set_tooltip_text(Some("Use pixel dimensions"));
|
||||
|
||||
let dims = get_first_image_dims(&files.borrow());
|
||||
let cur_w = ws.value();
|
||||
@@ -852,10 +896,31 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
gesture.set_state(gtk::EventSequenceState::Claimed);
|
||||
});
|
||||
thumb_picture.set_can_target(true);
|
||||
thumb_picture.set_focusable(true);
|
||||
thumb_picture.add_controller(click);
|
||||
thumb_picture.set_cursor_from_name(Some("pointer"));
|
||||
}
|
||||
|
||||
// Keyboard support for preview cycling (Space/Enter)
|
||||
{
|
||||
let pi = preview_index.clone();
|
||||
let rt = render_thumb.clone();
|
||||
let lf = loaded_files.clone();
|
||||
let key = gtk::EventControllerKey::new();
|
||||
key.connect_key_pressed(move |_, keyval, _, _| {
|
||||
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
|
||||
let count = lf.borrow().len();
|
||||
if count > 1 {
|
||||
pi.set((pi.get() + 1) % count);
|
||||
rt();
|
||||
}
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
thumb_picture.add_controller(key);
|
||||
}
|
||||
|
||||
// Initial render
|
||||
{
|
||||
let rt = render_thumb.clone();
|
||||
@@ -868,10 +933,14 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Re-render on page map
|
||||
// Sync enable toggle and re-render on page map
|
||||
{
|
||||
let rt = render_thumb.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let er = enable_row.clone();
|
||||
page.connect_map(move |_| {
|
||||
let enabled = jc.borrow().resize_enabled;
|
||||
er.set_active(enabled);
|
||||
rt();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Enable Watermark")
|
||||
.subtitle("Add text or image watermark to processed images")
|
||||
.active(cfg.watermark_enabled)
|
||||
.tooltip_text("Toggle watermark on or off")
|
||||
.build();
|
||||
enable_group.add(&enable_row);
|
||||
outer.append(&enable_group);
|
||||
@@ -37,6 +38,10 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
preview_picture.set_focusable(true);
|
||||
preview_picture.update_property(&[
|
||||
gtk::accessible::Property::Label("Watermark preview - press Space to cycle images"),
|
||||
]);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
@@ -78,6 +83,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Type")
|
||||
.subtitle("Choose text or image watermark")
|
||||
.use_subtitle(true)
|
||||
.tooltip_text("Choose between text or image/logo overlay")
|
||||
.build();
|
||||
type_row.set_model(Some(>k::StringList::new(&["Text Watermark", "Image Watermark"])));
|
||||
type_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
@@ -95,6 +101,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let text_row = adw::EntryRow::builder()
|
||||
.title("Watermark Text")
|
||||
.text(&cfg.watermark_text)
|
||||
.tooltip_text("The text that appears as a watermark on each image")
|
||||
.build();
|
||||
|
||||
let font_row = adw::ActionRow::builder()
|
||||
@@ -115,12 +122,16 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family);
|
||||
font_button.set_font_desc(&desc);
|
||||
}
|
||||
font_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Choose watermark font"),
|
||||
]);
|
||||
font_row.add_suffix(&font_button);
|
||||
|
||||
let font_size_row = adw::SpinRow::builder()
|
||||
.title("Font Size")
|
||||
.subtitle("Size in pixels")
|
||||
.adjustment(>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();
|
||||
|
||||
text_group.add(&text_row);
|
||||
@@ -144,7 +155,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
)
|
||||
.activatable(true)
|
||||
.build();
|
||||
image_path_row.add_prefix(>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()
|
||||
.icon_name("document-open-symbolic")
|
||||
@@ -152,6 +165,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.valign(gtk::Align::Center)
|
||||
.has_frame(false)
|
||||
.build();
|
||||
choose_image_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Choose logo image"),
|
||||
]);
|
||||
image_path_row.add_suffix(&choose_image_button);
|
||||
|
||||
image_group.add(&image_path_row);
|
||||
@@ -287,6 +303,9 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.rgba(&initial_color)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
color_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Choose watermark text color"),
|
||||
]);
|
||||
color_row.add_suffix(&color_button);
|
||||
|
||||
// Opacity slider + reset
|
||||
@@ -300,12 +319,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
opacity_scale.set_hexpand(false);
|
||||
opacity_scale.set_valign(gtk::Align::Center);
|
||||
opacity_scale.set_width_request(180);
|
||||
opacity_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Watermark opacity, 0 to 100 percent"),
|
||||
]);
|
||||
let opacity_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 50%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
opacity_reset.update_property(&[
|
||||
gtk::accessible::Property::Label("Reset opacity to 50%"),
|
||||
]);
|
||||
opacity_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01);
|
||||
opacity_row.add_suffix(&opacity_scale);
|
||||
opacity_row.add_suffix(&opacity_reset);
|
||||
@@ -321,12 +346,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
rotation_scale.set_hexpand(false);
|
||||
rotation_scale.set_valign(gtk::Align::Center);
|
||||
rotation_scale.set_width_request(180);
|
||||
rotation_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Watermark rotation, -180 to +180 degrees"),
|
||||
]);
|
||||
let rotation_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 0 degrees")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
rotation_reset.update_property(&[
|
||||
gtk::accessible::Property::Label("Reset rotation to 0 degrees"),
|
||||
]);
|
||||
rotation_reset.set_sensitive(cfg.watermark_rotation != 0);
|
||||
rotation_row.add_suffix(&rotation_scale);
|
||||
rotation_row.add_suffix(&rotation_reset);
|
||||
@@ -336,6 +367,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.title("Tiled / Repeated")
|
||||
.subtitle("Repeat watermark across the entire image")
|
||||
.active(cfg.watermark_tiled)
|
||||
.tooltip_text("Repeat the watermark in a grid pattern across the entire image")
|
||||
.build();
|
||||
|
||||
// Margin slider + reset
|
||||
@@ -349,12 +381,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
margin_scale.set_hexpand(false);
|
||||
margin_scale.set_valign(gtk::Align::Center);
|
||||
margin_scale.set_width_request(180);
|
||||
margin_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Watermark margin from edges, 0 to 200 pixels"),
|
||||
]);
|
||||
let margin_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 10 px")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
margin_reset.update_property(&[
|
||||
gtk::accessible::Property::Label("Reset margin to 10 pixels"),
|
||||
]);
|
||||
margin_reset.set_sensitive(cfg.watermark_margin != 10);
|
||||
margin_row.add_suffix(&margin_scale);
|
||||
margin_row.add_suffix(&margin_reset);
|
||||
@@ -371,12 +409,18 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
scale_scale.set_hexpand(false);
|
||||
scale_scale.set_valign(gtk::Align::Center);
|
||||
scale_scale.set_width_request(180);
|
||||
scale_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Watermark scale, 1 to 100 percent of image"),
|
||||
]);
|
||||
let scale_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 20%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
scale_reset.update_property(&[
|
||||
gtk::accessible::Property::Label("Reset scale to 20%"),
|
||||
]);
|
||||
scale_reset.set_sensitive((cfg.watermark_scale - 20.0).abs() > 0.5);
|
||||
scale_row.add_suffix(&scale_scale);
|
||||
scale_row.add_suffix(&scale_reset);
|
||||
@@ -573,6 +617,27 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// Keyboard support for preview cycling (Space/Enter)
|
||||
{
|
||||
let key = gtk::EventControllerKey::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
key.connect_key_pressed(move |_, keyval, _, _| {
|
||||
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
return gtk::glib::Propagation::Stop;
|
||||
}
|
||||
gtk::glib::Propagation::Proceed
|
||||
});
|
||||
preview_picture.add_controller(key);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
// Enable toggle
|
||||
@@ -857,12 +922,16 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
// Sync enable toggle, refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let er = enable_row.clone();
|
||||
page.connect_map(move |_| {
|
||||
let enabled = jc.borrow().watermark_enabled;
|
||||
er.set_active(enabled);
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
|
||||
@@ -33,6 +33,10 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
.homogeneous(true)
|
||||
.build();
|
||||
|
||||
builtin_flow.update_property(&[
|
||||
gtk::accessible::Property::Label("Workflow preset selection grid"),
|
||||
]);
|
||||
|
||||
// Custom card is always first (index 0)
|
||||
let custom_card = build_custom_card();
|
||||
builtin_flow.append(&custom_card);
|
||||
@@ -181,20 +185,37 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
.description("Import or save your own workflows")
|
||||
.build();
|
||||
|
||||
// Container for dynamically-rebuilt user preset rows
|
||||
let user_rows_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
// FlowBox for user preset cards (same look as built-in presets)
|
||||
let user_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.max_children_per_line(5)
|
||||
.min_children_per_line(2)
|
||||
.row_spacing(8)
|
||||
.column_spacing(8)
|
||||
.homogeneous(true)
|
||||
.build();
|
||||
user_group.add(&user_rows_box);
|
||||
user_flow.update_property(&[
|
||||
gtk::accessible::Property::Label("Your saved preset selection grid"),
|
||||
]);
|
||||
|
||||
let import_button = gtk::Button::builder()
|
||||
.label("Import Preset")
|
||||
.icon_name("document-open-symbolic")
|
||||
.action_name("win.import-preset")
|
||||
let user_clamp = adw::Clamp::builder()
|
||||
.maximum_size(1200)
|
||||
.child(&user_flow)
|
||||
.build();
|
||||
import_button.add_css_class("flat");
|
||||
user_group.add(&import_button);
|
||||
|
||||
let user_empty_label = gtk::Label::builder()
|
||||
.label("No saved presets yet. Process images and save your workflow as a preset, or import one.")
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.wrap(true)
|
||||
.justify(gtk::Justification::Center)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
user_group.add(&user_clamp);
|
||||
user_group.add(&user_empty_label);
|
||||
|
||||
content.append(&user_group);
|
||||
content.append(&custom_group);
|
||||
|
||||
@@ -228,39 +249,94 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
// Refresh user presets every time this page is shown
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let rows_box = user_rows_box.clone();
|
||||
let uf = user_flow.clone();
|
||||
let uel = user_empty_label.clone();
|
||||
page.connect_map(move |_| {
|
||||
// Clear existing rows
|
||||
while let Some(child) = rows_box.first_child() {
|
||||
rows_box.remove(&child);
|
||||
}
|
||||
// Clear existing cards
|
||||
uf.remove_all();
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
let mut has_custom = false;
|
||||
if let Ok(presets) = store.list() {
|
||||
for preset in &presets {
|
||||
if !preset.is_custom {
|
||||
continue;
|
||||
}
|
||||
let list_box = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
has_custom = true;
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
|
||||
// Card body (same style as built-in presets)
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 140);
|
||||
card.update_property(&[
|
||||
gtk::accessible::Property::Label(&format!("{}: {}", preset.name, preset.description)),
|
||||
]);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&preset.name)
|
||||
.subtitle(&preset.description)
|
||||
.activatable(true)
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name(&preset.icon)
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
if !preset.icon_color.is_empty() {
|
||||
icon.add_css_class(&preset.icon_color);
|
||||
}
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(&preset.name)
|
||||
.css_classes(["heading"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(16)
|
||||
.build();
|
||||
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(&preset.description)
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.wrap(true)
|
||||
.justify(gtk::Justification::Center)
|
||||
.max_width_chars(20)
|
||||
.build();
|
||||
|
||||
inner.append(&icon);
|
||||
inner.append(&name_label);
|
||||
inner.append(&desc_label);
|
||||
card.append(&inner);
|
||||
overlay.set_child(Some(&card));
|
||||
|
||||
// Action buttons overlay (top-right corner)
|
||||
let actions_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(0)
|
||||
.halign(gtk::Align::End)
|
||||
.valign(gtk::Align::Start)
|
||||
.margin_top(2)
|
||||
.margin_end(2)
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name(&preset.icon));
|
||||
|
||||
// Export button
|
||||
let export_btn = gtk::Button::builder()
|
||||
.icon_name("document-save-as-symbolic")
|
||||
.tooltip_text("Export preset")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
export_btn.add_css_class("flat");
|
||||
export_btn.add_css_class("circular");
|
||||
let preset_for_export = preset.clone();
|
||||
export_btn.connect_clicked(move |btn| {
|
||||
let p = preset_for_export.clone();
|
||||
@@ -280,40 +356,80 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
});
|
||||
}
|
||||
});
|
||||
row.add_suffix(&export_btn);
|
||||
|
||||
// Delete button
|
||||
let delete_btn = gtk::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Delete preset")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
delete_btn.add_css_class("flat");
|
||||
delete_btn.add_css_class("circular");
|
||||
delete_btn.add_css_class("error");
|
||||
let pname = preset.name.clone();
|
||||
let list_box_ref = list_box.clone();
|
||||
let rows_box_ref = rows_box.clone();
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let uf_ref = uf.clone();
|
||||
let uel_ref = uel.clone();
|
||||
delete_btn.connect_clicked(move |btn| {
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
let _ = store.delete(&pname);
|
||||
rows_box_ref.remove(&list_box_ref);
|
||||
// Remove the FlowBoxChild containing this card
|
||||
if let Some(child) = btn.ancestor(gtk::FlowBoxChild::static_type()) {
|
||||
if let Some(fbc) = child.downcast_ref::<gtk::FlowBoxChild>() {
|
||||
uf_ref.remove(fbc);
|
||||
// Show empty label if only the import card is left
|
||||
let mut c = uf_ref.first_child();
|
||||
let mut count = 0;
|
||||
while let Some(w) = c {
|
||||
count += 1;
|
||||
c = w.next_sibling();
|
||||
}
|
||||
uel_ref.set_visible(count <= 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
row.add_suffix(&delete_btn);
|
||||
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
actions_box.append(&export_btn);
|
||||
actions_box.append(&delete_btn);
|
||||
overlay.add_overlay(&actions_box);
|
||||
|
||||
let jc2 = jc.clone();
|
||||
let p = preset.clone();
|
||||
row.connect_activated(move |r| {
|
||||
let mut cfg = jc2.borrow_mut();
|
||||
apply_preset_to_config(&mut cfg, &p);
|
||||
uf.append(&overlay);
|
||||
}
|
||||
}
|
||||
uel.set_visible(!has_custom);
|
||||
|
||||
// Always append an "Import Preset" card at the end
|
||||
let import_card = build_import_card();
|
||||
uf.append(&import_card);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire user preset card activation
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
user_flow.connect_child_activated(move |flow, child| {
|
||||
// Count total children to know which is the import card (always last)
|
||||
let mut total = 0usize;
|
||||
let mut c = flow.first_child();
|
||||
while let Some(w) = c {
|
||||
total += 1;
|
||||
c = w.next_sibling();
|
||||
}
|
||||
|
||||
let activated_idx = child.index() as usize;
|
||||
|
||||
// Last card is always the import card
|
||||
if activated_idx == total - 1 {
|
||||
flow.activate_action("win.import-preset", None).ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
if let Ok(presets) = store.list() {
|
||||
let custom_presets: Vec<_> = presets.iter().filter(|p| p.is_custom).collect();
|
||||
if let Some(preset) = custom_presets.get(activated_idx) {
|
||||
let mut cfg = jc.borrow_mut();
|
||||
apply_preset_to_config(&mut cfg, preset);
|
||||
cfg.preset_mode = true;
|
||||
drop(cfg);
|
||||
r.activate_action("win.next-step", None).ok();
|
||||
});
|
||||
|
||||
list_box.append(&row);
|
||||
rows_box.append(&list_box);
|
||||
flow.activate_action("win.next-step", None).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -430,15 +546,18 @@ fn build_custom_card() -> gtk::Box {
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 120);
|
||||
card.set_size_request(180, 140);
|
||||
card.update_property(&[
|
||||
gtk::accessible::Property::Label("Custom: Pick and choose operations"),
|
||||
]);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
@@ -448,6 +567,7 @@ fn build_custom_card() -> gtk::Box {
|
||||
.icon_name("emblem-system-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label("Custom")
|
||||
@@ -470,6 +590,59 @@ fn build_custom_card() -> gtk::Box {
|
||||
card
|
||||
}
|
||||
|
||||
fn build_import_card() -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 140);
|
||||
card.set_tooltip_text(Some("Import a .pixstrip-preset file from disk"));
|
||||
card.update_property(&[
|
||||
gtk::accessible::Property::Label("Import preset from file"),
|
||||
]);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("folder-open-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label("Import Preset")
|
||||
.css_classes(["heading"])
|
||||
.build();
|
||||
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label("Load a preset from file")
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.wrap(true)
|
||||
.justify(gtk::Justification::Center)
|
||||
.max_width_chars(20)
|
||||
.build();
|
||||
|
||||
inner.append(&icon);
|
||||
inner.append(&name_label);
|
||||
inner.append(&desc_label);
|
||||
card.append(&inner);
|
||||
|
||||
card
|
||||
}
|
||||
|
||||
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
@@ -478,15 +651,18 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 120);
|
||||
card.set_size_request(180, 140);
|
||||
card.update_property(&[
|
||||
gtk::accessible::Property::Label(&format!("{}: {}", preset.name, preset.description)),
|
||||
]);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
@@ -496,6 +672,10 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
.icon_name(&preset.icon)
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
if !preset.icon_color.is_empty() {
|
||||
icon.add_css_class(&preset.icon_color);
|
||||
}
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(&preset.name)
|
||||
|
||||
@@ -1,46 +1,7 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
|
||||
struct TourStop {
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
icon: &'static str,
|
||||
}
|
||||
|
||||
const TOUR_STOPS: &[TourStop] = &[
|
||||
TourStop {
|
||||
title: "Step Indicator",
|
||||
description: "This bar shows your progress through the wizard. Click any completed step to jump back to it.",
|
||||
icon: "view-list-symbolic",
|
||||
},
|
||||
TourStop {
|
||||
title: "Choose a Workflow",
|
||||
description: "Start by picking a preset that matches what you need, or build a custom workflow from scratch.",
|
||||
icon: "applications-graphics-symbolic",
|
||||
},
|
||||
TourStop {
|
||||
title: "Add Your Images",
|
||||
description: "Drag and drop files here, or use the Add button. You can paste from the clipboard too.",
|
||||
icon: "image-x-generic-symbolic",
|
||||
},
|
||||
TourStop {
|
||||
title: "Navigation",
|
||||
description: "Use the Back and Next buttons to move between steps, or press Alt+Left/Right. Disabled steps are automatically skipped.",
|
||||
icon: "go-next-symbolic",
|
||||
},
|
||||
TourStop {
|
||||
title: "Main Menu",
|
||||
description: "Access settings, keyboard shortcuts, processing history, and preset management from here.",
|
||||
icon: "open-menu-symbolic",
|
||||
},
|
||||
TourStop {
|
||||
title: "You're Ready!",
|
||||
description: "That's everything you need to know. Each step also has a help button (?) in the header bar for detailed guidance.",
|
||||
icon: "emblem-ok-symbolic",
|
||||
},
|
||||
];
|
||||
|
||||
/// Show the tutorial overlay if the user hasn't completed it yet.
|
||||
/// Show the tutorial tour if the user hasn't completed it yet.
|
||||
/// Called after the welcome wizard closes on first launch.
|
||||
pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
|
||||
let config_store = pixstrip_core::storage::ConfigStore::new();
|
||||
@@ -53,28 +14,74 @@ pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
|
||||
// Small delay to let the welcome dialog fully dismiss
|
||||
let win = window.clone();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(400), move || {
|
||||
show_tour_dialog(&win, 0);
|
||||
show_tour_stop(&win, 0);
|
||||
});
|
||||
}
|
||||
|
||||
fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
|
||||
let stop = &TOUR_STOPS[stop_index];
|
||||
let total = TOUR_STOPS.len();
|
||||
/// Tour stops: (title, description, widget_name, popover_position)
|
||||
fn tour_stops() -> Vec<(&'static str, &'static str, &'static str, gtk::PositionType)> {
|
||||
vec![
|
||||
(
|
||||
"Choose a Workflow",
|
||||
"Pick a preset that matches your needs, or scroll down to build a custom workflow from scratch.",
|
||||
"tour-content",
|
||||
gtk::PositionType::Bottom,
|
||||
),
|
||||
(
|
||||
"Track Your Progress",
|
||||
"This bar shows where you are in the wizard. Click any completed step to jump back to it.",
|
||||
"tour-step-indicator",
|
||||
gtk::PositionType::Bottom,
|
||||
),
|
||||
(
|
||||
"Navigation",
|
||||
"Use Back and Next to move between steps, or press Alt+Left / Alt+Right. Disabled steps are automatically skipped.",
|
||||
"tour-next-button",
|
||||
gtk::PositionType::Top,
|
||||
),
|
||||
(
|
||||
"Main Menu",
|
||||
"Settings, keyboard shortcuts, processing history, and preset management live here.",
|
||||
"tour-menu-button",
|
||||
gtk::PositionType::Bottom,
|
||||
),
|
||||
(
|
||||
"Get Help",
|
||||
"Every step has a help button with detailed guidance specific to that step.",
|
||||
"tour-help-button",
|
||||
gtk::PositionType::Bottom,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Quick Tour")
|
||||
.content_width(420)
|
||||
.content_height(300)
|
||||
.build();
|
||||
fn show_tour_stop(window: &adw::ApplicationWindow, index: usize) {
|
||||
let stops = tour_stops();
|
||||
let total = stops.len();
|
||||
if index >= total {
|
||||
mark_tutorial_complete();
|
||||
return;
|
||||
}
|
||||
|
||||
let (title, description, widget_name, position) = stops[index];
|
||||
|
||||
// Find the target widget by name in the widget tree
|
||||
let Some(root) = window.content() else { return };
|
||||
let Some(target) = find_widget_by_name(&root, widget_name) else {
|
||||
// Widget not found - skip to next stop
|
||||
show_tour_stop(window, index + 1);
|
||||
return;
|
||||
};
|
||||
|
||||
// Build popover content
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(16)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.spacing(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
content.set_size_request(280, -1);
|
||||
|
||||
// Progress dots
|
||||
let dots_box = gtk::Box::builder()
|
||||
@@ -82,12 +89,11 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
|
||||
.spacing(6)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
for i in 0..total {
|
||||
let dot = gtk::Label::builder()
|
||||
.label(if i == stop_index { "\u{25CF}" } else { "\u{25CB}" })
|
||||
.label(if i == index { "\u{25CF}" } else { "\u{25CB}" })
|
||||
.build();
|
||||
if i == stop_index {
|
||||
if i == index {
|
||||
dot.add_css_class("accent");
|
||||
} else {
|
||||
dot.add_css_class("dim-label");
|
||||
@@ -96,94 +102,132 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
|
||||
}
|
||||
content.append(&dots_box);
|
||||
|
||||
// Icon
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name(stop.icon)
|
||||
.pixel_size(64)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
icon.add_css_class("accent");
|
||||
content.append(&icon);
|
||||
|
||||
// Title
|
||||
let title = gtk::Label::builder()
|
||||
.label(stop.title)
|
||||
.css_classes(["title-2"])
|
||||
.halign(gtk::Align::Center)
|
||||
let title_label = gtk::Label::builder()
|
||||
.label(title)
|
||||
.css_classes(["title-3"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
content.append(&title);
|
||||
|
||||
// Step counter
|
||||
let counter = gtk::Label::builder()
|
||||
.label(&format!("{} of {}", stop_index + 1, total))
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
content.append(&counter);
|
||||
content.append(&title_label);
|
||||
|
||||
// Description
|
||||
let desc = gtk::Label::builder()
|
||||
.label(stop.description)
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(description)
|
||||
.wrap(true)
|
||||
.halign(gtk::Align::Center)
|
||||
.justify(gtk::Justification::Center)
|
||||
.max_width_chars(36)
|
||||
.halign(gtk::Align::Start)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
content.append(&desc);
|
||||
content.append(&desc_label);
|
||||
|
||||
// Step counter
|
||||
let counter_label = gtk::Label::builder()
|
||||
.label(&format!("{} of {}", index + 1, total))
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
content.append(&counter_label);
|
||||
|
||||
// Buttons
|
||||
let button_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(8)
|
||||
.halign(gtk::Align::End)
|
||||
.margin_top(4)
|
||||
.build();
|
||||
|
||||
let skip_button = gtk::Button::builder()
|
||||
let skip_btn = gtk::Button::builder()
|
||||
.label("Skip Tour")
|
||||
.tooltip_text("Close the tour and start using Pixstrip")
|
||||
.build();
|
||||
skip_button.add_css_class("flat");
|
||||
skip_btn.add_css_class("flat");
|
||||
|
||||
let is_last = stop_index + 1 >= total;
|
||||
let next_label = if is_last { "Get Started" } else { "Next" };
|
||||
let next_button = gtk::Button::builder()
|
||||
.label(next_label)
|
||||
let is_last = index + 1 >= total;
|
||||
let next_btn = gtk::Button::builder()
|
||||
.label(if is_last { "Done" } else { "Next" })
|
||||
.tooltip_text(if is_last { "Finish the tour" } else { "Go to the next tour stop" })
|
||||
.build();
|
||||
next_button.add_css_class("suggested-action");
|
||||
next_button.add_css_class("pill");
|
||||
next_btn.add_css_class("suggested-action");
|
||||
next_btn.add_css_class("pill");
|
||||
|
||||
button_box.append(&skip_button);
|
||||
button_box.append(&next_button);
|
||||
button_box.append(&skip_btn);
|
||||
button_box.append(&next_btn);
|
||||
content.append(&button_box);
|
||||
|
||||
dialog.set_child(Some(&content));
|
||||
// Create popover attached to the target widget
|
||||
let popover = gtk::Popover::builder()
|
||||
.child(&content)
|
||||
.position(position)
|
||||
.autohide(false)
|
||||
.has_arrow(true)
|
||||
.build();
|
||||
popover.set_parent(&target);
|
||||
|
||||
// Wire skip - mark tutorial complete and close
|
||||
// For the content area (large widget), point to the upper portion
|
||||
// where the preset cards are visible
|
||||
if widget_name == "tour-content" {
|
||||
let w = target.width();
|
||||
if w > 0 {
|
||||
let rect = gtk::gdk::Rectangle::new(w / 2 - 10, 40, 20, 20);
|
||||
popover.set_pointing_to(Some(&rect));
|
||||
}
|
||||
}
|
||||
|
||||
// Accessible label for screen readers
|
||||
popover.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Tour step {} of {}: {}", index + 1, total, title)
|
||||
),
|
||||
]);
|
||||
|
||||
// Wire skip button
|
||||
{
|
||||
let dlg = dialog.clone();
|
||||
skip_button.connect_clicked(move |_| {
|
||||
let pop = popover.clone();
|
||||
skip_btn.connect_clicked(move |_| {
|
||||
mark_tutorial_complete();
|
||||
dlg.close();
|
||||
pop.popdown();
|
||||
let p = pop.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
p.unparent();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Wire next
|
||||
// Wire next button
|
||||
{
|
||||
let dlg = dialog.clone();
|
||||
let pop = popover.clone();
|
||||
let win = window.clone();
|
||||
next_button.connect_clicked(move |_| {
|
||||
dlg.close();
|
||||
next_btn.connect_clicked(move |_| {
|
||||
pop.popdown();
|
||||
let p = pop.clone();
|
||||
let w = win.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
p.unparent();
|
||||
if is_last {
|
||||
mark_tutorial_complete();
|
||||
} else {
|
||||
let w = win.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
show_tour_dialog(&w, stop_index + 1);
|
||||
});
|
||||
show_tour_stop(&w, index + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(window));
|
||||
popover.popup();
|
||||
}
|
||||
|
||||
/// Recursively search the widget tree for a widget with the given name.
|
||||
fn find_widget_by_name(root: >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() {
|
||||
|
||||