Compare commits

...

126 Commits

Author SHA1 Message Date
522883183d Add screenshots, update metainfo, and fix AppImage build script
- Add 18 screenshots sorted by workflow order to data/screenshots/
- Update metainfo with actual screenshot entries and correct dimensions
- Fix build script to copy all hicolor icon sizes and use linuxdeploy --output
- Add exported icon PNGs to icons/
2026-03-08 14:50:59 +02:00
f3668c45c3 Improve UX, add popover tour, metadata, and hicolor icons
- Redesign tutorial tour from modal dialogs to popovers pointing at actual UI elements
- Add beginner-friendly improvements: help buttons, tooltips, welcome wizard enhancements
- Add AppStream metainfo with screenshots, branding, categories, keywords, provides
- Update desktop file with GTK category and SingleMainWindow
- Add hicolor icon theme with all sizes (16-512px)
- Fix debounce SourceId panic in rename step
- Various step UI improvements and bug fixes
2026-03-08 14:18:15 +02:00
8d754017fa Clean up low-severity code quality issues
- Share IMAGE_EXTENSIONS between discovery.rs and watcher.rs (DRY)
- Extract compute_renamed_path() to deduplicate ~100 lines in executor
- Extract estimate_text_dimensions() to deduplicate watermark calc (3 copies)
- Fix encoder fallback defaults: WebP 85, AVIF 63 (match QualityPreset::High)
- Extract watch_config_dir() and load_watches() helpers in CLI (4 copies)
- Remove redundant else branches after unwrap_or_default()
- Rename misleading chrono_timestamp() to unix_timestamp()
2026-03-08 00:22:24 +02:00
7e5d19ab03 Fix 12 medium-severity bugs across all crates
- Escape backslashes in Nautilus preset names preventing Python injection
- Fix tiled watermarks starting at (spacing,spacing) instead of (0,0)
- Fix text watermark width overestimation (1.0x to 0.6x multiplier)
- Fix output_dpi forcing re-encoding for metadata-only presets
- Fix AVIF/WebP compression detection comparing against wrong preset values
- Add shared batch_updating guard for Ctrl+A/Ctrl+Shift+A select actions
- Fix overwrite conflict check ignoring preserve_directory_structure
- Add changes_filename()/changes_extension() for smarter overwrite checks
- Fix watch folder hardcoding "Blog Photos" preset
- Fix undo dropping history for partially-trashed batches
- Fix skipped files inflating size statistics
- Make CLI watch config writes atomic
2026-03-07 23:35:32 +02:00
1a174d40a7 Fix 5 deferred performance/UX issues from audit
M8: Pre-compile regex once before rename preview loop instead of
recompiling per file. Adds apply_simple_compiled() to RenameConfig.

M9: Cache font data in watermark module using OnceLock (default font)
and Mutex<HashMap> (named fonts) to avoid repeated filesystem walks
during preview updates.

M12: Add 150ms debounce to watermark opacity, rotation, margin, and
scale sliders to avoid spawning preview threads on every pixel of
slider movement.

M13: Add 150ms debounce to compress per-format quality sliders (JPEG,
PNG, WebP, AVIF) for the same reason.

M14: Move thumbnail loading to background threads instead of blocking
the GTK main loop. Each thumbnail is decoded via image crate in a
spawned thread and delivered to the main thread via channel polling.
2026-03-07 23:11:00 +02:00
9fcbe237bd Fix 16 medium-severity bugs from audit
CLI: add UTC suffix to timestamps, validate image extensions on
single-file input, canonicalize watch paths for reliable matching,
derive counter_enabled from template presence, warn when undo count
exceeds available batches.

Core: apply space/special-char transforms in template rename path,
warn on metadata preservation for unsupported formats, derive AVIF
speed from compress preset quality level.

GTK: use buffer size for apples-to-apples compress preview comparison,
shorten approximate format labels, cache file sizes to avoid repeated
syscalls on checkbox toggle, add batch-update guard to prevent O(n^2)
in select/deselect all, use widget names for reliable progress/log
lookup, add unique suffix for duplicate download filenames.
2026-03-07 23:02:57 +02:00
9ef33fa90f Fix CLI/GTK history timestamp format mismatch
CLI now stores Unix seconds (matching GTK) so age-based history
pruning works correctly. Human-readable formatting applied only
at display time in cmd_history and cmd_undo.
2026-03-07 22:35:25 +02:00
e65ed866eb Fix 5 high-risk bugs from 13th audit pass
- cleanup_placeholder now removes 1-byte marker files on error (regression)
- {width}/{height} template vars replaced with "0" when dimensions unavailable
- Text watermark width multiplier 0.6->1.0 to prevent CJK/wide char clipping
- cmd_undo preserves history entries when trash operations fail
- cmd_watch_remove exits on write failure instead of silent discard
2026-03-07 22:27:37 +02:00
d1cab8a691 Fix 40+ bugs from audit passes 9-12
- PNG chunk parsing overflow protection with checked arithmetic
- Font directory traversal bounded with global result limit
- find_unique_path TOCTOU race fixed with create_new + marker byte
- Watch mode "processed" dir exclusion narrowed to prevent false skips
- Metadata copy now checks format support before little_exif calls
- Clipboard temp files cleaned up on app exit
- Atomic writes for file manager integration scripts
- BMP format support added to encoder and convert step
- Regex DoS protection with DFA size limit
- Watermark NaN/negative scale guard
- Selective EXIF stripping for privacy/custom metadata modes
- CLI watch mode: file stability checks, per-file history saves
- High contrast toggle preserves and restores original theme
- Image list deduplication uses O(1) HashSet lookups
- Saturation/trim/padding overflow guards in adjustments
2026-03-07 22:14:48 +02:00
adef810691 Fix 30 critical and high severity bugs from audit passes 6-8
Critical fixes:
- Prevent path traversal via rename templates (sanitize_filename)
- Prevent input == output data loss (paths_are_same check)
- Undo now uses actual executor output paths instead of scanning directory
- Filter empty paths from output_files (prevents trashing CWD on undo)
- Sanitize URL download filenames to prevent path traversal writes

High severity fixes:
- Fix EXIF orientation 5/7 transforms per spec
- Atomic file creation in find_unique_path (TOCTOU race)
- Clean up 0-byte placeholder files on encoding failure
- Cap canvas padding to 10000px, total dimensions to 65535
- Clamp crop dimensions to minimum 1px
- Clamp DPI to 65535 before u16 cast in JPEG encoder
- Force pixel path for non-JPEG/TIFF metadata stripping
- Fast path now applies regex find/replace on rename stem
- Add output_dpi to needs_pixel_processing check
- Cap watermark image scale dimensions to 16384
- Cap template counter padding to 10
- Cap URL download size to 100MB
- Fix progress bar NaN when total is zero
- Fix calculate_eta underflow when current > total
- Fix loaded.len()-1 underflow in preview callbacks
- Replace ListItem downcast unwrap with if-let
- Fix resize preview division by zero on degenerate images
- Clamp rename cursor position to prevent overflow panic
- Watch mode: skip output dirs to prevent infinite loop
- Watch mode: drop tx sender so channel closes on exit
- Watch mode: add delay for partially-written files
- Watch mode: warn and skip unmatched files instead of wrong preset
- Clean temp download directory on app close
- Replace action downcast unwrap with checked if-let
- Add BatchResult.output_files for accurate undo tracking
2026-03-07 20:49:10 +02:00
b432cc7431 Fix 26 bugs, edge cases, and consistency issues from fifth audit pass
Critical: undo toast now trashes only batch output files (not entire dir),
JPEG scanline write errors propagated, selective metadata write result returned.

High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio
rejection, FM integration toggle infinite recursion guard, saturating counter
arithmetic in executor.

Medium: PNG compression level passed to oxipng, pct mode updates job_config,
external file loading updates step indicator, CLI undo removes history entries,
watch config write failures reported, fast-copy path reads image dimensions for
rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl),
CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot
fix, generation guards on all preview threads to cancel stale results, default
DPI aligned to 0, watermark text width uses char count not byte length.

Low: binary path escaped in Nautilus extension, file dialog filter aligned with
discovery, reset_wizard clears preset_mode and output_dir.
2026-03-07 19:47:23 +02:00
270a7db60d Fix step indicator layout warnings on narrow windows 2026-03-06 18:44:41 +02:00
4f23d25511 Add AppImage packaging, app icon, and AppStream metainfo 2026-03-06 18:31:18 +02:00
704f556867 Add EXIF auto-orient and aspect ratio lock toggle
- Implement auto_orient_from_exif() that reads EXIF Orientation tag
  and applies the correct rotation/flip for all 8 EXIF orientations
- Add aspect ratio lock toggle to resize step Width/Height mode
- When lock is active, changing width auto-calculates height from
  the first loaded image's aspect ratio, and vice versa
- Uses recursive update guard (Cell<bool>) to prevent infinite loops
2026-03-06 18:18:04 +02:00
0a7a6ee95c Implement watermark rotation for text and image watermarks
- Add rotate_watermark_image() using imageproc rotate_about_center
  for 45/-45 degree rotations and image::rotate90 for 90 degrees
- Add render_text_to_image() helper that renders text to a
  transparent buffer for rotation before compositing
- Apply rotation to single text, tiled text, single image, and
  tiled image watermark modes
- Fix tiled image watermark to use actual overlay dimensions
  (which change after rotation) instead of pre-rotation values
2026-03-06 18:14:53 +02:00
a666fbad05 Fix pipeline order, add selective metadata stripping, rename case/regex
- Move watermark step after compress in processing pipeline to match
  design doc order (resize, adjustments, convert, compress, metadata,
  watermark, rename)
- Implement selective EXIF metadata stripping for Privacy and Custom
  modes using little_exif tag filtering (GPS, camera, software,
  timestamps, copyright categories)
- Add case conversion support to rename (none/lower/upper/title)
- Add regex find-and-replace on original filenames
- Wire case and regex controls in rename step UI to JobConfig
- Add regex crate dependency to pixstrip-core
2026-03-06 18:12:18 +02:00
5104d66aaf Add EXIF-based rename template variables
Support {date}, {exif_date}, {camera}, and {original_ext} in rename
templates. Reads DateTimeOriginal and Model from EXIF metadata via
little_exif when these variables are used in templates.
2026-03-06 18:03:12 +02:00
5352b67887 Add thumbnail re-render preview to resize step
Shows an actual resized thumbnail alongside the dimension rectangle
visualization. Updates live when width/height values change.
2026-03-06 17:56:44 +02:00
e2aee57bd9 Integrate watch folder monitoring into GTK app
Active watch folders from settings are now monitored using the notify
crate. When new images appear, they are automatically processed using
the folder's linked preset. Toast notifications inform the user.
2026-03-06 17:53:41 +02:00
afabdf3548 Persist advanced options expand/collapse state per section
Each ExpanderRow now remembers its expanded state between sessions
using a per-section key stored in SessionState. Replaces the global
detailed_mode toggle with granular per-section persistence.
2026-03-06 17:49:07 +02:00
d8bb1a726a Add watermark tiling, rotation types, margin/scale controls
Wire tiled, margin, and scale UI controls to JobConfig and pass
through to WatermarkConfig. Add tiled text and image watermark
implementations that repeat across the full image. Add font family
filesystem search for named fonts. Add WatermarkRotation enum.
2026-03-06 17:36:07 +02:00
45247cdac5 Add collapsible watch folders panel in main window
Toggle button in the header bar reveals a bottom panel showing active
watch folders with their linked presets and watching status. The add
button opens Settings for full watch folder configuration.
2026-03-06 17:29:02 +02:00
0460763d42 Add fixed output folder option in settings
Users can now choose between subfolder-next-to-originals or a fixed
output folder in Settings > General. The fixed path is selectable via
a folder picker dialog and persisted across sessions.
2026-03-06 17:23:57 +02:00
fbb9cddbb8 Add PNG DPI support via pHYs chunk and fix font picker
PNG files now embed pHYs chunk for DPI when output_dpi is set, matching
the existing JPEG DPI support. Also fixed FontDialogButton signal handler
to properly unwrap the Option<FontDescription>.
2026-03-06 17:16:43 +02:00
d9ce1f8731 Add font family selector for watermark text
Font picker using GTK FontDialog/FontDialogButton lets users choose
any installed system font for text watermarks. The selected font family
is passed through the processing pipeline and used to find the matching
font file on disk.
2026-03-06 17:12:23 +02:00
3109f97786 Add overwrite confirmation dialog and preset file drop import
- When overwrite behavior is "Ask", show dialog before processing
  listing conflicting files with Overwrite/Skip/Auto-rename options
- Workflow page accepts .pixstrip-preset file drag-and-drop for import
- Split run_processing into two phases to support async dialog
2026-03-06 17:07:53 +02:00
f8fd073735 Add drag-and-drop import for .pixstrip-preset files
- Workflow page accepts .pixstrip-preset file drops
- Dropped preset files are imported and applied to current config
- Saved to user presets automatically on successful import
2026-03-06 17:06:02 +02:00
0ceec7eec4 Add preset export/delete buttons and single-instance support
- User preset rows now have export and delete action buttons
- Export opens file save dialog for .pixstrip-preset files
- Delete removes preset from storage and UI
- Single-instance via GIO HANDLES_OPEN for file manager integration
- Screen reader announcements on step navigation
2026-03-06 17:04:18 +02:00
683422f8f5 Improve screen reader support for wizard navigation
- Announce step number and name when navigating between steps
- Add accessible label to batch queue toggle button
- Add accessible label to navigation view with shortcut hint
- Step transitions update accessible property for screen readers
2026-03-06 17:02:04 +02:00
b9775d5632 Add single-instance file handling via GIO HANDLES_OPEN
- App flag HANDLES_OPEN enables receiving files from file manager
- connect_open handler filters for image files and activates window
- load-external-files action receives paths and adds to loaded files
- Auto-navigates to step 2 (images) when files arrive externally
- Second instance sends files to existing window instead of launching
2026-03-06 16:58:23 +02:00
a7dda7ad6f Enhance history dialog with expandable details and DPI support
- History entries now use ExpanderRow with detailed sub-rows
- Show formatted timestamp, input/output directories, size breakdown
- Clickable output directory row opens file manager
- Per-entry undo and open folder action buttons
- Error count shown for batches with failures
2026-03-06 16:56:23 +02:00
1ab21307d6 Apply output DPI to JPEG files via mozjpeg pixel density
- Add output_dpi field to EncoderOptions
- Set JFIF pixel density header in JPEG output when DPI > 0
- Wire output_dpi from ProcessingJob through to encoder in both
  parallel and sequential execution paths
2026-03-06 16:53:32 +02:00
5a56e25c2c Add sequential batch queue processing
- After a batch completes, automatically start next pending batch
- Mark completed/failed batches in queue with correct status
- Failed batches don't block the queue - next pending batch starts
- Queue processes batches in order, one at a time
2026-03-06 16:17:25 +02:00
a0bb00eddf Add Watch Folders settings page with full CRUD
- New settings page: Watch Folders with add/remove/edit controls
- Each watch folder has: path, linked preset dropdown, recursive toggle,
  active/inactive switch, and remove button
- Watch folder config persisted in AppConfig
- Empty state message when no folders configured
2026-03-06 16:13:14 +02:00
e976ca2c0a Add rename template presets and watermark color picker
- Rename step: quick-fill buttons for common patterns (Date+Name,
  EXIF Date+Name, Sequential, Dimensions, Camera+Date, Web-safe)
- Watermark step: color picker in advanced options using ColorDialogButton
- Add watermark_color field to JobConfig, wire through to core
2026-03-06 16:10:05 +02:00
45aaa02f19 Add URL drag-and-drop support for images from web browsers
Users can now drag image URLs from web browsers into the image
step. URLs ending in common image extensions are downloaded to a
temp directory and added to the batch. Uses GIO for the download
in a background thread.
2026-03-06 16:02:39 +02:00
fe12316bc4 Add thumbnail selection for compression and watermark previews
Users can now click different batch images in a thumbnail strip to
switch which image is used for the quality comparison preview and
watermark position preview. Shows up to 10 thumbnails with accent
highlight on the selected one.
2026-03-06 15:59:44 +02:00
fcb4b0e727 Improve output step with individual operation summary rows
Replace single-line arrow-delimited summary with a proper ListBox
showing each enabled operation as its own row with check icons.
Dynamically rebuilt when navigating to the output step. Also make
walk_widgets public for cross-module use.
2026-03-06 15:57:06 +02:00
a09462fd53 Add batch queue with slide-out side panel
Queue panel with OverlaySplitView sidebar. Users can add batches
from the results page via "Add to Queue" action. Queue shows
pending/active/completed batches with status icons. Toggle via
header bar button. Batches can be removed while pending.
2026-03-06 15:52:18 +02:00
ced65f10ec Add tutorial overlay tour after welcome wizard
Semi-transparent dialog-based tour with 6 stops covering step
indicator, workflow selection, image adding, navigation, menu,
and a final ready message. Skippable at any time. State persisted
via tutorial_complete flag in AppConfig.
2026-03-06 15:48:06 +02:00
33659a323b Enhance save preset dialog with settings summary and update option
Replace simple alert dialog with full dialog showing workflow summary,
new preset name entry, and option to update existing user presets.
Never overwrites built-in presets.
2026-03-06 15:43:45 +02:00
1d33be1e3d Wire DPI setting from resize step through to processing job
Add output_dpi field to JobConfig, ProcessingJob, and Preset.
Connect the DPI SpinRow in the resize step's advanced options to
update the config value.
2026-03-06 15:41:25 +02:00
eb8da4b3e9 Add live watermark preview with position and opacity updates
Shows a thumbnail of the first batch image with the watermark text
overlaid. Preview updates in real-time as user changes text, position
(9-point grid), and opacity.
2026-03-06 15:39:10 +02:00
b50147404a Add file manager integration install/uninstall logic
Implements actual extension file creation for Nautilus (Python
extension), Nemo (.nemo_action files), Thunar (custom actions XML),
and Dolphin (KDE service menu .desktop). Each extension creates a
"Process with Pixstrip" submenu with all presets listed. Toggle
switches in welcome wizard and settings now call install/uninstall.
2026-03-06 15:37:25 +02:00
5bdeb8a2e3 Wire skill level and accessibility settings to UI
- Add detailed_mode to AppState, derived from skill_level setting
- Expand advanced option sections by default in Detailed mode
  (resize, convert, compress, watermark steps)
- Fix high contrast to use HighContrast GTK theme
- Add completion sound via canberra-gtk-play
2026-03-06 15:28:02 +02:00
d6f7fc9c88 Initialize overwrite behavior from app settings
The overwrite behavior in the output step now defaults to the value
configured in Settings rather than always defaulting to "Ask".
2026-03-06 15:24:51 +02:00
4dd868078a Add completion sound, preserve directory structure support
- Play system notification sound via canberra-gtk-play when enabled
- Implement preserve_directory_structure in output_path_for to maintain
  relative paths from input directory in output
2026-03-06 15:24:04 +02:00
1e3ffaadd3 Add history pruning with configurable max entries and max days
- Add history_max_entries (default 50) and history_max_days (default 30)
  to AppConfig
- Add prune() method to HistoryStore that removes old entries by age
  and count limits
- Call prune after each history entry is added in the GUI
- Preserve history settings through settings dialog save/load cycle
2026-03-06 15:22:17 +02:00
5e83cb09ef Add --algorithm and --overwrite flags to CLI
Support resize algorithm selection (lanczos3/catmullrom/bilinear/nearest)
and overwrite behavior (auto-rename/overwrite/skip) in the CLI process
command, matching the GUI functionality.
2026-03-06 15:19:34 +02:00
a29256921e Wire resize algorithm selection, overwrite behavior, and fix rotation/flip scope
- Add ResizeAlgorithm enum (Lanczos3/CatmullRom/Bilinear/Nearest) to core
- Thread algorithm selection from UI ComboRow through ProcessingJob to resize_image
- Add OverwriteBehavior enum (AutoRename/Overwrite/Skip) to core
- Implement overwrite handling in executor with auto-rename suffix logic
- Wire overwrite behavior from output step through to processing job
- Fix rotation/flip to apply when resize step is enabled, not just adjustments
2026-03-06 15:17:59 +02:00
064194df3d Fix imported presets not persisting, remove duplicate help handler 2026-03-06 15:12:06 +02:00
5881fb6eae Restore session settings for convert format, quality preset, and metadata mode
- Parse stored format/quality/metadata strings back to enum values on launch
- Remove duplicate help button handler
- Session now properly round-trips all wizard state between launches
2026-03-06 15:10:28 +02:00
fdaedd8d1a Wire progressive JPEG, AVIF speed, and custom per-format quality to encoder
- Add EncoderOptions struct with progressive_jpeg and avif_speed fields
- Pass encoder options through ProcessingJob to PipelineExecutor
- mozjpeg set_progressive_mode() called when progressive JPEG enabled
- AVIF encoder speed now configurable (was hardcoded to 6)
- run_processing uses CompressConfig::Custom when user overrides preset defaults
- Executor properly handles AVIF quality and PNG level in Custom mode
2026-03-06 15:07:54 +02:00
52b7a7fed2 Wire help button to show contextual step help dialog
Connect the header bar help button to the existing show_step_help
function, showing context-aware help for the current wizard step.
Remove duplicate function definition.
2026-03-06 14:59:29 +02:00
edc5b1acee Guard output summary adjustments behind adjustments_enabled flag
Rotation, flip, brightness, contrast, and other adjustment operations
only appear in the output step summary when the adjustments step is
enabled in the workflow configuration.
2026-03-06 14:57:38 +02:00
abd8393079 Auto-show What's New dialog on first launch after update
Track last_seen_version in session state. Show the What's New dialog
automatically when the version changes, but not on very first run
(where the welcome wizard is shown instead).
2026-03-06 14:54:43 +02:00
d1b811aa6a Add wizard step-skipping for disabled operations
Steps for disabled operations (resize, adjustments, convert, compress,
metadata, watermark, rename) are automatically skipped when navigating
forward or backward. Jump-to-step also respects skip logic.
2026-03-06 14:53:04 +02:00
23c1f33d32 Support per-format conversion mapping in processing pipeline
Build FormatMapping when per-format overrides are set in the convert
step advanced options, falling back to SingleFormat when no overrides
are configured.
2026-03-06 14:51:17 +02:00
2c28c092d4 Wire missing UI controls to job config
- Add adjustments_enabled field and guard rotation/flip/adjustments behind it
- Wire adjustments toggle in workflow step
- Wire progressive JPEG toggle in convert and compress steps
- Wire format mapping ComboRows (JPEG/PNG/WebP/TIFF) in convert step
- Wire AVIF quality, WebP effort, AVIF speed controls in compress step
- Initialize all new controls from current config values
2026-03-06 14:50:12 +02:00
3aeb05c9a0 Replace custom shortcuts dialog with GtkShortcutsWindow, fix Process More button
- Use proper GtkShortcutsWindow with ShortcutsSection/Group/Shortcut widgets
  instead of custom AdwDialog with ActionRows
- Hide step indicator during processing and results screens
- Fix "Process More" button re-triggering processing instead of resetting wizard
- Add accessible label to resize step size preview DrawingArea
2026-03-06 14:46:54 +02:00
6dd81e5900 Add squoosh-style compression preview with draggable divider
Split-view comparison showing original vs compressed image side by
side. Draggable vertical divider with handle circle. Shows file sizes
and savings percentage. Compresses a sample image in a background
thread and renders via cairo with pixbuf clipping.
2026-03-06 14:29:01 +02:00
f71b55da72 Add image adjustments pipeline (brightness, contrast, crop, effects)
New AdjustmentsConfig with brightness, contrast, saturation, sharpen,
grayscale, sepia, crop to aspect ratio, trim whitespace, and canvas
padding. All wired from UI through to executor.

- Saturation uses luminance-based color blending
- Sepia uses standard matrix transformation
- Crop calculates center crop from aspect ratio
- Trim whitespace detects uniform border by corner pixel comparison
- Canvas padding adds white border around image
2026-03-06 14:24:14 +02:00
8212969e9d Wire all missing operations into pipeline executor
Executor now applies rotation, flip, resize, watermark, format
conversion, compression, renaming, and metadata handling. Previously
only resize, convert, and compress were active.

- Rotation: CW90/180/270 via image crate methods
- Flip: horizontal/vertical via image crate methods
- Watermark: text (imageproc + ab_glyph) and image overlay with
  alpha blending, positioned via WatermarkPosition enum
- Rename: apply_simple or template-based renaming with counter
- Metadata: re-encoding strips EXIF; KeepAll copies back via little_exif
2026-03-06 14:20:47 +02:00
5822959907 Add accessible labels to thumbnail grid and toolbar 2026-03-06 14:08:52 +02:00
8e50fa5e87 Add visual size preview to resize step
Show proportional rectangles comparing original image dimensions
(gray) vs target output dimensions (blue). Preview updates live
as the user changes width/height values. Uses first loaded image
for actual dimensions when available.
2026-03-06 14:05:52 +02:00
3070980241 Add CSS provider for thumbnail grid styling
Load application CSS at startup with rounded corners for thumbnail
frames and grid padding.
2026-03-06 14:02:49 +02:00
fc38c261c3 Replace image list with GtkGridView thumbnail grid
Upgrade the Add Images step from a plain ListBox to a GtkGridView
with thumbnail rendering. Each item shows a scaled thumbnail with
a checkbox overlay for include/exclude, and filename label below.
Thumbnails load asynchronously using Pixbuf at reduced size.
Uses GObject subclass (ImageItem) for the list model.
2026-03-06 14:01:52 +02:00
fa7936abbd Implement parallel image processing with rayon thread pool
The executor now uses rayon's thread pool for parallel processing when
thread_count > 1. Progress updates are sent via mpsc channel from worker
threads. Falls back to sequential processing for thread_count = 1.
2026-03-06 13:57:14 +02:00
b79ce58338 Add accessible value updates to processing progress bar
Screen readers now announce progress bar value with descriptive
text like "Processing 5 of 47: sunset.jpg" during batch processing,
instead of just reporting the fraction.
2026-03-06 13:53:32 +02:00
abd794b3b9 Wire thread count and error behavior settings into executor
The PipelineExecutor now stores thread_count and pause_on_error
fields. When pause_on_error is enabled, the executor sets the
pause flag on failures so the user can review errors. The GUI
reads these settings from AppConfig before starting processing.
2026-03-06 13:52:08 +02:00
be081307c4 Add accessible labels to sliders and watermark position grid
Screen readers now announce the purpose and range of brightness,
contrast, saturation, and compression quality sliders. The
watermark position grid frame also has a descriptive label.
2026-03-06 13:51:01 +02:00
bcf57927e9 Add per-step contextual help button in header bar
A "?" button in the header bar shows an AlertDialog with help
text specific to the current wizard step, explaining what each
step does and key shortcuts available.
2026-03-06 13:48:13 +02:00
257fd53bbc Add Ctrl+V clipboard paste for images
Reads image textures from the system clipboard, saves as temporary
PNG files, and adds them to the batch. Shows toast notification
on success or when no image is found.
2026-03-06 13:46:20 +02:00
0ca15536ae Add Escape key shortcut and subfolder prompt for folder drops
Escape now goes to previous wizard step (cancel/go back). When
a folder with subfolders is dropped on the images step, an alert
dialog asks whether to include subfolder images. The choice is
remembered for the rest of the session.
2026-03-06 13:45:06 +02:00
fa7a8f54bb Add accessible labels to processing progress bar, results icon, drop zone
- Progress bar gets "Processing progress" accessible label
- Results success icon gets "Success" accessible label
- Image drop zone gets descriptive accessible label for screen readers
2026-03-06 13:41:30 +02:00
07d47b6b3f Replace watermark position dropdown with visual 3x3 grid
- 9-point position grid using ToggleButtons in a Grid layout
- Visual feedback with radio-checked/radio-symbolic icons
- Position label below grid shows current selection name
- Much better UX than ComboRow for spatial position selection
2026-03-06 13:38:12 +02:00
e02d677e5c Add Ctrl+Z undo last batch and register select/deselect shortcuts
- Ctrl+Z moves output files from last batch to system trash via GIO
- Select all / deselect all actions now registered as window actions
- Shortcuts registered: Ctrl+A, Ctrl+Shift+A, Ctrl+Z
2026-03-06 13:35:45 +02:00
137bb77faa Add Ctrl+A and Ctrl+Shift+A keyboard shortcuts for select/deselect all
- Register select-all-images and deselect-all-images actions
- Wire Ctrl+A to clear exclusion set, Ctrl+Shift+A to exclude all
- Both shortcuts update checkbox state and count label in images step
- Make set_all_checkboxes_in public for cross-module access
2026-03-06 13:33:06 +02:00
819ac963de Respect excluded files in output step counts and processing validation
- Output step now shows only included (non-excluded) image count and size
- Navigate-to-step refresh accounts for exclusions
- Process action checks for included images, not just loaded files
2026-03-06 13:31:35 +02:00
a74010a121 Add file manager integration toggles and reset button to Settings
- Settings now has a File Manager Integration group with toggle rows
  for each detected file manager (Nautilus, Nemo, Thunar, Dolphin)
- Added Reset to Defaults button that restores all settings to defaults
2026-03-06 13:29:25 +02:00
2911c608c2 Add per-image checkboxes and wire Select All / Deselect All buttons
- Each image row now has a CheckButton for include/exclude from processing
- Select All clears exclusion set, Deselect All adds all files to it
- Count label shows "X/Y images selected" when some are excluded
- Processing respects excluded files - only processes checked images
- Clear All also resets exclusion set
- AppState gains excluded_files HashSet for tracking
2026-03-06 13:24:18 +02:00
32ea206c8c Add WebP effort, AVIF speed controls, case conversion and regex rename
- Compress step: WebP Encoding Effort (0-6) and AVIF Encoding Speed (1-10)
- Rename step: Case Conversion combo (lowercase/UPPERCASE/Title Case)
- Rename step: Find and Replace group with regex pattern and replacement
2026-03-06 13:19:11 +02:00
7840765e5b Add File Manager Integration page to welcome wizard
Add fourth welcome wizard page that detects installed file managers
(Nautilus, Nemo, Thunar, Dolphin) and offers integration toggles for
each. Follows the design doc specification for the first-run experience.
2026-03-06 13:16:11 +02:00
b78f1cd7c4 Add What's New dialog, accessibility labels, focus management
Add What's New dialog accessible from hamburger menu showing version
changelog. Add accessible property labels to step indicator for screen
reader support with current step/total announcements. Add focus
management on step transitions - focus moves to first interactive
element when navigating to a new step.
2026-03-06 13:13:39 +02:00
ea8444f039 Add format mapping to convert step, orientation controls to resize step
Add per-format mapping rows in convert step advanced options so each
input format can target a different output format. Move basic
orientation controls (rotate/flip) into the resize step per design doc
specification that these should be folded into the resize step.
2026-03-06 13:11:00 +02:00
3ae84297d5 Add crop/trim/canvas padding to adjustments, wire all sliders to config
Add crop to aspect ratio (8 ratios), trim whitespace, and canvas padding
controls to the adjustments step per design doc. Wire brightness,
contrast, saturation, sharpen, grayscale, and sepia to JobConfig. Add
Select All / Deselect All toolbar buttons to images step. Include new
adjustment operations in output step summary.
2026-03-06 13:09:45 +02:00
7c260c3534 Add Photographer metadata preset and improve quality descriptions
Add Photographer mode to metadata step that keeps copyright and camera
model while stripping GPS and software data. Improve compress quality
descriptions with estimated file size reduction percentages.
2026-03-06 13:05:07 +02:00
0234f872bc Add custom workflow operation toggles, improve output step summary
Workflow step: replace the auto-advancing Custom card with a proper
operation checklist using SwitchRow toggles for each operation (Resize,
Adjustments, Convert, Compress, Metadata, Watermark, Rename). Wired
to job config so selections persist through the wizard.

Output step: show actual file size alongside image count. Refresh
both count and size dynamically when navigating to the output step.
2026-03-06 13:00:52 +02:00
8f6e4382c4 Add visual format cards, per-image remove, shortcuts dialog, wire threads
Convert step: replace ComboRow with visual format card grid showing
icon, name, and description for each format. Much more beginner-friendly.

Images step: add per-image remove button on each file row so users
can exclude individual images from the batch.

Shortcuts: use adw::Dialog with structured layout since GtkShortcutsWindow
is deprecated in GTK 4.18+. Add file management and undo shortcuts.

Settings: wire thread count selection to actually save/restore the
ThreadCount config value instead of always defaulting to Auto.
2026-03-06 12:58:43 +02:00
29770be8b5 Enhance rename preview to show 5 files, add watermark advanced expander
Rename step now shows preview for first 5 loaded files (or fallback
examples) with incrementing counters instead of a single line.

Watermark step gains an advanced expander with rotation, tiling,
margin, and scale options alongside the existing opacity control.
2026-03-06 12:54:35 +02:00
81f92e5b35 Enhance adjustments with sliders/effects, add undo toast, compress AVIF/progressive 2026-03-06 12:47:12 +02:00
3284e066a0 Add session memory, resize mode tabs, improved output summary 2026-03-06 12:44:16 +02:00
9efcbd082e Add real pause, desktop notifications, undo, accessibility, CLI watch 2026-03-06 12:38:16 +02:00
b21b9edb36 Add rotate, flip, watermark, and rename CLI flags with helper functions 2026-03-06 12:33:21 +02:00
e969c4165e Add window persistence, clickable step indicator, file list improvements
- Window size/position remembered between sessions via SessionStore
- Step indicator dots now clickable to navigate directly to that step,
  with keyboard shortcut hints in tooltips
- File list in add-files dialog shows format and size per image,
  header shows total count and total size
- Welcome dialog now saves skill level choice to config
- SessionState extended with window_width, window_height, window_maximized
2026-03-06 12:27:19 +02:00
4fc4ea7017 Improve Images, Compress, Output, Workflow steps
- Images step: folder drag-and-drop with recursive image scanning, per-file
  list with format and size info, total file size in header, supported
  formats label in empty state
- Compress step: per-format quality controls moved into AdwExpanderRow,
  improved quality level descriptions
- Output step: dynamic image count with total size from loaded_files,
  initial overwrite behavior from config
- Workflow step: properly handle MetadataConfig::Custom in preset import,
  mapping all custom metadata fields to JobConfig
2026-03-06 12:22:15 +02:00
e8cdddd08d Add ETA calculation, activity log, keyboard shortcuts, expand format options
- Processing: ETA calculated from elapsed time and progress, per-image log
  entries added to activity log with auto-scroll
- Keyboard shortcuts: Ctrl+Q quit, Ctrl+, settings, Ctrl+?/F1 shortcuts dialog
- Shortcuts dialog: AdwAlertDialog with all keyboard shortcuts listed
- Hamburger menu: added Keyboard Shortcuts entry
- Convert step: added AVIF, GIF, TIFF format options with descriptions
- Resize step: removed duplicate rotate/flip (now in Adjustments step),
  added missing social media presets (PeerTube, Friendica, Funkwhale,
  Instagram Portrait, Facebook Cover/Profile, LinkedIn Cover/Profile,
  TikTok, YouTube Channel Art, Threads, Twitter/X, 4K, etc.)
2026-03-06 12:20:04 +02:00
8154324929 Add Adjustments, Watermark, Rename wizard steps; expand to 10-step wizard
- New step_adjustments: rotation (5 options) and flip (3 options)
- New step_watermark: text/image watermark with position, opacity, font size
- New step_rename: prefix/suffix/counter with live preview and template engine
- Updated step_metadata: added Custom mode with per-category checkboxes
  (GPS, camera, software, timestamps, copyright) with show/hide toggle
- Expanded JobConfig with all operation fields (watermark, rename, metadata custom)
- Updated wizard from 7 to 10 steps in correct pipeline order
- Fixed page index references from 6 to 9 for output step
- Added MetadataMode::Custom handling in preset builder and output summary
2026-03-06 12:15:02 +02:00
a7f1df2ba5 Wire remaining UI elements: presets, drag-drop, import/save, output summary
- Workflow preset cards now apply their config to JobConfig on selection
- User presets section shows saved custom presets from PresetStore
- Import Preset button opens file dialog and imports JSON presets
- Save as Preset button in results page saves current workflow
- Images step supports drag-and-drop for image files
- Images loaded state shows file list and clear button
- Output step dynamically shows operation summary when navigated to
- Output step wires preserve directory structure and overwrite behavior
- Results page displays individual error details in expandable section
- Pause button toggles visual state on processing page
2026-03-06 12:01:50 +02:00
b855955786 Wire all wizard step controls to shared JobConfig state
All four configurable steps (resize, convert, compress, metadata) now
have signal handlers that update the shared JobConfig via AppState.
The run_processing function builds ProcessingJob from actual user
choices instead of hardcoded values. Fixed clippy warnings (collapsed
if-let chain, removed needless borrow).
2026-03-06 11:51:01 +02:00
eeb418ccdd Wire settings dialog to persist config via ConfigStore
- Load current settings from ConfigStore on dialog open
- All switches, combos, and entries reflect saved values
- Save all settings back to ConfigStore on dialog close
- Covers: output, overwrite, skill level, threads, errors,
  accessibility, and notification preferences
2026-03-06 11:44:16 +02:00
f353bbe5e6 Wire welcome dialog with navigation and first-run detection
- Welcome dialog buttons navigate between pages
- Done button closes dialog and marks first_run_complete
- Show welcome dialog on first launch only
- Add first_run_complete field to AppConfig with serde(default)
2026-03-06 11:43:25 +02:00
c20e0db2ff Wire up step buttons: Browse, preset cards, output directory picker
- Browse Files button triggers win.add-files action
- Add More button in loaded state triggers win.add-files action
- Preset card activation (click) advances to next wizard step
- Custom workflow card activation advances to next step
- Choose output folder button opens folder dialog
- Output step shows current image count when navigated to
- Clean up dead code in update_count_in_box
2026-03-06 11:41:46 +02:00
b6aae711ec Wire up all GTK UI actions to real functionality
- Settings menu opens PreferencesDialog
- History menu shows HistoryStore entries in a dialog
- Add Files (Ctrl+O) opens FileDialog with image MIME filters
- Process button runs PipelineExecutor in background thread
- Progress bar updates via mpsc channel polled with glib timeout
- Cancel button sets AtomicBool flag to stop processing
- Results page shows real stats (images, sizes, savings, time)
- Open Output Folder launches default file manager
- Process Another Batch resets wizard to step 1
- Toast notifications via ToastOverlay for feedback
- History entries saved after each processing run
- Remove dead_code allows from processing.rs and settings.rs
2026-03-06 11:37:32 +02:00
eb16149824 Add welcome wizard, desktop entry, and Nautilus extension
First-run welcome dialog with skill level and output location setup.
Desktop entry file for GNOME app launcher integration.
Nautilus Python extension with dynamic 'Process with Pixstrip' submenu
that reads both built-in and user presets.
2026-03-06 11:18:28 +02:00
e1c2e11165 Add file watcher for watch folder functionality
inotify-based folder watcher using the notify crate that detects new
image files, ignores non-image files, and supports start/stop lifecycle.
WatchFolder config struct for preset-linked watched directories.
2026-03-06 11:17:02 +02:00
06860163f4 Implement fully functional CLI with process, preset, history, and undo commands
Process command discovers images, builds pipeline from preset or CLI
flags (--resize, --format, --quality, --strip-metadata, --recursive),
executes with progress output, prints results with size savings, and
saves to history. Preset list/export/import use the storage module.
2026-03-06 11:13:33 +02:00
1587764b1e Add processing, results, and settings UI screens
Processing screen with progress bar, activity log, and pause/cancel.
Results screen with stats summary, error section, and action buttons.
Settings dialog with General, Processing, Accessibility, Notifications.
2026-03-06 11:10:38 +02:00
a66db2b3bb Add all wizard step UIs: workflow, images, resize, convert, compress, metadata, output
Full Adwaita widget-based layouts for all 7 wizard steps with
PreferencesGroups, SwitchRows, SpinRows, ComboRows, FlowBoxes,
ExpanderRows for social media presets, quality slider with named
marks, metadata radio group, and output configuration.
2026-03-06 11:08:38 +02:00
20f4c24538 Add GTK app shell with wizard navigation, step indicator, and actions
7-step wizard flow (Workflow, Images, Resize, Convert, Compress,
Metadata, Output) with AdwNavigationView, step indicator dots,
Back/Next buttons, keyboard shortcuts (Alt+arrows, Alt+1-9),
and hamburger menu with Settings and History placeholders.
2026-03-06 11:03:11 +02:00
be7d345aa9 Add storage module for presets, config, session, and history persistence
Preset save/load/list/delete/import/export, config JSON persistence,
session state save/restore, and processing history log with append/clear.
All stored as JSON under ~/.config/pixstrip/.
2026-03-06 02:14:57 +02:00
8ced89a00f Add pipeline executor with progress reporting and cancellation
Sequential execution with per-image progress callbacks, cancellation
via atomic flag, batch result tracking (success/fail/sizes/timing).
Phase 5 complete - 85 tests passing, zero clippy warnings.
2026-03-06 02:08:11 +02:00
d4aef0b774 Add metadata stripping, watermark positioning, and rename template modules
Metadata: JPEG EXIF stripping (APP1 segment removal).
Watermark: 9-position grid calculation with margin support.
Rename: template parser with {name}, {ext}, {counter:N}, {width}, {height}
  and collision resolution with auto-suffix.
Phase 4 complete - 79 tests passing, zero clippy warnings.
2026-03-06 02:06:01 +02:00
ea4ea9c9c4 Add metadata, watermark, and rename dependencies: little_exif, imageproc, ab_glyph 2026-03-06 02:03:04 +02:00
52931daf53 Add output encoders (mozjpeg, oxipng, webp, avif) and integration tests
OutputEncoder dispatches to specialized encoders per format.
JPEG: mozjpeg with quality control. PNG: oxipng lossless optimization.
WebP: libwebp encoding. AVIF: ravif via image crate.
GIF/TIFF: fallback via image crate.
Phase 3 complete - 59 tests passing, zero clippy warnings.
2026-03-06 02:02:27 +02:00
cacd52e85b Add output encoder dependencies: mozjpeg, oxipng, webp 2026-03-06 01:59:52 +02:00
a658036326 Fix clippy: collapse nested if in discovery module
Phase 2 complete - 49 tests passing, zero clippy warnings.
2026-03-06 01:59:00 +02:00
fea51ed220 Add CLI skeleton with clap: process, preset, history, and undo subcommands 2026-03-06 01:57:43 +02:00
175324e1aa Add minimal GTK4/libadwaita window with header bar and status page 2026-03-06 01:57:03 +02:00
545bf27fe4 Add SIMD-accelerated resize operation using fast_image_resize
Lanczos3 filter, supports ByWidth, ByHeight, FitInBox, Exact modes.
All 5 resize tests passing.
2026-03-06 01:54:57 +02:00
c445f71163 Add ImageLoader and file discovery modules
ImageLoader: load image info (dimensions, format, file size) and pixels.
Discovery: find image files by extension, flat or recursive, single file or directory.
All 9 tests passing.
2026-03-06 01:50:46 +02:00
5c93dbf829 Add image processing dependencies: magick_rust, fast_image_resize, image, rayon, walkdir 2026-03-06 01:49:12 +02:00
d630d2fb3d Add AppConfig with overwrite behavior, skill level, thread count settings
All 3 config tests passing.
2026-03-06 01:43:22 +02:00
e7142604d4 Add Preset type with 8 built-in presets and JSON serialization
All 5 preset tests passing.
2026-03-06 01:42:35 +02:00
715d8ab626 Add ProcessingJob type with source management and output path resolution
All 6 pipeline tests passing.
2026-03-06 01:41:23 +02:00
0203044a43 Add operation configuration types: resize, convert, compress, metadata, watermark, rename
All 11 operation tests passing.
2026-03-06 01:40:24 +02:00
100 changed files with 24962 additions and 15 deletions

2396
Cargo.lock generated

File diff suppressed because it is too large Load Diff

98
build-appimage.sh Normal file
View 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."

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View 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

View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
data/screenshots/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

BIN
data/screenshots/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

BIN
data/screenshots/04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
data/screenshots/05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

BIN
data/screenshots/06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

BIN
data/screenshots/07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
data/screenshots/08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

BIN
data/screenshots/09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
data/screenshots/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
data/screenshots/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
data/screenshots/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
data/screenshots/13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
data/screenshots/14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
data/screenshots/15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
data/screenshots/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
data/screenshots/17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
data/screenshots/18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

BIN
icons/pixstrip-128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
icons/pixstrip-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

BIN
icons/pixstrip-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
icons/pixstrip-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
icons/pixstrip-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
icons/pixstrip-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
icons/pixstrip-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
icons/pixstrip-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
icons/pixstrip.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
icons/pixstrip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -7,3 +7,6 @@ license.workspace = true
[dependencies] [dependencies]
pixstrip-core = { workspace = true } pixstrip-core = { workspace = true }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
trash = "5"
dirs = "6"
serde_json = "1"

File diff suppressed because it is too large Load Diff

View File

@@ -8,3 +8,20 @@ license.workspace = true
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
magick_rust = "1"
fast_image_resize = "5"
image = "0.25"
rayon = "1"
walkdir = "2"
mozjpeg = "0.10"
oxipng = "10"
webp = "0.3"
little_exif = "0.4"
imageproc = "0.25"
ab_glyph = "0.2"
dirs = "6"
notify = "7"
regex = "1"
[dev-dependencies]
tempfile = "3"

View File

@@ -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,
}

View 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)
}

View 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()
}
}

View 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()
}

View 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;"),
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(())
}

View File

@@ -1,9 +1,16 @@
pub mod config; pub mod config;
pub mod discovery;
pub mod encoder;
pub mod error; pub mod error;
pub mod executor;
pub mod fm_integration;
pub mod loader;
pub mod operations; pub mod operations;
pub mod pipeline; pub mod pipeline;
pub mod preset; pub mod preset;
pub mod storage;
pub mod types; pub mod types;
pub mod watcher;
pub fn version() -> &'static str { pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")

View 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()
}
}

View 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)
}

View 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
}

View File

@@ -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<&regex::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)
}
}

View 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: &regex::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))
}
}

View 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))
}

View 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))
}

View File

@@ -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)
}
}

View File

@@ -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,
}
}
}

View 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()
}
}

View File

@@ -11,6 +11,7 @@ pub enum ImageFormat {
Avif, Avif,
Gif, Gif,
Tiff, Tiff,
Bmp,
} }
impl ImageFormat { impl ImageFormat {
@@ -22,6 +23,7 @@ impl ImageFormat {
"avif" => Some(Self::Avif), "avif" => Some(Self::Avif),
"gif" => Some(Self::Gif), "gif" => Some(Self::Gif),
"tiff" | "tif" => Some(Self::Tiff), "tiff" | "tif" => Some(Self::Tiff),
"bmp" => Some(Self::Bmp),
_ => None, _ => None,
} }
} }
@@ -34,6 +36,7 @@ impl ImageFormat {
Self::Avif => "avif", Self::Avif => "avif",
Self::Gif => "gif", Self::Gif => "gif",
Self::Tiff => "tiff", Self::Tiff => "tiff",
Self::Bmp => "bmp",
} }
} }
} }
@@ -66,10 +69,17 @@ pub struct Dimensions {
impl Dimensions { impl Dimensions {
pub fn aspect_ratio(&self) -> f64 { pub fn aspect_ratio(&self) -> f64 {
if self.height == 0 {
return 1.0;
}
self.width as f64 / self.height as f64 self.width as f64 / self.height as f64
} }
pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions { pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions {
if self.width == 0 || self.height == 0 || max.width == 0 || max.height == 0 {
return self;
}
if !allow_upscale && self.width <= max.width && self.height <= max.height { if !allow_upscale && self.width <= max.width && self.height <= max.height {
return self; return self;
} }
@@ -83,8 +93,8 @@ impl Dimensions {
} }
Dimensions { Dimensions {
width: (self.width as f64 * scale).round() as u32, width: (self.width as f64 * scale).round().max(1.0) as u32,
height: (self.height as f64 * scale).round() as u32, height: (self.height as f64 * scale).round().max(1.0) as u32,
} }
} }
} }
@@ -135,6 +145,36 @@ impl QualityPreset {
} }
} }
pub fn webp_effort(&self) -> u8 {
match self {
Self::Maximum => 6,
Self::High => 5,
Self::Medium => 4,
Self::Low => 3,
Self::WebOptimized => 4,
}
}
pub fn avif_quality(&self) -> u8 {
match self {
Self::Maximum => 80,
Self::High => 63,
Self::Medium => 50,
Self::Low => 35,
Self::WebOptimized => 40,
}
}
pub fn avif_speed(&self) -> u8 {
match self {
Self::Maximum => 4,
Self::High => 6,
Self::Medium => 6,
Self::Low => 8,
Self::WebOptimized => 8,
}
}
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
Self::Maximum => "Maximum", Self::Maximum => "Maximum",

View 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)
}

View 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);
}

View 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());
}

View 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);
}

View 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");
}

View 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);
}

View 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));
}

View 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);
}

View 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;
}
}
}

View 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));
}

View 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");
}

View 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());
}

View 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"
}

View 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);
}

View 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());
});
}

View File

@@ -81,3 +81,39 @@ fn quality_preset_values() {
assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality()); assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality());
assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality()); assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality());
} }
#[test]
fn dimensions_zero_height_aspect_ratio() {
let dims = Dimensions { width: 1920, height: 0 };
assert_eq!(dims.aspect_ratio(), 1.0);
}
#[test]
fn dimensions_zero_both_aspect_ratio() {
let dims = Dimensions { width: 0, height: 0 };
assert_eq!(dims.aspect_ratio(), 1.0);
}
#[test]
fn dimensions_fit_within_zero_self_width() {
let original = Dimensions { width: 0, height: 600 };
let max_box = Dimensions { width: 1200, height: 1200 };
let fitted = original.fit_within(max_box, false);
assert_eq!(fitted, original);
}
#[test]
fn dimensions_fit_within_zero_self_height() {
let original = Dimensions { width: 800, height: 0 };
let max_box = Dimensions { width: 1200, height: 1200 };
let fitted = original.fit_within(max_box, false);
assert_eq!(fitted, original);
}
#[test]
fn dimensions_fit_within_zero_max() {
let original = Dimensions { width: 800, height: 600 };
let max_box = Dimensions { width: 0, height: 0 };
let fitted = original.fit_within(max_box, false);
assert_eq!(fitted, original);
}

View 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());
}

View 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);
}

View File

@@ -8,3 +8,5 @@ license.workspace = true
pixstrip-core = { workspace = true } pixstrip-core = { workspace = true }
gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] } gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] }
adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] } adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] }
image = "0.25"
regex = "1"

4023
pixstrip-gtk/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,16 @@
mod app;
mod processing;
mod settings;
mod step_indicator;
mod steps;
mod tutorial;
pub(crate) mod utils;
mod welcome;
mod wizard;
use gtk::prelude::*;
fn main() { fn main() {
println!("Pixstrip GTK - v{}", pixstrip_core::version()); let app = app::build_app();
app.run();
} }

View 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()
}

View 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(&gtk::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(&notify_group);
dialog.add(&notify_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: &gtk::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(&gtk::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(&gtk::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
}

View 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: &gtk::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) -> &gtk::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
}
}

View 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
}

View 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(&gtk::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(&gtk::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(&gtk::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(&gtk::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
}

File diff suppressed because it is too large Load Diff

View 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(&gtk::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: &gtk::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(&gtk::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(&gtk::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);
}
}

File diff suppressed because it is too large Load Diff

View 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(&timestamps_row);
custom_group.add(&copyright_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
}

View 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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::Image::from_icon_name("emblem-ok-symbolic"));
sb.append(&row);
}
}
});
}
page
}

View 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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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
}

View 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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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
}

View 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(&gtk::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(&gtk::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(&gtk::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(&gtk::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
}

View 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
}

View 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: &gtk::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
View 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
View 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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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()
}

View 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
]
}