diff --git a/data/icons/hicolor/128x128/apps/live.lashman.Pixstrip.png b/data/icons/hicolor/128x128/apps/live.lashman.Pixstrip.png new file mode 100644 index 0000000..a724dbc Binary files /dev/null and b/data/icons/hicolor/128x128/apps/live.lashman.Pixstrip.png differ diff --git a/data/icons/hicolor/16x16/apps/live.lashman.Pixstrip.png b/data/icons/hicolor/16x16/apps/live.lashman.Pixstrip.png new file mode 100644 index 0000000..48b9483 Binary files /dev/null and b/data/icons/hicolor/16x16/apps/live.lashman.Pixstrip.png differ diff --git a/data/icons/hicolor/256x256/apps/live.lashman.Pixstrip.png b/data/icons/hicolor/256x256/apps/live.lashman.Pixstrip.png new file mode 100644 index 0000000..527862c Binary files /dev/null and b/data/icons/hicolor/256x256/apps/live.lashman.Pixstrip.png differ diff --git a/data/icons/hicolor/32x32/apps/live.lashman.Pixstrip.png b/data/icons/hicolor/32x32/apps/live.lashman.Pixstrip.png new file mode 100644 index 0000000..ea94598 Binary files /dev/null and b/data/icons/hicolor/32x32/apps/live.lashman.Pixstrip.png differ diff --git a/data/icons/hicolor/48x48/apps/live.lashman.Pixstrip.png b/data/icons/hicolor/48x48/apps/live.lashman.Pixstrip.png new file mode 100644 index 0000000..ca163a2 Binary files /dev/null and b/data/icons/hicolor/48x48/apps/live.lashman.Pixstrip.png differ diff --git a/data/icons/hicolor/512x512/apps/live.lashman.Pixstrip.png b/data/icons/hicolor/512x512/apps/live.lashman.Pixstrip.png new file mode 100644 index 0000000..caaa03d Binary files /dev/null and b/data/icons/hicolor/512x512/apps/live.lashman.Pixstrip.png differ diff --git a/data/icons/hicolor/64x64/apps/live.lashman.Pixstrip.png b/data/icons/hicolor/64x64/apps/live.lashman.Pixstrip.png new file mode 100644 index 0000000..5f134a0 Binary files /dev/null and b/data/icons/hicolor/64x64/apps/live.lashman.Pixstrip.png differ diff --git a/data/live.lashman.Pixstrip.desktop b/data/live.lashman.Pixstrip.desktop index bbc19e8..b2d2dcb 100644 --- a/data/live.lashman.Pixstrip.desktop +++ b/data/live.lashman.Pixstrip.desktop @@ -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 diff --git a/data/live.lashman.Pixstrip.metainfo.xml b/data/live.lashman.Pixstrip.metainfo.xml index 799f99d..c9286ab 100644 --- a/data/live.lashman.Pixstrip.metainfo.xml +++ b/data/live.lashman.Pixstrip.metainfo.xml @@ -3,16 +3,18 @@ live.lashman.Pixstrip CC0-1.0 CC0-1.0 + Pixstrip Batch image processor - resize, convert, compress, and more

- 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.

-

Features include:

+

Key features:

- live.lashman.Pixstrip.desktop + live.lashman.Pixstrip - https://git.lashman.live/lashman/pixstrip - https://git.lashman.live/lashman/pixstrip/issues + live.lashman.Pixstrip.desktop lashman + https://git.lashman.live/lashman/pixstrip + https://git.lashman.live/lashman/pixstrip/issues + https://git.lashman.live/lashman/pixstrip + https://ko-fi.com/lashman + https://git.lashman.live/lashman/pixstrip/issues + https://git.lashman.live/lashman/pixstrip + + lashman@robotbrush.com + + + + Workflow selection with built-in presets for common image tasks + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/01.png + + + Image selection with drag-and-drop and batch file management + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/02.png + + + Resize step with width, height, fit-in-box, and social media presets + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/03.png + + + Format conversion between JPEG, PNG, WebP, AVIF, GIF, and TIFF + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/04.png + + + Compression with live before/after preview and file size estimates + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/05.png + + + Metadata stripping with selective EXIF field management + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/06.png + + + Watermark placement with text and image options + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/07.png + + + Batch rename with templates, counters, and EXIF variables + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/08.png + + + Image adjustments for brightness, contrast, and saturation + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/09.png + + + Settings with output preferences and file manager integration + https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/10.png + + + - #99c1f1 - #1a5fb4 + #57a773 + #263226 + + Graphics + ImageProcessing + GTK + + + + Image + Photo + Resize + Convert + Compress + Batch + Metadata + Watermark + Rename + EXIF + WebP + AVIF + + 360 + + keyboard + pointing + + pointing keyboard touch + + pixstrip-gtk + pixstrip + + - + -

Initial release with full wizard workflow, 8 built-in presets, CLI parity, watch folders, and file manager integration.

+

Initial release of Pixstrip with core features:

+
    +
  • Wizard-driven batch processing with 8 built-in presets
  • +
  • Resize, convert, compress, metadata strip, watermark, rename, and adjust
  • +
  • Optimized encoders: mozjpeg, oxipng, libwebp, and ravif
  • +
  • Live compression preview with before/after comparison
  • +
  • Watch folders for automatic processing
  • +
  • Processing history with undo via system trash
  • +
  • Full CLI with feature parity
  • +
  • File manager integration for Nautilus, Nemo, Thunar, and Dolphin
  • +
diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index afdfa35..a07d17f 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -27,6 +27,33 @@ fn load_watches(watches_path: &std::path::Path) -> Vec + pixstrip process --help")] struct Cli { #[command(subcommand)] command: Commands, @@ -36,98 +63,356 @@ struct Cli { #[allow(clippy::large_enum_variant)] enum Commands { /// Process images with a preset or custom operations + #[command(after_long_help = "\ +EXAMPLES: + Resize all JPEGs in a folder to 1200px wide: + pixstrip process photos/ --resize 1200 -o output/ + + Fit images within a 1920x1080 box (no upscaling): + pixstrip process photos/ --resize fit:1920x1080 + + Convert all PNGs to WebP with high quality: + pixstrip process *.png --format webp --quality high + + Per-format conversion (PNGs to WebP, TIFFs to JPEG): + pixstrip process photos/ --format-map \"png=webp,tiff=jpeg\" + + Fine-tune compression per format: + pixstrip process photos/ --jpeg-quality 90 --webp-quality 85 --avif-quality 70 + + Strip location and camera data, keep copyright: + pixstrip process photos/ --strip-gps --strip-camera + + Adjust brightness/contrast and convert to grayscale: + pixstrip process photos/ --brightness 10 --contrast 20 --grayscale + + Add a tiled diagonal watermark in red: + pixstrip process photos/ --watermark \"(c) 2026\" --watermark-tiled \\ + --watermark-rotation 45 --watermark-color ff0000 --watermark-opacity 0.3 + + Add an image watermark (logo) scaled to 15%: + pixstrip process photos/ --watermark-image logo.png --watermark-scale 0.15 + + Rename to lowercase with hyphens, add prefix: + pixstrip process photos/ --rename-prefix \"blog-\" --rename-case lower \\ + --rename-spaces hyphen + + Rename with regex (remove \"IMG_\" prefix): + pixstrip process photos/ --rename-regex-find \"^IMG_\" --rename-regex-replace \"\" + + Sequential numbering (replace filename with counter): + pixstrip process photos/ --rename-counter-position replace-name \\ + --rename-counter-start 1 --rename-counter-padding 4 + + Use a saved preset: + pixstrip process photos/ --preset \"Blog Photos\" + + Override preset options (preset + custom flags): + pixstrip process photos/ --preset \"Blog Photos\" --resize 800 + + Combine multiple operations: + pixstrip process photos/ -r --resize fit:1920x1080 --format webp \\ + --quality high --strip-metadata --rename-case lower \\ + --watermark \"(c) 2026\" --overwrite skip + +TEMPLATE VARIABLES (for --rename-template): + {name} Original filename (without extension) + {ext} File extension + {counter} Sequential number (padding from --rename-counter-padding) + {counter:N} Sequential number with N-digit padding + {width} Image width in pixels + {height} Image height in pixels + {date} File modification date (YYYY-MM-DD) + + Example: --rename-template \"{name}_{width}x{height}.{ext}\"")] Process { /// Input files or directories #[arg(required = true)] input: Vec, - /// Preset name to use + /// Preset name to use (see 'pixstrip preset list' for available presets) #[arg(long)] preset: Option, - /// Output directory + /// Output directory [default: /processed] #[arg(short, long)] output: Option, - /// Resize to width (e.g., "1200" or "1200x900") - #[arg(long)] + // --- Resize --- + + /// Resize images. Formats: "1200" (width), "1200x900" (exact), "fit:1200x900" (fit in box) + #[arg(long, help_heading = "Resize")] resize: Option, - /// Output format (jpeg, png, webp, avif, gif, tiff) - #[arg(long)] - format: Option, - - /// Quality preset (maximum, high, medium, low, web) - #[arg(long)] - quality: Option, - - /// Strip all metadata - #[arg(long)] - strip_metadata: bool, - - /// Rotate images (90, 180, 270, auto) - #[arg(long)] - rotate: Option, - - /// Flip images (horizontal, vertical) - #[arg(long)] - flip: Option, - - /// Add text watermark (e.g., "(c) 2026 My Name") - #[arg(long)] - watermark: Option, - - /// Watermark position (top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right) - #[arg(long, default_value = "bottom-right")] - watermark_position: String, - - /// Watermark opacity (0.0-1.0) - #[arg(long, default_value = "0.5", value_parser = parse_opacity)] - watermark_opacity: f32, - - /// Rename with prefix - #[arg(long)] - rename_prefix: Option, - - /// Rename with suffix - #[arg(long)] - rename_suffix: Option, - - /// Rename template (e.g., "{name}_{counter:3}.{ext}") - #[arg(long)] - rename_template: Option, - - /// Resize algorithm (lanczos3, catmullrom, bilinear, nearest) - #[arg(long, default_value = "lanczos3")] + /// Resize algorithm + #[arg(long, default_value = "lanczos3", help_heading = "Resize", + value_parser = ["lanczos3", "catmullrom", "bilinear", "nearest"])] algorithm: String, - /// Overwrite behavior (auto-rename, overwrite, skip) - #[arg(long, default_value = "auto-rename")] + /// Allow upscaling when using fit-in-box resize mode + #[arg(long, help_heading = "Resize")] + allow_upscale: bool, + + // --- Convert --- + + /// Convert all images to one format + #[arg(long, help_heading = "Convert", + value_parser = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "bmp"])] + format: Option, + + /// Per-format conversion mapping (e.g., "png=webp,tiff=jpeg"). Cannot combine with --format + #[arg(long, help_heading = "Convert")] + format_map: Option, + + // --- Compress --- + + /// Quality preset + #[arg(long, help_heading = "Compress", + value_parser = ["maximum", "max", "high", "medium", "med", "low", "web"])] + quality: Option, + + /// JPEG quality (1-100). Overrides --quality for JPEG output + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")] + jpeg_quality: Option, + + /// PNG compression level (1-12, lower = larger but faster). Overrides --quality for PNG + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=12), help_heading = "Compress")] + png_level: Option, + + /// WebP quality (1-100). Overrides --quality for WebP output + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")] + webp_quality: Option, + + /// AVIF quality (1-100). Overrides --quality for AVIF output + #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")] + avif_quality: Option, + + /// AVIF encoding speed (0=slowest/best, 10=fastest/worst) + #[arg(long, value_parser = clap::value_parser!(u8).range(0..=10), help_heading = "Compress")] + avif_speed: Option, + + /// Enable progressive JPEG encoding (better for web, slightly larger files) + #[arg(long, help_heading = "Compress")] + progressive_jpeg: bool, + + // --- Metadata --- + + /// Metadata handling mode. "privacy" strips GPS, camera, and software info + #[arg(long, help_heading = "Metadata", + value_parser = ["strip-all", "keep-all", "privacy"])] + metadata: Option, + + /// Strip GPS/location data + #[arg(long, help_heading = "Metadata")] + strip_gps: bool, + + /// Strip camera make/model/settings info + #[arg(long, help_heading = "Metadata")] + strip_camera: bool, + + /// Strip software/editor tags + #[arg(long, help_heading = "Metadata")] + strip_software: bool, + + /// Strip date/time timestamps + #[arg(long, help_heading = "Metadata")] + strip_timestamps: bool, + + /// Strip copyright and author info + #[arg(long, help_heading = "Metadata")] + strip_copyright: bool, + + /// Strip all metadata (shorthand for --metadata strip-all) + #[arg(long, help_heading = "Metadata")] + strip_metadata: bool, + + // --- Rotation / Flip --- + + /// Rotate images. "auto" reads EXIF orientation and corrects it + #[arg(long, help_heading = "Transform", + value_parser = ["90", "180", "270", "auto", "none"])] + rotate: Option, + + /// Flip/mirror images + #[arg(long, help_heading = "Transform", + value_parser = ["horizontal", "vertical", "h", "v", "none"])] + flip: Option, + + // --- Adjustments --- + + /// Brightness adjustment (-100 to 100, 0 = no change) + #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")] + brightness: Option, + + /// Contrast adjustment (-100 to 100, 0 = no change) + #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")] + contrast: Option, + + /// Saturation adjustment (-100 to 100, 0 = no change) + #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")] + saturation: Option, + + /// Apply sharpening filter + #[arg(long, help_heading = "Adjustments")] + sharpen: bool, + + /// Convert to grayscale (black and white) + #[arg(long, help_heading = "Adjustments")] + grayscale: bool, + + /// Apply sepia tone (vintage look) + #[arg(long, help_heading = "Adjustments")] + sepia: bool, + + /// Crop to aspect ratio (e.g., "16:9", "4:3", "1:1", "3:2") + #[arg(long, help_heading = "Adjustments")] + crop_aspect_ratio: Option, + + /// Auto-trim whitespace/solid-color borders + #[arg(long, help_heading = "Adjustments")] + trim_whitespace: bool, + + /// Add uniform canvas padding in pixels (applied after all other adjustments) + #[arg(long, help_heading = "Adjustments")] + canvas_padding: Option, + + // --- Watermark --- + + /// Add text watermark. Cannot combine with --watermark-image + #[arg(long, help_heading = "Watermark")] + watermark: Option, + + /// Add image watermark from file. Cannot combine with --watermark + #[arg(long, help_heading = "Watermark")] + watermark_image: Option, + + /// Watermark position on the image + #[arg(long, default_value = "bottom-right", help_heading = "Watermark", + value_parser = ["top-left", "top-center", "top-right", + "center", "bottom-left", "bottom-center", "bottom-right", + "tl", "tc", "tr", "c", "bl", "bc", "br"])] + watermark_position: String, + + /// Watermark opacity (0.0 = invisible, 1.0 = fully opaque) + #[arg(long, default_value = "0.5", value_parser = parse_opacity, help_heading = "Watermark")] + watermark_opacity: f32, + + /// Font size for text watermarks in points + #[arg(long, default_value = "24", help_heading = "Watermark")] + watermark_font_size: f32, + + /// Font family name for text watermarks (searches system fonts) + #[arg(long, help_heading = "Watermark")] + watermark_font: Option, + + /// Text color as hex RGB or RGBA (e.g., "ffffff", "ff0000", "00000080") + #[arg(long, default_value = "ffffff", help_heading = "Watermark")] + watermark_color: String, + + /// Rotate watermark in degrees (45, -45, 90, or any value) + #[arg(long, help_heading = "Watermark")] + watermark_rotation: Option, + + /// Tile (repeat) the watermark across the entire image + #[arg(long, help_heading = "Watermark")] + watermark_tiled: bool, + + /// Watermark distance from image edges in pixels + #[arg(long, default_value = "10", help_heading = "Watermark")] + watermark_margin: u32, + + /// Scale for image watermarks (0.0-1.0, fraction of image size) + #[arg(long, default_value = "0.25", value_parser = parse_opacity, help_heading = "Watermark")] + watermark_scale: f32, + + // --- Rename --- + + /// Add prefix before filename (e.g., "blog-" turns "photo.jpg" into "blog-photo.jpg") + #[arg(long, help_heading = "Rename")] + rename_prefix: Option, + + /// Add suffix after filename (e.g., "-web" turns "photo.jpg" into "photo-web.jpg") + #[arg(long, help_heading = "Rename")] + rename_suffix: Option, + + /// Rename using a template. Overrides --rename-prefix/--rename-suffix. See TEMPLATE VARIABLES below + #[arg(long, help_heading = "Rename")] + rename_template: Option, + + /// Convert filename case + #[arg(long, help_heading = "Rename", + value_parser = ["lower", "upper", "title", "none"])] + rename_case: Option, + + /// Replace spaces in filenames + #[arg(long, help_heading = "Rename", + value_parser = ["underscore", "hyphen", "dot", "camelcase", "remove"])] + rename_spaces: Option, + + /// Filter special characters in filenames + #[arg(long, help_heading = "Rename", + value_parser = ["filesystem-safe", "web-safe", "hyphens-underscores", "hyphens", "alphanumeric", "none"])] + rename_special_chars: Option, + + /// Regex pattern to find in filenames (paired with --rename-regex-replace) + #[arg(long, help_heading = "Rename")] + rename_regex_find: Option, + + /// Replacement for regex matches (supports $1, $2 capture groups) + #[arg(long, help_heading = "Rename")] + rename_regex_replace: Option, + + /// Where to insert the counter in the filename + #[arg(long, default_value = "after-suffix", help_heading = "Rename", + value_parser = ["before-prefix", "before-name", "after-name", "after-suffix", "replace-name"])] + rename_counter_position: String, + + /// Counter starting number + #[arg(long, default_value = "1", help_heading = "Rename")] + rename_counter_start: u32, + + /// Counter zero-padding width (3 = "001", 4 = "0001") + #[arg(long, default_value = "3", help_heading = "Rename")] + rename_counter_padding: u32, + + // --- Output --- + + /// What to do when output file already exists + #[arg(long, default_value = "auto-rename", help_heading = "Output", + value_parser = ["auto-rename", "overwrite", "skip"])] overwrite: String, - /// Include subdirectories - #[arg(short, long)] + /// Preserve subdirectory structure from input in the output folder + #[arg(long, help_heading = "Output")] + preserve_dirs: bool, + + /// Set output DPI/resolution metadata (e.g., 72, 150, 300) + #[arg(long, help_heading = "Output")] + output_dpi: Option, + + /// Scan input directories recursively for images + #[arg(short, long, help_heading = "Output")] recursive: bool, }, - /// Manage presets + /// Manage presets (list, create, delete, import, export) Preset { #[command(subcommand)] action: PresetAction, }, - /// Manage watch folders + /// Manage watch folders for automatic processing of new images Watch { #[command(subcommand)] action: WatchAction, }, - /// View processing history + /// View processing history (recent batches, file counts, sizes, and times) History, - /// Undo last batch operation (moves output files to trash) + /// Undo recent batch operations by moving output files to system trash Undo { - /// Undo the last N batches (default 1) + /// Number of recent batches to undo #[arg(long, default_value = "1")] last: usize, }, @@ -135,40 +420,58 @@ enum Commands { #[derive(Subcommand)] enum WatchAction { - /// Add a watch folder with a linked preset + /// Add a folder to watch for new images, with a preset to apply automatically Add { - /// Folder path to watch + /// Folder path to watch for new images path: String, - /// Preset name to apply + /// Preset to apply to new images (see 'pixstrip preset list') #[arg(long)] preset: String, - /// Watch subdirectories recursively + /// Also watch subdirectories #[arg(short, long)] recursive: bool, }, - /// List configured watch folders + /// List all configured watch folders and their linked presets List, - /// Remove a watch folder + /// Remove a folder from the watch list Remove { - /// Folder path to remove + /// Folder path to stop watching path: String, }, - /// Start watching configured folders (blocks until Ctrl+C) + /// Start watching all active folders. Blocks until Ctrl+C. Processed files go to /processed/ Start, } #[derive(Subcommand)] enum PresetAction { - /// List all presets + /// List all available presets (built-in and user-created) List, - /// Export a preset to a file - Export { + /// Create a new empty user preset. Edit it by exporting, modifying the JSON, and re-importing + Create { + /// Name for the new preset name: String, + /// Optional description + #[arg(long, default_value = "")] + description: String, + }, + /// Delete a user preset. Built-in presets cannot be deleted + Delete { + /// Name of the preset to delete + name: String, + }, + /// Export a preset to a JSON file for backup or sharing + Export { + /// Name of the preset to export + name: String, + /// Output file path (e.g., "my-preset.json") #[arg(short, long)] output: String, }, - /// Import a preset from a file - Import { path: String }, + /// Import a preset from a JSON file + Import { + /// Path to the preset JSON file + path: String, + }, } fn main() { @@ -180,19 +483,60 @@ fn main() { preset, output, resize, + algorithm, + allow_upscale, format, + format_map, quality, + jpeg_quality, + png_level, + webp_quality, + avif_quality, + avif_speed, + progressive_jpeg, + metadata, + strip_gps, + strip_camera, + strip_software, + strip_timestamps, + strip_copyright, strip_metadata, rotate, flip, + brightness, + contrast, + saturation, + sharpen, + grayscale, + sepia, + crop_aspect_ratio, + trim_whitespace, + canvas_padding, watermark, + watermark_image, watermark_position, watermark_opacity, + watermark_font_size, + watermark_font, + watermark_color, + watermark_rotation, + watermark_tiled, + watermark_margin, + watermark_scale, rename_prefix, rename_suffix, rename_template, - algorithm, + rename_case, + rename_spaces, + rename_special_chars, + rename_regex_find, + rename_regex_replace, + rename_counter_position, + rename_counter_start, + rename_counter_padding, overwrite, + preserve_dirs, + output_dpi, recursive, } => { cmd_process(CmdProcessArgs { @@ -200,24 +544,67 @@ fn main() { preset, output, resize, + algorithm, + allow_upscale, format, + format_map, quality, + jpeg_quality, + png_level, + webp_quality, + avif_quality, + avif_speed, + progressive_jpeg, + metadata, + strip_gps, + strip_camera, + strip_software, + strip_timestamps, + strip_copyright, strip_metadata, rotate, flip, + brightness, + contrast, + saturation, + sharpen, + grayscale, + sepia, + crop_aspect_ratio, + trim_whitespace, + canvas_padding, watermark, + watermark_image, watermark_position, watermark_opacity, + watermark_font_size, + watermark_font, + watermark_color, + watermark_rotation, + watermark_tiled, + watermark_margin, + watermark_scale, rename_prefix, rename_suffix, rename_template, - algorithm, + rename_case, + rename_spaces, + rename_special_chars, + rename_regex_find, + rename_regex_replace, + rename_counter_position, + rename_counter_start, + rename_counter_padding, overwrite, + preserve_dirs, + output_dpi, recursive, }); } Commands::Preset { action } => match action { PresetAction::List => cmd_preset_list(), + PresetAction::Create { name, description } => cmd_preset_create(&name, &description), + PresetAction::Delete { name } => cmd_preset_delete(&name), PresetAction::Export { name, output } => cmd_preset_export(&name, &output), PresetAction::Import { path } => cmd_preset_import(&path), }, @@ -240,20 +627,70 @@ struct CmdProcessArgs { input: Vec, preset: Option, output: Option, + // Resize resize: Option, + algorithm: String, + allow_upscale: bool, + // Convert format: Option, + format_map: Option, + // Compress quality: Option, + jpeg_quality: Option, + png_level: Option, + webp_quality: Option, + avif_quality: Option, + avif_speed: Option, + progressive_jpeg: bool, + // Metadata + metadata: Option, + strip_gps: bool, + strip_camera: bool, + strip_software: bool, + strip_timestamps: bool, + strip_copyright: bool, strip_metadata: bool, + // Rotation / Flip rotate: Option, flip: Option, + // Adjustments + brightness: Option, + contrast: Option, + saturation: Option, + sharpen: bool, + grayscale: bool, + sepia: bool, + crop_aspect_ratio: Option, + trim_whitespace: bool, + canvas_padding: Option, + // Watermark watermark: Option, + watermark_image: Option, watermark_position: String, watermark_opacity: f32, + watermark_font_size: f32, + watermark_font: Option, + watermark_color: String, + watermark_rotation: Option, + watermark_tiled: bool, + watermark_margin: u32, + watermark_scale: f32, + // Rename rename_prefix: Option, rename_suffix: Option, rename_template: Option, - algorithm: String, + rename_case: Option, + rename_spaces: Option, + rename_special_chars: Option, + rename_regex_find: Option, + rename_regex_replace: Option, + rename_counter_position: String, + rename_counter_start: u32, + rename_counter_padding: u32, + // Output overwrite: String, + preserve_dirs: bool, + output_dpi: Option, recursive: bool, } @@ -309,66 +746,10 @@ fn cmd_process(args: CmdProcessArgs) { }; // Override with CLI flags + + // --- Resize --- if let Some(ref resize_str) = args.resize { - job.resize = Some(parse_resize(resize_str)); - } - if let Some(ref fmt_str) = args.format { - let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1)); - job.convert = Some(ConvertConfig::SingleFormat(fmt)); - } - if let Some(ref q_str) = args.quality { - let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1)); - job.compress = Some(CompressConfig::Preset(preset)); - } - if args.strip_metadata { - job.metadata = Some(MetadataConfig::StripAll); - } - if let Some(ref rot) = args.rotate { - job.rotation = Some(parse_rotation(rot)); - } - if let Some(ref fl) = args.flip { - job.flip = Some(parse_flip(fl)); - } - if let Some(ref text) = args.watermark { - let position = parse_watermark_position(&args.watermark_position); - job.watermark = Some(WatermarkConfig::Text { - text: text.clone(), - position, - font_size: 24.0, - opacity: args.watermark_opacity, - color: [255, 255, 255, 255], - font_family: None, - rotation: None, - tiled: false, - margin: 10, - }); - } - if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() { - if let Some(ref tmpl) = args.rename_template { - if args.rename_prefix.is_some() || args.rename_suffix.is_some() { - eprintln!("Warning: --rename-template overrides --rename-prefix/--rename-suffix"); - } - if !tmpl.contains('{') { - eprintln!("Warning: rename template '{}' has no placeholders. Use {{name}}, {{counter}}, {{ext}}, etc.", tmpl); - } - if !tmpl.contains("{ext}") && !tmpl.contains('.') { - eprintln!("Warning: rename template has no {{ext}} or file extension - output files may lack extensions"); - } - } - job.rename = Some(RenameConfig { - prefix: args.rename_prefix.unwrap_or_default(), - suffix: args.rename_suffix.unwrap_or_default(), - counter_start: 1, - counter_padding: 3, - counter_enabled: args.rename_template.is_some(), - counter_position: 3, - template: args.rename_template, - case_mode: 0, - replace_spaces: 0, - special_chars: 0, - regex_find: String::new(), - regex_replace: String::new(), - }); + job.resize = Some(parse_resize(resize_str, args.allow_upscale)); } job.resize_algorithm = match args.algorithm.to_lowercase().as_str() { @@ -382,6 +763,169 @@ fn cmd_process(args: CmdProcessArgs) { } }; + // --- Convert --- + if args.format.is_some() && args.format_map.is_some() { + eprintln!("Error: cannot use both --format and --format-map at the same time"); + std::process::exit(1); + } + if let Some(ref map_str) = args.format_map { + let mappings = parse_format_map(map_str); + if !mappings.is_empty() { + job.convert = Some(ConvertConfig::FormatMapping(mappings)); + } + } else if let Some(ref fmt_str) = args.format { + let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1)); + job.convert = Some(ConvertConfig::SingleFormat(fmt)); + } + + // --- Compress --- + if args.jpeg_quality.is_some() || args.png_level.is_some() || args.webp_quality.is_some() || args.avif_quality.is_some() { + // Per-format custom quality overrides the preset + job.compress = Some(CompressConfig::Custom { + jpeg_quality: args.jpeg_quality, + png_level: args.png_level, + webp_quality: args.webp_quality.map(|q| q as f32), + avif_quality: args.avif_quality.map(|q| q as f32), + }); + } else if let Some(ref q_str) = args.quality { + let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1)); + job.compress = Some(CompressConfig::Preset(preset)); + } + + if let Some(speed) = args.avif_speed { + job.avif_speed = speed; + } + if args.progressive_jpeg { + job.progressive_jpeg = true; + } + + // --- Metadata --- + if args.strip_metadata { + job.metadata = Some(MetadataConfig::StripAll); + } else if args.strip_gps || args.strip_camera || args.strip_software || args.strip_timestamps || args.strip_copyright { + job.metadata = Some(MetadataConfig::Custom { + strip_gps: args.strip_gps, + strip_camera: args.strip_camera, + strip_software: args.strip_software, + strip_timestamps: args.strip_timestamps, + strip_copyright: args.strip_copyright, + }); + } else if let Some(ref mode) = args.metadata { + job.metadata = Some(parse_metadata_mode(mode)); + } + + // --- Rotation / Flip --- + if let Some(ref rot) = args.rotate { + job.rotation = Some(parse_rotation(rot)); + } + if let Some(ref fl) = args.flip { + job.flip = Some(parse_flip(fl)); + } + + // --- Adjustments --- + let has_adjustments = args.brightness.is_some() + || args.contrast.is_some() + || args.saturation.is_some() + || args.sharpen + || args.grayscale + || args.sepia + || args.crop_aspect_ratio.is_some() + || args.trim_whitespace + || args.canvas_padding.is_some(); + if has_adjustments { + job.adjustments = Some(AdjustmentsConfig { + brightness: args.brightness.unwrap_or(0).clamp(-100, 100), + contrast: args.contrast.unwrap_or(0).clamp(-100, 100), + saturation: args.saturation.unwrap_or(0).clamp(-100, 100), + sharpen: args.sharpen, + grayscale: args.grayscale, + sepia: args.sepia, + crop_aspect_ratio: args.crop_aspect_ratio.as_deref().and_then(parse_aspect_ratio), + trim_whitespace: args.trim_whitespace, + canvas_padding: args.canvas_padding.unwrap_or(0), + }); + } + + // --- Watermark --- + if args.watermark.is_some() && args.watermark_image.is_some() { + eprintln!("Error: cannot use both --watermark (text) and --watermark-image at the same time"); + std::process::exit(1); + } + let wm_position = parse_watermark_position(&args.watermark_position); + let wm_rotation = args.watermark_rotation.as_deref().map(parse_watermark_rotation); + if let Some(ref text) = args.watermark { + let color = parse_hex_color(&args.watermark_color); + job.watermark = Some(WatermarkConfig::Text { + text: text.clone(), + position: wm_position, + font_size: args.watermark_font_size, + opacity: args.watermark_opacity, + color, + font_family: args.watermark_font.clone(), + rotation: wm_rotation, + tiled: args.watermark_tiled, + margin: args.watermark_margin, + }); + } else if let Some(ref img_path) = args.watermark_image { + let path = PathBuf::from(img_path); + if !path.exists() { + eprintln!("Watermark image not found: {}", img_path); + std::process::exit(1); + } + job.watermark = Some(WatermarkConfig::Image { + path, + position: wm_position, + opacity: args.watermark_opacity, + scale: args.watermark_scale, + rotation: wm_rotation, + tiled: args.watermark_tiled, + margin: args.watermark_margin, + }); + } + + // --- Rename --- + let has_rename = args.rename_prefix.is_some() + || args.rename_suffix.is_some() + || args.rename_template.is_some() + || args.rename_case.is_some() + || args.rename_spaces.is_some() + || args.rename_special_chars.is_some() + || args.rename_regex_find.is_some(); + if has_rename { + if let Some(ref tmpl) = args.rename_template { + if args.rename_prefix.is_some() || args.rename_suffix.is_some() { + eprintln!("Warning: --rename-template overrides --rename-prefix/--rename-suffix"); + } + if !tmpl.contains('{') { + eprintln!("Warning: rename template '{}' has no placeholders. Use {{name}}, {{counter}}, {{ext}}, etc.", tmpl); + } + if !tmpl.contains("{ext}") && !tmpl.contains('.') { + eprintln!("Warning: rename template has no {{ext}} or file extension - output files may lack extensions"); + } + } + let case_mode = args.rename_case.as_deref().map(parse_case_mode).unwrap_or(0); + let replace_spaces = args.rename_spaces.as_deref().map(parse_space_mode).unwrap_or(0); + let special_chars = args.rename_special_chars.as_deref().map(parse_special_chars_mode).unwrap_or(0); + let counter_position = parse_counter_position(&args.rename_counter_position); + let counter_enabled = args.rename_template.is_some() || counter_position != 3; + + job.rename = Some(RenameConfig { + prefix: args.rename_prefix.unwrap_or_default(), + suffix: args.rename_suffix.unwrap_or_default(), + counter_start: args.rename_counter_start, + counter_padding: args.rename_counter_padding, + counter_enabled, + counter_position, + template: args.rename_template, + case_mode, + replace_spaces, + special_chars, + regex_find: args.rename_regex_find.unwrap_or_default(), + regex_replace: args.rename_regex_replace.unwrap_or_default(), + }); + } + + // --- Output --- job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() { "overwrite" | "always" => OverwriteAction::Overwrite, "skip" => OverwriteAction::Skip, @@ -392,6 +936,13 @@ fn cmd_process(args: CmdProcessArgs) { } }; + if args.preserve_dirs { + job.preserve_directory_structure = true; + } + if let Some(dpi) = args.output_dpi { + job.output_dpi = dpi; + } + for file in &source_files { job.add_source(file); } @@ -488,6 +1039,46 @@ fn cmd_preset_list() { } } +fn cmd_preset_create(name: &str, description: &str) { + let store = PresetStore::new(); + // Check if name conflicts with a builtin + let lower = name.to_lowercase(); + if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) { + eprintln!("Cannot create preset '{}': conflicts with a built-in preset name", name); + std::process::exit(1); + } + let preset = Preset { + name: name.to_string(), + description: description.to_string(), + is_custom: true, + ..Preset::default() + }; + match store.save(&preset) { + Ok(()) => println!("Created preset '{}'", name), + Err(e) => { + eprintln!("Failed to create preset: {}", e); + std::process::exit(1); + } + } +} + +fn cmd_preset_delete(name: &str) { + let store = PresetStore::new(); + // Don't allow deleting builtins + let lower = name.to_lowercase(); + if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) { + eprintln!("Cannot delete built-in preset '{}'", name); + std::process::exit(1); + } + match store.delete(name) { + Ok(()) => println!("Deleted preset '{}'", name), + Err(e) => { + eprintln!("Failed to delete preset '{}': {}", name, e); + std::process::exit(1); + } + } +} + fn cmd_preset_export(name: &str, output: &str) { let preset = find_preset(name).unwrap_or_else(|| { eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name); @@ -912,7 +1503,31 @@ fn find_preset(name: &str) -> Option { None } -fn parse_resize(s: &str) -> ResizeConfig { +fn parse_resize(s: &str, allow_upscale: bool) -> ResizeConfig { + // Support "fit:WxH" for fit-in-box mode + if let Some(dims) = s.strip_prefix("fit:") { + let (w, h) = dims.split_once('x').unwrap_or_else(|| { + eprintln!("Invalid fit-in-box syntax: '{}'. Use 'fit:1200x900'", s); + std::process::exit(1); + }); + let width: u32 = w.parse().unwrap_or_else(|_| { + eprintln!("Invalid fit width: '{}'", w); + std::process::exit(1); + }); + let height: u32 = h.parse().unwrap_or_else(|_| { + eprintln!("Invalid fit height: '{}'", h); + std::process::exit(1); + }); + if width == 0 || height == 0 { + eprintln!("Fit dimensions must be greater than zero"); + std::process::exit(1); + } + return ResizeConfig::FitInBox { + max: Dimensions { width, height }, + allow_upscale, + }; + } + if let Some((w, h)) = s.split_once('x') { let width: u32 = w.parse().unwrap_or_else(|_| { eprintln!("Invalid resize width: '{}'", w); @@ -929,7 +1544,7 @@ fn parse_resize(s: &str) -> ResizeConfig { ResizeConfig::Exact(Dimensions { width, height }) } else { let width: u32 = s.parse().unwrap_or_else(|_| { - eprintln!("Invalid resize value: '{}'. Use a width like '1200' or dimensions like '1200x900'", s); + eprintln!("Invalid resize value: '{}'. Use a width like '1200', dimensions like '1200x900', or 'fit:1200x900'", s); std::process::exit(1); }); if width == 0 { @@ -1022,6 +1637,152 @@ fn parse_opacity(s: &str) -> std::result::Result { Ok(v) } +fn parse_format_map(s: &str) -> Vec<(ImageFormat, ImageFormat)> { + let mut mappings = Vec::new(); + for pair in s.split(',') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + let (from, to) = pair.split_once('=').unwrap_or_else(|| { + eprintln!("Invalid format mapping: '{}'. Use 'from=to' (e.g., 'png=webp')", pair); + std::process::exit(1); + }); + let from_fmt = parse_format(from.trim()).unwrap_or_else(|| std::process::exit(1)); + let to_fmt = parse_format(to.trim()).unwrap_or_else(|| std::process::exit(1)); + if from_fmt == to_fmt { + eprintln!("Warning: format mapping '{}={}' maps to itself, skipping", from.trim(), to.trim()); + continue; + } + mappings.push((from_fmt, to_fmt)); + } + mappings +} + +fn parse_metadata_mode(s: &str) -> MetadataConfig { + match s.to_lowercase().replace(' ', "-").as_str() { + "strip-all" | "strip" | "none" => MetadataConfig::StripAll, + "keep-all" | "keep" | "all" => MetadataConfig::KeepAll, + "privacy" => MetadataConfig::Privacy, + other => { + eprintln!("Unknown metadata mode: '{}'. Supported: strip-all, keep-all, privacy", other); + eprintln!("For selective stripping, use --strip-gps, --strip-camera, etc."); + std::process::exit(1); + } + } +} + +fn parse_hex_color(s: &str) -> [u8; 4] { + let hex = s.strip_prefix('#').unwrap_or(s); + if hex.len() == 6 || hex.len() == 8 { + if let Ok(val) = u32::from_str_radix(&hex[..6], 16) { + let r = ((val >> 16) & 0xFF) as u8; + let g = ((val >> 8) & 0xFF) as u8; + let b = (val & 0xFF) as u8; + let a = if hex.len() == 8 { + u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) + } else { + 255 + }; + return [r, g, b, a]; + } + } + eprintln!("Warning: invalid hex color '{}', using white. Format: rrggbb or rrggbbaa", s); + [255, 255, 255, 255] +} + +fn parse_watermark_rotation(s: &str) -> WatermarkRotation { + match s { + "45" => WatermarkRotation::Degrees45, + "-45" => WatermarkRotation::DegreesNeg45, + "90" => WatermarkRotation::Degrees90, + other => { + let deg: f32 = other.parse().unwrap_or_else(|_| { + eprintln!("Invalid watermark rotation: '{}'. Use 45, -45, 90, or a custom degree value", other); + std::process::exit(1); + }); + WatermarkRotation::Custom(deg) + } + } +} + +fn parse_aspect_ratio(s: &str) -> Option<(f64, f64)> { + if let Some((w, h)) = s.split_once(':') { + let w: f64 = w.parse().unwrap_or_else(|_| { + eprintln!("Invalid aspect ratio width: '{}'", w); + std::process::exit(1); + }); + let h: f64 = h.parse().unwrap_or_else(|_| { + eprintln!("Invalid aspect ratio height: '{}'", h); + std::process::exit(1); + }); + if w <= 0.0 || h <= 0.0 { + eprintln!("Aspect ratio values must be positive"); + std::process::exit(1); + } + Some((w, h)) + } else { + eprintln!("Invalid aspect ratio: '{}'. Use format like '16:9' or '4:3'", s); + std::process::exit(1); + } +} + +fn parse_case_mode(s: &str) -> u32 { + match s.to_lowercase().as_str() { + "lower" | "lowercase" => 1, + "upper" | "uppercase" => 2, + "title" | "titlecase" => 3, + "none" => 0, + other => { + eprintln!("Unknown case mode: '{}'. Supported: lower, upper, title, none", other); + std::process::exit(1); + } + } +} + +fn parse_space_mode(s: &str) -> u32 { + match s.to_lowercase().as_str() { + "underscore" | "underscores" => 1, + "hyphen" | "hyphens" | "dash" => 2, + "dot" | "dots" | "period" => 3, + "camelcase" | "camel" => 4, + "remove" | "none" => 5, + other => { + eprintln!("Unknown space mode: '{}'. Supported: underscore, hyphen, dot, camelcase, remove", other); + std::process::exit(1); + } + } +} + +fn parse_special_chars_mode(s: &str) -> u32 { + match s.to_lowercase().replace(' ', "-").as_str() { + "filesystem-safe" | "filesystem" | "safe" => 1, + "web-safe" | "web" => 2, + "hyphens-underscores" | "hyphens-and-underscores" => 3, + "hyphens" | "hyphens-only" => 4, + "alphanumeric" | "alnum" => 5, + "keep" | "none" => 0, + other => { + eprintln!("Unknown special chars mode: '{}'. Supported: filesystem-safe, web-safe, hyphens-underscores, hyphens, alphanumeric, none", other); + std::process::exit(1); + } + } +} + +fn parse_counter_position(s: &str) -> u32 { + match s.to_lowercase().replace(' ', "-").as_str() { + "before-prefix" => 0, + "before-name" => 1, + "after-name" => 2, + "after-suffix" => 3, + "replace-name" | "replace" => 4, + other => { + eprintln!("Unknown counter position: '{}'. Supported: before-prefix, before-name, after-name, after-suffix, replace-name", other); + std::process::exit(1); + } + } +} + fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes) @@ -1165,13 +1926,58 @@ mod tests { #[test] fn parse_resize_width_only() { - let config = parse_resize("1200"); + let config = parse_resize("1200", false); assert!(matches!(config, ResizeConfig::ByWidth(1200))); } #[test] fn parse_resize_exact() { - let config = parse_resize("1200x900"); + let config = parse_resize("1200x900", false); assert!(matches!(config, ResizeConfig::Exact(Dimensions { width: 1200, height: 900 }))); } + + #[test] + fn parse_resize_fit_in_box() { + let config = parse_resize("fit:1200x900", false); + assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 1200, height: 900 }, allow_upscale: false })); + let config = parse_resize("fit:800x600", true); + assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 800, height: 600 }, allow_upscale: true })); + } + + #[test] + fn parse_hex_color_valid() { + assert_eq!(parse_hex_color("ff0000"), [255, 0, 0, 255]); + assert_eq!(parse_hex_color("#00ff00"), [0, 255, 0, 255]); + assert_eq!(parse_hex_color("0000ff80"), [0, 0, 255, 128]); + } + + #[test] + fn parse_case_mode_valid() { + assert_eq!(parse_case_mode("lower"), 1); + assert_eq!(parse_case_mode("upper"), 2); + assert_eq!(parse_case_mode("title"), 3); + assert_eq!(parse_case_mode("none"), 0); + } + + #[test] + fn parse_space_mode_valid() { + assert_eq!(parse_space_mode("underscore"), 1); + assert_eq!(parse_space_mode("hyphen"), 2); + assert_eq!(parse_space_mode("dot"), 3); + assert_eq!(parse_space_mode("camelcase"), 4); + assert_eq!(parse_space_mode("remove"), 5); + } + + #[test] + fn parse_metadata_mode_valid() { + assert!(matches!(parse_metadata_mode("strip-all"), MetadataConfig::StripAll)); + assert!(matches!(parse_metadata_mode("keep-all"), MetadataConfig::KeepAll)); + assert!(matches!(parse_metadata_mode("privacy"), MetadataConfig::Privacy)); + } + + #[test] + fn parse_aspect_ratio_valid() { + assert_eq!(parse_aspect_ratio("16:9"), Some((16.0, 9.0))); + assert_eq!(parse_aspect_ratio("1:1"), Some((1.0, 1.0))); + } } diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index 0ff0447..4cae0f9 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -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, pub rotation: Option, @@ -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 { diff --git a/pixstrip-core/src/storage.rs b/pixstrip-core/src/storage.rs index 49cf3ee..c6290c1 100644 --- a/pixstrip-core/src/storage.rs +++ b/pixstrip-core/src/storage.rs @@ -189,6 +189,7 @@ pub struct SessionState { pub resize_enabled: Option, pub resize_width: Option, pub resize_height: Option, + pub adjustments_enabled: Option, pub convert_enabled: Option, pub convert_format: Option, pub compress_enabled: Option, diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index c1ba3cc..1d85c4b 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -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, 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"), + ("Right", "Next step"), + ("Left", "Previous step"), + ("1", "Jump to step (1-9)"), + ("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"), + ("o", "Add files"), + ("v", "Paste image from clipboard"), + ("a", "Select all images"), + ("a", "Deselect all images"), ("Delete", "Remove selected images"), ]), ("Application", &[ - ("Ctrl + ,", "Settings"), + ("comma", "Settings"), ("F1", "Keyboard shortcuts"), - ("Ctrl + Z", "Undo last batch"), - ("Ctrl + Q", "Quit"), + ("z", "Undo last batch"), + ("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; } diff --git a/pixstrip-gtk/src/processing.rs b/pixstrip-gtk/src/processing.rs index ade748d..6929481 100644 --- a/pixstrip-gtk/src/processing.rs +++ b/pixstrip-gtk/src/processing.rs @@ -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); diff --git a/pixstrip-gtk/src/settings.rs b/pixstrip-gtk/src/settings.rs index 89edd9e..beb57fa 100644 --- a/pixstrip-gtk/src/settings.rs +++ b/pixstrip-gtk/src/settings.rs @@ -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> = 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>> = @@ -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() diff --git a/pixstrip-gtk/src/step_indicator.rs b/pixstrip-gtk/src/step_indicator.rs index 0413563..e596e01 100644 --- a/pixstrip-gtk/src/step_indicator.rs +++ b/pixstrip-gtk/src/step_indicator.rs @@ -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()) + ), + ]); } } } diff --git a/pixstrip-gtk/src/steps/step_adjustments.rs b/pixstrip-gtk/src/steps/step_adjustments.rs index 11eb853..6d0b6a4 100644 --- a/pixstrip-gtk/src/steps/step_adjustments.rs +++ b/pixstrip-gtk/src/steps/step_adjustments.rs @@ -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(); }); diff --git a/pixstrip-gtk/src/steps/step_compress.rs b/pixstrip-gtk/src/steps/step_compress.rs index 2457073..48fd945 100644 --- a/pixstrip-gtk/src/steps/step_compress.rs +++ b/pixstrip-gtk/src/steps/step_compress.rs @@ -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>> = 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> = 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); diff --git a/pixstrip-gtk/src/steps/step_convert.rs b/pixstrip-gtk/src/steps/step_convert.rs index 7aba909..3e9871f 100644 --- a/pixstrip-gtk/src/steps/step_convert.rs +++ b/pixstrip-gtk/src/steps/step_convert.rs @@ -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); }); } diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index f46dabf..8faf466 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -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::()); 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(); diff --git a/pixstrip-gtk/src/steps/step_metadata.rs b/pixstrip-gtk/src/steps/step_metadata.rs index 489983d..86ccaf3 100644 --- a/pixstrip-gtk/src/steps/step_metadata.rs +++ b/pixstrip-gtk/src/steps/step_metadata.rs @@ -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 } diff --git a/pixstrip-gtk/src/steps/step_rename.rs b/pixstrip-gtk/src/steps/step_rename.rs index d6a04a2..be6fce2 100644 --- a/pixstrip-gtk/src/steps/step_rename.rs +++ b/pixstrip-gtk/src/steps/step_rename.rs @@ -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(); }); } diff --git a/pixstrip-gtk/src/steps/step_resize.rs b/pixstrip-gtk/src/steps/step_resize.rs index 26c5a3f..f4e3568 100644 --- a/pixstrip-gtk/src/steps/step_resize.rs +++ b/pixstrip-gtk/src/steps/step_resize.rs @@ -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(); }); } diff --git a/pixstrip-gtk/src/steps/step_watermark.rs b/pixstrip-gtk/src/steps/step_watermark.rs index a4be52a..6f0570e 100644 --- a/pixstrip-gtk/src/steps/step_watermark.rs +++ b/pixstrip-gtk/src/steps/step_watermark.rs @@ -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(); }); diff --git a/pixstrip-gtk/src/steps/step_workflow.rs b/pixstrip-gtk/src/steps/step_workflow.rs index 8e342ed..1496166 100644 --- a/pixstrip-gtk/src/steps/step_workflow.rs +++ b/pixstrip-gtk/src/steps/step_workflow.rs @@ -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); - }); - row.add_suffix(&delete_btn); - - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - - 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); - cfg.preset_mode = true; - drop(cfg); - r.activate_action("win.next-step", None).ok(); + // Remove the FlowBoxChild containing this card + if let Some(child) = btn.ancestor(gtk::FlowBoxChild::static_type()) { + if let Some(fbc) = child.downcast_ref::() { + 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); + } + } }); - list_box.append(&row); - rows_box.append(&list_box); + actions_box.append(&export_btn); + actions_box.append(&delete_btn); + overlay.add_overlay(&actions_box); + + uf.append(&overlay); + } + } + uel.set_visible(!has_custom); + + // Always append an "Import Preset" card at the end + let import_card = build_import_card(); + uf.append(&import_card); + }); + } + + // Wire user preset card activation + { + let jc = state.job_config.clone(); + user_flow.connect_child_activated(move |flow, child| { + // Count total children to know which is the import card (always last) + let mut total = 0usize; + let mut c = flow.first_child(); + while let Some(w) = c { + total += 1; + c = w.next_sibling(); + } + + let activated_idx = child.index() as usize; + + // Last card is always the import card + if activated_idx == total - 1 { + flow.activate_action("win.import-preset", None).ok(); + return; + } + + let store = pixstrip_core::storage::PresetStore::new(); + if let Ok(presets) = store.list() { + let custom_presets: Vec<_> = presets.iter().filter(|p| p.is_custom).collect(); + if let Some(preset) = custom_presets.get(activated_idx) { + let mut cfg = jc.borrow_mut(); + apply_preset_to_config(&mut cfg, preset); + cfg.preset_mode = true; + drop(cfg); + flow.activate_action("win.next-step", None).ok(); } } }); @@ -430,15 +546,18 @@ fn build_custom_card() -> gtk::Box { .vexpand(false) .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) diff --git a/pixstrip-gtk/src/tutorial.rs b/pixstrip-gtk/src/tutorial.rs index bcc5c64..f910c6e 100644 --- a/pixstrip-gtk/src/tutorial.rs +++ b/pixstrip-gtk/src/tutorial.rs @@ -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(); - if is_last { - mark_tutorial_complete(); - } else { - let w = win.clone(); - glib::idle_add_local_once(move || { - show_tour_dialog(&w, stop_index + 1); - }); - } + 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 { + 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 { + 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() {