Compare commits
132 Commits
ff55c91dc2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6279f05c15 | |||
| 7677e05b20 | |||
| e3b391b18a | |||
| bb75a7764f | |||
| 834bf5fbb6 | |||
| b94626036d | |||
| 2b1c328c93 | |||
| 28253f009d | |||
| 388670964a | |||
| 96f6973d39 | |||
| 72c519bebc | |||
| 0ef3893bce | |||
| 6392b7952f | |||
| 5ed5f3a1d5 | |||
| f471d22767 | |||
| 150d483fbe | |||
| 9e1562c4c4 | |||
| 6bf9d60430 | |||
| 780ac75e7b | |||
| 213e3d1de8 | |||
| 7feb8583cd | |||
| 62ad99d254 | |||
| 8edaab0e2b | |||
| 3fd602652a | |||
| 6cf5a6e80c | |||
| f301f1a78b | |||
| 646a698ac1 | |||
| 3e6a539749 | |||
| 3d200fc0a9 | |||
| ebf9f2ecfc | |||
| 727056f385 | |||
| 81e244e069 | |||
| f2b1f0be2e | |||
| 21e17d0c50 | |||
| 7e54ff1c72 | |||
| 230410e09e | |||
| e81698a367 | |||
| 8b39b1a678 | |||
| 35b1ae4e59 | |||
| fd0fbeb385 | |||
| 47d6813d6f | |||
| f3060479f5 | |||
| 84bf287851 | |||
| 1dc3abf691 | |||
| 5582aa21bd | |||
| d94ea19a0d | |||
| 4a328010d7 | |||
| d74fd0abc0 | |||
| 322f6440dd | |||
| 4409e596d8 | |||
| 88ee177467 | |||
| 135bbe80f8 | |||
| 245adec076 | |||
| acb60e409a | |||
| 2ad0538a13 | |||
| e871d47a80 | |||
| 0388d3a510 | |||
| 60c5a45c20 | |||
| cd286a23de | |||
| 3bc3d89ead | |||
| 105058d767 | |||
| 6838fd3b7e | |||
| 5b47d68d5c | |||
| 4eccbb1804 | |||
| 1feb9805bb | |||
| c31b30eed2 | |||
| d581640d0c | |||
| f3dc164018 | |||
| 600b36279e | |||
| 2b977b833a | |||
| 4a5114e6f9 | |||
| 0b635f6d81 | |||
| f8a23af851 | |||
| a87a976b6e | |||
| ce726a7838 | |||
| ded1880711 | |||
| b54e0545b0 | |||
| f5d55167fb | |||
| 324e121f43 | |||
| ea596e01fe | |||
| 5428214d6c | |||
| 31b12478b6 | |||
| ec81b059b2 | |||
| 42e3bfcec1 | |||
| 543368ee45 | |||
| 81da76310a | |||
| 9e99012a88 | |||
| 9dabd89a55 | |||
| 0c1dd2cd3b | |||
| dbc215f0ad | |||
| 36e291e486 | |||
| ac6f305bcc | |||
| 6ef0f14804 | |||
| 7531de5332 | |||
| fde0fbdf9f | |||
| 0eebeabd42 | |||
| 08c287843d | |||
| 24459ef9ac | |||
| 3d4ee6cac9 | |||
| d1bc450d7d | |||
| 3e63761b43 | |||
| 283206c411 | |||
| 457f132f51 | |||
| 6235d3d686 | |||
| 662270d7b9 | |||
| 86480545b7 | |||
| 609a682105 | |||
| a1df353521 | |||
| c4bf5cbdda | |||
| 4e03cc389d | |||
| cfd2660b95 | |||
| 9ad70c960b | |||
| 47e019dbb9 | |||
| 86307aeefc | |||
| d36f90565e | |||
| 9fe680dad5 | |||
| 0bc39542ee | |||
| 9b5cfd1212 | |||
| c81e2364af | |||
| 56f0a7172e | |||
| 90807f5632 | |||
| 4e34c77d46 | |||
| 9a65a034d1 | |||
| 2747cb9b4a | |||
| f10ee90c0a | |||
| 4b354b79c4 | |||
| 1fc958f068 | |||
| 34cac53eb8 | |||
| 0f539875bb | |||
| 7967e86a59 | |||
| f503c81df8 | |||
| f6cccda7bf |
2396
Cargo.lock
generated
349
README.md
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Pixstrip
|
||||||
|
|
||||||
|
**Your images. Your machine. Your rules.**
|
||||||
|
|
||||||
|
A native Linux batch image processor that keeps everything local, respects your privacy, and gets out of your way.
|
||||||
|
|
||||||
|
Resize. Convert. Compress. Strip metadata. Watermark. Rename. Adjust.
|
||||||
|
All in one wizard - or one command.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Download AppImage](https://git.lashman.live/lashman/pixstrip/releases) · [Report a Bug](https://git.lashman.live/lashman/pixstrip/issues) · [Source Code](https://git.lashman.live/lashman/pixstrip)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 What is Pixstrip
|
||||||
|
|
||||||
|
Pixstrip is a batch image processor for Linux built with GTK4 and libadwaita. It exists because preparing images for the web, for clients, for print, or for sharing shouldn't require proprietary software, cloud uploads, or a subscription.
|
||||||
|
|
||||||
|
Everything runs locally on your machine. No accounts. No telemetry. No cloud. No surprises. You own the tool and you own the output.
|
||||||
|
|
||||||
|
The GUI walks you through a step-by-step wizard where you enable only the operations you need. The CLI gives you the same power in a single command for scripting and automation. Both share the same processing engine, the same presets, and the same results.
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
Grab the AppImage from the [releases page](https://git.lashman.live/lashman/pixstrip/releases) and run it. That's it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x Pixstrip-x86_64.AppImage
|
||||||
|
./Pixstrip-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything is bundled inside - GTK4, libadwaita, all shared libraries. No package manager, no dependency chain, no root access needed. Download, run, own it.
|
||||||
|
|
||||||
|
## 🔧 Operations
|
||||||
|
|
||||||
|
Pixstrip combines seven operations into a single workflow. Enable what you need, skip what you don't. Each operation is a step in the wizard with its own controls, previews, and help text.
|
||||||
|
|
||||||
|
### ✂️ Resize
|
||||||
|
|
||||||
|
Scale images to exact dimensions, or let Pixstrip figure out the math. Five resize modes cover everything from rigid pixel targets to flexible fit-in-box scaling that preserves aspect ratio.
|
||||||
|
|
||||||
|
- **Width** - set a target width, height scales proportionally
|
||||||
|
- **Height** - set a target height, width scales proportionally
|
||||||
|
- **Exact** - force both width and height (may distort aspect ratio)
|
||||||
|
- **Fit in box** - scale to fit within a bounding box without cropping or stretching
|
||||||
|
- **Social media presets** - Mastodon Post, Pixelfed Square, Pixelfed Portrait, Lemmy Thumbnail, and more built in
|
||||||
|
|
||||||
|
Resize uses Lanczos3 interpolation by default. Catmull-Rom, bilinear, and nearest-neighbor are available for specific needs. Upscaling is disabled by default to prevent quality loss - enable it explicitly when you need it.
|
||||||
|
|
||||||
|
### 🎨 Adjustments
|
||||||
|
|
||||||
|
Tune the look of your images before they leave your hands. All adjustments apply non-destructively to the output - your originals are never touched.
|
||||||
|
|
||||||
|
- **Brightness** - lighten or darken (-100 to +100)
|
||||||
|
- **Contrast** - increase or reduce tonal range (-100 to +100)
|
||||||
|
- **Saturation** - boost or mute color intensity (-100 to +100)
|
||||||
|
- **Rotation** - 90, 180, 270 degrees, or flip horizontal/vertical
|
||||||
|
- **Crop to aspect ratio** - freeform or preset ratios (1:1, 4:3, 16:9, 3:2)
|
||||||
|
- **Trim whitespace** - auto-detect and remove white or near-white borders
|
||||||
|
- **Canvas padding** - add uniform padding around images
|
||||||
|
|
||||||
|
Four one-click effects are available: **Grayscale**, **Sepia**, **Sharpen**, and a combined **Grayscale + Sharpen** for crisp black-and-white output.
|
||||||
|
|
||||||
|
### 🔄 Convert
|
||||||
|
|
||||||
|
Change image formats across your entire batch. Pixstrip supports eight formats and knows which conversions make sense.
|
||||||
|
|
||||||
|
| Format | Type | Best for |
|
||||||
|
|---|---|---|
|
||||||
|
| JPEG | Lossy | Photos, web, email - universal support |
|
||||||
|
| PNG | Lossless | Graphics, screenshots, transparency |
|
||||||
|
| WebP | Lossy/Lossless | Modern web - smaller than JPEG at same quality |
|
||||||
|
| AVIF | Lossy/Lossless | Next-gen web - smallest files, growing support |
|
||||||
|
| GIF | Lossless (256 colors) | Simple graphics, legacy compatibility |
|
||||||
|
| TIFF | Lossless | Archival, print, professional workflows |
|
||||||
|
| BMP | Uncompressed | Legacy systems, raw pixel data |
|
||||||
|
|
||||||
|
**Per-format mapping** handles mixed batches intelligently. Instead of converting everything to one format, you can set rules: PNGs become WebP, TIFFs become JPEG, everything else stays as-is. One pass, multiple conversions.
|
||||||
|
|
||||||
|
### 📐 Compress
|
||||||
|
|
||||||
|
Squeeze file sizes without visible quality loss. Pixstrip uses the best open-source encoders available - the same ones used by the tools that big platforms keep behind paywalls.
|
||||||
|
|
||||||
|
- **mozjpeg** for JPEG - typically 10-15% smaller than standard JPEG at identical visual quality
|
||||||
|
- **oxipng** for PNG - lossless recompression that shaves bytes without touching pixels
|
||||||
|
- **libwebp** for WebP - Google's encoder with fine-grained quality control
|
||||||
|
- **ravif** for AVIF - the rav1e encoder with configurable speed/quality tradeoffs
|
||||||
|
|
||||||
|
Five quality presets (Maximum, High, Medium, Low, Web) give you quick access to sensible defaults. Per-format quality overrides let you set exact values: JPEG quality 92, WebP quality 85, AVIF quality 70 - all in the same batch.
|
||||||
|
|
||||||
|
The compression step includes a live **before/after preview** with a draggable split view. See exactly what you're trading before you commit. The preview calculates real file sizes from a sample image so you know the actual savings.
|
||||||
|
|
||||||
|
### 🛡️ Metadata
|
||||||
|
|
||||||
|
Your photos carry more information than you think. GPS coordinates, camera serial numbers, editing software history, timestamps - all embedded in EXIF data that travels with every file you share.
|
||||||
|
|
||||||
|
Pixstrip gives you five metadata modes:
|
||||||
|
|
||||||
|
- **Strip All** - remove everything for the smallest files and maximum privacy
|
||||||
|
- **Privacy Mode** - strip GPS, camera serial, and device info while keeping copyright and basic camera settings
|
||||||
|
- **Photographer Mode** - keep copyright, camera model, and lens data while stripping GPS and software info
|
||||||
|
- **Keep All** - preserve every byte of metadata exactly as it is
|
||||||
|
- **Custom** - toggle individual categories: GPS/Location, Camera Info, Software, Timestamps, Copyright/Author
|
||||||
|
|
||||||
|
Custom mode puts you in full control of what leaves your machine and what doesn't. Your data, your choice about who sees it.
|
||||||
|
|
||||||
|
### 💧 Watermark
|
||||||
|
|
||||||
|
Protect your work or brand your output with text or image watermarks. The watermark step provides a live preview that updates as you change settings.
|
||||||
|
|
||||||
|
**Text watermarks** support:
|
||||||
|
- Custom text with any system font, adjustable size, and bold/italic styling
|
||||||
|
- Nine-position placement grid (corners, edges, center)
|
||||||
|
- Rotation from -180 to +180 degrees
|
||||||
|
- Opacity from fully transparent to fully opaque
|
||||||
|
- Custom color with hex input
|
||||||
|
- **Tiling mode** - repeat the watermark across the entire image in a diagonal pattern
|
||||||
|
|
||||||
|
**Image watermarks** overlay a PNG or other image file (great for logos):
|
||||||
|
- Same nine-position grid and opacity controls
|
||||||
|
- Scale relative to the target image (5% to 100%)
|
||||||
|
- Transparency in the watermark image is preserved
|
||||||
|
|
||||||
|
### ✏️ Rename
|
||||||
|
|
||||||
|
Batch rename files with a flexible system that handles everything from simple prefix/suffix additions to complex template-driven naming with EXIF variables.
|
||||||
|
|
||||||
|
**Simple rename** operations:
|
||||||
|
- Add prefix or suffix to existing filenames
|
||||||
|
- Replace spaces with hyphens, underscores, or remove them
|
||||||
|
- Strip special characters
|
||||||
|
- Convert case: lowercase, UPPERCASE, Title Case
|
||||||
|
- Add sequential counters with configurable start number and zero-padding
|
||||||
|
|
||||||
|
**Template engine** for advanced renaming:
|
||||||
|
|
||||||
|
| Variable | Expands to |
|
||||||
|
|---|---|
|
||||||
|
| `{name}` | Original filename without extension |
|
||||||
|
| `{ext}` | File extension |
|
||||||
|
| `{counter}` | Sequential number |
|
||||||
|
| `{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) |
|
||||||
|
|
||||||
|
**Find and replace** with full regex support for surgical renaming. Remove prefixes, rewrite patterns, extract parts of filenames - anything regex can express.
|
||||||
|
|
||||||
|
The rename step shows a **live preview** of the first five files so you see exactly what the output filenames will be before processing. A stats bar at the bottom shows total files, naming conflicts, file extensions, and the longest resulting filename.
|
||||||
|
|
||||||
|
## ⚡ Presets
|
||||||
|
|
||||||
|
Presets save a complete workflow configuration and apply it in one click. Pixstrip ships with eight built-in presets that cover the most common batch processing tasks.
|
||||||
|
|
||||||
|
| Preset | What it does |
|
||||||
|
|---|---|
|
||||||
|
| **Blog Photos** | Resize 1200px wide, JPEG high quality, strip all metadata |
|
||||||
|
| **Social Media** | Fit 1080x1080, compress medium, strip metadata, sequential rename |
|
||||||
|
| **Web Optimization** | Convert to WebP, compress high, strip metadata, sequential rename |
|
||||||
|
| **Email Friendly** | Resize 800px wide, JPEG medium quality |
|
||||||
|
| **Privacy Clean** | Strip all metadata, no other changes |
|
||||||
|
| **Photographer Export** | Resize 2048px, compress high, privacy metadata mode, rename by date |
|
||||||
|
| **Archive Compress** | Lossless compression, preserve all metadata |
|
||||||
|
| **Print Ready** | Maximum quality, convert to PNG, keep all metadata |
|
||||||
|
|
||||||
|
**Create your own presets** from any workflow you build. After processing, hit Save as Preset to name it, pick an icon and color, and have it appear on the workflow page for next time. Export presets to share them with others - import presets others have shared with you.
|
||||||
|
|
||||||
|
Presets belong to you. They live as plain JSON files in `~/.config/pixstrip/presets/` - readable, editable, shareable, version-controllable. No proprietary format, no lock-in.
|
||||||
|
|
||||||
|
## 🖥️ Using the GUI
|
||||||
|
|
||||||
|
**Start with a preset** by clicking any card on the workflow page. Pixstrip loads the preset's settings and jumps straight to the image selection step. Add your images, review the output summary, and hit Process. Done.
|
||||||
|
|
||||||
|
**Build a custom workflow** by selecting the Custom card. Toggle the operations you want in the checklist at the bottom of the page, then click Next to step through each one. Only the operations you enabled appear in the wizard - everything else is skipped automatically.
|
||||||
|
|
||||||
|
The **step indicator** across the top tracks your progress. Each step shows as a dot with a label. Click any completed step to jump back and change settings. The Back and Next buttons (or Alt+Left / Alt+Right) move between steps.
|
||||||
|
|
||||||
|
Every step has a **help button** in the header bar that opens a detailed explanation of what the step does, what the options mean, and which keyboard shortcuts are available. A guided **tour** runs on first launch to walk you through the interface.
|
||||||
|
|
||||||
|
When processing finishes, the results page shows how many images were processed, original and output sizes, percentage saved, and processing time. From there you can open the output folder, process another batch, queue more images, or save the workflow as a preset.
|
||||||
|
|
||||||
|
## ⌨️ Using the CLI
|
||||||
|
|
||||||
|
The CLI provides the same operations with the same engine. Every flag maps to a GUI option.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Resize all images in a folder to 1200px wide
|
||||||
|
pixstrip process photos/ --resize 1200
|
||||||
|
|
||||||
|
# Convert PNGs to WebP at high quality
|
||||||
|
pixstrip process *.png --format webp --quality high
|
||||||
|
|
||||||
|
# Fit within 1920x1080, strip metadata, rename to lowercase
|
||||||
|
pixstrip process photos/ --resize fit:1920x1080 --strip-metadata --rename-case lower
|
||||||
|
|
||||||
|
# Use a saved preset
|
||||||
|
pixstrip process photos/ --preset "Blog Photos"
|
||||||
|
|
||||||
|
# Override a preset option
|
||||||
|
pixstrip process photos/ --preset "Blog Photos" --resize 800
|
||||||
|
|
||||||
|
# Per-format compression tuning
|
||||||
|
pixstrip process photos/ --jpeg-quality 90 --webp-quality 85 --avif-quality 70
|
||||||
|
|
||||||
|
# Tiled watermark with custom color and angle
|
||||||
|
pixstrip process photos/ --watermark "(c) 2026" --watermark-tiled \
|
||||||
|
--watermark-rotation 45 --watermark-color ff0000 --watermark-opacity 0.3
|
||||||
|
|
||||||
|
# Image watermark (logo)
|
||||||
|
pixstrip process photos/ --watermark-image logo.png --watermark-scale 0.15
|
||||||
|
|
||||||
|
# Regex rename - remove "IMG_" prefix
|
||||||
|
pixstrip process photos/ --rename-regex-find "^IMG_" --rename-regex-replace ""
|
||||||
|
|
||||||
|
# Sequential numbering with 4-digit padding
|
||||||
|
pixstrip process photos/ --rename-counter-position replace-name \
|
||||||
|
--rename-counter-start 1 --rename-counter-padding 4
|
||||||
|
|
||||||
|
# Template rename with dimensions
|
||||||
|
pixstrip process photos/ --rename-template "{name}_{width}x{height}.{ext}"
|
||||||
|
|
||||||
|
# Everything at once
|
||||||
|
pixstrip process photos/ -r --resize fit:1920x1080 --format webp \
|
||||||
|
--quality high --strip-metadata --rename-case lower \
|
||||||
|
--watermark "(c) 2026" --overwrite skip -o output/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preset and history management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List available presets
|
||||||
|
pixstrip preset list
|
||||||
|
|
||||||
|
# Show preset details
|
||||||
|
pixstrip preset show "Blog Photos"
|
||||||
|
|
||||||
|
# View processing history
|
||||||
|
pixstrip history
|
||||||
|
|
||||||
|
# Undo the last batch (restores from trash)
|
||||||
|
pixstrip undo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watch folders
|
||||||
|
|
||||||
|
Monitor directories and auto-process new images as they appear.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a watched directory with a preset
|
||||||
|
pixstrip watch add ~/incoming --preset "Web Optimization"
|
||||||
|
|
||||||
|
# List active watches
|
||||||
|
pixstrip watch list
|
||||||
|
|
||||||
|
# Start the watcher (runs until stopped)
|
||||||
|
pixstrip watch start
|
||||||
|
|
||||||
|
# Remove a watch
|
||||||
|
pixstrip watch remove ~/incoming
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 File manager integration
|
||||||
|
|
||||||
|
Right-click any image (or selection of images) in your file manager to process them directly with Pixstrip. A submenu lists all your presets for one-click processing, plus an "Open in Pixstrip" option for the full wizard.
|
||||||
|
|
||||||
|
| File Manager | How it works |
|
||||||
|
|---|---|
|
||||||
|
| **Nautilus** (GNOME Files) | Python extension - dynamically reads your presets |
|
||||||
|
| **Nemo** (Cinnamon) | Python extension - dynamically reads your presets |
|
||||||
|
| **Thunar** (Xfce) | Custom action - regenerated when presets change |
|
||||||
|
| **Dolphin** (KDE) | Service menu - regenerated when presets change |
|
||||||
|
|
||||||
|
Toggle each integration on or off in Settings. The extensions are installed into the standard locations for each file manager.
|
||||||
|
|
||||||
|
## 🏗️ Building from source
|
||||||
|
|
||||||
|
Pixstrip is written in Rust and builds with standard Cargo tooling.
|
||||||
|
|
||||||
|
**Dependencies:** Rust 1.85+, GTK4 4.16+ dev libraries, libadwaita 1.6+ dev libraries.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install build dependencies (Ubuntu/Debian)
|
||||||
|
sudo apt install libgtk-4-dev libadwaita-1-dev
|
||||||
|
|
||||||
|
# Clone, build, run
|
||||||
|
git clone https://git.lashman.live/lashman/pixstrip.git
|
||||||
|
cd pixstrip
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# The two binaries
|
||||||
|
./target/release/pixstrip-gtk # GUI
|
||||||
|
./target/release/pixstrip-cli # CLI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building an AppImage
|
||||||
|
|
||||||
|
The included script downloads `linuxdeploy` with its GTK plugin, bundles all shared libraries, and produces a self-contained AppImage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-appimage.sh
|
||||||
|
# Output: Pixstrip-x86_64.AppImage (~39 MB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗂️ Project structure
|
||||||
|
|
||||||
|
Pixstrip is a Cargo workspace with three crates that share a common core.
|
||||||
|
|
||||||
|
```
|
||||||
|
pixstrip/
|
||||||
|
pixstrip-core/ Processing engine, operations, presets, pipeline, watcher
|
||||||
|
pixstrip-gtk/ GTK4/libadwaita graphical interface
|
||||||
|
pixstrip-cli/ Command-line interface with clap
|
||||||
|
data/ Desktop file, AppStream metainfo, icons
|
||||||
|
```
|
||||||
|
|
||||||
|
The architecture is deliberately simple. `pixstrip-core` contains all the image processing logic and has no GUI dependencies. Both the GTK app and the CLI are thin frontends that build jobs and hand them to the core's pipeline executor. If you wanted to build a different frontend - a web UI, a TUI, a bot - you'd just depend on `pixstrip-core` and wire up the interface.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Pixstrip is built in the open and contributions are welcome. The project uses [CC0](https://creativecommons.org/publicdomain/zero/1.0/) - there are no copyright assignments, no CLAs, no legal barriers. Your contributions enter the public domain just like the rest of the code.
|
||||||
|
|
||||||
|
File bugs, suggest features, or submit patches through the [issue tracker](https://git.lashman.live/lashman/pixstrip/issues). The codebase is straightforward Rust with GTK4 bindings - if you can read Rust, you can contribute.
|
||||||
|
|
||||||
|
## 🔒 Privacy and trust
|
||||||
|
|
||||||
|
Pixstrip processes everything on your machine. It never phones home, never uploads your images, never tracks what you do with it. There is no analytics, no crash reporting, no update checker, no account system. The binary does exactly what you tell it to and nothing else.
|
||||||
|
|
||||||
|
Your images are yours. Your metadata is yours. Your workflow is yours. The tool is yours too - CC0 means nobody owns it, including us. Fork it, modify it, redistribute it, sell it, give it away. No permission needed.
|
||||||
|
|
||||||
|
## ⚖️ License
|
||||||
|
|
||||||
|
**CC0 1.0 - Public Domain.** No rights reserved. This work is dedicated to the public domain worldwide. You can copy, modify, distribute, and use it for any purpose, commercial or otherwise, without asking permission and without owing anyone anything.
|
||||||
|
|
||||||
|
Good tools should be freely available to everyone who needs them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<sub><i>This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.</i></sub>
|
||||||
98
build-appimage.sh
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build Pixstrip AppImage
|
||||||
|
# Requires: cargo, linuxdeploy (with gtk plugin), appimagetool
|
||||||
|
# Usage: ./build-appimage.sh [--release]
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
BUILD_TYPE="release"
|
||||||
|
CARGO_FLAGS="--release"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--debug" ]]; then
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
CARGO_FLAGS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_ID="live.lashman.Pixstrip"
|
||||||
|
APP_DIR="$SCRIPT_DIR/AppDir"
|
||||||
|
TOOLS_DIR="$SCRIPT_DIR/build-tools"
|
||||||
|
|
||||||
|
echo "=== Building Pixstrip AppImage ($BUILD_TYPE) ==="
|
||||||
|
|
||||||
|
# Step 1: Build binaries
|
||||||
|
echo "--- Building binaries ---"
|
||||||
|
cargo build $CARGO_FLAGS --workspace
|
||||||
|
|
||||||
|
# Step 2: Create AppDir structure
|
||||||
|
echo "--- Creating AppDir ---"
|
||||||
|
rm -rf "$APP_DIR"
|
||||||
|
mkdir -p "$APP_DIR/usr/bin"
|
||||||
|
mkdir -p "$APP_DIR/usr/share/applications"
|
||||||
|
mkdir -p "$APP_DIR/usr/share/icons/hicolor/scalable/apps"
|
||||||
|
mkdir -p "$APP_DIR/usr/share/metainfo"
|
||||||
|
|
||||||
|
# Step 3: Copy binaries
|
||||||
|
cp "target/$BUILD_TYPE/pixstrip-gtk" "$APP_DIR/usr/bin/pixstrip-gtk"
|
||||||
|
cp "target/$BUILD_TYPE/pixstrip-cli" "$APP_DIR/usr/bin/pixstrip"
|
||||||
|
|
||||||
|
# Step 4: Copy desktop file, icons, metainfo
|
||||||
|
cp "data/$APP_ID.desktop" "$APP_DIR/usr/share/applications/"
|
||||||
|
cp "data/$APP_ID.desktop" "$APP_DIR/"
|
||||||
|
cp "data/$APP_ID.metainfo.xml" "$APP_DIR/usr/share/metainfo/"
|
||||||
|
|
||||||
|
# Copy all hicolor icon sizes
|
||||||
|
for size_dir in data/icons/hicolor/*/apps; do
|
||||||
|
size=$(basename "$(dirname "$size_dir")")
|
||||||
|
mkdir -p "$APP_DIR/usr/share/icons/hicolor/$size/apps"
|
||||||
|
cp "$size_dir"/$APP_ID.* "$APP_DIR/usr/share/icons/hicolor/$size/apps/" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Top-level icon for AppImage spec
|
||||||
|
cp "data/icons/hicolor/256x256/apps/$APP_ID.png" "$APP_DIR/$APP_ID.png"
|
||||||
|
|
||||||
|
# Step 5: Create AppRun
|
||||||
|
cat > "$APP_DIR/AppRun" << 'APPRUN'
|
||||||
|
#!/bin/bash
|
||||||
|
SELF="$(readlink -f "$0")"
|
||||||
|
HERE="${SELF%/*}"
|
||||||
|
export PATH="${HERE}/usr/bin:${PATH}"
|
||||||
|
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
|
||||||
|
export XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
|
||||||
|
export GI_TYPELIB_PATH="${HERE}/usr/lib/x86_64-linux-gnu/girepository-1.0:${GI_TYPELIB_PATH:-}"
|
||||||
|
exec "${HERE}/usr/bin/pixstrip-gtk" "$@"
|
||||||
|
APPRUN
|
||||||
|
chmod +x "$APP_DIR/AppRun"
|
||||||
|
|
||||||
|
# Step 6: Bundle shared libraries
|
||||||
|
echo "--- Bundling libraries ---"
|
||||||
|
mkdir -p "$TOOLS_DIR"
|
||||||
|
|
||||||
|
# Download linuxdeploy if not present
|
||||||
|
if [[ ! -x "$TOOLS_DIR/linuxdeploy" ]]; then
|
||||||
|
echo "Downloading linuxdeploy..."
|
||||||
|
wget -q -O "$TOOLS_DIR/linuxdeploy" \
|
||||||
|
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||||
|
chmod +x "$TOOLS_DIR/linuxdeploy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download linuxdeploy GTK plugin if not present
|
||||||
|
if [[ ! -x "$TOOLS_DIR/linuxdeploy-plugin-gtk.sh" ]]; then
|
||||||
|
echo "Downloading linuxdeploy GTK plugin..."
|
||||||
|
wget -q -O "$TOOLS_DIR/linuxdeploy-plugin-gtk.sh" \
|
||||||
|
"https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh"
|
||||||
|
chmod +x "$TOOLS_DIR/linuxdeploy-plugin-gtk.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use linuxdeploy to bundle dependencies and create AppImage
|
||||||
|
export DEPLOY_GTK_VERSION=4
|
||||||
|
"$TOOLS_DIR/linuxdeploy" \
|
||||||
|
--appdir "$APP_DIR" \
|
||||||
|
--desktop-file "$APP_DIR/usr/share/applications/$APP_ID.desktop" \
|
||||||
|
--icon-file "$APP_DIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" \
|
||||||
|
--plugin gtk \
|
||||||
|
--output appimage
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== AppImage built ==="
|
||||||
|
ls -lh Pixstrip-*.AppImage 2>/dev/null || echo "Note: output filename may vary."
|
||||||
BIN
data/icons/hicolor/128x128/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
data/icons/hicolor/16x16/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 894 B |
BIN
data/icons/hicolor/256x256/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
data/icons/hicolor/32x32/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
data/icons/hicolor/48x48/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
data/icons/hicolor/512x512/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
data/icons/hicolor/64x64/apps/live.lashman.Pixstrip.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
53
data/icons/hicolor/scalable/apps/live.lashman.Pixstrip.svg
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||||
|
<!-- Pixstrip app icon: scissors cutting a film strip / image strip -->
|
||||||
|
|
||||||
|
<!-- Film strip background -->
|
||||||
|
<rect x="16" y="32" width="96" height="64" rx="6" ry="6" fill="#3584e4" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- Film strip sprocket holes - top -->
|
||||||
|
<rect x="24" y="36" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="40" y="36" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="56" y="36" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="72" y="36" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="88" y="36" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Film strip sprocket holes - bottom -->
|
||||||
|
<rect x="24" y="84" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="40" y="84" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="56" y="84" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="72" y="84" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
<rect x="88" y="84" width="8" height="8" rx="2" fill="#fff" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Image frames in the strip -->
|
||||||
|
<rect x="24" y="48" width="20" height="32" rx="2" fill="#fff" opacity="0.85"/>
|
||||||
|
<rect x="48" y="48" width="20" height="32" rx="2" fill="#fff" opacity="0.85"/>
|
||||||
|
<rect x="72" y="48" width="20" height="32" rx="2" fill="#fff" opacity="0.85"/>
|
||||||
|
|
||||||
|
<!-- Mini landscape icons inside frames -->
|
||||||
|
<circle cx="30" cy="56" r="3" fill="#77767b" opacity="0.6"/>
|
||||||
|
<polygon points="26,74 34,66 38,70 42,64 44,74" fill="#77767b" opacity="0.4"/>
|
||||||
|
|
||||||
|
<circle cx="54" cy="56" r="3" fill="#77767b" opacity="0.6"/>
|
||||||
|
<polygon points="50,74 58,66 62,70 66,64 68,74" fill="#77767b" opacity="0.4"/>
|
||||||
|
|
||||||
|
<circle cx="78" cy="56" r="3" fill="#77767b" opacity="0.6"/>
|
||||||
|
<polygon points="74,74 82,66 86,70 90,64 92,74" fill="#77767b" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Scissors - cutting diagonally across the strip -->
|
||||||
|
<!-- Scissor blade 1 (top-right) -->
|
||||||
|
<path d="M98,20 L70,58 L74,62 L102,24 Z" fill="#e01b24" opacity="0.9"/>
|
||||||
|
<circle cx="100" cy="18" r="8" fill="none" stroke="#e01b24" stroke-width="3" opacity="0.9"/>
|
||||||
|
<circle cx="100" cy="18" r="3" fill="#e01b24" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Scissor blade 2 (bottom-right) -->
|
||||||
|
<path d="M98,108 L70,70 L74,66 L102,104 Z" fill="#e01b24" opacity="0.9"/>
|
||||||
|
<circle cx="100" cy="110" r="8" fill="none" stroke="#e01b24" stroke-width="3" opacity="0.9"/>
|
||||||
|
<circle cx="100" cy="110" r="3" fill="#e01b24" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Scissor pivot -->
|
||||||
|
<circle cx="74" cy="64" r="3" fill="#a51d2d"/>
|
||||||
|
|
||||||
|
<!-- Cut line (dashed) -->
|
||||||
|
<line x1="74" y1="64" x2="16" y2="64" stroke="#a51d2d" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
12
data/live.lashman.Pixstrip.desktop
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=Pixstrip
|
||||||
|
Comment=Batch image processor - resize, convert, compress, and more
|
||||||
|
Exec=pixstrip-gtk %F
|
||||||
|
Icon=live.lashman.Pixstrip
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
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
|
||||||
192
data/live.lashman.Pixstrip.metainfo.xml
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>live.lashman.Pixstrip</id>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>CC0-1.0</project_license>
|
||||||
|
|
||||||
|
<name>Pixstrip</name>
|
||||||
|
<summary>Batch image processor - resize, convert, compress, and more</summary>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Pixstrip is a native GTK4/libadwaita batch image processor for Linux
|
||||||
|
that combines resize, convert, compress, metadata strip, watermark,
|
||||||
|
rename, and image adjustments into a single wizard-driven workflow.
|
||||||
|
It processes everything locally with no cloud dependency.
|
||||||
|
</p>
|
||||||
|
<p>Key features:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Resize images by width, height, fit-in-box, or social media presets</li>
|
||||||
|
<li>Convert between JPEG, PNG, WebP, AVIF, GIF, and TIFF</li>
|
||||||
|
<li>Compress with optimized encoders (mozjpeg, oxipng, libwebp, ravif)</li>
|
||||||
|
<li>Strip or selectively manage EXIF metadata for privacy</li>
|
||||||
|
<li>Add text or image watermarks with positioning and rotation</li>
|
||||||
|
<li>Rename files with templates, counters, regex, and EXIF variables</li>
|
||||||
|
<li>Adjust brightness, contrast, saturation, and apply effects</li>
|
||||||
|
<li>Built-in presets for common workflows with one-click processing</li>
|
||||||
|
<li>Custom workflow builder with step-by-step wizard</li>
|
||||||
|
<li>Watch folders for automatic processing</li>
|
||||||
|
<li>Processing history with undo via system trash</li>
|
||||||
|
<li>Full CLI with feature parity for scripting and automation</li>
|
||||||
|
<li>File manager integration for Nautilus, Nemo, Thunar, and Dolphin</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<icon type="stock">live.lashman.Pixstrip</icon>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">live.lashman.Pixstrip.desktop</launchable>
|
||||||
|
|
||||||
|
<developer id="live.lashman">
|
||||||
|
<name>lashman</name>
|
||||||
|
</developer>
|
||||||
|
|
||||||
|
<url type="homepage">https://git.lashman.live/lashman/pixstrip</url>
|
||||||
|
<url type="bugtracker">https://git.lashman.live/lashman/pixstrip/issues</url>
|
||||||
|
<url type="vcs-browser">https://git.lashman.live/lashman/pixstrip</url>
|
||||||
|
<url type="donation">https://ko-fi.com/lashman</url>
|
||||||
|
<url type="contact">https://git.lashman.live/lashman/pixstrip/issues</url>
|
||||||
|
<url type="contribute">https://git.lashman.live/lashman/pixstrip</url>
|
||||||
|
|
||||||
|
<update_contact>lashman@robotbrush.com</update_contact>
|
||||||
|
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Workflow selection with built-in and user presets for common image tasks</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/01.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Image selection with thumbnail grid and batch file management</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/02.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Resize step with live preview, presets, and dimension controls</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/03.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Format conversion with cards for JPEG, PNG, WebP, AVIF, GIF, TIFF, and BMP</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/04.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Compression with quality slider and live before/after preview</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/05.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Image adjustments for brightness, contrast, saturation, and effects</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/06.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Metadata handling with preset modes and custom category toggles</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/07.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Text watermark placement with live preview on image</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/08.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Batch rename with prefix, suffix, case conversion, and live file preview</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/09.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Output summary with operation checklist, directory, and overwrite settings</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/10.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Processing complete with results showing images processed and space saved</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/11.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Save workflow as a reusable preset with custom name, icon, and color</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/12.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Settings with output defaults, interface options, and file manager integration</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/13.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Processing history with recent batches and space savings</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/14.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Keyboard shortcuts for wizard navigation, file management, and more</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/15.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Welcome screen with quick introduction to Pixstrip</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/16.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Setup wizard to choose between simple and detailed interface modes</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/17.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Contextual help with detailed guidance for each wizard step</caption>
|
||||||
|
<image type="source" width="1369" height="1081">https://git.lashman.live/lashman/pixstrip/raw/branch/main/data/screenshots/18.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
|
||||||
|
<branding>
|
||||||
|
<color type="primary" scheme_preference="light">#57a773</color>
|
||||||
|
<color type="primary" scheme_preference="dark">#263226</color>
|
||||||
|
</branding>
|
||||||
|
|
||||||
|
<categories>
|
||||||
|
<category>Graphics</category>
|
||||||
|
<category>ImageProcessing</category>
|
||||||
|
<category>GTK</category>
|
||||||
|
</categories>
|
||||||
|
|
||||||
|
<keywords>
|
||||||
|
<keyword>Image</keyword>
|
||||||
|
<keyword>Photo</keyword>
|
||||||
|
<keyword>Resize</keyword>
|
||||||
|
<keyword>Convert</keyword>
|
||||||
|
<keyword>Compress</keyword>
|
||||||
|
<keyword>Batch</keyword>
|
||||||
|
<keyword>Metadata</keyword>
|
||||||
|
<keyword>Watermark</keyword>
|
||||||
|
<keyword>Rename</keyword>
|
||||||
|
<keyword>EXIF</keyword>
|
||||||
|
<keyword>WebP</keyword>
|
||||||
|
<keyword>AVIF</keyword>
|
||||||
|
</keywords>
|
||||||
|
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
|
||||||
|
<requires>
|
||||||
|
<display_length compare="ge">360</display_length>
|
||||||
|
</requires>
|
||||||
|
|
||||||
|
<recommends>
|
||||||
|
<control>keyboard</control>
|
||||||
|
<control>pointing</control>
|
||||||
|
</recommends>
|
||||||
|
|
||||||
|
<supports>
|
||||||
|
<control>pointing</control>
|
||||||
|
<control>keyboard</control>
|
||||||
|
<control>touch</control>
|
||||||
|
</supports>
|
||||||
|
|
||||||
|
<provides>
|
||||||
|
<binary>pixstrip-gtk</binary>
|
||||||
|
<binary>pixstrip</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<releases>
|
||||||
|
<release version="0.1.0" date="2026-03-06" type="stable">
|
||||||
|
<description>
|
||||||
|
<p>Initial release of Pixstrip with core features:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Wizard-driven batch processing with 8 built-in presets</li>
|
||||||
|
<li>Resize, convert, compress, metadata strip, watermark, rename, and adjust</li>
|
||||||
|
<li>Optimized encoders: mozjpeg, oxipng, libwebp, and ravif</li>
|
||||||
|
<li>Live compression preview with before/after comparison</li>
|
||||||
|
<li>Watch folders for automatic processing</li>
|
||||||
|
<li>Processing history with undo via system trash</li>
|
||||||
|
<li>Full CLI with feature parity</li>
|
||||||
|
<li>File manager integration for Nautilus, Nemo, Thunar, and Dolphin</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
112
data/nautilus-pixstrip.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Nautilus extension for Pixstrip - adds 'Process with Pixstrip' submenu to image context menu."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
from gi.repository import Nautilus, GObject
|
||||||
|
|
||||||
|
SUPPORTED_MIMETYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/avif",
|
||||||
|
"image/gif",
|
||||||
|
"image/tiff",
|
||||||
|
"image/bmp",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_presets():
|
||||||
|
"""Load built-in and user presets."""
|
||||||
|
presets = [
|
||||||
|
"Blog Photos",
|
||||||
|
"Social Media",
|
||||||
|
"Web Optimization",
|
||||||
|
"Email Friendly",
|
||||||
|
"Privacy Clean",
|
||||||
|
"Photographer Export",
|
||||||
|
"Archive Compress",
|
||||||
|
"Fediverse Ready",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Load user presets
|
||||||
|
config_dir = os.path.expanduser("~/.config/pixstrip/presets")
|
||||||
|
if os.path.isdir(config_dir):
|
||||||
|
for filename in sorted(os.listdir(config_dir)):
|
||||||
|
if filename.endswith(".json"):
|
||||||
|
filepath = os.path.join(config_dir, filename)
|
||||||
|
try:
|
||||||
|
with open(filepath) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
name = data.get("name", "")
|
||||||
|
if name and name not in presets:
|
||||||
|
presets.append(name)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return presets
|
||||||
|
|
||||||
|
|
||||||
|
class PixstripMenuProvider(GObject.GObject, Nautilus.MenuProvider):
|
||||||
|
def get_file_items(self, files):
|
||||||
|
# Only show for image files
|
||||||
|
if not files:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
if f.get_mime_type() not in SUPPORTED_MIMETYPES:
|
||||||
|
return []
|
||||||
|
|
||||||
|
items = []
|
||||||
|
|
||||||
|
# Main menu item
|
||||||
|
top_item = Nautilus.MenuItem(
|
||||||
|
name="PixstripMenuProvider::process",
|
||||||
|
label="Process with Pixstrip",
|
||||||
|
tip="Open images in Pixstrip for batch processing",
|
||||||
|
)
|
||||||
|
|
||||||
|
submenu = Nautilus.Menu()
|
||||||
|
top_item.set_submenu(submenu)
|
||||||
|
|
||||||
|
# Open in Pixstrip
|
||||||
|
open_item = Nautilus.MenuItem(
|
||||||
|
name="PixstripMenuProvider::open",
|
||||||
|
label="Open in Pixstrip...",
|
||||||
|
tip="Open the Pixstrip wizard with these images",
|
||||||
|
)
|
||||||
|
open_item.connect("activate", self._on_open, files)
|
||||||
|
submenu.append_item(open_item)
|
||||||
|
|
||||||
|
# Separator via disabled item
|
||||||
|
sep = Nautilus.MenuItem(
|
||||||
|
name="PixstripMenuProvider::sep1",
|
||||||
|
label="---",
|
||||||
|
sensitive=False,
|
||||||
|
)
|
||||||
|
submenu.append_item(sep)
|
||||||
|
|
||||||
|
# Preset items
|
||||||
|
for preset in get_presets():
|
||||||
|
safe_name = preset.replace(" ", "_").lower()
|
||||||
|
item = Nautilus.MenuItem(
|
||||||
|
name=f"PixstripMenuProvider::preset_{safe_name}",
|
||||||
|
label=preset,
|
||||||
|
tip=f"Process with {preset} preset",
|
||||||
|
)
|
||||||
|
item.connect("activate", self._on_preset, files, preset)
|
||||||
|
submenu.append_item(item)
|
||||||
|
|
||||||
|
items.append(top_item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def _on_open(self, menu, files):
|
||||||
|
paths = [f.get_location().get_path() for f in files]
|
||||||
|
subprocess.Popen(["pixstrip-gtk"] + paths)
|
||||||
|
|
||||||
|
def _on_preset(self, menu, files, preset):
|
||||||
|
paths = [f.get_location().get_path() for f in files]
|
||||||
|
subprocess.Popen(
|
||||||
|
["pixstrip-cli", "process"] + paths + ["--preset", preset]
|
||||||
|
)
|
||||||
BIN
data/screenshots/01.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
data/screenshots/02.png
Normal file
|
After Width: | Height: | Size: 794 KiB |
BIN
data/screenshots/03.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
data/screenshots/04.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
data/screenshots/05.png
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
data/screenshots/06.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
data/screenshots/07.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
data/screenshots/08.png
Normal file
|
After Width: | Height: | Size: 493 KiB |
BIN
data/screenshots/09.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
data/screenshots/10.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
data/screenshots/11.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
data/screenshots/12.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
data/screenshots/13.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
data/screenshots/14.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
data/screenshots/15.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
data/screenshots/16.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
data/screenshots/17.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
data/screenshots/18.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
icons/pixstrip-128x128.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
icons/pixstrip-16x16.png
Normal file
|
After Width: | Height: | Size: 894 B |
BIN
icons/pixstrip-192x192.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
icons/pixstrip-256x256.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
icons/pixstrip-32x32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/pixstrip-48x48.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
icons/pixstrip-512x512.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
icons/pixstrip-64x64.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
icons/pixstrip.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
icons/pixstrip.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
@@ -7,3 +7,6 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
pixstrip-core = { workspace = true }
|
pixstrip-core = { workspace = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
trash = "5"
|
||||||
|
dirs = "6"
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
@@ -8,3 +8,20 @@ license.workspace = true
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
magick_rust = "1"
|
||||||
|
fast_image_resize = "5"
|
||||||
|
image = "0.25"
|
||||||
|
rayon = "1"
|
||||||
|
walkdir = "2"
|
||||||
|
mozjpeg = "0.10"
|
||||||
|
oxipng = "10"
|
||||||
|
webp = "0.3"
|
||||||
|
little_exif = "0.4"
|
||||||
|
imageproc = "0.25"
|
||||||
|
ab_glyph = "0.2"
|
||||||
|
dirs = "6"
|
||||||
|
notify = "7"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -1 +1,83 @@
|
|||||||
// Application configuration
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::watcher::WatchFolder;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub first_run_complete: bool,
|
||||||
|
pub tutorial_complete: bool,
|
||||||
|
pub output_subfolder: String,
|
||||||
|
pub output_fixed_path: Option<String>,
|
||||||
|
pub overwrite_behavior: OverwriteBehavior,
|
||||||
|
pub remember_settings: bool,
|
||||||
|
pub skill_level: SkillLevel,
|
||||||
|
pub thread_count: ThreadCount,
|
||||||
|
pub error_behavior: ErrorBehavior,
|
||||||
|
pub notify_on_completion: bool,
|
||||||
|
pub play_completion_sound: bool,
|
||||||
|
pub auto_open_output: bool,
|
||||||
|
pub high_contrast: bool,
|
||||||
|
pub large_text: bool,
|
||||||
|
pub reduced_motion: bool,
|
||||||
|
pub history_max_entries: usize,
|
||||||
|
pub history_max_days: u32,
|
||||||
|
pub watch_folders: Vec<WatchFolder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
first_run_complete: false,
|
||||||
|
tutorial_complete: false,
|
||||||
|
output_subfolder: "processed".into(),
|
||||||
|
output_fixed_path: None,
|
||||||
|
overwrite_behavior: OverwriteBehavior::Ask,
|
||||||
|
remember_settings: true,
|
||||||
|
skill_level: SkillLevel::Simple,
|
||||||
|
thread_count: ThreadCount::Auto,
|
||||||
|
error_behavior: ErrorBehavior::SkipAndContinue,
|
||||||
|
notify_on_completion: true,
|
||||||
|
play_completion_sound: false,
|
||||||
|
auto_open_output: false,
|
||||||
|
high_contrast: false,
|
||||||
|
large_text: false,
|
||||||
|
reduced_motion: false,
|
||||||
|
history_max_entries: 50,
|
||||||
|
history_max_days: 30,
|
||||||
|
watch_folders: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum OverwriteBehavior {
|
||||||
|
Ask,
|
||||||
|
AutoRename,
|
||||||
|
Overwrite,
|
||||||
|
Skip,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum SkillLevel {
|
||||||
|
Simple,
|
||||||
|
Detailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillLevel {
|
||||||
|
pub fn is_advanced(&self) -> bool {
|
||||||
|
matches!(self, Self::Detailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ThreadCount {
|
||||||
|
Auto,
|
||||||
|
Manual(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ErrorBehavior {
|
||||||
|
SkipAndContinue,
|
||||||
|
PauseOnError,
|
||||||
|
}
|
||||||
|
|||||||
56
pixstrip-core/src/discovery.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
pub const IMAGE_EXTENSIONS: &[&str] = &[
|
||||||
|
"jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn is_image_extension(ext: &str) -> bool {
|
||||||
|
IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_images(path: &Path, recursive: bool) -> Vec<PathBuf> {
|
||||||
|
if path.is_file() {
|
||||||
|
if let Some(ext) = path.extension().and_then(|e| e.to_str())
|
||||||
|
&& is_image_extension(ext)
|
||||||
|
{
|
||||||
|
return vec![path.to_path_buf()];
|
||||||
|
}
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_depth = if recursive { usize::MAX } else { 1 };
|
||||||
|
|
||||||
|
WalkDir::new(path)
|
||||||
|
.max_depth(max_depth)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| entry.file_type().is_file())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(is_image_extension)
|
||||||
|
})
|
||||||
|
.map(|entry| entry.into_path())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_subdirectories(path: &Path) -> bool {
|
||||||
|
if !path.is_dir() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::fs::read_dir(path)
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.any(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
303
pixstrip-core/src/encoder.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
use crate::types::{ImageFormat, QualityPreset};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct EncoderOptions {
|
||||||
|
pub progressive_jpeg: bool,
|
||||||
|
pub avif_speed: u8,
|
||||||
|
/// Output DPI (0 means don't set / use default)
|
||||||
|
pub output_dpi: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OutputEncoder {
|
||||||
|
pub options: EncoderOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputEncoder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
options: EncoderOptions {
|
||||||
|
progressive_jpeg: false,
|
||||||
|
avif_speed: 6,
|
||||||
|
output_dpi: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_options(options: EncoderOptions) -> Self {
|
||||||
|
Self { options }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(
|
||||||
|
&self,
|
||||||
|
img: &image::DynamicImage,
|
||||||
|
format: ImageFormat,
|
||||||
|
quality: Option<u8>,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
match format {
|
||||||
|
ImageFormat::Jpeg => self.encode_jpeg(img, quality.unwrap_or(85)),
|
||||||
|
ImageFormat::Png => self.encode_png(img, quality.unwrap_or(3)),
|
||||||
|
ImageFormat::WebP => self.encode_webp(img, quality.unwrap_or(85)),
|
||||||
|
ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(63)),
|
||||||
|
ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif),
|
||||||
|
ImageFormat::Tiff => self.encode_fallback(img, image::ImageFormat::Tiff),
|
||||||
|
ImageFormat::Bmp => self.encode_fallback(img, image::ImageFormat::Bmp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_to_file(
|
||||||
|
&self,
|
||||||
|
img: &image::DynamicImage,
|
||||||
|
path: &Path,
|
||||||
|
format: ImageFormat,
|
||||||
|
quality: Option<u8>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let bytes = self.encode(img, format, quality)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, &bytes).map_err(PixstripError::Io)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quality_for_format(&self, format: ImageFormat, preset: &QualityPreset) -> u8 {
|
||||||
|
match format {
|
||||||
|
ImageFormat::Jpeg => preset.jpeg_quality(),
|
||||||
|
ImageFormat::WebP => preset.webp_quality() as u8,
|
||||||
|
ImageFormat::Avif => preset.avif_quality() as u8,
|
||||||
|
ImageFormat::Png => preset.png_level(),
|
||||||
|
ImageFormat::Gif | ImageFormat::Tiff | ImageFormat::Bmp => preset.jpeg_quality(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_jpeg(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
|
||||||
|
let rgb = img.to_rgb8();
|
||||||
|
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
|
let pixels = rgb.as_raw();
|
||||||
|
|
||||||
|
let mut comp = mozjpeg::Compress::new(mozjpeg::ColorSpace::JCS_RGB);
|
||||||
|
comp.set_size(width, height);
|
||||||
|
comp.set_quality(quality as f32);
|
||||||
|
if self.options.progressive_jpeg {
|
||||||
|
comp.set_progressive_mode();
|
||||||
|
}
|
||||||
|
if self.options.output_dpi > 0 {
|
||||||
|
comp.set_pixel_density(mozjpeg::PixelDensity {
|
||||||
|
unit: mozjpeg::PixelDensityUnit::Inches,
|
||||||
|
x: self.options.output_dpi.min(65535) as u16,
|
||||||
|
y: self.options.output_dpi.min(65535) as u16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let mut started = comp.start_compress(&mut output).map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "jpeg_encode".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let row_stride = width * 3;
|
||||||
|
for y in 0..height {
|
||||||
|
let start = y * row_stride;
|
||||||
|
let end = start + row_stride;
|
||||||
|
started.write_scanlines(&pixels[start..end]).map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "jpeg_scanline".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
started.finish().map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "jpeg_encode".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_png(&self, img: &image::DynamicImage, level: u8) -> Result<Vec<u8>> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let cursor = Cursor::new(&mut buf);
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let encoder = image::codecs::png::PngEncoder::new(cursor);
|
||||||
|
image::ImageEncoder::write_image(
|
||||||
|
encoder,
|
||||||
|
rgba.as_raw(),
|
||||||
|
rgba.width(),
|
||||||
|
rgba.height(),
|
||||||
|
image::ExtendedColorType::Rgba8,
|
||||||
|
)
|
||||||
|
.map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "png_encode".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Insert pHYs chunk for DPI if requested (before oxipng, which preserves it)
|
||||||
|
if self.options.output_dpi > 0 {
|
||||||
|
buf = insert_png_phys_chunk(&buf, self.options.output_dpi);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut opts = oxipng::Options::default();
|
||||||
|
opts.optimize_alpha = true;
|
||||||
|
opts.deflater = oxipng::Deflater::Libdeflater { compression: level.clamp(1, 12) };
|
||||||
|
|
||||||
|
let optimized = oxipng::optimize_from_memory(&buf, &opts)
|
||||||
|
.map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "png_optimize".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(optimized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_webp(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
|
||||||
|
let encoder = webp::Encoder::from_image(img).map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "webp_encode".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
let mem = encoder.encode(quality as f32);
|
||||||
|
Ok(mem.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_avif(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let cursor = Cursor::new(&mut buf);
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let speed = self.options.avif_speed.clamp(0, 10);
|
||||||
|
let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(
|
||||||
|
cursor,
|
||||||
|
speed,
|
||||||
|
quality,
|
||||||
|
);
|
||||||
|
image::ImageEncoder::write_image(
|
||||||
|
encoder,
|
||||||
|
rgba.as_raw(),
|
||||||
|
rgba.width(),
|
||||||
|
rgba.height(),
|
||||||
|
image::ExtendedColorType::Rgba8,
|
||||||
|
)
|
||||||
|
.map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "avif_encode".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_fallback(
|
||||||
|
&self,
|
||||||
|
img: &image::DynamicImage,
|
||||||
|
format: image::ImageFormat,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let cursor = Cursor::new(&mut buf);
|
||||||
|
img.write_to(cursor, format).map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "encode".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a pHYs chunk into PNG data to set DPI.
|
||||||
|
/// The pHYs chunk must appear before the first IDAT chunk.
|
||||||
|
/// DPI is converted to pixels per meter (1 inch = 0.0254 meters).
|
||||||
|
fn insert_png_phys_chunk(png_data: &[u8], dpi: u32) -> Vec<u8> {
|
||||||
|
// Not a valid PNG (too short for signature) - return as-is
|
||||||
|
if png_data.len() < 8 {
|
||||||
|
return png_data.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG pixels per meter = DPI / 0.0254
|
||||||
|
let ppm = (dpi as f64 / 0.0254).round() as u32;
|
||||||
|
|
||||||
|
// Build the pHYs chunk:
|
||||||
|
// 4 bytes: data length (9)
|
||||||
|
// 4 bytes: chunk type "pHYs"
|
||||||
|
// 4 bytes: pixels per unit X
|
||||||
|
// 4 bytes: pixels per unit Y
|
||||||
|
// 1 byte: unit (1 = meter)
|
||||||
|
// 4 bytes: CRC32 of type + data
|
||||||
|
let mut chunk_data = Vec::with_capacity(9);
|
||||||
|
chunk_data.extend_from_slice(&ppm.to_be_bytes()); // X pixels per unit
|
||||||
|
chunk_data.extend_from_slice(&ppm.to_be_bytes()); // Y pixels per unit
|
||||||
|
chunk_data.push(1); // unit = meter
|
||||||
|
|
||||||
|
let mut crc_input = Vec::with_capacity(13);
|
||||||
|
crc_input.extend_from_slice(b"pHYs");
|
||||||
|
crc_input.extend_from_slice(&chunk_data);
|
||||||
|
|
||||||
|
let crc = crc32_png(&crc_input);
|
||||||
|
|
||||||
|
let mut phys_chunk = Vec::with_capacity(21);
|
||||||
|
phys_chunk.extend_from_slice(&9u32.to_be_bytes()); // length
|
||||||
|
phys_chunk.extend_from_slice(b"pHYs"); // type
|
||||||
|
phys_chunk.extend_from_slice(&chunk_data); // data
|
||||||
|
phys_chunk.extend_from_slice(&crc.to_be_bytes()); // CRC
|
||||||
|
|
||||||
|
// Find the first IDAT chunk and insert pHYs before it.
|
||||||
|
// PNG structure: 8-byte signature, then chunks (each: 4 len + 4 type + data + 4 crc)
|
||||||
|
let mut result = Vec::with_capacity(png_data.len() + phys_chunk.len());
|
||||||
|
let mut pos = 8; // skip PNG signature
|
||||||
|
let mut phys_inserted = false;
|
||||||
|
result.extend_from_slice(&png_data[..8]);
|
||||||
|
|
||||||
|
while pos + 8 <= png_data.len() {
|
||||||
|
let chunk_len = u32::from_be_bytes([
|
||||||
|
png_data[pos], png_data[pos + 1], png_data[pos + 2], png_data[pos + 3],
|
||||||
|
]) as usize;
|
||||||
|
let chunk_type = &png_data[pos + 4..pos + 8];
|
||||||
|
// Use checked arithmetic to prevent overflow on malformed PNGs
|
||||||
|
let Some(total_chunk_size) = chunk_len.checked_add(12) else {
|
||||||
|
break; // chunk_len so large it overflows - malformed PNG
|
||||||
|
};
|
||||||
|
|
||||||
|
if pos.checked_add(total_chunk_size).map_or(true, |end| end > png_data.len()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip any existing pHYs (we're replacing it)
|
||||||
|
if chunk_type == b"pHYs" {
|
||||||
|
pos += total_chunk_size;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert our pHYs before the first IDAT
|
||||||
|
if chunk_type == b"IDAT" && !phys_inserted {
|
||||||
|
result.extend_from_slice(&phys_chunk);
|
||||||
|
phys_inserted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.extend_from_slice(&png_data[pos..pos + total_chunk_size]);
|
||||||
|
pos += total_chunk_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy any remaining bytes (e.g. trailing data after a truncated chunk)
|
||||||
|
if pos < png_data.len() {
|
||||||
|
result.extend_from_slice(&png_data[pos..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple CRC32 for PNG chunks (uses the standard PNG CRC polynomial)
|
||||||
|
fn crc32_png(data: &[u8]) -> u32 {
|
||||||
|
let mut crc: u32 = 0xFFFF_FFFF;
|
||||||
|
for &byte in data {
|
||||||
|
crc ^= byte as u32;
|
||||||
|
for _ in 0..8 {
|
||||||
|
if crc & 1 != 0 {
|
||||||
|
crc = (crc >> 1) ^ 0xEDB8_8320;
|
||||||
|
} else {
|
||||||
|
crc >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crc ^ 0xFFFF_FFFF
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OutputEncoder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
828
pixstrip-core/src/executor.rs
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use rayon::prelude::*;
|
||||||
|
|
||||||
|
use crate::encoder::{EncoderOptions, OutputEncoder};
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
use crate::loader::ImageLoader;
|
||||||
|
use crate::operations::adjustments::apply_adjustments;
|
||||||
|
use crate::operations::resize::resize_image_with_algorithm;
|
||||||
|
use crate::operations::watermark::apply_watermark;
|
||||||
|
use crate::operations::{Flip, Rotation};
|
||||||
|
use crate::pipeline::ProcessingJob;
|
||||||
|
use crate::types::ImageFormat;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProgressUpdate {
|
||||||
|
pub current: usize,
|
||||||
|
pub total: usize,
|
||||||
|
pub current_file: String,
|
||||||
|
pub succeeded_so_far: usize,
|
||||||
|
pub failed_so_far: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BatchResult {
|
||||||
|
pub total: usize,
|
||||||
|
pub succeeded: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
pub cancelled: bool,
|
||||||
|
pub total_input_bytes: u64,
|
||||||
|
pub total_output_bytes: u64,
|
||||||
|
pub errors: Vec<(String, String)>,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
/// Actual output file paths written by the executor (only successfully written files).
|
||||||
|
pub output_files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PipelineExecutor {
|
||||||
|
cancel_flag: Arc<AtomicBool>,
|
||||||
|
pause_flag: Arc<AtomicBool>,
|
||||||
|
thread_count: usize,
|
||||||
|
pause_on_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipelineExecutor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
pause_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
thread_count: num_cpus(),
|
||||||
|
pause_on_error: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_cancel(cancel_flag: Arc<AtomicBool>) -> Self {
|
||||||
|
Self {
|
||||||
|
cancel_flag,
|
||||||
|
pause_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
thread_count: num_cpus(),
|
||||||
|
pause_on_error: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_cancel_and_pause(
|
||||||
|
cancel_flag: Arc<AtomicBool>,
|
||||||
|
pause_flag: Arc<AtomicBool>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
cancel_flag,
|
||||||
|
pause_flag,
|
||||||
|
thread_count: num_cpus(),
|
||||||
|
pause_on_error: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_thread_count(&mut self, count: usize) {
|
||||||
|
self.thread_count = if count == 0 { num_cpus() } else { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pause_on_error(&mut self, pause: bool) {
|
||||||
|
self.pause_on_error = pause;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute<F>(&self, job: &ProcessingJob, mut on_progress: F) -> Result<BatchResult>
|
||||||
|
where
|
||||||
|
F: FnMut(ProgressUpdate) + Send,
|
||||||
|
{
|
||||||
|
let start = Instant::now();
|
||||||
|
let total = job.sources.len();
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return Ok(BatchResult {
|
||||||
|
total: 0,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
cancelled: false,
|
||||||
|
total_input_bytes: 0,
|
||||||
|
total_output_bytes: 0,
|
||||||
|
errors: Vec::new(),
|
||||||
|
elapsed_ms: 0,
|
||||||
|
output_files: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use single-threaded path when thread_count is 1
|
||||||
|
if self.thread_count <= 1 {
|
||||||
|
return self.execute_sequential(job, on_progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a scoped rayon thread pool with the configured count
|
||||||
|
let pool = rayon::ThreadPoolBuilder::new()
|
||||||
|
.num_threads(self.thread_count)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "thread_pool".into(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let completed = AtomicUsize::new(0);
|
||||||
|
let succeeded_count = AtomicUsize::new(0);
|
||||||
|
let failed_count = AtomicUsize::new(0);
|
||||||
|
let input_bytes = AtomicU64::new(0);
|
||||||
|
let output_bytes = AtomicU64::new(0);
|
||||||
|
let cancelled = AtomicBool::new(false);
|
||||||
|
let errors: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
|
||||||
|
let written_files: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
// Channel for progress updates from worker threads to the callback
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<ProgressUpdate>();
|
||||||
|
|
||||||
|
let cancel_flag = &self.cancel_flag;
|
||||||
|
let pause_flag = &self.pause_flag;
|
||||||
|
let pause_on_error = self.pause_on_error;
|
||||||
|
|
||||||
|
// Run the parallel work in a scoped thread so we can drain progress concurrently
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
// Spawn the rayon pool work
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
let completed_ref = &completed;
|
||||||
|
let succeeded_ref = &succeeded_count;
|
||||||
|
let failed_ref = &failed_count;
|
||||||
|
let input_bytes_ref = &input_bytes;
|
||||||
|
let output_bytes_ref = &output_bytes;
|
||||||
|
let cancelled_ref = &cancelled;
|
||||||
|
let errors_ref = &errors;
|
||||||
|
let written_ref = &written_files;
|
||||||
|
|
||||||
|
let worker = scope.spawn(move || {
|
||||||
|
pool.install(|| {
|
||||||
|
job.sources.par_iter().enumerate().for_each(|(idx, source)| {
|
||||||
|
// Check cancel
|
||||||
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
cancelled_ref.store(true, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait while paused
|
||||||
|
while pause_flag.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
cancelled_ref.store(true, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cancelled_ref.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = source
|
||||||
|
.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let encoder = OutputEncoder::with_options(EncoderOptions {
|
||||||
|
progressive_jpeg: job.progressive_jpeg,
|
||||||
|
avif_speed: job.avif_speed,
|
||||||
|
output_dpi: job.output_dpi,
|
||||||
|
});
|
||||||
|
|
||||||
|
match Self::process_single_static(job, source, &loader, &encoder, idx) {
|
||||||
|
Ok((in_size, out_size, out_path)) => {
|
||||||
|
succeeded_ref.fetch_add(1, Ordering::Relaxed);
|
||||||
|
input_bytes_ref.fetch_add(in_size, Ordering::Relaxed);
|
||||||
|
output_bytes_ref.fetch_add(out_size, Ordering::Relaxed);
|
||||||
|
if !out_path.as_os_str().is_empty() {
|
||||||
|
if let Ok(mut wf) = written_ref.lock() {
|
||||||
|
wf.push(out_path.display().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
failed_ref.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if let Ok(mut errs) = errors_ref.lock() {
|
||||||
|
errs.push((file_name.clone(), e.to_string()));
|
||||||
|
}
|
||||||
|
if pause_on_error {
|
||||||
|
pause_flag.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send progress after processing so counts are consistent
|
||||||
|
let current = completed_ref.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
let _ = tx_clone.send(ProgressUpdate {
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
current_file: file_name,
|
||||||
|
succeeded_so_far: succeeded_ref.load(Ordering::Relaxed),
|
||||||
|
failed_so_far: failed_ref.load(Ordering::Relaxed),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Drop sender so the receiver loop ends
|
||||||
|
drop(tx_clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop the original sender so only the worker's clone keeps the channel open
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
// Drain progress updates on this thread (the calling thread)
|
||||||
|
for update in rx {
|
||||||
|
on_progress(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for worker to finish (it already has by the time rx is drained)
|
||||||
|
let _ = worker.join();
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(BatchResult {
|
||||||
|
total,
|
||||||
|
succeeded: succeeded_count.load(Ordering::Relaxed),
|
||||||
|
failed: failed_count.load(Ordering::Relaxed),
|
||||||
|
cancelled: cancelled.load(Ordering::Relaxed),
|
||||||
|
total_input_bytes: input_bytes.load(Ordering::Relaxed),
|
||||||
|
total_output_bytes: output_bytes.load(Ordering::Relaxed),
|
||||||
|
errors: errors.into_inner().unwrap_or_default(),
|
||||||
|
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||||
|
output_files: written_files.into_inner().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_sequential<F>(&self, job: &ProcessingJob, mut on_progress: F) -> Result<BatchResult>
|
||||||
|
where
|
||||||
|
F: FnMut(ProgressUpdate) + Send,
|
||||||
|
{
|
||||||
|
let start = Instant::now();
|
||||||
|
let total = job.sources.len();
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let encoder = OutputEncoder::with_options(EncoderOptions {
|
||||||
|
progressive_jpeg: job.progressive_jpeg,
|
||||||
|
avif_speed: job.avif_speed,
|
||||||
|
output_dpi: job.output_dpi,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut result = BatchResult {
|
||||||
|
total,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
cancelled: false,
|
||||||
|
total_input_bytes: 0,
|
||||||
|
total_output_bytes: 0,
|
||||||
|
errors: Vec::new(),
|
||||||
|
elapsed_ms: 0,
|
||||||
|
output_files: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (i, source) in job.sources.iter().enumerate() {
|
||||||
|
if self.cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
result.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
while self.pause_flag.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
if self.cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
result.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.cancelled {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = source
|
||||||
|
.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
on_progress(ProgressUpdate {
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
current_file: file_name.clone(),
|
||||||
|
succeeded_so_far: result.succeeded,
|
||||||
|
failed_so_far: result.failed,
|
||||||
|
});
|
||||||
|
|
||||||
|
match Self::process_single_static(job, source, &loader, &encoder, i) {
|
||||||
|
Ok((input_size, output_size, out_path)) => {
|
||||||
|
result.succeeded += 1;
|
||||||
|
result.total_input_bytes += input_size;
|
||||||
|
result.total_output_bytes += output_size;
|
||||||
|
if !out_path.as_os_str().is_empty() {
|
||||||
|
result.output_files.push(out_path.display().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
result.failed += 1;
|
||||||
|
result.errors.push((file_name, e.to_string()));
|
||||||
|
if self.pause_on_error {
|
||||||
|
self.pause_flag.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.elapsed_ms = start.elapsed().as_millis() as u64;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the renamed output path for a source file.
|
||||||
|
/// `ext` is the output extension, `dims` is (width, height) or None.
|
||||||
|
fn compute_renamed_path(
|
||||||
|
job: &ProcessingJob,
|
||||||
|
rename: &crate::operations::RenameConfig,
|
||||||
|
source: &crate::types::ImageSource,
|
||||||
|
ext: &str,
|
||||||
|
index: usize,
|
||||||
|
dims: Option<(u32, u32)>,
|
||||||
|
) -> std::path::PathBuf {
|
||||||
|
let stem = source.path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
|
||||||
|
if let Some(ref template) = rename.template {
|
||||||
|
let original_ext = source.path.extension().and_then(|e| e.to_str());
|
||||||
|
let working_stem = crate::operations::rename::apply_regex_replace(
|
||||||
|
stem, &rename.regex_find, &rename.regex_replace,
|
||||||
|
);
|
||||||
|
let working_stem = crate::operations::rename::apply_space_replacement(&working_stem, rename.replace_spaces);
|
||||||
|
let working_stem = crate::operations::rename::apply_special_chars(&working_stem, rename.special_chars);
|
||||||
|
let new_name = crate::operations::rename::apply_template_full(
|
||||||
|
template, &working_stem, ext,
|
||||||
|
rename.counter_start.saturating_add(index as u32),
|
||||||
|
dims, original_ext, Some(&source.path), None,
|
||||||
|
);
|
||||||
|
let new_name = if rename.case_mode > 0 {
|
||||||
|
if let Some(dot_pos) = new_name.rfind('.') {
|
||||||
|
let (name_part, ext_part) = new_name.split_at(dot_pos);
|
||||||
|
format!("{}{}", crate::operations::rename::apply_case_conversion(name_part, rename.case_mode), ext_part)
|
||||||
|
} else {
|
||||||
|
crate::operations::rename::apply_case_conversion(&new_name, rename.case_mode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
new_name
|
||||||
|
};
|
||||||
|
job.output_dir.join(sanitize_filename(&new_name))
|
||||||
|
} else {
|
||||||
|
let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1));
|
||||||
|
job.output_dir.join(sanitize_filename(&new_name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_single_static(
|
||||||
|
job: &ProcessingJob,
|
||||||
|
source: &crate::types::ImageSource,
|
||||||
|
loader: &ImageLoader,
|
||||||
|
encoder: &OutputEncoder,
|
||||||
|
index: usize,
|
||||||
|
) -> std::result::Result<(u64, u64, std::path::PathBuf), PixstripError> {
|
||||||
|
let input_size = std::fs::metadata(&source.path)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Metadata stripping via little_exif only works for JPEG/TIFF.
|
||||||
|
// For other formats, force the pixel path so re-encoding strips metadata.
|
||||||
|
let metadata_needs_reencode = job.metadata.as_ref().is_some_and(|m| {
|
||||||
|
!matches!(m, crate::operations::MetadataConfig::KeepAll)
|
||||||
|
&& !matches!(
|
||||||
|
source.original_format,
|
||||||
|
Some(ImageFormat::Jpeg) | Some(ImageFormat::Tiff)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast path: if no pixel processing needed (rename-only or rename+metadata),
|
||||||
|
// just copy the file instead of decoding/re-encoding.
|
||||||
|
if !job.needs_pixel_processing() && !metadata_needs_reencode {
|
||||||
|
let output_path = if let Some(ref rename) = job.rename {
|
||||||
|
let ext = source.path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||||
|
// Read dimensions without full decode for {width}/{height} templates
|
||||||
|
let dims = if rename.template.is_some() {
|
||||||
|
image::ImageReader::open(&source.path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|r| r.with_guessed_format().ok())
|
||||||
|
.and_then(|r| r.into_dimensions().ok())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Self::compute_renamed_path(job, rename, source, ext, index, dims)
|
||||||
|
} else {
|
||||||
|
job.output_path_for(source, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent overwriting the source file
|
||||||
|
if paths_are_same(&source.path, &output_path) {
|
||||||
|
return Err(PixstripError::Processing {
|
||||||
|
operation: "output".into(),
|
||||||
|
reason: format!("output path is the same as source: {}", source.path.display()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_path = match job.overwrite_behavior {
|
||||||
|
crate::operations::OverwriteAction::Skip if output_path.exists() => {
|
||||||
|
return Ok((0, 0, std::path::PathBuf::new()));
|
||||||
|
}
|
||||||
|
crate::operations::OverwriteAction::AutoRename if output_path.exists() => {
|
||||||
|
find_unique_path(&output_path)
|
||||||
|
}
|
||||||
|
_ => output_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(parent) = output_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
cleanup_placeholder(&output_path);
|
||||||
|
PixstripError::Io(e)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::copy(&source.path, &output_path).map_err(|e| {
|
||||||
|
cleanup_placeholder(&output_path);
|
||||||
|
PixstripError::Io(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Metadata handling on the copy
|
||||||
|
if let Some(ref meta_config) = job.metadata {
|
||||||
|
match meta_config {
|
||||||
|
crate::operations::MetadataConfig::KeepAll => {
|
||||||
|
// Already a copy - metadata preserved
|
||||||
|
}
|
||||||
|
crate::operations::MetadataConfig::StripAll => {
|
||||||
|
if !strip_all_metadata(&output_path) {
|
||||||
|
eprintln!("Warning: failed to strip metadata from {}", output_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
strip_selective_metadata(&output_path, meta_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_size = std::fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
return Ok((input_size, output_size, output_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load image
|
||||||
|
let mut img = loader.load_pixels(&source.path)?;
|
||||||
|
|
||||||
|
// Rotation
|
||||||
|
if let Some(ref rotation) = job.rotation {
|
||||||
|
img = match rotation {
|
||||||
|
Rotation::None => img,
|
||||||
|
Rotation::Cw90 => img.rotate90(),
|
||||||
|
Rotation::Cw180 => img.rotate180(),
|
||||||
|
Rotation::Cw270 => img.rotate270(),
|
||||||
|
Rotation::AutoOrient => auto_orient_from_exif(img, &source.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip
|
||||||
|
if let Some(ref flip) = job.flip {
|
||||||
|
img = match flip {
|
||||||
|
Flip::None => img,
|
||||||
|
Flip::Horizontal => img.fliph(),
|
||||||
|
Flip::Vertical => img.flipv(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize
|
||||||
|
if let Some(ref config) = job.resize {
|
||||||
|
img = resize_image_with_algorithm(&img, config, job.resize_algorithm)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjustments (brightness, contrast, saturation, effects, crop, padding)
|
||||||
|
if let Some(ref adj) = job.adjustments {
|
||||||
|
if !adj.is_noop() {
|
||||||
|
img = apply_adjustments(img, adj)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output format
|
||||||
|
let output_format = if let Some(ref convert) = job.convert {
|
||||||
|
let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg);
|
||||||
|
convert.output_format(input_fmt)
|
||||||
|
} else {
|
||||||
|
source.original_format.unwrap_or(ImageFormat::Jpeg)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine quality
|
||||||
|
let quality = job.compress.as_ref().map(|c| match c {
|
||||||
|
crate::operations::CompressConfig::Preset(preset) => {
|
||||||
|
encoder.quality_for_format(output_format, preset)
|
||||||
|
}
|
||||||
|
crate::operations::CompressConfig::Custom {
|
||||||
|
jpeg_quality,
|
||||||
|
png_level,
|
||||||
|
webp_quality,
|
||||||
|
avif_quality,
|
||||||
|
} => match output_format {
|
||||||
|
ImageFormat::Jpeg => jpeg_quality.unwrap_or(85),
|
||||||
|
ImageFormat::Png => png_level.unwrap_or(6),
|
||||||
|
ImageFormat::WebP => webp_quality.map(|q| q.clamp(0.0, 100.0) as u8).unwrap_or(80),
|
||||||
|
ImageFormat::Avif => avif_quality.map(|q| q.clamp(0.0, 100.0) as u8).unwrap_or(50),
|
||||||
|
_ => 85,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine output path (with rename if configured)
|
||||||
|
let output_path = if let Some(ref rename) = job.rename {
|
||||||
|
let ext = output_format.extension();
|
||||||
|
let dims = Some((img.width(), img.height()));
|
||||||
|
Self::compute_renamed_path(job, rename, source, ext, index, dims)
|
||||||
|
} else {
|
||||||
|
job.output_path_for(source, Some(output_format))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent overwriting the source file
|
||||||
|
if paths_are_same(&source.path, &output_path) {
|
||||||
|
return Err(PixstripError::Processing {
|
||||||
|
operation: "output".into(),
|
||||||
|
reason: format!("output path is the same as source: {}", source.path.display()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle overwrite behavior
|
||||||
|
let output_path = match job.overwrite_behavior {
|
||||||
|
crate::operations::OverwriteAction::Skip => {
|
||||||
|
if output_path.exists() {
|
||||||
|
// Return 0 bytes written - file was skipped
|
||||||
|
return Ok((0, 0, std::path::PathBuf::new()));
|
||||||
|
}
|
||||||
|
output_path
|
||||||
|
}
|
||||||
|
crate::operations::OverwriteAction::AutoRename => {
|
||||||
|
if output_path.exists() {
|
||||||
|
find_unique_path(&output_path)
|
||||||
|
} else {
|
||||||
|
output_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::operations::OverwriteAction::Overwrite => output_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
if let Some(parent) = output_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
cleanup_placeholder(&output_path);
|
||||||
|
PixstripError::Io(e)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watermark (after compress settings determined, before encode)
|
||||||
|
if let Some(ref config) = job.watermark {
|
||||||
|
img = apply_watermark(img, config).map_err(|e| {
|
||||||
|
cleanup_placeholder(&output_path);
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode and save
|
||||||
|
encoder.encode_to_file(&img, &output_path, output_format, quality).map_err(|e| {
|
||||||
|
cleanup_placeholder(&output_path);
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Metadata handling: re-encoding strips all EXIF by default.
|
||||||
|
// KeepAll: copy everything back from source.
|
||||||
|
// Privacy/Custom: copy metadata back, then selectively strip certain tags.
|
||||||
|
// StripAll: do nothing (already stripped by re-encoding).
|
||||||
|
// Note: little_exif only supports JPEG and TIFF metadata manipulation.
|
||||||
|
if let Some(ref meta_config) = job.metadata {
|
||||||
|
let format_supports_exif = matches!(
|
||||||
|
output_format,
|
||||||
|
ImageFormat::Jpeg | ImageFormat::Tiff
|
||||||
|
);
|
||||||
|
match meta_config {
|
||||||
|
crate::operations::MetadataConfig::KeepAll => {
|
||||||
|
if format_supports_exif {
|
||||||
|
if !copy_metadata_from_source(&source.path, &output_path) {
|
||||||
|
eprintln!("Warning: failed to copy metadata to {}", output_path.display());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("Warning: metadata cannot be preserved for {} format ({})", output_format.extension(), output_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::operations::MetadataConfig::StripAll => {
|
||||||
|
// Already stripped by re-encoding - nothing to do
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Privacy or Custom: copy all metadata back, then strip unwanted tags
|
||||||
|
if format_supports_exif {
|
||||||
|
if !copy_metadata_from_source(&source.path, &output_path) {
|
||||||
|
eprintln!("Warning: failed to copy metadata to {}", output_path.display());
|
||||||
|
}
|
||||||
|
strip_selective_metadata(&output_path, meta_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_size = std::fs::metadata(&output_path)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok((input_size, output_size, output_path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PipelineExecutor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_cpus() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read EXIF orientation tag and apply the appropriate rotation/flip.
|
||||||
|
/// EXIF orientation values:
|
||||||
|
/// 1 = Normal, 2 = Flipped horizontal, 3 = Rotated 180,
|
||||||
|
/// 4 = Flipped vertical, 5 = Transposed (flip H + rotate 270),
|
||||||
|
/// 6 = Rotated 90 CW, 7 = Transverse (flip H + rotate 90),
|
||||||
|
/// 8 = Rotated 270 CW
|
||||||
|
fn auto_orient_from_exif(
|
||||||
|
img: image::DynamicImage,
|
||||||
|
path: &std::path::Path,
|
||||||
|
) -> image::DynamicImage {
|
||||||
|
let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(path) else {
|
||||||
|
return img;
|
||||||
|
};
|
||||||
|
|
||||||
|
let endian = metadata.get_endian();
|
||||||
|
let Some(tag) = metadata.get_tag(&little_exif::exif_tag::ExifTag::Orientation(Vec::new())) else {
|
||||||
|
return img;
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = tag.value_as_u8_vec(endian);
|
||||||
|
if bytes.len() < 2 {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orientation is a 16-bit unsigned integer
|
||||||
|
let orientation = match endian {
|
||||||
|
little_exif::endian::Endian::Little => u16::from_le_bytes([bytes[0], bytes[1]]),
|
||||||
|
little_exif::endian::Endian::Big => u16::from_be_bytes([bytes[0], bytes[1]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
match orientation {
|
||||||
|
1 => img, // Normal
|
||||||
|
2 => img.fliph(), // Flipped horizontal
|
||||||
|
3 => img.rotate180(), // Rotated 180
|
||||||
|
4 => img.flipv(), // Flipped vertical
|
||||||
|
5 => img.fliph().rotate270(), // Transposed
|
||||||
|
6 => img.rotate90(), // Rotated 90 CW
|
||||||
|
7 => img.fliph().rotate90(), // Transverse
|
||||||
|
8 => img.rotate270(), // Rotated 270 CW
|
||||||
|
_ => img,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip path separators and parent-directory components from a user-provided filename.
|
||||||
|
/// Prevents path traversal attacks via rename templates (e.g., "../../etc/passwd").
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
let path = std::path::Path::new(name);
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("output")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if two paths refer to the same file (resolves symlinks and relative paths).
|
||||||
|
fn paths_are_same(a: &std::path::Path, b: &std::path::Path) -> bool {
|
||||||
|
match (a.canonicalize(), b.canonicalize()) {
|
||||||
|
(Ok(ca), Ok(cb)) => ca == cb,
|
||||||
|
_ => {
|
||||||
|
// If canonicalize fails (output file doesn't exist yet),
|
||||||
|
// canonicalize parent directories and compare with filename appended
|
||||||
|
let resolve = |p: &std::path::Path| -> Option<std::path::PathBuf> {
|
||||||
|
let parent = p.parent()?;
|
||||||
|
let name = p.file_name()?;
|
||||||
|
Some(parent.canonicalize().ok()?.join(name))
|
||||||
|
};
|
||||||
|
match (resolve(a), resolve(b)) {
|
||||||
|
(Some(ra), Some(rb)) => ra == rb,
|
||||||
|
_ => a.as_os_str() == b.as_os_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a placeholder file created by find_unique_path on error.
|
||||||
|
/// Placeholders are either 0-byte (no marker) or 1-byte (marker `~`).
|
||||||
|
fn cleanup_placeholder(path: &std::path::Path) {
|
||||||
|
if let Ok(meta) = std::fs::metadata(path) {
|
||||||
|
if meta.len() <= 1 {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("output");
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("bin");
|
||||||
|
let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||||
|
|
||||||
|
// Use O_CREAT | O_EXCL via create_new() to atomically reserve the path,
|
||||||
|
// preventing TOCTOU races when multiple threads auto-rename concurrently.
|
||||||
|
for i in 1u32..10000 {
|
||||||
|
let candidate = parent.join(format!("{}_{}.{}", stem, i, ext));
|
||||||
|
match std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&candidate)
|
||||||
|
{
|
||||||
|
Ok(mut f) => {
|
||||||
|
// Write a marker byte so cleanup_placeholder (which only removes
|
||||||
|
// 0-byte files) won't delete our reservation before the real write
|
||||||
|
let _ = std::io::Write::write_all(&mut f, b"~");
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extremely unlikely fallback - use timestamp for uniqueness
|
||||||
|
parent.join(format!("{}_{}.{}", stem, std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0), ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) -> bool {
|
||||||
|
let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(source) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
metadata.write_to_file(output).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_all_metadata(path: &std::path::Path) -> bool {
|
||||||
|
let empty = little_exif::metadata::Metadata::new();
|
||||||
|
empty.write_to_file(path).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_selective_metadata(
|
||||||
|
path: &std::path::Path,
|
||||||
|
config: &crate::operations::MetadataConfig,
|
||||||
|
) -> bool {
|
||||||
|
use little_exif::exif_tag::ExifTag;
|
||||||
|
use little_exif::metadata::Metadata;
|
||||||
|
|
||||||
|
// Read the metadata we just wrote back
|
||||||
|
let Ok(source_meta) = Metadata::new_from_path(path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a set of tag IDs to strip
|
||||||
|
let mut strip_ids: Vec<u16> = Vec::new();
|
||||||
|
|
||||||
|
if config.should_strip_gps() {
|
||||||
|
// GPSInfo pointer (0x8825) - removing it strips the GPS sub-IFD reference
|
||||||
|
strip_ids.push(ExifTag::GPSInfo(Vec::new()).as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.should_strip_camera() {
|
||||||
|
strip_ids.push(ExifTag::Make(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::Model(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensModel(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensMake(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SerialNumber(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensSerialNumber(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensInfo(Vec::new()).as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.should_strip_software() {
|
||||||
|
strip_ids.push(ExifTag::Software(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::MakerNote(Vec::new()).as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.should_strip_timestamps() {
|
||||||
|
strip_ids.push(ExifTag::ModifyDate(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::DateTimeOriginal(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::CreateDate(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SubSecTime(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SubSecTimeOriginal(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SubSecTimeDigitized(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OffsetTime(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OffsetTimeOriginal(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OffsetTimeDigitized(String::new()).as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.should_strip_copyright() {
|
||||||
|
strip_ids.push(ExifTag::Copyright(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::Artist(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OwnerName(String::new()).as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new metadata with only the tags we want to keep
|
||||||
|
let mut new_meta = Metadata::new();
|
||||||
|
for tag in source_meta.data() {
|
||||||
|
if !strip_ids.contains(&tag.as_u16()) {
|
||||||
|
new_meta.set_tag(tag.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new_meta.write_to_file(path).is_ok()
|
||||||
|
}
|
||||||
463
pixstrip-core/src/fm_integration.rs
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::preset::Preset;
|
||||||
|
use crate::storage::{atomic_write, PresetStore};
|
||||||
|
|
||||||
|
/// Supported file managers for right-click integration.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FileManager {
|
||||||
|
Nautilus,
|
||||||
|
Nemo,
|
||||||
|
Thunar,
|
||||||
|
Dolphin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileManager {
|
||||||
|
/// Human-readable name.
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Nautilus => "Nautilus",
|
||||||
|
Self::Nemo => "Nemo",
|
||||||
|
Self::Thunar => "Thunar",
|
||||||
|
Self::Dolphin => "Dolphin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop file ID used to detect if the FM is installed.
|
||||||
|
pub fn desktop_id(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Nautilus => "org.gnome.Nautilus",
|
||||||
|
Self::Nemo => "org.nemo.Nemo",
|
||||||
|
Self::Thunar => "thunar",
|
||||||
|
Self::Dolphin => "org.kde.dolphin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the integration extension for this file manager.
|
||||||
|
pub fn install(&self) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Nautilus => install_nautilus(),
|
||||||
|
Self::Nemo => install_nemo(),
|
||||||
|
Self::Thunar => install_thunar(),
|
||||||
|
Self::Dolphin => install_dolphin(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the integration extension for this file manager.
|
||||||
|
pub fn uninstall(&self) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Nautilus => uninstall_nautilus(),
|
||||||
|
Self::Nemo => uninstall_nemo(),
|
||||||
|
Self::Thunar => uninstall_thunar(),
|
||||||
|
Self::Dolphin => uninstall_dolphin(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the integration is currently installed.
|
||||||
|
pub fn is_installed(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Nautilus => nautilus_extension_path().exists(),
|
||||||
|
Self::Nemo => nemo_action_path().exists(),
|
||||||
|
Self::Thunar => thunar_action_path().exists(),
|
||||||
|
Self::Dolphin => dolphin_service_path().exists(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regenerate the extension files for all installed file managers.
|
||||||
|
/// Call this after presets change so the submenu stays up to date.
|
||||||
|
pub fn regenerate_all() -> Result<()> {
|
||||||
|
for fm in &[FileManager::Nautilus, FileManager::Nemo, FileManager::Thunar, FileManager::Dolphin] {
|
||||||
|
if fm.is_installed() {
|
||||||
|
fm.install()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a string for safe use in shell Exec= lines and XML command elements.
|
||||||
|
/// Removes or replaces characters that could cause shell injection.
|
||||||
|
fn shell_safe(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '(' | ')' | ','))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the GTK binary for "Open in Pixstrip" actions.
|
||||||
|
fn pixstrip_gtk_bin() -> String {
|
||||||
|
// When running from AppImage, use the AppImage path directly
|
||||||
|
if let Ok(appimage) = std::env::var("APPIMAGE") {
|
||||||
|
return appimage;
|
||||||
|
}
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
let dir = exe.parent().unwrap_or(Path::new("/usr/bin"));
|
||||||
|
let gtk_path = dir.join("pixstrip-gtk");
|
||||||
|
if gtk_path.exists() {
|
||||||
|
return gtk_path.display().to_string();
|
||||||
|
}
|
||||||
|
return exe.display().to_string();
|
||||||
|
}
|
||||||
|
"pixstrip-gtk".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the CLI binary for preset processing actions.
|
||||||
|
fn pixstrip_cli_bin() -> String {
|
||||||
|
// When running from AppImage, use the AppImage path directly
|
||||||
|
if let Ok(appimage) = std::env::var("APPIMAGE") {
|
||||||
|
return appimage;
|
||||||
|
}
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
let dir = exe.parent().unwrap_or(Path::new("/usr/bin"));
|
||||||
|
let cli_path = dir.join("pixstrip");
|
||||||
|
if cli_path.exists() {
|
||||||
|
return cli_path.display().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pixstrip".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_preset_names() -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = Preset::all_builtins()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.name)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let store = PresetStore::new();
|
||||||
|
if let Ok(user_presets) = store.list() {
|
||||||
|
for p in user_presets {
|
||||||
|
if p.is_custom && !names.contains(&p.name) {
|
||||||
|
names.push(p.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nautilus (Python extension) ---
|
||||||
|
|
||||||
|
fn nautilus_extension_dir() -> PathBuf {
|
||||||
|
let data = std::env::var("XDG_DATA_HOME")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||||
|
format!("{}/.local/share", home)
|
||||||
|
});
|
||||||
|
PathBuf::from(data).join("nautilus-python").join("extensions")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nautilus_extension_path() -> PathBuf {
|
||||||
|
nautilus_extension_dir().join("pixstrip-integration.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_nautilus() -> Result<()> {
|
||||||
|
let dir = nautilus_extension_dir();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
|
let presets = get_preset_names();
|
||||||
|
|
||||||
|
let mut preset_items = String::new();
|
||||||
|
for name in &presets {
|
||||||
|
preset_items.push_str(&format!(
|
||||||
|
" item = Nautilus.MenuItem(\n\
|
||||||
|
\x20 name='Pixstrip::Preset::{}',\n\
|
||||||
|
\x20 label='{}',\n\
|
||||||
|
\x20 )\n\
|
||||||
|
\x20 item.connect('activate', self._on_preset, '{}', files)\n\
|
||||||
|
\x20 submenu.append_item(item)\n\n",
|
||||||
|
name.replace(' ', "_"),
|
||||||
|
name.replace('\\', "\\\\").replace('\'', "\\'"),
|
||||||
|
name.replace('\\', "\\\\").replace('\'', "\\'"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let escaped_gtk_bin = gtk_bin.replace('\\', "\\\\").replace('\'', "\\'");
|
||||||
|
let escaped_cli_bin = cli_bin.replace('\\', "\\\\").replace('\'', "\\'");
|
||||||
|
let script = format!(
|
||||||
|
r#"import subprocess
|
||||||
|
from gi.repository import Nautilus, GObject
|
||||||
|
|
||||||
|
class PixstripExtension(GObject.GObject, Nautilus.MenuProvider):
|
||||||
|
def get_file_items(self, files):
|
||||||
|
if not files:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Only show for image files
|
||||||
|
image_mimes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif',
|
||||||
|
'image/tiff', 'image/avif', 'image/bmp']
|
||||||
|
valid = [f for f in files if f.get_mime_type() in image_mimes or f.is_directory()]
|
||||||
|
if not valid:
|
||||||
|
return []
|
||||||
|
|
||||||
|
top = Nautilus.MenuItem(
|
||||||
|
name='Pixstrip::Menu',
|
||||||
|
label='Process with Pixstrip',
|
||||||
|
icon='applications-graphics-symbolic',
|
||||||
|
)
|
||||||
|
|
||||||
|
submenu = Nautilus.Menu()
|
||||||
|
top.set_submenu(submenu)
|
||||||
|
|
||||||
|
# Open in Pixstrip (wizard)
|
||||||
|
open_item = Nautilus.MenuItem(
|
||||||
|
name='Pixstrip::Open',
|
||||||
|
label='Open in Pixstrip...',
|
||||||
|
)
|
||||||
|
open_item.connect('activate', self._on_open, files)
|
||||||
|
submenu.append_item(open_item)
|
||||||
|
|
||||||
|
# Separator via disabled item
|
||||||
|
sep = Nautilus.MenuItem(
|
||||||
|
name='Pixstrip::Sep',
|
||||||
|
label='---',
|
||||||
|
sensitive=False,
|
||||||
|
)
|
||||||
|
submenu.append_item(sep)
|
||||||
|
|
||||||
|
# Preset items
|
||||||
|
{preset_items}
|
||||||
|
return [top]
|
||||||
|
|
||||||
|
def _on_open(self, menu, files):
|
||||||
|
paths = [f.get_location().get_path() for f in files if f.get_location()]
|
||||||
|
subprocess.Popen(['{gtk_bin}'] + paths)
|
||||||
|
|
||||||
|
def _on_preset(self, menu, preset_name, files):
|
||||||
|
paths = [f.get_location().get_path() for f in files if f.get_location()]
|
||||||
|
subprocess.Popen(['{cli_bin}', 'process', '--preset', preset_name] + paths)
|
||||||
|
"#,
|
||||||
|
preset_items = preset_items,
|
||||||
|
gtk_bin = escaped_gtk_bin,
|
||||||
|
cli_bin = escaped_cli_bin,
|
||||||
|
);
|
||||||
|
|
||||||
|
atomic_write(&nautilus_extension_path(), &script)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uninstall_nautilus() -> Result<()> {
|
||||||
|
let path = nautilus_extension_path();
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nemo (.nemo_action files) ---
|
||||||
|
|
||||||
|
fn nemo_action_dir() -> PathBuf {
|
||||||
|
let data = std::env::var("XDG_DATA_HOME")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||||
|
format!("{}/.local/share", home)
|
||||||
|
});
|
||||||
|
PathBuf::from(data).join("nemo").join("actions")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nemo_action_path() -> PathBuf {
|
||||||
|
nemo_action_dir().join("pixstrip-open.nemo_action")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_nemo() -> Result<()> {
|
||||||
|
let dir = nemo_action_dir();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
|
|
||||||
|
// Main "Open in Pixstrip" action
|
||||||
|
let open_action = format!(
|
||||||
|
"[Nemo Action]\n\
|
||||||
|
Name=Open in Pixstrip...\n\
|
||||||
|
Comment=Process images with Pixstrip\n\
|
||||||
|
Exec={gtk_bin} %F\n\
|
||||||
|
Icon-Name=applications-graphics-symbolic\n\
|
||||||
|
Selection=Any\n\
|
||||||
|
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
||||||
|
Mimetypes=image/*;\n",
|
||||||
|
gtk_bin = gtk_bin,
|
||||||
|
);
|
||||||
|
atomic_write(&nemo_action_path(), &open_action)?;
|
||||||
|
|
||||||
|
// Per-preset actions
|
||||||
|
let presets = get_preset_names();
|
||||||
|
for name in &presets {
|
||||||
|
let safe_name = name.replace(' ', "-").to_lowercase();
|
||||||
|
let action_path = dir.join(format!("pixstrip-preset-{}.nemo_action", safe_name));
|
||||||
|
let action = format!(
|
||||||
|
"[Nemo Action]\n\
|
||||||
|
Name=Pixstrip: {name}\n\
|
||||||
|
Comment=Process with {name} preset\n\
|
||||||
|
Exec={cli_bin} process --preset \"{safe_label}\" %F\n\
|
||||||
|
Icon-Name=applications-graphics-symbolic\n\
|
||||||
|
Selection=Any\n\
|
||||||
|
Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\
|
||||||
|
Mimetypes=image/*;\n",
|
||||||
|
name = name,
|
||||||
|
safe_label = shell_safe(name),
|
||||||
|
cli_bin = cli_bin,
|
||||||
|
);
|
||||||
|
atomic_write(&action_path, &action)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uninstall_nemo() -> Result<()> {
|
||||||
|
let dir = nemo_action_dir();
|
||||||
|
// Remove main action
|
||||||
|
let main_path = nemo_action_path();
|
||||||
|
if main_path.exists() {
|
||||||
|
std::fs::remove_file(main_path)?;
|
||||||
|
}
|
||||||
|
// Remove preset actions
|
||||||
|
if dir.exists() {
|
||||||
|
for entry in std::fs::read_dir(&dir)?.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
if name_str.starts_with("pixstrip-preset-") && name_str.ends_with(".nemo_action") {
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Thunar (Custom Actions via uca.xml) ---
|
||||||
|
|
||||||
|
fn thunar_action_dir() -> PathBuf {
|
||||||
|
let config = std::env::var("XDG_CONFIG_HOME")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||||
|
format!("{}/.config", home)
|
||||||
|
});
|
||||||
|
PathBuf::from(config).join("Thunar")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thunar_action_path() -> PathBuf {
|
||||||
|
thunar_action_dir().join("pixstrip-actions.xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_thunar() -> Result<()> {
|
||||||
|
let dir = thunar_action_dir();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
|
let presets = get_preset_names();
|
||||||
|
|
||||||
|
let mut actions = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<actions>\n");
|
||||||
|
|
||||||
|
// Open in Pixstrip
|
||||||
|
actions.push_str(&format!(
|
||||||
|
" <action>\n\
|
||||||
|
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
||||||
|
\x20 <name>Open in Pixstrip...</name>\n\
|
||||||
|
\x20 <command>{gtk_bin} %F</command>\n\
|
||||||
|
\x20 <description>Process images with Pixstrip</description>\n\
|
||||||
|
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
||||||
|
\x20 <image-files/>\n\
|
||||||
|
\x20 <directories/>\n\
|
||||||
|
</action>\n",
|
||||||
|
gtk_bin = gtk_bin,
|
||||||
|
));
|
||||||
|
|
||||||
|
for name in &presets {
|
||||||
|
actions.push_str(&format!(
|
||||||
|
" <action>\n\
|
||||||
|
\x20 <icon>applications-graphics-symbolic</icon>\n\
|
||||||
|
\x20 <name>Pixstrip: {xml_name}</name>\n\
|
||||||
|
\x20 <command>{cli_bin} process --preset \"{safe_label}\" %F</command>\n\
|
||||||
|
\x20 <description>Process with {xml_name} preset</description>\n\
|
||||||
|
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
|
||||||
|
\x20 <image-files/>\n\
|
||||||
|
\x20 <directories/>\n\
|
||||||
|
</action>\n",
|
||||||
|
xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """),
|
||||||
|
safe_label = shell_safe(name),
|
||||||
|
cli_bin = cli_bin,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push_str("</actions>\n");
|
||||||
|
atomic_write(&thunar_action_path(), &actions)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uninstall_thunar() -> Result<()> {
|
||||||
|
let path = thunar_action_path();
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dolphin (KDE Service Menu) ---
|
||||||
|
|
||||||
|
fn dolphin_service_dir() -> PathBuf {
|
||||||
|
let data = std::env::var("XDG_DATA_HOME")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into()));
|
||||||
|
format!("{}/.local/share", home)
|
||||||
|
});
|
||||||
|
PathBuf::from(data).join("kio").join("servicemenus")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dolphin_service_path() -> PathBuf {
|
||||||
|
dolphin_service_dir().join("pixstrip.desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_dolphin() -> Result<()> {
|
||||||
|
let dir = dolphin_service_dir();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
let gtk_bin = pixstrip_gtk_bin();
|
||||||
|
let cli_bin = pixstrip_cli_bin();
|
||||||
|
let presets = get_preset_names();
|
||||||
|
|
||||||
|
let mut desktop = format!(
|
||||||
|
"[Desktop Entry]\n\
|
||||||
|
Type=Service\n\
|
||||||
|
X-KDE-ServiceTypes=KonqPopupMenu/Plugin\n\
|
||||||
|
MimeType=image/jpeg;image/png;image/webp;image/gif;image/tiff;image/avif;image/bmp;inode/directory;\n\
|
||||||
|
Actions=Open;{preset_actions}\n\
|
||||||
|
X-KDE-Submenu=Process with Pixstrip\n\
|
||||||
|
Icon=applications-graphics-symbolic\n\n\
|
||||||
|
[Desktop Action Open]\n\
|
||||||
|
Name=Open in Pixstrip...\n\
|
||||||
|
Icon=applications-graphics-symbolic\n\
|
||||||
|
Exec={gtk_bin} %F\n\n",
|
||||||
|
gtk_bin = gtk_bin,
|
||||||
|
preset_actions = presets
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, _)| format!("Preset{}", i))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";"),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, name) in presets.iter().enumerate() {
|
||||||
|
desktop.push_str(&format!(
|
||||||
|
"[Desktop Action Preset{i}]\n\
|
||||||
|
Name={name}\n\
|
||||||
|
Icon=applications-graphics-symbolic\n\
|
||||||
|
Exec={cli_bin} process --preset \"{safe_label}\" %F\n\n",
|
||||||
|
i = i,
|
||||||
|
name = name,
|
||||||
|
safe_label = shell_safe(name),
|
||||||
|
cli_bin = cli_bin,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic_write(&dolphin_service_path(), &desktop)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uninstall_dolphin() -> Result<()> {
|
||||||
|
let path = dolphin_service_path();
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod discovery;
|
||||||
|
pub mod encoder;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod executor;
|
||||||
|
pub mod fm_integration;
|
||||||
|
pub mod loader;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
pub mod storage;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod watcher;
|
||||||
|
|
||||||
pub fn version() -> &'static str {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
|
|||||||
67
pixstrip-core/src/loader.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
use crate::types::{Dimensions, ImageFormat};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ImageInfo {
|
||||||
|
pub dimensions: Dimensions,
|
||||||
|
pub format: Option<ImageFormat>,
|
||||||
|
pub file_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImageLoader;
|
||||||
|
|
||||||
|
impl ImageLoader {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_info(&self, path: &Path) -> Result<ImageInfo> {
|
||||||
|
let metadata = std::fs::metadata(path).map_err(|e| PixstripError::ImageLoad {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let reader =
|
||||||
|
image::ImageReader::open(path).map_err(|e| PixstripError::ImageLoad {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let reader = reader.with_guessed_format().map_err(|e| PixstripError::ImageLoad {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Read only the image header for dimensions (avoids full decode into RAM)
|
||||||
|
let (width, height) = reader.into_dimensions().map_err(|e| PixstripError::ImageLoad {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let format = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.and_then(ImageFormat::from_extension);
|
||||||
|
|
||||||
|
Ok(ImageInfo {
|
||||||
|
dimensions: Dimensions { width, height },
|
||||||
|
format,
|
||||||
|
file_size: metadata.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_pixels(&self, path: &Path) -> Result<image::DynamicImage> {
|
||||||
|
image::open(path).map_err(|e| PixstripError::ImageLoad {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImageLoader {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
207
pixstrip-core/src/operations/adjustments.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
use image::{DynamicImage, Rgba, RgbaImage};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use super::AdjustmentsConfig;
|
||||||
|
|
||||||
|
pub fn apply_adjustments(
|
||||||
|
mut img: DynamicImage,
|
||||||
|
config: &AdjustmentsConfig,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
// Crop to aspect ratio (before other adjustments)
|
||||||
|
if let Some((w_ratio, h_ratio)) = config.crop_aspect_ratio {
|
||||||
|
img = crop_to_aspect_ratio(img, w_ratio, h_ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
if config.trim_whitespace {
|
||||||
|
img = trim_whitespace(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brightness
|
||||||
|
if config.brightness != 0 {
|
||||||
|
img = img.brighten(config.brightness);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrast
|
||||||
|
if config.contrast != 0 {
|
||||||
|
img = img.adjust_contrast(config.contrast as f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saturation
|
||||||
|
if config.saturation != 0 {
|
||||||
|
img = adjust_saturation(img, config.saturation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grayscale
|
||||||
|
if config.grayscale {
|
||||||
|
img = img.grayscale();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sepia
|
||||||
|
if config.sepia {
|
||||||
|
img = apply_sepia(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sharpen
|
||||||
|
if config.sharpen {
|
||||||
|
img = img.unsharpen(1.0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas padding
|
||||||
|
if config.canvas_padding > 0 {
|
||||||
|
img = add_canvas_padding(img, config.canvas_padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage {
|
||||||
|
let (iw, ih) = (img.width(), img.height());
|
||||||
|
if !w_ratio.is_finite() || !h_ratio.is_finite()
|
||||||
|
|| w_ratio <= 0.0 || h_ratio <= 0.0
|
||||||
|
|| iw == 0 || ih == 0
|
||||||
|
{
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
let target_ratio = w_ratio / h_ratio;
|
||||||
|
let current_ratio = iw as f64 / ih as f64;
|
||||||
|
|
||||||
|
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
||||||
|
// Image is wider than target, crop width
|
||||||
|
let new_w = (ih as f64 * target_ratio).round().max(1.0) as u32;
|
||||||
|
(new_w.min(iw).max(1), ih)
|
||||||
|
} else {
|
||||||
|
// Image is taller than target, crop height
|
||||||
|
let new_h = (iw as f64 / target_ratio).round().max(1.0) as u32;
|
||||||
|
(iw, new_h.min(ih).max(1))
|
||||||
|
};
|
||||||
|
|
||||||
|
let x = iw.saturating_sub(crop_w) / 2;
|
||||||
|
let y = ih.saturating_sub(crop_h) / 2;
|
||||||
|
|
||||||
|
img.crop_imm(x, y, crop_w, crop_h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_whitespace(img: DynamicImage) -> DynamicImage {
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let (w, h) = (rgba.width(), rgba.height());
|
||||||
|
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get corner pixel as reference background color
|
||||||
|
let bg = *rgba.get_pixel(0, 0);
|
||||||
|
let threshold = 30u32;
|
||||||
|
|
||||||
|
let is_bg = |p: &Rgba<u8>| -> bool {
|
||||||
|
let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs();
|
||||||
|
let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs();
|
||||||
|
let db = (p[2] as i32 - bg[2] as i32).unsigned_abs();
|
||||||
|
dr + dg + db < threshold
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut top = 0u32;
|
||||||
|
let mut bottom = h - 1;
|
||||||
|
let mut left = 0u32;
|
||||||
|
let mut right = w - 1;
|
||||||
|
|
||||||
|
// Find top
|
||||||
|
'top: for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) {
|
||||||
|
top = y;
|
||||||
|
break 'top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find bottom
|
||||||
|
'bottom: for y in (0..h).rev() {
|
||||||
|
for x in 0..w {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) {
|
||||||
|
bottom = y;
|
||||||
|
break 'bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find left
|
||||||
|
'left: for x in 0..w {
|
||||||
|
for y in top..=bottom {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) {
|
||||||
|
left = x;
|
||||||
|
break 'left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find right
|
||||||
|
'right: for x in (0..w).rev() {
|
||||||
|
for y in top..=bottom {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) {
|
||||||
|
right = x;
|
||||||
|
break 'right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let crop_w = right.saturating_sub(left).saturating_add(1);
|
||||||
|
let crop_h = bottom.saturating_sub(top).saturating_add(1);
|
||||||
|
|
||||||
|
if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.crop_imm(left, top, crop_w, crop_h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage {
|
||||||
|
let amount = amount.clamp(-100, 100);
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
let factor = 1.0 + (amount as f64 / 100.0);
|
||||||
|
|
||||||
|
for pixel in rgba.pixels_mut() {
|
||||||
|
let r = pixel[0] as f64;
|
||||||
|
let g = pixel[1] as f64;
|
||||||
|
let b = pixel[2] as f64;
|
||||||
|
|
||||||
|
let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
|
||||||
|
pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicImage::ImageRgba8(rgba)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_sepia(img: DynamicImage) -> DynamicImage {
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
|
||||||
|
for pixel in rgba.pixels_mut() {
|
||||||
|
let r = pixel[0] as f64;
|
||||||
|
let g = pixel[1] as f64;
|
||||||
|
let b = pixel[2] as f64;
|
||||||
|
|
||||||
|
pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicImage::ImageRgba8(rgba)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage {
|
||||||
|
// Cap padding to prevent unreasonable memory allocation
|
||||||
|
let padding = padding.min(10_000);
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
let new_w = w.saturating_add(padding.saturating_mul(2)).min(65_535);
|
||||||
|
let new_h = h.saturating_add(padding.saturating_mul(2)).min(65_535);
|
||||||
|
|
||||||
|
let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255]));
|
||||||
|
|
||||||
|
image::imageops::overlay(&mut canvas, &img.to_rgba8(), padding as i64, padding as i64);
|
||||||
|
|
||||||
|
DynamicImage::ImageRgba8(canvas)
|
||||||
|
}
|
||||||
185
pixstrip-core/src/operations/metadata.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
|
||||||
|
use super::MetadataConfig;
|
||||||
|
|
||||||
|
pub fn strip_metadata(
|
||||||
|
input: &Path,
|
||||||
|
output: &Path,
|
||||||
|
config: &MetadataConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
match config {
|
||||||
|
MetadataConfig::KeepAll => {
|
||||||
|
std::fs::copy(input, output).map_err(PixstripError::Io)?;
|
||||||
|
}
|
||||||
|
MetadataConfig::StripAll => {
|
||||||
|
strip_all_exif(input, output)?;
|
||||||
|
}
|
||||||
|
MetadataConfig::Privacy | MetadataConfig::Custom { .. } => {
|
||||||
|
// Copy file first, then selectively strip using little_exif
|
||||||
|
std::fs::copy(input, output).map_err(PixstripError::Io)?;
|
||||||
|
strip_selective_exif(output, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_selective_exif(path: &Path, config: &MetadataConfig) {
|
||||||
|
use little_exif::exif_tag::ExifTag;
|
||||||
|
use little_exif::metadata::Metadata;
|
||||||
|
|
||||||
|
let Ok(source_meta) = Metadata::new_from_path(path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut strip_ids: Vec<u16> = Vec::new();
|
||||||
|
|
||||||
|
if config.should_strip_gps() {
|
||||||
|
strip_ids.push(ExifTag::GPSInfo(Vec::new()).as_u16());
|
||||||
|
}
|
||||||
|
if config.should_strip_camera() {
|
||||||
|
strip_ids.push(ExifTag::Make(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::Model(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensModel(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensMake(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SerialNumber(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensSerialNumber(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::LensInfo(Vec::new()).as_u16());
|
||||||
|
}
|
||||||
|
if config.should_strip_software() {
|
||||||
|
strip_ids.push(ExifTag::Software(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::MakerNote(Vec::new()).as_u16());
|
||||||
|
}
|
||||||
|
if config.should_strip_timestamps() {
|
||||||
|
strip_ids.push(ExifTag::ModifyDate(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::DateTimeOriginal(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::CreateDate(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SubSecTime(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SubSecTimeOriginal(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::SubSecTimeDigitized(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OffsetTime(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OffsetTimeOriginal(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OffsetTimeDigitized(String::new()).as_u16());
|
||||||
|
}
|
||||||
|
if config.should_strip_copyright() {
|
||||||
|
strip_ids.push(ExifTag::Copyright(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::Artist(String::new()).as_u16());
|
||||||
|
strip_ids.push(ExifTag::OwnerName(String::new()).as_u16());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_meta = Metadata::new();
|
||||||
|
for tag in source_meta.data() {
|
||||||
|
if !strip_ids.contains(&tag.as_u16()) {
|
||||||
|
new_meta.set_tag(tag.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = new_meta.write_to_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_all_exif(input: &Path, output: &Path) -> Result<()> {
|
||||||
|
let data = std::fs::read(input).map_err(PixstripError::Io)?;
|
||||||
|
let cleaned = if data.len() >= 8 && &data[..8] == b"\x89PNG\r\n\x1a\n" {
|
||||||
|
remove_metadata_from_png(&data)
|
||||||
|
} else {
|
||||||
|
remove_exif_from_jpeg(&data)
|
||||||
|
};
|
||||||
|
std::fs::write(output, cleaned).map_err(PixstripError::Io)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_exif_from_jpeg(data: &[u8]) -> Vec<u8> {
|
||||||
|
// Only process JPEG files (starts with FF D8)
|
||||||
|
if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
|
||||||
|
return data.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(data.len());
|
||||||
|
result.push(0xFF);
|
||||||
|
result.push(0xD8);
|
||||||
|
|
||||||
|
let mut i = 2;
|
||||||
|
while i + 1 < data.len() {
|
||||||
|
if data[i] != 0xFF {
|
||||||
|
result.extend_from_slice(&data[i..]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = data[i + 1];
|
||||||
|
|
||||||
|
// APP1 (0xE1) contains EXIF - skip it
|
||||||
|
if marker == 0xE1 && i + 3 < data.len() {
|
||||||
|
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
|
||||||
|
let skip = 2 + len;
|
||||||
|
if i + skip <= data.len() {
|
||||||
|
i += skip;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOS (0xDA) - start of scan, copy everything from here
|
||||||
|
if marker == 0xDA {
|
||||||
|
result.extend_from_slice(&data[i..]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other markers - keep them
|
||||||
|
if i + 3 < data.len() {
|
||||||
|
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
|
||||||
|
let end = i + 2 + len;
|
||||||
|
if end <= data.len() {
|
||||||
|
result.extend_from_slice(&data[i..end]);
|
||||||
|
i = end;
|
||||||
|
} else {
|
||||||
|
result.extend_from_slice(&data[i..]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.extend_from_slice(&data[i..]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_metadata_from_png(data: &[u8]) -> Vec<u8> {
|
||||||
|
// PNG: 8-byte signature + chunks (4 len + 4 type + data + 4 CRC)
|
||||||
|
// Keep only rendering-essential chunks, strip textual/EXIF metadata.
|
||||||
|
const KEEP_CHUNKS: &[&[u8; 4]] = &[
|
||||||
|
b"IHDR", b"PLTE", b"IDAT", b"IEND",
|
||||||
|
b"tRNS", b"gAMA", b"cHRM", b"sRGB", b"iCCP", b"sBIT",
|
||||||
|
b"pHYs", b"bKGD", b"hIST", b"sPLT",
|
||||||
|
b"acTL", b"fcTL", b"fdAT",
|
||||||
|
];
|
||||||
|
|
||||||
|
if data.len() < 8 {
|
||||||
|
return data.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(data.len());
|
||||||
|
result.extend_from_slice(&data[..8]); // PNG signature
|
||||||
|
|
||||||
|
let mut pos = 8;
|
||||||
|
while pos + 12 <= data.len() {
|
||||||
|
let chunk_len = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
|
||||||
|
let chunk_type = &data[pos + 4..pos + 8];
|
||||||
|
let total = 12 + chunk_len; // 4 len + 4 type + data + 4 CRC
|
||||||
|
|
||||||
|
if pos + total > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let keep = KEEP_CHUNKS.iter().any(|k| *k == chunk_type);
|
||||||
|
if keep {
|
||||||
|
result.extend_from_slice(&data[pos..pos + total]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += total;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -1 +1,467 @@
|
|||||||
// Image processing operations
|
pub mod adjustments;
|
||||||
|
pub mod metadata;
|
||||||
|
pub mod rename;
|
||||||
|
pub mod resize;
|
||||||
|
pub mod watermark;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::types::{Dimensions, ImageFormat, QualityPreset};
|
||||||
|
|
||||||
|
// --- Resize ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ResizeConfig {
|
||||||
|
ByWidth(u32),
|
||||||
|
ByHeight(u32),
|
||||||
|
FitInBox {
|
||||||
|
max: Dimensions,
|
||||||
|
allow_upscale: bool,
|
||||||
|
},
|
||||||
|
Exact(Dimensions),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResizeConfig {
|
||||||
|
pub fn target_for(&self, original: Dimensions) -> Dimensions {
|
||||||
|
if original.width == 0 || original.height == 0 {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
let result = match self {
|
||||||
|
Self::ByWidth(w) => {
|
||||||
|
if *w == 0 {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
let scale = *w as f64 / original.width as f64;
|
||||||
|
Dimensions {
|
||||||
|
width: *w,
|
||||||
|
height: (original.height as f64 * scale).round().max(1.0) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::ByHeight(h) => {
|
||||||
|
if *h == 0 {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
let scale = *h as f64 / original.height as f64;
|
||||||
|
Dimensions {
|
||||||
|
width: (original.width as f64 * scale).round().max(1.0) as u32,
|
||||||
|
height: *h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::FitInBox { max, allow_upscale } => {
|
||||||
|
original.fit_within(*max, *allow_upscale)
|
||||||
|
}
|
||||||
|
Self::Exact(dims) => {
|
||||||
|
if dims.width == 0 || dims.height == 0 {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
*dims
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Dimensions {
|
||||||
|
width: result.width.max(1),
|
||||||
|
height: result.height.max(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resize Algorithm ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum ResizeAlgorithm {
|
||||||
|
Lanczos3,
|
||||||
|
CatmullRom,
|
||||||
|
Bilinear,
|
||||||
|
Nearest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResizeAlgorithm {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Lanczos3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rotation / Flip ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum Rotation {
|
||||||
|
None,
|
||||||
|
Cw90,
|
||||||
|
Cw180,
|
||||||
|
Cw270,
|
||||||
|
AutoOrient,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum Flip {
|
||||||
|
None,
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Convert ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ConvertConfig {
|
||||||
|
KeepOriginal,
|
||||||
|
SingleFormat(ImageFormat),
|
||||||
|
FormatMapping(Vec<(ImageFormat, ImageFormat)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConvertConfig {
|
||||||
|
pub fn output_format(&self, input: ImageFormat) -> ImageFormat {
|
||||||
|
match self {
|
||||||
|
Self::KeepOriginal => input,
|
||||||
|
Self::SingleFormat(fmt) => *fmt,
|
||||||
|
Self::FormatMapping(map) => {
|
||||||
|
map.iter()
|
||||||
|
.find(|(from, _)| *from == input)
|
||||||
|
.map(|(_, to)| *to)
|
||||||
|
.unwrap_or(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this conversion will change at least some file extensions.
|
||||||
|
pub fn changes_extension(&self) -> bool {
|
||||||
|
!matches!(self, Self::KeepOriginal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Compress ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum CompressConfig {
|
||||||
|
Preset(QualityPreset),
|
||||||
|
Custom {
|
||||||
|
jpeg_quality: Option<u8>,
|
||||||
|
png_level: Option<u8>,
|
||||||
|
webp_quality: Option<f32>,
|
||||||
|
avif_quality: Option<f32>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum MetadataConfig {
|
||||||
|
StripAll,
|
||||||
|
KeepAll,
|
||||||
|
Privacy,
|
||||||
|
Custom {
|
||||||
|
strip_gps: bool,
|
||||||
|
strip_camera: bool,
|
||||||
|
strip_software: bool,
|
||||||
|
strip_timestamps: bool,
|
||||||
|
strip_copyright: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetadataConfig {
|
||||||
|
pub fn should_strip_gps(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::StripAll => true,
|
||||||
|
Self::KeepAll => false,
|
||||||
|
Self::Privacy => true,
|
||||||
|
Self::Custom { strip_gps, .. } => *strip_gps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_strip_camera(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::StripAll => true,
|
||||||
|
Self::KeepAll => false,
|
||||||
|
Self::Privacy => true,
|
||||||
|
Self::Custom { strip_camera, .. } => *strip_camera,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_strip_copyright(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::StripAll => true,
|
||||||
|
Self::KeepAll => false,
|
||||||
|
Self::Privacy => false,
|
||||||
|
Self::Custom { strip_copyright, .. } => *strip_copyright,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_strip_software(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::StripAll => true,
|
||||||
|
Self::KeepAll => false,
|
||||||
|
Self::Privacy => true,
|
||||||
|
Self::Custom { strip_software, .. } => *strip_software,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_strip_timestamps(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::StripAll => true,
|
||||||
|
Self::KeepAll => false,
|
||||||
|
Self::Privacy => false,
|
||||||
|
Self::Custom { strip_timestamps, .. } => *strip_timestamps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Watermark ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum WatermarkPosition {
|
||||||
|
TopLeft,
|
||||||
|
TopCenter,
|
||||||
|
TopRight,
|
||||||
|
MiddleLeft,
|
||||||
|
Center,
|
||||||
|
MiddleRight,
|
||||||
|
BottomLeft,
|
||||||
|
BottomCenter,
|
||||||
|
BottomRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum WatermarkConfig {
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
font_size: f32,
|
||||||
|
opacity: f32,
|
||||||
|
color: [u8; 4],
|
||||||
|
font_family: Option<String>,
|
||||||
|
rotation: Option<WatermarkRotation>,
|
||||||
|
tiled: bool,
|
||||||
|
margin: u32,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
opacity: f32,
|
||||||
|
scale: f32,
|
||||||
|
rotation: Option<WatermarkRotation>,
|
||||||
|
tiled: bool,
|
||||||
|
margin: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum WatermarkRotation {
|
||||||
|
Degrees45,
|
||||||
|
DegreesNeg45,
|
||||||
|
Degrees90,
|
||||||
|
Custom(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Adjustments ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AdjustmentsConfig {
|
||||||
|
pub brightness: i32,
|
||||||
|
pub contrast: i32,
|
||||||
|
pub saturation: i32,
|
||||||
|
pub sharpen: bool,
|
||||||
|
pub grayscale: bool,
|
||||||
|
pub sepia: bool,
|
||||||
|
pub crop_aspect_ratio: Option<(f64, f64)>,
|
||||||
|
pub trim_whitespace: bool,
|
||||||
|
pub canvas_padding: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdjustmentsConfig {
|
||||||
|
pub fn is_noop(&self) -> bool {
|
||||||
|
self.brightness == 0
|
||||||
|
&& self.contrast == 0
|
||||||
|
&& self.saturation == 0
|
||||||
|
&& !self.sharpen
|
||||||
|
&& !self.grayscale
|
||||||
|
&& !self.sepia
|
||||||
|
&& self.crop_aspect_ratio.is_none()
|
||||||
|
&& !self.trim_whitespace
|
||||||
|
&& self.canvas_padding == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Overwrite Action (concrete action, no "Ask" variant) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum OverwriteAction {
|
||||||
|
AutoRename,
|
||||||
|
Overwrite,
|
||||||
|
Skip,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OverwriteAction {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::AutoRename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rename ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RenameConfig {
|
||||||
|
pub prefix: String,
|
||||||
|
pub suffix: String,
|
||||||
|
pub counter_start: u32,
|
||||||
|
pub counter_padding: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub counter_enabled: bool,
|
||||||
|
/// 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
||||||
|
#[serde(default = "default_counter_position")]
|
||||||
|
pub counter_position: u32,
|
||||||
|
pub template: Option<String>,
|
||||||
|
/// 0=none, 1=lowercase, 2=uppercase, 3=title case
|
||||||
|
pub case_mode: u32,
|
||||||
|
/// 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
||||||
|
#[serde(default)]
|
||||||
|
pub replace_spaces: u32,
|
||||||
|
/// 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
||||||
|
#[serde(default)]
|
||||||
|
pub special_chars: u32,
|
||||||
|
pub regex_find: String,
|
||||||
|
pub regex_replace: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_counter_position() -> u32 { 3 }
|
||||||
|
|
||||||
|
impl RenameConfig {
|
||||||
|
/// Returns true if this rename config would actually change any filename.
|
||||||
|
pub fn changes_filename(&self) -> bool {
|
||||||
|
!self.prefix.is_empty()
|
||||||
|
|| !self.suffix.is_empty()
|
||||||
|
|| self.counter_enabled
|
||||||
|
|| !self.regex_find.is_empty()
|
||||||
|
|| self.case_mode > 0
|
||||||
|
|| self.replace_spaces > 0
|
||||||
|
|| self.special_chars > 0
|
||||||
|
|| self.template.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-compile the regex for batch use. Call once before a loop of apply_simple_compiled.
|
||||||
|
pub fn compile_regex(&self) -> Option<regex::Regex> {
|
||||||
|
rename::compile_rename_regex(&self.regex_find)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String {
|
||||||
|
// 1. Apply regex find-and-replace on the original name
|
||||||
|
let working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace);
|
||||||
|
|
||||||
|
// 2. Apply space replacement
|
||||||
|
let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
|
||||||
|
|
||||||
|
// 3. Apply special character filtering
|
||||||
|
let working_name = rename::apply_special_chars(&working_name, self.special_chars);
|
||||||
|
|
||||||
|
// 4. Build counter string
|
||||||
|
let counter_str = if self.counter_enabled {
|
||||||
|
let counter = self.counter_start.saturating_add(index.saturating_sub(1));
|
||||||
|
let padding = (self.counter_padding as usize).min(10);
|
||||||
|
format!("{:0>width$}", counter, width = padding)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_counter = self.counter_enabled && !counter_str.is_empty();
|
||||||
|
|
||||||
|
// 5. Assemble parts based on counter position
|
||||||
|
// Positions: 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 0 {
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
result.push('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str(&self.prefix);
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 1 {
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
result.push('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 4 {
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
} else {
|
||||||
|
result.push_str(&working_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 2 {
|
||||||
|
result.push('_');
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str(&self.suffix);
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 3 {
|
||||||
|
result.push('_');
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Apply case conversion
|
||||||
|
let result = rename::apply_case_conversion(&result, self.case_mode);
|
||||||
|
|
||||||
|
format!("{}.{}", result, extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like apply_simple but uses a pre-compiled regex (avoids recompiling per file).
|
||||||
|
pub fn apply_simple_compiled(&self, original_name: &str, extension: &str, index: u32, compiled_re: Option<®ex::Regex>) -> String {
|
||||||
|
// 1. Apply regex find-and-replace on the original name
|
||||||
|
let working_name = match compiled_re {
|
||||||
|
Some(re) => rename::apply_regex_replace_compiled(original_name, re, &self.regex_replace),
|
||||||
|
None => original_name.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Apply space replacement
|
||||||
|
let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
|
||||||
|
|
||||||
|
// 3. Apply special character filtering
|
||||||
|
let working_name = rename::apply_special_chars(&working_name, self.special_chars);
|
||||||
|
|
||||||
|
// 4. Build counter string
|
||||||
|
let counter_str = if self.counter_enabled {
|
||||||
|
let counter = self.counter_start.saturating_add(index.saturating_sub(1));
|
||||||
|
let padding = (self.counter_padding as usize).min(10);
|
||||||
|
format!("{:0>width$}", counter, width = padding)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_counter = self.counter_enabled && !counter_str.is_empty();
|
||||||
|
|
||||||
|
// 5. Assemble parts based on counter position
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 0 {
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
result.push('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str(&self.prefix);
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 1 {
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
result.push('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 4 {
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
} else {
|
||||||
|
result.push_str(&working_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 2 {
|
||||||
|
result.push('_');
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str(&self.suffix);
|
||||||
|
|
||||||
|
if has_counter && self.counter_position == 3 {
|
||||||
|
result.push('_');
|
||||||
|
result.push_str(&counter_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Apply case conversion
|
||||||
|
let result = rename::apply_case_conversion(&result, self.case_mode);
|
||||||
|
|
||||||
|
format!("{}.{}", result, extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
306
pixstrip-core/src/operations/rename.rs
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn apply_template(
|
||||||
|
template: &str,
|
||||||
|
name: &str,
|
||||||
|
ext: &str,
|
||||||
|
counter: u32,
|
||||||
|
dimensions: Option<(u32, u32)>,
|
||||||
|
) -> String {
|
||||||
|
apply_template_full(template, name, ext, counter, dimensions, None, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_template_full(
|
||||||
|
template: &str,
|
||||||
|
name: &str,
|
||||||
|
ext: &str,
|
||||||
|
counter: u32,
|
||||||
|
dimensions: Option<(u32, u32)>,
|
||||||
|
original_ext: Option<&str>,
|
||||||
|
source_path: Option<&Path>,
|
||||||
|
exif_info: Option<&ExifRenameInfo>,
|
||||||
|
) -> String {
|
||||||
|
let mut result = template.to_string();
|
||||||
|
|
||||||
|
result = result.replace("{name}", name);
|
||||||
|
result = result.replace("{ext}", ext);
|
||||||
|
result = result.replace("{original_ext}", original_ext.unwrap_or(ext));
|
||||||
|
|
||||||
|
// Handle {counter:N} with padding
|
||||||
|
if let Some(start) = result.find("{counter:") {
|
||||||
|
if let Some(end) = result[start..].find('}') {
|
||||||
|
let spec = &result[start + 9..start + end];
|
||||||
|
if let Ok(padding) = spec.parse::<usize>() {
|
||||||
|
let padding = padding.min(10);
|
||||||
|
let counter_str = format!("{:0>width$}", counter, width = padding);
|
||||||
|
result = format!("{}{}{}", &result[..start], counter_str, &result[start + end + 1..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = result.replace("{counter}", &counter.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match dimensions {
|
||||||
|
Some((w, h)) => {
|
||||||
|
result = result.replace("{width}", &w.to_string());
|
||||||
|
result = result.replace("{height}", &h.to_string());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
result = result.replace("{width}", "0");
|
||||||
|
result = result.replace("{height}", "0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// {date} - today's date
|
||||||
|
if result.contains("{date}") {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let days = now / 86400;
|
||||||
|
// Simple date calculation (good enough for filenames)
|
||||||
|
let (y, m, d) = days_to_ymd(days);
|
||||||
|
result = result.replace("{date}", &format!("{:04}-{:02}-{:02}", y, m, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXIF-based variables
|
||||||
|
if let Some(info) = exif_info {
|
||||||
|
result = result.replace("{exif_date}", &info.date_taken);
|
||||||
|
result = result.replace("{camera}", &info.camera_model);
|
||||||
|
} else if result.contains("{exif_date}") || result.contains("{camera}") {
|
||||||
|
// Try to read EXIF from source file
|
||||||
|
let info = source_path.map(read_exif_rename_info).unwrap_or_default();
|
||||||
|
result = result.replace("{exif_date}", &info.date_taken);
|
||||||
|
result = result.replace("{camera}", &info.camera_model);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ExifRenameInfo {
|
||||||
|
pub date_taken: String,
|
||||||
|
pub camera_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_exif_rename_info(path: &Path) -> ExifRenameInfo {
|
||||||
|
let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(path) else {
|
||||||
|
return ExifRenameInfo::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut info = ExifRenameInfo::default();
|
||||||
|
let endian = metadata.get_endian();
|
||||||
|
|
||||||
|
// Read DateTimeOriginal
|
||||||
|
if let Some(tag) = metadata.get_tag(&little_exif::exif_tag::ExifTag::DateTimeOriginal(String::new())) {
|
||||||
|
let bytes = tag.value_as_u8_vec(endian);
|
||||||
|
if let Ok(s) = std::str::from_utf8(&bytes) {
|
||||||
|
// Convert "2026:03:06 14:30:00" to "2026-03-06"
|
||||||
|
let clean = s.trim_end_matches('\0');
|
||||||
|
if let Some(date_part) = clean.split(' ').next() {
|
||||||
|
info.date_taken = date_part.replace(':', "-");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read camera Model
|
||||||
|
if let Some(tag) = metadata.get_tag(&little_exif::exif_tag::ExifTag::Model(String::new())) {
|
||||||
|
let bytes = tag.value_as_u8_vec(endian);
|
||||||
|
if let Ok(s) = std::str::from_utf8(&bytes) {
|
||||||
|
info.camera_model = s.trim_end_matches('\0')
|
||||||
|
.replace(' ', "_")
|
||||||
|
.replace('/', "-")
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.date_taken.is_empty() {
|
||||||
|
info.date_taken = "unknown-date".to_string();
|
||||||
|
}
|
||||||
|
if info.camera_model.is_empty() {
|
||||||
|
info.camera_model = "unknown-camera".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
info
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days_to_ymd(total_days: u64) -> (u64, u64, u64) {
|
||||||
|
// Simplified Gregorian calendar calculation
|
||||||
|
let mut days = total_days;
|
||||||
|
let mut year = 1970u64;
|
||||||
|
loop {
|
||||||
|
if year > 9999 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||||
|
if days < days_in_year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
days -= days_in_year;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
let months_days: [u64; 12] = if is_leap(year) {
|
||||||
|
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
} else {
|
||||||
|
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
};
|
||||||
|
let mut month = 1u64;
|
||||||
|
for md in &months_days {
|
||||||
|
if days < *md {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
days -= md;
|
||||||
|
month += 1;
|
||||||
|
}
|
||||||
|
(year, month, days + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_leap(year: u64) -> bool {
|
||||||
|
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply space replacement on a filename
|
||||||
|
/// mode: 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
||||||
|
pub fn apply_space_replacement(name: &str, mode: u32) -> String {
|
||||||
|
match mode {
|
||||||
|
1 => name.replace(' ', "_"),
|
||||||
|
2 => name.replace(' ', "-"),
|
||||||
|
3 => name.replace(' ', "."),
|
||||||
|
4 => {
|
||||||
|
let mut result = String::with_capacity(name.len());
|
||||||
|
let mut capitalize_next = false;
|
||||||
|
for ch in name.chars() {
|
||||||
|
if ch == ' ' {
|
||||||
|
capitalize_next = true;
|
||||||
|
} else if capitalize_next {
|
||||||
|
result.extend(ch.to_uppercase());
|
||||||
|
capitalize_next = false;
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
5 => name.replace(' ', ""),
|
||||||
|
_ => name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply special character filtering on a filename
|
||||||
|
/// mode: 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
||||||
|
pub fn apply_special_chars(name: &str, mode: u32) -> String {
|
||||||
|
match mode {
|
||||||
|
1 => name.chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')).collect(),
|
||||||
|
2 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(),
|
||||||
|
3 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(),
|
||||||
|
4 => name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect(),
|
||||||
|
5 => name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(),
|
||||||
|
_ => name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply case conversion to a filename (without extension)
|
||||||
|
/// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case
|
||||||
|
pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
|
||||||
|
match case_mode {
|
||||||
|
1 => name.to_lowercase(),
|
||||||
|
2 => name.to_uppercase(),
|
||||||
|
3 => {
|
||||||
|
// Title case: capitalize first letter of each word, preserve original separators
|
||||||
|
let mut result = String::with_capacity(name.len());
|
||||||
|
let mut capitalize_next = true;
|
||||||
|
for c in name.chars() {
|
||||||
|
if c == '_' || c == '-' || c == ' ' {
|
||||||
|
result.push(c);
|
||||||
|
capitalize_next = true;
|
||||||
|
} else if capitalize_next {
|
||||||
|
for uc in c.to_uppercase() {
|
||||||
|
result.push(uc);
|
||||||
|
}
|
||||||
|
capitalize_next = false;
|
||||||
|
} else {
|
||||||
|
for lc in c.to_lowercase() {
|
||||||
|
result.push(lc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
_ => name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-compile a regex for batch use. Returns None (with message) if invalid.
|
||||||
|
pub fn compile_rename_regex(find: &str) -> Option<regex::Regex> {
|
||||||
|
if find.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
regex::RegexBuilder::new(find)
|
||||||
|
.size_limit(1 << 16)
|
||||||
|
.dfa_size_limit(1 << 16)
|
||||||
|
.build()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply regex find-and-replace on a filename
|
||||||
|
pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
|
||||||
|
if find.is_empty() {
|
||||||
|
return name.to_string();
|
||||||
|
}
|
||||||
|
match regex::RegexBuilder::new(find)
|
||||||
|
.size_limit(1 << 16)
|
||||||
|
.dfa_size_limit(1 << 16)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(re) => re.replace_all(name, replace).into_owned(),
|
||||||
|
Err(_) => name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a pre-compiled regex find-and-replace on a filename
|
||||||
|
pub fn apply_regex_replace_compiled(name: &str, re: ®ex::Regex, replace: &str) -> String {
|
||||||
|
re.replace_all(name, replace).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_collision(path: &Path) -> PathBuf {
|
||||||
|
// Use create_new (O_CREAT|O_EXCL) for atomic reservation, preventing TOCTOU races
|
||||||
|
match std::fs::OpenOptions::new().write(true).create_new(true).open(path) {
|
||||||
|
Ok(_) => return path.to_path_buf(),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||||
|
Err(_) => return path.to_path_buf(), // other errors (e.g. permission) - let caller handle
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = path.parent().unwrap_or(Path::new("."));
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("file");
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
for i in 1..1000 {
|
||||||
|
let candidate = if ext.is_empty() {
|
||||||
|
parent.join(format!("{}_{}", stem, i))
|
||||||
|
} else {
|
||||||
|
parent.join(format!("{}_{}.{}", stem, i, ext))
|
||||||
|
};
|
||||||
|
match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) {
|
||||||
|
Ok(_) => return candidate,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback with timestamp for uniqueness
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if ext.is_empty() {
|
||||||
|
parent.join(format!("{}_{}", stem, ts))
|
||||||
|
} else {
|
||||||
|
parent.join(format!("{}_{}.{}", stem, ts, ext))
|
||||||
|
}
|
||||||
|
}
|
||||||
74
pixstrip-core/src/operations/resize.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use fast_image_resize::{images::Image, Resizer, ResizeOptions, ResizeAlg, FilterType};
|
||||||
|
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
use crate::types::Dimensions;
|
||||||
|
|
||||||
|
use super::{ResizeConfig, ResizeAlgorithm};
|
||||||
|
|
||||||
|
pub fn resize_image(
|
||||||
|
src: &image::DynamicImage,
|
||||||
|
config: &ResizeConfig,
|
||||||
|
) -> Result<image::DynamicImage> {
|
||||||
|
resize_image_with_algorithm(src, config, ResizeAlgorithm::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize_image_with_algorithm(
|
||||||
|
src: &image::DynamicImage,
|
||||||
|
config: &ResizeConfig,
|
||||||
|
algorithm: ResizeAlgorithm,
|
||||||
|
) -> Result<image::DynamicImage> {
|
||||||
|
let original = Dimensions {
|
||||||
|
width: src.width(),
|
||||||
|
height: src.height(),
|
||||||
|
};
|
||||||
|
let target = config.target_for(original);
|
||||||
|
|
||||||
|
if target.width == original.width && target.height == original.height {
|
||||||
|
return Ok(src.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_rgba = src.to_rgba8();
|
||||||
|
let (src_w, src_h) = (src_rgba.width(), src_rgba.height());
|
||||||
|
|
||||||
|
let src_image = Image::from_vec_u8(
|
||||||
|
src_w,
|
||||||
|
src_h,
|
||||||
|
src_rgba.into_raw(),
|
||||||
|
fast_image_resize::PixelType::U8x4,
|
||||||
|
)
|
||||||
|
.map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "resize".into(),
|
||||||
|
reason: format!("Failed to create source image: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut dst_image = Image::new(
|
||||||
|
target.width,
|
||||||
|
target.height,
|
||||||
|
fast_image_resize::PixelType::U8x4,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut resizer = Resizer::new();
|
||||||
|
let alg = match algorithm {
|
||||||
|
ResizeAlgorithm::Lanczos3 => ResizeAlg::Convolution(FilterType::Lanczos3),
|
||||||
|
ResizeAlgorithm::CatmullRom => ResizeAlg::Convolution(FilterType::CatmullRom),
|
||||||
|
ResizeAlgorithm::Bilinear => ResizeAlg::Convolution(FilterType::Bilinear),
|
||||||
|
ResizeAlgorithm::Nearest => ResizeAlg::Nearest,
|
||||||
|
};
|
||||||
|
let options = ResizeOptions::new().resize_alg(alg);
|
||||||
|
|
||||||
|
resizer
|
||||||
|
.resize(&src_image, &mut dst_image, &options)
|
||||||
|
.map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "resize".into(),
|
||||||
|
reason: format!("Resize failed: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result_buf: image::RgbaImage =
|
||||||
|
image::ImageBuffer::from_raw(target.width, target.height, dst_image.into_vec())
|
||||||
|
.ok_or_else(|| PixstripError::Processing {
|
||||||
|
operation: "resize".into(),
|
||||||
|
reason: "Failed to create output image buffer".into(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(image::DynamicImage::ImageRgba8(result_buf))
|
||||||
|
}
|
||||||
537
pixstrip-core/src/operations/watermark.rs
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
use image::{DynamicImage, Rgba};
|
||||||
|
use imageproc::drawing::draw_text_mut;
|
||||||
|
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
use crate::types::Dimensions;
|
||||||
|
|
||||||
|
use super::{WatermarkConfig, WatermarkPosition};
|
||||||
|
|
||||||
|
pub fn calculate_position(
|
||||||
|
position: WatermarkPosition,
|
||||||
|
image_size: Dimensions,
|
||||||
|
watermark_size: Dimensions,
|
||||||
|
margin: u32,
|
||||||
|
) -> (u32, u32) {
|
||||||
|
let iw = image_size.width;
|
||||||
|
let ih = image_size.height;
|
||||||
|
let ww = watermark_size.width;
|
||||||
|
let wh = watermark_size.height;
|
||||||
|
|
||||||
|
let center_x = iw.saturating_sub(ww) / 2;
|
||||||
|
let center_y = ih.saturating_sub(wh) / 2;
|
||||||
|
let right_x = iw.saturating_sub(ww).saturating_sub(margin);
|
||||||
|
let bottom_y = ih.saturating_sub(wh).saturating_sub(margin);
|
||||||
|
|
||||||
|
match position {
|
||||||
|
WatermarkPosition::TopLeft => (margin, margin),
|
||||||
|
WatermarkPosition::TopCenter => (center_x, margin),
|
||||||
|
WatermarkPosition::TopRight => (right_x, margin),
|
||||||
|
WatermarkPosition::MiddleLeft => (margin, center_y),
|
||||||
|
WatermarkPosition::Center => (center_x, center_y),
|
||||||
|
WatermarkPosition::MiddleRight => (right_x, center_y),
|
||||||
|
WatermarkPosition::BottomLeft => (margin, bottom_y),
|
||||||
|
WatermarkPosition::BottomCenter => (center_x, bottom_y),
|
||||||
|
WatermarkPosition::BottomRight => (right_x, bottom_y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
config: &WatermarkConfig,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
match config {
|
||||||
|
WatermarkConfig::Text {
|
||||||
|
text,
|
||||||
|
position,
|
||||||
|
font_size,
|
||||||
|
opacity,
|
||||||
|
color,
|
||||||
|
font_family,
|
||||||
|
rotation,
|
||||||
|
tiled,
|
||||||
|
margin,
|
||||||
|
} => {
|
||||||
|
if *tiled {
|
||||||
|
apply_tiled_text_watermark(img, text, *font_size, *opacity, *color, font_family.as_deref(), *rotation, *margin)
|
||||||
|
} else {
|
||||||
|
apply_text_watermark(img, text, *position, *font_size, *opacity, *color, font_family.as_deref(), *rotation, *margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WatermarkConfig::Image {
|
||||||
|
path,
|
||||||
|
position,
|
||||||
|
opacity,
|
||||||
|
scale,
|
||||||
|
rotation,
|
||||||
|
tiled,
|
||||||
|
margin,
|
||||||
|
} => {
|
||||||
|
if *tiled {
|
||||||
|
apply_tiled_image_watermark(img, path, *opacity, *scale, *rotation, *margin)
|
||||||
|
} else {
|
||||||
|
apply_image_watermark(img, path, *position, *opacity, *scale, *rotation, *margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache for font data to avoid repeated filesystem walks during preview updates
|
||||||
|
static FONT_CACHE: std::sync::Mutex<Option<FontCache>> = std::sync::Mutex::new(None);
|
||||||
|
static DEFAULT_FONT_CACHE: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
struct FontCache {
|
||||||
|
entries: std::collections::HashMap<String, Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_system_font(family: Option<&str>) -> Result<Vec<u8>> {
|
||||||
|
// If a specific font family was requested, check the cache first
|
||||||
|
if let Some(name) = family {
|
||||||
|
if !name.is_empty() {
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if let Ok(cache) = FONT_CACHE.lock() {
|
||||||
|
if let Some(ref c) = *cache {
|
||||||
|
if let Some(data) = c.entries.get(&name_lower) {
|
||||||
|
return Ok(data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - search filesystem
|
||||||
|
let search_dirs = [
|
||||||
|
"/usr/share/fonts",
|
||||||
|
"/usr/local/share/fonts",
|
||||||
|
];
|
||||||
|
for dir in &search_dirs {
|
||||||
|
if let Ok(entries) = walkdir(std::path::Path::new(dir)) {
|
||||||
|
for path in entries {
|
||||||
|
let file_name = path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
if file_name.contains(&name_lower)
|
||||||
|
&& (file_name.ends_with(".ttf") || file_name.ends_with(".otf"))
|
||||||
|
&& (file_name.contains("regular") || (!file_name.contains("bold") && !file_name.contains("italic")))
|
||||||
|
{
|
||||||
|
if let Ok(data) = std::fs::read(&path) {
|
||||||
|
// Store in cache
|
||||||
|
if let Ok(mut cache) = FONT_CACHE.lock() {
|
||||||
|
let c = cache.get_or_insert_with(|| FontCache {
|
||||||
|
entries: std::collections::HashMap::new(),
|
||||||
|
});
|
||||||
|
c.entries.insert(name_lower, data.clone());
|
||||||
|
}
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default system fonts (cached via OnceLock)
|
||||||
|
let default = DEFAULT_FONT_CACHE.get_or_init(|| {
|
||||||
|
let candidates = [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
||||||
|
];
|
||||||
|
for path in &candidates {
|
||||||
|
if let Ok(data) = std::fs::read(path) {
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
});
|
||||||
|
|
||||||
|
match default {
|
||||||
|
Some(data) => Ok(data.clone()),
|
||||||
|
None => Err(PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: "No system font found for text watermark".into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively walk a directory and collect file paths (max depth 5)
|
||||||
|
fn walkdir(dir: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>> {
|
||||||
|
walkdir_depth(dir, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walkdir_depth(dir: &std::path::Path, max_depth: u32) -> std::io::Result<Vec<std::path::PathBuf>> {
|
||||||
|
const MAX_RESULTS: usize = 10_000;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
walkdir_depth_inner(dir, max_depth, &mut results, MAX_RESULTS);
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walkdir_depth_inner(dir: &std::path::Path, max_depth: u32, results: &mut Vec<std::path::PathBuf>, max: usize) {
|
||||||
|
if max_depth == 0 || !dir.is_dir() || results.len() >= max {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
|
for entry in entries {
|
||||||
|
if results.len() >= max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Ok(entry) = entry else { continue };
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
walkdir_depth_inner(&path, max_depth - 1, results, max);
|
||||||
|
} else {
|
||||||
|
results.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate text dimensions for a given string and font size.
|
||||||
|
/// Returns (width, height) in pixels.
|
||||||
|
fn estimate_text_dimensions(text: &str, font_size: f32) -> (u32, u32) {
|
||||||
|
let w = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32)
|
||||||
|
.saturating_add(4)
|
||||||
|
.min(16384);
|
||||||
|
let h = ((font_size.min(1000.0) * 1.4) as u32)
|
||||||
|
.saturating_add(4)
|
||||||
|
.min(4096);
|
||||||
|
(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render text onto a transparent RGBA buffer and return it as a DynamicImage
|
||||||
|
fn render_text_to_image(
|
||||||
|
text: &str,
|
||||||
|
font: &ab_glyph::FontArc,
|
||||||
|
font_size: f32,
|
||||||
|
color: [u8; 4],
|
||||||
|
opacity: f32,
|
||||||
|
) -> image::RgbaImage {
|
||||||
|
let scale = ab_glyph::PxScale::from(font_size);
|
||||||
|
let (text_width, text_height) = estimate_text_dimensions(text, font_size);
|
||||||
|
|
||||||
|
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||||
|
|
||||||
|
let mut buf = image::RgbaImage::new(text_width.max(1), text_height.max(1));
|
||||||
|
draw_text_mut(&mut buf, draw_color, 2, 2, scale, font, text);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand canvas so rotated content fits without clipping, then rotate
|
||||||
|
fn rotate_on_expanded_canvas(img: &image::RgbaImage, radians: f32) -> DynamicImage {
|
||||||
|
let w = img.width() as f32;
|
||||||
|
let h = img.height() as f32;
|
||||||
|
let cos = radians.abs().cos();
|
||||||
|
let sin = radians.abs().sin();
|
||||||
|
// Bounding box of rotated rectangle
|
||||||
|
let new_w = (w * cos + h * sin).ceil() as u32 + 2;
|
||||||
|
let new_h = (w * sin + h * cos).ceil() as u32 + 2;
|
||||||
|
|
||||||
|
// Place original in center of expanded transparent canvas
|
||||||
|
let mut expanded = image::RgbaImage::new(new_w, new_h);
|
||||||
|
let ox = (new_w.saturating_sub(img.width())) / 2;
|
||||||
|
let oy = (new_h.saturating_sub(img.height())) / 2;
|
||||||
|
image::imageops::overlay(&mut expanded, img, ox as i64, oy as i64);
|
||||||
|
|
||||||
|
imageproc::geometric_transformations::rotate_about_center(
|
||||||
|
&expanded,
|
||||||
|
radians,
|
||||||
|
imageproc::geometric_transformations::Interpolation::Bilinear,
|
||||||
|
Rgba([0, 0, 0, 0]),
|
||||||
|
).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate an RGBA image by the given WatermarkRotation
|
||||||
|
fn rotate_watermark_image(
|
||||||
|
img: DynamicImage,
|
||||||
|
rotation: super::WatermarkRotation,
|
||||||
|
) -> DynamicImage {
|
||||||
|
match rotation {
|
||||||
|
super::WatermarkRotation::Degrees90 => img.rotate90(),
|
||||||
|
super::WatermarkRotation::Degrees45 => {
|
||||||
|
rotate_on_expanded_canvas(&img.to_rgba8(), std::f32::consts::FRAC_PI_4)
|
||||||
|
}
|
||||||
|
super::WatermarkRotation::DegreesNeg45 => {
|
||||||
|
rotate_on_expanded_canvas(&img.to_rgba8(), -std::f32::consts::FRAC_PI_4)
|
||||||
|
}
|
||||||
|
super::WatermarkRotation::Custom(degrees) => {
|
||||||
|
rotate_on_expanded_canvas(&img.to_rgba8(), degrees.to_radians())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_text_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
text: &str,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
font_size: f32,
|
||||||
|
opacity: f32,
|
||||||
|
color: [u8; 4],
|
||||||
|
font_family: Option<&str>,
|
||||||
|
rotation: Option<super::WatermarkRotation>,
|
||||||
|
margin_px: u32,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
if text.is_empty() {
|
||||||
|
return Ok(img);
|
||||||
|
}
|
||||||
|
let font_data = find_system_font(font_family)?;
|
||||||
|
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||||
|
PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: "Failed to load font".into(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(rot) = rotation {
|
||||||
|
// Render text to buffer, rotate, then overlay
|
||||||
|
let text_buf = render_text_to_image(text, &font, font_size, color, opacity);
|
||||||
|
let rotated = rotate_watermark_image(DynamicImage::ImageRgba8(text_buf), rot);
|
||||||
|
|
||||||
|
let wm_dims = Dimensions {
|
||||||
|
width: rotated.width(),
|
||||||
|
height: rotated.height(),
|
||||||
|
};
|
||||||
|
let image_dims = Dimensions {
|
||||||
|
width: img.width(),
|
||||||
|
height: img.height(),
|
||||||
|
};
|
||||||
|
let margin = if margin_px > 0 { margin_px } else { (font_size * 0.5) as u32 };
|
||||||
|
let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
|
||||||
|
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
image::imageops::overlay(&mut rgba, &rotated.to_rgba8(), x as i64, y as i64);
|
||||||
|
Ok(DynamicImage::ImageRgba8(rgba))
|
||||||
|
} else {
|
||||||
|
// No rotation - draw text directly (faster)
|
||||||
|
let scale = ab_glyph::PxScale::from(font_size);
|
||||||
|
let (tw, th) = estimate_text_dimensions(text, font_size);
|
||||||
|
let text_dims = Dimensions {
|
||||||
|
width: tw,
|
||||||
|
height: th,
|
||||||
|
};
|
||||||
|
let image_dims = Dimensions {
|
||||||
|
width: img.width(),
|
||||||
|
height: img.height(),
|
||||||
|
};
|
||||||
|
let margin = if margin_px > 0 { margin_px } else { (font_size * 0.5) as u32 };
|
||||||
|
let (x, y) = calculate_position(position, image_dims, text_dims, margin);
|
||||||
|
|
||||||
|
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||||
|
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text);
|
||||||
|
Ok(DynamicImage::ImageRgba8(rgba))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tiled_text_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
text: &str,
|
||||||
|
font_size: f32,
|
||||||
|
opacity: f32,
|
||||||
|
color: [u8; 4],
|
||||||
|
font_family: Option<&str>,
|
||||||
|
rotation: Option<super::WatermarkRotation>,
|
||||||
|
margin: u32,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
if text.is_empty() {
|
||||||
|
return Ok(img);
|
||||||
|
}
|
||||||
|
let font_data = find_system_font(font_family)?;
|
||||||
|
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||||
|
PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: "Failed to load font".into(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let spacing = margin.max(20);
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
let (iw, ih) = (rgba.width(), rgba.height());
|
||||||
|
|
||||||
|
if let Some(rot) = rotation {
|
||||||
|
// Render a single tile, rotate it, then tile across the image
|
||||||
|
let tile_buf = render_text_to_image(text, &font, font_size, color, opacity);
|
||||||
|
let rotated = rotate_watermark_image(DynamicImage::ImageRgba8(tile_buf), rot);
|
||||||
|
let tile = rotated.to_rgba8();
|
||||||
|
let tw = tile.width();
|
||||||
|
let th = tile.height();
|
||||||
|
|
||||||
|
let mut y: i64 = 0;
|
||||||
|
while y < ih as i64 {
|
||||||
|
let mut x: i64 = 0;
|
||||||
|
while x < iw as i64 {
|
||||||
|
image::imageops::overlay(&mut rgba, &tile, x, y);
|
||||||
|
x += tw as i64 + spacing as i64;
|
||||||
|
}
|
||||||
|
y += th as i64 + spacing as i64;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No rotation - draw text directly (faster)
|
||||||
|
let scale = ab_glyph::PxScale::from(font_size);
|
||||||
|
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||||
|
|
||||||
|
let (text_width, text_height) = estimate_text_dimensions(text, font_size);
|
||||||
|
|
||||||
|
let mut y: i64 = 0;
|
||||||
|
while y < ih as i64 {
|
||||||
|
let mut x: i64 = 0;
|
||||||
|
while x < iw as i64 {
|
||||||
|
draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text);
|
||||||
|
x += text_width as i64 + spacing as i64;
|
||||||
|
}
|
||||||
|
y += text_height as i64 + spacing as i64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DynamicImage::ImageRgba8(rgba))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tiled_image_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
watermark_path: &std::path::Path,
|
||||||
|
opacity: f32,
|
||||||
|
scale: f32,
|
||||||
|
rotation: Option<super::WatermarkRotation>,
|
||||||
|
margin: u32,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: format!("Failed to load watermark image: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let safe_scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||||
|
let wm_width = ((watermark.width() as f32 * safe_scale) as u32).clamp(1, 16384);
|
||||||
|
let wm_height = ((watermark.height() as f32 * safe_scale) as u32).clamp(1, 16384);
|
||||||
|
|
||||||
|
let mut watermark = watermark.resize_exact(wm_width, wm_height, image::imageops::FilterType::Lanczos3);
|
||||||
|
if let Some(rot) = rotation {
|
||||||
|
watermark = rotate_watermark_image(watermark, rot);
|
||||||
|
}
|
||||||
|
let overlay = watermark.to_rgba8();
|
||||||
|
let ow = overlay.width();
|
||||||
|
let oh = overlay.height();
|
||||||
|
let alpha_factor = opacity.clamp(0.0, 1.0);
|
||||||
|
let spacing = margin.max(10);
|
||||||
|
|
||||||
|
let mut base = img.into_rgba8();
|
||||||
|
let (iw, ih) = (base.width(), base.height());
|
||||||
|
|
||||||
|
let mut ty = 0u32;
|
||||||
|
while ty < ih {
|
||||||
|
let mut tx = 0u32;
|
||||||
|
while tx < iw {
|
||||||
|
for oy in 0..oh {
|
||||||
|
for ox in 0..ow {
|
||||||
|
let px = tx + ox;
|
||||||
|
let py = ty + oy;
|
||||||
|
if px < iw && py < ih {
|
||||||
|
let wm_pixel = overlay.get_pixel(ox, oy);
|
||||||
|
let base_pixel = base.get_pixel(px, py);
|
||||||
|
let wm_alpha = (wm_pixel[3] as f32 / 255.0) * alpha_factor;
|
||||||
|
let base_alpha = base_pixel[3] as f32 / 255.0;
|
||||||
|
let out_alpha = wm_alpha + base_alpha * (1.0 - wm_alpha);
|
||||||
|
if out_alpha > 0.0 {
|
||||||
|
let r = ((wm_pixel[0] as f32 * wm_alpha + base_pixel[0] as f32 * base_alpha * (1.0 - wm_alpha)) / out_alpha) as u8;
|
||||||
|
let g = ((wm_pixel[1] as f32 * wm_alpha + base_pixel[1] as f32 * base_alpha * (1.0 - wm_alpha)) / out_alpha) as u8;
|
||||||
|
let b = ((wm_pixel[2] as f32 * wm_alpha + base_pixel[2] as f32 * base_alpha * (1.0 - wm_alpha)) / out_alpha) as u8;
|
||||||
|
let a = (out_alpha * 255.0) as u8;
|
||||||
|
base.put_pixel(px, py, Rgba([r, g, b, a]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx += ow + spacing;
|
||||||
|
}
|
||||||
|
ty += oh + spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DynamicImage::ImageRgba8(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_image_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
watermark_path: &std::path::Path,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
opacity: f32,
|
||||||
|
scale: f32,
|
||||||
|
rotation: Option<super::WatermarkRotation>,
|
||||||
|
margin: u32,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: format!("Failed to load watermark image: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Scale the watermark (capped to prevent OOM on extreme scale values)
|
||||||
|
let safe_scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||||
|
let wm_width = ((watermark.width() as f32 * safe_scale) as u32).clamp(1, 16384);
|
||||||
|
let wm_height = ((watermark.height() as f32 * safe_scale) as u32).clamp(1, 16384);
|
||||||
|
|
||||||
|
let mut watermark = watermark.resize_exact(
|
||||||
|
wm_width,
|
||||||
|
wm_height,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(rot) = rotation {
|
||||||
|
watermark = rotate_watermark_image(watermark, rot);
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_dims = Dimensions {
|
||||||
|
width: img.width(),
|
||||||
|
height: img.height(),
|
||||||
|
};
|
||||||
|
let wm_dims = Dimensions {
|
||||||
|
width: watermark.width(),
|
||||||
|
height: watermark.height(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
|
||||||
|
|
||||||
|
let mut base = img.into_rgba8();
|
||||||
|
let overlay = watermark.to_rgba8();
|
||||||
|
let ow = overlay.width();
|
||||||
|
let oh = overlay.height();
|
||||||
|
|
||||||
|
let alpha_factor = opacity.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
for oy in 0..oh {
|
||||||
|
for ox in 0..ow {
|
||||||
|
let px = x + ox;
|
||||||
|
let py = y + oy;
|
||||||
|
|
||||||
|
if px < base.width() && py < base.height() {
|
||||||
|
let wm_pixel = overlay.get_pixel(ox, oy);
|
||||||
|
let base_pixel = base.get_pixel(px, py);
|
||||||
|
|
||||||
|
let wm_alpha = (wm_pixel[3] as f32 / 255.0) * alpha_factor;
|
||||||
|
let base_alpha = base_pixel[3] as f32 / 255.0;
|
||||||
|
|
||||||
|
let out_alpha = wm_alpha + base_alpha * (1.0 - wm_alpha);
|
||||||
|
|
||||||
|
if out_alpha > 0.0 {
|
||||||
|
let r = ((wm_pixel[0] as f32 * wm_alpha
|
||||||
|
+ base_pixel[0] as f32 * base_alpha * (1.0 - wm_alpha))
|
||||||
|
/ out_alpha) as u8;
|
||||||
|
let g = ((wm_pixel[1] as f32 * wm_alpha
|
||||||
|
+ base_pixel[1] as f32 * base_alpha * (1.0 - wm_alpha))
|
||||||
|
/ out_alpha) as u8;
|
||||||
|
let b = ((wm_pixel[2] as f32 * wm_alpha
|
||||||
|
+ base_pixel[2] as f32 * base_alpha * (1.0 - wm_alpha))
|
||||||
|
/ out_alpha) as u8;
|
||||||
|
let a = (out_alpha * 255.0) as u8;
|
||||||
|
|
||||||
|
base.put_pixel(px, py, Rgba([r, g, b, a]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DynamicImage::ImageRgba8(base))
|
||||||
|
}
|
||||||
@@ -1 +1,122 @@
|
|||||||
// Processing pipeline
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::operations::*;
|
||||||
|
use crate::types::{ImageFormat, ImageSource};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProcessingJob {
|
||||||
|
pub input_dir: PathBuf,
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub sources: Vec<ImageSource>,
|
||||||
|
pub resize: Option<ResizeConfig>,
|
||||||
|
pub resize_algorithm: ResizeAlgorithm,
|
||||||
|
pub rotation: Option<Rotation>,
|
||||||
|
pub flip: Option<Flip>,
|
||||||
|
pub adjustments: Option<AdjustmentsConfig>,
|
||||||
|
pub convert: Option<ConvertConfig>,
|
||||||
|
pub compress: Option<CompressConfig>,
|
||||||
|
pub metadata: Option<MetadataConfig>,
|
||||||
|
pub watermark: Option<WatermarkConfig>,
|
||||||
|
pub rename: Option<RenameConfig>,
|
||||||
|
pub overwrite_behavior: OverwriteAction,
|
||||||
|
pub preserve_directory_structure: bool,
|
||||||
|
pub progressive_jpeg: bool,
|
||||||
|
pub avif_speed: u8,
|
||||||
|
pub output_dpi: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessingJob {
|
||||||
|
pub fn new(input_dir: impl AsRef<Path>, output_dir: impl AsRef<Path>) -> Self {
|
||||||
|
Self {
|
||||||
|
input_dir: input_dir.as_ref().to_path_buf(),
|
||||||
|
output_dir: output_dir.as_ref().to_path_buf(),
|
||||||
|
sources: Vec::new(),
|
||||||
|
resize: None,
|
||||||
|
resize_algorithm: ResizeAlgorithm::default(),
|
||||||
|
rotation: None,
|
||||||
|
flip: None,
|
||||||
|
adjustments: None,
|
||||||
|
convert: None,
|
||||||
|
compress: None,
|
||||||
|
metadata: None,
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
overwrite_behavior: OverwriteAction::default(),
|
||||||
|
preserve_directory_structure: false,
|
||||||
|
progressive_jpeg: false,
|
||||||
|
avif_speed: 6,
|
||||||
|
output_dpi: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_source(&mut self, path: impl AsRef<Path>) {
|
||||||
|
self.sources.push(ImageSource::from_path(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn operation_count(&self) -> usize {
|
||||||
|
let mut count = 0;
|
||||||
|
if self.resize.is_some() { count += 1; }
|
||||||
|
if self.rotation.is_some() { count += 1; }
|
||||||
|
if self.flip.is_some() { count += 1; }
|
||||||
|
if self.adjustments.as_ref().is_some_and(|a| !a.is_noop()) { count += 1; }
|
||||||
|
if self.convert.is_some() { count += 1; }
|
||||||
|
if self.compress.is_some() { count += 1; }
|
||||||
|
if self.metadata.is_some() { count += 1; }
|
||||||
|
if self.watermark.is_some() { count += 1; }
|
||||||
|
if self.rename.is_some() { count += 1; }
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the job requires decoding/encoding pixel data.
|
||||||
|
/// When false, we can use a fast copy-and-rename path.
|
||||||
|
pub fn needs_pixel_processing(&self) -> bool {
|
||||||
|
self.resize.is_some()
|
||||||
|
|| matches!(self.rotation, Some(r) if !matches!(r, Rotation::None))
|
||||||
|
|| matches!(self.flip, Some(f) if !matches!(f, Flip::None))
|
||||||
|
|| self.adjustments.as_ref().is_some_and(|a| !a.is_noop())
|
||||||
|
|| self.convert.is_some()
|
||||||
|
|| self.compress.is_some()
|
||||||
|
|| self.watermark.is_some()
|
||||||
|
|| self.output_dpi > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output_path_for(
|
||||||
|
&self,
|
||||||
|
source: &ImageSource,
|
||||||
|
output_format: Option<ImageFormat>,
|
||||||
|
) -> PathBuf {
|
||||||
|
let stem = source
|
||||||
|
.path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("output");
|
||||||
|
|
||||||
|
let ext = output_format
|
||||||
|
.map(|f| f.extension())
|
||||||
|
.or_else(|| {
|
||||||
|
source
|
||||||
|
.path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
})
|
||||||
|
.unwrap_or("bin");
|
||||||
|
|
||||||
|
let filename = format!("{}.{}", stem, ext);
|
||||||
|
|
||||||
|
if self.preserve_directory_structure {
|
||||||
|
// Maintain relative path from input_dir
|
||||||
|
if let Ok(rel) = source.path.strip_prefix(&self.input_dir) {
|
||||||
|
if let Some(parent) = rel.parent() {
|
||||||
|
if parent.components().count() > 0 {
|
||||||
|
return self.output_dir.join(parent).join(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.output_dir.join(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,289 @@
|
|||||||
// Preset management
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::operations::*;
|
||||||
|
use crate::pipeline::ProcessingJob;
|
||||||
|
use crate::types::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Preset {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub icon_color: String,
|
||||||
|
pub is_custom: bool,
|
||||||
|
pub resize: Option<ResizeConfig>,
|
||||||
|
pub rotation: Option<Rotation>,
|
||||||
|
pub flip: Option<Flip>,
|
||||||
|
pub convert: Option<ConvertConfig>,
|
||||||
|
pub compress: Option<CompressConfig>,
|
||||||
|
pub metadata: Option<MetadataConfig>,
|
||||||
|
pub watermark: Option<WatermarkConfig>,
|
||||||
|
pub rename: Option<RenameConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Preset {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: String::new(),
|
||||||
|
description: String::new(),
|
||||||
|
icon: "image-x-generic-symbolic".into(),
|
||||||
|
icon_color: String::new(),
|
||||||
|
is_custom: true,
|
||||||
|
resize: None,
|
||||||
|
rotation: None,
|
||||||
|
flip: None,
|
||||||
|
convert: None,
|
||||||
|
compress: None,
|
||||||
|
metadata: None,
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preset {
|
||||||
|
pub fn to_job(
|
||||||
|
&self,
|
||||||
|
input_dir: impl Into<std::path::PathBuf>,
|
||||||
|
output_dir: impl Into<std::path::PathBuf>,
|
||||||
|
) -> ProcessingJob {
|
||||||
|
ProcessingJob {
|
||||||
|
input_dir: input_dir.into(),
|
||||||
|
output_dir: output_dir.into(),
|
||||||
|
sources: Vec::new(),
|
||||||
|
resize: self.resize.clone(),
|
||||||
|
resize_algorithm: crate::operations::ResizeAlgorithm::default(),
|
||||||
|
rotation: self.rotation,
|
||||||
|
flip: self.flip,
|
||||||
|
adjustments: None,
|
||||||
|
convert: self.convert.clone(),
|
||||||
|
compress: self.compress.clone(),
|
||||||
|
metadata: self.metadata.clone(),
|
||||||
|
watermark: self.watermark.clone(),
|
||||||
|
rename: self.rename.clone(),
|
||||||
|
overwrite_behavior: crate::operations::OverwriteAction::default(),
|
||||||
|
preserve_directory_structure: false,
|
||||||
|
progressive_jpeg: false,
|
||||||
|
avif_speed: self.compress.as_ref().map(|c| match c {
|
||||||
|
crate::operations::CompressConfig::Preset(p) => p.avif_speed(),
|
||||||
|
_ => 6,
|
||||||
|
}).unwrap_or(6),
|
||||||
|
output_dpi: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_builtins() -> Vec<Preset> {
|
||||||
|
vec![
|
||||||
|
Self::builtin_blog_photos(),
|
||||||
|
Self::builtin_social_media(),
|
||||||
|
Self::builtin_web_optimization(),
|
||||||
|
Self::builtin_email_friendly(),
|
||||||
|
Self::builtin_privacy_clean(),
|
||||||
|
Self::builtin_photographer_export(),
|
||||||
|
Self::builtin_archive_compress(),
|
||||||
|
Self::builtin_fediverse_ready(),
|
||||||
|
Self::builtin_print_ready(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_blog_photos() -> Preset {
|
||||||
|
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,
|
||||||
|
flip: None,
|
||||||
|
convert: None,
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::High)),
|
||||||
|
metadata: Some(MetadataConfig::StripAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_social_media() -> Preset {
|
||||||
|
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 {
|
||||||
|
width: 1080,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
allow_upscale: false,
|
||||||
|
}),
|
||||||
|
rotation: None,
|
||||||
|
flip: None,
|
||||||
|
convert: None,
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::Medium)),
|
||||||
|
metadata: Some(MetadataConfig::StripAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_web_optimization() -> Preset {
|
||||||
|
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,
|
||||||
|
flip: None,
|
||||||
|
convert: Some(ConvertConfig::SingleFormat(ImageFormat::WebP)),
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::High)),
|
||||||
|
metadata: Some(MetadataConfig::StripAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: Some(RenameConfig {
|
||||||
|
prefix: String::new(),
|
||||||
|
suffix: String::new(),
|
||||||
|
counter_start: 1,
|
||||||
|
counter_padding: 3,
|
||||||
|
counter_enabled: true,
|
||||||
|
counter_position: 3,
|
||||||
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
replace_spaces: 0,
|
||||||
|
special_chars: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_email_friendly() -> Preset {
|
||||||
|
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,
|
||||||
|
flip: None,
|
||||||
|
convert: None,
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::Medium)),
|
||||||
|
metadata: Some(MetadataConfig::StripAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_privacy_clean() -> Preset {
|
||||||
|
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,
|
||||||
|
flip: None,
|
||||||
|
convert: None,
|
||||||
|
compress: None,
|
||||||
|
metadata: Some(MetadataConfig::StripAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_photographer_export() -> Preset {
|
||||||
|
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,
|
||||||
|
flip: None,
|
||||||
|
convert: None,
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::High)),
|
||||||
|
metadata: Some(MetadataConfig::Privacy),
|
||||||
|
watermark: None,
|
||||||
|
rename: Some(RenameConfig {
|
||||||
|
prefix: String::new(),
|
||||||
|
suffix: String::new(),
|
||||||
|
counter_start: 1,
|
||||||
|
counter_padding: 4,
|
||||||
|
counter_enabled: true,
|
||||||
|
counter_position: 3,
|
||||||
|
template: Some("{exif_date}_{name}_{counter:4}".into()),
|
||||||
|
case_mode: 0,
|
||||||
|
replace_spaces: 0,
|
||||||
|
special_chars: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_archive_compress() -> Preset {
|
||||||
|
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,
|
||||||
|
flip: None,
|
||||||
|
convert: None,
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::Maximum)),
|
||||||
|
metadata: Some(MetadataConfig::KeepAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_print_ready() -> Preset {
|
||||||
|
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,
|
||||||
|
flip: None,
|
||||||
|
convert: Some(ConvertConfig::SingleFormat(ImageFormat::Png)),
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::Maximum)),
|
||||||
|
metadata: Some(MetadataConfig::KeepAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn builtin_fediverse_ready() -> Preset {
|
||||||
|
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 {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
allow_upscale: false,
|
||||||
|
}),
|
||||||
|
rotation: None,
|
||||||
|
flip: None,
|
||||||
|
convert: Some(ConvertConfig::SingleFormat(ImageFormat::WebP)),
|
||||||
|
compress: Some(CompressConfig::Preset(QualityPreset::High)),
|
||||||
|
metadata: Some(MetadataConfig::StripAll),
|
||||||
|
watermark: None,
|
||||||
|
rename: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
342
pixstrip-core/src/storage.rs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
use crate::preset::Preset;
|
||||||
|
|
||||||
|
fn default_config_dir() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("pixstrip")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write to a temporary file then rename, for crash safety.
|
||||||
|
pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp, contents)?;
|
||||||
|
std::fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*' => '_',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Preset Store ---
|
||||||
|
|
||||||
|
pub struct PresetStore {
|
||||||
|
presets_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresetStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
presets_dir: default_config_dir().join("presets"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_base_dir(base: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
presets_dir: base.join("presets"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_dir(&self) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(&self.presets_dir).map_err(PixstripError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preset_path(&self, name: &str) -> PathBuf {
|
||||||
|
let filename = format!("{}.json", sanitize_filename(name));
|
||||||
|
self.presets_dir.join(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, preset: &Preset) -> Result<()> {
|
||||||
|
self.ensure_dir()?;
|
||||||
|
let path = self.preset_path(&preset.name);
|
||||||
|
let json = serde_json::to_string_pretty(preset)
|
||||||
|
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
||||||
|
atomic_write(&path, &json).map_err(PixstripError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self, name: &str) -> Result<Preset> {
|
||||||
|
let path = self.preset_path(name);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(PixstripError::Preset(format!(
|
||||||
|
"Preset '{}' not found",
|
||||||
|
name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?;
|
||||||
|
serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Result<Vec<Preset>> {
|
||||||
|
if !self.presets_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut presets = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(&self.presets_dir).map_err(PixstripError::Io)? {
|
||||||
|
let entry = entry.map_err(PixstripError::Io)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
||||||
|
let data = std::fs::read_to_string(&path).map_err(PixstripError::Io)?;
|
||||||
|
if let Ok(preset) = serde_json::from_str::<Preset>(&data) {
|
||||||
|
presets.push(preset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presets.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(presets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, name: &str) -> Result<()> {
|
||||||
|
let path = self.preset_path(name);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(PixstripError::Preset(format!(
|
||||||
|
"Preset '{}' not found",
|
||||||
|
name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
std::fs::remove_file(&path).map_err(PixstripError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_to_file(&self, preset: &Preset, path: &Path) -> Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(preset)
|
||||||
|
.map_err(|e| PixstripError::Preset(e.to_string()))?;
|
||||||
|
atomic_write(path, &json).map_err(PixstripError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_from_file(&self, path: &Path) -> Result<Preset> {
|
||||||
|
let data = std::fs::read_to_string(path).map_err(PixstripError::Io)?;
|
||||||
|
let mut preset: Preset =
|
||||||
|
serde_json::from_str(&data).map_err(|e| PixstripError::Preset(e.to_string()))?;
|
||||||
|
preset.is_custom = true;
|
||||||
|
Ok(preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PresetStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config Store ---
|
||||||
|
|
||||||
|
pub struct ConfigStore {
|
||||||
|
config_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
config_path: default_config_dir().join("config.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_base_dir(base: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
config_path: base.join("config.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, config: &AppConfig) -> Result<()> {
|
||||||
|
if let Some(parent) = self.config_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(config)
|
||||||
|
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||||
|
atomic_write(&self.config_path, &json).map_err(PixstripError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self) -> Result<AppConfig> {
|
||||||
|
if !self.config_path.exists() {
|
||||||
|
return Ok(AppConfig::default());
|
||||||
|
}
|
||||||
|
let data = std::fs::read_to_string(&self.config_path).map_err(PixstripError::Io)?;
|
||||||
|
serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConfigStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session Store ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SessionState {
|
||||||
|
pub last_input_dir: Option<String>,
|
||||||
|
pub last_output_dir: Option<String>,
|
||||||
|
pub last_preset_name: Option<String>,
|
||||||
|
pub current_step: u32,
|
||||||
|
pub window_width: Option<i32>,
|
||||||
|
pub window_height: Option<i32>,
|
||||||
|
pub window_maximized: bool,
|
||||||
|
// Last-used wizard settings
|
||||||
|
pub resize_enabled: Option<bool>,
|
||||||
|
pub resize_width: Option<u32>,
|
||||||
|
pub resize_height: Option<u32>,
|
||||||
|
pub adjustments_enabled: Option<bool>,
|
||||||
|
pub convert_enabled: Option<bool>,
|
||||||
|
pub convert_format: Option<String>,
|
||||||
|
pub compress_enabled: Option<bool>,
|
||||||
|
pub quality_preset: Option<String>,
|
||||||
|
pub metadata_enabled: Option<bool>,
|
||||||
|
pub metadata_mode: Option<String>,
|
||||||
|
pub watermark_enabled: Option<bool>,
|
||||||
|
pub rename_enabled: Option<bool>,
|
||||||
|
pub last_seen_version: Option<String>,
|
||||||
|
pub expanded_sections: std::collections::HashMap<String, bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SessionStore {
|
||||||
|
session_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
session_path: default_config_dir().join("session.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_base_dir(base: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
session_path: base.join("session.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, state: &SessionState) -> Result<()> {
|
||||||
|
if let Some(parent) = self.session_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(state)
|
||||||
|
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||||
|
atomic_write(&self.session_path, &json).map_err(PixstripError::Io)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self) -> Result<SessionState> {
|
||||||
|
if !self.session_path.exists() {
|
||||||
|
return Ok(SessionState::default());
|
||||||
|
}
|
||||||
|
let data = std::fs::read_to_string(&self.session_path).map_err(PixstripError::Io)?;
|
||||||
|
serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- History Store ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HistoryEntry {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub input_dir: String,
|
||||||
|
pub output_dir: String,
|
||||||
|
pub preset_name: Option<String>,
|
||||||
|
pub total: usize,
|
||||||
|
pub succeeded: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
pub total_input_bytes: u64,
|
||||||
|
pub total_output_bytes: u64,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
pub output_files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HistoryStore {
|
||||||
|
history_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
history_path: default_config_dir().join("history.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_base_dir(base: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
history_path: base.join("history.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&self, entry: HistoryEntry, max_entries: usize, max_days: u32) -> Result<()> {
|
||||||
|
let mut entries = self.list()?;
|
||||||
|
entries.push(entry);
|
||||||
|
self.write_all(&entries)?;
|
||||||
|
self.prune(max_entries, max_days)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune(&self, max_entries: usize, max_days: u32) -> Result<()> {
|
||||||
|
let mut entries = self.list()?;
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now_secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let cutoff_secs = now_secs.saturating_sub(max_days as u64 * 86400);
|
||||||
|
|
||||||
|
// Remove entries older than max_days (keep entries with unparseable timestamps)
|
||||||
|
entries.retain(|e| {
|
||||||
|
e.timestamp.parse::<u64>().map_or(true, |ts| ts >= cutoff_secs)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trim to max_entries (keep the most recent)
|
||||||
|
if entries.len() > max_entries {
|
||||||
|
let start = entries.len() - max_entries;
|
||||||
|
entries = entries.split_off(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_all(&entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Result<Vec<HistoryEntry>> {
|
||||||
|
if !self.history_path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let data = std::fs::read_to_string(&self.history_path).map_err(PixstripError::Io)?;
|
||||||
|
if data.trim().is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
serde_json::from_str(&data).map_err(|e| PixstripError::Config(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&self) -> Result<()> {
|
||||||
|
self.write_all(&Vec::<HistoryEntry>::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_all(&self, entries: &[HistoryEntry]) -> Result<()> {
|
||||||
|
if let Some(parent) = self.history_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(entries)
|
||||||
|
.map_err(|e| PixstripError::Config(e.to_string()))?;
|
||||||
|
atomic_write(&self.history_path, &json).map_err(PixstripError::Io)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HistoryStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub enum ImageFormat {
|
|||||||
Avif,
|
Avif,
|
||||||
Gif,
|
Gif,
|
||||||
Tiff,
|
Tiff,
|
||||||
|
Bmp,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImageFormat {
|
impl ImageFormat {
|
||||||
@@ -22,6 +23,7 @@ impl ImageFormat {
|
|||||||
"avif" => Some(Self::Avif),
|
"avif" => Some(Self::Avif),
|
||||||
"gif" => Some(Self::Gif),
|
"gif" => Some(Self::Gif),
|
||||||
"tiff" | "tif" => Some(Self::Tiff),
|
"tiff" | "tif" => Some(Self::Tiff),
|
||||||
|
"bmp" => Some(Self::Bmp),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ impl ImageFormat {
|
|||||||
Self::Avif => "avif",
|
Self::Avif => "avif",
|
||||||
Self::Gif => "gif",
|
Self::Gif => "gif",
|
||||||
Self::Tiff => "tiff",
|
Self::Tiff => "tiff",
|
||||||
|
Self::Bmp => "bmp",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,10 +69,17 @@ pub struct Dimensions {
|
|||||||
|
|
||||||
impl Dimensions {
|
impl Dimensions {
|
||||||
pub fn aspect_ratio(&self) -> f64 {
|
pub fn aspect_ratio(&self) -> f64 {
|
||||||
|
if self.height == 0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
self.width as f64 / self.height as f64
|
self.width as f64 / self.height as f64
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions {
|
pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions {
|
||||||
|
if self.width == 0 || self.height == 0 || max.width == 0 || max.height == 0 {
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
if !allow_upscale && self.width <= max.width && self.height <= max.height {
|
if !allow_upscale && self.width <= max.width && self.height <= max.height {
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
@@ -83,8 +93,8 @@ impl Dimensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Dimensions {
|
Dimensions {
|
||||||
width: (self.width as f64 * scale).round() as u32,
|
width: (self.width as f64 * scale).round().max(1.0) as u32,
|
||||||
height: (self.height as f64 * scale).round() as u32,
|
height: (self.height as f64 * scale).round().max(1.0) as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +145,36 @@ impl QualityPreset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn webp_effort(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Maximum => 6,
|
||||||
|
Self::High => 5,
|
||||||
|
Self::Medium => 4,
|
||||||
|
Self::Low => 3,
|
||||||
|
Self::WebOptimized => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn avif_quality(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Maximum => 80,
|
||||||
|
Self::High => 63,
|
||||||
|
Self::Medium => 50,
|
||||||
|
Self::Low => 35,
|
||||||
|
Self::WebOptimized => 40,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn avif_speed(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Maximum => 4,
|
||||||
|
Self::High => 6,
|
||||||
|
Self::Medium => 6,
|
||||||
|
Self::Low => 8,
|
||||||
|
Self::WebOptimized => 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn label(&self) -> &'static str {
|
pub fn label(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Maximum => "Maximum",
|
Self::Maximum => "Maximum",
|
||||||
|
|||||||
118
pixstrip-core/src/watcher.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use notify::{Event, EventKind, RecursiveMode, Watcher};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WatchFolder {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub preset_name: String,
|
||||||
|
pub recursive: bool,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum WatchEvent {
|
||||||
|
NewImage(PathBuf),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FolderWatcher {
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FolderWatcher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(
|
||||||
|
&self,
|
||||||
|
folder: &WatchFolder,
|
||||||
|
event_tx: mpsc::Sender<WatchEvent>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !folder.path.exists() {
|
||||||
|
return Err(PixstripError::Config(format!(
|
||||||
|
"Watch folder does not exist: {}",
|
||||||
|
folder.path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.running.store(true, Ordering::Relaxed);
|
||||||
|
let running = self.running.clone();
|
||||||
|
let watch_path = folder.path.clone();
|
||||||
|
let recursive = folder.recursive;
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let mut watcher = match notify::recommended_watcher(move |res: std::result::Result<Event, notify::Error>| {
|
||||||
|
if let Ok(event) = res {
|
||||||
|
let _ = tx.send(event);
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = event_tx.send(WatchEvent::Error(e.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mode = if recursive {
|
||||||
|
RecursiveMode::Recursive
|
||||||
|
} else {
|
||||||
|
RecursiveMode::NonRecursive
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = watcher.watch(&watch_path, mode) {
|
||||||
|
let _ = event_tx.send(WatchEvent::Error(e.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
match rx.recv_timeout(Duration::from_millis(500)) {
|
||||||
|
Ok(event) => {
|
||||||
|
if matches!(event.kind, EventKind::Create(_)) {
|
||||||
|
for path in event.paths {
|
||||||
|
if is_image_file(&path) {
|
||||||
|
let _ = event_tx.send(WatchEvent::NewImage(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.running.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FolderWatcher {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_image_file(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.is_some_and(crate::discovery::is_image_extension)
|
||||||
|
}
|
||||||
145
pixstrip-core/tests/adjustments_tests.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
use pixstrip_core::operations::AdjustmentsConfig;
|
||||||
|
use pixstrip_core::operations::adjustments::apply_adjustments;
|
||||||
|
use image::DynamicImage;
|
||||||
|
|
||||||
|
fn noop_config() -> AdjustmentsConfig {
|
||||||
|
AdjustmentsConfig {
|
||||||
|
brightness: 0,
|
||||||
|
contrast: 0,
|
||||||
|
saturation: 0,
|
||||||
|
sharpen: false,
|
||||||
|
grayscale: false,
|
||||||
|
sepia: false,
|
||||||
|
crop_aspect_ratio: None,
|
||||||
|
trim_whitespace: false,
|
||||||
|
canvas_padding: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_default() {
|
||||||
|
assert!(noop_config().is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_with_brightness() {
|
||||||
|
let mut config = noop_config();
|
||||||
|
config.brightness = 10;
|
||||||
|
assert!(!config.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_with_sharpen() {
|
||||||
|
let mut config = noop_config();
|
||||||
|
config.sharpen = true;
|
||||||
|
assert!(!config.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_with_crop() {
|
||||||
|
let mut config = noop_config();
|
||||||
|
config.crop_aspect_ratio = Some((16.0, 9.0));
|
||||||
|
assert!(!config.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_with_trim() {
|
||||||
|
let mut config = noop_config();
|
||||||
|
config.trim_whitespace = true;
|
||||||
|
assert!(!config.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_with_padding() {
|
||||||
|
let mut config = noop_config();
|
||||||
|
config.canvas_padding = 20;
|
||||||
|
assert!(!config.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_with_grayscale() {
|
||||||
|
let mut config = noop_config();
|
||||||
|
config.grayscale = true;
|
||||||
|
assert!(!config.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_noop_with_sepia() {
|
||||||
|
let mut config = noop_config();
|
||||||
|
config.sepia = true;
|
||||||
|
assert!(!config.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- crop_to_aspect_ratio edge cases ---
|
||||||
|
|
||||||
|
fn make_test_image(w: u32, h: u32) -> DynamicImage {
|
||||||
|
DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(w, h, image::Rgba([128, 128, 128, 255])))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crop_zero_ratio_returns_original() {
|
||||||
|
let img = make_test_image(100, 100);
|
||||||
|
let config = AdjustmentsConfig {
|
||||||
|
crop_aspect_ratio: Some((0.0, 9.0)),
|
||||||
|
..noop_config()
|
||||||
|
};
|
||||||
|
let result = apply_adjustments(img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 100);
|
||||||
|
assert_eq!(result.height(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crop_zero_height_ratio_returns_original() {
|
||||||
|
let img = make_test_image(100, 100);
|
||||||
|
let config = AdjustmentsConfig {
|
||||||
|
crop_aspect_ratio: Some((16.0, 0.0)),
|
||||||
|
..noop_config()
|
||||||
|
};
|
||||||
|
let result = apply_adjustments(img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 100);
|
||||||
|
assert_eq!(result.height(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crop_square_on_landscape() {
|
||||||
|
let img = make_test_image(200, 100);
|
||||||
|
let config = AdjustmentsConfig {
|
||||||
|
crop_aspect_ratio: Some((1.0, 1.0)),
|
||||||
|
..noop_config()
|
||||||
|
};
|
||||||
|
let result = apply_adjustments(img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 100);
|
||||||
|
assert_eq!(result.height(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crop_16_9_on_square() {
|
||||||
|
let img = make_test_image(100, 100);
|
||||||
|
let config = AdjustmentsConfig {
|
||||||
|
crop_aspect_ratio: Some((16.0, 9.0)),
|
||||||
|
..noop_config()
|
||||||
|
};
|
||||||
|
let result = apply_adjustments(img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 100);
|
||||||
|
assert_eq!(result.height(), 56);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canvas_padding_large_value() {
|
||||||
|
let img = make_test_image(10, 10);
|
||||||
|
let config = AdjustmentsConfig {
|
||||||
|
canvas_padding: 500,
|
||||||
|
..noop_config()
|
||||||
|
};
|
||||||
|
let result = apply_adjustments(img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 1010);
|
||||||
|
assert_eq!(result.height(), 1010);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noop_config_returns_same_dimensions() {
|
||||||
|
let img = make_test_image(200, 100);
|
||||||
|
let result = apply_adjustments(img, &noop_config()).unwrap();
|
||||||
|
assert_eq!(result.width(), 200);
|
||||||
|
assert_eq!(result.height(), 100);
|
||||||
|
}
|
||||||
27
pixstrip-core/tests/config_tests.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use pixstrip_core::config::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
assert_eq!(config.output_subfolder, "processed");
|
||||||
|
assert_eq!(config.overwrite_behavior, OverwriteBehavior::Ask);
|
||||||
|
assert!(config.remember_settings);
|
||||||
|
assert_eq!(config.skill_level, SkillLevel::Simple);
|
||||||
|
assert_eq!(config.thread_count, ThreadCount::Auto);
|
||||||
|
assert_eq!(config.error_behavior, ErrorBehavior::SkipAndContinue);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_serialization_roundtrip() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let deserialized: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.output_subfolder, config.output_subfolder);
|
||||||
|
assert_eq!(deserialized.overwrite_behavior, config.overwrite_behavior);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_level_toggle() {
|
||||||
|
assert!(!SkillLevel::Simple.is_advanced());
|
||||||
|
assert!(SkillLevel::Detailed.is_advanced());
|
||||||
|
}
|
||||||
52
pixstrip-core/tests/discovery_tests.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use pixstrip_core::discovery::discover_images;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn create_test_tree(root: &std::path::Path) {
|
||||||
|
fs::create_dir_all(root.join("subdir")).unwrap();
|
||||||
|
fs::write(root.join("photo1.jpg"), b"fake jpeg").unwrap();
|
||||||
|
fs::write(root.join("photo2.png"), b"fake png").unwrap();
|
||||||
|
fs::write(root.join("readme.txt"), b"not an image").unwrap();
|
||||||
|
fs::write(root.join("subdir/nested.webp"), b"fake webp").unwrap();
|
||||||
|
fs::write(root.join("subdir/doc.pdf"), b"not an image").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_flat() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
create_test_tree(dir.path());
|
||||||
|
|
||||||
|
let images = discover_images(dir.path(), false);
|
||||||
|
assert_eq!(images.len(), 2);
|
||||||
|
let names: Vec<String> = images
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
assert!(names.contains(&"photo1.jpg".to_string()));
|
||||||
|
assert!(names.contains(&"photo2.png".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_recursive() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
create_test_tree(dir.path());
|
||||||
|
|
||||||
|
let images = discover_images(dir.path(), true);
|
||||||
|
assert_eq!(images.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_empty_dir() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let images = discover_images(dir.path(), true);
|
||||||
|
assert!(images.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_single_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("single.jpg");
|
||||||
|
fs::write(&path, b"fake jpeg").unwrap();
|
||||||
|
|
||||||
|
let images = discover_images(&path, false);
|
||||||
|
assert_eq!(images.len(), 1);
|
||||||
|
}
|
||||||
88
pixstrip-core/tests/encoder_tests.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
use pixstrip_core::encoder::OutputEncoder;
|
||||||
|
use pixstrip_core::types::{ImageFormat, QualityPreset};
|
||||||
|
|
||||||
|
fn create_test_rgb_image(width: u32, height: u32) -> image::DynamicImage {
|
||||||
|
let img = image::RgbImage::from_fn(width, height, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||||
|
});
|
||||||
|
image::DynamicImage::ImageRgb8(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_rgba_image(width: u32, height: u32) -> image::DynamicImage {
|
||||||
|
let img = image::RgbaImage::from_fn(width, height, |x, y| {
|
||||||
|
image::Rgba([(x % 256) as u8, (y % 256) as u8, 100, 255])
|
||||||
|
});
|
||||||
|
image::DynamicImage::ImageRgba8(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_jpeg() {
|
||||||
|
let img = create_test_rgb_image(200, 150);
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
let bytes = encoder.encode(&img, ImageFormat::Jpeg, Some(85)).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
// JPEG magic bytes
|
||||||
|
assert_eq!(bytes[0], 0xFF);
|
||||||
|
assert_eq!(bytes[1], 0xD8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_jpeg_quality_affects_size() {
|
||||||
|
let img = create_test_rgb_image(200, 150);
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
let high = encoder.encode(&img, ImageFormat::Jpeg, Some(95)).unwrap();
|
||||||
|
let low = encoder.encode(&img, ImageFormat::Jpeg, Some(50)).unwrap();
|
||||||
|
assert!(high.len() > low.len(), "Higher quality should produce larger files");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_png() {
|
||||||
|
let img = create_test_rgba_image(200, 150);
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
let bytes = encoder.encode(&img, ImageFormat::Png, None).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
// PNG magic bytes
|
||||||
|
assert_eq!(&bytes[0..4], &[0x89, 0x50, 0x4E, 0x47]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_webp() {
|
||||||
|
let img = create_test_rgb_image(200, 150);
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
let bytes = encoder.encode(&img, ImageFormat::WebP, Some(80)).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
// RIFF header
|
||||||
|
assert_eq!(&bytes[0..4], b"RIFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_to_file() {
|
||||||
|
let img = create_test_rgb_image(200, 150);
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("output.jpg");
|
||||||
|
encoder.encode_to_file(&img, &path, ImageFormat::Jpeg, Some(85)).unwrap();
|
||||||
|
assert!(path.exists());
|
||||||
|
let metadata = std::fs::metadata(&path).unwrap();
|
||||||
|
assert!(metadata.len() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quality_from_preset() {
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
let preset = QualityPreset::High;
|
||||||
|
let q = encoder.quality_for_format(ImageFormat::Jpeg, &preset);
|
||||||
|
assert_eq!(q, 85);
|
||||||
|
let q = encoder.quality_for_format(ImageFormat::WebP, &preset);
|
||||||
|
assert!((q as f32 - 85.0).abs() < 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_gif_fallback() {
|
||||||
|
let img = create_test_rgba_image(50, 50);
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
let bytes = encoder.encode(&img, ImageFormat::Gif, None).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
// GIF magic bytes
|
||||||
|
assert_eq!(&bytes[0..3], b"GIF");
|
||||||
|
}
|
||||||
139
pixstrip-core/tests/executor_tests.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use pixstrip_core::executor::PipelineExecutor;
|
||||||
|
use pixstrip_core::operations::*;
|
||||||
|
use pixstrip_core::pipeline::ProcessingJob;
|
||||||
|
use pixstrip_core::types::*;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn create_test_jpeg(path: &std::path::Path) {
|
||||||
|
let img = image::RgbImage::from_fn(200, 150, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||||
|
});
|
||||||
|
img.save_with_format(path, image::ImageFormat::Jpeg).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_test_dir() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("input");
|
||||||
|
let output = dir.path().join("output");
|
||||||
|
std::fs::create_dir_all(&input).unwrap();
|
||||||
|
std::fs::create_dir_all(&output).unwrap();
|
||||||
|
(dir, input, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_single_image_resize() {
|
||||||
|
let (_dir, input, output) = setup_test_dir();
|
||||||
|
create_test_jpeg(&input.join("photo.jpg"));
|
||||||
|
|
||||||
|
let mut job = ProcessingJob::new(&input, &output);
|
||||||
|
job.add_source(input.join("photo.jpg"));
|
||||||
|
job.resize = Some(ResizeConfig::ByWidth(100));
|
||||||
|
|
||||||
|
let executor = PipelineExecutor::new();
|
||||||
|
let result = executor.execute(&job, |_| {}).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.succeeded, 1);
|
||||||
|
assert_eq!(result.failed, 0);
|
||||||
|
assert!(output.join("photo.jpg").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_resize_and_convert() {
|
||||||
|
let (_dir, input, output) = setup_test_dir();
|
||||||
|
create_test_jpeg(&input.join("photo.jpg"));
|
||||||
|
|
||||||
|
let mut job = ProcessingJob::new(&input, &output);
|
||||||
|
job.add_source(input.join("photo.jpg"));
|
||||||
|
job.resize = Some(ResizeConfig::ByWidth(100));
|
||||||
|
job.convert = Some(ConvertConfig::SingleFormat(ImageFormat::WebP));
|
||||||
|
|
||||||
|
let executor = PipelineExecutor::new();
|
||||||
|
let result = executor.execute(&job, |_| {}).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.succeeded, 1);
|
||||||
|
assert!(output.join("photo.webp").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_multiple_images() {
|
||||||
|
let (_dir, input, output) = setup_test_dir();
|
||||||
|
create_test_jpeg(&input.join("a.jpg"));
|
||||||
|
create_test_jpeg(&input.join("b.jpg"));
|
||||||
|
create_test_jpeg(&input.join("c.jpg"));
|
||||||
|
|
||||||
|
let mut job = ProcessingJob::new(&input, &output);
|
||||||
|
job.add_source(input.join("a.jpg"));
|
||||||
|
job.add_source(input.join("b.jpg"));
|
||||||
|
job.add_source(input.join("c.jpg"));
|
||||||
|
job.resize = Some(ResizeConfig::ByWidth(50));
|
||||||
|
|
||||||
|
let executor = PipelineExecutor::new();
|
||||||
|
let result = executor.execute(&job, |_| {}).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.succeeded, 3);
|
||||||
|
assert_eq!(result.failed, 0);
|
||||||
|
assert_eq!(result.total, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_collects_progress() {
|
||||||
|
let (_dir, input, output) = setup_test_dir();
|
||||||
|
create_test_jpeg(&input.join("photo.jpg"));
|
||||||
|
|
||||||
|
let mut job = ProcessingJob::new(&input, &output);
|
||||||
|
job.add_source(input.join("photo.jpg"));
|
||||||
|
job.resize = Some(ResizeConfig::ByWidth(100));
|
||||||
|
|
||||||
|
let updates = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let updates_clone = updates.clone();
|
||||||
|
|
||||||
|
let executor = PipelineExecutor::new();
|
||||||
|
executor.execute(&job, move |update| {
|
||||||
|
updates_clone.lock().unwrap().push(update);
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
let updates = updates.lock().unwrap();
|
||||||
|
assert!(!updates.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_with_cancellation() {
|
||||||
|
let (_dir, input, output) = setup_test_dir();
|
||||||
|
create_test_jpeg(&input.join("a.jpg"));
|
||||||
|
create_test_jpeg(&input.join("b.jpg"));
|
||||||
|
|
||||||
|
let mut job = ProcessingJob::new(&input, &output);
|
||||||
|
job.add_source(input.join("a.jpg"));
|
||||||
|
job.add_source(input.join("b.jpg"));
|
||||||
|
job.resize = Some(ResizeConfig::ByWidth(100));
|
||||||
|
|
||||||
|
let cancel = Arc::new(AtomicBool::new(true)); // cancel immediately
|
||||||
|
let executor = PipelineExecutor::with_cancel(cancel);
|
||||||
|
let result = executor.execute(&job, |_| {}).unwrap();
|
||||||
|
|
||||||
|
// Cancellation flag should be set
|
||||||
|
assert!(result.cancelled, "result.cancelled should be true when cancel flag is set");
|
||||||
|
// Total processed should be less than total sources (at least some skipped)
|
||||||
|
assert!(
|
||||||
|
result.succeeded + result.failed <= 2,
|
||||||
|
"processed count ({}) should not exceed total (2)",
|
||||||
|
result.succeeded + result.failed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_result_tracks_sizes() {
|
||||||
|
let (_dir, input, output) = setup_test_dir();
|
||||||
|
create_test_jpeg(&input.join("photo.jpg"));
|
||||||
|
|
||||||
|
let mut job = ProcessingJob::new(&input, &output);
|
||||||
|
job.add_source(input.join("photo.jpg"));
|
||||||
|
job.compress = Some(CompressConfig::Preset(QualityPreset::Low));
|
||||||
|
|
||||||
|
let executor = PipelineExecutor::new();
|
||||||
|
let result = executor.execute(&job, |_| {}).unwrap();
|
||||||
|
|
||||||
|
assert!(result.total_input_bytes > 0);
|
||||||
|
assert!(result.total_output_bytes > 0);
|
||||||
|
}
|
||||||
85
pixstrip-core/tests/integration_tests.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use pixstrip_core::encoder::OutputEncoder;
|
||||||
|
use pixstrip_core::loader::ImageLoader;
|
||||||
|
use pixstrip_core::operations::resize::resize_image;
|
||||||
|
use pixstrip_core::operations::ResizeConfig;
|
||||||
|
use pixstrip_core::types::ImageFormat;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn create_test_jpeg(path: &Path) {
|
||||||
|
let img = image::RgbImage::from_fn(400, 300, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||||
|
});
|
||||||
|
img.save_with_format(path, image::ImageFormat::Jpeg).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_png(path: &Path) {
|
||||||
|
let img = image::RgbaImage::from_fn(400, 300, |x, y| {
|
||||||
|
image::Rgba([(x % 256) as u8, (y % 256) as u8, 100, 255])
|
||||||
|
});
|
||||||
|
img.save_with_format(path, image::ImageFormat::Png).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_resize_save_as_webp() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("input.jpg");
|
||||||
|
let output = dir.path().join("output.webp");
|
||||||
|
create_test_jpeg(&input);
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let img = loader.load_pixels(&input).unwrap();
|
||||||
|
|
||||||
|
let config = ResizeConfig::ByWidth(200);
|
||||||
|
let resized = resize_image(&img, &config).unwrap();
|
||||||
|
assert_eq!(resized.width(), 200);
|
||||||
|
assert_eq!(resized.height(), 150);
|
||||||
|
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
encoder.encode_to_file(&resized, &output, ImageFormat::WebP, Some(80)).unwrap();
|
||||||
|
assert!(output.exists());
|
||||||
|
|
||||||
|
let info = loader.load_info(&output).unwrap();
|
||||||
|
assert_eq!(info.dimensions.width, 200);
|
||||||
|
assert_eq!(info.dimensions.height, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_png_compress_smaller() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("input.png");
|
||||||
|
let output = dir.path().join("output.png");
|
||||||
|
create_test_png(&input);
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let img = loader.load_pixels(&input).unwrap();
|
||||||
|
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
encoder.encode_to_file(&img, &output, ImageFormat::Png, None).unwrap();
|
||||||
|
|
||||||
|
let original_size = std::fs::metadata(&input).unwrap().len();
|
||||||
|
let optimized_size = std::fs::metadata(&output).unwrap().len();
|
||||||
|
assert!(
|
||||||
|
optimized_size <= original_size,
|
||||||
|
"Optimized PNG ({}) should be <= original ({})",
|
||||||
|
optimized_size,
|
||||||
|
original_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_jpeg_convert_to_png() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("input.jpg");
|
||||||
|
let output = dir.path().join("output.png");
|
||||||
|
create_test_jpeg(&input);
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let img = loader.load_pixels(&input).unwrap();
|
||||||
|
|
||||||
|
let encoder = OutputEncoder::new();
|
||||||
|
encoder.encode_to_file(&img, &output, ImageFormat::Png, None).unwrap();
|
||||||
|
assert!(output.exists());
|
||||||
|
|
||||||
|
let info = loader.load_info(&output).unwrap();
|
||||||
|
assert_eq!(info.format, Some(ImageFormat::Png));
|
||||||
|
}
|
||||||
73
pixstrip-core/tests/loader_tests.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use pixstrip_core::loader::ImageLoader;
|
||||||
|
use pixstrip_core::types::Dimensions;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn create_test_jpeg(path: &Path) {
|
||||||
|
use image::{RgbImage, ImageFormat};
|
||||||
|
let img = RgbImage::from_fn(100, 80, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||||
|
});
|
||||||
|
img.save_with_format(path, ImageFormat::Jpeg).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_png(path: &Path) {
|
||||||
|
use image::{RgbaImage, ImageFormat};
|
||||||
|
let img = RgbaImage::from_fn(200, 150, |x, y| {
|
||||||
|
image::Rgba([(x % 256) as u8, (y % 256) as u8, 100, 255])
|
||||||
|
});
|
||||||
|
img.save_with_format(path, ImageFormat::Png).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_jpeg_dimensions() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.jpg");
|
||||||
|
create_test_jpeg(&path);
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let info = loader.load_info(&path).unwrap();
|
||||||
|
assert_eq!(info.dimensions, Dimensions { width: 100, height: 80 });
|
||||||
|
assert_eq!(info.format, Some(pixstrip_core::types::ImageFormat::Jpeg));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_png_dimensions() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.png");
|
||||||
|
create_test_png(&path);
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let info = loader.load_info(&path).unwrap();
|
||||||
|
assert_eq!(info.dimensions, Dimensions { width: 200, height: 150 });
|
||||||
|
assert_eq!(info.format, Some(pixstrip_core::types::ImageFormat::Png));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_pixels() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.jpg");
|
||||||
|
create_test_jpeg(&path);
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let pixels = loader.load_pixels(&path).unwrap();
|
||||||
|
assert_eq!(pixels.width(), 100);
|
||||||
|
assert_eq!(pixels.height(), 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_nonexistent_file() {
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let result = loader.load_info(Path::new("/tmp/does_not_exist_pixstrip.jpg"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_size_bytes() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.jpg");
|
||||||
|
create_test_jpeg(&path);
|
||||||
|
|
||||||
|
let loader = ImageLoader::new();
|
||||||
|
let info = loader.load_info(&path).unwrap();
|
||||||
|
assert!(info.file_size > 0);
|
||||||
|
}
|
||||||
125
pixstrip-core/tests/metadata_tests.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use pixstrip_core::operations::MetadataConfig;
|
||||||
|
use pixstrip_core::operations::metadata::strip_metadata;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn create_test_jpeg(path: &Path) {
|
||||||
|
let img = image::RgbImage::from_fn(100, 80, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||||
|
});
|
||||||
|
img.save_with_format(path, image::ImageFormat::Jpeg).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_all_metadata_produces_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("test.jpg");
|
||||||
|
let output = dir.path().join("stripped.jpg");
|
||||||
|
create_test_jpeg(&input);
|
||||||
|
|
||||||
|
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||||
|
assert!(output.exists());
|
||||||
|
assert!(std::fs::metadata(&output).unwrap().len() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_all_metadata_copies_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("test.jpg");
|
||||||
|
let output = dir.path().join("kept.jpg");
|
||||||
|
create_test_jpeg(&input);
|
||||||
|
|
||||||
|
strip_metadata(&input, &output, &MetadataConfig::KeepAll).unwrap();
|
||||||
|
assert!(output.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn privacy_mode_strips_gps() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("test.jpg");
|
||||||
|
let output = dir.path().join("privacy.jpg");
|
||||||
|
create_test_jpeg(&input);
|
||||||
|
|
||||||
|
strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap();
|
||||||
|
assert!(output.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_png(path: &Path) {
|
||||||
|
let img = image::RgbaImage::from_fn(100, 80, |x, y| {
|
||||||
|
image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255])
|
||||||
|
});
|
||||||
|
img.save_with_format(path, image::ImageFormat::Png).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_png_metadata_produces_valid_png() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("test.png");
|
||||||
|
let output = dir.path().join("stripped.png");
|
||||||
|
create_test_png(&input);
|
||||||
|
|
||||||
|
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||||
|
assert!(output.exists());
|
||||||
|
// Output must be a valid PNG that can be opened
|
||||||
|
let img = image::open(&output).unwrap();
|
||||||
|
assert_eq!(img.width(), 100);
|
||||||
|
assert_eq!(img.height(), 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_png_removes_text_chunks() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("test.png");
|
||||||
|
let output = dir.path().join("stripped.png");
|
||||||
|
create_test_png(&input);
|
||||||
|
|
||||||
|
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||||
|
// Read output and verify no tEXt chunks remain
|
||||||
|
let data = std::fs::read(&output).unwrap();
|
||||||
|
let mut pos = 8; // skip PNG signature
|
||||||
|
while pos + 12 <= data.len() {
|
||||||
|
let chunk_len = u32::from_be_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]) as usize;
|
||||||
|
let chunk_type = &data[pos+4..pos+8];
|
||||||
|
assert_ne!(chunk_type, b"tEXt", "tEXt chunk should be stripped");
|
||||||
|
assert_ne!(chunk_type, b"iTXt", "iTXt chunk should be stripped");
|
||||||
|
assert_ne!(chunk_type, b"zTXt", "zTXt chunk should be stripped");
|
||||||
|
pos += 12 + chunk_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_png_output_smaller_or_equal() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("test.png");
|
||||||
|
let output = dir.path().join("stripped.png");
|
||||||
|
create_test_png(&input);
|
||||||
|
|
||||||
|
let input_size = std::fs::metadata(&input).unwrap().len();
|
||||||
|
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||||
|
let output_size = std::fs::metadata(&output).unwrap().len();
|
||||||
|
assert!(output_size <= input_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_jpeg_removes_app1_exif() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("test.jpg");
|
||||||
|
let output = dir.path().join("stripped.jpg");
|
||||||
|
create_test_jpeg(&input);
|
||||||
|
|
||||||
|
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
|
||||||
|
// Verify no APP1 (0xFFE1) markers remain
|
||||||
|
let data = std::fs::read(&output).unwrap();
|
||||||
|
let mut i = 2; // skip SOI
|
||||||
|
while i + 1 < data.len() {
|
||||||
|
if data[i] != 0xFF { break; }
|
||||||
|
let marker = data[i + 1];
|
||||||
|
if marker == 0xDA { break; } // SOS - rest is image data
|
||||||
|
assert_ne!(marker, 0xE1, "APP1/EXIF marker should be stripped");
|
||||||
|
if i + 3 < data.len() {
|
||||||
|
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
|
||||||
|
i += 2 + len;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
pixstrip-core/tests/operations_tests.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
use pixstrip_core::operations::*;
|
||||||
|
use pixstrip_core::types::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_config_width_only() {
|
||||||
|
let config = ResizeConfig::ByWidth(1200);
|
||||||
|
assert_eq!(
|
||||||
|
config.target_for(Dimensions { width: 4000, height: 3000 }),
|
||||||
|
Dimensions { width: 1200, height: 900 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_config_fit_in_box() {
|
||||||
|
let config = ResizeConfig::FitInBox {
|
||||||
|
max: Dimensions { width: 1920, height: 1080 },
|
||||||
|
allow_upscale: false,
|
||||||
|
};
|
||||||
|
let result = config.target_for(Dimensions { width: 4000, height: 3000 });
|
||||||
|
assert_eq!(result.width, 1440);
|
||||||
|
assert_eq!(result.height, 1080);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_config_exact() {
|
||||||
|
let config = ResizeConfig::Exact(Dimensions { width: 1080, height: 1080 });
|
||||||
|
assert_eq!(
|
||||||
|
config.target_for(Dimensions { width: 4000, height: 3000 }),
|
||||||
|
Dimensions { width: 1080, height: 1080 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_config_strip_all() {
|
||||||
|
let config = MetadataConfig::StripAll;
|
||||||
|
assert!(config.should_strip_gps());
|
||||||
|
assert!(config.should_strip_camera());
|
||||||
|
assert!(config.should_strip_copyright());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_config_privacy() {
|
||||||
|
let config = MetadataConfig::Privacy;
|
||||||
|
assert!(config.should_strip_gps());
|
||||||
|
assert!(config.should_strip_camera());
|
||||||
|
assert!(!config.should_strip_copyright());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_config_keep_all() {
|
||||||
|
let config = MetadataConfig::KeepAll;
|
||||||
|
assert!(!config.should_strip_gps());
|
||||||
|
assert!(!config.should_strip_camera());
|
||||||
|
assert!(!config.should_strip_copyright());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_config_single_format() {
|
||||||
|
let config = ConvertConfig::SingleFormat(ImageFormat::WebP);
|
||||||
|
assert_eq!(config.output_format(ImageFormat::Jpeg), ImageFormat::WebP);
|
||||||
|
assert_eq!(config.output_format(ImageFormat::Png), ImageFormat::WebP);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_config_keep_original() {
|
||||||
|
let config = ConvertConfig::KeepOriginal;
|
||||||
|
assert_eq!(config.output_format(ImageFormat::Jpeg), ImageFormat::Jpeg);
|
||||||
|
assert_eq!(config.output_format(ImageFormat::Png), ImageFormat::Png);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watermark_position_all_nine() {
|
||||||
|
let positions = [
|
||||||
|
WatermarkPosition::TopLeft,
|
||||||
|
WatermarkPosition::TopCenter,
|
||||||
|
WatermarkPosition::TopRight,
|
||||||
|
WatermarkPosition::MiddleLeft,
|
||||||
|
WatermarkPosition::Center,
|
||||||
|
WatermarkPosition::MiddleRight,
|
||||||
|
WatermarkPosition::BottomLeft,
|
||||||
|
WatermarkPosition::BottomCenter,
|
||||||
|
WatermarkPosition::BottomRight,
|
||||||
|
];
|
||||||
|
assert_eq!(positions.len(), 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_config_simple_template() {
|
||||||
|
let config = RenameConfig {
|
||||||
|
prefix: "blog_".into(),
|
||||||
|
suffix: String::new(),
|
||||||
|
counter_start: 1,
|
||||||
|
counter_padding: 3,
|
||||||
|
counter_enabled: true,
|
||||||
|
counter_position: 3,
|
||||||
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
replace_spaces: 0,
|
||||||
|
special_chars: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
|
};
|
||||||
|
let result = config.apply_simple("sunset", "jpg", 1);
|
||||||
|
assert_eq!(result, "blog_sunset_001.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_config_with_suffix() {
|
||||||
|
let config = RenameConfig {
|
||||||
|
prefix: String::new(),
|
||||||
|
suffix: "_web".into(),
|
||||||
|
counter_start: 1,
|
||||||
|
counter_padding: 2,
|
||||||
|
counter_enabled: true,
|
||||||
|
counter_position: 3,
|
||||||
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
replace_spaces: 0,
|
||||||
|
special_chars: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
|
};
|
||||||
|
let result = config.apply_simple("photo", "webp", 5);
|
||||||
|
assert_eq!(result, "photo_web_05.webp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_counter_overflow_saturates() {
|
||||||
|
let config = RenameConfig {
|
||||||
|
prefix: String::new(),
|
||||||
|
suffix: String::new(),
|
||||||
|
counter_start: u32::MAX,
|
||||||
|
counter_padding: 1,
|
||||||
|
counter_enabled: true,
|
||||||
|
counter_position: 3,
|
||||||
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
replace_spaces: 0,
|
||||||
|
special_chars: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
|
};
|
||||||
|
// Should not panic - saturating arithmetic
|
||||||
|
let result = config.apply_simple("photo", "jpg", u32::MAX);
|
||||||
|
assert!(result.contains("photo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_config_custom_selective() {
|
||||||
|
let config = MetadataConfig::Custom {
|
||||||
|
strip_gps: true,
|
||||||
|
strip_camera: false,
|
||||||
|
strip_software: true,
|
||||||
|
strip_timestamps: false,
|
||||||
|
strip_copyright: false,
|
||||||
|
};
|
||||||
|
assert!(config.should_strip_gps());
|
||||||
|
assert!(!config.should_strip_camera());
|
||||||
|
assert!(!config.should_strip_copyright());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_config_custom_all_off() {
|
||||||
|
let config = MetadataConfig::Custom {
|
||||||
|
strip_gps: false,
|
||||||
|
strip_camera: false,
|
||||||
|
strip_software: false,
|
||||||
|
strip_timestamps: false,
|
||||||
|
strip_copyright: false,
|
||||||
|
};
|
||||||
|
assert!(!config.should_strip_gps());
|
||||||
|
assert!(!config.should_strip_camera());
|
||||||
|
assert!(!config.should_strip_copyright());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_config_custom_all_on() {
|
||||||
|
let config = MetadataConfig::Custom {
|
||||||
|
strip_gps: true,
|
||||||
|
strip_camera: true,
|
||||||
|
strip_software: true,
|
||||||
|
strip_timestamps: true,
|
||||||
|
strip_copyright: true,
|
||||||
|
};
|
||||||
|
assert!(config.should_strip_gps());
|
||||||
|
assert!(config.should_strip_camera());
|
||||||
|
assert!(config.should_strip_copyright());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watermark_rotation_variants_exist() {
|
||||||
|
let rotations = [
|
||||||
|
WatermarkRotation::Degrees45,
|
||||||
|
WatermarkRotation::DegreesNeg45,
|
||||||
|
WatermarkRotation::Degrees90,
|
||||||
|
WatermarkRotation::Custom(30.0),
|
||||||
|
];
|
||||||
|
assert_eq!(rotations.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotation_auto_orient_variant() {
|
||||||
|
let rotation = Rotation::AutoOrient;
|
||||||
|
assert!(matches!(rotation, Rotation::AutoOrient));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overwrite_action_default_is_auto_rename() {
|
||||||
|
let default = OverwriteAction::default();
|
||||||
|
assert!(matches!(default, OverwriteAction::AutoRename));
|
||||||
|
}
|
||||||
61
pixstrip-core/tests/pipeline_tests.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use pixstrip_core::pipeline::*;
|
||||||
|
use pixstrip_core::operations::*;
|
||||||
|
use pixstrip_core::types::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn processing_job_default_has_no_operations() {
|
||||||
|
let job = ProcessingJob::new("/tmp/input/", "/tmp/output/");
|
||||||
|
assert!(job.resize.is_none());
|
||||||
|
assert!(job.convert.is_none());
|
||||||
|
assert!(job.compress.is_none());
|
||||||
|
assert!(job.metadata.is_none());
|
||||||
|
assert!(job.watermark.is_none());
|
||||||
|
assert!(job.rename.is_none());
|
||||||
|
assert!(job.sources.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn processing_job_add_sources() {
|
||||||
|
let mut job = ProcessingJob::new("/tmp/input/", "/tmp/output/");
|
||||||
|
job.add_source("/tmp/input/photo.jpg");
|
||||||
|
job.add_source("/tmp/input/image.png");
|
||||||
|
assert_eq!(job.sources.len(), 2);
|
||||||
|
assert_eq!(job.sources[0].original_format, Some(ImageFormat::Jpeg));
|
||||||
|
assert_eq!(job.sources[1].original_format, Some(ImageFormat::Png));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn processing_job_with_resize() {
|
||||||
|
let mut job = ProcessingJob::new("/tmp/input/", "/tmp/output/");
|
||||||
|
job.resize = Some(ResizeConfig::ByWidth(1200));
|
||||||
|
assert!(job.resize.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn processing_job_operation_count() {
|
||||||
|
let mut job = ProcessingJob::new("/tmp/input/", "/tmp/output/");
|
||||||
|
assert_eq!(job.operation_count(), 0);
|
||||||
|
job.resize = Some(ResizeConfig::ByWidth(1200));
|
||||||
|
assert_eq!(job.operation_count(), 1);
|
||||||
|
job.compress = Some(CompressConfig::Preset(QualityPreset::High));
|
||||||
|
assert_eq!(job.operation_count(), 2);
|
||||||
|
job.metadata = Some(MetadataConfig::StripAll);
|
||||||
|
assert_eq!(job.operation_count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn processing_job_output_path() {
|
||||||
|
let job = ProcessingJob::new("/tmp/input/", "/tmp/output/");
|
||||||
|
let source = ImageSource::from_path("/tmp/input/photo.jpg");
|
||||||
|
let output = job.output_path_for(&source, None);
|
||||||
|
assert_eq!(output.to_str().unwrap(), "/tmp/output/photo.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn processing_job_output_path_with_format_change() {
|
||||||
|
let mut job = ProcessingJob::new("/tmp/input/", "/tmp/output/");
|
||||||
|
job.convert = Some(ConvertConfig::SingleFormat(ImageFormat::WebP));
|
||||||
|
let source = ImageSource::from_path("/tmp/input/photo.jpg");
|
||||||
|
let output = job.output_path_for(&source, Some(ImageFormat::WebP));
|
||||||
|
assert_eq!(output.to_str().unwrap(), "/tmp/output/photo.webp");
|
||||||
|
}
|
||||||
59
pixstrip-core/tests/preset_tests.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use pixstrip_core::preset::*;
|
||||||
|
use pixstrip_core::operations::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_preset_blog_photos() {
|
||||||
|
let preset = Preset::builtin_blog_photos();
|
||||||
|
assert_eq!(preset.name, "Blog Photos");
|
||||||
|
assert!(preset.resize.is_some());
|
||||||
|
assert!(preset.compress.is_some());
|
||||||
|
assert!(preset.metadata.is_some());
|
||||||
|
assert!(!preset.is_custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_preset_privacy_clean() {
|
||||||
|
let preset = Preset::builtin_privacy_clean();
|
||||||
|
assert_eq!(preset.name, "Privacy Clean");
|
||||||
|
assert!(preset.resize.is_none());
|
||||||
|
assert!(preset.compress.is_none());
|
||||||
|
assert!(preset.metadata.is_some());
|
||||||
|
if let Some(MetadataConfig::StripAll) = &preset.metadata {
|
||||||
|
// correct
|
||||||
|
} else {
|
||||||
|
panic!("Expected StripAll metadata config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_serialization_roundtrip() {
|
||||||
|
let preset = Preset::builtin_blog_photos();
|
||||||
|
let json = serde_json::to_string_pretty(&preset).unwrap();
|
||||||
|
let deserialized: Preset = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.name, preset.name);
|
||||||
|
assert_eq!(deserialized.is_custom, preset.is_custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_builtin_presets() {
|
||||||
|
let presets = Preset::all_builtins();
|
||||||
|
assert_eq!(presets.len(), 9);
|
||||||
|
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
|
||||||
|
assert!(names.contains(&"Blog Photos"));
|
||||||
|
assert!(names.contains(&"Social Media"));
|
||||||
|
assert!(names.contains(&"Web Optimization"));
|
||||||
|
assert!(names.contains(&"Email Friendly"));
|
||||||
|
assert!(names.contains(&"Privacy Clean"));
|
||||||
|
assert!(names.contains(&"Photographer Export"));
|
||||||
|
assert!(names.contains(&"Archive Compress"));
|
||||||
|
assert!(names.contains(&"Fediverse Ready"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_to_processing_job() {
|
||||||
|
let preset = Preset::builtin_blog_photos();
|
||||||
|
let job = preset.to_job("/tmp/input/", "/tmp/output/");
|
||||||
|
assert!(job.resize.is_some());
|
||||||
|
assert!(job.compress.is_some());
|
||||||
|
assert!(job.metadata.is_some());
|
||||||
|
}
|
||||||
280
pixstrip-core/tests/rename_tests.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
use pixstrip_core::operations::rename::{
|
||||||
|
apply_template, apply_regex_replace, apply_space_replacement,
|
||||||
|
apply_special_chars, apply_case_conversion, resolve_collision,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_basic_variables() {
|
||||||
|
let result = apply_template(
|
||||||
|
"{name}_{counter:3}.{ext}",
|
||||||
|
"sunset",
|
||||||
|
"jpg",
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(result, "sunset_001.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_with_prefix() {
|
||||||
|
let result = apply_template(
|
||||||
|
"blog_{name}.{ext}",
|
||||||
|
"photo",
|
||||||
|
"webp",
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(result, "blog_photo.webp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_counter_padding() {
|
||||||
|
let result = apply_template(
|
||||||
|
"{name}_{counter:4}.{ext}",
|
||||||
|
"img",
|
||||||
|
"png",
|
||||||
|
42,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(result, "img_0042.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_counter_no_padding() {
|
||||||
|
let result = apply_template(
|
||||||
|
"{name}_{counter}.{ext}",
|
||||||
|
"img",
|
||||||
|
"png",
|
||||||
|
42,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(result, "img_42.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_width_height() {
|
||||||
|
let result = apply_template(
|
||||||
|
"{name}_{width}x{height}.{ext}",
|
||||||
|
"photo",
|
||||||
|
"jpg",
|
||||||
|
1,
|
||||||
|
Some((1920, 1080)),
|
||||||
|
);
|
||||||
|
assert_eq!(result, "photo_1920x1080.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collision_adds_suffix() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let base = dir.path().join("photo.jpg");
|
||||||
|
std::fs::write(&base, b"exists").unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_collision(&base);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.file_name().unwrap().to_str().unwrap(),
|
||||||
|
"photo_1.jpg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collision_increments() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("photo.jpg"), b"exists").unwrap();
|
||||||
|
std::fs::write(dir.path().join("photo_1.jpg"), b"exists").unwrap();
|
||||||
|
|
||||||
|
let resolved = resolve_collision(&dir.path().join("photo.jpg"));
|
||||||
|
assert_eq!(
|
||||||
|
resolved.file_name().unwrap().to_str().unwrap(),
|
||||||
|
"photo_2.jpg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_collision_returns_same() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("unique.jpg");
|
||||||
|
let resolved = resolve_collision(&path);
|
||||||
|
assert_eq!(resolved, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Regex replace tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regex_replace_basic() {
|
||||||
|
let result = apply_regex_replace("hello_world", "_", "-");
|
||||||
|
assert_eq!(result, "hello-world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regex_replace_pattern() {
|
||||||
|
let result = apply_regex_replace("IMG_20260307_001", r"\d{8}", "DATE");
|
||||||
|
assert_eq!(result, "IMG_DATE_001");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regex_replace_invalid_pattern_returns_original() {
|
||||||
|
let result = apply_regex_replace("hello", "[invalid", "x");
|
||||||
|
assert_eq!(result, "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regex_replace_empty_find_returns_original() {
|
||||||
|
let result = apply_regex_replace("hello", "", "x");
|
||||||
|
assert_eq!(result, "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Space replacement tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_replacement_none() {
|
||||||
|
assert_eq!(apply_space_replacement("hello world", 0), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_replacement_underscore() {
|
||||||
|
assert_eq!(apply_space_replacement("hello world", 1), "hello_world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_replacement_hyphen() {
|
||||||
|
assert_eq!(apply_space_replacement("hello world", 2), "hello-world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_replacement_dot() {
|
||||||
|
assert_eq!(apply_space_replacement("hello world", 3), "hello.world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_replacement_camelcase() {
|
||||||
|
assert_eq!(apply_space_replacement("hello world", 4), "helloWorld");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_replacement_remove() {
|
||||||
|
assert_eq!(apply_space_replacement("hello world", 5), "helloworld");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Special chars tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn special_chars_keep_all() {
|
||||||
|
assert_eq!(apply_special_chars("file<name>.txt", 0), "file<name>.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn special_chars_filesystem_safe() {
|
||||||
|
assert_eq!(apply_special_chars("file<name>", 1), "filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn special_chars_web_safe() {
|
||||||
|
assert_eq!(apply_special_chars("file name!@#", 2), "filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn special_chars_alphanumeric_only() {
|
||||||
|
assert_eq!(apply_special_chars("file-name_123", 5), "filename123");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Case conversion tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_conversion_none() {
|
||||||
|
assert_eq!(apply_case_conversion("Hello World", 0), "Hello World");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_conversion_lowercase() {
|
||||||
|
assert_eq!(apply_case_conversion("Hello World", 1), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_conversion_uppercase() {
|
||||||
|
assert_eq!(apply_case_conversion("Hello World", 2), "HELLO WORLD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_conversion_title_case() {
|
||||||
|
assert_eq!(apply_case_conversion("hello world", 3), "Hello World");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_conversion_title_preserves_separators() {
|
||||||
|
assert_eq!(apply_case_conversion("hello-world_foo", 3), "Hello-World_Foo");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RenameConfig::apply_simple edge cases ---
|
||||||
|
|
||||||
|
use pixstrip_core::operations::RenameConfig;
|
||||||
|
|
||||||
|
fn default_rename_config() -> RenameConfig {
|
||||||
|
RenameConfig {
|
||||||
|
prefix: String::new(),
|
||||||
|
suffix: String::new(),
|
||||||
|
counter_start: 1,
|
||||||
|
counter_padding: 3,
|
||||||
|
counter_enabled: false,
|
||||||
|
counter_position: 3,
|
||||||
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
replace_spaces: 0,
|
||||||
|
special_chars: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_simple_no_changes() {
|
||||||
|
let cfg = default_rename_config();
|
||||||
|
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_simple_with_prefix_suffix() {
|
||||||
|
let mut cfg = default_rename_config();
|
||||||
|
cfg.prefix = "web_".into();
|
||||||
|
cfg.suffix = "_final".into();
|
||||||
|
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "web_photo_final.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_simple_counter_after_suffix() {
|
||||||
|
let mut cfg = default_rename_config();
|
||||||
|
cfg.counter_enabled = true;
|
||||||
|
cfg.counter_position = 3;
|
||||||
|
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo_001.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_simple_counter_replaces_name() {
|
||||||
|
let mut cfg = default_rename_config();
|
||||||
|
cfg.counter_enabled = true;
|
||||||
|
cfg.counter_position = 4;
|
||||||
|
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "001.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_simple_empty_name() {
|
||||||
|
let cfg = default_rename_config();
|
||||||
|
assert_eq!(cfg.apply_simple("", "png", 1), ".png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_simple_case_lowercase() {
|
||||||
|
let mut cfg = default_rename_config();
|
||||||
|
cfg.case_mode = 1;
|
||||||
|
assert_eq!(cfg.apply_simple("MyPhoto", "JPG", 1), "myphoto.JPG");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_simple_large_counter_padding_capped() {
|
||||||
|
let mut cfg = default_rename_config();
|
||||||
|
cfg.counter_enabled = true;
|
||||||
|
cfg.counter_padding = 100; // should be capped to 10
|
||||||
|
cfg.counter_position = 3;
|
||||||
|
let result = cfg.apply_simple("photo", "jpg", 1);
|
||||||
|
// Counter portion should be at most 10 digits
|
||||||
|
assert!(result.len() <= 22); // "photo_" + 10 digits + ".jpg"
|
||||||
|
}
|
||||||
61
pixstrip-core/tests/resize_tests.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use pixstrip_core::operations::resize::resize_image;
|
||||||
|
use pixstrip_core::operations::ResizeConfig;
|
||||||
|
use pixstrip_core::types::Dimensions;
|
||||||
|
|
||||||
|
fn create_test_image(width: u32, height: u32) -> image::DynamicImage {
|
||||||
|
let img = image::RgbImage::from_fn(width, height, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||||
|
});
|
||||||
|
image::DynamicImage::ImageRgb8(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_by_width() {
|
||||||
|
let img = create_test_image(4000, 3000);
|
||||||
|
let config = ResizeConfig::ByWidth(1200);
|
||||||
|
let result = resize_image(&img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 1200);
|
||||||
|
assert_eq!(result.height(), 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_by_height() {
|
||||||
|
let img = create_test_image(4000, 3000);
|
||||||
|
let config = ResizeConfig::ByHeight(600);
|
||||||
|
let result = resize_image(&img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 800);
|
||||||
|
assert_eq!(result.height(), 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_fit_in_box() {
|
||||||
|
let img = create_test_image(4000, 3000);
|
||||||
|
let config = ResizeConfig::FitInBox {
|
||||||
|
max: Dimensions { width: 1920, height: 1080 },
|
||||||
|
allow_upscale: false,
|
||||||
|
};
|
||||||
|
let result = resize_image(&img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 1440);
|
||||||
|
assert_eq!(result.height(), 1080);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_no_upscale() {
|
||||||
|
let img = create_test_image(800, 600);
|
||||||
|
let config = ResizeConfig::FitInBox {
|
||||||
|
max: Dimensions { width: 1920, height: 1080 },
|
||||||
|
allow_upscale: false,
|
||||||
|
};
|
||||||
|
let result = resize_image(&img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 800);
|
||||||
|
assert_eq!(result.height(), 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_exact() {
|
||||||
|
let img = create_test_image(4000, 3000);
|
||||||
|
let config = ResizeConfig::Exact(Dimensions { width: 1080, height: 1080 });
|
||||||
|
let result = resize_image(&img, &config).unwrap();
|
||||||
|
assert_eq!(result.width(), 1080);
|
||||||
|
assert_eq!(result.height(), 1080);
|
||||||
|
}
|
||||||
282
pixstrip-core/tests/storage_tests.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
use pixstrip_core::preset::Preset;
|
||||||
|
use pixstrip_core::storage::*;
|
||||||
|
|
||||||
|
fn with_temp_config_dir(f: impl FnOnce(&std::path::Path)) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
f(dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Preset file I/O ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_preset() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
let preset = Preset::builtin_blog_photos();
|
||||||
|
|
||||||
|
store.save(&preset).unwrap();
|
||||||
|
let loaded = store.load("Blog Photos").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(loaded.name, preset.name);
|
||||||
|
assert_eq!(loaded.is_custom, preset.is_custom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_preset_creates_directory() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let presets_dir = base.join("presets");
|
||||||
|
assert!(!presets_dir.exists());
|
||||||
|
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
let preset = Preset::builtin_blog_photos();
|
||||||
|
store.save(&preset).unwrap();
|
||||||
|
|
||||||
|
assert!(presets_dir.exists());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_saved_presets() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
store.save(&Preset::builtin_blog_photos()).unwrap();
|
||||||
|
store.save(&Preset::builtin_privacy_clean()).unwrap();
|
||||||
|
|
||||||
|
let list = store.list().unwrap();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
|
||||||
|
let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
|
||||||
|
assert!(names.contains(&"Blog Photos"));
|
||||||
|
assert!(names.contains(&"Privacy Clean"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_preset() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
store.save(&Preset::builtin_blog_photos()).unwrap();
|
||||||
|
|
||||||
|
assert!(store.delete("Blog Photos").is_ok());
|
||||||
|
assert!(store.load("Blog Photos").is_err());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_nonexistent_preset_returns_error() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
assert!(store.load("Nonexistent").is_err());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_filename_sanitization() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
let mut preset = Preset::builtin_blog_photos();
|
||||||
|
preset.name = "My Preset / With <Special> Chars!".into();
|
||||||
|
preset.is_custom = true;
|
||||||
|
|
||||||
|
store.save(&preset).unwrap();
|
||||||
|
let loaded = store.load("My Preset / With <Special> Chars!").unwrap();
|
||||||
|
assert_eq!(loaded.name, preset.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Preset import/export ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_and_import_preset() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
let preset = Preset::builtin_blog_photos();
|
||||||
|
|
||||||
|
let export_path = base.join("exported.pixstrip-preset");
|
||||||
|
store.export_to_file(&preset, &export_path).unwrap();
|
||||||
|
|
||||||
|
assert!(export_path.exists());
|
||||||
|
|
||||||
|
let imported = store.import_from_file(&export_path).unwrap();
|
||||||
|
assert_eq!(imported.name, preset.name);
|
||||||
|
assert!(imported.is_custom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_preset_marks_as_custom() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let store = PresetStore::with_base_dir(base);
|
||||||
|
let preset = Preset::builtin_blog_photos();
|
||||||
|
|
||||||
|
let export_path = base.join("test.pixstrip-preset");
|
||||||
|
store.export_to_file(&preset, &export_path).unwrap();
|
||||||
|
|
||||||
|
let imported = store.import_from_file(&export_path).unwrap();
|
||||||
|
assert!(imported.is_custom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config persistence ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_config() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let config_store = ConfigStore::with_base_dir(base);
|
||||||
|
let config = pixstrip_core::config::AppConfig {
|
||||||
|
output_subfolder: "output_images".into(),
|
||||||
|
auto_open_output: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
config_store.save(&config).unwrap();
|
||||||
|
let loaded = config_store.load().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(loaded.output_subfolder, "output_images");
|
||||||
|
assert!(loaded.auto_open_output);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_missing_config_returns_default() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let config_store = ConfigStore::with_base_dir(base);
|
||||||
|
let config = config_store.load().unwrap();
|
||||||
|
let default = pixstrip_core::config::AppConfig::default();
|
||||||
|
|
||||||
|
assert_eq!(config.output_subfolder, default.output_subfolder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session memory ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_session() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let session_store = SessionStore::with_base_dir(base);
|
||||||
|
let session = SessionState {
|
||||||
|
last_input_dir: Some("/home/user/photos".into()),
|
||||||
|
last_output_dir: Some("/home/user/processed".into()),
|
||||||
|
last_preset_name: Some("Blog Photos".into()),
|
||||||
|
current_step: 3,
|
||||||
|
window_width: Some(1024),
|
||||||
|
window_height: Some(768),
|
||||||
|
window_maximized: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
session_store.save(&session).unwrap();
|
||||||
|
let loaded = session_store.load().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(loaded.last_input_dir.as_deref(), Some("/home/user/photos"));
|
||||||
|
assert_eq!(loaded.current_step, 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_missing_session_returns_default() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let session_store = SessionStore::with_base_dir(base);
|
||||||
|
let session = session_store.load().unwrap();
|
||||||
|
assert_eq!(session.current_step, 0);
|
||||||
|
assert!(session.last_input_dir.is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Processing history ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_and_list_history_entries() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let history = HistoryStore::with_base_dir(base);
|
||||||
|
|
||||||
|
let entry = HistoryEntry {
|
||||||
|
timestamp: "2026-03-06T12:00:00Z".into(),
|
||||||
|
input_dir: "/home/user/photos".into(),
|
||||||
|
output_dir: "/home/user/processed".into(),
|
||||||
|
preset_name: Some("Blog Photos".into()),
|
||||||
|
total: 10,
|
||||||
|
succeeded: 9,
|
||||||
|
failed: 1,
|
||||||
|
total_input_bytes: 50_000_000,
|
||||||
|
total_output_bytes: 10_000_000,
|
||||||
|
elapsed_ms: 5000,
|
||||||
|
output_files: vec![
|
||||||
|
"/home/user/processed/photo1.jpg".into(),
|
||||||
|
"/home/user/processed/photo2.jpg".into(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
history.add(entry.clone(), 50, 30).unwrap();
|
||||||
|
let entries = history.list().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].total, 10);
|
||||||
|
assert_eq!(entries[0].succeeded, 9);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_appends_entries() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let history = HistoryStore::with_base_dir(base);
|
||||||
|
|
||||||
|
for i in 0..3 {
|
||||||
|
history
|
||||||
|
.add(HistoryEntry {
|
||||||
|
timestamp: format!("2026-03-06T12:0{}:00Z", i),
|
||||||
|
input_dir: "/input".into(),
|
||||||
|
output_dir: "/output".into(),
|
||||||
|
preset_name: None,
|
||||||
|
total: i + 1,
|
||||||
|
succeeded: i + 1,
|
||||||
|
failed: 0,
|
||||||
|
total_input_bytes: 1000,
|
||||||
|
total_output_bytes: 500,
|
||||||
|
elapsed_ms: 100,
|
||||||
|
output_files: vec![],
|
||||||
|
}, 50, 30)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = history.list().unwrap();
|
||||||
|
assert_eq!(entries.len(), 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_history() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let history = HistoryStore::with_base_dir(base);
|
||||||
|
|
||||||
|
history
|
||||||
|
.add(HistoryEntry {
|
||||||
|
timestamp: "2026-03-06T12:00:00Z".into(),
|
||||||
|
input_dir: "/input".into(),
|
||||||
|
output_dir: "/output".into(),
|
||||||
|
preset_name: None,
|
||||||
|
total: 1,
|
||||||
|
succeeded: 1,
|
||||||
|
failed: 0,
|
||||||
|
total_input_bytes: 1000,
|
||||||
|
total_output_bytes: 500,
|
||||||
|
elapsed_ms: 100,
|
||||||
|
output_files: vec![],
|
||||||
|
}, 50, 30)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
history.clear().unwrap();
|
||||||
|
let entries = history.list().unwrap();
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_history_returns_empty_list() {
|
||||||
|
with_temp_config_dir(|base| {
|
||||||
|
let history = HistoryStore::with_base_dir(base);
|
||||||
|
let entries = history.list().unwrap();
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -81,3 +81,39 @@ fn quality_preset_values() {
|
|||||||
assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality());
|
assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality());
|
||||||
assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality());
|
assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dimensions_zero_height_aspect_ratio() {
|
||||||
|
let dims = Dimensions { width: 1920, height: 0 };
|
||||||
|
assert_eq!(dims.aspect_ratio(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dimensions_zero_both_aspect_ratio() {
|
||||||
|
let dims = Dimensions { width: 0, height: 0 };
|
||||||
|
assert_eq!(dims.aspect_ratio(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dimensions_fit_within_zero_self_width() {
|
||||||
|
let original = Dimensions { width: 0, height: 600 };
|
||||||
|
let max_box = Dimensions { width: 1200, height: 1200 };
|
||||||
|
let fitted = original.fit_within(max_box, false);
|
||||||
|
assert_eq!(fitted, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dimensions_fit_within_zero_self_height() {
|
||||||
|
let original = Dimensions { width: 800, height: 0 };
|
||||||
|
let max_box = Dimensions { width: 1200, height: 1200 };
|
||||||
|
let fitted = original.fit_within(max_box, false);
|
||||||
|
assert_eq!(fitted, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dimensions_fit_within_zero_max() {
|
||||||
|
let original = Dimensions { width: 800, height: 600 };
|
||||||
|
let max_box = Dimensions { width: 0, height: 0 };
|
||||||
|
let fitted = original.fit_within(max_box, false);
|
||||||
|
assert_eq!(fitted, original);
|
||||||
|
}
|
||||||
|
|||||||
115
pixstrip-core/tests/watcher_tests.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use pixstrip_core::watcher::*;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watch_folder_serialization() {
|
||||||
|
let folder = WatchFolder {
|
||||||
|
path: "/home/user/photos".into(),
|
||||||
|
preset_name: "Blog Photos".into(),
|
||||||
|
recursive: true,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&folder).unwrap();
|
||||||
|
let deserialized: WatchFolder = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.preset_name, "Blog Photos");
|
||||||
|
assert!(deserialized.recursive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_starts_and_stops() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let folder = WatchFolder {
|
||||||
|
path: dir.path().to_path_buf(),
|
||||||
|
preset_name: "Test".into(),
|
||||||
|
recursive: false,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let watcher = FolderWatcher::new();
|
||||||
|
let (tx, _rx) = mpsc::channel();
|
||||||
|
|
||||||
|
watcher.start(&folder, tx).unwrap();
|
||||||
|
assert!(watcher.is_running());
|
||||||
|
|
||||||
|
watcher.stop();
|
||||||
|
// Give the thread time to see the stop signal
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
|
assert!(!watcher.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_detects_new_image() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let folder = WatchFolder {
|
||||||
|
path: dir.path().to_path_buf(),
|
||||||
|
preset_name: "Test".into(),
|
||||||
|
recursive: false,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let watcher = FolderWatcher::new();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
watcher.start(&folder, tx).unwrap();
|
||||||
|
|
||||||
|
// Wait for watcher to be ready
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Create an image file
|
||||||
|
let img_path = dir.path().join("new_photo.jpg");
|
||||||
|
std::fs::write(&img_path, b"fake jpeg data").unwrap();
|
||||||
|
|
||||||
|
// Wait and check for event
|
||||||
|
let event = rx.recv_timeout(std::time::Duration::from_secs(3));
|
||||||
|
watcher.stop();
|
||||||
|
|
||||||
|
match event {
|
||||||
|
Ok(WatchEvent::NewImage(path)) => {
|
||||||
|
assert_eq!(path, img_path);
|
||||||
|
}
|
||||||
|
Ok(WatchEvent::Error(e)) => panic!("Unexpected error: {}", e),
|
||||||
|
Err(_) => panic!("No event received within timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_ignores_non_image_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let folder = WatchFolder {
|
||||||
|
path: dir.path().to_path_buf(),
|
||||||
|
preset_name: "Test".into(),
|
||||||
|
recursive: false,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let watcher = FolderWatcher::new();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
watcher.start(&folder, tx).unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Create a non-image file
|
||||||
|
std::fs::write(dir.path().join("readme.txt"), b"text file").unwrap();
|
||||||
|
|
||||||
|
// Should not receive any event
|
||||||
|
let result = rx.recv_timeout(std::time::Duration::from_secs(1));
|
||||||
|
watcher.stop();
|
||||||
|
|
||||||
|
assert!(result.is_err(), "Should not receive event for non-image file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_rejects_nonexistent_path() {
|
||||||
|
let folder = WatchFolder {
|
||||||
|
path: "/nonexistent/path/to/watch".into(),
|
||||||
|
preset_name: "Test".into(),
|
||||||
|
recursive: false,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let watcher = FolderWatcher::new();
|
||||||
|
let (tx, _rx) = mpsc::channel();
|
||||||
|
|
||||||
|
assert!(watcher.start(&folder, tx).is_err());
|
||||||
|
}
|
||||||
245
pixstrip-core/tests/watermark_tests.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
use pixstrip_core::operations::watermark::calculate_position;
|
||||||
|
use pixstrip_core::operations::WatermarkPosition;
|
||||||
|
use pixstrip_core::types::Dimensions;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_top_left() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::TopLeft,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 10);
|
||||||
|
assert_eq!(y, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_center() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::Center,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 860); // (1920 - 200) / 2
|
||||||
|
assert_eq!(y, 515); // (1080 - 50) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_bottom_right() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::BottomRight,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 1710); // 1920 - 200 - 10
|
||||||
|
assert_eq!(y, 1020); // 1080 - 50 - 10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_top_center() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::TopCenter,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 860); // (1920 - 200) / 2
|
||||||
|
assert_eq!(y, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_bottom_center() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::BottomCenter,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 860);
|
||||||
|
assert_eq!(y, 1020);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_middle_left() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::MiddleLeft,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 10);
|
||||||
|
assert_eq!(y, 515);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_middle_right() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::MiddleRight,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 1710);
|
||||||
|
assert_eq!(y, 515);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_top_right() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::TopRight,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 1710);
|
||||||
|
assert_eq!(y, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn position_bottom_left() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::BottomLeft,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 10);
|
||||||
|
assert_eq!(y, 1020);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Margin variation tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn margin_zero_top_left() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::TopLeft,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 0);
|
||||||
|
assert_eq!(y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn margin_zero_bottom_right() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::BottomRight,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 1720); // 1920 - 200
|
||||||
|
assert_eq!(y, 1030); // 1080 - 50
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_margin_top_left() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::TopLeft,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 100);
|
||||||
|
assert_eq!(y, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_margin_bottom_right() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::BottomRight,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 1620); // 1920 - 200 - 100
|
||||||
|
assert_eq!(y, 930); // 1080 - 50 - 100
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn margin_does_not_affect_center() {
|
||||||
|
let (x1, y1) = calculate_position(
|
||||||
|
WatermarkPosition::Center,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let (x2, y2) = calculate_position(
|
||||||
|
WatermarkPosition::Center,
|
||||||
|
Dimensions { width: 1920, height: 1080 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
assert_eq!(x1, x2);
|
||||||
|
assert_eq!(y1, y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edge case tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watermark_larger_than_image() {
|
||||||
|
// Watermark is bigger than the image - should not panic, saturating_sub clamps to 0
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::BottomRight,
|
||||||
|
Dimensions { width: 100, height: 100 },
|
||||||
|
Dimensions { width: 200, height: 200 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
// (100 - 200 - 10) saturates to 0
|
||||||
|
assert_eq!(x, 0);
|
||||||
|
assert_eq!(y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watermark_exact_image_size() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::Center,
|
||||||
|
Dimensions { width: 200, height: 100 },
|
||||||
|
Dimensions { width: 200, height: 100 },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 0);
|
||||||
|
assert_eq!(y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_size_image() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::Center,
|
||||||
|
Dimensions { width: 0, height: 0 },
|
||||||
|
Dimensions { width: 200, height: 50 },
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 0);
|
||||||
|
assert_eq!(y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn margin_exceeds_available_space() {
|
||||||
|
// Margin is huge relative to image size
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::BottomRight,
|
||||||
|
Dimensions { width: 100, height: 100 },
|
||||||
|
Dimensions { width: 50, height: 50 },
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
// saturating_sub: 100 - 50 - 200 = 0
|
||||||
|
assert_eq!(x, 0);
|
||||||
|
assert_eq!(y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_pixel_image() {
|
||||||
|
let (x, y) = calculate_position(
|
||||||
|
WatermarkPosition::TopLeft,
|
||||||
|
Dimensions { width: 1, height: 1 },
|
||||||
|
Dimensions { width: 1, height: 1 },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(x, 0);
|
||||||
|
assert_eq!(y, 0);
|
||||||
|
}
|
||||||
@@ -8,3 +8,5 @@ license.workspace = true
|
|||||||
pixstrip-core = { workspace = true }
|
pixstrip-core = { workspace = true }
|
||||||
gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] }
|
gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] }
|
||||||
adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] }
|
adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] }
|
||||||
|
image = "0.25"
|
||||||
|
regex = "1"
|
||||||
|
|||||||
4023
pixstrip-gtk/src/app.rs
Normal file
@@ -1,3 +1,16 @@
|
|||||||
|
mod app;
|
||||||
|
mod processing;
|
||||||
|
mod settings;
|
||||||
|
mod step_indicator;
|
||||||
|
mod steps;
|
||||||
|
mod tutorial;
|
||||||
|
pub(crate) mod utils;
|
||||||
|
mod welcome;
|
||||||
|
mod wizard;
|
||||||
|
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("Pixstrip GTK - v{}", pixstrip_core::version());
|
let app = app::build_app();
|
||||||
|
app.run();
|
||||||
}
|
}
|
||||||
|
|||||||
264
pixstrip-gtk/src/processing.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn build_processing_page() -> adw::NavigationPage {
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(16)
|
||||||
|
.margin_top(24)
|
||||||
|
.margin_bottom(24)
|
||||||
|
.margin_start(24)
|
||||||
|
.margin_end(24)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
let title = gtk::Label::builder()
|
||||||
|
.label("Processing...")
|
||||||
|
.css_classes(["title-1"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Progress info
|
||||||
|
let progress_label = gtk::Label::builder()
|
||||||
|
.label("0 / 0 images")
|
||||||
|
.css_classes(["heading"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
progress_label.set_widget_name("processing-count-label");
|
||||||
|
|
||||||
|
let progress_bar = gtk::ProgressBar::builder()
|
||||||
|
.fraction(0.0)
|
||||||
|
.show_text(true)
|
||||||
|
.text("0%")
|
||||||
|
.build();
|
||||||
|
progress_bar.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Processing progress"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let eta_label = gtk::Label::builder()
|
||||||
|
.label("Estimating time remaining...")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
eta_label.set_widget_name("processing-eta-label");
|
||||||
|
|
||||||
|
// Activity log
|
||||||
|
let log_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Activity")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let log_scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.min_content_height(200)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let log_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(2)
|
||||||
|
.build();
|
||||||
|
log_box.set_widget_name("processing-log-box");
|
||||||
|
|
||||||
|
log_scrolled.set_child(Some(&log_box));
|
||||||
|
log_group.add(&log_scrolled);
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
let button_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(12)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_top(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let pause_button = gtk::Button::builder()
|
||||||
|
.label("Pause")
|
||||||
|
.tooltip_text("Pause after current image")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let cancel_button = gtk::Button::builder()
|
||||||
|
.label("Cancel")
|
||||||
|
.tooltip_text("Cancel processing")
|
||||||
|
.build();
|
||||||
|
cancel_button.add_css_class("destructive-action");
|
||||||
|
|
||||||
|
button_box.append(&pause_button);
|
||||||
|
button_box.append(&cancel_button);
|
||||||
|
|
||||||
|
content.append(&title);
|
||||||
|
content.append(&progress_label);
|
||||||
|
content.append(&progress_bar);
|
||||||
|
content.append(&eta_label);
|
||||||
|
content.append(&log_group);
|
||||||
|
content.append(&button_box);
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Processing")
|
||||||
|
.tag("processing")
|
||||||
|
.child(&content)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_results_page() -> adw::NavigationPage {
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(16)
|
||||||
|
.margin_top(24)
|
||||||
|
.margin_bottom(24)
|
||||||
|
.margin_start(24)
|
||||||
|
.margin_end(24)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Success icon and title
|
||||||
|
let status_icon = gtk::Image::builder()
|
||||||
|
.icon_name("emblem-ok-symbolic")
|
||||||
|
.pixel_size(48)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.css_classes(["success"])
|
||||||
|
.build();
|
||||||
|
status_icon.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Success"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let title = gtk::Label::builder()
|
||||||
|
.label("Processing Complete")
|
||||||
|
.css_classes(["title-1"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
content.append(&status_icon);
|
||||||
|
content.append(&title);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
let stats_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Results")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let images_row = adw::ActionRow::builder()
|
||||||
|
.title("Images processed")
|
||||||
|
.subtitle("0 images")
|
||||||
|
.build();
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
stats_group.add(&size_after_row);
|
||||||
|
stats_group.add(&savings_row);
|
||||||
|
stats_group.add(&time_row);
|
||||||
|
content.append(&stats_group);
|
||||||
|
|
||||||
|
// Errors section (initially hidden)
|
||||||
|
let errors_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Errors")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let errors_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("0 errors occurred")
|
||||||
|
.build();
|
||||||
|
errors_group.add(&errors_expander);
|
||||||
|
content.append(&errors_group);
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
let action_group = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
let open_row = adw::ActionRow::builder()
|
||||||
|
.title("Open Output Folder")
|
||||||
|
.subtitle("View processed images in file manager")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
action_group.add(&add_queue_row);
|
||||||
|
action_group.add(&save_preset_row);
|
||||||
|
content.append(&action_group);
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Results")
|
||||||
|
.tag("results")
|
||||||
|
.child(&scrolled)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
766
pixstrip-gtk/src/settings.rs
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use pixstrip_core::config::{AppConfig, ErrorBehavior, OverwriteBehavior, SkillLevel};
|
||||||
|
use pixstrip_core::storage::ConfigStore;
|
||||||
|
|
||||||
|
pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||||
|
let dialog = adw::PreferencesDialog::builder()
|
||||||
|
.title("Settings")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let config_store = ConfigStore::new();
|
||||||
|
let config = match config_store.load() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load config, using defaults: {}", e);
|
||||||
|
AppConfig::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// General page
|
||||||
|
let general_page = adw::PreferencesPage::builder()
|
||||||
|
.title("General")
|
||||||
|
.icon_name("preferences-system-symbolic")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let output_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Output")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Output mode: subfolder or fixed path
|
||||||
|
let output_mode_row = adw::ComboRow::builder()
|
||||||
|
.title("Default output location")
|
||||||
|
.subtitle("Where processed images are saved by default")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
let output_mode_model = gtk::StringList::new(&[
|
||||||
|
"Subfolder next to originals",
|
||||||
|
"Fixed output folder",
|
||||||
|
]);
|
||||||
|
output_mode_row.set_model(Some(&output_mode_model));
|
||||||
|
output_mode_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||||
|
output_mode_row.set_selected(if config.output_fixed_path.is_some() { 1 } else { 0 });
|
||||||
|
|
||||||
|
let subfolder_row = adw::EntryRow::builder()
|
||||||
|
.title("Default output subfolder")
|
||||||
|
.text(&config.output_subfolder)
|
||||||
|
.visible(config.output_fixed_path.is_none())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let fixed_path_row = adw::ActionRow::builder()
|
||||||
|
.title("Fixed output folder")
|
||||||
|
.subtitle(
|
||||||
|
config.output_fixed_path
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("No folder selected"),
|
||||||
|
)
|
||||||
|
.activatable(true)
|
||||||
|
.visible(config.output_fixed_path.is_some())
|
||||||
|
.build();
|
||||||
|
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")
|
||||||
|
.tooltip_text("Choose output folder")
|
||||||
|
.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
|
||||||
|
let fixed_path_state: std::rc::Rc<std::cell::RefCell<Option<String>>> =
|
||||||
|
std::rc::Rc::new(std::cell::RefCell::new(config.output_fixed_path.clone()));
|
||||||
|
|
||||||
|
// Wire output mode toggle
|
||||||
|
{
|
||||||
|
let sf = subfolder_row.clone();
|
||||||
|
let fp = fixed_path_row.clone();
|
||||||
|
output_mode_row.connect_selected_notify(move |row| {
|
||||||
|
let is_fixed = row.selected() == 1;
|
||||||
|
sf.set_visible(!is_fixed);
|
||||||
|
fp.set_visible(is_fixed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire fixed path chooser
|
||||||
|
{
|
||||||
|
let fps = fixed_path_state.clone();
|
||||||
|
let fpr = fixed_path_row.clone();
|
||||||
|
choose_fixed_btn.connect_clicked(move |btn| {
|
||||||
|
let fps = fps.clone();
|
||||||
|
let fpr = fpr.clone();
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title("Choose Output Folder")
|
||||||
|
.modal(true)
|
||||||
|
.build();
|
||||||
|
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
||||||
|
dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
||||||
|
if let Ok(file) = result
|
||||||
|
&& let Some(path) = file.path()
|
||||||
|
{
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
fpr.set_subtitle(&path_str);
|
||||||
|
*fps.borrow_mut() = Some(path_str);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let overwrite_row = adw::ComboRow::builder()
|
||||||
|
.title("Default overwrite behavior")
|
||||||
|
.subtitle("What to do when output files already exist")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
let overwrite_model = gtk::StringList::new(&[
|
||||||
|
"Ask before overwriting",
|
||||||
|
"Auto-rename with suffix",
|
||||||
|
"Always overwrite",
|
||||||
|
"Skip existing files",
|
||||||
|
]);
|
||||||
|
overwrite_row.set_model(Some(&overwrite_model));
|
||||||
|
overwrite_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||||
|
overwrite_row.set_selected(match config.overwrite_behavior {
|
||||||
|
OverwriteBehavior::Ask => 0,
|
||||||
|
OverwriteBehavior::AutoRename => 1,
|
||||||
|
OverwriteBehavior::Overwrite => 2,
|
||||||
|
OverwriteBehavior::Skip => 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
let remember_row = adw::SwitchRow::builder()
|
||||||
|
.title("Remember last-used settings")
|
||||||
|
.subtitle("Restore wizard state on next launch")
|
||||||
|
.active(config.remember_settings)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
output_group.add(&output_mode_row);
|
||||||
|
output_group.add(&subfolder_row);
|
||||||
|
output_group.add(&fixed_path_row);
|
||||||
|
output_group.add(&overwrite_row);
|
||||||
|
output_group.add(&remember_row);
|
||||||
|
general_page.add(&output_group);
|
||||||
|
|
||||||
|
let ui_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Interface")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let skill_row = adw::ComboRow::builder()
|
||||||
|
.title("Detail level")
|
||||||
|
.subtitle("Controls how many options are visible by default")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
let skill_model = gtk::StringList::new(&["Simple", "Detailed"]);
|
||||||
|
skill_row.set_model(Some(&skill_model));
|
||||||
|
skill_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||||
|
skill_row.set_selected(match config.skill_level {
|
||||||
|
SkillLevel::Simple => 0,
|
||||||
|
SkillLevel::Detailed => 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let reset_button = gtk::Button::builder()
|
||||||
|
.label("Reset to Defaults")
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.margin_top(8)
|
||||||
|
.build();
|
||||||
|
reset_button.add_css_class("destructive-action");
|
||||||
|
|
||||||
|
// Reset welcome wizard / tutorial
|
||||||
|
let reset_welcome_state: std::rc::Rc<Cell<bool>> = std::rc::Rc::new(Cell::new(false));
|
||||||
|
|
||||||
|
let reset_welcome_row = adw::ActionRow::builder()
|
||||||
|
.title("Reset welcome wizard")
|
||||||
|
.subtitle("Show the setup wizard and tutorial again on next launch")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let reset_welcome_btn = gtk::Button::builder()
|
||||||
|
.label("Reset")
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
reset_welcome_btn.add_css_class("destructive-action");
|
||||||
|
|
||||||
|
{
|
||||||
|
let rws = reset_welcome_state.clone();
|
||||||
|
let row = reset_welcome_row.clone();
|
||||||
|
reset_welcome_btn.connect_clicked(move |btn| {
|
||||||
|
rws.set(true);
|
||||||
|
btn.set_sensitive(false);
|
||||||
|
row.set_subtitle("Will show on next launch");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_welcome_row.add_suffix(&reset_welcome_btn);
|
||||||
|
|
||||||
|
ui_group.add(&skill_row);
|
||||||
|
ui_group.add(&reset_welcome_row);
|
||||||
|
general_page.add(&ui_group);
|
||||||
|
|
||||||
|
// File Manager Integration
|
||||||
|
let fm_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("File Manager Integration")
|
||||||
|
.description("Add 'Process with Pixstrip' to your file manager's right-click menu")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
use pixstrip_core::fm_integration::FileManager;
|
||||||
|
let file_managers = [
|
||||||
|
(FileManager::Nautilus, "org.gnome.Nautilus"),
|
||||||
|
(FileManager::Nemo, "org.nemo.Nemo"),
|
||||||
|
(FileManager::Thunar, "thunar"),
|
||||||
|
(FileManager::Dolphin, "org.kde.dolphin"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut found_fm = false;
|
||||||
|
for (fm, desktop_id) in &file_managers {
|
||||||
|
let is_installed = gtk::gio::AppInfo::all()
|
||||||
|
.iter()
|
||||||
|
.any(|info| {
|
||||||
|
info.id()
|
||||||
|
.map(|id| id.as_str().contains(desktop_id))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_installed {
|
||||||
|
found_fm = true;
|
||||||
|
let already_installed = fm.is_installed();
|
||||||
|
let row = adw::SwitchRow::builder()
|
||||||
|
.title(fm.name())
|
||||||
|
.subtitle(format!("Add right-click menu to {}", fm.name()))
|
||||||
|
.active(already_installed)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let fm_copy = *fm;
|
||||||
|
let reverting = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||||
|
let reverting_clone = reverting.clone();
|
||||||
|
row.connect_active_notify(move |row| {
|
||||||
|
if reverting_clone.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result = if row.is_active() {
|
||||||
|
fm_copy.install()
|
||||||
|
} else {
|
||||||
|
fm_copy.uninstall()
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("File manager integration error for {}: {}", fm_copy.name(), e);
|
||||||
|
reverting_clone.set(true);
|
||||||
|
row.set_active(!row.is_active());
|
||||||
|
reverting_clone.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fm_group.add(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found_fm {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title("No supported file managers detected")
|
||||||
|
.subtitle("Nautilus, Nemo, Thunar, and Dolphin are supported")
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic"));
|
||||||
|
fm_group.add(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
general_page.add(&fm_group);
|
||||||
|
|
||||||
|
// Reset defaults group
|
||||||
|
let reset_group = adw::PreferencesGroup::new();
|
||||||
|
reset_group.add(&reset_button);
|
||||||
|
general_page.add(&reset_group);
|
||||||
|
|
||||||
|
dialog.add(&general_page);
|
||||||
|
|
||||||
|
// Processing page
|
||||||
|
let processing_page = adw::PreferencesPage::builder()
|
||||||
|
.title("Processing")
|
||||||
|
.icon_name("system-run-symbolic")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let threads_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Performance")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let threads_row = adw::ComboRow::builder()
|
||||||
|
.title("Processing threads")
|
||||||
|
.subtitle("Auto uses all available CPU cores")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]);
|
||||||
|
threads_row.set_model(Some(&threads_model));
|
||||||
|
threads_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||||
|
threads_row.set_selected(match config.thread_count {
|
||||||
|
pixstrip_core::config::ThreadCount::Auto => 0,
|
||||||
|
pixstrip_core::config::ThreadCount::Manual(1) => 1,
|
||||||
|
pixstrip_core::config::ThreadCount::Manual(2) => 2,
|
||||||
|
pixstrip_core::config::ThreadCount::Manual(n) if n <= 4 => 3,
|
||||||
|
pixstrip_core::config::ThreadCount::Manual(_) => 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
let error_row = adw::ComboRow::builder()
|
||||||
|
.title("On error")
|
||||||
|
.subtitle("What to do when an image fails to process")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
let error_model = gtk::StringList::new(&["Skip and continue", "Pause on error"]);
|
||||||
|
error_row.set_model(Some(&error_model));
|
||||||
|
error_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||||
|
error_row.set_selected(match config.error_behavior {
|
||||||
|
ErrorBehavior::SkipAndContinue => 0,
|
||||||
|
ErrorBehavior::PauseOnError => 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
threads_group.add(&threads_row);
|
||||||
|
threads_group.add(&error_row);
|
||||||
|
processing_page.add(&threads_group);
|
||||||
|
dialog.add(&processing_page);
|
||||||
|
|
||||||
|
// Accessibility page
|
||||||
|
let a11y_page = adw::PreferencesPage::builder()
|
||||||
|
.title("Accessibility")
|
||||||
|
.icon_name("preferences-desktop-accessibility-symbolic")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let a11y_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Visual Preferences")
|
||||||
|
.description("Override system settings for this app only")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let contrast_row = adw::SwitchRow::builder()
|
||||||
|
.title("High contrast")
|
||||||
|
.subtitle("Increase visual contrast throughout the app")
|
||||||
|
.active(config.high_contrast)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let large_text_row = adw::SwitchRow::builder()
|
||||||
|
.title("Large text")
|
||||||
|
.subtitle("Increase text size throughout the app")
|
||||||
|
.active(config.large_text)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let motion_row = adw::SwitchRow::builder()
|
||||||
|
.title("Reduced motion")
|
||||||
|
.subtitle("Minimize animations and transitions")
|
||||||
|
.active(config.reduced_motion)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Wire high contrast to apply immediately
|
||||||
|
{
|
||||||
|
let original_theme: std::rc::Rc<std::cell::RefCell<Option<gtk::glib::GString>>> =
|
||||||
|
std::rc::Rc::new(std::cell::RefCell::new(
|
||||||
|
gtk::Settings::default().and_then(|s| s.gtk_theme_name())
|
||||||
|
));
|
||||||
|
let orig_theme = original_theme.clone();
|
||||||
|
contrast_row.connect_active_notify(move |row| {
|
||||||
|
if let Some(settings) = gtk::Settings::default() {
|
||||||
|
if row.is_active() {
|
||||||
|
// Capture current theme before switching (if not already captured)
|
||||||
|
let mut saved = orig_theme.borrow_mut();
|
||||||
|
if saved.is_none() {
|
||||||
|
*saved = settings.gtk_theme_name();
|
||||||
|
}
|
||||||
|
settings.set_gtk_theme_name(Some("HighContrast"));
|
||||||
|
} else {
|
||||||
|
let saved = orig_theme.borrow();
|
||||||
|
settings.set_gtk_theme_name(saved.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire large text to apply immediately
|
||||||
|
{
|
||||||
|
// Capture the initial DPI at construction so we can restore it later
|
||||||
|
let initial_dpi = gtk::Settings::default()
|
||||||
|
.map(|s| s.gtk_xft_dpi())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let original_dpi: std::rc::Rc<Cell<i32>> = std::rc::Rc::new(Cell::new(initial_dpi));
|
||||||
|
let orig_dpi = original_dpi.clone();
|
||||||
|
large_text_row.connect_active_notify(move |row| {
|
||||||
|
if let Some(settings) = gtk::Settings::default() {
|
||||||
|
if row.is_active() {
|
||||||
|
// Store original DPI before modifying (refresh if not yet set)
|
||||||
|
let current_dpi = settings.gtk_xft_dpi();
|
||||||
|
if current_dpi > 0 {
|
||||||
|
orig_dpi.set(current_dpi);
|
||||||
|
settings.set_gtk_xft_dpi(current_dpi * 5 / 4);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Restore the original DPI (only if we actually changed it)
|
||||||
|
let saved = orig_dpi.get();
|
||||||
|
if saved > 0 {
|
||||||
|
settings.set_gtk_xft_dpi(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire reduced motion to apply immediately
|
||||||
|
{
|
||||||
|
motion_row.connect_active_notify(move |row| {
|
||||||
|
if let Some(settings) = gtk::Settings::default() {
|
||||||
|
settings.set_gtk_enable_animations(!row.is_active());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
a11y_group.add(&contrast_row);
|
||||||
|
a11y_group.add(&large_text_row);
|
||||||
|
a11y_group.add(&motion_row);
|
||||||
|
a11y_page.add(&a11y_group);
|
||||||
|
dialog.add(&a11y_page);
|
||||||
|
|
||||||
|
// Notifications page
|
||||||
|
let notify_page = adw::PreferencesPage::builder()
|
||||||
|
.title("Notifications")
|
||||||
|
.icon_name("preferences-system-notifications-symbolic")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let notify_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Completion")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let desktop_notify_row = adw::SwitchRow::builder()
|
||||||
|
.title("Desktop notification")
|
||||||
|
.subtitle("Show notification when processing completes")
|
||||||
|
.active(config.notify_on_completion)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let sound_row = adw::SwitchRow::builder()
|
||||||
|
.title("Completion sound")
|
||||||
|
.subtitle("Play a sound when processing completes")
|
||||||
|
.active(config.play_completion_sound)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let auto_open_row = adw::SwitchRow::builder()
|
||||||
|
.title("Auto-open output folder")
|
||||||
|
.subtitle("Open the output folder in file manager when done")
|
||||||
|
.active(config.auto_open_output)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
notify_group.add(&desktop_notify_row);
|
||||||
|
notify_group.add(&sound_row);
|
||||||
|
notify_group.add(&auto_open_row);
|
||||||
|
notify_page.add(¬ify_group);
|
||||||
|
dialog.add(¬ify_page);
|
||||||
|
|
||||||
|
// Watch Folders page
|
||||||
|
let watch_page = adw::PreferencesPage::builder()
|
||||||
|
.title("Watch Folders")
|
||||||
|
.icon_name("folder-visiting-symbolic")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let watch_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Monitored Folders")
|
||||||
|
.description("Automatically process images added to these folders")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let watch_list = gtk::ListBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
watch_list.set_widget_name("watch-folder-list");
|
||||||
|
watch_list.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Configured watch folders for automatic processing"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Shared state for watch folders
|
||||||
|
let watch_folders_state: std::rc::Rc<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>> =
|
||||||
|
std::rc::Rc::new(std::cell::RefCell::new(config.watch_folders.clone()));
|
||||||
|
|
||||||
|
// Build preset names list for dropdown
|
||||||
|
let builtin_presets = pixstrip_core::preset::Preset::all_builtins();
|
||||||
|
let preset_names: Vec<String> = builtin_presets.iter().map(|p| p.name.clone()).collect();
|
||||||
|
|
||||||
|
// Populate existing watch folders
|
||||||
|
{
|
||||||
|
let folders = watch_folders_state.borrow();
|
||||||
|
for folder in folders.iter() {
|
||||||
|
let row = build_watch_folder_row(folder, &preset_names, &watch_folders_state, &watch_list);
|
||||||
|
watch_list.append(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
let empty_label = gtk::Label::builder()
|
||||||
|
.label("No watch folders configured.\nAdd a folder to start automatic processing.")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_top(16)
|
||||||
|
.margin_bottom(16)
|
||||||
|
.justify(gtk::Justification::Center)
|
||||||
|
.build();
|
||||||
|
empty_label.set_visible(config.watch_folders.is_empty());
|
||||||
|
|
||||||
|
watch_group.add(&watch_list);
|
||||||
|
watch_group.add(&empty_label);
|
||||||
|
|
||||||
|
// Add folder button
|
||||||
|
let add_button = gtk::Button::builder()
|
||||||
|
.label("Add Watch Folder")
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.margin_top(8)
|
||||||
|
.build();
|
||||||
|
add_button.add_css_class("suggested-action");
|
||||||
|
add_button.add_css_class("pill");
|
||||||
|
|
||||||
|
{
|
||||||
|
let wfs = watch_folders_state.clone();
|
||||||
|
let wl = watch_list.clone();
|
||||||
|
let el = empty_label.clone();
|
||||||
|
let pnames = preset_names.clone();
|
||||||
|
add_button.connect_clicked(move |btn| {
|
||||||
|
let wfs = wfs.clone();
|
||||||
|
let wl = wl.clone();
|
||||||
|
let el = el.clone();
|
||||||
|
let pnames = pnames.clone();
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title("Choose Watch Folder")
|
||||||
|
.modal(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
||||||
|
dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
||||||
|
if let Ok(file) = result
|
||||||
|
&& let Some(path) = file.path()
|
||||||
|
{
|
||||||
|
let new_folder = pixstrip_core::watcher::WatchFolder {
|
||||||
|
path: path.clone(),
|
||||||
|
preset_name: "Blog Photos".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
let row = build_watch_folder_row(&new_folder, &pnames, &wfs, &wl);
|
||||||
|
wl.append(&row);
|
||||||
|
wfs.borrow_mut().push(new_folder);
|
||||||
|
el.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let add_group = adw::PreferencesGroup::new();
|
||||||
|
add_group.add(&add_button);
|
||||||
|
|
||||||
|
watch_page.add(&watch_group);
|
||||||
|
watch_page.add(&add_group);
|
||||||
|
dialog.add(&watch_page);
|
||||||
|
|
||||||
|
// Wire reset button
|
||||||
|
{
|
||||||
|
let subfolder = subfolder_row.clone();
|
||||||
|
let overwrite = overwrite_row.clone();
|
||||||
|
let remember = remember_row.clone();
|
||||||
|
let skill = skill_row.clone();
|
||||||
|
let threads = threads_row.clone();
|
||||||
|
let error = error_row.clone();
|
||||||
|
let contrast = contrast_row.clone();
|
||||||
|
let large_text = large_text_row.clone();
|
||||||
|
let motion = motion_row.clone();
|
||||||
|
let notify = desktop_notify_row.clone();
|
||||||
|
let sound = sound_row.clone();
|
||||||
|
let auto_open = auto_open_row.clone();
|
||||||
|
let output_mode = output_mode_row.clone();
|
||||||
|
let fps_reset = fixed_path_state.clone();
|
||||||
|
let wfs_reset = watch_folders_state.clone();
|
||||||
|
let wl_reset = watch_list.clone();
|
||||||
|
let el_reset = empty_label.clone();
|
||||||
|
reset_button.connect_clicked(move |_| {
|
||||||
|
let defaults = AppConfig::default();
|
||||||
|
subfolder.set_text(&defaults.output_subfolder);
|
||||||
|
overwrite.set_selected(0);
|
||||||
|
remember.set_active(defaults.remember_settings);
|
||||||
|
skill.set_selected(0);
|
||||||
|
threads.set_selected(0);
|
||||||
|
error.set_selected(0);
|
||||||
|
contrast.set_active(defaults.high_contrast);
|
||||||
|
large_text.set_active(defaults.large_text);
|
||||||
|
motion.set_active(defaults.reduced_motion);
|
||||||
|
notify.set_active(defaults.notify_on_completion);
|
||||||
|
sound.set_active(defaults.play_completion_sound);
|
||||||
|
auto_open.set_active(defaults.auto_open_output);
|
||||||
|
output_mode.set_selected(0);
|
||||||
|
*fps_reset.borrow_mut() = None;
|
||||||
|
// Clear watch folders
|
||||||
|
wfs_reset.borrow_mut().clear();
|
||||||
|
wl_reset.remove_all();
|
||||||
|
el_reset.set_visible(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve history settings from current config (not exposed in UI yet)
|
||||||
|
let hist_max_entries = config.history_max_entries;
|
||||||
|
let hist_max_days = config.history_max_days;
|
||||||
|
|
||||||
|
// Save settings when the dialog closes
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
let welcome_reset = reset_welcome_state.get();
|
||||||
|
let new_config = AppConfig {
|
||||||
|
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()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
overwrite_behavior: match overwrite_row.selected() {
|
||||||
|
1 => OverwriteBehavior::AutoRename,
|
||||||
|
2 => OverwriteBehavior::Overwrite,
|
||||||
|
3 => OverwriteBehavior::Skip,
|
||||||
|
_ => OverwriteBehavior::Ask,
|
||||||
|
},
|
||||||
|
remember_settings: remember_row.is_active(),
|
||||||
|
skill_level: match skill_row.selected() {
|
||||||
|
1 => SkillLevel::Detailed,
|
||||||
|
_ => SkillLevel::Simple,
|
||||||
|
},
|
||||||
|
thread_count: match threads_row.selected() {
|
||||||
|
1 => pixstrip_core::config::ThreadCount::Manual(1),
|
||||||
|
2 => pixstrip_core::config::ThreadCount::Manual(2),
|
||||||
|
3 => pixstrip_core::config::ThreadCount::Manual(4),
|
||||||
|
4 => pixstrip_core::config::ThreadCount::Manual(8),
|
||||||
|
_ => pixstrip_core::config::ThreadCount::Auto,
|
||||||
|
},
|
||||||
|
error_behavior: match error_row.selected() {
|
||||||
|
1 => ErrorBehavior::PauseOnError,
|
||||||
|
_ => ErrorBehavior::SkipAndContinue,
|
||||||
|
},
|
||||||
|
notify_on_completion: desktop_notify_row.is_active(),
|
||||||
|
play_completion_sound: sound_row.is_active(),
|
||||||
|
auto_open_output: auto_open_row.is_active(),
|
||||||
|
high_contrast: contrast_row.is_active(),
|
||||||
|
large_text: large_text_row.is_active(),
|
||||||
|
reduced_motion: motion_row.is_active(),
|
||||||
|
history_max_entries: hist_max_entries,
|
||||||
|
history_max_days: hist_max_days,
|
||||||
|
watch_folders: watch_folders_state.borrow().clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let store = ConfigStore::new();
|
||||||
|
let _ = store.save(&new_config);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_watch_folder_row(
|
||||||
|
folder: &pixstrip_core::watcher::WatchFolder,
|
||||||
|
preset_names: &[String],
|
||||||
|
watch_state: &std::rc::Rc<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>>,
|
||||||
|
list_box: >k::ListBox,
|
||||||
|
) -> adw::ExpanderRow {
|
||||||
|
let display_path = folder.path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or_else(|| folder.path.to_str().unwrap_or("Unknown"));
|
||||||
|
|
||||||
|
let row = adw::ExpanderRow::builder()
|
||||||
|
.title(display_path)
|
||||||
|
.subtitle(&folder.path.display().to_string())
|
||||||
|
.show_enable_switch(true)
|
||||||
|
.enable_expansion(folder.active)
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name("folder-visiting-symbolic"));
|
||||||
|
|
||||||
|
// Preset selector
|
||||||
|
let preset_row = adw::ComboRow::builder()
|
||||||
|
.title("Linked Preset")
|
||||||
|
.subtitle("Preset to apply to new images")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
let preset_model = gtk::StringList::new(
|
||||||
|
&preset_names.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
preset_row.set_model(Some(&preset_model));
|
||||||
|
preset_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||||
|
|
||||||
|
// Set selected to matching preset
|
||||||
|
let selected_idx = preset_names.iter()
|
||||||
|
.position(|n| *n == folder.preset_name)
|
||||||
|
.unwrap_or(0);
|
||||||
|
preset_row.set_selected(selected_idx as u32);
|
||||||
|
|
||||||
|
// Recursive toggle
|
||||||
|
let recursive_row = adw::SwitchRow::builder()
|
||||||
|
.title("Include Subfolders")
|
||||||
|
.subtitle("Monitor subfolders recursively")
|
||||||
|
.active(folder.recursive)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Remove button
|
||||||
|
let remove_row = adw::ActionRow::builder()
|
||||||
|
.title("Remove This Folder")
|
||||||
|
.build();
|
||||||
|
remove_row.add_prefix(>k::Image::from_icon_name("user-trash-symbolic"));
|
||||||
|
let remove_btn = gtk::Button::builder()
|
||||||
|
.icon_name("edit-delete-symbolic")
|
||||||
|
.tooltip_text("Remove watch folder")
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
remove_btn.add_css_class("flat");
|
||||||
|
remove_btn.add_css_class("error");
|
||||||
|
remove_row.add_suffix(&remove_btn);
|
||||||
|
|
||||||
|
row.add_row(&preset_row);
|
||||||
|
row.add_row(&recursive_row);
|
||||||
|
row.add_row(&remove_row);
|
||||||
|
|
||||||
|
// Wire enable toggle
|
||||||
|
let folder_path = folder.path.clone();
|
||||||
|
{
|
||||||
|
let wfs = watch_state.clone();
|
||||||
|
let fp = folder_path.clone();
|
||||||
|
row.connect_enable_expansion_notify(move |r| {
|
||||||
|
let mut folders = wfs.borrow_mut();
|
||||||
|
if let Some(f) = folders.iter_mut().find(|f| f.path == fp) {
|
||||||
|
f.active = r.enables_expansion();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire preset change
|
||||||
|
{
|
||||||
|
let wfs = watch_state.clone();
|
||||||
|
let fp = folder_path.clone();
|
||||||
|
let pnames = preset_names.to_vec();
|
||||||
|
preset_row.connect_selected_notify(move |r| {
|
||||||
|
let mut folders = wfs.borrow_mut();
|
||||||
|
if let Some(f) = folders.iter_mut().find(|f| f.path == fp) {
|
||||||
|
f.preset_name = pnames.get(r.selected() as usize)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire recursive toggle
|
||||||
|
{
|
||||||
|
let wfs = watch_state.clone();
|
||||||
|
let fp = folder_path.clone();
|
||||||
|
recursive_row.connect_active_notify(move |r| {
|
||||||
|
let mut folders = wfs.borrow_mut();
|
||||||
|
if let Some(f) = folders.iter_mut().find(|f| f.path == fp) {
|
||||||
|
f.recursive = r.is_active();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire remove button
|
||||||
|
{
|
||||||
|
let wfs = watch_state.clone();
|
||||||
|
let lb = list_box.clone();
|
||||||
|
let fp = folder_path;
|
||||||
|
let r = row.clone();
|
||||||
|
remove_btn.connect_clicked(move |_| {
|
||||||
|
wfs.borrow_mut().retain(|f| f.path != fp);
|
||||||
|
lb.remove(&r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
row
|
||||||
|
}
|
||||||
208
pixstrip-gtk/src/step_indicator.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::glib::prelude::ToVariant;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StepIndicator {
|
||||||
|
container: gtk::Box,
|
||||||
|
grid: gtk::Grid,
|
||||||
|
dots: RefCell<Vec<StepDot>>,
|
||||||
|
/// Maps visual index -> actual step index
|
||||||
|
step_map: RefCell<Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct StepDot {
|
||||||
|
button: gtk::Button,
|
||||||
|
icon: gtk::Image,
|
||||||
|
label: gtk::Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StepIndicator {
|
||||||
|
pub fn new(step_names: &[String]) -> Self {
|
||||||
|
let container = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.spacing(0)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
container.set_overflow(gtk::Overflow::Hidden);
|
||||||
|
|
||||||
|
container.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Wizard step indicator"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let grid = gtk::Grid::builder()
|
||||||
|
.column_homogeneous(false)
|
||||||
|
.row_spacing(2)
|
||||||
|
.column_spacing(0)
|
||||||
|
.hexpand(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let indices: Vec<usize> = (0..step_names.len()).collect();
|
||||||
|
let dots = Self::build_dots(&grid, step_names, &indices);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
container,
|
||||||
|
grid,
|
||||||
|
dots: RefCell::new(dots),
|
||||||
|
step_map: RefCell::new(indices),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_dots(grid: >k::Grid, names: &[String], step_indices: &[usize]) -> Vec<StepDot> {
|
||||||
|
let mut dots = Vec::new();
|
||||||
|
|
||||||
|
for (visual_i, (name, &actual_i)) in names.iter().zip(step_indices.iter()).enumerate() {
|
||||||
|
let col = (visual_i * 2) as i32;
|
||||||
|
|
||||||
|
if visual_i > 0 {
|
||||||
|
let line = gtk::Separator::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.hexpand(false)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
line.set_size_request(12, -1);
|
||||||
|
grid.attach(&line, col - 1, 0, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon = gtk::Image::builder()
|
||||||
|
.icon_name("radio-symbolic")
|
||||||
|
.pixel_size(16)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let button = gtk::Button::builder()
|
||||||
|
.child(&icon)
|
||||||
|
.has_frame(false)
|
||||||
|
.tooltip_text(format!("Step {}: {} (Alt+{})", visual_i + 1, name, actual_i + 1))
|
||||||
|
.sensitive(false)
|
||||||
|
.action_name("win.goto-step")
|
||||||
|
.action_target(&(actual_i as i32 + 1).to_variant())
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
button.add_css_class("circular");
|
||||||
|
|
||||||
|
let label = gtk::Label::builder()
|
||||||
|
.label(name)
|
||||||
|
.css_classes(["caption"])
|
||||||
|
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||||
|
.width_chars(10)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
grid.attach(&button, col, 0, 1, 1);
|
||||||
|
grid.attach(&label, col, 1, 1, 1);
|
||||||
|
|
||||||
|
dots.push(StepDot {
|
||||||
|
button,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the indicator to show only the given steps.
|
||||||
|
/// `visible_steps` is a list of (actual_step_index, name).
|
||||||
|
pub fn rebuild(&self, visible_steps: &[(usize, String)]) {
|
||||||
|
// Clear the grid
|
||||||
|
while let Some(child) = self.grid.first_child() {
|
||||||
|
self.grid.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let names: Vec<String> = visible_steps.iter().map(|(_, n)| n.clone()).collect();
|
||||||
|
let indices: Vec<usize> = visible_steps.iter().map(|(i, _)| *i).collect();
|
||||||
|
|
||||||
|
let dots = Self::build_dots(&self.grid, &names, &indices);
|
||||||
|
*self.dots.borrow_mut() = dots;
|
||||||
|
*self.step_map.borrow_mut() = indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current step by actual step index. Finds the visual position.
|
||||||
|
pub fn set_current(&self, actual_index: usize) {
|
||||||
|
let dots = self.dots.borrow();
|
||||||
|
let map = self.step_map.borrow();
|
||||||
|
let total = dots.len();
|
||||||
|
for (visual_i, dot) in dots.iter().enumerate() {
|
||||||
|
let is_current = map.get(visual_i) == Some(&actual_index);
|
||||||
|
if is_current {
|
||||||
|
dot.icon.set_icon_name(Some("radio-checked-symbolic"));
|
||||||
|
dot.button.set_sensitive(true);
|
||||||
|
dot.label.add_css_class("accent");
|
||||||
|
dot.button.update_property(&[
|
||||||
|
gtk::accessible::Property::Label(
|
||||||
|
&format!("Step {} of {}: {} (current)", visual_i + 1, total, dot.label.label())
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} else if dot.icon.icon_name().as_deref() != Some("emblem-ok-symbolic") {
|
||||||
|
dot.icon.set_icon_name(Some("radio-symbolic"));
|
||||||
|
dot.label.remove_css_class("accent");
|
||||||
|
dot.button.update_property(&[
|
||||||
|
gtk::accessible::Property::Label(
|
||||||
|
&format!("Step {} of {}: {}", visual_i + 1, total, dot.label.label())
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a step as completed by actual step index.
|
||||||
|
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())
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget(&self) -> >k::Box {
|
||||||
|
&self.container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow appending the step indicator to containers
|
||||||
|
impl std::ops::Deref for StepIndicator {
|
||||||
|
type Target = gtk::Box;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.container
|
||||||
|
}
|
||||||
|
}
|
||||||
40
pixstrip-gtk/src/steps/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
pub mod step_adjustments;
|
||||||
|
pub mod step_compress;
|
||||||
|
pub mod step_convert;
|
||||||
|
pub mod step_images;
|
||||||
|
pub mod step_metadata;
|
||||||
|
pub mod step_output;
|
||||||
|
pub mod step_rename;
|
||||||
|
pub mod step_resize;
|
||||||
|
pub mod step_watermark;
|
||||||
|
pub mod step_workflow;
|
||||||
|
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
/// Creates a list factory for ComboRow dropdowns where labels never truncate.
|
||||||
|
/// The default GTK factory ellipsizes text, which cuts off long option names.
|
||||||
|
pub fn full_text_list_factory() -> gtk::SignalListItemFactory {
|
||||||
|
let factory = gtk::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup(|_, item| {
|
||||||
|
let Some(item) = item.downcast_ref::<gtk::ListItem>() else { return };
|
||||||
|
let label = gtk::Label::builder()
|
||||||
|
.xalign(0.0)
|
||||||
|
.margin_start(8)
|
||||||
|
.margin_end(8)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.build();
|
||||||
|
item.set_child(Some(&label));
|
||||||
|
});
|
||||||
|
factory.connect_bind(|_, item| {
|
||||||
|
let Some(item) = item.downcast_ref::<gtk::ListItem>() else { return };
|
||||||
|
if let Some(obj) = item.item() {
|
||||||
|
if let Some(string_obj) = obj.downcast_ref::<gtk::StringObject>() {
|
||||||
|
if let Some(label) = item.child().and_downcast_ref::<gtk::Label>() {
|
||||||
|
label.set_label(&string_obj.string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
factory
|
||||||
|
}
|
||||||
747
pixstrip-gtk/src/steps/step_adjustments.rs
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
|
// === OUTER LAYOUT ===
|
||||||
|
let outer = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(0)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Enable toggle (full width) ---
|
||||||
|
let enable_group = adw::PreferencesGroup::builder()
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.build();
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// === LEFT SIDE: Preview ===
|
||||||
|
|
||||||
|
let preview_picture = gtk::Picture::builder()
|
||||||
|
.content_fit(gtk::ContentFit::Contain)
|
||||||
|
.hexpand(true)
|
||||||
|
.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")
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_top(4)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let preview_frame = gtk::Frame::builder()
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
preview_frame.set_child(Some(&preview_picture));
|
||||||
|
|
||||||
|
let preview_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(4)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
preview_box.append(&preview_frame);
|
||||||
|
preview_box.append(&info_label);
|
||||||
|
|
||||||
|
// === RIGHT SIDE: Controls (scrollable) ===
|
||||||
|
|
||||||
|
let controls = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Orientation group ---
|
||||||
|
let orient_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Orientation")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let rotate_row = adw::ComboRow::builder()
|
||||||
|
.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",
|
||||||
|
"90 clockwise",
|
||||||
|
"180",
|
||||||
|
"270 clockwise",
|
||||||
|
"Auto-orient (from EXIF)",
|
||||||
|
])));
|
||||||
|
rotate_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
rotate_row.set_selected(cfg.rotation);
|
||||||
|
|
||||||
|
let flip_row = adw::ComboRow::builder()
|
||||||
|
.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()));
|
||||||
|
flip_row.set_selected(cfg.flip);
|
||||||
|
|
||||||
|
orient_group.add(&rotate_row);
|
||||||
|
orient_group.add(&flip_row);
|
||||||
|
controls.append(&orient_group);
|
||||||
|
|
||||||
|
// --- Color adjustments group ---
|
||||||
|
let color_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Color")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Helper to build a slider row with reset button
|
||||||
|
let make_slider_row = |title: &str, label_text: &str, value: i32| -> (adw::ActionRow, gtk::Scale, gtk::Button) {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(title)
|
||||||
|
.subtitle(&format!("{}", value))
|
||||||
|
.build();
|
||||||
|
let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||||
|
scale.set_value(value as f64);
|
||||||
|
scale.set_draw_value(false);
|
||||||
|
scale.set_hexpand(false);
|
||||||
|
scale.set_valign(gtk::Align::Center);
|
||||||
|
scale.set_width_request(180);
|
||||||
|
scale.set_tooltip_text(Some(label_text));
|
||||||
|
scale.update_property(&[
|
||||||
|
gtk::accessible::Property::Label(label_text),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let reset_btn = gtk::Button::builder()
|
||||||
|
.icon_name("edit-undo-symbolic")
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.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);
|
||||||
|
row.add_suffix(&reset_btn);
|
||||||
|
(row, scale, reset_btn)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (brightness_row, brightness_scale, brightness_reset) =
|
||||||
|
make_slider_row("Brightness", "Brightness adjustment, -100 to +100", cfg.brightness);
|
||||||
|
let (contrast_row, contrast_scale, contrast_reset) =
|
||||||
|
make_slider_row("Contrast", "Contrast adjustment, -100 to +100", cfg.contrast);
|
||||||
|
let (saturation_row, saturation_scale, saturation_reset) =
|
||||||
|
make_slider_row("Saturation", "Saturation adjustment, -100 to +100", cfg.saturation);
|
||||||
|
|
||||||
|
color_group.add(&brightness_row);
|
||||||
|
color_group.add(&contrast_row);
|
||||||
|
color_group.add(&saturation_row);
|
||||||
|
controls.append(&color_group);
|
||||||
|
|
||||||
|
// --- Effects group (compact toggle buttons) ---
|
||||||
|
let effects_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Effects")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let effects_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.margin_top(4)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let grayscale_btn = gtk::ToggleButton::builder()
|
||||||
|
.label("Grayscale")
|
||||||
|
.active(cfg.grayscale)
|
||||||
|
.tooltip_text("Convert to grayscale")
|
||||||
|
.build();
|
||||||
|
grayscale_btn.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Grayscale effect toggle"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let sepia_btn = gtk::ToggleButton::builder()
|
||||||
|
.label("Sepia")
|
||||||
|
.active(cfg.sepia)
|
||||||
|
.tooltip_text("Apply sepia tone")
|
||||||
|
.build();
|
||||||
|
sepia_btn.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Sepia effect toggle"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let sharpen_btn = gtk::ToggleButton::builder()
|
||||||
|
.label("Sharpen")
|
||||||
|
.active(cfg.sharpen)
|
||||||
|
.tooltip_text("Sharpen the image")
|
||||||
|
.build();
|
||||||
|
sharpen_btn.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Sharpen effect toggle"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
effects_box.append(&grayscale_btn);
|
||||||
|
effects_box.append(&sepia_btn);
|
||||||
|
effects_box.append(&sharpen_btn);
|
||||||
|
effects_group.add(&effects_box);
|
||||||
|
controls.append(&effects_group);
|
||||||
|
|
||||||
|
// --- Crop & Canvas group ---
|
||||||
|
let crop_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Crop and Canvas")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let crop_row = adw::ComboRow::builder()
|
||||||
|
.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",
|
||||||
|
"1:1 (Square)",
|
||||||
|
"4:3",
|
||||||
|
"3:2",
|
||||||
|
"16:9",
|
||||||
|
"9:16 (Portrait)",
|
||||||
|
"3:4 (Portrait)",
|
||||||
|
"2:3 (Portrait)",
|
||||||
|
])));
|
||||||
|
crop_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
crop_row.set_selected(cfg.crop_aspect_ratio);
|
||||||
|
|
||||||
|
let trim_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
crop_group.add(&trim_row);
|
||||||
|
crop_group.add(&padding_row);
|
||||||
|
controls.append(&crop_group);
|
||||||
|
|
||||||
|
// Scrollable controls
|
||||||
|
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.width_request(360)
|
||||||
|
.child(&controls)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// === Main layout: 60/40 side-by-side ===
|
||||||
|
let main_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
preview_box.set_width_request(400);
|
||||||
|
main_box.append(&preview_box);
|
||||||
|
main_box.append(&controls_scrolled);
|
||||||
|
outer.append(&main_box);
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
// === Preview update closure ===
|
||||||
|
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let update_preview = {
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pic = preview_picture.clone();
|
||||||
|
let info = info_label.clone();
|
||||||
|
let pidx = preview_index.clone();
|
||||||
|
let bind_gen = preview_gen.clone();
|
||||||
|
|
||||||
|
Rc::new(move || {
|
||||||
|
let loaded = files.borrow();
|
||||||
|
if loaded.is_empty() {
|
||||||
|
info.set_label("No images loaded");
|
||||||
|
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = pidx.get().min(loaded.len().saturating_sub(1));
|
||||||
|
pidx.set(idx);
|
||||||
|
let path = loaded[idx].clone();
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||||
|
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||||
|
|
||||||
|
let cfg = jc.borrow();
|
||||||
|
let rotation = cfg.rotation;
|
||||||
|
let flip = cfg.flip;
|
||||||
|
let brightness = cfg.brightness;
|
||||||
|
let contrast = cfg.contrast;
|
||||||
|
let saturation = cfg.saturation;
|
||||||
|
let grayscale = cfg.grayscale;
|
||||||
|
let sepia = cfg.sepia;
|
||||||
|
let sharpen = cfg.sharpen;
|
||||||
|
let crop_aspect = cfg.crop_aspect_ratio;
|
||||||
|
let trim_ws = cfg.trim_whitespace;
|
||||||
|
let padding = cfg.canvas_padding;
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
let my_gen = bind_gen.get().wrapping_add(1);
|
||||||
|
bind_gen.set(my_gen);
|
||||||
|
let gen_check = bind_gen.clone();
|
||||||
|
|
||||||
|
let pic = pic.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = (|| -> Option<Vec<u8>> {
|
||||||
|
let img = image::open(&path).ok()?;
|
||||||
|
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||||
|
|
||||||
|
// Rotation
|
||||||
|
let mut img = match rotation {
|
||||||
|
1 => img.rotate90(),
|
||||||
|
2 => img.rotate180(),
|
||||||
|
3 => img.rotate270(),
|
||||||
|
// 4 = auto-orient from EXIF - skip in preview (would need exif crate)
|
||||||
|
_ => img,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flip
|
||||||
|
match flip {
|
||||||
|
1 => img = img.fliph(),
|
||||||
|
2 => img = img.flipv(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop to aspect ratio
|
||||||
|
if crop_aspect > 0 {
|
||||||
|
let (target_w, target_h): (f64, f64) = match crop_aspect {
|
||||||
|
1 => (1.0, 1.0), // 1:1
|
||||||
|
2 => (4.0, 3.0), // 4:3
|
||||||
|
3 => (3.0, 2.0), // 3:2
|
||||||
|
4 => (16.0, 9.0), // 16:9
|
||||||
|
5 => (9.0, 16.0), // 9:16
|
||||||
|
6 => (3.0, 4.0), // 3:4
|
||||||
|
7 => (2.0, 3.0), // 2:3
|
||||||
|
_ => (1.0, 1.0),
|
||||||
|
};
|
||||||
|
let iw = img.width() as f64;
|
||||||
|
let ih = img.height() as f64;
|
||||||
|
let target_ratio = target_w / target_h;
|
||||||
|
let current_ratio = iw / ih;
|
||||||
|
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
||||||
|
((ih * target_ratio) as u32, img.height())
|
||||||
|
} else {
|
||||||
|
(img.width(), (iw / target_ratio) as u32)
|
||||||
|
};
|
||||||
|
let cx = (img.width().saturating_sub(crop_w)) / 2;
|
||||||
|
let cy = (img.height().saturating_sub(crop_h)) / 2;
|
||||||
|
img = img.crop_imm(cx, cy, crop_w, crop_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace (matches core algorithm with threshold)
|
||||||
|
if trim_ws {
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let (w, h) = (rgba.width(), rgba.height());
|
||||||
|
if w > 2 && h > 2 {
|
||||||
|
let bg = *rgba.get_pixel(0, 0);
|
||||||
|
let threshold = 30u32;
|
||||||
|
let is_bg = |p: &image::Rgba<u8>| -> bool {
|
||||||
|
let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs();
|
||||||
|
let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs();
|
||||||
|
let db = (p[2] as i32 - bg[2] as i32).unsigned_abs();
|
||||||
|
dr + dg + db < threshold
|
||||||
|
};
|
||||||
|
let mut top = 0u32;
|
||||||
|
let mut bottom = h - 1;
|
||||||
|
let mut left = 0u32;
|
||||||
|
let mut right = w - 1;
|
||||||
|
'top: for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) { top = y; break 'top; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'bottom: for y in (0..h).rev() {
|
||||||
|
for x in 0..w {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) { bottom = y; break 'bottom; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'left: for x in 0..w {
|
||||||
|
for y in top..=bottom {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) { left = x; break 'left; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'right: for x in (0..w).rev() {
|
||||||
|
for y in top..=bottom {
|
||||||
|
if !is_bg(rgba.get_pixel(x, y)) { right = x; break 'right; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cw = right.saturating_sub(left).saturating_add(1);
|
||||||
|
let ch = bottom.saturating_sub(top).saturating_add(1);
|
||||||
|
if cw > 0 && ch > 0 && (cw < w || ch < h) {
|
||||||
|
img = img.crop_imm(left, top, cw, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brightness
|
||||||
|
if brightness != 0 {
|
||||||
|
img = img.brighten(brightness);
|
||||||
|
}
|
||||||
|
// Contrast
|
||||||
|
if contrast != 0 {
|
||||||
|
img = img.adjust_contrast(contrast as f32);
|
||||||
|
}
|
||||||
|
// Saturation
|
||||||
|
if saturation != 0 {
|
||||||
|
let sat = saturation.clamp(-100, 100);
|
||||||
|
let factor = 1.0 + (sat as f64 / 100.0);
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
for pixel in rgba.pixels_mut() {
|
||||||
|
let r = pixel[0] as f64;
|
||||||
|
let g = pixel[1] as f64;
|
||||||
|
let b = pixel[2] as f64;
|
||||||
|
let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||||
|
}
|
||||||
|
img = image::DynamicImage::ImageRgba8(rgba);
|
||||||
|
}
|
||||||
|
// Sharpen
|
||||||
|
if sharpen {
|
||||||
|
img = img.unsharpen(1.0, 5);
|
||||||
|
}
|
||||||
|
// Grayscale
|
||||||
|
if grayscale {
|
||||||
|
img = image::DynamicImage::ImageLuma8(img.to_luma8());
|
||||||
|
}
|
||||||
|
// Sepia
|
||||||
|
if sepia {
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
for pixel in rgba.pixels_mut() {
|
||||||
|
let r = pixel[0] as f64;
|
||||||
|
let g = pixel[1] as f64;
|
||||||
|
let b = pixel[2] as f64;
|
||||||
|
pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8;
|
||||||
|
pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8;
|
||||||
|
}
|
||||||
|
img = image::DynamicImage::ImageRgba8(rgba);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas padding
|
||||||
|
if padding > 0 {
|
||||||
|
let pad = padding.min(200); // cap for preview
|
||||||
|
let new_w = img.width().saturating_add(pad.saturating_mul(2));
|
||||||
|
let new_h = img.height().saturating_add(pad.saturating_mul(2));
|
||||||
|
let mut canvas = image::RgbaImage::from_pixel(
|
||||||
|
new_w, new_h,
|
||||||
|
image::Rgba([255, 255, 255, 255]),
|
||||||
|
);
|
||||||
|
image::imageops::overlay(&mut canvas, &img.to_rgba8(), pad as i64, pad as i64);
|
||||||
|
img = image::DynamicImage::ImageRgba8(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(
|
||||||
|
&mut std::io::Cursor::new(&mut buf),
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
).ok()?;
|
||||||
|
Some(buf)
|
||||||
|
})();
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||||
|
if gen_check.get() != my_gen {
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
}
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Some(bytes)) => {
|
||||||
|
let gbytes = glib::Bytes::from(&bytes);
|
||||||
|
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||||
|
pic.set_paintable(Some(&texture));
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Ok(None) => glib::ControlFlow::Break,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(_) => glib::ControlFlow::Break,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Click-to-cycle on preview
|
||||||
|
{
|
||||||
|
let click = gtk::GestureClick::new();
|
||||||
|
let pidx = preview_index.clone();
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
click.connect_released(move |_, _, _, _| {
|
||||||
|
let loaded = files.borrow();
|
||||||
|
if loaded.len() > 1 {
|
||||||
|
let next = (pidx.get() + 1) % loaded.len();
|
||||||
|
pidx.set(next);
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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 ===
|
||||||
|
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
enable_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().adjustments_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
rotate_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().rotation = row.selected();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
flip_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().flip = row.selected();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-slider debounce counters (separate to avoid cross-slider cancellation)
|
||||||
|
let brightness_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let contrast_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let saturation_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let padding_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
|
||||||
|
// Brightness
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let row = brightness_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let rst = brightness_reset.clone();
|
||||||
|
let did = brightness_debounce.clone();
|
||||||
|
brightness_scale.connect_value_changed(move |scale| {
|
||||||
|
let val = scale.value().round() as i32;
|
||||||
|
jc.borrow_mut().brightness = val;
|
||||||
|
row.set_subtitle(&format!("{}", val));
|
||||||
|
rst.set_sensitive(val != 0);
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id {
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scale = brightness_scale.clone();
|
||||||
|
brightness_reset.connect_clicked(move |_| {
|
||||||
|
scale.set_value(0.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrast
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let row = contrast_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let rst = contrast_reset.clone();
|
||||||
|
let did = contrast_debounce.clone();
|
||||||
|
contrast_scale.connect_value_changed(move |scale| {
|
||||||
|
let val = scale.value().round() as i32;
|
||||||
|
jc.borrow_mut().contrast = val;
|
||||||
|
row.set_subtitle(&format!("{}", val));
|
||||||
|
rst.set_sensitive(val != 0);
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id {
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scale = contrast_scale.clone();
|
||||||
|
contrast_reset.connect_clicked(move |_| {
|
||||||
|
scale.set_value(0.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saturation
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let row = saturation_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let rst = saturation_reset.clone();
|
||||||
|
let did = saturation_debounce.clone();
|
||||||
|
saturation_scale.connect_value_changed(move |scale| {
|
||||||
|
let val = scale.value().round() as i32;
|
||||||
|
jc.borrow_mut().saturation = val;
|
||||||
|
row.set_subtitle(&format!("{}", val));
|
||||||
|
rst.set_sensitive(val != 0);
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id {
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scale = saturation_scale.clone();
|
||||||
|
saturation_reset.connect_clicked(move |_| {
|
||||||
|
scale.set_value(0.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effects toggle buttons
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
grayscale_btn.connect_toggled(move |btn| {
|
||||||
|
jc.borrow_mut().grayscale = btn.is_active();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
sepia_btn.connect_toggled(move |btn| {
|
||||||
|
jc.borrow_mut().sepia = btn.is_active();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
sharpen_btn.connect_toggled(move |btn| {
|
||||||
|
jc.borrow_mut().sharpen = btn.is_active();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop & Canvas
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
crop_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().crop_aspect_ratio = row.selected();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
trim_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().trim_whitespace = row.is_active();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let did = padding_debounce.clone();
|
||||||
|
padding_row.connect_value_notify(move |row| {
|
||||||
|
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id {
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Adjustments")
|
||||||
|
.tag("step-adjustments")
|
||||||
|
.child(&outer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page
|
||||||
|
}
|
||||||
1200
pixstrip-gtk/src/steps/step_compress.rs
Normal file
508
pixstrip-gtk/src/steps/step_convert.rs
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
use pixstrip_core::types::ImageFormat;
|
||||||
|
|
||||||
|
/// All format labels shown in the card grid.
|
||||||
|
/// Keep Original + 7 common formats = 8 cards.
|
||||||
|
const CARD_FORMATS: &[(&str, &str, &str, Option<ImageFormat>)] = &[
|
||||||
|
("Keep Original", "No conversion", "edit-copy-symbolic", None),
|
||||||
|
("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)),
|
||||||
|
("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)),
|
||||||
|
("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)),
|
||||||
|
("AVIF", "Next-gen format\nBest compression", "emblem-favorite-symbolic", Some(ImageFormat::Avif)),
|
||||||
|
("GIF", "Animations, 256 colors\nSimple graphics", "media-playback-start-symbolic", Some(ImageFormat::Gif)),
|
||||||
|
("TIFF", "Archival, lossless\nVery large files", "drive-harddisk-symbolic", Some(ImageFormat::Tiff)),
|
||||||
|
("BMP", "Uncompressed bitmap\nLegacy format", "image-x-generic-symbolic", Some(ImageFormat::Bmp)),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Extra formats available only in the "Other Formats" dropdown: (short name, dropdown label)
|
||||||
|
const DROPDOWN_ONLY_FORMATS: &[(&str, &str)] = &[
|
||||||
|
("ICO", "ICO - Favicon and icon format, max 256x256 pixels"),
|
||||||
|
("HDR", "HDR - Radiance HDR, high dynamic range imaging"),
|
||||||
|
("PNM/PPM", "PNM/PPM - Portable anymap, simple uncompressed"),
|
||||||
|
("TGA", "TGA - Targa, legacy game/video format"),
|
||||||
|
("HEIC/HEIF", "HEIC/HEIF - Apple's photo format, excellent compression"),
|
||||||
|
("JXL (JPEG XL)", "JXL (JPEG XL) - Next-gen JPEG successor, lossless and lossy"),
|
||||||
|
("QOI", "QOI - Quite OK Image, fast lossless compression"),
|
||||||
|
("EXR", "EXR - OpenEXR, VFX/film industry HDR standard"),
|
||||||
|
("Farbfeld", "Farbfeld - Minimal 16-bit lossless, suckless format"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Ordered list of format labels for the per-format mapping dropdowns.
|
||||||
|
/// Index 0 = "Same as above", 1 = "Keep Original", then common formats with descriptions.
|
||||||
|
const MAPPING_CHOICES: &[&str] = &[
|
||||||
|
"Same as above - use the global output format",
|
||||||
|
"Keep Original - no conversion for this type",
|
||||||
|
"JPEG - universal photo format, lossy compression",
|
||||||
|
"PNG - lossless with transparency, larger files",
|
||||||
|
"WebP - modern web format, excellent compression",
|
||||||
|
"AVIF - next-gen, best compression, slower encode",
|
||||||
|
"GIF - 256 colors, animation support",
|
||||||
|
"TIFF - archival lossless, very large files",
|
||||||
|
"BMP - uncompressed bitmap, legacy format",
|
||||||
|
"ICO - favicon/icon format, max 256x256",
|
||||||
|
"HDR - Radiance high dynamic range",
|
||||||
|
"PNM/PPM - portable anymap, uncompressed",
|
||||||
|
"TGA - Targa, legacy game/video format",
|
||||||
|
"HEIC/HEIF - Apple photo format, great compression",
|
||||||
|
"JXL (JPEG XL) - next-gen JPEG successor",
|
||||||
|
"QOI - fast lossless compression",
|
||||||
|
"EXR - OpenEXR, VFX/film HDR standard",
|
||||||
|
"Farbfeld - minimal 16-bit lossless",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(24)
|
||||||
|
.margin_end(24)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.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();
|
||||||
|
enable_group.add(&enable_row);
|
||||||
|
content.append(&enable_group);
|
||||||
|
|
||||||
|
// --- Visual format cards grid ---
|
||||||
|
let cards_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Output Format")
|
||||||
|
.description("Choose the format all images will be converted to")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let flow = gtk::FlowBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::Single)
|
||||||
|
.max_children_per_line(4)
|
||||||
|
.min_children_per_line(2)
|
||||||
|
.row_spacing(8)
|
||||||
|
.column_spacing(8)
|
||||||
|
.homogeneous(true)
|
||||||
|
.margin_top(4)
|
||||||
|
.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 {
|
||||||
|
let card = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(4)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(false)
|
||||||
|
.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)
|
||||||
|
.spacing(4)
|
||||||
|
.margin_top(6)
|
||||||
|
.margin_bottom(6)
|
||||||
|
.margin_start(4)
|
||||||
|
.margin_end(4)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let icon = gtk::Image::builder()
|
||||||
|
.icon_name(*icon_name)
|
||||||
|
.pixel_size(28)
|
||||||
|
.build();
|
||||||
|
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||||
|
|
||||||
|
let name_label = gtk::Label::builder()
|
||||||
|
.label(*name)
|
||||||
|
.css_classes(["heading"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let desc_label = gtk::Label::builder()
|
||||||
|
.label(*desc)
|
||||||
|
.css_classes(["caption", "dim-label"])
|
||||||
|
.wrap(true)
|
||||||
|
.justify(gtk::Justification::Center)
|
||||||
|
.max_width_chars(18)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
inner.append(&icon);
|
||||||
|
inner.append(&name_label);
|
||||||
|
inner.append(&desc_label);
|
||||||
|
card.append(&inner);
|
||||||
|
flow.append(&card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the initial card (only if format matches a card)
|
||||||
|
let initial_idx = card_index_for_format(initial_format);
|
||||||
|
if let Some(idx) = initial_idx
|
||||||
|
&& let Some(child) = flow.child_at_index(idx)
|
||||||
|
{
|
||||||
|
flow.select_child(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap FlowBox in a Clamp so cards don't stretch too wide when maximized
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(800)
|
||||||
|
.child(&flow)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Format info label (updates based on selection)
|
||||||
|
let info_label = gtk::Label::builder()
|
||||||
|
.label(format_info(cfg.convert_format))
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.wrap(true)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.margin_start(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
cards_group.add(&clamp);
|
||||||
|
cards_group.add(&info_label);
|
||||||
|
|
||||||
|
// --- "Other Formats" dropdown for less common formats ---
|
||||||
|
let other_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Other Formats")
|
||||||
|
.description("Less common formats not shown in the card grid")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let other_combo = adw::ComboRow::builder()
|
||||||
|
.title("Select format")
|
||||||
|
.subtitle("Choosing a format here deselects the card grid")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Build model: first entry is "(none)" for no selection, then extra formats with descriptions
|
||||||
|
let mut other_items: Vec<&str> = vec!["(none)"];
|
||||||
|
for (_short, label) in DROPDOWN_ONLY_FORMATS {
|
||||||
|
other_items.push(label);
|
||||||
|
}
|
||||||
|
other_combo.set_model(Some(>k::StringList::new(&other_items)));
|
||||||
|
other_combo.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
other_combo.set_selected(0);
|
||||||
|
|
||||||
|
other_group.add(&other_combo);
|
||||||
|
content.append(&cards_group);
|
||||||
|
content.append(&other_group);
|
||||||
|
|
||||||
|
// --- JPEG encoding options (only visible when JPEG or Keep Original is selected) ---
|
||||||
|
let jpeg_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("JPEG Encoding")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let progressive_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// Show only for JPEG (card index 1) or Keep Original (card index 0)
|
||||||
|
let shows_jpeg = matches!(initial_format, None | Some(ImageFormat::Jpeg));
|
||||||
|
jpeg_group.set_visible(shows_jpeg);
|
||||||
|
|
||||||
|
content.append(&jpeg_group);
|
||||||
|
|
||||||
|
// --- Format mapping group (dynamic, rebuilt on page map) ---
|
||||||
|
let mapping_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Format Mapping")
|
||||||
|
.description("Override the output format for specific input types")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Container for dynamically added mapping rows
|
||||||
|
let mapping_list = gtk::ListBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mapping_group.add(&mapping_list);
|
||||||
|
content.append(&mapping_group);
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
// --- Wire signals ---
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
enable_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().convert_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card grid selection
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let label = info_label.clone();
|
||||||
|
let combo = other_combo.clone();
|
||||||
|
let jg = jpeg_group.clone();
|
||||||
|
flow.connect_child_activated(move |_flow, child| {
|
||||||
|
let idx = child.index() as usize;
|
||||||
|
let mut c = jc.borrow_mut();
|
||||||
|
c.convert_format = format_for_card_index(idx);
|
||||||
|
label.set_label(&format_info(c.convert_format));
|
||||||
|
// Deselect the "Other Formats" dropdown when a card is picked
|
||||||
|
combo.set_selected(0);
|
||||||
|
// Progressive JPEG only relevant for JPEG or Keep Original
|
||||||
|
jg.set_visible(idx == 0 || idx == 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Other Formats" dropdown selection
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let label = info_label;
|
||||||
|
let fl = flow.clone();
|
||||||
|
let jg = jpeg_group.clone();
|
||||||
|
other_combo.connect_selected_notify(move |row| {
|
||||||
|
let selected = row.selected();
|
||||||
|
if selected == 0 {
|
||||||
|
// "(none)" selected - do nothing, cards take priority
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deselect all cards
|
||||||
|
fl.unselect_all();
|
||||||
|
// Hide progressive JPEG - none of the "other" formats are JPEG
|
||||||
|
jg.set_visible(false);
|
||||||
|
// These formats are not in the ImageFormat enum,
|
||||||
|
// so set convert_format to None and show a note
|
||||||
|
let mut c = jc.borrow_mut();
|
||||||
|
c.convert_format = None;
|
||||||
|
let name = DROPDOWN_ONLY_FORMATS
|
||||||
|
.get((selected - 1) as usize)
|
||||||
|
.map(|(short, _)| *short)
|
||||||
|
.unwrap_or("Unknown");
|
||||||
|
label.set_label(&format!(
|
||||||
|
"{}: This format is not yet supported by the processing engine. \
|
||||||
|
Support is planned for a future release.",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progressive JPEG toggle
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
progressive_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().progressive_jpeg = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Convert")
|
||||||
|
.tag("step-convert")
|
||||||
|
.child(&scrolled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the card grid index for a given ImageFormat, or None if not in the card grid.
|
||||||
|
fn card_index_for_format(format: Option<ImageFormat>) -> Option<i32> {
|
||||||
|
match format {
|
||||||
|
None => Some(0),
|
||||||
|
Some(ImageFormat::Jpeg) => Some(1),
|
||||||
|
Some(ImageFormat::Png) => Some(2),
|
||||||
|
Some(ImageFormat::WebP) => Some(3),
|
||||||
|
Some(ImageFormat::Avif) => Some(4),
|
||||||
|
Some(ImageFormat::Gif) => Some(5),
|
||||||
|
Some(ImageFormat::Tiff) => Some(6),
|
||||||
|
Some(ImageFormat::Bmp) => Some(7),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the ImageFormat for a given card grid index.
|
||||||
|
fn format_for_card_index(idx: usize) -> Option<ImageFormat> {
|
||||||
|
match idx {
|
||||||
|
1 => Some(ImageFormat::Jpeg),
|
||||||
|
2 => Some(ImageFormat::Png),
|
||||||
|
3 => Some(ImageFormat::WebP),
|
||||||
|
4 => Some(ImageFormat::Avif),
|
||||||
|
5 => Some(ImageFormat::Gif),
|
||||||
|
6 => Some(ImageFormat::Tiff),
|
||||||
|
7 => Some(ImageFormat::Bmp),
|
||||||
|
_ => None, // 0 = Keep Original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_info(format: Option<ImageFormat>) -> String {
|
||||||
|
match format {
|
||||||
|
None => "Images will keep their original format. No conversion applied.".into(),
|
||||||
|
Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, \
|
||||||
|
no transparency support. Universally compatible with all devices and browsers."
|
||||||
|
.into(),
|
||||||
|
Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. \
|
||||||
|
Lossless compression, supports full transparency. Produces larger files \
|
||||||
|
than JPEG or WebP."
|
||||||
|
.into(),
|
||||||
|
Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless \
|
||||||
|
compression. Supports transparency and animation. Widely supported in modern browsers."
|
||||||
|
.into(),
|
||||||
|
Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. \
|
||||||
|
Best compression ratios available. Supports transparency and HDR. Slower to encode, \
|
||||||
|
growing browser support."
|
||||||
|
.into(),
|
||||||
|
Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic \
|
||||||
|
animation and binary transparency. Best for simple graphics and short animations."
|
||||||
|
.into(),
|
||||||
|
Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, \
|
||||||
|
supports layers and rich metadata. Very large files. Not suitable for web."
|
||||||
|
.into(),
|
||||||
|
Some(ImageFormat::Bmp) => "BMP: Uncompressed bitmap format. Very large files, no \
|
||||||
|
compression. Legacy format mainly used for compatibility with older software."
|
||||||
|
.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the per-format mapping rows based on which file types are actually loaded.
|
||||||
|
/// Uses a dedicated ListBox container that can be easily cleared and rebuilt.
|
||||||
|
fn rebuild_format_mapping(
|
||||||
|
list: >k::ListBox,
|
||||||
|
loaded_files: &[PathBuf],
|
||||||
|
job_config: &Rc<RefCell<crate::app::JobConfig>>,
|
||||||
|
) {
|
||||||
|
// Clear all existing rows from the list
|
||||||
|
list.remove_all();
|
||||||
|
|
||||||
|
// Detect which file extensions are present in loaded files
|
||||||
|
let mut seen_extensions: HashSet<String> = HashSet::new();
|
||||||
|
for path in loaded_files {
|
||||||
|
if let Some(ext) = path.extension()
|
||||||
|
&& let Some(ext_str) = ext.to_str()
|
||||||
|
{
|
||||||
|
seen_extensions.insert(ext_str.to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen_extensions.is_empty() {
|
||||||
|
// No files loaded yet - add a placeholder row
|
||||||
|
let placeholder = adw::ActionRow::builder()
|
||||||
|
.title("No files loaded")
|
||||||
|
.subtitle("Load images first to configure per-format mappings")
|
||||||
|
.build();
|
||||||
|
placeholder.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic"));
|
||||||
|
list.append(&placeholder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize extensions to canonical display names, maintaining a stable order
|
||||||
|
let mut format_entries: Vec<(String, String)> = Vec::new(); // (canonical ext, display name)
|
||||||
|
let ext_to_name: &[(&[&str], &str)] = &[
|
||||||
|
(&["jpg", "jpeg"], "JPEG"),
|
||||||
|
(&["png"], "PNG"),
|
||||||
|
(&["webp"], "WebP"),
|
||||||
|
(&["avif"], "AVIF"),
|
||||||
|
(&["gif"], "GIF"),
|
||||||
|
(&["tiff", "tif"], "TIFF"),
|
||||||
|
(&["bmp"], "BMP"),
|
||||||
|
(&["ico"], "ICO"),
|
||||||
|
(&["hdr"], "HDR"),
|
||||||
|
(&["pnm", "ppm", "pgm", "pbm"], "PNM/PPM"),
|
||||||
|
(&["tga"], "TGA"),
|
||||||
|
(&["heic", "heif"], "HEIC/HEIF"),
|
||||||
|
(&["jxl"], "JXL (JPEG XL)"),
|
||||||
|
(&["qoi"], "QOI"),
|
||||||
|
(&["exr"], "EXR"),
|
||||||
|
(&["ff", "farbfeld"], "Farbfeld"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut added_names: HashSet<String> = HashSet::new();
|
||||||
|
for (exts, display_name) in ext_to_name {
|
||||||
|
for ext in *exts {
|
||||||
|
if seen_extensions.contains(*ext) && added_names.insert(display_name.to_string()) {
|
||||||
|
// Use the first extension as canonical key
|
||||||
|
format_entries.push((exts[0].to_string(), display_name.to_string()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also handle any unknown extensions in sorted order
|
||||||
|
let mut unknown: Vec<String> = Vec::new();
|
||||||
|
for ext in &seen_extensions {
|
||||||
|
let known = ext_to_name
|
||||||
|
.iter()
|
||||||
|
.any(|(exts, _)| exts.contains(&ext.as_str()));
|
||||||
|
if !known {
|
||||||
|
unknown.push(ext.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unknown.sort();
|
||||||
|
for ext in unknown {
|
||||||
|
let upper = ext.to_uppercase();
|
||||||
|
if added_names.insert(upper.clone()) {
|
||||||
|
format_entries.push((ext, upper));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = job_config.borrow();
|
||||||
|
|
||||||
|
for (canonical_ext, display_name) in &format_entries {
|
||||||
|
let combo = adw::ComboRow::builder()
|
||||||
|
.title(format!("{} inputs", display_name))
|
||||||
|
.subtitle(format!("Output format for {} source files", display_name))
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
combo.set_model(Some(>k::StringList::new(MAPPING_CHOICES)));
|
||||||
|
combo.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
|
||||||
|
// Restore saved selection if any
|
||||||
|
let saved = cfg.format_mappings.get(canonical_ext).copied().unwrap_or(0);
|
||||||
|
combo.set_selected(saved);
|
||||||
|
|
||||||
|
// Wire signal to save selection
|
||||||
|
let jc = job_config.clone();
|
||||||
|
let ext_key = canonical_ext.clone();
|
||||||
|
combo.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut()
|
||||||
|
.format_mappings
|
||||||
|
.insert(ext_key.clone(), row.selected());
|
||||||
|
});
|
||||||
|
|
||||||
|
list.append(&combo);
|
||||||
|
}
|
||||||
|
}
|
||||||
1058
pixstrip-gtk/src/steps/step_images.rs
Normal file
296
pixstrip-gtk/src/steps/step_metadata.rs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use crate::app::{AppState, MetadataMode};
|
||||||
|
|
||||||
|
pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(24)
|
||||||
|
.margin_end(24)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.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();
|
||||||
|
enable_group.add(&enable_row);
|
||||||
|
content.append(&enable_group);
|
||||||
|
|
||||||
|
// Quick presets
|
||||||
|
let presets_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Metadata Mode")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let strip_all_row = adw::ActionRow::builder()
|
||||||
|
.title("Strip All")
|
||||||
|
.subtitle("Remove all metadata - smallest files, maximum privacy")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
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);
|
||||||
|
strip_all_row.set_activatable_widget(Some(&strip_all_check));
|
||||||
|
|
||||||
|
let privacy_row = adw::ActionRow::builder()
|
||||||
|
.title("Privacy Mode")
|
||||||
|
.subtitle("Strip GPS and camera serial, keep copyright")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
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);
|
||||||
|
privacy_row.add_suffix(&privacy_check);
|
||||||
|
privacy_row.set_activatable_widget(Some(&privacy_check));
|
||||||
|
|
||||||
|
let keep_all_row = adw::ActionRow::builder()
|
||||||
|
.title("Keep All")
|
||||||
|
.subtitle("Preserve all original metadata")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
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);
|
||||||
|
keep_all_row.add_suffix(&keep_all_check);
|
||||||
|
keep_all_row.set_activatable_widget(Some(&keep_all_check));
|
||||||
|
|
||||||
|
let photographer_row = adw::ActionRow::builder()
|
||||||
|
.title("Photographer")
|
||||||
|
.subtitle("Keep copyright and camera model, strip GPS and software")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
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);
|
||||||
|
photographer_row.set_activatable_widget(Some(&photographer_check));
|
||||||
|
|
||||||
|
let custom_row = adw::ActionRow::builder()
|
||||||
|
.title("Custom")
|
||||||
|
.subtitle("Choose exactly which metadata categories to strip")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
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);
|
||||||
|
custom_row.add_suffix(&custom_check);
|
||||||
|
custom_row.set_activatable_widget(Some(&custom_check));
|
||||||
|
|
||||||
|
presets_group.add(&strip_all_row);
|
||||||
|
presets_group.add(&privacy_row);
|
||||||
|
presets_group.add(&photographer_row);
|
||||||
|
presets_group.add(&keep_all_row);
|
||||||
|
presets_group.add(&custom_row);
|
||||||
|
content.append(&presets_group);
|
||||||
|
|
||||||
|
// Custom category checkboxes
|
||||||
|
let custom_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Custom Categories")
|
||||||
|
.description("Select which metadata categories to strip")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let gps_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
custom_group.add(&camera_row);
|
||||||
|
custom_group.add(&software_row);
|
||||||
|
custom_group.add(×tamps_row);
|
||||||
|
custom_group.add(©right_row);
|
||||||
|
|
||||||
|
// Only show custom group when Custom mode is selected
|
||||||
|
custom_group.set_visible(cfg.metadata_mode == MetadataMode::Custom);
|
||||||
|
|
||||||
|
content.append(&custom_group);
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
// Wire signals
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
enable_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().metadata_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let cg = custom_group.clone();
|
||||||
|
strip_all_check.connect_toggled(move |check| {
|
||||||
|
if check.is_active() {
|
||||||
|
jc.borrow_mut().metadata_mode = MetadataMode::StripAll;
|
||||||
|
cg.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let cg = custom_group.clone();
|
||||||
|
privacy_check.connect_toggled(move |check| {
|
||||||
|
if check.is_active() {
|
||||||
|
jc.borrow_mut().metadata_mode = MetadataMode::Privacy;
|
||||||
|
cg.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let cg = custom_group.clone();
|
||||||
|
let gps_c = gps_row.clone();
|
||||||
|
let camera_c = camera_row.clone();
|
||||||
|
let software_c = software_row.clone();
|
||||||
|
let timestamps_c = timestamps_row.clone();
|
||||||
|
let copyright_c = copyright_row.clone();
|
||||||
|
photographer_check.connect_toggled(move |check| {
|
||||||
|
if check.is_active() {
|
||||||
|
{
|
||||||
|
let mut cfg = jc.borrow_mut();
|
||||||
|
cfg.metadata_mode = MetadataMode::Custom;
|
||||||
|
// Photographer: keep copyright + camera model, strip GPS + software
|
||||||
|
cfg.strip_gps = true;
|
||||||
|
cfg.strip_camera = false;
|
||||||
|
cfg.strip_software = true;
|
||||||
|
cfg.strip_timestamps = false;
|
||||||
|
cfg.strip_copyright = false;
|
||||||
|
}
|
||||||
|
// Update UI to match (after dropping borrow to avoid re-entrancy)
|
||||||
|
gps_c.set_active(true);
|
||||||
|
camera_c.set_active(false);
|
||||||
|
software_c.set_active(true);
|
||||||
|
timestamps_c.set_active(false);
|
||||||
|
copyright_c.set_active(false);
|
||||||
|
cg.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let cg = custom_group.clone();
|
||||||
|
keep_all_check.connect_toggled(move |check| {
|
||||||
|
if check.is_active() {
|
||||||
|
jc.borrow_mut().metadata_mode = MetadataMode::KeepAll;
|
||||||
|
cg.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let cg = custom_group;
|
||||||
|
custom_check.connect_toggled(move |check| {
|
||||||
|
if check.is_active() {
|
||||||
|
jc.borrow_mut().metadata_mode = MetadataMode::Custom;
|
||||||
|
cg.set_visible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire custom category toggles
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
gps_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().strip_gps = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
camera_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().strip_camera = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
software_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().strip_software = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
timestamps_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().strip_timestamps = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
copyright_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().strip_copyright = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Metadata")
|
||||||
|
.tag("step-metadata")
|
||||||
|
.child(&scrolled)
|
||||||
|
.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
|
||||||
|
}
|
||||||
256
pixstrip-gtk/src/steps/step_output.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use crate::app::AppState;
|
||||||
|
use crate::utils::format_size;
|
||||||
|
|
||||||
|
pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(24)
|
||||||
|
.margin_end(24)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Operation summary - dynamically rebuilt when this step is shown
|
||||||
|
let summary_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Operation Summary")
|
||||||
|
.description("Review your processing settings before starting")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let summary_box = gtk::ListBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
summary_box.set_widget_name("ops-summary-list");
|
||||||
|
|
||||||
|
summary_group.add(&summary_box);
|
||||||
|
content.append(&summary_group);
|
||||||
|
|
||||||
|
// Output directory
|
||||||
|
let output_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Output Directory")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let default_output = state.output_dir.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "processed/ (subfolder next to originals)".to_string());
|
||||||
|
|
||||||
|
let output_row = adw::ActionRow::builder()
|
||||||
|
.title("Output Location")
|
||||||
|
.subtitle(&default_output)
|
||||||
|
.activatable(true)
|
||||||
|
.action_name("win.choose-output")
|
||||||
|
.build();
|
||||||
|
output_row.add_prefix(>k::Image::from_icon_name("folder-symbolic"));
|
||||||
|
|
||||||
|
let choose_button = gtk::Button::builder()
|
||||||
|
.icon_name("folder-open-symbolic")
|
||||||
|
.tooltip_text("Choose output folder")
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.action_name("win.choose-output")
|
||||||
|
.build();
|
||||||
|
choose_button.add_css_class("flat");
|
||||||
|
output_row.add_suffix(&choose_button);
|
||||||
|
output_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
|
let structure_row = adw::SwitchRow::builder()
|
||||||
|
.title("Preserve Directory Structure")
|
||||||
|
.subtitle("Keep subfolder hierarchy in output")
|
||||||
|
.active(cfg.preserve_dir_structure)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
output_group.add(&output_row);
|
||||||
|
output_group.add(&structure_row);
|
||||||
|
content.append(&output_group);
|
||||||
|
|
||||||
|
// Overwrite behavior
|
||||||
|
let overwrite_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("If Files Already Exist")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let overwrite_row = adw::ComboRow::builder()
|
||||||
|
.title("Overwrite Behavior")
|
||||||
|
.subtitle("What to do when output file already exists")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
let overwrite_model = gtk::StringList::new(&[
|
||||||
|
"Ask before overwriting",
|
||||||
|
"Auto-rename with suffix",
|
||||||
|
"Always overwrite",
|
||||||
|
"Skip existing files",
|
||||||
|
]);
|
||||||
|
overwrite_row.set_model(Some(&overwrite_model));
|
||||||
|
overwrite_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
overwrite_row.set_selected(cfg.overwrite_behavior as u32);
|
||||||
|
|
||||||
|
overwrite_group.add(&overwrite_row);
|
||||||
|
content.append(&overwrite_group);
|
||||||
|
|
||||||
|
// Image count - dynamically updated
|
||||||
|
let stats_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Batch Info")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let excluded = state.excluded_files.borrow();
|
||||||
|
let files = state.loaded_files.borrow();
|
||||||
|
let included_count = files.iter().filter(|p| !excluded.contains(*p)).count();
|
||||||
|
let total_size: u64 = files.iter()
|
||||||
|
.filter(|p| !excluded.contains(*p))
|
||||||
|
.filter_map(|p| std::fs::metadata(p).ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum();
|
||||||
|
drop(files);
|
||||||
|
drop(excluded);
|
||||||
|
|
||||||
|
let count_row = adw::ActionRow::builder()
|
||||||
|
.title("Images to process")
|
||||||
|
.subtitle(format!("{} images ({})", included_count, format_size(total_size)))
|
||||||
|
.build();
|
||||||
|
count_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
||||||
|
|
||||||
|
stats_group.add(&count_row);
|
||||||
|
content.append(&stats_group);
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
// Wire preserve directory structure
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
structure_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().preserve_dir_structure = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire overwrite behavior
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
overwrite_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().overwrite_behavior = row.selected() as u8;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Output & Process")
|
||||||
|
.tag("step-output")
|
||||||
|
.child(&scrolled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Refresh stats and summary when navigating to this page
|
||||||
|
{
|
||||||
|
let lf = state.loaded_files.clone();
|
||||||
|
let ef = state.excluded_files.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let od = state.output_dir.clone();
|
||||||
|
let cr = count_row.clone();
|
||||||
|
let or = output_row.clone();
|
||||||
|
let sb = summary_box.clone();
|
||||||
|
page.connect_map(move |_| {
|
||||||
|
// Update image count and size
|
||||||
|
let files = lf.borrow();
|
||||||
|
let excluded = ef.borrow();
|
||||||
|
let included_count = files.iter().filter(|p| !excluded.contains(*p)).count();
|
||||||
|
let total_size: u64 = files.iter()
|
||||||
|
.filter(|p| !excluded.contains(*p))
|
||||||
|
.filter_map(|p| std::fs::metadata(p).ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum();
|
||||||
|
cr.set_subtitle(&format!("{} images ({})", included_count, format_size(total_size)));
|
||||||
|
drop(files);
|
||||||
|
drop(excluded);
|
||||||
|
|
||||||
|
// Update output directory display
|
||||||
|
let dir_text = od.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "processed/ (subfolder next to originals)".to_string());
|
||||||
|
or.set_subtitle(&dir_text);
|
||||||
|
|
||||||
|
// Build operation summary
|
||||||
|
while let Some(child) = sb.first_child() {
|
||||||
|
sb.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = jc.borrow();
|
||||||
|
let mut ops: Vec<(&str, String)> = Vec::new();
|
||||||
|
|
||||||
|
if cfg.resize_enabled {
|
||||||
|
let mode = match cfg.resize_mode {
|
||||||
|
0 => format!("{}x{} (exact)", cfg.resize_width, cfg.resize_height),
|
||||||
|
_ => format!("fit {}x{}", cfg.resize_width, cfg.resize_height),
|
||||||
|
};
|
||||||
|
ops.push(("Resize", mode));
|
||||||
|
}
|
||||||
|
if cfg.adjustments_enabled {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if cfg.rotation > 0 { parts.push("rotate"); }
|
||||||
|
if cfg.flip > 0 { parts.push("flip"); }
|
||||||
|
if cfg.brightness != 0 { parts.push("brightness"); }
|
||||||
|
if cfg.contrast != 0 { parts.push("contrast"); }
|
||||||
|
if cfg.saturation != 0 { parts.push("saturation"); }
|
||||||
|
if cfg.grayscale { parts.push("grayscale"); }
|
||||||
|
if cfg.sepia { parts.push("sepia"); }
|
||||||
|
if cfg.sharpen { parts.push("sharpen"); }
|
||||||
|
if cfg.crop_aspect_ratio > 0 { parts.push("crop"); }
|
||||||
|
if cfg.trim_whitespace { parts.push("trim"); }
|
||||||
|
if cfg.canvas_padding > 0 { parts.push("padding"); }
|
||||||
|
let desc = if parts.is_empty() { "enabled".into() } else { parts.join(", ") };
|
||||||
|
ops.push(("Adjustments", desc));
|
||||||
|
}
|
||||||
|
if cfg.convert_enabled {
|
||||||
|
let fmt = cfg.convert_format.map(|f| f.extension().to_uppercase())
|
||||||
|
.unwrap_or_else(|| "per-format mapping".into());
|
||||||
|
ops.push(("Convert", fmt));
|
||||||
|
}
|
||||||
|
if cfg.compress_enabled {
|
||||||
|
ops.push(("Compress", cfg.quality_preset.label().into()));
|
||||||
|
}
|
||||||
|
if cfg.metadata_enabled {
|
||||||
|
let mode = match &cfg.metadata_mode {
|
||||||
|
crate::app::MetadataMode::StripAll => "strip all",
|
||||||
|
crate::app::MetadataMode::KeepAll => "keep all",
|
||||||
|
crate::app::MetadataMode::Privacy => "privacy mode",
|
||||||
|
crate::app::MetadataMode::Custom => "custom",
|
||||||
|
};
|
||||||
|
ops.push(("Metadata", mode.into()));
|
||||||
|
}
|
||||||
|
if cfg.watermark_enabled {
|
||||||
|
let wm_type = if cfg.watermark_use_image { "image" } else { "text" };
|
||||||
|
ops.push(("Watermark", wm_type.into()));
|
||||||
|
}
|
||||||
|
if cfg.rename_enabled {
|
||||||
|
ops.push(("Rename", "enabled".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ops.is_empty() {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title("No operations enabled")
|
||||||
|
.subtitle("Go back and enable at least one operation")
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name("dialog-warning-symbolic"));
|
||||||
|
sb.append(&row);
|
||||||
|
} else {
|
||||||
|
for (name, desc) in &ops {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(*name)
|
||||||
|
.subtitle(desc.as_str())
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name("emblem-ok-symbolic"));
|
||||||
|
sb.append(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page
|
||||||
|
}
|
||||||
901
pixstrip-gtk/src/steps/step_rename.rs
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
|
// === OUTER LAYOUT ===
|
||||||
|
let outer = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(0)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Enable toggle (full width) ---
|
||||||
|
let enable_group = adw::PreferencesGroup::builder()
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.build();
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// === LEFT SIDE: Preview ===
|
||||||
|
|
||||||
|
let show_all: Rc<Cell<bool>> = Rc::new(Cell::new(false));
|
||||||
|
|
||||||
|
let preview_header = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let showing_label = gtk::Label::builder()
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let show_all_button = gtk::Button::builder()
|
||||||
|
.label("Show all")
|
||||||
|
.build();
|
||||||
|
show_all_button.add_css_class("pill");
|
||||||
|
show_all_button.add_css_class("caption");
|
||||||
|
|
||||||
|
preview_header.append(&showing_label);
|
||||||
|
preview_header.append(&show_all_button);
|
||||||
|
|
||||||
|
// Preview rows container
|
||||||
|
let preview_rows = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(2)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let preview_scroll = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&preview_rows)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Conflict banner
|
||||||
|
let conflict_banner = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.margin_top(8)
|
||||||
|
.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")
|
||||||
|
.margin_start(8)
|
||||||
|
.margin_top(4)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.build();
|
||||||
|
conflict_icon.add_css_class("warning");
|
||||||
|
|
||||||
|
let conflict_label = gtk::Label::builder()
|
||||||
|
.css_classes(["caption"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.hexpand(true)
|
||||||
|
.wrap(true)
|
||||||
|
.margin_top(4)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.margin_end(8)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
conflict_banner.append(&conflict_icon);
|
||||||
|
conflict_banner.append(&conflict_label);
|
||||||
|
|
||||||
|
// Stats label
|
||||||
|
let stats_label = gtk::Label::builder()
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.wrap(true)
|
||||||
|
.margin_top(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let preview_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(4)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
preview_box.append(&preview_header);
|
||||||
|
preview_box.append(&preview_scroll);
|
||||||
|
preview_box.append(&conflict_banner);
|
||||||
|
preview_box.append(&stats_label);
|
||||||
|
|
||||||
|
// === RIGHT SIDE: Controls (scrollable) ===
|
||||||
|
|
||||||
|
let controls = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Reset button at end of controls (added later)
|
||||||
|
let reset_button = gtk::Button::builder()
|
||||||
|
.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");
|
||||||
|
|
||||||
|
// --- Simple Rename group ---
|
||||||
|
let simple_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Simple Rename")
|
||||||
|
.description("Add prefix, suffix, and text transformations")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
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()
|
||||||
|
.title("Replace Spaces")
|
||||||
|
.subtitle("How to handle spaces in filenames")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
replace_spaces_row.set_model(Some(>k::StringList::new(&[
|
||||||
|
"No change",
|
||||||
|
"Underscores (_)",
|
||||||
|
"Hyphens (-)",
|
||||||
|
"Dots (.)",
|
||||||
|
"CamelCase",
|
||||||
|
"Remove spaces",
|
||||||
|
])));
|
||||||
|
replace_spaces_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
replace_spaces_row.set_selected(cfg.rename_replace_spaces);
|
||||||
|
|
||||||
|
let special_chars_row = adw::ComboRow::builder()
|
||||||
|
.title("Special Characters")
|
||||||
|
.subtitle("Filter non-standard characters from filenames")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
special_chars_row.set_model(Some(>k::StringList::new(&[
|
||||||
|
"Keep all",
|
||||||
|
"Filesystem-safe (remove / \\ : * ? \" < > |)",
|
||||||
|
"Web-safe (a-z, 0-9, -, _, .)",
|
||||||
|
"Hyphens + underscores (a-z, 0-9, -, _)",
|
||||||
|
"Hyphens only (a-z, 0-9, -)",
|
||||||
|
"Alphanumeric only (a-z, 0-9)",
|
||||||
|
])));
|
||||||
|
special_chars_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
special_chars_row.set_selected(cfg.rename_special_chars);
|
||||||
|
|
||||||
|
let case_row = adw::ComboRow::builder()
|
||||||
|
.title("Case Conversion")
|
||||||
|
.subtitle("Convert filename case")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
case_row.set_model(Some(>k::StringList::new(&["No change", "lowercase", "UPPERCASE", "Title Case"])));
|
||||||
|
case_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
case_row.set_selected(cfg.rename_case);
|
||||||
|
|
||||||
|
// Counter toggle + expandable sub-options
|
||||||
|
let counter_row = adw::ExpanderRow::builder()
|
||||||
|
.title("Add Sequential Counter")
|
||||||
|
.subtitle("Append a numbered sequence to filenames")
|
||||||
|
.show_enable_switch(true)
|
||||||
|
.enable_expansion(cfg.rename_counter_enabled)
|
||||||
|
.expanded(cfg.rename_counter_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let counter_position_row = adw::ComboRow::builder()
|
||||||
|
.title("Counter Position")
|
||||||
|
.subtitle("Where the counter number appears")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
counter_position_row.set_model(Some(>k::StringList::new(&[
|
||||||
|
"Before prefix",
|
||||||
|
"Before name",
|
||||||
|
"After name",
|
||||||
|
"After suffix",
|
||||||
|
"Replace name",
|
||||||
|
])));
|
||||||
|
counter_position_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
counter_position_row.set_selected(cfg.rename_counter_position);
|
||||||
|
|
||||||
|
let counter_start_row = adw::SpinRow::builder()
|
||||||
|
.title("Counter Start")
|
||||||
|
.subtitle("First number in sequence")
|
||||||
|
.adjustment(>k::Adjustment::new(cfg.rename_counter_start as f64, 0.0, 99999.0, 1.0, 10.0, 0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let counter_padding_row = adw::SpinRow::builder()
|
||||||
|
.title("Counter Padding")
|
||||||
|
.subtitle("Minimum digits (e.g., 3 = 001, 002, 003)")
|
||||||
|
.adjustment(>k::Adjustment::new(cfg.rename_counter_padding as f64, 1.0, 10.0, 1.0, 1.0, 0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
counter_row.add_row(&counter_position_row);
|
||||||
|
counter_row.add_row(&counter_start_row);
|
||||||
|
counter_row.add_row(&counter_padding_row);
|
||||||
|
|
||||||
|
simple_group.add(&prefix_row);
|
||||||
|
simple_group.add(&suffix_row);
|
||||||
|
simple_group.add(&replace_spaces_row);
|
||||||
|
simple_group.add(&special_chars_row);
|
||||||
|
simple_group.add(&case_row);
|
||||||
|
simple_group.add(&counter_row);
|
||||||
|
controls.append(&simple_group);
|
||||||
|
|
||||||
|
// --- Advanced group ---
|
||||||
|
let advanced_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Advanced")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let advanced_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Template Engine and Find/Replace")
|
||||||
|
.subtitle("Advanced rename options with variables and regex")
|
||||||
|
.show_enable_switch(false)
|
||||||
|
.expanded(state.is_section_expanded("rename-advanced"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
{
|
||||||
|
let st = state.clone();
|
||||||
|
advanced_expander.connect_expanded_notify(move |row| {
|
||||||
|
st.set_section_expanded("rename-advanced", row.is_expanded());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
let preset_templates = [
|
||||||
|
("Date + Name", "{date}_{name}"),
|
||||||
|
("EXIF Date + Name", "{exif_date}_{name}"),
|
||||||
|
("Sequential", "{name}_{counter:4}"),
|
||||||
|
("Dimensions", "{name}_{width}x{height}"),
|
||||||
|
("Camera + Date", "{camera}_{exif_date}_{counter:3}"),
|
||||||
|
("Web-safe", "{name}_web"),
|
||||||
|
("Date + Counter", "{date}_{counter:4}"),
|
||||||
|
("Name + Size", "{name}_{width}x{height}_{counter:3}"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let presets_box = gtk::FlowBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.max_children_per_line(6)
|
||||||
|
.min_children_per_line(2)
|
||||||
|
.row_spacing(4)
|
||||||
|
.column_spacing(4)
|
||||||
|
.margin_top(4)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.homogeneous(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for (label, template) in &preset_templates {
|
||||||
|
let btn = gtk::Button::builder()
|
||||||
|
.label(*label)
|
||||||
|
.tooltip_text(*template)
|
||||||
|
.build();
|
||||||
|
btn.add_css_class("flat");
|
||||||
|
|
||||||
|
let tr = template_row.clone();
|
||||||
|
let tmpl = template.to_string();
|
||||||
|
btn.connect_clicked(move |_| {
|
||||||
|
tr.set_text(&tmpl);
|
||||||
|
tr.set_position(tmpl.chars().count() as i32);
|
||||||
|
});
|
||||||
|
presets_box.append(&btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let presets_list_row = gtk::ListBoxRow::builder()
|
||||||
|
.activatable(false)
|
||||||
|
.selectable(false)
|
||||||
|
.child(&presets_box)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Variable chips (collapsible)
|
||||||
|
let variables_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Available Variables")
|
||||||
|
.subtitle("Click to insert at cursor position in template")
|
||||||
|
.show_enable_switch(false)
|
||||||
|
.expanded(state.is_section_expanded("rename-variables"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
{
|
||||||
|
let st = state.clone();
|
||||||
|
variables_expander.connect_expanded_notify(move |row| {
|
||||||
|
st.set_section_expanded("rename-variables", row.is_expanded());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let variables = [
|
||||||
|
("{name}", "Original filename (no extension)"),
|
||||||
|
("{ext}", "Output file extension"),
|
||||||
|
("{original_ext}", "Original file extension"),
|
||||||
|
("{counter}", "Sequential counter number"),
|
||||||
|
("{counter:3}", "Counter, zero-padded to 3 digits"),
|
||||||
|
("{counter:4}", "Counter, zero-padded to 4 digits"),
|
||||||
|
("{date}", "Today's date (YYYY-MM-DD)"),
|
||||||
|
("{exif_date}", "Date photo was taken (from EXIF)"),
|
||||||
|
("{camera}", "Camera model (from EXIF)"),
|
||||||
|
("{width}", "Output image width in pixels"),
|
||||||
|
("{height}", "Output image height in pixels"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let vars_flow = gtk::FlowBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.max_children_per_line(3)
|
||||||
|
.min_children_per_line(1)
|
||||||
|
.row_spacing(4)
|
||||||
|
.column_spacing(4)
|
||||||
|
.margin_top(4)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.homogeneous(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for (var_name, description) in &variables {
|
||||||
|
let chip_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(1)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let name_label = gtk::Label::builder()
|
||||||
|
.label(*var_name)
|
||||||
|
.css_classes(["monospace", "caption"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let desc_label = gtk::Label::builder()
|
||||||
|
.label(*description)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
desc_label.add_css_class("caption");
|
||||||
|
|
||||||
|
chip_box.append(&name_label);
|
||||||
|
chip_box.append(&desc_label);
|
||||||
|
|
||||||
|
let btn = gtk::Button::builder()
|
||||||
|
.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();
|
||||||
|
btn.connect_clicked(move |_| {
|
||||||
|
let current = tr.text().to_string();
|
||||||
|
if current.is_empty() {
|
||||||
|
tr.set_text(&var_text);
|
||||||
|
tr.set_position(var_text.chars().count() as i32);
|
||||||
|
} else {
|
||||||
|
let char_count = current.chars().count();
|
||||||
|
let pos = (tr.position().max(0) as usize).min(char_count);
|
||||||
|
let byte_pos = current.char_indices()
|
||||||
|
.nth(pos)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(current.len());
|
||||||
|
let mut new_text = current.clone();
|
||||||
|
new_text.insert_str(byte_pos, &var_text);
|
||||||
|
tr.set_text(&new_text);
|
||||||
|
tr.set_position((pos.saturating_add(var_text.chars().count())) as i32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vars_flow.append(&btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vars_list_row = gtk::ListBoxRow::builder()
|
||||||
|
.activatable(false)
|
||||||
|
.selectable(false)
|
||||||
|
.child(&vars_flow)
|
||||||
|
.build();
|
||||||
|
variables_expander.add_row(&vars_list_row);
|
||||||
|
|
||||||
|
// Find/replace
|
||||||
|
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);
|
||||||
|
advanced_expander.add_row(&presets_list_row);
|
||||||
|
advanced_expander.add_row(&find_row);
|
||||||
|
advanced_expander.add_row(&replace_row);
|
||||||
|
|
||||||
|
advanced_group.add(&advanced_expander);
|
||||||
|
advanced_group.add(&variables_expander);
|
||||||
|
controls.append(&advanced_group);
|
||||||
|
controls.append(&reset_button);
|
||||||
|
|
||||||
|
// Scrollable controls
|
||||||
|
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.width_request(420)
|
||||||
|
.child(&controls)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// === Main layout: 60/40 side-by-side ===
|
||||||
|
let main_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
preview_box.set_width_request(400);
|
||||||
|
main_box.append(&preview_box);
|
||||||
|
main_box.append(&controls_scrolled);
|
||||||
|
outer.append(&main_box);
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
// === Preview update closure ===
|
||||||
|
let update_preview = {
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let rows_box = preview_rows.clone();
|
||||||
|
let showing = showing_label.clone();
|
||||||
|
let show_all_state = show_all.clone();
|
||||||
|
let conflict_banner_c = conflict_banner.clone();
|
||||||
|
let conflict_label_c = conflict_label.clone();
|
||||||
|
let stats = stats_label.clone();
|
||||||
|
let show_btn = show_all_button.clone();
|
||||||
|
|
||||||
|
Rc::new(move || {
|
||||||
|
let loaded = files.borrow();
|
||||||
|
let cfg = jc.borrow();
|
||||||
|
let total = loaded.len();
|
||||||
|
|
||||||
|
// Clear existing rows
|
||||||
|
while let Some(child) = rows_box.first_child() {
|
||||||
|
rows_box.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
showing.set_label("No images loaded");
|
||||||
|
stats.set_label("");
|
||||||
|
conflict_banner_c.set_visible(false);
|
||||||
|
show_btn.set_visible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_count = if show_all_state.get() { total } else { total.min(5) };
|
||||||
|
show_btn.set_visible(total > 5);
|
||||||
|
if show_all_state.get() {
|
||||||
|
show_btn.set_label("Show less");
|
||||||
|
showing.set_label(&format!("All {} files", total));
|
||||||
|
} else {
|
||||||
|
show_btn.set_label("Show all");
|
||||||
|
showing.set_label(&format!("Showing {} of {} files", display_count, total));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute all renames for conflict detection
|
||||||
|
let mut all_results: Vec<String> = Vec::with_capacity(total);
|
||||||
|
let mut ext_counts: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut longest_name = 0usize;
|
||||||
|
|
||||||
|
// Pre-compile regex once for the entire batch (avoids recompiling per file)
|
||||||
|
let rename_cfg = pixstrip_core::operations::RenameConfig {
|
||||||
|
prefix: cfg.rename_prefix.clone(),
|
||||||
|
suffix: cfg.rename_suffix.clone(),
|
||||||
|
counter_start: cfg.rename_counter_start,
|
||||||
|
counter_padding: cfg.rename_counter_padding,
|
||||||
|
counter_enabled: cfg.rename_counter_enabled,
|
||||||
|
counter_position: cfg.rename_counter_position,
|
||||||
|
template: None,
|
||||||
|
case_mode: cfg.rename_case,
|
||||||
|
replace_spaces: cfg.rename_replace_spaces,
|
||||||
|
special_chars: cfg.rename_special_chars,
|
||||||
|
regex_find: cfg.rename_find.clone(),
|
||||||
|
regex_replace: cfg.rename_replace.clone(),
|
||||||
|
};
|
||||||
|
let compiled_re = rename_cfg.compile_regex();
|
||||||
|
|
||||||
|
for (i, path) in loaded.iter().enumerate() {
|
||||||
|
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||||
|
|
||||||
|
*ext_counts.entry(ext.to_string()).or_insert(0) += 1;
|
||||||
|
|
||||||
|
let result = if !cfg.rename_template.is_empty() {
|
||||||
|
let counter = cfg.rename_counter_start + i as u32;
|
||||||
|
pixstrip_core::operations::rename::apply_template(
|
||||||
|
&cfg.rename_template, name, ext, counter, None,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rename_cfg.apply_simple_compiled(name, ext, (i + 1) as u32, compiled_re.as_ref())
|
||||||
|
};
|
||||||
|
|
||||||
|
if result.len() > longest_name {
|
||||||
|
longest_name = result.len();
|
||||||
|
}
|
||||||
|
all_results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect conflicts
|
||||||
|
let mut name_counts: HashMap<&str, usize> = HashMap::new();
|
||||||
|
for result in &all_results {
|
||||||
|
*name_counts.entry(result.as_str()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
let conflicts: usize = name_counts.values().filter(|&&c| c > 1).map(|c| c).sum();
|
||||||
|
|
||||||
|
if conflicts > 0 {
|
||||||
|
conflict_banner_c.set_visible(true);
|
||||||
|
let dupes: Vec<&str> = name_counts.iter()
|
||||||
|
.filter(|(_, c)| **c > 1)
|
||||||
|
.take(3)
|
||||||
|
.map(|(n, _)| *n)
|
||||||
|
.collect();
|
||||||
|
let dupe_list = dupes.join(", ");
|
||||||
|
let msg = if name_counts.values().filter(|c| **c > 1).count() > 3 {
|
||||||
|
format!("{} files have duplicate names (e.g. {}, ...)", conflicts, dupe_list)
|
||||||
|
} else {
|
||||||
|
format!("{} files have duplicate names: {}", conflicts, dupe_list)
|
||||||
|
};
|
||||||
|
conflict_label_c.set_label(&msg);
|
||||||
|
} else {
|
||||||
|
conflict_banner_c.set_visible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build preview rows (vertical: original on top, new name below)
|
||||||
|
for (i, path) in loaded.iter().take(display_count).enumerate() {
|
||||||
|
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||||
|
|
||||||
|
let new_full = &all_results[i];
|
||||||
|
|
||||||
|
let row = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(1)
|
||||||
|
.margin_top(3)
|
||||||
|
.margin_bottom(3)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let orig_label = gtk::Label::builder()
|
||||||
|
.label(&format!("{}.{}", name, ext))
|
||||||
|
.css_classes(["monospace", "caption", "dim-label"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.ellipsize(gtk::pango::EllipsizeMode::Middle)
|
||||||
|
.max_width_chars(50)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let new_line = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let arrow_label = gtk::Label::builder()
|
||||||
|
.label("->")
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let new_name_label = gtk::Label::builder()
|
||||||
|
.label(new_full.as_str())
|
||||||
|
.css_classes(["monospace", "caption"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.hexpand(true)
|
||||||
|
.ellipsize(gtk::pango::EllipsizeMode::Middle)
|
||||||
|
.max_width_chars(50)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
new_line.append(&new_name_label);
|
||||||
|
|
||||||
|
row.append(&orig_label);
|
||||||
|
row.append(&new_line);
|
||||||
|
rows_box.append(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
let mut ext_parts: Vec<String> = ext_counts
|
||||||
|
.iter()
|
||||||
|
.map(|(e, c)| format!("{} .{}", c, e))
|
||||||
|
.collect();
|
||||||
|
ext_parts.sort();
|
||||||
|
|
||||||
|
stats.set_label(&format!(
|
||||||
|
"{} files | {} conflicts | {} | Longest: {} chars",
|
||||||
|
total,
|
||||||
|
conflicts,
|
||||||
|
ext_parts.join(", "),
|
||||||
|
longest_name,
|
||||||
|
));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced wrapper for text entry handlers (150ms)
|
||||||
|
let debounce_source: Rc<Cell<Option<gtk::glib::SourceId>>> = Rc::new(Cell::new(None));
|
||||||
|
let debounced_preview = {
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let ds = debounce_source.clone();
|
||||||
|
Rc::new(move || {
|
||||||
|
if let Some(id) = ds.take() {
|
||||||
|
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 || {
|
||||||
|
ds2.set(None);
|
||||||
|
up2();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ds.set(Some(id));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call once for initial state
|
||||||
|
update_preview();
|
||||||
|
|
||||||
|
// === Wire signals ===
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
enable_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show all toggle
|
||||||
|
{
|
||||||
|
let sa = show_all.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
show_all_button.connect_clicked(move |_| {
|
||||||
|
sa.set(!sa.get());
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let prefix_r = prefix_row.clone();
|
||||||
|
let suffix_r = suffix_row.clone();
|
||||||
|
let spaces_r = replace_spaces_row.clone();
|
||||||
|
let special_r = special_chars_row.clone();
|
||||||
|
let case_r = case_row.clone();
|
||||||
|
let counter_r = counter_row.clone();
|
||||||
|
let counter_pos_r = counter_position_row.clone();
|
||||||
|
let counter_start_r = counter_start_row.clone();
|
||||||
|
let counter_pad_r = counter_padding_row.clone();
|
||||||
|
let template_r = template_row.clone();
|
||||||
|
let find_r = find_row.clone();
|
||||||
|
let replace_r = replace_row.clone();
|
||||||
|
|
||||||
|
reset_button.connect_clicked(move |_| {
|
||||||
|
{
|
||||||
|
let mut cfg = jc.borrow_mut();
|
||||||
|
cfg.rename_prefix = String::new();
|
||||||
|
cfg.rename_suffix = String::new();
|
||||||
|
cfg.rename_counter_enabled = false;
|
||||||
|
cfg.rename_counter_start = 1;
|
||||||
|
cfg.rename_counter_padding = 3;
|
||||||
|
cfg.rename_counter_position = 3;
|
||||||
|
cfg.rename_replace_spaces = 0;
|
||||||
|
cfg.rename_special_chars = 0;
|
||||||
|
cfg.rename_case = 0;
|
||||||
|
cfg.rename_template = String::new();
|
||||||
|
cfg.rename_find = String::new();
|
||||||
|
cfg.rename_replace = String::new();
|
||||||
|
}
|
||||||
|
prefix_r.set_text("");
|
||||||
|
suffix_r.set_text("");
|
||||||
|
spaces_r.set_selected(0);
|
||||||
|
special_r.set_selected(0);
|
||||||
|
case_r.set_selected(0);
|
||||||
|
counter_r.set_enable_expansion(false);
|
||||||
|
counter_r.set_expanded(false);
|
||||||
|
counter_pos_r.set_selected(3);
|
||||||
|
counter_start_r.set_value(1.0);
|
||||||
|
counter_pad_r.set_value(3.0);
|
||||||
|
template_r.set_text("");
|
||||||
|
find_r.set_text("");
|
||||||
|
replace_r.set_text("");
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = debounced_preview.clone();
|
||||||
|
prefix_row.connect_changed(move |row| {
|
||||||
|
jc.borrow_mut().rename_prefix = row.text().to_string();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suffix
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = debounced_preview.clone();
|
||||||
|
suffix_row.connect_changed(move |row| {
|
||||||
|
jc.borrow_mut().rename_suffix = row.text().to_string();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace spaces
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
replace_spaces_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_replace_spaces = row.selected();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special chars
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
special_chars_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_special_chars = row.selected();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case conversion
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
case_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_case = row.selected();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter enable
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
counter_row.connect_enable_expansion_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_counter_enabled = row.enables_expansion();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter position
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
counter_position_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_counter_position = row.selected();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter start
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
counter_start_row.connect_value_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_counter_start = row.value() as u32;
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter padding
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
counter_padding_row.connect_value_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_counter_padding = row.value() as u32;
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = debounced_preview.clone();
|
||||||
|
template_row.connect_changed(move |row| {
|
||||||
|
jc.borrow_mut().rename_template = row.text().to_string();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find regex
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = debounced_preview.clone();
|
||||||
|
find_row.connect_changed(move |row| {
|
||||||
|
let text = row.text().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
if regex::Regex::new(&text).is_err() {
|
||||||
|
row.add_css_class("error");
|
||||||
|
} else {
|
||||||
|
row.remove_css_class("error");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.remove_css_class("error");
|
||||||
|
}
|
||||||
|
jc.borrow_mut().rename_find = text;
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = debounced_preview.clone();
|
||||||
|
replace_row.connect_changed(move |row| {
|
||||||
|
jc.borrow_mut().rename_replace = row.text().to_string();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Rename")
|
||||||
|
.tag("step-rename")
|
||||||
|
.child(&outer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page
|
||||||
|
}
|
||||||
949
pixstrip-gtk/src/steps/step_resize.rs
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
fn get_first_image_aspect(files: &[std::path::PathBuf]) -> f64 {
|
||||||
|
let Some(first) = files.first() else { return 0.0 };
|
||||||
|
let Ok((w, h)) = image::image_dimensions(first) else { return 0.0 };
|
||||||
|
if h == 0 { return 0.0; }
|
||||||
|
w as f64 / h as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_image_dims(path: &std::path::Path) -> (u32, u32) {
|
||||||
|
image::image_dimensions(path).unwrap_or((4000, 3000))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_first_image_dims(files: &[std::path::PathBuf]) -> (u32, u32) {
|
||||||
|
let Some(first) = files.first() else { return (4000, 3000) };
|
||||||
|
get_image_dims(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn algo_index_to_filter(idx: u32) -> image::imageops::FilterType {
|
||||||
|
match idx {
|
||||||
|
1 => image::imageops::FilterType::CatmullRom,
|
||||||
|
2 => image::imageops::FilterType::Triangle,
|
||||||
|
3 => image::imageops::FilterType::Nearest,
|
||||||
|
_ => image::imageops::FilterType::Lanczos3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: &[&str] = &[
|
||||||
|
"Fediverse / Open Platforms",
|
||||||
|
"Mainstream Platforms",
|
||||||
|
"Common / Web",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn presets_for_category(cat: u32) -> &'static [(&'static str, u32, u32)] {
|
||||||
|
match cat {
|
||||||
|
0 => &[
|
||||||
|
("Mastodon Post (1920 x 1080)", 1920, 1080),
|
||||||
|
("Mastodon Profile (400 x 400)", 400, 400),
|
||||||
|
("Mastodon Header (1500 x 500)", 1500, 500),
|
||||||
|
("Pixelfed Post (1080 x 1080)", 1080, 1080),
|
||||||
|
("Pixelfed Story (1080 x 1920)", 1080, 1920),
|
||||||
|
("Bluesky Post (1200 x 630)", 1200, 630),
|
||||||
|
("Bluesky Profile (400 x 400)", 400, 400),
|
||||||
|
("Bluesky Banner (1500 x 500)", 1500, 500),
|
||||||
|
("Lemmy Post (1200 x 630)", 1200, 630),
|
||||||
|
("PeerTube Thumbnail (1280 x 720)", 1280, 720),
|
||||||
|
("Friendica Post (1200 x 630)", 1200, 630),
|
||||||
|
("Funkwhale Cover (1400 x 1400)", 1400, 1400),
|
||||||
|
],
|
||||||
|
1 => &[
|
||||||
|
("Instagram Post Square (1080 x 1080)", 1080, 1080),
|
||||||
|
("Instagram Post Portrait (1080 x 1350)", 1080, 1350),
|
||||||
|
("Instagram Story/Reel (1080 x 1920)", 1080, 1920),
|
||||||
|
("Facebook Post (1200 x 630)", 1200, 630),
|
||||||
|
("Facebook Cover (820 x 312)", 820, 312),
|
||||||
|
("Facebook Profile (170 x 170)", 170, 170),
|
||||||
|
("YouTube Thumbnail (1280 x 720)", 1280, 720),
|
||||||
|
("YouTube Channel Art (2560 x 1440)", 2560, 1440),
|
||||||
|
("LinkedIn Post (1200 x 627)", 1200, 627),
|
||||||
|
("LinkedIn Cover (1584 x 396)", 1584, 396),
|
||||||
|
("LinkedIn Profile (400 x 400)", 400, 400),
|
||||||
|
("Pinterest Pin (1000 x 1500)", 1000, 1500),
|
||||||
|
("TikTok Video Cover (1080 x 1920)", 1080, 1920),
|
||||||
|
("Threads Post (1080 x 1080)", 1080, 1080),
|
||||||
|
],
|
||||||
|
_ => &[
|
||||||
|
("4K UHD (3840 x 2160)", 3840, 2160),
|
||||||
|
("Full HD (1920 x 1080)", 1920, 1080),
|
||||||
|
("HD Ready (1280 x 720)", 1280, 720),
|
||||||
|
("Blog Standard (800 wide)", 800, 0),
|
||||||
|
("Email Header (600 x 200)", 600, 200),
|
||||||
|
("Large Thumbnail (300 x 300)", 300, 300),
|
||||||
|
("Small Thumbnail (150 x 150)", 150, 150),
|
||||||
|
("Favicon (32 x 32)", 32, 32),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_size_model(size_row: &adw::ComboRow, cat: u32) {
|
||||||
|
let presets = presets_for_category(cat);
|
||||||
|
let mut names: Vec<&str> = vec!["(select a size)"];
|
||||||
|
names.extend(presets.iter().map(|p| p.0));
|
||||||
|
size_row.set_model(Some(>k::StringList::new(&names)));
|
||||||
|
size_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
size_row.set_selected(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
|
// === OUTER LAYOUT ===
|
||||||
|
let outer = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(0)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Enable toggle (full width) ---
|
||||||
|
let enable_group = adw::PreferencesGroup::builder()
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.build();
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// --- Horizontal split: Preview (left 60%) | Controls (right 40%) ---
|
||||||
|
let split = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// ========== LEFT: Preview ==========
|
||||||
|
let preview_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(8)
|
||||||
|
.hexpand(true)
|
||||||
|
.valign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let thumb_picture = gtk::Picture::builder()
|
||||||
|
.content_fit(gtk::ContentFit::Contain)
|
||||||
|
.height_request(250)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
thumb_picture.add_css_class("card");
|
||||||
|
thumb_picture.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Resize preview - click to cycle images"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let dims_label = gtk::Label::builder()
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_top(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let no_preview_label = gtk::Label::builder()
|
||||||
|
.label("Add images to see resize preview")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
preview_box.append(&thumb_picture);
|
||||||
|
preview_box.append(&dims_label);
|
||||||
|
preview_box.append(&no_preview_label);
|
||||||
|
|
||||||
|
// ========== RIGHT: Controls ==========
|
||||||
|
let controls_scroll = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.width_request(340)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let controls = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Group 1: Preset (two-level: category then size) ---
|
||||||
|
let preset_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Preset")
|
||||||
|
.description("Pick a category, then a size")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
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()));
|
||||||
|
|
||||||
|
let size_row = adw::ComboRow::builder()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
preset_group.add(&category_row);
|
||||||
|
preset_group.add(&size_row);
|
||||||
|
controls.append(&preset_group);
|
||||||
|
|
||||||
|
// --- Group 2: Dimensions ---
|
||||||
|
let dims_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Dimensions")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Custom horizontal row: [W] [width_spin] [lock] [height_spin] [H] [px|%]
|
||||||
|
let dim_row = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(6)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(10)
|
||||||
|
.margin_bottom(10)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let w_label = gtk::Label::builder()
|
||||||
|
.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,
|
||||||
|
))
|
||||||
|
.numeric(true)
|
||||||
|
.width_chars(6)
|
||||||
|
.tooltip_text("Width")
|
||||||
|
.build();
|
||||||
|
width_spin.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Width"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let lock_btn = gtk::ToggleButton::builder()
|
||||||
|
.icon_name("changes-prevent-symbolic")
|
||||||
|
.active(true)
|
||||||
|
.tooltip_text("Aspect ratio locked")
|
||||||
|
.build();
|
||||||
|
lock_btn.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Lock aspect ratio"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let height_spin = gtk::SpinButton::builder()
|
||||||
|
.adjustment(>k::Adjustment::new(
|
||||||
|
cfg.resize_height as f64, 0.0, 10000.0, 1.0, 100.0, 0.0,
|
||||||
|
))
|
||||||
|
.numeric(true)
|
||||||
|
.width_chars(6)
|
||||||
|
.tooltip_text("Height")
|
||||||
|
.build();
|
||||||
|
height_spin.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Height"),
|
||||||
|
]);
|
||||||
|
let h_label = gtk::Label::builder()
|
||||||
|
.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");
|
||||||
|
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);
|
||||||
|
|
||||||
|
dim_row.append(&w_label);
|
||||||
|
dim_row.append(&width_spin);
|
||||||
|
dim_row.append(&lock_btn);
|
||||||
|
dim_row.append(&height_spin);
|
||||||
|
dim_row.append(&h_label);
|
||||||
|
dim_row.append(&unit_box);
|
||||||
|
dims_group.add(&dim_row);
|
||||||
|
|
||||||
|
// Mode
|
||||||
|
let mode_row = adw::ComboRow::builder()
|
||||||
|
.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",
|
||||||
|
"Fit Within Box",
|
||||||
|
])));
|
||||||
|
mode_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
|
||||||
|
// Upscale
|
||||||
|
let upscale_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
dims_group.add(&upscale_row);
|
||||||
|
controls.append(&dims_group);
|
||||||
|
|
||||||
|
// --- Group 3: Advanced ---
|
||||||
|
let advanced_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Advanced")
|
||||||
|
.build();
|
||||||
|
let advanced_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Advanced Settings")
|
||||||
|
.subtitle("Resize algorithm and output DPI")
|
||||||
|
.show_enable_switch(false)
|
||||||
|
.expanded(state.is_section_expanded("resize-advanced"))
|
||||||
|
.build();
|
||||||
|
{
|
||||||
|
let st = state.clone();
|
||||||
|
advanced_expander.connect_expanded_notify(move |row| {
|
||||||
|
st.set_section_expanded("resize-advanced", row.is_expanded());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let algorithm_row = adw::ComboRow::builder()
|
||||||
|
.title("Resize Algorithm")
|
||||||
|
.use_subtitle(true)
|
||||||
|
.build();
|
||||||
|
algorithm_row.set_model(Some(>k::StringList::new(&[
|
||||||
|
"Lanczos3 (Best quality)",
|
||||||
|
"CatmullRom (Good, faster)",
|
||||||
|
"Bilinear (Fast)",
|
||||||
|
"Nearest (Pixelated)",
|
||||||
|
])));
|
||||||
|
algorithm_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||||
|
|
||||||
|
let dpi_row = adw::SpinRow::builder()
|
||||||
|
.title("DPI")
|
||||||
|
.subtitle("Output resolution in dots per inch")
|
||||||
|
.adjustment(>k::Adjustment::new(72.0, 72.0, 600.0, 1.0, 10.0, 0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
advanced_expander.add_row(&algorithm_row);
|
||||||
|
advanced_expander.add_row(&dpi_row);
|
||||||
|
advanced_group.add(&advanced_expander);
|
||||||
|
controls.append(&advanced_group);
|
||||||
|
|
||||||
|
controls_scroll.set_child(Some(&controls));
|
||||||
|
|
||||||
|
split.append(&preview_box);
|
||||||
|
split.append(&controls_scroll);
|
||||||
|
outer.append(&split);
|
||||||
|
|
||||||
|
// === SHARED STATE ===
|
||||||
|
let preview_width = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_width));
|
||||||
|
let preview_height = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_height));
|
||||||
|
let preview_upscale = std::rc::Rc::new(std::cell::Cell::new(cfg.allow_upscale));
|
||||||
|
let preview_mode = std::rc::Rc::new(std::cell::Cell::new(cfg.resize_mode));
|
||||||
|
let preview_algo = std::rc::Rc::new(std::cell::Cell::new(0u32));
|
||||||
|
let preview_index = std::rc::Rc::new(std::cell::Cell::new(0usize));
|
||||||
|
let is_pct = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||||
|
let updating = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||||
|
let loaded_files = state.loaded_files.clone();
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
// === RENDER CLOSURE ===
|
||||||
|
let resize_preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let render_thumb = {
|
||||||
|
let lf = loaded_files.clone();
|
||||||
|
let pw = preview_width.clone();
|
||||||
|
let ph = preview_height.clone();
|
||||||
|
let pu = preview_upscale.clone();
|
||||||
|
let pm = preview_mode.clone();
|
||||||
|
let pa = preview_algo.clone();
|
||||||
|
let pi = preview_index.clone();
|
||||||
|
let pic = thumb_picture.clone();
|
||||||
|
let dlbl = dims_label.clone();
|
||||||
|
let npl = no_preview_label.clone();
|
||||||
|
let bind_gen = resize_preview_gen.clone();
|
||||||
|
std::rc::Rc::new(move || {
|
||||||
|
let files = lf.borrow();
|
||||||
|
if files.is_empty() {
|
||||||
|
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||||
|
pic.set_visible(false);
|
||||||
|
npl.set_visible(true);
|
||||||
|
npl.set_label("Add images to see resize preview");
|
||||||
|
dlbl.set_label("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let idx = pi.get() % files.len();
|
||||||
|
let Some(current) = files.get(idx) else { return };
|
||||||
|
pic.set_visible(true);
|
||||||
|
npl.set_visible(false);
|
||||||
|
|
||||||
|
let tw = *pw.borrow();
|
||||||
|
let th = *ph.borrow();
|
||||||
|
if tw == 0 && th == 0 {
|
||||||
|
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||||
|
npl.set_visible(true);
|
||||||
|
npl.set_label("Set dimensions to preview");
|
||||||
|
pic.set_visible(false);
|
||||||
|
dlbl.set_label("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_count = files.len();
|
||||||
|
let (orig_w, orig_h) = get_image_dims(current.as_path());
|
||||||
|
|
||||||
|
let actual_tw = if tw > 0 { tw } else { orig_w };
|
||||||
|
let actual_th = if th > 0 {
|
||||||
|
th
|
||||||
|
} else if tw > 0 {
|
||||||
|
let scale = tw as f64 / orig_w as f64;
|
||||||
|
(orig_h as f64 * scale).round() as u32
|
||||||
|
} else {
|
||||||
|
orig_h
|
||||||
|
};
|
||||||
|
|
||||||
|
let allow_up = pu.get();
|
||||||
|
let (render_tw, render_th) = if !allow_up {
|
||||||
|
(actual_tw.min(orig_w), actual_th.min(orig_h))
|
||||||
|
} else {
|
||||||
|
(actual_tw, actual_th)
|
||||||
|
};
|
||||||
|
|
||||||
|
let scale_pct = if orig_w > 0 {
|
||||||
|
(render_tw as f64 / orig_w as f64 * 100.0).round() as u32
|
||||||
|
} else {
|
||||||
|
100
|
||||||
|
};
|
||||||
|
|
||||||
|
let counter = if file_count > 1 {
|
||||||
|
format!(" [{}/{}]", idx + 1, file_count)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let clamp_note = if !allow_up && (actual_tw > orig_w || actual_th > orig_h) {
|
||||||
|
" (clamped)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
dlbl.set_label(&format!(
|
||||||
|
"{} x {} -> {} x {} ({}%){}{}", orig_w, orig_h,
|
||||||
|
render_tw, render_th, scale_pct, clamp_note, counter,
|
||||||
|
));
|
||||||
|
|
||||||
|
let my_gen = bind_gen.get().wrapping_add(1);
|
||||||
|
bind_gen.set(my_gen);
|
||||||
|
let gen_check = bind_gen.clone();
|
||||||
|
|
||||||
|
let path = current.clone();
|
||||||
|
let pic = pic.clone();
|
||||||
|
let algo = algo_index_to_filter(pa.get());
|
||||||
|
let mode = pm.get();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = (|| -> Option<Vec<u8>> {
|
||||||
|
let img = image::open(&path).ok()?;
|
||||||
|
let target_w = if render_tw > 0 { render_tw } else { img.width().max(1) };
|
||||||
|
let target_h = if render_th > 0 {
|
||||||
|
render_th
|
||||||
|
} else if img.width() > 0 {
|
||||||
|
let scale = target_w as f64 / img.width() as f64;
|
||||||
|
(img.height() as f64 * scale).round().max(1.0) as u32
|
||||||
|
} else {
|
||||||
|
target_w
|
||||||
|
};
|
||||||
|
let resized = if mode == 0 && render_th > 0 {
|
||||||
|
// Exact: stretch to exact dimensions
|
||||||
|
img.resize_exact(target_w.min(1024), target_h.min(1024), algo)
|
||||||
|
} else {
|
||||||
|
// Fit within box (or width-only): maintain aspect ratio
|
||||||
|
img.resize(target_w.min(1024), target_h.min(1024), algo)
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
resized.write_to(
|
||||||
|
&mut std::io::Cursor::new(&mut buf),
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
).ok()?;
|
||||||
|
Some(buf)
|
||||||
|
})();
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||||
|
if gen_check.get() != my_gen {
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
}
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Some(bytes)) => {
|
||||||
|
let gbytes = glib::Bytes::from(&bytes);
|
||||||
|
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||||
|
pic.set_paintable(Some(&texture));
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Ok(None) => glib::ControlFlow::Break,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(_) => glib::ControlFlow::Break,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// === WIRE SIGNALS ===
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
enable_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().resize_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category selection - rebuild the size dropdown
|
||||||
|
{
|
||||||
|
let sr = size_row.clone();
|
||||||
|
category_row.connect_selected_notify(move |row| {
|
||||||
|
rebuild_size_model(&sr, row.selected());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size selection - auto-fill dimensions and lock aspect ratio
|
||||||
|
{
|
||||||
|
let ws = width_spin.clone();
|
||||||
|
let hs = height_spin.clone();
|
||||||
|
let lb = lock_btn.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pw = preview_width.clone();
|
||||||
|
let ph = preview_height.clone();
|
||||||
|
let upd = updating.clone();
|
||||||
|
let ip = is_pct.clone();
|
||||||
|
let px = px_btn.clone();
|
||||||
|
let pct = pct_btn.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
let cr = category_row.clone();
|
||||||
|
size_row.connect_selected_notify(move |row| {
|
||||||
|
let sel = row.selected() as usize;
|
||||||
|
if sel == 0 {
|
||||||
|
return; // "(select a size)" - don't change anything
|
||||||
|
}
|
||||||
|
let presets = presets_for_category(cr.selected());
|
||||||
|
let Some(&(_, w, h)) = presets.get(sel - 1) else { return };
|
||||||
|
|
||||||
|
// Switch to pixels if currently in percentage
|
||||||
|
if ip.get() {
|
||||||
|
ip.set(false);
|
||||||
|
px.add_css_class("suggested-action");
|
||||||
|
pct.remove_css_class("suggested-action");
|
||||||
|
ws.set_range(0.0, 10000.0);
|
||||||
|
ws.set_increments(1.0, 100.0);
|
||||||
|
hs.set_range(0.0, 10000.0);
|
||||||
|
hs.set_increments(1.0, 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
upd.set(true);
|
||||||
|
ws.set_value(w as f64);
|
||||||
|
hs.set_value(h as f64);
|
||||||
|
upd.set(false);
|
||||||
|
|
||||||
|
// Lock aspect ratio
|
||||||
|
if !lb.is_active() {
|
||||||
|
lb.set_active(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut c = jc.borrow_mut();
|
||||||
|
c.resize_width = w;
|
||||||
|
c.resize_height = h;
|
||||||
|
*pw.borrow_mut() = w;
|
||||||
|
*ph.borrow_mut() = h;
|
||||||
|
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock button icon change
|
||||||
|
{
|
||||||
|
let lb = lock_btn.clone();
|
||||||
|
lock_btn.connect_active_notify(move |btn| {
|
||||||
|
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"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width spin with aspect lock
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pw = preview_width.clone();
|
||||||
|
let ph = preview_height.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
let lb = lock_btn.clone();
|
||||||
|
let hs = height_spin.clone();
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let upd = updating.clone();
|
||||||
|
let ip = is_pct.clone();
|
||||||
|
let pr = size_row.clone();
|
||||||
|
width_spin.connect_value_notify(move |spin| {
|
||||||
|
if upd.get() { return; }
|
||||||
|
let val = spin.value();
|
||||||
|
|
||||||
|
let pixel_val = if ip.get() {
|
||||||
|
let dims = get_first_image_dims(&files.borrow());
|
||||||
|
(val / 100.0 * dims.0 as f64).round() as u32
|
||||||
|
} else {
|
||||||
|
val as u32
|
||||||
|
};
|
||||||
|
|
||||||
|
jc.borrow_mut().resize_width = pixel_val;
|
||||||
|
*pw.borrow_mut() = pixel_val;
|
||||||
|
|
||||||
|
// Reset preset to Custom when manually editing
|
||||||
|
if pr.selected() != 0 {
|
||||||
|
upd.set(true);
|
||||||
|
pr.set_selected(0);
|
||||||
|
upd.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if lb.is_active() && val > 0.0 {
|
||||||
|
if ip.get() {
|
||||||
|
upd.set(true);
|
||||||
|
hs.set_value(val);
|
||||||
|
let dims = get_first_image_dims(&files.borrow());
|
||||||
|
let pixel_h = (val / 100.0 * dims.1 as f64).round() as u32;
|
||||||
|
jc.borrow_mut().resize_height = pixel_h;
|
||||||
|
*ph.borrow_mut() = pixel_h;
|
||||||
|
upd.set(false);
|
||||||
|
} else {
|
||||||
|
let aspect = get_first_image_aspect(&files.borrow());
|
||||||
|
if aspect > 0.0 {
|
||||||
|
let new_h = (val / aspect).round() as u32;
|
||||||
|
upd.set(true);
|
||||||
|
hs.set_value(new_h as f64);
|
||||||
|
jc.borrow_mut().resize_height = new_h;
|
||||||
|
*ph.borrow_mut() = new_h;
|
||||||
|
upd.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height spin with aspect lock
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pw = preview_width.clone();
|
||||||
|
let ph = preview_height.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
let lb = lock_btn.clone();
|
||||||
|
let ws = width_spin.clone();
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let upd = updating.clone();
|
||||||
|
let ip = is_pct.clone();
|
||||||
|
let pr = size_row.clone();
|
||||||
|
height_spin.connect_value_notify(move |spin| {
|
||||||
|
if upd.get() { return; }
|
||||||
|
let val = spin.value();
|
||||||
|
|
||||||
|
let pixel_val = if ip.get() {
|
||||||
|
let dims = get_first_image_dims(&files.borrow());
|
||||||
|
(val / 100.0 * dims.1 as f64).round() as u32
|
||||||
|
} else {
|
||||||
|
val as u32
|
||||||
|
};
|
||||||
|
|
||||||
|
jc.borrow_mut().resize_height = pixel_val;
|
||||||
|
*ph.borrow_mut() = pixel_val;
|
||||||
|
|
||||||
|
// Reset preset to Custom when manually editing
|
||||||
|
if pr.selected() != 0 {
|
||||||
|
upd.set(true);
|
||||||
|
pr.set_selected(0);
|
||||||
|
upd.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if lb.is_active() && val > 0.0 {
|
||||||
|
if ip.get() {
|
||||||
|
upd.set(true);
|
||||||
|
ws.set_value(val);
|
||||||
|
let dims = get_first_image_dims(&files.borrow());
|
||||||
|
let pixel_w = (val / 100.0 * dims.0 as f64).round() as u32;
|
||||||
|
jc.borrow_mut().resize_width = pixel_w;
|
||||||
|
*pw.borrow_mut() = pixel_w;
|
||||||
|
upd.set(false);
|
||||||
|
} else {
|
||||||
|
let aspect = get_first_image_aspect(&files.borrow());
|
||||||
|
if aspect > 0.0 {
|
||||||
|
let new_w = (val * aspect).round() as u32;
|
||||||
|
upd.set(true);
|
||||||
|
ws.set_value(new_w as f64);
|
||||||
|
jc.borrow_mut().resize_width = new_w;
|
||||||
|
*pw.borrow_mut() = new_w;
|
||||||
|
upd.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unit toggle: px button
|
||||||
|
{
|
||||||
|
let pct = pct_btn.clone();
|
||||||
|
let px = px_btn.clone();
|
||||||
|
let ip = is_pct.clone();
|
||||||
|
let ws = width_spin.clone();
|
||||||
|
let hs = height_spin.clone();
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let upd = updating.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pw = preview_width.clone();
|
||||||
|
let ph = preview_height.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
px_btn.connect_clicked(move |_| {
|
||||||
|
if !ip.get() { return; } // already pixels
|
||||||
|
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();
|
||||||
|
let pct_h = hs.value();
|
||||||
|
let pixel_w = (pct_w / 100.0 * dims.0 as f64).round();
|
||||||
|
let pixel_h = (pct_h / 100.0 * dims.1 as f64).round();
|
||||||
|
|
||||||
|
upd.set(true);
|
||||||
|
ws.set_range(0.0, 10000.0);
|
||||||
|
ws.set_increments(1.0, 100.0);
|
||||||
|
hs.set_range(0.0, 10000.0);
|
||||||
|
hs.set_increments(1.0, 100.0);
|
||||||
|
ws.set_value(pixel_w);
|
||||||
|
hs.set_value(pixel_h);
|
||||||
|
upd.set(false);
|
||||||
|
|
||||||
|
let mut c = jc.borrow_mut();
|
||||||
|
c.resize_width = pixel_w as u32;
|
||||||
|
c.resize_height = pixel_h as u32;
|
||||||
|
*pw.borrow_mut() = pixel_w as u32;
|
||||||
|
*ph.borrow_mut() = pixel_h as u32;
|
||||||
|
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unit toggle: % button
|
||||||
|
{
|
||||||
|
let px = px_btn.clone();
|
||||||
|
let pct = pct_btn.clone();
|
||||||
|
let ip = is_pct.clone();
|
||||||
|
let ws = width_spin.clone();
|
||||||
|
let hs = height_spin.clone();
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let upd = updating.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pw = preview_width.clone();
|
||||||
|
let ph = preview_height.clone();
|
||||||
|
pct_btn.connect_clicked(move |_| {
|
||||||
|
if ip.get() { return; } // already percentage
|
||||||
|
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();
|
||||||
|
let cur_h = hs.value();
|
||||||
|
let pct_w = if dims.0 > 0 { (cur_w / dims.0 as f64 * 100.0).round() } else { 100.0 };
|
||||||
|
let pct_h = if dims.1 > 0 { (cur_h / dims.1 as f64 * 100.0).round() } else { 100.0 };
|
||||||
|
|
||||||
|
upd.set(true);
|
||||||
|
ws.set_range(0.0, 1000.0);
|
||||||
|
ws.set_increments(1.0, 10.0);
|
||||||
|
hs.set_range(0.0, 1000.0);
|
||||||
|
hs.set_increments(1.0, 10.0);
|
||||||
|
ws.set_value(pct_w);
|
||||||
|
hs.set_value(pct_h);
|
||||||
|
upd.set(false);
|
||||||
|
|
||||||
|
// Update job_config with the pixel values
|
||||||
|
let pixel_w = (pct_w / 100.0 * dims.0 as f64).round() as u32;
|
||||||
|
let pixel_h = (pct_h / 100.0 * dims.1 as f64).round() as u32;
|
||||||
|
let mut c = jc.borrow_mut();
|
||||||
|
c.resize_width = pixel_w;
|
||||||
|
c.resize_height = pixel_h;
|
||||||
|
*pw.borrow_mut() = pixel_w;
|
||||||
|
*ph.borrow_mut() = pixel_h;
|
||||||
|
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode toggle - update labels and job_config
|
||||||
|
{
|
||||||
|
let ws = width_spin.clone();
|
||||||
|
let hs = height_spin.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
let pmode = preview_mode.clone();
|
||||||
|
mode_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().resize_mode = row.selected();
|
||||||
|
pmode.set(row.selected());
|
||||||
|
if row.selected() == 1 {
|
||||||
|
ws.set_tooltip_text(Some("Maximum width"));
|
||||||
|
hs.set_tooltip_text(Some("Maximum height"));
|
||||||
|
} else {
|
||||||
|
ws.set_tooltip_text(Some("Width"));
|
||||||
|
hs.set_tooltip_text(Some("Height"));
|
||||||
|
}
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upscale toggle
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pu = preview_upscale.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
upscale_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().allow_upscale = row.is_active();
|
||||||
|
pu.set(row.is_active());
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Algorithm
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pa = preview_algo.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
algorithm_row.connect_selected_notify(move |row| {
|
||||||
|
jc.borrow_mut().resize_algorithm = row.selected();
|
||||||
|
pa.set(row.selected());
|
||||||
|
rt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DPI
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
dpi_row.connect_value_notify(move |row| {
|
||||||
|
jc.borrow_mut().output_dpi = row.value() as u32;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click preview to cycle images
|
||||||
|
{
|
||||||
|
let pi = preview_index.clone();
|
||||||
|
let rt = render_thumb.clone();
|
||||||
|
let lf = loaded_files.clone();
|
||||||
|
let click = gtk::GestureClick::new();
|
||||||
|
click.connect_released(move |gesture, _, _, _| {
|
||||||
|
let count = lf.borrow().len();
|
||||||
|
if count > 1 {
|
||||||
|
pi.set((pi.get() + 1) % count);
|
||||||
|
rt();
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
glib::idle_add_local_once(move || rt());
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Resize")
|
||||||
|
.tag("step-resize")
|
||||||
|
.child(&outer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page
|
||||||
|
}
|
||||||
941
pixstrip-gtk/src/steps/step_watermark.rs
Normal file
@@ -0,0 +1,941 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
|
// === OUTER LAYOUT ===
|
||||||
|
let outer = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(0)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Enable toggle (full width) ---
|
||||||
|
let enable_group = adw::PreferencesGroup::builder()
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.build();
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// === LEFT SIDE: Preview ===
|
||||||
|
|
||||||
|
let preview_picture = gtk::Picture::builder()
|
||||||
|
.content_fit(gtk::ContentFit::Contain)
|
||||||
|
.hexpand(true)
|
||||||
|
.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")
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_top(4)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let preview_frame = gtk::Frame::builder()
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
preview_frame.set_child(Some(&preview_picture));
|
||||||
|
|
||||||
|
let preview_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(4)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
preview_box.append(&preview_frame);
|
||||||
|
preview_box.append(&info_label);
|
||||||
|
|
||||||
|
// === RIGHT SIDE: Controls (scrollable) ===
|
||||||
|
|
||||||
|
let controls = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Watermark type ---
|
||||||
|
let type_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Watermark Type")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let type_row = adw::ComboRow::builder()
|
||||||
|
.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()));
|
||||||
|
type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 });
|
||||||
|
|
||||||
|
type_group.add(&type_row);
|
||||||
|
controls.append(&type_group);
|
||||||
|
|
||||||
|
// --- Text watermark settings ---
|
||||||
|
let text_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Text Watermark")
|
||||||
|
.visible(!cfg.watermark_use_image)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
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()
|
||||||
|
.title("Font Family")
|
||||||
|
.subtitle("Choose a typeface for the watermark text")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let font_dialog = gtk::FontDialog::builder()
|
||||||
|
.title("Choose Watermark Font")
|
||||||
|
.modal(true)
|
||||||
|
.build();
|
||||||
|
let font_button = gtk::FontDialogButton::builder()
|
||||||
|
.dialog(&font_dialog)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if !cfg.watermark_font_family.is_empty() {
|
||||||
|
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);
|
||||||
|
text_group.add(&font_row);
|
||||||
|
text_group.add(&font_size_row);
|
||||||
|
controls.append(&text_group);
|
||||||
|
|
||||||
|
// --- Image watermark settings ---
|
||||||
|
let image_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Image Watermark")
|
||||||
|
.visible(cfg.watermark_use_image)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let image_path_row = adw::ActionRow::builder()
|
||||||
|
.title("Logo Image")
|
||||||
|
.subtitle(
|
||||||
|
cfg.watermark_image_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "No image selected".to_string()),
|
||||||
|
)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
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")
|
||||||
|
.tooltip_text("Choose logo image")
|
||||||
|
.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);
|
||||||
|
controls.append(&image_group);
|
||||||
|
|
||||||
|
// --- Position group with 3x3 grid ---
|
||||||
|
let position_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Position")
|
||||||
|
.description("Choose where the watermark appears on the image")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let position_names = [
|
||||||
|
"Top Left", "Top Center", "Top Right",
|
||||||
|
"Middle Left", "Center", "Middle Right",
|
||||||
|
"Bottom Left", "Bottom Center", "Bottom Right",
|
||||||
|
];
|
||||||
|
|
||||||
|
let grid = gtk::Grid::builder()
|
||||||
|
.row_spacing(4)
|
||||||
|
.column_spacing(4)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Frame styled to look like a miniature image
|
||||||
|
let grid_outer = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let grid_frame = gtk::Frame::builder()
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
grid_frame.set_child(Some(&grid));
|
||||||
|
grid_frame.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Watermark position grid. Select where the watermark appears on the image."),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Image outline label above the grid
|
||||||
|
let grid_title = gtk::Label::builder()
|
||||||
|
.label("Image")
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
grid_outer.append(&grid_title);
|
||||||
|
grid_outer.append(&grid_frame);
|
||||||
|
|
||||||
|
let mut first_button: Option<gtk::ToggleButton> = None;
|
||||||
|
let buttons: Vec<gtk::ToggleButton> = position_names.iter().enumerate().map(|(i, name)| {
|
||||||
|
let btn = gtk::ToggleButton::builder()
|
||||||
|
.tooltip_text(*name)
|
||||||
|
.width_request(48)
|
||||||
|
.height_request(48)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
btn.update_property(&[
|
||||||
|
gtk::accessible::Property::Label(&format!("Watermark position: {}", name)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let icon = if i == cfg.watermark_position as usize {
|
||||||
|
"radio-checked-symbolic"
|
||||||
|
} else {
|
||||||
|
"radio-symbolic"
|
||||||
|
};
|
||||||
|
btn.set_child(Some(>k::Image::from_icon_name(icon)));
|
||||||
|
btn.set_active(i == cfg.watermark_position as usize);
|
||||||
|
|
||||||
|
if let Some(ref first) = first_button {
|
||||||
|
btn.set_group(Some(first));
|
||||||
|
} else {
|
||||||
|
first_button = Some(btn.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = i / 3;
|
||||||
|
let col = i % 3;
|
||||||
|
grid.attach(&btn, col as i32, row as i32, 1, 1);
|
||||||
|
|
||||||
|
btn
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
position_group.add(&grid_outer);
|
||||||
|
|
||||||
|
let position_label = gtk::Label::builder()
|
||||||
|
.label(position_names.get(cfg.watermark_position as usize).copied().unwrap_or("Center"))
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_bottom(4)
|
||||||
|
.build();
|
||||||
|
position_group.add(&position_label);
|
||||||
|
|
||||||
|
controls.append(&position_group);
|
||||||
|
|
||||||
|
// --- Advanced options ---
|
||||||
|
let advanced_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Advanced")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let advanced_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Advanced Options")
|
||||||
|
.subtitle("Color, opacity, rotation, tiling, margin, scale")
|
||||||
|
.show_enable_switch(false)
|
||||||
|
.expanded(state.is_section_expanded("watermark-advanced"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
{
|
||||||
|
let st = state.clone();
|
||||||
|
advanced_expander.connect_expanded_notify(move |row| {
|
||||||
|
st.set_section_expanded("watermark-advanced", row.is_expanded());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text color picker
|
||||||
|
let color_row = adw::ActionRow::builder()
|
||||||
|
.title("Text Color")
|
||||||
|
.subtitle("Color of the watermark text")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let initial_color = gtk::gdk::RGBA::new(
|
||||||
|
cfg.watermark_color[0] as f32 / 255.0,
|
||||||
|
cfg.watermark_color[1] as f32 / 255.0,
|
||||||
|
cfg.watermark_color[2] as f32 / 255.0,
|
||||||
|
cfg.watermark_color[3] as f32 / 255.0,
|
||||||
|
);
|
||||||
|
let color_dialog = gtk::ColorDialog::builder()
|
||||||
|
.with_alpha(true)
|
||||||
|
.title("Watermark Text Color")
|
||||||
|
.build();
|
||||||
|
let color_button = gtk::ColorDialogButton::builder()
|
||||||
|
.dialog(&color_dialog)
|
||||||
|
.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
|
||||||
|
let opacity_row = adw::ActionRow::builder()
|
||||||
|
.title("Opacity")
|
||||||
|
.subtitle(&format!("{}%", (cfg.watermark_opacity * 100.0).round() as i32))
|
||||||
|
.build();
|
||||||
|
let opacity_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 100.0, 1.0);
|
||||||
|
opacity_scale.set_value((cfg.watermark_opacity * 100.0) as f64);
|
||||||
|
opacity_scale.set_draw_value(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Rotation slider + reset (-180 to +180)
|
||||||
|
let rotation_row = adw::ActionRow::builder()
|
||||||
|
.title("Rotation")
|
||||||
|
.subtitle(&format!("{} degrees", cfg.watermark_rotation))
|
||||||
|
.build();
|
||||||
|
let rotation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -180.0, 180.0, 1.0);
|
||||||
|
rotation_scale.set_value(cfg.watermark_rotation as f64);
|
||||||
|
rotation_scale.set_draw_value(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Tiled toggle
|
||||||
|
let tiled_row = adw::SwitchRow::builder()
|
||||||
|
.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
|
||||||
|
let margin_row = adw::ActionRow::builder()
|
||||||
|
.title("Margin from Edges")
|
||||||
|
.subtitle(&format!("{} px", cfg.watermark_margin))
|
||||||
|
.build();
|
||||||
|
let margin_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 200.0, 1.0);
|
||||||
|
margin_scale.set_value(cfg.watermark_margin as f64);
|
||||||
|
margin_scale.set_draw_value(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Scale slider + reset (only relevant for image watermarks)
|
||||||
|
let scale_row = adw::ActionRow::builder()
|
||||||
|
.title("Scale (% of image)")
|
||||||
|
.subtitle(&format!("{}%", cfg.watermark_scale.round() as i32))
|
||||||
|
.visible(cfg.watermark_use_image)
|
||||||
|
.build();
|
||||||
|
let scale_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0);
|
||||||
|
scale_scale.set_value(cfg.watermark_scale as f64);
|
||||||
|
scale_scale.set_draw_value(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
advanced_expander.add_row(&color_row);
|
||||||
|
advanced_expander.add_row(&opacity_row);
|
||||||
|
advanced_expander.add_row(&rotation_row);
|
||||||
|
advanced_expander.add_row(&tiled_row);
|
||||||
|
advanced_expander.add_row(&margin_row);
|
||||||
|
advanced_expander.add_row(&scale_row);
|
||||||
|
|
||||||
|
advanced_group.add(&advanced_expander);
|
||||||
|
controls.append(&advanced_group);
|
||||||
|
|
||||||
|
// Scrollable controls
|
||||||
|
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.width_request(360)
|
||||||
|
.child(&controls)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// === Main layout: 60/40 side-by-side ===
|
||||||
|
let main_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
preview_box.set_width_request(400);
|
||||||
|
main_box.append(&preview_box);
|
||||||
|
main_box.append(&controls_scrolled);
|
||||||
|
outer.append(&main_box);
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
// === Preview update closure ===
|
||||||
|
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let update_preview = {
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let pic = preview_picture.clone();
|
||||||
|
let info = info_label.clone();
|
||||||
|
let pidx = preview_index.clone();
|
||||||
|
let bind_gen = preview_gen.clone();
|
||||||
|
|
||||||
|
Rc::new(move || {
|
||||||
|
let loaded = files.borrow();
|
||||||
|
if loaded.is_empty() {
|
||||||
|
info.set_label("No images loaded");
|
||||||
|
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = pidx.get().min(loaded.len().saturating_sub(1));
|
||||||
|
pidx.set(idx);
|
||||||
|
let path = loaded[idx].clone();
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||||
|
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||||
|
|
||||||
|
let cfg = jc.borrow();
|
||||||
|
let wm_text = cfg.watermark_text.clone();
|
||||||
|
let wm_use_image = cfg.watermark_use_image;
|
||||||
|
let wm_image_path = cfg.watermark_image_path.clone();
|
||||||
|
let wm_position = cfg.watermark_position;
|
||||||
|
let wm_opacity = cfg.watermark_opacity;
|
||||||
|
let wm_font_size = cfg.watermark_font_size;
|
||||||
|
let wm_color = cfg.watermark_color;
|
||||||
|
let wm_font_family = cfg.watermark_font_family.clone();
|
||||||
|
let wm_tiled = cfg.watermark_tiled;
|
||||||
|
let wm_margin = cfg.watermark_margin;
|
||||||
|
let wm_scale = cfg.watermark_scale;
|
||||||
|
let wm_rotation = cfg.watermark_rotation;
|
||||||
|
let wm_enabled = cfg.watermark_enabled;
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
|
let my_gen = bind_gen.get().wrapping_add(1);
|
||||||
|
bind_gen.set(my_gen);
|
||||||
|
let gen_check = bind_gen.clone();
|
||||||
|
|
||||||
|
let pic = pic.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = (|| -> Option<Vec<u8>> {
|
||||||
|
let img = image::open(&path).ok()?;
|
||||||
|
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||||
|
|
||||||
|
let img = if wm_enabled {
|
||||||
|
let position = match wm_position {
|
||||||
|
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
|
||||||
|
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
|
||||||
|
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
|
||||||
|
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
|
||||||
|
4 => pixstrip_core::operations::WatermarkPosition::Center,
|
||||||
|
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
|
||||||
|
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
|
||||||
|
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
|
||||||
|
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
|
||||||
|
};
|
||||||
|
let rotation = if wm_rotation != 0 {
|
||||||
|
Some(pixstrip_core::operations::WatermarkRotation::Custom(wm_rotation as f32))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let wm_config = if wm_use_image {
|
||||||
|
wm_image_path.as_ref().map(|p| {
|
||||||
|
pixstrip_core::operations::WatermarkConfig::Image {
|
||||||
|
path: p.clone(),
|
||||||
|
position,
|
||||||
|
opacity: wm_opacity,
|
||||||
|
scale: wm_scale / 100.0,
|
||||||
|
rotation,
|
||||||
|
tiled: wm_tiled,
|
||||||
|
margin: wm_margin,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if !wm_text.is_empty() {
|
||||||
|
Some(pixstrip_core::operations::WatermarkConfig::Text {
|
||||||
|
text: wm_text,
|
||||||
|
position,
|
||||||
|
font_size: wm_font_size,
|
||||||
|
opacity: wm_opacity,
|
||||||
|
color: wm_color,
|
||||||
|
font_family: if wm_font_family.is_empty() { None } else { Some(wm_font_family) },
|
||||||
|
rotation,
|
||||||
|
tiled: wm_tiled,
|
||||||
|
margin: wm_margin,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(config) = wm_config {
|
||||||
|
pixstrip_core::operations::watermark::apply_watermark(img, &config).ok()?
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(
|
||||||
|
&mut std::io::Cursor::new(&mut buf),
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
).ok()?;
|
||||||
|
Some(buf)
|
||||||
|
})();
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||||
|
if gen_check.get() != my_gen {
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
}
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Some(bytes)) => {
|
||||||
|
let gbytes = glib::Bytes::from(&bytes);
|
||||||
|
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||||
|
pic.set_paintable(Some(&texture));
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
}
|
||||||
|
Ok(None) => glib::ControlFlow::Break,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
|
Err(_) => glib::ControlFlow::Break,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Click-to-cycle on preview
|
||||||
|
{
|
||||||
|
let click = gtk::GestureClick::new();
|
||||||
|
let pidx = preview_index.clone();
|
||||||
|
let files = state.loaded_files.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
click.connect_released(move |_, _, _, _| {
|
||||||
|
let loaded = files.borrow();
|
||||||
|
if loaded.len() > 1 {
|
||||||
|
let next = (pidx.get() + 1) % loaded.len();
|
||||||
|
pidx.set(next);
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
enable_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().watermark_enabled = row.is_active();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type selector
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let text_group_c = text_group.clone();
|
||||||
|
let image_group_c = image_group.clone();
|
||||||
|
let scale_row_c = scale_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
type_row.connect_selected_notify(move |row| {
|
||||||
|
let use_image = row.selected() == 1;
|
||||||
|
jc.borrow_mut().watermark_use_image = use_image;
|
||||||
|
text_group_c.set_visible(!use_image);
|
||||||
|
image_group_c.set_visible(use_image);
|
||||||
|
scale_row_c.set_visible(use_image);
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text entry (debounced to avoid preview on every keystroke)
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let debounce_id: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
text_row.connect_changed(move |row| {
|
||||||
|
let text = row.text().to_string();
|
||||||
|
jc.borrow_mut().watermark_text = text;
|
||||||
|
let up = up.clone();
|
||||||
|
let did = debounce_id.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(300), move || {
|
||||||
|
if did.get() == id {
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font family
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
font_button.connect_font_desc_notify(move |btn| {
|
||||||
|
if let Some(desc) = btn.font_desc() {
|
||||||
|
if let Some(family) = desc.family() {
|
||||||
|
jc.borrow_mut().watermark_font_family = family.to_string();
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font size
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
font_size_row.connect_value_notify(move |row| {
|
||||||
|
jc.borrow_mut().watermark_font_size = row.value() as f32;
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position grid buttons
|
||||||
|
for (i, btn) in buttons.iter().enumerate() {
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let label = position_label.clone();
|
||||||
|
let names = position_names;
|
||||||
|
let all_buttons = buttons.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
btn.connect_toggled(move |b| {
|
||||||
|
if b.is_active() {
|
||||||
|
jc.borrow_mut().watermark_position = i as u32;
|
||||||
|
label.set_label(names[i]);
|
||||||
|
for (j, other) in all_buttons.iter().enumerate() {
|
||||||
|
let icon_name = if j == i {
|
||||||
|
"radio-checked-symbolic"
|
||||||
|
} else {
|
||||||
|
"radio-symbolic"
|
||||||
|
};
|
||||||
|
other.set_child(Some(>k::Image::from_icon_name(icon_name)));
|
||||||
|
}
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
color_button.connect_rgba_notify(move |btn| {
|
||||||
|
let c = btn.rgba();
|
||||||
|
jc.borrow_mut().watermark_color = [
|
||||||
|
(c.red() * 255.0) as u8,
|
||||||
|
(c.green() * 255.0) as u8,
|
||||||
|
(c.blue() * 255.0) as u8,
|
||||||
|
(c.alpha() * 255.0) as u8,
|
||||||
|
];
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-slider debounce counters (separate to avoid cross-slider cancellation)
|
||||||
|
let opacity_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let rotation_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let margin_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
let scale_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||||
|
|
||||||
|
// Opacity slider
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let row = opacity_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let rst = opacity_reset.clone();
|
||||||
|
let did = opacity_debounce.clone();
|
||||||
|
opacity_scale.connect_value_changed(move |scale| {
|
||||||
|
let val = scale.value().round() as i32;
|
||||||
|
let opacity = val as f32 / 100.0;
|
||||||
|
jc.borrow_mut().watermark_opacity = opacity;
|
||||||
|
row.set_subtitle(&format!("{}%", val));
|
||||||
|
rst.set_sensitive(val != 50);
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id { up(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scale = opacity_scale.clone();
|
||||||
|
opacity_reset.connect_clicked(move |_| {
|
||||||
|
scale.set_value(50.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation slider
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let row = rotation_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let rst = rotation_reset.clone();
|
||||||
|
let did = rotation_debounce.clone();
|
||||||
|
rotation_scale.connect_value_changed(move |scale| {
|
||||||
|
let val = scale.value().round() as i32;
|
||||||
|
jc.borrow_mut().watermark_rotation = val;
|
||||||
|
row.set_subtitle(&format!("{} degrees", val));
|
||||||
|
rst.set_sensitive(val != 0);
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id { up(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scale = rotation_scale.clone();
|
||||||
|
rotation_reset.connect_clicked(move |_| {
|
||||||
|
scale.set_value(0.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiled toggle
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
tiled_row.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().watermark_tiled = row.is_active();
|
||||||
|
up();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Margin slider
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let row = margin_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let rst = margin_reset.clone();
|
||||||
|
let did = margin_debounce.clone();
|
||||||
|
margin_scale.connect_value_changed(move |scale| {
|
||||||
|
let val = scale.value().round() as i32;
|
||||||
|
jc.borrow_mut().watermark_margin = val as u32;
|
||||||
|
row.set_subtitle(&format!("{} px", val));
|
||||||
|
rst.set_sensitive(val != 10);
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id { up(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scale = margin_scale.clone();
|
||||||
|
margin_reset.connect_clicked(move |_| {
|
||||||
|
scale.set_value(10.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale slider
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let row = scale_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
let rst = scale_reset.clone();
|
||||||
|
let did = scale_debounce.clone();
|
||||||
|
scale_scale.connect_value_changed(move |scale| {
|
||||||
|
let val = scale.value().round() as i32;
|
||||||
|
jc.borrow_mut().watermark_scale = val as f32;
|
||||||
|
row.set_subtitle(&format!("{}%", val));
|
||||||
|
rst.set_sensitive((val - 20).abs() > 0);
|
||||||
|
let up = up.clone();
|
||||||
|
let did = did.clone();
|
||||||
|
let id = did.get().wrapping_add(1);
|
||||||
|
did.set(id);
|
||||||
|
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||||
|
if did.get() == id { up(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scale = scale_scale.clone();
|
||||||
|
scale_reset.connect_clicked(move |_| {
|
||||||
|
scale.set_value(20.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image chooser button
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let path_row = image_path_row.clone();
|
||||||
|
let up = update_preview.clone();
|
||||||
|
choose_image_button.connect_clicked(move |btn| {
|
||||||
|
let jc = jc.clone();
|
||||||
|
let path_row = path_row.clone();
|
||||||
|
let up = up.clone();
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title("Choose Watermark Image")
|
||||||
|
.modal(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let filter = gtk::FileFilter::new();
|
||||||
|
filter.set_name(Some("Images"));
|
||||||
|
filter.add_mime_type("image/png");
|
||||||
|
filter.add_mime_type("image/svg+xml");
|
||||||
|
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
|
||||||
|
filters.append(&filter);
|
||||||
|
dialog.set_filters(Some(&filters));
|
||||||
|
|
||||||
|
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
||||||
|
dialog.open(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
||||||
|
if let Ok(file) = result
|
||||||
|
&& let Some(path) = file.path()
|
||||||
|
{
|
||||||
|
path_row.set_subtitle(&path.display().to_string());
|
||||||
|
jc.borrow_mut().watermark_image_path = Some(path);
|
||||||
|
up();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Watermark")
|
||||||
|
.tag("step-watermark")
|
||||||
|
.child(&outer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page
|
||||||
|
}
|
||||||
699
pixstrip-gtk/src/steps/step_workflow.rs
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use pixstrip_core::preset::Preset;
|
||||||
|
use pixstrip_core::operations::*;
|
||||||
|
use crate::app::{AppState, JobConfig, MetadataMode};
|
||||||
|
|
||||||
|
pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(24)
|
||||||
|
.margin_end(24)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Built-in presets section
|
||||||
|
let builtin_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Built-in Workflows")
|
||||||
|
.description("Select a preset to get started quickly")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let builtin_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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Then all built-in presets (indices 1..=9)
|
||||||
|
let builtins = Preset::all_builtins();
|
||||||
|
for preset in &builtins {
|
||||||
|
let card = build_preset_card(preset);
|
||||||
|
builtin_flow.append(&card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom workflow section (hidden until Custom card is selected)
|
||||||
|
let custom_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Custom Workflow")
|
||||||
|
.description("Choose which operations to include, then click Next")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let resize_check = adw::SwitchRow::builder()
|
||||||
|
.title("Resize")
|
||||||
|
.subtitle("Scale images to new dimensions")
|
||||||
|
.active(state.job_config.borrow().resize_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let adjustments_check = adw::SwitchRow::builder()
|
||||||
|
.title("Adjustments")
|
||||||
|
.subtitle("Rotate, flip, brightness, contrast, effects")
|
||||||
|
.active(state.job_config.borrow().adjustments_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let convert_check = adw::SwitchRow::builder()
|
||||||
|
.title("Convert")
|
||||||
|
.subtitle("Change image format (JPEG, PNG, WebP, AVIF)")
|
||||||
|
.active(state.job_config.borrow().convert_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let compress_check = adw::SwitchRow::builder()
|
||||||
|
.title("Compress")
|
||||||
|
.subtitle("Reduce file size with quality control")
|
||||||
|
.active(state.job_config.borrow().compress_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let metadata_check = adw::SwitchRow::builder()
|
||||||
|
.title("Metadata")
|
||||||
|
.subtitle("Strip or modify EXIF, GPS, camera data")
|
||||||
|
.active(state.job_config.borrow().metadata_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let watermark_check = adw::SwitchRow::builder()
|
||||||
|
.title("Watermark")
|
||||||
|
.subtitle("Add text or image overlay")
|
||||||
|
.active(state.job_config.borrow().watermark_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let rename_check = adw::SwitchRow::builder()
|
||||||
|
.title("Rename")
|
||||||
|
.subtitle("Rename files with prefix, suffix, or template")
|
||||||
|
.active(state.job_config.borrow().rename_enabled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
custom_group.add(&resize_check);
|
||||||
|
custom_group.add(&adjustments_check);
|
||||||
|
custom_group.add(&convert_check);
|
||||||
|
custom_group.add(&compress_check);
|
||||||
|
custom_group.add(&metadata_check);
|
||||||
|
custom_group.add(&watermark_check);
|
||||||
|
custom_group.add(&rename_check);
|
||||||
|
|
||||||
|
// When a card is activated: Custom shows toggles, presets apply config and advance
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
let custom_group_c = custom_group.clone();
|
||||||
|
builtin_flow.connect_child_activated(move |flow, child| {
|
||||||
|
let idx = child.index() as usize;
|
||||||
|
if idx == 0 {
|
||||||
|
// Custom card - show toggles, don't advance, enable step-by-step mode
|
||||||
|
jc.borrow_mut().preset_mode = false;
|
||||||
|
custom_group_c.set_visible(true);
|
||||||
|
} else {
|
||||||
|
// Built-in preset - apply config, skip intermediate steps, advance
|
||||||
|
custom_group_c.set_visible(false);
|
||||||
|
if let Some(preset) = builtins.get(idx - 1) {
|
||||||
|
let mut cfg = jc.borrow_mut();
|
||||||
|
apply_preset_to_config(&mut cfg, preset);
|
||||||
|
cfg.preset_mode = true;
|
||||||
|
}
|
||||||
|
flow.activate_action("win.next-step", None).ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let builtin_clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(1200)
|
||||||
|
.child(&builtin_flow)
|
||||||
|
.build();
|
||||||
|
builtin_group.add(&builtin_clamp);
|
||||||
|
content.append(&builtin_group);
|
||||||
|
|
||||||
|
// Wire custom operation toggles to job config
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
resize_check.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().resize_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
adjustments_check.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().adjustments_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
convert_check.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().convert_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
compress_check.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().compress_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
metadata_check.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().metadata_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
watermark_check.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().watermark_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let jc = state.job_config.clone();
|
||||||
|
rename_check.connect_active_notify(move |row| {
|
||||||
|
jc.borrow_mut().rename_enabled = row.is_active();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// User presets section
|
||||||
|
let user_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Your Presets")
|
||||||
|
.description("Import or save your own workflows")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 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_flow.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Your saved preset selection grid"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let user_clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(1200)
|
||||||
|
.child(&user_flow)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
// Drop target for .pixstrip-preset files
|
||||||
|
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
||||||
|
let jc_drop = state.job_config.clone();
|
||||||
|
drop_target.connect_drop(move |_target, value, _x, _y| {
|
||||||
|
if let Ok(file) = value.get::<gtk::gio::File>() {
|
||||||
|
if let Some(path) = file.path() {
|
||||||
|
if path.extension().and_then(|e| e.to_str()) == Some("pixstrip-preset") {
|
||||||
|
let store = pixstrip_core::storage::PresetStore::new();
|
||||||
|
if let Ok(preset) = store.import_from_file(&path) {
|
||||||
|
apply_preset_to_config(&mut jc_drop.borrow_mut(), &preset);
|
||||||
|
let _ = store.save(&preset);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
scrolled.add_controller(drop_target);
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Choose a Workflow")
|
||||||
|
.tag("step-workflow")
|
||||||
|
.child(&scrolled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Refresh user presets every time this page is shown
|
||||||
|
{
|
||||||
|
let uf = user_flow.clone();
|
||||||
|
let uel = user_empty_label.clone();
|
||||||
|
page.connect_map(move |_| {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
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 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();
|
||||||
|
|
||||||
|
let export_btn = gtk::Button::builder()
|
||||||
|
.icon_name("document-save-as-symbolic")
|
||||||
|
.tooltip_text("Export preset")
|
||||||
|
.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();
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title("Export Preset")
|
||||||
|
.initial_name(&format!("{}.pixstrip-preset", p.name))
|
||||||
|
.modal(true)
|
||||||
|
.build();
|
||||||
|
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
||||||
|
dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
||||||
|
if let Ok(file) = result
|
||||||
|
&& let Some(path) = file.path()
|
||||||
|
{
|
||||||
|
let store = pixstrip_core::storage::PresetStore::new();
|
||||||
|
let _ = store.export_to_file(&p, &path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let delete_btn = gtk::Button::builder()
|
||||||
|
.icon_name("user-trash-symbolic")
|
||||||
|
.tooltip_text("Delete preset")
|
||||||
|
.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 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);
|
||||||
|
// Remove the FlowBoxChild containing this card
|
||||||
|
if let Some(child) = btn.ancestor(gtk::FlowBoxChild::static_type()) {
|
||||||
|
if let Some(fbc) = child.downcast_ref::<gtk::FlowBoxChild>() {
|
||||||
|
uf_ref.remove(fbc);
|
||||||
|
// Show empty label if only the import card is left
|
||||||
|
let mut c = uf_ref.first_child();
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(w) = c {
|
||||||
|
count += 1;
|
||||||
|
c = w.next_sibling();
|
||||||
|
}
|
||||||
|
uel_ref.set_visible(count <= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
||||||
|
// Resize
|
||||||
|
match &preset.resize {
|
||||||
|
Some(ResizeConfig::ByWidth(w)) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = *w;
|
||||||
|
cfg.resize_height = 0;
|
||||||
|
cfg.allow_upscale = false;
|
||||||
|
}
|
||||||
|
Some(ResizeConfig::ByHeight(h)) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = 0;
|
||||||
|
cfg.resize_height = *h;
|
||||||
|
cfg.allow_upscale = false;
|
||||||
|
}
|
||||||
|
Some(ResizeConfig::FitInBox { max, allow_upscale }) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = max.width;
|
||||||
|
cfg.resize_height = max.height;
|
||||||
|
cfg.allow_upscale = *allow_upscale;
|
||||||
|
}
|
||||||
|
Some(ResizeConfig::Exact(dims)) => {
|
||||||
|
cfg.resize_enabled = true;
|
||||||
|
cfg.resize_width = dims.width;
|
||||||
|
cfg.resize_height = dims.height;
|
||||||
|
cfg.allow_upscale = true;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.resize_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert
|
||||||
|
match &preset.convert {
|
||||||
|
Some(ConvertConfig::SingleFormat(fmt)) => {
|
||||||
|
cfg.convert_enabled = true;
|
||||||
|
cfg.convert_format = Some(*fmt);
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
cfg.convert_enabled = true;
|
||||||
|
cfg.convert_format = None;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.convert_enabled = false;
|
||||||
|
cfg.convert_format = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress
|
||||||
|
match &preset.compress {
|
||||||
|
Some(CompressConfig::Preset(q)) => {
|
||||||
|
cfg.compress_enabled = true;
|
||||||
|
cfg.quality_preset = *q;
|
||||||
|
}
|
||||||
|
Some(CompressConfig::Custom { jpeg_quality, png_level, webp_quality, .. }) => {
|
||||||
|
cfg.compress_enabled = true;
|
||||||
|
if let Some(jq) = jpeg_quality {
|
||||||
|
cfg.jpeg_quality = *jq;
|
||||||
|
}
|
||||||
|
if let Some(pl) = png_level {
|
||||||
|
cfg.png_level = *pl;
|
||||||
|
}
|
||||||
|
if let Some(wq) = webp_quality {
|
||||||
|
cfg.webp_quality = *wq as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.compress_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
match &preset.metadata {
|
||||||
|
Some(MetadataConfig::StripAll) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::StripAll;
|
||||||
|
}
|
||||||
|
Some(MetadataConfig::Privacy) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::Privacy;
|
||||||
|
}
|
||||||
|
Some(MetadataConfig::KeepAll) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::KeepAll;
|
||||||
|
}
|
||||||
|
Some(MetadataConfig::Custom { strip_gps, strip_camera, strip_software, strip_timestamps, strip_copyright }) => {
|
||||||
|
cfg.metadata_enabled = true;
|
||||||
|
cfg.metadata_mode = MetadataMode::Custom;
|
||||||
|
cfg.strip_gps = *strip_gps;
|
||||||
|
cfg.strip_camera = *strip_camera;
|
||||||
|
cfg.strip_software = *strip_software;
|
||||||
|
cfg.strip_timestamps = *strip_timestamps;
|
||||||
|
cfg.strip_copyright = *strip_copyright;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cfg.metadata_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_custom_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.update_property(&[
|
||||||
|
gtk::accessible::Property::Label("Custom: Pick and choose operations"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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("emblem-system-symbolic")
|
||||||
|
.pixel_size(32)
|
||||||
|
.build();
|
||||||
|
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||||
|
|
||||||
|
let name_label = gtk::Label::builder()
|
||||||
|
.label("Custom")
|
||||||
|
.css_classes(["heading"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let desc_label = gtk::Label::builder()
|
||||||
|
.label("Pick and choose operations")
|
||||||
|
.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_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)
|
||||||
|
.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 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"])
|
||||||
|
.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);
|
||||||
|
|
||||||
|
card
|
||||||
|
}
|
||||||
239
pixstrip-gtk/src/tutorial.rs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
if let Ok(cfg) = config_store.load()
|
||||||
|
&& cfg.tutorial_complete
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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_stop(&win, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(6)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
for i in 0..total {
|
||||||
|
let dot = gtk::Label::builder()
|
||||||
|
.label(if i == index { "\u{25CF}" } else { "\u{25CB}" })
|
||||||
|
.build();
|
||||||
|
if i == index {
|
||||||
|
dot.add_css_class("accent");
|
||||||
|
} else {
|
||||||
|
dot.add_css_class("dim-label");
|
||||||
|
}
|
||||||
|
dots_box.append(&dot);
|
||||||
|
}
|
||||||
|
content.append(&dots_box);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
let title_label = gtk::Label::builder()
|
||||||
|
.label(title)
|
||||||
|
.css_classes(["title-3"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
content.append(&title_label);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
let desc_label = gtk::Label::builder()
|
||||||
|
.label(description)
|
||||||
|
.wrap(true)
|
||||||
|
.max_width_chars(36)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.xalign(0.0)
|
||||||
|
.build();
|
||||||
|
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::End)
|
||||||
|
.margin_top(4)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let skip_btn = gtk::Button::builder()
|
||||||
|
.label("Skip Tour")
|
||||||
|
.tooltip_text("Close the tour and start using Pixstrip")
|
||||||
|
.build();
|
||||||
|
skip_btn.add_css_class("flat");
|
||||||
|
|
||||||
|
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_btn.add_css_class("suggested-action");
|
||||||
|
next_btn.add_css_class("pill");
|
||||||
|
|
||||||
|
button_box.append(&skip_btn);
|
||||||
|
button_box.append(&next_btn);
|
||||||
|
content.append(&button_box);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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 pop = popover.clone();
|
||||||
|
skip_btn.connect_clicked(move |_| {
|
||||||
|
mark_tutorial_complete();
|
||||||
|
pop.popdown();
|
||||||
|
let p = pop.clone();
|
||||||
|
glib::idle_add_local_once(move || {
|
||||||
|
p.unparent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire next button
|
||||||
|
{
|
||||||
|
let pop = popover.clone();
|
||||||
|
let win = window.clone();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
popover.popup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively search the widget tree for a widget with the given name.
|
||||||
|
fn find_widget_by_name(root: >k::Widget, name: &str) -> Option<gtk::Widget> {
|
||||||
|
if root.widget_name().as_str() == name {
|
||||||
|
return Some(root.clone());
|
||||||
|
}
|
||||||
|
let mut child = root.first_child();
|
||||||
|
while let Some(c) = child {
|
||||||
|
if let Some(found) = find_widget_by_name(&c, name) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
child = c.next_sibling();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_tutorial_complete() {
|
||||||
|
let config_store = pixstrip_core::storage::ConfigStore::new();
|
||||||
|
if let Ok(mut cfg) = config_store.load() {
|
||||||
|
cfg.tutorial_complete = true;
|
||||||
|
let _ = config_store.save(&cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
pixstrip-gtk/src/utils.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pub fn format_size(bytes: u64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{} B", bytes)
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||||
|
} else if bytes < 1024 * 1024 * 1024 {
|
||||||
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
349
pixstrip-gtk/src/welcome.rs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn show_welcome_if_first_launch(window: &adw::ApplicationWindow) {
|
||||||
|
let config = pixstrip_core::storage::ConfigStore::new();
|
||||||
|
if let Ok(cfg) = config.load()
|
||||||
|
&& cfg.first_run_complete
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialog = build_welcome_dialog();
|
||||||
|
let win = window.clone();
|
||||||
|
let win_for_present = window.clone();
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
let config = pixstrip_core::storage::ConfigStore::new();
|
||||||
|
if let Ok(mut cfg) = config.load() {
|
||||||
|
cfg.first_run_complete = true;
|
||||||
|
let _ = config.save(&cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch the tutorial tour after the welcome wizard
|
||||||
|
crate::tutorial::show_tutorial_if_needed(&win);
|
||||||
|
});
|
||||||
|
dialog.present(Some(&win_for_present));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_welcome_dialog() -> adw::Dialog {
|
||||||
|
let dialog = adw::Dialog::builder()
|
||||||
|
.title("Welcome to Pixstrip")
|
||||||
|
.content_width(500)
|
||||||
|
.content_height(450)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let nav_view = adw::NavigationView::new();
|
||||||
|
|
||||||
|
let welcome_page = build_welcome_page(&nav_view);
|
||||||
|
nav_view.add(&welcome_page);
|
||||||
|
|
||||||
|
let skill_page = build_skill_page(&nav_view);
|
||||||
|
nav_view.add(&skill_page);
|
||||||
|
|
||||||
|
let fm_page = build_file_manager_page(&nav_view);
|
||||||
|
nav_view.add(&fm_page);
|
||||||
|
|
||||||
|
let output_page = build_output_page(&dialog);
|
||||||
|
nav_view.add(&output_page);
|
||||||
|
|
||||||
|
dialog.set_child(Some(&nav_view));
|
||||||
|
dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_welcome_page(nav_view: &adw::NavigationView) -> adw::NavigationPage {
|
||||||
|
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 status = adw::StatusPage::builder()
|
||||||
|
.title("Welcome to Pixstrip")
|
||||||
|
.description("A quick and powerful batch image processor.\nResize, convert, compress, strip metadata, watermark, and rename - all in a simple wizard.")
|
||||||
|
.icon_name("image-x-generic-symbolic")
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let next_button = gtk::Button::builder()
|
||||||
|
.label("Get Started")
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
next_button.add_css_class("suggested-action");
|
||||||
|
next_button.add_css_class("pill");
|
||||||
|
|
||||||
|
let nav = nav_view.clone();
|
||||||
|
next_button.connect_clicked(move |_| {
|
||||||
|
nav.push_by_tag("skill-level");
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(&status);
|
||||||
|
content.append(&next_button);
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Welcome")
|
||||||
|
.tag("welcome")
|
||||||
|
.child(&content)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_skill_page(nav_view: &adw::NavigationView) -> adw::NavigationPage {
|
||||||
|
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 title = gtk::Label::builder()
|
||||||
|
.label("How much detail do you want?")
|
||||||
|
.css_classes(["title-2"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let subtitle = gtk::Label::builder()
|
||||||
|
.label("You can change this anytime in Settings.")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
let simple_row = adw::ActionRow::builder()
|
||||||
|
.title("Simple")
|
||||||
|
.subtitle("Fewer options visible, great for quick tasks. Advanced options are still available behind expanders.")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
simple_row.add_prefix(>k::Image::from_icon_name("emblem-ok-symbolic"));
|
||||||
|
let simple_check = gtk::CheckButton::new();
|
||||||
|
simple_check.set_active(true);
|
||||||
|
simple_row.add_suffix(&simple_check);
|
||||||
|
simple_row.set_activatable_widget(Some(&simple_check));
|
||||||
|
|
||||||
|
let detailed_row = adw::ActionRow::builder()
|
||||||
|
.title("Detailed")
|
||||||
|
.subtitle("All options visible by default. Best for power users who want full control.")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
detailed_row.add_prefix(>k::Image::from_icon_name("preferences-system-symbolic"));
|
||||||
|
let detailed_check = gtk::CheckButton::new();
|
||||||
|
detailed_check.set_group(Some(&simple_check));
|
||||||
|
detailed_row.add_suffix(&detailed_check);
|
||||||
|
detailed_row.set_activatable_widget(Some(&detailed_check));
|
||||||
|
|
||||||
|
group.add(&simple_row);
|
||||||
|
group.add(&detailed_row);
|
||||||
|
|
||||||
|
let next_button = gtk::Button::builder()
|
||||||
|
.label("Continue")
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
next_button.add_css_class("suggested-action");
|
||||||
|
next_button.add_css_class("pill");
|
||||||
|
|
||||||
|
let nav = nav_view.clone();
|
||||||
|
let sc = simple_check.clone();
|
||||||
|
next_button.connect_clicked(move |_| {
|
||||||
|
// Save skill level choice
|
||||||
|
let store = pixstrip_core::storage::ConfigStore::new();
|
||||||
|
if let Ok(mut cfg) = store.load() {
|
||||||
|
cfg.skill_level = if sc.is_active() {
|
||||||
|
pixstrip_core::config::SkillLevel::Simple
|
||||||
|
} else {
|
||||||
|
pixstrip_core::config::SkillLevel::Detailed
|
||||||
|
};
|
||||||
|
let _ = store.save(&cfg);
|
||||||
|
}
|
||||||
|
nav.push_by_tag("file-manager");
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(&title);
|
||||||
|
content.append(&subtitle);
|
||||||
|
content.append(&group);
|
||||||
|
content.append(&next_button);
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Skill Level")
|
||||||
|
.tag("skill-level")
|
||||||
|
.child(&content)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_file_manager_page(nav_view: &adw::NavigationView) -> adw::NavigationPage {
|
||||||
|
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 title = gtk::Label::builder()
|
||||||
|
.label("File Manager Integration")
|
||||||
|
.css_classes(["title-2"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let subtitle = gtk::Label::builder()
|
||||||
|
.label("Add 'Process with Pixstrip' to your file manager's right-click menu.")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.wrap(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Detected File Managers")
|
||||||
|
.description("Toggle integration for each file manager")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Detect installed file managers
|
||||||
|
use pixstrip_core::fm_integration::FileManager;
|
||||||
|
let file_managers = [
|
||||||
|
(FileManager::Nautilus, "org.gnome.Nautilus", "system-file-manager-symbolic"),
|
||||||
|
(FileManager::Nemo, "org.nemo.Nemo", "system-file-manager-symbolic"),
|
||||||
|
(FileManager::Thunar, "thunar", "system-file-manager-symbolic"),
|
||||||
|
(FileManager::Dolphin, "org.kde.dolphin", "system-file-manager-symbolic"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut found_any = false;
|
||||||
|
for (fm, desktop_id, icon) in &file_managers {
|
||||||
|
let is_installed = gtk::gio::AppInfo::all()
|
||||||
|
.iter()
|
||||||
|
.any(|info| {
|
||||||
|
info.id()
|
||||||
|
.map(|id| id.as_str().contains(desktop_id))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_installed {
|
||||||
|
found_any = true;
|
||||||
|
let already_installed = fm.is_installed();
|
||||||
|
let row = adw::SwitchRow::builder()
|
||||||
|
.title(fm.name())
|
||||||
|
.subtitle(format!("Add right-click menu to {}", fm.name()))
|
||||||
|
.active(already_installed)
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name(icon));
|
||||||
|
|
||||||
|
let fm_copy = *fm;
|
||||||
|
row.connect_active_notify(move |row| {
|
||||||
|
if row.is_active() {
|
||||||
|
let _ = fm_copy.install();
|
||||||
|
} else {
|
||||||
|
let _ = fm_copy.uninstall();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group.add(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found_any {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title("No supported file managers found")
|
||||||
|
.subtitle("You can configure this later in Settings")
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic"));
|
||||||
|
group.add(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_button = gtk::Button::builder()
|
||||||
|
.label("Continue")
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
next_button.add_css_class("suggested-action");
|
||||||
|
next_button.add_css_class("pill");
|
||||||
|
|
||||||
|
let nav = nav_view.clone();
|
||||||
|
next_button.connect_clicked(move |_| {
|
||||||
|
nav.push_by_tag("output-location");
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(&title);
|
||||||
|
content.append(&subtitle);
|
||||||
|
content.append(&group);
|
||||||
|
content.append(&next_button);
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("File Manager")
|
||||||
|
.tag("file-manager")
|
||||||
|
.child(&content)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_output_page(dialog: &adw::Dialog) -> adw::NavigationPage {
|
||||||
|
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 title = gtk::Label::builder()
|
||||||
|
.label("Where should processed images go?")
|
||||||
|
.css_classes(["title-2"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let subtitle = gtk::Label::builder()
|
||||||
|
.label("You can change this per-batch or in Settings.")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
let subfolder_row = adw::ActionRow::builder()
|
||||||
|
.title("Subfolder next to originals")
|
||||||
|
.subtitle("Creates a 'processed' folder next to your images")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
subfolder_row.add_prefix(>k::Image::from_icon_name("folder-symbolic"));
|
||||||
|
let subfolder_check = gtk::CheckButton::new();
|
||||||
|
subfolder_check.set_active(true);
|
||||||
|
subfolder_row.add_suffix(&subfolder_check);
|
||||||
|
subfolder_row.set_activatable_widget(Some(&subfolder_check));
|
||||||
|
|
||||||
|
let fixed_row = adw::ActionRow::builder()
|
||||||
|
.title("Fixed output folder")
|
||||||
|
.subtitle("Always save to the same folder")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
fixed_row.add_prefix(>k::Image::from_icon_name("folder-open-symbolic"));
|
||||||
|
let fixed_check = gtk::CheckButton::new();
|
||||||
|
fixed_check.set_group(Some(&subfolder_check));
|
||||||
|
fixed_row.add_suffix(&fixed_check);
|
||||||
|
fixed_row.set_activatable_widget(Some(&fixed_check));
|
||||||
|
|
||||||
|
group.add(&subfolder_row);
|
||||||
|
group.add(&fixed_row);
|
||||||
|
|
||||||
|
let done_button = gtk::Button::builder()
|
||||||
|
.label("Start Using Pixstrip")
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
done_button.add_css_class("suggested-action");
|
||||||
|
done_button.add_css_class("pill");
|
||||||
|
|
||||||
|
let dlg = dialog.clone();
|
||||||
|
done_button.connect_clicked(move |_| {
|
||||||
|
dlg.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(&title);
|
||||||
|
content.append(&subtitle);
|
||||||
|
content.append(&group);
|
||||||
|
content.append(&done_button);
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Output Location")
|
||||||
|
.tag("output-location")
|
||||||
|
.child(&content)
|
||||||
|
.build()
|
||||||
|
}
|
||||||