Compare commits
126 Commits
7677e05b20
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 522883183d | |||
| f3668c45c3 | |||
| 8d754017fa | |||
| 7e5d19ab03 | |||
| 1a174d40a7 | |||
| 9fcbe237bd | |||
| 9ef33fa90f | |||
| e65ed866eb | |||
| d1cab8a691 | |||
| adef810691 | |||
| b432cc7431 | |||
| 270a7db60d | |||
| 4f23d25511 | |||
| 704f556867 | |||
| 0a7a6ee95c | |||
| a666fbad05 | |||
| 5104d66aaf | |||
| 5352b67887 | |||
| e2aee57bd9 | |||
| afabdf3548 | |||
| d8bb1a726a | |||
| 45247cdac5 | |||
| 0460763d42 | |||
| fbb9cddbb8 | |||
| d9ce1f8731 | |||
| 3109f97786 | |||
| f8fd073735 | |||
| 0ceec7eec4 | |||
| 683422f8f5 | |||
| b9775d5632 | |||
| a7dda7ad6f | |||
| 1ab21307d6 | |||
| 5a56e25c2c | |||
| a0bb00eddf | |||
| e976ca2c0a | |||
| 45aaa02f19 | |||
| fe12316bc4 | |||
| fcb4b0e727 | |||
| a09462fd53 | |||
| ced65f10ec | |||
| 33659a323b | |||
| 1d33be1e3d | |||
| eb8da4b3e9 | |||
| b50147404a | |||
| 5bdeb8a2e3 | |||
| d6f7fc9c88 | |||
| 4dd868078a | |||
| 1e3ffaadd3 | |||
| 5e83cb09ef | |||
| a29256921e | |||
| 064194df3d | |||
| 5881fb6eae | |||
| fdaedd8d1a | |||
| 52b7a7fed2 | |||
| edc5b1acee | |||
| abd8393079 | |||
| d1b811aa6a | |||
| 23c1f33d32 | |||
| 2c28c092d4 | |||
| 3aeb05c9a0 | |||
| 6dd81e5900 | |||
| f71b55da72 | |||
| 8212969e9d | |||
| 5822959907 | |||
| 8e50fa5e87 | |||
| 3070980241 | |||
| fc38c261c3 | |||
| fa7936abbd | |||
| b79ce58338 | |||
| abd794b3b9 | |||
| be081307c4 | |||
| bcf57927e9 | |||
| 257fd53bbc | |||
| 0ca15536ae | |||
| fa7a8f54bb | |||
| 07d47b6b3f | |||
| e02d677e5c | |||
| 137bb77faa | |||
| 819ac963de | |||
| a74010a121 | |||
| 2911c608c2 | |||
| 32ea206c8c | |||
| 7840765e5b | |||
| b78f1cd7c4 | |||
| ea8444f039 | |||
| 3ae84297d5 | |||
| 7c260c3534 | |||
| 0234f872bc | |||
| 8f6e4382c4 | |||
| 29770be8b5 | |||
| 81f92e5b35 | |||
| 3284e066a0 | |||
| 9efcbd082e | |||
| b21b9edb36 | |||
| e969c4165e | |||
| 4fc4ea7017 | |||
| e8cdddd08d | |||
| 8154324929 | |||
| a7f1df2ba5 | |||
| b855955786 | |||
| eeb418ccdd | |||
| f353bbe5e6 | |||
| c20e0db2ff | |||
| b6aae711ec | |||
| eb16149824 | |||
| e1c2e11165 | |||
| 06860163f4 | |||
| 1587764b1e | |||
| a66db2b3bb | |||
| 20f4c24538 | |||
| be7d345aa9 | |||
| 8ced89a00f | |||
| d4aef0b774 | |||
| ea4ea9c9c4 | |||
| 52931daf53 | |||
| cacd52e85b | |||
| a658036326 | |||
| fea51ed220 | |||
| 175324e1aa | |||
| 545bf27fe4 | |||
| c445f71163 | |||
| 5c93dbf829 | |||
| d630d2fb3d | |||
| e7142604d4 | |||
| 715d8ab626 | |||
| 0203044a43 |
2396
Cargo.lock
generated
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]
|
||||
pixstrip-core = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
trash = "5"
|
||||
dirs = "6"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -8,3 +8,20 @@ license.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json = { 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()
|
||||
}
|
||||
442
pixstrip-core/src/fm_integration.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
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()
|
||||
}
|
||||
|
||||
fn pixstrip_bin() -> String {
|
||||
// Try to find the pixstrip binary path
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// If running from the GTK app, find the CLI sibling
|
||||
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();
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
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 bin = pixstrip_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_bin = 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(['{bin}', '--files'] + 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(['{bin}', '--preset', preset_name, '--files'] + paths)
|
||||
"#,
|
||||
preset_items = preset_items,
|
||||
bin = escaped_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 bin = pixstrip_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={bin} --files %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",
|
||||
bin = 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={bin} --preset \"{safe_label}\" --files %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),
|
||||
bin = 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 bin = pixstrip_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>{bin} --files %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",
|
||||
bin = 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>{bin} --preset \"{safe_label}\" --files %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),
|
||||
bin = 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 bin = pixstrip_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={bin} --files %F\n\n",
|
||||
bin = 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={bin} --preset \"{safe_label}\" --files %F\n\n",
|
||||
i = i,
|
||||
name = name,
|
||||
safe_label = shell_safe(name),
|
||||
bin = 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 discovery;
|
||||
pub mod encoder;
|
||||
pub mod error;
|
||||
pub mod executor;
|
||||
pub mod fm_integration;
|
||||
pub mod loader;
|
||||
pub mod operations;
|
||||
pub mod pipeline;
|
||||
pub mod preset;
|
||||
pub mod storage;
|
||||
pub mod types;
|
||||
pub mod watcher;
|
||||
|
||||
pub fn version() -> &'static str {
|
||||
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,
|
||||
Gif,
|
||||
Tiff,
|
||||
Bmp,
|
||||
}
|
||||
|
||||
impl ImageFormat {
|
||||
@@ -22,6 +23,7 @@ impl ImageFormat {
|
||||
"avif" => Some(Self::Avif),
|
||||
"gif" => Some(Self::Gif),
|
||||
"tiff" | "tif" => Some(Self::Tiff),
|
||||
"bmp" => Some(Self::Bmp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -34,6 +36,7 @@ impl ImageFormat {
|
||||
Self::Avif => "avif",
|
||||
Self::Gif => "gif",
|
||||
Self::Tiff => "tiff",
|
||||
Self::Bmp => "bmp",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,10 +69,17 @@ pub struct Dimensions {
|
||||
|
||||
impl Dimensions {
|
||||
pub fn aspect_ratio(&self) -> f64 {
|
||||
if self.height == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.width as f64 / self.height as f64
|
||||
}
|
||||
|
||||
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 {
|
||||
return self;
|
||||
}
|
||||
@@ -83,8 +93,8 @@ impl Dimensions {
|
||||
}
|
||||
|
||||
Dimensions {
|
||||
width: (self.width as f64 * scale).round() as u32,
|
||||
height: (self.height 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().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 {
|
||||
match self {
|
||||
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::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 }
|
||||
gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] }
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
1064
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()
|
||||
}
|
||||
67
pixstrip-gtk/src/wizard.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use crate::app::AppState;
|
||||
use crate::steps;
|
||||
|
||||
pub struct WizardState {
|
||||
pub current_step: usize,
|
||||
pub total_steps: usize,
|
||||
pub visited: Vec<bool>,
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
impl WizardState {
|
||||
pub fn new() -> Self {
|
||||
let names = vec![
|
||||
"Workflow".into(),
|
||||
"Images".into(),
|
||||
"Resize".into(),
|
||||
"Adjustments".into(),
|
||||
"Convert".into(),
|
||||
"Compress".into(),
|
||||
"Metadata".into(),
|
||||
"Watermark".into(),
|
||||
"Rename".into(),
|
||||
"Output".into(),
|
||||
];
|
||||
let total = names.len();
|
||||
let mut visited = vec![false; total];
|
||||
visited[0] = true;
|
||||
|
||||
Self {
|
||||
current_step: 0,
|
||||
total_steps: total,
|
||||
visited,
|
||||
names,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn step_names(&self) -> Vec<String> {
|
||||
self.names.clone()
|
||||
}
|
||||
|
||||
pub fn can_go_next(&self) -> bool {
|
||||
self.current_step < self.total_steps - 1
|
||||
}
|
||||
|
||||
pub fn can_go_back(&self) -> bool {
|
||||
self.current_step > 0
|
||||
}
|
||||
|
||||
pub fn is_last_step(&self) -> bool {
|
||||
self.current_step == self.total_steps - 1
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_wizard_pages(state: &AppState) -> Vec<adw::NavigationPage> {
|
||||
vec![
|
||||
steps::step_workflow::build_workflow_page(state), // 0: Workflow
|
||||
steps::step_images::build_images_page(state), // 1: Images
|
||||
steps::step_resize::build_resize_page(state), // 2: Resize
|
||||
steps::step_adjustments::build_adjustments_page(state),// 3: Adjustments
|
||||
steps::step_convert::build_convert_page(state), // 4: Convert
|
||||
steps::step_compress::build_compress_page(state), // 5: Compress
|
||||
steps::step_metadata::build_metadata_page(state), // 6: Metadata
|
||||
steps::step_watermark::build_watermark_page(state), // 7: Watermark
|
||||
steps::step_rename::build_rename_page(state), // 8: Rename
|
||||
steps::step_output::build_output_page(state), // 9: Output
|
||||
]
|
||||
}
|
||||